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

Emberware Asset Pipeline

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


Quick Start

Getting assets into an Emberware 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:

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

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
ember-export build assets.toml

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

# Convert individual files
ember-export mesh player.gltf -o player.ewzmesh
ember-export texture grass.png -o grass.ewztex
ember-export audio jump.wav -o jump.ewzsnd

Output Files

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

  • player.ewzmesh, enemy.ewzmesh, level.ewzmesh
  • player_diffuse.ewztex
  • jump.ewzsnd

Output File Formats

EmberZMesh (.ewzmesh)

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.

EmberZTexture (.ewztex)

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 .ewztex files, regenerate them with:
    ember-export texture <source.png> -o <output.ewztex>
    
  • Symptom of old format: “invalid dimensions” error during load

EmberZSound (.ewzsnd)

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 Emberware runtime supports 16 vertex format combinations, controlled by format flags.

#![allow(unused)]
fn main() {
FORMAT_UV      = 1   // Texture coordinates
FORMAT_COLOR   = 2   // Per-vertex color
FORMAT_NORMAL  = 4   // Surface normals
FORMAT_SKINNED = 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

ember-export

The asset conversion CLI tool.

Build from manifest:

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

Convert individual files:

# Meshes
ember-export mesh player.gltf -o player.ewzmesh
ember-export mesh level.obj -o level.ewzmesh --format POS_UV_NORMAL

# Textures
ember-export texture grass.png -o grass.ewztex

# Audio
ember-export audio jump.wav -o jump.ewzsnd

Loading Assets (FFI)

The simplest way to load assets is using the EmberZ 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.ewzmesh");
static GRASS_TEX: &[u8] = include_bytes!("assets/grass.ewztex");
static JUMP_SFX: &[u8] = include_bytes!("assets/jump.ewzsnd");

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 EmberZ format and provide raw data directly:

Convenience API (f32 input, auto-packed):

#![allow(unused)]
fn main() {
load_mesh(data_ptr, vertex_count, format) -> u32
load_mesh_indexed(data_ptr, vertex_count, index_ptr, index_count, format) -> u32
}

Power User API (pre-packed data):

#![allow(unused)]
fn main() {
load_mesh_packed(data_ptr, vertex_count, format) -> u32
load_mesh_indexed_packed(data_ptr, vertex_count, index_ptr, index_count, format) -> u32
}

Constraints

Emberware enforces these limits:

ResourceLimit
ROM size12 MB
VRAM4 MB
Bones per skeleton256

All assets are embedded in the WASM binary at compile time. There is no runtime 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);
    }
}
}

ember.toml vs include_bytes!()

There are two ways to include assets in your game:

Method 1: ember.toml + ROM Packing

Best for: Production games with many assets

# ember.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 .ewzx 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.ewztex");

// 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 assetsember.toml + ROM
Games with large texturesember.toml (compression)

Planned Features

The following features are planned but not yet implemented:

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