Moud

Textures, models, lights, post effects and held items#

Everything on this page is an instance you add to the tree, like a part. A MeshPart is a part drawn as a model file, a light is an instance that lights the world around it, a post effect is an instance that changes the finished picture, and a body's hands hold whatever you write into two properties. You add them with add, change them by writing properties, and take them away with destroy.

A texture on a part#

A part takes a texture of its own, on every face at once:

luau
world:add("Part", {
    size = vec3(12, 1, 12),
    cframe = cframe(0, 64, 0),
    texture = "minecraft:block/oak_planks",
    studsPerTile = 1,
    anchored = true,
})

texture takes what every other image property takes: a file in the place, an image a script drew, or any texture the game itself has, as Images lists. studsPerTile is how many metres one copy of the image covers, so the floor above repeats the planks once a metre and widening it adds copies instead of stretching the ones it has. color still tints what is drawn, so one image serves a whole palette of parts.

The image is laid on in world space, from three directions at once, which means it needs no texture coordinates and lands correctly on a ball, a cylinder or a wedge — the shapes a Decal was never able to cover. Use a Decal or a Texture when you want one face of a part to differ from the others; use texture when the whole part is made of the same thing.

Parts that share a texture are drawn together, so the cost is counted per texture and not per part. A part with no texture is drawn exactly as before.

textureOffset slides the image across the part, in metres along each world axis. Tween it and a tiled texture scrolls, which is a conveyor belt, a river or a sign that crawls:

luau
local belt = world:add("Part", {
    size = vec3(12, 0.4, 3),
    cframe = cframe(0, 64, 6),
    texture = "minecraft:block/oak_planks",
    studsPerTile = 2,
    anchored = true,
})
belt:tween({ textureOffset = vec3(20, 0, 0) }, { time = 10, easing = "linear", repeats = -1 })

An offset of one studsPerTile along an axis brings the image back to where it started, so a loop that moves by a whole number of tiles never jumps.

A video as a texture#

A video file goes anywhere an image does, on a part, a model's material or a decal:

luau
world:add("Part", {
    size = vec3(4, 3, 3),
    cframe = cframe(3, 65.5, -2),
    texture = "res://video/lava.mp4",
    studsPerTile = 4,
    anchored = true,
})
local sign = world:add("MeshPart", { meshId = "res://models/sign.glb", size = vec3(4, 3, 0.5), cframe = cframe(-3, 65.5, -2) })
sign:add("MaterialOverride", { material = "Screen", texture = "res://video/lava.mp4" })

The video plays silent and on a loop, from the moment something shows it. Everything that names the same file shares one copy of it, so ten parts made of the same video decode it once and show the same picture. When nothing has shown the file for two seconds it is closed and its memory freed, and it starts again from the beginning the next time it is shown.

There is no way to pause it, seek it or hear it. For a screen that does those things, put a VideoFrame in a SurfaceGui on the part instead.

Shading a part#

luau
world:add("Part", {
    shape = "ball",
    size = vec3(3, 3, 3),
    cframe = cframe(0, 66, 0),
    color = color(0.8, 0.2, 0.15),
    shading = "pbr",
    roughness = 0.2,
    metallic = 0,
    anchored = true,
})

A part is drawn the way Minecraft draws a block unless you say otherwise: each face a flat shade brighter or darker by which way it points. shading = "pbr" lights it the way a glTF MeshPart is lit instead, from the sun and the sky around it, with a highlight and a reflection. A ball or a cylinder is then smooth all the way round instead of faceted.

  • shading is "flat" (the default, the Minecraft look) or "pbr".
  • roughness runs from 0, a mirror, to 1, chalk. It only matters with "pbr". Default 0.5.
  • metallic from 0 to 1. A metal takes its reflection's colour from color and has no dull colour of its own, so gold is a yellow color with metallic = 1.

The reflection and the soft light come from the sky, and from the Sky's hdri when there is one, scaled by the Lighting's environmentDiffuseScale and environmentSpecularScale. Torchlight and night still darken a pbr part the way they darken a flat one, and it casts and takes shadows the same way. It works with texture too: the image becomes the colour the light falls on.

The sun gives a pbr part five times the light the sky does, the way it does outdoors, so the side turned away from the sun is about as dark as grass in a shadow. Metals still show the sky in their shine. Parts, textured parts and MeshParts share one lighting and one tonemap, which keeps colours up to about 80% brightness exactly as set and only rounds off the brightest highlights. A sun shadow falling on a pbr part takes away only its sunlight, so a lamp or torch next to it still lights it fully.

Flat and pbr parts are drawn together, so switching one part over costs nothing extra.

Maps on a part#

