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:
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:
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:
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#
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.
shadingis"flat"(the default, the Minecraft look) or"pbr".roughnessruns from 0, a mirror, to 1, chalk. It only matters with"pbr". Default 0.5.metallicfrom 0 to 1. A metal takes its reflection's colour fromcolorand has no dull colour of its own, so gold is a yellowcolorwithmetallic = 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:
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,
})normalMaptilts 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.roughnessMapis a grey image, white rough and black glossy. It is used in place ofroughness.metallicMapis a grey image, white metal and black not. It is used in place ofmetallic.occlusionMapis 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:
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:
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,
})meshIdis the model file. Moud reads.gltf,.glb,.ammeshand.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. Theuriof anEditableMeshdraws a mesh a script built, see EditableMesh.sizeworks as it does on any part, and the model is stretched to fill it. That means aMeshPartmoves, scales and turns like a part, and you never scale the model file itself.animationnames a clip inside the file, withanimationSpeedandanimationLoopeddeciding how it plays. A name the file does not have is logged once, along with the names it does have.- A
MeshPartwithBones inside it is drawn posed by them instead, piece by piece, and ignoresanimation. See Bones and importing a Blockbench model. - A
MeshPartcollides 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.
transparencyfades 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:
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:
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,
})materialis 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.colormultiplies the material's colour, and white leaves it as it is.texturereplaces the material's colour image, and takes what every other image property takes. See Images.roughnessandmetallicreplace the material's own, and-1, the default, keeps them.normalMap,roughnessMap,metallicMapandocclusionMapreplace 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 ofroughnessandmetallic, 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.emissionMapreplaces the material's glow image. It multiplies the glow colour, so on a material that does not glow in the file, setemissiontoo.emissionreplaces the colour the material glows with, and black, the default, keeps the file's.emissionStrengthmultiplies the glow, so0turns it off and4makes it bloom. See Glowing models.transparencymakes the material see-through, on top of any transparency it already has.doubleSideddraws both sides of every face, for leaves and flat sheets exported one-sided. Off keeps the file's setting.dithereddraws what is see-through in the material, its image's soft edges and itstransparency, 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.filterisdefault,nearestorlinear.nearestkeeps pixel-art images sharp up close.offsetU,offsetV,repeatUandrepeatVmove 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:
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:
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.
// 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;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:
// 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:
// 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.
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, timesscale. It stretches on every axis, so a"sphere"in a 4 by 1 by 1 part is a long egg, where a part withshape = "ball"stays round. - A
"fileMesh"is drawn at the size it has in the file, timesscale, centred on the part. The part'ssizedoes not stretch it. That is how it differs from aMeshPart. - 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,metallicandtransparency. With atextureId, the image takes the place ofcolorandvertexColortints it. - A
"fileMesh"keeps the file's own materials, tinted byvertexColor. AtextureIdreplaces 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
SpecialMeshin a part is used. ASpecialMeshinside aMeshPart, a body's limb or a part in aViewportFramedoes nothing.
offset and scale move only the picture, so tweening them never touches the physics:
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.
-- 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,.ammeshor.bbmodel. It waits for the file, so call it insidetask.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 doesother: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'ssizedoes not stretch it, as it stretches a model file. sizeis still the box the part collides, is queried and is selected with, centred on the part. Build the mesh aroundvec3(0, 0, 0)and give the partmesh:getSize(), and the two match.- A
MaterialOverridewith nomaterialchanges the whole mesh. Itstexturelays an image on the mesh by its UVs,colortints it anddoubleSideddraws the back of every face. - A
SpecialMeshwithmeshType = "fileMesh"takes a mesh'suritoo. It draws the mesh from its own origin, timesscale, 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:
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 |
raycastreachesrangemetres alongdirection, 1000 by default, and hits a face from either side. When it hits nothing, all three results arenil.- The weights are a
Vector3: x, y and z are how much of the first, second and third corner ofgetFaceVerticesthe 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 withcframe:vectorToObjectSpace(direction).
Here a click pushes in the sheet the camera looks at, which is the start of a sculpting tool:
-- 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:
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
SpotLightpoints down its own frame, socframe.lookAt(from, to)aims it at something. - An
AreaLighttakesshape = "rectangle"withwidthandheight, orshape = "disc", wherewidthis the diameter. - A
TubeLightruns along its right axis forlengthmetres.
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:
// res://lights/flicker.glsl
color *= 0.8 + 0.2 * sin(LightTime * 23.0) * sin(LightTime * 7.0);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:
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 againDestroying 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:
-- client
world:add("VignetteEffect", { radius = 0.5, softness = 0.4, coversInterface = true })
world:add("PosterizeEffect", { levels = 6, coversInterface = true })- Effects with
coversInterfacerun after all the others, still inorderamong themselves. - The first-person hand and every
ScreenGuiare under them. The chat, the pause menu and other game screens are not. FogEffect,OutlineEffectandDepthOfFieldEffectread 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 aPostShaderthat only callssceneColor, are the ones to use here.
A time budget for shadows#
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:
local crt = world:add("PostShader", { shader = "res://post/scanlines.glsl", order = 85 })
crt:add("NumberValue", { name = "Lines", value = 240 })// 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)andlinearDepth(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
#versionis 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 |
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" })// 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, orminecraft: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:
// 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:
npc.rightItem = "minecraft:diamond_sword"
npc.leftItem = "minecraft:shield"
npc.rightItem = "" -- empty handA 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:
body.rightItemOverride = "minecraft:blaze_rod" -- drawn whatever they hold
body.leftItemOverride = "minecraft:air" -- an empty hand
body.rightItemOverride = "" -- back to their inventoryAn 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.
