Moud

Shaders and your own look#

The engine draws the world one way out of the box: parts, models and blocks lit by the sun and the sky, with shadows, and the post effects you add on top. There is no toon mode, no outline switch and no built-in style to pick from. A look of your own is a few lines of GLSL you write and keep in your place, and this page shows where those lines go.

There are three places a shader can go:

Where Property What it changes
a Part, MeshPart or MultiMesh shader, vertexShader, lightingShader how one thing is shaded and shaped
the screen a PostShader the whole picture, after the world is drawn
the sky Sky.shader everything behind the world, and the light it reflects

Each file is plain GLSL, and almost always just the body of a function: no main, no #version, nothing to register. Saving the file redraws it while the game runs. A file that does not compile leaves the thing drawn the usual way and prints the compiler's message in the output.

Shaders on a part#

A part takes the same three files a MeshPart takes:

luau
world:add("Part", {
    shape = "ball",
    size = vec3(2, 2, 2),
    cframe = cframe(0, 66, 0),
    color = color(0.9, 0.4, 0.2),
    shader = "res://shaders/toon.glsl",
    anchored = true,
})
  • shader runs for every pixel of the part before any light lands on it. It is given s, the surface, and changes what it needs.
  • vertexShader runs for every corner of the part and returns how far to move it, in world space.
  • lightingShader runs after the lamps have lit the picture, and returns the final colour.

The surface s a part's shader gets:

Field What it is
s.albedo the colour the light falls on: color times texture
s.alpha how solid it is, 1 minus transparency
s.emission light it gives off, in linear colour
s.roughness, s.metallic the pbr material, from the properties or the maps
s.normal which way the surface faces, in world space; smooth on balls and cylinders
s.uv where on the texture this pixel is, laid on the way texture is
s.worldPos, s.localPos where the pixel is in the world, and on the part from -0.5 to 0.5
s.view the direction from the pixel to the camera
s.sunDirection, s.sunColor where the sun is and how strong it is right now
s.light Minecraft's light here: x for torches, y for the sky, each 0 to 1
s.time seconds, for anything that moves

Turning the engine's light off#

A shader that starts with a render_mode line changes how the part is drawn:

glsl
render_mode unshaded, cull_disabled;
Mode What it does
unshaded no sun, sky, torch or lamp light: the part shows s.albedo plus s.emission, nothing else
cull_disabled both sides of every face are drawn
shadows_disabled the part throws no shadow, whatever castShadow says

unshaded is what a stylized look starts from. The engine stops lighting the part, and your shader decides how light and dark it is from s.normal, s.sunDirection and s.light.

A toon ramp#

glsl
// res://shaders/toon.glsl
render_mode unshaded;

uniform float bands = 3.0;
uniform vec3 shadowTint = vec3(0.35, 0.4, 0.6);

float facing = max(dot(s.normal, s.sunDirection), 0.0);
float level = floor(facing * bands + 0.5) / bands;
vec3 light = mix(shadowTint, vec3(1.0), level);
s.albedo *= light * mix(0.25, 1.0, s.light.y);
  • facing is how squarely the surface looks at the sun, 0 side-on or away and 1 straight at it.
  • Rounding it to bands steps is the whole toon look: the light jumps from one tone to the next instead of fading.
  • The dark tone is shadowTint rather than black, the way a painter shades with blue.
  • s.light.y is the sky light Minecraft has at the spot, so the part still gets dark at night and inside a cave.

Values from the part#

Each uniform at the top of a file is filled from a child of the part with the same name, so the same file can look different on every part. = 3.0 after a uniform is what it uses when the part has no child of that name:

luau
local ball = world:add("Part", { shape = "ball", shader = "res://shaders/toon.glsl" })
ball:add("NumberValue", { name = "bands", value = 1 })
ball:add("ColorValue", { name = "shadowTint", value = color(0.6, 0.2, 0.5) })
Child Declare it as
NumberValue float or int
BoolValue bool
Vector3Value vec3 or vec2
ColorValue vec3 or vec4
StringValue holding an image sampler2D

The same = works in the shaders of a MeshPart, a MultiMesh, a PostShader and a Sky.

A part's file takes up to eight numbers (a vec3 counts as three) and three images. Numbers are free to change every frame; parts whose images differ are drawn separately.

In the editor, the Values section of a part with a shader lists every uniform the files declare that the part has no child for yet, with its type and its default. Add makes the child with that default, ready to change.

A rim light#

glsl
// res://shaders/rim.glsl
uniform vec3 rimColor = vec3(0.3, 0.7, 1.0);
uniform float rimPower = 3.0;

float rim = pow(1.0 - max(dot(s.normal, s.view), 0.0), rimPower);
s.emission += rimColor * rim * 2.0;

