Scanner pulse
A ring of light that expands from a point in the world, leaving a fading grid on every surface it has crossed.
- 01GoalReveal
- 02TechniqueWorld-space scan
- 03ExecutionScanner pulse
Parameters
Copy for your platform
- GLSLCopy
- GodotCopy
Node chain
Built step by step, in GLSL style, with its node names.
Built step by step, in Godot style, with its node names.
No Unreal Engine 5 version of this card yet, so the nodes show the GLSL names.
No Unity version of this card yet, so the nodes show the GLSL names.
No Blender version of this card yet, so the nodes show the GLSL names.
distanceDistance3Ddistancedistancedistance PositionTime01 Distance from the scanner
-Subtract--- In02 Behind the pulse
smoothstepSmoothStepsmoothstepsmoothstepsmoothstep In03 Pulse front
expExpexpexpexp In04 Fading trail
fractFractfractfractfract In05 World grid
*Multiply*** In06 Glow
How it works
The card puts a scanner just under the test sphere, as if it sat on the ground below it, and sends out a pulse every Seconds per pulse. The sphere is invisible until the pulse reaches it: a bright shell of light climbs over it, and behind the shell a grid drawn in world units glows for a moment and fades. Everything comes from one number per pixel, its distance from the scanner.
1. Distance from the scanner
Varying A value the vertex shader writes for each vertex (an out) and the fragment shader reads (an in). The rasterizer blends it across the triangle, so each pixel gets its own mix.i.position is the surface point in world space, which the vertex shader passed down as a
varyingA value the vertex shader writes for each vertex (an out) and the fragment shader reads (an in). The rasterizer blends it across the triangle, so each pixel gets its own mix.distance(i.position, origin) is how far that point is
from the scanner. The scanner’s position is a constant here so the preview needs no script; in a game it is a uniform
the scanner’s script sets every frame, and nothing else changes. Because the distance is measured in the world, it
doesn’t matter which object a pixel belongs to: a floor, a wall and a crate at the same distance light up together.
2. Behind the pulse
The pulse’s radius grows with time: fract(i.time / p_period) * p_range climbs from 0 to Range world units once
per period and starts over. radius - dist is positive where the pulse has already passed the surface and negative
ahead of it, and its size says how long ago, in world units. The next three steps are all shaping curves of this one
number.
3. Pulse front
The front is a thin shell right at the radius. One smoothstepA soft threshold. It is 0 below the first edge, 1 above the second, and follows an S-shaped curve with flat ends in between. Smoothstep A soft threshold. It is 0 below the first edge, 1 above the second, and follows an S-shaped curve with flat ends in between.t = clamp((x − e0) / (e1 − e0), 0, 1); s = t² (3 − 2t)
4. Fading trail
Behind the front, exp(-behind / p_decay) starts at 1 and halves roughly every 0.7 × Trail length units, so the
area the pulse has just covered glows and then dies out smoothly. step(0.0, behind) keeps it to the area behind the
front; ahead of the pulse there is no trail.
5. World grid
The trail alone would light the surface evenly. Multiplying it by a grid makes it read as a scan. gridLines checks
how close each coordinate of the world position is to a multiple of Grid spacing: fract(p / spacing + 0.5) - 0.5
is the signed distance to the nearest grid plane in cells, and a smoothstep turns “within 8% of a cell” into a line.
The grid is in world space too, so its lines stay the same size on every object and line up across them; they don’t
stretch with the object’s UVs.
6. Glow
The front plus the gridded trail, times Color and Intensity, is the light the pulse gives off. The pass is additive and the card’s output role is emission, so the light is added on top of whatever is behind, and wherever the pulse isn’t, the surface adds nothing and stays invisible. One last factor fades the pulse out over the last 40% of its range, so the jump of the radius back to 0 never shows.
Make it in GLSL
The copy button gives a complete WebGL2 fragment shader. Its main() writes the light premultiplied by its alpha,
with the brightest channel as alpha, so draw it with additive blending and without writing depth, after the opaque
geometry:
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE);
gl.depthMask(false);
gl.depthFunc(gl.LEQUAL); // when it draws over the same mesh
The one thing the vertex shader must get right is the position. The effect measures distances in the world, so
v_position has to be the vertex transformed by the model matrix and nothing else:
uniform mat4 u_model;
uniform mat4 u_viewProjection;
layout(location = 0) in vec3 a_position;
out vec3 v_position;
// ...declare and write v_uv, v_normal and v_view too; main() reads them
void main() {
vec4 world = u_model * vec4(a_position, 1.0);
v_position = world.xyz;
gl_Position = u_viewProjection * world;
}
In a scene, the scanner moves. Replace the const vec3 origin line with a uniform and set it from your game code;
every mesh that uses the shader then shares one pulse:
uniform vec3 origin;
To fire one pulse on demand instead of a loop, replace i.time in the radius with your own uniform that counts the
seconds since the scan started.
Make it in Godot
In a VisualShader, the world position is the Vertex input multiplied by the InvViewMatrix input (a TransformVectorMult with w = 1); a Distance3D from the scanner's position, a Subtract from the radius, two SmoothStep nodes and an Exp build the pulse, and the result goes to Emission with blend_add set in the shader's render mode.
The shader is meant to go over an object’s own material, not to replace it. Select a MeshInstance3D, and in its
Geometry section give it a new ShaderMaterial as Material Overlay, then paste the shader into it. The overlay is
drawn after the mesh’s own materials with blend_add, so the pulse’s light is added on top of the lit object and
adds nothing where the pulse isn’t. The shader writes a black albedo and the pulse as EMISSION, so with Glow enabled
in the WorldEnvironment the front blooms.
The scanner’s position is a constant in the shader. To move it, and to share it with every object at once, make it a
global uniform. Add scanner_origin (a vec3) under Project Settings > Globals > Shader Globals, then replace the
const vec3 origin line with
global uniform vec3 scanner_origin;
rename origin to scanner_origin in effect(), and set it from the scanner’s script:
func _process(_delta: float) -> void:
RenderingServer.global_shader_parameter_set("scanner_origin", global_position)
Every material that reads the global follows the same scanner without any per-object script. For a pulse that fires
on demand instead of looping, add a second global, scan_age (a float), that the scanner’s script sets to 0 when a
scan starts and advances by delta every frame, and use it in place of i.time. A script can’t read the shader’s
TIME, so the age has to come from the script’s own clock. Near the end of a period the pulse has faded out; hold the
age just under the period until the next scan, or the radius wraps to 0 and the pulse starts over.
No Unreal Engine 5 tutorial for this card yet.
No Unity tutorial for this card yet.
No Blender tutorial for this card yet.
Code
#version 300 es// World-space scan → Scanner pulse, web GLSL (GLSL ES 3.00). From Creative Center, MIT license.precision highp float;uniform float u_time;uniform vec2 u_resolution; // this pass's output size in pixelsuniform float p_period; // Seconds per pulse: 2.5 (0.5 to 6.0)uniform float p_range; // Range: 2.8 (1.0 to 6.0)uniform float p_decay; // Trail length: 0.35 (0.05 to 2.0)uniform float p_spacing; // Grid spacing: 0.3 (0.05 to 1.0)uniform vec3 p_color; // Color: vec3(0.3, 0.85, 1.0), sRGB; upload it decoded to linearuniform float p_intensity; // Intensity: 1.5 (0.0 to 4.0)in vec2 v_uv;in vec3 v_position;in vec3 v_normal;in vec3 v_view;out vec4 fragColor;struct Inputs { vec2 uv; vec3 position; vec3 normal; vec3 view; float time; vec2 fragCoord; vec2 resolution;};vec3 u_toSrgb(vec3 c) { return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));}// The scanner sits just under the test sphere, as if on the ground below it. In a game this is a uniform the script// sets to the scanner's world position.const vec3 origin = vec3(0.0, -1.2, 0.0);// 1 on the world planes where a coordinate is a whole multiple of the spacing, fading out within 8% of a cellfloat gridLines(vec3 p) { vec3 offset = abs(fract(p / p_spacing + 0.5) - 0.5); vec3 line = 1.0 - smoothstep(vec3(0.0), vec3(0.08), offset); return max(line.x, max(line.y, line.z));}vec4 effect(Inputs i) { float radius = fract(i.time / p_period) * p_range; float dist = distance(i.position, origin); // Positive where the pulse has already passed, negative ahead of it float behind = radius - dist; float front = smoothstep(-0.02, 0.0, behind) * (1.0 - smoothstep(0.0, 0.08, behind)); float trail = step(0.0, behind) * exp(-behind / p_decay); float lines = gridLines(i.position); // The pulse dies out before it restarts, so the jump back to radius 0 never shows float fade = 1.0 - smoothstep(0.6 * p_range, p_range, radius); vec3 glow = p_color * p_intensity * (front + trail * lines) * fade; return vec4(glow, 1.0);}void main() { Inputs i; i.uv = v_uv; i.position = v_position; // Interpolation shortens the vectors between vertices i.normal = normalize(v_normal); i.view = normalize(v_view); i.time = u_time; i.fragCoord = gl_FragCoord.xy; i.resolution = u_resolution; vec4 frag_out = effect(i); // The output is sRGB: clamped and encoded once, here, with no tone mapping // Additive light over a transparent target: premultiplied, as bright as its alpha, so dark adds nothing vec3 frag_light = u_toSrgb(clamp(frag_out.rgb, 0.0, 1.0)) * frag_out.a; fragColor = vec4(frag_light, max(max(frag_light.r, frag_light.g), frag_light.b));}// World-space scan → Scanner pulse, Godot 4. From Creative Center, MIT license.shader_type spatial;// Emission: a black base color, no specular or ambient light, and the effect as EMISSION, which glow catchesrender_mode specular_disabled, ambient_light_disabled, blend_add;uniform float period : hint_range(0.5, 6.0, 0.1) = 2.5;uniform float range : hint_range(1.0, 6.0, 0.1) = 2.8;uniform float decay : hint_range(0.05, 2.0, 0.05) = 0.35;uniform float spacing : hint_range(0.05, 1.0, 0.01) = 0.3;uniform vec3 color : source_color = vec3(0.3, 0.85, 1.0);uniform float intensity : hint_range(0.0, 4.0, 0.05) = 1.5;struct Inputs { vec2 uv; vec3 position; vec3 normal; vec3 view; float time; vec2 fragCoord; vec2 resolution;};// The scanner sits just under the test sphere, as if on the ground below it. In a game this is a uniform the script// sets to the scanner's world position.const vec3 origin = vec3(0.0, -1.2, 0.0);// 1 on the world planes where a coordinate is a whole multiple of the spacing, fading out within 8% of a cellfloat gridLines(vec3 p) { vec3 offset = abs(fract(p / spacing + 0.5) - 0.5); vec3 line = 1.0 - smoothstep(vec3(0.0), vec3(0.08), offset); return max(line.x, max(line.y, line.z));}vec4 effect(Inputs i) { float radius = fract(i.time / period) * range; float dist = distance(i.position, origin); // Positive where the pulse has already passed, negative ahead of it float behind = radius - dist; float front = smoothstep(-0.02, 0.0, behind) * (1.0 - smoothstep(0.0, 0.08, behind)); float trail = step(0.0, behind) * exp(-behind / decay); float lines = gridLines(i.position); // The pulse dies out before it restarts, so the jump back to radius 0 never shows float fade = 1.0 - smoothstep(0.6 * range, range, radius); vec3 glow = color * intensity * (front + trail * lines) * fade; return vec4(glow, 1.0);}void fragment() { Inputs i; // Godot's UV starts at the top-left; the effect expects GLSL's bottom-left i.uv = vec2(UV.x, 1.0 - UV.y); // VERTEX, NORMAL and VIEW are in view space here; the effect works in world space i.position = (INV_VIEW_MATRIX * vec4(VERTEX, 1.0)).xyz; i.normal = normalize((INV_VIEW_MATRIX * vec4(NORMAL, 0.0)).xyz); i.view = normalize((INV_VIEW_MATRIX * vec4(VIEW, 0.0)).xyz); i.time = TIME; // FRAGCOORD's origin is the top-left too i.resolution = VIEWPORT_SIZE; i.fragCoord = vec2(FRAGCOORD.x, i.resolution.y - FRAGCOORD.y); vec4 frag_out = effect(i); ALBEDO = vec3(0.0); EMISSION = frag_out.rgb; ALPHA = frag_out.a;}No Unreal Engine 5 code for this card yet.
No Unity code for this card yet.
No Blender code for this card yet.
Further reading
- Coordinate Systems (opens in a new tab)Article
- Spatial shaders (opens in a new tab)Docs
- GeometryInstance3D (opens in a new tab)Reference
- distance (opens in a new tab)Reference
- smoothstep (opens in a new tab)Reference
- exp (opens in a new tab)Reference
- fract (opens in a new tab)Reference