Texture
An image on a 3D surface: a GPU texture that a Material samples (map, uniforms),
decoded natively by the 3D engine. A 2D sprite's image is a Texture2D — the
two are not interchangeable; load the same file into both if a project uses it in both worlds.
- put an image on a 3D object
- load a normal map or mask without colour conversion
- use compressed KTX2 textures to save memory on mobile
- make a texture from generated pixels
- show a drawn canvas on a 3D surface
- stop a texture from repeating at the edges
- set texture quality: anisotropic filtering and a size cap
- play a video on a 3D screen
Platforms: iOS, Android, desktop, the web, the web player, the le.codes viewer app. Android (and
its viewer app) and the web store a PNG/JPG as colour whatever srgb says — a KTX2's own header
decides there; the web has no Texture.fromCanvas and ignores anisotropy / maxSize; headless
tests decode nothing and play no video.
At a glance
const main = async () => {
const scene = new Scene()
scene.add(Light.sun())
scene.open()
const tex = await Texture.load(asset('./crate.jpg'))
const crate = Mesh.box({ material: Material.lit({ map: tex }) })
scene.add(crate)
}
main()Load an image as a texture: Texture.load {#loading}
Texture.load(source: string | FetchResponse | File, options?: {
srgb?: boolean // default true: COLOUR, stored sRGB; false = DATA (normal map, mask, heightmap), kept linear
fullSize?: boolean // ignore Texture.maxSize for this texture (a lookup table); default false
}): Promise<Texture>Accepts an asset/remote URL string, an already-fetched FetchResponse, or a File (e.g. from
openFilePicker). URL loads reject on HTTP errors (status ≥ 400); every load rejects on a decode
failure. PNG / JPG are decoded by the platform, with a mip chain on hosts that build one.
Normal maps, masks and heightmaps: srgb: false
Load a normal map, a mask or a heightmap with srgb: false: it is DATA, kept linear, not colour.
A flat normal read through sRGB bends by ~35°. The built-in lit / unlit sample only a colour
map, so a data map goes to a uniform of your own shader.
const scene = new Scene()
scene.add(Light.sun())
scene.open()
const main = async () => {
const brick = await Material.load(asset('./brick.mat')) // a shader with a normalMap sampler
brick.set('normalMap', await Texture.load(asset('./brick_n.png'), { srgb: false })) // data: kept linear
scene.add(Mesh.box({ material: brick }))
}
main()Android and the web (Web Lite) store a PNG/JPG as colour whatever srgb says; a KTX2
carries its colour space in its header, so a data map that must be linear everywhere is a KTX2.
Compressed textures for mobile: KTX2
KTX2 (Basis Universal, detected by content, not extension) is transcoded on load to the best
compressed format the GPU has (ASTC, BC7, ETC2 or BC3; uncompressed RGBA8 where none is available)
and kept compressed in VRAM, with the file's mip chain. Use KTX2 for large textures on mobile: a 2K
RGBA8 costs ~21 MB with mips, the same image compressed ~5 MB. lecodes assets doctor --ktx2
produces them; a KTX2's colour space (sRGB vs linear data map) comes from its own header, whatever
srgb says.
A texture from raw pixels: Texture.fromPixels
Texture.fromPixels(width, height, data: Uint8Array, options?: {
channels?: 1 | 2 | 3 | 4 // bytes per pixel; default 4
srgb?: boolean // default true (colour); false = data
}): Texture
tex.update(x, y, width, height, data: Uint8Array): void // re-upload a rectangle (same channel count)A texture from raw bytes — row-major, width × height × channels, no mips, copied on the call.
fromPixels throws on a host without it, or when data is too short.
const scene = new Scene()
scene.add(Light.sun())
scene.open()
// an 8×8 checkerboard, one byte per pixel (grey)
const size = 8
const px = new Uint8Array(size * size)
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) px[y * size + x] = (x + y) % 2 ? 255 : 40
const checker = Texture.fromPixels(size, size, px, { channels: 1 })
scene.add(Mesh.box({ material: Material.unlit({ map: checker }) }))
px.fill(255, 0, size) // later: change the top row
checker.update(0, 0, size, 1, px.subarray(0, size))A drawn canvas on a 3D surface: Texture.fromCanvas
Texture.fromCanvas(canvas: Canvas): TextureBakes a Canvas surface into a texture — synchronous, no decode. The GPU
texture is cached on the canvas, so repeated calls (or several materials) share one, and
canvas.update() re-uploads it. You rarely call this yourself: assigning a Canvas to
material.map (or Material.lit({ map: canvas })) does it for you.
const scene = new Scene()
scene.add(Light.sun())
scene.open()
const label = new Canvas(256, 128)
label.fillStyle = '#1e293b'
label.fillRect(0, 0, 256, 128)
label.fillStyle = '#ffffff'
label.font = '48px sans-serif'
label.fillText('Score 0', 20, 80)
const sign = Mesh.box({ material: Material.unlit({ map: label }) }) // baked to a texture on assign
scene.add(sign)
// later: draw again, then
label.update() // re-uploads the texture the material samplesmaterial.map = canvas does the same in one step (the material bakes the canvas).
Size and wrap mode (tiling): width, wrapS / wrapT {#properties}
tex.width, tex.height // pixel dimensions (read-only)
tex.wrapS = 0 // horizontal (U) wrap: 0 clamp-to-edge, 1 repeat (default), 2 mirrored repeat
tex.wrapT = 0 // vertical (V) wrap, same valueswrapS / wrapT are applied when the texture is assigned to a material uniform — set them
before material.set('baseColorMap', tex) (or Material.lit({ map: tex })).
const scene = new Scene()
scene.add(Light.sun())
scene.open()
const main = async () => {
const tiles = await Texture.load(asset('./tiles.png'))
tiles.wrapS = 0 // clamp-to-edge horizontally
tiles.wrapT = 2 // mirrored repeat vertically
scene.add(Mesh.box({ material: Material.lit({ map: tiles }) })) // set the wrap BEFORE assigning
}
main()there is no dispose() on Texture. A texture is freed by itself once you drop every
reference to it AND no material samples it any more; while a material uses it, it stays. Load big
textures once and reuse them.
Texture quality settings: Texture.anisotropy / Texture.maxSize {#engine-wide-settings}
Texture.anisotropy = 4 // anisotropic filtering, 1 (off) … 16 (clamped); default 2
Texture.maxSize = 1024 // texture size cap in px, 0 = none (default)A "texture quality" menu. Both reach only textures bound or loaded after they are set — a loaded
level keeps its textures — so set them before the level loads (SceneOptions.anisotropy /
maxTextureSize do it up front, see Scene). Under maxSize a KTX2
loses its top mip levels on load and a glTF image is downsampled; fullSize exempts one texture. A
host may pin either (desktop CREATOR_TEXTURE_ANISOTROPY / CREATOR_TEXTURE_MAX_SIZE).
Play a video on a 3D surface: VideoPlayer.texture {#video-textures}
A texture can't be created from a video file directly — play it with a VideoPlayer and sample
its texture:
const player = new VideoPlayer(asset('./clip.mp4'))
const screen = Mesh.plane({ material: Material.video(player.texture) })player.texture reports width/height of 0 (the video's size isn't known at texture-creation
time). See Media and Material.video.
Pitfalls
// ✗ feeding a 3D Texture to a Sprite (or a Texture2D to a Material)
new Sprite({ texture: await Texture.load(url) }) // wrong engine — use Texture2D.load
// ✓ each engine loads its own type
new Sprite({ texture: await Texture2D.load(url) })
// ✗ a data map (normal, mask, heightmap) loaded as colour — read back bent through sRGB
material.set('normalMap', await Texture.load(asset('./brick_n.png')))
// ✓ material.set('normalMap', await Texture.load(asset('./brick_n.png'), { srgb: false }))