Log inGet started

texture

Updated October 13, 2026

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)? Read core/resource-model first.

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_type link, and
  • a material that references a texture records a real .refs edge 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::Texture the 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" })
keyvaluesdefaultmeaning
textureTypecolor / normalMap / mask / spritecolor, or guessed from the namewhat 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.
colorSpacesrgb / linearfollows textureType: srgb for color and sprite, linear for normalMap and maskhow the stored values are read when sampled. srgb for albedo/emissive/base-color, UI and sprites; linear for normal/RM/AO/height/mask data.
compressionuastc / rgba8 / rgba16 / rgba32fa new texture: uastc, a sprite rgba8what the texels are stored as — see "What each compression stores" below.
effortfastest / fast / thoroughfastesthow long a uastc encode searches for each block — see "Block compression" below.
uastcRdonone / low / medium / highnonehow 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.
generateMipmapstrue / falsetruebake the full mip chain into data.ztex at encode time.
maxDimensioninteger, 1 to 8192a new texture: 4096the 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.
filterlinear / nearestlinearsampler 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.
wrapUrepeat / clamp / mirrorfollows textureType: clamp for sprite, else repeathow a material samples the texture past its left and right edges: tiled, the edge texel held, or mirrored. Recorded in the payload.
wrapVrepeat / clamp / mirrorfollows textureType: clamp for sprite, else repeatthe same, past its top and bottom edges.
anisotropyworld / 1 / 2 / 4 / 8 / 16worldsamples 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.
alphainput / premultiplied / noneinputwhat 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.
alphaIsTransparencytrue / falsefollows textureType: true for sprite, else falsefill 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.
keepCputrue / falsefalsekeep 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

compressionIn data.ztexOn the GPULimits
uastcUASTC 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.
rgba8From 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
rgba16Raw 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.
rgba32fRaw 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.

  • asset-type
  • reference