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:
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,
})shaderruns for every pixel of the part before any light lands on it. It is givens, the surface, and changes what it needs.vertexShaderruns for every corner of the part and returns how far to move it, in world space.lightingShaderruns 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:
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#
// 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);facingis how squarely the surface looks at the sun, 0 side-on or away and 1 straight at it.- Rounding it to
bandssteps is the whole toon look: the light jumps from one tone to the next instead of fading. - The dark tone is
shadowTintrather than black, the way a painter shades with blue. s.light.yis 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:
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#
// 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#
// 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:
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:
// 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;// 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;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
snapsteps 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. flatPosis where the corner ended up. Declarednoperspective, 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
imageon fromflatPos, repeating everytilemetres, the waytexturelays on from the true position. The image comes from the shader rather than the part'stexture, 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:
// 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#
// 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);
}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:
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:
// 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;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:
// 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));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.
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:
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.
