Skip to content

The asset import pipeline

Assets are processed into runtime-ready data by an import pipeline that is driven by the settings in each asset’s .meta file. The editor, tools and agents run the same code (crates/sparklight-assets, module pipeline) through three commands: assets.import, assets.reimport and assets.report (commands.md, schemas in schemas/commands/assets.*.json).

Related: commands.md (the command surface), rendering.md, animation3d.md. The README of the crate describes the asset database (GUIDs, .meta, atlas).

Terminal window
# import every texture and model of the assets folder; results are stored in a content-hash cache
spark call assets.import '{"all": true}' --json
# one asset, with settings written into its .meta first (textures: ASTC, no mips)
spark call assets.reimport '{"path":"textures/wall.png","settings":{"texture":{"format":"astc4x4","mipmaps":false}}}' --json
# what is imported, stale (source or settings changed since), new or invalid
spark call assets.report '{}' --json

The cache is asset-cache below the output folder (.gwdev/out, so an agent run never writes into the project) unless cache names another folder inside the project or the output folder. assets.import never writes .meta files; only assets.reimport with settings does (and only where the session may write: below the project or the output folder).

Command What it does
assets.import {path | paths | all, force?, root?, cache?} Imports; a cached result is returned without processing. One path that fails is the command’s error; paths and all list failures in failed and import the rest. force processes again and compares with the stored entry
assets.reimport {path, settings?, root?, cache?} Optionally writes settings into the .meta (validated first, other fields and the GUID stay), then processes the asset again. record.unchanged says whether the bytes equal the stored entry
assets.report {root?, cache?} Per asset: up_to_date, stale, not_imported, invalid (with the error), unsupported; the cache key, outputs and summary; totals and the cache size. Processes nothing

Errors: E_IMPORT_UNSUPPORTED (not a png, jpg, jpeg, glb, gltf or fbx), E_IMPORT_SETTINGS (the .meta settings are invalid: unknown field, bad value), E_IMPORT_FAILED (damaged source, unsupported feature), E_ASSET_IO (missing file, no assets folder), E_OUTPUT_REFUSED (a path outside the allowed folders).

{
"guid": "5f0d…",
"import": {
"texture": { "format": "bc7", "srgb": null, "mipmaps": true, "normal_map": false, "flip_green": false, "max_size": 8192, "quality": "normal" },
"model": { "lods": [0.5, 0.25, 0.125], "lod_error": 0.05, "lod_min_triangles": 64, "quantize": true, "optimize": true,
"overdraw_threshold": 1.05, "texture_format": "auto", "texture_quality": "normal", "texture_max_size": 4096, "prefab": true, "virtual_geometry": false }
}
}

Every field is optional (the values above are the defaults); an unknown field is an error. The JSON Schema is schemas/import-settings.schema.json. The file kind decides which object is used: texture for png and jpg, model for glb, gltf and fbx (the fbx object adds the settings only FBX needs).

Field Meaning
format auto (BC7), bc7, astc4x4, rgba8
srgb null: sRGB colour, except a normal map (linear). true / false forces it
mipmaps Full chain down to 1x1
normal_map Linear, mips are averaged vectors renormalised to unit length. srgb: true with it is E_IMPORT_SETTINGS
flip_green Invert green (DirectX-style normal maps); only for normal maps
max_size Halve until both sides are at most this (4 to 16384)
quality fast, normal, high: the number of endpoint refinement rounds of the block encoders (1, 3, 6)
Field Meaning
lods Triangle count of each extra LOD as a fraction of LOD 0 (decreasing, in (0, 1), at most 8). Empty: no LODs
lod_error The simplifier may not exceed this error, relative to the mesh extent (0.05 = 5 %); it stops short of the requested count to keep it
lod_min_triangles Meshes with at most this many triangles get no LODs
quantize Quantised vertex attributes (below); off: 32-bit floats
optimize Vertex cache and vertex fetch order
overdraw_threshold Overdraw reordering may cost up to this factor of cache efficiency (1.05 = 5 %); 0 turns the pass off
texture_format, texture_quality, texture_max_size Settings of the textures the materials use; the colour space comes from the role (base colour and emissive sRGB, normal map linear and renormalised, metallic-roughness and occlusion linear)
prefab Write model.prefab.json
virtual_geometry Also build the cluster DAG of every unskinned mesh: meshes/<n>.vgeo and MeshAsset.vgeo (see virtual-geometry.md); skinned meshes get a warning

A texture import writes texture.ktx2 (KTX 2.0): a 2D texture, no supercompression, a basic data format descriptor, KTXorientation = rd and KTXwriter = sparklight-assets import v<pipeline version> (no timestamps, so the file is the same bytes every time). Levels are stored smallest first, each aligned to 16 bytes (4 for RGBA8).

Format vkFormat Block
bc7_srgb / bc7_unorm 146 / 145 4x4 texels, 16 bytes
astc4x4_srgb / astc4x4_unorm 158 / 157 4x4 texels, 16 bytes
rgba8_srgb / rgba8_unorm 43 / 37 4 bytes per texel

