From 6f5ce488a9270a48e7b7e46207b0899ad9b52340 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 4 Aug 2026 07:42:34 +0100 Subject: [PATCH] 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. --- Game/scenes/arena_02.tscn | 22 ++++++------- Game/shaders/nebula_dust.gdshader | 52 +++++++++++++++++++++++++++++++ TODO.md | 2 +- 3 files changed, 63 insertions(+), 13 deletions(-) create mode 100644 Game/shaders/nebula_dust.gdshader diff --git a/Game/scenes/arena_02.tscn b/Game/scenes/arena_02.tscn index a190fb82..45c0bc27 100644 --- a/Game/scenes/arena_02.tscn +++ b/Game/scenes/arena_02.tscn @@ -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"] diff --git a/Game/shaders/nebula_dust.gdshader b/Game/shaders/nebula_dust.gdshader new file mode 100644 index 00000000..665a3714 --- /dev/null +++ b/Game/shaders/nebula_dust.gdshader @@ -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; +} diff --git a/TODO.md b/TODO.md index e43000ea..1d1b2ef2 100644 --- a/TODO.md +++ b/TODO.md @@ -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`.