A pbr part takes more images than its colour. Each one says something about the surface at every point of it, so bricks get their mortar sunk in and rust shows dull on polished steel. Any of them can be a PNG, JPEG or WebP file:

luau
world:add("Part", {
    size = vec3(4, 3, 1),
    cframe = cframe(0, 65.5, 0),
    texture = "res://textures/brick_color.png",
    normalMap = "res://textures/brick_normal.png",
    roughnessMap = "res://textures/brick_roughness.png",
    occlusionMap = "res://textures/brick_ao.png",
    studsPerTile = 2,
    shading = "pbr",
    anchored = true,
})
  • normalMap tilts the surface under the light, so flat faces show bumps, grooves and seams that catch the sun and the lamps. It is a tangent-space map, the purple-blue kind, with green pointing up the image, which is what Blender and glTF write. A map made for DirectX has green pointing down and shows its bumps pushed in instead of out; flip its green channel before using it.
  • roughnessMap is a grey image, white rough and black glossy. It is used in place of roughness.
  • metallicMap is a grey image, white metal and black not. It is used in place of metallic.
  • occlusionMap is a grey image that darkens the light from the sky and from torches where it is dark, for the cracks and gaps that light would not reach. The sun and lamps are left as they are.

Roughness, metallic and occlusion are read from the red channel, so a grey image works, and so does any Minecraft texture: roughnessMap = "minecraft:block/cobblestone" makes the stones rough and the gaps between them glossy. A packed image that keeps all three in its red, green and blue channels only works as an occlusionMap.

Every map is laid on the way texture is, in world space from three directions, and studsPerTile and textureOffset move them all together, so the bumps stay on the bricks as the belt scrolls. A map takes what texture takes: a file in the place, an image a script drew, or a game texture. A map does nothing on a "flat" part, since a flat part is not lit by anything a map could change.

Where a face runs between two of the three directions, on the slope of a wedge or around a ball, the image from each direction is blended in, so the bumps of two of them show at once. A map whose bumps line up with the edges of the part looks best on a box.

Parts that share a texture and the same maps are drawn together, like parts that share a texture.

A glowing part#

emission makes a part glow, in the dark and without a light on it. emissionStrength multiplies it, and with a BloomEffect above 1 spills light around it:

luau
world:add("Part", {
    size = vec3(2, 2, 2),
    cframe = cframe(0, 66, 0),
    emission = color(1, 0.5, 0.1),
    emissionStrength = 3,
    emissionMap = "minecraft:block/redstone_lamp_on",
    anchored = true,
})

emissionMap multiplies the glow by an image, so only its bright parts glow, and it is laid on like texture. Black emission, the default, or an emissionStrength of 0 turns the glow off. Unlike the other maps, glow works on a "flat" part too.

MeshPart#

A MeshPart is a part that is drawn as a 3D model instead of a box:

luau
world:add("MeshPart", {
    meshId = "res://models/crate.glb",    -- .gltf, .glb, .ammesh or .bbmodel, or a resource pack id
    size = vec3(1, 1, 1),
    cframe = cframe(0, 65, 0),
    animation = "idle", animationSpeed = 1, animationLooped = true,
})
  • meshId is the model file. Moud reads .gltf, .glb, .ammesh and .bbmodel, and a resource pack id works in its place. The images a model carries or points to may be PNG, JPEG or WebP. # and a name after it draws one piece of the file, see One piece of a file. The uri of an EditableMesh draws a mesh a script built, see EditableMesh.
  • size works as it does on any part, and the model is stretched to fill it. That means a MeshPart moves, scales and turns like a part, and you never scale the model file itself.
  • animation names a clip inside the file, with animationSpeed and animationLooped deciding how it plays. A name the file does not have is logged once, along with the names it does have.
  • A MeshPart with Bones inside it is drawn posed by them instead, piece by piece, and ignores animation. See Bones and importing a Blockbench model.
  • A MeshPart collides as its box, not as the shape of the model.
  • Saving the model file reloads it while the game runs, so you can export from your modelling program and see the new shape without touching the place.
  • transparency fades the whole model, as it fades a part. See See-through models.

One piece of a file#

A scene exported as one file holds many objects. Add # and a name to meshId to draw one of them:

luau
world:add("MeshPart", {
    meshId = "res://models/tower.glb#Cube_018",
    size = vec3(1.2, 2, 1.2),
    cframe = cframe(4, 65, 0),
})

The name is a node of the file, as your modelling program named the object, and the node is drawn with everything under it, so naming a group draws the whole group. When no node has that name, a mesh with that name is used instead. A name the file does not have is logged once, along with the names it does have.

The piece is drawn in its own frame, not where it stood in the file: its position and its turn in the scene are taken off, and size fills the piece alone. So one chair of a room lands at the part's cframe, upright, the same way a file holding only that chair would. The file is loaded once however many pieces of it you place.

