Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8d913a320 | ||
|
|
e53bf8dbb4 | ||
|
|
a4dbfedbcf | ||
|
|
50b5aac069 | ||
|
|
f0072a3e11 |
No files matched your search
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
[gd_scene load_steps=5 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="PackedScene" path="res://game/world/sites/sunlit_shallows_environment.tscn" id="1_environment"]
|
||||||
|
[ext_resource type="PackedScene" path="res://game/world/submarine/submarine_actor.tscn" id="2_submarine"]
|
||||||
|
[ext_resource type="PackedScene" path="res://game/presentation/cockpit/cockpit_camera_rig.tscn" id="3_camera"]
|
||||||
|
[ext_resource type="PackedScene" path="res://game/presentation/cockpit/cockpit_interior.tscn" id="4_cockpit"]
|
||||||
|
|
||||||
|
[node name="SunlitShallowsWorld" type="Node3D"]
|
||||||
|
|
||||||
|
[node name="Environment" parent="." instance=ExtResource("1_environment")]
|
||||||
|
|
||||||
|
[node name="SubmarineActor" parent="." instance=ExtResource("2_submarine")]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
position = Vector3(0, -30, 0)
|
||||||
|
|
||||||
|
[node name="CockpitInterior" parent="SubmarineActor" instance=ExtResource("4_cockpit")]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
|
||||||
|
[node name="CockpitCameraRig" parent="SubmarineActor" instance=ExtResource("3_camera")]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
|
||||||
|
[node name="LeftProjector" parent="SubmarineActor" index="0"]
|
||||||
|
light_energy = 3.0
|
||||||
|
light_volumetric_fog_energy = 0.15
|
||||||
|
|
||||||
|
[node name="RightProjector" parent="SubmarineActor" index="1"]
|
||||||
|
light_energy = 3.0
|
||||||
|
light_volumetric_fog_energy = 0.15
|
||||||
@@ -108,6 +108,10 @@ func practical_light_count() -> int:
|
|||||||
return 0 if _practical_lighting == null else _practical_lighting.practical_light_count()
|
return 0 if _practical_lighting == null else _practical_lighting.practical_light_count()
|
||||||
|
|
||||||
|
|
||||||
|
func fill_light_ignores_porthole_glass() -> bool:
|
||||||
|
return _practical_lighting != null and _practical_lighting.fill_light_ignores_porthole_glass()
|
||||||
|
|
||||||
|
|
||||||
func gauges_are_dashboard_mounted() -> bool:
|
func gauges_are_dashboard_mounted() -> bool:
|
||||||
return _analog_gauges != null and _analog_gauges.get_parent() == get_parent().get_node("Dashboard")
|
return _analog_gauges != null and _analog_gauges.get_parent() == get_parent().get_node("Dashboard")
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ const CockpitMeshFactory = preload(
|
|||||||
"res://game/presentation/cockpit/cockpit_mesh_factory.gd"
|
"res://game/presentation/cockpit/cockpit_mesh_factory.gd"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const CockpitPressureBulkhead = preload(
|
||||||
|
"res://game/presentation/cockpit/cockpit_pressure_bulkhead.gd"
|
||||||
|
)
|
||||||
|
|
||||||
const CAGE_LAMP_SCENE = preload("res://game/presentation/cockpit/assets/models/cage_lamp.glb")
|
const CAGE_LAMP_SCENE = preload("res://game/presentation/cockpit/assets/models/cage_lamp.glb")
|
||||||
|
|
||||||
## Flange centre of the right-hand lamp, on the flat end cap of the pressure
|
## Flange centre of the right-hand lamp, on the flat end cap of the pressure
|
||||||
@@ -34,6 +38,7 @@ const WARM_LAMP_SHADOW_BIAS := 0.03
|
|||||||
var _materials: CockpitMaterialPalette
|
var _materials: CockpitMaterialPalette
|
||||||
var _warm_lights: Array[OmniLight3D] = []
|
var _warm_lights: Array[OmniLight3D] = []
|
||||||
var _emergency_light: OmniLight3D
|
var _emergency_light: OmniLight3D
|
||||||
|
var _porthole_fill: OmniLight3D
|
||||||
|
|
||||||
|
|
||||||
func _init(materials: CockpitMaterialPalette) -> void:
|
func _init(materials: CockpitMaterialPalette) -> void:
|
||||||
@@ -103,7 +108,20 @@ func _add_fill_light() -> void:
|
|||||||
porthole_fill.light_energy = 1.0
|
porthole_fill.light_energy = 1.0
|
||||||
porthole_fill.omni_range = 1.1
|
porthole_fill.omni_range = 1.1
|
||||||
porthole_fill.shadow_enabled = false
|
porthole_fill.shadow_enabled = false
|
||||||
|
# 30 cm behind the glass, it would mirror itself as a hard blue point.
|
||||||
|
porthole_fill.light_cull_mask &= ~CockpitPressureBulkhead.PORTHOLE_GLASS_RENDER_LAYER_BIT
|
||||||
add_child(porthole_fill)
|
add_child(porthole_fill)
|
||||||
|
_porthole_fill = porthole_fill
|
||||||
|
|
||||||
|
|
||||||
|
func fill_light_ignores_porthole_glass() -> bool:
|
||||||
|
return (
|
||||||
|
_porthole_fill != null
|
||||||
|
and (
|
||||||
|
_porthole_fill.light_cull_mask
|
||||||
|
& CockpitPressureBulkhead.PORTHOLE_GLASS_RENDER_LAYER_BIT
|
||||||
|
) == 0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
func _add_emergency_light() -> void:
|
func _add_emergency_light() -> void:
|
||||||
|
|||||||
@@ -4,6 +4,18 @@ extends Node3D
|
|||||||
## Imported pressure hull (end cap, knuckle and barrel in one shell) and the
|
## Imported pressure hull (end cap, knuckle and barrel in one shell) and the
|
||||||
## porthole bolted into it. Both models are instanced by `cockpit_interior.tscn`.
|
## porthole bolted into it. Both models are instanced by `cockpit_interior.tscn`.
|
||||||
|
|
||||||
|
## The porthole glass lives alone on render layer 2 so interior lights that
|
||||||
|
## sit right behind it (the cool porthole fill) can leave it out of their cull
|
||||||
|
## mask instead of mirroring themselves as a hard specular point.
|
||||||
|
const PORTHOLE_GLASS_RENDER_LAYER_BIT := 1 << 1
|
||||||
|
## Imported glass is near-mirror smooth; a little roughness turns the remaining
|
||||||
|
## lamp reflections into soft smears rather than pinpoints.
|
||||||
|
const PORTHOLE_GLASS_ROUGHNESS := 0.45
|
||||||
|
|
||||||
|
const CockpitMeshFactory = preload(
|
||||||
|
"res://game/presentation/cockpit/cockpit_mesh_factory.gd"
|
||||||
|
)
|
||||||
|
|
||||||
const WHITEBOX_NAMES: Array[String] = [
|
const WHITEBOX_NAMES: Array[String] = [
|
||||||
"LeftBulkhead",
|
"LeftBulkhead",
|
||||||
"RightBulkhead",
|
"RightBulkhead",
|
||||||
@@ -26,6 +38,21 @@ func _ready() -> void:
|
|||||||
push_error("Pressure hull or porthole model is missing a node")
|
push_error("Pressure hull or porthole model is missing a node")
|
||||||
return
|
return
|
||||||
_porthole_glass.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
_porthole_glass.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||||
|
_porthole_glass.layers = PORTHOLE_GLASS_RENDER_LAYER_BIT
|
||||||
|
var glass_material := CockpitMeshFactory.own_surface_material(_porthole_glass)
|
||||||
|
if glass_material != null:
|
||||||
|
glass_material.roughness = PORTHOLE_GLASS_ROUGHNESS
|
||||||
|
|
||||||
|
|
||||||
|
func glass_is_on_dedicated_layer() -> bool:
|
||||||
|
return _porthole_glass != null and _porthole_glass.layers == PORTHOLE_GLASS_RENDER_LAYER_BIT
|
||||||
|
|
||||||
|
|
||||||
|
func glass_roughness() -> float:
|
||||||
|
if _porthole_glass == null:
|
||||||
|
return 0.0
|
||||||
|
var material := _porthole_glass.get_active_material(0) as BaseMaterial3D
|
||||||
|
return 0.0 if material == null else material.roughness
|
||||||
|
|
||||||
|
|
||||||
func panel_count() -> int:
|
func panel_count() -> int:
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// Flat sandy floor with slowly drifting caustic lines. The pattern is purely
|
||||||
|
// presentational and time-driven by the shader clock; no gameplay reads it.
|
||||||
|
shader_type spatial;
|
||||||
|
|
||||||
|
uniform vec3 sand_color : source_color = vec3(0.34, 0.32, 0.25);
|
||||||
|
uniform vec3 caustic_tint : source_color = vec3(0.55, 0.82, 0.92);
|
||||||
|
uniform sampler2D caustic_noise : repeat_enable, filter_linear_mipmap;
|
||||||
|
uniform float caustic_scale = 0.055;
|
||||||
|
uniform float caustic_speed = 0.018;
|
||||||
|
uniform float caustic_strength : hint_range(0.0, 2.0) = 0.25;
|
||||||
|
|
||||||
|
varying vec2 world_xz;
|
||||||
|
|
||||||
|
void vertex() {
|
||||||
|
world_xz = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xz;
|
||||||
|
}
|
||||||
|
|
||||||
|
void fragment() {
|
||||||
|
vec2 drift_a = TIME * caustic_speed * vec2(1.0, 0.6);
|
||||||
|
vec2 drift_b = TIME * caustic_speed * vec2(-0.7, 1.0);
|
||||||
|
float layer_a = texture(caustic_noise, world_xz * caustic_scale + drift_a).r;
|
||||||
|
float layer_b = texture(caustic_noise, world_xz * caustic_scale * 1.35 + drift_b).r;
|
||||||
|
float caustic = pow(clamp(min(layer_a, layer_b) * 1.9, 0.0, 1.0), 4.0);
|
||||||
|
ALBEDO = sand_color;
|
||||||
|
ROUGHNESS = 0.95;
|
||||||
|
METALLIC = 0.0;
|
||||||
|
EMISSION = caustic_tint * caustic * caustic_strength;
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
class_name SunlitShallowsEnvironment
|
||||||
|
extends Node3D
|
||||||
|
|
||||||
|
## Visual test site: flat floor, calm water, fixed daytime sun.
|
||||||
|
## The hour never changes, so the sun transform is a constant and the only
|
||||||
|
## motion is the slow ripple drift inside the two shaders.
|
||||||
|
|
||||||
|
const SURFACE_LEVEL_M := 0.0
|
||||||
|
const FLOOR_DEPTH_M := -40.0
|
||||||
|
## Air above the calm surface holds no scattering medium: a subtractive
|
||||||
|
## FogVolume cancels the global volumetric fog there so the view never shows
|
||||||
|
## an unshadowed glow above the water.
|
||||||
|
const AIR_VOLUME_HEIGHT_M := 80.0
|
||||||
|
const VOLUMETRIC_FOG_DENSITY := 0.035
|
||||||
|
const SITE_EXTENT_M := 240.0
|
||||||
|
## Sun sits ahead of the observer (world -Z) and high: shafts travel down
|
||||||
|
## toward the submarine, as in the cockpit visual target.
|
||||||
|
const SUN_ROTATION_DEGREES := Vector3(-44.0, 162.0, 0.0)
|
||||||
|
const NOISE_SEED := 7
|
||||||
|
## Calm water: ripple drift stays well under 0.05 texture units per second.
|
||||||
|
const SURFACE_DRIFT_SPEED := 0.012
|
||||||
|
## Sunlight only breaks through a pool of surface a few tens of metres ahead of
|
||||||
|
## the submarine (world -Z), so the shafts land in front of the porthole.
|
||||||
|
const SHAFT_POOL_CENTER_XZ_M := Vector2(-14.0, -31.0)
|
||||||
|
const SHAFT_POOL_RADIUS_M := 22.0
|
||||||
|
const SHAFT_POOL_EDGE_M := 12.0
|
||||||
|
## Coarser ripples give a few broken oblique shafts rather than a dense fan.
|
||||||
|
const SURFACE_RIPPLE_SCALE := 0.025
|
||||||
|
## The underside of the surface is drawn dark, lit only inside the pool.
|
||||||
|
const SURFACE_CEILING_LIT_SCALE := 0.3
|
||||||
|
|
||||||
|
const SURFACE_OCCLUDER_SHADER := preload(
|
||||||
|
"res://game/world/sites/sunlit_shallows_surface_occluder.gdshader"
|
||||||
|
)
|
||||||
|
const CAUSTIC_FLOOR_SHADER := preload(
|
||||||
|
"res://game/world/sites/sunlit_shallows_caustic_floor.gdshader"
|
||||||
|
)
|
||||||
|
const SURFACE_CEILING_SHADER := preload(
|
||||||
|
"res://game/world/sites/sunlit_shallows_surface_ceiling.gdshader"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
_fix_sun()
|
||||||
|
_add_flat_floor()
|
||||||
|
_add_surface_occluder()
|
||||||
|
_add_surface_ceiling()
|
||||||
|
_add_air_fog_cut()
|
||||||
|
|
||||||
|
|
||||||
|
func _fix_sun() -> void:
|
||||||
|
var sun := get_node_or_null("Sun") as DirectionalLight3D
|
||||||
|
if sun == null:
|
||||||
|
return
|
||||||
|
sun.rotation_degrees = SUN_ROTATION_DEGREES
|
||||||
|
|
||||||
|
|
||||||
|
func _add_flat_floor() -> void:
|
||||||
|
var plane := PlaneMesh.new()
|
||||||
|
plane.size = Vector2(SITE_EXTENT_M, SITE_EXTENT_M)
|
||||||
|
plane.subdivide_width = 1
|
||||||
|
plane.subdivide_depth = 1
|
||||||
|
|
||||||
|
var material := ShaderMaterial.new()
|
||||||
|
material.shader = CAUSTIC_FLOOR_SHADER
|
||||||
|
material.set_shader_parameter("caustic_noise", _make_ripple_noise(0.007))
|
||||||
|
|
||||||
|
var body := StaticBody3D.new()
|
||||||
|
body.name = "FlatFloor"
|
||||||
|
body.position = Vector3(0.0, FLOOR_DEPTH_M, 0.0)
|
||||||
|
body.set_meta("sonar_material", 1)
|
||||||
|
body.set_meta("sonar_reflectivity", 0.6)
|
||||||
|
|
||||||
|
var mesh_instance := MeshInstance3D.new()
|
||||||
|
mesh_instance.name = "Mesh"
|
||||||
|
mesh_instance.mesh = plane
|
||||||
|
mesh_instance.material_override = material
|
||||||
|
body.add_child(mesh_instance)
|
||||||
|
|
||||||
|
var collision := CollisionShape3D.new()
|
||||||
|
collision.name = "Collision"
|
||||||
|
var shape := BoxShape3D.new()
|
||||||
|
shape.size = Vector3(SITE_EXTENT_M, 1.0, SITE_EXTENT_M)
|
||||||
|
collision.shape = shape
|
||||||
|
collision.position = Vector3(0.0, -0.5, 0.0)
|
||||||
|
body.add_child(collision)
|
||||||
|
add_child(body)
|
||||||
|
|
||||||
|
|
||||||
|
func _add_surface_occluder() -> void:
|
||||||
|
var plane := PlaneMesh.new()
|
||||||
|
plane.size = Vector2(SITE_EXTENT_M, SITE_EXTENT_M)
|
||||||
|
|
||||||
|
var material := ShaderMaterial.new()
|
||||||
|
material.shader = SURFACE_OCCLUDER_SHADER
|
||||||
|
material.set_shader_parameter("ripple_noise", _make_ripple_noise(0.009))
|
||||||
|
material.set_shader_parameter("drift_speed", SURFACE_DRIFT_SPEED)
|
||||||
|
material.set_shader_parameter("ripple_scale", SURFACE_RIPPLE_SCALE)
|
||||||
|
material.set_shader_parameter("pool_center_xz", SHAFT_POOL_CENTER_XZ_M)
|
||||||
|
material.set_shader_parameter("pool_radius", SHAFT_POOL_RADIUS_M)
|
||||||
|
material.set_shader_parameter("pool_edge", SHAFT_POOL_EDGE_M)
|
||||||
|
|
||||||
|
var occluder := MeshInstance3D.new()
|
||||||
|
occluder.name = "CalmSurfaceOccluder"
|
||||||
|
occluder.position = Vector3(0.0, SURFACE_LEVEL_M, 0.0)
|
||||||
|
occluder.mesh = plane
|
||||||
|
occluder.material_override = material
|
||||||
|
occluder.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_SHADOWS_ONLY
|
||||||
|
add_child(occluder)
|
||||||
|
|
||||||
|
|
||||||
|
func _add_surface_ceiling() -> void:
|
||||||
|
var plane := PlaneMesh.new()
|
||||||
|
plane.size = Vector2(SITE_EXTENT_M * 1.5, SITE_EXTENT_M * 1.5)
|
||||||
|
|
||||||
|
var material := ShaderMaterial.new()
|
||||||
|
material.shader = SURFACE_CEILING_SHADER
|
||||||
|
material.set_shader_parameter("ripple_noise", _make_ripple_noise(0.009))
|
||||||
|
material.set_shader_parameter("drift_speed", SURFACE_DRIFT_SPEED)
|
||||||
|
material.set_shader_parameter("pool_center_xz", SHAFT_POOL_CENTER_XZ_M)
|
||||||
|
material.set_shader_parameter("pool_radius", SHAFT_POOL_RADIUS_M)
|
||||||
|
material.set_shader_parameter("lit_scale", SURFACE_CEILING_LIT_SCALE)
|
||||||
|
|
||||||
|
var ceiling := MeshInstance3D.new()
|
||||||
|
ceiling.name = "SurfaceCeiling"
|
||||||
|
ceiling.position = Vector3(0.0, SURFACE_LEVEL_M, 0.0)
|
||||||
|
ceiling.mesh = plane
|
||||||
|
ceiling.material_override = material
|
||||||
|
ceiling.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||||
|
add_child(ceiling)
|
||||||
|
|
||||||
|
|
||||||
|
func _add_air_fog_cut() -> void:
|
||||||
|
var air := FogVolume.new()
|
||||||
|
air.name = "AirFogCut"
|
||||||
|
air.shape = RenderingServer.FOG_VOLUME_SHAPE_BOX
|
||||||
|
air.size = Vector3(SITE_EXTENT_M * 1.5, AIR_VOLUME_HEIGHT_M, SITE_EXTENT_M * 1.5)
|
||||||
|
air.position = Vector3(0.0, SURFACE_LEVEL_M + AIR_VOLUME_HEIGHT_M * 0.5, 0.0)
|
||||||
|
var material := FogMaterial.new()
|
||||||
|
material.density = -VOLUMETRIC_FOG_DENSITY
|
||||||
|
material.edge_fade = 0.0
|
||||||
|
air.material = material
|
||||||
|
add_child(air)
|
||||||
|
|
||||||
|
|
||||||
|
func _make_ripple_noise(frequency: float) -> NoiseTexture2D:
|
||||||
|
var noise := FastNoiseLite.new()
|
||||||
|
noise.seed = NOISE_SEED
|
||||||
|
noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
|
||||||
|
noise.frequency = frequency
|
||||||
|
noise.fractal_octaves = 3
|
||||||
|
noise.fractal_lacunarity = 2.1
|
||||||
|
noise.fractal_gain = 0.45
|
||||||
|
|
||||||
|
var texture := NoiseTexture2D.new()
|
||||||
|
texture.width = 512
|
||||||
|
texture.height = 512
|
||||||
|
texture.seamless = true
|
||||||
|
texture.generate_mipmaps = true
|
||||||
|
texture.noise = noise
|
||||||
|
return texture
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
[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
|
||||||
|
background_color = Color(0.005, 0.03, 0.06, 1)
|
||||||
|
ambient_light_source = 3
|
||||||
|
ambient_light_color = Color(0.14, 0.32, 0.42, 1)
|
||||||
|
ambient_light_energy = 0.22
|
||||||
|
tonemap_mode = 2
|
||||||
|
tonemap_exposure = 0.75
|
||||||
|
glow_enabled = true
|
||||||
|
glow_levels/1 = 0.6
|
||||||
|
glow_levels/2 = 1.0
|
||||||
|
glow_levels/3 = 0.8
|
||||||
|
glow_levels/4 = 0.3
|
||||||
|
glow_intensity = 0.35
|
||||||
|
glow_blend_mode = 0
|
||||||
|
glow_hdr_threshold = 1.6
|
||||||
|
fog_enabled = true
|
||||||
|
fog_light_color = Color(0.04, 0.16, 0.24, 1)
|
||||||
|
fog_light_energy = 0.12
|
||||||
|
fog_sun_scatter = 0.0
|
||||||
|
fog_density = 0.003
|
||||||
|
fog_aerial_perspective = 0.6
|
||||||
|
fog_sky_affect = 1.0
|
||||||
|
volumetric_fog_enabled = true
|
||||||
|
volumetric_fog_density = 0.035
|
||||||
|
volumetric_fog_albedo = Color(0.26, 0.52, 0.7, 1)
|
||||||
|
volumetric_fog_emission_energy = 0.0
|
||||||
|
volumetric_fog_anisotropy = 0.55
|
||||||
|
volumetric_fog_length = 150.0
|
||||||
|
volumetric_fog_detail_spread = 1.6
|
||||||
|
volumetric_fog_ambient_inject = 0.015
|
||||||
|
volumetric_fog_sky_affect = 1.0
|
||||||
|
volumetric_fog_temporal_reprojection_enabled = true
|
||||||
|
volumetric_fog_temporal_reprojection_amount = 0.92
|
||||||
|
|
||||||
|
[node name="SunlitShallowsEnvironment" type="Node3D"]
|
||||||
|
script = ExtResource("1_environment")
|
||||||
|
|
||||||
|
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
|
||||||
|
environment = SubResource("EnvironmentSunlitShallows")
|
||||||
|
|
||||||
|
[node name="Sun" type="DirectionalLight3D" parent="."]
|
||||||
|
rotation_degrees = Vector3(-44, 162, 0)
|
||||||
|
light_color = Color(0.86, 0.95, 1, 1)
|
||||||
|
light_energy = 1.0
|
||||||
|
light_volumetric_fog_energy = 1.4
|
||||||
|
shadow_enabled = true
|
||||||
|
shadow_bias = 0.04
|
||||||
|
shadow_normal_bias = 1.5
|
||||||
|
shadow_blur = 2.5
|
||||||
|
directional_shadow_mode = 1
|
||||||
|
directional_shadow_split_1 = 0.2
|
||||||
|
directional_shadow_blend_splits = true
|
||||||
|
directional_shadow_max_distance = 160.0
|
||||||
|
|
||||||
|
[node name="ObserverCamera" type="Camera3D" parent="."]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 0.9962, 0.0872, 0, -0.0872, 0.9962, 0, -30, 0)
|
||||||
|
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)
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// Underside of the calm surface as seen from below: dark everywhere, lit with
|
||||||
|
// drifting crests only inside the pool where the occluder lets the sun through.
|
||||||
|
// Purely visual; shadows and shafts come from the occluder mesh.
|
||||||
|
shader_type spatial;
|
||||||
|
render_mode unshaded, cull_disabled;
|
||||||
|
|
||||||
|
uniform sampler2D ripple_noise : repeat_enable, filter_linear_mipmap;
|
||||||
|
uniform vec2 pool_center_xz = vec2(-14.0, -31.0);
|
||||||
|
uniform float pool_radius = 22.0;
|
||||||
|
uniform float ripple_scale = 0.03;
|
||||||
|
uniform float drift_speed = 0.012;
|
||||||
|
uniform float lit_scale : hint_range(0.0, 1.0) = 0.3;
|
||||||
|
uniform vec3 dark_color : source_color = vec3(0.01, 0.05, 0.09);
|
||||||
|
uniform vec3 lit_color : source_color = vec3(0.5, 0.85, 1.0);
|
||||||
|
|
||||||
|
varying vec2 world_xz;
|
||||||
|
|
||||||
|
void vertex() {
|
||||||
|
world_xz = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xz;
|
||||||
|
}
|
||||||
|
|
||||||
|
void fragment() {
|
||||||
|
vec2 slow_drift = vec2(TIME * drift_speed);
|
||||||
|
vec2 cross_drift = vec2(-TIME * drift_speed * 0.7);
|
||||||
|
float broad = texture(ripple_noise, world_xz * ripple_scale + slow_drift).r;
|
||||||
|
float fine = texture(ripple_noise, world_xz * ripple_scale * 1.9 + cross_drift).r;
|
||||||
|
float crest = smoothstep(0.45, 0.7, broad * 0.65 + fine * 0.35);
|
||||||
|
float pool = 1.0 - smoothstep(
|
||||||
|
pool_radius * 0.6, pool_radius * 1.6, distance(world_xz, pool_center_xz)
|
||||||
|
);
|
||||||
|
ALBEDO = mix(dark_color, lit_color * (0.35 + crest) * lit_scale, pool);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Calm-water surface seen only through its shadow: the mesh is invisible
|
||||||
|
// (SHADOW_ONLY) and the alpha-scissor cut-outs let the fixed daytime sun carve
|
||||||
|
// light shafts into the volumetric fog below.
|
||||||
|
shader_type spatial;
|
||||||
|
render_mode cull_disabled, unshaded, depth_prepass_alpha;
|
||||||
|
|
||||||
|
uniform sampler2D ripple_noise : repeat_enable, filter_linear_mipmap;
|
||||||
|
uniform float ripple_scale = 0.025;
|
||||||
|
uniform float drift_speed = 0.012;
|
||||||
|
uniform float coverage : hint_range(0.0, 1.0) = 0.30;
|
||||||
|
// World-space pool where the surface lets sunlight through. Outside it the
|
||||||
|
// surface is fully opaque, so shafts only form in that patch of water.
|
||||||
|
uniform vec2 pool_center_xz = vec2(-14.0, -31.0);
|
||||||
|
uniform float pool_radius = 22.0;
|
||||||
|
uniform float pool_edge = 12.0;
|
||||||
|
|
||||||
|
varying vec2 world_xz;
|
||||||
|
|
||||||
|
void vertex() {
|
||||||
|
world_xz = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xz;
|
||||||
|
}
|
||||||
|
|
||||||
|
void fragment() {
|
||||||
|
vec2 slow_drift = TIME * drift_speed * vec2(1.0, 0.35);
|
||||||
|
vec2 cross_drift = TIME * drift_speed * vec2(-0.4, 0.9);
|
||||||
|
float broad = texture(ripple_noise, world_xz * ripple_scale + slow_drift).r;
|
||||||
|
float fine = texture(ripple_noise, world_xz * ripple_scale * 1.9 + cross_drift).r;
|
||||||
|
float raw_ripple = broad * 0.65 + fine * 0.35;
|
||||||
|
// Inside the pool the crests are sharpened so the gaps cut crisp shafts.
|
||||||
|
float pool_ripple = smoothstep(0.35, 0.65, raw_ripple);
|
||||||
|
// Outside the pool only the deepest troughs open: a dim dapple, not darkness.
|
||||||
|
float dim_ripple = smoothstep(0.16, 0.30, raw_ripple);
|
||||||
|
float pool = 1.0 - smoothstep(
|
||||||
|
pool_radius - pool_edge, pool_radius, distance(world_xz, pool_center_xz)
|
||||||
|
);
|
||||||
|
ALBEDO = vec3(0.0);
|
||||||
|
ALPHA = mix(dim_ripple, pool_ripple, pool);
|
||||||
|
ALPHA_SCISSOR_THRESHOLD = coverage;
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -23,4 +23,6 @@ window/stretch/mode="canvas_items"
|
|||||||
|
|
||||||
[rendering]
|
[rendering]
|
||||||
|
|
||||||
|
environment/volumetric_fog/volume_size=128
|
||||||
|
environment/volumetric_fog/volume_depth=128
|
||||||
textures/default_filters/use_nearest_mipmap_filter=false
|
textures/default_filters/use_nearest_mipmap_filter=false
|
||||||
@@ -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]))
|
||||||
@@ -108,7 +108,7 @@ if [ -d game ]; then
|
|||||||
|
|
||||||
input_violations="$(
|
input_violations="$(
|
||||||
rg -n '\bInput\.' game --glob '*.gd' \
|
rg -n '\bInput\.' game --glob '*.gd' \
|
||||||
| rg -v '^game/infrastructure/input/' \
|
| rg -v '^game[/\\]infrastructure[/\\]input[/\\]' \
|
||||||
|| true
|
|| true
|
||||||
)"
|
)"
|
||||||
fail_with_matches "direct Input access outside infrastructure/input" "$input_violations"
|
fail_with_matches "direct Input access outside infrastructure/input" "$input_violations"
|
||||||
|
|||||||
@@ -113,6 +113,16 @@ check_godot_log \
|
|||||||
"production foundation integration" \
|
"production foundation integration" \
|
||||||
"$QUALITY_TEMP_DIRECTORY/foundation-integration-console.log"
|
"$QUALITY_TEMP_DIRECTORY/foundation-integration-console.log"
|
||||||
|
|
||||||
|
printf 'Quality gate: sunlit shallows environment integration\n'
|
||||||
|
"$GODOT_EXECUTABLE" --headless \
|
||||||
|
--log-file "$QUALITY_TEMP_DIRECTORY/sunlit-shallows-integration.log" \
|
||||||
|
--path . \
|
||||||
|
--script tests/integration/run_sunlit_shallows_environment.gd \
|
||||||
|
2>&1 | tee "$QUALITY_TEMP_DIRECTORY/sunlit-shallows-integration-console.log"
|
||||||
|
check_godot_log \
|
||||||
|
"sunlit shallows environment integration" \
|
||||||
|
"$QUALITY_TEMP_DIRECTORY/sunlit-shallows-integration-console.log"
|
||||||
|
|
||||||
printf 'Quality gate: POC B integration\n'
|
printf 'Quality gate: POC B integration\n'
|
||||||
"$GODOT_EXECUTABLE" --headless \
|
"$GODOT_EXECUTABLE" --headless \
|
||||||
--log-file "$QUALITY_TEMP_DIRECTORY/poc_b_integration.log" \
|
--log-file "$QUALITY_TEMP_DIRECTORY/poc_b_integration.log" \
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
## Checks the sunlit shallows visual site keeps its three promises:
|
||||||
|
## a flat floor, a calm shadow-only water surface, and a daytime sun whose
|
||||||
|
## hour never changes.
|
||||||
|
|
||||||
|
const SCENE_PATH := "res://game/world/sites/sunlit_shallows_environment.tscn"
|
||||||
|
const WORLD_SCENE_PATH := (
|
||||||
|
"res://game/composition/vertical_slice/sunlit_shallows_world.tscn"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _failures: Array[String] = []
|
||||||
|
var _checks := 0
|
||||||
|
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
_run.call_deferred()
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
var scene := load(SCENE_PATH) as PackedScene
|
||||||
|
_expect(scene != null, "scene loads")
|
||||||
|
if scene == null:
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
|
||||||
|
var site := scene.instantiate() as SunlitShallowsEnvironment
|
||||||
|
_expect(site != null, "scene root is SunlitShallowsEnvironment")
|
||||||
|
if site == null:
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
root.add_child(site)
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
_test_fixed_daytime_sun(site)
|
||||||
|
_test_flat_floor(site)
|
||||||
|
_test_calm_surface_occluder(site)
|
||||||
|
_test_scattering_environment(site)
|
||||||
|
_test_air_fog_cut(site)
|
||||||
|
_test_surface_ceiling(site)
|
||||||
|
_test_wreck(site)
|
||||||
|
|
||||||
|
site.queue_free()
|
||||||
|
await process_frame
|
||||||
|
_test_composed_world()
|
||||||
|
_finish()
|
||||||
|
|
||||||
|
|
||||||
|
func _test_composed_world() -> void:
|
||||||
|
var world_scene := load(WORLD_SCENE_PATH) as PackedScene
|
||||||
|
_expect(world_scene != null, "composed world scene loads")
|
||||||
|
if world_scene == null:
|
||||||
|
return
|
||||||
|
var world := world_scene.instantiate() as Node3D
|
||||||
|
_expect(
|
||||||
|
world.get_node_or_null("Environment") is SunlitShallowsEnvironment,
|
||||||
|
"composed world uses the sunlit shallows environment"
|
||||||
|
)
|
||||||
|
var submarine := world.get_node_or_null("SubmarineActor") as Node3D
|
||||||
|
_expect(submarine != null, "composed world has a submarine")
|
||||||
|
if submarine != null:
|
||||||
|
_expect(
|
||||||
|
submarine.position.y < SunlitShallowsEnvironment.SURFACE_LEVEL_M
|
||||||
|
and submarine.position.y > SunlitShallowsEnvironment.FLOOR_DEPTH_M,
|
||||||
|
"submarine starts between surface and floor"
|
||||||
|
)
|
||||||
|
_expect(
|
||||||
|
submarine.get_node_or_null("CockpitInterior") != null
|
||||||
|
and submarine.get_node_or_null("CockpitCameraRig") != null,
|
||||||
|
"submarine carries cockpit and camera rig"
|
||||||
|
)
|
||||||
|
for projector_name: String in ["LeftProjector", "RightProjector"]:
|
||||||
|
var projector := submarine.get_node_or_null(projector_name) as SpotLight3D
|
||||||
|
_expect(projector != null, "%s exists" % projector_name)
|
||||||
|
if projector != null:
|
||||||
|
_expect(
|
||||||
|
projector.light_volumetric_fog_energy < 1.0,
|
||||||
|
"%s does not wash out the sun shafts in the fog" % projector_name
|
||||||
|
)
|
||||||
|
world.free()
|
||||||
|
|
||||||
|
|
||||||
|
func _test_fixed_daytime_sun(site: SunlitShallowsEnvironment) -> void:
|
||||||
|
var sun := site.get_node_or_null("Sun") as DirectionalLight3D
|
||||||
|
_expect(sun != null, "sun is a DirectionalLight3D")
|
||||||
|
if sun == null:
|
||||||
|
return
|
||||||
|
_expect(
|
||||||
|
sun.rotation_degrees.is_equal_approx(
|
||||||
|
SunlitShallowsEnvironment.SUN_ROTATION_DEGREES
|
||||||
|
),
|
||||||
|
"sun hour is the fixed constant"
|
||||||
|
)
|
||||||
|
var sun_direction := -sun.global_transform.basis.z
|
||||||
|
_expect(sun_direction.y < -0.6, "sun shines downward from above the porthole (daytime)")
|
||||||
|
_expect(
|
||||||
|
sun_direction.z > 0.15,
|
||||||
|
"sun shines toward the observer (rays come from ahead, world -Z)"
|
||||||
|
)
|
||||||
|
_expect(sun.shadow_enabled, "sun casts shadows (needed for light shafts)")
|
||||||
|
_expect(
|
||||||
|
sun.light_volumetric_fog_energy >= 1.0,
|
||||||
|
"sun injects energy into the volumetric fog"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func _test_flat_floor(site: SunlitShallowsEnvironment) -> void:
|
||||||
|
var floor_mesh := site.get_node_or_null("FlatFloor/Mesh") as MeshInstance3D
|
||||||
|
_expect(floor_mesh != null, "floor mesh exists")
|
||||||
|
if floor_mesh == null:
|
||||||
|
return
|
||||||
|
var vertices := floor_mesh.mesh.get_faces()
|
||||||
|
var flat := true
|
||||||
|
for vertex: Vector3 in vertices:
|
||||||
|
if not is_zero_approx(vertex.y):
|
||||||
|
flat = false
|
||||||
|
break
|
||||||
|
_expect(flat, "floor mesh is flat")
|
||||||
|
_expect(
|
||||||
|
is_equal_approx(
|
||||||
|
floor_mesh.global_position.y,
|
||||||
|
SunlitShallowsEnvironment.FLOOR_DEPTH_M
|
||||||
|
),
|
||||||
|
"floor sits at the site depth"
|
||||||
|
)
|
||||||
|
_expect(
|
||||||
|
floor_mesh.material_override is ShaderMaterial,
|
||||||
|
"floor uses the caustic shader material"
|
||||||
|
)
|
||||||
|
var collision := site.get_node_or_null("FlatFloor/Collision") as CollisionShape3D
|
||||||
|
_expect(collision != null and collision.shape is BoxShape3D, "floor has a box collider")
|
||||||
|
|
||||||
|
|
||||||
|
func _test_calm_surface_occluder(site: SunlitShallowsEnvironment) -> void:
|
||||||
|
var occluder := site.get_node_or_null("CalmSurfaceOccluder") as MeshInstance3D
|
||||||
|
_expect(occluder != null, "surface occluder exists")
|
||||||
|
if occluder == null:
|
||||||
|
return
|
||||||
|
_expect(
|
||||||
|
occluder.cast_shadow == GeometryInstance3D.SHADOW_CASTING_SETTING_SHADOWS_ONLY,
|
||||||
|
"surface occluder is shadow-only"
|
||||||
|
)
|
||||||
|
_expect(
|
||||||
|
is_equal_approx(
|
||||||
|
occluder.global_position.y,
|
||||||
|
SunlitShallowsEnvironment.SURFACE_LEVEL_M
|
||||||
|
),
|
||||||
|
"surface occluder sits at water level"
|
||||||
|
)
|
||||||
|
var material := occluder.material_override as ShaderMaterial
|
||||||
|
_expect(material != null, "surface occluder uses a shader material")
|
||||||
|
if material == null:
|
||||||
|
return
|
||||||
|
var drift: Variant = material.get_shader_parameter("drift_speed")
|
||||||
|
_expect(
|
||||||
|
drift is float and drift > 0.0 and drift < 0.05,
|
||||||
|
"surface drift is slow (calm water)"
|
||||||
|
)
|
||||||
|
_expect(
|
||||||
|
material.get_shader_parameter("ripple_noise") is NoiseTexture2D,
|
||||||
|
"surface ripple noise is assigned"
|
||||||
|
)
|
||||||
|
var pool_center: Variant = material.get_shader_parameter("pool_center_xz")
|
||||||
|
_expect(
|
||||||
|
pool_center is Vector2 and pool_center.y < -20.0 and pool_center.y > -120.0,
|
||||||
|
"light pool sits a few tens of metres ahead (world -Z)"
|
||||||
|
)
|
||||||
|
var pool_radius: Variant = material.get_shader_parameter("pool_radius")
|
||||||
|
_expect(
|
||||||
|
pool_radius is float and pool_radius > 5.0 and pool_radius < 60.0,
|
||||||
|
"light pool radius is local, not the whole surface"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func _test_scattering_environment(site: SunlitShallowsEnvironment) -> void:
|
||||||
|
var world_environment := site.get_node_or_null("WorldEnvironment") as WorldEnvironment
|
||||||
|
_expect(world_environment != null, "world environment exists")
|
||||||
|
if world_environment == null:
|
||||||
|
return
|
||||||
|
var environment := world_environment.environment
|
||||||
|
_expect(environment.volumetric_fog_enabled, "volumetric fog is enabled")
|
||||||
|
_expect(environment.volumetric_fog_anisotropy > 0.5, "fog scatters forward")
|
||||||
|
_expect(environment.fog_enabled, "depth fog is enabled")
|
||||||
|
|
||||||
|
|
||||||
|
func _test_surface_ceiling(site: SunlitShallowsEnvironment) -> void:
|
||||||
|
var ceiling := site.get_node_or_null("SurfaceCeiling") as MeshInstance3D
|
||||||
|
_expect(ceiling != null, "visible surface ceiling exists")
|
||||||
|
if ceiling == null:
|
||||||
|
return
|
||||||
|
_expect(
|
||||||
|
is_equal_approx(ceiling.global_position.y, SunlitShallowsEnvironment.SURFACE_LEVEL_M),
|
||||||
|
"surface ceiling sits at water level"
|
||||||
|
)
|
||||||
|
_expect(
|
||||||
|
ceiling.cast_shadow == GeometryInstance3D.SHADOW_CASTING_SETTING_OFF,
|
||||||
|
"surface ceiling leaves shadowing to the occluder"
|
||||||
|
)
|
||||||
|
var ceiling_material := ceiling.material_override as ShaderMaterial
|
||||||
|
var occluder := site.get_node_or_null("CalmSurfaceOccluder") as MeshInstance3D
|
||||||
|
var occluder_material: ShaderMaterial = null if occluder == null else occluder.material_override
|
||||||
|
_expect(
|
||||||
|
ceiling_material != null
|
||||||
|
and occluder_material != null
|
||||||
|
and ceiling_material.get_shader_parameter("pool_center_xz")
|
||||||
|
== occluder_material.get_shader_parameter("pool_center_xz")
|
||||||
|
and ceiling_material.get_shader_parameter("pool_radius")
|
||||||
|
== occluder_material.get_shader_parameter("pool_radius"),
|
||||||
|
"surface ceiling lights the same pool the occluder opens"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func _test_air_fog_cut(site: SunlitShallowsEnvironment) -> void:
|
||||||
|
var air := site.get_node_or_null("AirFogCut") as FogVolume
|
||||||
|
_expect(air != null, "air fog cut exists")
|
||||||
|
if air == null:
|
||||||
|
return
|
||||||
|
var bottom := air.global_position.y - air.size.y * 0.5
|
||||||
|
_expect(
|
||||||
|
is_equal_approx(bottom, SunlitShallowsEnvironment.SURFACE_LEVEL_M),
|
||||||
|
"air fog cut starts at the water surface"
|
||||||
|
)
|
||||||
|
var material := air.material as FogMaterial
|
||||||
|
_expect(material != null and material.density < 0.0, "air fog cut subtracts fog")
|
||||||
|
var world_environment := site.get_node("WorldEnvironment") as WorldEnvironment
|
||||||
|
_expect(
|
||||||
|
material != null and is_equal_approx(
|
||||||
|
-material.density,
|
||||||
|
world_environment.environment.volumetric_fog_density
|
||||||
|
),
|
||||||
|
"air fog cut cancels the global volumetric density exactly"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
_failures.append(label)
|
||||||
|
|
||||||
|
|
||||||
|
func _finish() -> void:
|
||||||
|
if _failures.is_empty():
|
||||||
|
print("Sunlit shallows environment: %d passed, 0 failed." % _checks)
|
||||||
|
quit(0)
|
||||||
|
else:
|
||||||
|
for failure: String in _failures:
|
||||||
|
push_error("sunlit shallows environment: %s" % failure)
|
||||||
|
print("Sunlit shallows environment: %d failed." % _failures.size())
|
||||||
|
quit(1)
|
||||||
@@ -30,7 +30,7 @@ const ProductionInputAdapter = preload(
|
|||||||
"res://game/infrastructure/input/production_input_adapter.gd"
|
"res://game/infrastructure/input/production_input_adapter.gd"
|
||||||
)
|
)
|
||||||
|
|
||||||
const TEST_COUNT := 91
|
const TEST_COUNT := 94
|
||||||
|
|
||||||
var _failures: Array[String] = []
|
var _failures: Array[String] = []
|
||||||
|
|
||||||
@@ -186,6 +186,9 @@ func _test_vehicle_slice(composition: VerticalSliceRoot) -> void:
|
|||||||
_expect(detailing.practical_light_count() == 2, "cockpit uses two caged practical lights")
|
_expect(detailing.practical_light_count() == 2, "cockpit uses two caged practical lights")
|
||||||
_expect(pressure_bulkhead.panel_count() == 1, "porthole bulkhead uses one continuous curved shell")
|
_expect(pressure_bulkhead.panel_count() == 1, "porthole bulkhead uses one continuous curved shell")
|
||||||
_expect(pressure_bulkhead.rectangular_whitebox_is_hidden(), "porthole rectangular whitebox is hidden")
|
_expect(pressure_bulkhead.rectangular_whitebox_is_hidden(), "porthole rectangular whitebox is hidden")
|
||||||
|
_expect(pressure_bulkhead.glass_is_on_dedicated_layer(), "porthole glass sits alone on its render layer")
|
||||||
|
_expect(pressure_bulkhead.glass_roughness() >= 0.4, "porthole glass is rough enough to smear lamp reflections")
|
||||||
|
_expect(detailing.fill_light_ignores_porthole_glass(), "porthole fill light does not reflect in the glass")
|
||||||
_expect(analog_gauges.gauge_count() == 4, "dashboard exposes four primary analog instruments")
|
_expect(analog_gauges.gauge_count() == 4, "dashboard exposes four primary analog instruments")
|
||||||
_expect(alert_box.indicator_count() == 3, "alert box exposes three pilot lights")
|
_expect(alert_box.indicator_count() == 3, "alert box exposes three pilot lights")
|
||||||
_expect(alert_box.active_indicator_count() == 1, "exactly one pilot light reflects the alert state")
|
_expect(alert_box.active_indicator_count() == 1, "exactly one pilot light reflects the alert state")
|
||||||
|
|||||||
Reference in new issue
Block a user