Log inGet started

texture

Updated 5 September 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 (TextureRefCountsdrop_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) — the conversion step decides its shape. Two shapes are produced today:

Plain image container

<name>.texture/
  <name>.png          the primary image (or .jpg / .webp / .hdr / …)

Produced by asset.create("texture", name, { bytes, ext }) and the svg→texture importer. The renderer decodes the image and generates mips on upload.

Managed (compressed) container

Produced by the texture importer when a loose raster image lands in /source/:

<name>.texture/
  source.<ext>   the original raster, MOVED in (never lost)
  meta.yaml      compression settings — see below
  data.tex       canonical pre-encoded + pre-mipped payload (ZTEX magic)
  README.md

data.tex is the preferred primary: when present the renderer resolves and uploads it verbatim — no decode, no re-mip. When absent the primary falls through to the raster image, so plain containers keep working.

That fallthrough reaches every *.png in the folder, the generated preview.png thumbnail included, so a container that lost its encoded payload loads the thumbnail at the thumbnail's size. asset.primaryFile(ref).path names the file the resolution landed on, and asset.diagnose(ref) reports whether it can be used and why not — see the asset-system guide.

meta.yaml settings

Editing any of these re-runs compression against the sibling source.<ext> and rewrites data.tex:

keyvaluesmeaning
formatsrgb (default) / rgba8 / bc7 / bc7_srgb / rgba16 / rgba32foutput format. srgb for albedo/emissive/base-color; rgba8 (linear) for normal/RM/AO/height; bc7 / bc7_srgb for block compression — sixteen bytes per 4x4 texels, so the texture costs a quarter of RGBA8 on disk and in VRAM, at a small loss; rgba16 / rgba32f for data rasters 8 bits per channel quantizes visibly.
generateMipmapstrue (default) / falsebake the full mip chain into data.tex at import time.
srgbtrue / falseoptional override applied when format is omitted or non-srgb.
maxDimensioninteger, 0 = no capdownsample so neither axis exceeds this.

The data.tex payload format lives in crates/zero_texture/src/blob.rs (ZTEX magic + 32-byte header + mip levels); the CPU encoder is exposed to Luau as __texture.* and consumed by the texture importer and this assetType.

Block compression

format = "bc7" (or "bc7_srgb") compresses every mip level to BC7 blocks — sixteen bytes per 4x4 texels. The blocks are what reaches the GPU, so the texture is resident at one byte per texel instead of four; an adapter without BC7 support uploads the payload decoded to RGBA8 instead, so the texture is correct everywhere and compressed where the hardware allows it. Compression is lossy and runs on the CPU at import time, which makes it the choice for colour and detail maps rather than for a mask whose exact codes matter.

Each 4x4 block is encoded under whichever BC7 mode reconstructs it closest: one endpoint pair across the whole block, or two endpoint pairs over one of the format's 64 fixed splits of the block's texels, which is what carries a block whose colours vary along two directions at once. Searching those splits is what the import spends its time on — roughly a second per megatexel on one core, so a 4096x4096 import is a wait rather than an instant.

  • asset-type
  • reference