diff --git a/docs/briefs/captures/zone1_vigilant_patch_reef.png b/docs/briefs/captures/zone1_vigilant_patch_reef.png index 850aca5..e164a3c 100644 Binary files a/docs/briefs/captures/zone1_vigilant_patch_reef.png and b/docs/briefs/captures/zone1_vigilant_patch_reef.png differ diff --git a/docs/briefs/terrain-site-zone1-gameplay.md b/docs/briefs/terrain-site-zone1-gameplay.md index 017a2df..4101fd2 100644 --- a/docs/briefs/terrain-site-zone1-gameplay.md +++ b/docs/briefs/terrain-site-zone1-gameplay.md @@ -270,6 +270,12 @@ de contacts existant les sépare du fond. semés sur la roche douce (< 30°) de 12 à 40 m en patch reefs par bruit, une `MultiMesh` par tuile et par variante, shader opaque `seabed_coral.gdshader` (couleur de sommet × teinte d'instance, marbrure, même colonne d'eau). +- Particules en suspension (même jour) : `MarineSnow`, émetteur + `GPUParticles3D` en espace monde recentré sur la caméra active, boîte de + 44 × 28 × 44 m, 1 800 flocons de 3 à 9 cm qui coulent à 3,5 cm/s avec + turbulence, disques doux additifs en billboard, atténués par la colonne + d'eau et fondus en début et fin de vie. Remplace le volume de sphères + attaché au sous-marin (qui tournait avec lui). - Brouillard, lumière directionnelle et particules de la scène de test actuelle sont conservés et réglés par site. - Passe lumière du 2026-10-06 (`dive_site.tscn`) : l'ambiance vient d'un ciel diff --git a/game/composition/vertical_slice/dive_world.tscn b/game/composition/vertical_slice/dive_world.tscn index a4454d3..ff5b844 100644 --- a/game/composition/vertical_slice/dive_world.tscn +++ b/game/composition/vertical_slice/dive_world.tscn @@ -1,25 +1,10 @@ -[gd_scene load_steps=7 format=3] +[gd_scene load_steps=5 format=3] [ext_resource type="PackedScene" path="res://game/world/sites/dive_site.tscn" id="1_site"] [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"] -[sub_resource type="ParticleProcessMaterial" id="SuspendedMotion"] -emission_shape = 3 -emission_box_extents = Vector3(45, 28, 70) -direction = Vector3(0.1, 0.04, 0) -spread = 180.0 -initial_velocity_min = 0.02 -initial_velocity_max = 0.09 -gravity = Vector3(0, 0, 0) -scale_min = 0.025 -scale_max = 0.08 - -[sub_resource type="SphereMesh" id="SuspendedParticleMesh"] -radius = 0.035 -height = 0.07 - [node name="DiveWorld" type="Node3D"] [node name="DiveSite" parent="." instance=ExtResource("1_site")] @@ -28,14 +13,6 @@ unique_name_in_owner = true [node name="SubmarineActor" parent="." instance=ExtResource("2_submarine")] unique_name_in_owner = true -[node name="SuspendedParticles" type="GPUParticles3D" parent="SubmarineActor"] -amount = 360 -lifetime = 14.0 -preprocess = 14.0 -visibility_aabb = AABB(-50, -35, -78, 100, 70, 156) -process_material = SubResource("SuspendedMotion") -draw_pass_1 = SubResource("SuspendedParticleMesh") - [node name="CockpitInterior" parent="SubmarineActor" instance=ExtResource("4_cockpit")] unique_name_in_owner = true diff --git a/game/world/sites/dive_site.tscn b/game/world/sites/dive_site.tscn index 05bfb51..4849caa 100644 --- a/game/world/sites/dive_site.tscn +++ b/game/world/sites/dive_site.tscn @@ -1,9 +1,10 @@ -[gd_scene load_steps=10 format=3] +[gd_scene load_steps=11 format=3] [ext_resource type="Script" path="res://game/world/sites/dive_site.gd" id="1_site"] [ext_resource type="Script" path="res://game/world/sites/bathymetric_terrain_actor.gd" id="2_terrain"] [ext_resource type="Shader" path="res://game/world/sites/water_column_fog.gdshader" id="3_water_column"] [ext_resource type="Script" path="res://game/world/sites/seabed_flora_actor.gd" id="4_flora"] +[ext_resource type="Script" path="res://game/world/sites/marine_snow.gd" id="5_snow"] [sub_resource type="ShaderMaterial" id="WaterColumnMaterial"] shader = ExtResource("3_water_column") @@ -101,5 +102,8 @@ script = ExtResource("2_terrain") unique_name_in_owner = true script = ExtResource("4_flora") +[node name="MarineSnow" type="GPUParticles3D" parent="."] +script = ExtResource("5_snow") + [node name="Placements" type="Node3D" parent="."] unique_name_in_owner = true diff --git a/game/world/sites/marine_snow.gd b/game/world/sites/marine_snow.gd new file mode 100644 index 0000000..5cf4e66 --- /dev/null +++ b/game/world/sites/marine_snow.gd @@ -0,0 +1,76 @@ +class_name MarineSnow +extends GPUParticles3D + +## Suspended particles (marine snow, sediment) around the viewer. The emitter is a +## world-space box re-centred on the active camera every frame, so flakes keep their +## own slow drift while the submarine moves through them instead of travelling with +## it. Sinks a few centimetres per second with a little turbulence, as detritus does. + +const BOX_EXTENTS_M := Vector3(16.0, 10.0, 16.0) +const PARTICLE_COUNT := 2400 +const LIFETIME_S := 16.0 +const SINK_SPEED_M_S := 0.035 +const DRIFT_SPEED_M_S := 0.06 +const FLAKE_SIZE_M := Vector2(0.05, 0.16) + +const SNOW_SHADER = preload("res://game/world/sites/marine_snow.gdshader") + + +func _ready() -> void: + local_coords = false + amount = PARTICLE_COUNT + lifetime = LIFETIME_S + preprocess = LIFETIME_S + fixed_fps = 30 + visibility_aabb = AABB(-BOX_EXTENTS_M * 1.5, BOX_EXTENTS_M * 3.0) + process_material = _process_material() + var quad := QuadMesh.new() + quad.size = Vector2.ONE + var material := ShaderMaterial.new() + material.shader = SNOW_SHADER + quad.material = material + draw_pass_1 = quad + emitting = true + + +## A jump larger than the box (spawn, teleport, first camera) would leave the viewer in +## empty water for a whole lifetime: the emitter restarts and preprocesses in place. +func _process(_delta: float) -> void: + var camera := get_viewport().get_camera_3d() if is_inside_tree() else null + if camera == null: + return + var jumped := global_position.distance_to(camera.global_position) > BOX_EXTENTS_M.y + global_position = camera.global_position + if jumped: + restart() + + +func _process_material() -> ParticleProcessMaterial: + var material := ParticleProcessMaterial.new() + material.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX + material.emission_box_extents = BOX_EXTENTS_M + material.direction = Vector3(1.0, 0.0, 0.3) + material.spread = 180.0 + material.initial_velocity_min = DRIFT_SPEED_M_S * 0.3 + material.initial_velocity_max = DRIFT_SPEED_M_S + material.gravity = Vector3(0.0, -SINK_SPEED_M_S, 0.0) + material.turbulence_enabled = true + material.turbulence_noise_strength = 0.4 + material.turbulence_noise_scale = 6.0 + material.turbulence_noise_speed_random = 0.2 + material.turbulence_influence_min = 0.02 + material.turbulence_influence_max = 0.06 + material.scale_min = FLAKE_SIZE_M.x + material.scale_max = FLAKE_SIZE_M.y + # Fade in and out through alpha so flakes never pop at the box edge or at respawn. + var alpha := Gradient.new() + alpha.set_offset(0, 0.0) + alpha.set_color(0, Color(1.0, 1.0, 1.0, 0.0)) + alpha.add_point(0.15, Color(1.0, 1.0, 1.0, 1.0)) + alpha.add_point(0.85, Color(1.0, 1.0, 1.0, 1.0)) + alpha.set_offset(alpha.get_point_count() - 1, 1.0) + alpha.set_color(alpha.get_point_count() - 1, Color(1.0, 1.0, 1.0, 0.0)) + var ramp := GradientTexture1D.new() + ramp.gradient = alpha + material.color_ramp = ramp + return material diff --git a/game/world/sites/marine_snow.gdshader b/game/world/sites/marine_snow.gdshader new file mode 100644 index 0000000..a868db7 --- /dev/null +++ b/game/world/sites/marine_snow.gdshader @@ -0,0 +1,36 @@ +shader_type spatial; +render_mode blend_add, depth_draw_never, cull_disabled, unshaded; + +// Marine snow: camera-facing soft discs drawn by GPUParticles3D. Each particle is a +// unit quad; the flake is a radial falloff in UV. Brightness follows the daylight of +// the water column (so flakes dim with depth) and fades in and out over the life of +// the particle through COLOR.a set by the process material. + +#include "res://game/world/sites/water_light.gdshaderinc" + +uniform vec3 flake_color : source_color = vec3(0.78, 0.86, 0.9); +uniform float flake_strength = 1.4; +uniform vec3 water_absorption_per_m = vec3(0.11, 0.04, 0.032); +uniform float reference_depth_m = 24.0; +uniform float minimum_daylight = 0.08; + +varying float brightness; + +void vertex() { + // Billboard: keep the particle's world position, face the camera. + vec3 world_origin = (MODEL_MATRIX * vec4(0.0, 0.0, 0.0, 1.0)).xyz; + float size = length(MODEL_MATRIX[0].xyz); + mat3 view_basis = mat3(INV_VIEW_MATRIX); + vec3 world = world_origin + (view_basis[0] * VERTEX.x + view_basis[1] * VERTEX.y) * size; + VERTEX = (VIEW_MATRIX * vec4(world, 1.0)).xyz; + NORMAL = (VIEW_MATRIX * vec4(view_basis[2], 0.0)).xyz; + vec3 transmittance = water_transmittance(-world_origin.y, water_absorption_per_m, reference_depth_m); + brightness = water_daylight(transmittance, minimum_daylight) * COLOR.a; +} + +void fragment() { + float radial = length(UV - vec2(0.5)) * 2.0; + float disc = 1.0 - smoothstep(0.35, 1.0, radial); + ALBEDO = flake_color * disc * brightness * flake_strength; + ALPHA = disc; +} diff --git a/tests/integration/run_site_terrain_integration.gd b/tests/integration/run_site_terrain_integration.gd index c9502ce..253ac12 100644 --- a/tests/integration/run_site_terrain_integration.gd +++ b/tests/integration/run_site_terrain_integration.gd @@ -20,7 +20,7 @@ const DIVE_SITE_SCENE = preload("res://game/world/sites/dive_site.tscn") const ZONE1_SITE_PATH := "res://content/sites/zone1_vigilant.tres" const SHOWCASE_SITE_PATH := "res://content/sites/terrain_showcase.tres" const GROOVE_PROBE_OFFSET_M := 25.0 -const TEST_COUNT := 51 +const TEST_COUNT := 52 var _failures: Array[String] = [] @@ -210,6 +210,8 @@ func _test_dive_site_scene() -> void: var first_pass := flora.sample_transforms(SeabedFloraActor.Kind.SEAGRASS) flora.configure(field_for_flora, hash(String(definition.site_id))) _expect(first_pass == flora.sample_transforms(SeabedFloraActor.Kind.SEAGRASS), "flora placement is deterministic for a seed") + var snow := site.get_node_or_null("MarineSnow") as GPUParticles3D + _expect(snow != null and snow.emitting and snow.amount > 0 and not snow.local_coords, "site carries world-space marine snow around the viewer") var water_column := site.get_node_or_null("WaterColumn") as FogVolume _expect( water_column != null and water_column.material is ShaderMaterial