feat: lit particle shader for nebula dust weather effect

Replace NebulaDust's flat unshaded glow material with a custom
ShaderMaterial: a fake per-pixel puff normal on the billboarded quad
feeds a light() override so motes catch the directional light and a
nebula-core color bias, alpha gets a depth-texture soft-particle fade
so motes no longer hard-clip through boundary/decoration geometry,
and a per-particle hash adds subtle sparkle.
This commit is contained in:
Josh Creek
2026-08-04 07:42:34 +01:00
parent 73fb83a0da
commit 6f5ce488a9
3 changed files with 63 additions and 13 deletions
+10 -12
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=15 format=3]
[gd_scene load_steps=16 format=3]
[ext_resource type="Script" path="res://scripts/arena.gd" id="1_iywne"]
[ext_resource type="PackedScene" path="res://objects/arena_boundary.tscn" id="2_bndry"]
@@ -8,6 +8,7 @@
[ext_resource type="PackedScene" path="res://assets/models/nebula_debris.glb" id="9_debris"]
[ext_resource type="Texture2D" path="res://assets/textures/particle_glow.png" id="10_dust"]
[ext_resource type="PackedScene" path="res://assets/models/nebula_planet.glb" id="11_planet"]
[ext_resource type="Shader" path="res://shaders/nebula_dust.gdshader" id="12_dust_shader"]
[sub_resource type="PanoramaSkyMaterial" id="PanoramaSkyMaterial_nebula"]
panorama = ExtResource("7_nebula")
@@ -39,19 +40,16 @@ adjustment_brightness = 1.0
adjustment_contrast = 1.05
adjustment_saturation = 1.08
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_dust"]
transparency = 1
shading_mode = 0
billboard_mode = 1
vertex_color_use_as_albedo = true
albedo_color = Color(1, 0.75, 0.9, 1)
albedo_texture = ExtResource("10_dust")
emission_enabled = true
emission = Color(1, 0.6, 0.85, 1)
emission_energy_multiplier = 2.5
[sub_resource type="ShaderMaterial" id="ShaderMaterial_dust"]
shader = ExtResource("12_dust_shader")
shader_parameter/albedo_tex = ExtResource("10_dust")
shader_parameter/core_bias_color = Color(1, 0.6, 0.85, 1)
shader_parameter/core_bias_amount = 0.35
shader_parameter/emission_strength = 2.5
shader_parameter/soft_fade_distance = 1.0
[sub_resource type="QuadMesh" id="QuadMesh_dust"]
material = SubResource("StandardMaterial3D_dust")
material = SubResource("ShaderMaterial_dust")
size = Vector2(0.4, 0.4)
[sub_resource type="Gradient" id="Gradient_dust"]
+52
View File
@@ -0,0 +1,52 @@
shader_type spatial;
render_mode blend_mix, depth_draw_never, cull_disabled, specular_disabled;
uniform sampler2D albedo_tex : source_color;
uniform sampler2D depth_tex : hint_depth_texture, filter_linear_mipmap;
uniform vec3 core_bias_color : source_color = vec3(1.0, 0.6, 0.85);
uniform float core_bias_amount : hint_range(0.0, 1.0) = 0.35;
uniform float emission_strength : hint_range(0.0, 5.0) = 2.5;
uniform float soft_fade_distance : hint_range(0.0, 5.0) = 1.0;
varying float v_flicker;
void vertex() {
// true billboard: strip rotation from the per-instance modelview, keep translation + scale
mat4 mv = MODELVIEW_MATRIX;
mv[0].xyz = vec3(length(mv[0].xyz), 0.0, 0.0);
mv[1].xyz = vec3(0.0, length(mv[1].xyz), 0.0);
mv[2].xyz = vec3(0.0, 0.0, length(mv[2].xyz));
VERTEX = (mv * vec4(VERTEX, 1.0)).xyz;
// INSTANCE_CUSTOM.x is GPUParticles3D's per-particle random seed, baked at spawn
float seed = INSTANCE_CUSTOM.x;
float flicker_hash = fract(sin((seed + floor(TIME * 6.0)) * 127.1) * 43758.5453);
v_flicker = 0.85 + 0.15 * flicker_hash;
}
void fragment() {
// fake a puff/sphere normal from local UV so a camera-facing billboard can still catch directional light;
// the quad is billboarded to face the camera in vertex(), so this normal is already view-space aligned
vec2 centered = UV * 2.0 - 1.0;
float r2 = dot(centered, centered);
float mask = clamp(1.0 - r2, 0.0, 1.0);
NORMAL = normalize(vec3(centered, sqrt(max(mask, 0.001))));
vec4 tex = texture(albedo_tex, UV);
ALBEDO = tex.rgb * mix(vec3(1.0), core_bias_color, core_bias_amount);
EMISSION = core_bias_color * tex.rgb * emission_strength * 0.15;
ALPHA = tex.a * COLOR.a * mask;
float raw_depth = texture(depth_tex, SCREEN_UV).r;
vec3 ndc = vec3(SCREEN_UV * 2.0 - 1.0, raw_depth);
vec4 view_pos = INV_PROJECTION_MATRIX * vec4(ndc, 1.0);
view_pos.xyz /= view_pos.w;
float scene_depth = -view_pos.z;
float particle_depth = -VERTEX.z;
ALPHA *= clamp((scene_depth - particle_depth) / soft_fade_distance, 0.0, 1.0);
}
void light() {
float ndotl = clamp(dot(NORMAL, LIGHT), 0.0, 1.0);
DIFFUSE_LIGHT += LIGHT_COLOR * ndotl * ALBEDO * emission_strength * v_flicker * ATTENUATION;
}
+1 -1
View File
@@ -44,7 +44,7 @@ A subagent ran the game and critiqued arena_02 head-on against Rocket League 2 i
- [x] **Richer nebula sky + planet surface textures** (extends the existing procedural generation — no new asset types). `sky_nebula.png` is essentially one noise-filter pass over a bright core; `planet_surface.png` is a flat gradient with a single vortex swirl and no bands, craters, or day/night terminator. Done: `tools/textures/gen_nebula_sky.py` gained two more dust-lane layers at different scales plus subtle patchy hue variation within the bright core; `tools/textures/gen_planet_surface.py` is a new generator (no prior script existed, despite the note below) producing latitude bands, 3 storm vortices, and a lit/unlit terminator baked from the sphere's exact UV convention (reverse-engineered from `nebula_planet.glb`'s vertex data) dotted against `arena_02.tscn`'s actual `DirectionalLight3D` direction — verified in-engine via godot-mcp screenshots showing a clean crescent terminator; both `Game/assets/textures/planet_surface.png` and the live-feeding sidecar `Game/assets/models/nebula_planet_planet_surface.png` were regenerated and reimported.
Prompt: "Extend the nebula/planet texture generators (see `tools/textures/` once committed, per the star-sprite TODO item above) with more detail layers: for the sky, add a second/third dust-lane layer at a different scale plus subtler color variation within the bright core (real nebulae aren't one flat color); for the planet, add a proper lit/unlit terminator gradient (the side facing the arena's directional light should read brighter), more band variation at different latitudes, and a couple more storm-vortex features so it doesn't read as a single gradient with one twist. Regenerate both textures, reimport, and screenshot-verify."
- [ ] **Particle lighting response for the nebula dust ("weather") effect** (no models — particle material/shader only). The `NebulaDust` `GPUParticles3D` motes are generic soft glow sprites with no lighting interaction, no depth-based fade, and no secondary motion (no sparkle/color shift).
- [x] **Particle lighting response for the nebula dust ("weather") effect** (no models — particle material/shader only). `NebulaDust`'s `StandardMaterial3D` was replaced with a new `ShaderMaterial` (`Game/shaders/nebula_dust.gdshader`, the project's first particle/spatial shader): a fake per-pixel puff normal is derived from local UV on the true-billboarded quad and fed into a `light()` override (`LIGHT`/`LIGHT_COLOR`/`ATTENUATION`) so motes visibly catch the arena's directional light and pick up a fixed nebula-core color bias; alpha is multiplied by a depth-texture-based soft-particle fade so motes no longer hard-clip through boundary/decoration geometry; a per-particle flicker (hashed from `INSTANCE_CUSTOM.x` + `TIME`) adds subtle sparkle. Verified via godot-mcp screenshots from multiple angles (visible lit/unlit shading variation, smooth fade near geometry, per-mote sparkle) and zero-error headless runs of `free_play`/`match`/`spectate`/`training`.
Prompt: "In `arena_02.tscn`'s `NebulaDust` particle system, replace the current unshaded glow-sprite material with a custom shader that fakes lighting response — note the sprites are camera-facing billboards, so they have no fixed world-space normal and true per-pixel PBR lighting won't behave like it would on a solid mesh. A practical approach: derive a fake per-pixel normal from the quad's local UV (as if each sprite were a small sphere/puff, same trick used for 2D lit particle effects) and light that against the directional light + a fixed 'glow color' bias toward the nebula core direction, rather than relying on the mesh's real (camera-facing) normal. Add soft-particle depth fade (compare particle depth to the depth buffer) so motes don't hard-clip through the boundary/decoration geometry, and add subtle per-particle brightness flicker via the color ramp or shader for sparkle. Verify visually via godot-mcp screenshots that the dust reads as lit rather than flat-glowing, from a couple of different camera angles (billboard lighting tricks can look wrong from some angles even when right from others)."
- [x] **Ship + ball visual pass**: `Game/objects/ship.tscn`'s 5 primitives replaced with a Blender-greebled hull/nose/canopy/tailfin/twin nacelles (source `Game/assets/blender_models/ship.blend`, generator `tools/blender/gen_ship.py`); ball fully remodeled as a smooth round icosphere with a crossed emissive accent pattern (`ball.blend`, `gen_ball.py`) rather than just a material tweak, replacing the old flat-shaded `gold_ball`. `Nose`/`TailFin` node names preserved for `_apply_team_color()`; `CollisionShape3D`/`RigidBody3D` physics on both ship and ball untouched (verified). Each part's mesh is extracted to a standalone `.res` (`tools/blender/extract_meshes.gd`) rather than referenced via `glb::ArrayMesh_xxx`, which doesn't reliably resolve across scene files. Verified via in-game screenshots (both team colors, orientation) and headless runs of `free_play`/`training`/`match`.