Changing a model's materials#

A MaterialOverride inside a MeshPart changes the model's materials for that part only, with no shader:

luau
local rack = world:add("MeshPart", { meshId = "res://models/rack.glb", size = vec3(3, 8, 3) })
rack:add("MaterialOverride", { color = color(1, 0.3, 0.2) })                   -- every material
rack:add("MaterialOverride", { material = "Metal", metallic = 1, roughness = 0.1 })
rack:add("MaterialOverride", {
    material = "Floor",
    texture = "minecraft:block/oak_planks",
    filter = "nearest",
    repeatU = 4, repeatV = 4,
})
  • material is the name of the material in the file. Left empty, the override changes every material. Overrides for every material apply first, then the named ones on top. A name the file does not have is logged once, along with the names it does have.
  • color multiplies the material's colour, and white leaves it as it is.
  • texture replaces the material's colour image, and takes what every other image property takes. See Images.
  • roughness and metallic replace the material's own, and -1, the default, keeps them.
  • normalMap, roughnessMap, metallicMap and occlusionMap replace the material's images, and mean what they mean on a part: see Maps on a part. A roughness or metallic map is used in place of roughness and metallic, and in place of any roughness and metallic the file packed into one image. A model saved without tangents still takes a normal map; the tangents are worked out from its texture coordinates.
  • emissionMap replaces the material's glow image. It multiplies the glow colour, so on a material that does not glow in the file, set emission too.
  • emission replaces the colour the material glows with, and black, the default, keeps the file's. emissionStrength multiplies the glow, so 0 turns it off and 4 makes it bloom. See Glowing models.
  • transparency makes the material see-through, on top of any transparency it already has.
  • doubleSided draws both sides of every face, for leaves and flat sheets exported one-sided. Off keeps the file's setting.
  • dithered draws what is see-through in the material, its image's soft edges and its transparency, as a fine pattern of dropped pixels instead of blending it. Anti-aliasing smooths the pattern out, and the material never has to be sorted. It suits leaves. See Soft edges.
  • filter is default, nearest or linear. nearest keeps pixel-art images sharp up close.
  • offsetU, offsetV, repeatU and repeatV move and repeat the material's images. See Scrolling a texture.

Two parts showing the same file each keep their own overrides, so one model file serves a red crate and a blue one.

See-through models#

A material exported as blended, a material with an override's transparency, and a MeshPart with transparency are drawn after every solid model, the farthest first, without hiding what is behind them. A window in one model shows another model through it, whichever was added first. A material exported with an alpha cutoff (glTF MASK) stays solid and cuts its holes instead, which suits leaves, fences and grilles. A double-sided material is lit correctly from both sides.

Scrolling a texture#

offsetU and offsetV slide a material's images, in whole images, and a tween moves them:

luau
local screen = world:add("MeshPart", { meshId = "res://models/screen.glb", size = vec3(4, 3, 0.2) })
local scroll = screen:add("MaterialOverride", { material = "Display" })
scroll:tween({ offsetU = 1 }, { time = 4, easing = "linear", repeats = -1 })

Images repeat, so sliding by exactly 1 comes back to where it started, and the loop above scrolls forever without a jump. repeatU and repeatV tile the image more times across the material. The colour, the normal, the roughness and the glow images all move together. Fog planes and water sheets work the same way.

Glowing models#

A material that glows in the file keeps glowing in the dark, and with a BloomEffect in the world, bright glow spills light around it:

luau
world:add("BloomEffect", { intensity = 1.2 })
local lamp = world:add("MeshPart", { meshId = "res://models/lamp.glb" })
lamp:add("MaterialOverride", { material = "Bulb", emission = color(1, 0.8, 0.5), emissionStrength = 4 })

Glow comes from the file's emission (with its glTF emission strength), from an override's emission and emissionStrength, and from s.emission in a surface shader. The brighter it is, the wider it blooms: above the bloom's threshold it spreads.

Shaders on a model#

A MeshPart takes three GLSL files of your own, each one run at a different moment of drawing it:

Property When it runs What it changes
shader while the model is drawn, before any light the surface: colour, transparency, glow, roughness, metallic, normal
lightingShader after the lights the final colour
vertexShader before anything is drawn where each vertex is

Like a light shader, each file is the body of a function: no main, and saving the file redraws the model while the game runs.

shader is given s, the surface about to be lit, and changes what it needs: s.uv, s.albedo, s.alpha, s.emission, s.roughness, s.metallic, s.normal. It can read s.worldPos and s.time, in seconds.

A file may declare its own uniforms at the top. Each one is filled from a child of the part with the same name: a NumberValue for a float or an int, a BoolValue for a bool, a Vector3Value for a vec3 or a colour, and a StringValue holding an image for a sampler2D.

