Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Nethercore Asset Pipeline

Convert 3D models, textures, and audio into optimized Nethercore formats.


Quick Start

Getting assets into a Nethercore game is 3 steps:

1. Export from your 3D tool (Blender, Maya, etc.) as glTF, GLB, or OBJ

2. Create assets.toml:

[output]
dir = "assets/"

[meshes]
player = "models/player.gltf"
enemy = "models/enemy.glb"

[textures]
grass = "textures/grass.png"

[sounds]
jump = "audio/jump.wav"

3. Build and use:

nether-export build assets.toml
#![allow(unused)]
fn main() {
static PLAYER_MESH: &[u8] = include_bytes!("assets/player.nczxmesh");
static GRASS_TEX: &[u8] = include_bytes!("assets/grass.nczxtex");

fn init() {
    let player = load_zmesh(PLAYER_MESH.as_ptr() as u32, PLAYER_MESH.len() as u32);
    let grass = load_ztex(GRASS_TEX.as_ptr() as u32, GRASS_TEX.len() as u32);
}
}

One manifest, one command, simple FFI calls.


Supported Input Formats

3D Models

FormatExtensionStatus
glTF 2.0.gltf, .glbImplemented
OBJ.objImplemented

Recommendation: Use glTF for new projects. It’s the “JPEG of 3D” - efficient, well-documented, and supported everywhere.

Textures

FormatStatus
PNGImplemented
JPGImplemented

Audio

FormatStatus
WAVImplemented

Fonts

FormatStatus
TTFPlanned

Manifest-Based Asset Pipeline

Define all your game assets in a single assets.toml file, then build everything with one command.

assets.toml Reference

# Output configuration
[output]
dir = "assets/"                  # Output directory for converted files
# rust = "src/assets.rs"         # Planned: Generated Rust module

# 3D Models
[meshes]
player = "models/player.gltf"                           # Simple: just the path
enemy = "models/enemy.glb"
level = { path = "models/level.obj", format = "POS_UV_NORMAL" }  # With options

# Textures
[textures]
player_diffuse = "textures/player.png"

# Audio
[sounds]
jump = "audio/jump.wav"

# Fonts (planned)
# [fonts]
# ui = { path = "fonts/roboto.ttf", size = 16 }

Build Commands

# Build all assets from manifest
nether-export build assets.toml

# Validate manifest without building
nether-export check assets.toml

# Convert individual files
nether-export mesh player.gltf -o player.nczxmesh
nether-export texture grass.png -o grass.nczxtex
nether-export audio jump.wav -o jump.nczxsnd

Output Files

Running nether-export build assets.toml generates binary asset files:

  • player.nczxmesh, enemy.nczxmesh, level.nczxmesh
  • player_diffuse.nczxtex
  • jump.nczxsnd

Output File Formats

NetherZXMesh (.nczxmesh)

Binary format for 3D meshes with GPU-optimized packed vertex data. POD format (no magic bytes).

Header (12 bytes):

Offset | Type | Description
-------|------|----------------------------------
0x00   | u32  | Vertex count
0x04   | u32  | Index count
0x08   | u8   | Vertex format flags (0-15)
0x09   | u8   | Reserved (padding)
0x0A   | u16  | Reserved (padding)
0x0C   | data | Vertex data (vertex_count * stride bytes)
0x??   | u16[]| Index data (index_count * 2 bytes)

Stride is calculated from the format flags at runtime.

NetherZTexture (.nczxtex)

Binary format for textures. POD format (no magic bytes).

Current Header (4 bytes):

Offset | Type | Description
-------|------|----------------------------------
0x00   | u16  | Width in pixels (max 65535)
0x02   | u16  | Height in pixels (max 65535)
0x04   | u8[] | Pixel data (RGBA8, width * height * 4 bytes)

⚠️ Format Change (Dec 12, 2024):

  • Old format (before commit 3ed67ef): 8-byte header with u32 width + u32 height
  • Current format: 4-byte header with u16 width + u16 height
  • If you have old .nczxtex files, regenerate them with:
    nether-export texture <source.png> -o <output.nczxtex>
    
  • Symptom of old format: “invalid dimensions” error during load

NetherZSound (.nczxsnd)

Binary format for audio. POD format (no magic bytes).

Header (4 bytes):

Offset | Type  | Description
-------|-------|----------------------------------
0x00   | u32   | Sample count
0x04   | i16[] | PCM samples (22050Hz mono)

Vertex Formats

The Nethercore runtime supports 16 vertex format combinations, controlled by format flags.

