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) — 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:
| key | values | meaning |
|---|---|---|
format | srgb (default) / rgba8 / bc7 / bc7_srgb / rgba16 / rgba32f | output 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. |
generateMipmaps | true (default) / false | bake the full mip chain into data.tex at import time. |
srgb | true / false | optional override applied when format is omitted or non-srgb. |
maxDimension | integer, 0 = no cap | downsample 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.