glsl
// res://shaders/glow_strip.glsl
uniform float progress;
uniform vec3 tint;
uniform sampler2D mask;

float lit = 1.0 - step(0.06, abs(progress - texture(mask, s.uv).r));
s.emission = tint * lit * 3.0;
luau
local rack = world:add("MeshPart", {
    meshId = "res://models/rack.glb",
    size = vec3(2, 6, 2),
    shader = "res://shaders/glow_strip.glsl",
})
local progress = rack:add("NumberValue", { name = "progress" })
rack:add("Vector3Value", { name = "tint", value = vec3(0.2, 1, 0.3) })
rack:add("StringValue", { name = "mask", value = "res://textures/strip_mask.png" })

game.renderStepped:connect(function()
    progress.value = (os.clock() * 0.5) % 1
end)

The mask's red channel says when each spot of the model lights up, and moving progress from 0 to 1 sweeps a band of light across the model. Every part has its own values, so ten racks with the same shader can each be at a different point of the sweep. A file takes at most sixteen values and four images.

lightingShader is given s once the lights have landed: s.albedo, s.normal, s.fragPos, s.roughness, s.metallic, s.lightmap, s.time, and s.radiance, the colour the model would have had. It has to return a colour:

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

vertexShader is given v: v.localPos, v.worldPos, v.origin, v.uv and v.time. It returns how far to move the vertex, in world space:

glsl
// res://shaders/sway.glsl
return vec3(sin(v.time * 2.0 + v.worldPos.y) * 0.05 * v.localPos.y, 0.0, 0.0);

The model's lighting follows the moved shape, so a flag that ripples is shaded along its folds.

SpecialMesh#

A SpecialMesh inside a part changes the shape the part is drawn as. The part still collides, is queried and is selected as its own box. Only the picture changes.

luau
local egg = world:add("Part", {
    size = vec3(2, 2, 2),
    cframe = cframe(0, 65, 0),
    color = color(0.9, 0.85, 0.7),
    anchored = true,
})
local shape = egg:add("SpecialMesh", { meshType = "sphere", scale = vec3(0.8, 1.2, 0.8) })
Property Default
meshType "brick" the shape drawn: "brick", "sphere", "cylinder", "wedge", "head" or "fileMesh"
meshId "" with "fileMesh", the model drawn. It takes what a MeshPart's meshId takes
textureId "" an image laid on the shape by its texture coordinates
scale vec3(1, 1, 1) multiplies the size it is drawn at
offset vec3(0, 0, 0) moves the drawn shape away from the part's centre, in metres along the part's own axes
vertexColor white tints the image, and the model of a "fileMesh"
  • A built-in shape fills the part's size, times scale. It stretches on every axis, so a "sphere" in a 4 by 1 by 1 part is a long egg, where a part with shape = "ball" stays round.
  • A "fileMesh" is drawn at the size it has in the file, times scale, centred on the part. The part's size does not stretch it. That is how it differs from a MeshPart.
  • A "cylinder" lies along the part's x axis, as a cylinder part does. A "wedge" slopes the way a wedge part does. A "head" is an upright cylinder with rounded top and bottom edges.
  • A built-in shape takes the part's color, roughness, metallic and transparency. With a textureId, the image takes the place of color and vertexColor tints it.
  • A "fileMesh" keeps the file's own materials, tinted by vertexColor. A textureId replaces the colour image of every material.
  • The part is lit the way a model is, from the sun and the sky, whatever its shading.
  • Only the first SpecialMesh in a part is used. A SpecialMesh inside a MeshPart, a body's limb or a part in a ViewportFrame does nothing.

offset and scale move only the picture, so tweening them never touches the physics:

luau
shape:tween({ offset = vec3(0, 0.5, 0) }, { time = 1, reverses = true, repeats = -1 })

EditableMesh#

meshes is a global that makes meshes a script builds, vertex by vertex, while the game runs. Each one is an EditableMesh. A MeshPart shows one when its meshId is the mesh's uri.

luau
-- client
local mesh = meshes.create()
local a = mesh:addVertex(vec3(-1, -1, 0))
local b = mesh:addVertex(vec3(1, -1, 0))
local c = mesh:addVertex(vec3(0, 1, 0))
mesh:addTriangle(a, b, c)

world:add("MeshPart", { meshId = mesh.uri, size = mesh:getSize(), cframe = cframe(0, 65, 0) })
  • meshes.create() makes an empty mesh.
  • meshes.load(source) reads a model file of the place into a new mesh: .gltf, .glb, .ammesh or .bbmodel. It waits for the file, so call it inside task.spawn. It keeps the shape, the normals, the UVs and the colour of each material, and drops the images, the bones and the clips. It works in client scripts only.
  • meshes.load(other.uri) returns a copy of another mesh, and so does other:copy().
  • mesh:destroy() throws a mesh away. Every mesh a script made is thrown away when its scripts stop, which includes every reload.