#![allow(unused)]
fn main() {
const FORMAT_UV: u8 = 1;      // Texture coordinates
const FORMAT_COLOR: u8 = 2;   // Per-vertex color
const FORMAT_NORMAL: u8 = 4;  // Surface normals
const FORMAT_SKINNED: u8 = 8; // Bone weights for skeletal animation
}

All 16 Formats

FormatNamePacked Stride
0POS8 bytes
1POS_UV12 bytes
2POS_COLOR12 bytes
3POS_UV_COLOR16 bytes
4POS_NORMAL12 bytes
5POS_UV_NORMAL16 bytes
6POS_COLOR_NORMAL16 bytes
7POS_UV_COLOR_NORMAL20 bytes
8POS_SKINNED16 bytes
9POS_UV_SKINNED20 bytes
10POS_COLOR_SKINNED20 bytes
11POS_UV_COLOR_SKINNED24 bytes
12POS_NORMAL_SKINNED20 bytes
13POS_UV_NORMAL_SKINNED24 bytes
14POS_COLOR_NORMAL_SKINNED24 bytes
15POS_UV_COLOR_NORMAL_SKINNED28 bytes

Common formats:

  • Format 5 (POS_UV_NORMAL): Most common for textured, lit meshes
  • Format 13 (POS_UV_NORMAL_SKINNED): Animated characters

Packed Vertex Data

The runtime automatically packs vertex data using GPU-optimized formats for smaller memory footprint and faster uploads.

Attribute Encoding

AttributePacked FormatSizeNotes
PositionFloat16x48 bytesx, y, z, w=1.0
UVUnorm16x24 bytes65536 values in [0,1], better precision than f16
ColorUnorm8x44 bytesRGBA, alpha=255 if not provided
NormalOctahedral u324 bytes~0.02° angular precision
Bone IndicesUint8x44 bytesUp to 256 bones
Bone WeightsUnorm8x44 bytesNormalized to [0,255]

Octahedral Normal Encoding

Normals use octahedral encoding for uniform angular precision with 66% size reduction:

Standard normal: 3 floats × 4 bytes = 12 bytes
Octahedral:      1 u32              =  4 bytes

How it works:

  1. Project 3D unit vector onto octahedron surface
  2. Unfold octahedron to 2D square [-1, 1]²
  3. Pack as 2× snorm16 into single u32

Precision: ~0.02° worst-case angular error - uniform across the entire sphere.

The vertex shader decodes the normal automatically.

Memory Savings

FormatUnpackedPackedSavings
POS_UV_NORMAL32 bytes16 bytes50%
POS_UV_NORMAL_SKINNED52 bytes24 bytes54%
Full format (15)64 bytes28 bytes56%

Skeletal Animation

Vertex Skinning Data

Each skinned vertex stores:

  • Bone Indices: Uint8x4 (4 bytes) - which bones affect this vertex (0-255)
  • Bone Weights: Unorm8x4 (4 bytes) - influence weights, normalized

Bone Matrices

Bone transforms use 3×4 affine matrices (not 4×4):

set_bones(matrices_ptr, count)

3×4 Matrix Layout (column-major, 12 floats per bone):

[m00, m10, m20]  ← column 0 (X basis)
[m01, m11, m21]  ← column 1 (Y basis)
[m02, m12, m22]  ← column 2 (Z basis)
[m03, m13, m23]  ← column 3 (translation)

The bottom row [0, 0, 0, 1] is implicit (affine transform).

Limits:

  • Maximum 256 bones per skeleton
  • 48 bytes per bone (vs 64 bytes for 4×4) - 25% memory savings

Tool Reference

nether-export

The asset conversion CLI tool.

Build from manifest:

nether-export build assets.toml           # Build all assets
nether-export check assets.toml           # Validate only

Convert individual files:

# Meshes
nether-export mesh player.gltf -o player.nczxmesh
nether-export mesh level.obj -o level.nczxmesh --format POS_UV_NORMAL

# Textures
nether-export texture grass.png -o grass.nczxtex

# Audio
nether-export audio jump.wav -o jump.nczxsnd

Loading Assets (FFI)

The simplest way to load assets is using the NetherZ format functions. These parse the POD headers host-side and upload to GPU.

#![allow(unused)]
fn main() {
// FFI declarations
extern "C" {
    fn load_zmesh(data_ptr: u32, data_len: u32) -> u32;
    fn load_ztex(data_ptr: u32, data_len: u32) -> u32;
    fn load_zsound(data_ptr: u32, data_len: u32) -> u32;
}

// Embed assets at compile time
static PLAYER_MESH: &[u8] = include_bytes!("assets/player.nczxmesh");
static GRASS_TEX: &[u8] = include_bytes!("assets/grass.nczxtex");
static JUMP_SFX: &[u8] = include_bytes!("assets/jump.nczxsnd");

fn init() {
    let player = load_zmesh(PLAYER_MESH.as_ptr() as u32, PLAYER_MESH.len() as u32);
    let grass = load_ztex(GRASS_TEX.as_ptr() as u32, GRASS_TEX.len() as u32);
    let jump = load_zsound(JUMP_SFX.as_ptr() as u32, JUMP_SFX.len() as u32);
}
}

