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
| Format | Extension | Status |
|---|---|---|
| glTF 2.0 | .gltf, .glb | Implemented |
| OBJ | .obj | Implemented |
Recommendation: Use glTF for new projects. It’s the “JPEG of 3D” - efficient, well-documented, and supported everywhere.
Textures
| Format | Status |
|---|---|
| PNG | Implemented |
| JPG | Implemented |
Audio
| Format | Status |
|---|---|
| WAV | Implemented |
Fonts
| Format | Status |
|---|---|
| TTF | Planned |
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.ewzmeshplayer_diffuse.ewztexjump.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
.ewztexfiles, 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
| Format | Name | Packed Stride |
|---|---|---|
| 0 | POS | 8 bytes |
| 1 | POS_UV | 12 bytes |
| 2 | POS_COLOR | 12 bytes |
| 3 | POS_UV_COLOR | 16 bytes |
| 4 | POS_NORMAL | 12 bytes |
| 5 | POS_UV_NORMAL | 16 bytes |
| 6 | POS_COLOR_NORMAL | 16 bytes |
| 7 | POS_UV_COLOR_NORMAL | 20 bytes |
| 8 | POS_SKINNED | 16 bytes |
| 9 | POS_UV_SKINNED | 20 bytes |
| 10 | POS_COLOR_SKINNED | 20 bytes |
| 11 | POS_UV_COLOR_SKINNED | 24 bytes |
| 12 | POS_NORMAL_SKINNED | 20 bytes |
| 13 | POS_UV_NORMAL_SKINNED | 24 bytes |
| 14 | POS_COLOR_NORMAL_SKINNED | 24 bytes |
| 15 | POS_UV_COLOR_NORMAL_SKINNED | 28 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
| Attribute | Packed Format | Size | Notes |
|---|---|---|---|
| Position | Float16x4 | 8 bytes | x, y, z, w=1.0 |
| UV | Unorm16x2 | 4 bytes | 65536 values in [0,1], better precision than f16 |
| Color | Unorm8x4 | 4 bytes | RGBA, alpha=255 if not provided |
| Normal | Octahedral u32 | 4 bytes | ~0.02° angular precision |
| Bone Indices | Uint8x4 | 4 bytes | Up to 256 bones |
| Bone Weights | Unorm8x4 | 4 bytes | Normalized 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:
- Project 3D unit vector onto octahedron surface
- Unfold octahedron to 2D square [-1, 1]²
- 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
| Format | Unpacked | Packed | Savings |
|---|---|---|---|
| POS_UV_NORMAL | 32 bytes | 16 bytes | 50% |
| POS_UV_NORMAL_SKINNED | 52 bytes | 24 bytes | 54% |
| Full format (15) | 64 bytes | 28 bytes | 56% |
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)
EmberZ Format Loading (Recommended)
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:
| Resource | Limit |
|---|---|
| ROM size | 12 MB |
| VRAM | 4 MB |
| Bones per skeleton | 256 |
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
.ewzxROM 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?
| Scenario | Recommendation |
|---|---|
| Learning/prototyping | include_bytes!() or procedural |
| Simple arcade games | Either works |
| Complex games with many assets | ember.toml + ROM |
| Games with large textures | ember.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 --watchfor auto-rebuild on changes - Rust code generation - Auto-generated asset loading module