Show meshes from a client script. A mesh lives in the memory of the side that made it. A server Script can build a mesh and cast rays against it, but its uri means nothing on a player's machine, so players do not see it.

Showing a mesh#

  • A mesh is drawn at its own size, in the part's frame: a vertex at vec3(1, 0, 0) is drawn one metre along the part's right. The part's size does not stretch it, as it stretches a model file.
  • size is still the box the part collides, is queried and is selected with, centred on the part. Build the mesh around vec3(0, 0, 0) and give the part mesh:getSize(), and the two match.
  • A MaterialOverride with no material changes the whole mesh. Its texture lays an image on the mesh by its UVs, color tints it and doubleSided draws the back of every face.
  • A SpecialMesh with meshType = "fileMesh" takes a mesh's uri too. It draws the mesh from its own origin, times scale, without centring it.
  • A change shows up the next time the mesh is drawn. The whole mesh goes to the graphics card again, at most once a frame however many calls you made, so the cost grows with the size of the mesh.

Vertices and faces#

Every add call returns a number, the id of what it added. An id never changes and is never handed out again: after removeFace(4), the faces are 1, 2, 3 and 5. Vertices, normals, UVs, colours and faces each count on their own, so vertex 3 and face 3 are two different things. Go through a mesh with getVertices() and getFaces() rather than counting from 1.

Call What it does
mesh:addVertex(position) adds a vertex and returns its id
mesh:getPosition(vertex), mesh:setPosition(vertex, position) read and move a vertex
mesh:addTriangle(vertex1, vertex2, vertex3) adds a face and returns its id
mesh:removeFace(face) removes a face. Its vertices stay
mesh:getFaceVertices(face), mesh:setFaceVertices(face, vertices) read and change the three vertices of a face
mesh:getVertices(), mesh:getFaces() every id there is
mesh:removeUnused() removes every vertex, normal, UV and colour no face uses
mesh:clear() removes everything
  • A face is a triangle. Its front is the side from which its three vertices go round counterclockwise, and only the front is drawn.
  • Positions are metres in the mesh's own frame.
  • A mesh holds at most 65536 vertices and 65536 faces. Adding more is an error.

Normals, UVs and colours#

A vertex has one position. Its normal, UV and colour belong to each corner of each face instead, so a vertex shared by two faces can look smooth across their edge, or sharp.

When addTriangle adds a face, each corner takes the normal, UV and colour its vertex already has in another face. A vertex no face uses yet gets a new normal, the UV (0, 0) and white. So a grid of shared vertices comes out smooth, with one UV per vertex.

To make an edge sharp, give each face its own normal:

luau
local face = mesh:addTriangle(a, b, c)
local normal = mesh:addNormal()
mesh:setFaceNormals(face, { normal, normal, normal })
Call What it does
mesh:addNormal(normal?) adds a normal. Without a value it is worked out from the faces that use it
mesh:getNormal(id), mesh:setNormal(id, normal) read and set one. A normal you set is no longer worked out
mesh:resetNormal(id) works it out again
mesh:addUV(uv) adds a UV. x runs across the image and y down it. z is ignored
mesh:getUV(id), mesh:setUV(id, uv) read and set one
mesh:addColor(color, transparency?) adds a colour
mesh:getColor(id), mesh:setColor(id, color, transparency?) read and set one. getColor returns the colour and its transparency
mesh:getFaceNormals(face), mesh:setFaceNormals(face, ids) the normal of each corner, in the order of getFaceVertices
mesh:getFaceUVs(face), mesh:setFaceUVs(face, ids) the UV of each corner
mesh:getFaceColors(face), mesh:setFaceColors(face, ids) the colour of each corner
mesh:getNormals(), mesh:getUVs(), mesh:getColors() every id there is
  • A worked-out normal is the average of the faces that use it, the bigger faces counting more.
  • UV (0, 0) is the top left of the image and (1, 1) the bottom right. Past 1 the image repeats.
  • A face is drawn in one colour, the average of its three corners. The colour multiplies the texture when there is one.
  • The mesh is drawn in one piece per colour, so keep the number of different colours small.

Finding things on a mesh#

