Shaders
A shader changes the whole picture at once. It is a function that answers one question: what colour should this pixel be? The game asks it for every pixel of the screen, in every frame, so a shader can blur the picture, split its colours, tint it, bend it, or do something nobody has done before.
The camera's children in the editor (Blur, RGB split, Colour correction and the rest) are shaders the game brings along. With shader.new you write your own.
A first shader
This one turns the picture black and white:
local grey = shader.new(function(pixel)
local colour = pixel.screen(pixel.at)
local light = shader.grey(colour)
return shader.vec(light, light, light, 1)
end)Read it line by line:
pixel.atis where the pixel is on the screen.pixel.screen(place)is the picture's colour at a place. Atpixel.at, that is the pixel as it is now.shader.grey(colour)is how bright a colour is, as a single number.shader.vec(...)puts numbers together. Here: red, green, blue, and a fourth part that is always 1.- What the function gives back is the pixel's new colour.
The shader shows as soon as it is made. Test play (F5) to see it: the editor's own view does not run scripts.
Values with parts
Inside a shader, a value has up to four parts.
| What | Parts | Their names |
|---|---|---|
| A number | 1 | |
| A place on the screen | 2 | x, y |
| A colour | 4 | r, g, b, a (or x, y, z, w) |
A place's x goes from 0 at the left edge of the screen to 1 at the right edge, and its y from 0 at the top to 1 at the bottom. A colour's parts go from 0 (none) to 1 (full).
Parts are read by name, one or several at a time:
local swapped = shader.new(function(pixel)
local colour = pixel.screen(pixel.at)
local red = colour.r -- one part
local rgb = colour.rgb -- three of them
return shader.vec(colour.b, colour.g, red, 1) -- blue and red changed over
end)+, -, *, /, % and ^ work on values part by part. A plain number works on every part:
local darker = shader.new(function(pixel)
local colour = pixel.screen(pixel.at)
return shader.vec(colour.rgb * 0.5, 1)
end)Two values in one sum must have the same number of parts, unless one of them is a single number. A colour plus a place is a mistake, and the game says so.
What the function is given
The function gets one value, which these pages call pixel. Name it pixel too, and the editor shows what it has as you type.
| Name | What it is |
|---|---|
pixel.at | Where the pixel is: a place. |
pixel.screen(place) | The picture's colour at a place. |
pixel.time | Seconds since the level began. |
pixel.beat | The beat the song is at. |
pixel.size | The screen's size in units, x and y. It is always 1080 units tall. |
Reading the picture somewhere else
A shader does not have to read the picture where the pixel is. Read it a little to the side, and the picture moves. Read it somewhere different for every row, and the picture bends:
local wavy = shader.new(function(pixel)
local at = pixel.at
local push = shader.sin(at.y * 40 + pixel.time * 3) * 0.01
return pixel.screen(shader.vec(at.x + push, at.y))
end)Read red, green and blue in three different places, and the colours come apart:
local split = shader.new(function(pixel)
local far = shader.vec(0.01, 0)
local red = pixel.screen(pixel.at + far).r
local green = pixel.screen(pixel.at).g
local blue = pixel.screen(pixel.at - far).b
return shader.vec(red, green, blue, 1)
end)To move by a length that looks the same on every screen, count in units and divide by pixel.size: pixel.at + shader.vec(20, 0) / pixel.size is 20 units to the right.
The function runs once
This is the one thing about shaders that is different from the rest of Luawe. The function is not run for every pixel. It runs once, when the shader is made. What it works with are not real colours yet: each value only remembers what was done to it. From that the game makes a list of steps, and the graphics card works through the list for every pixel.
Three things follow from it.
Use the shader's own maths. math.sin wants a real number. Inside a shader, use shader.sin, shader.floor, shader.mix and the others on the shader page. (math is still fine for plain numbers, like the angle in the loop below.)
A loop is done while the shader is made. That is how a blur is written: read the picture at many places, add the colours up and share them out.
local blur = shader.new(function(pixel)
local sum = shader.vec(0, 0, 0, 0)
for y = -1, 1 do
for x = -1, 1 do
sum = sum + pixel.screen(pixel.at + shader.vec(x, y) * 6 / pixel.size)
end
end
return sum / 9
end)A shader can't use if on a pixel's value. if pixel.at.x < 0.5 then asks for one yes or no, but every pixel has its own answer. Use shader.pick instead, which takes a test and the two values to pick from:
local half = shader.new(function(pixel)
local colour = pixel.screen(pixel.at)
local light = shader.grey(colour)
local left = shader.less(pixel.at.x, 0.5) -- 1 on the left half, 0 on the right
return shader.pick(left, shader.vec(light, light, light, 1), colour)
end)shader.less(a, b) and shader.more(a, b) make the test. For a soft edge instead of a hard one, shader.smooth(low, high, value) rises gently from 0 to 1, and shader.mix(a, b, amount) goes from one value to another by that much.
A shader's own numbers
A shader would not be much use if it could never change. Give shader.new a table of numbers, and the script can change them while the level plays:
local tint = shader.new(function(pixel)
local colour = pixel.screen(pixel.at)
return shader.vec(shader.mix(colour.rgb, pixel.paint, pixel.amount), 1)
end, { amount = 0, paint = { 1, 0.2, 0.2 } })
-- Red comes over the picture while Space is held.
while true do
tint.amount = input.isDown("space") and 0.6 or 0
task.wait()
endInside the function a number is pixel.amount. Outside it is tint.amount. A number can be a single one, or a list of two to four (a place, a colour): set a list with a list, tint.paint = { 0, 0, 1 }.
Every shader also has:
| Name | What it does |
|---|---|
made.on | false switches the shader off, true switches it back on. |
made:remove() | Takes the shader away for good. |
local flash = shader.new(function(pixel)
return shader.vec(1, 1, 1, 2) - pixel.screen(pixel.at) -- the picture's negative
end)
flash.on = false
level.beat:connect(function(beat)
flash.on = beat % 8 == 0
end)More than one
Shaders work in the order they were made, each on what the one before it left. They come after everything else the camera does to the picture, so a shader sees the camera's children already at work.
Limits
A shader runs for millions of pixels in every frame, so it has to stay small.
| What | The most |
|---|---|
| Shaders a level has at once | 8 |
| Shaders showing at once | 4 |
| Steps for one pixel | 96 |
| Reads of the screen for one pixel | 16 |
| Values waiting to be used at once | 16 |
| A shader's own numbers | 16 |
A step is one thing done: one sum, one read, one shader.sin. When a shader is too long, shader.new says so with the line it was made on, and nothing else in the script is harmed.
A value waits from when it is worked out until the last time it is used. Plain numbers, a shader's own numbers and pixel.at never count. A shader that adds things up as it goes, like the blur above, keeps two or three values waiting, so this is a limit you will most likely never meet. If you do, use what the shader works out sooner, before working out the next thing.
Players can switch screen effects off in the game's settings, and some computers can't run shaders at all. There the level looks plainer and plays the same. So never put anything the player needs in a shader alone: a shader is for how a level looks, not for how it works.