Images#
An image in Moud is a string that says where the pixels come from. An ImageLabel shows one, a
Decal puts one on a part, a particle flies one around. This page covers where images can go, how
an image fills the box it is given, and how a script draws its own images, pixel by pixel, while the
game runs.
Showing an image#
ImageLabel shows an image. ImageButton is an ImageLabel you can click. See
Image buttons for its hover and pressed images.
-- client
local hud = game.world:add("ScreenGui", { name = "hud" })
hud:add("ImageLabel", {
position = udim2.fromOffset(16, 16),
size = udim2.fromOffset(64, 64),
image = "res://ui/heart.png",
})An ImageLabel has no background until you set backgroundTransparency. imageColor tints the
image and imageTransparency fades it.
Where an image can go#
Every one of these takes the same kind of image string:
| Where | Property |
|---|---|
ImageLabel, ImageButton in a ScreenGui, SurfaceGui or BillboardGui |
image, hoverImage, pressedImage |
a ScreenGui shown in a window |
same as above |
Decal, Texture on a part |
texture |
MaterialOverride in a MeshPart |
texture |
SpecialMesh in a part |
textureId |
ParticleEmitter, Beam, Trail |
texture |
Sky |
skyboxBk and the other faces, sunTextureId, moonTextureId |
| rich text | <img src="..."> |
Only an ImageLabel or ImageButton can fit, tile, slice or crop its image. The rest draw the
whole image. See Particles, beams, trails, highlights and decals for how effects
read theirs.
Sources#
| Value | Image |
|---|---|
"res://ui/heart.png" |
a file in the place |
"minecraft:item/diamond" |
a Minecraft texture. textures/ and .png are added, and the full id "minecraft:textures/item/diamond.png" works too |
canvas.uri |
an image a script drew or loaded, see Drawing |
"res://video/lava.mp4" |
a video file in the place, playing silent and on a loop. See A video as a texture |
Particles also take a bare particle name such as "flame", which is looked up under
textures/particle/.
An image file in the place can be a PNG, a JPEG or a WebP, and works the same in every place listed above. A PNG or WebP keeps its transparency, a WebP is read as a still picture, and a JPEG has none to keep.
Preloading#
A res:// image is read and sent to the graphics card the first time something draws it, and a
big sprite sheet takes long enough to make that frame stutter. images.preload does the work
ahead, on a loading screen, so the first frame that shows it is smooth.
-- client
local status = hud:add("TextLabel", { text = "Loading", size = udim2.fromOffset(200, 24) })
local sheets = { "res://images/BOYFRIEND.png", "res://images/GF_assets.png", "res://images/NOTE_assets.png" }
task.spawn(function()
for n, sheet in sheets do
images.preload(sheet)
status.text = `Loading {n}/{#sheets}`
end
status:destroy()
end)images.preload(path)orimages.preload({ ...paths })waits until every image is ready, so call it insidetask.spawn. One that can not be read is an error naming it, after the rest are done.- The file is read and decoded on another thread. Only the upload is on the frame, a few milliseconds of it at most, and what is left goes on the next frame.
images.isLoaded(path)says whether an image is ready, without loading it.- Only
res://image files, PNG, JPEG or WebP, are loaded ahead. A Minecraft texture, a drawn image or a video is ready straight away. - It runs in client scripts and the loading script. What the loading screen preloads stays ready for the place.
- A loaded image stays loaded until the game closes, preloaded or not. Changing the file while the game runs does not show until a restart.
Fitting an image in its box#
scaleType says how the image fills the widget.
scaleType |
What happens |
|---|---|
"stretch" |
the image is stretched to the box. This is the default |
"fit" |
the whole image shows, as large as fits, centred. The rest of the box stays empty |
"crop" |
the image covers the whole box, centred. What sticks out is cut off |
"tile" |
the image repeats, one copy every tileSize |
"slice" |
nine-slice: the corners keep their size and the middle stretches |
fit and crop keep the image's shape. stretch does not.
Tiles#
tileSize is a UDim2. Its offset is pixels and its scale is a fraction of the widget's size. The
default, udim2.fromScale(1, 1), is one tile as big as the box.
hud:add("ImageLabel", {
size = udim2.fromOffset(300, 120),
image = "res://ui/checker.png",
scaleType = "tile",
tileSize = udim2.fromOffset(32, 32),
})Tiles start at the top left. The last row and column are cut where the box ends. A box that would need more than 4096 tiles gets bigger tiles instead.
tileOffset slides the pattern inside the box, in pixels, x and y, and the tiles cut off at one edge
come back in at the other. Moving it every frame scrolls a background:
-- client
local stars = hud:add("ImageLabel", {
size = udim2.fromScale(1, 1), image = "res://ui/stars.png",
scaleType = "tile", tileSize = udim2.fromOffset(128, 128),
})
game.renderStepped:connect(function(dt)
stars.tileOffset += vec3(20 * dt, 10 * dt, 0)
end)The stars drift right and down 20 and 10 pixels a second. tileOffset can also be tweened. It only
moves a "tile" image; to show another part of a sprite sheet, use imageRectOffset.
Nine-slice#
A nine-slice image is cut into a 3 by 3 grid. The four corners keep their size, the four edges stretch one way, and the middle stretches both ways. That keeps a rounded frame's corners round at any size.
There is no Rect type in Moud. The middle of the grid is given by two points, in image pixels:
sliceMinis the top left corner of the stretchy middle.sliceMaxis its bottom right corner.
Both are Vector3s. Only x and y are read.
-- a 24 by 24 frame with 8 pixel corners
hud:add("ImageLabel", {
size = udim2.fromOffset(400, 200),
image = "res://ui/frame.png",
scaleType = "slice",
sliceMin = vec3(8, 8, 0),
sliceMax = vec3(16, 16, 0),
sliceScale = 1,
})sliceScalescales the corners.2draws them twice as big. It is at least0.01.- When the box is smaller than the corners, the corners shrink so they still fit.
- When
sliceMaxis not pastsliceMinon both axes, the whole image is stretched instead.
Sprite sheets#
imageRectOffset and imageRectSize pick a part of the image to show, in pixels. Everything else,
fitting and slicing included, works on that part only. Both are Vector3s and z is ignored.
imageRectOffsetis the top left of the part.imageRectSizeis its size.0on an axis means "to the edge of the image". The default shows the whole image.
Move imageRectOffset to play a sprite sheet. Here the sheet has four 16 pixel frames side by side:
-- client
local sprite = hud:add("ImageLabel", {
size = udim2.fromOffset(64, 64),
image = "res://ui/coin.png",
imageRectSize = vec3(16, 16, 0),
resampleMode = "pixelated",
})
local frame = 0
task.every(0.12, function()
frame = (frame + 1) % 4
sprite.imageRectOffset = vec3(frame * 16, 0, 0)
end)Pixel art#
resampleMode = "pixelated" keeps each pixel a sharp square when the image is drawn bigger than it
is. The default, "default", smooths it. It only changes ImageLabel and ImageButton.
Flipping#
flipX = true mirrors the image left to right, flipY = true top to bottom. It flips what ends up
in the box, so a sprite sheet frame from imageRectOffset flips on its own without touching the
sheet, and a character can face the other way by flipping a single property:
-- client
local hero = hud:add("ImageLabel", {
size = udim2.fromOffset(128, 128),
image = "res://ui/hero.png",
imageRectOffset = vec3(0, 0, 0), imageRectSize = vec3(64, 64, 0),
})
hero.flipX = trueWith "fit" the picture stays where it was and is mirrored in place. A "tile" or "slice" image
flips as a whole, the tiles or the corners swapping sides along with it, so a nine-slice that is not
symmetric shows its other side.
Glows#
blendMode = "additive" adds the image's colour to what is behind it instead of covering it. Black
adds nothing, so a glow or a flash drawn on black needs no transparency at all, and stacking two
brightens. The default is "normal".
-- client
hud:add("ImageLabel", {
size = udim2.fromOffset(200, 200),
image = "res://ui/glow.png",
blendMode = "additive",
imageColor = color(1, 0.6, 0.2),
})Each additive image is drawn on its own, outside the batch the rest of the interface shares, so a few hundred of them cost more than a few hundred normal ones. Keep them for the glows.
Drawing#
images is a global that makes images a script can draw on. Each one is an EditableImage.
-- client
local canvas = images.create(128, 128, color(1, 1, 1))
canvas:drawCircle(64, 64, 20, color(0.9, 0.2, 0.2), { smooth = true })
hud:add("ImageLabel", { size = udim2.fromOffset(128, 128), image = canvas.uri })images.create(width, height, color?, transparency?)makes a blank image. Without a colour every pixel is fully see-through.images.fromPixels(width, height, pixels)makes one from a list of numbers, laid out likereadPixels.
canvas.width and canvas.height read its size.
Showing it#
canvas.uri is the image's string, such as "editable://3". Put it anywhere an image goes, from
the table above.
Assigning the image object itself, label.image = canvas, also works while the game runs. Strict
mode reports it as an error, because image is a string. Use .uri.
What you draw shows up the next time the image is drawn. The pixels go to the graphics card at most
once a frame, however many changes you make, so a thousand setPixel calls cost one upload.
Show images from a client script. An image lives in the memory of the side that made it. A
server Script can make, load and read images, but its uri means nothing on a player's
machine, so players do not see it.
Coordinates#
Coordinates are pixels. (0, 0) is the top left corner, x goes right and y goes down.
Pixel (x, y) is the square from x to x + 1 and from y to y + 1. The calls read that in two
ways:
drawRectangle,drawGradient,drawPolygon,drawImageanddrawImageTransformedtake edges. A rectangle at(0, 0)with size24, 24covers exactly the pixels 0 to 23.drawCircle,drawEllipseanddrawLinetake pixels. The point is the centre of that pixel, sodrawCircle(10, 10, ...)is centred in pixel 10, 10.
setPixel, getPixel, floodFill, crop and the calls that read and write pixels take whole
pixels. Fractions are dropped.
Drawing past the edge of the image is fine. The part outside is left out.
Transparency#
Transparency is 0 for solid and 1 for gone, as everywhere else in Moud. A colour's own alpha,
from color(r, g, b, a), counts too: the two multiply.
readPixels, writePixels and their buffer versions are the exception. They work with alpha:
1 for solid, or 255 in a buffer.
Limits#
An image is 1 to 1024 pixels on each side. create, fromPixels and resize error outside that.
A loaded image bigger than that is scaled down to fit.
Colours and pixels#
| Call | What it does |
|---|---|
canvas:setPixel(x, y, color, transparency?) |
sets one pixel exactly, transparency included |
canvas:getPixel(x, y) |
returns the pixel's Color and its transparency. Outside the image it is black and fully see-through |
canvas:fill(color, transparency?) |
sets every pixel exactly |
canvas:clear() |
makes every pixel fully see-through |
local c, transparency = canvas:getPixel(3, 4)setPixel and fill replace what was there. They do not mix with it.
Shapes#
canvas:drawRectangle(x, y, width, height, color, options?)
canvas:drawCircle(x, y, radius, color, options?)
canvas:drawEllipse(x, y, radiusX, radiusY, color, options?)
canvas:drawLine(x1, y1, x2, y2, color, options?)
canvas:drawPolygon(points, color, options?)Every shape takes the same options table, DrawOptions:
| Option | Default | |
|---|---|---|
transparency |
0 |
how see-through the paint is |
blend |
"over" |
how the paint mixes with what is there, see Blends |
smooth |
false |
soft edges instead of hard ones |
filled |
true |
false draws only the outline |
thickness |
1 |
the outline's width, or the line's width, in pixels. At least 1 |
cornerRadius |
0 |
rounds a rectangle's corners, up to half its shorter side |
canvas:drawRectangle(8, 8, 48, 32, color(0.2, 0.3, 0.8), { cornerRadius = 6, smooth = true })
canvas:drawRectangle(8, 8, 48, 32, color(1, 1, 1), { cornerRadius = 6, smooth = true, filled = false, thickness = 2 })
canvas:drawLine(0, 0, 63, 63, color(0, 0, 0), { thickness = 3, smooth = true })- An outline grows inwards from the shape's edge, so a 2 pixel outline on a rectangle stays inside the rectangle.
- A line is
thicknesswide in total, with round ends. cornerRadiusis only read bydrawRectangle.drawPolygonwithfilled = falsedraws the outline, closing the last point back to the first, withthickness.
Points. drawPolygon takes its corners as a list of Vector3s, where z is ignored, or as a flat
list of numbers, x then y:
canvas:drawPolygon({ 32, 4, 60, 60, 4, 60 }, color(1, 0.8, 0.2), { smooth = true })
canvas:drawPolygon({ vec3(32, 4, 0), vec3(60, 60, 0), vec3(4, 60, 0) }, color(1, 0.8, 0.2))The polygon needs at least three corners. Its sides may cross; a spot is inside when a line from it crosses the sides an odd number of times.
Hard and smooth edges. Without smooth, a pixel is painted when its centre is inside the shape,
and not at all otherwise. That is what pixel art wants. With smooth = true, pixels on the edge are
painted partly, by how much of them the shape covers. That is what a drawing at normal size wants.
Gradients#
canvas:drawGradient(x, y, width, height, from, to, options?)from is the colour at one side and to the colour at the other. GradientOptions:
| Option | Default | |
|---|---|---|
rotation |
0 |
the direction, in degrees. 0 runs left to right, 90 top to bottom |
fromTransparency |
0 |
transparency at the from side |
toTransparency |
0 |
transparency at the to side |
blend |
"over" |
see Blends |
canvas:drawGradient(0, 0, 128, 128, color(0.1, 0.1, 0.3), color(0.9, 0.5, 0.2), { rotation = 90 })A gradient fills a plain rectangle. It has no smooth edges or rounded corners.
Drawing one image onto another#
canvas:drawImage(other, x, y, options?)x and y are where the top left of other lands. ImageDrawOptions:
| Option | Default | |
|---|---|---|
sourceX, sourceY |
0 |
the top left of the part of other to copy |
sourceWidth, sourceHeight |
to the edge of other |
the size of that part |
width, height |
the part's size | the size it is drawn at; different sizes scale it |
transparency |
0 |
fades it |
blend |
"over" |
see Blends |
smooth |
false |
smooths it when it is scaled; without it, pixels stay square |
An image may be drawn onto itself.
Turning and scaling an image#
canvas:drawImageTransformed(other, x, y, options?)other is scaled and turned about its pivot, and the pivot lands on x, y. TransformOptions:
| Option | Default | |
|---|---|---|
scaleX, scaleY |
1 |
how many times wider and taller it is drawn. A negative scale mirrors it |
rotation |
0 |
how far it is turned, in degrees, clockwise |
pivotX, pivotY |
the middle | the point it turns about, in pixels of other |
sourceX, sourceY, sourceWidth, sourceHeight |
the whole of other |
the part of other to draw, as for drawImage |
transparency |
0 |
fades it |
blend |
"over" |
see Blends |
smooth |
false |
smooths it; without it, pixels stay square |
-- client
local needle = images.create(4, 40, color(0.9, 0.2, 0.2))
dial:drawImageTransformed(needle, 64, 64, { rotation = 45, pivotX = 2, pivotY = 40, smooth = true })The needle turns about the middle of its bottom edge, which sits on the centre of the dial.
- The pivot is counted from the top left of the part drawn, so with
sourceXset,pivotX = 0is the left edge of that part. - A scale of
0draws nothing. - An image may be drawn onto itself.
Painting onto a mesh#
canvas:drawImageProjected(mesh, other, projection, options?)drawImageProjected shines other onto an EditableMesh the way a
projector would, and paints what lands on each face into canvas, where the face's UVs point. Show
canvas on that mesh and the picture sits on its surface, bent around its shape. It is how a splat,
a sticker or a scorch mark goes onto a model.
Projection:
| Field | |
|---|---|
position |
where the projector stands |
direction |
which way it shines |
size |
x and y are the width and height of the picture, z how far it reaches from position |
up |
which way is up in the picture. The default is vec3(0, 1, 0) |
ProjectOptions:
| Option | Default | |
|---|---|---|
fadeAngle |
90 |
faces turned further than this from the projector, in degrees, get less paint, down to none at 90 |
transparency |
0 |
fades it |
blend |
"over" |
see Blends |
smooth |
false |
smooths other; without it, pixels stay square |
-- client
local skin = images.create(256, 256, color(1, 1, 1))
blob:add("MaterialOverride", { texture = skin.uri })
local splat = images.create(32, 32)
splat:drawCircle(15.5, 15.5, 15, color(0.2, 0.6, 1), { smooth = true })
local function paint(worldPoint: Vector3, worldNormal: Vector3)
local at = blob.cframe:pointToObjectSpace(worldPoint)
local facing = blob.cframe:vectorToObjectSpace(worldNormal)
skin:drawImageProjected(mesh, splat, {
position = at + facing * 0.5,
direction = -facing,
size = vec3(0.6, 0.6, 1),
}, { fadeAngle = 60, smooth = true })
endblob is a MeshPart showing mesh, and paint takes a point and a normal from a raycast.
position,directionandupare in the mesh's own frame, not the world's.- Only faces that face the projector are painted.
- A face behind another one is painted too. The projector does not know what is in front.
- Each pixel of
canvasis painted at most once a call, by the first face over it. - A face whose UVs fall outside
0to1is painted only where they are inside.
Flood fill#
local painted = canvas:floodFill(x, y, color, options?)Paints the pixel at x, y and every pixel joined to it that looks the same, like a paint bucket.
Pixels join up, down, left and right, not across corners. It returns how many pixels it painted.
FillOptions:
| Option | Default | |
|---|---|---|
tolerance |
0 |
from 0 to 1. How far a pixel's red, green, blue and alpha may each be from the first pixel's and still count as the same. 0 only takes exact matches |
transparency |
0 |
how see-through the paint is |
blend |
"over" |
see Blends |
Text#
-- client
local width, height = canvas:drawText("Score: 12", 4, 4, color(1, 1, 1), { shadow = true, size = 2 })drawText(text, x, y, color, options?) writes text in Minecraft's own font and returns the width
and height of what it drew, in pixels. TextOptions:
| Option | Default | |
|---|---|---|
size |
1 |
how many pixels one pixel of the font takes. Fractions work. Above 0 |
shadow |
false |
draws the text again first, one font pixel right and down, in a quarter of the colour, as Minecraft does |
align |
"left" |
"left", "center" or "right". Which part of each line x is |
wrap |
0 |
the widest a line may be, in pixels. 0 never wraps |
lineHeight |
0 |
pixels from the top of one line to the next. 0 is the font's, 9 font pixels |
transparency |
0 |
how see-through the text is |
blend |
"over" |
see Blends |
bold |
false |
draws each letter twice, one font pixel apart, and adds a pixel after each |
italic |
false |
leans each letter, as Minecraft does |
smooth |
false |
smooths the letters when size scales them; without it, pixels stay square |
xandyare where the text starts. With"center"each line is centred onx, and with"right"each line ends atx.yis the top of the tallest letter in the font, not the top of the letters you wrote. In Minecraft's font the accented capitals are tallest, so plain ASCII lands 3 font pixels lower, 3 pixels at size 1 and 6 at size 2."\n"starts a new line. Withwrap, a line breaks at its last space that fits, and the spaces at the break are dropped. A word wider thanwrapbreaks between two letters.- The colour multiplies the letter's own colour. The font's letters are white, so they come out in your colour.
- A character the font does not have draws as an empty box, 5 by 8 font pixels.
canvas:measureText(text, options?) and images.measureText(text, options?) return the same width
and height without drawing anything, so you can place text before you draw it:
-- client
local options = { size = 2, shadow = true }
local w, h = images.measureText("Game over", options)
canvas:drawText("Game over", (canvas.width - w) / 2, (canvas.height - h) / 2, color(1, 0.3, 0.3), options)- The width is the widest line, and counts the one pixel gap after the last letter.
- The height runs from
yto the lowest pixel a letter of the font can reach on the last line. At size 1 in Minecraft's font one line is 12 pixels tall, and each line after it adds 9. shadowaddssizeto both.italicis not counted, so leaning text reaches up to about 3 font pixels past the width.
The font is read from the game on the player's machine, with its resource packs, so a pack that changes the font changes the text. Letters come from the font's bitmap files. Characters Minecraft only has in its unifont fallback, such as most Chinese, Japanese and Korean, draw as the box.
Text needs Minecraft's font, which only a player's game has. Draw and measure text in a
LocalScript. A server Script can do it only when the server runs inside a player's game, as
in singleplayer. On a dedicated server it is an error.
Blends#
blend says how new paint mixes with the pixels already there.
| Blend | What happens |
|---|---|
"over" |
the paint goes on top, as a brush would. See-through paint lets what is under it show. The default |
"replace" |
the pixel becomes the paint, transparency included. Painting with transparency 1 cuts a hole |
"add" |
the paint's colour is added, which brightens. Good for glows |
"multiply" |
the colour is multiplied by the paint, which darkens. Transparency stays as it was |
"erase" |
the paint's colour is ignored and its strength wipes the pixel away. A solid paint makes it fully see-through |
On a smooth edge every blend is applied only as much as the shape covers the pixel.
Reading and writing pixels#
local pixels = canvas:readPixels(x?, y?, width?, height?)
canvas:writePixels(x, y, width, height, pixels)Pixels are a flat list of numbers from 0 to 1, four per pixel: red, green, blue, alpha. They go row
by row, left to right, starting at the top left. Pixel (px, py) of the area starts at index
(py * width + px) * 4 + 1.
readPixels()with no arguments reads the whole image. Pixels outside the image read as0, 0, 0, 0. It reads at most 1024 by 1024 pixels at once.writePixelsneeds exactlywidth * height * 4numbers. Numbers below 0 or above 1 are clamped. Pixels outside the image are skipped. It replaces the pixels; it does not blend.
One writePixels call is much faster than many setPixel calls. Keep the list and fill it again
each frame, as the plasma example does.
Pixels in a buffer#
local bytes = canvas:readPixelsBuffer(x?, y?, width?, height?)
canvas:writePixelsBuffer(x, y, width, height, bytes)These read and write the same pixels as readPixels and writePixels, in a Luau buffer instead
of a table: four bytes a pixel, red, green, blue and alpha, each from 0 to 255. Pixel (px, py) of
the area starts at byte (py * width + px) * 4.
local bytes = canvas:readPixelsBuffer()
for at = 0, buffer.len(bytes) - 4, 4 do
local red = buffer.readu8(bytes, at)
buffer.writeu8(bytes, at, 255 - red)
end
canvas:writePixelsBuffer(0, 0, canvas.width, canvas.height, bytes)That turns the red of every pixel around.
- The rules are those of
readPixelsandwritePixels: at most 1024 by 1024 pixels read at once, pixels outside the image read as0and are skipped when written, and writing replaces the pixels without blending. writePixelsBufferneeds exactlywidth * height * 4bytes.
Changing an image#
| Call | What it does |
|---|---|
canvas:resize(width, height, smooth?) |
scales the image to a new size. smooth smooths it, otherwise pixels stay square |
canvas:crop(x, y, width, height) |
returns a new image cut out of this one. Parts outside this image come out see-through |
canvas:copy() |
returns a new image with the same pixels |
canvas:flip("horizontal") |
mirrors it left to right. "vertical" mirrors it top to bottom |
canvas:rotate(quarterTurns) |
turns it clockwise by quarter turns. Negative turns go the other way. An odd number swaps width and height |
resize, flip and rotate change the image in place, so everything showing it changes too. crop
and copy leave it alone.
Destroying#
canvas:destroy() throws the pixels away. Anything showing it stops showing it, and every other call
on it errors with "this image was destroyed". width, height and uri can still be read.
You rarely need to. Every image a script made is thrown away when its scripts stop, which includes every reload.
Images made by crop and copy are thrown away with the others.
Loading#
images.load and images.skin return an EditableImage like any other, ready to draw on or read.
Both wait for the image, so call them inside task.spawn or a function that may yield. If the image
cannot be read they raise an error, which pcall catches.
-- client
task.spawn(function()
local logo = images.load("res://ui/logo.png")
logo:drawRectangle(0, 0, logo.width, logo.height, color(1, 1, 1), { filled = false, thickness = 2 })
badge.image = logo.uri
end)images.load(source) reads:
| Source | Where it works |
|---|---|
"res://ui/logo.png" |
a file in the place. Scripts and client scripts |
"https://example.com/cat.png" |
a web address. Scripts and client scripts, with httpRequests on |
"minecraft:block/stone", "item/diamond" |
a Minecraft texture. Client scripts only |
- Files may be PNG, JPEG, WebP, GIF or BMP. An animated GIF or WebP gives its first frame.
- An image bigger than 1024 pixels on a side is scaled down, keeping its shape, until it fits.
- A web address needs
httpRequests = trueunder[features]inplace.toml, as for http. The switch is read on the machine the script runs on. The download stops after 20 seconds, or if the file is bigger than 16 MB, or if the address answers with anything but success. - A Minecraft texture is looked up under
textures/, and.pngis added. Both may be written out:"minecraft:textures/block/stone.png"works too. A namespace left out isminecraft.
Skins#
images.skin(player, part?) reads a player's skin. It works in client scripts only.
-- client
task.spawn(function()
local ok, head = pcall(function() return images.skin(game.players:me(), "head") end)
if ok then face.image = head.uri end
end)playeris aPlayer, or a body that belongs to one.partis"full"for the whole skin sheet, which is the default, or"head"for an 8 by 8 face with the hat layer drawn over it.- A skin is downloaded after the player joins.
skinwaits up to 2 seconds for it. A player whose skin has still not arrived gets the default skin. A player who is not in the game after 2 seconds is an error.
Show a head with resampleMode = "pixelated", or its 8 pixels are smoothed into a blur.
Examples#
These come from the image showcase place. They all run in client/main.luau, and screen is a
ScreenGui made at the top of it.
A paint canvas, mirrored in the world#
The canvas is one image. The button on the screen and the decal on a part show the same image, so what you paint on the screen appears on the easel in the world at the same moment.
local PAINT = 256
local canvas = images.create(PAINT, PAINT, color(1, 1, 1))
local board = screen:add("ImageButton", {
position = udim2.fromOffset(20, 36), size = udim2.fromOffset(PAINT, PAINT),
image = canvas.uri, autoButtonColor = false, backgroundTransparency = 1,
}) :: ImageButton
local easel = game.world:waitForChild("Easel") :: Part
easel:add("Decal", { texture = canvas.uri, face = "back" })
local brush = color(0.1, 0.1, 0.1)
local drawing = false
local lastX, lastY = 0, 0
local function toCanvas(x: number, y: number): (number, number)
local at, size = board.absolutePosition, board.absoluteSize
return (x - at.x) / size.x * PAINT, (y - at.y) / size.y * PAINT
end
board.mouseButton1Down:connect(function(x, y)
drawing = true
lastX, lastY = toCanvas(x, y)
canvas:drawCircle(lastX, lastY, 3, brush, { smooth = true })
end)
board.mouseMoved:connect(function(x, y)
if not drawing then return end
local cx, cy = toCanvas(x, y)
canvas:drawLine(lastX, lastY, cx, cy, brush, { thickness = 6, smooth = true })
lastX, lastY = cx, cy
end)
board.mouseButton1Up:connect(function() drawing = false end)
board.mouseLeave:connect(function() drawing = false end)
-- right click fills like a paint bucket
board.mouseButton2Click:connect(function(x, y)
local cx, cy = toCanvas(x, y)
canvas:floodFill(math.floor(cx), math.floor(cy), brush, { tolerance = 0.1 })
end)toCanvas turns a mouse position on the screen into a pixel of the canvas. Joining each mouse
position to the last one with a thick line keeps a fast stroke from breaking into dots.
A plasma animation#
A 48 by 48 image computed in Luau every frame and written in one call. The list of pixels is made once and reused.
local SIZE = 48
local plasma = images.create(SIZE, SIZE)
screen:add("ImageLabel", {
size = udim2.fromOffset(144, 144),
image = plasma.uri, resampleMode = "pixelated",
})
local pixels: { number } = table.create(SIZE * SIZE * 4, 1)
local clock = 0
game.renderStepped:connect(function(dt: number)
clock += dt
local at = 1
for y = 0, SIZE - 1 do
for x = 0, SIZE - 1 do
local v = math.sin(x / 5 + clock) + math.sin(y / 7 - clock * 1.3)
+ math.sin((x + y) / 9 + clock * 0.7)
local t = v / 3 * math.pi
pixels[at] = 0.5 + 0.5 * math.sin(t)
pixels[at + 1] = 0.5 + 0.5 * math.sin(t + 2.1)
pixels[at + 2] = 0.5 + 0.5 * math.sin(t + 4.2)
pixels[at + 3] = 1
at += 4
end
end
plasma:writePixels(0, 0, SIZE, SIZE, pixels)
end)Your own face#
local face = screen:add("ImageLabel", {
size = udim2.fromOffset(80, 80),
resampleMode = "pixelated",
}) :: ImageLabel
task.spawn(function()
local me = game.players:me()
if me == nil then return end
local ok, head = pcall(function() return images.skin(me, "head") end)
if ok then face.image = head.uri end
end)The label starts empty and gets its image once the skin has been read.
A frame drawn in code, stretched with nine-slice#
A 24 by 24 image is enough for a panel of any size. The 8 pixel corners hold the rounding, and nine-slice stretches only the middle.
local frameArt = images.create(24, 24)
frameArt:drawRectangle(0, 0, 24, 24, color(0.08, 0.09, 0.12), { cornerRadius = 8, smooth = true, transparency = 0.1 })
frameArt:drawRectangle(0, 0, 24, 24, color(0.45, 0.55, 0.95), { cornerRadius = 8, smooth = true, filled = false, thickness = 2 })
local panel = screen:add("ImageLabel", {
position = udim2.fromOffset(20, 20),
size = udim2.fromOffset(560, 340),
image = frameArt.uri,
scaleType = "slice",
sliceMin = vec3(8, 8, 0),
sliceMax = vec3(16, 16, 0),
})