The edge of a ball faces away from the camera, so dot(s.normal, s.view) is near 0 there and near 1 in the middle. Adding the glow to s.emission keeps the part lit by the sun as usual, with a halo on top that a BloomEffect spreads further.

Moving the corners#

glsl
// res://shaders/wave.glsl
uniform float height = 0.3;

return vec3(0.0, sin(v.time * 2.0 + v.worldPos.x) * height * (v.localPos.y + 0.5), 0.0);

v holds v.localPos (-0.5 to 0.5 across the part), v.worldPos, v.origin (the middle of the part) and v.time, and the camera: v.viewProj, v.inverseViewProj, v.cameraPos and v.screenSize in pixels. Multiplying by v.localPos.y + 0.5 leaves the bottom where it is and moves the top the most, so the block sways like a plant instead of floating.

The light follows the moved shape. The shadow does not: it is drawn from the part as it stands.

Passing values from the corners#

A varying line declares a value the vertexShader writes at each corner and the shader reads for each pixel, blended across the triangle in between. Both files declare it the same way:

glsl
varying vec3 tint;

A varying is a float, vec2, vec3 or vec4. Putting noperspective in front blends it evenly across the triangle on screen instead of in depth, and flat gives the whole triangle the value of one corner. Varyings work on a Part and a MultiMesh.

A retro console look#

The wobbling corners and swimming textures of a late 90s console game are two small files. The corners snap to a grid across the screen, and the texture is laid on without perspective:

glsl
// res://shaders/retro_vertex.glsl
uniform float snap = 160.0;
varying noperspective vec3 flatPos;

vec4 clip = v.viewProj * vec4(v.worldPos, 1.0);
vec3 offset = vec3(0.0);
if (clip.w > 0.0) {
    vec2 grid = floor(clip.xy / clip.w * snap + 0.5) / snap;
    vec4 back = v.inverseViewProj * vec4(grid * clip.w, clip.zw);
    offset = back.xyz / back.w - v.worldPos;
}
flatPos = v.worldPos + offset;
return offset;
glsl
// res://shaders/retro.glsl
uniform sampler2D image;
uniform float tile = 4.0;
varying noperspective vec3 flatPos;

vec3 facing = abs(s.normal);
vec3 at = flatPos / tile;
vec2 uv = facing.x > facing.y && facing.x > facing.z ? at.zy : facing.y > facing.z ? at.xz : at.xy;
s.albedo *= texture(image, uv).rgb;
luau
for _, part in world:descendants() do
    if part.className == "Part" then
        part.vertexShader = "res://shaders/retro_vertex.glsl"
        part.shader = "res://shaders/retro.glsl"
        part:add("StringValue", { name = "image", value = "res://textures/bricks.png" })
    end
end
  • The vertex file finds where the corner lands on screen, rounds it to snap steps from the middle to each edge, and moves the corner back into the world at the rounded spot. 160 is the classic look; a smaller number wobbles more as the camera moves.
  • flatPos is where the corner ended up. Declared noperspective, it is blended across the triangle the way old consoles blended their texture coordinates, so the texture slides as the camera turns.
  • The surface file lays image on from flatPos, repeating every tile metres, the way texture lays on from the true position. The image comes from the shader rather than the part's texture, which would be laid on straight underneath.

Shadows are drawn from the part as it stands, so they hold still while it wobbles. For the low resolution the look goes with, see a smaller picture.

After the lights#

A lightingShader sees the picture once the sun and every lamp have landed. It is given s.radiance, the colour the pixel ended up with, plus s.albedo, s.normal, s.fragPos, s.roughness, s.metallic and s.time, and returns the colour to show:

glsl
// res://shaders/bands.glsl
return floor(s.radiance * 4.0) / 4.0;

This one cuts every colour on the part into four steps, lamps included, which a shader cannot do because it runs before the lamps.

Shaders on the screen#

A PostShader runs over the whole picture. Besides the colour and the depth it can read which way each pixel faces, which is what an outline needs:

Call What it gives
sceneColor(uv) the picture so far
sceneDepth(uv), linearDepth(uv) the depth buffer, and the distance in metres
sceneNormal(uv) the direction the surface faces, in world space
viewPosition(uv), worldPosition(uv) where the pixel is, from the camera or in the world
previousColor(uv) the picture this shader made last frame

An outline#

glsl
// res://shaders/outline.glsl
uniform vec4 Color = vec4(0.05, 0.05, 0.08, 1.0);