Raw Data Loading (Advanced)

For fine-grained control, you can bypass the NetherZ format and provide raw data directly:

Convenience API (f32 input, auto-packed):

#![allow(unused)]
fn main() {
extern "C" {
    fn load_mesh(data_ptr: u32, vertex_count: u32, format: u8) -> u32;
    fn load_mesh_indexed(data_ptr: u32, vertex_count: u32, index_ptr: u32, index_count: u32, format: u8) -> u32;
}
}

Power User API (pre-packed data):

#![allow(unused)]
fn main() {
extern "C" {
    fn load_mesh_packed(data_ptr: u32, vertex_count: u32, format: u8) -> u32;
    fn load_mesh_indexed_packed(data_ptr: u32, vertex_count: u32, index_ptr: u32, index_count: u32, format: u8) -> u32;
}
}

Constraints

Nethercore enforces these limits:

ResourceLimit
ROM size16 MB
VRAM4 MB
Bones per skeleton256

All assets are packaged into the ROM at build time. There is no runtime filesystem/network asset loading — this ensures deterministic builds required for rollback netcode.


Starter Assets

Don’t have assets yet? Here are some ready-to-use procedural assets you can copy directly into your game.

Procedural Textures

Checkerboard (8x8)

#![allow(unused)]
fn main() {
const CHECKERBOARD: [u8; 256] = {
    let mut pixels = [0u8; 256];
    let white = [0xFF, 0xFF, 0xFF, 0xFF];
    let gray = [0x88, 0x88, 0x88, 0xFF];
    let mut y = 0;
    while y < 8 {
        let mut x = 0;
        while x < 8 {
            let idx = (y * 8 + x) * 4;
            let color = if (x + y) % 2 == 0 { white } else { gray };
            pixels[idx] = color[0];
            pixels[idx + 1] = color[1];
            pixels[idx + 2] = color[2];
            pixels[idx + 3] = color[3];
            x += 1;
        }
        y += 1;
    }
    pixels
};
}

Player Sprite (8x8)

#![allow(unused)]
fn main() {
const PLAYER_SPRITE: [u8; 256] = {
    let mut pixels = [0u8; 256];
    let white = [0xFF, 0xFF, 0xFF, 0xFF];
    let trans = [0x00, 0x00, 0x00, 0x00];
    let pattern: [[u8; 8]; 8] = [
        [0, 0, 1, 1, 1, 1, 0, 0],
        [0, 1, 1, 1, 1, 1, 1, 0],
        [0, 1, 1, 1, 1, 1, 1, 0],
        [0, 0, 1, 1, 1, 1, 0, 0],
        [0, 1, 1, 1, 1, 1, 1, 0],
        [1, 1, 1, 1, 1, 1, 1, 1],
        [0, 0, 1, 0, 0, 1, 0, 0],
        [0, 0, 1, 0, 0, 1, 0, 0],
    ];
    let mut y = 0;
    while y < 8 {
        let mut x = 0;
        while x < 8 {
            let idx = (y * 8 + x) * 4;
            let color = if pattern[y][x] == 1 { white } else { trans };
            pixels[idx] = color[0];
            pixels[idx + 1] = color[1];
            pixels[idx + 2] = color[2];
            pixels[idx + 3] = color[3];
            x += 1;
        }
        y += 1;
    }
    pixels
};
}

Coin/Collectible (8x8)

#![allow(unused)]
fn main() {
const COIN_SPRITE: [u8; 256] = {
    let mut pixels = [0u8; 256];
    let gold = [0xFF, 0xD7, 0x00, 0xFF];
    let shine = [0xFF, 0xFF, 0x88, 0xFF];
    let trans = [0x00, 0x00, 0x00, 0x00];
    let pattern: [[u8; 8]; 8] = [
        [0, 0, 1, 1, 1, 1, 0, 0],
        [0, 1, 2, 2, 1, 1, 1, 0],
        [1, 2, 2, 1, 1, 1, 1, 1],
        [1, 2, 1, 1, 1, 1, 1, 1],
        [1, 1, 1, 1, 1, 1, 1, 1],
        [1, 1, 1, 1, 1, 1, 1, 1],
        [0, 1, 1, 1, 1, 1, 1, 0],
        [0, 0, 1, 1, 1, 1, 0, 0],
    ];
    let mut y = 0;
    while y < 8 {
        let mut x = 0;
        while x < 8 {
            let idx = (y * 8 + x) * 4;
            let color = match pattern[y][x] {
                0 => trans, 1 => gold, _ => shine,
            };
            pixels[idx] = color[0];
            pixels[idx + 1] = color[1];
            pixels[idx + 2] = color[2];
            pixels[idx + 3] = color[3];
            x += 1;
        }
        y += 1;
    }
    pixels
};
}

