Skip to content

Fragment shaders

A fragment shader is a function that answers one question: what colour is the pixel at x, y? You write the function; gfx.fill runs it for every pixel on the panel.

function fragment(x, y, u)
return x / 63, y / 63, 0.25
end
function _draw()
gfx.fill(shader or fragment)
end

That draws a gradient: more red to the right, more green towards the bottom.

  • x and y are the pixel’s coordinates, as numbers starting at 0.
  • u is the uniforms table.
  • The function returns red, green and blue as numbers from 0 to 1, not 0 to 255. Values outside that range are clamped.
  • gfx.fill(shader or fragment) uses a compiled version of the function when there is one and the function itself otherwise. The next page explains the difference. Until then, read it as gfx.fill(fragment).

A shader has no loops over pixels and no state. Each pixel is computed from its position alone, which is what lets the panel run the same function for many pixels at once. It is also a compact way to describe a picture: the plasma further down is four lines.

A shader cannot remember anything between frames, so time comes in as a uniform. Declare it in a comment, advance it in _update, read it in the shader:

-- @uniform time float
function _update(dt)
uniforms.time = (uniforms.time or 0) + dt
end
function fragment(x, y, u)
local wave = 0.5 + 0.5 * sin(x * 0.2 + u.time * 3)
return wave, 0.2, 1 - wave
end
function _draw()
gfx.fill(shader or fragment)
end

The -- @uniform name type line tells the shader compiler what u.time is. The type is float, vec2, vec3 or vec4. It is only a comment to Lua, but get in the habit of writing one for every uniform the shader reads.

Positions and colours come in groups, so shaders have vector types: vec2, vec3 and vec4.

-- @uniform resolution vec2
function _init()
uniforms.resolution = vec2(gfx.width(), gfx.height())
end
function fragment(x, y, u)
local p = vec2(x, y) / u.resolution -- 0..1 across the panel
local centre = p - 0.5 -- -0.5..0.5, zero in the middle
local d = length(centre) -- distance from the middle
return vec3(1 - d * 2, p.y, 0.3) -- one vec3 instead of three numbers
end
function _draw()
gfx.fill(shader or fragment)
end

What you can do with them:

  • Build one from numbers or smaller vectors: vec3(1, 0.5, 0), vec3(p, 1), or vec3(0.5) for three copies of one number.
  • Do arithmetic component by component: a + b, a * b, a / b. A number combines with a vector of any size: p * 2, p - 0.5.
  • Pick components by name: p.x, p.y, or several at once: p.xy, p.yx, c.rgb. The names x y z w and r g b a are interchangeable.
  • Pass them to the math functions, which then work on each component: sin(p), abs(p), mix(a, b, 0.5).

These are available as plain names inside and outside a shader. The full list with descriptions is on the Shader built-ins page.

Group Functions
Trigonometry sin cos tan asin acos atan
Powers sqrt pow exp log
Rounding floor ceil fract abs sign mod
Ranges min max clamp step smoothstep mix
Vectors dot length distance normalize cross
Noise hash noise fbm

Three that do a lot of work in shaders:

  • mix(a, b, t) blends from a to b as t goes from 0 to 1.
  • smoothstep(lo, hi, v) is 0 below lo, 1 above hi, and a smooth ramp in between. It turns a distance into a soft edge.
  • fbm(x, y, octaves) is layered noise: clouds, smoke, terrain.

A plasma, the classic:

-- @uniform time float
local PI = 3.14159265
function _update(dt)
uniforms.time = (uniforms.time or 0) + dt
end
function fragment(x, y, u)
local t = u.time
local v = sin(x * 0.1 + t) + sin((y * 0.1 + t) * 0.7) + sin((x + y) * 0.05 + t)
return 0.5 + 0.5 * sin(v * PI), 0.5 + 0.5 * cos(v * PI), 0.5 + 0.5 * sin(v * PI + 2.0)
end
function _draw()
gfx.fill(shader or fragment)
end

A soft ring, using a helper function and smoothstep:

-- @uniform time float
-- @uniform resolution vec2
local function ring(p, radius)
return smoothstep(0.06, 0.0, abs(length(p) - radius))
end
function _init()
uniforms.time = 0
uniforms.resolution = vec2(gfx.width(), gfx.height())
end
function _update(dt)
uniforms.time = uniforms.time + dt
end
function fragment(x, y, u)
local p = vec2(x, y) / u.resolution - 0.5
local glow = ring(p, 0.25 + 0.1 * sin(u.time))
return mix(vec3(0.05, 0.0, 0.2), vec3(1.0, 0.8, 0.4), glow)
end
function _draw()
gfx.fill(shader or fragment)
end

Drifting clouds from noise:

-- @uniform time float
function _update(dt)
uniforms.time = (uniforms.time or 0) + dt * 0.3
end
function fragment(x, y, u)
local v = fbm(x * 0.05, y * 0.05 + u.time, 4)
return v, v * 0.8, v * 0.5
end
function _draw()
gfx.fill(shader or fragment)
end

gfx.fill is just another drawing call. Fill the background with a shader, then draw on top:

-- @uniform time float
function _init()
uniforms.time = 0
ball = { x = 5, y = 5, vx = 20, vy = 13 }
end
function _update(dt)
uniforms.time = uniforms.time + dt
ball.x = ball.x + ball.vx * dt
ball.y = ball.y + ball.vy * dt
if ball.x < 0 or ball.x > gfx.width() - 3 then ball.vx = -ball.vx end
if ball.y < 0 or ball.y > gfx.height() - 3 then ball.vy = -ball.vy end
end
function fragment(x, y, u)
local n = fbm(x * 0.05, y * 0.05 + u.time * 0.2, 3)
return n * 0.2, n * 0.1, n * 0.5
end
function _draw()
gfx.fill(shader or fragment)
gfx.rectfill(math.floor(ball.x), math.floor(ball.y), 3, 3, 255, 220, 60)
end

Run as written, a shader is slow: the panel makes a Lua call for each of the 4,096 pixels. The plasma above, which uses only plain numbers, manages about 8 frames per second that way, and the ring and the vector example go over the per-frame work limit and stop. Compiled, all of them run, the plasma at about 30 frames per second. That is the subject of the next page.

Next: Interpreted and compiled.