Skip to content

Tutorials · Light and Shadow · Chapter 12

Your own falloff

25 minutes Uses: LightShader, Light

By the end

  • candle.wgsl, a light shader of your own
  • A Light Shader on the third lantern, with its values set
  • How a light shader is read, and what it gives up
On this page

A light that fades your way

A light shader replaces how one light fades. You write it as shader code, in WGSL, the language graphics cards read, in a .wgsl file. The editor compiles it as it loads it, and so does an exported game: there is nothing to install and nothing to build. Effects and shaders covers shaders in general; this one is a candle.

  1. In Assets, click Assets in the path bar, so the shader is written beside project.rengy, then press Create, choose New shader, type candle and press Enter. The editor writes candle.wgsl and gives it a .meta.
  2. Double-click candle.wgsl to open it in the Code panel, replace everything in it with the text below, and press Ctrl+S.
wgsl candle.wgsl
//! param ring_at: float = 0.55 range 0.1 0.9
//! param ring_width: float = 0.12 range 0.02 0.4
//! param wobble: float = 3.0 range 0 8
//! param speed: float = 2.5 range 0 10

// The third lantern's candle: a hot core that falls off as the built-in light
// does, and a bright ring part of the way out that wavers like a flame in a
// draught.

// Before the light is measured, each point is lit from a few units away,
// swaying back and forth over time, so the ring's edge wavers.
fn light_vertex(world: vec2<f32>) -> vec2<f32> {
    let t = time() * speed();
    let sway = vec2<f32>(sin(world.y * 0.15 + t), cos(world.x * 0.15 + t * 1.3));
    return world + sway * wobble();
}

fn light(l: Light) -> vec4<f32> {
    // How far out this point is: 0 at the flame, 1 at the radius.
    let reach = l.distance / max(l.radius, 0.001);
    // 1 on the ring, falling to 0 a ring_width either side of it.
    let band = 1.0 - smoothstep(0.0, ring_width(), abs(reach - ring_at()));
    let glow = l.attenuation + band * 0.6 * max(1.0 - reach, 0.0);
    return vec4<f32>(l.color * l.energy * glow, 1.0);
}

How the shader is read

//! param NAME: float = DEFAULT range LOW HIGH
A value the Inspector shows as a slider from LOW to HIGH, which starts at DEFAULT. The shader reads it as NAME(): ring_at().
fn light_vertex(world: vec2<f32>) -> vec2<f32>
Where each point of the light is measured from. The light, and what it draws, do not move: a point is lit as if it were somewhere else. Here it sways by wobble() world units over time, so the ring’s edge wavers. Keep the sway small next to the Radius.
fn light(l: Light) -> vec4<f32>
The colour the light adds at a point. It replaces the built-in fade whole.
l.distance, l.radius
How far this point is from the light, and the light’s Radius, in world units.
l.attenuation
The built-in fade: 1 at the light, falling to 0 at its Radius.
l.color, l.energy
The Light’s Color and Intensity.
time()
Seconds since the game started.

The part that makes the candle is these lines of light:

wgsl part of candle.wgsl
    // How far out this point is: 0 at the flame, 1 at the radius.
    let reach = l.distance / max(l.radius, 0.001);
    // 1 on the ring, falling to 0 a ring_width either side of it.
    let band = 1.0 - smoothstep(0.0, ring_width(), abs(reach - ring_at()));
    let glow = l.attenuation + band * 0.6 * max(1.0 - reach, 0.0);
    return vec4<f32>(l.color * l.energy * glow, 1.0);

reach is how far out the point is: 0 at the flame, 1 at the Radius. band is 1 on the ring and falls to 0 a ring_width() either side of it, which smoothstep does as a soft ramp. glow is the built-in fade plus the band, the band scaled by 0.6 and fading out toward the Radius, so the ring never outshines the flame. The last line is the light’s colour, times its Intensity, times glow.