Procedural Sounds

Beep (short hit sound)

#![allow(unused)]
fn main() {
fn generate_beep() -> [i16; 2205] {
    let mut samples = [0i16; 2205]; // 0.1 sec @ 22050 Hz
    for i in 0..2205 {
        let t = i as f32 / 22050.0;
        let envelope = 1.0 - (i as f32 / 2205.0);
        let value = libm::sinf(2.0 * core::f32::consts::PI * 440.0 * t) * envelope;
        samples[i] = (value * 32767.0 * 0.3) as i16;
    }
    samples
}
}

Jump sound (ascending)

#![allow(unused)]
fn main() {
fn generate_jump() -> [i16; 4410] {
    let mut samples = [0i16; 4410]; // 0.2 sec
    for i in 0..4410 {
        let t = i as f32 / 22050.0;
        let progress = i as f32 / 4410.0;
        let freq = 200.0 + (400.0 * progress); // 200 → 600 Hz
        let envelope = 1.0 - progress;
        let value = libm::sinf(2.0 * core::f32::consts::PI * freq * t) * envelope;
        samples[i] = (value * 32767.0 * 0.3) as i16;
    }
    samples
}
}

Coin collect (sparkle)

#![allow(unused)]
fn main() {
fn generate_collect() -> [i16; 6615] {
    let mut samples = [0i16; 6615]; // 0.3 sec
    for i in 0..6615 {
        let t = i as f32 / 22050.0;
        let progress = i as f32 / 6615.0;
        // Two frequencies for shimmer effect
        let f1 = 880.0;
        let f2 = 1320.0; // Perfect fifth
        let envelope = 1.0 - progress;
        let v1 = libm::sinf(2.0 * core::f32::consts::PI * f1 * t);
        let v2 = libm::sinf(2.0 * core::f32::consts::PI * f2 * t);
        let value = (v1 + v2 * 0.5) * envelope;
        samples[i] = (value * 32767.0 * 0.2) as i16;
    }
    samples
}
}

Using Starter Assets

Load procedural assets in your init():

#![allow(unused)]
fn main() {
static mut PLAYER_TEX: u32 = 0;
static mut JUMP_SFX: u32 = 0;

#[no_mangle]
pub extern "C" fn init() {
    unsafe {
        // Load texture
        PLAYER_TEX = load_texture(8, 8, PLAYER_SPRITE.as_ptr());
        texture_filter(0); // Nearest-neighbor for crisp pixels

        // Load sound
        let jump = generate_jump();
        JUMP_SFX = load_sound(jump.as_ptr(), (jump.len() * 2) as u32);
    }
}
}

nether.toml vs include_bytes!()

There are two ways to include assets in your game:

Method 1: nether.toml + ROM Packing

Best for: Production games with many assets

# nether.toml
[game]
id = "my-game"
title = "My Game"

[[assets.textures]]
id = "player"
path = "assets/player.png"

[[assets.sounds]]
id = "jump"
path = "assets/jump.wav"

Load with ROM functions:

#![allow(unused)]
fn main() {
let player_tex = rom_texture(b"player".as_ptr(), 6);
let jump_sfx = rom_sound(b"jump".as_ptr(), 4);
}

Benefits:

  • Assets are pre-processed and compressed
  • Single .nczx ROM file
  • Automatic GPU format conversion

Method 2: include_bytes!() + Procedural

Best for: Small games, prototyping, tutorials

#![allow(unused)]
fn main() {
// Compile-time embedding
static TEXTURE_DATA: &[u8] = include_bytes!("../assets/player.nczxtex");

// Or generate at runtime
const PIXELS: [u8; 256] = generate_pixels();
}

Benefits:

  • Simple, no build step
  • Good for procedural content
  • Self-contained WASM file

Which Should I Use?

ScenarioRecommendation
Learning/prototypinginclude_bytes!() or procedural
Simple arcade gamesEither works
Complex games with many assetsnether.toml + ROM
Games with large texturesnether.toml (compression)

Planned Features

The following features are planned but not yet implemented:

  • Font conversion - TTF/OTF to bitmap font atlas (.nczxfont)
  • Watch mode - nether-export build --watch for auto-rebuild on changes
  • Rust code generation - Auto-generated asset loading module