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; // Arena play volume (world space) this weather effect must stay clear of — // see ArenaBoundary.INNER_HALF_X/INNER_HALF_Z/INNER_HEIGHT. Dust is fully // hidden inside that box and fades in over arena_clear_distance beyond it, // so it reads as drifting around the station rather than through the pitch. uniform vec3 arena_half_extents = vec3(18.0, 9.0, 27.0); uniform vec3 arena_centre = vec3(0.0, 9.0, 0.0); uniform float arena_clear_distance : hint_range(0.0, 10.0) = 2.0; varying float v_flicker; varying float v_arena_fade; 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; // MODEL_MATRIX's translation column is this particle's world-space // origin (pre-billboard, so unaffected by the VERTEX rewrite above). // Chebyshev (box) distance outside arena_half_extents, in metres. vec3 particle_pos = MODEL_MATRIX[3].xyz; vec3 outside = abs(particle_pos - arena_centre) - arena_half_extents; float outside_dist = max(max(outside.x, outside.y), outside.z); v_arena_fade = smoothstep(0.0, arena_clear_distance, outside_dist); } 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 * v_arena_fade; 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; }