Call Returns
mesh:raycast(origin, direction, range?) the face hit, the point, and the weights of its three corners
mesh:findClosestPointOnSurface(point) the closest face, the point on it, and the weights of its three corners
mesh:findClosestVertex(point) the closest vertex
mesh:findVerticesWithinSphere(center, radius) every vertex inside the sphere
mesh:getVertexFaces(vertex) the faces that use the vertex
mesh:getAdjacentVertices(vertex) the vertices that share a face with it
mesh:getAdjacentFaces(face) the faces that share an edge with it
mesh:getSize(), mesh:getCenter() the size and the middle of the box the vertices fill
  • raycast reaches range metres along direction, 1000 by default, and hits a face from either side. When it hits nothing, all three results are nil.
  • The weights are a Vector3: x, y and z are how much of the first, second and third corner of getFaceVertices the point is made of. They add up to 1, so they mix anything a corner holds, such as a UV or a normal.
  • Everything is in the mesh's own frame. Turn a world point into it with the part's cframe:pointToObjectSpace(point), and a direction with cframe:vectorToObjectSpace(direction).

Here a click pushes in the sheet the camera looks at, which is the start of a sculpting tool:

luau
-- client
input.inputBegan:connect(function(object, gameProcessed)
    if gameProcessed or object.inputType ~= "mouseButton1" then return end
    local frame = sheet.cframe
    local face, point = mesh:raycast(
        frame:pointToObjectSpace(camera.cframe.position),
        frame:vectorToObjectSpace(camera.cframe.lookVector), 50)
    if face == nil or point == nil then return end
    for _, vertex in mesh:findVerticesWithinSphere(point, 0.5) do
        mesh:setPosition(vertex, mesh:getPosition(vertex) + vec3(0, 0, -0.05))
    end
end)

sheet is the MeshPart and mesh the EditableMesh it shows.

Lights#

A light is an instance you put in the world to light what is around it. There are four classes, and each one has a shape:

luau
world:add("PointLight", { color = color(1, 0.8, 0.5), brightness = 2, range = 12 })
world:add("SpotLight", { innerAngle = 20, outerAngle = 35, cframe = cframe.lookAt(from, to) })
world:add("AreaLight", { shape = "rectangle", width = 2, height = 1 })   -- or "disc", width is the diameter
world:add("TubeLight", { length = 3 })                                    -- along its right axis
  • A SpotLight points down its own frame, so cframe.lookAt(from, to) aims it at something.
  • An AreaLight takes shape = "rectangle" with width and height, or shape = "disc", where width is the diameter.
  • A TubeLight runs along its right axis for length metres.

Every light has the same set of properties, whichever class it is: enabled, color, temperature (kelvin; replaces color when above 0), brightness, range, falloff (smooth, linear, inverseSquare, exponent), falloffExponent, shadows, shadowStrength, godrays.

falloff is inverseSquare by default, which is how a real lamp behaves: bright within a metre or two of the source and dim well before range. Raise brightness to reach further rather than widening range, which only moves the point where the light is cut off. smooth and linear spread the light evenly all the way to range instead, which reads as a fill light rather than a bulb, and exponent shapes the curve with falloffExponent.

A light only stops at walls when shadows is on, and it is off by default. A light left without shadows shines through whatever is in the way.

A light lights the colour of what it lands on, not what the daylight already made of it. A lamp in a room the sun never reaches shows the room in its own colours, where the rest stays dark.

A light parented to a part moves with it, and faces down its frame's forward. Parent one to a lantern part and the light follows the lantern wherever physics or a tween takes it.

The sun, the sky, the weather and the distance fog are not lights, and none of them have the properties above. They belong to a Lighting instance. See Lighting, sky, weather and presets.

Custom light shaders#

shader names a GLSL file that changes how the light lands. The file is the body of a function that may change color, atten and p, and can read lightPos, lightDir, stage and LightTime:

glsl
// res://lights/flicker.glsl
color *= 0.8 + 0.2 * sin(LightTime * 23.0) * sin(LightTime * 7.0);
luau
world:add("PointLight", { shader = "res://lights/flicker.glsl" })

There is no main and no uniform block to write. The file is the body, so the one line above is a whole shader. Editing the file relights the scene while the game runs.

Post effects#

Post effects are instances. They apply while they are in the tree and enabled, on every client. Add one to the world to turn it on, and destroy it to turn it off.

Built on Amnetic. The first enabled one of each class is used. Removing it puts the setting back to how it was.

Class Properties
BloomEffect intensity, threshold, knee, size, resolution, occlude
ColorGradeEffect exposure, contrast, saturation, brightness, temperature, tint, gamma, lut, lutSize, lutIntensity
AmbientOcclusionEffect radius, intensity, bias, power, resolution, temporal
GlobalIlluminationEffect radius, intensity, resolution, history
ReflectionEffect intensity, reflectivity, maxDistance, steps, thickness, edgeFade, resolution, temporal
AntiAliasingEffect history, sharpness, clip
VolumetricEffect strength, density, anisotropy, steps, resolution, shadows
ContactShadowEffect distance, thickness, steps
ShadowQuality resolution, sunResolution, sunCascades, sunDistance, maxDistance, softness, contactHardening, lightSize, entities, timeBudget, reduction