The candle on the lantern

  1. Select lantern_3, press Add component, and choose LightShaderComponent. It works on the Light already on the node.
  2. Click its Shader slot and choose candle.wgsl, or drag the file onto the slot. Under Values is a row for each param: ring_at, ring_width, wobble and speed, each reading unset, which means the file’s own default applies.
  3. Press Set the 4 missing parameter(s). The light now holds all four, at the file’s own 0.55, 0.12, 3.0 and 2.5, and each row reads = default.
  4. Set ring_at to 0.5 and wobble to 2.5. Leave ring_width and speed as they are: the light holds them at the file’s own 0.12 and 2.5.
  5. Press Ctrl+S.
lantern_3 selected, its Inspector narrowed to its Light Shader: Shader candle.wgsl, a Values list of four items, and under it four sliders, each with a bin: ring_at 0.50, ring_width 0.12 marked = default, wobble 2.5, and speed 2.5 marked = default; in the Viewport the third lantern’s Light as a large circle round it
Shader
The light shader this light is drawn with: a .wgsl file in the project, candle.wgsl here. Left empty, the light fades the built-in way, and so does a light whose shader will not compile.
Values
The values this light holds for the shader’s params, one item each. A param it holds none for reads unset, and the file’s own default applies; one whose value equals the default reads = default. Set the 4 missing parameter(s) holds every unset one at its default, and the bin at the end of a row lets one go again. Four here: ring_at, ring_width, wobble and speed, each defined below.
Name, Value
Each item’s two parts. Name is the param it sets, ring_at for the first; the Inspector fills it in from the shader, so you do not type it. Value is the number, drawn as a slider over the param’s range. A Value holds four numbers whatever the param’s kind: a float param reads the first, and the other three stay 0.
ring_at
Where the ring sits, as a share of the Radius: 0 at the flame, 1 at the Radius. 0.1 to 0.9; 0.5 here, halfway out. The file’s own is 0.55.
ring_width
How far the ring fades away either side of itself, in the same share of the Radius. 0.02 to 0.4; 0.12 here, which is also the file’s own.
wobble
How far, in world units, the ring’s edge sways. 0 to 8; 0 holds it still, 2.5 here. The file’s own is 3.0.
speed
How fast it sways. 0 to 10; 2.5 here, which is also the file’s own.

A row reads unset while the scene holds no value for it and the file’s default applies. It reads = default when the scene holds a value equal to the default, as the picture shows for ring_width and speed. Both draw the same.

The window, the third lantern and the right arch with the third lantern’s built-in fade: a soft green wash round the lantern on the wall beside the window, the rune on the floor below it, the second lantern’s bricks at the right edge, and a timber leaning at the bottom right
Before: the third lantern’s built-in fade.
The same view with the candle shader: the green wash brighter and a little wider, with a faint lighter ring in it about halfway out, its edge slightly streaked where it wavers
After: the candle, a soft ring of light about halfway out, its edge wavering.

The ring is a soft band, brightest where it crosses something pale. On this dark wall it reads as a gentle swell in the light, not a line, and the three pictures that follow move it.

The same view with ring_at 0.3: the faint ring close round the lantern, inside the wash
For comparison, ring_at 0.3: the ring nearer the lantern.
The same view with ring_at 0.8: the faint ring out near the edge of the light, past the arch’s pillar
For comparison, ring_at 0.8: the ring near the edge of the light.
The same view with wobble 8, the top of its range: the faint ring’s edge wavy, in soft streaks across the wall above and to the right of the lantern
For comparison, wobble 8: the ring’s edge pulled out of true.

A still picture cannot show a ring waver, so the last one shows how far it can sway: at 8 the ring’s edge is wavy. At the 2.5 you set, the sway is small next to the ring’s width, and a still picture of it looks almost like wobble 0. In the game the ring’s edge moves.

What it costs

The candle

FieldValue
Shadercandle.wgsl
ring_at0.5
ring_width0.12, the file’s own
wobble2.5
speed2.5, the file’s own

Check it works