Processing: the image is decoded to RGBA8, halved to max_size, and converted to linear light for sRGB data. Each mip level is the average of a 2x2 footprint (clamped at the border for odd sizes); colour is weighted by alpha, so transparent texels do not darken their neighbours. The conversion between sRGB codes and linear values uses a fixed table (no powf), so results do not depend on the platform’s maths library.

The encoders are written in Rust for this engine (no native dependency) and are deterministic:

  • BC7: every block uses mode 6 (one subset, RGBA 7-bit endpoints with a p-bit each, 16 index levels). The endpoints start on the principal axis of the block’s colours, the indices minimise squared error, and the endpoints are refitted by least squares for a few rounds. Opaque blocks keep alpha exactly 255.
  • ASTC 4x4 LDR: one partition, colour endpoint mode 12 (RGBA direct), a 4x4 grid of 3-bit weights (8 levels), endpoints in the 192-level trit encoding. Flat blocks become exact void-extent blocks.

Both are verified in tests/pipeline_textures.rs against independent decoders (texture2ddecoder for BC7 and ASTC, astc-decode for ASTC) and the KTX2 files are parsed back with the ktx2 crate. Measured (cargo run --release -p sparklight-assets --example pipeline_bench: a 1024x1024 picture with ring pattern, gradients and noise of +-32): BC7 49.6 dB, ASTC 45.6 dB PSNR on RGB (fast, normal and high differ by less than 0.2 dB there; high costs more time). The files are 1 398 592 bytes against 5 592 916 for RGBA8 with mips (4.0x smaller). Timings were taken on a machine under heavy load and are not quoted.

The block encoders work on all cores and join the blocks in order: the output does not depend on the number of threads.

A model import (glb or gltf, buffers and images may be external files) writes into one cache entry:

File Content
model.json The model asset (schema): nodes, meshes with primitives, materials, textures, skins, animations, one binary buffer
model.bin Every binary array the JSON points at (vertex and index data, inverse bind matrices, animation keys), 4-byte aligned
textures/<n>.ktx2 The images the materials use, one per image and colour-space role
meshes/<n>.vgeo The virtual geometry of mesh n (only with virtual_geometry)
model.prefab.json The scene as prefab objects (schema), unless prefab is off

Per primitive: degenerate triangles are dropped, identical vertices are merged, indices are optimised for the post-transform vertex cache, then for overdraw (overdraw_threshold), and the vertices are reordered for fetch. LODs come from edge-collapse simplification (the meshoptimizer algorithm, with normals and UVs as attributes) with an error limit. Measured on a shuffled 200 000 triangle sphere: the cache miss ratio (ACMR, FIFO 16) goes from 3.00 to 0.66; the LODs have 50 %, 25 % and 12.5 % of the triangles with errors of 0.01 %, 0.02 % and 0.03 % of the extent.

  • LOD 0 is the full mesh. All LODs index one vertex buffer; the indices of all LODs are in one index buffer, one range per LOD (index_offset, index_count). u16 indices unless the primitive has more than 65 535 vertices.
  • Each LOD has error, the geometric error against LOD 0 as a length in model units, and error_relative (a fraction of the mesh extent). A renderer picks the coarsest LOD whose error projected to the screen stays under its pixel threshold.
  • bounds (box, centre, radius) are computed from the source positions of LOD 0; the model has the union.
  • A level that is hardly smaller than the previous one (more than 95 % of its triangles) ends the LOD chain.

Vertex layout (layout.attributes, interleaved, stride a multiple of 4), with quantize:

Attribute Format Decode
POSITION unorm16x3 min + u / 65535 * (max - min) with bounds; error at most extent / 131 070 per axis
NORMAL oct16x2 octahedral unit vector, two snorm16
TANGENT oct8x4 octahedral xyz in two snorm8, handedness in the third byte (-127 or +127)
TEXCOORD_0/1 unorm16x2 within uv_ranges[set] ([min_u, min_v, max_u, max_v])
COLOR_0 unorm8x4
JOINTS_0 uint8x4 (or uint16x4 above 255 joints) exact joint indices into the skin
WEIGHTS_0 unorm8x4 normalised to sum to exactly 255

Without quantize: float32x3, float32x3, float32x4, float32x2, float32x4 and float32x4 for weights. Measured: the quantised vertex data of the sphere is 50 % of the float data. pipeline::mesh::decode turns a primitive back into arrays (tests, tools and the reference for a renderer’s vertex fetch).

  • skins: the joint node indices in JOINTS_0 order, the skeleton node and the inverse bind matrices (column-major f32, at inverse_bind_offset in model.bin). Vertex joints and weights are kept (weights normalised to sum to 1 first; four influences, JOINTS_1 is reported as a warning).
  • animations: every channel of the file with node, path (translation, rotation, scale, weights), interpolation (step, linear, cubic_spline), key times and values (three values per key for cubic_spline, as in glTF).
  • The prefab document lists the nodes as objects (root first, parents before children; several scene roots get a synthetic root named after the scene), each with its transform, mesh and skin references; skins[].joints are object indices; animations are names.

