Many copies of one mesh#
A MultiMesh draws one mesh thousands of times in a single draw. Grass, a forest, rain splashes, a
crowd in the stands, bullets, debris: anything made of many copies of the same thing. Each copy has
its own place, turn, size, colour and four numbers of your own, and a script hands all of them over
at once in a Luau buffer. There is no instance per copy and no table per copy, so fifty thousand
copies cost about what one part costs to add.
-- client
local count = 1000
local data = buffer.create(count * 64)
for n = 0, count - 1 do
local at = n * 64
buffer.writef32(data, at, math.random() * 40 - 20) -- x
buffer.writef32(data, at + 4, 65) -- y
buffer.writef32(data, at + 8, math.random() * 40 - 20) -- z
buffer.writef32(data, at + 16, math.random() * math.pi) -- yaw
for axis = 0, 2 do
buffer.writef32(data, at + 24 + axis * 4, 0.5) -- size
buffer.writef32(data, at + 36 + axis * 4, 1) -- colour
end
end
local crates = game.world:add("MultiMesh", { shape = "block", texture = "minecraft:block/barrel_side" })
crates:setCopies(data)That is a thousand barrels in one draw. The rest of this page is what each of those numbers means, the mesh a copy is drawn as, how copies are lit and faded, and three places built with it.
Copies#
Every copy takes 64 bytes of the buffer: sixteen 32-bit floats, written with buffer.writef32.
Copy n, counting from 0, starts at byte n * 64.
| Byte | Floats | What it is |
|---|---|---|
| 0 | x, y, z | where the copy stands, in the MultiMesh's own frame |
| 12 | pitch, yaw, roll | how it is turned, in radians, in the order cframe.angles takes them |
| 24 | x, y, z | its size, which multiplies the mesh |
| 36 | red, green, blue | its colour, which multiplies the mesh's own |
| 48 | four numbers | anything you like, read by your shaders as custom |
- A new buffer is all zeros, and a copy whose size is zero on every axis is not drawn. A buffer bigger than you need is harmless.
- Colour multiplies, so write
1, 1, 1to keep the mesh's own colours. A colour left at zero draws the copy black. cframemoves and turns every copy at once, and the copies follow theMultiMeshwhen it sits in a moving part, like any child.
Setting and changing copies#
mesh:setCopies(data) -- the copies are exactly those in data
mesh:setCopies(changed, 101) -- copies 101, 102, ... become those in changed, the rest stay- With one argument, the mesh draws the copies in
dataand nothing else. The number of copies is the length of the buffer divided by 64. - With a second argument, the copies in the buffer replace the ones from that number on, counting from 1. Only those are sent to the graphics card again, so moving twenty copies out of fifty thousand costs twenty copies. Writing past the last copy adds copies.
- The buffer is read when you call
setCopies. Changing it afterwards does nothing until you callsetCopiesagain, so one buffer can be filled and sent every frame.
Copies stay on the client#
The copies of a MultiMesh live on the client that set them, like an image or an
editable mesh. setCopies and setArray work in client scripts only.
A client script can add a MultiMesh of its own, which only that player sees. The server can add
one too: then its properties replicate like any instance's, and every client fills the copies of its
own mirror of it with setCopies. The server decides what the forest looks like and where it
stands; each client scatters the trees itself, from the same seed. A client cannot change the
properties of a MultiMesh the server made, only its copies and arrays.
Copies are not saved with a scene and are not copied by clone.
The mesh#
| Property | Default | |
|---|---|---|
shape |
"block" |
the shape every copy is drawn as: "block", "ball", "cylinder", "wedge" or "card" |
meshId |
"" |
a model drawn in place of the shape: a .gltf, .glb, .ammesh or .bbmodel file, or an editable mesh's uri |
texture |
"" |
an image laid on every copy by its texture coordinates, see Images |
- The shapes are a part's shapes, one metre across, centred on the copy. A
"cylinder"lies along x, as a cylinder part does. - A
"card"is a flat upright square, one metre wide and one tall, facing along z. It stands on its origin rather than around it, so a card grows up from where you place it, which is what grass, a flower, a splash or a sign needs. It is drawn from both sides, and is cut in four rows up its height so that a vertex shader can bend it smoothly. - A model is drawn at the size it has in the file, times the copy's size, around the file's origin. Every material keeps its colour and its image, so a tree with bark and leaves draws both. A material the file marks as two-sided is drawn from both sides.
texturereplaces every image of the model. A shape without atextureis plain colour.
Light and shadow#
A MultiMesh is lit the way a part is: by the sun, by the sky and by the light of the blocks around
it, day and night.
| Property | Default | |
|---|---|---|
shading |
"flat" |
"flat" lights it the way Minecraft lights a block, "pbr" adds a highlight and a reflection |
roughness |
0.5 |
with "pbr", 0 is a mirror and 1 is chalk |
metallic |
0 |
with "pbr", 1 is bare metal |
castShadow |
true |
off, the copies throw no shadow |
Near the camera, each copy takes the light of the blocks it stands in, so grass under a torch glows
and grass in a cave is dark. Farther than about sixteen metres, every copy of one MultiMesh takes
the light found above the middle of its copies. That is one more reason to split a big area into
tiles, as the grass below does.
Shadows cost a second draw of every copy. Turn castShadow off for grass, splashes and anything
small and plentiful.
Distance#
| Property | Default | |
|---|---|---|
drawDistance |
256 |
copies farther than this, in metres from the camera, are not drawn |
fadeDistance |
0 |
copies fade out over this many metres before drawDistance instead of popping |
A fading copy shrinks away, or dithers away when dithered is on. Each copy fades on its own, by
its own distance, so a field thins out from the edge in rather than all at once.
The engine also keeps a box around all the copies of a MultiMesh, and skips the whole draw when
that box is behind the camera or beyond drawDistance. That test is one sum per MultiMesh, so
split a big area into tiles of a few chunks each, and whatever is out of view costs nothing.
Soft edges#
dithered = true draws the see-through parts of an image by dropping a fine, shifting pattern of
pixels instead of cutting them sharply at half. With an AntiAliasingEffect
in the world the pattern blends into soft edges and smooth fades. Leaves, grass cards and anything
that fades in or out look best this way. It never needs sorting, so a field of dithered cards costs
what a field of solid ones does.
Without it, a pixel of the image is drawn when it is more than half opaque and dropped otherwise.
A MaterialOverride takes dithered too, for a
MeshPart's leaves or a see-through material that should not be sorted:
tree:add("MaterialOverride", { material = "Leaves", dithered = true })Shaders#
A MultiMesh takes the three shader files a MeshPart takes,
vertexShader, shader and lightingShader, written the same way. On top of what a model's shaders
are given:
v.customands.customare the copy's four numbers of its own, as avec4.v.copyis the number of the copy, counting from 1.v.originis where the copy stands, in the world, andv.worldPosis the world position of the vertex, sov.worldPos.y - v.origin.yis how high up the copy a vertex is.v.timeisclock()in seconds, less every whole hour. A script that writesclock() % 3600into a copy'scustomlets its shader know how old the copy is.
A shader's uniforms are filled from children of the MultiMesh with the same name, as a model's
are: a NumberValue, a BoolValue, a Vector3Value, or a StringValue holding an image. An array
is filled from a buffer instead:
uniform vec4 bodies[8];mesh:setArray("bodies", data) -- 32-bit floats, four for each vec4An array holds at most sixteen entries of float, vec2, vec3 or vec4. Missing entries are
zeros. Values and arrays are sent every frame, so writing them each frame is the intended way to
feed a shader what is happening around it: where the players stand, the wind, a shockwave.
A shader that does not compile is reported once in the log, and the copies are drawn without it until the file is fixed.
Grass#
A client script that scatters grass over the ground around the player, in tiles of 16 by 16 blocks, and bends it away from anyone walking through:
-- client/main.luau
local TILE, REACH, PER_BLOCK = 16, 2, 10
type Tile = { x: number, z: number, grass: MultiMesh }
local tiles: { [number]: Tile } = {}
local bodies = buffer.create(8 * 16)
local function plant(tx: number, tz: number): Tile?
local from = vec3(tx * TILE, 0, tz * TILE)
local tops, kinds = game.blocks:topsIn(from, from + vec3(TILE - 1, 255, TILE - 1), { "minecraft:grass_block" })
local spots = {}
for column = 0, TILE * TILE - 1 do
if buffer.readu16(kinds, column * 2) > 0 then table.insert(spots, column) end
end
if #spots == 0 then return nil end
local data = buffer.create(#spots * PER_BLOCK * 64)
for n = 0, #spots * PER_BLOCK - 1 do
local column = spots[n // PER_BLOCK + 1]
local at, tall = n * 64, 0.5 + math.random() * 0.5
buffer.writef32(data, at, from.x + column % TILE + math.random())
buffer.writef32(data, at + 4, buffer.readf32(tops, column * 4))
buffer.writef32(data, at + 8, from.z + column // TILE + math.random())
buffer.writef32(data, at + 16, math.random() * math.pi)
buffer.writef32(data, at + 24, tall)
buffer.writef32(data, at + 28, tall)
buffer.writef32(data, at + 36, 0.45 + math.random() * 0.15)
buffer.writef32(data, at + 40, 0.8)
buffer.writef32(data, at + 44, 0.3)
buffer.writef32(data, at + 48, math.random())
end
local grass = game.world:add("MultiMesh", {
shape = "card", texture = "minecraft:block/short_grass", dithered = true, castShadow = false,
drawDistance = TILE * REACH, fadeDistance = 8, vertexShader = "res://shaders/grass.glsl",
})
grass:setCopies(data)
return { x = tx, z = tz, grass = grass }
end
game.renderStepped:connect(function()
local me = game.players:me()
if not me then return end
buffer.fill(bodies, 0, 0)
for n, body in game.world:query("Character") do
local feet = (body :: Character).position
if n > 8 then break end
for axis, value in { feet.x, feet.y - 1, feet.z, 1.2 } do
buffer.writef32(bodies, (n - 1) * 16 + (axis - 1) * 4, value)
end
end
local cx, cz = me.position.x // TILE, me.position.z // TILE
local planting = 1
for tx = cx - REACH, cx + REACH do
for tz = cz - REACH, cz + REACH do
local key = tx * 4096 + tz
if not tiles[key] and planting > 0 then
planting -= 1
tiles[key] = plant(tx, tz) :: Tile
end
if tiles[key] then tiles[key].grass:setArray("bodies", bodies) end
end
end
for key, tile in tiles do
if math.max(math.abs(tile.x - cx), math.abs(tile.z - cz)) > REACH then
tile.grass:destroy()
tiles[key] = nil
end
end
end)// res://shaders/grass.glsl
uniform vec4 bodies[8];
float rise = v.worldPos.y - v.origin.y;
vec2 lean = vec2(sin(v.time * 1.6 + v.origin.x * 0.4 + v.custom.x * 3.0), sin(v.time * 1.1 + v.origin.z * 0.4)) * 0.12;
for (int i = 0; i < 8; i++) {
vec3 away = v.origin - bodies[i].xyz;
if (bodies[i].w > 0.0 && abs(away.y) < 2.0) {
lean += normalize(away.xz + 0.001) * (1.0 - smoothstep(0.2, bodies[i].w, length(away.xz)));
}
}
lean *= rise * min(1.0, 0.9 / max(length(lean), 0.001));
return vec3(lean.x, -0.5 * dot(lean, lean), lean.y) * rise;topsInfinds the grass blocks of a tile in one call, see Blocks. Ten cards stand on each, at random spots and turns, with a random height and a little variation in green. The fourth custom number is a random seed the shader uses to put each card out of step with its neighbours.- A tile whose chunks have not arrived yet finds no grass, so it is planted again on a later frame. One tile is planted a frame at most, which keeps walking into new ground smooth.
minecraft:block/short_grassis the game's own grass image, grey so that the copy's colour paints it.ditheredsoftens its edges and the fade at the far side of the field.- Each tile is its own
MultiMesh. Tiles out of view are skipped whole, and tiles more than two away from the player are destroyed. - Every frame, the feet of up to eight bodies go into one buffer, four numbers each, and every tile
gets it as
bodies. The shader sways each card with the time and pushes it away from any body within its reach, more at the tip than at the root, and lowers the tip as it leans so that a card keeps its length.
Rain splashes#
Splashes come and go every frame, so this keeps a ring of 4096 of them and only ever sends the newest. Each splash is a drop that jumps up and falls back; its shader works out where the drop is from its age, so a drop is written once and never touched again:
-- client/main.luau
local SLOTS = 4096
local PER_FRAME = 96
local splashes = game.world:add("MultiMesh", {
shape = "ball",
castShadow = false,
drawDistance = 64,
vertexShader = "res://shaders/splash.glsl",
})
splashes:setCopies(buffer.create(SLOTS * 64))
local fresh = buffer.create(PER_FRAME * 64)
local slot = 0
game.renderStepped:connect(function()
local me = game.players:me()
if not me then return end
local now = clock() % 3600
for n = 0, PER_FRAME - 1 do
local x = me.position.x + math.random() * 24 - 12
local z = me.position.z + math.random() * 24 - 12
local at = n * 64
buffer.writef32(fresh, at, x)
buffer.writef32(fresh, at + 4, game.blocks:topAt(x, z) + 1)
buffer.writef32(fresh, at + 8, z)
for axis = 0, 2 do
buffer.writef32(fresh, at + 24 + axis * 4, 0.09)
buffer.writef32(fresh, at + 36 + axis * 4, 0.9 + axis * 0.05)
end
buffer.writef32(fresh, at + 48, math.random() * 2 - 1)
buffer.writef32(fresh, at + 52, 1.5 + math.random() * 1.5)
buffer.writef32(fresh, at + 56, math.random() * 2 - 1)
buffer.writef32(fresh, at + 60, now)
end
splashes:setCopies(fresh, slot + 1)
slot = (slot + PER_FRAME) % SLOTS
end)// res://shaders/splash.glsl
float age = v.time - v.custom.w;
if (age < 0.0 || age > 0.5) return v.origin - v.worldPos;
vec3 flight = v.custom.xyz * age + vec3(0.0, -9.8, 0.0) * age * age * 0.5;
return flight - (v.worldPos - v.origin) * age * 2.0;- The first
setCopiesmakes 4096 copies of size zero, which draw nothing. - Every frame, 96 new drops are written into a small buffer and sent into the ring with
setCopies(fresh, slot + 1). That is 6 KB a frame, whatever the size of the ring. - The fourth custom number is the time the drop was made, on the shader's clock. A drop older than half a second shrinks to nothing.
A forest#
A client script that plants a model tree on about one grass block in thirty, each turned and sized its own way and some of them turning to autumn:
-- client/main.luau
local from = vec3(-44, 0, 12)
local to = vec3(-8, 255, 44)
local tops, kinds, names
repeat
task.wait(0.5)
tops, kinds, names = game.blocks:topsIn(from, to, { "minecraft:grass_block" })
until #names > 0
local width = to.x - from.x + 1
local spots = {}
for column = 0, buffer.len(kinds) // 2 - 1 do
if buffer.readu16(kinds, column * 2) > 0 and math.random() < 0.03 then
table.insert(spots, column)
end
end
local data = buffer.create(#spots * 64)
for n, column in spots do
local at = (n - 1) * 64
local scale = 0.45 + math.random() * 0.3
local autumn = math.random()
buffer.writef32(data, at, from.x + column % width + 0.5)
buffer.writef32(data, at + 4, buffer.readf32(tops, column * 4))
buffer.writef32(data, at + 8, from.z + column // width + 0.5)
buffer.writef32(data, at + 16, math.random() * math.pi * 2)
buffer.writef32(data, at + 24, scale)
buffer.writef32(data, at + 28, scale)
buffer.writef32(data, at + 32, scale)
buffer.writef32(data, at + 36, 1)
buffer.writef32(data, at + 40, 1 - autumn * 0.35)
buffer.writef32(data, at + 44, 1 - autumn * 0.7)
end
local forest = game.world:add("MultiMesh", { meshId = "res://models/tree.glb", drawDistance = 200 })
forest:setCopies(data)- The script waits until the ground is there:
namesstays empty until the chunks have arrived. - The tree's bark and its leaves each keep their own image. The copy's colour multiplies both, so lowering green and blue together browns the leaves, and the trunk goes a little darker with them.
- Swap
meshIdfor a character model and the same script is a crowd.
What it costs#
Measured on a GeForce GTX 1060 at 1920 by 1080:
| What | Cost |
|---|---|
| the grass above: 25 tiles, about 60,000 cards, drawn out to 32 metres | 1.4 ms of the graphics card a frame |
| the same grass without its vertex shader | 1.3 ms |
| writing 50,000 copies into a buffer in Luau, once | 33 ms, and 1.4 ms for setCopies |
| moving 5,000 copies every frame, writing and sending all of them | 2 ms of Luau and 0.1 ms for setCopies |
| planting one grass tile of 2,560 cards | 8 to 25 ms of Luau |
- Drawing is cheap and writing copies in Luau is not, so write copies once and move them in a vertex shader when you can, as the grass and the rain do.
- A copy beyond
drawDistanceis dropped by the graphics card one vertex at a time, so it is cheap but not free while the rest of itsMultiMeshis in view. Small tiles let the whole draw be skipped instead. - Planting a tile takes several milliseconds of Luau, so plant new tiles one a frame rather than all at once, as the grass does.