AntiAliasingEffect blends each frame with the ones before it. history is how much of the last frame a pixel keeps, 0.94 by default, and clip is how far that history may stray from the new frame before it is pulled back. Parts, characters and mesh parts tell it how far they moved, and so do vanilla mobs walking, so moving things keep their edges smooth instead of leaving a trail. Particles, glass and other see-through effects lean on the new frame wherever they are drawn.

AmbientOcclusionEffect darkens the corners where things meet: under a cube on a table, round the foot of a table leg. It works from what is on screen, so it follows moving things but fades out at the edge of the picture and past its radius, half a metre by default. It only takes away light that comes from the sky, never sunlight or torchlight, so a lamp still lights a corner fully. Blocks against blocks keep Minecraft's own corner shading and are left alone; the effect only adds the shade parts, meshes and characters cast onto blocks and onto each other. Parts also get darkening that is worked out ahead of time from the blocks and still parts around them, which the Lighting's bakedOcclusion scales. The two stack.

Adding a second BloomEffect does nothing while the first one is enabled. To swap settings, either write the properties of the one that is already there, or disable it so the next one takes over.

Screen effects. Any number of these stack. They are drawn in order, lowest first, and every one has intensity from 0 to 1.

Class Properties Default order
FogEffect color, start, density, heightFalloff, baseHeight, sky 0
OutlineEffect color, thickness, threshold 5
DepthOfFieldEffect focusDistance, focusRange, falloff, size 10
MotionBlurEffect strength, samples 20
TonemapEffect tonemapper (aces, agx, reinhard, uncharted, none), exposure 30
BlurEffect size 40
ChromaticAberrationEffect amount 50
VignetteEffect radius, softness, color 60
PixelateEffect pixelSize 70
PosterizeEffect levels 75
SharpenEffect amount 80
FilmGrainEffect amount, size 90

Because effects are instances, a whole look is a folder you can throw away in one call:

luau
local night = world:add("Folder", { name = "night" })
night:add("ColorGradeEffect", { exposure = 0.8, saturation = 0.7, temperature = -0.3 })
night:add("VignetteEffect", { radius = 0.6, softness = 0.5 })
night:add("FogEffect", { color = color(0.1, 0.12, 0.2), start = 8, density = 0.05 })
-- night:destroy() takes all three away again

Destroying the folder destroys the three effects inside it, and each one puts its setting back as it goes.

Effects over the interface#

A screen effect normally touches only the world, and every ScreenGui is drawn on top of it untouched. Turn on coversInterface and the effect runs after the screen guis are drawn, so it reaches the menu text too:

luau
-- client
world:add("VignetteEffect", { radius = 0.5, softness = 0.4, coversInterface = true })
world:add("PosterizeEffect", { levels = 6, coversInterface = true })
  • Effects with coversInterface run after all the others, still in order among themselves.
  • The first-person hand and every ScreenGui are under them. The chat, the pause menu and other game screens are not.
  • FogEffect, OutlineEffect and DepthOfFieldEffect read the world's depth, which the interface does not write, so they treat a label like whatever is behind it. The effects that only look at colour, such as a vignette, posterize, pixelate, blur, grain or a PostShader that only calls sceneColor, are the ones to use here.

A time budget for shadows#

luau
world:add("ShadowQuality", { sunResolution = 4096, sunCascades = 4, timeBudget = 2 })

timeBudget is how many milliseconds a frame may spend drawing shadow maps. 0, the default, means no limit. When the shadow pass keeps running over it, the sun's shadow is made cheaper a step at a time: a shorter reach first, then a lower resolution, then fewer cascades. When there is room again it climbs back a step at a time, more slowly, and it never goes above what the ShadowQuality asks for. A setting that keeps tipping it over waits longer before it is tried again, so the shadows do not flicker between two qualities.

reduction reads how many steps down it is right now, from 0 to 6, on the client. The time measured is the time the game spends sending the shadow work, which follows the real cost closely but is not a GPU timer.

What shadows cost#

The sun's shadow is split into sunCascades maps, the sharpest one nearest the camera. That one is drawn every frame. The others take turns, one per frame, and reuse what they drew until their turn comes back, so four cascades cost little more than two. A camera that jumps, or a sun that moves by more than about a degree, redraws them all at once, so the shadow doesn't trail behind.

Solid ground costs its surface: the faces buried between two full blocks are never drawn into the shadow, and empty air is skipped. softness below 1 samples the map once per pixel; from 1 up it takes 8 samples for a wider edge.

A lamp's shadow only draws the parts within its range, so a crowded place costs each lamp only what stands near it.

