texture
New to how a texture exists as an asset, as CPU-side pixels, and as a live GPU texture (and how
renderer.texture.*and render targets move between them)? Readcore/resource-modelfirst.
texture is a first-class assetType (not a bare plural-dir category) so
that:
- a texture has a stable asset identity and an explicit
asset_typelink, and - a material that references a texture records a real
.refsedge to it — which is what lets the CPU-residency system cascade from a material to the textures it actually uses.
CPU residency vs GPU residency
A texture can be resident two fundamentally different ways, and they must not be conflated:
- CPU residency (this assetType): the raw image bytes in memory — readable, hashable, decodable. You can operate on the bytes.
- GPU residency: those bytes uploaded to the device as a
wgpu::Texturethe renderer binds/samples. Byte-level reads are not possible on the GPU variant — that is simply how the device works. GPU residency is managed separately (TextureRefCounts→drop_texture) and is not part of this assetType.
Same texture identity, two separate residency states, two separate
operation sets, two separate reference counts. See
docs/specs/runtime-asset-copying.md.
Internal layout
A .texture/ is permissive (allow_unlisted: true). asset.create("texture", name, ...) and the texture importer (which creates through it when a loose
raster image lands in /source/) produce this shape:
<name>.texture/
source.<ext> the retained source raster — see below
source.ztex the full-resolution texels a cook dropped some of — see below
settings.yaml the texture's settings — see below
data.ztex canonical pre-encoded + pre-mipped payload (ZTEX magic)
.metadata what data.ztex holds and was encoded from
asset.create keeps a copy of the image bytes it was given as source.<ext>;
the importer moves the uploaded image in under its own name instead
(foo.png stays foo.png). Either way it is the texture's retained source:
rewriting it re-encodes data.ztex from the new pixels, and a rewrite carrying
the bytes data.ztex was already encoded from changes nothing.
data.ztex is the preferred primary: when present the renderer resolves
and uploads it verbatim — no re-mip. When absent the primary falls through to
a raster image in the folder, so a container holding only an image file reads
that image.
(data.tex is the pre-rename legacy payload name — still resolved for
existing assets; new content writes data.ztex.)
That fallthrough takes the lowest-named raster image in the folder and never
the generated preview.png thumbnail, which is rendered from the payload. A
container that lost its encoded payload and holds no other image has no
primary: asset.diagnose(ref) reports noPrimaryFile, asset.validate(ref)
reports texture.no_payload, and renderer.texture.loadCpu refuses it.
asset.primaryFile(ref).path names the file the resolution landed on; the
asset-system guide covers diagnose.
settings.yaml settings
A texture's settings are declared in settings.luau and live in its
settings.yaml. asset.create stamps them when the texture is made, and
texRef:setSettings({ ... }) (or asset.setSettings(ref, patch)) changes
them. Editing an encode-relevant one re-runs the encode against the retained
source and rewrites data.ztex. A texture whose settings were stored under an
earlier schema reads with the same meaning, and its first local edit rewrites
them into settings.yaml under the fields below.
local tex = asset.create("texture", "hero_albedo", {
bytes = png, ext = "png",
settings = { maxDimension = 1024 },
})
tex:setSettings({ uastcRdo = "medium" })
| key | values | default | meaning |
|---|---|---|---|
textureType | color / normalMap / mask / sprite | color, or guessed from the name | what the texture holds, recorded in the payload. normalMap: tangent-space normals — when generateMipmaps builds the chain, every level of it, the base included, is renormalised to unit length; with generateMipmaps = false the one stored level is the source as given, and the chain the GPU builds from it at upload is a plain box filter. mask: linear data (roughness, metallic, occlusion, height, masks). sprite: a 2D image with transparency. A new texture's type is guessed from its name — _normal / _nrm / _n / … → normalMap, _rough / _metal / _ao / _height / _mask / … → mask — unless it is given. |
colorSpace | srgb / linear | follows textureType: srgb for color and sprite, linear for normalMap and mask | how the stored values are read when sampled. srgb for albedo/emissive/base-color, UI and sprites; linear for normal/RM/AO/height/mask data. |
compression | uastc / rgba8 / rgba16 / rgba32f | a new texture: uastc, a sprite rgba8 | what the texels are stored as — see "What each compression stores" below. |
effort | fastest / fast / thorough | fastest | how long a uastc encode searches for each block — see "Block compression" below. |
uastcRdo | none / low / medium / high | none | how far a uastc encode moves from the source to make a payload that compresses smaller on disk and over the network — see "Block compression" below. |
generateMipmaps | true / false | true | bake the full mip chain into data.ztex at encode time. |
maxDimension | integer, 1 to 8192 | a new texture: 4096 | the longest side the encoded texture may have; a larger source is downsampled to fit, and a uastc texture's longest side is the largest power of two within it. A value past 8192 is refused. |
filter | linear / nearest | linear | sampler filtering recorded in the payload. A material slot and a UI image widget sample the texture with it. A post-process effect's texture property, a render pass's @texture input and a compute shader's texture_2d binding sample through a sampler of their own (the effect's postprocess.setSampler, the pass's, the compute shader's named sampler binding), which this setting does not change. |
wrapU | repeat / clamp / mirror | follows textureType: clamp for sprite, else repeat | how a material samples the texture past its left and right edges: tiled, the edge texel held, or mirrored. Recorded in the payload. |
wrapV | repeat / clamp / mirror | follows textureType: clamp for sprite, else repeat | the same, past its top and bottom edges. |
anisotropy | world / 1 / 2 / 4 / 8 / 16 | world | samples taken along a surface seen at a grazing angle. world follows the render.anisotropy world setting (renderer.setAnisotropy); a number fixes it for this texture, clamped to what the device supports. A nearest texture samples with 1. A write may give the number itself (anisotropy = 8); it is stored as the string. Recorded in the payload. |
alpha | input / premultiplied / none | input | what the encode does to alpha. input: kept as the source has it (straight alpha). premultiplied: colour multiplied by alpha — decoded to linear light, multiplied and re-encoded for an srgb texture, multiplied as stored for a linear one — the source a material with the premultiplied blend mode (src + dst * (1 - src.a)) composites. none: every texel opaque, colour kept. |
alphaIsTransparency | true / false | follows textureType: true for sprite, else false | fill every fully transparent texel with the average colour of its non-transparent neighbours, spreading outward, so bilinear filtering and mip averaging never pull black into the edge of a transparent area. Alpha stays 0. Runs before alpha and before the mips. |
keepCpu | true / false | false | keep the decoded pixels in the CPU store after the GPU upload — see below. |
The type's colour space, alphaIsTransparency, wrapU and wrapV are
stamped when the texture is created, and a later write that changes
textureType moves them with it — unless the same write names them. A texture whose settings predate
textureType reads as color, with alpha = input and no transparency fill,
which is what its payload holds.
A cook that drops texels — a maxDimension smaller than the source, or a
uastc cook resampling a side to a power of two — keeps the full-resolution
input as source.ztex (one RGBA8 level) when the container holds no source
image, so raising maxDimension later re-encodes from every texel. A cook
that drops nothing keeps nothing extra. A texture made from raw pixels whose
cook dropped nothing keeps its payload as its only source, so a settings
change re-encodes from that payload read back with straight alpha: what an
earlier encode discarded stays discarded, after alpha = none its alpha is
opaque, and a uastc payload re-encoded is the blocks' texels, not the
pixels first given.
A new texture's settings start from the texture type's own defaults —
uastc with a maxDimension of 4096; rgba8 for a sprite; a 16-bit PNG
or .hdr source left to the schema's rgba8 unless a layer names a
compression — and a world's Preset Manager rows
for texture override them (a world that ships at 2048, say), then an
importer's settings, then the settings the create names.
filter, wrapU, wrapV and anisotropy say how the texture is sampled,
not what its texels are. A write that changes only those rewrites the
payload's header (renderer.texture.withSampler): every level keeps its
bytes, and no preview is rendered, because no pixel changed. Every material
slot samples through its own texture's state, so two textures on one material
can wrap and filter differently. UI image widgets honour filter and draw each
image over its own bounds, so the wraps apply to materials. A post-process
effect, a render pass @texture input and a compute shader sample through
samplers of their own, so none of the four settings reaches them. A texture whose
payload predates wrap and anisotropy reads repeat / repeat / world,
which is how it has always been sampled.
keepCpu is a lifecycle setting, not an encode input — it never re-runs the
encode. It acts on an already-materialised texture right away instead: true
loads the decoded pixels into the CPU store when the texture is GPU-resident,
false drops a resident CPU copy.
The data.ztex payload format is zero_texture::blob (ZTEX magic +
32-byte header + mip levels); the CPU encoder is exposed to Luau as
renderer.texture.* and consumed by the texture importer and this assetType.
What each compression stores
compression | In data.ztex | On the GPU | Limits |
|---|---|---|---|
uastc | UASTC 4x4 blocks, whole mip chain, one byte per texel, each level compressed for storage. | Transcoded at upload to a block format the adapter samples: Bc7RgbaUnorm[Srgb] where it samples BC textures (desktop native, desktop WebGPU), ASTC 4x4 where it samples ASTC and not BC (phones, a browser on one), ETC2 RGBA8 where it samples only ETC2, each one byte per texel; RGBA8 at four bytes per texel on an adapter that samples none of them, and for a texture with a side under four texels. | each side is resampled to the nearest power of two (halfway rounding up), nearest-neighbour for a nearest texture. |
rgba8 | From an image with generateMipmaps = false, a maxDimension the image fits within, alpha = input and alphaIsTransparency = false: the source PNG / JPEG / WebP itself, decoded at load. Otherwise raw 8-bit RGBA with the encoder's box-filtered mip chain. | Rgba8Unorm / Rgba8UnormSrgb, four bytes per texel. | none |
rgba16 | Raw 16-bit unsigned normalized, one level. An image is decoded at its own depth: a 16-bit PNG keeps every bit, an 8-bit channel v is stored as v * 257. The frames of an animated image are decoded at 8 bits. | Rgba16Unorm where the adapter grants 16-bit normalized textures (native); half float (Rgba16Float, about 11 significant bits) where it does not (every browser). | one level, no maxDimension, linear only. |
rgba32f | Raw 32-bit float, one level. An image channel is stored as its value over the channel's maximum. | Rgba32Float. Where the GPU filters 32-bit float, every consumer samples it. Where it does not (every browser): a material slot draws the shader's declared default; a UI image draws nothing; a render pass naming it as an @texture input is skipped; a compute shader that declares no sampler binds it and reads it with textureLoad, and one that declares a sampler refuses the dispatch — each logging once, naming the texture and its format. CPU reads (:load(), renderer.texture.decode) return every bit on every device. | one level, no maxDimension, linear only. |
A high-precision compression turns generateMipmaps off, maxDimension to
8192 and the colour space to linear when the same write does not name them,
and refuses a write that names a contradicting value.
A payload of BC7 blocks, written before uastc existed, stays as it is:
Bc7RgbaUnorm[Srgb] where the adapter samples BC textures, re-encoded as it
loads into ASTC 4x4 or ETC2 where the adapter samples those instead, and
decoded to RGBA8 where it samples none. Its settings read
compression = uastc, what an encode of it now asks for, while
texRef:info() reports the bc7 payload it holds.
Block compression
compression = "uastc" stores every mip level as UASTC blocks — sixteen bytes
per 4x4 texels — a format no GPU samples directly but every one can be handed:
at upload each level is transcoded to a block format the adapter samples, BC7
on a desktop GPU and ASTC 4x4 on a phone's (ETC2 on one that samples ETC2
alone), so one payload is resident at one byte per texel on each, and RGBA8 on
an adapter that samples none; renderer.textureMemory().blockFormat names the
format this adapter holds it in. Only the levels a device keeps are
transcoded: under a texture-size cap (renderer.setMaxTextureSize) a texture
is uploaded from the first level of its chain within the cap. Compression is
lossy, which makes it the choice for colour, normal and detail maps; rgba8
holds a texture exactly, which is what pixel art and a mask whose exact codes
matter need.
effort is how long the encode searches for each block. fastest tries one
candidate for an opaque block, fast takes about 6 times as long and
thorough about 17 times, each landing closer to the source texels. What the
GPU holds is the same size at every effort, and so is the time a load takes.
uastcRdo trades accuracy for a payload that compresses smaller: none
encodes each block as close to the source as the encoder finds, and low,
medium and high let a block move further from the source when a nearby
block's bytes would let the payload compress better. What the GPU holds is the
same size either way; what moves is the size of data.ztex on disk and over
the network, and the encode time, which a pass more than doubles.
The encode runs on the CPU when the texture is created or a setting that decides its texels changes. A call from a task that can yield cooks off the frame and the task waits for it, so the world keeps running: a 1024x1024 texture with its mip chain takes about a second on a desktop CPU.
A texture whose settings predate compression (stored before it was a
setting) is healed by its first local edit: a nearest-filtered one becomes a
sprite in rgba8, an rgba16 or rgba32f one keeps its format, and every
other one becomes uastc and is re-encoded; an older "no cap" becomes a
maxDimension of 8192. Loading one changes nothing, and an edit arriving from
another session or a texture installed from a library is never healed. A
texture whose settings say rgba8 is never compressed.
Reading an asset from its ref
texRef:settings() reads the container's settings with every schema default
filled in and texRef:setSettings({ maxDimension = 1024 }) patches them;
texRef:load() decodes the payload's pixels into the CPU store for per-pixel
access, texRef:handle() puts the texture on the GPU under the asset's own
guid, and texRef:preview() renders it as a swatch.
texRef:info() reads the compiled data.ztex payload's header: the format it
was compiled to — also as its compression and colorSpace — the
textureType, alpha and alphaIsTransparency it was encoded under, its width and height, its mip and array-layer counts, the
sampler recorded in it (filter, wrapU, wrapV, anisotropy), and, for an animated sequence, each layer's
display time and the length of one pass:
local info = texRef:info()
print(info.width, info.height, info.format, info.mipCount)
It answers off that payload's own header, so a compiled texture reports how big
it is for a header read where load() decodes its pixels and handle() uploads
them. A container whose primary is a source image answers (nil, err), and
load() reads its size. The compression and colorSpace it reports are what
the payload holds; the settings table holds what the encode was asked for.
In the Inspector
Selecting a texture opens its Texture section right below the asset's
identity, then its Settings. The section draws the encoded texture through the
texture's own sampler, so a filter = "nearest" texture reads crisp at every
zoom. The zoom row offers Fit and each integer zoom (1×, 2×, 4×, 8×) whose
drawn size fits 512 px; a 128 px texture offers up to 4×, and one wider than
512 px offers Fit alone. Below the view, the section lists every field of the
data.ztex header (texRef:info()) — size, format, mip levels, filter, layers,
and a sequence's frames and duration — and the GPU memory the encoded mip chain
takes across its layers. The filter and the other encode settings change in
the Settings section; the Texture section reports the re-encoded result.