Not imported, and reported in warnings of the record: morph target geometry, cameras, lights, glTF extensions the file declares (a required extension, such as Draco compression, fails the import), points and lines, a second joint set.

<cache>/objects/<key[0..2]>/<key>/manifest.json the outputs with sizes and SHA-256, the summary, warnings
/<output files>
<cache>/assets/<guid>.json the entry an asset was last imported to
  • The key is the SHA-256 of the pipeline version, the kind, the settings of that kind (as JSON), the source bytes, and for a gltf the bytes of every external file it reads (buffers, images). Equal key, equal bytes: an existing entry is never rewritten.
  • An entry is written to a temporary folder and renamed into place; a folder without a whole manifest is replaced.
  • The pipeline version (PIPELINE_VERSION) is part of every key and of KTXwriter: a change that alters any output bumps it.
  • assets.report compares the current key with the cache: up_to_date (entry exists), stale (the asset was imported before, the key differs now), not_imported.

Determinism. The same input bytes and settings give the same output bytes: in another folder, in another process, from several threads (tests the_same_input_bytes_give_the_same_output_bytes_in_any_place_any_time and the texture and mesh determinism tests). Nothing depends on hash-map order, the clock, the path of the asset or the number of threads. meshoptimizer (the C++ library behind the meshopt crate, vendored and built by cc like the Lua of the script crate) is deterministic for the same input on one platform;

Renderer hooks (not yet implemented in sparklight-3d)

Section titled “Renderer hooks (not yet implemented in sparklight-3d)”

The pipeline’s outputs need these in the renderer; sparklight-3d was not touched:

  1. KTX2 upload: read texture.ktx2 (levels, vkFormat), create a wgpu::Texture with Bc7RgbaUnorm[Srgb] or Astc { block: B4x4, channel: Unorm } (feature TEXTURE_COMPRESSION_BC / TEXTURE_COMPRESSION_ASTC) or Rgba8Unorm[Srgb], upload each level from the file bytes,, and fall back to decoding to RGBA8 on the CPU where the adapter lacks the feature (BC is common on desktop GPUs, ASTC on mobile ones; ask the adapter).
  2. Model asset loader: read model.json + model.bin, create vertex/index buffers per primitive with the layout (the decode of the quantised formats belongs in the vertex shader: unorm16x3 with the bounds, oct16x2, unorm16x2 with the UV range), and the materials with the KTX2 textures.
  3. LOD selection: per draw, choose the LOD from lods[i].error projected with the camera (error / distance * screen_height / (2 tan(fov/2)) against a pixel threshold, with hysteresis), draw index_count indices at index_offset.
  4. Prefab instantiation: map model.prefab.json onto Prefab3D objects (Transform3D, mesh renderer, Skeleton with the skin joints as bones, an Animator with the clips of animations).
  • BC7 mode 6 and ASTC with a single partition give lower quality than an exhaustive encoder (every mode, partitions); they are a good, fast, reproducible default. Other ASTC block sizes (6x6, 8x8) are not written yet.
  • Textures are not resized to powers of two; sizes that are not multiples of 4 are padded with the edge texels inside the last block.
  • The model buffers are not compressed (no meshopt compression of the index/vertex streams).
  • FBX: see the FBX section.
  • Morph targets and sparse accessors beyond what the gltf reader resolves are not imported.

.fbx files (binary or ASCII) import as models. The parser is ufbx through the official ufbx crate (licence MIT OR Unlicense; the C library it compiles is MIT or public domain, both permissive; the repository has no cargo deny configuration yet). pipeline::fbx reads the file into a scene converted to the pipeline’s space (right-handed, +Y up, +Z front, one unit = one metre), writes it as an in-memory GLB and hands that to the glTF code path, so LODs, quantisation, textures, the prefab and the cache work as for a .glb.

{ "guid": "...", "import": {
"model": { "lods": [0.5, 0.25], "quantize": true },
"fbx": { "scale": 1.0, "up_axis": "auto", "skins": true, "animations": true, "animation_fps": 30 } } }
Setting Meaning
fbx.scale Extra uniform scale on top of the file’s unit (the unit converts to metres by itself). 0 to 1,000,000
fbx.up_axis auto trusts the header; y or z says what the geometry really is when an exporter wrote the wrong header
fbx.skins Joints, weights (the four strongest per vertex, normalised) and inverse bind matrices. Skinned meshes are written in world space at the bind pose with an identity node, as glTF means it
fbx.animations Every animation stack, baked to translation / rotation / scale keys (linear), with key reduction
fbx.animation_fps Bake rate, 1 to 240

Textures are the PNG or JPEG files the FBX names, found next to it (the stored relative name, the name as written, or the bare file name, also in textures/; absolute paths and .. are never followed) or embedded in the FBX. They are part of the cache key. A texture that is missing, not PNG/JPEG, or procedural is a warning. Cameras, lights, blend shapes and separate metalness / roughness maps are not imported (warnings). Blender’s FBX exporter writes no metalness, so Blender models import with metallic 0. Fixtures are made by Tools/blender/fbx/make_fixtures.py (Blender headless); tests: crates/sparklight-assets/tests/pipeline_fbx.rs.