Your own screen shader#

PostShader runs a GLSL fragment shader over the picture. It takes its place among the screen effects by order, so the one below is drawn between SharpenEffect and FilmGrainEffect:

luau
local crt = world:add("PostShader", { shader = "res://post/scanlines.glsl", order = 85 })
crt:add("NumberValue", { name = "Lines", value = 240 })
glsl
// res://post/scanlines.glsl
uniform float Lines;

void main() {
    vec4 c = sceneColor(vUV);
    float line = 0.85 + 0.15 * sin(vUV.y * Lines * 3.14159);
    FragColor = vec4(mix(c.rgb, c.rgb * line, Intensity), c.a);
}
  • The shader gets sceneColor(uv), sceneDepth(uv), isSky(uv), viewPosition(uv), worldPosition(uv) and linearDepth(uv).
  • Uniforms provided: Time, ScreenSize, Intensity, CameraPosition, PrevCameraPosition, ViewProj, InvViewProj, PrevViewProj.
  • The effect's own properties are there too, with a capital first letter: Intensity, Order.
  • A file that starts with #version is used as written, with none of the above added.
  • Saving the file recompiles it.

Every child is a uniform with the same name. The NumberValue named Lines above is the uniform float Lines the shader reads, so writing crt:find("Lines").value from a script changes the picture. The value goes in as whatever type the file declares:

Child Declare it as
NumberValue float, int (rounded), or a vec2, vec3, vec4 with the number in every slot
BoolValue bool or int, 1 or 0
Vector3Value vec3, vec2 (x, y) or vec4 (x, y, z, 1)
ColorValue vec4 (r, g, b, a) or vec3 (r, g, b)
StringValue holding an image sampler2D
luau
local tv = world:add("PostShader", { shader = "res://post/tv.glsl", coversInterface = true })
tv:add("Vector3Value", { name = "Offset", value = vec3(0.002, 0.001, 0) })
tv:add("ColorValue", { name = "Tint", value = color(1, 0.9, 0.8) })
tv:add("StringValue", { name = "Noise", value = "res://textures/noise.png" })
glsl
// res://post/tv.glsl
uniform vec2 Offset;
uniform vec4 Tint;
uniform sampler2D Noise;

void main() {
    float grain = texture(Noise, fract(vUV * ScreenSize / vec2(textureSize(Noise, 0)) + Time)).r;
    vec4 c = sceneColor(vUV + Offset * grain);
    FragColor = vec4(c.rgb * Tint.rgb, c.a);
}
  • An image is found like an ImageLabel's: res:// from the place, or minecraft: and the like. One that isn't loaded yet reads white. textureSize(Noise, 0) is its size in pixels.
  • A shader takes up to ten images.
  • A uniform the shader never reads is dropped by the compiler, so a value for it does nothing.

Which way is up. vUV runs from 0, 0 at the bottom left to 1, 1 at the top right, the GL way. A shader written with y going down, like flixel's or OpenFL's, can use uvDown() for its coordinate and sceneColorDown(uv) to read the picture with it, so nothing else has to flip:

glsl
// a flixel glitch, ported
uniform float Amount;

void main() {
    vec2 uv = uvDown();
    float band = step(0.9, fract(uv.y * 12.0 + Time));
    FragColor = sceneColorDown(uv + vec2(band * Amount, 0.0));
}

uvDown(uv) flips any coordinate either way. #pragma header is not needed, openfl_TextureCoordv is uvDown(), flixel_texture2D(bitmap, uv) is sceneColorDown(uv), openfl_TextureSize is ScreenSize and gl_FragColor is FragColor.

A PostShader inside a Frame of an interface only runs on that frame, which Effects on an interface explains.

See-through parts#

A part with transparency between 0 and 1 is drawn after everything solid, the furthest first, and does not hide what is behind it. Both its sides are drawn, so standing inside a tinted box tints the view. It casts no shadow.

Held items#

A body draws what each hand holds, with the game's own item renderer. A player's body shows their real items. For bodies a place owns, you write the item id into rightItem and leftItem:

luau
npc.rightItem = "minecraft:diamond_sword"
npc.leftItem = "minecraft:shield"
npc.rightItem = ""        -- empty hand

A player's rightItem and leftItem come off their own inventory, so writing them on a worn body does nothing. The server overrides what a player is shown holding instead:

luau
body.rightItemOverride = "minecraft:blaze_rod"   -- drawn whatever they hold
body.leftItemOverride = "minecraft:air"          -- an empty hand
body.rightItemOverride = ""                      -- back to their inventory

An override works on any body, and wins over rightItem / leftItem while it is set.

If you write rightItem on a player's body and nothing changes, that is why. Use rightItemOverride, and set it back to "" to hand the player their own inventory again.