void main() {
    vec2 t = 1.0 / ScreenSize;
    float d = linearDepth(vUV);
    vec3 n = sceneNormal(vUV);
    float edge = 0.0;
    for (int i = 0; i < 4; i++) {
        vec2 o = vec2(i == 0 ? t.x : i == 1 ? -t.x : 0.0, i == 2 ? t.y : i == 3 ? -t.y : 0.0);
        edge = max(edge, abs(linearDepth(vUV + o) - d) / max(d, 0.001) > 0.05 ? 1.0 : 0.0);
        edge = max(edge, 1.0 - dot(sceneNormal(vUV + o), n) > 0.4 ? 1.0 : 0.0);
    }
    vec4 c = sceneColor(vUV);
    FragColor = vec4(mix(c.rgb, Color.rgb, edge), c.a);
}
luau
world:add("PostShader", { shader = "res://shaders/outline.glsl" })

It looks at the four pixels around each pixel. A jump in distance draws the outline round the edge of a thing against what is behind it, and a jump in facing draws the creases inside it, such as where a wall meets the floor. The two numbers, 0.05 and 0.4, are how big a jump has to be to count. Blocks outline too.

previousColor is last frame's result of this same shader. Mixing a little of it in gives a trail behind everything that moves:

glsl
void main() {
    FragColor = mix(sceneColor(vUV), previousColor(vUV), 0.8);
}

The sky#

A Sky with a shader draws the sky with your GLSL. The file is the body of a function given s that returns the colour of the sky in one direction, in linear light:

glsl
// res://shaders/dusk.glsl
uniform vec3 top = vec3(0.1, 0.12, 0.35);
uniform vec3 bottom = vec3(1.0, 0.45, 0.2);

float up = clamp(s.direction.y, 0.0, 1.0);
vec3 color = mix(bottom, top, pow(up, 0.5));
float sun = max(dot(s.direction, s.sunDirection), 0.0);
return color + vec3(1.0, 0.8, 0.5) * pow(sun, 200.0) * 4.0;
luau
lighting:add("Sky", { shader = "res://shaders/dusk.glsl" })

A plain gradient with a zenith, horizon and ground colour needs no images either. Its colours are values under the Sky, so a script can change them:

glsl
// res://shaders/gradient.glsl
uniform vec3 zenith = vec3(0.2, 0.35, 0.7);
uniform vec3 horizon = vec3(0.75, 0.8, 0.85);
uniform vec3 ground = vec3(0.3, 0.28, 0.25);

float y = s.direction.y;
return y >= 0.0 ? mix(horizon, zenith, pow(y, 0.6)) : mix(horizon, ground, pow(-y, 0.6));
luau
local sky = lighting:add("Sky", { shader = "res://shaders/gradient.glsl" })
sky:add("ColorValue", { name = "zenith", value = color(0.1, 0.1, 0.3) })

s.direction is the way the camera looks through this pixel, s.sunDirection where the Lighting puts the sun and s.time seconds. The sky is also what shiny parts and models reflect and what lights their shaded sides, so a red dusk sky turns metal red and fills shadows with orange without any other setting. Distant blocks fade into the colour of the sky behind them.

A sky whose file mentions time only updates its reflections every so often, so fast moving clouds lag a little in a chrome ball.

The physical sky#

shader = "moud:sky/physical" is the engine's own sky shader: blue overhead, pale at the horizon and red and orange at sunset, from how sunlight crosses real air, with nothing else to set.

luau
lighting:add("Sky", { shader = "moud:sky/physical" })

Its values work like any shader's, as children of the Sky:

Value Default What it does
exposure 24 how bright the sky is drawn
sunSize 2 how wide the sun is, in degrees
sunBrightness 40 how bright the sun's disc is
rayleigh 1 how much air there is: 0 is a black sky, 2 a deeper blue and redder sunsets
mie 1 haze and dust: more makes a whiter sky and a bigger glow round the sun
mieEccentricity 0.8 how tightly the haze glows round the sun, -1 to 1
ozone 1 the ozone layer, which keeps the sky blue at dusk
groundColor 0.3 grey the ground under the air: what you see below the horizon past the edge of the world, and what shiny parts reflect underneath

A shader of your own can start from it too: atmosphere(s.direction) gives the physical sky's colour in any direction and sunlight(s.direction) the colour sunlight has after crossing the air that way, so a stylized sky can posterize the real one instead of starting from nothing:

glsl
vec3 up = normalize(vec3(s.direction.x, max(s.direction.y, 0.0), s.direction.z));
vec3 real = atmosphere(up) * 24.0;
return floor(real * 6.0) / 6.0;

Below the horizon atmosphere gives the hazy ground under the air. Lifting the direction to the horizon, as the first line does, carries the horizon colour down instead, for a sky with no ground.

What it costs#

A part shader costs what its lines cost, for the pixels the part covers, and nothing for parts without one. A PostShader runs once for every pixel on screen. To see what a shader costs in your own place, open the pass list with F8, see Debugging.

A part, model or MultiMesh whose shader is still compiling is left out until it is ready, instead of the game freezing. Compiled shaders are cached, so the next start is fast.