feat(world): world-space marine snow around the viewer
Quality Gate / Quality Gate (pull_request) Failing after 3m10s

A GPUParticles3D emitter in the dive site re-centres on the active camera
every frame and restarts after a jump, so flakes keep their own slow sink
and drift while the submarine moves through them. Soft additive billboard
discs dimmed by the water column and faded over their life replace the
sphere volume that was attached to the submarine and turned with it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
OlivierandClaude Fable 5.1 committed 2026-10-07 09:25:02 +02:00
1 parent 48e054557c
commit 029e2b99bd
7 files changed
+127 -26

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 405 KiB

After

Width:  |  Height:  |  Size: 406 KiB

@@ -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 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` `MultiMesh` par tuile et par variante, shader opaque `seabed_coral.gdshader`
(couleur de sommet × teinte d'instance, marbrure, même colonne d'eau). (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 - Brouillard, lumière directionnelle et particules de la scène de test
actuelle sont conservés et réglés par site. 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 - Passe lumière du 2026-10-06 (`dive_site.tscn`) : l'ambiance vient d'un ciel
@@ -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/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/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_camera_rig.tscn" id="3_camera"]
[ext_resource type="PackedScene" path="res://game/presentation/cockpit/cockpit_interior.tscn" id="4_cockpit"] [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="DiveWorld" type="Node3D"]
[node name="DiveSite" parent="." instance=ExtResource("1_site")] [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")] [node name="SubmarineActor" parent="." instance=ExtResource("2_submarine")]
unique_name_in_owner = true 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")] [node name="CockpitInterior" parent="SubmarineActor" instance=ExtResource("4_cockpit")]
unique_name_in_owner = true unique_name_in_owner = true
+5 -1
View File
@@ -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/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="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="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/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"] [sub_resource type="ShaderMaterial" id="WaterColumnMaterial"]
shader = ExtResource("3_water_column") shader = ExtResource("3_water_column")
@@ -101,5 +102,8 @@ script = ExtResource("2_terrain")
unique_name_in_owner = true unique_name_in_owner = true
script = ExtResource("4_flora") script = ExtResource("4_flora")
[node name="MarineSnow" type="GPUParticles3D" parent="."]
script = ExtResource("5_snow")
[node name="Placements" type="Node3D" parent="."] [node name="Placements" type="Node3D" parent="."]
unique_name_in_owner = true unique_name_in_owner = true
+76
View File
@@ -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
+36
View File
@@ -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;
}
@@ -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 ZONE1_SITE_PATH := "res://content/sites/zone1_vigilant.tres"
const SHOWCASE_SITE_PATH := "res://content/sites/terrain_showcase.tres" const SHOWCASE_SITE_PATH := "res://content/sites/terrain_showcase.tres"
const GROOVE_PROBE_OFFSET_M := 25.0 const GROOVE_PROBE_OFFSET_M := 25.0
const TEST_COUNT := 51 const TEST_COUNT := 52
var _failures: Array[String] = [] var _failures: Array[String] = []
@@ -210,6 +210,8 @@ func _test_dive_site_scene() -> void:
var first_pass := flora.sample_transforms(SeabedFloraActor.Kind.SEAGRASS) var first_pass := flora.sample_transforms(SeabedFloraActor.Kind.SEAGRASS)
flora.configure(field_for_flora, hash(String(definition.site_id))) 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") _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 var water_column := site.get_node_or_null("WaterColumn") as FogVolume
_expect( _expect(
water_column != null and water_column.material is ShaderMaterial water_column != null and water_column.material is ShaderMaterial