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

Introduction

View the Gallery - See live demos of Nightshade's features running in your browser.

Nightshade is a modern game engine written in Rust, designed for creating 3D games and interactive applications. It provides a complete toolkit for game development including rendering, physics, audio, animation, and more.

What is Nightshade?

Nightshade is a batteries-included game engine that handles the complexity of modern 3D graphics while remaining approachable for developers of all skill levels. Whether you're building a simple visualizer, a physics playground, or a complete 3D game, Nightshade provides the foundation you need.

The engine is built on top of industry-standard libraries:

  • wgpu for cross-platform GPU access (Vulkan, Metal, DirectX 12, WebGPU)
  • Rapier3D for physics simulation
  • Kira for audio playback and spatial sound
  • egui for immediate-mode UI
  • glTF for 3D model loading

Key Features

Rendering

  • Physically Based Rendering (PBR) - Metallic-roughness workflow with support for all standard texture maps
  • Dynamic Lighting - Directional, point, and spot lights with real-time shadows
  • Post-Processing - Bloom, SSAO, depth of field, tonemapping, and custom effects
  • Skeletal Animation - Smooth blending and crossfading between animations
  • Particle Systems - GPU-accelerated particles with configurable emitters
  • Terrain - Procedural generation with tessellation and LOD
  • Grass - Thousands of interactive grass blades with wind simulation

Physics

  • Rigid Body Dynamics - Dynamic, kinematic, and static bodies
  • Collision Shapes - Box, sphere, capsule, cylinder, convex hull, trimesh, heightfield
  • Character Controllers - Built-in player movement with slopes, steps, and jumping
  • Physics Joints - Fixed, revolute, prismatic, spherical, rope, and spring joints
  • Raycasting - Query the physics world for line-of-sight and hit detection

Audio

  • Sound Playback - WAV, OGG, MP3, FLAC support
  • Spatial Audio - 3D positioned sound sources with distance attenuation
  • FFT Analysis - Real-time spectral analysis for music visualizers

Input

  • Keyboard & Mouse - Full key detection with press/release states
  • Gamepad - Controller support with analog sticks, triggers, and rumble
  • Cursor Control - Lock and hide cursor for FPS-style games

Tools

  • Navigation Mesh - AI pathfinding with Recast integration
  • Debug Rendering - Lines, boxes, spheres for visualization
  • HUD Text - Screen-space text rendering with anchoring
  • Screenshot Capture - Save frames to PNG
  • Developer Console - In-game command console with custom commands

Platform

  • OpenXR VR - Virtual reality with head/hand tracking and locomotion
  • Steam - Achievements, stats, multiplayer, and friends integration
  • Webview - Host web frontends (Leptos, Yew) with bidirectional IPC
  • Mosaic - Multi-pane desktop application framework with dockable widgets
  • WASM Plugins - Extend the engine with WebAssembly plugins

Architecture Overview

Nightshade follows a simple architecture centered around the State trait and the World container:

┌─────────────────────────────────────────────────────────────┐
│                      Your Game (State)                       │
├─────────────────────────────────────────────────────────────┤
│  initialize()  │  run_systems()  │  ui()  │  input handlers │
└────────────────┴─────────────────┴────────┴─────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                          World                               │
├──────────────────┬──────────────────┬───────────────────────┤
│     Entities     │    Components    │      Resources        │
│  (unique IDs)    │  (data arrays)   │  (global singletons)  │
└──────────────────┴──────────────────┴───────────────────────┘
                              │
        ┌─────────────────────┼─────────────────────┐
        ▼                     ▼                     ▼
┌───────────────┐    ┌───────────────┐    ┌───────────────┐
│   Renderer    │    │    Physics    │    │     Audio     │
│    (wgpu)     │    │   (Rapier)    │    │    (Kira)     │
└───────────────┘    └───────────────┘    └───────────────┘

The Game Loop

Each frame, Nightshade:

  1. Processes window and input events
  2. Calls your run_systems() method
  3. Updates physics simulation
  4. Propagates transform hierarchies
  5. Renders the scene
  6. Presents to the screen

You control game logic in run_systems(), and the engine handles everything else.

Design Philosophy

Nightshade follows these core principles:

Simplicity

The API surface is minimal and consistent. Common tasks like spawning entities, loading models, and handling input should be intuitive. If something feels overly complex, it's probably a bug.

Performance

The engine uses data-oriented design throughout. The ECS stores components in contiguous arrays for cache-friendly access. The renderer batches draw calls and uses GPU instancing where possible.

Flexibility

Feature flags let you include only what you need. Building a simple visualizer? Just use engine. Need physics? Add physics. Everything is opt-in.

Cross-Platform

Write once, run everywhere. The same code runs on Windows, macOS, Linux, and WebAssembly. The engine automatically selects the appropriate graphics backend.

When to Use Nightshade

Nightshade is well-suited for:

  • 3D Games - Action games, platformers, simulations
  • Visualizers - Music-reactive graphics, data visualization
  • Prototypes - Quickly test game ideas
  • Learning - Understanding game engine concepts

Nightshade may not be ideal for:

  • 2D-only games - Consider a dedicated 2D engine (though Nightshade does support sprites)
  • Mobile - Not yet optimized for mobile platforms

Version

This documentation covers Nightshade using Rust 2024 Edition.

Getting Help

Let's get started!

Interactive Demo

Experience Nightshade running directly in your browser via WebGPU.

Controls

  • Mouse Drag: Orbit the camera around the scene
  • Scroll Wheel: Zoom in and out

What You're Seeing

This demo showcases several Nightshade features:

  • Primitives: A cube, sphere, and torus rendered with PBR materials
  • Emissive Materials: Each object has emissive properties that create a glowing effect
  • Bloom Post-Processing: The glow spreads beyond object boundaries
  • Infinite Grid: A reference grid at ground level
  • Nebula Skybox: A procedural space background
  • Pan-Orbit Camera: Interactive camera controls
  • Animation: Objects rotate and bob with smooth interpolation

Requirements

This demo requires a browser with WebGPU support:

  • Chrome/Edge: Version 113+ (enabled by default)
  • Firefox: Version 141+ (enabled by default)
  • Safari: Version 18+ (Technology Preview)

If you see a blank frame, your browser may not support WebGPU yet.

Source Code

Cargo.toml

[package]
name = "hello-nightshade"
version = "0.1.0"
edition = "2024"

[dependencies]
nightshade = { git = "https://github.com/matthewjberger/nightshade", features = ["egui"] }

[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"

[profile.release]
opt-level = "z"
lto = true

src/main.rs

use nightshade::prelude::*;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    launch(HelloNightshade::default())
}

#[derive(Default)]
struct HelloNightshade {
    cube: Option<Entity>,
    sphere: Option<Entity>,
    torus: Option<Entity>,
    time: f32,
}

impl State for HelloNightshade {
    fn title(&self) -> &str {
        "Hello Nightshade"
    }

    fn initialize(&mut self, world: &mut World) {
        world.resources.user_interface.enabled = false;
        world.resources.graphics.atmosphere = Atmosphere::Nebula;
        capture_procedural_atmosphere_ibl(world, Atmosphere::Nebula, 0.0);
        world.resources.graphics.bloom_enabled = true;
        world.resources.graphics.bloom_intensity = 0.15;
        world.resources.graphics.show_grid = true;

        let camera = spawn_pan_orbit_camera(
            world,
            Vec3::new(0.0, 0.0, 0.0),
            8.0,
            0.5,
            0.3,
            "Camera".to_string(),
        );
        world.resources.active_camera = Some(camera);

        spawn_sun(world);

        let cube = spawn_mesh_at(
            world,
            "Cube",
            Vec3::new(-3.0, 0.0, 0.0),
            Vec3::new(1.0, 1.0, 1.0),
        );
        spawn_material(
            world,
            cube,
            "CubeMaterial".to_string(),
            Material {
                base_color: [0.2, 0.6, 1.0, 1.0],
                metallic: 0.8,
                roughness: 0.2,
                emissive_factor: [0.1, 0.3, 0.5],
                emissive_strength: 2.0,
                ..Default::default()
            },
        );
        self.cube = Some(cube);

        let sphere = spawn_mesh_at(
            world,
            "Sphere",
            Vec3::new(0.0, 0.0, 0.0),
            Vec3::new(1.2, 1.2, 1.2),
        );
        spawn_material(
            world,
            sphere,
            "SphereMaterial".to_string(),
            Material {
                base_color: [1.0, 1.0, 1.0, 1.0],
                metallic: 1.0,
                roughness: 0.0,
                ..Default::default()
            },
        );
        self.sphere = Some(sphere);

        let torus = spawn_mesh_at(
            world,
            "Torus",
            Vec3::new(3.0, 0.0, 0.0),
            Vec3::new(0.8, 0.8, 0.8),
        );
        spawn_material(
            world,
            torus,
            "TorusMaterial".to_string(),
            Material {
                base_color: [0.3, 1.0, 0.4, 1.0],
                metallic: 0.7,
                roughness: 0.3,
                emissive_factor: [0.15, 0.5, 0.2],
                emissive_strength: 2.0,
                ..Default::default()
            },
        );
        self.torus = Some(torus);
    }

    fn run_systems(&mut self, world: &mut World) {
        pan_orbit_camera_system(world);

        let dt = world.resources.window.timing.delta_time;
        self.time += dt;

        if let Some(entity) = self.cube {
            if let Some(transform) = world.core.get_local_transform_mut(entity) {
                transform.rotation = nalgebra_glm::quat_angle_axis(self.time * 0.8, &Vec3::y())
                    * nalgebra_glm::quat_angle_axis(self.time * 0.5, &Vec3::x());
                transform.translation.y = (self.time * 1.5).sin() * 0.5;
            }
            mark_local_transform_dirty(world, entity);
        }

        if let Some(entity) = self.sphere {
            if let Some(transform) = world.core.get_local_transform_mut(entity) {
                transform.rotation = nalgebra_glm::quat_angle_axis(self.time * 0.3, &Vec3::y());
                let pulse = 1.0 + (self.time * 2.0).sin() * 0.1;
                transform.scale = Vec3::new(1.2 * pulse, 1.2 * pulse, 1.2 * pulse);
            }
            mark_local_transform_dirty(world, entity);
        }

        if let Some(entity) = self.torus {
            if let Some(transform) = world.core.get_local_transform_mut(entity) {
                transform.rotation = nalgebra_glm::quat_angle_axis(self.time * 1.2, &Vec3::z())
                    * nalgebra_glm::quat_angle_axis(self.time * 0.7, &Vec3::x());
                transform.translation.y = (self.time * 1.2 + 2.0).sin() * 0.5;
            }
            mark_local_transform_dirty(world, entity);
        }
    }
}

Installation

Prerequisites

  • Rust 1.90+ with the 2024 edition
  • A graphics driver supporting Vulkan 1.2, Metal, or DirectX 12

Quick Start

Clone the template repository:

git clone https://github.com/matthewjberger/nightshade-template my-game
cd my-game

Run it:

just run

You should see a 3D scene with a nebula skybox, a grid, and a pan-orbit camera.

What's in the Template

The template gives you a working project with:

  • src/main.rs - A minimal Nightshade application with a camera, sun, and egui UI
  • Cargo.toml - Nightshade dependency with egui feature enabled
  • justfile - Build, run, lint, and deploy commands for native, WASM, VR, and Steam Deck
  • index.html + Trunk.toml - WASM web build configuration
  • .github/workflows/ - CI (clippy, tests, WASM build) and GitHub Pages deployment
  • rust-toolchain - Pinned Rust version with WASM target

Starter Code

The template's src/main.rs:

use nightshade::prelude::*;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    launch(Template)?;
    Ok(())
}

#[derive(Default)]
struct Template;

impl State for Template {
    fn title(&self) -> &str { "Template" }

    fn initialize(&mut self, world: &mut World) {
        world.resources.user_interface.enabled = true;
        world.resources.graphics.show_grid = true;
        world.resources.graphics.atmosphere = Atmosphere::Nebula;
        spawn_sun(world);
        let camera_entity = spawn_pan_orbit_camera(
            world,
            Vec3::new(0.0, 0.0, 0.0),
            15.0,
            0.0,
            std::f32::consts::FRAC_PI_4,
            "Main Camera".to_string(),
        );
        world.resources.active_camera = Some(camera_entity);
    }

    fn ui(&mut self, _world: &mut World, ui_context: &egui::Context) {
        egui::Window::new("Template").show(ui_context, |_ui| {});
    }

    fn run_systems(&mut self, world: &mut World) {
        pan_orbit_camera_system(world);
    }

    fn on_keyboard_input(
        &mut self,
        world: &mut World,
        key_code: KeyCode,
        key_state: ElementState,
    ) {
        if matches!((key_code, key_state), (KeyCode::KeyQ, ElementState::Pressed)) {
            world.resources.window.should_exit = true;
        }
    }
}

Justfile Commands

CommandDescription
just runBuild and run in release mode
just run-wasmBuild for web and open in browser
just lintRun clippy with warnings as errors
just testRun the test suite
just build-wasmBuild WASM release only
just run-openxrRun with VR headset support

Feature Flags

The template enables egui by default. Add more features in Cargo.toml:

[dependencies]
nightshade = { version = "0.6.70", features = ["egui", "physics", "audio"] }
FeatureDescription
eguiImmediate-mode UI
physicsRapier3D physics simulation
audioKira audio playback
gamepadGamepad input via gilrs
openxrVR headset support
steamSteamworks integration
scriptingWASM plugin system

See the Feature Flags appendix for the complete list.

Platform-Specific Notes

Windows

DirectX 12 is the default backend. Ensure your graphics drivers are up to date.

macOS

Metal is used automatically. No additional setup required.

Linux

Vulkan is required. Install Vulkan drivers for your GPU:

# Ubuntu/Debian
sudo apt install vulkan-tools libvulkan-dev

# Fedora
sudo dnf install vulkan-tools vulkan-loader-devel

# Arch
sudo pacman -S vulkan-tools vulkan-icd-loader

WebAssembly

WebGPU support requires a compatible browser (Chrome 113+, Firefox 121+). The template includes just run-wasm which uses Trunk to build and serve.

Your First Application

Let's create a simple application that displays a 3D scene with a camera, lighting, and some cubes.

Basic Structure

Every Nightshade application implements the State trait:

use nightshade::prelude::*;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    nightshade::launch(MyGame::default())
}

#[derive(Default)]
struct MyGame;

impl State for MyGame {
    fn title(&self) -> &str {
        "My First Game"
    }

    fn initialize(&mut self, world: &mut World) {
    }

    fn run_systems(&mut self, world: &mut World) {
    }
}

Adding a Camera

The camera determines what the player sees:

#![allow(unused)]
fn main() {
fn initialize(&mut self, world: &mut World) {
    let camera = spawn_camera(
        world,
        Vec3::new(0.0, 5.0, 10.0),
        "Main Camera".to_string(),
    );
    world.resources.active_camera = Some(camera);
}
}

Adding Lighting

Without lights, everything is dark. Add a directional light (sun):

#![allow(unused)]
fn main() {
fn initialize(&mut self, world: &mut World) {
    spawn_sun(world);
}
}

Enabling the Grid

For development, a ground grid is helpful:

#![allow(unused)]
fn main() {
fn initialize(&mut self, world: &mut World) {
    world.resources.graphics.show_grid = true;
    world.resources.graphics.atmosphere = Atmosphere::Sky;
}
}

Adding Geometry

Spawn a cube using the built-in primitive (available from the prelude):

#![allow(unused)]
fn main() {
fn initialize(&mut self, world: &mut World) {
    spawn_cube_at(world, Vec3::new(0.0, 1.0, 0.0));
}
}

Camera Controls

Add a fly camera system so you can navigate the scene:

#![allow(unused)]
fn main() {
fn run_systems(&mut self, world: &mut World) {
    fly_camera_system(world);
    escape_key_exit_system(world);
}
}

Complete Example

use nightshade::prelude::*;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    nightshade::launch(MyGame::default())
}

#[derive(Default)]
struct MyGame;

impl State for MyGame {
    fn title(&self) -> &str {
        "My First Game"
    }

    fn initialize(&mut self, world: &mut World) {
        world.resources.graphics.show_grid = true;
        world.resources.graphics.atmosphere = Atmosphere::Sky;

        let camera = spawn_camera(
            world,
            Vec3::new(0.0, 5.0, 10.0),
            "Main Camera".to_string(),
        );
        world.resources.active_camera = Some(camera);

        spawn_sun(world);

        spawn_cube_at(world, Vec3::new(0.0, 1.0, 0.0));
        spawn_cube_at(world, Vec3::new(3.0, 0.5, 0.0));
        spawn_cube_at(world, Vec3::new(-2.0, 1.5, 2.0));
    }

    fn run_systems(&mut self, world: &mut World) {
        fly_camera_system(world);
        escape_key_exit_system(world);
    }
}

Controls

With the fly camera system enabled:

KeyAction
W/A/S/DMove forward/left/back/right
SpaceMove up
ShiftMove down
MouseLook around
EscapeExit

Next Steps

Now that you have a basic scene, explore:

Project Structure

A typical Nightshade project follows this structure:

my_game/
├── Cargo.toml
├── src/
│   ├── main.rs           # Entry point
│   ├── game.rs           # Game state
│   └── systems/          # Game systems
│       ├── player.rs
│       ├── camera.rs
│       └── ...
├── assets/
│   ├── models/           # glTF/GLB files
│   ├── textures/         # PNG, JPG, HDR
│   └── sounds/           # Audio files
└── README.md

Cargo.toml

[package]
name = "my_game"
version = "0.1.0"
edition = "2024"

[dependencies]
nightshade = { git = "https://github.com/matthewjberger/nightshade.git", features = ["engine", "wgpu"] }

Entry Point (main.rs)

Keep main.rs minimal:

mod game;

use nightshade::prelude::*;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    nightshade::launch(game::MyGame::default())
}

Game State (game.rs)

Implement your game logic:

#![allow(unused)]
fn main() {
use nightshade::prelude::*;

#[derive(Default)]
pub struct MyGame {
    player: Option<Entity>,
    score: u32,
}

impl State for MyGame {
    fn title(&self) -> &str {
        "My Game"
    }

    fn initialize(&mut self, world: &mut World) {
        // Setup code
    }

    fn run_systems(&mut self, world: &mut World) {
        // Per-frame logic
    }
}
}

Embedding Assets

For distribution, embed assets directly in the binary:

#![allow(unused)]
fn main() {
const MODEL_BYTES: &[u8] = include_bytes!("../assets/models/character.glb");
const SKY_HDR: &[u8] = include_bytes!("../assets/textures/sky.hdr");
}

Custom ECS

For complex games, create a separate game ECS alongside the engine's World:

#![allow(unused)]
fn main() {
use freecs::ecs;

ecs! {
    GameWorld {
        components {
            player_state: PlayerState,
            inventory: Inventory,
            health: Health,
        },
        resources {
            game_time: GameTime,
            score: u32,
        }
    }
}

pub struct MyGame {
    game: GameWorld,
}
}

This keeps game-specific data separate from engine data while allowing both to coexist.

Module Organization

For larger projects, organize systems into modules:

#![allow(unused)]
fn main() {
// src/systems/mod.rs
pub mod camera;
pub mod player;
pub mod enemies;
pub mod ui;

// src/game.rs
mod systems;

impl State for MyGame {
    fn run_systems(&mut self, world: &mut World) {
        systems::player::update(&mut self.game, world);
        systems::camera::follow(&self.game, world);
        systems::enemies::ai(&mut self.game, world);
        systems::ui::update(&self.game, world);
    }
}
}

Architecture Overview

Nightshade is organized as a layered dependency graph (DAG). Each layer builds on the one below it, and no layer references anything above it. This chapter shows how the pieces fit together.

Feature Layer:     Terrain, Particles, NavMesh, Grass, SDF, Lattice, Scripting
                            |
Application Layer: State Trait, Main Loop, Event Bus
                            |
Rendering Layer:   Render Graph -> Passes -> Materials -> Textures -> Shaders
                            |
Simulation Layer:  Physics (Rapier), Animation, Audio (Kira)
                            |
Core Layer:        World, Transform Hierarchy, Input, Time, Windowing
                            |
Foundation Layer:  ECS (freecs), Math (nalgebra_glm), GPU (wgpu)

Foundation Layer

The lowest layer contains three independent systems that everything else depends on.

ECS (freecs) provides compile-time code-generated entity storage with struct-of-arrays layout. The ecs! macro generates the World struct, component accessors, query methods, and entity management. Zero unsafe code, all statically dispatched.

Math (nalgebra_glm) provides vectors (Vec2, Vec3, Vec4), matrices (Mat4), quaternions (Quat), and all standard linear algebra operations. Nightshade uses nalgebra_glm exclusively for all math.

GPU (wgpu) provides cross-platform GPU access. wgpu targets Vulkan, Metal, DirectX 12, and WebGPU from a single API surface.

Core Layer

Built on the foundation, the core layer manages per-frame state and the entity hierarchy.

World is the central data container generated by the freecs::ecs! macro. It holds all entity storage and a Resources struct containing global singletons (timing, input, graphics settings, caches, physics world, audio engine, etc.).

Transform Hierarchy propagates LocalTransform through parent-child relationships to compute GlobalTransform matrices each frame. Dirty-flag tracking ensures only modified subtrees are recomputed.

Input aggregates keyboard, mouse, and gamepad state each frame into world.resources.input. Provides both polling (is_key_pressed) and event-driven (on_keyboard_input) access patterns.

Time is accessed through world.resources.window.timing and provides delta_time, frames_per_second, uptime_milliseconds, frame_counter, and raw/speed-adjusted variants.

Windowing wraps winit for window creation, event handling, and surface management. On native platforms, secondary windows are supported via SecondaryWindows.

Simulation Layer

Systems that update world state each frame, independent of rendering.

Physics (Rapier3D) runs at a fixed 60Hz timestep with interpolation for smooth rendering. Provides rigid bodies, colliders, character controllers, joints, and raycasting. Gated behind the physics feature flag.

Animation plays back skeletal animations loaded from glTF files. Supports blending, crossfading, speed control, and looping. Bone transforms are written directly into the ECS each frame.

Audio (Kira) handles sound playback with spatial positioning, distance attenuation, and FFT analysis. Gated behind the audio feature flag.

Rendering Layer

Transforms ECS data into pixels on screen.

Render Graph is a dependency-driven frame graph built on petgraph. Passes declare which resources they read and write via named slots. The graph automatically builds dependency edges, topologically sorts passes, computes resource lifetimes, aliases transient GPU memory, and determines optimal load/store operations.

Passes implement the PassNode trait. Each pass owns its GPU pipelines and bind group layouts. Built-in passes cover shadow mapping, PBR mesh rendering, skeletal animation, water, grass, particles, text, UI, and post-processing (SSAO, SSGI, SSR, bloom, depth of field, tonemapping, effects).

Materials use a PBR metallic-roughness workflow stored in a MaterialRegistry. Materials reference textures by name and are assigned to entities via MaterialRef.

Textures are managed by a TextureCache that handles GPU upload, format conversion, and atlas packing (for sprites). Textures are loaded via WorldCommand::LoadTexture.

Shaders are written in WGSL and embedded at compile time via include_str!.

Application Layer

The interface between engine and game code.

State Trait is implemented by your game struct. Its methods (initialize, run_systems, ui, configure_render_graph, etc.) are called by the engine at specific points in the frame lifecycle.

Main Loop drives the frame lifecycle: process events, update input, call run_systems, dispatch events, run the frame schedule (engine systems), render, present.

Frame Schedule is a data-driven ordered list of engine system functions stored as a resource (world.resources.frame_schedule). It dispatches audio, camera, physics, scripting, animation, transform propagation, and cleanup systems each frame. Users can insert, remove, or reorder systems in State::initialize().

Event Bus provides decoupled communication via world.resources.event_bus. Supports typed app events and input messages.

Feature Layer

High-level gameplay systems built on everything below.

FeatureDependencies
TerrainRendering (mesh generation, tessellation), Physics (heightfield collider)
ParticlesRendering (GPU billboard pass), ECS (emitter component)
NavMeshPhysics (geometry), ECS (agent component), Core (transforms)
GrassRendering (instanced GPU pass), ECS (region component)
SDF SculptingRendering (compute + raymarching), ECS (SDF world resource)
Lattice DeformationCore (transforms), ECS (control points)
ScriptingWASM plugin runtime, ECS (component access API)

Data Flow

A typical frame flows data through the layers like this:

Input Events (winit)
    |
    v
Input State (world.resources.input)
    |
    v
Game Logic (State::run_systems)
    |
    v
ECS Mutations (spawn, despawn, set components)
    |
    v
FrameSchedule Dispatch:
    |-- Audio initialization and update
    |-- Camera aspect ratios
    |-- Physics Step (Rapier simulation, sync back to ECS)
    |-- Script execution
    |-- Animation (bone transforms written to ECS)
    |-- Transform Propagation (LocalTransform -> GlobalTransform)
    |-- Instanced mesh caches, text sync
    |-- Input reset, deferred commands, cleanup
    |
    v
Render Graph Execution
    |-- Shadow Depth Pass (reads GlobalTransform, Light)
    |-- Geometry Passes (reads GlobalTransform, RenderMesh, MaterialRef)
    |-- Post-Processing (reads scene_color, depth, normals)
    |-- UI Pass (reads egui UI state)
    |-- Blit Pass (writes to swapchain)
    |
    v
Present (wgpu surface present)

Feature Flags

Most subsystems are opt-in via Cargo feature flags:

FlagWhat it enables
engineCore rendering, ECS, transforms, input
physicsRapier3D physics simulation
audioKira audio playback
eguiegui immediate-mode UI
gamepadGamepad input via gilrs
assetsImage/HDR loading via the image crate
openxrOpenXR VR support
steamSteamworks integration
scriptingWASM plugin system
sdf_sculptSDF sculpting tools
scene_graphScene serialization

Entity Component System

Nightshade uses freecs, a compile-time code-generated ECS with struct-of-arrays (SoA) storage. freecs generates all entity management, component storage, and query methods at compile time via the ecs! macro, with zero unsafe code.

What is an ECS?

An Entity Component System separates data from behavior:

  • Entities are unique identifiers (IDs). They have no data of their own.
  • Components are plain data structs attached to entities. Each component type is stored in its own contiguous array.
  • Systems are functions that query entities by their component masks and process matching entities.

This is fundamentally different from object-oriented game architectures where a GameObject class owns its data and behavior through inheritance. The OOP approach leads to deep inheritance hierarchies (the "diamond problem"), poor cache locality (objects scattered across the heap), and rigid coupling between data and logic. ECS inverts this: data is organized by type, not by object, and logic operates on slices of data rather than individual objects.

How Archetype Storage Works

freecs uses archetype-based SoA (struct-of-arrays) storage. To understand why this matters, consider how data layouts affect performance.

Array of Structs vs Struct of Arrays

In a traditional OOP game, entities are stored as an array of structs (AoS):

Memory: [Entity0{pos,vel,hp,mesh}] [Entity1{pos,vel,hp,mesh}] [Entity2{pos,vel,hp,mesh}]

When a system iterates over all positions, it loads entire entity structs into cache lines even though it only needs the pos field. The rest is wasted bandwidth.

In SoA storage, each component type gets its own contiguous array:

Positions:  [pos0] [pos1] [pos2] [pos3] ...
Velocities: [vel0] [vel1] [vel2] [vel3] ...
Health:     [hp0]  [hp1]  [hp2]  [hp3]  ...

Now a system iterating over positions reads a dense, contiguous block of memory. Every byte loaded into a cache line is useful. This can be 5-10x faster for large entity counts due to CPU cache prefetching.

Archetype Tables

Not every entity has the same components. An entity with Position | Velocity is different from one with Position | Velocity | Health. freecs groups entities by their component mask (the exact set of components they have) into archetype tables.

Each table stores only entities with identical component masks. Within a table, components are stored in SoA layout:

Table A (mask: Position | Velocity):
  positions:  [p0, p1, p2]
  velocities: [v0, v1, v2]

Table B (mask: Position | Velocity | Health):
  positions:  [p3, p4]
  velocities: [v3, v4]
  healths:    [h3, h4]

When querying for Position | Velocity, the ECS checks each table's mask with a single bitwise AND. Table A matches, Table B also matches (it has a superset of the requested components). Tables whose masks don't include all requested components are skipped entirely without examining any entities.

Component Masks as Bitflags

Each component is assigned a bit position at compile time. An entity's component mask is a single integer where bit N is set if the entity has component N:

LOCAL_TRANSFORM    = 0b0001
GLOBAL_TRANSFORM   = 0b0010
RENDER_MESH        = 0b0100
MATERIAL_REF       = 0b1000

Querying query_entities(RENDER_MESH | LOCAL_TRANSFORM) becomes table_mask & query_mask == query_mask, which is two CPU instructions (AND + CMP). This is why ECS queries scale to millions of entities.

Why freecs?

freecs generates all ECS infrastructure at compile time from a single macro invocation. This means:

  • No runtime overhead for component lookups - everything is statically dispatched, no vtables, no hash lookups
  • SoA storage - components of the same type are stored in contiguous arrays, optimal for CPU cache utilization
  • Archetype tables - entities with the same component mask share a table, so queries skip irrelevant entities entirely
  • Zero unsafe - the generated code uses only safe Rust
  • Bitflag queries - component presence is checked with bitmask operations, which is a single CPU instruction
  • Compile-time monomorphization - accessor methods like get_local_transform() compile down to a direct array index with no indirection

Quick Overview

#![allow(unused)]
fn main() {
use nightshade::prelude::*;

// Spawn an entity with transform and mesh components
let entity = world.spawn_entities(
    LOCAL_TRANSFORM | GLOBAL_TRANSFORM | RENDER_MESH | MATERIAL_REF,
    1,
)[0];

// Set component values
world.core.set_local_transform(entity, LocalTransform {
    translation: Vec3::new(0.0, 5.0, 0.0),
    rotation: Quat::identity(),
    scale: Vec3::new(1.0, 1.0, 1.0),
});
world.core.set_render_mesh(entity, RenderMesh {
    name: "cube".to_string(),
    id: None,
});
world.core.set_material_ref(entity, MaterialRef::new("Default"));

// Query all entities with a specific set of components
for entity in world.core.query_entities(RENDER_MESH | LOCAL_TRANSFORM) {
    let transform = world.core.get_local_transform(entity).unwrap();
    let mesh = world.core.get_render_mesh(entity).unwrap();
}
}

Component Flags

Each component has a corresponding bitflag constant. Combine flags with | to describe which components an entity has:

#![allow(unused)]
fn main() {
const RENDERABLE: ComponentFlags =
    LOCAL_TRANSFORM | GLOBAL_TRANSFORM | RENDER_MESH | MATERIAL_REF;

let entity = world.spawn_entities(RENDERABLE, 1)[0];
}

The full list of built-in component flags is in the Components chapter.

Custom Game ECS

For complex games, define a separate ECS for game-specific data:

#![allow(unused)]
fn main() {
use freecs::ecs;

ecs! {
    GameWorld {
        player_state: PlayerState => PLAYER_STATE,
        inventory: Inventory => INVENTORY,
        health: Health => HEALTH,
        enemy_ai: EnemyAI => ENEMY_AI,
    }
    Resources {
        game_time: GameTime,
        score: u32,
        level: u32,
    }
}
}

Then use both worlds together in your State:

#![allow(unused)]
fn main() {
pub struct MyGame {
    game: GameWorld,
}

impl State for MyGame {
    fn run_systems(&mut self, world: &mut World) {
        update_player(&mut self.game);
        sync_positions(&self.game, world);
    }
}
}

Chapter Guide

The freecs Macro

The freecs::ecs! macro generates the entire World struct, component storage, accessors, query methods, and entity management from a declarative definition.

Macro Syntax

#![allow(unused)]
fn main() {
freecs::ecs! {
    World {
        Core {
            local_transform: LocalTransform => LOCAL_TRANSFORM,
            global_transform: GlobalTransform => GLOBAL_TRANSFORM,
            render_mesh: RenderMesh => RENDER_MESH,
            material_ref: MaterialRef => MATERIAL_REF,
            camera: Camera => CAMERA,
            light: Light => LIGHT,
        }
        Ui {
            ui_layout_root: UiLayoutRoot => UI_LAYOUT_ROOT,
            ui_layout_node: UiLayoutNode => UI_LAYOUT_NODE,
        }
        Sprite2d {
            sprite: Sprite => SPRITE,
            sprite_animator: SpriteAnimator => SPRITE_ANIMATOR,
        }
    }
    Resources {
        window: Window,
        input: Input,
        graphics: Graphics,
        active_camera: Option<Entity>,
    }
}
}

Nightshade uses three subsystems: Core (3D engine components), Ui (retained UI), and Sprite2d (2D sprites). Component accessors are scoped to their subsystem: world.core.get_light(entity), world.ui.get_ui_layout_node(entity), world.sprite2d.get_sprite(entity).

Each line in the component block declares:

  1. A field name (snake_case) - used to generate accessor methods
  2. A type - the Rust struct stored for this component
  3. A flag constant (UPPER_SNAKE_CASE) - the bitflag for queries

What Gets Generated

From the macro invocation, freecs generates:

The World Struct

#![allow(unused)]
fn main() {
pub struct World {
    // Internal entity storage (archetype tables, free lists, etc.)
    entities: EntityStorage,
    // All global resources
    pub resources: Resources,
}
}

Per-Component Accessors

For each component foo: Foo => FOO, the macro generates:

#![allow(unused)]
fn main() {
// Immutable access
world.core.get_foo(entity) -> Option<&Foo>

// Mutable access
world.core.get_foo_mut(entity) -> Option<&mut Foo>

// Set value
world.core.set_foo(entity, value: Foo)
}

Entity Management

#![allow(unused)]
fn main() {
// Spawn entities with a component mask
world.spawn_entities(flags: ComponentFlags, count: usize) -> Vec<Entity>

// Despawn entities
world.despawn_entities(entities: &[Entity])

// Check if entity has components
world.core.entity_has_components(entity: Entity, flags: ComponentFlags) -> bool
}

Query Methods

#![allow(unused)]
fn main() {
// Query entities matching a component mask
world.core.query_entities(flags: ComponentFlags) -> impl Iterator<Item = Entity>
}

Component Flag Constants

#![allow(unused)]
fn main() {
pub const LOCAL_TRANSFORM: ComponentFlags = 1 << 0;
pub const GLOBAL_TRANSFORM: ComponentFlags = 1 << 1;
pub const RENDER_MESH: ComponentFlags = 1 << 2;
// ... one per component, powers of 2
}

The Resources Struct

#![allow(unused)]
fn main() {
pub struct Resources {
    pub window: Window,
    pub input: Input,
    pub graphics: Graphics,
    pub active_camera: Option<Entity>,
    // ... all declared resources with Default initialization
}
}

Nightshade's World Definition

Nightshade declares 45 core components, 10 UI components, and 4 sprite components across three subsystems (Core, Ui, Sprite2d), plus 30+ resources. Here is the Core component declaration (simplified paths for readability):

Components

FlagFieldTypeCategory
ANIMATION_PLAYERanimation_playerAnimationPlayerAnimation
NAMEnameNameIdentity
LOCAL_TRANSFORMlocal_transformLocalTransformTransform
GLOBAL_TRANSFORMglobal_transformGlobalTransformTransform
LOCAL_TRANSFORM_DIRTYlocal_transform_dirtyLocalTransformDirtyTransform
PARENTparentParentTransform
IGNORE_PARENT_SCALEignore_parent_scaleIgnoreParentScaleTransform
AUDIO_SOURCEaudio_sourceAudioSourceAudio
AUDIO_LISTENERaudio_listenerAudioListenerAudio
CAMERAcameraCameraCamera
PAN_ORBIT_CAMERApan_orbit_cameraPanOrbitCameraCamera
LIGHTlightLightLighting
LINESlinesLinesDebug
VISIBILITYvisibilityVisibilityRendering
DECALdecalDecalRendering
RENDER_MESHrender_meshRenderMeshRendering
MATERIAL_REFmaterial_refMaterialRefRendering
RENDER_LAYERrender_layerRenderLayerRendering
TEXTtextTextText
TEXT_CHARACTER_COLORStext_character_colorsTextCharacterColorsText
TEXT_CHARACTER_BACKGROUND_COLORStext_character_background_colorsTextCharacterBackgroundColorsText
BOUNDING_VOLUMEbounding_volumeBoundingVolumeSpatial
HOVEREDhoveredHoveredInput
ROTATIONrotationRotationTransform
CASTS_SHADOWcasts_shadowCastsShadowRendering
RIGID_BODYrigid_bodyRigidBodyComponentPhysics
COLLIDERcolliderColliderComponentPhysics
CHARACTER_CONTROLLERcharacter_controllerCharacterControllerComponentPhysics
COLLISION_LISTENERcollision_listenerCollisionListenerPhysics
PHYSICS_INTERPOLATIONphysics_interpolationPhysicsInterpolationPhysics
INSTANCED_MESHinstanced_meshInstancedMeshRendering
PARTICLE_EMITTERparticle_emitterParticleEmitterParticles
PREFAB_SOURCEprefab_sourcePrefabSourcePrefabs
PREFAB_INSTANCEprefab_instancePrefabInstancePrefabs
SCRIPTscriptScriptScripting
SKINskinSkinAnimation
JOINTjointJointAnimation
MORPH_WEIGHTSmorph_weightsMorphWeightsAnimation
NAVMESH_AGENTnavmesh_agentNavMeshAgentNavigation
LATTICElatticeLatticeDeformation
LATTICE_INFLUENCEDlattice_influencedLatticeInfluencedDeformation
WATERwaterWaterRendering
GRASS_REGIONgrass_regionGrassRegionRendering
GRASS_INTERACTORgrass_interactorGrassInteractorRendering
TWEENtweenTweenAnimation

Resources

The Resources block includes:

FieldTypeFeature Gate
windowWindowalways
secondary_windowsSecondaryWindowsalways
user_interfaceUserInterfacealways
graphicsGraphicsalways
inputInputalways
audioAudioEngineaudio
physicsPhysicsWorldphysics
navmeshNavMeshWorldalways
text_cacheTextCachealways
mesh_cacheMeshCachealways
animation_cacheAnimationCachealways
prefab_cachePrefabCachealways
material_registryMaterialRegistryalways
texture_cacheTextureCachealways
active_cameraOption<Entity>always
event_busEventBusalways
command_queueVec<WorldCommand>always
entity_namesHashMap<String, Entity>always

Conditional resources are included only when their feature flag is enabled, using #[cfg(feature = "...")] attributes in the macro.

Entities

Entities are unique identifiers that group components together. An entity is just an ID - it has no data of its own.

Entity Type

#![allow(unused)]
fn main() {
pub use freecs::Entity;
}

Entity is a lightweight handle containing a generation and an index. Generations prevent dangling references - if an entity is despawned and its slot reused, the old Entity handle will no longer match.

Spawning Entities

Basic Spawning

Spawn entities with spawn_entities, specifying component flags and count:

#![allow(unused)]
fn main() {
let entity = world.spawn_entities(
    LOCAL_TRANSFORM | GLOBAL_TRANSFORM | RENDER_MESH | MATERIAL_REF,
    1,
)[0];
}

Then set component values:

#![allow(unused)]
fn main() {
world.core.set_local_transform(entity, LocalTransform {
    translation: Vec3::new(0.0, 1.0, 0.0),
    rotation: Quat::identity(),
    scale: Vec3::new(1.0, 1.0, 1.0),
});

world.core.set_render_mesh(entity, RenderMesh {
    name: "cube".to_string(),
    id: None,
});

world.core.set_material_ref(entity, MaterialRef::new("Default"));
}

Batch Spawning

Spawn multiple entities at once:

#![allow(unused)]
fn main() {
let entities = world.spawn_entities(LOCAL_TRANSFORM | GLOBAL_TRANSFORM, 100);

for (index, entity) in entities.iter().enumerate() {
    world.core.set_local_transform(*entity, LocalTransform {
        translation: Vec3::new(index as f32 * 2.0, 0.0, 0.0),
        ..Default::default()
    });
}
}

Helper Functions

Nightshade provides convenience functions for common entities:

#![allow(unused)]
fn main() {
// Primitives
spawn_cube_at(world, Vec3::new(0.0, 1.0, 0.0));
spawn_sphere_at(world, Vec3::new(2.0, 1.0, 0.0));
spawn_plane_at(world, Vec3::zeros());
spawn_cylinder_at(world, Vec3::new(4.0, 1.0, 0.0));
spawn_cone_at(world, Vec3::new(6.0, 1.0, 0.0));
spawn_torus_at(world, Vec3::new(8.0, 1.0, 0.0));

// Camera
let camera = spawn_camera(world, Vec3::new(0.0, 5.0, 10.0), "Main Camera".to_string());
world.resources.active_camera = Some(camera);

// Sun light
let sun = spawn_sun(world);

// First-person player with character controller
let (player_entity, camera_entity) = spawn_first_person_player(world, Vec3::new(0.0, 2.0, 0.0));
}

Loading Models

Load glTF/GLB models:

#![allow(unused)]
fn main() {
use nightshade::ecs::prefab::*;

let model_bytes = include_bytes!("../assets/character.glb");
let result = import_gltf_from_bytes(model_bytes)?;

for (name, (rgba_data, width, height)) in result.textures {
    world.queue_command(WorldCommand::LoadTexture {
        name,
        rgba_data,
        width,
        height,
    });
}

for (name, mesh) in result.meshes {
    mesh_cache_insert(&mut world.resources.mesh_cache, name, mesh);
}

for prefab in result.prefabs {
    spawn_prefab_with_animations(
        world,
        &prefab,
        &result.animations,
        Vec3::new(0.0, 0.0, 0.0),
    );
}
}

Despawning Entities

#![allow(unused)]
fn main() {
// Despawn specific entities
world.despawn_entities(&[entity]);

// Despawn entity and all children recursively
despawn_recursive_immediate(world, entity);

// Deferred despawn via command queue
world.queue_command(WorldCommand::DespawnRecursive { entity });
}

Adding Components After Spawn

Add component flags to an existing entity with add_components:

#![allow(unused)]
fn main() {
world.core.add_components(entity, AUDIO_SOURCE);
world.core.set_audio_source(entity, AudioSource::new("music").playing());

world.core.add_components(camera, AUDIO_LISTENER);
world.core.set_audio_listener(camera, AudioListener);
}

This is useful when you need to augment entities created by helper functions (like spawn_cube_at) with additional components.

Checking Components

#![allow(unused)]
fn main() {
if world.core.entity_has_components(entity, RENDER_MESH) {
    // Entity has a mesh
}

if world.core.entity_has_components(entity, ANIMATION_PLAYER | SKIN) {
    // Entity is an animated skinned model
}
}

Parent-Child Relationships

Spawn an entity as a child of another:

#![allow(unused)]
fn main() {
let parent = world.spawn_entities(LOCAL_TRANSFORM | GLOBAL_TRANSFORM, 1)[0];
let child = world.spawn_entities(LOCAL_TRANSFORM | GLOBAL_TRANSFORM | PARENT, 1)[0];

world.core.set_parent(child, Parent(Some(parent)));
world.core.set_local_transform(child, LocalTransform {
    translation: Vec3::new(0.0, 1.0, 0.0),
    ..Default::default()
});
}

See Transform Hierarchy for details on parent-child transforms.

Spawning Entities

Basic Entity Spawning

Spawn entities with spawn_entities, specifying component flags:

#![allow(unused)]
fn main() {
let entity = world.spawn_entities(
    LOCAL_TRANSFORM | GLOBAL_TRANSFORM | RENDER_MESH | MATERIAL_REF,
    1
)[0];
}

Then set component values:

#![allow(unused)]
fn main() {
world.core.set_local_transform(entity, LocalTransform {
    translation: Vec3::new(0.0, 1.0, 0.0),
    rotation: Quat::identity(),
    scale: Vec3::new(1.0, 1.0, 1.0),
});

world.core.set_render_mesh(entity, RenderMesh {
    name: "cube".to_string(),
    id: None,
});

world.core.set_material_ref(entity, MaterialRef::new("Default"));
}

Spawning Multiple Entities

Spawn multiple entities at once:

#![allow(unused)]
fn main() {
let entities = world.spawn_entities(LOCAL_TRANSFORM | GLOBAL_TRANSFORM, 100);

for (index, entity) in entities.iter().enumerate() {
    world.core.set_local_transform(*entity, LocalTransform {
        translation: Vec3::new(index as f32 * 2.0, 0.0, 0.0),
        ..Default::default()
    });
}
}

Helper Functions

Nightshade provides convenience functions for common entities. Primitive spawn helpers and fly_camera_system / escape_key_exit_system are available from the prelude.

Cameras

#![allow(unused)]
fn main() {
let camera = spawn_camera(world, Vec3::new(0.0, 5.0, 10.0), "Main Camera".to_string());
world.resources.active_camera = Some(camera);

let orbit_camera = spawn_pan_orbit_camera(
    world,
    Vec3::zeros(),
    10.0,
    0.5,
    0.4,
    "Orbit Camera".to_string(),
);
}

Lights

#![allow(unused)]
fn main() {
let sun = spawn_sun(world);
}

To create a point light, manually spawn an entity with the LIGHT flag and set the Light component with LightType::Point:

#![allow(unused)]
fn main() {
let light = world.spawn_entities(
    LOCAL_TRANSFORM | GLOBAL_TRANSFORM | LIGHT,
    1,
)[0];

world.core.set_local_transform(light, LocalTransform {
    translation: Vec3::new(0.0, 3.0, 0.0),
    ..Default::default()
});

world.core.set_light(light, Light {
    light_type: LightType::Point,
    color: Vec3::new(1.0, 0.8, 0.6),
    intensity: 5.0,
    range: 10.0,
    cast_shadows: true,
    shadow_bias: 0.005,
    inner_cone_angle: 0.0,
    outer_cone_angle: 0.0,
});
}

Primitives

All primitive spawn helpers are available from the prelude:

#![allow(unused)]
fn main() {
spawn_cube_at(world, Vec3::new(0.0, 1.0, 0.0));
spawn_sphere_at(world, Vec3::new(2.0, 1.0, 0.0));
spawn_plane_at(world, Vec3::zeros());
spawn_cylinder_at(world, Vec3::new(4.0, 1.0, 0.0));
spawn_cone_at(world, Vec3::new(6.0, 1.0, 0.0));
spawn_torus_at(world, Vec3::new(8.0, 1.0, 0.0));
}

Physics Objects

Physics spawn convenience functions are available in nightshade::ecs::physics::commands (not in the prelude):

#![allow(unused)]
fn main() {
use nightshade::ecs::physics::commands::*;

let dynamic_cube = spawn_dynamic_physics_cube_with_material(
    world,
    Vec3::new(0.0, 5.0, 0.0),
    Vec3::new(1.0, 1.0, 1.0),
    1.0,
    Material {
        base_color: [0.8, 0.2, 0.2, 1.0],
        ..Default::default()
    },
);

let floor = spawn_static_physics_cube_with_material(
    world,
    Vec3::new(0.0, -0.5, 0.0),
    Vec3::new(50.0, 1.0, 50.0),
    Material::default(),
);
}

Available helpers: spawn_static_physics_cube_with_material, spawn_dynamic_physics_cube_with_material, spawn_dynamic_physics_sphere_with_material, spawn_dynamic_physics_cylinder_with_material.

You can also create physics entities manually by combining the appropriate component flags:

#![allow(unused)]
fn main() {
let entity = world.spawn_entities(
    LOCAL_TRANSFORM | GLOBAL_TRANSFORM | LOCAL_TRANSFORM_DIRTY
    | RENDER_MESH | MATERIAL_REF | RIGID_BODY | COLLIDER,
    1,
)[0];

world.core.set_local_transform(entity, LocalTransform {
    translation: Vec3::new(0.0, 5.0, 0.0),
    ..Default::default()
});

world.core.set_render_mesh(entity, RenderMesh {
    name: "cube".to_string(),
    id: None,
});

world.core.set_material_ref(entity, MaterialRef::new("Default"));

world.core.set_rigid_body(entity, RigidBodyComponent {
    body_type: RigidBodyType::Dynamic,
    ..Default::default()
});
}

Character Controllers

#![allow(unused)]
fn main() {
let (player_entity, camera_entity) = spawn_first_person_player(world, Vec3::new(0.0, 2.0, 0.0));
}

spawn_first_person_player takes the world and position, and returns a tuple of (Entity, Entity) for the player entity and camera entity.

Loading Models

Load glTF/GLB models:

#![allow(unused)]
fn main() {
use nightshade::ecs::prefab::*;

let model_bytes = include_bytes!("../assets/character.glb");
let result = import_gltf_from_bytes(model_bytes)?;

for (name, (rgba_data, width, height)) in result.textures {
    world.queue_command(WorldCommand::LoadTexture {
        name,
        rgba_data,
        width,
        height,
    });
}

for (name, mesh) in result.meshes {
    mesh_cache_insert(&mut world.resources.mesh_cache, name, mesh);
}

for prefab in result.prefabs {
    let entity = spawn_prefab_with_animations(
        world,
        &prefab,
        &result.animations,
        Vec3::new(0.0, 0.0, 0.0),
    );
}
}

Adding Components After Spawn

Add component flags to an existing entity:

#![allow(unused)]
fn main() {
world.core.add_components(entity, AUDIO_SOURCE);
world.core.set_audio_source(entity, AudioSource::new("music").playing());
}

Despawning Entities

Remove entities from the world:

#![allow(unused)]
fn main() {
world.despawn_entities(&[entity]);

despawn_recursive_immediate(world, entity);
}

Entity with Parent

Spawn an entity as a child:

#![allow(unused)]
fn main() {
let parent = world.spawn_entities(LOCAL_TRANSFORM | GLOBAL_TRANSFORM, 1)[0];
let child = world.spawn_entities(LOCAL_TRANSFORM | GLOBAL_TRANSFORM | PARENT, 1)[0];

world.core.set_parent(child, Parent(Some(parent)));
world.core.set_local_transform(child, LocalTransform {
    translation: Vec3::new(0.0, 1.0, 0.0),
    ..Default::default()
});
}

Components

Nightshade provides 59 built-in components across several categories. Core components (45) are listed below. UI components (10) are covered in Retained UI and Sprite2D components (4) in Sprites.

Transform Components

ComponentDescription
LocalTransformPosition, rotation, scale relative to parent
GlobalTransformComputed world-space transformation matrix
LocalTransformDirtyMarker for transforms needing propagation
ParentParent entity reference for hierarchy
IgnoreParentScaleExclude parent's scale from transform hierarchy
RotationAdditional rotation component
#![allow(unused)]
fn main() {
pub struct LocalTransform {
    pub translation: Vec3,
    pub rotation: Quat,
    pub scale: Vec3,
}

pub struct GlobalTransform(pub Mat4);

pub struct Parent(pub Option<Entity>);
}

Rendering Components

ComponentDescription
RenderMeshReferences mesh by name
MaterialRefReferences material by name
Sprite2D billboard rendering
RenderLayerDepth/layer for ordering
CastsShadowMarks mesh for shadow maps
VisibilityVisibility toggle
BoundingVolumeBounding volume for culling and picking
InstancedMeshGPU-instanced mesh data
#![allow(unused)]
fn main() {
pub struct RenderMesh {
    pub name: String,
    pub id: Option<MeshId>,
}

pub struct MaterialRef {
    pub name: String,
    pub id: Option<MaterialId>,
}
}

MaterialRef::new(name) takes impl Into<String>, so you can pass &str directly:

#![allow(unused)]
fn main() {
MaterialRef::new("Default")
}

Camera Components

ComponentDescription
CameraProjection mode and smoothing
PanOrbitCameraOrbiting camera controller

There is a single CAMERA component flag. The projection type is determined by the Projection enum inside the Camera struct:

#![allow(unused)]
fn main() {
pub struct Camera {
    pub projection: Projection,
    pub smoothing: Option<Smoothing>,
}

pub enum Projection {
    Perspective(PerspectiveCamera),
    Orthographic(OrthographicCamera),
}

pub struct PerspectiveCamera {
    pub aspect_ratio: Option<f32>,
    pub y_fov_rad: f32,
    pub z_far: Option<f32>,
    pub z_near: f32,
}

pub struct OrthographicCamera {
    pub x_mag: f32,
    pub y_mag: f32,
    pub z_far: f32,
    pub z_near: f32,
}
}

Lighting

ComponentDescription
LightDirectional, Point, or Spot light
#![allow(unused)]
fn main() {
pub struct Light {
    pub light_type: LightType,
    pub color: Vec3,
    pub intensity: f32,
    pub range: f32,
    pub cast_shadows: bool,
    pub shadow_bias: f32,
    pub inner_cone_angle: f32,
    pub outer_cone_angle: f32,
}

pub enum LightType {
    Directional,
    Point,
    Spot,
}
}

Physics Components

ComponentDescription
RigidBodyComponentDynamic/Fixed/Kinematic body
ColliderComponentCollision shape
CharacterControllerComponentKinematic player controller
PhysicsInterpolationSmooth physics rendering
CollisionListenerReceives collision events
#![allow(unused)]
fn main() {
pub struct RigidBodyComponent {
    pub handle: Option<RigidBodyHandle>,
    pub body_type: RigidBodyType,
    pub translation: [f32; 3],
    pub rotation: [f32; 4],
    pub linvel: [f32; 3],
    pub angvel: [f32; 3],
    pub mass: f32,
    pub locked_axes: LockedAxes,
}

pub enum RigidBodyType {
    Dynamic,
    Fixed,
    KinematicPositionBased,
    KinematicVelocityBased,
}
}

Constructor methods:

  • RigidBodyComponent::new_dynamic()
  • RigidBodyComponent::new_static() (creates a Fixed body type)
  • RigidBodyComponent::new_kinematic() (creates KinematicPositionBased)

The component flag for rigid bodies is RIGID_BODY (not RIGID_BODY_COMPONENT).

Animation Components

ComponentDescription
AnimationPlayerAnimation playback control
SkinSkeleton definition
JointBone in skeleton
MorphWeightsBlend shape weights
#![allow(unused)]
fn main() {
pub struct AnimationPlayer {
    pub clips: Vec<AnimationClip>,
    pub current_clip: Option<usize>,
    pub blend_from_clip: Option<usize>,
    pub blend_factor: f32,
    pub playing: bool,
    pub speed: f32,
    pub time: f32,
    pub looping: bool,
}
}

Audio Components

ComponentDescription
AudioSourceSound playback
AudioListener3D audio receiver

Text Components

ComponentDescription
Text3D world text and screen-space UI text
TextCharacterColorsPer-character text colors
TextCharacterBackgroundColorsPer-character text background colors

Geometry Components

ComponentDescription
LinesDebug line rendering
#![allow(unused)]
fn main() {
pub struct Lines {
    pub lines: Vec<Line>,
}

pub struct Line {
    pub start: Vec3,
    pub end: Vec3,
    pub color: Vec4,
}
}

Advanced Components

ComponentDescription
ParticleEmitterGPU particle system
GrassRegionProcedural grass field
GrassInteractorGrass bending interaction
NavMeshAgentAI pathfinding agent
WaterWater surface/volume
DecalProjected texture
LatticeLattice deformation controller
LatticeInfluencedEntity deformed by a lattice
TweenTween animation component
PrefabSourceSource prefab reference
PrefabInstanceInstance of a spawned prefab
ScriptScripting component (requires scripting feature)
HoveredInput hover state
NameString identifier

Queries & Iteration

Queries find entities by their component masks. Since component presence is tracked as bitflags, queries are extremely fast - a single bitmask comparison per archetype table.

Basic Queries

Query entities with specific components using component flags:

#![allow(unused)]
fn main() {
for entity in world.core.query_entities(LOCAL_TRANSFORM | GLOBAL_TRANSFORM) {
    if let Some(transform) = world.core.get_local_transform(entity) {
        let position = transform.translation;
    }
}
}

The query returns all entities that have at least the specified components. An entity with LOCAL_TRANSFORM | GLOBAL_TRANSFORM | RENDER_MESH will match a query for LOCAL_TRANSFORM | GLOBAL_TRANSFORM.

Common Query Patterns

Renderable Entities

#![allow(unused)]
fn main() {
const RENDERABLE: ComponentFlags =
    LOCAL_TRANSFORM | GLOBAL_TRANSFORM | RENDER_MESH | MATERIAL_REF;

for entity in world.core.query_entities(RENDERABLE) {
    let transform = world.core.get_global_transform(entity).unwrap();
    let mesh = world.core.get_render_mesh(entity).unwrap();
}
}

Physics Entities

#![allow(unused)]
fn main() {
for entity in world.core.query_entities(RIGID_BODY | LOCAL_TRANSFORM) {
    if let Some(rb) = world.core.get_rigid_body(entity) {
        if rb.body_type == RigidBodyType::Dynamic {
            // Process dynamic bodies
        }
    }
}
}

Animated Entities

#![allow(unused)]
fn main() {
for entity in world.core.query_entities(ANIMATION_PLAYER) {
    if let Some(player) = world.core.get_animation_player_mut(entity) {
        player.speed = 1.0;
    }
}
}

First Match

For singleton-like entities, use iterator methods:

#![allow(unused)]
fn main() {
let player = world.core.query_entities(CHARACTER_CONTROLLER).next();

if let Some(player_entity) = player {
    let controller = world.core.get_character_controller(player_entity);
}
}

Filtering

Combine queries with additional runtime checks:

#![allow(unused)]
fn main() {
for entity in world.core.query_entities(LIGHT) {
    let light = world.core.get_light(entity).unwrap();
    if light.light_type == LightType::Point {
        // Process point lights only
    }
}
}

Entity Count

#![allow(unused)]
fn main() {
let renderable_count = world.core.query_entities(RENDER_MESH).count();
let light_count = world.core.query_entities(LIGHT).count();
}

Named Entity Lookup

If entities have the Name component:

#![allow(unused)]
fn main() {
fn find_by_name(world: &World, name: &str) -> Option<Entity> {
    for entity in world.core.query_entities(NAME) {
        if let Some(entity_name) = world.core.get_name(entity) {
            if entity_name.0 == name {
                return Some(entity);
            }
        }
    }
    None
}

let player = find_by_name(world, "Player");
}

The engine also maintains world.resources.entity_names as a HashMap<String, Entity> for fast name-based lookups.

Children Queries

Query children of a specific parent:

#![allow(unused)]
fn main() {
if let Some(children) = world.resources.children_cache.get(&parent_entity) {
    for child in children {
        if let Some(transform) = world.core.get_local_transform(*child) {
            // Process child
        }
    }
}
}

Collecting Results

Collect query results for later processing (useful when you need to mutate during iteration):

#![allow(unused)]
fn main() {
let enemies: Vec<Entity> = world.core.query_entities(ENEMY_TAG | HEALTH).collect();

for enemy in &enemies {
    apply_damage(world, *enemy, 10.0);
}
}

Iteration with Index

#![allow(unused)]
fn main() {
for (index, entity) in world.core.query_entities(RENDER_MESH).enumerate() {
    // index is the iteration position, not the entity ID
}
}

Querying Entities

Basic Queries

Query entities with specific components using component flags:

#![allow(unused)]
fn main() {
for entity in world.core.query_entities(LOCAL_TRANSFORM | GLOBAL_TRANSFORM) {
    if let Some(transform) = world.core.get_local_transform(entity) {
        let position = transform.translation;
    }
}
}

Common Query Patterns

Renderable Entities

#![allow(unused)]
fn main() {
const RENDERABLE: ComponentFlags = LOCAL_TRANSFORM | GLOBAL_TRANSFORM | RENDER_MESH | MATERIAL_REF;

for entity in world.core.query_entities(RENDERABLE) {
    let transform = world.core.get_global_transform(entity).unwrap();
    let mesh = world.core.get_render_mesh(entity).unwrap();
}
}

Physics Entities

#![allow(unused)]
fn main() {
for entity in world.core.query_entities(RIGID_BODY | LOCAL_TRANSFORM) {
    if let Some(rb) = world.core.get_rigid_body(entity) {
        if rb.body_type == RigidBodyType::Dynamic {
        }
    }
}
}

Animated Entities

#![allow(unused)]
fn main() {
for entity in world.core.query_entities(ANIMATION_PLAYER) {
    if let Some(player) = world.core.get_animation_player_mut(entity) {
        player.speed = 1.0;
    }
}
}

Filtering Results

Filter query results with additional checks:

#![allow(unused)]
fn main() {
for entity in world.core.query_entities(LIGHT) {
    let light = world.core.get_light(entity).unwrap();

    if light.light_type == LightType::Point {
    }
}
}

Checking Components

Check if an entity has specific components:

#![allow(unused)]
fn main() {
if world.core.entity_has_components(entity, RENDER_MESH) {
}

if world.core.entity_has_components(entity, ANIMATION_PLAYER | SKIN) {
}
}

Getting Single Entities

For singleton-like entities, query and take the first:

#![allow(unused)]
fn main() {
let player = world.core.query_entities(CHARACTER_CONTROLLER).next();

if let Some(player_entity) = player {
    let controller = world.core.get_character_controller(player_entity);
}
}

Entity Count

Count entities matching a query:

#![allow(unused)]
fn main() {
let renderable_count = world.core.query_entities(RENDER_MESH).count();
let light_count = world.core.query_entities(LIGHT).count();
}

Named Entity Lookup

If entities have the Name component:

#![allow(unused)]
fn main() {
fn find_by_name(world: &World, name: &str) -> Option<Entity> {
    for entity in world.core.query_entities(NAME) {
        if let Some(entity_name) = world.core.get_name(entity) {
            if entity_name.0 == name {
                return Some(entity);
            }
        }
    }
    None
}

let player = find_by_name(world, "Player");
}

Children Queries

Query children of a specific parent:

#![allow(unused)]
fn main() {
if let Some(children) = world.resources.children_cache.get(&parent_entity) {
    for child in children {
        if let Some(transform) = world.core.get_local_transform(*child) {
        }
    }
}
}

Iteration with Index

When you need the iteration index:

#![allow(unused)]
fn main() {
for (index, entity) in world.core.query_entities(RENDER_MESH).enumerate() {
}
}

Collecting Entities

Collect query results for later processing:

#![allow(unused)]
fn main() {
let enemies: Vec<Entity> = world.core.query_entities(ENEMY_TAG | HEALTH).collect();

for enemy in &enemies {
    apply_damage(world, *enemy, 10.0);
}
}

Resources

Resources are global singletons stored in world.resources. Unlike components (which are per-entity), each resource type exists exactly once in the world.

Accessing Resources

All resources are accessed through world.resources:

#![allow(unused)]
fn main() {
fn run_systems(&mut self, world: &mut World) {
    let dt = world.resources.window.timing.delta_time;

    if world.resources.input.keyboard.is_key_pressed(KeyCode::Space) {
        self.jump();
    }

    world.resources.graphics.bloom_enabled = true;
}
}

Resource Catalog

Time & Window

ResourceTypeDescription
windowWindowWindow handle, timing, and display info
secondary_windowsSecondaryWindowsMulti-window state
window.timingWindowTimingFrame timing: delta_time, frames_per_second, uptime_milliseconds

Input

ResourceTypeDescription
inputInputKeyboard, mouse, and gamepad state
input.keyboardKeyboardKey states, is_key_pressed(), just_pressed()
input.mouseMousePosition, delta, button state, scroll

Graphics

ResourceTypeDescription
graphicsGraphicsAll rendering settings
graphics.atmosphereAtmosphereSky type (None, Color, Sky)
graphics.bloom_enabledboolBloom toggle
graphics.ssao_enabledboolSSAO toggle
graphics.color_gradingColorGradingTonemapping, gamma, saturation, brightness, contrast

Caches

ResourceTypeDescription
mesh_cacheMeshCacheLoaded mesh data by name
material_registryMaterialRegistryRegistered materials
texture_cacheTextureCacheGPU textures
animation_cacheAnimationCacheAnimation clip data
prefab_cachePrefabCacheLoaded prefab templates
text_cacheTextCacheFont atlas and glyph data

Scene

ResourceTypeDescription
active_cameraOption<Entity>Currently rendering camera
children_cacheHashMap<Entity, Vec<Entity>>Parent-to-children mapping
entity_namesHashMap<String, Entity>Name-to-entity lookup
transform_dirty_entitiesVec<Entity>Entities needing transform propagation

Simulation

ResourceTypeFeature
physicsPhysicsWorldphysics
audioAudioEngineaudio
navmeshNavMeshWorldalways

Communication

ResourceTypeDescription
event_busEventBusMessage passing between systems
command_queueVec<WorldCommand>Deferred GPU/scene operations
frame_scheduleFrameScheduleOrdered list of engine systems dispatched each frame

Platform

ResourceTypeFeature
xrXrResourcesopenxr
steamSteamResourcessteam
script_runtimeScriptRuntimescripting
sdf_worldSdfWorldsdf_sculpt

Conditional Resources

Some resources are only available when their feature flag is enabled:

#![allow(unused)]
fn main() {
#[cfg(feature = "physics")]
{
    world.resources.physics.gravity = Vec3::new(0.0, -9.81, 0.0);
}

#[cfg(feature = "audio")]
{
    world.resources.audio.master_volume = 0.8;
}
}

World Commands

Operations that require GPU access or must be deferred are queued as commands:

#![allow(unused)]
fn main() {
world.queue_command(WorldCommand::LoadTexture {
    name: "my_texture".to_string(),
    rgba_data: texture_bytes,
    width: 256,
    height: 256,
});

world.queue_command(WorldCommand::DespawnRecursive { entity });
world.queue_command(WorldCommand::LoadHdrSkybox { hdr_data });
world.queue_command(WorldCommand::CaptureScreenshot { path: None });
}

Commands are processed during the render phase when GPU access is available.

Tags, Events & Commands

This chapter covers the communication and deferred-operation patterns in Nightshade's ECS.

Event Bus

The event bus provides decoupled communication between systems via world.resources.event_bus:

#![allow(unused)]
fn main() {
pub struct EventBus {
    pub messages: VecDeque<Message>,
}

pub enum Message {
    Input { event: InputEvent },
    App {
        type_name: &'static str,
        payload: Box<dyn Any + Send + Sync>,
    },
}
}

Publishing Events

Define event structs and publish them:

#![allow(unused)]
fn main() {
pub struct EnemyDied {
    pub entity: Entity,
    pub position: Vec3,
}

pub struct ItemPickedUp {
    pub item_type: ItemType,
    pub quantity: u32,
}

fn combat_system(world: &mut World) {
    for enemy in world.core.query_entities(ENEMY | HEALTH) {
        let health = world.core.get_health(enemy).unwrap();
        if health.current <= 0.0 {
            let position = world.core.get_global_transform(enemy)
                .map(|t| t.0.column(3).xyz())
                .unwrap_or(Vec3::zeros());

            publish_app_event(world, EnemyDied {
                entity: enemy,
                position,
            });

            world.despawn_entities(&[enemy]);
        }
    }
}
}

Consuming Events

Process events in your game loop:

#![allow(unused)]
fn main() {
fn run_systems(&mut self, world: &mut World) {
    while let Some(msg) = world.resources.event_bus.messages.pop_front() {
        match msg {
            Message::App { payload, .. } => {
                if let Some(died) = payload.downcast_ref::<EnemyDied>() {
                    self.handle_enemy_death(world, died);
                }
                if let Some(pickup) = payload.downcast_ref::<ItemPickedUp>() {
                    self.update_inventory(pickup);
                }
            }
            Message::Input { event } => {
                self.handle_input_event(event);
            }
        }
    }
}
}

Event Patterns

Events enable one-to-many communication without coupling:

#![allow(unused)]
fn main() {
// One system publishes
publish_app_event(world, DoorOpened { door_id: 42 });

// Multiple handlers respond independently
if let Some(door) = payload.downcast_ref::<DoorOpened>() {
    trigger_cutscene(world, door.door_id);
}

if let Some(door) = payload.downcast_ref::<DoorOpened>() {
    play_door_sound(world, door.door_id);
}
}

World Commands

Commands are deferred operations that require GPU access or must happen at a specific point in the frame. They are queued during run_systems and processed later during the render phase.

#![allow(unused)]
fn main() {
world.queue_command(WorldCommand::LoadTexture {
    name: "brick".to_string(),
    rgba_data: texture_bytes,
    width: 512,
    height: 512,
});

world.queue_command(WorldCommand::DespawnRecursive { entity });
world.queue_command(WorldCommand::LoadHdrSkybox { hdr_data });
world.queue_command(WorldCommand::CaptureScreenshot { path: None });
}

Available Commands

CommandDescription
LoadTextureUpload RGBA texture data to the GPU
DespawnRecursiveRemove entity and all children
LoadHdrSkyboxLoad an HDR environment map
CaptureScreenshotSave the next frame to a PNG

Immediate vs Deferred

For operations that don't need GPU access, use immediate functions:

#![allow(unused)]
fn main() {
// Immediate - happens right now
world.despawn_entities(&[entity]);
despawn_recursive_immediate(world, entity);

// Deferred - happens during render phase
world.queue_command(WorldCommand::DespawnRecursive { entity });
}

State Trait Event Handling

The State trait provides a dedicated handle_event method for processing event bus messages:

#![allow(unused)]
fn main() {
fn handle_event(&mut self, world: &mut World, message: &Message) {
    match message {
        Message::App { payload, .. } => {
            if let Some(event) = payload.downcast_ref::<MyEvent>() {
                self.process_event(world, event);
            }
        }
        _ => {}
    }
}
}

This is called by the engine after run_systems during the event dispatch phase of the frame lifecycle.

Input Events

The event bus also carries input events:

#![allow(unused)]
fn main() {
pub enum InputEvent {
    KeyboardInput { key_code: u32, state: KeyState },
    GamepadConnected { gamepad_id: usize },
    GamepadDisconnected { gamepad_id: usize },
}

pub enum KeyState {
    Pressed,
    Released,
}
}

Best Practices

  1. Keep events small - only include necessary data
  2. Process events each frame - don't let the queue grow unbounded
  3. Avoid circular events - A handling B which triggers A causes infinite loops
  4. Use commands for GPU operations, immediate functions for pure ECS operations

Math & Coordinates

Nightshade uses nalgebra_glm exclusively for all linear algebra. This chapter covers the types, conventions, and common operations you'll use throughout the engine.

Core Types

TypeDescriptionExample
Vec22D vectorScreen positions, UV coordinates
Vec33D vectorPositions, directions, colors
Vec44D vectorHomogeneous coordinates, RGBA colors
Mat44x4 matrixTransform matrices
QuatQuaternionRotations

All types are re-exported through the prelude:

#![allow(unused)]
fn main() {
use nightshade::prelude::*;

let position = Vec3::new(1.0, 2.0, 3.0);
let direction = Vec3::y();
let identity = Mat4::identity();
let rotation = Quat::identity();
}

Coordinate System

Nightshade uses a right-handed Y-up coordinate system:

    +Y (up)
     |
     |
     +--- +X (right)
    /
   /
  +Z (forward, toward camera)
  • +X points right
  • +Y points up
  • +Z points toward the camera (out of the screen)
  • -Z points into the screen (forward into the scene)

This matches the glTF convention and nalgebra_glm's default handedness.

Vector Operations

Construction

#![allow(unused)]
fn main() {
let a = Vec3::new(1.0, 2.0, 3.0);
let zero = Vec3::zeros();
let one = Vec3::new(1.0, 1.0, 1.0);
let up = Vec3::y();
let right = Vec3::x();
let forward = -Vec3::z();
}

Arithmetic

#![allow(unused)]
fn main() {
let sum = a + b;
let difference = a - b;
let scaled = a * 2.0;

// Element-wise multiplication requires component_mul
let element_wise = a.component_mul(&b);
}

The * operator on Vec2/Vec3 performs scalar multiplication, not element-wise. Use component_mul() when you need per-component multiplication.

Common Operations

#![allow(unused)]
fn main() {
let length = nalgebra_glm::length(&v);
let normalized = nalgebra_glm::normalize(&v);
let dot = nalgebra_glm::dot(&a, &b);
let cross = nalgebra_glm::cross(&a, &b);
let distance = nalgebra_glm::distance(&a, &b);
let lerped = nalgebra_glm::lerp(&a, &b, 0.5);
}

Quaternion Rotations

Nightshade uses quaternions for all rotations. They avoid gimbal lock and interpolate smoothly.

#![allow(unused)]
fn main() {
// Rotation around an axis
let rotation = nalgebra_glm::quat_angle_axis(
    std::f32::consts::FRAC_PI_4,  // 45 degrees
    &Vec3::y(),                     // around Y axis
);

// Look-at rotation
let forward = nalgebra_glm::normalize(&(target - position));
let rotation = nalgebra_glm::quat_look_at(&forward, &Vec3::y());

// Interpolation
let blended = rotation_a.slerp(&rotation_b, 0.5);

// Apply rotation to a vector
let rotated = nalgebra_glm::quat_rotate_vec3(&rotation, &direction);
}

Transform Matrices

GlobalTransform stores a 4x4 matrix. LocalTransform stores decomposed translation/rotation/scale:

#![allow(unused)]
fn main() {
pub struct LocalTransform {
    pub translation: Vec3,
    pub rotation: Quat,
    pub scale: Vec3,
}

pub struct GlobalTransform(pub Mat4);
}

Building Matrices

#![allow(unused)]
fn main() {
let translation = nalgebra_glm::translation(&Vec3::new(1.0, 2.0, 3.0));
let rotation = nalgebra_glm::quat_to_mat4(&some_quat);
let scale = nalgebra_glm::scaling(&Vec3::new(2.0, 2.0, 2.0));
let combined = translation * rotation * scale;
}

Extracting from Matrices

#![allow(unused)]
fn main() {
let global = world.core.get_global_transform(entity).unwrap();
let position = global.0.column(3).xyz();
}

Angles

nalgebra_glm works in radians. Convert from degrees when needed:

#![allow(unused)]
fn main() {
let radians = nalgebra_glm::radians(&nalgebra_glm::vec1(45.0)).x;
let degrees = nalgebra_glm::degrees(&nalgebra_glm::vec1(std::f32::consts::FRAC_PI_4)).x;
}

Depth Range and Reversed-Z

wgpu uses a [0, 1] depth range (not [-1, 1] like OpenGL).

Nightshade uses reversed-Z depth, where 0.0 is the far plane and 1.0 is the near plane. This is the opposite of the traditional convention where 0 is near and 1 is far.

Why Reversed-Z?

Floating-point numbers have more precision near zero and less precision near one (because of how the exponent and mantissa are distributed). In a standard depth buffer, the near plane maps to 0 and the far plane maps to 1. But perspective projection is highly nonlinear: most of the [0, 1] range is consumed by geometry close to the near plane, leaving very little precision for distant objects. This causes z-fighting (flickering surfaces) at medium to far distances.

Reversed-Z exploits the floating-point distribution by mapping the far plane to 0 (where float precision is highest) and the near plane to 1. The perspective nonlinearity and the floating-point precision curve partially cancel each other out, resulting in nearly uniform depth precision across the entire view range. This is especially important for large outdoor scenes.

Practically, this means:

  • Depth clear value is 0.0 (far plane)
  • Depth comparison function is Greater or GreaterEqual (closer objects have larger depth values)
  • Projection matrices are constructed with reversed_infinite_perspective_rh_zo

Time

All timing information lives in world.resources.window.timing. There is no separate Time resource.

WindowTiming

#![allow(unused)]
fn main() {
pub struct WindowTiming {
    pub frames_per_second: f32,
    pub delta_time: f32,
    pub raw_delta_time: f32,
    pub time_speed: f32,
    pub last_frame_start_instant: Option<web_time::Instant>,
    pub current_frame_start_instant: Option<web_time::Instant>,
    pub initial_frame_start_instant: Option<web_time::Instant>,
    pub frame_counter: u32,
    pub uptime_milliseconds: u64,
}
}

Accessing Time

#![allow(unused)]
fn main() {
fn run_systems(&mut self, world: &mut World) {
    let dt = world.resources.window.timing.delta_time;
    let fps = world.resources.window.timing.frames_per_second;
    let elapsed = world.resources.window.timing.uptime_milliseconds as f32 / 1000.0;
    let frame = world.resources.window.timing.frame_counter;
}
}

Delta Time

delta_time is the time in seconds since the last frame, adjusted by time_speed. Use it for all frame-rate-independent movement:

#![allow(unused)]
fn main() {
fn move_entity(world: &mut World, entity: Entity, velocity: Vec3) {
    let dt = world.resources.window.timing.delta_time;
    if let Some(transform) = world.core.get_local_transform_mut(entity) {
        transform.translation += velocity * dt;
    }
}
}

raw_delta_time is the unscaled delta time before time_speed is applied. Use raw_delta_time for UI animations or anything that should ignore time scaling.

Time Speed

Control the speed of time:

#![allow(unused)]
fn main() {
world.resources.window.timing.time_speed = 0.5;  // Half speed (slow motion)
world.resources.window.timing.time_speed = 2.0;  // Double speed
world.resources.window.timing.time_speed = 0.0;  // Paused
}

delta_time = raw_delta_time * time_speed, so setting time_speed to zero pauses all time-dependent movement without stopping the render loop.

Periodic Actions

Use an accumulator for timed events:

#![allow(unused)]
fn main() {
struct MyGame {
    spawn_timer: f32,
}

fn run_systems(&mut self, world: &mut World) {
    let dt = world.resources.window.timing.delta_time;
    self.spawn_timer += dt;
    if self.spawn_timer >= 2.0 {
        spawn_enemy(world);
        self.spawn_timer = 0.0;
    }
}
}

Uptime

uptime_milliseconds counts wall-clock time since the application started. Useful for shader animations and effects:

#![allow(unused)]
fn main() {
let time = world.resources.window.timing.uptime_milliseconds as f32 / 1000.0;
let wave = (time * 2.0).sin();
}

Web Compatibility

Timing uses web_time::Instant instead of std::time::Instant for cross-platform compatibility between native and WASM targets.

The State Trait

The State trait is the primary interface between your game and the Nightshade engine. Every game implements this trait to define its behavior.

Trait Definition

#![allow(unused)]
fn main() {
pub trait State {
    fn title(&self) -> &str { "Nightshade" }
    fn log_config(&self) -> LogConfig { LogConfig::default() }
    fn icon_bytes(&self) -> Option<&'static [u8]> { /* default: built-in icon */ }
    fn initialize(&mut self, _world: &mut World) {}
    fn next_state(&mut self, _world: &mut World) -> Option<Box<dyn State>> { None }
    fn configure_render_graph(
        &mut self,
        graph: &mut RenderGraph<World>,
        device: &wgpu::Device,
        surface_format: wgpu::TextureFormat,
        resources: RenderResources,
    ) { /* default: bloom + post-processing + swapchain blit */ }
    fn ui(&mut self, _world: &mut World, _ui_context: &egui::Context) {}
    fn secondary_ui(&mut self, _world: &mut World, _window_index: usize, _ui_context: &egui::Context) {}
    fn run_systems(&mut self, _world: &mut World) {}
    fn pre_render(&mut self, _renderer: &mut dyn Render, _world: &mut World) {}
    fn update_render_graph(&mut self, _graph: &mut RenderGraph<World>, _world: &World) {}
    fn handle_event(&mut self, _world: &mut World, _message: &Message) {}
    fn on_keyboard_input(&mut self, _world: &mut World, _key_code: KeyCode, _key_state: ElementState) {}
    fn on_dropped_file(&mut self, _world: &mut World, _path: &std::path::Path) {}
    fn on_dropped_file_data(&mut self, _world: &mut World, _name: &str, _data: &[u8]) {}
    fn on_hovered_file(&mut self, _world: &mut World, _path: &std::path::Path) {}
    fn on_hovered_file_cancelled(&mut self, _world: &mut World) {}
    fn on_gamepad_event(&mut self, _world: &mut World, _event: gilrs::Event) {}
    fn on_suspend(&mut self, _world: &mut World) {}
    fn on_resume(&mut self, _world: &mut World) {}
    fn on_mouse_input(&mut self, _world: &mut World, _state: ElementState, _button: MouseButton) {}
    fn handle_mcp_command(&mut self, _world: &mut World, _command: &McpCommand) -> Option<McpResponse> { None }
    fn after_mcp_command(&mut self, _world: &mut World, _command: &McpCommand, _response: &McpResponse) {}
}
}

All methods have default implementations, so you only need to implement the ones relevant to your game.

Commonly Used Methods

title()

Returns the window title. Defaults to "Nightshade" if not overridden:

#![allow(unused)]
fn main() {
fn title(&self) -> &str {
    "My Awesome Game"
}
}

log_config()

Configure file-based logging. Desktop only, requires the tracing feature. Returns a LogConfig with directory, rotation strategy, and default log filter:

#![allow(unused)]
fn main() {
fn log_config(&self) -> LogConfig {
    LogConfig {
        directory: "logs".to_string(),
        rotation: LogRotation::Daily,
        default_filter: "info".to_string(),
        timestamp_format: "%Y-%m-%d_%H-%M-%S".to_string(),
    }
}
}

Rotation options: LogRotation::PerSession (new file each launch), LogRotation::Daily, LogRotation::Never (append to single file).

initialize()

Called once when the application starts. Use this to set up your initial game state:

#![allow(unused)]
fn main() {
fn initialize(&mut self, world: &mut World) {
    world.resources.graphics.atmosphere = Atmosphere::Sky;

    let camera = spawn_camera(world, Vec3::new(0.0, 5.0, 10.0), "Camera".to_string());
    world.resources.active_camera = Some(camera);

    self.player = Some(spawn_player(world));
}
}

run_systems()

Called every frame. This is where your game logic lives:

#![allow(unused)]
fn main() {
fn run_systems(&mut self, world: &mut World) {
    player_movement_system(world);
    enemy_ai_system(world);
    collision_response_system(world);
}
}

Optional Methods

ui()

For egui-based user interfaces. Requires the egui feature. Note that world comes before ui_context:

#![allow(unused)]
fn main() {
fn ui(&mut self, world: &mut World, ui_context: &egui::Context) {
    egui::Window::new("Debug").show(ui_context, |ui| {
        ui.label(format!("FPS: {:.0}", world.resources.window.timing.frames_per_second));
        ui.label(format!("Entities: {}", world.core.query_entities(RENDER_MESH).count()));
    });
}
}

on_keyboard_input()

Handle keyboard events directly:

#![allow(unused)]
fn main() {
fn on_keyboard_input(&mut self, world: &mut World, key_code: KeyCode, key_state: ElementState) {
    if key_state == ElementState::Pressed {
        match key_code {
            KeyCode::Escape => self.paused = !self.paused,
            KeyCode::F11 => toggle_fullscreen(world),
            _ => {}
        }
    }
}
}

on_mouse_input()

Handle mouse button events:

#![allow(unused)]
fn main() {
fn on_mouse_input(&mut self, world: &mut World, state: ElementState, button: MouseButton) {
    if state == ElementState::Pressed {
        match button {
            MouseButton::Left => self.shoot(world),
            MouseButton::Right => self.aim(world),
            _ => {}
        }
    }
}
}

on_gamepad_event()

Handle gamepad button presses:

#![allow(unused)]
fn main() {
fn on_gamepad_event(&mut self, world: &mut World, event: gilrs::Event) {
    if let gilrs::EventType::ButtonPressed(button, _) = event.event {
        match button {
            gilrs::Button::Start => self.paused = !self.paused,
            gilrs::Button::South => self.player_jump(),
            _ => {}
        }
    }
}
}

on_suspend() / on_resume()

Android lifecycle hooks. Called when the app is suspended (backgrounded) or resumed. Use these to release and restore GPU resources:

#![allow(unused)]
fn main() {
fn on_suspend(&mut self, world: &mut World) {
    self.save_state(world);
}

fn on_resume(&mut self, world: &mut World) {
    self.restore_state(world);
}
}

secondary_ui()

For multi-window applications using egui. Called with a window_index parameter that identifies which secondary window is being drawn, allowing you to render different UI per window. Requires the egui feature:

#![allow(unused)]
fn main() {
fn secondary_ui(&mut self, world: &mut World, window_index: usize, ui_context: &egui::Context) {
    match window_index {
        0 => {
            egui::Window::new("Inspector").show(ui_context, |ui| {
                ui.label("Secondary window 0");
            });
        }
        1 => {
            egui::Window::new("Scene View").show(ui_context, |ui| {
                ui.label("Secondary window 1");
            });
        }
        _ => {}
    }
}
}

configure_render_graph()

Customize the rendering pipeline. Called once during initialization. The default implementation sets up bloom, post-processing, and swapchain blit passes. Override this to replace or extend the default pipeline:

#![allow(unused)]
fn main() {
fn configure_render_graph(
    &mut self,
    graph: &mut RenderGraph<World>,
    device: &wgpu::Device,
    surface_format: wgpu::TextureFormat,
    resources: RenderResources,
) {
    let bloom_pass = passes::BloomPass::new(device, 1920, 1080);
    graph.add_pass(
        Box::new(bloom_pass),
        &[("input", resources.scene_color), ("output", resources.bloom)],
    );
}
}

update_render_graph()

Called each frame to update render graph state dynamically:

#![allow(unused)]
fn main() {
fn update_render_graph(&mut self, graph: &mut RenderGraph<World>, world: &World) {
    if self.bloom_changed {
        graph.set_enabled("bloom_pass", self.bloom_enabled);
        self.bloom_changed = false;
    }
}
}

pre_render()

Called before rendering begins each frame. Useful for custom GPU uploads or renderer state changes:

#![allow(unused)]
fn main() {
fn pre_render(&mut self, renderer: &mut dyn Render, world: &mut World) {
    renderer.update_custom_buffer(world, &self.custom_data);
}
}

next_state()

Allows transitioning to a different State. Return Some(new_state) to switch:

#![allow(unused)]
fn main() {
fn next_state(&mut self, world: &mut World) -> Option<Box<dyn State>> {
    if self.transition_to_gameplay {
        Some(Box::new(GameplayState::new()))
    } else {
        None
    }
}
}

on_dropped_file() / on_dropped_file_data()

Handle files dropped onto the window:

#![allow(unused)]
fn main() {
fn on_dropped_file(&mut self, world: &mut World, path: &Path) {
    if path.extension() == Some("glb".as_ref()) {
        self.load_model(world, path);
    }
}

fn on_dropped_file_data(&mut self, world: &mut World, name: &str, data: &[u8]) {
    self.process_dropped_data(world, name, data);
}
}

on_hovered_file() / on_hovered_file_cancelled()

Handle file drag hover events:

#![allow(unused)]
fn main() {
fn on_hovered_file(&mut self, world: &mut World, path: &Path) {
    self.show_drop_indicator = true;
}

fn on_hovered_file_cancelled(&mut self, world: &mut World) {
    self.show_drop_indicator = false;
}
}

handle_mcp_command()

Intercept MCP commands before the engine processes them. Requires the mcp feature. Return Some(response) to handle a command yourself, or None to let the engine handle it with default behavior:

#![allow(unused)]
fn main() {
#[cfg(all(feature = "mcp", not(target_arch = "wasm32")))]
fn handle_mcp_command(
    &mut self,
    world: &mut World,
    command: &McpCommand,
) -> Option<McpResponse> {
    match command {
        McpCommand::SpawnEntity { name, .. } => {
            self.pending_scene_refresh = true;
            None
        }
        _ => None,
    }
}
}

See AI Integration for full details on the MCP system.

after_mcp_command()

Called after an MCP command has been processed (either by the engine or by your handle_mcp_command override). Receives both the command and the response. Useful for recording undo entries, refreshing UI state, or reacting to the outcome of MCP operations:

#![allow(unused)]
fn main() {
#[cfg(all(feature = "mcp", not(target_arch = "wasm32")))]
fn after_mcp_command(
    &mut self,
    world: &mut World,
    command: &McpCommand,
    response: &McpResponse,
) {
    if let McpResponse::Success(_) = response {
        match command {
            McpCommand::SpawnEntity { name, .. } => {
                self.scene_tree_dirty = true;
            }
            McpCommand::DespawnEntity { .. } => {
                self.scene_tree_dirty = true;
            }
            _ => {}
        }
    }
}
}

handle_event()

Handle custom EventBus messages:

#![allow(unused)]
fn main() {
fn handle_event(&mut self, world: &mut World, message: &Message) {
    match message {
        Message::Custom(data) => self.process_event(world, data),
        _ => {}
    }
}
}

Launching Your Game

Use the nightshade::launch function to run your game:

fn main() -> Result<(), Box<dyn std::error::Error>> {
    nightshade::launch(MyGame::default())
}

Or with an HDR skybox:

#![allow(unused)]
fn main() {
fn initialize(&mut self, world: &mut World) {
    load_hdr_skybox(world, include_bytes!("../assets/sky.hdr").to_vec());
}
}

Main Loop

Understanding the frame lifecycle helps you structure game logic correctly and debug timing issues.

Frame Execution Order

Each frame executes in this order:

1.  Process window/input events (winit)
2.  Update input state from events
3.  Calculate delta time
4.  Begin egui frame (if enabled)
5.  Call State::run_systems() — Your game logic
6.  Dispatch EventBus messages
7.  Process MCP commands (if mcp feature enabled)
    - Calls State::handle_mcp_command() (pre-hook)
    - Executes command
    - Calls State::after_mcp_command() (post-hook)
8.  Run FrameSchedule — Engine systems dispatched in order:
    a. Initialize and update audio (if audio feature)
    b. Update camera aspect ratios
    c. Step physics simulation (if physics feature)
    d. Run scripts (if scripting feature)
    e. Update tweens
    f. Update animation players
    g. Apply animations to transforms
    h. Propagate transform hierarchy
    i. Update instanced mesh caches
    j. Run retained UI systems (input sync, picking, layout, rendering)
    k. Reset mouse, keyboard, and touch input state
    l. Process deferred commands
    m. Cleanup unused resources
9.  Execute render graph passes
10. End egui frame
11. Present to swapchain

The frame schedule is stored as a resource at world.resources.frame_schedule. You can customize it in State::initialize() to insert your own systems between engine systems, remove systems you don't need, or reorder dispatch:

#![allow(unused)]
fn main() {
fn initialize(&mut self, world: &mut World) {
    world.resources.frame_schedule.insert_after(
        system_names::RUN_PHYSICS,
        "my_gameplay_system",
        my_gameplay_system,
    );

    world.resources.frame_schedule.remove(system_names::UI_LAYOUT_COMPUTE);
}
}

See system_names in the prelude for the full list of engine system name constants.

Timing

All timing information is accessed through world.resources.window.timing:

#![allow(unused)]
fn main() {
pub struct WindowTiming {
    pub frames_per_second: f32,
    pub delta_time: f32,
    pub raw_delta_time: f32,
    pub time_speed: f32,
    pub last_frame_start_instant: Option<web_time::Instant>,
    pub current_frame_start_instant: Option<web_time::Instant>,
    pub initial_frame_start_instant: Option<web_time::Instant>,
    pub frame_counter: u32,
    pub uptime_milliseconds: u64,
}

fn run_systems(&mut self, world: &mut World) {
    let dt = world.resources.window.timing.delta_time;
    let elapsed = world.resources.window.timing.uptime_milliseconds as f32 / 1000.0;
    let frame = world.resources.window.timing.frame_counter;
}
}

Fixed Timestep Physics

Physics runs at a fixed 60 Hz regardless of frame rate:

#![allow(unused)]
fn main() {
const PHYSICS_TIMESTEP: f32 = 1.0 / 60.0;

fn update_physics(world: &mut World, dt: f32) {
    world.resources.physics_accumulator += dt;

    while world.resources.physics_accumulator >= PHYSICS_TIMESTEP {
        store_physics_state(world);
        world.resources.physics.step(PHYSICS_TIMESTEP);
        world.resources.physics_accumulator -= PHYSICS_TIMESTEP;
    }

    let alpha = world.resources.physics_accumulator / PHYSICS_TIMESTEP;
    interpolate_physics_transforms(world, alpha);
}
}

Physics Interpolation

For smooth rendering between physics steps:

#![allow(unused)]
fn main() {
pub struct PhysicsInterpolation {
    pub previous_translation: Vec3,
    pub previous_rotation: Quat,
    pub current_translation: Vec3,
    pub current_rotation: Quat,
}

fn interpolate_physics_transforms(world: &mut World, alpha: f32) {
    for entity in world.core.query_entities(PHYSICS_INTERPOLATION) {
        let interp = world.core.get_physics_interpolation(entity).unwrap();
        let translation = interp.previous_translation.lerp(&interp.current_translation, alpha);
        let rotation = interp.previous_rotation.slerp(&interp.current_rotation, alpha);
    }
}
}

System Ordering

Before Physics

Place movement and input handling before physics:

#![allow(unused)]
fn main() {
fn run_systems(&mut self, world: &mut World) {
    handle_input(world);

    player_movement_system(world);

    ai_decision_system(world);
}
}

After Physics

Query physics results after the step:

#![allow(unused)]
fn main() {
fn run_systems(&mut self, world: &mut World) {
    let contacts = get_all_contacts(world);
    for contact in contacts {
        handle_collision(world, contact);
    }
}
}

Delta Time Usage

Always multiply movement by delta time for frame-rate independence:

#![allow(unused)]
fn main() {
fn move_entity(world: &mut World, entity: Entity, velocity: Vec3) {
    let dt = world.resources.window.timing.delta_time;

    if let Some(transform) = world.core.get_local_transform_mut(entity) {
        transform.translation += velocity * dt;
    }
}
}

Accumulating Time

For periodic actions:

#![allow(unused)]
fn main() {
struct MyGame {
    spawn_timer: f32,
}

fn run_systems(&mut self, world: &mut World) {
    let dt = world.resources.window.timing.delta_time;

    self.spawn_timer += dt;
    if self.spawn_timer >= 2.0 {
        spawn_enemy(world);
        self.spawn_timer = 0.0;
    }
}
}

Entry Points

Desktop

fn main() -> Result<(), Box<dyn std::error::Error>> {
    nightshade::launch(MyGame::default())
}

WASM

#![allow(unused)]
fn main() {
#[wasm_bindgen(start)]
pub async fn start() {
    nightshade::launch(MyGame::default()).await;
}
}

VR (OpenXR)

fn main() -> Result<(), Box<dyn std::error::Error>> {
    nightshade::launch_xr(MyGame::default())
}

Debugging Frame Issues

Frame Spikes

If you see occasional stuttering:

#![allow(unused)]
fn main() {
fn run_systems(&mut self, world: &mut World) {
    let dt = world.resources.window.timing.delta_time;
    if dt > 0.1 {
        tracing::warn!("Long frame: {:.3}s", dt);
    }
}
}

Consistent Slowdown

Profile your systems:

#![allow(unused)]
fn main() {
fn run_systems(&mut self, world: &mut World) {
    let start = std::time::Instant::now();

    expensive_system(world);

    let elapsed = start.elapsed();
    if elapsed.as_millis() > 5 {
        tracing::info!("expensive_system took {:?}", elapsed);
    }
}
}

Best Practices

  1. Don't block the main thread: Use async for file I/O
  2. Batch similar operations: Process all enemies together, not interleaved
  3. Use spatial partitioning: For collision checks with many entities
  4. Profile before optimizing: Measure, don't guess
  5. Consider fixed timestep for gameplay: Not just physics

World & Resources

The World is the central data container in Nightshade. It holds all entities, components, and global resources.

World Structure

The World is generated by the freecs::ecs! macro and contains:

#![allow(unused)]
fn main() {
pub struct World {
    pub entities: EntityStorage,
    pub resources: Resources,
}
}

Resources

Resources are global singletons accessible throughout the engine:

#![allow(unused)]
fn main() {
pub struct Resources {
    pub world_id: u64,
    pub is_runtime: bool,
    pub window: Window,
    pub secondary_windows: SecondaryWindows,
    pub user_interface: UserInterface,
    pub graphics: Graphics,
    pub input: Input,
    #[cfg(feature = "audio")]
    pub audio: AudioEngine,
    #[cfg(feature = "physics")]
    pub physics: PhysicsWorld,
    pub navmesh: NavMeshWorld,
    pub text_cache: TextCache,
    pub mesh_cache: MeshCache,
    pub animation_cache: AnimationCache,
    pub prefab_cache: PrefabCache,
    pub material_registry: MaterialRegistry,
    pub texture_cache: TextureCache,
    pub pending_font_loads: Vec<PendingFontLoad>,
    pub active_camera: Option<Entity>,
    pub event_bus: EventBus,
    pub command_queue: Vec<WorldCommand>,
    pub transform_dirty_entities: Vec<Entity>,
    pub children_cache: HashMap<Entity, Vec<Entity>>,
    pub children_cache_valid: bool,
    pub cleanup_frame_counter: u64,
    pub dropped_files: Vec<DroppedFile>,
    pub skinning_offsets: HashMap<Entity, usize>,
    pub total_skinning_joints: u32,
    #[cfg(feature = "scripting")]
    pub script_runtime: ScriptRuntime,
    #[cfg(feature = "openxr")]
    pub xr: XrResources,
    #[cfg(all(feature = "steam", not(target_arch = "wasm32")))]
    pub steam: SteamResources,
    #[cfg(feature = "physics")]
    pub picking_world: PickingWorld,
    pub gpu_picking: GpuPicking,
    #[cfg(feature = "sdf_sculpt")]
    pub sdf_world: SdfWorld,
    #[cfg(feature = "sdf_sculpt")]
    pub sdf_materials: SdfMaterialRegistry,
    pub mesh_render_state: MeshRenderState,
    #[cfg(feature = "scene_graph")]
    pub asset_registry: AssetRegistry,
    pub entity_names: HashMap<String, Entity>,
    pub entity_tags: HashMap<Entity, Vec<String>>,
    pub entity_metadata: HashMap<Entity, HashMap<String, MetadataValue>>,
    pub pending_particle_textures: Vec<ParticleTextureUpload>,
    pub ibl_views: IblViews,
    pub retained_ui: RetainedUiState,
    pub frame_schedule: FrameSchedule,
    pub sprite_slot_allocator: SpriteSlotAllocator,
    #[cfg(all(feature = "mcp", not(target_arch = "wasm32")))]
    pub mcp_command_queue: CommandQueue,
}
}

There is no separate Time resource. Timing information is accessed through world.resources.window.timing.

Accessing Resources

Resources are accessed through world.resources:

#![allow(unused)]
fn main() {
fn run_systems(&mut self, world: &mut World) {
    let dt = world.resources.window.timing.delta_time;

    if world.resources.input.keyboard.is_key_pressed(KeyCode::Space) {
        self.jump();
    }

    world.resources.graphics.bloom_enabled = true;
    world.resources.graphics.bloom_intensity = 0.5;
}
}

Common Resources

Time & Timing

Timing is accessed through world.resources.window.timing:

#![allow(unused)]
fn main() {
let dt = world.resources.window.timing.delta_time;
let fps = world.resources.window.timing.frames_per_second;
let elapsed = world.resources.window.timing.uptime_milliseconds as f32 / 1000.0;
}

The WindowTiming struct contains:

#![allow(unused)]
fn main() {
pub struct WindowTiming {
    pub frames_per_second: f32,
    pub delta_time: f32,
    pub raw_delta_time: f32,
    pub time_speed: f32,
    pub last_frame_start_instant: Option<web_time::Instant>,
    pub current_frame_start_instant: Option<web_time::Instant>,
    pub initial_frame_start_instant: Option<web_time::Instant>,
    pub frame_counter: u32,
    pub uptime_milliseconds: u64,
}
}

Input

#![allow(unused)]
fn main() {
if world.resources.input.keyboard.is_key_pressed(KeyCode::KeyW) {
    move_forward();
}

let mouse_pos = world.resources.input.mouse.position;

if world.resources.input.mouse.state.contains(MouseState::LEFT_JUST_PRESSED) {
    shoot();
}
}

Graphics Settings

#![allow(unused)]
fn main() {
world.resources.graphics.show_grid = true;
world.resources.graphics.atmosphere = Atmosphere::Sky;
world.resources.graphics.bloom_enabled = true;
world.resources.graphics.ssao_enabled = true;

world.resources.graphics.color_grading.tonemap_algorithm = TonemapAlgorithm::Aces;
}

Active Camera

#![allow(unused)]
fn main() {
world.resources.active_camera = Some(camera_entity);

if let Some(camera) = world.resources.active_camera {
    let transform = world.core.get_global_transform(camera);
}
}

World Commands

Operations that require GPU access or must be deferred are queued as WorldCommand values and processed during the render phase:

#![allow(unused)]
fn main() {
world.queue_command(WorldCommand::LoadTexture {
    name: "my_texture".to_string(),
    rgba_data: texture_bytes,
    width: 256,
    height: 256,
});

world.queue_command(WorldCommand::DespawnRecursive { entity });
world.queue_command(WorldCommand::LoadHdrSkybox { hdr_data });
world.queue_command(WorldCommand::CaptureScreenshot { path: None });
}

For immediate recursive despawning without deferral:

#![allow(unused)]
fn main() {
despawn_recursive_immediate(world, entity);
}

Scene Hierarchy

Nightshade supports parent-child relationships between entities, enabling hierarchical transforms where child entities move relative to their parents.

Parent-Child Relationships

Setting a Parent

#![allow(unused)]
fn main() {
world.core.set_parent(child_entity, Parent(Some(parent_entity)));
world.core.set_local_transform_dirty(child_entity, LocalTransformDirty);
}

When you set a parent, the child's LocalTransform becomes relative to the parent's position, rotation, and scale.

Getting Children

#![allow(unused)]
fn main() {
if let Some(children) = world.resources.children_cache.get(&parent_entity) {
    for child in children {
    }
}
}

Removing a Parent

#![allow(unused)]
fn main() {
world.core.set_parent(child_entity, Parent(None));
}

Transform Propagation

Each frame, transforms propagate through the hierarchy:

#![allow(unused)]
fn main() {
propagate_transforms(world);
}

How It Works

  1. Find all entities marked with LocalTransformDirty
  2. Mark all their descendants as dirty
  3. Sort entities by depth (parents before children)
  4. For each entity:
    • If has parent: GlobalTransform = parent.GlobalTransform * LocalTransform
    • If no parent: GlobalTransform = LocalTransform.to_matrix()
  5. Remove the LocalTransformDirty marker

Local vs Global Transform

#![allow(unused)]
fn main() {
pub struct LocalTransform {
    pub translation: Vec3,
    pub rotation: Quat,
    pub scale: Vec3,
}

pub struct GlobalTransform(pub Mat4);
}
  • LocalTransform: Position, rotation, scale relative to parent (or world if no parent)
  • GlobalTransform: Final world-space transformation matrix used for rendering

Scene Serialization

Scenes can be saved and loaded for level editing and persistence.

Scene Structure

#![allow(unused)]
fn main() {
pub struct Scene {
    pub name: String,
    pub entities: Vec<SerializedEntity>,
    pub hierarchy: Vec<HierarchyNode>,
    pub assets: SceneAssets,
}

pub struct SerializedEntity {
    pub id: u64,
    pub name: Option<String>,
    pub components: SerializedComponents,
}

pub struct SceneAssets {
    pub textures: Vec<TextureReference>,
    pub materials: Vec<MaterialReference>,
    pub meshes: Vec<MeshReference>,
}
}

Saving a Scene

#![allow(unused)]
fn main() {
let scene = world_to_scene(world);
save_scene(&scene, "level1.scene")?;
}

Loading a Scene

#![allow(unused)]
fn main() {
let scene = load_scene("level1.scene")?;
spawn_scene(world, &scene);
}

Binary Serialization

For faster loading and smaller file sizes:

#![allow(unused)]
fn main() {
let bytes = serialize_scene_binary(&scene)?;
let scene = deserialize_scene_binary(&bytes)?;
}

Recursive Operations

Despawning with Children

#![allow(unused)]
fn main() {
despawn_recursive_immediate(world, parent_entity);
}

This removes the entity and all its descendants from the world.

Cloning Hierarchy

#![allow(unused)]
fn main() {
let clone = clone_entity_recursive(world, original_entity);
}

Creates a deep copy of an entity and all its children.

Example: Building a Robot Arm

#![allow(unused)]
fn main() {
fn spawn_robot_arm(world: &mut World) -> Entity {
    let base = spawn_cube_at(world, Vec3::zeros());
    world.core.set_name(base, Name("Base".to_string()));

    let lower_arm = spawn_cube_at(world, Vec3::new(0.0, 1.5, 0.0));
    world.core.set_name(lower_arm, Name("Lower Arm".to_string()));
    world.core.set_parent(lower_arm, Parent(Some(base)));

    let upper_arm = spawn_cube_at(world, Vec3::new(0.0, 2.0, 0.0));
    world.core.set_name(upper_arm, Name("Upper Arm".to_string()));
    world.core.set_parent(upper_arm, Parent(Some(lower_arm)));

    let hand = spawn_cube_at(world, Vec3::new(0.0, 1.5, 0.0));
    world.core.set_name(hand, Name("Hand".to_string()));
    world.core.set_parent(hand, Parent(Some(upper_arm)));

    base
}

fn rotate_arm(world: &mut World, lower_arm: Entity, angle: f32) {
    if let Some(transform) = world.core.get_local_transform_mut(lower_arm) {
        transform.rotation = nalgebra_glm::quat_angle_axis(
            angle,
            &Vec3::z(),
        );
    }
    world.core.set_local_transform_dirty(lower_arm, LocalTransformDirty);
}
}

Rotating the lower arm automatically rotates the upper arm and hand with it.

Input System

Input state is aggregated each frame into world.resources.input. Nightshade supports keyboard, mouse, and gamepad input through both polling and event-driven patterns.

Polling Input

Check input state at any point during run_systems:

#![allow(unused)]
fn main() {
fn run_systems(&mut self, world: &mut World) {
    // Keyboard
    if world.resources.input.keyboard.is_key_pressed(KeyCode::KeyW) {
        move_forward(world);
    }

    // Mouse position
    let mouse_pos = world.resources.input.mouse.position;

    // Mouse buttons
    if world.resources.input.mouse.state.contains(MouseState::LEFT_JUST_PRESSED) {
        shoot(world);
    }
}
}

Event-Driven Input

Handle input events through the State trait:

#![allow(unused)]
fn main() {
fn on_keyboard_input(&mut self, world: &mut World, key_code: KeyCode, key_state: ElementState) {
    if key_state == ElementState::Pressed {
        match key_code {
            KeyCode::Escape => self.paused = !self.paused,
            KeyCode::F11 => toggle_fullscreen(world),
            _ => {}
        }
    }
}

fn on_mouse_input(&mut self, world: &mut World, state: ElementState, button: MouseButton) {
    if state == ElementState::Pressed && button == MouseButton::Left {
        self.shoot(world);
    }
}
}

Built-in Systems

Nightshade provides ready-to-use input systems:

#![allow(unused)]
fn main() {
fn run_systems(&mut self, world: &mut World) {
    // WASD + mouse fly camera
    fly_camera_system(world);

    // Escape key exits the application
    escape_key_exit_system(world);

    // Pan-orbit camera (middle mouse drag to orbit, scroll to zoom)
    pan_orbit_camera_system(world);
}
}

Chapters

Keyboard & Mouse

Handle keyboard and mouse input through the input resources.

Keyboard Input

Checking Key State

#![allow(unused)]
fn main() {
fn run_systems(&mut self, world: &mut World) {
    let keyboard = &world.resources.input.keyboard;

    // Key currently held down
    if keyboard.is_key_pressed(KeyCode::KeyW) {
        move_forward();
    }

    if keyboard.is_key_pressed(KeyCode::Space) {
        jump();
    }

    if keyboard.is_key_pressed(KeyCode::ShiftLeft) {
        sprint();
    }
}
}

Common Key Codes

KeyCode
LettersKeyCode::KeyA through KeyCode::KeyZ
NumbersKeyCode::Digit0 through KeyCode::Digit9
Arrow keysKeyCode::ArrowUp, ArrowDown, ArrowLeft, ArrowRight
SpaceKeyCode::Space
ShiftKeyCode::ShiftLeft, KeyCode::ShiftRight
ControlKeyCode::ControlLeft, KeyCode::ControlRight
AltKeyCode::AltLeft, KeyCode::AltRight
EscapeKeyCode::Escape
EnterKeyCode::Enter
TabKeyCode::Tab
F keysKeyCode::F1 through KeyCode::F12

Direct Event Handling

Handle key events in the State trait:

#![allow(unused)]
fn main() {
fn on_keyboard_input(&mut self, world: &mut World, key: KeyCode, state: ElementState) {
    if state == ElementState::Pressed {
        match key {
            KeyCode::Escape => self.paused = !self.paused,
            KeyCode::F11 => toggle_fullscreen(world),
            KeyCode::Digit1 => self.select_weapon(0),
            KeyCode::Digit2 => self.select_weapon(1),
            _ => {}
        }
    }
}
}

Mouse Input

Mouse Position

#![allow(unused)]
fn main() {
let mouse = &world.resources.input.mouse;
let position = mouse.position;  // Screen coordinates (x, y)
}

Mouse Movement (Delta)

#![allow(unused)]
fn main() {
let delta = world.resources.input.mouse.position_delta;
camera_yaw += delta.x * sensitivity;
camera_pitch += delta.y * sensitivity;
}

Mouse Buttons

#![allow(unused)]
fn main() {
let mouse = &world.resources.input.mouse;

// Button held
if mouse.state.contains(MouseState::LEFT_CLICKED) {
    fire_weapon();
}

// Button just pressed
if mouse.state.contains(MouseState::LEFT_JUST_PRESSED) {
    start_drag();
}

// Button just released
if mouse.state.contains(MouseState::LEFT_JUST_RELEASED) {
    end_drag();
}

// Right mouse button
if mouse.state.contains(MouseState::RIGHT_CLICKED) {
    aim_down_sights();
}

// Middle mouse button
if mouse.state.contains(MouseState::MIDDLE_CLICKED) {
    pan_camera();
}
}

Mouse Scroll

#![allow(unused)]
fn main() {
let scroll = world.resources.input.mouse.wheel_delta;
if scroll.y != 0.0 {
    zoom_camera(scroll.y);
}
}

Direct Event Handling

#![allow(unused)]
fn main() {
fn on_mouse_input(&mut self, world: &mut World, state: ElementState, button: MouseButton) {
    match (button, state) {
        (MouseButton::Left, ElementState::Pressed) => self.shoot(),
        (MouseButton::Right, ElementState::Pressed) => self.aim(),
        (MouseButton::Right, ElementState::Released) => self.stop_aim(),
        _ => {}
    }
}
}

Movement Input Pattern

Common WASD movement:

#![allow(unused)]
fn main() {
fn get_movement_input(world: &World) -> Vec3 {
    let keyboard = &world.resources.input.keyboard;
    let mut direction = Vec3::zeros();

    if keyboard.is_key_pressed(KeyCode::KeyW) {
        direction.z -= 1.0;
    }
    if keyboard.is_key_pressed(KeyCode::KeyS) {
        direction.z += 1.0;
    }
    if keyboard.is_key_pressed(KeyCode::KeyA) {
        direction.x -= 1.0;
    }
    if keyboard.is_key_pressed(KeyCode::KeyD) {
        direction.x += 1.0;
    }

    if direction.magnitude() > 0.0 {
        direction.normalize_mut();
    }

    direction
}
}

Mouse Look Pattern

First-person camera control:

#![allow(unused)]
fn main() {
fn mouse_look_system(world: &mut World, sensitivity: f32) {
    let delta = world.resources.input.mouse.position_delta;

    if let Some(camera) = world.resources.active_camera {
        if let Some(transform) = world.core.get_local_transform_mut(camera) {
            // Horizontal rotation (yaw)
            let yaw = nalgebra_glm::quat_angle_axis(
                -delta.x * sensitivity,
                &Vec3::y(),
            );

            // Vertical rotation (pitch) - clamped
            let pitch = nalgebra_glm::quat_angle_axis(
                -delta.y * sensitivity,
                &Vec3::x(),
            );

            transform.rotation = yaw * transform.rotation * pitch;
        }
    }
}
}

Cursor Visibility

For first-person games:

#![allow(unused)]
fn main() {
fn initialize(&mut self, world: &mut World) {
    world.set_cursor_locked(true);
    world.set_cursor_visible(false);
}
}

Key Bindings

Create rebindable controls:

#![allow(unused)]
fn main() {
struct KeyBindings {
    move_forward: KeyCode,
    move_back: KeyCode,
    move_left: KeyCode,
    move_right: KeyCode,
    jump: KeyCode,
    sprint: KeyCode,
}

impl Default for KeyBindings {
    fn default() -> Self {
        Self {
            move_forward: KeyCode::KeyW,
            move_back: KeyCode::KeyS,
            move_left: KeyCode::KeyA,
            move_right: KeyCode::KeyD,
            jump: KeyCode::Space,
            sprint: KeyCode::ShiftLeft,
        }
    }
}
}

Input Buffer

Buffer inputs for responsive controls:

#![allow(unused)]
fn main() {
struct InputBuffer {
    jump_buffer: f32,
}

fn update_input_buffer(buffer: &mut InputBuffer, world: &World, dt: f32) {
    buffer.jump_buffer = (buffer.jump_buffer - dt).max(0.0);

    if world.resources.input.keyboard.is_key_pressed(KeyCode::Space) {
        buffer.jump_buffer = 0.15;
    }
}

fn try_jump(buffer: &mut InputBuffer, grounded: bool) -> bool {
    if grounded && buffer.jump_buffer > 0.0 {
        buffer.jump_buffer = 0.0;
        return true;
    }
    false
}
}

Gamepad Support

Nightshade provides cross-platform gamepad support through the gilrs library.

Enabling Gamepad

Gamepad requires the gamepad feature:

[dependencies]
nightshade = { git = "...", features = ["engine", "gamepad"] }

Gamepad Resource

The gamepad state lives in world.resources.input.gamepad, which wraps the gilrs library:

#![allow(unused)]
fn main() {
pub struct Gamepad {
    pub gilrs: Option<gilrs::Gilrs>,
    pub gamepad: Option<gilrs::GamepadId>,
    pub events: Vec<gilrs::Event>,
}
}

The engine automatically initializes gilrs and tracks the active gamepad.

Querying the Active Gamepad

Use query_active_gamepad to get a gilrs gamepad handle for polling input:

#![allow(unused)]
fn main() {
use nightshade::ecs::input::queries::query_active_gamepad;

fn run_systems(&mut self, world: &mut World) {
    if let Some(gamepad) = query_active_gamepad(world) {
        let left_x = gamepad.value(gilrs::Axis::LeftStickX);
        let left_y = gamepad.value(gilrs::Axis::LeftStickY);

        if gamepad.is_pressed(gilrs::Button::South) {
            self.jump();
        }
    }
}
}

Button Input

Button States

#![allow(unused)]
fn main() {
use nightshade::ecs::input::queries::query_active_gamepad;

fn run_systems(&mut self, world: &mut World) {
    if let Some(gamepad) = query_active_gamepad(world) {
        if gamepad.is_pressed(gilrs::Button::South) {
            jump();
        }

        if gamepad.is_pressed(gilrs::Button::West) {
            attack();
        }
    }
}
}

Button Mapping

gilrs::ButtonXboxPlayStationNintendo
SouthACrossB
EastBCircleA
WestXSquareY
NorthYTriangleX
LeftTriggerLBL1L
RightTriggerRBR1R
SelectViewShare-
StartMenuOptions+
DPadUp/Down/Left/RightD-PadD-PadD-Pad

Analog Sticks

#![allow(unused)]
fn main() {
if let Some(gamepad) = query_active_gamepad(world) {
    let move_x = gamepad.value(gilrs::Axis::LeftStickX);
    let move_y = gamepad.value(gilrs::Axis::LeftStickY);

    let look_x = gamepad.value(gilrs::Axis::RightStickX);
    let look_y = gamepad.value(gilrs::Axis::RightStickY);
}
}

Axis values range from -1.0 to 1.0.

Triggers

#![allow(unused)]
fn main() {
if let Some(gamepad) = query_active_gamepad(world) {
    let left = gamepad.value(gilrs::Axis::LeftZ);
    let right = gamepad.value(gilrs::Axis::RightZ);

    let acceleration = right * max_acceleration;
}
}

Event-Based Input

Handle gamepad events in the State trait using raw gilrs events:

#![allow(unused)]
fn main() {
fn on_gamepad_event(&mut self, world: &mut World, event: gilrs::Event) {
    if let gilrs::EventType::ButtonPressed(button, _) = event.event {
        match button {
            gilrs::Button::Start => self.paused = !self.paused,
            gilrs::Button::South => self.player_jump(),
            _ => {}
        }
    }
}
}

Combining Keyboard and Gamepad

#![allow(unused)]
fn main() {
use nightshade::ecs::input::queries::query_active_gamepad;

struct PlayerInput {
    movement: Vec2,
    jump: bool,
    attack: bool,
}

fn gather_input(world: &mut World) -> PlayerInput {
    let mut input = PlayerInput {
        movement: Vec2::zeros(),
        jump: false,
        attack: false,
    };

    let keyboard = &world.resources.input.keyboard;
    if keyboard.is_key_pressed(KeyCode::KeyW) { input.movement.y -= 1.0; }
    if keyboard.is_key_pressed(KeyCode::KeyS) { input.movement.y += 1.0; }
    if keyboard.is_key_pressed(KeyCode::KeyA) { input.movement.x -= 1.0; }
    if keyboard.is_key_pressed(KeyCode::KeyD) { input.movement.x += 1.0; }
    input.jump |= keyboard.is_key_pressed(KeyCode::Space);

    if let Some(gamepad) = query_active_gamepad(world) {
        let stick_x = gamepad.value(gilrs::Axis::LeftStickX);
        let stick_y = gamepad.value(gilrs::Axis::LeftStickY);

        if stick_x.abs() > 0.15 || stick_y.abs() > 0.15 {
            input.movement = Vec2::new(stick_x, stick_y);
        }

        input.jump |= gamepad.is_pressed(gilrs::Button::South);
        input.attack |= gamepad.is_pressed(gilrs::Button::West);
    }

    input
}
}

Event System

Nightshade provides an event bus for decoupled communication between systems. Events are published to a queue and consumed by interested systems.

EventBus

The event bus is accessible through world.resources.event_bus:

#![allow(unused)]
fn main() {
pub struct EventBus {
    pub messages: VecDeque<Message>,
}

pub enum Message {
    Input(InputMessage),
    App(Box<dyn Any + Send + Sync>),
}
}

Defining Custom Events

Create a struct for your event:

#![allow(unused)]
fn main() {
pub struct EnemyDied {
    pub entity: Entity,
    pub position: Vec3,
    pub killer: Option<Entity>,
}

pub struct PlayerLeveledUp {
    pub new_level: u32,
    pub skills_unlocked: Vec<String>,
}

pub struct ItemPickedUp {
    pub item_type: ItemType,
    pub quantity: u32,
}
}

Publishing Events

Use publish_app_event to send events:

#![allow(unused)]
fn main() {
fn combat_system(world: &mut World, game: &mut GameState) {
    for enemy in world.core.query_entities(ENEMY | HEALTH) {
        let health = world.core.get_health(enemy).unwrap();
        if health.current <= 0 {
            let position = world.core.get_global_transform(enemy)
                .map(|t| t.matrix.column(3).xyz())
                .unwrap_or(Vec3::zeros());

            publish_app_event(world, EnemyDied {
                entity: enemy,
                position,
                killer: game.last_attacker,
            });

            world.despawn_entities(&[enemy]);
        }
    }
}
}

Consuming Events

Process events in your game loop:

#![allow(unused)]
fn main() {
fn run_systems(&mut self, world: &mut World) {
    while let Some(msg) = world.resources.event_bus.messages.pop_front() {
        match msg {
            Message::App(event) => {
                if let Some(died) = event.downcast_ref::<EnemyDied>() {
                    self.handle_enemy_death(world, died);
                }
                if let Some(levelup) = event.downcast_ref::<PlayerLeveledUp>() {
                    self.show_levelup_ui(levelup);
                }
                if let Some(pickup) = event.downcast_ref::<ItemPickedUp>() {
                    self.update_inventory(pickup);
                }
            }
            Message::Input(input_msg) => {
                self.handle_input_message(input_msg);
            }
        }
    }
}

fn handle_enemy_death(&mut self, world: &mut World, event: &EnemyDied) {
    spawn_explosion_effect(world, event.position);
    self.score += 100;
    self.enemies_killed += 1;
}
}

Event Patterns

One-to-Many Communication

Events allow one system to notify multiple listeners without direct coupling:

#![allow(unused)]
fn main() {
publish_app_event(world, DoorOpened { door_id: 42 });
}
#![allow(unused)]
fn main() {
if let Some(door) = event.downcast_ref::<DoorOpened>() {
    trigger_cutscene(world, door.door_id);
}

if let Some(door) = event.downcast_ref::<DoorOpened>() {
    play_door_sound(world, door.door_id);
}

if let Some(door) = event.downcast_ref::<DoorOpened>() {
    update_minimap(world, door.door_id);
}
}

Deferred Actions

Events let you schedule actions without immediate execution:

#![allow(unused)]
fn main() {
pub struct SpawnEnemy {
    pub enemy_type: EnemyType,
    pub position: Vec3,
    pub delay_frames: u32,
}

fn wave_spawner_system(world: &mut World, pending: &mut Vec<SpawnEnemy>) {
    pending.retain_mut(|spawn| {
        if spawn.delay_frames == 0 {
            spawn_enemy(world, spawn.enemy_type, spawn.position);
            false
        } else {
            spawn.delay_frames -= 1;
            true
        }
    });
}
}

Request-Response Pattern

For queries that need responses, use a shared resource:

#![allow(unused)]
fn main() {
pub struct DamageRequest {
    pub target: Entity,
    pub amount: f32,
    pub damage_type: DamageType,
}

pub struct DamageResult {
    pub target: Entity,
    pub actual_damage: f32,
    pub killed: bool,
}

fn damage_system(world: &mut World, requests: &[DamageRequest]) -> Vec<DamageResult> {
    requests.iter().map(|req| {
        let actual = apply_damage(world, req.target, req.amount, req.damage_type);
        let killed = is_dead(world, req.target);
        DamageResult {
            target: req.target,
            actual_damage: actual,
            killed,
        }
    }).collect()
}
}

Input Messages

The event bus also handles input-related messages:

#![allow(unused)]
fn main() {
pub enum InputMessage {
    KeyPressed(KeyCode),
    KeyReleased(KeyCode),
    MouseMoved { x: f32, y: f32 },
    MouseButton { button: MouseButton, pressed: bool },
    GamepadButton { button: GamepadButton, pressed: bool },
}
}

Best Practices

  1. Keep events small: Only include necessary data
  2. Use descriptive names: PlayerDied not Event1
  3. Process events each frame: Don't let the queue grow unbounded
  4. Consider event ordering: Events are processed in FIFO order
  5. Avoid circular events: A handling B which triggers A can cause infinite loops

Rendering Architecture

This chapter explains how ECS data flows to pixels on screen. Nightshade's renderer is built on wgpu and uses a dependency-driven render graph to orchestrate all GPU work.

High-Level Flow

ECS World State
    |
    v
Renderer (WgpuRenderer)
    |-- Sync data: upload transforms, materials, lights to GPU buffers
    |-- Prepare passes: each pass updates its bind groups and uniforms
    |-- Execute render graph: run passes in dependency order
    |-- Submit command buffers to GPU queue
    |-- Present swapchain surface
    |
    v
Pixels on Screen

The Render Trait

All rendering goes through the Render trait, which abstracts the GPU backend:

#![allow(unused)]
fn main() {
pub trait Render {
    fn render_frame(&mut self, world: &mut World, state: &mut dyn State);
    fn resize(&mut self, width: u32, height: u32, world: &mut World);
    // ... additional methods for texture upload, screenshot, etc.
}
}

WgpuRenderer is the concrete implementation that owns the wgpu device, queue, surface, and render graph.

WgpuRenderer

The renderer holds all GPU state:

  • Instance, Adapter, Device, Queue - wgpu initialization chain
  • Surface - the window's swapchain
  • RenderGraph - the dependency-driven frame graph with all passes
  • Resource IDs - handles to all transient and external textures
  • Texture Cache - uploaded GPU textures
  • Font Atlas - glyph texture for text rendering
  • Camera Viewports - render-to-texture for editor viewports

Initialization

When the application starts:

  1. Instance creation - wgpu creates a wgpu::Instance, which selects the GPU backend (Vulkan on Linux/Windows, Metal on macOS, DX12 on Windows, WebGPU on WASM). The instance is the entry point to the GPU API.
  2. Adapter request - The instance queries available GPUs and selects one. The adapter describes the GPU's capabilities (supported texture formats, limits, features).
  3. Device and queue - The adapter opens a logical device (the interface for creating GPU resources) and a command queue (where command buffers are submitted for execution). All GPU work goes through the queue.
  4. Surface configuration - The window surface is configured with the GPU's preferred format (typically Bgra8UnormSrgb) and the presentation mode (Fifo for vsync, Mailbox for low-latency).
  5. Pass creation - All built-in passes are created. Each pass constructs its shader modules, pipeline layouts, render pipelines, bind group layouts, and any persistent GPU buffers during initialization.
  6. Render graph construction - A RenderGraph<World> is constructed with all transient textures and passes registered.
  7. User configuration - State::configure_render_graph() is called, allowing the game to add custom passes, textures, or modify the pipeline.
  8. Graph compilation - The graph builds dependency edges, topologically sorts passes, computes resource lifetimes, determines aliasing, and calculates load/store operations.

Transient Textures

The renderer declares all intermediate textures at initialization:

TextureFormatDescription
depthDepth32FloatMain depth buffer (reversed-Z, 0.0 = far)
scene_colorRgba16FloatHDR color accumulation buffer
compute_outputSurface formatPost-processed output before swapchain blit
shadow_depthDepth32FloatCascaded shadow map (8192x8192 native, 4096 WASM)
spotlight_shadow_atlasDepth32FloatSpotlight shadow atlas (4096 native, 1024 WASM)
entity_idR32FloatEntity ID buffer for GPU picking
view_normalsRgba16FloatView-space normals for SSAO/SSGI
selection_maskR8UnormSelection mask for editor outlines
ssao_rawR8UnormRaw SSAO before blur
ssaoR8UnormBlurred SSAO
ssgi_rawRgba16FloatRaw SSGI (half resolution)
ssgiRgba16FloatBlurred SSGI (half resolution)
ssr_rawRgba16FloatRaw screen-space reflections
ssrRgba16FloatBlurred SSR
ui_depthDepth32FloatSeparate depth for UI rendering

External textures (provided each frame):

  • swapchain - the window surface texture
  • viewport_output - editor viewport render target

Per-Frame Rendering

Each frame, render_frame() executes:

  1. Sync data - Upload transform matrices, material uniforms, light data, and animation bone matrices to GPU buffers
  2. Process commands - Handle queued WorldCommand values (texture loads, screenshots, etc.)
  3. Set swapchain texture - Acquire the next swapchain image and bind it as the external swapchain resource
  4. Call State::update_render_graph() - Allow per-frame graph modifications
  5. Execute render graph - Run all enabled, non-culled passes in topological order, collecting command buffers
  6. Submit - Send command buffers to the GPU queue
  7. Present - Display the frame

Resize Handling

When the window resizes:

  1. The surface is reconfigured with new dimensions
  2. All transient textures are resized to match
  3. The render graph recomputes resource aliasing
  4. Passes that cache bind groups invalidate them

SSGI textures resize to half the new dimensions.

Custom Rendering

Games customize rendering through two State methods:

  • configure_render_graph() - Called once at startup. Add custom passes, textures, and change the pipeline structure.
  • update_render_graph() - Called each frame. Enable/disable passes, update pass parameters.

See The Render Graph for details on how the graph system works, and Custom Passes for implementation examples.

The Render Graph

Live Demos: Custom Pass | Custom Multipass | Render Layers

The render graph is a dependency-driven frame graph that automatically schedules GPU work. Instead of manually ordering render passes, you declare what each pass reads and writes, and the graph figures out the rest.

The Problem: Manual Pass Ordering

A modern renderer has dozens of passes: shadow maps, geometry, SSAO, SSR, bloom, tonemapping, UI. Each reads from and writes to intermediate textures. Without automation, you must:

  1. Manually order passes - Shadow maps before geometry, geometry before SSAO, SSAO before compositing. Add one pass and you must figure out where it fits in the chain. Reorder one pass and you break three others.
  2. Manually manage textures - Allocate intermediate textures, track which ones are alive when, decide when to clear vs load, when to store vs discard. Get it wrong and you see black screens or stale data from previous frames.
  3. Manually optimize memory - SSAO's intermediate texture and SSR's intermediate texture might never be alive at the same time. Without aliasing, you waste VRAM on textures that could share the same memory.
  4. Manually handle dynamic passes - Disabling bloom shouldn't require rewriting the compositing pass's inputs. But with hardcoded ordering, every conditional pass is an if statement that must be threaded through the entire pipeline.

A render graph (also called a frame graph, as described in the Frostbite GDC 2017 talk "FrameGraph: Extensible Rendering Architecture in Frostbite") solves all of this. You describe what each pass needs, and the graph handles ordering, memory, and lifecycle.

How It Works

The render graph models the frame as a directed acyclic graph (DAG) where:

  • Nodes are render passes
  • Edges are resource dependencies (an edge from A to B means "A produces data that B consumes")

This is the same abstraction as a build system (Make, Bazel) or a task scheduler. Given the dependency edges, a topological sort produces a valid execution order. The graph can then analyze resource lifetimes across that order to alias memory, compute load/store operations, and cull unused passes.

The key insight is that passes declare their dependencies declaratively through named slots, not imperatively through explicit ordering. This makes the system composable: adding a new pass means declaring what it reads and writes, not editing every other pass that touches the same resources.

Why a Render Graph?

  • Automatic ordering - Passes are topologically sorted based on read/write dependencies
  • Automatic memory management - Transient textures with non-overlapping lifetimes share GPU memory
  • Automatic load/store ops - The graph determines whether to Clear, Load, Store, or Discard each attachment
  • Dead pass culling - Passes that don't contribute to any external output are automatically skipped
  • Runtime toggling - Passes can be enabled/disabled at runtime without recompiling the graph

The RenderGraph Struct

#![allow(unused)]
fn main() {
pub struct RenderGraph<C = ()> {
    graph: DiGraph<GraphNode<C>, ResourceId>,  // petgraph directed graph
    pass_nodes: HashMap<String, NodeIndex>,     // pass name -> graph node
    resources: RenderGraphResources,            // texture/buffer descriptors and handles
    execution_order: Vec<NodeIndex>,            // topologically sorted pass order
    store_ops: HashMap<ResourceId, StoreOp>,    // per-resource store operations
    clear_ops: HashSet<(NodeIndex, ResourceId)>,// which passes clear which resources
    aliasing_info: Option<ResourceAliasingInfo>,// memory sharing between transients
    culled_passes: HashSet<NodeIndex>,          // passes removed by dead-pass culling
    // ...
}
}

The generic parameter C is the "configs" type passed to passes during execution. Nightshade uses RenderGraph<World> so passes can read ECS state.

Lifecycle

1. Setup Phase (once at startup)

#![allow(unused)]
fn main() {
let mut graph = RenderGraph::new();

// Declare textures
let depth = graph.add_depth_texture("depth")
    .size(1920, 1080)
    .clear_depth(0.0)
    .transient();

let scene_color = graph.add_color_texture("scene_color")
    .format(wgpu::TextureFormat::Rgba16Float)
    .size(1920, 1080)
    .clear_color(wgpu::Color::BLACK)
    .transient();

let swapchain = graph.add_color_texture("swapchain")
    .format(surface_format)
    .external();

// Add passes with slot bindings
graph.add_pass(
    Box::new(clear_pass),
    &[("color", scene_color), ("depth", depth)],
)?;

graph.add_pass(
    Box::new(mesh_pass),
    &[("color", scene_color), ("depth", depth)],
)?;

graph.add_pass(
    Box::new(blit_pass),
    &[("input", scene_color), ("output", swapchain)],
)?;

// Compile: build edges, sort, compute aliasing
graph.compile()?;
}

2. Per-Frame Execution

#![allow(unused)]
fn main() {
// Provide the swapchain texture for this frame
graph.set_external_texture(swapchain_id, swapchain_view, width, height);

// Execute all passes, get command buffers
let command_buffers = graph.execute(&device, &queue, &world)?;

// Submit to GPU
queue.submit(command_buffers);
}

Key Methods

MethodDescription
new()Create an empty graph
add_color_texture()Declare a color render target (returns builder)
add_depth_texture()Declare a depth buffer (returns builder)
add_buffer()Declare a GPU buffer (returns builder)
add_pass()Add a pass with slot-to-resource bindings
pass()Fluent pass builder (alternative to add_pass)
compile()Build dependency graph, topological sort, compute aliasing
execute()Prepare and run all passes, return command buffers
set_external_texture()Provide an external texture (e.g. swapchain) each frame
set_pass_enabled()Enable/disable a pass at runtime
get_pass_mut()Access a pass for runtime configuration
resize_transient_resource()Change dimensions of a transient texture

Compilation Steps

When compile() is called:

  1. Build dependency edges - For each resource, the graph creates an edge from writer to reader
  2. Topological sort - Passes are sorted so every pass executes after its dependencies
  3. Compute store ops - Determine Store vs Discard for each resource write
  4. Compute clear ops - Determine which pass performs the initial Clear for each resource
  5. Compute resource lifetimes - Track first_use and last_use for each transient resource
  6. Compute resource aliasing - Transient resources with non-overlapping lifetimes share GPU memory
  7. Dead pass culling - Passes that don't contribute to external outputs are marked for skipping

Sub-Chapters

Resources & Textures

Render graph resources are GPU textures and buffers that passes read from and write to. Each resource has a ResourceId handle used for all graph operations.

ResourceId

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ResourceId(pub u32);
}

ResourceId is an opaque handle returned when you declare a resource. Pass it to add_pass() slot bindings to connect passes to resources.

Resource Types

TypeDescription
ExternalColorColor texture provided externally each frame (e.g. swapchain)
TransientColorColor texture managed by the graph (allocated, aliased, freed automatically)
ExternalDepthDepth texture provided externally
TransientDepthDepth texture managed by the graph
ExternalBufferGPU buffer provided externally
TransientBufferGPU buffer managed by the graph

External vs Transient

External resources are owned by you. You provide them each frame via set_external_texture(). The graph never creates or destroys them. The swapchain texture is the most common external resource.

Transient resources are owned by the graph. The graph creates GPU textures/buffers as needed, tracks their lifetimes, and can alias them (share memory between resources with non-overlapping lifetimes) to minimize VRAM usage.

Creating Color Textures

Use the fluent builder:

#![allow(unused)]
fn main() {
// Transient - graph manages lifetime and may alias memory
let hdr = graph.add_color_texture("scene_color")
    .format(wgpu::TextureFormat::Rgba16Float)
    .size(1920, 1080)
    .clear_color(wgpu::Color::BLACK)
    .transient();

// External - you provide the texture each frame
let swapchain = graph.add_color_texture("swapchain")
    .format(wgpu::TextureFormat::Bgra8UnormSrgb)
    .external();
}

Builder Methods

MethodDescription
format(TextureFormat)Pixel format (default: Rgba8UnormSrgb)
size(width, height)Texture dimensions
usage(TextureUsages)GPU usage flags
sample_count(u32)MSAA sample count
mip_levels(u32)Mipmap level count
clear_color(Color)Clear color (only for the first pass that writes)
no_store()Don't force store after last write
transient()Finalize as transient (returns ResourceId)
external()Finalize as external (returns ResourceId)

Creating Depth Textures

#![allow(unused)]
fn main() {
let depth = graph.add_depth_texture("depth")
    .size(1920, 1080)
    .format(wgpu::TextureFormat::Depth32Float)
    .clear_depth(0.0)
    .transient();
}

Depth Builder Methods

MethodDescription
format(TextureFormat)Depth format (default: Depth32Float)
size(width, height)Texture dimensions
usage(TextureUsages)GPU usage flags
array_layers(u32)For texture arrays (e.g. shadow cascades)
clear_depth(f32)Clear depth value
no_store()Don't force store
transient() / external()Finalize

Creating Buffers

#![allow(unused)]
fn main() {
let data_buffer = graph.add_buffer("compute_data")
    .size(1024 * 1024)
    .usage(wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST)
    .transient();
}

Resource Templates

For creating multiple similar resources, use templates:

#![allow(unused)]
fn main() {
let template = ResourceTemplate::new(
    wgpu::TextureFormat::Rgba16Float,
    1920,
    1080,
).usage(wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING);

let texture_a = graph.transient_color_from_template("blur_a", &template);
let texture_b = graph.transient_color_from_template("blur_b", &template);
}

Template Methods

MethodDescription
new(format, width, height)Create a template
usage(TextureUsages)Set usage flags
sample_count(u32)MSAA samples
mip_levels(u32)Mipmap levels
array_layers(u32)Texture array layers
cube_map()Configure as cube map (6 layers)
dimension_3d(depth)3D texture

Resource Pools

For batch allocation from a template:

#![allow(unused)]
fn main() {
let mut pool = graph.resource_pool(&template);
let [blur_a, blur_b, blur_c] = [
    pool.transient("blur_a"),
    pool.transient("blur_b"),
    pool.transient("blur_c"),
];
}

Setting External Textures Per-Frame

External textures must be provided each frame before execute():

#![allow(unused)]
fn main() {
// Each frame, provide the swapchain texture
let surface_texture = surface.get_current_texture()?;
let view = surface_texture.texture.create_view(&Default::default());
graph.set_external_texture(swapchain_id, view, width, height);
}

Resizing Transient Textures

When the window resizes:

#![allow(unused)]
fn main() {
graph.resize_transient_resource(&device, depth_id, new_width, new_height)?;
graph.resize_transient_resource(&device, scene_color_id, new_width, new_height)?;
}

This invalidates the aliasing info and triggers reallocation on the next execution.

Passes & the PassNode Trait

Every render pass implements the PassNode trait to declare its resource dependencies and execute GPU commands.

The PassNode Trait

#![allow(unused)]
fn main() {
pub trait PassNode<C = ()>: Send + Sync + Any {
    fn name(&self) -> &str;
    fn reads(&self) -> Vec<&str>;
    fn writes(&self) -> Vec<&str>;
    fn reads_writes(&self) -> Vec<&str> { Vec::new() }
    fn optional_reads(&self) -> Vec<&str> { Vec::new() }
    fn prepare(&mut self, _device: &Device, _queue: &wgpu::Queue, _configs: &C) {}
    fn invalidate_bind_groups(&mut self) {}
    fn execute<'r, 'e>(
        &mut self,
        context: PassExecutionContext<'r, 'e, C>,
    ) -> Result<Vec<SubGraphRunCommand<'r>>>;
}
}

On WASM, the Send + Sync bounds are removed.

Slot-Based Resource Binding

Passes declare named slots that map to graph resources. The slot names are strings that match the keys in the add_pass() bindings:

#![allow(unused)]
fn main() {
impl PassNode<World> for MyPass {
    fn name(&self) -> &str { "my_pass" }

    // Slots this pass reads from (input textures)
    fn reads(&self) -> Vec<&str> { vec!["input"] }

    // Slots this pass writes to (output attachments)
    fn writes(&self) -> Vec<&str> { vec!["output"] }

    // Slots that are both read and written (read-modify-write)
    fn reads_writes(&self) -> Vec<&str> { vec![] }

    // Slots that are read if available, but don't create dependencies if absent
    fn optional_reads(&self) -> Vec<&str> { vec![] }
    // ...
}
}

When adding the pass to the graph, you bind slot names to resource IDs:

#![allow(unused)]
fn main() {
graph.add_pass(
    Box::new(my_pass),
    &[("input", scene_color_id), ("output", swapchain_id)],
)?;
}

PassExecutionContext

The context provides access to resources during execution:

#![allow(unused)]
fn main() {
pub struct PassExecutionContext<'r, 'e, C = ()> {
    pub encoder: &'e mut CommandEncoder,
    pub resources: &'r RenderGraphResources,
    pub device: &'r Device,
    pub queue: &'r wgpu::Queue,
    pub configs: &'r C,  // For Nightshade, this is &World
    // ... internal fields
}
}

Context Methods

MethodReturnsDescription
get_texture_view(slot)&TextureViewGet a texture view for sampling
get_color_attachment(slot)(view, LoadOp, StoreOp)Get color attachment with automatic load/store ops
get_depth_attachment(slot)(view, LoadOp, StoreOp)Get depth attachment with automatic load/store ops
get_buffer(slot)&BufferGet a GPU buffer
get_texture_size(slot)(u32, u32)Get texture dimensions
is_pass_enabled()boolCheck if this pass is currently enabled
run_sub_graph(name, inputs)-Execute a sub-graph

Automatic Load/Store Operations

The graph automatically determines the correct load and store operations:

  • LoadOp::Clear - Used when this pass is the first writer and the resource has a clear value
  • LoadOp::Load - Used when a previous pass already wrote to this resource
  • StoreOp::Store - Used when another pass will read this resource later
  • StoreOp::Discard - Used when no subsequent pass reads this resource

You don't choose these yourself - the get_color_attachment() and get_depth_attachment() methods return the correct ops.

Prepare Phase

prepare() is called before execution for each non-culled pass. Use it to upload uniforms:

#![allow(unused)]
fn main() {
fn prepare(&mut self, device: &Device, queue: &wgpu::Queue, configs: &World) {
    let camera_data = extract_camera_uniforms(configs);
    queue.write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&camera_data));
}
}

Bind Group Invalidation

When the graph reallocates resources (e.g. after resize), invalidate_bind_groups() is called on affected passes. Clear any cached bind groups:

#![allow(unused)]
fn main() {
fn invalidate_bind_groups(&mut self) {
    self.bind_group = None;
}
}

The graph tracks resource versions and only invalidates passes that reference changed resources.

Full Example

#![allow(unused)]
fn main() {
pub struct BlurPass {
    pipeline: wgpu::RenderPipeline,
    bind_group_layout: wgpu::BindGroupLayout,
    bind_group: Option<wgpu::BindGroup>,
    sampler: wgpu::Sampler,
}

impl PassNode<World> for BlurPass {
    fn name(&self) -> &str { "blur_pass" }
    fn reads(&self) -> Vec<&str> { vec!["input"] }
    fn writes(&self) -> Vec<&str> { vec!["output"] }

    fn invalidate_bind_groups(&mut self) {
        self.bind_group = None;
    }

    fn execute<'r, 'e>(
        &mut self,
        ctx: PassExecutionContext<'r, 'e, World>,
    ) -> Result<Vec<SubGraphRunCommand<'r>>> {
        if !ctx.is_pass_enabled() {
            return Ok(vec![]);
        }

        let input_view = ctx.get_texture_view("input")?;
        let (output_view, load_op, store_op) = ctx.get_color_attachment("output")?;

        if self.bind_group.is_none() {
            self.bind_group = Some(ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
                layout: &self.bind_group_layout,
                entries: &[
                    wgpu::BindGroupEntry {
                        binding: 0,
                        resource: wgpu::BindingResource::TextureView(input_view),
                    },
                    wgpu::BindGroupEntry {
                        binding: 1,
                        resource: wgpu::BindingResource::Sampler(&self.sampler),
                    },
                ],
                label: Some("blur_bind_group"),
            }));
        }

        let mut pass = ctx.encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
            label: Some("blur_pass"),
            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
                view: output_view,
                resolve_target: None,
                ops: wgpu::Operations { load: load_op, store: store_op },
            })],
            depth_stencil_attachment: None,
            ..Default::default()
        });

        pass.set_pipeline(&self.pipeline);
        pass.set_bind_group(0, self.bind_group.as_ref().unwrap(), &[]);
        pass.draw(0..3, 0..1);  // Fullscreen triangle

        Ok(vec![])
    }
}
}

Sub-Graph Execution

Passes can trigger sub-graph execution for multi-pass effects:

#![allow(unused)]
fn main() {
fn execute<'r, 'e>(
    &mut self,
    mut ctx: PassExecutionContext<'r, 'e, World>,
) -> Result<Vec<SubGraphRunCommand<'r>>> {
    ctx.run_sub_graph("bloom_mip_chain".to_string(), vec![
        SlotValue::TextureView {
            view: ctx.get_texture_view("hdr")?,
            width: self.width,
            height: self.height,
        },
    ]);

    Ok(ctx.into_sub_graph_commands())
}
}

Dependency Resolution & Scheduling

The render graph automatically orders passes based on their resource dependencies. This chapter explains how that works.

Dependency Edge Construction

When compile() is called, the graph builds edges between passes:

  1. Iterate through all passes
  2. For each resource a pass reads, find the pass that last wrote to it
  3. Create a directed edge from the writer to the reader
Pass A writes texture T
Pass B reads texture T
  => Edge: A -> B (B depends on A)

For reads_writes resources, the pass is treated as both a reader and a writer. Optional reads create edges only if a writer exists.

Topological Sort

After building edges, the graph performs a topological sort using petgraph. A topological sort of a DAG produces a linear ordering where for every edge A -> B, A appears before B. This guarantees every pass runs after all the passes that produce its inputs.

For a graph with V passes and E dependency edges, topological sorting runs in O(V + E) time using Kahn's algorithm (iteratively remove nodes with no incoming edges) or depth-first search. petgraph implements this efficiently.

If the graph contains cycles (A depends on B, B depends on A), no valid topological ordering exists and compilation fails with RenderGraphError::CyclicDependency. Cycles in a render graph indicate a logical error: two passes cannot each depend on the other's output.

Dead Pass Culling

Not all passes may contribute to the final output. The graph uses backward reachability from external resources to determine which passes are needed:

  1. Start with all external resources as "required"
  2. Walk backward through the execution order
  3. A pass is required if it writes to a required resource (or has no writes/reads_writes, indicating side effects)
  4. If a pass is required, all resources it reads become required too
  5. Passes not marked as required are culled
Pass A writes T1
Pass B reads T1, writes T2     <- T2 is not read by anyone
Pass C reads T1, writes output  <- output is external

Result: A and C execute, B is culled

Runtime Pass Toggling

Passes can be enabled or disabled at runtime without recompiling the graph:

#![allow(unused)]
fn main() {
graph.set_pass_enabled("bloom_pass", false)?;
}

When a pass is disabled, its execute() method receives is_pass_enabled() == false. The pass can check this and skip all GPU work:

#![allow(unused)]
fn main() {
fn execute<'r, 'e>(
    &mut self,
    ctx: PassExecutionContext<'r, 'e, World>,
) -> Result<Vec<SubGraphRunCommand<'r>>> {
    if !ctx.is_pass_enabled() {
        return Ok(vec![]);
    }
    // ... normal execution
}
}

Recompilation

The graph tracks a needs_recompile flag. Adding or removing passes sets this flag. On the next execute(), the graph:

  1. Removes all existing edges
  2. Rebuilds dependency edges
  3. Re-sorts topologically
  4. Recomputes store ops, clear ops, lifetimes, and aliasing

This happens automatically - you don't need to call compile() again manually.

Store and Clear Operations

Store Operations

On tile-based GPU architectures (mobile GPUs, Apple Silicon), render pass attachments are stored in fast on-chip tile memory during rendering. At the end of the render pass, the driver must decide whether to write that tile memory back to main VRAM. This write-back is called a "store" operation and it has significant bandwidth cost.

For each resource write, the graph determines whether to store the result:

  • Store - If any later pass reads this resource, or if it's an external resource with force_store. The data must survive to be read later.
  • Discard - If no later pass reads this resource. The GPU can skip the write-back entirely, saving memory bandwidth. This is a significant optimization on tile-based architectures.

Clear Operations

Similarly, at the start of a render pass, the GPU must decide what to do with the existing attachment contents:

  • Clear - Write a known value (black, zero depth) into the attachment. This is cheap because the GPU can initialize tile memory without reading from VRAM.
  • Load - Read the existing contents from VRAM into tile memory. This is necessary when a previous pass has already written data that this pass needs to preserve.

The first pass that writes to a resource with a clear value (clear_color or clear_depth) gets a LoadOp::Clear. All subsequent writers get LoadOp::Load.

These are computed during compilation and used automatically by get_color_attachment() and get_depth_attachment(). Getting these wrong is a common source of rendering bugs: using Clear when you should Load erases previous passes' work; using Load when you should Clear wastes bandwidth loading garbage data.

Resource Aliasing & Memory

Transient resources with non-overlapping lifetimes can share the same GPU memory. The render graph computes this automatically to minimize VRAM usage.

Why Aliasing Matters

A modern rendering pipeline might use 15+ intermediate textures: shadow maps, SSAO buffers, bloom mip chains, SSR buffers, selection masks, and more. At 1080p, a single Rgba16Float texture is about 16 MB. At 4K, it's 64 MB. Without aliasing, all these textures exist simultaneously in VRAM even if they're never alive at the same time.

Resource aliasing is the GPU equivalent of stack allocation: the same memory region is reused by different variables (textures) whose lifetimes don't overlap. The render graph's execution order gives a total ordering of pass execution, which makes it possible to compute exact lifetimes and find aliasing opportunities.

This technique is inspired by the Frostbite engine's frame graph (GDC 2017) and is standard practice in modern engines. The savings can be 30-50% of transient VRAM depending on the pipeline.

How It Works

After topological sorting, the graph knows the execution order. For each transient resource, it computes:

  • first_use - The pass index where the resource is first written
  • last_use - The pass index where the resource is last read

If resource A's lifetime ends before resource B's lifetime begins, and they have compatible formats, they can share the same GPU texture:

Pass 0: writes A
Pass 1: reads A, writes B
Pass 2: reads B, writes C    <- A's memory can be reused for C
Pass 3: reads C, writes output

Here, A's lifetime is [0, 1] and C's lifetime is [2, 3]. Since they don't overlap and have compatible descriptors, the graph assigns them to the same pool slot.

Compatibility Requirements

Two textures can alias if they have identical:

  • Format
  • Width and height
  • Sample count
  • Mip level count
  • The reuser's usage flags are a subset of the pool's usage flags

Two buffers can alias if the pool's size is at least as large as the reuser's, and usage flags match exactly.

If a reuser needs additional usage flags, the pool texture is recreated with the combined flags.

Pool-Based Allocation

The aliasing system uses a pool with a BinaryHeap for efficient slot reuse:

  1. Sort transient resources by first_use
  2. For each resource:
    • Check if any pool slot has a lifetime_end before this resource's first_use
    • If a compatible slot is found, reuse it
    • Otherwise, allocate a new pool slot
  3. Each pool slot holds at most one GPU texture/buffer at a time

The heap is ordered by lifetime_end (min-heap), so the earliest-expiring slots are checked first.

Bind Group Invalidation

When a transient resource gets a new GPU texture (because the pool slot was reallocated), any passes that reference that resource need to recreate their bind groups.

The graph tracks a version number per resource. When a resource's version changes between frames:

  1. Find all passes that read, write, or reads_write that resource
  2. Call invalidate_bind_groups() on those passes
  3. Update the stored version

This ensures passes always reference the correct GPU texture, even after aliasing changes or window resizes.

Memory Savings

For a typical scene with 15+ transient textures, aliasing can reduce VRAM usage significantly. For example:

  • ssao_raw and ssgi_raw may never be alive at the same time
  • Shadow depth maps are only needed during shadow passes, then their memory can be reused
  • Intermediate blur textures from bloom can share memory with SSR blur textures

The exact savings depend on pass ordering and resource sizes.

External Resources

External resources (swapchain, viewport outputs) are never aliased. They are always owned externally and provided fresh each frame.

Custom Passes

Customize the rendering pipeline by adding your own passes to the render graph.

configure_render_graph()

Override this State method to add custom passes at startup:

#![allow(unused)]
fn main() {
fn configure_render_graph(
    &mut self,
    graph: &mut RenderGraph<World>,
    device: &wgpu::Device,
    surface_format: wgpu::TextureFormat,
    resources: RenderResources,
) {
    // Add custom textures
    let my_texture = graph.add_color_texture("my_effect")
        .format(wgpu::TextureFormat::Rgba16Float)
        .size(1920, 1080)
        .clear_color(wgpu::Color::BLACK)
        .transient();

    // Add custom passes
    let my_pass = MyCustomPass::new(device);
    graph.add_pass(
        Box::new(my_pass),
        &[("input", resources.scene_color), ("output", my_texture)],
    );
}
}

RenderResources

The RenderResources struct provides resource IDs for all built-in textures:

FieldDescription
scene_colorHDR color buffer (Rgba16Float)
depthMain depth buffer (Depth32Float)
compute_outputPost-processed output before swapchain blit
swapchainFinal swapchain output
view_normalsView-space normals
ssao_raw / ssaoRaw and blurred SSAO
ssgi_raw / ssgiRaw and blurred SSGI
ssr_raw / ssrRaw and blurred SSR
surface_width / surface_heightCurrent window dimensions in pixels

update_render_graph()

For per-frame changes, use update_render_graph():

#![allow(unused)]
fn main() {
fn update_render_graph(&mut self, graph: &mut RenderGraph<World>, world: &World) {
    if self.bloom_changed {
        let _ = graph.set_pass_enabled("bloom_pass", self.bloom_enabled);
        self.bloom_changed = false;
    }
}
}

Adding Built-in Passes

Use the built-in pass types in your custom graph:

#![allow(unused)]
fn main() {
use nightshade::render::passes;

fn configure_render_graph(
    &mut self,
    graph: &mut RenderGraph<World>,
    device: &wgpu::Device,
    surface_format: wgpu::TextureFormat,
    resources: RenderResources,
) {
    let bloom_texture = graph.add_color_texture("bloom")
        .format(wgpu::TextureFormat::Rgba16Float)
        .size(960, 540)
        .clear_color(wgpu::Color::BLACK)
        .transient();

    // Bloom
    let bloom_pass = passes::BloomPass::new(device, 1920, 1080);
    graph.add_pass(
        Box::new(bloom_pass),
        &[("hdr", resources.scene_color), ("bloom", bloom_texture)],
    );

    // SSAO
    let ssao_pass = passes::SsaoPass::new(device, 1920, 1080);
    graph.add_pass(
        Box::new(ssao_pass),
        &[
            ("depth", resources.depth),
            ("normals", resources.view_normals),
            ("ssao_raw", resources.ssao_raw),
        ],
    );

    let ssao_blur_pass = passes::SsaoBlurPass::new(device, 1920, 1080);
    graph.add_pass(
        Box::new(ssao_blur_pass),
        &[("ssao_raw", resources.ssao_raw), ("ssao", resources.ssao)],
    );

    // Final compositing
    let postprocess_pass = passes::PostProcessPass::new(device, surface_format, 0.3);
    graph.add_pass(
        Box::new(postprocess_pass),
        &[
            ("hdr", resources.scene_color),
            ("bloom", bloom_texture),
            ("ssao", resources.ssao),
            ("output", resources.compute_output),
        ],
    );

    // Blit to swapchain
    let blit_pass = passes::BlitPass::new(device, surface_format);
    graph.add_pass(
        Box::new(blit_pass),
        &[("input", resources.compute_output), ("output", resources.swapchain)],
    );
}
}

PassBuilder Fluent API

Instead of add_pass(), you can use the fluent builder:

#![allow(unused)]
fn main() {
graph.pass(Box::new(bloom_pass))
    .read("hdr", resources.scene_color)
    .write("bloom", bloom_texture);

graph.pass(Box::new(postprocess_pass))
    .read("hdr", resources.scene_color)
    .read("bloom", bloom_texture)
    .read("ssao", resources.ssao)
    .write("output", resources.swapchain);
}

The PassBuilder automatically adds the pass to the graph when it goes out of scope (via Drop).

Conditional Passes

Enable or disable passes based on settings:

#![allow(unused)]
fn main() {
fn configure_render_graph(
    &mut self,
    graph: &mut RenderGraph<World>,
    device: &wgpu::Device,
    surface_format: wgpu::TextureFormat,
    resources: RenderResources,
) {
    if self.ssao_enabled {
        let ssao_pass = passes::SsaoPass::new(device, 1920, 1080);
        graph.add_pass(
            Box::new(ssao_pass),
            &[
                ("depth", resources.depth),
                ("normals", resources.view_normals),
                ("ssao_raw", resources.ssao_raw),
            ],
        );
    }
}
}

Or toggle at runtime:

#![allow(unused)]
fn main() {
fn update_render_graph(&mut self, graph: &mut RenderGraph<World>, _world: &World) {
    let _ = graph.set_pass_enabled("ssao_pass", self.ssao_enabled);
}
}

Default Pipeline

If you don't override configure_render_graph(), the default implementation adds:

  1. BloomPass - HDR bloom at half resolution
  2. PostProcessPass - Tonemapping and compositing
  3. BlitPass - Copy to swapchain

The engine always adds the core passes (clear, sky, shadow, mesh, skinned mesh, water, grass, grid, lines, selection) regardless of your custom configuration.

The Default Pipeline

The engine constructs a default render graph with all built-in passes. This chapter shows the complete pass ordering and data flow.

Pass Execution Order

The graph topologically sorts these passes based on their dependencies. The typical execution order is:

1.  ClearPass           - Clear scene_color and depth
2.  ShadowDepthPass     - Render cascaded shadow maps + spotlight shadows
3.  SkyPass             - Render procedural atmosphere to scene_color
4.  ScenePass           - Render scene objects (simple path) to scene_color
5.  MeshPass            - Render PBR meshes with shadows and lighting
6.  SkinnedMeshPass     - Render animated skeletal meshes
7.  WaterPass           - Render water surface
8.  WaterMeshPass       - Render water mesh displacement
9.  GrassPass           - Render GPU-instanced grass
10. GridPass            - Render infinite ground grid
11. LinesPass           - Render debug lines
12. SelectionMaskPass   - Generate selection mask for editor
13. OutlinePass         - Render selection outline

    --- User passes (from configure_render_graph) ---

14. BloomPass           - HDR bloom (default)
15. PostProcessPass     - Tonemapping and compositing (default)
16. BlitPass            - Copy to swapchain (default)

    --- UI passes ---

17. EguiPass            - egui UI (if egui feature enabled)

Data Flow Diagram

                    shadow_depth
ShadowDepthPass --> spotlight_shadow_atlas
                        |
                        v
SkyPass ----------> scene_color <-- ClearPass
                        |
MeshPass ----------> scene_color, depth, entity_id, view_normals
                        |
SkinnedMeshPass --> scene_color, depth
                        |
WaterPass --------> scene_color, depth
GrassPass --------> scene_color, depth
GridPass ---------> scene_color, depth
LinesPass --------> scene_color, depth
                        |
                        v
BloomPass --------> bloom (half-res)
                        |
PostProcessPass --> compute_output
    reads: scene_color, bloom, ssao
                        |
BlitPass ---------> swapchain

Core Passes (Always Present)

These passes are added by the engine during renderer initialization. They cannot be removed, but can be disabled at runtime.

ClearPass

Clears scene_color to black and depth to 0.0 (reversed-Z far plane).

SkyPass

Renders the procedural atmosphere or solid background color. Controlled by world.resources.graphics.atmosphere.

ScenePass

A simple scene rendering pass for basic objects.

ShadowDepthPass

Renders the shadow map. See Shadow Mapping.

MeshPass

The main PBR mesh rendering pass. See Geometry Passes.

SkinnedMeshPass

Renders animated skeletal meshes with GPU skinning.

Selection Passes

SelectionMaskPass and OutlinePass generate and render editor selection outlines.

User-Configurable Passes (Default)

These passes are added by the default configure_render_graph() implementation. Override this method to replace them.

BloomPass

Reads scene_color, writes a half-resolution bloom texture.

PostProcessPass

Reads scene_color, bloom, and ssao. Performs tonemapping and compositing. Writes to compute_output.

BlitPass

Copies compute_output to swapchain for presentation.

Adding SSAO/SSGI/SSR

The default pipeline only includes bloom and tonemapping. To enable SSAO, SSGI, or SSR, override configure_render_graph() and add the appropriate passes. See Post-Processing for the full list of available post-processing passes and Custom Passes for examples.

Shadow Mapping

Live Demos: Shadows | Spotlight Shadows

Nightshade uses cascaded shadow mapping for directional lights and a shadow atlas for spotlights.

How Shadow Mapping Works

Shadow mapping is a two-pass technique. In the first pass, the scene is rendered from the light's point of view into a depth-only texture (the shadow map). In the second pass (the main geometry pass), each fragment projects itself into the light's coordinate space and compares its depth against the stored shadow map value. If the fragment is farther from the light than the shadow map records, something closer is blocking the light, and the fragment is in shadow.

The core idea is that the shadow map captures the "closest surface to the light" at every pixel. Any surface behind that closest surface must be occluded.

The Resolution Problem

A single shadow map has finite resolution. A directional light (like the sun) illuminates the entire scene, but the shadow map must cover it all. Objects near the camera need high-resolution shadows (you can see the shadow edges clearly), while distant objects can tolerate lower resolution. A single shadow map wastes resolution on distant geometry while providing insufficient detail nearby.

Cascaded Shadow Maps (CSM)

CSM solves this by splitting the camera's view frustum into multiple depth ranges (cascades), each with its own shadow map. Near cascades cover a small area at high texel density. Far cascades cover a large area at lower density.

The ShadowDepthPass renders 4 shadow cascades (NUM_SHADOW_CASCADES = 4) into a single large depth texture:

  • Cascade 0 - Near range, highest detail (covers roughly 0-10% of the view distance)
  • Cascade 1 - Mid-near range (covers roughly 10-30%)
  • Cascade 2 - Mid-far range (covers roughly 30-60%)
  • Cascade 3 - Far range, lowest detail (covers roughly 60-100%)

Shadow Map Resolution

PlatformResolution
Native8192 x 8192
WASM4096 x 4096

Each cascade uses a quarter of the texture (rendered into its own viewport region), giving each cascade an effective resolution of 4096x4096 on native.

How Cascades Work

Each frame, the engine:

  1. Frustum computation - Computes the camera's view frustum (the truncated pyramid defined by the near plane, far plane, and field of view)
  2. Frustum splitting - Divides the frustum into 4 depth ranges using a logarithmic-linear split scheme. Logarithmic splitting gives more resolution to nearby cascades, while linear splitting distributes more evenly. A blend between the two (typically 0.5-0.8 toward logarithmic) produces good results across most scenes.
  3. Tight projection fitting - For each cascade, computes the 8 corner points of that frustum slice, transforms them into light space, and builds a tight orthographic projection matrix that just encompasses those points. This minimizes wasted shadow map texels.
  4. Shadow rendering - Renders all shadow-casting meshes from the directional light's perspective into each cascade's viewport region of the shadow texture.

During the mesh pass, each fragment determines which cascade to sample based on its distance from the camera. The shader selects the highest-resolution cascade that contains the fragment, projects it into that cascade's light-space coordinates, and performs the depth comparison.

Cascade Selection and Blending

At cascade boundaries, shadows can exhibit visible seams where resolution changes abruptly. The fragment shader compares the fragment's view-space depth against cascade split distances to choose the appropriate cascade. Some implementations blend between adjacent cascades at boundaries for smooth transitions.

Spotlight Shadow Atlas

Spotlights use a separate shadow atlas:

PlatformAtlas Size
Native4096 x 4096
WASM1024 x 1024

Each spotlight that has cast_shadows: true is assigned a slot in the atlas. The atlas is subdivided to accommodate multiple spotlights.

Enabling Shadows

Directional Light Shadows

spawn_sun() creates a directional light with shadows enabled by default:

#![allow(unused)]
fn main() {
let sun = spawn_sun(world);
}

To manually configure:

#![allow(unused)]
fn main() {
world.core.set_light(entity, Light {
    light_type: LightType::Directional,
    cast_shadows: true,
    shadow_bias: 0.005,
    ..Default::default()
});
}

Spotlight Shadows

#![allow(unused)]
fn main() {
world.core.set_light(entity, Light {
    light_type: LightType::Spot,
    cast_shadows: true,
    shadow_bias: 0.002,
    inner_cone_angle: 0.2,
    outer_cone_angle: 0.5,
    ..Default::default()
});
}

Per-Mesh Shadow Casting

Control which meshes cast shadows:

#![allow(unused)]
fn main() {
world.core.add_components(entity, CASTS_SHADOW);
world.core.set_casts_shadow(entity, CastsShadow);

// Disable:
world.core.remove_components(entity, CASTS_SHADOW);
}

Shadow Quality

Shadow Bias

Shadow acne occurs because the shadow map has limited resolution. A surface that should be lit samples the shadow map at a slightly different position than where it was rendered, and floating-point imprecision causes the surface to falsely report itself as in shadow. This creates a Moiré-like pattern of alternating lit and shadowed stripes on surfaces.

Shadow bias adds a small depth offset during the shadow comparison, pushing the comparison point slightly toward the light so surfaces don't self-shadow. The trade-off is that too much bias causes peter-panning: shadows detach from the base of objects because the bias pushes them too far away.

shadow_bias controls this offset:

#![allow(unused)]
fn main() {
light.shadow_bias = 0.005;  // Good default for directional lights
light.shadow_bias = 0.002;  // Good default for spotlights
}

Spotlights need less bias because their shadow maps cover a smaller area with higher effective resolution.

Cascade Settings

Shadow cascades are configured at the renderer level. The engine uses 4 cascades (NUM_SHADOW_CASCADES = 4) with the shadow map resolution set at initialization (8192 native, 4096 WASM). These are not runtime-configurable through Graphics resources.

Geometry Passes

Geometry passes render scene objects into the HDR color buffer and depth buffer. Each pass handles a different type of geometry.

ClearPass

Clears scene_color to black and depth to 0.0 (reversed-Z far plane). Always runs first.

Writes: scene_color, depth

SkyPass

Renders the sky/atmosphere background. Controlled by world.resources.graphics.atmosphere:

  • Atmosphere::None - Solid background color (default)
  • Atmosphere::Sky - Procedural clear sky gradient
  • Atmosphere::CloudySky - Procedural sky with volumetric clouds
  • Atmosphere::Space - Procedural starfield
  • Atmosphere::Nebula - Procedural nebula with stars
  • Atmosphere::Sunset - Procedural sunset gradient
  • Atmosphere::DayNight - Procedural day/night cycle driven by hour parameter
  • Atmosphere::Hdr - HDR environment cubemap

Writes: scene_color

ScenePass

Basic scene rendering pass for simple objects.

Reads/Writes: scene_color, depth

MeshPass

The main PBR mesh rendering pass. Renders all entities with RENDER_MESH | MATERIAL_REF | GLOBAL_TRANSFORM. Features:

  • PBR shading with metallic-roughness workflow
  • Cascaded shadow mapping - Samples the shadow depth texture
  • Spotlight shadows - Samples the spotlight shadow atlas
  • Normal mapping - Per-pixel normals from normal textures
  • Alpha modes - Opaque, mask (alpha cutoff), and blend
  • Entity ID output - Writes entity IDs for GPU picking
  • View normals output - Writes view-space normals for SSAO/SSGI

Reads: shadow_depth, spotlight_shadow_atlas Writes: scene_color, depth, entity_id, view_normals

SkinnedMeshPass

Renders animated skeletal meshes. Reads bone matrices from the skinning buffer and transforms vertices on the GPU.

Reads: shadow_depth, spotlight_shadow_atlas Writes: scene_color, depth

WaterPass

Renders water surfaces with procedural wave displacement, reflections, and refractions.

Reads/Writes: scene_color, depth

WaterMeshPass

Renders water mesh geometry with tessellation and displacement.

Reads/Writes: scene_color, depth

GrassPass

GPU-instanced grass rendering. Renders thousands of grass blades using instance data from GrassRegion components. Supports wind animation and interactive bending via GrassInteractor components.

Reads/Writes: scene_color, depth

DecalPass

Renders projected decals onto scene geometry. Decals sample the depth buffer to project textures onto surfaces.

Reads/Writes: scene_color, depth

ParticlePass

GPU billboard particle rendering. Reads ParticleEmitter component data and renders camera-facing quads.

Reads/Writes: scene_color, depth

TextPass

Renders 3D world-space text using the font atlas.

Reads/Writes: scene_color, depth

HudPass

Renders screen-space HUD text. Unlike TextPass, HUD text is rendered in screen coordinates with configurable anchoring.

Reads/Writes: scene_color, depth

LinesPass

Renders debug lines from Lines components. Useful for visualization, bounding boxes, and debugging.

Reads/Writes: scene_color, depth

GridPass

Renders an infinite ground grid. Controlled by world.resources.graphics.show_grid.

Reads/Writes: scene_color, depth

UiRectPass

Renders UI rectangles for the immediate-mode UI system.

SelectionMaskPass

Generates a selection mask texture for selected entities. Used by the editor for selection outlines.

Reads: depth Writes: selection_mask

OutlinePass

Reads the selection mask and renders outlines around selected entities by detecting edges in the mask.

Reads: selection_mask Writes: scene_color

SDF Passes (feature: sdf_sculpt)

SdfComputePass

Computes SDF brick maps on the GPU.

SdfPass

Raymarches signed distance fields for real-time sculpting visualization.

InteriorMappingPass

Renders interior mapping (parallax cubemap) for building windows.

ProjectionPass / HiZPass

Hierarchical-Z buffer generation for occlusion culling.

Post-Processing

Live Demos: Bloom | SSAO | Depth of Field

Post-processing passes read the HDR scene color, depth, and normals to produce the final image. These passes are added in configure_render_graph().

Available Passes

PassDescriptionReadsWrites
SsaoPassScreen-space ambient occlusiondepth, normalsssao_raw
SsaoBlurPassBilateral blur for SSAOssao_rawssao
SsgiPassScreen-space global illumination (half-res)scene_color, depth, normalsssgi_raw
SsgiBlurPassBilateral blur for SSGIssgi_rawssgi
SsrPassScreen-space reflectionsscene_color, depth, normalsssr_raw
SsrBlurPassBlur for SSRssr_rawssr
BloomPassHDR bloom with mip chainscene_colorbloom
DepthOfFieldPassBokeh depth of fieldscene_color, depthscene_color
PostProcessPassFinal tonemapping and compositingscene_color, bloom, ssaooutput
EffectsPassCustom shader effectsscene_colorscene_color
OutlinePassSelection outlineselection_maskscene_color
BlitPassSimple texture copyinputoutput
ComputeGrayscalePassGrayscale conversioninputoutput

Enabling Effects

Control post-processing through world.resources.graphics:

#![allow(unused)]
fn main() {
fn initialize(&mut self, world: &mut World) {
    world.resources.graphics.bloom_enabled = true;
    world.resources.graphics.bloom_intensity = 0.3;

    world.resources.graphics.ssao_enabled = true;
    world.resources.graphics.ssao_radius = 0.5;
    world.resources.graphics.ssao_intensity = 1.0;

    world.resources.graphics.color_grading.tonemap_algorithm = TonemapAlgorithm::Aces;
}
}

SSAO (Screen-Space Ambient Occlusion)

In the real world, corners, crevices, and enclosed spaces receive less ambient light because surrounding geometry occludes incoming light from many directions. SSAO approximates this effect in screen space by analyzing the depth buffer.

How SSAO Works

For each pixel, the shader reconstructs the 3D position from the depth buffer, then samples several random points in a hemisphere oriented along the surface normal. Each sample point is projected back into screen space to check the depth buffer: if the stored depth is closer than the sample point, that direction is occluded. The ratio of occluded samples to total samples gives the occlusion factor.

The key inputs are:

  • Depth buffer - Provides the 3D position of each pixel
  • View-space normals - Orients the sampling hemisphere along the surface
  • Random noise - Rotates the sample kernel per-pixel to avoid banding patterns

The raw SSAO output is noisy because of the limited sample count (typically 16-64 samples per pixel). A bilateral blur pass smooths the result while preserving edges (it avoids blurring across depth discontinuities, which would cause halos around objects).

#![allow(unused)]
fn main() {
world.resources.graphics.ssao_enabled = true;
world.resources.graphics.ssao_radius = 0.5;
world.resources.graphics.ssao_intensity = 1.0;
world.resources.graphics.ssao_bias = 0.025;
}
  • ssao_radius - The hemisphere radius in world units. Larger values detect occlusion from farther geometry but can cause over-darkening.
  • ssao_bias - A small depth offset to prevent self-occlusion artifacts on flat surfaces.
  • ssao_intensity - Multiplier for the final occlusion factor.

SSGI (Screen-Space Global Illumination)

In real-world lighting, light bounces between surfaces. A red wall next to a white floor tints the floor red. Traditional rasterization only computes direct lighting (light source to surface to camera). Global illumination (GI) adds these indirect bounces.

SSGI approximates one bounce of indirect light using only screen-space information. For each pixel, the shader traces short rays through the depth buffer to find nearby surfaces, then samples the color at those hit points as incoming indirect light. This is conceptually similar to SSAO but samples color instead of just occlusion.

SSGI is computed at half resolution for performance (the indirect illumination is low-frequency and doesn't need full resolution), then bilaterally blurred and upsampled.

SSR (Screen-Space Reflections)

SSR adds dynamic reflections by ray-marching through the depth buffer. For each reflective pixel, the shader computes the reflection vector from the camera direction and the surface normal, then steps along that vector in screen space, checking the depth buffer at each step. When the ray intersects a surface (the ray's depth exceeds the depth buffer value), the color at that screen position becomes the reflection.

This technique works well for reflections of on-screen geometry but has inherent limitations: off-screen objects cannot be reflected, and reflections at grazing angles stretch across large screen areas. The blur pass hides artifacts from these limitations, and a fallback to environment maps or IBL fills in where SSR has no data.

Bloom

Bloom simulates the light scattering that occurs in real cameras and the human eye when bright light sources bleed into surrounding areas. In HDR rendering, pixels can have values above 1.0 (the displayable range). Bloom extracts these bright pixels and spreads their light outward.

How Bloom Works

The bloom pipeline uses a progressive downsample/upsample approach (similar to the technique described in the Call of Duty: Advanced Warfare presentation):

  1. Threshold - Extract pixels brighter than a threshold from the HDR scene color
  2. Downsample chain - Progressively halve the resolution through multiple mip levels (e.g., 1920x1080 -> 960x540 -> 480x270 -> ...), applying a blur at each step. This is much cheaper than blurring at full resolution because each mip level has 1/4 the pixels.
  3. Upsample chain - Walk back up the mip chain, additively blending each level with the one above it. This produces a smooth, wide blur that spans many pixels without requiring a massive blur kernel.
  4. Composite - Add the bloom result to the scene color during the final post-process pass.

The mip-chain approach produces natural-looking bloom because it captures both tight glow (from the high-resolution mips) and wide glow (from the low-resolution mips) simultaneously.

Bloom creates a glow effect around bright pixels using this mip-chain downsample/upsample approach:

#![allow(unused)]
fn main() {
world.resources.graphics.bloom_enabled = true;
world.resources.graphics.bloom_intensity = 0.5;
}

Materials with high emissive values produce the strongest bloom:

#![allow(unused)]
fn main() {
let glowing = Material {
    base_color: [0.2, 0.8, 1.0, 1.0],
    emissive_factor: [2.0, 8.0, 10.0],
    ..Default::default()
};
}

Depth of Field

Depth of field simulates the optical behavior of a physical camera lens. A real lens can only focus at one distance; objects nearer or farther than the focal plane appear blurred. The amount of blur (the circle of confusion, or CoC) increases with distance from the focal plane and is controlled by the aperture size.

How DoF Works

  1. CoC computation - For each pixel, compute the circle of confusion from the depth buffer value, the focus distance, and the aperture. The CoC is the diameter (in pixels) of the blur disc for that pixel.
  2. Blur - Apply a variable-radius blur where the kernel size is proportional to the CoC. Pixels with large CoC values (far from focus) get blurred heavily; pixels near the focal plane remain sharp.
  3. Bokeh - Bright out-of-focus highlights form characteristic shapes (circles, hexagons) called bokeh. The shader can emphasize bright pixels during the blur to simulate this optical effect.

Focus blur based on distance from a focus plane:

#![allow(unused)]
fn main() {
world.resources.graphics.depth_of_field.enabled = true;
world.resources.graphics.depth_of_field.focus_distance = 10.0;
world.resources.graphics.depth_of_field.focus_range = 5.0;
world.resources.graphics.depth_of_field.max_blur_radius = 10.0;
world.resources.graphics.depth_of_field.bokeh_threshold = 1.0;
world.resources.graphics.depth_of_field.bokeh_intensity = 1.0;
}

Tonemapping

HDR rendering computes lighting in a physically linear color space where values can range from 0 to thousands. But displays can only show values between 0 and 1. Tonemapping is the process of compressing the HDR range into the displayable LDR range while preserving the perception of brightness differences and color relationships.

Different tonemapping curves make different trade-offs:

  • Reinhard - Simple color / (color + 1) mapping. Preserves highlights but can look washed out.
  • ACES (Academy Color Encoding System) - Film-industry standard curve with good contrast and a slight warm tint. Widely used in games.
  • AgX - A more recent curve designed to handle highly saturated colors better than ACES (which can produce hue shifts in bright saturated regions).
  • Neutral - Minimal color manipulation, useful when color grading is handled externally.

The PostProcessPass performs HDR-to-LDR tonemapping:

#![allow(unused)]
fn main() {
pub enum TonemapAlgorithm {
    Reinhard,
    Aces,
    ReinhardExtended,
    Uncharted2,
    AgX,
    Neutral,
    None,
}

world.resources.graphics.color_grading.tonemap_algorithm = TonemapAlgorithm::Aces;
}

Color Grading

#![allow(unused)]
fn main() {
world.resources.graphics.color_grading.saturation = 1.0;
world.resources.graphics.color_grading.contrast = 1.0;
world.resources.graphics.color_grading.brightness = 0.0;
}

Effects Pass

The EffectsPass runs custom WGSL shader effects for specialized visual treatments:

  • Color grading presets
  • Chromatic aberration
  • Film grain
  • Custom shader effects

See Effects Pass for details.

Custom Post-Processing

Add custom post-processing passes via the render graph. See Custom Passes for implementation examples.

Performance

EffectCostNotes
BloomMediumMultiple blur passes at half resolution
SSAOHighMany depth samples per pixel
SSGIHighHalf resolution helps, but still expensive
SSRHighRay tracing through depth buffer
DoFMediumGaussian blur
TonemappingLowPer-pixel math
Color GradingLowPer-pixel math

Disable expensive effects for better performance:

#![allow(unused)]
fn main() {
fn set_quality_low(world: &mut World) {
    world.resources.graphics.ssao_enabled = false;
    world.resources.graphics.bloom_enabled = false;
}

fn set_quality_high(world: &mut World) {
    world.resources.graphics.ssao_enabled = true;
    world.resources.graphics.bloom_enabled = true;
}
}

Cameras

Live Demo: Skybox

Cameras define the viewpoint and projection used to render the scene. Nightshade uses reversed-Z depth buffers for both perspective and orthographic projections, and supports infinite far planes, input smoothing, and arc-ball orbit controllers.

Camera Component

A camera entity needs a transform and the CAMERA component:

#![allow(unused)]
fn main() {
let camera = world.spawn_entities(
    LOCAL_TRANSFORM | GLOBAL_TRANSFORM | CAMERA,
    1
)[0];
}
#![allow(unused)]
fn main() {
pub struct Camera {
    pub projection: Projection,
    pub smoothing: Option<Smoothing>,
}

pub enum Projection {
    Perspective(PerspectiveCamera),
    Orthographic(OrthographicCamera),
}
}

The default Camera uses a perspective projection (45 degree FOV, infinite far plane, 0.01 near plane) with smoothing enabled.

Spawning Cameras

Basic Camera

#![allow(unused)]
fn main() {
let camera = spawn_camera(
    world,
    Vec3::new(0.0, 5.0, 10.0),
    "Main Camera".to_string(),
);
world.resources.active_camera = Some(camera);
}

Pan-Orbit Camera

For editor-style arc-ball controls:

#![allow(unused)]
fn main() {
use nightshade::ecs::camera::commands::spawn_pan_orbit_camera;

let camera = spawn_pan_orbit_camera(
    world,
    Vec3::new(0.0, 2.0, 0.0),  // focus point
    10.0,                       // radius (distance)
    0.5,                        // yaw (horizontal angle)
    0.4,                        // pitch (vertical angle)
    "Orbit Camera".to_string(),
);
}

Perspective Projection

#![allow(unused)]
fn main() {
pub struct PerspectiveCamera {
    pub aspect_ratio: Option<f32>,
    pub y_fov_rad: f32,
    pub z_far: Option<f32>,
    pub z_near: f32,
}
}
FieldDefaultDescription
aspect_ratioNoneWidth/height ratio. None uses the viewport aspect ratio
y_fov_rad0.7854 (45 deg)Vertical field of view in radians
z_farNoneFar plane distance. None uses an infinite far plane
z_near0.01Near plane distance

Reversed-Z Projection

Nightshade uses reversed-Z depth buffers where the near plane maps to depth 1.0 and the far plane maps to 0.0. This distributes floating-point precision more evenly across the depth range, dramatically reducing z-fighting artifacts at large distances.

With an infinite far plane (z_far: None), the projection matrix is:

f = 1 / tan(fov / 2)

| f/aspect  0     0      0     |
| 0         f     0      0     |
| 0         0     0      z_near|
| 0         0    -1      0     |

With a finite far plane, the matrix maps [z_near, z_far] to [1.0, 0.0]:

| f/aspect  0     0                          0                           |
| 0         f     0                          0                           |
| 0         0     z_near/(z_far - z_near)    z_near*z_far/(z_far-z_near) |
| 0         0    -1                          0                           |
#![allow(unused)]
fn main() {
world.core.set_camera(camera, Camera {
    projection: Projection::Perspective(PerspectiveCamera {
        y_fov_rad: 1.0,
        aspect_ratio: None,
        z_near: 0.1,
        z_far: Some(1000.0),
    }),
    smoothing: None,
});
}

Orthographic Projection

#![allow(unused)]
fn main() {
pub struct OrthographicCamera {
    pub x_mag: f32,
    pub y_mag: f32,
    pub z_far: f32,
    pub z_near: f32,
}
}
FieldDefaultDescription
x_mag10.0Half-width of the view volume (horizontal extent is ±x_mag)
y_mag10.0Half-height of the view volume (vertical extent is ±y_mag)
z_far1000.0Far clipping plane distance
z_near0.01Near clipping plane distance

The orthographic projection also uses reversed-Z, mapping [z_near, z_far] to [1.0, 0.0]:

#![allow(unused)]
fn main() {
world.core.set_camera(camera, Camera {
    projection: Projection::Orthographic(OrthographicCamera {
        x_mag: 10.0,
        y_mag: 10.0,
        z_near: 0.1,
        z_far: 100.0,
    }),
    smoothing: None,
});
}

Camera Systems

Fly Camera

Free-flying FPS-style camera with WASD movement:

#![allow(unused)]
fn main() {
fn run_systems(&mut self, world: &mut World) {
    fly_camera_system(world);
}
}

Pan-Orbit Camera

Arc-ball camera that orbits around a focus point:

#![allow(unused)]
fn main() {
use nightshade::ecs::camera::systems::pan_orbit_camera_system;

fn run_systems(&mut self, world: &mut World) {
    pan_orbit_camera_system(world);
}
}

Orthographic Camera

For 2D or isometric views:

#![allow(unused)]
fn main() {
use nightshade::ecs::camera::systems::ortho_camera_system;

fn run_systems(&mut self, world: &mut World) {
    ortho_camera_system(world);
}
}

Input Smoothing

The Smoothing component applies frame-rate-independent exponential smoothing to all camera input. The smoothing factor is computed as:

smoothing_factor = 1.0 - smoothness^7 ^ delta_time

Where smoothness is the per-device smoothness parameter. A smoothness of 0 gives instant response; values approaching 1 make the input increasingly sluggish. The powi(7) exponent makes the smoothness parameter feel linear to adjust.

#![allow(unused)]
fn main() {
pub struct Smoothing {
    pub mouse_sensitivity: f32,
    pub mouse_smoothness: f32,
    pub mouse_dpi_scale: f32,
    pub keyboard_smoothness: f32,
    pub gamepad_sensitivity: f32,
    pub gamepad_smoothness: f32,
    pub gamepad_deadzone: f32,
}
}
FieldDefaultDescription
mouse_sensitivity0.5Mouse look speed multiplier
mouse_smoothness0.05Mouse input smoothing (0 = instant, 1 = no change)
mouse_dpi_scale1.0DPI scaling factor for mouse input
keyboard_smoothness0.08Keyboard movement smoothing
gamepad_sensitivity1.5Gamepad stick look speed
gamepad_smoothness0.06Gamepad input smoothing
gamepad_deadzone0.15Gamepad stick deadzone threshold
#![allow(unused)]
fn main() {
world.core.set_camera(camera, Camera {
    projection: Projection::Perspective(PerspectiveCamera::default()),
    smoothing: Some(Smoothing {
        mouse_sensitivity: 0.5,
        mouse_smoothness: 0.05,
        keyboard_smoothness: 0.08,
        ..Smoothing::default()
    }),
});
}

Pan-Orbit Camera Configuration

The PanOrbitCamera component provides a fully configurable arc-ball camera with Blender-style controls by default.

#![allow(unused)]
fn main() {
pub struct PanOrbitCamera {
    pub focus: Vec3,
    pub radius: f32,
    pub yaw: f32,
    pub pitch: f32,
    pub target_focus: Vec3,
    pub target_radius: f32,
    pub target_yaw: f32,
    pub target_pitch: f32,
    pub enabled: bool,
    // ... configuration fields
}
}

Default Controls

ActionMouseGamepadTouch
OrbitMiddle buttonRight stickSingle finger drag
PanShift + Middle buttonLeft stickTwo finger drag
Zoom (drag)Ctrl + Middle buttonTriggersPinch
Zoom (step)Scroll wheel

Builder API

#![allow(unused)]
fn main() {
let pan_orbit = PanOrbitCamera::new(focus, 10.0)
    .with_yaw_pitch(0.5, 0.4)
    .with_zoom_limits(1.0, Some(100.0))
    .with_pitch_limits(-1.5, 1.5)
    .with_smoothness(0.1, 0.02, 0.1)
    .with_buttons(PanOrbitButton::Middle, PanOrbitButton::Middle)
    .with_modifiers(None, Some(PanOrbitModifier::Shift))
    .with_upside_down(false);
}

Sensitivity and Smoothness

Each action has independent sensitivity and smoothness parameters:

ParameterDefaultDescription
orbit_sensitivity1.0Mouse orbit speed
pan_sensitivity1.0Mouse pan speed
zoom_sensitivity1.0Scroll zoom speed
orbit_smoothness0.1Orbit interpolation smoothness
pan_smoothness0.02Pan interpolation smoothness
zoom_smoothness0.1Zoom interpolation smoothness
gamepad_orbit_sensitivity2.0Gamepad orbit speed
gamepad_pan_sensitivity10.0Gamepad pan speed
gamepad_zoom_sensitivity5.0Gamepad zoom speed
gamepad_deadzone0.15Stick deadzone
gamepad_smoothness0.06Gamepad smoothing

Target values (target_yaw, target_pitch, target_focus, target_radius) are set by user input, then the current values interpolate towards them using the smoothing formula. The system snaps to the target when the difference falls below 0.001.

Zoom and Pitch Limits

#![allow(unused)]
fn main() {
if let Some(pan_orbit) = world.core.get_pan_orbit_camera_mut(camera) {
    pan_orbit.zoom_lower_limit = 1.0;
    pan_orbit.zoom_upper_limit = Some(50.0);
    pan_orbit.pitch_upper_limit = std::f32::consts::FRAC_PI_2 - 0.01;
    pan_orbit.pitch_lower_limit = -(std::f32::consts::FRAC_PI_2 - 0.01);
}
}

Upside-Down Handling

When allow_upside_down is true, the pitch can exceed ±90 degrees. When the camera goes upside down, the yaw direction is automatically reversed for intuitive mouse control.

Runtime Control

#![allow(unused)]
fn main() {
if let Some(pan_orbit) = world.core.get_pan_orbit_camera_mut(camera) {
    pan_orbit.target_focus = Vec3::new(0.0, 2.0, 0.0);
    pan_orbit.target_radius = 5.0;
    pan_orbit.target_yaw += 0.1;
    pan_orbit.target_pitch += 0.05;
}
}

Computing Camera Transform

The pan-orbit camera position is computed from yaw, pitch, and radius:

#![allow(unused)]
fn main() {
let (position, rotation) = pan_orbit.compute_camera_transform();
}

The camera is placed at focus + rotate(yaw, pitch) * (0, 0, radius) — the rotation is composed as yaw (around Y) then pitch (around X).

Screen-to-World Conversion

Convert screen coordinates to a world-space ray:

#![allow(unused)]
fn main() {
use nightshade::ecs::picking::PickingRay;

let screen_pos = world.resources.input.mouse.position;
if let Some(ray) = PickingRay::from_screen_position(world, screen_pos) {
    let origin = ray.origin;
    let direction = ray.direction;
}
}

For perspective cameras, the ray origin is the camera position and the direction is computed by unprojecting through the inverse view-projection matrix. For orthographic cameras, the origin is the unprojected near-plane point and the direction is the camera's forward vector.

Multiple Cameras

Switch between cameras:

#![allow(unused)]
fn main() {
fn on_keyboard_input(&mut self, world: &mut World, key: KeyCode, state: ElementState) {
    if state == ElementState::Pressed && key == KeyCode::Tab {
        let current = world.resources.active_camera;
        world.resources.active_camera = if current == Some(self.main_camera) {
            Some(self.debug_camera)
        } else {
            Some(self.main_camera)
        };
    }
}
}

Materials

Live Demos: Textures | Alpha Blending

Materials define the visual appearance of meshes using PBR (Physically Based Rendering) following the glTF 2.0 metallic-roughness workflow. Nightshade supports the full glTF PBR model plus several extensions: KHR_materials_transmission, KHR_materials_volume, KHR_materials_specular, and KHR_materials_emissive_strength.

Material Structure

#![allow(unused)]
fn main() {
pub struct Material {
    // Core PBR
    pub base_color: [f32; 4],
    pub roughness: f32,
    pub metallic: f32,
    pub emissive_factor: [f32; 3],
    pub emissive_strength: f32,
    pub alpha_mode: AlphaMode,
    pub alpha_cutoff: f32,
    pub unlit: bool,
    pub double_sided: bool,
    pub uv_scale: [f32; 2],

    // Textures
    pub base_texture: Option<String>,
    pub base_texture_uv_set: u32,
    pub emissive_texture: Option<String>,
    pub emissive_texture_uv_set: u32,
    pub normal_texture: Option<String>,
    pub normal_texture_uv_set: u32,
    pub normal_scale: f32,
    pub normal_map_flip_y: bool,
    pub normal_map_two_component: bool,
    pub metallic_roughness_texture: Option<String>,
    pub metallic_roughness_texture_uv_set: u32,
    pub occlusion_texture: Option<String>,
    pub occlusion_texture_uv_set: u32,
    pub occlusion_strength: f32,

    // Transmission (KHR_materials_transmission)
    pub transmission_factor: f32,
    pub transmission_texture: Option<String>,
    pub transmission_texture_uv_set: u32,

    // Volume (KHR_materials_volume)
    pub thickness: f32,
    pub thickness_texture: Option<String>,
    pub thickness_texture_uv_set: u32,
    pub attenuation_color: [f32; 3],
    pub attenuation_distance: f32,
    pub ior: f32,

    // Specular (KHR_materials_specular)
    pub specular_factor: f32,
    pub specular_color_factor: [f32; 3],
    pub specular_texture: Option<String>,
    pub specular_texture_uv_set: u32,
    pub specular_color_texture: Option<String>,
    pub specular_color_texture_uv_set: u32,
}
}

Core PBR Fields

FieldDefaultDescription
base_color[0.7, 0.7, 0.7, 1.0]RGBA albedo color, multiplied with base_texture
roughness0.5Surface roughness (0 = mirror, 1 = fully diffuse)
metallic0.0Metalness (0 = dielectric, 1 = conductor)
emissive_factor[0.0, 0.0, 0.0]RGB emissive color, multiplied with emissive_texture
emissive_strength1.0HDR intensity multiplier for emissive output
alpha_modeOpaqueTransparency handling mode
alpha_cutoff0.5Alpha threshold for AlphaMode::Mask
unlitfalseSkip lighting calculations (flat shaded)
double_sidedfalseRender both sides of faces
uv_scale[1.0, 1.0]UV coordinate scale multiplier

Normal Map Options

FieldDefaultDescription
normal_scale1.0Normal map intensity multiplier
normal_map_flip_yfalseFlip the Y (green) channel for DirectX-style normal maps
normal_map_two_componentfalseTwo-component normal map (RG only, B reconstructed)
occlusion_strength1.0Ambient occlusion effect strength (0 = none, 1 = full)

Transmission and Volume

These fields implement light transmission through surfaces (glass, water, thin-shell materials):

FieldDefaultDescription
transmission_factor0.0Fraction of light transmitted through the surface (0 = opaque, 1 = fully transmissive)
thickness0.0Volume thickness for refraction (0 = thin-wall)
attenuation_color[1.0, 1.0, 1.0]Color of light absorbed inside the volume
attenuation_distance0.0Distance at which light is attenuated to attenuation_color
ior1.5Index of refraction (1.0 = air, 1.33 = water, 1.5 = glass, 2.42 = diamond)

Specular

Overrides the default Fresnel reflectance for dielectric materials:

FieldDefaultDescription
specular_factor1.0Specular intensity override (0 = no specular, 1 = default F0)
specular_color_factor[1.0, 1.0, 1.0]Tints the specular reflection color

Alpha Modes

#![allow(unused)]
fn main() {
pub enum AlphaMode {
    Opaque,  // Fully opaque, alpha ignored
    Mask,    // Binary transparency using alpha_cutoff
    Blend,   // Full alpha blending
}
}

Creating Materials

Basic Colored Material

#![allow(unused)]
fn main() {
let red_material = Material {
    base_color: [1.0, 0.0, 0.0, 1.0],
    roughness: 0.5,
    metallic: 0.0,
    ..Default::default()
};

material_registry_insert(
    &mut world.resources.material_registry,
    "red".to_string(),
    red_material,
);
}

Metallic Material

#![allow(unused)]
fn main() {
let gold = Material {
    base_color: [1.0, 0.84, 0.0, 1.0],
    roughness: 0.3,
    metallic: 1.0,
    ..Default::default()
};
}

Emissive Material

The final emissive output is emissive_factor * emissive_strength * emissive_texture:

#![allow(unused)]
fn main() {
let neon = Material {
    base_color: [0.2, 0.8, 1.0, 1.0],
    emissive_factor: [0.2, 0.8, 1.0],
    emissive_strength: 10.0,
    roughness: 0.8,
    ..Default::default()
};
}

Glass / Transmissive Material

#![allow(unused)]
fn main() {
let glass = Material {
    base_color: [0.95, 0.95, 1.0, 1.0],
    roughness: 0.05,
    metallic: 0.0,
    transmission_factor: 0.95,
    ior: 1.5,
    ..Default::default()
};
}

Colored Glass with Volume Absorption

#![allow(unused)]
fn main() {
let stained_glass = Material {
    base_color: [0.8, 0.2, 0.2, 1.0],
    roughness: 0.05,
    transmission_factor: 0.9,
    thickness: 0.02,
    attenuation_color: [0.8, 0.1, 0.1],
    attenuation_distance: 0.05,
    ior: 1.52,
    ..Default::default()
};
}

Transparent (Alpha Blended) Material

#![allow(unused)]
fn main() {
let ghost = Material {
    base_color: [0.9, 0.95, 1.0, 0.3],
    alpha_mode: AlphaMode::Blend,
    roughness: 0.1,
    ..Default::default()
};
}

Foliage (Alpha Mask)

#![allow(unused)]
fn main() {
let foliage = Material {
    base_texture: Some("leaf_color".to_string()),
    alpha_mode: AlphaMode::Mask,
    alpha_cutoff: 0.5,
    double_sided: true,
    ..Default::default()
};
}

Textured Materials

Loading Textures

#![allow(unused)]
fn main() {
let texture_bytes = include_bytes!("../assets/wood.png");
let image = image::load_from_memory(texture_bytes).unwrap().to_rgba8();

world.queue_command(WorldCommand::LoadTexture {
    name: "wood".to_string(),
    rgba_data: image.to_vec(),
    width: image.width(),
    height: image.height(),
});
}

Full PBR Texture Set

#![allow(unused)]
fn main() {
let brick = Material {
    base_texture: Some("brick_color".to_string()),
    normal_texture: Some("brick_normal".to_string()),
    normal_scale: 1.0,
    metallic_roughness_texture: Some("brick_metallic_roughness".to_string()),
    occlusion_texture: Some("brick_ao".to_string()),
    roughness: 1.0,
    metallic: 1.0,
    ..Default::default()
};
}

When a metallic_roughness_texture is present, the roughness and metallic values are multiplied with the texture's green and blue channels respectively.

UV Scaling

Tile a texture by scaling UV coordinates:

#![allow(unused)]
fn main() {
let tiled = Material {
    base_texture: Some("tile".to_string()),
    uv_scale: [4.0, 4.0],
    ..Default::default()
};
}

DirectX Normal Maps

Some normal maps (e.g., from Substance or older tools) use a flipped Y channel:

#![allow(unused)]
fn main() {
let material = Material {
    normal_texture: Some("dx_normal".to_string()),
    normal_map_flip_y: true,
    ..Default::default()
};
}

For two-component normal maps (RG only, B reconstructed from RG):

#![allow(unused)]
fn main() {
let material = Material {
    normal_texture: Some("bc5_normal".to_string()),
    normal_map_two_component: true,
    ..Default::default()
};
}

Assigning Materials to Entities

#![allow(unused)]
fn main() {
material_registry_insert(
    &mut world.resources.material_registry,
    "my_material".to_string(),
    my_material,
);

if let Some(&index) = world.resources.material_registry.registry.name_to_index.get("my_material") {
    world.resources.material_registry.registry.add_reference(index);
}

world.core.set_material_ref(entity, MaterialRef::new("my_material"));
}

Procedural Textures

Generate textures at runtime:

#![allow(unused)]
fn main() {
fn create_checkerboard(size: usize) -> Vec<u8> {
    let mut data = vec![0u8; size * size * 4];

    for y in 0..size {
        for x in 0..size {
            let index = (y * size + x) * 4;
            let checker = ((x / 32) + (y / 32)) % 2 == 0;
            let value = if checker { 255 } else { 64 };
            data[index] = value;
            data[index + 1] = value;
            data[index + 2] = value;
            data[index + 3] = 255;
        }
    }

    data
}

let checkerboard = create_checkerboard(256);
world.queue_command(WorldCommand::LoadTexture {
    name: "checkerboard".to_string(),
    rgba_data: checkerboard,
    width: 256,
    height: 256,
});
}

Meshes & Models

Live Demo: Prefabs

Built-in Primitives

Nightshade provides basic geometric primitives:

#![allow(unused)]
fn main() {
use nightshade::prelude::*;

spawn_cube_at(world, Vec3::new(0.0, 1.0, 0.0));
spawn_sphere_at(world, Vec3::new(2.0, 1.0, 0.0));
spawn_plane_at(world, Vec3::zeros());
spawn_cylinder_at(world, Vec3::new(-2.0, 1.0, 0.0));
}

Loading glTF/GLB Models

Basic Loading

#![allow(unused)]
fn main() {
use nightshade::ecs::prefab::*;

const MODEL_BYTES: &[u8] = include_bytes!("../assets/character.glb");

fn load_model(world: &mut World) -> Option<Entity> {
    let result = import_gltf_from_bytes(MODEL_BYTES).ok()?;

    // Register textures
    for (name, (rgba_data, width, height)) in result.textures {
        world.queue_command(WorldCommand::LoadTexture {
            name,
            rgba_data,
            width,
            height,
        });
    }

    // Register meshes
    for (name, mesh) in result.meshes {
        mesh_cache_insert(&mut world.resources.mesh_cache, name, mesh);
    }

    // Spawn first prefab
    result.prefabs.first().map(|prefab| {
        spawn_prefab_with_skins(
            world,
            prefab,
            &result.animations,
            &result.skins,
            Vec3::zeros(),
        )
    })
}
}

With Custom Position/Transform

#![allow(unused)]
fn main() {
fn spawn_model_at(world: &mut World, prefab: &Prefab, position: Vec3, scale: f32) -> Entity {
    let entity = spawn_prefab(world, prefab, position);

    if let Some(transform) = world.core.get_local_transform_mut(entity) {
        transform.scale = Vec3::new(scale, scale, scale);
    }

    entity
}
}

Filtered Animation Channels

Remove root motion from animations:

#![allow(unused)]
fn main() {
let root_bone_indices: HashSet<usize> = [0, 1, 2, 3].into();

let filtered_animations: Vec<AnimationClip> = result
    .animations
    .iter()
    .map(|clip| AnimationClip {
        name: clip.name.clone(),
        duration: clip.duration,
        channels: clip
            .channels
            .iter()
            .filter(|channel| {
                // Skip translation on all bones
                if channel.target_property == AnimationProperty::Translation {
                    return false;
                }
                // Skip rotation on root bones
                if root_bone_indices.contains(&channel.target_node)
                    && channel.target_property == AnimationProperty::Rotation
                {
                    return false;
                }
                true
            })
            .cloned()
            .collect(),
    })
    .collect();
}

Manual Mesh Creation

Create meshes programmatically:

#![allow(unused)]
fn main() {
use nightshade::ecs::mesh::*;

let vertices = vec![
    Vertex {
        position: [0.0, 0.0, 0.0],
        normal: [0.0, 1.0, 0.0],
        tex_coords: [0.0, 0.0],
        ..Default::default()
    },
    Vertex {
        position: [1.0, 0.0, 0.0],
        normal: [0.0, 1.0, 0.0],
        tex_coords: [1.0, 0.0],
        ..Default::default()
    },
    Vertex {
        position: [0.5, 0.0, 1.0],
        normal: [0.0, 1.0, 0.0],
        tex_coords: [0.5, 1.0],
        ..Default::default()
    },
];

let indices = vec![0, 1, 2];

let mesh = Mesh {
    vertices,
    indices,
    ..Default::default()
};

mesh_cache_insert(&mut world.resources.mesh_cache, "triangle".to_string(), mesh);
}

Mesh Component

Assign a mesh to an entity:

#![allow(unused)]
fn main() {
let entity = world.spawn_entities(
    LOCAL_TRANSFORM | GLOBAL_TRANSFORM | RENDER_MESH | MATERIAL_REF,
    1
)[0];

world.core.set_render_mesh(entity, RenderMesh {
    name: "triangle".to_string(),
    id: None,
});

world.core.set_material_ref(entity, MaterialRef::new("default"));
}

Instanced Meshes

For rendering many copies of the same mesh efficiently:

#![allow(unused)]
fn main() {
world.core.set_instanced_mesh(entity, InstancedMesh {
    mesh_name: "tree".to_string(),
    instance_count: 1000,
    instance_data: instance_transforms,
});
}

Mesh Cache

Access the mesh cache:

#![allow(unused)]
fn main() {
// Check if mesh exists
if world.resources.mesh_cache.contains("cube") {
    // Mesh is available
}

// Get mesh data (for physics, etc.)
if let Some(mesh) = world.resources.mesh_cache.get("terrain") {
    let vertices: Vec<Vec3> = mesh.vertices.iter()
        .map(|v| Vec3::new(v.position[0], v.position[1], v.position[2]))
        .collect();
}
}

Skinned Meshes

For animated characters with skeletons:

#![allow(unused)]
fn main() {
let entity = spawn_prefab_with_skins(
    world,
    &prefab,
    &animations,
    &skins,
    position,
);

// The entity will have Skin and AnimationPlayer components
if let Some(player) = world.core.get_animation_player_mut(entity) {
    player.playing = true;
    player.looping = true;
}
}

Shadow Casting

Control whether a mesh casts shadows:

#![allow(unused)]
fn main() {
// Enable shadow casting
world.core.add_components(entity, CASTS_SHADOW);
world.core.set_casts_shadow(entity, CastsShadow);

// Disable shadow casting (for UI elements, etc.)
world.core.remove_components(entity, CASTS_SHADOW);
}

Lighting

Live Demos: Lights | Shadows | Spotlight Shadows

Nightshade supports three types of lights: directional, point, and spot lights.

Light Types

Directional Light (Sun)

Illuminates the entire scene from a direction, simulating distant light sources like the sun:

#![allow(unused)]
fn main() {
use nightshade::prelude::*;

let sun = spawn_sun(world);
}

spawn_sun returns the Entity for the directional light, which you can further configure:

#![allow(unused)]
fn main() {
if let Some(light) = world.core.get_light_mut(sun) {
    light.color = Vec3::new(1.0, 0.98, 0.95);
    light.intensity = 2.0;
}
}

Point Light

Emits light in all directions from a point:

#![allow(unused)]
fn main() {
fn create_point_light(world: &mut World, position: Vec3, color: Vec3, intensity: f32) -> Entity {
    let entity = world.spawn_entities(
        LIGHT | LOCAL_TRANSFORM | GLOBAL_TRANSFORM | LOCAL_TRANSFORM_DIRTY,
        1
    )[0];

    world.core.set_light(entity, Light {
        light_type: LightType::Point,
        color,
        intensity,
        range: 10.0,
        cast_shadows: false,
        ..Default::default()
    });

    world.core.set_local_transform(entity, LocalTransform {
        translation: position,
        ..Default::default()
    });

    entity
}
}

Spot Light

Cone-shaped light, perfect for flashlights or stage lighting:

#![allow(unused)]
fn main() {
fn create_spotlight(world: &mut World, position: Vec3, direction: Vec3) -> Entity {
    let entity = world.spawn_entities(
        LIGHT | LOCAL_TRANSFORM | GLOBAL_TRANSFORM | LOCAL_TRANSFORM_DIRTY,
        1
    )[0];

    world.core.set_light(entity, Light {
        light_type: LightType::Spot,
        color: Vec3::new(1.0, 0.95, 0.9),
        intensity: 15.0,
        range: 20.0,
        inner_cone_angle: 0.2,  // Full intensity cone
        outer_cone_angle: 0.5,  // Falloff cone
        cast_shadows: true,
        shadow_bias: 0.002,
    });

    world.core.set_local_transform(entity, LocalTransform {
        translation: position,
        rotation: nalgebra_glm::quat_look_at(&direction.normalize(), &Vec3::y()),
        ..Default::default()
    });

    entity
}
}

Light Properties

PropertyDescription
colorRGB color of the light
intensityBrightness multiplier
rangeMaximum distance for point/spot lights
cast_shadowsWhether this light creates shadows
shadow_biasOffset to reduce shadow acne
inner_cone_angleSpot light inner cone (full intensity)
outer_cone_angleSpot light outer cone (falloff edge)

Dynamic Lighting

Flickering Light

Create a flickering fire/torch effect:

#![allow(unused)]
fn main() {
fn update_flickering_light(world: &mut World, light_entity: Entity) {
    let time = world.resources.window.timing.uptime_milliseconds as f32 / 1000.0;

    if let Some(light) = world.core.get_light_mut(light_entity) {
        let flicker1 = (time * 8.0).sin() * 0.15;
        let flicker2 = (time * 12.5).sin() * 0.1;
        let flicker3 = (time * 23.0).sin() * 0.08;

        let base_intensity = 3.5;
        light.intensity = base_intensity + flicker1 + flicker2 + flicker3;
    }
}
}

Color Cycling

Animated color changes:

#![allow(unused)]
fn main() {
fn update_disco_light(world: &mut World, light_entity: Entity) {
    let time = world.resources.window.timing.uptime_milliseconds as f32 / 1000.0;

    if let Some(light) = world.core.get_light_mut(light_entity) {
        light.color = Vec3::new(
            (time * 2.0).sin() * 0.5 + 0.5,
            (time * 2.0 + 2.094).sin() * 0.5 + 0.5,
            (time * 2.0 + 4.188).sin() * 0.5 + 0.5,
        );
    }
}
}

Flashlight (Camera-Attached Spotlight)

Attach a spotlight to the camera for a flashlight effect:

#![allow(unused)]
fn main() {
fn update_flashlight(world: &mut World, flashlight: Entity) {
    let Some(camera) = world.resources.active_camera else { return };
    let Some(camera_transform) = world.core.get_global_transform(camera) else { return };

    let position = camera_transform.translation();
    let forward = camera_transform.forward_vector();

    if let Some(transform) = world.core.get_local_transform_mut(flashlight) {
        transform.translation = position;
        transform.rotation = nalgebra_glm::quat_look_at(&forward, &Vec3::y());
    }
    mark_local_transform_dirty(world, flashlight);
}
}

How Lighting Works

Nightshade uses a clustered forward rendering pipeline. The view frustum is divided into a 16x9x24 grid of clusters. A compute shader assigns each light to the clusters it overlaps, producing a per-cluster light list (up to 256 lights per cluster). During the mesh pass, each fragment looks up its cluster and only evaluates the lights assigned to it, avoiding the cost of testing every light for every pixel.

PBR Lighting Model

All lights are evaluated using the Cook-Torrance microfacet BRDF:

  • Normal Distribution Function (D): Trowbridge-Reitz GGX models the statistical distribution of microfacet orientations. The squared roughness parameter (a = roughness * roughness) controls how concentrated the specular highlight is.

  • Geometry Function (G): Schlick-Beckmann approximation with Smith's method accounts for self-shadowing between microfacets. Two terms are combined: one for the view direction and one for the light direction.

  • Fresnel (F): Schlick's approximation computes how reflectivity changes with viewing angle. For dielectrics, F0 is derived from the index of refraction. For metals, F0 equals the base color.

The final light contribution per light is:

(kD * albedo / PI + specular) * radiance * NdotL

where kD = (1 - F) * (1 - metallic) ensures metals have no diffuse component.

Image-Based Lighting

Ambient lighting comes from two pre-computed cubemaps:

  • Irradiance map: Pre-convolved diffuse environment lighting, sampled in the surface normal direction
  • Prefiltered environment map: 5 mip levels of increasingly blurred specular reflections, sampled in the reflection direction at a mip level determined by roughness

A 2D BRDF lookup texture (computed via the split-sum approximation) combines with the prefiltered map to produce the final specular IBL contribution.

Atmosphere

Set the sky rendering mode:

#![allow(unused)]
fn main() {
world.resources.graphics.atmosphere = Atmosphere::Sky;
}

Multiple Lights

Nightshade supports multiple lights in a scene. Create point and spot lights manually as shown above:

#![allow(unused)]
fn main() {
fn setup_lighting(world: &mut World) {
    spawn_sun(world);

    create_point_light(world, Vec3::new(5.0, 3.0, 5.0), Vec3::new(0.8, 0.9, 1.0), 2.0);
    create_point_light(world, Vec3::new(-5.0, 3.0, -5.0), Vec3::new(1.0, 0.8, 0.7), 1.5);

    create_spotlight(world, Vec3::new(0.0, 5.0, 0.0), Vec3::new(0.0, -1.0, 0.0));
}
}

Textures & the Texture Cache

Nightshade manages GPU textures through a centralized TextureCache with generational indexing and reference counting. Textures can be loaded synchronously, asynchronously, or generated procedurally.

Texture Cache

The TextureCache stores all loaded textures as TextureEntry values (wgpu texture + view + sampler) in a GenerationalRegistry. Each texture is identified by a TextureId containing an index and generation counter, ensuring stale references are detected.

Loading Textures

The most common way to load a texture is through WorldCommand::LoadTexture:

#![allow(unused)]
fn main() {
world.queue_command(WorldCommand::LoadTexture {
    name: "my_texture".to_string(),
    rgba_data: image_bytes,
    width: 512,
    height: 512,
});
}

The renderer processes this command and uploads the RGBA data to the GPU. The texture is stored in the cache under the given name.

Procedural Textures

The engine provides built-in procedural textures loaded at startup via load_procedural_textures():

#![allow(unused)]
fn main() {
load_procedural_textures(world);
}

This creates three textures:

NameDescription
"checkerboard"Black and white checkerboard pattern
"gradient"Horizontal gradient
"uv_test"UV coordinate visualization

Looking Up Textures

Find a loaded texture by name:

#![allow(unused)]
fn main() {
let texture_id = texture_cache_lookup_id(&cache, "my_texture");
}

Reference Counting

Textures use reference counting for lifecycle management:

#![allow(unused)]
fn main() {
texture_cache_add_reference(&mut cache, "my_texture");
texture_cache_remove_reference(&mut cache, "my_texture");
texture_cache_remove_unused(&mut cache);
}

When a texture's reference count reaches zero, texture_cache_remove_unused() will free it.

Dummy Textures

If a texture is missing, texture_cache_ensure_dummy() creates a 64x64 purple-and-black checkerboard placeholder. This prevents rendering errors from missing assets.

Async Texture Loading

For loading textures without blocking the main thread, use the TextureLoadQueue system.

Setup

#![allow(unused)]
fn main() {
use nightshade::ecs::texture_loader::*;

struct MyState {
    queue: SharedTextureQueue,
    loading_state: AssetLoadingState,
}

fn initialize(&mut self, world: &mut World) {
    self.queue = create_shared_queue();

    queue_texture_from_path(&self.queue, "assets/textures/albedo.png");
    queue_texture_from_path(&self.queue, "assets/textures/normal.png");

    self.loading_state = AssetLoadingState::new(2);
}
}

Processing Each Frame

#![allow(unused)]
fn main() {
fn run_systems(&mut self, world: &mut World) {
    let status = process_and_load_textures(
        &self.queue,
        world,
        &mut self.loading_state,
        4,
    );

    if status == AssetLoadingStatus::Complete {
        // All textures loaded
    }
}
}

Loading Progress

Track loading progress for loading screens:

#![allow(unused)]
fn main() {
let progress = self.loading_state.progress(); // 0.0 to 1.0
let is_done = self.loading_state.is_complete();
let loaded = self.loading_state.loaded_textures;
let failed = self.loading_state.failed_textures;
}

Platform Behavior

PlatformLoading Method
DesktopSynchronous file read from disk
WASMAsync HTTP fetch via ehttp

Asset Search Paths

Configure where texture files are searched:

#![allow(unused)]
fn main() {
set_asset_search_paths(vec![
    "assets/".to_string(),
    "content/textures/".to_string(),
]);

queue_texture_from_path(&queue, "player.png");
// Searches: assets/player.png, content/textures/player.png
}

Sprite Texture Atlas

Sprites use a separate texture atlas rather than the main texture cache. The atlas is a single large GPU texture divided into a grid of slots.

ConstantValue
SPRITE_ATLAS_TOTAL_SLOTS128
SPRITE_ATLAS_SLOT_SIZE512 x 512 pixels

Upload textures to specific atlas slots via WorldCommand::UploadSpriteTexture:

#![allow(unused)]
fn main() {
world.queue_command(WorldCommand::UploadSpriteTexture {
    slot: 0,
    rgba_data: image_bytes,
    width: 256,
    height: 256,
});
}

The Sprite component references textures by their slot index. See Sprites for details.

Material Textures

PBR materials reference textures by name through MaterialRef:

#![allow(unused)]
fn main() {
let material = Material {
    base_texture: Some("albedo".to_string()),
    normal_texture: Some("normal_map".to_string()),
    metallic_roughness_texture: Some("metallic_roughness".to_string()),
    ..Default::default()
};
}

See Materials for the full PBR material workflow.

Water

Live Demo: Water

Nightshade includes a procedural water system with three rendering paths: ray-marched surface water, mesh-based water with vertex displacement, and volumetric water for waterfalls and mist.

How Water Rendering Works

Wave Generation

Water surfaces are animated using multi-octave procedural noise. The sea_octave function creates wave-like patterns by combining abs(sin) and abs(cos) of noise-distorted UV coordinates, then raising the result to a choppiness exponent. Higher choppiness values produce sharper wave peaks.

The height map evaluates 5 octaves of this function. Each octave doubles the frequency and reduces amplitude by 0.22x, while increasing choppiness by 20%. Between octaves, UV coordinates are rotated by a fixed 2x2 matrix [1.6, 1.2; -1.2, 1.6] to prevent visible repetition. Two offset directions (uv + time and uv - time) create coherent wave interference patterns.

Fresnel Reflections

The water shader computes the Fresnel effect to blend between refraction (seeing into the water) and reflection (seeing the sky):

fresnel = pow(1.0 - dot(normal, view_direction), fresnel_power)

At steep viewing angles (looking straight down), fresnel is near 0 and the water shows its base color. At grazing angles (looking across the surface), fresnel approaches 1 and the water reflects the sky. The fresnel_power parameter (default 3.0) controls how quickly this transition happens.

Specular sun reflections use a cosine power of 60 for tight, bright highlights on wave crests.

Three Rendering Paths

Path 1: Ray-Marched Surface Water - For bounded water regions without mesh geometry. The fragment shader traces rays from the camera through the 3D height field using heightmap_tracing() with 32 march steps and geometric refinement iterations. This produces per-pixel correct reflections and refractions. Supports polygon bounds with soft edge feathering. Limited to 16 simultaneous water regions.

Path 2: Mesh-Based Water - For large flat surfaces. The vertex shader applies procedural wave displacement to mesh vertices using the same water_height() function. The fragment shader computes normals from height gradients, applies Fresnel-based sky reflection, and adds subsurface scattering: pow(max(dot(view, -sun_dir), 0.0), 2.0) * 0.2. More efficient than ray-marching and integrates properly with the depth buffer.

Path 3: Volumetric Water - For waterfalls, mist, and cascading water. A per-pixel ray-marching shader traces through SDF-bounded volumes (box, cylinder, or sphere) with up to 64 steps. Three flow types have different density functions:

  • Waterfall: High vertical stretch with turbulence, top-to-bottom falloff
  • Mist: Rising motion with horizontal drift, wispy patterns using 3D FBM noise
  • Cascade: Multiple parallel streams using 3 layered FBM noise functions

Volumetric water is lit with sun shadowing through the volume and foam blending based on accumulated density.

Vertical Water

For waterfall surfaces, a separate shader (water_mesh_vertical.wgsl) displaces vertices along the surface normal instead of the Y-axis. Wave frequency is stretched (2.0x horizontally, 0.5x vertically) to create vertical streaks. Foam patterns are generated from layered noise and blended with the water color.

Frustum Culling

A compute shader (water_mesh_culling.wgsl) tests each water object's bounding sphere against the 6 frustum planes. Culled objects skip rendering entirely. For volumetric water, the bounding sphere is derived from the volume's half-size. Visible instances are appended to an indirect draw buffer via atomic operations.

Water Component

#![allow(unused)]
fn main() {
pub struct Water {
    pub wave_height: f32,           // Wave amplitude (default: 0.6)
    pub choppy: f32,                // Wave sharpness (default: 4.0, higher = sharper peaks)
    pub speed: f32,                 // Animation speed (default: 0.8)
    pub frequency: f32,             // Wave frequency (default: 0.16, lower = longer waves)
    pub base_height: f32,           // Water level (default: 0.0)
    pub base_color: Vec4,           // Dark water color
    pub water_color: Vec4,          // Light water color
    pub specular_strength: f32,     // Sun reflection intensity (default: 1.0)
    pub fresnel_power: f32,         // Reflection balance (default: 3.0)
    pub edge_feather_distance: f32, // Shore softness (default: 2.0)
    pub is_vertical: bool,          // Waterfall mode
    pub is_volumetric: bool,        // 3D volume mode
    pub volume_shape: VolumeShape,  // Box, Cylinder, or Sphere
    pub volume_flow_type: VolumeFlowType, // Waterfall, Mist, or Cascade
    pub volume_size: Vec3,          // Volume dimensions
    pub flow_direction: Vec2,       // Normalized flow direction
    pub flow_strength: f32,         // Flow intensity
}
}

Spawning Water

Planar Water

#![allow(unused)]
fn main() {
let water_entity = world.spawn_entities(
    LOCAL_TRANSFORM | GLOBAL_TRANSFORM | WATER,
    1
)[0];

world.core.set_local_transform(water_entity, LocalTransform {
    translation: Vec3::new(0.0, 0.0, 0.0),
    ..Default::default()
});

world.core.set_water(water_entity, Water {
    wave_height: 0.5,
    speed: 1.0,
    frequency: 0.5,
    choppy: 4.0,
    base_color: Vec4::new(0.0, 0.1, 0.2, 1.0),
    water_color: Vec4::new(0.0, 0.3, 0.5, 1.0),
    fresnel_power: 3.0,
    specular_strength: 1.0,
    edge_feather_distance: 1.0,
    ..Default::default()
});
}

Volumetric Water (Waterfall)

#![allow(unused)]
fn main() {
world.core.set_water(waterfall_entity, Water {
    is_volumetric: true,
    volume_shape: VolumeShape::Box,
    volume_flow_type: VolumeFlowType::Waterfall,
    volume_size: Vec3::new(2.0, 10.0, 1.0),
    flow_direction: Vec2::new(0.0, -1.0),
    flow_strength: 2.0,
    base_color: Vec4::new(0.0, 0.15, 0.25, 1.0),
    water_color: Vec4::new(0.1, 0.4, 0.6, 1.0),
    ..Default::default()
});
}

Wave Parameters

ParameterRangeEffect
wave_height0.1-2.0Vertical amplitude of waves
choppy1.0-8.0Higher = sharper peaks, lower = smooth rounded waves
speed0.1-2.0Animation speed (time multiplier)
frequency0.05-0.5Lower = longer wavelengths, higher = finer detail
fresnel_power1.0-10.0Higher = stronger reflection at grazing angles
specular_strength0.0-2.0Sun reflection intensity

Dynamic Weather

Water properties can be changed at runtime for weather transitions:

#![allow(unused)]
fn main() {
fn stormy_weather(world: &mut World, water: Entity) {
    if let Some(w) = world.core.get_water_mut(water) {
        w.wave_height = 3.0;
        w.speed = 2.0;
        w.choppy = 6.0;
    }
}

fn calm_weather(world: &mut World, water: Entity) {
    if let Some(w) = world.core.get_water_mut(water) {
        w.wave_height = 0.3;
        w.speed = 0.5;
        w.choppy = 2.0;
    }
}
}

Decals

Live Demo: Decals

Decals are textures projected onto scene geometry using deferred projection through the depth buffer. They are used for bullet holes, blood splatters, footprints, scorch marks, and environmental details without modifying the underlying mesh geometry.

How Decal Rendering Works

Each decal is rendered as a unit cube positioned and oriented in world space. The fragment shader reconstructs the world position of the scene geometry behind the cube by sampling the depth buffer, then transforms that position into the decal's local space using the inverse model matrix. If the reconstructed point falls within the decal's projection volume (±1 in XY, 0 to depth in Z), the decal texture is sampled at those local XY coordinates and blended onto the scene.

The normal threshold test compares the scene surface normal (from the depth buffer gradients) against the decal's forward direction. Surfaces angled beyond the threshold are rejected, preventing decals from wrapping around sharp edges.

Distance fade uses a smoothstep between fade_start and fade_end based on the camera-to-decal distance.

Decal Component

#![allow(unused)]
fn main() {
pub struct Decal {
    pub texture: Option<String>,
    pub emissive_texture: Option<String>,
    pub emissive_strength: f32,
    pub color: [f32; 4],
    pub size: Vec2,
    pub depth: f32,
    pub normal_threshold: f32,
    pub fade_start: f32,
    pub fade_end: f32,
}
}
FieldDefaultDescription
textureNoneTexture name in the texture cache
emissive_textureNoneOptional emissive texture for glowing decals
emissive_strength1.0HDR multiplier for emissive texture
color[1, 1, 1, 1]RGBA tint multiplied with the texture
size(1.0, 1.0)Width and height of the projected decal
depth1.0Projection depth (how far the decal penetrates into surfaces)
normal_threshold0.5Surface angle cutoff (0 = accept all, 1 = perpendicular only)
fade_start50.0Distance where fade begins
fade_end100.0Distance where the decal is fully transparent

Spawning Decals

#![allow(unused)]
fn main() {
fn spawn_bullet_hole(world: &mut World, position: Vec3, normal: Vec3) -> Entity {
    let entity = world.spawn_entities(
        LOCAL_TRANSFORM | GLOBAL_TRANSFORM | LOCAL_TRANSFORM_DIRTY | DECAL,
        1
    )[0];

    let rotation = nalgebra_glm::quat_look_at(&normal, &Vec3::y());

    world.core.set_local_transform(entity, LocalTransform {
        translation: position,
        rotation,
        ..Default::default()
    });

    world.core.set_decal(entity, Decal::new("bullet_hole")
        .with_size(0.2, 0.2)
        .with_depth(0.1)
        .with_normal_threshold(0.5)
        .with_fade(20.0, 30.0));

    entity
}
}

Builder API

The Decal struct supports a builder pattern:

#![allow(unused)]
fn main() {
let decal = Decal::new("texture_name")
    .with_size(0.5, 0.5)
    .with_depth(0.2)
    .with_color([1.0, 0.0, 0.0, 1.0])
    .with_normal_threshold(0.3)
    .with_fade(30.0, 50.0)
    .with_emissive("rune_glow", 3.0);
}

Common Use Cases

Blood Splatter

#![allow(unused)]
fn main() {
fn spawn_blood(world: &mut World, position: Vec3, normal: Vec3) -> Entity {
    let entity = world.spawn_entities(
        LOCAL_TRANSFORM | GLOBAL_TRANSFORM | LOCAL_TRANSFORM_DIRTY | DECAL,
        1
    )[0];

    let rotation = nalgebra_glm::quat_look_at(&normal, &Vec3::y());
    let random_angle = rand::random::<f32>() * std::f32::consts::TAU;
    let rotation = rotation * nalgebra_glm::quat_angle_axis(random_angle, &Vec3::z());

    world.core.set_local_transform(entity, LocalTransform {
        translation: position,
        rotation,
        ..Default::default()
    });

    world.core.set_decal(entity, Decal::new("blood")
        .with_size(0.8, 0.8)
        .with_depth(0.1)
        .with_normal_threshold(0.3)
        .with_fade(30.0, 50.0));

    entity
}
}

Emissive Rune

#![allow(unused)]
fn main() {
fn spawn_magic_rune(world: &mut World, position: Vec3) -> Entity {
    let entity = world.spawn_entities(
        LOCAL_TRANSFORM | GLOBAL_TRANSFORM | LOCAL_TRANSFORM_DIRTY | DECAL,
        1
    )[0];

    world.core.set_local_transform(entity, LocalTransform {
        translation: position,
        rotation: nalgebra_glm::quat_angle_axis(
            -std::f32::consts::FRAC_PI_2,
            &Vec3::x(),
        ),
        ..Default::default()
    });

    world.core.set_decal(entity, Decal::new("rune")
        .with_size(2.0, 2.0)
        .with_depth(0.5)
        .with_normal_threshold(0.7)
        .with_fade(50.0, 80.0)
        .with_emissive("rune_glow", 3.0));

    entity
}
}

Footprints

#![allow(unused)]
fn main() {
fn spawn_footprint(world: &mut World, position: Vec3, direction: Vec3, left: bool) -> Entity {
    let entity = world.spawn_entities(
        LOCAL_TRANSFORM | GLOBAL_TRANSFORM | LOCAL_TRANSFORM_DIRTY | DECAL,
        1
    )[0];

    let rotation = nalgebra_glm::quat_look_at(&Vec3::y(), &direction);
    let flip = if left { 1.0 } else { -1.0 };

    world.core.set_local_transform(entity, LocalTransform {
        translation: position,
        rotation,
        scale: Vec3::new(flip, 1.0, 1.0),
    });

    world.core.set_decal(entity, Decal::new("footprint")
        .with_size(0.15, 0.3)
        .with_depth(0.05)
        .with_normal_threshold(0.8)
        .with_fade(15.0, 25.0));

    entity
}
}

Sprites

Live Demo: Sprites

Sprites are 2D textured quads rendered in 3D space. They support texture blending, animation, and GPU-instanced rendering with automatic z-sorting.

Sprite Component

The Sprite component defines the visual properties of a sprite entity:

#![allow(unused)]
fn main() {
pub struct Sprite {
    pub position: Vec2,
    pub size: Vec2,
    pub scale: Vec2,
    pub rotation: f32,
    pub depth: f32,
    pub color: [f32; 4],
    pub texture_index: u32,
    pub texture_index2: u32,
    pub blend_factor: f32,
    pub uv_min: Vec2,
    pub uv_max: Vec2,
    pub blend_mode: SpriteBlendMode,
}
}
FieldDescription
positionWorld-space 2D position
sizeWidth and height in world units
scaleScale multiplier
rotationRotation in radians
depthZ-ordering (higher values draw on top)
colorRGBA tint color, multiplied with the texture
texture_indexPrimary texture slot in the sprite atlas
texture_index2Secondary texture slot for blending
blend_factorBlend weight between the two textures (0.0 = first, 1.0 = second)
uv_min / uv_maxUV coordinates within the atlas slot
blend_modeSpriteBlendMode::Alpha, Additive, or Screen

Creating Sprites

Sprites live in the Sprite2d sub-ECS, not Core. Use the spawn_sprite helper or manually add sprite components:

#![allow(unused)]
fn main() {
let entity = spawn_sprite(world, Vec2::new(0.0, 0.0), Vec2::new(32.0, 32.0));

if let Some(sprite) = world.sprite2d.get_sprite_mut(entity) {
    sprite.texture_index = 0;
    sprite.color = [1.0, 0.5, 0.5, 1.0];
}
}

Core and Sprite2d flags cannot be mixed in one spawn_entities call. The spawn_sprite helper handles this for you.

Texture Blending

Blend between two textures for smooth transitions:

#![allow(unused)]
fn main() {
let sprite = Sprite::new()
    .with_multitexture(0, 1, 0.5);
}

Loading Sprite Textures

Sprite textures are stored in the sprite texture atlas, not the main texture cache. Upload textures to atlas slots using WorldCommand::UploadSpriteTexture:

#![allow(unused)]
fn main() {
world.queue_command(WorldCommand::UploadSpriteTexture {
    slot: 0,
    rgba_data: image_bytes,
    width: 256,
    height: 256,
});
}

Each slot is 512x512 pixels. There are 128 total slots. Textures smaller than the slot size are placed in the top-left corner of the slot.

Sprite Animation

The SpriteAnimator component drives frame-based animation by updating the sprite's UV coordinates each frame.

Grid-Based Animation

For sprite sheets arranged in a grid:

#![allow(unused)]
fn main() {
let animator = SpriteAnimator::from_grid(
    8,      // columns
    4,      // rows
    32,     // total frames
    0.1,    // seconds per frame
);

world.sprite2d.add_components(entity, SPRITE_ANIMATOR);
world.sprite2d.set_sprite_animator(entity, animator);
}

This computes UV coordinates for each frame automatically based on the grid layout.

Loop Modes

#![allow(unused)]
fn main() {
let animator = SpriteAnimator::from_grid(4, 1, 4, 0.15)
    .with_loop_mode(LoopMode::Loop);      // default
}
ModeBehavior
LoopMode::OncePlays once and stops on the last frame
LoopMode::LoopLoops back to the first frame (default)
LoopMode::PingPongPlays forward, then backward, repeating

Playback Control

#![allow(unused)]
fn main() {
animator.play();
animator.pause();
animator.reset();
}

Speed

#![allow(unused)]
fn main() {
let animator = SpriteAnimator::from_grid(4, 1, 4, 0.1)
    .with_speed(2.0); // 2x playback speed
}

Manual Frames

For non-uniform frame layouts, define frames individually:

#![allow(unused)]
fn main() {
let animator = SpriteAnimator {
    frames: vec![
        SpriteFrame {
            uv_min: Vec2::new(0.0, 0.0),
            uv_max: Vec2::new(0.25, 0.5),
            duration: 0.1,
            texture_index: None,
        },
        SpriteFrame {
            uv_min: Vec2::new(0.25, 0.0),
            uv_max: Vec2::new(0.5, 0.5),
            duration: 0.2,
            texture_index: Some(1), // switch to atlas slot 1
        },
    ],
    ..Default::default()
};
}

The texture_index field on SpriteFrame optionally switches the sprite's atlas slot on that frame.

Animation System

The sprite_animation_system must run each frame to advance animations:

#![allow(unused)]
fn main() {
fn run_systems(&mut self, world: &mut World) {
    sprite_animation_system(world);
}
}

Rendering

The SpritePass renders all sprite entities using GPU instancing. It:

  • Collects all entities with Sprite + GlobalTransform components
  • Performs frustum culling based on camera position
  • Sorts sprites by depth for correct alpha blending
  • Renders camera-facing quads with instanced draw calls

Sprites are rendered as part of the geometry pass pipeline and write to both scene_color and depth.

Physics Overview

Live Demo: Physics

Nightshade integrates Rapier3D for physics simulation, providing rigid body dynamics, collision detection, and character controllers.

Enabling Physics

Physics is enabled with the physics feature:

[dependencies]
nightshade = { git = "...", features = ["engine", "physics"] }

Physics World

The physics world is accessed through resources:

#![allow(unused)]
fn main() {
let physics = &mut world.resources.physics;

// Configuration
physics.gravity = Vec3::new(0.0, -9.81, 0.0);
physics.fixed_timestep = 1.0 / 60.0;
}

Core Concepts

Rigid Bodies

Objects that can move and be affected by forces:

  • Dynamic: Affected by gravity and forces
  • Kinematic: Moved by code, affects dynamic bodies
  • Fixed: Immovable, infinite mass

Colliders

Shapes used for collision detection:

  • Cuboid, Ball, Capsule, Cylinder
  • Triangle mesh, Heightfield
  • Compound shapes

Character Controllers

Kinematic bodies with special handling for player movement.

Quick Start

#![allow(unused)]
fn main() {
use nightshade::ecs::physics::*;

fn initialize(&mut self, world: &mut World) {
    spawn_cube_at(world, Vec3::new(0.0, -0.5, 0.0));

    for index in 0..10 {
        spawn_cube_at(world, Vec3::new(0.0, 2.0 + index as f32 * 1.5, 0.0));
    }
}
}

Physics Synchronization

Physics runs at a fixed timestep. Transforms are automatically synchronized:

  1. Game logic updates entity transforms
  2. Physics simulation steps (may run multiple times per frame)
  3. Physics transforms sync back to entities
  4. Interpolation smooths visual positions

Querying Physics

Picking (Raycasting)

The picking system casts rays from screen coordinates to find entities:

#![allow(unused)]
fn main() {
let (width, height) = world.resources.window.cached_viewport_size.unwrap_or((800, 600));
let screen_center = Vec2::new(width as f32 / 2.0, height as f32 / 2.0);

if let Some(hit) = pick_closest_entity(world, screen_center) {
    let hit_entity = hit.entity;
    let hit_distance = hit.distance;
    let hit_position = hit.world_position;
}
}

For precise trimesh-based raycasting (requires physics feature):

#![allow(unused)]
fn main() {
if let Some(hit) = pick_closest_entity_trimesh(world, screen_center) {
    let hit_entity = hit.entity;
    let hit_position = hit.world_position;
}
}

Physics Materials

Friction, restitution, and density are set directly on the ColliderComponent:

#![allow(unused)]
fn main() {
world.core.set_collider(entity, ColliderComponent::new_cuboid(0.5, 0.5, 0.5)
    .with_friction(0.5)
    .with_restitution(0.3)
    .with_density(1.0));
}

Debug Visualization

Enable physics debug drawing:

#![allow(unused)]
fn main() {
world.resources.physics.debug_draw = true;

// In run_systems
physics_debug_draw_system(world);
}

This renders:

  • Collider shapes (wireframe)
  • Contact points
  • Collision normals

Performance Tips

  1. Use simple collider shapes (boxes, spheres) when possible
  2. Disable collision between groups that don't need it
  3. Use compound colliders instead of many small colliders
  4. Set bodies to sleep when inactive
  5. Use appropriate fixed timestep (60 Hz is standard)

Joints

Connect bodies with joints for:

  • Doors (revolute)
  • Drawers (prismatic)
  • Ropes (rope/spring)
  • Chains (spherical)

See Physics Joints for details.

Rigid Bodies

Rigid bodies are the foundation of physics simulation. They define how objects move and respond to forces.

Body Types

Dynamic Bodies

Fully simulated physics objects:

#![allow(unused)]
fn main() {
let entity = world.spawn_entities(
    LOCAL_TRANSFORM | GLOBAL_TRANSFORM | RIGID_BODY | COLLIDER,
    1
)[0];

world.core.set_rigid_body(entity, RigidBodyComponent::new_dynamic());

world.core.set_collider(entity, ColliderComponent::new_ball(0.5));
}

Kinematic Bodies

Controlled by code, not affected by forces:

#![allow(unused)]
fn main() {
world.core.set_rigid_body(entity, RigidBodyComponent::new_kinematic());
}

Move kinematic bodies by updating their transform:

#![allow(unused)]
fn main() {
if let Some(transform) = world.core.get_local_transform_mut(kinematic_entity) {
    transform.translation.x += velocity.x * dt;
}
mark_local_transform_dirty(world, kinematic_entity);
}

Static Bodies

Immovable objects (floors, walls):

#![allow(unused)]
fn main() {
world.core.set_rigid_body(entity, RigidBodyComponent::new_static());
}

Helper Functions

Spawning Physics Cubes

#![allow(unused)]
fn main() {
use nightshade::ecs::physics::*;

let cube = spawn_cube_at(world, Vec3::new(0.0, 5.0, 0.0));
}

Spawning Physics Spheres

#![allow(unused)]
fn main() {
let sphere = spawn_sphere_at(world, Vec3::new(0.0, 10.0, 0.0));
}

Applying Forces

Access the Rapier rigid body directly:

#![allow(unused)]
fn main() {
fn apply_force(world: &mut World, entity: Entity, force: Vec3) {
    let Some(rb_component) = world.core.get_rigid_body(entity) else { return };
    let Some(handle) = rb_component.handle else { return };

    if let Some(rigid_body) = world.resources.physics.rigid_body_set.get_mut(handle.into()) {
        rigid_body.add_force(
            rapier3d::prelude::Vector::new(force.x, force.y, force.z),
            true,  // wake up if sleeping
        );
    }
}
}

Applying Impulses

Instant velocity change:

#![allow(unused)]
fn main() {
fn apply_impulse(world: &mut World, entity: Entity, impulse: Vec3) {
    let Some(rb_component) = world.core.get_rigid_body(entity) else { return };
    let Some(handle) = rb_component.handle else { return };

    if let Some(rigid_body) = world.resources.physics.rigid_body_set.get_mut(handle.into()) {
        rigid_body.apply_impulse(
            rapier3d::prelude::Vector::new(impulse.x, impulse.y, impulse.z),
            true,
        );
    }
}
}

Setting Velocity

#![allow(unused)]
fn main() {
fn set_velocity(world: &mut World, entity: Entity, velocity: Vec3) {
    let Some(rb_component) = world.core.get_rigid_body(entity) else { return };
    let Some(handle) = rb_component.handle else { return };

    if let Some(rigid_body) = world.resources.physics.rigid_body_set.get_mut(handle.into()) {
        rigid_body.set_linvel(
            rapier3d::prelude::Vector::new(velocity.x, velocity.y, velocity.z),
            true,
        );
    }
}
}

Getting Velocity

#![allow(unused)]
fn main() {
fn get_velocity(world: &World, entity: Entity) -> Option<Vec3> {
    let rb_component = world.core.get_rigid_body(entity)?;
    let handle = rb_component.handle?;
    let rigid_body = world.resources.physics.rigid_body_set.get(handle.into())?;

    let vel = rigid_body.linvel();
    Some(Vec3::new(vel.x, vel.y, vel.z))
}
}

Mass Properties

#![allow(unused)]
fn main() {
fn set_mass(world: &mut World, entity: Entity, mass: f32) {
    let Some(rb_component) = world.core.get_rigid_body(entity) else { return };
    let Some(handle) = rb_component.handle else { return };

    if let Some(rigid_body) = world.resources.physics.rigid_body_set.get_mut(handle.into()) {
        rigid_body.set_additional_mass(mass, true);
    }
}
}

Locking Axes

Prevent rotation or translation on specific axes:

#![allow(unused)]
fn main() {
if let Some(rigid_body) = world.resources.physics.rigid_body_set.get_mut(handle.into()) {
    // Lock rotation (useful for character controllers)
    rigid_body.lock_rotations(true, true);

    // Lock specific translation axes
    // rigid_body.lock_translations(true, true);  // Lock X and Y
}
}

Damping

Add drag to slow objects:

#![allow(unused)]
fn main() {
if let Some(rigid_body) = world.resources.physics.rigid_body_set.get_mut(handle.into()) {
    rigid_body.set_linear_damping(0.5);   // Translation damping
    rigid_body.set_angular_damping(0.5);  // Rotation damping
}
}

Sleeping

Bodies automatically sleep when stationary. Wake them:

#![allow(unused)]
fn main() {
if let Some(rigid_body) = world.resources.physics.rigid_body_set.get_mut(handle.into()) {
    rigid_body.wake_up(true);
}
}

Colliders

Colliders define the physical shape of objects for collision detection.

Collider Shapes

Ball (Sphere)

#![allow(unused)]
fn main() {
world.core.set_collider(entity, ColliderComponent::new_ball(0.5));
}

Cuboid (Box)

#![allow(unused)]
fn main() {
world.core.set_collider(entity, ColliderComponent::new_cuboid(1.0, 0.5, 1.0));
}

Capsule

Perfect for characters:

#![allow(unused)]
fn main() {
world.core.set_collider(entity, ColliderComponent::new_capsule(1.0, 0.3));
}

Cylinder

#![allow(unused)]
fn main() {
world.core.set_collider(entity, ColliderComponent {
    shape: ColliderShape::Cylinder {
        half_height: 1.0,
        radius: 0.5,
    },
    ..Default::default()
});
}

Cone

#![allow(unused)]
fn main() {
world.core.set_collider(entity, ColliderComponent {
    shape: ColliderShape::Cone {
        half_height: 1.0,
        radius: 0.5,
    },
    ..Default::default()
});
}

Triangle Mesh

For complex static geometry:

#![allow(unused)]
fn main() {
let vertices: Vec<[f32; 3]> = mesh.vertices.iter()
    .map(|v| v.position)
    .collect();

let indices: Vec<[u32; 3]> = mesh.indices
    .chunks(3)
    .map(|c| [c[0], c[1], c[2]])
    .collect();

world.core.set_collider(entity, ColliderComponent {
    shape: ColliderShape::TriMesh { vertices, indices },
    ..Default::default()
});
}

Heightfield

For terrain:

#![allow(unused)]
fn main() {
let heights: Vec<f32> = generate_height_grid(64, 64);

world.core.set_collider(entity, ColliderComponent {
    shape: ColliderShape::HeightField {
        nrows: 64,
        ncols: 64,
        heights,
        scale: [100.0, 50.0, 100.0],
    },
    ..Default::default()
});
}

Compound

Multiple shapes combined:

#![allow(unused)]
fn main() {
let body_collider = ColliderComponent::new_cuboid(0.5, 0.1, 0.5);
let head_collider = ColliderComponent::new_ball(0.3);
}

Physics Materials

Friction, restitution, and density are fields directly on ColliderComponent:

#![allow(unused)]
fn main() {
world.core.set_collider(entity, ColliderComponent::new_cuboid(0.5, 0.5, 0.5)
    .with_friction(0.5)       // Sliding resistance (0 = ice, 1 = rubber)
    .with_restitution(0.3)    // Bounciness (0 = no bounce, 1 = perfect bounce)
    .with_density(1.0));      // Mass per unit volume
}

Material Examples

#![allow(unused)]
fn main() {
// Ice
let ice = ColliderComponent::new_cuboid(0.5, 0.5, 0.5)
    .with_friction(0.05)
    .with_restitution(0.1)
    .with_density(0.9);

// Rubber
let rubber = ColliderComponent::new_ball(0.5)
    .with_friction(0.9)
    .with_restitution(0.8)
    .with_density(1.1);

// Metal
let metal = ColliderComponent::new_cuboid(0.5, 0.5, 0.5)
    .with_friction(0.4)
    .with_restitution(0.2)
    .with_density(7.8);
}

Collision Groups

Filter which objects collide:

#![allow(unused)]
fn main() {
use rapier3d::prelude::*;

// Define groups
const GROUP_PLAYER: Group = Group::GROUP_1;
const GROUP_ENEMY: Group = Group::GROUP_2;
const GROUP_PROJECTILE: Group = Group::GROUP_3;
const GROUP_WORLD: Group = Group::GROUP_4;

// Player collides with enemies and world, not own projectiles
let player_filter = CollisionGroups::new(
    GROUP_PLAYER,
    GROUP_ENEMY | GROUP_WORLD,
);
}

Sensor Colliders

Detect overlaps without physical response:

#![allow(unused)]
fn main() {
// Create a trigger zone
if let Some(collider) = world.resources.physics.collider_set.get_mut(handle.into()) {
    collider.set_sensor(true);
}
}

Sensor collisions are reported through the collision events system (see below).

Collision Events

Query collision pairs each frame. collision_events() returns &[CollisionEvent]:

#![allow(unused)]
fn main() {
fn run_systems(&mut self, world: &mut World) {
    for event in world.resources.physics.collision_events() {
        match event.kind {
            CollisionEventKind::Started => {
                handle_collision_start(event.entity_a, event.entity_b);
            }
            CollisionEventKind::Stopped => {
                handle_collision_end(event.entity_a, event.entity_b);
            }
        }
    }
}
}

Each CollisionEvent has:

#![allow(unused)]
fn main() {
pub struct CollisionEvent {
    pub entity_a: Entity,
    pub entity_b: Entity,
    pub kind: CollisionEventKind,
    pub is_sensor: bool,
}
}

Convex Decomposition

For complex shapes on dynamic bodies, use convex decomposition:

#![allow(unused)]
fn main() {
use rapier3d::prelude::*;

// This creates multiple convex pieces from a concave mesh
let decomposed = SharedShape::convex_decomposition(&vertices, &indices);
}

Performance Tips

ShapePerformanceUse Case
BallFastestRolling objects
CuboidFastCrates, buildings
CapsuleFastCharacters
CylinderMediumBarrels, pillars
ConvexMediumSimple props
TrimeshSlowStatic terrain only
CompoundVariesComplex dynamic objects
  • Prefer primitive shapes over meshes
  • Use trimesh only for static geometry
  • Compound colliders are better than multiple entities
  • Simplify collision geometry vs visual geometry

Character Controllers

Character controllers provide smooth player movement with collision handling, slopes, and stairs.

First-Person Player

The easiest way to get started:

#![allow(unused)]
fn main() {
use nightshade::ecs::physics::commands::spawn_first_person_player;
use nightshade::ecs::physics::character_controller::character_controller_input_system;

fn initialize(&mut self, world: &mut World) {
    let (player_entity, camera_entity) = spawn_first_person_player(
        world,
        Vec3::new(0.0, 2.0, 0.0),
    );

    self.player = Some(player_entity);

    if let Some(controller) = world.core.get_character_controller_mut(player_entity) {
        controller.max_speed = 5.0;
        controller.is_sprinting = false;
        controller.jump_impulse = 6.0;
    }
}
}

Custom Character Controller

For third-person or specialized characters:

#![allow(unused)]
fn main() {
fn spawn_character(world: &mut World, position: Vec3) -> Entity {
    let entity = world.spawn_entities(
        NAME | LOCAL_TRANSFORM | GLOBAL_TRANSFORM | LOCAL_TRANSFORM_DIRTY | CHARACTER_CONTROLLER,
        1,
    )[0];

    world.core.set_name(entity, Name("Player".to_string()));
    world.core.set_local_transform(entity, LocalTransform {
        translation: position,
        ..Default::default()
    });

    if let Some(controller) = world.core.get_character_controller_mut(entity) {
        *controller = CharacterControllerComponent::new_capsule(0.5, 0.3);
        controller.max_speed = 3.0;
        controller.acceleration = 15.0;
        controller.jump_impulse = 4.0;
        controller.is_sprinting = false;
        controller.is_crouching = false;
    }

    entity
}
}

Controller Properties

PropertyDescriptionDefault
max_speedWalking speed5.0
is_sprintingSprint activefalse
accelerationSpeed up rate20.0
jump_impulseJump strength5.0
can_jumpAllow jumpingtrue
is_crouchingCrouch activefalse

Movement Input

The built-in character_controller_input_system(world) handles WASD movement, jumping, sprinting, and crouching automatically. Call it each frame:

#![allow(unused)]
fn main() {
fn run_systems(&mut self, world: &mut World) {
    character_controller_input_system(world);
}
}

Ground Detection

Check if the character is grounded:

#![allow(unused)]
fn main() {
if let Some(controller) = world.core.get_character_controller(player) {
    if controller.grounded {
        // On ground - can jump
    } else {
        // In air
    }
}
}

Slope Handling

Controllers automatically handle slopes:

#![allow(unused)]
fn main() {
if let Some(controller) = world.core.get_character_controller_mut(player) {
    controller.config.max_slope_climb_angle = 0.8;  // ~45 degrees in radians
    controller.config.min_slope_slide_angle = 0.5;   // ~30 degrees
}
}

Camera Integration

First-Person Camera

The camera is a child of the player:

#![allow(unused)]
fn main() {
let camera = world.spawn_entities(
    LOCAL_TRANSFORM | GLOBAL_TRANSFORM | LOCAL_TRANSFORM_DIRTY | CAMERA | PARENT,
    1
)[0];

world.core.set_parent(camera, Parent(Some(player)));
world.core.set_local_transform(camera, LocalTransform {
    translation: Vec3::new(0.0, 0.8, 0.0),  // Eye height
    ..Default::default()
});

world.resources.active_camera = Some(camera);
}

Third-Person Camera

Follow the character with an offset:

#![allow(unused)]
fn main() {
fn third_person_camera_system(world: &mut World, player: Entity, camera: Entity) {
    let Some(player_pos) = world.core.get_local_transform(player).map(|t| t.translation) else {
        return;
    };

    // Camera behind and above player
    let offset = Vec3::new(0.0, 3.0, 8.0);
    let target_pos = player_pos + offset;

    if let Some(pan_orbit) = world.core.get_pan_orbit_camera_mut(camera) {
        pan_orbit.target_focus = player_pos + Vec3::new(0.0, 1.0, 0.0);
    }
}
}

Step Climbing

Controllers can automatically climb small steps:

#![allow(unused)]
fn main() {
if let Some(controller) = world.core.get_character_controller_mut(player) {
    controller.config.autostep_max_height = Some(0.3);  // Can climb 30cm steps
    controller.config.autostep_min_width = Some(0.2);   // Minimum step width
}
}

Interaction Cooldowns

Prevent rapid repeated actions:

#![allow(unused)]
fn main() {
struct PlayerState {
    interaction_cooldown: f32,
}

fn update_cooldown(state: &mut PlayerState, dt: f32) {
    state.interaction_cooldown = (state.interaction_cooldown - dt).max(0.0);
}

fn can_interact(state: &PlayerState) -> bool {
    state.interaction_cooldown <= 0.0
}

fn set_cooldown(state: &mut PlayerState, duration: f32) {
    state.interaction_cooldown = duration;
}
}

Physics Joints

Joints connect two rigid bodies together, constraining their relative motion.

Joint Types

JointDescriptionUse Cases
FixedRigid connectionWelded objects
SphericalBall-and-socketPendulums, ragdolls
RevoluteHingeDoors, wheels
PrismaticSliderDrawers, pistons
RopeMax distanceRopes, chains
SpringElasticSuspension, bouncy connections

Fixed Joint

Rigidly connects two bodies:

#![allow(unused)]
fn main() {
use nightshade::ecs::physics::joints::*;

create_fixed_joint(
    world,
    body_a,
    body_b,
    FixedJoint::new()
        .with_local_anchor1(Vec3::new(0.5, 0.0, 0.0))
        .with_local_anchor2(Vec3::new(-0.5, 0.0, 0.0)),
);
}

Spherical Joint (Ball-and-Socket)

Allows rotation in all directions:

#![allow(unused)]
fn main() {
// Pendulum
let anchor = spawn_cube_at(world, Vec3::new(0.0, 5.0, 0.0));
let ball = spawn_sphere_at(world, Vec3::new(0.0, 3.0, 0.0));

create_spherical_joint(
    world,
    anchor,
    ball,
    SphericalJoint::new()
        .with_local_anchor1(Vec3::new(0.0, -0.15, 0.0))
        .with_local_anchor2(Vec3::new(0.0, 1.0, 0.0)),
);
}

Revolute Joint (Hinge)

Rotates around a single axis:

#![allow(unused)]
fn main() {
// Door hinge
let door_frame = spawn_cube_at(world, Vec3::zeros());
let door = spawn_cube_at(world, Vec3::new(0.5, 1.0, 0.0));

create_revolute_joint(
    world,
    door_frame,
    door,
    RevoluteJoint::new(JointAxisDirection::Y)  // Rotate around Y axis
        .with_local_anchor1(Vec3::new(0.0, 0.0, 0.0))
        .with_local_anchor2(Vec3::new(-0.5, 0.0, 0.0))
        .with_limits(JointLimits::new(-1.5, 1.5)),  // Limit rotation
);
}

Adding Motor

Make the door swing automatically:

#![allow(unused)]
fn main() {
RevoluteJoint::new(JointAxisDirection::Y)
    .with_motor(JointMotor::position(0.0, 5.0, 100.0))
}

Prismatic Joint (Slider)

Slides along an axis:

#![allow(unused)]
fn main() {
// Drawer
let cabinet = spawn_cube_at(world, Vec3::zeros());
let drawer = spawn_cube_at(world, Vec3::new(0.0, 0.0, 0.5));

create_prismatic_joint(
    world,
    cabinet,
    drawer,
    PrismaticJoint::new(JointAxisDirection::Z)  // Slide on Z axis
        .with_local_anchor1(Vec3::new(0.0, 0.0, 0.0))
        .with_local_anchor2(Vec3::new(0.0, 0.0, -0.5))
        .with_limits(JointLimits::new(0.0, 0.8)),  // Min/max extension
);
}

Rope Joint

Maximum distance constraint:

#![allow(unused)]
fn main() {
let ceiling = spawn_cube_at(world, Vec3::zeros());
let weight = spawn_sphere_at(world, Vec3::new(0.0, 0.0, 0.0));

create_rope_joint(
    world,
    ceiling,
    weight,
    RopeJoint::new(2.0)  // Max length
        .with_local_anchor1(Vec3::new(0.0, -0.15, 0.0))
        .with_local_anchor2(Vec3::new(0.0, 0.0, 0.0)),
);
}

Spring Joint

Elastic connection:

#![allow(unused)]
fn main() {
let anchor = spawn_cube_at(world, Vec3::zeros());
let bob = spawn_sphere_at(world, Vec3::new(0.0, -2.0, 0.0));

create_spring_joint(
    world,
    anchor,
    bob,
    SpringJoint::new(1.5, 50.0, 2.0)  // rest_length, stiffness, damping
        .with_local_anchor1(Vec3::new(0.0, -0.15, 0.0))
        .with_local_anchor2(Vec3::new(0.0, 0.2, 0.0)),
);
}

Joint Limits

Constrain movement range:

#![allow(unused)]
fn main() {
// Rotation limits (radians)
RevoluteJoint::new(JointAxisDirection::Z)
    .with_limits(JointLimits::new(-1.57, 1.57))  // -90° to +90°

// Translation limits (meters)
PrismaticJoint::new(JointAxisDirection::X)
    .with_limits(JointLimits::new(-2.0, 2.0))
}

Breaking Joints

Joints can break under force:

#![allow(unused)]
fn main() {
if let Some(joint) = world.resources.physics.get_joint_mut(joint_handle) {
    joint.set_max_force(1000.0);  // Break if force exceeds this
}
}

Chain Example

Create a chain of connected spheres:

#![allow(unused)]
fn main() {
fn create_chain(world: &mut World, start: Vec3, links: usize) {
    let mut previous = spawn_cube_at(world, start);

    for index in 0..links {
        let position = start - Vec3::new(0.0, (index + 1) as f32 * 0.5, 0.0);
        let link = spawn_sphere_at(world, position);

        create_spherical_joint(
            world,
            previous,
            link,
            SphericalJoint::new()
                .with_local_anchor1(Vec3::new(0.0, -0.2, 0.0))
                .with_local_anchor2(Vec3::new(0.0, 0.2, 0.0)),
        );

        previous = link;
    }
}
}

Interactive Door Example

Complete door with momentum:

#![allow(unused)]
fn main() {
fn spawn_interactive_door(world: &mut World, position: Vec3) -> Entity {
    let frame = spawn_cube_at(world, position);

    let door = spawn_cube_at(world, position + Vec3::new(0.5, 0.0, 0.0));

    // Lock vertical rotation
    if let Some(rb) = world.core.get_rigid_body(door) {
        if let Some(handle) = rb.handle {
            if let Some(body) = world.resources.physics.rigid_body_set.get_mut(handle.into()) {
                body.lock_rotations(true, true);  // Only Y rotation allowed
            }
        }
    }

    create_revolute_joint(
        world,
        frame,
        door,
        RevoluteJoint::new(JointAxisDirection::Y)
            .with_local_anchor1(Vec3::new(0.05, 0.0, 0.0))
            .with_local_anchor2(Vec3::new(-0.5, 0.0, 0.0))
            .with_limits(JointLimits::new(-2.0, 2.0)),
    );

    door
}
}

Loading Animated Models

Nightshade supports skeletal animation through glTF/GLB files.

Loading an Animated Model

#![allow(unused)]
fn main() {
use nightshade::ecs::prefab::*;

const CHARACTER_GLB: &[u8] = include_bytes!("../assets/character.glb");

fn load_character(world: &mut World) -> Option<Entity> {
    let result = import_gltf_from_bytes(CHARACTER_GLB).ok()?;

    // Register textures
    for (name, (rgba_data, width, height)) in result.textures {
        world.queue_command(WorldCommand::LoadTexture {
            name,
            rgba_data,
            width,
            height,
        });
    }

    // Register meshes
    for (name, mesh) in result.meshes {
        mesh_cache_insert(&mut world.resources.mesh_cache, name, mesh);
    }

    // Spawn with animations and skins
    result.prefabs.first().map(|prefab| {
        spawn_prefab_with_animations(
            world,
            prefab,
            &result.animations,
            Vec3::zeros(),
        )
    })
}
}

Animation Data Structure

Loaded animations contain:

#![allow(unused)]
fn main() {
pub struct AnimationClip {
    pub name: String,
    pub duration: f32,
    pub channels: Vec<AnimationChannel>,
}

pub struct AnimationChannel {
    pub target_node: usize,
    pub target_property: AnimationProperty,
    pub interpolation: Interpolation,
    pub times: Vec<f32>,
    pub values: Vec<f32>,
}

pub enum AnimationProperty {
    Translation,
    Rotation,
    Scale,
    MorphWeights,
}
}

Filtering Animation Channels

Remove unwanted channels (like root motion):

#![allow(unused)]
fn main() {
fn filter_animations(animations: &[AnimationClip]) -> Vec<AnimationClip> {
    let root_bone_indices: std::collections::HashSet<usize> = [0, 1, 2, 3].into();

    animations
        .iter()
        .map(|clip| AnimationClip {
            name: clip.name.clone(),
            duration: clip.duration,
            channels: clip
                .channels
                .iter()
                .filter(|channel| {
                    // Remove translation from all bones (prevent sliding)
                    if channel.target_property == AnimationProperty::Translation {
                        return false;
                    }
                    // Remove rotation from root bones
                    if root_bone_indices.contains(&channel.target_node)
                        && channel.target_property == AnimationProperty::Rotation
                    {
                        return false;
                    }
                    true
                })
                .cloned()
                .collect(),
        })
        .collect()
}
}

Storing Animation Indices

Track which animations are which:

#![allow(unused)]
fn main() {
struct AnimationIndices {
    idle: Option<usize>,
    walk: Option<usize>,
    run: Option<usize>,
    jump: Option<usize>,
}

fn find_animation_indices(clips: &[AnimationClip]) -> AnimationIndices {
    let mut indices = AnimationIndices {
        idle: None,
        walk: None,
        run: None,
        jump: None,
    };

    for (index, clip) in clips.iter().enumerate() {
        let name = clip.name.to_lowercase();
        if name.contains("idle") {
            indices.idle = Some(index);
        } else if name.contains("walk") {
            indices.walk = Some(index);
        } else if name.contains("run") {
            indices.run = Some(index);
        } else if name.contains("jump") {
            indices.jump = Some(index);
        }
    }

    indices
}
}

Skeleton Structure

Skinned meshes have a skeleton:

#![allow(unused)]
fn main() {
pub struct Skin {
    pub joints: Vec<Entity>,
    pub inverse_bind_matrices: Vec<Mat4>,
}
}

The joints array contains entities for each bone in the skeleton.

Attaching Objects to Bones

Attach items to specific bones:

#![allow(unused)]
fn main() {
fn attach_to_bone(world: &mut World, item: Entity, bone: Entity) {
    world.core.set_parent(item, Parent(Some(bone)));

    if let Some(transform) = world.core.get_local_transform_mut(item) {
        transform.translation = Vec3::new(0.0, 0.1, 0.0);  // Local offset
        transform.scale = Vec3::new(1.0, 1.0, 1.0);
    }
}

// Example: Attach hat to head bone
fn attach_hat(world: &mut World, character: Entity, hat: Entity) {
    // Find head bone (usually named "Head" or similar in the model)
    if let Some(skin) = world.core.get_skin(character) {
        for joint in &skin.joints {
            if let Some(name) = world.core.get_name(*joint) {
                if name.0.contains("Head") {
                    attach_to_bone(world, hat, *joint);
                    return;
                }
            }
        }
    }
}
}

Finding Bones by Name

#![allow(unused)]
fn main() {
fn find_bone_by_name(world: &World, character: Entity, bone_name: &str) -> Option<Entity> {
    let skin = world.core.get_skin(character)?;

    for joint in &skin.joints {
        if let Some(name) = world.core.get_name(*joint) {
            if name.0.contains(bone_name) {
                return Some(*joint);
            }
        }
    }
    None
}
}

Multiple Animated Characters

Load once, spawn many:

#![allow(unused)]
fn main() {
struct CharacterFactory {
    prefab: Prefab,
    animations: Vec<AnimationClip>,
}

impl CharacterFactory {
    fn new(bytes: &[u8]) -> Option<Self> {
        let result = import_gltf_from_bytes(bytes).ok()?;
        Some(Self {
            prefab: result.prefabs.into_iter().next()?,
            animations: result.animations,
        })
    }

    fn spawn(&self, world: &mut World, position: Vec3) -> Entity {
        spawn_prefab_with_animations(
            world,
            &self.prefab,
            &self.animations,
            position,
        )
    }
}
}

Animation Playback

Live Demo: Dance

Control animation playback through the AnimationPlayer component.

AnimationPlayer Component

#![allow(unused)]
fn main() {
pub struct AnimationPlayer {
    pub clips: Vec<AnimationClip>,
    pub current_clip: Option<usize>,
    pub time: f32,
    pub speed: f32,
    pub looping: bool,
    pub playing: bool,
    pub blend_from_clip: Option<usize>,
    pub blend_factor: f32,
}
}

Basic Playback

#![allow(unused)]
fn main() {
fn play_animation(world: &mut World, entity: Entity, clip_index: usize) {
    if let Some(player) = world.core.get_animation_player_mut(entity) {
        player.play(clip_index);
        player.looping = true;
    }
}
}

Controlling Speed

#![allow(unused)]
fn main() {
fn set_animation_speed(world: &mut World, entity: Entity, speed: f32) {
    if let Some(player) = world.core.get_animation_player_mut(entity) {
        player.speed = speed;  // 1.0 = normal, 0.5 = half speed, 2.0 = double
    }
}
}

Pausing and Resuming

#![allow(unused)]
fn main() {
fn pause_animation(world: &mut World, entity: Entity) {
    if let Some(player) = world.core.get_animation_player_mut(entity) {
        player.pause();
    }
}

fn resume_animation(world: &mut World, entity: Entity) {
    if let Some(player) = world.core.get_animation_player_mut(entity) {
        player.resume();
    }
}
}

Looping

#![allow(unused)]
fn main() {
fn set_looping(world: &mut World, entity: Entity, looping: bool) {
    if let Some(player) = world.core.get_animation_player_mut(entity) {
        player.looping = looping;
    }
}
}

Checking Animation State

#![allow(unused)]
fn main() {
fn is_animation_finished(world: &World, entity: Entity) -> bool {
    if let Some(player) = world.core.get_animation_player(entity) {
        if !player.looping {
            if let Some(index) = player.current_clip {
                let clip = &player.clips[index];
                return player.time >= clip.duration;
            }
        }
    }
    false
}

fn get_animation_progress(world: &World, entity: Entity) -> f32 {
    if let Some(player) = world.core.get_animation_player(entity) {
        if let Some(index) = player.current_clip {
            let clip = &player.clips[index];
            return player.time / clip.duration;
        }
    }
    0.0
}
}

Animation by Name

Find and play animations by name:

#![allow(unused)]
fn main() {
fn play_animation_by_name(world: &mut World, entity: Entity, name: &str) -> bool {
    if let Some(player) = world.core.get_animation_player_mut(entity) {
        for (index, clip) in player.clips.iter().enumerate() {
            if clip.name.to_lowercase().contains(&name.to_lowercase()) {
                player.play(index);
                return true;
            }
        }
    }
    false
}
}

State-Based Animation

Common pattern for character animation:

#![allow(unused)]
fn main() {
#[derive(Clone, Copy, PartialEq)]
enum MovementState {
    Idle,
    Walking,
    Running,
    Jumping,
}

fn update_character_animation(
    world: &mut World,
    entity: Entity,
    state: MovementState,
    indices: &AnimationIndices,
    current: &mut Option<usize>,
) {
    let target = match state {
        MovementState::Idle => indices.idle,
        MovementState::Walking => indices.walk,
        MovementState::Running => indices.run,
        MovementState::Jumping => indices.jump,
    };

    // Only change if different
    if target != *current {
        if let Some(index) = target {
            if let Some(player) = world.core.get_animation_player_mut(entity) {
                player.blend_to(index, 0.2);  // Smooth transition
                *current = Some(index);
            }
        }
    }
}
}

Speed Based on Movement

Match animation speed to movement speed:

#![allow(unused)]
fn main() {
fn sync_animation_to_movement(world: &mut World, entity: Entity, velocity: f32) {
    if let Some(player) = world.core.get_animation_player_mut(entity) {
        // Assuming walk animation matches 3 m/s
        let base_speed = 3.0;
        player.speed = (velocity / base_speed).clamp(0.5, 2.0);
    }
}
}

One-Shot Animations

Play an animation once without looping:

#![allow(unused)]
fn main() {
fn play_once(world: &mut World, entity: Entity, clip_index: usize) {
    if let Some(player) = world.core.get_animation_player_mut(entity) {
        player.play(clip_index);
        player.looping = false;
    }
}
}

Animation Events

Trigger events at specific times:

#![allow(unused)]
fn main() {
fn check_animation_events(world: &World, entity: Entity, event_time: f32) -> bool {
    if let Some(player) = world.core.get_animation_player(entity) {
        let prev_time = player.time - world.resources.window.timing.delta_time * player.speed;
        // Check if we crossed the event time this frame
        prev_time < event_time && player.time >= event_time
    } else {
        false
    }
}

fn footstep_system(world: &mut World, character: Entity, footstep_source: Entity) {
    if check_animation_events(world, character, 0.3) || check_animation_events(world, character, 0.8) {
        if let Some(audio) = world.core.get_audio_source_mut(footstep_source) {
            audio.playing = true;
        }
    }
}
}

Blending & Transitions

Live Demos: Dance | Morph Targets

Smooth transitions between animations using cross-fading.

Cross-Fade Transition

The blend_to method smoothly transitions between animations:

#![allow(unused)]
fn main() {
if let Some(player) = world.core.get_animation_player_mut(entity) {
    player.blend_to(new_animation_index, 0.2);  // 0.2 second transition
}
}

Blend Duration

Choose appropriate durations:

TransitionDurationNotes
Idle → Walk0.2sNatural start
Walk → Run0.15sQuick acceleration
Run → Idle0.3sGradual stop
Any → Jump0.1sResponsive
Attack0.05sImmediate

Movement State Machine

Manage animation states cleanly:

#![allow(unused)]
fn main() {
#[derive(Clone, Copy, PartialEq, Eq)]
enum CharacterState {
    Idle,
    Walking,
    Running,
    Jumping,
    Falling,
    Landing,
}

struct AnimationController {
    state: CharacterState,
    current_animation: Option<usize>,
    indices: AnimationIndices,
}

impl AnimationController {
    fn update(&mut self, world: &mut World, entity: Entity, new_state: CharacterState) {
        if self.state == new_state {
            return;
        }

        let blend_time = self.get_blend_time(self.state, new_state);
        let target_anim = self.get_animation_for_state(new_state);

        if let Some(index) = target_anim {
            if let Some(player) = world.core.get_animation_player_mut(entity) {
                player.blend_to(index, blend_time);
                self.current_animation = Some(index);
            }
        }

        self.state = new_state;
    }

    fn get_blend_time(&self, from: CharacterState, to: CharacterState) -> f32 {
        match (from, to) {
            (CharacterState::Idle, CharacterState::Walking) => 0.2,
            (CharacterState::Walking, CharacterState::Running) => 0.15,
            (CharacterState::Running, CharacterState::Idle) => 0.3,
            (_, CharacterState::Jumping) => 0.1,
            _ => 0.2,
        }
    }

    fn get_animation_for_state(&self, state: CharacterState) -> Option<usize> {
        match state {
            CharacterState::Idle => self.indices.idle,
            CharacterState::Walking => self.indices.walk,
            CharacterState::Running => self.indices.run,
            CharacterState::Jumping => self.indices.jump,
            CharacterState::Falling => self.indices.jump,  // Reuse jump
            CharacterState::Landing => self.indices.idle,   // Brief idle
        }
    }
}
}

Speed-Based Blending

Blend between walk and run based on speed:

#![allow(unused)]
fn main() {
fn update_locomotion(world: &mut World, entity: Entity, speed: f32, indices: &AnimationIndices) {
    let walk_threshold = 2.0;
    let run_threshold = 5.0;

    let state = if speed < 0.1 {
        MovementState::Idle
    } else if speed < walk_threshold {
        MovementState::Walking
    } else {
        MovementState::Running
    };

    let target_anim = match state {
        MovementState::Idle => indices.idle,
        MovementState::Walking => indices.walk,
        MovementState::Running => indices.run,
    };

    if let Some(index) = target_anim {
        if let Some(player) = world.core.get_animation_player_mut(entity) {
            if player.current_clip != Some(index) {
                player.blend_to(index, 0.2);
            }

            // Adjust playback speed
            player.speed = match state {
                MovementState::Idle => 1.0,
                MovementState::Walking => speed / walk_threshold,
                MovementState::Running => speed / run_threshold,
            };
        }
    }
}
}

Interrupt Handling

Handle animation interrupts gracefully:

#![allow(unused)]
fn main() {
fn try_attack(world: &mut World, entity: Entity, attack_anim: usize, current_state: &mut CharacterState) -> bool {
    if let Some(player) = world.core.get_animation_player_mut(entity) {
        // Quick blend to attack
        player.blend_to(attack_anim, 0.05);
        player.looping = false;
        *current_state = CharacterState::Attacking;
        return true;
    }
    false
}

fn check_attack_finished(world: &World, entity: Entity) -> bool {
    if let Some(player) = world.core.get_animation_player(entity) {
        if !player.looping {
            if let Some(index) = player.current_clip {
                let clip = &player.clips[index];
                return player.time >= clip.duration * 0.9;
            }
        }
    }
    false
}
}

Additive Blending

Layer animations (e.g., breathing on top of idle):

#![allow(unused)]
fn main() {
// Note: This is a conceptual example - actual implementation depends on engine support
struct LayeredAnimation {
    base_animation: usize,
    additive_animations: Vec<(usize, f32)>,  // (index, weight)
}
}

Root Motion

When animations include movement:

#![allow(unused)]
fn main() {
fn apply_root_motion(world: &mut World, entity: Entity) {
    let Some(player) = world.core.get_animation_player(entity) else { return };

    let Some(current_index) = player.current_clip else { return };
    let clip = &player.clips[current_index];
    // Extract root bone translation from animation
    // Apply to character controller

    // Note: Often you'll want to remove root motion from animations
    // and drive movement from game code instead
}
}

Transition Rules

Define clear rules for when transitions can occur:

#![allow(unused)]
fn main() {
fn can_transition(from: CharacterState, to: CharacterState) -> bool {
    match (from, to) {
        // Can always go to these states
        (_, CharacterState::Idle) => true,
        (_, CharacterState::Falling) => true,

        // Can't interrupt attacks
        (CharacterState::Attacking, _) => false,

        // Can only jump from ground
        (CharacterState::Falling, CharacterState::Jumping) => false,
        (CharacterState::Jumping, CharacterState::Jumping) => false,

        _ => true,
    }
}
}

Audio System

Live Demo: Audio

Nightshade uses Kira for audio playback, supporting both sound effects and music.

Enabling Audio

Audio requires the audio feature:

[dependencies]
nightshade = { git = "...", features = ["engine", "audio"] }

Loading Sounds

Load sounds at initialization using load_sound_from_bytes and world.resources.audio.load_sound:

#![allow(unused)]
fn main() {
use nightshade::ecs::audio::*;

const EXPLOSION_WAV: &[u8] = include_bytes!("../assets/sounds/explosion.wav");
const MUSIC_OGG: &[u8] = include_bytes!("../assets/sounds/music.ogg");

fn initialize(&mut self, world: &mut World) {
    if let Ok(data) = load_sound_from_bytes(EXPLOSION_WAV) {
        world.resources.audio.load_sound("explosion", data);
    }
    if let Ok(data) = load_sound_from_bytes(MUSIC_OGG) {
        world.resources.audio.load_sound("music", data);
    }
}
}

load_sound_from_bytes decodes raw audio bytes into StaticSoundData, and world.resources.audio.load_sound caches it by name for later reference.

Playing Sounds

Entity-Based Playback

Sounds are played through the AudioSource component, which references a cached sound by name:

#![allow(unused)]
fn main() {
let entity = world.spawn_entities(AUDIO_SOURCE | LOCAL_TRANSFORM | GLOBAL_TRANSFORM, 1)[0];

world.core.set_audio_source(entity, AudioSource::new("explosion").playing());
}

Looping Music

#![allow(unused)]
fn main() {
let music = world.spawn_entities(AUDIO_SOURCE, 1)[0];

world.core.set_audio_source(music, AudioSource::new("music")
    .with_looping(true)
    .playing(),
);
}

Audio Source Component

The AudioSource component controls playback:

#![allow(unused)]
fn main() {
pub struct AudioSource {
    pub audio_ref: Option<String>,
    pub volume: f32,
    pub looping: bool,
    pub playing: bool,
    pub spatial: bool,
    pub reverb: bool,
}
}

Builder methods:

#![allow(unused)]
fn main() {
AudioSource::new("name")
    .with_volume(0.8)
    .with_looping(true)
    .with_spatial(true)
    .with_reverb(true)
    .playing()
}

Audio Listener

Mark the entity that "hears" sounds (usually the camera):

#![allow(unused)]
fn main() {
world.core.add_components(camera_entity, AUDIO_LISTENER);
world.core.set_audio_listener(camera_entity, AudioListener);
}

Spatial Audio Sources

Attach sounds to positioned entities for 3D audio:

#![allow(unused)]
fn main() {
const ENGINE_LOOP: &[u8] = include_bytes!("../assets/sounds/engine_loop.wav");

fn initialize(&mut self, world: &mut World) {
    if let Ok(data) = load_sound_from_bytes(ENGINE_LOOP) {
        world.resources.audio.load_sound("engine_loop", data);
    }

    let entity = world.spawn_entities(
        AUDIO_SOURCE | LOCAL_TRANSFORM | GLOBAL_TRANSFORM,
        1,
    )[0];

    world.core.set_audio_source(entity, AudioSource::new("engine_loop")
        .with_spatial(true)
        .with_looping(true)
        .playing(),
    );
}
}

Sound Variations

Play random variations by switching the audio_ref:

#![allow(unused)]
fn main() {
const FOOTSTEP_1: &[u8] = include_bytes!("../assets/sounds/footstep_1.wav");
const FOOTSTEP_2: &[u8] = include_bytes!("../assets/sounds/footstep_2.wav");
const FOOTSTEP_3: &[u8] = include_bytes!("../assets/sounds/footstep_3.wav");
const FOOTSTEP_4: &[u8] = include_bytes!("../assets/sounds/footstep_4.wav");

fn initialize(&mut self, world: &mut World) {
    for (name, bytes) in [
        ("footstep_1", FOOTSTEP_1),
        ("footstep_2", FOOTSTEP_2),
        ("footstep_3", FOOTSTEP_3),
        ("footstep_4", FOOTSTEP_4),
    ] {
        if let Ok(data) = load_sound_from_bytes(bytes) {
            world.resources.audio.load_sound(name, data);
        }
    }
}

fn play_footstep(world: &mut World, source_entity: Entity) {
    let sounds = ["footstep_1", "footstep_2", "footstep_3", "footstep_4"];
    let index = rand::random::<usize>() % sounds.len();

    if let Some(audio) = world.core.get_audio_source_mut(source_entity) {
        audio.audio_ref = Some(sounds[index].to_string());
        audio.playing = true;
    }
}
}

Triggering Sounds on Events

#![allow(unused)]
fn main() {
fn run_systems(&mut self, world: &mut World) {
    for event in world.resources.physics.collision_events() {
        if matches!(event.kind, CollisionEventKind::Started) {
            if let Some(audio) = world.core.get_audio_source_mut(self.impact_source) {
                audio.audio_ref = Some("impact".to_string());
                audio.playing = true;
            }
        }
    }
}
}

Stopping Sounds

Stop a sound attached to an entity:

#![allow(unused)]
fn main() {
world.resources.audio.stop_sound(entity);
}

Or set playing to false:

#![allow(unused)]
fn main() {
if let Some(audio) = world.core.get_audio_source_mut(entity) {
    audio.playing = false;
}
}

Supported Formats

FormatExtensionNotes
WAV.wavUncompressed, fast loading
OGG.oggCompressed, good for music
MP3.mp3Compressed, widely supported
FLAC.flacLossless compression

Spatial Audio

3D positional audio creates immersive soundscapes where sounds have position and direction.

Audio Listener

The listener is the "ear" in the scene, represented as an entity with the AUDIO_LISTENER component. Usually attached to the camera:

#![allow(unused)]
fn main() {
fn initialize(&mut self, world: &mut World) {
    let camera = spawn_camera(world, Vec3::new(0.0, 2.0, 10.0), "Camera".to_string());
    world.resources.active_camera = Some(camera);

    world.core.add_components(camera, AUDIO_LISTENER);
    world.core.set_audio_listener(camera, AudioListener);
}
}

Spatial Audio Source

Attach sounds to entities for positional audio:

#![allow(unused)]
fn main() {
fn spawn_ambient_sound(world: &mut World, position: Vec3, sound_name: &str) -> Entity {
    let entity = world.spawn_entities(
        AUDIO_SOURCE | LOCAL_TRANSFORM | GLOBAL_TRANSFORM,
        1
    )[0];

    world.core.set_local_transform(entity, LocalTransform {
        translation: position,
        ..Default::default()
    });

    world.core.set_audio_source(entity, AudioSource::new(sound_name)
        .with_spatial(true)
        .with_looping(true)
        .playing(),
    );

    entity
}
}

Distance Attenuation

Sounds get quieter with distance:

#![allow(unused)]
fn main() {
world.core.set_audio_source(entity, AudioSource::new("waterfall")
    .with_spatial(true)
    .with_looping(true)
    .playing(),
);
}

Rolloff Modes

ModeDescription
LinearLinear falloff between min/max distance
InverseRealistic 1/distance falloff
ExponentialSteep falloff, good for small sounds

Moving Sound Sources

Sounds automatically track their entity's position:

#![allow(unused)]
fn main() {
fn update_helicopter(world: &mut World, helicopter: Entity, dt: f32) {
    if let Some(transform) = world.core.get_local_transform_mut(helicopter) {
        transform.translation.x += 10.0 * dt;
    }
    mark_local_transform_dirty(world, helicopter);
}
}

Non-Spatial (2D) Audio

UI sounds and music should not be spatial:

#![allow(unused)]
fn main() {
world.core.set_audio_source(entity, AudioSource::new("ui_click").playing());
}

Directional Audio Sources

Some sounds are directional (like a speaker):

#![allow(unused)]
fn main() {
world.core.set_audio_source(entity, AudioSource::new("announcement")
    .with_spatial(true)
    .playing(),
);
}

Manual Volume Control

Adjust volume based on distance to the listener:

#![allow(unused)]
fn main() {
fn update_audio_attenuation(world: &mut World, source: Entity, listener: Entity) {
    let source_pos = world.core.get_global_transform(source).map(|t| t.translation());
    let listener_pos = world.core.get_global_transform(listener).map(|t| t.translation());

    if let (Some(src), Some(lst)) = (source_pos, listener_pos) {
        let distance = (lst - src).magnitude();
        let max_distance = 50.0;
        let volume = (1.0 - distance / max_distance).clamp(0.0, 1.0);

        if let Some(audio) = world.core.get_audio_source_mut(source) {
            audio.volume = volume;
        }
    }
}
}

Common Patterns

Ambient Soundscape

#![allow(unused)]
fn main() {
fn setup_forest_ambience(world: &mut World) {
    spawn_ambient_sound(world, Vec3::zeros(), "forest_ambient");

    spawn_ambient_sound(world, Vec3::new(10.0, 5.0, 0.0), "bird_chirp");
    spawn_ambient_sound(world, Vec3::new(-8.0, 4.0, 5.0), "bird_song");

    spawn_ambient_sound(world, Vec3::new(0.0, 0.0, 20.0), "stream");
}
}

Footstep System

#![allow(unused)]
fn main() {
fn play_footstep_at_position(world: &mut World, position: Vec3) {
    let entity = world.spawn_entities(AUDIO_SOURCE | LOCAL_TRANSFORM | GLOBAL_TRANSFORM, 1)[0];

    world.core.set_local_transform(entity, LocalTransform {
        translation: position,
        ..Default::default()
    });

    world.core.set_audio_source(entity, AudioSource::new("footstep")
        .with_spatial(true)
        .playing(),
    );
}
}

Audio Analyzer

The AudioAnalyzer provides real-time FFT-based spectral analysis for music-reactive applications. It extracts frequency bands, detects beats, estimates tempo, and identifies musical structure changes like buildups, drops, and breakdowns.

Enabling the Feature

The AudioAnalyzer requires the fft feature flag:

[dependencies]
nightshade = { git = "...", features = ["engine", "audio", "fft"] }

Creating an Analyzer

#![allow(unused)]
fn main() {
use nightshade::prelude::*;

let mut analyzer = AudioAnalyzer::new();

// Optional: configure sample rate and FFT size
let analyzer = AudioAnalyzer::new()
    .with_sample_rate(44100)
    .with_fft_size(4096);
}

Loading Audio Samples

The analyzer works with raw Vec<f32> audio samples (mono, normalized to -1.0..1.0). You must decode audio files yourself using a crate like symphonia:

#![allow(unused)]
fn main() {
analyzer.load_samples(samples, sample_rate);

if analyzer.has_samples() {
    let duration = analyzer.total_duration();
    let rate = analyzer.sample_rate();
}
}

Analyzing Audio

Call analyze_at_time each frame with the current playback position:

#![allow(unused)]
fn main() {
fn run_systems(&mut self, world: &mut World) {
    let current_time = self.playback_time; // seconds
    self.analyzer.analyze_at_time(current_time);

    // Now use analysis results
    let bass_level = self.analyzer.smoothed_bass;
}
}

Frequency Bands

The analyzer splits the spectrum into six frequency bands:

BandFrequency RangeUse Cases
sub_bass20-60 HzDeep rumble, sub drops
bass60-250 HzKick drums, bass lines
low_mids250-500 HzGuitar body, warmth
mids500-2000 HzVocals, melody
high_mids2000-4000 HzPresence, clarity
highs4000-12000 HzHi-hats, cymbals, air

Each band has raw and smoothed variants:

#![allow(unused)]
fn main() {
// Raw values (instant, can be jumpy)
analyzer.sub_bass
analyzer.bass
analyzer.low_mids
analyzer.mids
analyzer.high_mids
analyzer.highs

// Smoothed values (attack/release filtered)
analyzer.smoothed_sub_bass
analyzer.smoothed_bass
analyzer.smoothed_low_mids
analyzer.smoothed_mids
analyzer.smoothed_high_mids
analyzer.smoothed_highs
}

Values are normalized to 0.0-1.0 range using dB scaling.

Beat Detection

The analyzer detects different drum elements:

#![allow(unused)]
fn main() {
// General onset detection
if analyzer.onset_detected {
    // Any significant transient occurred
}
analyzer.onset_decay  // 0.0-1.0, decays after onset

// Drum-specific detection
analyzer.kick_decay   // Triggers on kick drums (low frequency transients)
analyzer.snare_decay  // Triggers on snares (mid frequency transients)
analyzer.hat_decay    // Triggers on hi-hats (high frequency transients)
}

Decay values start at 1.0 when triggered and decay over time.

Example: Reactive Visuals

#![allow(unused)]
fn main() {
fn run_systems(&mut self, world: &mut World) {
    self.analyzer.analyze_at_time(self.time);

    // Scale objects on kick
    if let Some(transform) = world.core.get_local_transform_mut(self.cube) {
        let scale = 1.0 + self.analyzer.kick_decay * 0.5;
        transform.scale = Vec3::new(scale, scale, scale);
    }

    // Flash lights on snare
    if let Some(light_entity) = self.light {
        if let Some(light) = world.core.get_light_mut(light_entity) {
            light.intensity = 5.0 + self.analyzer.snare_decay * 20.0;
        }
    }

    // Particle burst on onset
    if self.analyzer.onset_detected {
        self.spawn_burst_particles(world);
    }
}
}

Tempo and Beat Phase

The analyzer estimates BPM from onset timing patterns:

#![allow(unused)]
fn main() {
analyzer.estimated_bpm    // Estimated beats per minute (60-200 range)
analyzer.beat_confidence  // 0.0-1.0, confidence in BPM estimate
analyzer.beat_phase       // 0.0-1.0, position within current beat
analyzer.time_since_last_beat  // Seconds since last detected kick
}

Example: Beat-Synced Animation

#![allow(unused)]
fn main() {
fn run_systems(&mut self, world: &mut World) {
    self.analyzer.analyze_at_time(self.time);

    // Pulse on beat (phase goes 0->1 each beat)
    let phase = self.analyzer.beat_phase;
    let pulse = 1.0 - phase; // High at beat start, low at end

    // Or use groove_sync for smooth beat alignment
    let sync = self.analyzer.groove_sync; // 1.0 at beat, 0.0 between
}
}

Spectral Features

Advanced spectral descriptors for music analysis:

#![allow(unused)]
fn main() {
// Spectral centroid: "brightness" of sound (0.0-1.0, normalized)
analyzer.spectral_centroid
analyzer.smoothed_centroid

// Spectral flatness: noise vs tonal (0.0=tonal, 1.0=noise)
analyzer.spectral_flatness
analyzer.smoothed_flatness

// Spectral rolloff: frequency below which 85% of energy lies
analyzer.spectral_rolloff
analyzer.smoothed_rolloff

// Spectral flux: rate of spectral change
analyzer.spectral_flux

// Brightness change between frames
analyzer.brightness_delta

// Harmonic content change
analyzer.harmonic_change
}

Example: Color Based on Brightness

#![allow(unused)]
fn main() {
fn update_material(&self, world: &mut World) {
    use nightshade::ecs::generational_registry::registry_entry_by_name_mut;

    let brightness = self.analyzer.smoothed_centroid;

    let color = [
        1.0 - brightness,
        0.2,
        brightness,
        1.0
    ];

    if let Some(mat_ref) = world.core.get_material_ref(self.entity).cloned() {
        if let Some(material) = registry_entry_by_name_mut(
            &mut world.resources.material_registry.registry,
            &mat_ref.name,
        ) {
            material.base_color = color;
        }
    }
}
}

Energy and Intensity

Track overall loudness and dynamics:

#![allow(unused)]
fn main() {
// Current energy level
analyzer.average_energy     // Short-term average
analyzer.long_term_energy   // Long-term average (for normalization)

// Intensity: current energy relative to long-term (can exceed 1.0)
analyzer.intensity

// Transient vs sustained balance
analyzer.transient_energy   // How "punchy" the sound is
analyzer.sustained_energy   // How "smooth" the sound is
analyzer.transient_ratio    // transient/sustained (0.0-2.0)

// Per-band transients
analyzer.low_transient      // Sudden low frequency increase
analyzer.mid_transient      // Sudden mid frequency increase
analyzer.high_transient     // Sudden high frequency increase
}

Music Structure Detection

Detect musical sections automatically:

#![allow(unused)]
fn main() {
// Building up (energy increasing, pre-drop)
analyzer.is_building
analyzer.build_intensity  // 0.0-1.0, increases during buildup

// Drop (sudden energy increase with kick)
analyzer.is_dropping
analyzer.drop_intensity   // Starts at 1.0, decays

// Breakdown (low energy section)
analyzer.is_breakdown
analyzer.breakdown_intensity
}

Example: Reactive Scene

#![allow(unused)]
fn main() {
fn run_systems(&mut self, world: &mut World) {
    self.analyzer.analyze_at_time(self.time);

    // Dim lights during breakdown
    if self.analyzer.is_breakdown {
        let intensity = 0.1 + 0.1 * self.analyzer.breakdown_intensity;
        world.resources.graphics.ambient_light = [intensity, intensity, intensity, 1.0];
    }

    // Camera shake on drop
    if self.analyzer.is_dropping {
        self.camera_shake = self.analyzer.drop_intensity * 0.5;
    }

    // Speed up particles during buildup
    if self.analyzer.is_building {
        self.particle_speed = 1.0 + self.analyzer.build_intensity * 3.0;
    }
}
}

Groove Analysis

For tight rhythm synchronization:

#![allow(unused)]
fn main() {
// How well current timing aligns with detected beat grid
analyzer.groove_sync      // 1.0 at beat positions, 0.0 between

// Consistency of beat timing
analyzer.pocket_tightness // 0.0-1.0, higher = more consistent tempo
}

Song Progress

Track position within the loaded audio:

#![allow(unused)]
fn main() {
let progress = analyzer.song_progress(current_time); // 0.0-1.0
}

Resetting State

Reset all analysis state (useful when seeking or changing songs):

#![allow(unused)]
fn main() {
analyzer.reset();
}

Complete Example

use nightshade::ecs::generational_registry::registry_entry_by_name_mut;
use nightshade::prelude::*;

struct MusicVisualizer {
    analyzer: AudioAnalyzer,
    playback_time: f32,
    cube: Option<Entity>,
    light: Option<Entity>,
}

impl Default for MusicVisualizer {
    fn default() -> Self {
        Self {
            analyzer: AudioAnalyzer::new(),
            playback_time: 0.0,
            cube: None,
            light: None,
        }
    }
}

impl State for MusicVisualizer {
    fn initialize(&mut self, world: &mut World) {
        // Decode audio to raw samples (requires symphonia or similar crate)
        // self.analyzer.load_samples(samples, sample_rate);

        // Create scene
        self.cube = Some(spawn_cube_at(world, Vec3::zeros()));

        let light_entity = world.spawn_entities(
            LIGHT | LOCAL_TRANSFORM | GLOBAL_TRANSFORM | LOCAL_TRANSFORM_DIRTY,
            1
        )[0];
        world.core.set_light(light_entity, Light {
            light_type: LightType::Point,
            color: Vec3::new(1.0, 1.0, 1.0),
            intensity: 10.0,
            range: 20.0,
            ..Default::default()
        });
        world.core.set_local_transform(light_entity, LocalTransform {
            translation: Vec3::new(0.0, 3.0, 0.0),
            ..Default::default()
        });
        self.light = Some(light_entity);

        let camera = spawn_camera(world, Vec3::new(0.0, 5.0, 10.0), "Camera".to_string());
        world.resources.active_camera = Some(camera);
        spawn_sun(world);
    }

    fn run_systems(&mut self, world: &mut World) {
        let dt = world.resources.window.timing.delta_time;
        self.playback_time += dt;

        // Analyze current audio position
        self.analyzer.analyze_at_time(self.playback_time);

        // React to bass
        if let Some(cube) = self.cube {
            if let Some(transform) = world.core.get_local_transform_mut(cube) {
                let bass_scale = 1.0 + self.analyzer.smoothed_bass * 0.5;
                transform.scale = Vec3::new(bass_scale, bass_scale, bass_scale);
            }

            if let Some(mat_ref) = world.core.get_material_ref(cube).cloned() {
                if let Some(material) = registry_entry_by_name_mut(
                    &mut world.resources.material_registry.registry,
                    &mat_ref.name,
                ) {
                    material.emissive_factor = [
                        self.analyzer.smoothed_bass,
                        self.analyzer.smoothed_mids,
                        self.analyzer.smoothed_highs,
                    ];
                    material.emissive_strength = self.analyzer.intensity * 2.0;
                }
            }
        }

        // Flash light on kick
        if let Some(light_entity) = self.light {
            if let Some(light) = world.core.get_light_mut(light_entity) {
                light.intensity = 5.0 + self.analyzer.kick_decay * 30.0;
            }
        }

        // Adjust ambient based on structure
        if self.analyzer.is_breakdown {
            world.resources.graphics.ambient_light = [0.05, 0.05, 0.05, 1.0];
        } else if self.analyzer.is_dropping {
            world.resources.graphics.ambient_light = [0.3, 0.3, 0.3, 1.0];
        } else {
            world.resources.graphics.ambient_light = [0.15, 0.15, 0.15, 1.0];
        }
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    nightshade::launch(MusicVisualizer::default())
}

Constants

Internal analysis parameters:

ConstantValuePurpose
FFT_SIZE4096FFT window size
SPECTRUM_BINS256Number of spectrum display bins
ENERGY_HISTORY_SIZE90Frames of energy history
FLUX_HISTORY_SIZE20Frames of spectral flux history
ONSET_HISTORY_SIZE512Onset times stored for tempo estimation

Performance Notes

  • FFT analysis runs at most every 8ms to avoid redundant computation
  • The analyzer is designed for pre-loaded audio, not real-time microphone input
  • For best results, use uncompressed or high-quality audio (WAV, FLAC)
  • Tempo estimation improves over time as more onsets are detected

egui Integration

Live Demo: UI

Nightshade integrates egui, a popular immediate-mode GUI library for Rust. Use it for debug interfaces, tools, and in-game menus.

Enabling egui

Add the egui feature to your Cargo.toml:

nightshade = { git = "...", features = ["engine", "wgpu", "egui"] }

The ui() Method

Implement the ui() method on your State to render egui:

#![allow(unused)]
fn main() {
impl State for MyGame {
    fn ui(&mut self, world: &mut World, ctx: &egui::Context) {
        egui::Window::new("Debug")
            .show(ctx, |ui| {
                ui.label("Hello, World!");
            });
    }
}
}

Common Widgets

Labels

#![allow(unused)]
fn main() {
ui.label("Static text");
ui.label(format!("FPS: {:.0}", world.resources.window.timing.frames_per_second));
ui.colored_label(egui::Color32::RED, "Warning!");
}

Buttons

#![allow(unused)]
fn main() {
if ui.button("Click me").clicked() {
    self.counter += 1;
}

if ui.button("Spawn Enemy").clicked() {
    spawn_enemy(world);
}
}

Sliders

#![allow(unused)]
fn main() {
ui.add(egui::Slider::new(&mut self.speed, 0.0..=10.0).text("Speed"));

ui.add(egui::Slider::new(&mut world.resources.graphics.bloom_intensity, 0.0..=2.0)
    .text("Bloom Intensity"));
}

Checkboxes

#![allow(unused)]
fn main() {
ui.checkbox(&mut self.paused, "Paused");

ui.checkbox(&mut world.resources.graphics.bloom_enabled, "Bloom");
ui.checkbox(&mut world.resources.graphics.ssao_enabled, "SSAO");
}

Text Input

#![allow(unused)]
fn main() {
ui.text_edit_singleline(&mut self.player_name);

ui.text_edit_multiline(&mut self.notes);
}

Combo Boxes

#![allow(unused)]
fn main() {
egui::ComboBox::from_label("Difficulty")
    .selected_text(format!("{:?}", self.difficulty))
    .show_ui(ui, |ui| {
        ui.selectable_value(&mut self.difficulty, Difficulty::Easy, "Easy");
        ui.selectable_value(&mut self.difficulty, Difficulty::Normal, "Normal");
        ui.selectable_value(&mut self.difficulty, Difficulty::Hard, "Hard");
    });
}

Color Picker

#![allow(unused)]
fn main() {
ui.color_edit_button_rgb(&mut self.light_color);

let mut color = [
    world.resources.graphics.ambient_color.x,
    world.resources.graphics.ambient_color.y,
    world.resources.graphics.ambient_color.z,
];
if ui.color_edit_button_rgb(&mut color).changed() {
    world.resources.graphics.ambient_color = Vec3::new(color[0], color[1], color[2]);
}
}

Layouts

Horizontal

#![allow(unused)]
fn main() {
ui.horizontal(|ui| {
    ui.label("Name:");
    ui.text_edit_singleline(&mut self.name);
});
}

Vertical

#![allow(unused)]
fn main() {
ui.vertical(|ui| {
    ui.label("Line 1");
    ui.label("Line 2");
    ui.label("Line 3");
});
}

Columns

#![allow(unused)]
fn main() {
ui.columns(2, |columns| {
    columns[0].label("Left column");
    columns[1].label("Right column");
});
}

Spacing

#![allow(unused)]
fn main() {
ui.add_space(10.0);
ui.separator();
}

Windows

Basic Window

#![allow(unused)]
fn main() {
egui::Window::new("Settings")
    .show(ctx, |ui| {
        ui.label("Settings content");
    });
}

Configurable Window

#![allow(unused)]
fn main() {
egui::Window::new("Inspector")
    .resizable(true)
    .collapsible(true)
    .default_pos([10.0, 10.0])
    .default_size([300.0, 400.0])
    .show(ctx, |ui| {
        // Content
    });
}

Conditional Window

#![allow(unused)]
fn main() {
if self.show_inventory {
    egui::Window::new("Inventory")
        .open(&mut self.show_inventory)
        .show(ctx, |ui| {
            // Inventory content
        });
}
}

Panels

Side Panel

#![allow(unused)]
fn main() {
egui::SidePanel::left("left_panel")
    .default_width(200.0)
    .show(ctx, |ui| {
        ui.heading("Tools");
        if ui.button("Select").clicked() {
            self.tool = Tool::Select;
        }
        if ui.button("Move").clicked() {
            self.tool = Tool::Move;
        }
    });
}

Top/Bottom Panel

#![allow(unused)]
fn main() {
egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
    egui::menu::bar(ui, |ui| {
        ui.menu_button("File", |ui| {
            if ui.button("New").clicked() {
                self.new_scene();
            }
            if ui.button("Open").clicked() {
                self.open_scene();
            }
            if ui.button("Save").clicked() {
                self.save_scene();
            }
        });
    });
});
}

Central Panel

#![allow(unused)]
fn main() {
egui::CentralPanel::default().show(ctx, |ui| {
    ui.heading("Main Content Area");
});
}

Debug UI Example

#![allow(unused)]
fn main() {
fn ui(&mut self, world: &mut World, ctx: &egui::Context) {
    egui::Window::new("Debug Info").show(ctx, |ui| {
        ui.heading("Performance");
        ui.label(format!("FPS: {:.0}", world.resources.window.timing.frames_per_second));
        ui.label(format!("Frame Time: {:.2}ms", world.resources.window.timing.delta_time * 1000.0));
        ui.label(format!("Entities: {}", world.core.query_entities(RENDER_MESH).count()));

        ui.separator();

        ui.heading("Graphics");
        ui.checkbox(&mut world.resources.graphics.bloom_enabled, "Bloom");
        ui.checkbox(&mut world.resources.graphics.ssao_enabled, "SSAO");
        ui.add(egui::Slider::new(&mut world.resources.graphics.bloom_intensity, 0.0..=2.0).text("Bloom Intensity"));

        ui.separator();

        ui.heading("Player");
        if let Some(transform) = world.core.get_local_transform(self.player) {
            ui.label(format!("Position: ({:.1}, {:.1}, {:.1})",
                transform.translation.x,
                transform.translation.y,
                transform.translation.z
            ));
        }
    });
}
}

Input Handling

Check if egui wants keyboard/mouse input in the ui() method, then store the state for use in run_systems():

#![allow(unused)]
fn main() {
impl State for MyGame {
    fn ui(&mut self, world: &mut World, ctx: &egui::Context) {
        self.ui_wants_keyboard = ctx.wants_keyboard_input();
        self.ui_wants_pointer = ctx.wants_pointer_input();

        egui::Window::new("Debug").show(ctx, |ui| {
            ui.label("Hello, World!");
        });
    }

    fn run_systems(&mut self, world: &mut World) {
        if !self.ui_wants_keyboard {
            handle_game_keyboard(world);
        }

        if !self.ui_wants_pointer {
            handle_game_mouse(world);
        }
    }
}
}

Or check input state directly in the ui() method to control behavior immediately:

#![allow(unused)]
fn main() {
fn ui(&mut self, world: &mut World, ctx: &egui::Context) {
    egui::Window::new("Debug").show(ctx, |ui| {
        ui.label("Hello, World!");
    });

    if !ctx.wants_keyboard_input() {
        handle_game_keyboard(world);
    }

    if !ctx.wants_pointer_input() {
        handle_game_mouse(world);
    }
}
}

Styling

Dark Theme (Default)

#![allow(unused)]
fn main() {
ctx.set_visuals(egui::Visuals::dark());
}

Light Theme

#![allow(unused)]
fn main() {
ctx.set_visuals(egui::Visuals::light());
}

Custom Colors

#![allow(unused)]
fn main() {
let mut visuals = egui::Visuals::dark();
visuals.widgets.active.bg_fill = egui::Color32::from_rgb(60, 60, 120);
ctx.set_visuals(visuals);
}

Best Practices

  1. Keep debug UI toggleable: Add a key to show/hide debug windows
  2. Group related settings: Use collapsing headers and separators
  3. Show real-time data: FPS, entity counts, memory usage
  4. Provide quick actions: Spawn entities, reload assets, reset state
  5. Don't block gameplay: Check wants_keyboard_input() before processing game input

Mosaic Framework

The mosaic feature provides a multi-pane desktop application framework built on egui_tiles. It handles dockable tile-based layouts, serializable widgets, theming, modals, notifications, and more.

Enabling Mosaic

nightshade = { git = "...", features = ["mosaic"] }

The mosaic feature requires egui (pulled in automatically).

Architecture

ComponentDescription
Widget<C, M> traitImplement for each widget type - title, ui, lifecycle hooks, closability, camera requirements, catalog
Mosaic<W, C, M>Manages the tile tree, rendering, widget lifecycle, modals, layout persistence, messaging, and tree walking
MosaicConfigConfigurable behavior - tab bar height, gap width, closability, add button visibility
TileBehavior<W, C, M>Implements egui_tiles::Behavior with closable tabs, drag-and-drop, and an add-widget popup with search
ViewportWidgetBuilt-in 3D viewport with camera rendering, selection, and multi-camera selector
WidgetContext<C, M>Passed to widgets during rendering with world access, modal service, viewport state, app context, and message sending
ModalsModal dialog service for confirm and text input dialogs
Pane<W>Wraps a widget instance for the tile tree

Core Concepts

The Widget Trait

Every pane in a mosaic layout implements the Widget trait. Widgets must be Clone + serde::Serialize + serde::Deserialize + 'static so layouts can be saved and restored.

#![allow(unused)]
fn main() {
use nightshade::prelude::*;
use nightshade::mosaic::{Widget, WidgetContext, WidgetEntry, Pane, ViewportWidget};

#[derive(Clone, serde::Serialize, serde::Deserialize)]
enum AppWidget {
    Viewport(ViewportWidget),
    Inspector(InspectorWidget),
}

impl Widget for AppWidget {
    fn title(&self) -> String {
        match self {
            AppWidget::Viewport(v) => v.title(),
            AppWidget::Inspector(_) => "Inspector".to_string(),
        }
    }

    fn ui(&mut self, ui: &mut egui::Ui, context: &mut WidgetContext) {
        match self {
            AppWidget::Viewport(v) => v.ui(ui, context),
            AppWidget::Inspector(v) => v.ui(ui, context),
        }
    }

    fn catalog() -> Vec<WidgetEntry<Self>> {
        vec![
            WidgetEntry {
                name: "Viewport".to_string(),
                create: || AppWidget::Viewport(ViewportWidget::default()),
            },
            WidgetEntry {
                name: "Inspector".to_string(),
                create: || AppWidget::Inspector(InspectorWidget),
            },
        ]
    }
}
}

The catalog() method defines the list of widgets available in the "+" add-widget popup.

Full trait definition:

#![allow(unused)]
fn main() {
trait Widget<C = (), M = ()>: Clone + Serialize + DeserializeOwned + 'static {
    fn title(&self) -> String;
    fn ui(&mut self, ui: &mut egui::Ui, context: &mut WidgetContext<C, M>);

    fn on_add(&mut self, _context: &mut WidgetContext<C, M>) {}
    fn on_remove(&mut self, _context: &mut WidgetContext<C, M>) {}
    fn closable(&self) -> bool { true }
    fn required_camera(&self, _cached_cameras: &[Entity]) -> Option<Entity> { None }

    fn catalog() -> Vec<WidgetEntry<Self>>;
}
}

The Mosaic Struct

Mosaic<W, C, M> manages a tile tree of widgets. The three type parameters are:

  • W - your widget enum (implements Widget<C, M>)
  • C - shared application context passed to all widgets (default ())
  • M - message type for inter-widget communication (default ())
#![allow(unused)]
fn main() {
use nightshade::mosaic::Mosaic;

struct MyApp {
    mosaic: Mosaic<AppWidget>,
}
}

Construction:

#![allow(unused)]
fn main() {
Mosaic::new()                              // Empty mosaic
Mosaic::with_panes(vec![...])              // Single tab group with panes
Mosaic::with_tree(tree)                    // Custom egui_tiles::Tree
Mosaic::from_window_layout(layout)         // From a saved WindowLayout
mosaic.with_config(config)                 // Builder: set MosaicConfig
mosaic.with_title(title)                   // Builder: set window title
}

Create with initial panes:

#![allow(unused)]
fn main() {
let mosaic = Mosaic::with_panes(vec![
    AppWidget::Viewport(ViewportWidget::default()),
    AppWidget::Inspector(InspectorWidget),
]);
}

Or with an explicit egui_tiles::Tree:

#![allow(unused)]
fn main() {
let mut tiles = egui_tiles::Tiles::default();
let viewport = tiles.insert_pane(Pane::new(AppWidget::Viewport(ViewportWidget::default())));
let inspector = tiles.insert_pane(Pane::new(AppWidget::Inspector(InspectorWidget)));
let root = tiles.insert_tab_tile(vec![viewport, inspector]);
let tree = egui_tiles::Tree::new("my_tree", root, tiles);
let mosaic = Mosaic::with_tree(tree);
}

Rendering

Call mosaic.show() inside your State::ui() method:

#![allow(unused)]
fn main() {
impl State for MyApp {
    fn ui(&mut self, world: &mut World, ctx: &egui::Context) {
        self.mosaic.show(world, ctx, &mut ());
    }
}
}

For rendering inside a specific egui::Ui region instead of the full window, use show_inside:

#![allow(unused)]
fn main() {
self.mosaic.show_inside(world, ui, &mut ());
}

WidgetContext

Every widget receives a WidgetContext in its ui() method:

#![allow(unused)]
fn main() {
context.world()                            // &World
context.world_mut()                        // &mut World
context.world_and_app()                    // (&mut World, &mut C)
context.world_app_modals()                 // (&mut World, &mut C, &mut Modals)
context.send(message)                      // Send an M message to the app
context.receive()                          // Receive messages sent to this widget
context.has_incoming()                     // Check if there are pending messages
context.viewport_textures                  // Rendered viewport textures
context.current_tile_id                    // This widget's tile ID
context.selected_viewport_tile             // Currently selected viewport
context.modals                             // &mut Modals for showing dialogs
context.app                                // &mut C application context
context.window_index                       // Optional window index
context.is_active_window                   // Whether this is the active window
context.cached_cameras                     // &[Entity] of all camera entities
}

Application Context and Messages

For non-trivial apps, use the context and message type parameters to share state and communicate between widgets and the app.

#![allow(unused)]
fn main() {
struct AppContext {
    selected_entity: Option<Entity>,
    fps_counter: FpsCounter,
}

enum AppMessage {
    EntitySelected(Entity),
    Log(String),
}

struct MyApp {
    mosaic: Mosaic<AppWidget, AppContext, AppMessage>,
    context: AppContext,
}

impl State for MyApp {
    fn ui(&mut self, world: &mut World, ctx: &egui::Context) {
        self.mosaic.show(world, ctx, &mut self.context);

        for message in self.mosaic.drain_messages() {
            match message {
                AppMessage::EntitySelected(entity) => {
                    self.context.selected_entity = Some(entity);
                }
                AppMessage::Log(text) => {
                    // ...
                }
            }
        }
    }
}
}

Targeted Messaging

Send messages to specific widgets or broadcast to all:

#![allow(unused)]
fn main() {
mosaic.send_to(tile_id, AppMessage::Refresh);
mosaic.broadcast(AppMessage::ThemeChanged);
mosaic.send_matching(|w| matches!(w, AppWidget::Log(_)), AppMessage::NewEntry);
}

Widgets receive targeted messages via context.receive():

#![allow(unused)]
fn main() {
fn ui(&mut self, ui: &mut egui::Ui, context: &mut WidgetContext<AppContext, AppMessage>) {
    for message in context.receive() {
        match message {
            AppMessage::Refresh => { /* handle */ }
            _ => {}
        }
    }
}
}

Built-in ViewportWidget

ViewportWidget renders a camera's output into a pane. It supports camera selection when multiple cameras exist, viewport selection highlighting, and automatic active camera management.

#![allow(unused)]
fn main() {
use nightshade::mosaic::ViewportWidget;

let viewport = ViewportWidget { camera_index: 0 };
}

Implement required_camera on your widget enum to tell the mosaic which cameras need rendering. The cached_cameras slice contains all camera entities, pre-sorted by entity ID:

#![allow(unused)]
fn main() {
fn required_camera(&self, cached_cameras: &[Entity]) -> Option<Entity> {
    match self {
        AppWidget::Viewport(v) => v.required_camera(cached_cameras),
        _ => None,
    }
}
}

Configuration

MosaicConfig controls layout behavior:

#![allow(unused)]
fn main() {
use nightshade::mosaic::MosaicConfig;

let config = MosaicConfig {
    tab_bar_height: 24.0,
    close_button_size: 16.0,
    gap_width: 1.0,
    min_size: 32.0,
    all_closable: true,
    show_add_button: true,
    simplification_options: egui_tiles::SimplificationOptions {
        all_panes_must_have_tabs: true,
        ..Default::default()
    },
};

let mosaic = Mosaic::with_panes(vec![/* ... */]).with_config(config);
}

Theming

The mosaic module includes a theme system with 11 built-in presets and a visual theme editor.

Built-in presets: Dark, Light, Dracula, Nord, Gruvbox Dark, Solarized Dark, Solarized Light, Monokai, One Dark, Tokyo Night, Catppuccin Mocha.

ThemeConfig supports ~80 override fields covering all egui visual properties: colors, stroke widths, corner radii, expansion, shadow sizes, and miscellaneous flags (button frames, striped backgrounds, slider trailing fill, etc.).

The preset combo box supports hover-to-preview - hovering a preset temporarily applies its visuals so you can see the effect before committing.

#![allow(unused)]
fn main() {
use nightshade::mosaic::{ThemeState, ThemeConfig, apply_theme, get_active_theme_visuals, render_theme_editor_window};

let mut theme_state = ThemeState::default();

// Apply theme each frame (uses preview theme when hovering presets)
apply_theme(ctx, &theme_state);

// Show the editor window
if render_theme_editor_window(ctx, &mut theme_state) {
    // theme changed - save if desired
}

// Switch preset by name
theme_state.select_preset_by_name("Dracula");

// Get the effective visuals (preview-aware)
let visuals = get_active_theme_visuals(&theme_state);
}

Modals

Show confirmation dialogs and text input prompts:

#![allow(unused)]
fn main() {
fn ui(&mut self, ui: &mut egui::Ui, context: &mut WidgetContext<AppContext, AppMessage>) {
    if ui.button("Delete").clicked() {
        context.modals.show_confirm("delete", "Confirm Delete", "Are you sure?");
    }

    if let Some(result) = context.modals.take_result("delete") {
        match result {
            ModalResult::Confirmed => { /* delete */ }
            ModalResult::Cancelled => {}
            _ => {}
        }
    }
}
}

Text input modals:

#![allow(unused)]
fn main() {
context.modals.show_text_input("rename", "Rename", "Enter new name:", "default");

if let Some(ModalResult::TextInput(name)) = context.modals.take_result("rename") {
    // use name
}
}

Full Modals API:

#![allow(unused)]
fn main() {
modals.show_confirm(id, title, body)
modals.show_confirm_with_text(id, title, body, confirm_text, cancel_text)
modals.show_text_input(id, title, prompt, default_text)
modals.show_text_input_with_text(id, title, prompt, default_text, confirm_text, cancel_text)
modals.take_result(id) -> Option<ModalResult>
modals.has_open_modal() -> bool
}

Toast Notifications

Four toast kinds: Info, Success, Warning, Error - each with a distinct accent color and fade-out animation.

#![allow(unused)]
fn main() {
use nightshade::mosaic::{Toasts, ToastKind};

let mut toasts = Toasts::new();
toasts.push(ToastKind::Success, "Project saved", 3.0);
toasts.push(ToastKind::Error, "Failed to load file", 4.0);
toasts.push(ToastKind::Warning, "Low disk space", 3.0);
toasts.push(ToastKind::Info, "Update available", 3.0);

// Each frame:
toasts.tick(delta_time);
toasts.render(ctx);
}

Status Bar

#![allow(unused)]
fn main() {
use nightshade::mosaic::StatusBar;

let mut status_bar = StatusBar::new();
status_bar.add_left("FPS: 60");
status_bar.add_left_colored("Ready", egui::Color32::GREEN);
status_bar.add_right("Theme: Nord");
status_bar.add_right_with_tooltip("v1.0", "Application version");
status_bar.render(ctx);
}

Command Palette

#![allow(unused)]
fn main() {
use nightshade::mosaic::CommandPalette;

let mut palette = CommandPalette::new();
palette.register("New File", Some("Ctrl+N".to_string()), || { /* ... */ });
palette.register("Save", Some("Ctrl+S".to_string()), || { /* ... */ });

// Toggle with a keybinding
palette.toggle();

// Each frame:
palette.render(ctx);
}

Keyboard Shortcuts

#![allow(unused)]
fn main() {
use nightshade::mosaic::{ShortcutManager, KeyBinding};

let mut shortcuts = ShortcutManager::new();
shortcuts.register("Save", KeyBinding::ctrl(egui::Key::S), || { /* ... */ });
shortcuts.register("Undo", KeyBinding::ctrl(egui::Key::Z), || { /* ... */ });
shortcuts.register("Redo", KeyBinding::ctrl_shift(egui::Key::Z), || { /* ... */ });

// Each frame:
shortcuts.process(ctx);
}

KeyBinding constructors: new(key), ctrl(key), shift(key), ctrl_shift(key), alt(key).

File Dialogs

Mosaic re-exports the cross-platform file I/O functions from nightshade::filesystem for convenience. See the File System chapter for full documentation of all types and functions.

Native-only functions (pick_file, pick_folder, save_file_dialog, read_file, write_file) require the file_dialog feature, which is included in engine by default:

#![allow(unused)]
fn main() {
use nightshade::mosaic::{FileFilter, pick_file, pick_folder, save_file_dialog, read_file, write_file};

let filters = [FileFilter {
    name: "JSON".to_string(),
    extensions: vec!["json".to_string()],
}];

if let Some(path) = pick_file(&filters) {
    let bytes = read_file(&path).unwrap();
}

if let Some(path) = save_file_dialog(&filters, Some("data.json")) {
    write_file(&path, data.as_bytes()).unwrap();
}

if let Some(folder) = pick_folder() {
    // use folder path
}
}

For cross-platform save/load that works on both native and WASM without #[cfg] gates, use save_file and request_file_load:

#![allow(unused)]
fn main() {
use nightshade::filesystem::{save_file, request_file_load, FileFilter};

// Save (native: save dialog, WASM: browser download)
let filters = [FileFilter {
    name: "JSON".to_string(),
    extensions: vec!["json".to_string()],
}];
save_file("data.json", &bytes, &filters)?;

// Load (native: file picker + sync read, WASM: file input + async read)
let pending = request_file_load(&filters);
// poll pending.take() each frame
}

Settings Persistence

Save and load application settings to <config_dir>/app_name/settings.json. Falls back to T::default() on missing or corrupt files.

#![allow(unused)]
fn main() {
use nightshade::mosaic::Settings;

#[derive(Default, serde::Serialize, serde::Deserialize)]
struct AppSettings {
    theme_name: Option<String>,
    recent_files: Vec<String>,
}

let settings: Settings<AppSettings> = Settings::load("my-app-name");
// settings.data.theme_name ...
settings.save().ok();
}

FPS Counter

#![allow(unused)]
fn main() {
use nightshade::mosaic::FpsCounter;

let mut fps = FpsCounter::new(0.5); // update every 0.5 seconds
// or: let mut fps = FpsCounter::default(); // same 0.5s interval

// Each frame:
fps.tick(delta_time);

// Read smoothed FPS:
let fps_value = fps.fps();           // f32
let fps_rounded = fps.fps_rounded(); // u32
}

Event Log

Timestamped event log with category coloring and automatic scroll-to-bottom.

#![allow(unused)]
fn main() {
use nightshade::mosaic::EventLog;

let mut log = EventLog::new(500);
log.log("SYS", "Application started");
log.tick(delta_time);
log.render(ui, |category| match category {
    "SYS" => egui::Color32::GRAY,
    "ERR" => egui::Color32::RED,
    _ => egui::Color32::WHITE,
});
}

Recent Files

#![allow(unused)]
fn main() {
use nightshade::mosaic::RecentFiles;

let mut recent = RecentFiles::new(10);
recent.add(path);
for path in recent.paths() { /* render menu items */ }
}

Clipboard

#![allow(unused)]
fn main() {
use nightshade::mosaic::{get_clipboard_text, set_clipboard_text};

set_clipboard_text(ctx, "copied text");
if let Some(text) = get_clipboard_text(ctx) { /* paste */ }
}

Drag and Drop

#![allow(unused)]
fn main() {
use nightshade::mosaic::{get_dropped_files, is_file_hovering, render_drop_overlay};

render_drop_overlay(ctx, "Drop files here");
for file in get_dropped_files(ctx) {
    if let Some(path) = file.path { /* handle file */ }
    if let Some(bytes) = file.bytes { /* handle raw bytes */ }
}
}

Project Save/Load

Save and load entire mosaic layouts. ProjectSaveFile<W, D> supports an optional generic data field for storing application-specific state alongside the layout:

#![allow(unused)]
fn main() {
use nightshade::mosaic::{save_project, load_project};

// Save (with no extra data)
let project = save_project("My Project", "1.0", &[&mosaic], None::<()>);
let json = serde_json::to_string_pretty(&project).unwrap();

// Save (with application data)
let project = save_project("My Project", "1.0", &[&mosaic], Some(app_data));

// Load
let project = serde_json::from_str(&json).unwrap();
let (windows, data) = load_project(project);
for window in windows {
    let mosaic = Mosaic::from_window_layout(window);
}
}

ProjectSaveFile also supports direct file I/O (native only) with JSON and binary (bincode) formats:

#![allow(unused)]
fn main() {
use nightshade::mosaic::ProjectSaveFile;

project.save_to_path(Path::new("project.json"))?;
project.save_to_path(Path::new("project.bin"))?;
let project = ProjectSaveFile::load_from_path(Path::new("project.json"))?;
}

Or save/load just the tile tree:

#![allow(unused)]
fn main() {
let tree_json = mosaic.save_tree().unwrap();
mosaic.load_tree(tree_json).unwrap();
}

Or save/load a named layout:

#![allow(unused)]
fn main() {
let save = mosaic.save_layout("Main", "1.0.0");
mosaic.load_layout(save);
}

Cross-Platform File Save/Load

Mosaic provides convenience methods that use nightshade::filesystem under the hood. These work on both native and WASM with no #[cfg] gates:

#![allow(unused)]
fn main() {
// Save layout to file (native: save dialog, WASM: browser download)
mosaic.save_project_to_file("my_layout.json")?;

// Request file load (returns PendingFileLoad)
let pending = mosaic.request_project_load();

// Poll each frame:
if let Some(file) = pending.take() {
    mosaic.load_project_from_bytes(&file.bytes)?;
}
}
  • save_project_to_file(filename) — serializes the tile tree to JSON and calls nightshade::filesystem::save_file
  • request_project_load() — calls nightshade::filesystem::request_file_load with a JSON filter
  • load_project_from_bytes(bytes) — deserializes and restores the tile tree from raw bytes

Widget Management

Add and remove widgets programmatically:

#![allow(unused)]
fn main() {
mosaic.insert_pane(widget)                     // Add widget to root container
mosaic.insert_pane_in(container_id, widget)    // Add widget to specific container
mosaic.remove_pane(tile_id)                    // Remove widget, returns owned widget
mosaic.find_widget(|w| predicate)              // Find first matching widget's TileId
mosaic.get_widget(tile_id)                     // Get &W by TileId
mosaic.get_widget_mut(tile_id)                 // Get &mut W by TileId
mosaic.activate_tab(tile_id)                   // Focus a widget's tab
mosaic.widget_count()                          // Number of widget panes
}

Tree Walking

#![allow(unused)]
fn main() {
mosaic.for_each_widget(|widget| { ... });
mosaic.for_each_widget_mut(|widget| { ... });
mosaic.for_each_widget_with_id(|tile_id, widget| { ... });
mosaic.for_each_widget_with_id_mut(|tile_id, widget| { ... });
}

Layout State

#![allow(unused)]
fn main() {
mosaic.layout_modified()                   // Check if layout changed (drag, close, add)
mosaic.take_layout_modified()              // Check and reset layout flag
mosaic.layout_name                         // Current layout name (pub field)
mosaic.title                               // Window title (pub field)
mosaic.viewport_rects()                    // Rendered pane rectangles by TileId
mosaic.selected_viewport_tile()            // Currently selected viewport
Mosaic::clear_required_cameras(world)      // Clear the required cameras list
mosaic.modals()                            // Get &mut Modals
}

Layout Management

Mosaic supports multiple named layouts that can be switched, created, saved, and deleted:

#![allow(unused)]
fn main() {
mosaic.switch_layout(index)                // Switch to layout by index
mosaic.create_layout("name", default_tree) // Create a new layout
mosaic.save_current_layout()               // Save current tree to active layout
mosaic.delete_current_layout()             // Delete active layout (if more than one)
mosaic.rename_layout(index, name)          // Rename a layout
mosaic.reset_layout(default_tree)          // Reset active layout to default
mosaic.active_layout_name()                // Name of the active layout
mosaic.active_layout_index()               // Index of the active layout
mosaic.layout_count()                      // Number of layouts
mosaic.layouts()                           // &[WindowLayout<W>]
mosaic.load_layouts(layouts, active_index) // Load layouts from saved data
mosaic.save_layouts()                      // (Vec<WindowLayout<W>>, usize) for persistence
}

A built-in layout menu UI is available:

#![allow(unused)]
fn main() {
let events = mosaic.render_layout_section(ui, || create_default_tree());
for event in events {
    match event {
        LayoutEvent::Switched(name) => { /* layout switched */ }
        LayoutEvent::Created(name) => { /* new layout created */ }
        LayoutEvent::Saved(name) => { /* layout saved */ }
        LayoutEvent::Deleted(name) => { /* layout deleted */ }
        LayoutEvent::Reset => { /* layout reset to default */ }
        LayoutEvent::Renamed(name) => { /* layout renamed */ }
    }
}
}

Multi-Window Support

Create one Mosaic per window. Each has its own layout, modals, and config:

#![allow(unused)]
fn main() {
mosaic.is_active_window = true;            // Set whether this window is active (pub field)
mosaic.window_index = Some(index);         // Set the window index (pub field)
mosaic.set_viewport_textures(textures);    // Set viewport textures for secondary windows
}

Use nightshade's secondary window system to spawn additional windows:

#![allow(unused)]
fn main() {
impl State for MyApp {
    fn ui(&mut self, world: &mut World, ctx: &egui::Context) {
        self.primary.show(world, ctx, &mut self.context);
    }

    fn secondary_ui(&mut self, world: &mut World, window_index: usize, ctx: &egui::Context) {
        let mosaic = self.secondary.entry(window_index).or_insert_with(|| {
            let mut m = Mosaic::with_panes(vec![AppWidget::Viewport(ViewportWidget::default())]);
            m.window_index = Some(window_index);
            m
        });
        mosaic.show(world, ctx, &mut self.context);
    }

    fn pre_render(&mut self, renderer: &mut dyn Render, world: &mut World) {
        let cameras = world.resources.user_interface.required_cameras.clone();
        for (&window_index, mosaic) in &mut self.secondary {
            let textures = renderer.register_camera_viewports_for_secondary(window_index, &cameras);
            mosaic.set_viewport_textures(textures);
        }
    }
}
}

Use save_project / load_project to persist all windows together.

Editor Infrastructure

The editor feature provides reusable scene-editor infrastructure for building custom editors on top of the Nightshade engine. It powers the official Nightshade Editor and can be used to build specialized tools with the same gizmo, undo, inspector, and picking capabilities.

nightshade = { git = "...", features = ["editor"] }

The editor feature requires mosaic (which requires egui) and picking.

Architecture

The editor module is organized into focused submodules:

ModulePurpose
contextEditorContext — central state struct holding selection, undo history, gizmo state, snap settings, and transform edit state
undoUndo/redo system with entity snapshots and hierarchy capture
selectionEntity selection, multi-select, delete, duplicate, copy/paste, select all
clipboardSystem clipboard integration via arboard (native only)
gizmoTransform gizmos (translate, rotate, scale) with drag math and modal operations
inputKeyboard shortcut handler returning InputSignal values for app-level actions (Blender-style G/R/S keys, Delete, Ctrl+Z, etc.)
pickingGPU-based entity picking, marquee selection, context menu trigger
camera_controlsView presets (front, back, left, right, top, bottom), ortho toggle
inspectorComponentInspector trait, InspectorContext, and UI for all built-in component types
treeScene tree widget with drag-and-drop reparenting and prefab instantiation
menusContext menus, add-node modal, add-primitive popup
spawningEntity spawning helpers (primitives, lines, text)
asset_loadingglTF/FBX loading and viewport spawn
code_editorCode editor widget with syntax highlighting (syntect, native only)
add_nodeAdd node modal dialog

EditorContext

EditorContext holds the core editor state that the engine needs:

#![allow(unused)]
fn main() {
use nightshade::editor::EditorContext;

let mut context = EditorContext::default();

// Selection
context.selection.set_single(entity);
context.selection.clear();

// Undo
context.undo_history.push(operation, "description".to_string());

// Gizmo mode
context.gizmo_interaction.mode = nightshade::ecs::gizmos::GizmoMode::Rotation;

// Snap settings
context.snap_settings.enabled = true;
context.snap_settings.translation_snap = 0.5;
}

Fields on EditorContext:

FieldTypePurpose
gizmo_interactionGizmoInteractionActive gizmo state, mode, hover axis, drag state
transform_editTransformEditPending transform edits and modal transform state
selectionEntitySelectionSelected entities
marqueeMarqueeStateMarquee (box) selection state
coordinate_spaceCoordinateSpaceWorld or Local coordinate space
snap_settingsSnapSettingsTranslation, rotation, scale snap values
undo_historyUndoHistoryUndo/redo stack

Methods on EditorContext:

#![allow(unused)]
fn main() {
context.gizmo_root()                                       // Option<Entity>
context.capture_selection_transforms(world)                 // HashMap<Entity, LocalTransform>
context.begin_selection_transform_tracking(world)           // Start tracking single-entity transform
context.commit_selection_transforms(world, initial, desc)   // Commit transforms to undo history
}

App-level concerns like UI visibility, popup state, project state, and notification management are intended to be owned by the application layer (e.g., in your AppContext), not stored in EditorContext.

Input Handling

The keyboard handler returns an InputResult instead of directly mutating app-level state. This lets the engine handle editor-internal shortcuts (gizmo, undo, selection) while the app decides what to do with signals like quit confirmations and popup triggers:

#![allow(unused)]
fn main() {
use nightshade::editor::{on_keyboard_input_handler, InputResult, InputSignal};

let result = on_keyboard_input_handler(
    &mut context,
    world,
    key_code,
    key_state,
);

if result.tree_dirty {
    // Scene tree needs rebuilding
}

if result.project_modified {
    // Project has unsaved changes
}

match result.signal {
    Some(InputSignal::QuitRequested) => {
        // Show quit confirmation dialog
    }
    Some(InputSignal::AddPrimitiveRequested(position)) => {
        // Open add-primitive popup at position
    }
    None => {}
}
}

Component Inspectors

The inspector system uses a trait-based approach. Each component type implements ComponentInspector, which receives an InspectorContext containing only the fields inspectors need:

#![allow(unused)]
fn main() {
use nightshade::editor::{ComponentInspector, InspectorContext};

pub trait ComponentInspector {
    fn name(&self) -> &str;
    fn has_component(&self, world: &World, entity: Entity) -> bool;
    fn add_component(&self, world: &mut World, entity: Entity);
    fn remove_component(&self, world: &mut World, entity: Entity);
    fn ui(
        &mut self,
        world: &mut World,
        entity: Entity,
        ui: &mut egui::Ui,
        context: &mut InspectorContext,
    );
}
}

InspectorContext provides narrowly-scoped access to the specific editor state that inspectors need:

#![allow(unused)]
fn main() {
pub struct InspectorContext<'a> {
    pub transform_edit_pending: &'a mut Option<(Entity, LocalTransform)>,
    pub undo_history: &'a mut UndoHistory,
    pub pending_notifications: &'a mut Vec<(ToastKind, String)>,
    pub actions: &'a mut Vec<InspectorAction>,
    pub selection: &'a EntitySelection,
}
}

InspectorAction is an enum for deferred inspector side effects:

#![allow(unused)]
fn main() {
pub enum InspectorAction {
    LookupMaterial(String),
}
}

Using ComponentInspectorUi

ComponentInspectorUi renders inspectors for all components on the selected entity:

#![allow(unused)]
fn main() {
use nightshade::editor::ComponentInspectorUi;

let mut inspector_ui = ComponentInspectorUi::default();

// Add custom inspectors
inspector_ui.add_inspector(Box::new(MyCustomInspector));

// In your right panel (returns true if project was modified):
let modified = inspector_ui.ui(
    &mut inspector_context,
    world,
    ui,
);
}

Built-in inspectors: transform, material, light, camera, mesh, text, water, animation, lines, render layer, and name.

Feature-gated inspectors:

  • NavMesh inspector requires the navmesh feature
  • Script inspector requires the scripting feature

Gizmo System

The gizmo system supports three interaction modes:

  • Direct manipulation: Click and drag gizmo handles in the viewport
  • Modal transform: Press G (grab/translate), R (rotate), or S (scale) then move the mouse, with optional axis constraints (X, Y, Z)
#![allow(unused)]
fn main() {
use nightshade::editor::{update_gizmo, recreate_gizmo_for_mode};

// Per-frame gizmo update
nightshade::editor::gizmo::update::update_gizmo(&mut context, &mut project_state, world);

// Change gizmo mode
context.gizmo_interaction.mode = nightshade::ecs::gizmos::GizmoMode::Scale;
recreate_gizmo_for_mode(&mut context, world);
}

Undo/Redo

The undo system captures entity state as snapshots and supports hierarchy-aware operations:

#![allow(unused)]
fn main() {
use nightshade::editor::{UndoHistory, UndoableOperation, capture_hierarchy};

// Capture state before a destructive operation
let hierarchy = Box::new(capture_hierarchy(world, entity));

// Push an undoable operation
context.undo_history.push(
    UndoableOperation::EntityCreated {
        hierarchy,
        current_entity: entity,
    },
    "Spawn cube".to_string(),
);

// Undo/redo
nightshade::editor::selection::undo_with_selection_update(&mut context, &mut project_state, world);
nightshade::editor::selection::redo_with_selection_update(&mut context, &mut project_state, world);
}

Scene Tree

The WorldTreeUi renders a hierarchical tree view of all entities in the scene:

#![allow(unused)]
fn main() {
use nightshade::editor::{WorldTreeUi, TreeCache};

let mut tree_cache = TreeCache::default();

let open_modal = WorldTreeUi::ui_with_context(
    world,
    &mut context.selection,
    &mut context.undo_history,
    &mut project_state,
    &mut tree_cache,
    gizmo_root,
    ui,
);
}

Picking

GPU-based entity picking with support for single click, marquee selection, and context menu:

#![allow(unused)]
fn main() {
use nightshade::editor::picking;

// Entity picking (click to select)
picking::update_picking(&mut context, world);

// Marquee selection (drag to box-select)
picking::update_marquee_selection(&mut context, world);

// Draw the marquee rectangle overlay
picking::draw_marquee_selection(&context.marquee, ui_context);

// Check for right-click context menu (returns mouse position if triggered)
if let Some(position) = picking::check_context_menu_trigger(&context, world) {
    // Open context menu at position
}
}

Add Node Modal

The add node modal takes individual parameters rather than the full EditorContext. Returns true if an entity was created:

#![allow(unused)]
fn main() {
use nightshade::editor::render_add_node_modal;

let created = render_add_node_modal(
    &mut selection,
    &mut undo_history,
    &mut add_node_open,
    &mut add_node_search,
    &mut tree_cache,
    world,
    ui_context,
);
}

Building a Custom Editor

To build a custom editor using the infrastructure:

  1. Enable the editor feature (and mosaic for the multi-pane layout)
  2. Create your own State implementation
  3. Instantiate EditorContext
  4. Own app-level state (project state, UI visibility, popup state, notifications) separately from EditorContext
  5. Use the provided widgets (tree, inspector, gizmo, picking) in your layout
  6. Handle InputSignal values from the keyboard handler in your app
  7. Define your own Widget enum and panel layout using the Mosaic framework

The official Nightshade Editor (apps/editor/) serves as a complete reference implementation.

Retained UI

Live Demos: Retained UI | Widget Gallery

Nightshade provides a retained UI system built on ECS entities. Each widget is an entity with layout, styling, and interaction components. UI state persists between frames — you build the tree once and react to events.

Building a UI

Use UiTreeBuilder to construct UI hierarchies:

#![allow(unused)]
fn main() {
fn initialize(&mut self, world: &mut World) {
    let camera = spawn_ortho_camera(world, Vec2::new(0.0, 0.0));
    world.resources.active_camera = Some(camera);

    let mut tree = UiTreeBuilder::new(world);
    tree.build_ui(tree.root_entity(), |ui| {
        ui.label("Hello, Nightshade!");
        ui.button("Click me");
        ui.row(|ui| {
            ui.label("Left");
            ui.button("Right");
        });
    });
    self.root = Some(tree.finish());
}
}

Reacting to Events

Check widget events each frame:

#![allow(unused)]
fn main() {
fn run_systems(&mut self, world: &mut World) {
    if world.ui_button_clicked(self.my_button) {
        self.counter += 1;
    }

    if world.ui_slider_changed(self.volume_slider) {
        let value = world.ui_slider_value(self.volume_slider);
        self.volume = value;
    }
}
}

Or iterate all events:

#![allow(unused)]
fn main() {
fn run_systems(&mut self, world: &mut World) {
    for event in world.ui_events().to_vec() {
        match event {
            UiEvent::ButtonClicked(entity) => { /* handle */ }
            UiEvent::SliderChanged { entity, value } => { /* handle */ }
            _ => {}
        }
    }
}
}

Layout Types

Flow Layout (Default)

Vertical or horizontal stacking:

#![allow(unused)]
fn main() {
tree.build_ui(root, |ui| {
    ui.column(|ui| {
        ui.label("Item 1");
        ui.label("Item 2");
        ui.label("Item 3");
    });

    ui.row(|ui| {
        ui.button("Cancel");
        ui.button("OK");
    });
});
}

Docked Panels

#![allow(unused)]
fn main() {
tree.build_ui(root, |ui| {
    ui.docked_panel_left("Tools", 250.0, |ui| {
        ui.label("Tool options");
    });

    ui.docked_panel_right("Inspector", 300.0, |ui| {
        ui.label("Properties");
    });

    ui.docked_panel_bottom("Console", 150.0, |ui| {
        ui.label("Output");
    });
});
}

Floating Panels

#![allow(unused)]
fn main() {
tree.build_ui(root, |ui| {
    ui.floating_panel(
        "Settings",
        Rect { x: 100.0, y: 100.0, width: 300.0, height: 400.0 },
        |ui| {
            ui.label("Window content");
        },
    );
});
}

Widgets

Labels and Text

#![allow(unused)]
fn main() {
ui.label("Simple text");
ui.label_colored("Error!", Vec4::new(1.0, 0.2, 0.2, 1.0));
ui.heading("Section Title");
ui.separator();
ui.spacing(10.0);
}

Buttons

#![allow(unused)]
fn main() {
let btn = ui.button("Start Game");
let colored = ui.button_colored("Delete", Vec4::new(0.8, 0.2, 0.2, 1.0));
}

Sliders and Drag Values

#![allow(unused)]
fn main() {
let slider = ui.slider(0.0, 100.0, 50.0);
let drag = ui.drag_value(1.0, 0.0, 10.0, 0.1, 2);
}

Toggles and Checkboxes

#![allow(unused)]
fn main() {
let toggle = ui.toggle(false);
let checkbox = ui.checkbox("Fullscreen", false);
}

Text Input

#![allow(unused)]
fn main() {
let input = ui.text_input("Enter name...");
}
#![allow(unused)]
fn main() {
let dropdown = ui.dropdown(vec!["Low".into(), "Medium".into(), "High".into()], 1);
let menu = ui.menu("File", vec!["New".into(), "Open".into(), "Save".into()]);
}

Color Picker

#![allow(unused)]
fn main() {
let picker = ui.color_picker(Vec4::new(1.0, 0.5, 0.0, 1.0));
}

Scroll Areas

#![allow(unused)]
fn main() {
ui.scroll_area(Vec2::new(300.0, 200.0), |ui| {
    for index in 0..50 {
        ui.label(&format!("Item {index}"));
    }
});
}

Collapsing Headers

#![allow(unused)]
fn main() {
ui.collapsing_header("Advanced Settings", true, |ui| {
    ui.checkbox("Debug mode", false);
    ui.slider(0.0, 1.0, 0.5);
});
}

Tab Bars

#![allow(unused)]
fn main() {
let tabs = ui.tab_bar(vec!["General".into(), "Audio".into(), "Video".into()], 0);
}

Tree Views

#![allow(unused)]
fn main() {
ui.tree_view(false, |ui| {
    ui.tree_node(tree_entity, root_entity, "Root", 0, None);
});
}

Bidirectional Binding

Bind widget values directly to variables:

#![allow(unused)]
fn main() {
fn run_systems(&mut self, world: &mut World) {
    world.ui_bind_slider(self.volume_slider, &mut self.volume);
    world.ui_bind_toggle(self.fullscreen_toggle, &mut self.fullscreen);
    world.ui_bind_text_input(self.name_input, &mut self.player_name);
    world.ui_bind_dropdown(self.quality_dropdown, &mut self.quality_index);
}
}

Reactive Properties

Track changes efficiently with UiProperty:

#![allow(unused)]
fn main() {
struct MyEditor {
    rotation: UiProperty<f32>,
    slider: Entity,
}

fn run_systems(&mut self, world: &mut World) {
    world.ui_bind_reactive_slider(self.slider, &mut self.rotation);

    if self.rotation.take_dirty() {
        apply_rotation(world, *self.rotation);
    }
}
}

Composite Widgets

Create reusable widget groups:

#![allow(unused)]
fn main() {
struct Vec3Editor {
    x_drag: Entity,
    y_drag: Entity,
    z_drag: Entity,
}

impl CompositeWidget for Vec3Editor {
    type Value = Vec3;

    fn build(tree: &mut UiTreeBuilder) -> Self {
        let x = tree.add_drag_value(0.0, -1000.0, 1000.0, 0.1, 2);
        let y = tree.add_drag_value(0.0, -1000.0, 1000.0, 0.1, 2);
        let z = tree.add_drag_value(0.0, -1000.0, 1000.0, 0.1, 2);
        Self { x_drag: x, y_drag: y, z_drag: z }
    }

    fn value(&self, world: &World) -> Vec3 {
        Vec3::new(
            world.ui_drag_value(self.x_drag),
            world.ui_drag_value(self.y_drag),
            world.ui_drag_value(self.z_drag),
        )
    }
}
}

Flexible Units

Position and size values support multiple unit types that can be combined:

UnitDescription
Ab(value)Absolute pixels
Rl(value)Relative to parent (0–100%)
Rw(value)Relative to parent width
Rh(value)Relative to parent height
Em(value)Font-size multiples
Vp(value)Viewport percentage
Vw(value)Viewport width percentage
Vh(value)Viewport height percentage

Units combine with +:

#![allow(unused)]
fn main() {
Rl(Vec2::new(100.0, 0.0)) + Ab(Vec2::new(0.0, 32.0))
}

Toast Notifications

#![allow(unused)]
fn main() {
world.ui_show_toast("File saved", 3.0, ToastSeverity::Success);
world.ui_show_toast("Connection lost", 5.0, ToastSeverity::Error);
}
#![allow(unused)]
fn main() {
let dialog = ui.confirm_dialog("Delete?", "This cannot be undone.");

fn run_systems(&mut self, world: &mut World) {
    if let Some(confirmed) = world.ui_modal_result(self.dialog) {
        if confirmed {
            self.delete_item(world);
        }
    }
}
}

Theming

Widgets use theme colors that adapt to the active theme:

#![allow(unused)]
fn main() {
node.with_theme_color::<UiBase>(ThemeColor::Background)
    .with_theme_color::<UiHover>(ThemeColor::BackgroundHover)
    .with_theme_color::<UiPressed>(ThemeColor::BackgroundActive)
    .with_theme_border_color(ThemeColor::Border)
}

Use egui for complex debug tools and editor interfaces. Use the retained UI for in-game menus, HUD elements, and game UI that benefits from persistent state and ECS integration.

Screen-Space Text

Live Demo: HUD Text

Screen-space text rendering for UI, scores, and debug information.

Quick Start

#![allow(unused)]
fn main() {
fn initialize(&mut self, world: &mut World) {
    spawn_ui_text(world, "Score: 0", Vec2::new(20.0, 20.0));
}
}

Text Properties

Customize text appearance with TextProperties:

#![allow(unused)]
fn main() {
let properties = TextProperties {
    font_size: 32.0,
    color: Vec4::new(1.0, 1.0, 1.0, 1.0),
    alignment: TextAlignment::Center,
    vertical_alignment: VerticalAlignment::Top,
    line_height: 1.5,
    letter_spacing: 0.0,
    outline_width: 2.0,
    outline_color: Vec4::new(0.0, 0.0, 0.0, 1.0),
    smoothing: 0.003,
    monospace_width: None,
    anchor_character: None,
};

spawn_ui_text_with_properties(
    world,
    "Custom Text",
    Vec2::new(20.0, 20.0),
    properties,
);
}

Text Alignment

#![allow(unused)]
fn main() {
TextAlignment::Left
TextAlignment::Center
TextAlignment::Right
}

Vertical Alignment

#![allow(unused)]
fn main() {
VerticalAlignment::Top
VerticalAlignment::Middle
VerticalAlignment::Bottom
VerticalAlignment::Baseline
}

Positioning

The position parameter is a Vec2 specifying screen-space coordinates:

#![allow(unused)]
fn main() {
spawn_ui_text(world, "Top-left area", Vec2::new(20.0, 20.0));

spawn_ui_text(world, "Centered", Vec2::new(400.0, 300.0));

spawn_ui_text(world, "Bottom area", Vec2::new(20.0, 550.0));
}

Updating Text at Runtime

Text content is stored in the TextCache. To update it, set the new string and mark the Text component dirty:

#![allow(unused)]
fn main() {
fn run_systems(&mut self, world: &mut World) {
    if let Some(entity) = self.score_text {
        if let Some(text) = world.core.get_text_mut(entity) {
            world.resources.text_cache.set_text(text.text_index, &format!("Score: {}", self.score));
            text.dirty = true;
        }
    }
}
}

Text Outlines

Add outlines for better visibility against varying backgrounds:

#![allow(unused)]
fn main() {
let properties = TextProperties {
    font_size: 24.0,
    color: Vec4::new(1.0, 1.0, 1.0, 1.0),
    outline_width: 2.0,
    outline_color: Vec4::new(0.0, 0.0, 0.0, 1.0),
    ..Default::default()
};
}

Multi-line Text

Newlines in the string produce multiple lines. Line height is controlled by TextProperties::line_height:

#![allow(unused)]
fn main() {
let properties = TextProperties {
    line_height: 1.5,
    ..Default::default()
};

spawn_ui_text_with_properties(world, "Line 1\nLine 2\nLine 3", Vec2::new(20.0, 20.0), properties);
}

Custom Fonts

Load a font from bytes and use the returned index on the Text component:

#![allow(unused)]
fn main() {
fn initialize(&mut self, world: &mut World) {
    let font_bytes = include_bytes!("assets/fonts/custom.ttf").to_vec();
    match load_font_from_bytes(world, font_bytes, 48.0) {
        Ok(font_index) => self.custom_font = Some(font_index),
        Err(_) => {}
    }
}

fn run_systems(&mut self, world: &mut World) {
    if let (Some(entity), Some(font_index)) = (self.text_entity, self.custom_font) {
        if let Some(text) = world.core.get_text_mut(entity) {
            text.font_index = font_index;
            text.dirty = true;
        }
    }
}
}

3D World-Space Text

For text positioned in the 3D scene, use the world-space spawn functions. These create entities with transform components so the text exists at a position in world coordinates.

#![allow(unused)]
fn main() {
let properties = TextProperties {
    font_size: 0.5,
    ..Default::default()
};

spawn_3d_text_with_properties(world, "Sign", Vec3::new(0.0, 2.0, 0.0), properties);
}

Billboard Text

Billboard text always faces the camera:

#![allow(unused)]
fn main() {
let properties = TextProperties {
    font_size: 0.5,
    color: Vec4::new(1.0, 1.0, 0.0, 1.0),
    ..Default::default()
};

spawn_3d_billboard_text_with_properties(world, "Player Name", Vec3::new(0.0, 3.0, 0.0), properties);
}

The Text Component

All text entities carry a Text component:

#![allow(unused)]
fn main() {
pub struct Text {
    pub text_index: usize,
    pub properties: TextProperties,
    pub font_index: usize,
    pub dirty: bool,
    pub cached_mesh: Option<TextMesh>,
    pub billboard: bool,
}
}

Access it with world.core.get_text_mut(entity).

Removing Text

#![allow(unused)]
fn main() {
despawn_recursive_immediate(world, entity);
}

Common Patterns

Score Display

#![allow(unused)]
fn main() {
struct GameState {
    score: u32,
    score_text: Option<Entity>,
}

impl State for GameState {
    fn initialize(&mut self, world: &mut World) {
        let properties = TextProperties {
            font_size: 24.0,
            outline_width: 1.0,
            outline_color: Vec4::new(0.0, 0.0, 0.0, 1.0),
            ..Default::default()
        };

        self.score_text = Some(spawn_ui_text_with_properties(
            world,
            "Score: 0",
            Vec2::new(20.0, 20.0),
            properties,
        ));
    }

    fn run_systems(&mut self, world: &mut World) {
        if let Some(entity) = self.score_text {
            if let Some(text) = world.core.get_text_mut(entity) {
                world.resources.text_cache.set_text(text.text_index, &format!("Score: {}", self.score));
                text.dirty = true;
            }
        }
    }
}
}

FPS Counter

#![allow(unused)]
fn main() {
struct FpsState {
    text_entity: Option<Entity>,
    frame_times: Vec<f32>,
}

impl State for FpsState {
    fn initialize(&mut self, world: &mut World) {
        let properties = TextProperties {
            font_size: 14.0,
            color: Vec4::new(0.0, 1.0, 0.0, 1.0),
            ..Default::default()
        };

        self.text_entity = Some(spawn_ui_text_with_properties(
            world,
            "FPS: --",
            Vec2::new(10.0, 10.0),
            properties,
        ));
    }

    fn run_systems(&mut self, world: &mut World) {
        let delta_time = world.resources.window.timing.delta_time;
        self.frame_times.push(delta_time);
        if self.frame_times.len() > 60 {
            self.frame_times.remove(0);
        }

        let average = self.frame_times.iter().sum::<f32>() / self.frame_times.len() as f32;
        let fps = (1.0 / average) as u32;

        if let Some(entity) = self.text_entity {
            if let Some(text) = world.core.get_text_mut(entity) {
                world.resources.text_cache.set_text(text.text_index, &format!("FPS: {}", fps));
                text.dirty = true;
            }
        }
    }
}
}

Game Over Message

#![allow(unused)]
fn main() {
fn show_game_over(world: &mut World) -> Entity {
    let properties = TextProperties {
        font_size: 64.0,
        color: Vec4::new(1.0, 0.0, 0.0, 1.0),
        alignment: TextAlignment::Center,
        outline_width: 3.0,
        outline_color: Vec4::new(0.0, 0.0, 0.0, 1.0),
        ..Default::default()
    };

    spawn_ui_text_with_properties(
        world,
        "GAME OVER\nPress R to Restart",
        Vec2::new(400.0, 300.0),
        properties,
    )
}
}

Terrain

Live Demo: Terrain

Nightshade supports procedural terrain generation with noise-based heightmaps, physics collision, and PBR materials.

How Terrain Works

Terrain in Nightshade is a regular mesh generated from a noise-based heightmap. The engine creates a grid of vertices at configurable resolution, samples a noise function at each vertex to determine its height, computes per-vertex normals from the surrounding triangle faces, and registers the result in the mesh cache. The terrain entity is then rendered through the standard mesh pipeline with full PBR material support, shadow casting, and physics collision.

Mesh Generation Pipeline

The generate_terrain_mesh function builds the terrain in four steps:

  1. Vertex generation - Creates a grid of resolution_x * resolution_z vertices. The grid is centered at the origin, with X coordinates ranging from -width/2 to +width/2 and Z from -depth/2 to +depth/2. Each vertex's Y coordinate is sampled from the noise function and multiplied by height_scale. UV coordinates are computed as the normalized grid position multiplied by uv_scale, controlling how textures tile across the surface.

  2. Index generation - Creates two triangles per grid cell with counter-clockwise winding order. For a cell at grid position (x, z), the two triangles use indices [top_left, bottom_left, top_right] and [top_right, bottom_left, bottom_right]. Total indices: (resolution_x - 1) * (resolution_z - 1) * 6.

  3. Normal calculation - Per-vertex normals are accumulated from the face normals of all adjacent triangles. Each face normal is computed from the cross product of two triangle edges. After accumulation, normals are normalized to unit length.

  4. Bounding volume - An OBB (Oriented Bounding Box) is computed from the min/max heights, used for frustum culling during rendering.

Noise Sampling

The terrain uses the noise crate with four algorithms:

NoiseTypeAlgorithmCharacter
PerlinFbmSmooth rolling hills
SimplexFbmSimilar to Perlin, fewer directional artifacts
BillowBillowRounded, cloud-like features
RidgedMultiRidgedMultiSharp ridges, good for mountains

All types are wrapped in multi-octave fractional Brownian motion (fBm), which layers multiple noise samples at increasing frequency and decreasing amplitude. The octaves parameter controls how many layers are added: more octaves add finer detail but cost more to evaluate. Lacunarity is the frequency multiplier per octave (default 2.0), and persistence is the amplitude multiplier per octave (default 0.5).

Physics Integration

spawn_terrain automatically creates a static rigid body with a heightfield collider. The heightfield shape stores a 2D grid of height values with a scale factor. The height data is transposed from mesh ordering (z * resolution_x + x) to heightfield ordering (z + resolution_z * x) because Rapier expects column-major layout. The collider has high friction (0.9), no restitution, and all collision groups enabled.

Enabling Terrain

Terrain requires the terrain feature:

[dependencies]
nightshade = { git = "...", features = ["engine", "terrain"] }

Basic Terrain

#![allow(unused)]
fn main() {
use nightshade::ecs::terrain::*;

fn initialize(&mut self, world: &mut World) {
    let config = TerrainConfig::new(100.0, 100.0, 64, 64)
        .with_height_scale(10.0)
        .with_frequency(0.02);

    spawn_terrain(world, &config, Vec3::zeros());
}
}

Terrain Configuration

#![allow(unused)]
fn main() {
pub struct TerrainConfig {
    pub width: f32,           // Terrain width in world units
    pub depth: f32,           // Terrain depth in world units
    pub resolution_x: u32,   // Vertex count along X
    pub resolution_z: u32,   // Vertex count along Z
    pub height_scale: f32,   // Height multiplier for noise values
    pub noise: NoiseConfig,  // Noise generation settings
    pub uv_scale: [f32; 2],  // Texture tiling [u, v]
}
}

Builder Methods

#![allow(unused)]
fn main() {
let config = TerrainConfig::new(200.0, 200.0, 128, 128)
    .with_height_scale(25.0)
    .with_noise(NoiseConfig {
        noise_type: NoiseType::RidgedMulti,
        frequency: 0.01,
        octaves: 6,
        lacunarity: 2.0,
        persistence: 0.5,
        seed: 42,
    })
    .with_uv_scale([8.0, 8.0]);
}

Terrain with Material

#![allow(unused)]
fn main() {
let material = Material {
    base_color: [0.3, 0.5, 0.2, 1.0],
    roughness: 0.85,
    metallic: 0.0,
    ..Default::default()
};

spawn_terrain_with_material(world, &config, Vec3::zeros(), material);
}

Sampling Terrain Height

Query the terrain height at any world position without mesh lookup. This samples the noise function directly:

#![allow(unused)]
fn main() {
let height = sample_terrain_height(x, z, &config);
}

Use this to place objects on the terrain surface:

#![allow(unused)]
fn main() {
fn place_on_terrain(world: &mut World, entity: Entity, x: f32, z: f32, config: &TerrainConfig) {
    let y = sample_terrain_height(x, z, config);

    if let Some(transform) = world.core.get_local_transform_mut(entity) {
        transform.translation = Vec3::new(x, y, z);
    }
}
}

Rendering

Terrain is rendered through the standard mesh pipeline (MeshPass). It uses the same PBR shader as all other meshes, which means terrain automatically gets:

  • Directional and point light shadows (cascaded shadow maps)
  • Screen-space ambient occlusion (SSAO)
  • Screen-space global illumination (SSGI)
  • Image-based lighting (IBL)
  • Normal mapping if a normal texture is provided in the material
  • Full Cook-Torrance BRDF with metallic-roughness workflow

The mesh is uploaded to GPU vertex and index buffers once at creation time. During rendering, the MeshPass performs frustum culling using the terrain's bounding volume, then draws it with the assigned material's textures bound.

Entity Components

spawn_terrain creates an entity with these components:

ComponentPurpose
NAME"Terrain"
LOCAL_TRANSFORMPosition in world
GLOBAL_TRANSFORMComputed world matrix
LOCAL_TRANSFORM_DIRTYTriggers transform update
RENDER_MESHReferences cached mesh
MATERIAL_REFPBR material
BOUNDING_VOLUMEOBB for frustum culling
CASTS_SHADOWEnabled by default
RIGID_BODYStatic physics body
COLLIDERHeightField shape
VISIBILITYVisible by default

Particle Systems

Live Demo: Fireworks

GPU-accelerated particle systems for fire, smoke, fireworks, sparks, and weather effects.

How Particles Work

Nightshade's particle system is entirely GPU-driven. Up to 1,000,000 particles are simulated and rendered without CPU involvement per frame. The system uses three compute shaders (reset, update, spawn) followed by a render pass, all operating on GPU storage buffers.

GPU Simulation Pipeline

Each frame executes four stages:

  1. Reset (1 workgroup) - Clears the alive count and draw command instance count to zero.

  2. Update (max_particles / 256 workgroups) - For each alive particle:

    • Increment age by delta_time
    • If age exceeds lifetime, mark as dead and push index to the free list
    • Apply gravity: velocity += gravity * delta_time
    • Apply drag: velocity *= (1 - drag * delta_time)
    • Apply turbulence: curl noise computed from simplex_noise_3d() with spatial derivatives creates a divergence-free vector field that swirls particles naturally
    • Integrate position: position += velocity * delta_time
    • Interpolate size and color between start/end values based on age / lifetime
    • Push to alive list and increment draw counter via atomics
  3. Spawn (one workgroup per emitter, 256 threads each) - Each thread:

    • Atomically decrements the free list to allocate a particle slot
    • Seeds an RNG using particle_index * 1973 + time * 10000 + spawn_index * 7919 + emitter_index * 6997
    • Generates a spawn offset based on emitter shape (point, sphere, cone, or box)
    • Applies velocity spread as a random cone angle around the emission direction
    • Samples the color gradient at t=0.15 and t=0.9 for lifetime interpolation endpoints
    • Writes initial position, velocity, color, lifetime, size range, gravity, drag, turbulence, and texture index
  4. Render - Camera-facing billboard quads using draw_indirect with the alive count. The vertex shader generates 6 vertices (2 triangles) per particle using camera right/up basis vectors extracted from the inverse view matrix. The fragment shader applies either procedural shapes or texture sampling.

Procedural Particle Shapes

The fragment shader generates several built-in shapes mathematically:

ShapeAlgorithm
Firework glowMultiple stacked exponential falloffs (coefficients 120, 40, 15, 6, 2.5)
FireVertically stretched (y *= 0.65) with core, flame, and outer glow layers
SmokeGaussian soft circle (coefficient 4.0)
SparkTight bright core with steep exponential falloff
StarCosine-based pointiness with adjustable sharpness

Blending Modes

Two render pipelines handle different particle types:

  • Alpha blending (SrcAlpha, OneMinusSrcAlpha) for standard particles like smoke
  • Additive blending (SrcAlpha, One) for emissive particles like fire and sparks, which accumulate brightness and interact with HDR bloom. The additive fragment shader boosts color: hdr_color + hdr_color^2 * 0.3

Both pipelines disable depth writes (particles are transparent) but enable depth testing with GreaterEqual (reversed-Z).

Memory Management

Particle slots are managed with a GPU-side free list. Dead particles push their index onto the free list via atomic operations. Spawning particles pop indices off the free list. This lock-free approach handles millions of spawn/death events per second entirely on the GPU.

Particle Emitter Component

#![allow(unused)]
fn main() {
pub struct ParticleEmitter {
    pub emitter_type: EmitterType,       // Firework, Fire, Smoke, Sparks, Trail
    pub shape: EmitterShape,             // Point, Sphere, Cone, Box
    pub position: Vec3,                  // Local offset from transform
    pub direction: Vec3,                 // Primary emission direction
    pub spawn_rate: f32,                 // Particles per second
    pub burst_count: u32,               // One-time spawn count
    pub particle_lifetime_min: f32,      // Minimum lifetime (seconds)
    pub particle_lifetime_max: f32,      // Maximum lifetime (seconds)
    pub initial_velocity_min: f32,       // Min velocity along direction
    pub initial_velocity_max: f32,       // Max velocity along direction
    pub velocity_spread: f32,            // Cone angle (radians)
    pub gravity: Vec3,                   // Acceleration vector
    pub drag: f32,                       // Velocity damping (0-1)
    pub size_start: f32,                 // Billboard size at birth
    pub size_end: f32,                   // Billboard size at death
    pub color_gradient: ColorGradient,   // Color over lifetime
    pub emissive_strength: f32,          // HDR multiplier for bloom
    pub turbulence_strength: f32,        // Curl noise strength
    pub turbulence_frequency: f32,       // Curl noise scale
    pub texture_index: u32,             // 0 = procedural, 1+ = texture array slot
    pub enabled: bool,
    pub one_shot: bool,                  // Burst once then disable
}
}

Emitter Shapes

#![allow(unused)]
fn main() {
EmitterShape::Point                             // Spawn from center
EmitterShape::Sphere { radius: 0.5 }           // Random within sphere
EmitterShape::Cone { angle: 0.5, height: 1.0 } // Cone spread
EmitterShape::Box { half_extents: Vec3::new(1.0, 0.1, 1.0) }
}

Color Gradients

Define how particles change color over their lifetime:

#![allow(unused)]
fn main() {
pub struct ColorGradient {
    pub colors: Vec<(f32, Vec4)>,  // (normalized_time, rgba_color)
}
}

Built-in gradients:

#![allow(unused)]
fn main() {
ColorGradient::fire()       // Yellow -> orange -> red -> black
ColorGradient::smoke()      // Gray with varying alpha
ColorGradient::sparks()     // Bright yellow -> orange -> red
}

Built-in Presets

The ParticleEmitter struct provides 30+ factory methods:

#![allow(unused)]
fn main() {
ParticleEmitter::fire(position)
ParticleEmitter::smoke(position)
ParticleEmitter::sparks(position)
ParticleEmitter::explosion(position)
ParticleEmitter::willow(position)
ParticleEmitter::chrysanthemum(position)
ParticleEmitter::palm_explosion(position, color)
ParticleEmitter::comet_shell(position)
ParticleEmitter::strobe_effect(position)
}

Creating Emitters

#![allow(unused)]
fn main() {
let entity = world.spawn_entities(
    PARTICLE_EMITTER | LOCAL_TRANSFORM | GLOBAL_TRANSFORM | LOCAL_TRANSFORM_DIRTY,
    1
)[0];

world.core.set_particle_emitter(entity, ParticleEmitter::fire(Vec3::zeros()));

world.core.set_local_transform(entity, LocalTransform {
    translation: Vec3::new(0.0, 1.0, 0.0),
    ..Default::default()
});
}

Custom Emitter

#![allow(unused)]
fn main() {
world.core.set_particle_emitter(entity, ParticleEmitter {
    emitter_type: EmitterType::Fire,
    shape: EmitterShape::Sphere { radius: 0.1 },
    direction: Vec3::y(),
    spawn_rate: 100.0,
    particle_lifetime_min: 0.3,
    particle_lifetime_max: 0.8,
    initial_velocity_min: 1.0,
    initial_velocity_max: 2.0,
    velocity_spread: 0.3,
    gravity: Vec3::new(0.0, -2.0, 0.0),
    drag: 0.1,
    size_start: 0.15,
    size_end: 0.02,
    color_gradient: ColorGradient::fire(),
    emissive_strength: 3.0,
    turbulence_strength: 0.5,
    turbulence_frequency: 1.0,
    enabled: true,
    ..Default::default()
});
}

Updating Emitters

The CPU-side update system must run each frame to accumulate spawn counts:

#![allow(unused)]
fn main() {
fn run_systems(&mut self, world: &mut World) {
    update_particle_emitters(world);
}
}

For continuous emitters, this adds spawn_rate * delta_time to an accumulator. For one-shot bursts, it sets the spawn count to burst_count once.

Controlling Emitters

#![allow(unused)]
fn main() {
if let Some(emitter) = world.core.get_particle_emitter_mut(entity) {
    emitter.enabled = false;
    emitter.emissive_strength = 5.0;
}
}

Custom Particle Textures

Upload textures to the particle texture array (64 slots, 512x512 each):

#![allow(unused)]
fn main() {
world.resources.pending_particle_textures.push(ParticleTextureUpload {
    slot: 1,
    rgba_data: image_bytes,
    width: 512,
    height: 512,
});
}

Set texture_index to the slot number (1+) to use a custom texture instead of procedural shapes.

Capacity

LimitValue
Maximum particles1,000,000
Maximum emitters512
Texture slots64
Texture slot size512 x 512
Compute workgroup size256

Navigation Mesh

Live Demo: NavMesh

AI pathfinding using Recast navigation mesh generation, A*/Dijkstra/Greedy pathfinding, funnel path smoothing, and agent movement with local avoidance.

How Navigation Meshes Work

A navigation mesh (navmesh) is a set of convex polygons (usually triangles) that represent the walkable surfaces of a level. Instead of testing every point in the world for walkability, AI agents pathfind through connected triangles, then smooth the resulting path through shared edges.

Nightshade uses the Recast algorithm (via the rerecast crate) to automatically generate a navmesh from world geometry. The pipeline has 13 steps:

  1. Build trimesh - Collect all mesh vertices and indices from the scene
  2. Mark walkable triangles - Classify each triangle by its slope angle against the walkable threshold
  3. Create heightfield - Rasterize the scene into a voxel grid with configurable cell size and height
  4. Rasterize triangles - Project each walkable triangle into the heightfield
  5. Filter obstacles - Remove low-hanging obstacles, ledge spans, and low-height spans that agents can't traverse
  6. Compact heightfield - Convert to a more efficient representation for region building
  7. Erode walkable area - Shrink walkable areas by the agent radius to prevent wall clipping
  8. Build distance field - Compute the distance from each cell to the nearest boundary
  9. Create regions - Group connected cells into regions using watershed partitioning. min_region_size (default 8) filters tiny regions, merge_region_size (default 20) combines small adjacent regions
  10. Build contours - Trace region boundaries into simplified contour polygons, controlled by max_simplification_error
  11. Convert to polygon mesh - Triangulate contours into convex polygons (up to max_vertices_per_polygon, default 6)
  12. Generate detail mesh - Add interior detail vertices for height accuracy, controlled by detail_sample_dist and detail_sample_max_error
  13. Convert to NavMeshWorld - Build the engine's navmesh data structure with adjacency and spatial hash

Pathfinding Algorithms

Three algorithms are available:

A* (default) - Explores nodes ordered by f = g + h where g is the cost so far and h is the heuristic (straight-line distance to goal). Finds the optimal path efficiently by prioritizing nodes closer to the destination.

Dijkstra - Explores nodes ordered only by g cost, ignoring direction to goal. Explores more nodes but guarantees the shortest path even with complex cost functions.

Greedy Best-First - Explores nodes ordered only by heuristic h, ignoring path cost. Very fast but may not find optimal paths, especially around concave obstacles.

All three operate on the triangle adjacency graph, where edges connect triangles that share an edge and costs are the distances between triangle centers.

Funnel Algorithm (Path Smoothing)

Raw paths through the navmesh are sequences of triangle centers, which zig-zag unnecessarily. The funnel algorithm produces smooth, natural-looking paths:

  1. Portal collection - Extract the shared edges (portals) between consecutive triangles in the path
  2. Funnel narrowing - Maintain a funnel (left and right boundaries) that starts wide at the first portal. As you advance through portals, narrow the funnel. When a portal would flip the funnel inside-out, emit the funnel apex as a waypoint and restart
  3. Simplification - Remove waypoints that don't improve the path within an epsilon tolerance (collinear point removal)

The result is the shortest path through the triangle corridor that doesn't cross any triangle boundaries.

Agent Movement System

Five systems run each frame via run_navmesh_systems:

  1. Triangle tracking - Finds which navmesh triangle each agent currently occupies using point-in-triangle tests with barycentric coordinates
  2. Path processing - Agents in PathPending state get their path computed, smoothed, and simplified. State transitions to Moving or NoPath
  3. Local avoidance - Repulsive forces between nearby agents (within agent_radius * 2.5) prevent crowding. Avoidance velocity is blended at 25% with the primary movement direction
  4. Movement - Advances agents along waypoints at their configured speed. Samples navmesh height at each new position using barycentric interpolation for vertical alignment. Maximum step height of 1.0 unit prevents teleporting through terrain
  5. Surface snapping - Idle and arrived agents are snapped to the navmesh surface when their Y position drifts more than 0.01 units

Enabling NavMesh

[dependencies]
nightshade = { git = "...", features = ["engine", "navmesh"] }

Generating a NavMesh

#![allow(unused)]
fn main() {
use nightshade::ecs::navmesh::*;

fn setup_navmesh(world: &mut World) {
    let config = RecastNavMeshConfig::default();
    generate_navmesh_recast(world, &config);
}
}
#![allow(unused)]
fn main() {
pub struct RecastNavMeshConfig {
    pub agent_radius: f32,              // Character collision radius (0.4)
    pub agent_height: f32,              // Character height (1.8)
    pub cell_size_fraction: f32,        // XZ voxel resolution divisor (3.0)
    pub cell_height_fraction: f32,      // Y voxel resolution divisor (6.0)
    pub walkable_climb: f32,            // Max step height (0.4)
    pub walkable_slope_angle: f32,      // Max walkable slope in degrees (45)
    pub min_region_size: i32,           // Min region area (8)
    pub merge_region_size: i32,         // Region merge threshold (20)
    pub max_simplification_error: f32,  // Contour simplification (1.3)
    pub max_vertices_per_polygon: i32,  // Polygon complexity (6)
    pub detail_sample_dist: f32,        // Detail mesh sampling (6.0)
    pub detail_sample_max_error: f32,   // Detail mesh error (1.0)
}
}

Agent State Machine

Idle → PathPending → Moving → Arrived
              ↓
           NoPath

Creating Agents

#![allow(unused)]
fn main() {
let agent = spawn_navmesh_agent(world, position, speed);
}

Setting Destinations

#![allow(unused)]
fn main() {
set_agent_destination(world, agent, target_position);
}

Running the Navigation System

#![allow(unused)]
fn main() {
fn run_systems(&mut self, world: &mut World) {
    run_navmesh_systems(world);
}
}

Checking Agent Status

#![allow(unused)]
fn main() {
match get_agent_state(world, agent) {
    NavMeshAgentState::Idle => { /* waiting */ }
    NavMeshAgentState::PathPending => { /* computing */ }
    NavMeshAgentState::Moving => { /* walking */ }
    NavMeshAgentState::Arrived => { /* at destination */ }
    NavMeshAgentState::NoPath => { /* unreachable */ }
}
}

Patrol Behavior

#![allow(unused)]
fn main() {
struct PatrolBehavior {
    waypoints: Vec<Vec3>,
    current_waypoint: usize,
}

fn update_patrol(world: &mut World, agent: Entity, patrol: &mut PatrolBehavior) {
    if matches!(get_agent_state(world, agent), NavMeshAgentState::Arrived) {
        patrol.current_waypoint = (patrol.current_waypoint + 1) % patrol.waypoints.len();
        set_agent_destination(world, agent, patrol.waypoints[patrol.current_waypoint]);
    }
}
}

Debug Visualization

#![allow(unused)]
fn main() {
set_navmesh_debug_draw(world, true);
}

The debug visualization draws:

  • Navmesh triangles as green wireframe (offset 0.15 units above surface)
  • Agent paths as yellow lines between waypoints
  • Current path segment as cyan from agent to next waypoint
  • Waypoints as orange crosses
  • Destination as a magenta diamond marker with a vertical pole

The generated navmesh is stored in world.resources.navmesh as a NavMeshWorld:

  • Vertices - All navmesh vertex positions
  • Triangles - Walkable triangles with center, normal, area, and edge indices
  • Edges - Triangle edges with indices of the two triangles they belong to (for portal detection)
  • Adjacency - HashMap<usize, Vec<NavMeshConnection>> mapping each triangle to its neighbors with traversal costs
  • Spatial Hash - Grid-based spatial index for fast point-in-triangle queries

Grass System

Live Demo: Grass

GPU-accelerated grass rendering with wind animation, character interaction, LOD, and subsurface scattering.

How Grass Rendering Works

The grass system renders up to 500,000 blades using a multi-stage compute + render pipeline. Each blade is a 7-vertex triangle strip generated in the vertex shader from per-instance data stored in GPU storage buffers.

Rendering Pipeline

Each frame executes five stages:

  1. Interaction update (compute, 16x16 workgroups) - Updates a 128x128 bend map texture from interactor positions. Each texel stores an XZ displacement vector. Interactors (player, NPCs) apply force based on proximity with smooth falloff. Velocity influences strength: strength * (1 + velocity_length * 0.3). Previous-frame bend values decay at a configurable rate, creating persistent trails. Double-buffered (ping-pong) to avoid read-write hazards.

  2. Bend sampling (compute, instances/256 workgroups) - Each grass instance samples the bend map at its world position to get an XZ displacement vector, stored in the instance buffer's bend field.

  3. Reset (compute, 1 workgroup) - Atomically resets the indirect draw command's instance count to zero.

  4. Culling (compute, instances/256 workgroups) - Each blade is tested against the camera frustum using its center position plus a radius. Blades outside the frustum are discarded. Distance-based LOD selects a density scale from 4 configurable thresholds. Statistical culling uses a hash of the instance ID: hash(id) > density_scale skips the blade. Surviving blades are appended to a visible index buffer via atomic operations, capped at 200,000.

  5. Render - Triangle strip rendering using indirect draw with the culled instance count. Each blade generates 7 vertices: 2 base (wide), 2 mid (narrowing), 2 upper (narrower), 1 tip point. The vertex shader applies width narrowing and curvature per segment, rotates around the Y-axis using the instance's random rotation, displaces by wind and interaction bend, and outputs a height factor for color interpolation.

Blade Geometry

Each blade is a curved triangle strip with width tapering from base to tip:

    *          (tip, 1 vertex)
   / \
  /   \        (upper, 2 vertices)
 /     \
|       |      (mid, 2 vertices)
|       |
|_______|      (base, 2 vertices)

Curvature is applied per-segment by offsetting vertices forward based on their height squared, creating a natural forward lean.

Wind Animation

Wind uses multi-layered sine waves in the vertex shader:

  • Base wave: sin(position.x * frequency + time * speed) at the configured strength
  • Gust layer: Higher frequency oscillation layered on top

Wind displacement scales with the square of the blade's height factor, so the base stays anchored while the tip sways. The wind_direction vector controls the primary direction on the XZ plane.

Interaction Bend

The bend map is a 128x128 RG32Float storage texture covering the grass region. When an interactor (entity with GrassInteractor component) moves through the grass:

  1. The compute shader samples each texel's distance to each interactor
  2. Within the interactor's radius, a smooth-step falloff function computes a bend direction away from the interactor
  3. The bend accumulates with the existing value (from previous frames)
  4. A decay rate gradually returns the bend to zero, creating a visible recovery trail

In the vertex shader, the bend displacement is applied with quadratic falloff based on height: the base barely moves while the tip receives full displacement.

Kajiya-Kay Specular

The fragment shader implements Kajiya-Kay anisotropic specular lighting, originally developed for hair rendering. Instead of computing a standard Phong or GGX specular highlight, it uses the blade's tangent direction to produce elongated highlights that run perpendicular to the blade direction, mimicking how light reflects off thin strands.

Subsurface Scattering

Light passing through grass blades creates a bright rim when the sun is behind the blade relative to the camera. The fragment shader computes a subsurface scattering contribution based on the dot product between the view direction and the negated sun direction, with edge fade for natural falloff. The sss_color and sss_intensity per-species parameters control the appearance.

Distance Fade

Blades beyond 180m begin alpha fading, reaching full transparency at 200m (smoothstep). Blade tips also have a separate fade (smoothstep 0.9-1.0 of the height factor) for soft tip transparency.

Enabling Grass

[dependencies]
nightshade = { git = "...", features = ["engine", "grass"] }

Basic Grass Region

#![allow(unused)]
fn main() {
use nightshade::ecs::grass::*;

fn initialize(&mut self, world: &mut World) {
    let config = GrassConfig::default();
    spawn_grass_region(world, config);
}
}

Grass Configuration

#![allow(unused)]
fn main() {
pub struct GrassConfig {
    pub blades_per_patch: u32,        // Density (default: 64)
    pub patch_size: f32,              // Patch size (default: 8.0)
    pub stream_radius: f32,           // Render distance (default: 200.0)
    pub unload_radius: f32,           // Unload distance (default: 220.0)
    pub wind_strength: f32,           // Wind intensity (default: 1.0)
    pub wind_frequency: f32,          // Wind speed (default: 1.0)
    pub wind_direction: [f32; 2],     // XZ direction (default: [1.0, 0.0])
    pub interaction_radius: f32,      // Player interaction radius (default: 1.0)
    pub interaction_strength: f32,    // Bending strength (default: 1.0)
    pub interactors_enabled: bool,    // Enable grass bending (default: true)
    pub cast_shadows: bool,           // Shadow casting (default: true)
    pub receive_shadows: bool,        // Shadow receiving (default: true)
    pub lod_distances: [f32; 4],      // LOD thresholds
    pub lod_density_scales: [f32; 4], // Density at each LOD
}
}

Grass Species

Define visual characteristics:

#![allow(unused)]
fn main() {
pub struct GrassSpecies {
    pub blade_width: f32,
    pub blade_height_min: f32,
    pub blade_height_max: f32,
    pub blade_curvature: f32,
    pub base_color: [f32; 4],
    pub tip_color: [f32; 4],
    pub sss_color: [f32; 4],        // Subsurface scattering color
    pub sss_intensity: f32,
    pub specular_power: f32,         // Kajiya-Kay exponent
    pub specular_strength: f32,
    pub density_scale: f32,
}
}

Preset Species

#![allow(unused)]
fn main() {
GrassSpecies::meadow()   // Short, dense lawn
GrassSpecies::tall()     // Tall field grass
GrassSpecies::short()    // Very short grass
GrassSpecies::flowers()  // Colorful flowers mixed with grass
}

Multi-Species Grass

#![allow(unused)]
fn main() {
let entity = spawn_grass_region(world, config);

add_grass_species(world, entity, GrassSpecies::meadow(), 0.6);
add_grass_species(world, entity, GrassSpecies::flowers(), 0.4);
}

Wind Control

#![allow(unused)]
fn main() {
set_grass_wind(world, entity, 1.5, 2.0);
set_grass_wind_direction(world, entity, 1.0, 0.5);
}

Grass Interaction

#![allow(unused)]
fn main() {
attach_grass_interactor(world, player_entity, 1.0, 1.0);
}

Or spawn a standalone interactor:

#![allow(unused)]
fn main() {
let interactor = spawn_grass_interactor(world, 1.0, 1.0);
}

LOD System

Statistical density culling reduces blade count at distance:

LOD LevelDefault DistanceDefault Density
00-20m100%
120-50m60%
250-100m30%
3100-200m10%

Transitions are smooth because culling uses a per-instance hash compared against the density threshold. No popping artifacts.

Shadow Casting

Grass uses a separate shadow depth shader (grass_shadow_depth.wgsl) that generates simplified blades (fixed curvature 0.3) with the same wind animation, projected into light space. This shader writes only depth, no color.

Capacity

LimitValue
Maximum blades500,000
Maximum visible per frame200,000
Vertices per blade7 (triangle strip)
Maximum species8
Maximum interactors16
Heightmap resolution256 x 256
Bend map resolution128 x 128

Lines Rendering

Live Demo: Lines

Debug line drawing for visualization, gizmos, and wireframes.

How Lines Rendering Works

The lines system is a GPU-driven rendering pipeline that uses instanced rendering with compute-based frustum culling. Rather than submitting geometry for each line, the engine uploads all line data to a GPU storage buffer and renders them using a two-vertex line primitive with one instance per line.

The Two-Vertex Trick

The vertex buffer contains only two vertices: position [0, 0, 0] and position [1, 0, 0]. Every line in the scene reuses these same two vertices through instancing. The vertex shader uses the instance index to look up the actual line data (start, end, color) from a storage buffer, then uses the vertex position's X coordinate (0.0 or 1.0) to interpolate between start and end:

let line = lines[in.instance_index];
let pos = mix(line.start.xyz, line.end.xyz, in.position.x);
out.clip_position = uniforms.view_proj * vec4<f32>(pos, 1.0);
out.color = line.color;

This means rendering 100,000 lines requires only 2 vertices in GPU memory regardless of line count. All line data lives in a storage buffer that grows dynamically (starting at 1,024 lines, doubling as needed, up to 1,000,000).

GPU Frustum Culling

A compute shader (line_culling_gpu.wgsl) runs before the render pass to determine which lines are visible. For each line, it tests both endpoints against the camera frustum planes. If either endpoint is inside the frustum, the line is visible. If neither is, the shader samples 8 intermediate points along the line segment to catch lines that span across the view without either endpoint being visible.

Visible lines generate DrawIndexedIndirectCommand structs via atomic append:

let command_index = atomicAdd(&draw_count, 1u);
draw_commands[command_index].index_count = 2u;
draw_commands[command_index].instance_count = 1u;
draw_commands[command_index].first_instance = line_index;

The render pass then executes these commands with multi_draw_indexed_indirect_count (or multi_draw_indexed_indirect on macOS/WASM/OpenXR where count buffers are unavailable).

Bounding Volume Lines

When show_bounding_volumes is enabled, a separate compute shader (bounding_volume_lines.wgsl) generates wireframe lines from entity OBBs (Oriented Bounding Boxes). Each bounding volume produces exactly 12 edge lines. The shader:

  1. Computes the 8 OBB corners using quaternion rotation in local space
  2. Transforms all corners to world space via the entity's model matrix
  3. Writes 12 edge lines (the box wireframe) into the line buffer at a pre-allocated offset

Normal Visualization Lines

Another compute shader (normal_lines.wgsl) generates lines showing mesh surface normals. For each vertex, it:

  1. Transforms the vertex position to world space using the model matrix
  2. Transforms the normal using the upper-left 3x3 of the model matrix (the normal matrix)
  3. Computes the endpoint by extending along the normal by the configured length
  4. Writes a single line from the vertex position to the endpoint

GPU Data Layout

Each line on the GPU is a 64-byte structure aligned to 16 bytes:

#![allow(unused)]
fn main() {
struct GpuLineData {
    start: [f32; 4],      // World-space start + padding
    end: [f32; 4],        // World-space end + padding
    color: [f32; 4],      // RGBA color
    entity_id: u32,        // Source entity ID
    visible: u32,          // Visibility flag
    _padding: [u32; 2],
}
}

Data Synchronization

Each frame, sync_lines_data queries all entities with LINES | GLOBAL_TRANSFORM | VISIBILITY components, transforms each line's start and end positions to world space using the entity's global transform matrix, packs them into GpuLineData structs, and uploads them to the GPU via queue.write_buffer. The total buffer includes user lines, bounding volume lines, and normal visualization lines.

Lines Component

#![allow(unused)]
fn main() {
pub struct Lines {
    pub lines: Vec<Line>,
    pub version: u64,
}

pub struct Line {
    pub start: Vec3,
    pub end: Vec3,
    pub color: Vec4,
}
}

The version field is a dirty counter. Calling push(), clear(), or mark_dirty() increments it, enabling the renderer to detect changes and skip re-uploading unchanged line data.

Basic Line Drawing

#![allow(unused)]
fn main() {
fn initialize(&mut self, world: &mut World) {
    let entity = world.spawn_entities(LINES, 1)[0];

    let mut lines = Lines::new();
    lines.add(
        Vec3::new(0.0, 0.0, 0.0),
        Vec3::new(1.0, 1.0, 1.0),
        Vec4::new(1.0, 0.0, 0.0, 1.0),
    );

    world.core.set_lines(entity, lines);
}
}

Adding Lines

#![allow(unused)]
fn main() {
let mut lines = Lines::new();

// Single line
lines.add(start, end, color);

// Coordinate axes
lines.add(Vec3::zeros(), Vec3::x(), Vec4::new(1.0, 0.0, 0.0, 1.0));
lines.add(Vec3::zeros(), Vec3::y(), Vec4::new(0.0, 1.0, 0.0, 1.0));
lines.add(Vec3::zeros(), Vec3::z(), Vec4::new(0.0, 0.0, 1.0, 1.0));
}

Drawing Shapes

Wireframe Box

#![allow(unused)]
fn main() {
fn draw_box(lines: &mut Lines, center: Vec3, half_extents: Vec3, color: Vec4) {
    let min = center - half_extents;
    let max = center + half_extents;

    // Bottom face
    lines.add(Vec3::new(min.x, min.y, min.z), Vec3::new(max.x, min.y, min.z), color);
    lines.add(Vec3::new(max.x, min.y, min.z), Vec3::new(max.x, min.y, max.z), color);
    lines.add(Vec3::new(max.x, min.y, max.z), Vec3::new(min.x, min.y, max.z), color);
    lines.add(Vec3::new(min.x, min.y, max.z), Vec3::new(min.x, min.y, min.z), color);

    // Top face
    lines.add(Vec3::new(min.x, max.y, min.z), Vec3::new(max.x, max.y, min.z), color);
    lines.add(Vec3::new(max.x, max.y, min.z), Vec3::new(max.x, max.y, max.z), color);
    lines.add(Vec3::new(max.x, max.y, max.z), Vec3::new(min.x, max.y, max.z), color);
    lines.add(Vec3::new(min.x, max.y, max.z), Vec3::new(min.x, max.y, min.z), color);

    // Vertical edges
    lines.add(Vec3::new(min.x, min.y, min.z), Vec3::new(min.x, max.y, min.z), color);
    lines.add(Vec3::new(max.x, min.y, min.z), Vec3::new(max.x, max.y, min.z), color);
    lines.add(Vec3::new(max.x, min.y, max.z), Vec3::new(max.x, max.y, max.z), color);
    lines.add(Vec3::new(min.x, min.y, max.z), Vec3::new(min.x, max.y, max.z), color);
}
}

Wireframe Sphere

#![allow(unused)]
fn main() {
fn draw_sphere(lines: &mut Lines, center: Vec3, radius: f32, color: Vec4, segments: u32) {
    let step = std::f32::consts::TAU / segments as f32;

    for index in 0..segments {
        let angle1 = index as f32 * step;
        let angle2 = (index + 1) as f32 * step;

        // XY circle
        let p1 = center + Vec3::new(angle1.cos() * radius, angle1.sin() * radius, 0.0);
        let p2 = center + Vec3::new(angle2.cos() * radius, angle2.sin() * radius, 0.0);
        lines.add(p1, p2, color);

        // XZ circle
        let p1 = center + Vec3::new(angle1.cos() * radius, 0.0, angle1.sin() * radius);
        let p2 = center + Vec3::new(angle2.cos() * radius, 0.0, angle2.sin() * radius);
        lines.add(p1, p2, color);

        // YZ circle
        let p1 = center + Vec3::new(0.0, angle1.cos() * radius, angle1.sin() * radius);
        let p2 = center + Vec3::new(0.0, angle2.cos() * radius, angle2.sin() * radius);
        lines.add(p1, p2, color);
    }
}
}

Updating Lines Each Frame

#![allow(unused)]
fn main() {
fn run_systems(&mut self, world: &mut World) {
    if let Some(lines) = world.core.get_lines_mut(self.debug_lines) {
        lines.clear();

        for entity in world.core.query_entities(RIGID_BODY | LOCAL_TRANSFORM) {
            if let (Some(body), Some(transform)) = (
                world.core.get_rigid_body(entity),
                world.core.get_local_transform(entity),
            ) {
                let start = transform.translation;
                let end = start + body.velocity;
                lines.add(start, end, Vec4::new(1.0, 1.0, 0.0, 1.0));
            }
        }
    }
}
}

Built-in Debug Visualization

Bounding Volumes

#![allow(unused)]
fn main() {
world.resources.graphics.show_bounding_volumes = true;
}

Selected Entity Bounds

#![allow(unused)]
fn main() {
world.resources.graphics.show_selected_bounding_volume = true;
world.resources.graphics.bounding_volume_selected_entity = Some(entity);
}

Surface Normals

#![allow(unused)]
fn main() {
world.resources.graphics.show_normals = true;
world.resources.graphics.normal_line_length = 0.2;
world.resources.graphics.normal_line_color = [0.0, 1.0, 0.0, 1.0];
}

GPU Culling

Lines are frustum-culled on the GPU via a compute shader. Toggle this with:

#![allow(unused)]
fn main() {
world.resources.graphics.gpu_culling_enabled = true;
}

When enabled, only lines visible to the camera are drawn. The compute shader outputs indirect draw commands, so the CPU never needs to know which lines survived culling.

Line Limits

#![allow(unused)]
fn main() {
const MAX_LINES: u32 = 1_000_000;
}

The buffer starts at 1,024 lines and grows by 2x when capacity is exceeded.

Transform Gizmos

Built-in gizmos for entity manipulation:

#![allow(unused)]
fn main() {
use nightshade::ecs::gizmos::*;

create_translation_gizmo(world, entity);
create_rotation_gizmo(world, entity);
create_scale_gizmo(world, entity);
}

Rendering Pipeline

The lines pass fits into the render graph as a geometry pass that writes to scene_color and depth. The execution order each frame is:

  1. Generate bounding volume lines (compute shader, 64 threads/workgroup, 12 lines per bounding volume)
  2. Generate normal visualization lines (compute shader, 256 threads/workgroup, 1 line per vertex)
  3. Frustum cull all lines (compute shader, 256 threads/workgroup, outputs indirect draw commands)
  4. Render visible lines (instanced LineList primitive with alpha blending and GreaterEqual depth test for reversed-Z)

The render pipeline uses wgpu::PrimitiveTopology::LineList, draws indices 0-1 per instance, and routes each instance to the correct line data via first_instance in the indirect draw command. The fragment shader is a simple color passthrough.

Picking System

Live Demo: Picking

Picking allows you to select entities in the 3D world using mouse clicks or screen positions. Nightshade provides two picking methods: fast bounding volume ray intersection and precise triangle mesh raycasting via Rapier physics colliders.

How Picking Works

Screen-to-Ray Conversion

PickingRay::from_screen_position converts a 2D screen coordinate into a 3D ray. It computes NDC coordinates from the screen position, builds the inverse view-projection matrix from the active camera, then unprojects through it:

  • Perspective cameras: The ray origin is the camera position. A clip-space point at z=1.0 (reversed-Z near plane) is unprojected to get the world direction.
  • Orthographic cameras: Both near (z=1.0) and far (z=0.0) clip-space points are unprojected. The ray origin is the near point; the direction is the vector from near to far.

Viewport rectangles are handled by converting screen coordinates to local viewport space and scaling by the viewport-to-window ratio.

#![allow(unused)]
fn main() {
pub struct PickingRay {
    pub origin: Vec3,
    pub direction: Vec3,
}

let screen_pos = world.resources.input.mouse.position;
if let Some(ray) = PickingRay::from_screen_position(world, screen_pos) {
    // ray.origin and ray.direction are in world space
}
}

Bounding Volume Picking (Fast)

The fast picking path tests the ray against every entity's bounding volume. For each entity with a BoundingVolume component:

  1. Transform the bounding volume by the entity's global transform
  2. Early reject using a bounding sphere test (project center onto ray, check distance)
  3. Test against the oriented bounding box (OBB) for a precise intersection distance
  4. Optionally skip invisible entities via the Visibility component

Results are sorted by distance (closest first).

#![allow(unused)]
fn main() {
if let Some(hit) = pick_closest_entity(world, screen_pos) {
    let entity = hit.entity;
    let distance = hit.distance;
    let position = hit.world_position;
}
}

Pick All Entities

Return all entities hit by the ray, sorted by distance:

#![allow(unused)]
fn main() {
let hits = pick_entities(world, screen_pos, PickingOptions::default());

for hit in &hits {
    let entity = hit.entity;
    let distance = hit.distance;
}
}

Picking Options

#![allow(unused)]
fn main() {
pub struct PickingOptions {
    pub max_distance: f32,       // Maximum ray distance (default: infinity)
    pub ignore_invisible: bool,  // Skip entities with Visibility { visible: false } (default: true)
}
}

Triangle Mesh Picking (Precise)

For pixel-precise picking, register entities for trimesh picking. This creates a Rapier physics collider from the entity's mesh geometry in a dedicated PickingWorld collision set.

Registering Entities

#![allow(unused)]
fn main() {
use nightshade::ecs::picking::commands::*;

register_entity_for_trimesh_picking(world, entity);
}

This extracts the mesh vertices and indices from the entity's RenderMesh, applies the global transform's scale, and creates a SharedShape::trimesh collider positioned at the entity's world transform. The collider is stored in the PickingWorld resource (a ColliderSet with entity-to-handle mappings).

For hierarchies (parent with child meshes):

#![allow(unused)]
fn main() {
register_entity_hierarchy_for_trimesh_picking(world, root_entity);
}

Trimesh Raycasting

#![allow(unused)]
fn main() {
if let Some(hit) = pick_closest_entity_trimesh(world, screen_pos) {
    let entity = hit.entity;
    let distance = hit.distance;
    let position = hit.world_position;
}
}

This casts a Rapier ray against all registered trimesh colliders using shape.cast_ray(), returning the time of impact for each intersection.

Updating Transforms

When a pickable entity moves, update its collider position:

#![allow(unused)]
fn main() {
update_picking_transform(world, entity);
}

Unregistering

#![allow(unused)]
fn main() {
unregister_entity_from_picking(world, entity);
}

Pick Result

#![allow(unused)]
fn main() {
pub struct PickingResult {
    pub entity: Entity,
    pub distance: f32,
    pub world_position: Vec3,
}
}

Utility Functions

Ground Plane Intersection

Get the world position where a screen ray hits a horizontal plane:

#![allow(unused)]
fn main() {
if let Some(ground_pos) = get_ground_position_from_screen(world, screen_pos, 0.0) {
    // ground_pos is on the Y=0 plane
}
}

Frustum Picking

Test which entities from a list are visible in the camera frustum:

#![allow(unused)]
fn main() {
let visible = pick_entities_in_frustum(world, &entity_list);
}

This projects each entity's bounding sphere center into clip space and tests against NDC bounds, accounting for the sphere radius in NDC space.

Plane Intersection

#![allow(unused)]
fn main() {
let ray = PickingRay::from_screen_position(world, screen_pos)?;

// Intersect with any plane (normal + distance from origin)
if let Some(point) = ray.intersect_plane(Vec3::y(), 0.0) {
    // point is on the plane
}

// Shorthand for horizontal ground plane
if let Some(point) = ray.intersect_ground_plane(0.0) {
    // point is on Y=0
}
}

Mouse Click Selection

#![allow(unused)]
fn main() {
fn on_mouse_input(&mut self, world: &mut World, state: ElementState, button: MouseButton) {
    if button == MouseButton::Left && state == ElementState::Pressed {
        let screen_pos = world.resources.input.mouse.position;

        if let Some(hit) = pick_closest_entity(world, screen_pos) {
            self.selected_entity = Some(hit.entity);
        } else {
            self.selected_entity = None;
        }
    }
}
}

SDF Sculpting

Live Demo: Voxels

Nightshade includes a voxel-based Signed Distance Field (SDF) system for real-time terrain sculpting and procedural geometry. The system stores distance values in a sparse brick map organized as a multi-level clipmap, with edits applied as CSG operations on SDF primitives and re-evaluated on the GPU each frame.

Enabling SDF

Add the sdf_sculpt feature:

nightshade = { git = "...", features = ["engine", "sdf_sculpt"] }

How the SDF System Works

Sparse Brick Map

The SDF volume is stored as a sparse grid of bricks. Each brick covers an 8-voxel cube (8x8x8 voxels) with 9x9x9 corner distance samples. The extra corners provide overlap for trilinear interpolation across brick boundaries.

Each BrickData stores:

  • distances: [f32; 729] (9^3 corner samples) — signed distance values at each corner
  • material_ids: [u32; 512] (8^3 voxel cells) — material index per voxel

A BrickPointerGrid maps 3D brick coordinates to atlas slots using a 128x128x128 virtual grid with toroidal wrapping. Unoccupied bricks store -1 (empty). The brick atlas is a 3D texture: 450x450x450 texels on native (360x360x360 on WASM), subdivided into 50x50x50 brick slots of 9 texels each, for a maximum of 125,000 bricks.

Clipmap LOD

The SDF uses a clipmap with 10 levels of detail (configurable via SdfWorld::with_config). Each level doubles the voxel size from the previous:

LevelVoxel SizeBrick CoverageGrid Extent
00.1251.0128.0
10.252.0256.0
20.54.0512.0
............
964.0512.065,536.0

The clipmap centers on the camera position. When the camera moves, bricks that scroll out of a level's grid are deallocated and returned to the free list. Newly scrolled-in bricks are marked dirty and re-evaluated. A BVH over all edit bounds accelerates this: only bricks whose expanded AABB intersects at least one edit's bounds are marked dirty.

Brick Allocation

A BrickAllocator manages atlas slots with a free list. When a dirty brick is evaluated and contains surface data (has both positive and negative distance values), it gets allocated a slot. Bricks with no surface are left empty. When bricks scroll out of the grid, their slots are returned to the free list for reuse.

GPU Dispatch

Each frame, SdfWorld::update() collects dirty bricks across all clipmap levels and creates GpuBrickDispatch records sorted by distance from the camera (closest bricks evaluated first). A per-frame budget (max_updates_per_frame, default 4000) limits how many bricks are re-evaluated.

SDF World

#![allow(unused)]
fn main() {
pub struct SdfWorld {
    pub edits: Vec<SdfEdit>,
    pub clipmap: SdfClipmap,
    pub bvh: SdfEditBvh,
    pub dirty: bool,
    pub pending_gpu_dispatches: Vec<GpuBrickDispatch>,
    pub max_updates_per_frame: usize,
    pub terrain: TerrainConfig,
    pub smoothness_scale: f32,
}
}

Create with default settings (10 levels, base voxel size 0.125):

#![allow(unused)]
fn main() {
let sdf_world = SdfWorld::new();
}

Or with custom LOD configuration:

#![allow(unused)]
fn main() {
let sdf_world = SdfWorld::with_config(8, 0.25);
}

SDF Primitives

Six primitive shapes are available, each with an analytic distance function evaluated in local space:

#![allow(unused)]
fn main() {
pub enum SdfPrimitive {
    Sphere { radius: f32 },
    Box { half_extents: Vec3 },
    Cylinder { radius: f32, half_height: f32 },
    Torus { major_radius: f32, minor_radius: f32 },
    Capsule { radius: f32, half_height: f32 },
    Plane { normal: Vec3, offset: f32 },
}
}

Sphere

#![allow(unused)]
fn main() {
SdfPrimitive::Sphere { radius: 1.0 }
}

Box

#![allow(unused)]
fn main() {
SdfPrimitive::Box {
    half_extents: Vec3::new(1.0, 2.0, 0.5)
}
}

Cylinder

#![allow(unused)]
fn main() {
SdfPrimitive::Cylinder {
    radius: 1.0,
    half_height: 1.5
}
}

Torus

#![allow(unused)]
fn main() {
SdfPrimitive::Torus {
    major_radius: 2.0,
    minor_radius: 0.5
}
}

Capsule

#![allow(unused)]
fn main() {
SdfPrimitive::Capsule {
    radius: 0.5,
    half_height: 1.0
}
}

Plane

An infinite half-space defined by a normal direction and offset:

#![allow(unused)]
fn main() {
SdfPrimitive::Plane {
    normal: Vec3::new(0.0, 1.0, 0.0),
    offset: 0.0,
}
}

CSG Operations

#![allow(unused)]
fn main() {
pub enum CsgOperation {
    Union,
    Subtraction,
    Intersection,
    SmoothUnion { smoothness: f32 },
    SmoothSubtraction { smoothness: f32 },
    SmoothIntersection { smoothness: f32 },
}
}

The hard operations use min/max:

OperationFormula
Unionmin(a, b)
Subtractionmax(a, -b)
Intersectionmax(a, b)

The smooth variants use polynomial smooth min (h = clamp(0.5 + 0.5*(b-a)/k, 0, 1)) for organic blending between shapes. The smoothness parameter controls the blend radius — larger values create a wider transition zone.

Material blending during smooth operations uses a dither threshold: when the blend factor exceeds 0.5, the first material is used; otherwise the second.

SDF Edits

An SdfEdit combines a primitive, a CSG operation, a 4x4 transform, and a material ID. The transform is stored alongside its precomputed inverse and uniform scale factor for efficient evaluation:

#![allow(unused)]
fn main() {
let edit = SdfEdit::union(
    SdfPrimitive::Sphere { radius: 2.0 },
    nalgebra_glm::translation(&Vec3::new(0.0, 5.0, 0.0)),
    material_id,
);

let edit = SdfEdit::smooth_subtraction(
    SdfPrimitive::Sphere { radius: 1.5 },
    nalgebra_glm::translation(&position),
    0,
    0.5,
);

let edit = SdfEdit::from_operation(
    SdfPrimitive::Box { half_extents: Vec3::new(1.0, 1.0, 1.0) },
    CsgOperation::SmoothUnion { smoothness: 0.3 },
    transform,
    material_id,
);
}

To evaluate an edit at a world-space point, the point is transformed into local space via the inverse matrix, the primitive's distance function is evaluated, and the result is scaled by the uniform scale factor.

Adding and Modifying Edits

Convenience Methods

#![allow(unused)]
fn main() {
world.resources.sdf_world.add_sphere(center, 2.0, material_id);
world.resources.sdf_world.add_box(center, half_extents, material_id);
world.resources.sdf_world.add_ground_plane(0.0, material_id);
world.resources.sdf_world.subtract_sphere(center, 1.5, 0.3);
}

Direct Edit API

#![allow(unused)]
fn main() {
let index = world.resources.sdf_world.add_edit(edit);
}

Each add_edit call pushes an undo action onto the undo stack and clears the redo stack. Use add_edit_no_undo to bypass undo tracking (useful for procedural generation).

Modifying Existing Edits

#![allow(unused)]
fn main() {
world.resources.sdf_world.modify_edit(index, |edit| {
    edit.set_transform(new_transform);
});
}

This marks both the old and new bounds as dirty. Use modify_edit_no_undo for interactive sculpting where intermediate states shouldn't be individually undoable.

Removing Edits

#![allow(unused)]
fn main() {
world.resources.sdf_world.remove_edit(index);
}

Undo/Redo

The SDF world maintains undo and redo stacks (default max 100 entries):

#![allow(unused)]
fn main() {
if world.resources.sdf_world.can_undo() {
    world.resources.sdf_world.undo();
}

if world.resources.sdf_world.can_redo() {
    world.resources.sdf_world.redo();
}

world.resources.sdf_world.clear_undo_history();
}

Three action types are tracked: AddEdit, RemoveEdit, and ModifyEdit. Each undo/redo operation re-marks the affected bounds as dirty and triggers re-evaluation.

SDF Materials

#![allow(unused)]
fn main() {
pub struct SdfMaterial {
    pub base_color: Vec3,
    pub roughness: f32,
    pub metallic: f32,
    pub emissive: Vec3,
}
}

Materials are managed through the SdfMaterialRegistry resource. A default material (gray, roughness 0.5, non-metallic) is always present at index 0:

#![allow(unused)]
fn main() {
let rock = world.resources.sdf_material_registry.add_material(
    SdfMaterial::new(Vec3::new(0.5, 0.45, 0.4))
        .with_roughness(0.9)
);

let gold = world.resources.sdf_material_registry.add_material(
    SdfMaterial::new(Vec3::new(1.0, 0.84, 0.0))
        .with_roughness(0.3)
        .with_metallic(1.0)
);

let lava = world.resources.sdf_material_registry.add_material(
    SdfMaterial::new(Vec3::new(0.8, 0.2, 0.0))
        .with_roughness(0.7)
        .with_emissive(Vec3::new(5.0, 1.0, 0.0))
);
}

SDF Raycast

The SDF world provides CPU-side sphere tracing (up to 512 steps) for picking and collision:

#![allow(unused)]
fn main() {
let origin = ray.origin;
let direction = ray.direction;
let max_distance = 100.0;

if let Some(hit_point) = world.resources.sdf_world.raycast(origin, direction, max_distance) {
    let normal = world.resources.sdf_world.evaluate_normal_at(hit_point);
}
}

The raycast marches along the ray, stepping by the evaluated distance at each point (with a minimum step of half the base voxel size to avoid getting stuck inside surfaces). A hit is detected when the absolute distance falls below 0.1 times the base voxel size.

Normal estimation uses central differences: the gradient of the distance field is computed by evaluating at six points offset by half the base voxel size along each axis.

Terrain Generation

The SDF system includes built-in fBm (fractal Brownian motion) terrain with derivative-based dampening and domain rotation between octaves:

#![allow(unused)]
fn main() {
pub struct TerrainConfig {
    pub enabled: bool,
    pub base_height: f32,
    pub material_id: u32,
    pub seed: u32,
    pub frequency: f32,
    pub amplitude: f32,
    pub octaves: u32,
    pub lacunarity: f32,
    pub gain: f32,
}
}
FieldDefaultDescription
enabledfalseWhether terrain is active
base_height0.0Vertical offset of the terrain surface
material_id0SDF material index for the terrain
seed0Hash seed for noise generation
frequency0.01Base noise frequency (lower = broader features)
amplitude30.0Maximum height variation
octaves11Number of noise layers
lacunarity2.0Frequency multiplier per octave
gain0.5Amplitude multiplier per octave

Enable terrain:

#![allow(unused)]
fn main() {
world.resources.sdf_world.core.set_terrain_config(TerrainConfig {
    enabled: true,
    base_height: -5.0,
    frequency: 0.02,
    amplitude: 20.0,
    octaves: 8,
    seed: 42,
    ..Default::default()
});
}

The terrain noise uses quintic smoothstep interpolation (6t^5 - 15t^4 + 10t^3) with analytic derivatives for gradient computation. Each octave applies a 2D rotation matrix ([1.6, -1.2; 1.2, 1.6]) to the sample coordinates before the next octave, which reduces directional artifacts. The derivative accumulator dampens amplitude in areas of high gradient, producing naturally eroded ridgelines.

Terrain is evaluated only in the first 4 clipmap levels (highest detail) to avoid excessive computation at coarse LODs.

Query terrain height at a world XZ position:

#![allow(unused)]
fn main() {
let height = world.resources.sdf_world.terrain.height_at(x, z);
}

Sculpting Tool Example

#![allow(unused)]
fn main() {
struct SculptTool {
    brush_size: f32,
    material_id: u32,
    operation: CsgOperation,
}

fn run_systems(&mut self, world: &mut World) {
    if world.resources.input.mouse.left_pressed {
        let screen_pos = world.resources.input.mouse.position;
        let ray = PickingRay::from_screen_position(world, screen_pos);

        if let Some(ray) = ray {
            if let Some(hit) = world.resources.sdf_world.raycast(
                ray.origin,
                ray.direction,
                100.0,
            ) {
                let normal = world.resources.sdf_world.evaluate_normal_at(hit);

                let sculpt_pos = match self.tool.operation {
                    CsgOperation::Union | CsgOperation::SmoothUnion { .. } => {
                        hit + normal * self.tool.brush_size * 0.5
                    }
                    CsgOperation::Subtraction | CsgOperation::SmoothSubtraction { .. } => {
                        hit - normal * self.tool.brush_size * 0.5
                    }
                    _ => hit,
                };

                let edit = SdfEdit::from_operation(
                    SdfPrimitive::Sphere { radius: self.tool.brush_size },
                    self.tool.operation,
                    nalgebra_glm::translation(&sculpt_pos),
                    self.tool.material_id,
                );
                world.resources.sdf_world.add_edit(edit);
            }
        }
    }
}
}

Evaluating the Distance Field

Query the combined SDF (terrain + all edits) at any world-space point:

#![allow(unused)]
fn main() {
let distance = world.resources.sdf_world.evaluate_at(point);

let distance_lod = world.resources.sdf_world.evaluate_at_lod(point, 6);
}

The evaluate_at_lod variant limits the terrain noise octaves for cheaper evaluation at coarse LODs.

Updating the SDF World

Call update() each frame with the camera position to recenter the clipmap, rebuild the BVH if needed, and generate GPU brick dispatches:

#![allow(unused)]
fn main() {
fn run_systems(&mut self, world: &mut World) {
    let camera_pos = get_active_camera_position(world);
    world.resources.sdf_world.update(camera_pos);
}
}

Querying SDF State

#![allow(unused)]
fn main() {
let edit_count = world.resources.sdf_world.edit_count();
let allocated_bricks = world.resources.sdf_world.allocated_brick_count();
let max_bricks = world.resources.sdf_world.max_brick_count();
let level_count = world.resources.sdf_world.level_count();
let voxel_sizes = world.resources.sdf_world.voxel_sizes();
let base_voxel_size = world.resources.sdf_world.base_voxel_size();
}

Lattice Deformation

Live Demo: Lattice

Lattice deformation (Free-Form Deformation / FFD) deforms meshes by manipulating a grid of control points surrounding the mesh. Vertices are displaced based on trilinear interpolation of the nearest control point displacements, producing smooth spatial warping effects like bending, twisting, tapering, and bulging.

Enabling Lattice

Add the lattice feature:

nightshade = { git = "...", features = ["engine", "lattice"] }

How Lattice Deformation Works

The Lattice component defines a 3D grid of control points within an axis-aligned bounding box. Each control point has a base position (computed from the grid dimensions and bounds) and a displacement vector. When a mesh vertex needs to be deformed:

  1. The vertex's world-space position is converted to UVW coordinates (0-1 range within the lattice bounds)
  2. The 8 surrounding control points are looked up from the grid indices
  3. The displacement is computed by trilinear interpolation of those 8 control point displacements
  4. Vertices outside the lattice bounds are either unaffected (falloff = 0) or smoothly blended based on the falloff distance

The deformation is applied as a morph target on the GPU. The system converts world-space displacements back to local space via the entity's inverse model matrix, creates a MorphTarget with per-vertex position offsets, and updates the mesh cache.

Lattice Component

#![allow(unused)]
fn main() {
pub struct Lattice {
    pub base_points: Vec<Vec3>,
    pub displacements: Vec<Vec3>,
    pub dimensions: [usize; 3],
    pub bounds_min: Vec3,
    pub bounds_max: Vec3,
    pub falloff: f32,
    pub version: u32,
}
}
FieldDescription
base_pointsUndeformed control point positions, computed from bounds and dimensions
displacementsPer-control-point displacement vectors (initially zero)
dimensionsGrid resolution as [x, y, z]
bounds_minLower corner of the lattice bounding box
bounds_maxUpper corner of the lattice bounding box
falloffDistance beyond lattice bounds where deformation fades to zero (0 = hard cutoff)
versionAuto-incremented when displacements change, used for dirty detection

Creating a Lattice

#![allow(unused)]
fn main() {
use nightshade::ecs::lattice::systems::create_lattice_entity;

let lattice_entity = create_lattice_entity(
    world,
    Vec3::new(-2.0, -2.0, -2.0),
    Vec3::new(2.0, 2.0, 2.0),
    [4, 4, 4],
);
}

This spawns an entity with the LATTICE component. The constructor computes base point positions by subdividing the bounding box evenly according to the dimensions. For a 4x4x4 lattice, this creates 64 control points.

With falloff for smooth blending beyond the bounds:

#![allow(unused)]
fn main() {
let lattice = Lattice::new(bounds_min, bounds_max, [4, 4, 4])
    .with_falloff(1.0);
world.core.set_lattice(lattice_entity, lattice);
}

Registering Influenced Meshes

#![allow(unused)]
fn main() {
use nightshade::ecs::lattice::systems::register_entity_for_lattice_deformation;

register_entity_for_lattice_deformation(world, mesh_entity, lattice_entity);
}

This adds LATTICE_INFLUENCED and MORPH_WEIGHTS components to the mesh entity. The LatticeInfluenced component stores the lattice entity reference, the last known lattice version, and the last entity position — used to skip re-evaluation when nothing has changed.

Manipulating Control Points

Displacements are set per-grid-coordinate:

#![allow(unused)]
fn main() {
if let Some(lattice) = world.core.get_lattice_mut(lattice_entity) {
    lattice.set_displacement(1, 2, 1, Vec3::new(0.0, 0.5, 0.0));
}
}

The set_displacement method automatically increments the version counter, which triggers re-evaluation on influenced meshes.

To read the current deformed position (base + displacement):

#![allow(unused)]
fn main() {
let point = lattice.get_point(1, 2, 1);
}

To get the displacement alone:

#![allow(unused)]
fn main() {
let displacement = lattice.get_displacement(1, 2, 1);
}

Reset all displacements to zero:

#![allow(unused)]
fn main() {
lattice.reset_displacements();
}

Indexing

Control point indices follow x-major ordering: index = z * (nx * ny) + y * nx + x. The get_index method computes this:

#![allow(unused)]
fn main() {
let index = lattice.get_index(x, y, z);
}

Deformation System

Call the lattice deformation system each frame to apply updated displacements:

#![allow(unused)]
fn main() {
fn run_systems(&mut self, world: &mut World) {
    lattice_deformation_system(world);
}
}

The system queries all entities with LATTICE_INFLUENCED | RENDER_MESH | MORPH_WEIGHTS | GLOBAL_TRANSFORM. For each entity, it checks whether the lattice version or entity position has changed since the last update. If so, it:

  1. Reads the mesh vertices from the mesh cache
  2. Transforms each vertex position to world space via the entity's global transform
  3. Calls lattice.sample(world_pos) to get the world-space displacement
  4. Converts the displacement back to local space via the inverse model matrix
  5. Creates a MorphTarget with the per-vertex local-space displacements
  6. Updates the mesh cache and marks it dirty for GPU re-upload

Trilinear Interpolation

The sample() method converts a world position to UVW coordinates within the lattice bounds, finds the 8 surrounding control points, and interpolates their displacements:

uvw = (world_pos - bounds_min) / (bounds_max - bounds_min)

fx = uvw.x * (nx - 1)    // fractional grid coordinate
x0 = floor(fx)           // lower grid index
tx = fx - x0             // interpolation weight

// Interpolate 8 corners along X, then Y, then Z
d00 = lerp(d000, d100, tx)
d10 = lerp(d010, d110, tx)
d01 = lerp(d001, d101, tx)
d11 = lerp(d011, d111, tx)
d0  = lerp(d00, d10, ty)
d1  = lerp(d01, d11, ty)
result = lerp(d0, d1, tz)

For points outside the lattice bounds, the UVW coordinates are clamped to [0, 1] and the displacement is attenuated by the falloff factor. The falloff is computed as max(0, 1 - normalized_distance / falloff) where normalized_distance is the distance from the point to the clamped position, scaled by the average lattice dimension.

Common Deformation Effects

Bend

#![allow(unused)]
fn main() {
fn bend_lattice(world: &mut World, lattice_entity: Entity, amount: f32) {
    if let Some(lattice) = world.core.get_lattice_mut(lattice_entity) {
        let [nx, ny, nz] = lattice.dimensions;

        for z in 0..nz {
            for y in 0..ny {
                for x in 0..nx {
                    let t = x as f32 / (nx - 1) as f32;
                    let bend = (t * std::f32::consts::PI).sin() * amount;
                    lattice.set_displacement(x, y, z, Vec3::new(0.0, bend, 0.0));
                }
            }
        }
    }
}
}

Twist

#![allow(unused)]
fn main() {
fn twist_lattice(world: &mut World, lattice_entity: Entity, angle: f32) {
    if let Some(lattice) = world.core.get_lattice_mut(lattice_entity) {
        let [nx, ny, nz] = lattice.dimensions;
        let bounds_min = lattice.bounds_min;
        let bounds_max = lattice.bounds_max;
        let height = bounds_max.y - bounds_min.y;

        for z in 0..nz {
            for y in 0..ny {
                for x in 0..nx {
                    let base = lattice.base_points[lattice.get_index(x, y, z)];
                    let t = (base.y - bounds_min.y) / height;
                    let twist_angle = t * angle;

                    let new_x = base.x * twist_angle.cos() - base.z * twist_angle.sin();
                    let new_z = base.x * twist_angle.sin() + base.z * twist_angle.cos();

                    lattice.set_displacement(
                        x, y, z,
                        Vec3::new(new_x - base.x, 0.0, new_z - base.z),
                    );
                }
            }
        }
    }
}
}

Taper

#![allow(unused)]
fn main() {
fn taper_lattice(world: &mut World, lattice_entity: Entity, top_scale: f32) {
    if let Some(lattice) = world.core.get_lattice_mut(lattice_entity) {
        let [nx, ny, nz] = lattice.dimensions;
        let bounds_min = lattice.bounds_min;
        let bounds_max = lattice.bounds_max;
        let center_x = (bounds_min.x + bounds_max.x) * 0.5;
        let center_z = (bounds_min.z + bounds_max.z) * 0.5;
        let height = bounds_max.y - bounds_min.y;

        for z in 0..nz {
            for y in 0..ny {
                for x in 0..nx {
                    let base = lattice.base_points[lattice.get_index(x, y, z)];
                    let t = (base.y - bounds_min.y) / height;
                    let scale = 1.0 + (top_scale - 1.0) * t;

                    let new_x = center_x + (base.x - center_x) * scale;
                    let new_z = center_z + (base.z - center_z) * scale;

                    lattice.set_displacement(
                        x, y, z,
                        Vec3::new(new_x - base.x, 0.0, new_z - base.z),
                    );
                }
            }
        }
    }
}
}

Animated Wave

#![allow(unused)]
fn main() {
fn wave_lattice(world: &mut World, lattice_entity: Entity, amplitude: f32, frequency: f32, time: f32) {
    if let Some(lattice) = world.core.get_lattice_mut(lattice_entity) {
        let [nx, ny, nz] = lattice.dimensions;

        for z in 0..nz {
            for y in 0..ny {
                for x in 0..nx {
                    let base = lattice.base_points[lattice.get_index(x, y, z)];
                    let wave = (base.x * frequency + time).sin() * amplitude;
                    lattice.set_displacement(x, y, z, Vec3::new(0.0, wave, 0.0));
                }
            }
        }
    }
}
}

Scripting

Live Demo: Block Breaker with Scripts

Nightshade supports runtime scripting using Rhai, an embedded scripting language for Rust. Scripts run each frame and communicate with the engine through scope variables — reading entity transforms, input state, and time, then writing back updated positions, rotations, and commands.

Enabling Scripting

Add the scripting feature:

nightshade = { git = "...", features = ["engine", "scripting"] }

Script Component

#![allow(unused)]
fn main() {
pub struct Script {
    pub source: ScriptSource,
    pub enabled: bool,
}

pub enum ScriptSource {
    File { path: String },
    Embedded { source: String },
}
}

Scripts can be loaded from a file path (with hot-reloading on native) or embedded as a string:

#![allow(unused)]
fn main() {
let script = Script::from_source(r#"
    pos_x += dt * 5.0;
"#);

let script = Script::from_file("scripts/enemy.rhai");
}

Attaching Scripts to Entities

#![allow(unused)]
fn main() {
let entity = world.spawn_entities(
    LOCAL_TRANSFORM | GLOBAL_TRANSFORM | SCRIPT,
    1
)[0];

world.core.set_script(entity, Script::from_source(r#"
    pos_y = (time * 2.0).sin() + 1.0;
"#));
}

Scripts are disabled by default. Enable them to start execution:

#![allow(unused)]
fn main() {
if let Some(script) = world.core.get_script_mut(entity) {
    script.enabled = true;
}
}

Scope Variables

Scripts communicate with the engine entirely through variables injected into the Rhai scope. The system reads these variables after script execution to apply changes.

Transform Variables

Read and write the entity's local transform:

VariableTypeDescription
pos_x, pos_y, pos_zf64Entity position
rot_x, rot_y, rot_zf64Entity rotation (Euler angles in radians)
scale_x, scale_y, scale_zf64Entity scale

Changes are only applied if the values actually differ from the current transform (compared with epsilon tolerance).

Time Variables

VariableTypeDescription
dt / delta_timef64Frame delta time in seconds
timef64Accumulated total time since scripts started

Input Variables

VariableTypeDescription
mouse_x, mouse_yf64Current mouse position
pressed_keysArrayCurrently held key names (e.g., ["W", "SPACE"])
just_pressed_keysArrayKeys pressed this frame (not held from previous)

Key names are uppercase strings: A-Z, 0-9, SPACE, ENTER, ESCAPE, SHIFT, CTRL, ALT, TAB, BACKSPACE, UP, DOWN, LEFT, RIGHT.

Entity Access

VariableTypeDescription
entity_idi64This entity's ID (constant)
entitiesMapNamed entities with their positions and scales
entity_namesArrayList of all named entity names

Access other entities by name:

let player = entities["Player"];
let player_x = player.x;
let player_y = player.y;
let player_z = player.z;

Game State

A shared state map persists across frames and is accessible to all scripts:

state["score"] = state["score"] + 1.0;
state["game_over"] = 1.0;

State values are f64. The state map is shared across all script entities.

Spawning and Despawning

Set these variables to spawn or despawn entities:

VariableTypeDescription
do_spawn_cubeboolSpawn a cube at (spawn_cube_x/y/z)
spawn_cube_x/y/zf64Spawn position for cube
do_spawn_sphereboolSpawn a sphere at (spawn_sphere_x/y/z)
spawn_sphere_x/y/zf64Spawn position for sphere
do_despawnboolDespawn this entity
despawn_namesArrayNames of other entities to despawn

Example Scripts

Moving Object

let speed = 5.0;
pos_x += speed * dt;

if pos_x > 10.0 {
    pos_x = -10.0;
}

Keyboard Control

let speed = 8.0;

if pressed_keys.contains("W") { pos_z -= speed * dt; }
if pressed_keys.contains("S") { pos_z += speed * dt; }
if pressed_keys.contains("A") { pos_x -= speed * dt; }
if pressed_keys.contains("D") { pos_x += speed * dt; }

if just_pressed_keys.contains("SPACE") {
    do_spawn_sphere = true;
    spawn_sphere_x = pos_x;
    spawn_sphere_y = pos_y + 1.0;
    spawn_sphere_z = pos_z;
}

Follow Player

let speed = 3.0;

if "Player" in entities {
    let player = entities["Player"];
    let dx = player.x - pos_x;
    let dz = player.z - pos_z;
    let dist = (dx * dx + dz * dz).sqrt();

    if dist > 1.0 {
        pos_x += (dx / dist) * speed * dt;
        pos_z += (dz / dist) * speed * dt;
    }
}

Rotating Object

let rotation_speed = 1.0;
rot_y += rotation_speed * dt;

Bobbing Animation

let amplitude = 0.5;
let frequency = 2.0;
pos_y = 1.0 + (time * frequency).sin() * amplitude;

Scorekeeping

if !("score" in state) {
    state["score"] = 0.0;
}

if just_pressed_keys.contains("E") {
    state["score"] = state["score"] + 10.0;
}

Despawning Named Entities

if just_pressed_keys.contains("X") {
    despawn_names.push("Enemy_1");
    despawn_names.push("Enemy_2");
}

Script Runtime

The ScriptRuntime manages compilation, caching, and execution:

#![allow(unused)]
fn main() {
pub struct ScriptRuntime {
    pub engine: rhai::Engine,
    pub game_state: HashMap<String, f64>,
}
}

The engine automatically runs scripts each frame via the FrameSchedule. The scripting system is registered as system_names::RUN_SCRIPTS and executes after physics but before animation. No manual call is needed — attaching a Script component to an entity and setting enabled = true is sufficient.

Script Compilation

Scripts are compiled to AST on first execution and cached by a hash of the source code. Recompilation only occurs when the source changes. For file-based scripts, modification times are tracked and the script is automatically recompiled when the file changes (hot-reloading on native only).

Custom Functions

Register additional Rhai functions:

#![allow(unused)]
fn main() {
runtime.engine.register_fn("custom_function", |x: i64, y: i64| {
    x + y
});
}

Game State

The runtime's game_state map is injected into every script's scope as the state variable. Values persist across frames:

#![allow(unused)]
fn main() {
runtime.set_state("difficulty".to_string(), 1.0);
let score = runtime.get_state("score");
runtime.reset_game_state();
}

Hot Reloading

On native platforms, file-based scripts are automatically hot-reloaded when modified. The runtime tracks file modification times and invalidates the compiled cache when changes are detected. This allows editing scripts in an external editor while the game is running.

Effects Pass

Live Demo: PSX Retro Effects

The EffectsPass is a configurable post-processing system with 38 shader parameters for visual effects. It includes distortions, color grading, raymarched overlays, retro effects, and more.

Overview

The EffectsPass operates as a render graph node that processes the rendered scene through a fullscreen shader. Effects can be combined and animated for music visualizers, stylized games, or creative applications.

Setup

Create and configure the effects state, then add the pass to your render graph:

#![allow(unused)]
fn main() {
use nightshade::render::wgpu::passes::postprocess::effects::*;

fn configure_render_graph(
    &mut self,
    graph: &mut RenderGraph<World>,
    device: &wgpu::Device,
    surface_format: wgpu::TextureFormat,
    resources: RenderResources,
) {
    // Create shared state handle
    let effects_state = create_effects_state();
    self.effects_state = Some(effects_state.clone());

    // Create and add the pass
    let effects_pass = EffectsPass::new(device, surface_format, effects_state);
    graph.add_pass(Box::new(effects_pass));
}
}

Modifying Effects

Access the state handle to modify effect parameters:

#![allow(unused)]
fn main() {
fn run_systems(&mut self, world: &mut World) {
    if let Some(state_handle) = &self.effects_state {
        if let Ok(mut state) = state_handle.write() {
            // Modify uniforms
            state.uniforms.chromatic_aberration = 0.02;
            state.uniforms.vignette = 0.3;

            // Enable/disable the entire pass
            state.enabled = true;

            // Auto-animate hue rotation
            state.animate_hue = false;
        }
    }
}
}

Effect Parameters

Distortion Effects

#![allow(unused)]
fn main() {
// Chromatic aberration: RGB channel separation (0.0-0.1 typical)
uniforms.chromatic_aberration = 0.02;

// Wave distortion: sinusoidal screen warping
uniforms.wave_distortion = 0.5;

// Glitch intensity: digital glitch artifacts
uniforms.glitch_intensity = 0.3;

// VHS distortion: analog tape wobble and noise
uniforms.vhs_distortion = 0.4;

// Heat distortion: rising heat shimmer effect
uniforms.heat_distortion = 0.2;

// Screen shake: camera shake offset
uniforms.screen_shake = 0.1;

// Warp speed: hyperspace stretch effect
uniforms.warp_speed = 0.5;
}

Color Effects

#![allow(unused)]
fn main() {
// Hue rotation: shift all colors around the wheel (0.0-1.0)
uniforms.hue_rotation = 0.5;

// Saturation: color intensity (0.0=grayscale, 1.0=normal, 2.0=oversaturated)
uniforms.saturation = 1.0;

// Color shift: global color offset
uniforms.color_shift = 0.1;

// Invert: color inversion (0.0=normal, 1.0=inverted)
uniforms.invert = 1.0;

// Color posterize: reduce color depth (0.0=off, higher=fewer colors)
uniforms.color_posterize = 4.0;

// Color cycle speed: rate of automatic color animation
uniforms.color_cycle_speed = 1.0;
}

Color Grading

Apply preset color grades:

#![allow(unused)]
fn main() {
uniforms.color_grade_mode = ColorGradeMode::Cyberpunk as f32;
}

Available modes:

ModeValueDescription
None0No color grading
Cyberpunk1Teal and magenta, high contrast
Sunset2Warm orange and purple tones
Grayscale3Black and white
Sepia4Vintage brown tones
Matrix5Green tinted, digital look
HotMetal6Heat map colors

Geometric Effects

#![allow(unused)]
fn main() {
// Kaleidoscope: mirror segments (0=off, 6-12 typical)
uniforms.kaleidoscope_segments = 6.0;

// Mirror mode: horizontal/vertical mirroring
uniforms.mirror_mode = 1.0;

// Zoom pulse: rhythmic zoom in/out
uniforms.zoom_pulse = 0.5;

// Radial blur: motion blur from center
uniforms.radial_blur = 0.2;

// Pixelate: reduce resolution (0=off, higher=larger pixels)
uniforms.pixelate = 8.0;
}

Raymarched Overlays

Blend raymarched 3D effects over the scene:

#![allow(unused)]
fn main() {
uniforms.raymarch_mode = RaymarchMode::Tunnel as f32;
uniforms.raymarch_blend = 0.5; // 0.0-1.0 blend with scene
uniforms.tunnel_speed = 1.0;   // Animation speed
uniforms.fractal_iterations = 4.0;
}

Available raymarch modes:

ModeValueDescription
Off0No raymarching
Tunnel1Infinite tunnel flythrough
Fractal22D fractal pattern
Mandelbulb33D mandelbulb fractal
PlasmaVortex4Swirling plasma effect
Geometric5Repeating geometric shapes

Retro Effects

#![allow(unused)]
fn main() {
// CRT scanlines: horizontal line overlay
uniforms.crt_scanlines = 0.5;

// Film grain: random noise overlay
uniforms.film_grain = 0.1;

// ASCII mode: convert to ASCII art characters
uniforms.ascii_mode = 1.0;

// Digital rain: Matrix-style falling characters
uniforms.digital_rain = 0.5;
}

Glow and Light Effects

#![allow(unused)]
fn main() {
// Vignette: darken screen edges (0.0-1.0)
uniforms.vignette = 0.3;

// Glow intensity: bloom-like glow
uniforms.glow_intensity = 0.5;

// Lens flare: bright light artifacts
uniforms.lens_flare = 0.3;

// Edge glow: outline bright edges
uniforms.edge_glow = 0.2;

// Strobe: flashing white overlay
uniforms.strobe = 0.5;
}

Plasma and Patterns

#![allow(unused)]
fn main() {
// Plasma intensity: colorful plasma overlay
uniforms.plasma_intensity = 0.5;

// Pulse rings: expanding circular rings
uniforms.pulse_rings = 0.3;

// Speed lines: motion/action lines
uniforms.speed_lines = 0.5;
}

Image Processing

#![allow(unused)]
fn main() {
// Sharpen: edge enhancement (0.0-1.0)
uniforms.sharpen = 0.5;

// Feedback amount: recursive frame blending
uniforms.feedback_amount = 0.3;
}

Combining Effects

Effects can be layered for complex looks:

#![allow(unused)]
fn main() {
// Cyberpunk aesthetic
state.uniforms.chromatic_aberration = 0.015;
state.uniforms.crt_scanlines = 0.2;
state.uniforms.vignette = 0.4;
state.uniforms.color_grade_mode = ColorGradeMode::Cyberpunk as f32;
state.uniforms.glow_intensity = 0.3;

// VHS tape look
state.uniforms.vhs_distortion = 0.4;
state.uniforms.crt_scanlines = 0.3;
state.uniforms.film_grain = 0.15;
state.uniforms.chromatic_aberration = 0.01;
state.uniforms.saturation = 0.8;

// Psychedelic visualizer
state.uniforms.kaleidoscope_segments = 8.0;
state.uniforms.plasma_intensity = 0.3;
state.uniforms.hue_rotation = time * 0.1;
state.uniforms.wave_distortion = 0.2;
}

Music-Reactive Effects

Combine with AudioAnalyzer for reactive visuals:

#![allow(unused)]
fn main() {
fn run_systems(&mut self, world: &mut World) {
    self.analyzer.analyze_at_time(self.time);

    if let Some(state_handle) = &self.effects_state {
        if let Ok(mut state) = state_handle.write() {
            // Chromatic aberration on bass
            state.uniforms.chromatic_aberration =
                self.analyzer.smoothed_bass * 0.05;

            // Glitch on snare hits
            state.uniforms.glitch_intensity =
                self.analyzer.snare_decay * 0.5;

            // Zoom pulse on kick
            state.uniforms.zoom_pulse =
                self.analyzer.kick_decay * 0.3;

            // Color cycling based on energy
            state.uniforms.hue_rotation =
                self.time * self.analyzer.intensity * 0.2;

            // Screen shake on drops
            if self.analyzer.is_dropping {
                state.uniforms.screen_shake =
                    self.analyzer.drop_intensity * 0.1;
            } else {
                state.uniforms.screen_shake *= 0.9;
            }

            // Switch to tunnel during breakdown
            if self.analyzer.is_breakdown {
                state.uniforms.raymarch_mode = RaymarchMode::Tunnel as f32;
                state.uniforms.raymarch_blend =
                    self.analyzer.breakdown_intensity * 0.5;
            } else {
                state.uniforms.raymarch_blend *= 0.95;
            }
        }
    }
}
}

Custom Input/Output Slots

By default, the EffectsPass reads from "input" and writes to "output". Configure custom slots:

#![allow(unused)]
fn main() {
let effects_pass = EffectsPass::with_slots(
    device,
    surface_format,
    effects_state,
    "post_bloom",    // Input slot name
    "final_output"   // Output slot name
);
}

Disabling the Pass

Temporarily bypass all effects:

#![allow(unused)]
fn main() {
if let Ok(mut state) = state_handle.write() {
    state.enabled = false; // Pass through without processing
}
}

When disabled, the pass performs a simple blit operation with no effects applied.

Auto-Animate Hue

Enable automatic hue rotation:

#![allow(unused)]
fn main() {
if let Ok(mut state) = state_handle.write() {
    state.animate_hue = true; // Continuously rotate hue based on time
}
}

Complete Example

use nightshade::prelude::*;
use nightshade::render::wgpu::passes::postprocess::effects::*;

struct VisualDemo {
    effects_state: Option<EffectsStateHandle>,
    analyzer: AudioAnalyzer,
    time: f32,
}

impl Default for VisualDemo {
    fn default() -> Self {
        Self {
            effects_state: None,
            analyzer: AudioAnalyzer::new(),
            time: 0.0,
        }
    }
}

impl State for VisualDemo {
    fn initialize(&mut self, world: &mut World) {
        let camera = spawn_camera(world, Vec3::new(0.0, 5.0, 10.0), "Camera".to_string());
        world.resources.active_camera = Some(camera);
        spawn_sun(world);

        spawn_cube_at(world, Vec3::new(0.0, 1.0, 0.0));
        spawn_sphere_at(world, Vec3::new(3.0, 1.0, 0.0));

        // Decode audio to raw samples (requires symphonia or similar crate)
        // self.analyzer.load_samples(samples, sample_rate);
    }

    fn run_systems(&mut self, world: &mut World) {
        let dt = world.resources.window.timing.delta_time;
        self.time += dt;

        // Analyze audio
        self.analyzer.analyze_at_time(self.time);

        // Update effects based on audio
        if let Some(state_handle) = &self.effects_state {
            if let Ok(mut state) = state_handle.write() {
                // Base effects
                state.uniforms.vignette = 0.3;
                state.uniforms.crt_scanlines = 0.15;

                // Audio-reactive
                state.uniforms.chromatic_aberration =
                    self.analyzer.smoothed_bass * 0.04;
                state.uniforms.glow_intensity =
                    self.analyzer.intensity * 0.5;
                state.uniforms.zoom_pulse =
                    self.analyzer.kick_decay * 0.2;

                // Structure-based
                if self.analyzer.is_dropping {
                    state.uniforms.color_grade_mode =
                        ColorGradeMode::Cyberpunk as f32;
                    state.uniforms.strobe =
                        self.analyzer.drop_intensity * 0.3;
                } else if self.analyzer.is_breakdown {
                    state.uniforms.color_grade_mode =
                        ColorGradeMode::Grayscale as f32;
                } else {
                    state.uniforms.color_grade_mode =
                        ColorGradeMode::None as f32;
                    state.uniforms.strobe = 0.0;
                }
            }
        }
    }

    fn configure_render_graph(
        &mut self,
        graph: &mut RenderGraph<World>,
        device: &wgpu::Device,
        surface_format: wgpu::TextureFormat,
        resources: RenderResources,
    ) {
        // Add standard passes first...

        // Create effects pass
        let effects_state = create_effects_state();
        self.effects_state = Some(effects_state.clone());

        let effects_pass = EffectsPass::new(device, surface_format, effects_state);
        graph.add_pass(Box::new(effects_pass));
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    nightshade::launch(VisualDemo::default())
}

Parameter Reference

ParameterRangeDefaultDescription
time0.0+0.0Elapsed time (auto-updated)
chromatic_aberration0.0-0.10.0RGB channel offset
wave_distortion0.0-1.00.0Sinusoidal screen warp
color_shift0.0-1.00.0Global color offset
kaleidoscope_segments0-160.0Mirror segment count
crt_scanlines0.0-1.00.0Scanline intensity
vignette0.0-1.00.0Edge darkening
plasma_intensity0.0-1.00.0Plasma overlay strength
glitch_intensity0.0-1.00.0Digital glitch amount
mirror_mode0.0-1.00.0Screen mirroring
invert0.0-1.00.0Color inversion
hue_rotation0.0-1.00.0Hue shift amount
raymarch_mode0-50.0Raymarch effect type
raymarch_blend0.0-1.00.0Raymarch overlay blend
film_grain0.0-1.00.0Noise grain intensity
sharpen0.0-1.00.0Edge sharpening
pixelate0-640.0Pixel size (0=off)
color_posterize0-160.0Color quantization
radial_blur0.0-1.00.0Center blur amount
tunnel_speed0.0-5.01.0Tunnel animation speed
fractal_iterations1-84.0Fractal detail level
glow_intensity0.0-1.00.0Bloom-like glow
screen_shake0.0-0.50.0Camera shake offset
zoom_pulse0.0-1.00.0Rhythmic zoom amount
speed_lines0.0-1.00.0Motion line intensity
color_grade_mode0-60.0Color grading preset
vhs_distortion0.0-1.00.0VHS tape wobble
lens_flare0.0-1.00.0Light flare intensity
edge_glow0.0-1.00.0Edge highlight amount
saturation0.0-2.01.0Color saturation
warp_speed0.0-1.00.0Hyperspace stretch
pulse_rings0.0-1.00.0Expanding ring effect
heat_distortion0.0-1.00.0Heat shimmer amount
digital_rain0.0-1.00.0Matrix rain effect
strobe0.0-1.00.0Flash intensity
color_cycle_speed0.0-5.01.0Auto color animation rate
feedback_amount0.0-1.00.0Frame feedback blend
ascii_mode0.0-1.00.0ASCII art conversion

OpenXR VR

Requires feature: openxr

Nightshade supports VR headsets through the OpenXR standard using the Vulkan graphics backend.

Feature Flag

[dependencies]
nightshade = { version = "...", features = ["openxr"] }

This pulls in openxr, ash, wgpu-hal, and gpu-allocator dependencies and forces the Vulkan backend.

Launching in VR

Use launch_xr() instead of the normal application entry point:

#![allow(unused)]
fn main() {
launch_xr(MyState::default());
}

This initializes the OpenXR runtime, creates a VR session, and begins the render loop with stereo rendering.

XR Resources

Access VR state through world.resources.xr:

#![allow(unused)]
fn main() {
let xr = &world.resources.xr;
}

The XrResources struct provides:

FieldDescription
locomotion_enabledEnable/disable thumbstick locomotion
locomotion_speedMovement speed multiplier

Controller Input

Read controller state through XrInput:

#![allow(unused)]
fn main() {
let input = &world.resources.xr.input;

if input.a_button_pressed() {
    // A button on right controller
}

if input.left_trigger_pressed() {
    // Left trigger
}

let left_pos = input.left_hand_position();
let left_rot = input.left_hand_rotation();
let head_pos = input.head_position();
}

Available Inputs

MethodDescription
left_trigger_pressed() / right_trigger_pressed()Trigger buttons
left_grip_pressed() / right_grip_pressed()Grip buttons
a_button_pressed() / b_button_pressed()Face buttons (right controller)
x_button_pressed() / y_button_pressed()Face buttons (left controller)
left_hand_position() / right_hand_position()Controller positions in world space
left_hand_rotation() / right_hand_rotation()Controller orientations
head_position() / head_rotation()Headset tracking
left_thumbstick() / right_thumbstick()Thumbstick axes

Stereo Rendering

The XrRenderer renders the scene twice per frame (once per eye) using the same render passes as the desktop renderer. View and projection matrices are provided by the OpenXR runtime through XrFrameContext.

Requirements

  • An OpenXR-compatible runtime must be installed (SteamVR, Oculus, etc.)
  • A VR headset must be connected
  • Vulkan GPU support is required
  • Desktop only (not available on WASM)

The engine configures Oculus Touch controller bindings by default.

Steam Integration

Requires feature: steam

Nightshade provides Steam platform integration including achievements, stats, friends, P2P networking, and rich presence.

Feature Flag

[dependencies]
nightshade = { version = "...", features = ["steam"] }

This pulls in the steamworks and steamworks-sys dependencies. Desktop only.

Initialization

Access Steam through world.resources.steam:

#![allow(unused)]
fn main() {
fn initialize(&mut self, world: &mut World) {
    world.resources.steam.initialize();
}
}

Call run_callbacks() each frame to process Steam events:

#![allow(unused)]
fn main() {
fn run_systems(&mut self, world: &mut World) {
    world.resources.steam.run_callbacks();
}
}

Achievements

#![allow(unused)]
fn main() {
let steam = &mut world.resources.steam;

steam.unlock_achievement("FIRST_BLOOD");
steam.clear_achievement("FIRST_BLOOD");
steam.refresh_achievements();

for achievement in &steam.achievements {
    let name = &achievement.api_name;
    let unlocked = achievement.achieved;
}
}

Stats

#![allow(unused)]
fn main() {
let steam = &mut world.resources.steam;

steam.set_stat_int("kills", 42);
steam.set_stat_float("play_time", 3.5);
steam.store_stats();

steam.refresh_stats();
for stat in &steam.stats {
    match &stat.value {
        StatValue::Int(value) => { /* ... */ }
        StatValue::Float(value) => { /* ... */ }
    }
}
}

Friends

#![allow(unused)]
fn main() {
let steam = &mut world.resources.steam;
steam.refresh_friends();

for friend in &steam.friends {
    let name = &friend.name;
    let state = &friend.persona_state; // Online, Offline, Busy, Away, etc.
}
}

P2P Networking

Send and receive messages between players:

#![allow(unused)]
fn main() {
let steam = &mut world.resources.steam;

steam.setup_networking_callbacks();
steam.send_message(peer_steam_id, data_bytes, channel);

let messages = steam.receive_messages(channel);
for message in messages {
    let sender = message.sender;
    let data = &message.data;
}
}

Session Management

#![allow(unused)]
fn main() {
steam.close_session(peer_steam_id);
let state = steam.get_session_state(peer_steam_id);
steam.refresh_session_states();
}

Session states: None, Connecting, Connected, ClosedByPeer, ProblemDetected, Failed.

Rich Presence

#![allow(unused)]
fn main() {
steam.set_rich_presence("status", "In Battle - Level 5");
steam.clear_rich_presence();
}

Overlays

#![allow(unused)]
fn main() {
steam.open_invite_dialog();
steam.open_overlay_to_user(friend_steam_id);
}

Platform Notes

  • Requires Steam client running on the user's machine
  • Desktop only (not available on WASM)
  • Graceful handling if Steam is unavailable — check is_initialized() before using

AI Integration

Nightshade provides two features for AI integration: mcp for exposing the engine as a Model Context Protocol server, and claude for embedding Claude Code CLI as a subprocess. Both are native-only (not available on WASM).

MCP Server

The mcp feature starts an HTTP-based MCP server on http://127.0.0.1:3333/mcp when the application launches. Any MCP-compatible client can connect and manipulate the running scene through structured tool calls.

Setup

Enable the feature in Cargo.toml:

nightshade = { git = "...", features = ["mcp"] }

The server starts automatically during engine initialization. No code changes are needed beyond enabling the feature.

Connecting Claude Code

Register the running engine as an MCP server:

claude mcp add --transport http nightshade http://127.0.0.1:3333/mcp

Claude Code can then call any of the engine's MCP tools directly during a conversation.

Available Tools

The MCP server exposes 50+ tools organized by category:

Entity Management

ToolDescription
list_entitiesList all named entities in the scene
query_entityQuery detailed info about a specific entity (transform, material, components)
spawn_entitySpawn a new entity with mesh, position, scale, color, emissive, parent, and alpha mode
despawn_entityRemove an entity by name
clear_sceneRemove all named entities

Transforms

ToolDescription
set_positionSet entity position as [x, y, z]
set_rotationSet entity rotation using euler angles in radians
set_scaleSet entity scale as [x, y, z]
set_parentSet or clear the parent of an entity
set_visibilityShow or hide an entity

Materials

ToolDescription
set_material_colorSet the base color of an entity
set_emissiveSet emissive color (values > 1.0 create HDR bloom)
set_materialSet full material properties (roughness, metallic, colors, alpha mode)
set_casts_shadowToggle shadow casting

Lighting

ToolDescription
spawn_lightSpawn a point, spot, or directional light
set_lightModify existing light properties (color, intensity, range, cone angles, shadows)

Camera

ToolDescription
set_cameraSet main camera position, target, and field of view

Assets

ToolDescription
load_assetLoad a 3D asset from file (.glb, .gltf, or .fbx)
spawn_prefabSpawn a loaded asset as a named entity
list_loaded_assetsList all assets available for spawning

Environment

ToolDescription
set_atmosphereSet skybox type (none, sky, cloudy_sky, space, nebula, sunset, hdr)
load_hdrLoad an HDR skybox from file
set_graphicsConfigure bloom, SSAO, fog, tonemapping, DOF, gamma, saturation, grid, and more

Effects

ToolDescription
spawn_waterSpawn a water plane with wave parameters
spawn_particlesSpawn a particle emitter with preset (fire, smoke, sparks, firework variants)
set_particlesModify emitter settings (enabled, spawn rate, emissive strength)
spawn_decalSpawn a projected texture decal

Text

ToolDescription
spawn_3d_textSpawn text in world space with position, font size, color, and optional billboard mode

Physics (requires physics feature)

ToolDescription
add_rigid_bodyAdd a rigid body (dynamic, kinematic, or static)
add_colliderAdd a collider shape (ball, cuboid, capsule, cylinder)
apply_impulseApply an instant impulse or torque impulse
apply_forceApply a continuous force or torque
set_velocitySet linear and/or angular velocity

Animation

ToolDescription
play_animationPlay an animation clip (by name or index) with looping, speed, and blend duration
stop_animationStop animation playback
list_animationsList available animation clips on an entity

Scripting (requires scripting feature)

ToolDescription
set_scriptAdd or update a Rhai script on an entity
remove_scriptRemove a script from an entity
set_game_stateSet values in the shared game state
get_game_stateRead values from the shared game state

Debug

ToolDescription
add_lineDraw a debug line
add_linesDraw multiple debug lines
clear_linesClear all debug lines
get_inputGet current input state (pressed keys, mouse position)
get_timeGet delta time and elapsed time

Batch Operations

ToolDescription
batchExecute multiple operations atomically in a single frame
runExecute concise text commands (e.g. spawn sun Sphere 0,0,0 scale:2 emissive:5,4,0)

Intercepting MCP Commands

Applications can intercept MCP commands before the engine processes them by implementing handle_mcp_command on the State trait:

#![allow(unused)]
fn main() {
#[cfg(all(feature = "mcp", not(target_arch = "wasm32")))]
fn handle_mcp_command(
    &mut self,
    world: &mut World,
    command: &McpCommand,
) -> Option<McpResponse> {
    match command {
        McpCommand::SpawnEntity { name, .. } => {
            // Update editor scene tree after engine handles the spawn
            self.pending_scene_refresh = true;
            None // let engine handle it
        }
        McpCommand::DespawnEntity { name } => {
            self.scene_tree.remove(name);
            None // let engine handle it
        }
        McpCommand::ClearScene => {
            self.scene_tree.clear();
            None
        }
        _ => None,
    }
}
}

Return Some(McpResponse) to fully handle a command yourself (the engine skips its default handler), or None to let the engine process it normally. This is useful for blocking certain commands or implementing custom command handling.

Reacting to MCP Results

after_mcp_command is called after a command has been processed (by the engine or your pre-hook). It receives both the command and the response, making it the right place to record undo entries, refresh scene trees, or trigger other side effects based on whether the command succeeded:

#![allow(unused)]
fn main() {
#[cfg(all(feature = "mcp", not(target_arch = "wasm32")))]
fn after_mcp_command(
    &mut self,
    world: &mut World,
    command: &McpCommand,
    response: &McpResponse,
) {
    let is_success = matches!(response, McpResponse::Success(_));

    match command {
        McpCommand::SpawnEntity { name, .. } => {
            if is_success {
                if let Some(&entity) = world.resources.entity_names.get(name) {
                    let hierarchy = capture_hierarchy(world, entity);
                    self.undo_history.push(
                        UndoableOperation::EntityCreated {
                            hierarchy: Box::new(hierarchy),
                            current_entity: entity,
                        },
                        format!("MCP: Spawn {}", name),
                    );
                }
            }
            self.scene_tree_dirty = true;
        }
        McpCommand::DespawnEntity { .. } | McpCommand::ClearScene => {
            self.scene_tree_dirty = true;
        }
        _ => {}
    }
}
}

The pre-hook/post-hook pattern works together: use handle_mcp_command to capture before-state (e.g. an entity's transform before it changes), then use after_mcp_command to create undo entries by comparing the before-state with the result.

Architecture

The MCP server runs on a background thread using tokio and axum. Communication with the main engine thread happens through synchronized queues:

  1. MCP client sends a tool call via HTTP
  2. The server deserializes the request into an McpCommand and pushes it to the command queue
  3. On the next frame, the engine drains the queue and processes each command
  4. Responses are written to the response queue
  5. The server reads the response and returns it to the MCP client

Commands are processed once per frame, after run_systems() and before the FrameSchedule dispatch. This means MCP-driven changes are visible to the same frame's transform, physics, and rendering systems.

Claude Code CLI

The claude feature provides a background worker for spawning Claude Code as a subprocess and streaming its JSON output. This lets applications embed an AI chat interface.

Setup

nightshade = { git = "...", features = ["claude"] }

Requires the claude CLI to be installed and available on PATH.

Usage

Create channels and spawn the worker:

#![allow(unused)]
fn main() {
let (command_sender, command_receiver, event_sender, event_receiver) =
    nightshade::claude::create_cli_channels();

nightshade::claude::spawn_cli_worker(
    command_receiver,
    event_sender,
    ClaudeConfig {
        system_prompt: Some("You are a scene designer.".to_string()),
        mcp_config: McpConfig::Auto,
        ..Default::default()
    },
);
}

Send a query:

#![allow(unused)]
fn main() {
command_sender.send(CliCommand::StartQuery {
    prompt: "Create a forest scene with 10 trees".to_string(),
    session_id: None,
    model: None,
}).ok();
}

Poll for events each frame:

#![allow(unused)]
fn main() {
while let Ok(event) = event_receiver.try_recv() {
    match event {
        CliEvent::TextDelta { text } => self.chat_buffer.push_str(&text),
        CliEvent::ThinkingDelta { text } => self.thinking_buffer.push_str(&text),
        CliEvent::ToolUseStarted { tool_name, .. } => {
            self.status = format!("Using tool: {}", tool_name);
        }
        CliEvent::Complete { total_cost_usd, num_turns, .. } => {
            self.status = format!("Done ({} turns)", num_turns);
        }
        CliEvent::Error { message } => {
            self.status = format!("Error: {}", message);
        }
        _ => {}
    }
}
}

ClaudeConfig

FieldTypeDescription
system_promptOption<String>Appended to Claude's system prompt via --append-system-prompt
allowed_toolsOption<Vec<String>>Restrict which tools Claude can use (--allowedTools)
disallowed_toolsOption<Vec<String>>Block specific tools (--disallowedTools)
mcp_configMcpConfigAuto (auto-connect to engine MCP), Custom(json), or None
custom_argsVec<String>Additional CLI arguments passed directly to claude

Auto MCP Configuration

When both claude and mcp features are enabled and mcp_config is set to McpConfig::Auto (the default), the worker automatically passes --mcp-config with a JSON payload pointing at http://127.0.0.1:3333/mcp. This means Claude Code can call engine tools without any manual mcp add step.

CliEvent Types

EventFieldsDescription
SessionStartedsession_idA new Claude session was created
TextDeltatextIncremental text output from Claude
ThinkingDeltatextIncremental thinking/reasoning output
ToolUseStartedtool_name, tool_idClaude began calling a tool
ToolUseInputDeltatool_id, partial_jsonStreaming tool input JSON
ToolUseFinishedtool_idTool call completed
TurnCompletesession_idClaude finished a turn (may continue)
Completesession_id, total_cost_usd, num_turnsFull query completed
ErrormessageAn error occurred

Webview

Requires feature: webview

Nightshade can embed web views inside the application window, enabling hybrid native/web UIs. The system provides bidirectional communication between the engine (host) and web content (client) using typed commands and events.

Feature Flag

[dependencies]
nightshade = { version = "...", features = ["webview"] }

This pulls in wry (Tauri's webview library) and tiny_http on desktop, and wasm-bindgen/web-sys on WASM.

Architecture

The webview system uses a generic WebviewContext<Cmd, Evt> where:

  • Cmd — command type sent from the web client to the engine
  • Evt — event type sent from the engine to the web client

Both types must implement serde::Serialize and serde::Deserialize. Messages are serialized with postcard (compact binary format) and encoded as base64 for transport.

Host Side (Desktop)

Serving Web Content

Embed static web assets and serve them via a local HTTP server:

#![allow(unused)]
fn main() {
serve_embedded_dir(embedded_assets, port);
}

Creating the Webview

#![allow(unused)]
fn main() {
let webview_context: WebviewContext<MyCommand, MyEvent> = WebviewContext::new();
webview_context.ensure_webview(bounds, window_handle);
}

The webview is positioned within the application window at the specified bounds.

Sending Events to the Client

#![allow(unused)]
fn main() {
webview_context.send_event(MyEvent::ScoreUpdated(42));
}

Receiving Commands from the Client

#![allow(unused)]
fn main() {
for command in webview_context.drain_commands() {
    match command {
        MyCommand::StartGame => { /* ... */ }
        MyCommand::SetOption(key, value) => { /* ... */ }
    }
}
}

Client Side (WASM)

The web content runs as a standard web page that communicates with the engine through IPC.

Connecting

#![allow(unused)]
fn main() {
connect::<MyCommand, MyEvent>(|event| {
    match event {
        MyEvent::ScoreUpdated(score) => { /* update UI */ }
    }
});
}

Sending Commands

#![allow(unused)]
fn main() {
send(MyCommand::StartGame);
}

Transport

Communication uses the __nwv__ IPC handler:

DirectionDesktopWASM
Host → Clientwebview.evaluate_script()N/A
Client → Hostipc.postMessage()window.__nwv__

Messages are serialized as: postcard binary → base64 string.

Platform Support

PlatformBackend
WindowsWebView2 (Edge/Chromium)
macOSWKWebView
LinuxWebKitGTK
WASMBrowser-native (client only)

File System

The nightshade::filesystem module provides a cross-platform file I/O abstraction. On native platforms it wraps the rfd crate for file dialogs and std::fs for reading/writing. On WebAssembly it uses browser download/upload APIs (Blob anchors for save, <input type="file"> for load) behind the same function signatures.

Feature Requirements

FunctionNativeWASM
save_filefile_dialog featurealways available
request_file_loadfile_dialog featurealways available
pick_filefile_dialog featurenot available
pick_folderfile_dialog featurenot available
save_file_dialogfile_dialog featurenot available
read_filefile_dialog featurenot available
write_filefile_dialog featurenot available

The engine aggregate feature includes file_dialog by default.

Types

FileFilter

Describes a file type filter for dialog boxes and browser accept strings.

#![allow(unused)]
fn main() {
use nightshade::filesystem::FileFilter;

let filters = [
    FileFilter {
        name: "JSON".to_string(),
        extensions: vec!["json".to_string()],
    },
    FileFilter {
        name: "Images".to_string(),
        extensions: vec!["png".to_string(), "jpg".to_string()],
    },
];
}

FileError

Error type returned by file operations.

  • FileError::NotFound(String) — file does not exist at the given path
  • FileError::ReadError(String) — read operation failed
  • FileError::WriteError(String) — write operation failed

Implements Display for convenient error formatting.

LoadedFile

Represents a file that has been read into memory.

#![allow(unused)]
fn main() {
pub struct LoadedFile {
    pub name: String,   // filename
    pub bytes: Vec<u8>, // raw contents
}
}

PendingFileLoad

A handle for an asynchronous file load operation. On native platforms the load completes synchronously during request_file_load, but on WASM the browser reads the file asynchronously, so you poll for completion each frame.

#![allow(unused)]
fn main() {
pub struct PendingFileLoad { /* ... */ }

impl PendingFileLoad {
    pub fn empty() -> Self;
    pub fn ready(file: LoadedFile) -> Self;
    pub fn is_ready(&self) -> bool;
    pub fn take(&self) -> Option<LoadedFile>;
}
}

Cross-Platform Functions

These two functions have implementations on both native and WASM. Use them when you want a single code path with no #[cfg] gates.

save_file

Opens a save dialog (native) or triggers a browser download (WASM).

#![allow(unused)]
fn main() {
use nightshade::filesystem::{save_file, FileFilter};

let filters = [FileFilter {
    name: "JSON".to_string(),
    extensions: vec!["json".to_string()],
}];

save_file("my_data.json", &bytes, &filters)?;
}

request_file_load

Opens a file picker and returns a PendingFileLoad. On native, the file is read immediately. On WASM, the file is read asynchronously after the user selects it.

#![allow(unused)]
fn main() {
use nightshade::filesystem::{request_file_load, FileFilter, PendingFileLoad};

let filters = [FileFilter {
    name: "JSON".to_string(),
    extensions: vec!["json".to_string()],
}];

let pending: PendingFileLoad = request_file_load(&filters);

// Poll each frame:
if let Some(loaded) = pending.take() {
    println!("Loaded {} ({} bytes)", loaded.name, loaded.bytes.len());
}
}

Native-Only Functions

These functions are available only on non-WASM targets with the file_dialog feature enabled. They provide PathBuf-based access for workflows that need the filesystem path (e.g., loading assets by path, tracking recent files).

pick_file

Opens a file picker dialog. Returns Option<PathBuf>.

#![allow(unused)]
fn main() {
use nightshade::filesystem::{pick_file, FileFilter};

let filters = [FileFilter {
    name: "Scene files".to_string(),
    extensions: vec!["json".to_string(), "bin".to_string()],
}];

if let Some(path) = pick_file(&filters) {
    // use path
}
}

pick_folder

Opens a folder picker dialog. Returns Option<PathBuf>.

#![allow(unused)]
fn main() {
use nightshade::filesystem::pick_folder;

if let Some(folder) = pick_folder() {
    // use folder path
}
}

save_file_dialog

Opens a save dialog. Returns Option<PathBuf> without writing anything. An optional default filename can be suggested to the user.

#![allow(unused)]
fn main() {
use nightshade::filesystem::{save_file_dialog, FileFilter};

let filters = [FileFilter {
    name: "Project".to_string(),
    extensions: vec!["project.json".to_string()],
}];

if let Some(path) = save_file_dialog(&filters, Some("my_project.json")) {
    // write to path yourself
}
}

read_file

Reads a file into a byte vector. Returns Result<Vec<u8>, FileError>.

#![allow(unused)]
fn main() {
use nightshade::filesystem::read_file;

let bytes = read_file(std::path::Path::new("settings.json"))?;
let settings: MySettings = serde_json::from_slice(&bytes)?;
}

write_file

Writes bytes to a file, creating parent directories as needed. Returns Result<(), FileError>.

#![allow(unused)]
fn main() {
use nightshade::filesystem::write_file;

let json = serde_json::to_vec_pretty(&settings)?;
write_file(std::path::Path::new("settings.json"), &json)?;
}

Polling Pattern

For cross-platform file loading, store a PendingFileLoad in your application state and poll it each frame:

#![allow(unused)]
fn main() {
struct MyApp {
    pending_load: Option<nightshade::filesystem::PendingFileLoad>,
}

// When the user clicks "Load":
self.pending_load = Some(nightshade::filesystem::request_file_load(&filters));

// Each frame in ui() or run_systems():
if let Some(ref pending) = self.pending_load {
    if let Some(file) = pending.take() {
        self.pending_load = None;
        self.process_loaded_file(&file.name, &file.bytes);
    }
}
}

This pattern works identically on native and WASM with no conditional compilation.

WASM Plugins

Requires feature: plugin_runtime

Nightshade includes a WASM-based plugin system that allows loading and executing WebAssembly modules at runtime. Plugins can spawn entities, manipulate transforms, load assets, and respond to input events.

Feature Flags

[dependencies]
nightshade = { version = "...", features = ["plugin_runtime"] }

The plugin_runtime feature enables the Wasmtime-based runtime and WASI support. The base plugins feature provides the shared command/event types for writing plugin guest code. Desktop only.

Plugin Runtime

Loading Plugins

#![allow(unused)]
fn main() {
let mut runtime = PluginRuntime::new(PluginRuntimeConfig {
    plugins_path: "plugins/".to_string(),
    ..Default::default()
});

runtime.load_plugin("plugins/my_plugin.wasm");
runtime.load_plugins_from_directory("plugins/");
}

Running Plugins Each Frame

#![allow(unused)]
fn main() {
fn run_systems(&mut self, world: &mut World) {
    runtime.run_plugins_frame(world);
    runtime.process_pending_commands(world);
}
}

run_plugins_frame() calls each plugin's on_frame() export. process_pending_commands() executes any engine commands the plugins have queued.

Plugin Lifecycle

Plugins are compiled WASM modules that export specific functions:

ExportRequiredDescription
on_init()NoCalled once when the plugin is loaded
on_frame()NoCalled every frame
plugin_alloc(size) -> *mut u8YesMemory allocation for receiving events
plugin_receive_event(ptr, len)YesReceives serialized events from the engine

Engine Commands

Plugins send commands to the engine through a host-provided API:

CommandDescription
SpawnPrimitiveCreate a cube, sphere, cylinder, plane, or cone
DespawnEntityRemove an entity
SetEntityPositionSet entity world position
SetEntityScaleSet entity scale
SetEntityRotationSet entity rotation
GetEntityPositionRequest entity position (async)
GetEntityScaleRequest entity scale (async)
GetEntityRotationRequest entity rotation (async)
SetEntityMaterialSet material properties
SetEntityColorSet entity color
LoadTextureLoad a texture by path
LoadPrefabLoad a glTF prefab
ReadFileRead a file from disk
LogPrint a message to the host console

Guest API

From within a plugin:

#![allow(unused)]
fn main() {
use nightshade::plugin::*;

fn on_init() {
    let entity_id = spawn_primitive(Primitive::Cube, 0.0, 1.0, 0.0);
    set_entity_position(entity_id, 5.0, 2.0, 0.0);
    log("Plugin initialized!");
}
}

Engine Events

Events sent from the engine to plugins:

EventDescription
FrameStartNew frame beginning
KeyPressed / KeyReleasedKeyboard input
MouseMovedMouse position change
MouseButtonPressed / MouseButtonReleasedMouse button input
EntitySpawnedEntity creation confirmed with host entity ID
FileLoaded / TextureLoaded / PrefabLoadedAsync asset load results
ErrorError notification

Entity ID Mapping

Plugins use their own entity ID space. The runtime maintains a bidirectional mapping between plugin entity IDs and host entity IDs. When a plugin spawns an entity, it receives a local ID immediately and gets the real host ID through an EntitySpawned event.

Custom Linker Functions

Extend the plugin API with custom host functions:

#![allow(unused)]
fn main() {
runtime.with_custom_linker(|linker| {
    linker.func_wrap("env", "my_custom_function", |param: i32| -> i32 {
        param * 2
    });
});
}

Platform Notes

  • Desktop only (uses Wasmtime, not available on WASM targets)
  • Full WASI P1 support for file I/O within plugins
  • Memory-safe communication via postcard serialization
  • Automatic cleanup of plugin resources on configurable intervals

Tutorial: Building a 3D Game

This tutorial walks through building a complete 3D Pong game from scratch. By the end, you'll have two paddles, a bouncing ball, AI opponent, scoring, pause/unpause, and a game-over screen — all rendered in 3D with PBR materials and egui overlays.

Project Setup

Create a new project:

cargo init pong-game

Cargo.toml:

[package]
name = "pong-game"
version = "0.1.0"
edition = "2024"

[dependencies]
nightshade = { git = "https://github.com/user/nightshade", features = ["engine", "wgpu"] }
rand = "0.9"

Step 1: The Empty Window

Every Nightshade application starts with a struct that implements the State trait and a call to launch:

use nightshade::prelude::*;

struct PongGame;

impl State for PongGame {
    fn title(&self) -> &str {
        "Pong"
    }

    fn initialize(&mut self, world: &mut World) {
        let camera = spawn_camera(world, Vec3::new(0.0, 0.0, 15.0), "Camera".to_string());
        world.resources.active_camera = Some(camera);
        spawn_sun(world);
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    launch(PongGame)
}

launch creates the window, initializes the wgpu renderer, calls initialize once, then runs the game loop calling run_systems every frame. The camera is positioned at (0, 0, 15) looking toward the origin, and we add a directional light so objects are visible.

Run it and you'll see an empty scene with a grid floor.

Step 2: Game Constants and State

Define the arena dimensions and game state. All game data lives in your state struct — the engine doesn't own any of it:

#![allow(unused)]
fn main() {
use nightshade::ecs::material::resources::material_registry_insert;
use nightshade::prelude::*;

const PADDLE_WIDTH: f32 = 0.3;
const PADDLE_HEIGHT: f32 = 2.0;
const PADDLE_DEPTH: f32 = 0.3;
const PADDLE_SPEED: f32 = 8.0;
const BALL_SIZE: f32 = 0.3;
const BALL_SPEED: f32 = 6.0;
const ARENA_WIDTH: f32 = 12.0;
const ARENA_HEIGHT: f32 = 8.0;
const WINNING_SCORE: u32 = 5;

#[derive(Default)]
struct PongGame {
    left_paddle_y: f32,
    right_paddle_y: f32,
    ball_x: f32,
    ball_y: f32,
    ball_vel_x: f32,
    ball_vel_y: f32,
    left_score: u32,
    right_score: u32,
    left_paddle_entity: Option<Entity>,
    right_paddle_entity: Option<Entity>,
    ball_entity: Option<Entity>,
    paused: bool,
    game_over: bool,
}
}

The game state is separate from the ECS world. The ECS holds the visual entities (meshes, transforms, materials). Your struct holds game logic data (positions, velocities, scores). Each frame, you update game logic first, then sync the ECS transforms to match.

Step 3: Spawning Game Objects

Create the paddles, ball, and walls. Each is a mesh entity with a material:

#![allow(unused)]
fn main() {
impl PongGame {
    fn create_game_objects(&mut self, world: &mut World) {
        self.left_paddle_entity = Some(self.spawn_colored_mesh(
            world,
            "Cube",
            Vec3::new(-ARENA_WIDTH / 2.0 + 0.5, 0.0, 0.0),
            Vec3::new(PADDLE_WIDTH, PADDLE_HEIGHT, PADDLE_DEPTH),
            [0.2, 0.6, 1.0, 1.0],
        ));

        self.right_paddle_entity = Some(self.spawn_colored_mesh(
            world,
            "Cube",
            Vec3::new(ARENA_WIDTH / 2.0 - 0.5, 0.0, 0.0),
            Vec3::new(PADDLE_WIDTH, PADDLE_HEIGHT, PADDLE_DEPTH),
            [1.0, 0.4, 0.2, 1.0],
        ));

        self.ball_entity = Some(self.spawn_colored_mesh(
            world,
            "Sphere",
            Vec3::zeros(),
            Vec3::new(BALL_SIZE, BALL_SIZE, BALL_SIZE),
            [1.0, 1.0, 1.0, 1.0],
        ));

        self.spawn_colored_mesh(
            world,
            "Cube",
            Vec3::new(0.0, ARENA_HEIGHT / 2.0 + 0.25, 0.0),
            Vec3::new(ARENA_WIDTH + 1.0, 0.5, 0.5),
            [0.5, 0.5, 0.5, 1.0],
        );

        self.spawn_colored_mesh(
            world,
            "Cube",
            Vec3::new(0.0, -ARENA_HEIGHT / 2.0 - 0.25, 0.0),
            Vec3::new(ARENA_WIDTH + 1.0, 0.5, 0.5),
            [0.5, 0.5, 0.5, 1.0],
        );
    }

    fn spawn_colored_mesh(
        &self,
        world: &mut World,
        mesh_name: &str,
        position: Vec3,
        scale: Vec3,
        color: [f32; 4],
    ) -> Entity {
        let entity = spawn_mesh(world, mesh_name, position, scale);

        let material_name = format!("mat_{}", entity.id);
        material_registry_insert(
            &mut world.resources.material_registry,
            material_name.clone(),
            Material {
                base_color: color,
                ..Default::default()
            },
        );

        if let Some(&index) = world
            .resources
            .material_registry
            .registry
            .name_to_index
            .get(&material_name)
        {
            world.resources.material_registry.registry.add_reference(index);
        }

        world.core.set_material_ref(entity, MaterialRef::new(material_name));
        entity
    }
}
}

spawn_mesh creates an entity with LOCAL_TRANSFORM, GLOBAL_TRANSFORM, and RENDER_MESH components. The material is registered in the global MaterialRegistry by name, then assigned to the entity via MaterialRef. Each material needs a unique name — using the entity ID ensures no collisions.

Step 4: Ball Movement and Reset

The ball moves in a straight line, bouncing off walls and paddles:

#![allow(unused)]
fn main() {
impl PongGame {
    fn reset_ball(&mut self) {
        self.ball_x = 0.0;
        self.ball_y = 0.0;
        let angle = (rand::random::<f32>() - 0.5) * std::f32::consts::PI * 0.5;
        self.ball_vel_x = BALL_SPEED * angle.cos();
        self.ball_vel_y = BALL_SPEED * angle.sin();
    }

    fn ball_movement_system(&mut self, world: &mut World) {
        let dt = world.resources.window.timing.delta_time;
        self.ball_x += self.ball_vel_x * dt;
        self.ball_y += self.ball_vel_y * dt;
    }

    fn normalize_ball_speed(&mut self) {
        let speed = (self.ball_vel_x * self.ball_vel_x + self.ball_vel_y * self.ball_vel_y).sqrt();
        self.ball_vel_x *= BALL_SPEED / speed;
        self.ball_vel_y *= BALL_SPEED / speed;
    }
}
}

Time comes from world.resources.window.timing.delta_time, which gives the frame duration in seconds. Multiplying velocity by delta time makes movement frame-rate independent.

Step 5: Input and AI

The player controls the left paddle with W/S or arrow keys. The AI tracks the ball's Y position:

#![allow(unused)]
fn main() {
impl PongGame {
    fn input_system(&mut self, world: &mut World) {
        let dt = world.resources.window.timing.delta_time;
        let keyboard = &world.resources.input.keyboard;

        if keyboard.is_key_pressed(KeyCode::KeyW) || keyboard.is_key_pressed(KeyCode::ArrowUp) {
            self.left_paddle_y += PADDLE_SPEED * dt;
        }
        if keyboard.is_key_pressed(KeyCode::KeyS) || keyboard.is_key_pressed(KeyCode::ArrowDown) {
            self.left_paddle_y -= PADDLE_SPEED * dt;
        }

        let max_y = ARENA_HEIGHT / 2.0 - PADDLE_HEIGHT / 2.0;
        self.left_paddle_y = self.left_paddle_y.clamp(-max_y, max_y);
    }

    fn ai_system(&mut self, world: &mut World) {
        let dt = world.resources.window.timing.delta_time;
        let distance = self.ball_y - self.right_paddle_y;

        if distance.abs() > 0.2 {
            self.right_paddle_y += distance.signum() * PADDLE_SPEED * 0.75 * dt;
        }

        let max_y = ARENA_HEIGHT / 2.0 - PADDLE_HEIGHT / 2.0;
        self.right_paddle_y = self.right_paddle_y.clamp(-max_y, max_y);
    }
}
}

Input is polled via world.resources.input.keyboard.is_key_pressed(). This checks whether a key is currently held down (not just pressed this frame).

Step 6: Collision Detection

Check ball against walls and paddles. When the ball passes a paddle's edge, score a point:

#![allow(unused)]
fn main() {
impl PongGame {
    fn collision_system(&mut self) {
        let ball_max_y = ARENA_HEIGHT / 2.0 - BALL_SIZE;
        if self.ball_y > ball_max_y {
            self.ball_y = ball_max_y;
            self.ball_vel_y = -self.ball_vel_y.abs();
        } else if self.ball_y < -ball_max_y {
            self.ball_y = -ball_max_y;
            self.ball_vel_y = self.ball_vel_y.abs();
        }

        let left_x = -ARENA_WIDTH / 2.0 + 0.5;
        if self.ball_x < left_x + PADDLE_WIDTH / 2.0 + BALL_SIZE
            && self.ball_x > left_x - PADDLE_WIDTH / 2.0
            && (self.ball_y - self.left_paddle_y).abs() < PADDLE_HEIGHT / 2.0 + BALL_SIZE
        {
            self.ball_x = left_x + PADDLE_WIDTH / 2.0 + BALL_SIZE;
            self.ball_vel_x = self.ball_vel_x.abs();
            let hit_offset = (self.ball_y - self.left_paddle_y) / (PADDLE_HEIGHT / 2.0);
            self.ball_vel_y += hit_offset * 2.0;
            self.normalize_ball_speed();
        }

        let right_x = ARENA_WIDTH / 2.0 - 0.5;
        if self.ball_x > right_x - PADDLE_WIDTH / 2.0 - BALL_SIZE
            && self.ball_x < right_x + PADDLE_WIDTH / 2.0
            && (self.ball_y - self.right_paddle_y).abs() < PADDLE_HEIGHT / 2.0 + BALL_SIZE
        {
            self.ball_x = right_x - PADDLE_WIDTH / 2.0 - BALL_SIZE;
            self.ball_vel_x = -self.ball_vel_x.abs();
            let hit_offset = (self.ball_y - self.right_paddle_y) / (PADDLE_HEIGHT / 2.0);
            self.ball_vel_y += hit_offset * 2.0;
            self.normalize_ball_speed();
        }

        if self.ball_x < -ARENA_WIDTH / 2.0 - 1.0 {
            self.right_score += 1;
            self.reset_ball();
            if self.right_score >= WINNING_SCORE {
                self.game_over = true;
            }
        } else if self.ball_x > ARENA_WIDTH / 2.0 + 1.0 {
            self.left_score += 1;
            self.reset_ball();
            if self.left_score >= WINNING_SCORE {
                self.game_over = true;
            }
        }
    }
}
}

Where the ball hits the paddle affects the bounce angle — hitting the edge sends the ball at a steeper angle, hitting the center keeps it flat. After adjusting the velocity, normalize_ball_speed() ensures the ball always moves at BALL_SPEED.

Step 7: Syncing Visuals

After updating game logic, write the positions back to the ECS transforms. This is where game state becomes visible:

#![allow(unused)]
fn main() {
impl PongGame {
    fn update_visuals(&mut self, world: &mut World) {
        if let Some(entity) = self.left_paddle_entity {
            if let Some(transform) = world.core.get_local_transform_mut(entity) {
                transform.translation.y = self.left_paddle_y;
            }
            mark_local_transform_dirty(world, entity);
        }

        if let Some(entity) = self.right_paddle_entity {
            if let Some(transform) = world.core.get_local_transform_mut(entity) {
                transform.translation.y = self.right_paddle_y;
            }
            mark_local_transform_dirty(world, entity);
        }

        if let Some(entity) = self.ball_entity {
            if let Some(transform) = world.core.get_local_transform_mut(entity) {
                transform.translation.x = self.ball_x;
                transform.translation.y = self.ball_y;
            }
            mark_local_transform_dirty(world, entity);
        }
    }
}
}

mark_local_transform_dirty tells the engine that this entity's transform changed and the global transform hierarchy needs to be recalculated. Without it, the entity won't visually move.

Step 8: The Game Loop

Wire everything together in the State trait implementation:

#![allow(unused)]
fn main() {
impl State for PongGame {
    fn title(&self) -> &str {
        "Pong"
    }

    fn initialize(&mut self, world: &mut World) {
        world.resources.graphics.atmosphere = Atmosphere::Space;
        world.resources.graphics.show_grid = false;
        world.resources.user_interface.enabled = true;

        spawn_sun_without_shadows(world);

        let camera = spawn_camera(world, Vec3::new(0.0, 0.0, 15.0), "Camera".to_string());
        if let Some(camera_component) = world.core.get_camera_mut(camera) {
            camera_component.projection = Projection::Perspective(PerspectiveCamera {
                aspect_ratio: None,
                y_fov_rad: 60.0_f32.to_radians(),
                z_far: Some(1000.0),
                z_near: 0.1,
            });
        }
        world.resources.active_camera = Some(camera);

        self.create_game_objects(world);
        self.reset_ball();
    }

    fn run_systems(&mut self, world: &mut World) {
        escape_key_exit_system(world);

        if !self.paused && !self.game_over {
            self.input_system(world);
            self.ai_system(world);
            self.ball_movement_system(world);
            self.collision_system();
        }

        self.update_visuals(world);
    }

    fn on_keyboard_input(&mut self, _world: &mut World, key: KeyCode, state: ElementState) {
        if state == ElementState::Pressed {
            match key {
                KeyCode::Space => self.paused = !self.paused,
                KeyCode::KeyR => self.reset_game(),
                _ => {}
            }
        }
    }
}
}

run_systems is called every frame. The pattern is: check input → update game logic → detect collisions → sync visuals. on_keyboard_input handles one-shot key events (pressed/released) rather than held keys.

Step 9: UI Overlay with egui

Add score display and pause/game-over screens. The ui method receives an egui context for immediate-mode UI:

#![allow(unused)]
fn main() {
impl State for PongGame {
    fn ui(&mut self, _world: &mut World, ctx: &egui::Context) {
        egui::Window::new("Score")
            .anchor(egui::Align2::CENTER_TOP, [0.0, 10.0])
            .resizable(false)
            .collapsible(false)
            .title_bar(false)
            .show(ctx, |ui| {
                ui.heading(format!("{} - {}", self.left_score, self.right_score));
            });

        if self.paused {
            egui::CentralPanel::default()
                .frame(egui::Frame::new().fill(egui::Color32::from_black_alpha(180)))
                .show(ctx, |ui| {
                    ui.vertical_centered(|ui| {
                        ui.add_space(100.0);
                        ui.heading("PAUSED");
                        ui.add_space(20.0);
                        ui.label("Press SPACE to resume");
                        ui.label("Press R to restart");
                    });
                });
        }

        if self.game_over {
            egui::CentralPanel::default()
                .frame(egui::Frame::new().fill(egui::Color32::from_black_alpha(180)))
                .show(ctx, |ui| {
                    ui.vertical_centered(|ui| {
                        ui.add_space(100.0);
                        let winner = if self.left_score >= WINNING_SCORE {
                            "You Win!"
                        } else {
                            "AI Wins!"
                        };
                        ui.heading(winner);
                        ui.add_space(10.0);
                        ui.label(format!("Final Score: {} - {}", self.left_score, self.right_score));
                        ui.add_space(20.0);
                        ui.label("Press R to play again");
                    });
                });
        }

        egui::Window::new("Controls")
            .anchor(egui::Align2::LEFT_BOTTOM, [10.0, -10.0])
            .resizable(false)
            .collapsible(false)
            .show(ctx, |ui| {
                ui.label("W/S or Up/Down - Move paddle");
                ui.label("SPACE - Pause");
                ui.label("R - Restart");
                ui.label("ESC - Exit");
            });
    }
}
}

egui runs at the end of each frame, after rendering. The anchor method positions windows relative to screen edges. CentralPanel covers the entire screen — useful for overlay menus.

Step 10: Game Reset

#![allow(unused)]
fn main() {
impl PongGame {
    fn reset_game(&mut self) {
        self.left_paddle_y = 0.0;
        self.right_paddle_y = 0.0;
        self.left_score = 0;
        self.right_score = 0;
        self.paused = false;
        self.game_over = false;
        self.reset_ball();
    }
}
}

Since game state lives in your struct (not the ECS), resetting is just zeroing your fields. The ECS entities remain — they just get new transform values next frame.

Key Patterns Demonstrated

PatternWhere Used
State trait lifecycleinitialize, run_systems, on_keyboard_input, ui
Entity spawningspawn_mesh + material registration
Frame-rate independent movementvelocity * delta_time
Input pollingkeyboard.is_key_pressed() for held keys
One-shot input eventson_keyboard_input for press/release
Transform updatesget_local_transform_mut + mark_local_transform_dirty
Game state separationLogic in struct fields, visuals in ECS
egui overlaysScore display, pause menu, game over screen

Where to Go Next

From this foundation you can add:

  • Physics: Replace manual collision with Rapier rigid bodies and colliders. See Physics Overview.
  • Audio: Add sound effects with AudioSource entities. See Audio System.
  • 3D Models: Replace cubes with loaded glTF models via import_gltf_from_bytes. See Meshes & Models.
  • Particles: Add spark effects on ball collision. See Particle Systems.
  • Materials: Make the ball emissive so it glows. See Materials.

Tutorial: Building a Terminal Game

This tutorial walks through building a Snake game that runs entirely in the terminal. Nightshade's TUI framework provides an ECS, double-buffered rendering, input handling, and collision detection — the same architecture as the 3D engine, but rendering characters instead of meshes.

Project Setup

cargo init terminal-snake

Cargo.toml:

[package]
name = "terminal-snake"
version = "0.1.0"
edition = "2024"

[dependencies]
nightshade = { git = "https://github.com/user/nightshade", features = ["terminal"] }
rand = "0.9"

The terminal feature enables the crossterm-based terminal renderer. No GPU, no window — just your terminal emulator.

Step 1: The Empty Terminal App

use nightshade::tui::prelude::*;

struct SnakeGame;

impl State for SnakeGame {
    fn title(&self) -> &str {
        "Snake"
    }

    fn initialize(&mut self, world: &mut World) {
        world.resources.timing.target_fps = 60;
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    launch(Box::new(SnakeGame))
}

The TUI State trait mirrors the 3D engine's trait:

  • initialize — called once at startup
  • run_systems — called every frame
  • on_keyboard_input — key press/release events
  • next_state — state transitions (title screen → gameplay → game over)

launch takes a Box<dyn State>, enters raw terminal mode, hides the cursor, enables mouse capture, and runs the game loop. On exit (or panic), it restores the terminal.

Step 2: The TUI ECS

The TUI has its own ECS with components designed for character-cell rendering:

ComponentFlagDescription
PositionPOSITIONColumn/row coordinates (f64 for sub-cell movement)
VelocityVELOCITYColumn/row per tick (used by movement_system)
SpriteSPRITESingle character with foreground/background color
LabelLABELMulti-character text string
TilemapTILEMAPGrid of characters for larger structures
ColliderCOLLIDERAABB collision box (width, height, layer, mask)
ZIndexZ_INDEXRender ordering (higher = on top)
VisibilityVISIBILITYShow/hide toggle
ParentPARENTParent entity reference
LocalOffsetLOCAL_OFFSETOffset from parent position
NameNAMEEntity name string
SpriteAnimationSPRITE_ANIMATIONFrame-based character animation

Resources are accessed through world.resources:

ResourceDescription
terminal_sizeCurrent terminal dimensions (columns, rows)
timingdelta_seconds, elapsed, frame_count, target_fps
keyboardis_pressed(), is_just_pressed(), is_just_released()
mousePosition, button states, scroll delta
cameraViewport offset (offset_column, offset_row)
should_exitSet to true to quit

Step 3: Game State

#![allow(unused)]
fn main() {
use nightshade::tui::prelude::*;
use rand::Rng;

const BOARD_WIDTH: i32 = 40;
const BOARD_HEIGHT: i32 = 20;
const TICK_INTERVAL: f64 = 0.12;

#[derive(Clone, Copy, PartialEq, Eq)]
enum Direction {
    Up,
    Down,
    Left,
    Right,
}

struct SnakeGame {
    segments: Vec<(i32, i32)>,
    direction: Direction,
    next_direction: Direction,
    food_position: (i32, i32),
    score: u32,
    game_over: bool,
    tick_timer: f64,
    board_offset_x: i32,
    board_offset_y: i32,
    segment_entities: Vec<Entity>,
    food_entity: Option<Entity>,
    wall_entities: Vec<Entity>,
    score_entities: Vec<Entity>,
}

impl SnakeGame {
    fn new() -> Self {
        Self {
            segments: vec![(BOARD_WIDTH / 2, BOARD_HEIGHT / 2)],
            direction: Direction::Right,
            next_direction: Direction::Right,
            food_position: (0, 0),
            score: 0,
            game_over: false,
            tick_timer: 0.0,
            board_offset_x: 0,
            board_offset_y: 0,
            segment_entities: Vec::new(),
            food_entity: None,
            wall_entities: Vec::new(),
            score_entities: Vec::new(),
        }
    }
}
}

The snake is a Vec<(i32, i32)> of grid positions. The head is segments[0]. Each frame the game logic ticks on a fixed interval — the tick timer accumulates delta_seconds and advances the snake when it exceeds TICK_INTERVAL.

Step 4: Drawing the Board

#![allow(unused)]
fn main() {
impl SnakeGame {
    fn spawn_walls(&mut self, world: &mut World) {
        for column in 0..BOARD_WIDTH {
            self.spawn_wall_cell(world, column, 0, '=');
            self.spawn_wall_cell(world, column, BOARD_HEIGHT - 1, '=');
        }

        for row in 1..(BOARD_HEIGHT - 1) {
            self.spawn_wall_cell(world, 0, row, '|');
            self.spawn_wall_cell(world, BOARD_WIDTH - 1, row, '|');
        }
    }

    fn spawn_wall_cell(&mut self, world: &mut World, column: i32, row: i32, character: char) {
        let entity = world.spawn_entities(POSITION | SPRITE | Z_INDEX, 1)[0];
        world.set_position(entity, Position {
            column: (self.board_offset_x + column) as f64,
            row: (self.board_offset_y + row) as f64,
        });
        world.set_sprite(entity, Sprite {
            character,
            foreground: TermColor::Grey,
            background: TermColor::Black,
        });
        world.set_z_index(entity, ZIndex(1));
        self.wall_entities.push(entity);
    }
}
}

Each wall cell is its own entity with a Position, Sprite, and ZIndex. The Position uses f64 coordinates — for grid-based games, cast to integer. The ZIndex determines draw order when entities overlap.

Step 5: Spawning the Snake and Food

#![allow(unused)]
fn main() {
impl SnakeGame {
    fn spawn_food(&mut self, world: &mut World) {
        let mut rng = rand::rng();
        loop {
            let column = rng.random_range(1..(BOARD_WIDTH - 1));
            let row = rng.random_range(1..(BOARD_HEIGHT - 1));

            if !self.segments.contains(&(column, row)) {
                self.food_position = (column, row);
                break;
            }
        }

        if let Some(entity) = self.food_entity {
            if let Some(position) = world.get_position_mut(entity) {
                position.column = (self.board_offset_x + self.food_position.0) as f64;
                position.row = (self.board_offset_y + self.food_position.1) as f64;
            }
        } else {
            let entity = world.spawn_entities(POSITION | SPRITE | Z_INDEX, 1)[0];
            world.set_position(entity, Position {
                column: (self.board_offset_x + self.food_position.0) as f64,
                row: (self.board_offset_y + self.food_position.1) as f64,
            });
            world.set_sprite(entity, Sprite {
                character: '*',
                foreground: TermColor::Red,
                background: TermColor::Black,
            });
            world.set_z_index(entity, ZIndex(2));
            self.food_entity = Some(entity);
        }
    }

    fn sync_snake_entities(&mut self, world: &mut World) {
        while self.segment_entities.len() > self.segments.len() {
            let entity = self.segment_entities.pop().unwrap();
            world.despawn_entities(&[entity]);
        }

        while self.segment_entities.len() < self.segments.len() {
            let entity = world.spawn_entities(POSITION | SPRITE | Z_INDEX, 1)[0];
            world.set_z_index(entity, ZIndex(3));
            self.segment_entities.push(entity);
        }

        for (index, &(column, row)) in self.segments.iter().enumerate() {
            let entity = self.segment_entities[index];
            world.set_position(entity, Position {
                column: (self.board_offset_x + column) as f64,
                row: (self.board_offset_y + row) as f64,
            });

            let (character, color) = if index == 0 {
                ('@', TermColor::Green)
            } else {
                ('o', TermColor::DarkGreen)
            };

            world.set_sprite(entity, Sprite {
                character,
                foreground: color,
                background: TermColor::Black,
            });
        }
    }
}
}

The snake head renders as @ in bright green, body segments as o in dark green, and food as * in red. The entity list grows and shrinks to match the snake length — entities are spawned or despawned as needed.

Step 6: Game Logic

#![allow(unused)]
fn main() {
impl SnakeGame {
    fn tick(&mut self, world: &mut World) {
        self.direction = self.next_direction;

        let (head_column, head_row) = self.segments[0];
        let (new_column, new_row) = match self.direction {
            Direction::Up => (head_column, head_row - 1),
            Direction::Down => (head_column, head_row + 1),
            Direction::Left => (head_column - 1, head_row),
            Direction::Right => (head_column + 1, head_row),
        };

        if new_column <= 0
            || new_column >= BOARD_WIDTH - 1
            || new_row <= 0
            || new_row >= BOARD_HEIGHT - 1
        {
            self.game_over = true;
            return;
        }

        if self.segments.contains(&(new_column, new_row)) {
            self.game_over = true;
            return;
        }

        self.segments.insert(0, (new_column, new_row));

        if (new_column, new_row) == self.food_position {
            self.score += 1;
            self.spawn_food(world);
        } else {
            self.segments.pop();
        }

        self.sync_snake_entities(world);
    }
}
}

Each tick: move the head one cell in the current direction, check for wall/self collision, and either grow (if eating food) or remove the tail. The next_direction buffer prevents reversing into yourself — it's set by input but only applied at tick time.

Step 7: Input Handling

#![allow(unused)]
fn main() {
impl State for SnakeGame {
    fn on_keyboard_input(&mut self, world: &mut World, key: KeyCode, pressed: bool) {
        if !pressed {
            return;
        }

        match key {
            KeyCode::Up | KeyCode::Char('w') => {
                if self.direction != Direction::Down {
                    self.next_direction = Direction::Up;
                }
            }
            KeyCode::Down | KeyCode::Char('s') => {
                if self.direction != Direction::Up {
                    self.next_direction = Direction::Down;
                }
            }
            KeyCode::Left | KeyCode::Char('a') => {
                if self.direction != Direction::Right {
                    self.next_direction = Direction::Left;
                }
            }
            KeyCode::Right | KeyCode::Char('d') => {
                if self.direction != Direction::Left {
                    self.next_direction = Direction::Right;
                }
            }
            KeyCode::Escape | KeyCode::Char('q') => {
                world.resources.should_exit = true;
            }
            _ => {}
        }
    }
}
}

TUI key events use KeyCode::Char('w') for letter keys and KeyCode::Up for arrow keys. The pressed parameter distinguishes press from release. Setting world.resources.should_exit = true cleanly exits the game loop and restores the terminal.

Step 8: Score Display

#![allow(unused)]
fn main() {
impl SnakeGame {
    fn update_score_display(&mut self, world: &mut World) {
        for &entity in &self.score_entities {
            world.despawn_entities(&[entity]);
        }
        self.score_entities.clear();

        let text = format!("Score: {}", self.score);
        let start_column = self.board_offset_x;
        let row = self.board_offset_y - 1;

        for (index, character) in text.chars().enumerate() {
            let entity = world.spawn_entities(POSITION | SPRITE | Z_INDEX, 1)[0];
            world.set_position(entity, Position {
                column: (start_column + index as i32) as f64,
                row: row as f64,
            });
            world.set_sprite(entity, Sprite {
                character,
                foreground: TermColor::White,
                background: TermColor::Black,
            });
            world.set_z_index(entity, ZIndex(10));
            self.score_entities.push(entity);
        }
    }
}
}

There's no built-in text rendering for the terminal — text is rendered character by character as individual Sprite entities. For text that changes every frame, despawn the old entities and spawn new ones. For static text, spawn once in initialize.

Step 9: Putting It All Together

#![allow(unused)]
fn main() {
impl State for SnakeGame {
    fn title(&self) -> &str {
        "Snake"
    }

    fn initialize(&mut self, world: &mut World) {
        world.resources.timing.target_fps = 60;

        let terminal = world.resources.terminal_size;
        self.board_offset_x = (terminal.columns as i32 - BOARD_WIDTH) / 2;
        self.board_offset_y = (terminal.rows as i32 - BOARD_HEIGHT) / 2;
        if self.board_offset_x < 0 { self.board_offset_x = 0; }
        if self.board_offset_y < 1 { self.board_offset_y = 1; }

        self.spawn_walls(world);
        self.spawn_food(world);
        self.sync_snake_entities(world);
        self.update_score_display(world);
    }

    fn run_systems(&mut self, world: &mut World) {
        if self.game_over {
            return;
        }

        let delta = world.resources.timing.delta_seconds;
        self.tick_timer += delta;

        if self.tick_timer >= TICK_INTERVAL {
            self.tick_timer -= TICK_INTERVAL;
            self.tick(world);
            self.update_score_display(world);
        }
    }

    fn on_keyboard_input(&mut self, world: &mut World, key: KeyCode, pressed: bool) {
        // ... (from Step 7)
    }

    fn next_state(&mut self, world: &mut World) -> Option<Box<dyn State>> {
        if self.game_over {
            let score = self.score;
            let all_entities: Vec<Entity> = world.query_entities(POSITION | SPRITE).collect();
            world.despawn_entities(&all_entities);
            return Some(Box::new(GameOverState { score, restart: false }));
        }
        None
    }
}
}

The board is centered in the terminal using world.resources.terminal_size. The game ticks on a fixed interval (TICK_INTERVAL = 0.12 seconds, about 8 moves per second), while the render loop runs at 60 FPS for smooth input response.

Step 10: State Transitions

The next_state method enables screen transitions. Return Some(Box::new(...)) to switch states:

struct GameOverState {
    score: u32,
    restart: bool,
}

impl State for GameOverState {
    fn title(&self) -> &str {
        "Snake - Game Over"
    }

    fn initialize(&mut self, world: &mut World) {
        world.resources.timing.target_fps = 30;

        let terminal = world.resources.terminal_size;
        let center_column = terminal.columns as i32 / 2;
        let center_row = terminal.rows as i32 / 2;

        let lines = [
            ("GAME OVER", TermColor::Red),
            ("", TermColor::Black),
            (&format!("Score: {}", self.score), TermColor::White),
            ("", TermColor::Black),
            ("Press R to restart", TermColor::White),
            ("Press ESC to quit", TermColor::Grey),
        ];

        for (line_index, (text, color)) in lines.iter().enumerate() {
            if text.is_empty() { continue; }
            let start_col = center_column - text.len() as i32 / 2;
            for (char_index, character) in text.chars().enumerate() {
                let entity = world.spawn_entities(POSITION | SPRITE | Z_INDEX, 1)[0];
                world.set_position(entity, Position {
                    column: (start_col + char_index as i32) as f64,
                    row: (center_row - 3 + line_index as i32) as f64,
                });
                world.set_sprite(entity, Sprite {
                    character,
                    foreground: *color,
                    background: TermColor::Black,
                });
                world.set_z_index(entity, ZIndex(10));
            }
        }
    }

    fn on_keyboard_input(&mut self, world: &mut World, key: KeyCode, pressed: bool) {
        if !pressed { return; }
        match key {
            KeyCode::Char('r') => self.restart = true,
            KeyCode::Escape | KeyCode::Char('q') => world.resources.should_exit = true,
            _ => {}
        }
    }

    fn next_state(&mut self, world: &mut World) -> Option<Box<dyn State>> {
        if self.restart {
            let all_entities: Vec<Entity> = world.query_entities(POSITION | SPRITE).collect();
            world.despawn_entities(&all_entities);
            return Some(Box::new(SnakeGame::new()));
        }
        None
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    launch(Box::new(SnakeGame::new()))
}

When transitioning, despawn all entities from the current state before returning the new state. The engine calls initialize on the new state with a fresh world (but the same world instance — entities persist unless you remove them).

Available Colors

#![allow(unused)]
fn main() {
pub enum TermColor {
    Black, DarkGrey, Red, DarkRed, Green, DarkGreen,
    Yellow, DarkYellow, Blue, DarkBlue, Magenta, DarkMagenta,
    Cyan, DarkCyan, White, Grey,
    Rgb { r: u8, g: u8, b: u8 },
}
}

The 16 named colors work in all terminals. Rgb requires true-color terminal support (most modern terminals).

Built-In Systems

The TUI framework provides these systems you can call in run_systems:

SystemDescription
movement_system(world)Applies Velocity to Position each frame
collision_pairs(world)Returns Vec<Contact> for all overlapping Collider pairs
resolve_collision(world, &contact)Pushes both entities apart equally
resolve_collision_static(world, &contact, static_entity)Pushes only the non-static entity
parent_transform_system(world)Updates child positions from Parent + LocalOffset
cascade_despawn(world, entity)Despawns entity and all its children

Key Differences from 3D Engine

3D EngineTUI Framework
nightshade::prelude::*nightshade::tui::prelude::*
launch(state)launch(Box::new(state))
world.resources.window.timing.delta_timeworld.resources.timing.delta_seconds
KeyCode::KeyWKeyCode::Char('w')
ElementState (Pressed/Released)bool (pressed)
LocalTransform (Vec3 position)Position (column/row f64)
3D meshes + materialsSprite (char + colors)
mark_local_transform_dirty()Not needed — positions take effect immediately

Where to Go Next

The TUI framework supports much more than Snake:

  • Tilemaps: Use Tilemap for efficient grid rendering (roguelikes, RPGs)
  • Sprite Animation: Use SpriteAnimation with a list of frame characters
  • Collision Detection: Use Collider with layers and masks for selective collision
  • Mouse Input: Handle clicks and movement with on_mouse_input
  • Camera Scrolling: Set world.resources.camera.offset_column/row for viewport scrolling

See the 34 terminal examples in the nightshade-examples repository for complete implementations of roguelikes, platformers, puzzle games, strategy games, and more.

Minimal Example

The simplest possible Nightshade application.

Complete Code

use nightshade::prelude::*;

struct MinimalGame;

impl State for MinimalGame {
    fn initialize(&mut self, world: &mut World) {
        let camera = spawn_camera(world, Vec3::new(0.0, 5.0, 10.0), "Camera".to_string());
        world.resources.active_camera = Some(camera);

        spawn_cube_at(world, Vec3::new(0.0, 0.0, -5.0));

        spawn_sun(world);
    }

    fn run_systems(&mut self, world: &mut World) {
        fly_camera_system(world);
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    nightshade::launch(MinimalGame)
}

Step-by-Step Breakdown

1. Import the Prelude

#![allow(unused)]
fn main() {
use nightshade::prelude::*;
}

The prelude exports all commonly used types:

  • State trait
  • World struct
  • Entity type
  • Math types (Vec3, Vec4, Mat4, etc.)
  • Component flags (LOCAL_TRANSFORM, RENDER_MESH, etc.)
  • Common functions (spawn_cube_at, spawn_camera, etc.)

2. Define Your Game State

#![allow(unused)]
fn main() {
struct MinimalGame;
}

Your game state struct holds all game-specific data. It can be empty for simple demos or contain complex game logic:

#![allow(unused)]
fn main() {
struct MinimalGame {
    score: u32,
    player: Option<Entity>,
    enemies: Vec<Entity>,
}
}

3. Implement the State Trait

#![allow(unused)]
fn main() {
impl State for MinimalGame {
    fn initialize(&mut self, world: &mut World) {
        // Called once at startup
    }
}
}

The State trait has many optional methods:

MethodPurpose
initializeSetup at startup
run_systemsGame logic each frame
uiegui-based UI
on_keyboard_inputKey press/release
on_mouse_inputMouse button events
on_gamepad_eventGamepad input
configure_render_graphCustom rendering
next_stateState transitions

4. Set Up the Scene

#![allow(unused)]
fn main() {
fn initialize(&mut self, world: &mut World) {
    let camera = spawn_camera(world, Vec3::new(0.0, 5.0, 10.0), "Camera".to_string());
    world.resources.active_camera = Some(camera);

    spawn_cube_at(world, Vec3::new(0.0, 0.0, -5.0));

    spawn_sun(world);
}
}

5. Run the Application

fn main() -> Result<(), Box<dyn std::error::Error>> {
    nightshade::launch(MinimalGame)
}

The launch function:

  1. Creates the window
  2. Initializes the renderer
  3. Calls initialize on your state
  4. Runs the game loop
  5. Handles input events
  6. Calls run_systems each frame

Adding Cargo.toml

[package]
name = "minimal-game"
version = "0.1.0"
edition = "2024"

[dependencies]
nightshade = { git = "https://github.com/user/nightshade", features = ["engine", "wgpu"] }

Running

cargo run --release

Release mode is recommended for better performance.

Controls

The fly camera uses standard controls:

  • WASD - Move horizontally
  • Space/Shift - Move up/down
  • Mouse - Look around
  • Escape - Release cursor

Extending the Example

Add More Objects

#![allow(unused)]
fn main() {
fn initialize(&mut self, world: &mut World) {
    let camera = spawn_camera(world, Vec3::new(0.0, 5.0, 10.0), "Camera".to_string());
    world.resources.active_camera = Some(camera);

    spawn_plane_at(world, Vec3::zeros());

    for index in 0..5 {
        spawn_cube_at(world, Vec3::new(index as f32 * 2.0 - 4.0, 0.5, -5.0));
    }

    spawn_sun(world);
}
}

Add Animation

#![allow(unused)]
fn main() {
struct MinimalGame {
    cube: Option<Entity>,
    time: f32,
}

impl State for MinimalGame {
    fn initialize(&mut self, world: &mut World) {
        let camera = spawn_camera(world, Vec3::new(0.0, 5.0, 10.0), "Camera".to_string());
        world.resources.active_camera = Some(camera);

        self.cube = Some(spawn_cube_at(world, Vec3::new(0.0, 0.0, -5.0)));

        spawn_sun(world);
    }

    fn run_systems(&mut self, world: &mut World) {
        let dt = world.resources.window.timing.delta_time;
        self.time += dt;

        if let Some(cube) = self.cube {
            if let Some(transform) = world.core.get_local_transform_mut(cube) {
                transform.rotation = nalgebra_glm::quat_angle_axis(
                    self.time,
                    &Vec3::y(),
                );
            }
        }
    }
}
}

Add Input Handling

#![allow(unused)]
fn main() {
impl State for MinimalGame {
    fn on_keyboard_input(&mut self, world: &mut World, key: KeyCode, state: ElementState) {
        if state == ElementState::Pressed {
            match key {
                KeyCode::Escape => std::process::exit(0),
                KeyCode::Space => self.spawn_cube(world),
                _ => {}
            }
        }
    }
}
}

What's Next

From this foundation, you can:

  • Add physics with rigid bodies and colliders
  • Load 3D models with import_gltf_from_path and spawn_prefab_with_animations
  • Add skeletal animation
  • Implement game logic in run_systems
  • Create UI with egui
  • Add audio with Kira

See the other examples for complete implementations of these features.

First Person Game

A complete first-person shooter/exploration template with physics, audio, and weapons.

Complete Example

use nightshade::prelude::*;
use nightshade::ecs::physics::commands::{
    spawn_static_physics_cube_with_material,
    spawn_dynamic_physics_cube_with_material,
};
use nightshade::ecs::physics::RigidBodyType;

struct FirstPersonGame {
    player: Option<Entity>,
    camera: Option<Entity>,
    weapon: Option<Entity>,
    health: f32,
    ammo: u32,
    score: u32,
    footstep_timer: f32,
    paused: bool,
}

impl Default for FirstPersonGame {
    fn default() -> Self {
        Self {
            player: None,
            camera: None,
            weapon: None,
            health: 100.0,
            ammo: 30,
            score: 0,
            footstep_timer: 0.0,
            paused: false,
        }
    }
}

impl State for FirstPersonGame {
    fn initialize(&mut self, world: &mut World) {
        self.setup_player(world);
        self.setup_level(world);
        self.setup_lighting(world);
        self.setup_ui(world);

        world.set_cursor_visible(false);
        world.set_cursor_locked(true);
    }

    fn run_systems(&mut self, world: &mut World) {
        let dt = world.resources.window.timing.delta_time;

        self.update_player_movement(world, dt);
        self.update_weapon_sway(world, dt);
        self.update_footsteps(world, dt);

        run_physics_systems(world);
        sync_transforms_from_physics_system(world);
    }

    fn on_keyboard_input(&mut self, world: &mut World, key: KeyCode, state: ElementState) {
        if state == ElementState::Pressed {
            match key {
                KeyCode::Escape => self.toggle_pause(world),
                KeyCode::KeyR => self.reload_weapon(),
                _ => {}
            }
        }
    }

    fn on_mouse_input(&mut self, world: &mut World, state: ElementState, button: MouseButton) {
        if button == MouseButton::Left && state == ElementState::Pressed {
            self.fire_weapon(world);
        }
    }
}

impl FirstPersonGame {
    fn setup_player(&mut self, world: &mut World) {
        let player = world.spawn_entities(
            NAME | LOCAL_TRANSFORM | GLOBAL_TRANSFORM | LOCAL_TRANSFORM_DIRTY
                | CHARACTER_CONTROLLER,
            1,
        )[0];

        world.core.set_name(player, Name("Player".to_string()));
        world.core.set_local_transform(player, LocalTransform {
            translation: Vec3::new(0.0, 1.8, 0.0),
            ..Default::default()
        });

        if let Some(controller) = world.core.get_character_controller_mut(player) {
            *controller = CharacterControllerComponent::new_capsule(0.7, 0.3);
            controller.max_speed = 5.0;
            controller.acceleration = 20.0;
            controller.jump_impulse = 7.0;
        }

        let camera = world.spawn_entities(
            NAME | LOCAL_TRANSFORM | GLOBAL_TRANSFORM | LOCAL_TRANSFORM_DIRTY
                | CAMERA | PARENT,
            1,
        )[0];

        world.core.set_name(camera, Name("Player Camera".to_string()));
        world.core.set_local_transform(camera, LocalTransform {
            translation: Vec3::new(0.0, 0.7, 0.0),
            ..Default::default()
        });

        world.core.set_camera(camera, Camera {
            projection: Projection::Perspective(PerspectiveCamera {
                y_fov_rad: 75.0_f32.to_radians(),
                z_near: 0.1,
                z_far: Some(1000.0),
                aspect_ratio: None,
            }),
            smoothing: None,
        });

        world.core.set_parent(camera, Parent(Some(player)));
        world.resources.active_camera = Some(camera);

        self.setup_weapon(world, camera);
        self.player = Some(player);
        self.camera = Some(camera);
    }

    fn setup_weapon(&mut self, world: &mut World, camera: Entity) {
        let weapon = spawn_cube_at(world, Vec3::zeros());

        world.core.set_local_transform(weapon, LocalTransform {
            translation: Vec3::new(0.3, -0.2, -0.5),
            rotation: nalgebra_glm::quat_angle_axis(
                std::f32::consts::PI,
                &Vec3::y(),
            ),
            scale: Vec3::new(0.05, 0.05, 0.3),
        });

        set_material_with_textures(world, weapon, Material {
            base_color: [0.2, 0.2, 0.2, 1.0],
            roughness: 0.4,
            metallic: 0.9,
            ..Default::default()
        });

        world.core.set_parent(weapon, Parent(Some(camera)));
        self.weapon = Some(weapon);
    }

    fn setup_level(&mut self, world: &mut World) {
        spawn_static_physics_cube_with_material(
            world,
            Vec3::zeros(),
            Vec3::new(100.0, 0.2, 100.0),
            Material {
                base_color: [0.3, 0.3, 0.3, 1.0],
                roughness: 0.9,
                ..Default::default()
            },
        );

        for index in 0..10 {
            let angle = index as f32 * std::f32::consts::TAU / 10.0;
            let distance = 20.0;

            spawn_static_physics_cube_with_material(
                world,
                Vec3::new(angle.cos() * distance, 2.0, angle.sin() * distance),
                Vec3::new(5.0, 4.0, 0.5),
                Material {
                    base_color: [0.5, 0.5, 0.5, 1.0],
                    roughness: 0.8,
                    ..Default::default()
                },
            );
        }

        for index in 0..5 {
            spawn_dynamic_physics_cube_with_material(
                world,
                Vec3::new((index as f32 - 2.0) * 3.0, 0.5, -10.0),
                Vec3::new(1.0, 1.0, 1.0),
                10.0,
                Material {
                    base_color: [0.6, 0.4, 0.2, 1.0],
                    roughness: 0.8,
                    ..Default::default()
                },
            );
        }
    }

    fn setup_lighting(&mut self, world: &mut World) {
        spawn_sun(world);
        world.resources.graphics.ambient_light = [0.1, 0.1, 0.1, 1.0];
    }

    fn setup_ui(&mut self, world: &mut World) {
        spawn_ui_text(world, &format!("Health: {}", self.health as u32), Vec2::new(20.0, 550.0));

        spawn_ui_text(world, &format!("Ammo: {}", self.ammo), Vec2::new(700.0, 550.0));

        let crosshair = spawn_ui_text_with_properties(
            world,
            "+",
            Vec2::new(400.0, 300.0),
            TextProperties {
                font_size: 24.0,
                color: Vec4::new(1.0, 1.0, 1.0, 0.8),
                alignment: TextAlignment::Center,
                ..Default::default()
            },
        );
    }

    fn update_player_movement(&mut self, world: &mut World, dt: f32) {
        let Some(player) = self.player else { return };

        let keyboard = &world.resources.input.keyboard;
        let position_delta = world.resources.input.mouse.position_delta;

        let mut move_input = Vec3::zeros();
        if keyboard.is_key_pressed(KeyCode::KeyW) { move_input.z -= 1.0; }
        if keyboard.is_key_pressed(KeyCode::KeyS) { move_input.z += 1.0; }
        if keyboard.is_key_pressed(KeyCode::KeyA) { move_input.x -= 1.0; }
        if keyboard.is_key_pressed(KeyCode::KeyD) { move_input.x += 1.0; }

        if move_input.magnitude() > 0.0 {
            move_input = move_input.normalize();
        }

        let sprint = keyboard.is_key_pressed(KeyCode::ShiftLeft);
        let speed = if sprint { 8.0 } else { 5.0 };

        if let Some(controller) = world.core.get_character_controller_mut(player) {
            if let Some(transform) = world.core.get_local_transform(player) {
                let forward = transform.rotation * Vec3::new(0.0, 0.0, -1.0);
                let right = transform.rotation * Vec3::new(1.0, 0.0, 0.0);

                let forward_flat = Vec3::new(forward.x, 0.0, forward.z).normalize();
                let right_flat = Vec3::new(right.x, 0.0, right.z).normalize();

                let world_move = forward_flat * -move_input.z + right_flat * move_input.x;
                controller.velocity.x = world_move.x * speed;
                controller.velocity.z = world_move.z * speed;

                if keyboard.is_key_pressed(KeyCode::Space) && controller.grounded {
                    controller.velocity.y = controller.jump_impulse;
                }
            }
        }

        if let Some(transform) = world.core.get_local_transform_mut(player) {
            let sensitivity = 0.002;
            let yaw = nalgebra_glm::quat_angle_axis(
                -position_delta.x * sensitivity,
                &Vec3::y(),
            );
            transform.rotation = yaw * transform.rotation;
        }

        if let Some(camera) = world.resources.active_camera {
            if let Some(transform) = world.core.get_local_transform_mut(camera) {
                let sensitivity = 0.002;
                let pitch = nalgebra_glm::quat_angle_axis(
                    -position_delta.y * sensitivity,
                    &Vec3::x(),
                );
                transform.rotation = transform.rotation * pitch;
            }
        }
    }

    fn update_weapon_sway(&mut self, world: &mut World, dt: f32) {
        let Some(weapon) = self.weapon else { return };

        let position_delta = world.resources.input.mouse.position_delta;

        if let Some(transform) = world.core.get_local_transform_mut(weapon) {
            let target_x = 0.3 - position_delta.x * 0.001;
            let target_y = -0.2 - position_delta.y * 0.001;

            transform.translation.x += (target_x - transform.translation.x) * dt * 10.0;
            transform.translation.y += (target_y - transform.translation.y) * dt * 10.0;
        }
    }

    fn update_footsteps(&mut self, world: &mut World, dt: f32) {
        let Some(player) = self.player else { return };

        let keyboard = &world.resources.input.keyboard;
        let moving = keyboard.is_key_pressed(KeyCode::KeyW) ||
                     keyboard.is_key_pressed(KeyCode::KeyS) ||
                     keyboard.is_key_pressed(KeyCode::KeyA) ||
                     keyboard.is_key_pressed(KeyCode::KeyD);

        if let Some(controller) = world.core.get_character_controller(player) {
            if moving && controller.grounded {
                self.footstep_timer -= dt;
                if self.footstep_timer <= 0.0 {
                    self.footstep_timer = 0.4;
                }
            }
        }
    }

    fn fire_weapon(&mut self, world: &mut World) {
        if self.ammo == 0 {
            return;
        }

        self.ammo -= 1;

        let Some(camera) = world.resources.active_camera else { return };
        let Some(transform) = world.core.get_global_transform(camera) else { return };

        let origin = transform.translation();
        let direction = transform.forward_vector();

        for entity in world.core.query_entities(RIGID_BODY | GLOBAL_TRANSFORM) {
            let Some(entity_transform) = world.core.get_global_transform(entity) else {
                continue;
            };

            let to_entity = entity_transform.translation() - origin;
            let distance = to_entity.magnitude();

            if distance > 100.0 || distance < 0.1 {
                continue;
            }

            let dot = direction.dot(&to_entity.normalize());
            if dot > 0.99 {
                if let Some(body) = world.core.get_rigid_body_mut(entity) {
                    if body.body_type == RigidBodyType::Dynamic {
                        body.linvel = [
                            body.linvel[0] + direction.x * 10.0,
                            body.linvel[1] + direction.y * 10.0,
                            body.linvel[2] + direction.z * 10.0,
                        ];
                    }
                }
                break;
            }
        }
    }

    fn reload_weapon(&mut self) {
        self.ammo = 30;
    }

    fn toggle_pause(&mut self, world: &mut World) {
        self.paused = !self.paused;
        world.set_cursor_visible(self.paused);
        world.set_cursor_locked(!self.paused);
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    nightshade::launch(FirstPersonGame::default())
}

Key Components

Character Controller

The character controller handles physics-based movement:

#![allow(unused)]
fn main() {
if let Some(controller) = world.core.get_character_controller_mut(player) {
    *controller = CharacterControllerComponent::new_capsule(0.7, 0.3);
    controller.max_speed = 5.0;
    controller.acceleration = 20.0;
    controller.jump_impulse = 7.0;
}
}

Camera Setup

First-person camera is parented to the player:

#![allow(unused)]
fn main() {
world.core.set_parent(camera, Parent(Some(player)));
}

This makes the camera follow the player automatically.

Weapon Attachment

The weapon is parented to the camera so it stays in view:

#![allow(unused)]
fn main() {
world.core.set_parent(weapon, Parent(Some(camera)));
}

Mouse Look

Horizontal rotation (yaw) goes on the player body, vertical rotation (pitch) goes on the camera:

#![allow(unused)]
fn main() {
transform.rotation = yaw * transform.rotation;

transform.rotation = transform.rotation * pitch;
}

This prevents gimbal lock and feels natural.

Physics Spawning

Static objects (floors, walls) use spawn_static_physics_cube_with_material. Dynamic objects (crates) use spawn_dynamic_physics_cube_with_material:

#![allow(unused)]
fn main() {
use nightshade::ecs::physics::commands::{
    spawn_static_physics_cube_with_material,
    spawn_dynamic_physics_cube_with_material,
};

spawn_static_physics_cube_with_material(world, position, size, material);
spawn_dynamic_physics_cube_with_material(world, position, size, mass, material);
}

Cargo.toml

[package]
name = "fps-game"
version = "0.1.0"
edition = "2024"

[dependencies]
nightshade = { git = "...", features = ["engine", "wgpu", "physics"] }

Third Person Game

A complete third-person action game template with character animation, combat, and camera control.

Complete Example

use nightshade::prelude::*;
use nightshade::ecs::physics::commands::spawn_static_physics_cube_with_material;
use nightshade::ecs::physics::RigidBodyType;

struct ThirdPersonGame {
    player: Option<Entity>,
    camera: Option<Entity>,
    camera_target: Vec3,
    camera_distance: f32,
    camera_pitch: f32,
    camera_yaw: f32,

    player_state: PlayerState,
    attack_timer: f32,
    dodge_timer: f32,
    health: f32,
}

#[derive(Default, PartialEq)]
enum PlayerState {
    #[default]
    Idle,
    Walking,
    Running,
    Attacking,
    Dodging,
}

impl Default for ThirdPersonGame {
    fn default() -> Self {
        Self {
            player: None,
            camera: None,
            camera_target: Vec3::zeros(),
            camera_distance: 5.0,
            camera_pitch: 0.3,
            camera_yaw: 0.0,
            player_state: PlayerState::Idle,
            attack_timer: 0.0,
            dodge_timer: 0.0,
            health: 100.0,
        }
    }
}

impl State for ThirdPersonGame {
    fn initialize(&mut self, world: &mut World) {
        self.setup_player(world);
        self.setup_camera(world);
        self.setup_level(world);
        self.setup_lighting(world);

        world.set_cursor_visible(false);
        world.set_cursor_locked(true);
    }

    fn run_systems(&mut self, world: &mut World) {
        let dt = world.resources.window.timing.delta_time;

        self.update_camera_input(world);
        self.update_player_movement(world, dt);
        self.update_player_state(world, dt);
        self.update_camera_position(world, dt);
        self.update_animations(world);

        run_physics_systems(world);
        sync_transforms_from_physics_system(world);
        update_animation_players(world, dt);
    }

    fn on_mouse_input(&mut self, world: &mut World, state: ElementState, button: MouseButton) {
        if state == ElementState::Pressed {
            match button {
                MouseButton::Left => self.attack(world),
                MouseButton::Right => self.dodge(world),
                _ => {}
            }
        }
    }
}

impl ThirdPersonGame {
    fn setup_player(&mut self, world: &mut World) {
        let controller_entity = world.spawn_entities(
            NAME | LOCAL_TRANSFORM | GLOBAL_TRANSFORM | LOCAL_TRANSFORM_DIRTY
                | CHARACTER_CONTROLLER | COLLIDER,
            1,
        )[0];

        world.core.set_name(controller_entity, Name("Player".to_string()));

        if let Some(controller) = world.core.get_character_controller_mut(controller_entity) {
            *controller = CharacterControllerComponent::new_capsule(0.6, 0.4);
            controller.max_speed = 4.0;
            controller.acceleration = 20.0;
            controller.jump_impulse = 8.0;
        }

        if let Some(collider) = world.core.get_collider_mut(controller_entity) {
            *collider = ColliderComponent::new_capsule(0.6, 0.4);
        }

        let model = spawn_cube_at(world, Vec3::zeros());
        if let Some(transform) = world.core.get_local_transform_mut(model) {
            transform.translation = Vec3::new(0.0, -0.9, 0.0);
            transform.scale = Vec3::new(0.6, 1.8, 0.4);
        }
        set_material_with_textures(world, model, Material {
            base_color: [0.3, 0.5, 0.8, 1.0],
            roughness: 0.6,
            ..Default::default()
        });
        world.core.set_parent(model, Parent(Some(controller_entity)));

        self.player = Some(controller_entity);
    }

    fn setup_camera(&mut self, world: &mut World) {
        let camera = world.spawn_entities(
            NAME | LOCAL_TRANSFORM | GLOBAL_TRANSFORM | LOCAL_TRANSFORM_DIRTY | CAMERA,
            1,
        )[0];

        world.core.set_name(camera, Name("Camera".to_string()));
        world.core.set_camera(camera, Camera {
            projection: Projection::Perspective(PerspectiveCamera {
                y_fov_rad: 60.0_f32.to_radians(),
                z_near: 0.1,
                z_far: Some(1000.0),
                aspect_ratio: None,
            }),
            smoothing: None,
        });

        world.resources.active_camera = Some(camera);
        self.camera = Some(camera);
    }

    fn setup_level(&mut self, world: &mut World) {
        spawn_static_physics_cube_with_material(
            world,
            Vec3::zeros(),
            Vec3::new(200.0, 0.2, 200.0),
            Material {
                base_color: [0.2, 0.5, 0.2, 1.0],
                roughness: 0.9,
                ..Default::default()
            },
        );

        for index in 0..20 {
            let x = (index % 5) as f32 * 15.0 - 30.0;
            let z = (index / 5) as f32 * 15.0 - 30.0;
            let scale = 1.0 + (index as f32 * 0.3) % 1.5;

            spawn_static_physics_cube_with_material(
                world,
                Vec3::new(x, scale * 0.5, z),
                Vec3::new(scale, scale, scale),
                Material {
                    base_color: [0.4, 0.4, 0.4, 1.0],
                    roughness: 0.95,
                    ..Default::default()
                },
            );
        }
    }

    fn setup_lighting(&mut self, world: &mut World) {
        spawn_sun(world);
        world.resources.graphics.ambient_light = [0.2, 0.2, 0.2, 1.0];
    }

    fn update_camera_input(&mut self, world: &mut World) {
        let position_delta = world.resources.input.mouse.position_delta;
        let scroll = world.resources.input.mouse.wheel_delta;

        let sensitivity = 0.003;
        self.camera_yaw -= position_delta.x * sensitivity;
        self.camera_pitch -= position_delta.y * sensitivity;

        self.camera_pitch = self.camera_pitch.clamp(-1.2, 1.2);

        self.camera_distance -= scroll.y * 0.5;
        self.camera_distance = self.camera_distance.clamp(2.0, 15.0);
    }

    fn update_player_movement(&mut self, world: &mut World, dt: f32) {
        if self.player_state == PlayerState::Attacking ||
           self.player_state == PlayerState::Dodging {
            return;
        }

        let Some(player) = self.player else { return };

        let keyboard = &world.resources.input.keyboard;

        let mut move_input = Vec2::zeros();
        if keyboard.is_key_pressed(KeyCode::KeyW) { move_input.y -= 1.0; }
        if keyboard.is_key_pressed(KeyCode::KeyS) { move_input.y += 1.0; }
        if keyboard.is_key_pressed(KeyCode::KeyA) { move_input.x -= 1.0; }
        if keyboard.is_key_pressed(KeyCode::KeyD) { move_input.x += 1.0; }

        let running = keyboard.is_key_pressed(KeyCode::ShiftLeft);

        if move_input.magnitude() > 0.0 {
            move_input = move_input.normalize();

            let camera_forward = Vec3::new(
                self.camera_yaw.sin(),
                0.0,
                self.camera_yaw.cos(),
            );
            let camera_right = Vec3::new(
                self.camera_yaw.cos(),
                0.0,
                -self.camera_yaw.sin(),
            );

            let world_direction = camera_forward * -move_input.y + camera_right * move_input.x;

            if let Some(transform) = world.core.get_local_transform_mut(player) {
                let target_rotation = nalgebra_glm::quat_angle_axis(
                    world_direction.x.atan2(world_direction.z),
                    &Vec3::y(),
                );
                transform.rotation = nalgebra_glm::quat_slerp(
                    &transform.rotation,
                    &target_rotation,
                    dt * 10.0,
                );
            }

            let speed = if running { 8.0 } else { 4.0 };
            if let Some(controller) = world.core.get_character_controller_mut(player) {
                controller.velocity.x = world_direction.x * speed;
                controller.velocity.z = world_direction.z * speed;
            }

            self.player_state = if running { PlayerState::Running } else { PlayerState::Walking };
        } else {
            if let Some(controller) = world.core.get_character_controller_mut(player) {
                controller.velocity.x = 0.0;
                controller.velocity.z = 0.0;
            }
            self.player_state = PlayerState::Idle;
        }

        if keyboard.is_key_pressed(KeyCode::Space) {
            if let Some(controller) = world.core.get_character_controller_mut(player) {
                if controller.grounded {
                    controller.velocity.y = controller.jump_impulse;
                }
            }
        }
    }

    fn update_player_state(&mut self, world: &mut World, dt: f32) {
        if self.attack_timer > 0.0 {
            self.attack_timer -= dt;
            if self.attack_timer <= 0.0 {
                self.player_state = PlayerState::Idle;
            }
        }

        if self.dodge_timer > 0.0 {
            self.dodge_timer -= dt;
            if self.dodge_timer <= 0.0 {
                self.player_state = PlayerState::Idle;
            }
        }
    }

    fn update_camera_position(&mut self, world: &mut World, dt: f32) {
        let Some(player) = self.player else { return };
        let Some(camera) = self.camera else { return };

        if let Some(player_transform) = world.core.get_global_transform(player) {
            let target = player_transform.translation() + Vec3::new(0.0, 1.5, 0.0);
            self.camera_target = nalgebra_glm::lerp(
                &self.camera_target,
                &target,
                dt * 8.0,
            );
        }

        let offset = Vec3::new(
            self.camera_yaw.sin() * self.camera_pitch.cos(),
            self.camera_pitch.sin(),
            self.camera_yaw.cos() * self.camera_pitch.cos(),
        ) * self.camera_distance;

        let camera_position = self.camera_target + offset;

        if let Some(transform) = world.core.get_local_transform_mut(camera) {
            transform.translation = camera_position;

            let direction = (self.camera_target - camera_position).normalize();
            let pitch = (-direction.y).asin();
            let yaw = direction.x.atan2(direction.z);

            transform.rotation = nalgebra_glm::quat_angle_axis(yaw, &Vec3::y())
                * nalgebra_glm::quat_angle_axis(pitch, &Vec3::x());
        }
    }

    fn update_animations(&mut self, world: &mut World) {
        let Some(player) = self.player else { return };

        let children = world.resources.children_cache.get(&player).cloned().unwrap_or_default();
        for child in children {
            if let Some(animation_player) = world.core.get_animation_player_mut(child) {
                let animation_name = match self.player_state {
                    PlayerState::Idle => "idle",
                    PlayerState::Walking => "walk",
                    PlayerState::Running => "run",
                    PlayerState::Attacking => "attack",
                    PlayerState::Dodging => "dodge",
                };

                if animation_player.current_animation() != Some(animation_name) {
                    animation_player.blend_to(animation_name, 0.2);
                }
            }
        }
    }

    fn attack(&mut self, world: &mut World) {
        if self.player_state == PlayerState::Attacking ||
           self.player_state == PlayerState::Dodging {
            return;
        }

        self.player_state = PlayerState::Attacking;
        self.attack_timer = 0.6;

        self.check_attack_hits(world);
    }

    fn check_attack_hits(&self, world: &mut World) {
        let Some(player) = self.player else { return };

        if let Some(transform) = world.core.get_global_transform(player) {
            let attack_origin = transform.translation() + Vec3::new(0.0, 1.0, 0.0);
            let forward = transform.forward_vector();
            let attack_range = 2.0;

            for entity in world.core.query_entities(GLOBAL_TRANSFORM) {
                if entity == player { continue; }

                if let Some(target_transform) = world.core.get_global_transform(entity) {
                    let to_target = target_transform.translation() - attack_origin;
                    let distance = to_target.magnitude();
                    let dot = forward.dot(&to_target.normalize());

                    if distance < attack_range && dot > 0.5 {
                        self.apply_damage(world, entity, 25.0);
                    }
                }
            }
        }
    }

    fn apply_damage(&self, world: &mut World, entity: Entity, damage: f32) {
    }

    fn dodge(&mut self, world: &mut World) {
        if self.player_state == PlayerState::Attacking ||
           self.player_state == PlayerState::Dodging {
            return;
        }

        let Some(player) = self.player else { return };

        self.player_state = PlayerState::Dodging;
        self.dodge_timer = 0.5;

        if let Some(transform) = world.core.get_local_transform(player) {
            let forward = transform.rotation * Vec3::new(0.0, 0.0, -1.0);
            if let Some(controller) = world.core.get_character_controller_mut(player) {
                controller.velocity.x = forward.x * 12.0;
                controller.velocity.z = forward.z * 12.0;
            }
        }
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    nightshade::launch(ThirdPersonGame::default())
}

Key Systems

Orbit Camera

The camera orbits around the player using spherical coordinates:

#![allow(unused)]
fn main() {
let offset = Vec3::new(
    self.camera_yaw.sin() * self.camera_pitch.cos(),
    self.camera_pitch.sin(),
    self.camera_yaw.cos() * self.camera_pitch.cos(),
) * self.camera_distance;
}

Mouse X controls yaw, mouse Y controls pitch, scroll controls distance.

Camera-Relative Movement

Player moves relative to where the camera is looking:

#![allow(unused)]
fn main() {
let camera_forward = Vec3::new(
    self.camera_yaw.sin(),
    0.0,
    self.camera_yaw.cos(),
);

let world_direction = camera_forward * -move_input.y + camera_right * move_input.x;
}

Character Rotation

The character smoothly rotates to face movement direction:

#![allow(unused)]
fn main() {
transform.rotation = nalgebra_glm::quat_slerp(
    &transform.rotation,
    &target_rotation,
    dt * 10.0,
);
}

Animation Blending

Animations blend smoothly when state changes:

#![allow(unused)]
fn main() {
animation_player.blend_to(animation_name, 0.2);
}

State Machine

Simple state machine prevents conflicting actions:

#![allow(unused)]
fn main() {
if self.player_state == PlayerState::Attacking ||
   self.player_state == PlayerState::Dodging {
    return;
}
}

Cargo.toml

[package]
name = "third-person-game"
version = "0.1.0"
edition = "2024"

[dependencies]
nightshade = { git = "...", features = ["engine", "wgpu", "physics"] }

Physics Playground

Live Demo: Physics

An interactive physics sandbox demonstrating rigid bodies, colliders, joints, and forces.

Complete Example

use nightshade::prelude::*;
use nightshade::ecs::physics::commands::{
    spawn_static_physics_cube_with_material,
    spawn_dynamic_physics_cube_with_material,
    spawn_dynamic_physics_sphere_with_material,
    spawn_dynamic_physics_cylinder_with_material,
};
use nightshade::ecs::physics::{
    RigidBodyType, SphericalJoint, create_spherical_joint,
};

struct PhysicsPlayground {
    spawn_mode: SpawnMode,
    selected_entity: Option<Entity>,
    holding_entity: Option<Entity>,
    grab_distance: f32,
}

#[derive(Default, Clone, Copy)]
enum SpawnMode {
    #[default]
    Cube,
    Sphere,
    Cylinder,
    Chain,
    Ragdoll,
}

impl Default for PhysicsPlayground {
    fn default() -> Self {
        Self {
            spawn_mode: SpawnMode::Cube,
            selected_entity: None,
            holding_entity: None,
            grab_distance: 5.0,
        }
    }
}

impl State for PhysicsPlayground {
    fn initialize(&mut self, world: &mut World) {
        let camera = spawn_camera(world, Vec3::new(0.0, 5.0, 10.0), "Camera".to_string());
        world.resources.active_camera = Some(camera);
        self.setup_environment(world);
        self.setup_ui(world);

        world.set_cursor_visible(false);
        world.set_cursor_locked(true);
    }

    fn run_systems(&mut self, world: &mut World) {
        fly_camera_system(world);
        self.update_held_object(world);

        run_physics_systems(world);
        sync_transforms_from_physics_system(world);
    }

    fn on_keyboard_input(&mut self, world: &mut World, key: KeyCode, state: ElementState) {
        if state == ElementState::Pressed {
            match key {
                KeyCode::Digit1 => self.spawn_mode = SpawnMode::Cube,
                KeyCode::Digit2 => self.spawn_mode = SpawnMode::Sphere,
                KeyCode::Digit3 => self.spawn_mode = SpawnMode::Cylinder,
                KeyCode::Digit4 => self.spawn_mode = SpawnMode::Chain,
                KeyCode::Digit5 => self.spawn_mode = SpawnMode::Ragdoll,
                KeyCode::KeyR => self.reset_scene(world),
                KeyCode::KeyF => self.apply_explosion(world),
                KeyCode::KeyG => self.toggle_gravity(world),
                _ => {}
            }
        }
    }

    fn on_mouse_input(&mut self, world: &mut World, state: ElementState, button: MouseButton) {
        match (button, state) {
            (MouseButton::Left, ElementState::Pressed) => {
                self.spawn_object(world);
            }
            (MouseButton::Right, ElementState::Pressed) => {
                self.grab_object(world);
            }
            (MouseButton::Right, ElementState::Released) => {
                self.release_object(world);
            }
            (MouseButton::Middle, ElementState::Pressed) => {
                self.delete_at_cursor(world);
            }
            _ => {}
        }
    }
}

impl PhysicsPlayground {
    fn setup_environment(&mut self, world: &mut World) {
        spawn_static_physics_cube_with_material(
            world,
            Vec3::zeros(),
            Vec3::new(100.0, 0.2, 100.0),
            Material {
                base_color: [0.3, 0.3, 0.35, 1.0],
                roughness: 0.8,
                ..Default::default()
            },
        );

        self.spawn_walls(world);

        spawn_sun(world);
        world.resources.graphics.ambient_light = [0.2, 0.2, 0.2, 1.0];
    }

    fn spawn_walls(&mut self, world: &mut World) {
        let wall_positions = [
            (Vec3::new(25.0, 2.5, 0.0), Vec3::new(1.0, 5.0, 100.0)),
            (Vec3::new(-25.0, 2.5, 0.0), Vec3::new(1.0, 5.0, 100.0)),
            (Vec3::new(0.0, 2.5, 25.0), Vec3::new(100.0, 5.0, 1.0)),
            (Vec3::new(0.0, 2.5, -25.0), Vec3::new(100.0, 5.0, 1.0)),
        ];

        for (position, size) in wall_positions {
            spawn_static_physics_cube_with_material(
                world,
                position,
                size,
                Material {
                    base_color: [0.4, 0.4, 0.45, 1.0],
                    roughness: 0.9,
                    ..Default::default()
                },
            );
        }
    }

    fn setup_ui(&mut self, world: &mut World) {
        let help_text = "Controls:\n\
            1-5: Select spawn mode\n\
            Left Click: Spawn object\n\
            Right Click: Grab/throw\n\
            Middle Click: Delete\n\
            R: Reset scene\n\
            F: Explosion\n\
            G: Toggle gravity";

        spawn_ui_text(world, help_text, Vec2::new(20.0, 20.0));
        spawn_ui_text(world, "Mode: Cube", Vec2::new(700.0, 20.0));
    }

    fn spawn_object(&mut self, world: &mut World) {
        let Some(camera) = world.resources.active_camera else { return };
        let Some(transform) = world.core.get_global_transform(camera) else { return };

        let spawn_position = transform.translation() +
            transform.forward_vector() * 5.0;

        match self.spawn_mode {
            SpawnMode::Cube => { self.spawn_cube(world, spawn_position); }
            SpawnMode::Sphere => { self.spawn_sphere(world, spawn_position); }
            SpawnMode::Cylinder => { self.spawn_cylinder(world, spawn_position); }
            SpawnMode::Chain => self.spawn_chain(world, spawn_position),
            SpawnMode::Ragdoll => self.spawn_ragdoll(world, spawn_position),
        }
    }

    fn spawn_cube(&self, world: &mut World, position: Vec3) -> Entity {
        spawn_dynamic_physics_cube_with_material(
            world,
            position,
            Vec3::new(1.0, 1.0, 1.0),
            1.0,
            Material {
                base_color: random_color(),
                roughness: 0.7,
                metallic: 0.1,
                ..Default::default()
            },
        )
    }

    fn spawn_sphere(&self, world: &mut World, position: Vec3) -> Entity {
        spawn_dynamic_physics_sphere_with_material(
            world,
            position,
            0.5,
            1.0,
            Material {
                base_color: random_color(),
                roughness: 0.3,
                metallic: 0.8,
                ..Default::default()
            },
        )
    }

    fn spawn_cylinder(&self, world: &mut World, position: Vec3) -> Entity {
        spawn_dynamic_physics_cylinder_with_material(
            world,
            position,
            0.5,
            0.3,
            1.0,
            Material {
                base_color: random_color(),
                roughness: 0.5,
                metallic: 0.3,
                ..Default::default()
            },
        )
    }

    fn spawn_chain(&self, world: &mut World, start_position: Vec3) {
        let link_count = 10;
        let link_spacing = 0.8;
        let mut previous_link: Option<Entity> = None;

        for index in 0..link_count {
            let position = start_position + Vec3::new(0.0, -(index as f32 * link_spacing), 0.0);

            let link = spawn_dynamic_physics_cylinder_with_material(
                world,
                position,
                0.15,
                0.1,
                if index == 0 { 0.0 } else { 0.5 },
                Material {
                    base_color: [0.7, 0.7, 0.75, 1.0],
                    roughness: 0.3,
                    metallic: 0.9,
                    ..Default::default()
                },
            );

            if index == 0 {
                if let Some(body) = world.core.get_rigid_body_mut(link) {
                    *body = RigidBodyComponent::new_static()
                        .with_translation(position.x, position.y, position.z);
                }
            }

            if let Some(prev) = previous_link {
                create_spherical_joint(
                    world,
                    prev,
                    link,
                    SphericalJoint::new()
                        .with_local_anchor1(Vec3::new(0.0, -link_spacing / 2.0, 0.0))
                        .with_local_anchor2(Vec3::new(0.0, link_spacing / 2.0, 0.0)),
                );
            }

            previous_link = Some(link);
        }
    }

    fn spawn_ragdoll(&self, world: &mut World, position: Vec3) {
        let torso = self.spawn_body_part(world, position, Vec3::new(0.6, 0.8, 0.4), [0.8, 0.6, 0.5, 1.0]);
        let head = self.spawn_body_part(world, position + Vec3::new(0.0, 0.6, 0.0), Vec3::new(0.3, 0.3, 0.3), [0.9, 0.7, 0.6, 1.0]);
        let left_arm = self.spawn_body_part(world, position + Vec3::new(-0.5, 0.2, 0.0), Vec3::new(0.5, 0.16, 0.16), [0.8, 0.6, 0.5, 1.0]);
        let right_arm = self.spawn_body_part(world, position + Vec3::new(0.5, 0.2, 0.0), Vec3::new(0.5, 0.16, 0.16), [0.8, 0.6, 0.5, 1.0]);
        let left_leg = self.spawn_body_part(world, position + Vec3::new(-0.15, -0.6, 0.0), Vec3::new(0.2, 0.6, 0.2), [0.3, 0.3, 0.5, 1.0]);
        let right_leg = self.spawn_body_part(world, position + Vec3::new(0.15, -0.6, 0.0), Vec3::new(0.2, 0.6, 0.2), [0.3, 0.3, 0.5, 1.0]);

        create_spherical_joint(world, torso, head, SphericalJoint::new()
            .with_local_anchor1(Vec3::new(0.0, 0.4, 0.0))
            .with_local_anchor2(Vec3::new(0.0, -0.15, 0.0)));
        create_spherical_joint(world, torso, left_arm, SphericalJoint::new()
            .with_local_anchor1(Vec3::new(-0.3, 0.2, 0.0))
            .with_local_anchor2(Vec3::new(0.25, 0.0, 0.0)));
        create_spherical_joint(world, torso, right_arm, SphericalJoint::new()
            .with_local_anchor1(Vec3::new(0.3, 0.2, 0.0))
            .with_local_anchor2(Vec3::new(-0.25, 0.0, 0.0)));
        create_spherical_joint(world, torso, left_leg, SphericalJoint::new()
            .with_local_anchor1(Vec3::new(-0.15, -0.4, 0.0))
            .with_local_anchor2(Vec3::new(0.0, 0.3, 0.0)));
        create_spherical_joint(world, torso, right_leg, SphericalJoint::new()
            .with_local_anchor1(Vec3::new(0.15, -0.4, 0.0))
            .with_local_anchor2(Vec3::new(0.0, 0.3, 0.0)));
    }

    fn spawn_body_part(&self, world: &mut World, position: Vec3, size: Vec3, color: [f32; 4]) -> Entity {
        let mass = size.x * size.y * size.z * 8.0;
        spawn_dynamic_physics_cube_with_material(
            world,
            position,
            size,
            mass,
            Material {
                base_color: color,
                roughness: 0.8,
                ..Default::default()
            },
        )
    }

    fn grab_object(&mut self, world: &mut World) {
        let Some(camera) = world.resources.active_camera else { return };
        let Some(transform) = world.core.get_global_transform(camera) else { return };

        let origin = transform.translation();
        let direction = transform.forward_vector();

        for entity in world.core.query_entities(RIGID_BODY | GLOBAL_TRANSFORM) {
            let Some(entity_transform) = world.core.get_global_transform(entity) else { continue };
            let to_entity = entity_transform.translation() - origin;
            let distance = to_entity.magnitude();
            let dot = direction.dot(&to_entity.normalize());

            if distance < 20.0 && dot > 0.95 {
                self.holding_entity = Some(entity);
                self.grab_distance = distance;
                break;
            }
        }
    }

    fn release_object(&mut self, world: &mut World) {
        if let Some(entity) = self.holding_entity.take() {
            let Some(camera) = world.resources.active_camera else { return };
            let Some(transform) = world.core.get_global_transform(camera) else { return };
            let throw_direction = transform.forward_vector();

            if let Some(body) = world.core.get_rigid_body_mut(entity) {
                body.linvel = [
                    throw_direction.x * 20.0,
                    throw_direction.y * 20.0,
                    throw_direction.z * 20.0,
                ];
            }
        }
    }

    fn update_held_object(&mut self, world: &mut World) {
        let Some(entity) = self.holding_entity else { return };
        let Some(camera) = world.resources.active_camera else { return };
        let Some(camera_transform) = world.core.get_global_transform(camera) else { return };

        let target = camera_transform.translation() +
            camera_transform.forward_vector() * self.grab_distance;

        if let Some(transform) = world.core.get_local_transform(entity) {
            let to_target = target - transform.translation;
            if let Some(body) = world.core.get_rigid_body_mut(entity) {
                body.linvel = [to_target.x * 20.0, to_target.y * 20.0, to_target.z * 20.0];
            }
        }
    }

    fn delete_at_cursor(&mut self, world: &mut World) {
        let Some(camera) = world.resources.active_camera else { return };
        let Some(transform) = world.core.get_global_transform(camera) else { return };

        let origin = transform.translation();
        let direction = transform.forward_vector();

        let mut closest: Option<(Entity, f32)> = None;
        for entity in world.core.query_entities(RIGID_BODY | GLOBAL_TRANSFORM) {
            let Some(entity_transform) = world.core.get_global_transform(entity) else { continue };
            let to_entity = entity_transform.translation() - origin;
            let distance = to_entity.magnitude();
            let dot = direction.dot(&to_entity.normalize());

            if distance < 50.0 && dot > 0.95 {
                if closest.map_or(true, |(_, closest_distance)| distance < closest_distance) {
                    closest = Some((entity, distance));
                }
            }
        }

        if let Some((entity, _)) = closest {
            world.despawn_entities(&[entity]);
        }
    }

    fn apply_explosion(&self, world: &mut World) {
        let Some(camera) = world.resources.active_camera else { return };
        let Some(transform) = world.core.get_global_transform(camera) else { return };

        let explosion_center = transform.translation() +
            transform.forward_vector() * 5.0;
        let explosion_radius = 10.0;
        let explosion_force = 50.0;

        for entity in world.core.query_entities(RIGID_BODY | GLOBAL_TRANSFORM) {
            if let (Some(body), Some(entity_transform)) = (
                world.core.get_rigid_body_mut(entity),
                world.core.get_global_transform(entity),
            ) {
                let to_entity = entity_transform.translation() - explosion_center;
                let distance = to_entity.magnitude();

                if distance < explosion_radius && distance > 0.1 {
                    let falloff = 1.0 - (distance / explosion_radius);
                    let force = to_entity.normalize() * explosion_force * falloff;
                    body.linvel = [
                        body.linvel[0] + force.x,
                        body.linvel[1] + force.y,
                        body.linvel[2] + force.z,
                    ];
                }
            }
        }
    }

    fn toggle_gravity(&self, world: &mut World) {
        let gravity = &mut world.resources.physics.gravity;
        if gravity.y < 0.0 {
            *gravity = Vec3::zeros();
        } else {
            *gravity = Vec3::new(0.0, -9.81, 0.0);
        }
    }

    fn reset_scene(&mut self, world: &mut World) {
        let entities_to_remove: Vec<Entity> = world.core
            .query_entities(RIGID_BODY)
            .filter(|entity| {
                world.core.get_rigid_body(*entity)
                    .map(|body| body.body_type == RigidBodyType::Dynamic)
                    .unwrap_or(false)
            })
            .collect();

        world.despawn_entities(&entities_to_remove);

        self.holding_entity = None;
        self.selected_entity = None;
    }
}

fn random_color() -> [f32; 4] {
    static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(12345);
    let mut seed = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);

    let mut next = || -> f32 {
        seed = seed.wrapping_mul(1103515245).wrapping_add(12345);
        seed as f32 / u32::MAX as f32
    };

    [
        0.3 + 0.7 * next(),
        0.3 + 0.7 * next(),
        0.3 + 0.7 * next(),
        1.0,
    ]
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    nightshade::launch(PhysicsPlayground::default())
}

Features Demonstrated

Object Spawning

Spawn various physics primitives with random colors using the convenience functions:

#![allow(unused)]
fn main() {
use nightshade::ecs::physics::commands::{
    spawn_dynamic_physics_cube_with_material,
    spawn_dynamic_physics_sphere_with_material,
    spawn_dynamic_physics_cylinder_with_material,
    spawn_static_physics_cube_with_material,
};

spawn_dynamic_physics_cube_with_material(world, position, size, mass, material);
spawn_dynamic_physics_sphere_with_material(world, position, radius, mass, material);
spawn_dynamic_physics_cylinder_with_material(world, position, half_height, radius, mass, material);
spawn_static_physics_cube_with_material(world, position, size, material);
}

Joint Systems

Chain: A series of capsules connected by spherical joints, anchored at the top.

Ragdoll: A humanoid figure made of box body parts connected by joints:

  • Head connected to torso
  • Arms connected to torso
  • Legs connected to torso

Joints are created using the SphericalJoint builder:

#![allow(unused)]
fn main() {
use nightshade::ecs::physics::{SphericalJoint, create_spherical_joint};

create_spherical_joint(world, parent, child, SphericalJoint::new()
    .with_local_anchor1(Vec3::new(0.0, 0.4, 0.0))
    .with_local_anchor2(Vec3::new(0.0, -0.15, 0.0)));
}

Object Manipulation

Grab: Right-click to grab objects and move them with the camera.

Throw: Release right-click to throw grabbed objects.

Delete: Middle-click to delete objects.

Physics Effects

Explosion: Press F to apply radial force to nearby objects.

Gravity Toggle: Press G to toggle between normal gravity and zero gravity.

Cargo.toml

[package]
name = "physics-playground"
version = "0.1.0"
edition = "2024"

[dependencies]
nightshade = { git = "...", features = ["engine", "wgpu", "physics"] }

Feature Flags

Nightshade uses Cargo feature flags to enable optional functionality. This allows you to include only the features you need, reducing compile times and binary size.

Default Features

Nightshade defaults to ["engine", "wgpu"]:

[dependencies]
nightshade = { git = "https://github.com/matthewjberger/nightshade.git" }

This gives you the full engine with the wgpu rendering backend. You only need to specify features explicitly if you want additional optional features or a minimal configuration.

Aggregate Features

engine (default)

The main engine feature. Includes everything needed for building games.

nightshade = { git = "...", features = ["engine"] }

Includes: runtime, assets, scene_graph, picking, file_dialog, async_runtime, terrain, screenshot, plus rand, rayon, ehttp, futures, and WASM support libraries (js-sys, wasm-bindgen, wasm-bindgen-futures, web-sys).

Provides:

  • Window creation and event loop
  • wgpu rendering backend
  • ECS (freecs)
  • Transform hierarchy
  • Camera systems
  • Mesh rendering and GPU instancing
  • PBR material system
  • Lighting (directional, point, spot) with shadows
  • Post-processing (bloom, SSAO, depth of field, tonemapping)
  • Procedural atmospheres (sky, clouds, space, nebula)
  • glTF/GLB model loading
  • Scene save/load
  • Input handling (keyboard, mouse, touch)
  • Procedural terrain
  • Picking (bounding box ray casting)
  • File dialogs
  • Screenshot capture

runtime

Minimal rendering without asset loading. Use for lightweight apps that don't need glTF/image loading.

nightshade = { default-features = false, features = ["runtime", "wgpu", "egui"] }

Includes: core, text, behaviors.

full

Everything in engine plus all major optional features.

nightshade = { git = "...", features = ["full"] }

Includes: engine, wgpu, egui, shell, audio, physics, gamepad, navmesh, scripting, fbx, lattice, sdf_sculpt, mosaic, editor, plugins.

Granular Features

These provide fine-grained control over dependencies:

core

Foundation: ECS (freecs), math (nalgebra, nalgebra-glm), windowing (winit), time (web-time), graph (petgraph), tracing, UUIDs, JSON serialization.

text

MSDF text rendering using ttf-parser. Requires core.

assets

Asset loading: gltf, image, half, bincode, serde_json, lz4 compression. Requires core.

scene_graph

Scene hierarchy system with save/load. Requires assets.

terrain

Procedural terrain generation using noise and rand. Requires core.

file_dialog

Native file dialogs using rfd and dirs. Enables the native-only functions in nightshade::filesystem (pick_file, pick_folder, save_file_dialog, read_file, write_file). The cross-platform functions (save_file, request_file_load) are always available on WASM and require this feature on native. See the File System chapter. Requires core.

async_runtime

Tokio async runtime for non-blocking operations. Requires core.

screenshot

PNG screenshot saving using image.

picking

Ray-based entity picking with bounding box intersection. Trimesh picking requires physics.

file_watcher

File watching support via notify. Enables FileWatcher for monitoring file changes and triggering hot-reloads. Native only (not available on WASM).

behaviors

Built-in behavior components and systems.

Rendering

wgpu (default)

WebGPU-based rendering backend supporting DirectX 12, Metal, Vulkan, and WebGPU.

Optional Features

egui

Immediate mode GUI framework. Enables fn ui() on the State trait.

nightshade = { git = "...", features = ["egui"] }

Additional Dependencies: egui, egui_extras, egui-winit, egui-wgpu, egui_tiles

shell

Developer console with command registration. Press backtick to open. Requires egui.

nightshade = { git = "...", features = ["shell"] }

audio

Audio playback using Kira.

nightshade = { git = "...", features = ["audio"] }

Provides:

  • Sound loading (WAV, OGG, MP3, FLAC)
  • Sound playback with volume, pitch, panning
  • Spatial/3D audio with distance attenuation
  • Audio listener and source components
  • Looping and one-shot sounds

fft

FFT-based audio analysis for music-reactive applications.

nightshade = { git = "...", features = ["fft"] }

Provides:

  • Real-time FFT spectral analysis
  • Six-band frequency decomposition (sub-bass to highs)
  • Beat detection (kick, snare, hi-hat)
  • BPM estimation and beat phase tracking
  • Spectral features (centroid, flatness, rolloff, flux)
  • Onset detection with adaptive thresholding

Additional Dependencies: rustfft

physics

Rapier3D physics integration.

nightshade = { git = "...", features = ["physics"] }

Provides:

  • Rigid body simulation (dynamic, kinematic, static)
  • Collider shapes (box, sphere, capsule, cylinder, cone, convex hull, trimesh, heightfield)
  • Collision detection
  • Character controllers
  • Physics interpolation for smooth rendering
  • Trimesh picking (when combined with picking)

Additional Dependencies: rapier3d

gamepad

Gamepad/controller support via gilrs.

nightshade = { git = "...", features = ["gamepad"] }

Provides:

  • Gamepad detection and hot-plugging
  • Button input (face buttons, triggers, bumpers, D-pad)
  • Analog stick input with deadzone handling
  • Trigger pressure (0.0 - 1.0)
  • Rumble/vibration
  • Multiple gamepad support

AI navigation mesh generation via Recast.

nightshade = { git = "...", features = ["navmesh"] }

Provides:

  • Navigation mesh generation from world geometry
  • A* and Dijkstra pathfinding
  • NavMesh agent component with autonomous movement
  • Height sampling on navigation mesh
  • Debug visualization

Additional Dependencies: rerecast, glam

scripting

Rhai scripting runtime for dynamic behavior.

nightshade = { git = "...", features = ["scripting"] }

fbx

FBX model loading using ufbx. Requires assets.

nightshade = { git = "...", features = ["fbx"] }

lattice

Lattice deformation system for free-form mesh deformation.

nightshade = { git = "...", features = ["lattice"] }

sdf_sculpt

Signed Distance Field sculpting system.

nightshade = { git = "...", features = ["sdf_sculpt"] }

Platform Features

openxr

OpenXR VR support. Uses Vulkan backend. Provides launch_xr() entry point, VR input (head/hand tracking, controllers), and locomotion.

nightshade = { git = "...", features = ["openxr"] }

Additional Dependencies: openxr, ash, wgpu-hal, gpu-allocator

steam

Steamworks integration for achievements, stats, multiplayer, and friends.

nightshade = { git = "...", features = ["steam"] }

Additional Dependencies: steamworks, steamworks-sys

webview

Bidirectional IPC for hosting web frontends (Leptos, Yew, etc.) inside a nightshade window.

nightshade = { git = "...", features = ["webview"] }

Additional Dependencies: wry, tiny_http, include_dir, wasm-bindgen, js-sys, web-sys

mosaic

Multi-pane desktop application framework built on egui_tiles. Provides dockable tile-based layouts, a Widget trait for serializable panes, theming with 11 presets, modal dialogs, toast notifications, command palettes, keyboard shortcuts, file dialogs, project save/load, status bars, FPS counters, undo/redo history, clipboard helpers, drag-and-drop support, and a built-in ViewportWidget for rendering camera outputs.

Requires egui.

nightshade = { git = "...", features = ["mosaic"] }

Key types: Mosaic, Widget, Pane, WidgetContext, ViewportWidget, ThemeState, Modals, Toasts, StatusBar, CommandPalette, ShortcutManager, Settings, EventLog, FpsCounter, MosaicConfig, ProjectSaveFile, LayoutEvent

editor

Scene editor infrastructure for building custom editors. Provides gizmo manipulation, undo/redo, component inspectors, entity picking, selection management, clipboard operations, keyboard shortcuts, scene tree UI, context menus, camera controls, and asset loading utilities.

Requires mosaic and picking.

nightshade = { git = "...", features = ["editor"] }

Provides:

  • Transform gizmos (translate, rotate, scale) with local/global coordinate spaces
  • Modal transform operations (Blender-style G/R/S keys with axis constraints)
  • Undo/redo history with entity snapshots
  • Component inspector UI for all built-in components
  • GPU-based entity picking and marquee selection
  • Entity selection with multi-select, copy/paste, duplicate
  • Scene tree with drag-and-drop reparenting
  • Context menus and add-node modal
  • Camera view presets and ortho toggle
  • Keyboard shortcut handler (Blender-style)
  • Code editor with syntax highlighting (syntect, native only)
  • Clipboard integration (arboard, native only)

Additional Dependencies: syntect (native only), arboard (native only)

Key types: EditorContext, UndoHistory, ComponentInspectorUi, InspectorContext, WorldTreeUi, EntitySelection, TreeCache, GizmoInteraction, InputSignal

windows-app-icon

Embed a custom icon into Windows executables at build time.

nightshade = { git = "...", features = ["windows-app-icon"] }

Additional Dependencies: winresource, ico, image

Profiling Features

tracing

Rolling log files and RUST_LOG support via tracing-appender.

tracy

Real-time profiling with Tracy. Implies tracing.

chrome

Chrome tracing output for chrome://tracing. Implies tracing.

Plugin Features

plugins

Guest-side WASM plugin API for creating plugins.

plugin_runtime

WASM plugin hosting via Wasmtime. Requires assets.

Additional Dependencies: wasmtime, wasmtime-wasi, anyhow

Terminal Features

tui

Terminal UI framework built on the engine's rendering. Includes runtime and text.

Additional Dependencies: rand

terminal

Crossterm-based terminal applications without the full rendering pipeline.

Additional Dependencies: crossterm, rand, freecs

Other Features

mcp

Starts an HTTP-based Model Context Protocol server on http://127.0.0.1:3333/mcp when the application launches. Any MCP client (Claude Code, Claude Desktop, or custom tooling) can connect and manipulate the running scene in real time through 50+ tools covering entities, transforms, materials, lighting, physics, animation, scripting, and more.

Requires async_runtime and behaviors. Native only (not available on WASM).

nightshade = { git = "...", features = ["mcp"] }

Connect Claude Code to the running engine:

claude mcp add --transport http nightshade http://127.0.0.1:3333/mcp

Applications can intercept MCP commands before the engine processes them with handle_mcp_command and react to results with after_mcp_command on the State trait. See the AI Integration chapter for details.

Additional Dependencies: axum, rmcp, schemars

claude

Provides types and a background worker thread for spawning Claude Code CLI as a subprocess and streaming its JSON output. This lets applications embed an AI assistant that can send queries, receive streamed responses (text, thinking, tool use events), and manage sessions.

Native only (not available on WASM).

nightshade = { git = "...", features = ["claude"] }

Key types:

  • ClaudeConfig — system prompt, allowed/disallowed tools, MCP config, custom CLI args
  • CliCommandStartQuery (with prompt, optional session ID and model) and Cancel
  • CliEventSessionStarted, TextDelta, ThinkingDelta, ToolUseStarted, ToolUseInputDelta, ToolUseFinished, TurnComplete, Complete, Error

When both claude and mcp are enabled, McpConfig::Auto automatically generates the MCP config JSON pointing at http://127.0.0.1:3333/mcp, so Claude Code connects to the engine without manual setup.

Additional Dependencies: serde_json

Feature Combinations

Minimal Rendering App

nightshade = { default-features = false, features = ["runtime", "wgpu", "egui"] }

Lightweight egui app without asset loading.

Standard Game

nightshade = { git = "...", features = ["egui", "physics", "audio", "gamepad"] }

Full game features with UI, physics, audio, and gamepad.

Open World Game

nightshade = { git = "...", features = [
    "egui",
    "physics",
    "audio",
    "gamepad",
    "navmesh",
] }

Everything for large outdoor environments with AI pathfinding.

VR Game

nightshade = { git = "...", features = ["openxr", "physics", "audio"] }

Virtual reality with physics and audio.

Music Visualizer

nightshade = { git = "...", features = ["audio", "fft"] }

Audio-reactive visualizations with FFT analysis.

Desktop Tool

nightshade = { git = "...", features = ["mosaic", "egui"] }

Multi-pane desktop application with dockable widgets.

Custom Scene Editor

nightshade = { git = "...", features = ["editor", "fbx", "physics", "navmesh"] }

Full scene editor with gizmos, inspectors, undo/redo, FBX import, physics colliders, and navmesh editing.

Feature Dependencies

Some features have implicit dependencies:

FeatureDepends On
engineruntime, assets, scene_graph, picking, terrain, file_dialog, async_runtime, screenshot
runtimecore, text, behaviors
fullengine, wgpu, egui, shell, audio, physics, gamepad, navmesh, scripting, fbx, lattice, sdf_sculpt, mosaic, editor, plugins
mosaicegui
editormosaic, picking
shellegui
fbxassets
scene_graphassets
assetscore
textcore
terraincore
tuiruntime, text
plugin_runtimeassets
mcpasync_runtime, behaviors
claude(none)
tracytracing
chrometracing
openxrwgpu (Vulkan backend)

Checking Enabled Features

Use cfg attributes for conditional compilation:

#![allow(unused)]
fn main() {
fn initialize(&mut self, world: &mut World) {
    let camera = spawn_camera(world, Vec3::new(5.0, 3.0, 5.0), "Camera".to_string());
    world.resources.active_camera = Some(camera);
    spawn_sun(world);

    #[cfg(feature = "physics")]
    {
        let entity = world.spawn_entities(
            RIGID_BODY | COLLIDER | LOCAL_TRANSFORM | GLOBAL_TRANSFORM | LOCAL_TRANSFORM_DIRTY | RENDER_MESH,
            1,
        )[0];
        world.core.set_rigid_body(entity, RigidBodyComponent::new_dynamic());
        world.core.set_collider(entity, ColliderComponent::new_cuboid(0.5, 0.5, 0.5));
    }

    #[cfg(feature = "audio")]
    {
        let source = world.spawn_entities(AUDIO_SOURCE | LOCAL_TRANSFORM | GLOBAL_TRANSFORM, 1)[0];
        world.core.set_audio_source(source, AudioSource::new("music").with_looping(true).playing());
    }
}
}

Platform Support

Nightshade supports multiple platforms through wgpu's cross-platform abstractions.

Supported Platforms

PlatformStatusBackendNotes
Windows 10/11Full SupportVulkan, DX12Primary development platform
LinuxFull SupportVulkanX11 and Wayland
macOSFull SupportMetalRequires macOS 10.13+
Web (WASM)ExperimentalWebGPUModern browsers only

Windows

Requirements

  • Windows 10 version 1903 or later (for DX12)
  • Windows 10 version 1607 or later (for Vulkan)
  • GPU with Vulkan 1.1 or DirectX 12 support

Graphics Backends

Windows supports multiple backends in order of preference:

  1. Vulkan - Best performance and feature support
  2. DirectX 12 - Native Windows API, good compatibility
  3. DirectX 11 - Fallback for older hardware

Building

cargo build --release

Distribution

The executable is self-contained. Include your assets folder alongside the executable.

game/
├── game.exe
└── assets/
    ├── models/
    ├── textures/
    └── audio/

Linux

Requirements

  • X11 or Wayland display server
  • Vulkan 1.1 compatible GPU and drivers
  • Common distributions: Ubuntu 20.04+, Fedora 33+, Arch Linux

Dependencies

Install Vulkan development packages:

Ubuntu/Debian:

sudo apt install libvulkan1 vulkan-tools libvulkan-dev
sudo apt install libasound2-dev  # For audio feature

Fedora:

sudo dnf install vulkan-loader vulkan-tools vulkan-headers
sudo dnf install alsa-lib-devel  # For audio feature

Arch Linux:

sudo pacman -S vulkan-icd-loader vulkan-tools vulkan-headers
sudo pacman -S alsa-lib  # For audio feature

Building

cargo build --release

Wayland Support

Nightshade uses winit which supports both X11 and Wayland. The backend is selected automatically based on environment:

# Force X11
WINIT_UNIX_BACKEND=x11 ./game

# Force Wayland
WINIT_UNIX_BACKEND=wayland ./game

Distribution

Create an AppImage or distribute with a shell script:

#!/bin/bash
cd "$(dirname "$0")"
./game

macOS

Requirements

  • macOS 10.13 (High Sierra) or later
  • Metal-capable GPU (most Macs from 2012+)

Building

cargo build --release

Code Signing

For distribution, sign your application:

codesign --deep --force --sign "Developer ID Application: Your Name" target/release/game

App Bundle

Create a macOS app bundle:

Game.app/
└── Contents/
    ├── Info.plist
    ├── MacOS/
    │   └── game
    └── Resources/
        └── assets/

Info.plist:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>CFBundleExecutable</key>
    <string>game</string>
    <key>CFBundleIdentifier</key>
    <string>com.yourcompany.game</string>
    <key>CFBundleName</key>
    <string>Game</string>
    <key>CFBundleVersion</key>
    <string>1.0</string>
    <key>CFBundleShortVersionString</key>
    <string>1.0</string>
    <key>LSMinimumSystemVersion</key>
    <string>10.13</string>
</dict>
</plist>

Web (WebAssembly)

Requirements

  • Modern browser with WebGPU support
  • Chrome 113+, Edge 113+, Firefox 141+

Building

Install wasm-pack:

cargo install wasm-pack

Build for web:

wasm-pack build --target web

HTML Template

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>Game</title>
    <style>
        body { margin: 0; overflow: hidden; }
        canvas { width: 100vw; height: 100vh; display: block; }
    </style>
</head>
<body>
    <canvas id="canvas"></canvas>
    <script type="module">
        import init from './pkg/game.js';
        init();
    </script>
</body>
</html>

Limitations

Web builds have some limitations:

FeatureStatus
RenderingSupported
InputSupported
AudioSupported (Web Audio)
GamepadSupported (Gamepad API)
PhysicsSupported
File SystemLimited (no direct access)
ThreadsLimited (requires SharedArrayBuffer)

Asset Loading

Assets must be served over HTTP. Use fetch API for loading:

#![allow(unused)]
fn main() {
#[cfg(target_arch = "wasm32")]
async fn load_assets() {
    // Assets loaded via HTTP fetch
}
}

Cross-Compilation

Windows from Linux

Install the MinGW toolchain:

sudo apt install mingw-w64
rustup target add x86_64-pc-windows-gnu
cargo build --release --target x86_64-pc-windows-gnu

Linux from Windows

Use WSL2 or Docker:

# In WSL2
cargo build --release --target x86_64-unknown-linux-gnu

macOS Cross-Compilation

Cross-compiling to macOS is complex due to SDK requirements. Consider using CI/CD services like GitHub Actions with macOS runners.

GPU Requirements

Minimum Requirements

FeatureRequirement
APIVulkan 1.1 / DX12 / Metal
VRAM2 GB
Shader Model5.0
FeatureRequirement
APIVulkan 1.2+
VRAM4+ GB
Shader Model6.0

Feature Support by GPU Generation

GPUBasic RenderingTessellationCompute Culling
Intel HD 4000+YesYesYes
NVIDIA GTX 600+YesYesYes
AMD GCN 1.0+YesYesYes
Apple M1+YesYesYes

Performance by Platform

Relative performance (higher is better):

PlatformPerformanceNotes
Windows (Vulkan)100%Best overall
Windows (DX12)95%Slightly more overhead
Linux (Vulkan)98%Excellent with proper drivers
macOS (Metal)90%Good, but Metal has different characteristics
Web (WebGPU)70%JavaScript overhead

Troubleshooting

Windows: "No suitable adapter found"

  • Update GPU drivers
  • Install Vulkan Runtime
  • Try forcing DX12: WGPU_BACKEND=dx12 ./game.exe

Linux: "Failed to create Vulkan instance"

  • Install Vulkan drivers for your GPU
  • Check vulkaninfo command works
  • Verify ICD loader: ls /usr/share/vulkan/icd.d/

macOS: "Metal not available"

  • Update macOS to 10.13+
  • Check GPU supports Metal: Apple Menu > About This Mac > System Report > Graphics

Web: "WebGPU not supported"

  • Use Chrome 113+ or Edge 113+
  • Enable WebGPU flag in browser settings if needed
  • Check navigator.gpu exists in browser console

API Quick Reference

Quick lookup for common Nightshade API functions and types.

World

Entity Management

#![allow(unused)]
fn main() {
let entities = world.spawn_entities(flags, count);
let entity = world.spawn_entities(LOCAL_TRANSFORM | RENDER_MESH, 1)[0];

world.despawn_entities(&[entity]);

for entity in world.core.query_entities(LOCAL_TRANSFORM | RENDER_MESH) {
    let transform = world.core.get_local_transform(entity);
}
}

Component Access

#![allow(unused)]
fn main() {
world.core.get_local_transform(entity) -> Option<&LocalTransform>
world.core.get_global_transform(entity) -> Option<&GlobalTransform>
world.core.get_render_mesh(entity) -> Option<&RenderMesh>
world.core.get_material_ref(entity) -> Option<&MaterialRef>
world.core.get_camera(entity) -> Option<&Camera>
world.core.get_rigid_body(entity) -> Option<&RigidBodyComponent>
world.core.get_collider(entity) -> Option<&ColliderComponent>
world.core.get_animation_player(entity) -> Option<&AnimationPlayer>
world.core.get_parent(entity) -> Option<&Parent>
world.core.get_visibility(entity) -> Option<&Visibility>

world.core.get_local_transform_mut(entity) -> Option<&mut LocalTransform>
world.core.get_rigid_body_mut(entity) -> Option<&mut RigidBodyComponent>

world.core.set_local_transform(entity, LocalTransform { ... })
world.core.set_light(entity, Light { ... })

set_material_with_textures(world, entity, Material { ... })
}

Resources

#![allow(unused)]
fn main() {
world.resources.window.timing.delta_time
world.resources.window.timing.frames_per_second
world.resources.window.timing.uptime_milliseconds
world.resources.input.keyboard
world.resources.input.mouse
world.resources.graphics.show_cursor
world.set_cursor_visible(bool)
world.set_cursor_locked(bool)
world.resources.active_camera
world.resources.graphics.ambient_light
world.resources.physics.gravity
}

Component Flags

#![allow(unused)]
fn main() {
ANIMATION_PLAYER
NAME
LOCAL_TRANSFORM
GLOBAL_TRANSFORM
LOCAL_TRANSFORM_DIRTY
PARENT
IGNORE_PARENT_SCALE
AUDIO_SOURCE
AUDIO_LISTENER
CAMERA
PAN_ORBIT_CAMERA
LIGHT
LINES
VISIBILITY
DECAL
RENDER_MESH
MATERIAL_REF
RENDER_LAYER
SPRITE
SPRITE_ANIMATOR
TEXT
TEXT_CHARACTER_COLORS
TEXT_CHARACTER_BACKGROUND_COLORS
BOUNDING_VOLUME
HOVERED
ROTATION
CASTS_SHADOW
RIGID_BODY
COLLIDER
CHARACTER_CONTROLLER
COLLISION_LISTENER
PHYSICS_INTERPOLATION
INSTANCED_MESH
PARTICLE_EMITTER
PREFAB_SOURCE
PREFAB_INSTANCE
SCRIPT
SKIN
JOINT
MORPH_WEIGHTS
NAVMESH_AGENT
LATTICE
LATTICE_INFLUENCED
WATER
GRASS_REGION
GRASS_INTERACTOR
TWEEN
}

Transform

#![allow(unused)]
fn main() {
LocalTransform {
    translation: Vec3,
    rotation: nalgebra_glm::Quat,
    scale: Vec3,
}

LocalTransform::default()

LocalTransform {
    translation: Vec3::new(x, y, z),
    rotation: nalgebra_glm::quat_angle_axis(angle, &axis),
    scale: Vec3::new(1.0, 1.0, 1.0),
}
}

Camera

#![allow(unused)]
fn main() {
Camera {
    projection: Projection,
    smoothing: Option<Smoothing>,
}

PerspectiveCamera {
    aspect_ratio: Option<f32>,
    y_fov_rad: f32,
    z_far: Option<f32>,
    z_near: f32,
}

OrthographicCamera {
    x_mag: f32,
    y_mag: f32,
    z_far: f32,
    z_near: f32,
}

spawn_camera(world, position: Vec3, name: String) -> Entity
spawn_pan_orbit_camera(world, focus: Vec3, radius: f32, yaw: f32, pitch: f32, name: String) -> Entity
spawn_ortho_camera(world, position: Vec2) -> Entity
}

Primitives

#![allow(unused)]
fn main() {
spawn_cube_at(world, position: Vec3) -> Entity
spawn_sphere_at(world, position: Vec3) -> Entity
spawn_plane_at(world, position: Vec3) -> Entity
spawn_cylinder_at(world, position: Vec3) -> Entity
spawn_cone_at(world, position: Vec3) -> Entity
spawn_torus_at(world, position: Vec3) -> Entity
spawn_mesh_at(world, mesh_name: &str, position: Vec3, scale: Vec3) -> Entity
spawn_water_plane_at(world, position: Vec3) -> Entity
}

Model Loading

#![allow(unused)]
fn main() {
let result = import_gltf_from_bytes(model_data).unwrap();
let prefab = result.prefabs.first().unwrap();
let entity = spawn_prefab_with_animations(world, prefab, &result.animations, Vec3::zeros());

for entity in entities {
    if let Some(transform) = world.core.get_local_transform_mut(entity) {
        transform.scale = Vec3::new(0.01, 0.01, 0.01);
    }
}
}

Materials

#![allow(unused)]
fn main() {
Material {
    base_color: [f32; 4],
    emissive_factor: [f32; 3],
    alpha_mode: AlphaMode,
    alpha_cutoff: f32,
    base_texture: Option<String>,
    base_texture_uv_set: u32,
    emissive_texture: Option<String>,
    emissive_texture_uv_set: u32,
    normal_texture: Option<String>,
    normal_texture_uv_set: u32,
    normal_scale: f32,
    normal_map_flip_y: bool,
    normal_map_two_component: bool,
    metallic_roughness_texture: Option<String>,
    metallic_roughness_texture_uv_set: u32,
    occlusion_texture: Option<String>,
    occlusion_texture_uv_set: u32,
    occlusion_strength: f32,
    roughness: f32,
    metallic: f32,
    unlit: bool,
    double_sided: bool,
    uv_scale: [f32; 2],
    transmission_factor: f32,
    transmission_texture: Option<String>,
    transmission_texture_uv_set: u32,
    thickness: f32,
    thickness_texture: Option<String>,
    thickness_texture_uv_set: u32,
    attenuation_color: [f32; 3],
    attenuation_distance: f32,
    ior: f32,
    specular_factor: f32,
    specular_color_factor: [f32; 3],
    specular_texture: Option<String>,
    specular_texture_uv_set: u32,
    specular_color_texture: Option<String>,
    specular_color_texture_uv_set: u32,
    emissive_strength: f32,
}
}

Lighting

#![allow(unused)]
fn main() {
spawn_sun(world) -> Entity
spawn_sun_without_shadows(world) -> Entity

Light {
    light_type: LightType,
    color: Vec3,
    intensity: f32,
    range: f32,
    inner_cone_angle: f32,
    outer_cone_angle: f32,
    cast_shadows: bool,
    shadow_bias: f32,
}
}

Physics

#![allow(unused)]
fn main() {
world.core.set_rigid_body(entity, RigidBodyComponent::new_dynamic())
world.core.set_collider(entity, ColliderComponent::new_cuboid(hx, hy, hz))

RigidBodyComponent::new_dynamic()
RigidBodyComponent::new_static()
RigidBodyComponent::new_kinematic()

ColliderComponent::new_cuboid(hx: f32, hy: f32, hz: f32)
ColliderComponent::new_ball(radius: f32)
ColliderComponent::new_capsule(half_height: f32, radius: f32)
ColliderComponent::new_cylinder(half_height: f32, radius: f32)
ColliderComponent::new_cone(half_height: f32, radius: f32)

spawn_static_physics_cube_with_material(world, position, size, material)
spawn_dynamic_physics_cube_with_material(world, position, size, mass, material)
spawn_dynamic_physics_sphere_with_material(world, position, radius, mass, material)
spawn_dynamic_physics_cylinder_with_material(world, position, half_height, radius, mass, material)

run_physics_systems(world)
sync_transforms_from_physics_system(world)
sync_transforms_to_physics_system(world)
initialize_physics_bodies_system(world)
physics_interpolation_system(world)
character_controller_system(world)
character_controller_input_system(world)
}

Joints

#![allow(unused)]
fn main() {
create_fixed_joint(world, parent, child, FixedJoint::new()
    .with_local_anchor1(anchor1).with_local_anchor2(anchor2)) -> Option<JointHandle>
create_revolute_joint(world, parent, child, RevoluteJoint::new(axis)
    .with_local_anchor1(anchor1).with_local_anchor2(anchor2)) -> Option<JointHandle>
create_prismatic_joint(world, parent, child, PrismaticJoint::new(axis)
    .with_local_anchor1(anchor1).with_local_anchor2(anchor2)) -> Option<JointHandle>
create_spherical_joint(world, parent, child, SphericalJoint::new()
    .with_local_anchor1(anchor1).with_local_anchor2(anchor2)) -> Option<JointHandle>
create_rope_joint(world, parent, child, RopeJoint::new(max_distance)
    .with_local_anchor1(anchor1).with_local_anchor2(anchor2)) -> Option<JointHandle>
create_spring_joint(world, parent, child, SpringJoint::new(rest_length, stiffness, damping)
    .with_local_anchor1(anchor1).with_local_anchor2(anchor2)) -> Option<JointHandle>
}

Animation

#![allow(unused)]
fn main() {
if let Some(player) = world.core.get_animation_player_mut(entity) {
    player.play(clip_index);
    player.blend_to(clip_index, duration);
    player.stop();
    player.pause();
    player.resume();
    player.looping = true;
    player.speed = 1.0;
    player.time = 0.0;
    player.playing
    player.current_clip
}

update_animation_players(world, dt: f32)
}

Audio

Requires the audio feature.

#![allow(unused)]
fn main() {
let data = load_sound_from_bytes(include_bytes!("sound.ogg")).unwrap();
let data = load_sound_from_cursor(cursor_data).unwrap();

world.resources.audio.load_sound("name", data);
world.resources.audio.stop_sound(entity);

let source = world.spawn_entities(AUDIO_SOURCE | LOCAL_TRANSFORM | GLOBAL_TRANSFORM, 1)[0];
world.core.set_audio_source(source, AudioSource::new("name")
    .with_spatial(true)
    .with_looping(true)
    .with_reverb(true)
    .with_volume(0.8)
    .playing(),
);
}

Input

#![allow(unused)]
fn main() {
world.resources.input.keyboard.is_key_pressed(KeyCode::KeyW) -> bool

world.resources.input.mouse.position -> Vec2
world.resources.input.mouse.position_delta -> Vec2
world.resources.input.mouse.wheel_delta -> Vec2
world.resources.input.mouse.state.contains(MouseState::LEFT_CLICKED) -> bool
world.resources.input.mouse.state.contains(MouseState::RIGHT_CLICKED) -> bool
world.resources.input.mouse.state.contains(MouseState::LEFT_JUST_PRESSED) -> bool

world.resources.graphics.show_cursor = false;
world.set_cursor_visible(false);
world.set_cursor_locked(true);

query_active_gamepad(world) -> Option<gilrs::Gamepad<'_>>
}

Screen-Space Text

#![allow(unused)]
fn main() {
spawn_ui_text(world, text: impl Into<String>, position: Vec2) -> Entity
spawn_ui_text_with_properties(world, text: impl Into<String>, position: Vec2, properties: TextProperties) -> Entity

TextProperties {
    font_size: f32,
    color: Vec4,
    alignment: TextAlignment,
    vertical_alignment: VerticalAlignment,
    line_height: f32,
    letter_spacing: f32,
    outline_width: f32,
    outline_color: Vec4,
    smoothing: f32,
    monospace_width: Option<f32>,
    anchor_character: Option<usize>,
}

TextAlignment::Left | Center | Right
VerticalAlignment::Top | Middle | Bottom | Baseline
}

Particles

#![allow(unused)]
fn main() {
ParticleEmitter {
    emitter_type: EmitterType,
    shape: EmitterShape,
    position: Vec3,
    direction: Vec3,
    spawn_rate: f32,
    burst_count: u32,
    particle_lifetime_min: f32,
    particle_lifetime_max: f32,
    initial_velocity_min: f32,
    initial_velocity_max: f32,
    velocity_spread: f32,
    gravity: Vec3,
    drag: f32,
    size_start: f32,
    size_end: f32,
    color_gradient: ColorGradient,
    emissive_strength: f32,
    turbulence_strength: f32,
    turbulence_frequency: f32,
    enabled: bool,
}
}

Requires the navmesh feature.

#![allow(unused)]
fn main() {
generate_navmesh_recast(vertices: &[[f32; 3]], indices: &[[u32; 3]], config: &RecastNavMeshConfig) -> Option<NavMeshWorld>

spawn_navmesh_agent(world, position: Vec3, radius: f32, height: f32) -> Entity
set_agent_destination(world, entity: Entity, destination: Vec3)
set_agent_speed(world, entity: Entity, speed: f32)
stop_agent(world, entity: Entity)
get_agent_state(world, entity: Entity) -> Option<NavMeshAgentState>
get_agent_path_length(world, entity: Entity) -> Option<f32>

find_closest_point_on_navmesh(navmesh: &NavMeshWorld, point: Vec3) -> Option<Vec3>
sample_navmesh_height(navmesh: &NavMeshWorld, x: f32, z: f32) -> Option<f32>
set_navmesh_debug_draw(world, enabled: bool)
clear_navmesh(world)

run_navmesh_systems(world, delta_time: f32)
}

Math (nalgebra_glm)

#![allow(unused)]
fn main() {
Vec2::new(x, y)
Vec3::new(x, y, z)
Vec4::new(x, y, z, w)
Vec3::zeros()
Vec3::x()
Vec3::y()
Vec3::z()

vec.normalize()
vec.magnitude()
vec.dot(&other)
vec.cross(&other)

nalgebra_glm::quat_identity()
nalgebra_glm::quat_angle_axis(angle: f32, axis: &Vec3) -> Quat
nalgebra_glm::quat_slerp(from: &Quat, to: &Quat, t: f32) -> Quat

nalgebra_glm::lerp(from: &Vec3, to: &Vec3, t: f32) -> Vec3
}

State Trait

#![allow(unused)]
fn main() {
trait State {
    fn title(&self) -> &str { "Nightshade" }
    fn icon_bytes(&self) -> Option<&'static [u8]> { ... }
    fn initialize(&mut self, world: &mut World) {}
    fn run_systems(&mut self, world: &mut World) {}
    fn ui(&mut self, world: &mut World, ctx: &egui::Context) {}
    fn secondary_ui(&mut self, world: &mut World, window_index: usize, ctx: &egui::Context) {}
    fn on_keyboard_input(&mut self, world: &mut World, key_code: KeyCode, key_state: ElementState) {}
    fn on_mouse_input(&mut self, world: &mut World, state: ElementState, button: MouseButton) {}
    fn on_gamepad_event(&mut self, world: &mut World, event: gilrs::Event) {}
    fn handle_event(&mut self, world: &mut World, message: &Message) {}
    fn on_dropped_file(&mut self, world: &mut World, path: &Path) {}
    fn on_dropped_file_data(&mut self, world: &mut World, name: &str, data: &[u8]) {}
    fn on_hovered_file(&mut self, world: &mut World, path: &Path) {}
    fn on_hovered_file_cancelled(&mut self, world: &mut World) {}
    fn configure_render_graph(&mut self, graph: &mut RenderGraph<World>, device: &wgpu::Device, surface_format: wgpu::TextureFormat, resources: RenderResources) {}
    fn update_render_graph(&mut self, graph: &mut RenderGraph<World>, world: &World) {}
    fn pre_render(&mut self, renderer: &mut dyn Render, world: &mut World) {}
    fn next_state(&mut self, world: &mut World) -> Option<Box<dyn State>> { None }
    fn handle_mcp_command(&mut self, world: &mut World, command: &McpCommand) -> Option<McpResponse> { None }
    fn after_mcp_command(&mut self, world: &mut World, command: &McpCommand, response: &McpResponse) {}
}
}

Audio Analyzer (fft feature)

#![allow(unused)]
fn main() {
let mut analyzer = AudioAnalyzer::new();
analyzer.load_samples(samples, sample_rate);

analyzer.analyze_at_time(time_seconds);

analyzer.sub_bass
analyzer.bass
analyzer.low_mids
analyzer.mids
analyzer.high_mids
analyzer.highs

analyzer.smoothed_bass
analyzer.smoothed_mids

analyzer.onset_detected
analyzer.kick_decay
analyzer.snare_decay
analyzer.hat_decay

analyzer.estimated_bpm
analyzer.beat_phase
analyzer.beat_confidence

analyzer.is_building
analyzer.is_dropping
analyzer.is_breakdown
analyzer.build_intensity
analyzer.drop_intensity

analyzer.spectral_centroid
analyzer.spectral_flatness
analyzer.spectral_flux
analyzer.intensity
}

Effects Pass

#![allow(unused)]
fn main() {
use nightshade::render::wgpu::passes::postprocess::effects::*;

let effects_state = create_effects_state();

if let Ok(mut state) = effects_state.write() {
    state.uniforms.chromatic_aberration = 0.02;
    state.uniforms.vignette = 0.3;
    state.uniforms.glitch_intensity = 0.5;
    state.uniforms.wave_distortion = 0.2;
    state.uniforms.crt_scanlines = 0.3;
    state.uniforms.film_grain = 0.1;
    state.uniforms.hue_rotation = 0.5;
    state.uniforms.saturation = 1.2;
    state.uniforms.color_grade_mode = ColorGradeMode::Cyberpunk as f32;
    state.uniforms.raymarch_mode = RaymarchMode::Tunnel as f32;
    state.uniforms.raymarch_blend = 0.5;
    state.enabled = true;
}
}

Debug Lines

#![allow(unused)]
fn main() {
let lines_entity = world.spawn_entities(LOCAL_TRANSFORM | LINES, 1)[0];

world.core.set_lines(lines_entity, Lines {
    lines: vec![
        Line { start: Vec3::zeros(), end: Vec3::new(1.0, 0.0, 0.0), color: Vec4::new(1.0, 0.0, 0.0, 1.0) },
        Line { start: Vec3::zeros(), end: Vec3::new(0.0, 1.0, 0.0), color: Vec4::new(0.0, 1.0, 0.0, 1.0) },
        Line { start: Vec3::zeros(), end: Vec3::new(0.0, 0.0, 1.0), color: Vec4::new(0.0, 0.0, 1.0, 1.0) },
    ],
    version: 0,
});
}

World Commands

#![allow(unused)]
fn main() {
world.queue_command(WorldCommand::LoadTexture {
    name: "my_texture".to_string(),
    rgba_data: texture_bytes,
    width: 256,
    height: 256,
});

world.queue_command(WorldCommand::DespawnRecursive { entity });
world.queue_command(WorldCommand::LoadHdrSkybox { hdr_data });
world.queue_command(WorldCommand::CaptureScreenshot { path: None });

despawn_recursive_immediate(world, entity);
}

Running

fn main() -> Result<(), Box<dyn std::error::Error>> {
    nightshade::launch(MyGame::default())
}

Cookbook

Quick recipes organized by what you want to accomplish. Each recipe is self-contained and uses real Nightshade API patterns.

I Want To... Move Things

Move a player with WASD

#![allow(unused)]
fn main() {
fn player_movement(world: &mut World, player: Entity, speed: f32) {
    let dt = world.resources.window.timing.delta_time;
    let keyboard = &world.resources.input.keyboard;

    let mut direction = Vec3::zeros();

    if keyboard.is_key_pressed(KeyCode::KeyW) { direction.z -= 1.0; }
    if keyboard.is_key_pressed(KeyCode::KeyS) { direction.z += 1.0; }
    if keyboard.is_key_pressed(KeyCode::KeyA) { direction.x -= 1.0; }
    if keyboard.is_key_pressed(KeyCode::KeyD) { direction.x += 1.0; }

    if direction.magnitude() > 0.0 {
        direction = direction.normalize();

        if let Some(transform) = world.core.get_local_transform_mut(player) {
            transform.translation += direction * speed * dt;
        }
        mark_local_transform_dirty(world, player);
    }
}
}

Move a player relative to the camera

#![allow(unused)]
fn main() {
fn camera_relative_movement(
    world: &mut World,
    player: Entity,
    camera: Entity,
    speed: f32,
) {
    let dt = world.resources.window.timing.delta_time;
    let keyboard = &world.resources.input.keyboard;

    let mut input = Vec2::zeros();
    if keyboard.is_key_pressed(KeyCode::KeyW) { input.y -= 1.0; }
    if keyboard.is_key_pressed(KeyCode::KeyS) { input.y += 1.0; }
    if keyboard.is_key_pressed(KeyCode::KeyA) { input.x -= 1.0; }
    if keyboard.is_key_pressed(KeyCode::KeyD) { input.x += 1.0; }

    if input.magnitude() < 0.01 {
        return;
    }
    input = input.normalize();

    let Some(camera_transform) = world.core.get_global_transform(camera) else { return };
    let forward = camera_transform.forward_vector();
    let forward_flat = Vec3::new(forward.x, 0.0, forward.z).normalize();
    let right_flat = Vec3::new(forward.z, 0.0, -forward.x).normalize();

    let world_direction = forward_flat * -input.y + right_flat * input.x;

    if let Some(transform) = world.core.get_local_transform_mut(player) {
        transform.translation += world_direction * speed * dt;

        let target_yaw = world_direction.x.atan2(world_direction.z);
        let target_rotation = nalgebra_glm::quat_angle_axis(target_yaw, &Vec3::y());
        transform.rotation = nalgebra_glm::quat_slerp(
            &transform.rotation,
            &target_rotation,
            dt * 10.0,
        );
    }
    mark_local_transform_dirty(world, player);
}
}

Add jumping with gravity

#![allow(unused)]
fn main() {
struct JumpState {
    velocity_y: f32,
    grounded: bool,
}

fn handle_jumping(
    world: &mut World,
    player: Entity,
    state: &mut JumpState,
    jump_force: f32,
    gravity: f32,
) {
    let dt = world.resources.window.timing.delta_time;

    if state.grounded && world.resources.input.keyboard.is_key_pressed(KeyCode::Space) {
        state.velocity_y = jump_force;
        state.grounded = false;
    }

    if !state.grounded {
        state.velocity_y -= gravity * dt;
    }

    if let Some(transform) = world.core.get_local_transform_mut(player) {
        transform.translation.y += state.velocity_y * dt;

        if transform.translation.y <= 0.0 {
            transform.translation.y = 0.0;
            state.velocity_y = 0.0;
            state.grounded = true;
        }
    }
    mark_local_transform_dirty(world, player);
}
}

Make an object bob up and down

#![allow(unused)]
fn main() {
fn bob_system(world: &mut World, entity: Entity, time: f32, amplitude: f32, frequency: f32) {
    if let Some(transform) = world.core.get_local_transform_mut(entity) {
        transform.translation.y = 1.0 + (time * frequency).sin() * amplitude;
    }
    mark_local_transform_dirty(world, entity);
}
}

Rotate an object continuously

#![allow(unused)]
fn main() {
fn spin_system(world: &mut World, entity: Entity, time: f32) {
    if let Some(transform) = world.core.get_local_transform_mut(entity) {
        transform.rotation = nalgebra_glm::quat_angle_axis(time, &Vec3::y());
    }
    mark_local_transform_dirty(world, entity);
}
}

I Want To... Set Up Cameras

Make a first-person camera

Horizontal yaw on the player body, vertical pitch on the camera. The camera is parented to the player so it follows automatically:

#![allow(unused)]
fn main() {
fn setup_fps_camera(world: &mut World, player: Entity) -> Entity {
    let camera = world.spawn_entities(
        LOCAL_TRANSFORM | GLOBAL_TRANSFORM | CAMERA | PARENT,
        1,
    )[0];

    world.core.set_local_transform(camera, LocalTransform {
        translation: Vec3::new(0.0, 0.7, 0.0),
        ..Default::default()
    });
    world.core.set_camera(camera, Camera::default());
    world.core.set_parent(camera, Parent(Some(player)));
    world.resources.active_camera = Some(camera);

    camera
}

fn fps_look(world: &mut World, player: Entity, camera: Entity) {
    let mouse_delta = world.resources.input.mouse.position_delta;
    let sensitivity = 0.002;

    if let Some(transform) = world.core.get_local_transform_mut(player) {
        let yaw = nalgebra_glm::quat_angle_axis(-mouse_delta.x * sensitivity, &Vec3::y());
        transform.rotation = yaw * transform.rotation;
    }
    mark_local_transform_dirty(world, player);

    if let Some(transform) = world.core.get_local_transform_mut(camera) {
        let pitch = nalgebra_glm::quat_angle_axis(-mouse_delta.y * sensitivity, &Vec3::x());
        transform.rotation = transform.rotation * pitch;
    }
    mark_local_transform_dirty(world, camera);
}
}

Make a third-person orbit camera

#![allow(unused)]
fn main() {
struct OrbitCamera {
    target: Entity,
    distance: f32,
    yaw: f32,
    pitch: f32,
}

fn orbit_camera_system(world: &mut World, camera: Entity, orbit: &mut OrbitCamera) {
    let mouse_delta = world.resources.input.mouse.position_delta;
    let scroll = world.resources.input.mouse.wheel_delta;

    orbit.yaw -= mouse_delta.x * 0.003;
    orbit.pitch -= mouse_delta.y * 0.003;
    orbit.pitch = orbit.pitch.clamp(-1.4, 1.4);
    orbit.distance = (orbit.distance - scroll.y * 0.5).clamp(2.0, 20.0);

    let Some(target_transform) = world.core.get_global_transform(orbit.target) else { return };
    let target_pos = target_transform.translation() + Vec3::new(0.0, 1.5, 0.0);

    let offset = Vec3::new(
        orbit.yaw.sin() * orbit.pitch.cos(),
        orbit.pitch.sin(),
        orbit.yaw.cos() * orbit.pitch.cos(),
    ) * orbit.distance;

    let camera_pos = target_pos + offset;

    if let Some(transform) = world.core.get_local_transform_mut(camera) {
        transform.translation = camera_pos;

        let direction = (target_pos - camera_pos).normalize();
        let pitch = (-direction.y).asin();
        let yaw = direction.x.atan2(direction.z);

        transform.rotation = nalgebra_glm::quat_angle_axis(yaw, &Vec3::y())
            * nalgebra_glm::quat_angle_axis(pitch, &Vec3::x());
    }
    mark_local_transform_dirty(world, camera);
}
}

Make a smooth follow camera

#![allow(unused)]
fn main() {
fn follow_camera(
    world: &mut World,
    target: Entity,
    camera: Entity,
    offset: Vec3,
    smoothness: f32,
) {
    let dt = world.resources.window.timing.delta_time;

    let Some(target_transform) = world.core.get_global_transform(target) else { return };
    let target_pos = target_transform.translation() + offset;

    if let Some(cam_transform) = world.core.get_local_transform_mut(camera) {
        cam_transform.translation = nalgebra_glm::lerp(
            &cam_transform.translation,
            &target_pos,
            dt * smoothness,
        );

        let look_at = target_transform.translation();
        let direction = (look_at - cam_transform.translation).normalize();
        let pitch = (-direction.y).asin();
        let yaw = direction.x.atan2(direction.z);

        cam_transform.rotation = nalgebra_glm::quat_angle_axis(yaw, &Vec3::y())
            * nalgebra_glm::quat_angle_axis(pitch, &Vec3::x());
    }
    mark_local_transform_dirty(world, camera);
}
}

I Want To... Spawn Objects

Spawn a colored cube

#![allow(unused)]
fn main() {
fn spawn_colored_cube(world: &mut World, position: Vec3, color: [f32; 4]) -> Entity {
    let cube = spawn_cube_at(world, position);

    material_registry_insert(
        &mut world.resources.material_registry,
        format!("cube_{}", cube.id),
        Material {
            base_color: color,
            ..Default::default()
        },
    );

    let material_name = format!("cube_{}", cube.id);
    if let Some(&index) = world.resources.material_registry.registry.name_to_index.get(&material_name) {
        world.resources.material_registry.registry.add_reference(index);
    }
    world.core.set_material_ref(cube, MaterialRef::new(material_name));

    cube
}
}

Spawn objects at random positions

#![allow(unused)]
fn main() {
fn random_position_in_box(center: Vec3, half_extents: Vec3) -> Vec3 {
    Vec3::new(
        center.x + (rand::random::<f32>() - 0.5) * 2.0 * half_extents.x,
        center.y + (rand::random::<f32>() - 0.5) * 2.0 * half_extents.y,
        center.z + (rand::random::<f32>() - 0.5) * 2.0 * half_extents.z,
    )
}

fn random_position_on_circle(center: Vec3, radius: f32) -> Vec3 {
    let angle = rand::random::<f32>() * std::f32::consts::TAU;
    Vec3::new(
        center.x + angle.cos() * radius,
        center.y,
        center.z + angle.sin() * radius,
    )
}
}

Spawn a physics object that falls

#![allow(unused)]
fn main() {
use nightshade::ecs::physics::commands::spawn_dynamic_physics_cube_with_material;

fn spawn_physics_cube(world: &mut World, position: Vec3) -> Entity {
    spawn_dynamic_physics_cube_with_material(
        world,
        position,
        Vec3::new(1.0, 1.0, 1.0),
        1.0,
        Material {
            base_color: [0.6, 0.4, 0.2, 1.0],
            ..Default::default()
        },
    )
}
}

Spawn a wave of enemies at intervals

#![allow(unused)]
fn main() {
struct WaveSpawner {
    wave: u32,
    enemies_remaining: u32,
    spawn_timer: f32,
    spawn_interval: f32,
}

impl WaveSpawner {
    fn update(&mut self, world: &mut World, dt: f32) {
        if self.enemies_remaining == 0 {
            self.wave += 1;
            self.enemies_remaining = 5 + self.wave * 2;
            self.spawn_interval = (2.0 - self.wave as f32 * 0.1).max(0.3);
            return;
        }

        self.spawn_timer -= dt;
        if self.spawn_timer <= 0.0 {
            let position = random_position_on_circle(Vec3::zeros(), 20.0);
            spawn_cube_at(world, position);
            self.enemies_remaining -= 1;
            self.spawn_timer = self.spawn_interval;
        }
    }
}
}

Load a 3D model

#![allow(unused)]
fn main() {
use nightshade::ecs::prefab::commands::gltf_import::import_gltf_from_path;

fn initialize(&mut self, world: &mut World) {
    let result = import_gltf_from_path(std::path::Path::new("assets/models/character.glb"))
        .expect("Failed to load model");

    if let Some(prefab) = result.prefabs.first() {
        let root = spawn_prefab_with_animations(world, prefab, &result.animations, Vec3::zeros());
        world.core.set_local_transform(root, LocalTransform {
            translation: Vec3::new(0.0, 0.0, 0.0),
            scale: Vec3::new(1.0, 1.0, 1.0),
            ..Default::default()
        });
    }
}
}

I Want To... Use Physics

Apply an explosion force

#![allow(unused)]
fn main() {
fn explosion(world: &mut World, center: Vec3, radius: f32, force: f32) {
    for entity in world.core.query_entities(RIGID_BODY | GLOBAL_TRANSFORM) {
        let Some(transform) = world.core.get_global_transform(entity) else { continue };
        let to_entity = transform.translation() - center;
        let distance = to_entity.magnitude();

        if distance < radius && distance > 0.1 {
            let falloff = 1.0 - (distance / radius);
            let impulse = to_entity.normalize() * force * falloff;

            if let Some(body) = world.core.get_rigid_body_mut(entity) {
                body.linvel = [
                    body.linvel[0] + impulse.x,
                    body.linvel[1] + impulse.y,
                    body.linvel[2] + impulse.z,
                ];
            }
        }
    }
}
}

Pick entity from the camera

#![allow(unused)]
fn main() {
fn shoot_from_camera(world: &mut World) {
    let (width, height) = world.resources.window.cached_viewport_size.unwrap_or((800, 600));
    let screen_center = Vec2::new(width as f32 / 2.0, height as f32 / 2.0);

    if let Some(hit) = pick_closest_entity_trimesh(world, screen_center) {
        let hit_position = hit.world_position;
        let hit_entity = hit.entity;
        let hit_distance = hit.distance;
    }
}
}

Grab and throw objects

#![allow(unused)]
fn main() {
struct GrabState {
    entity: Option<Entity>,
    distance: f32,
}

fn grab_object(world: &mut World, state: &mut GrabState) {
    let Some(camera) = world.resources.active_camera else { return };
    let Some(transform) = world.core.get_global_transform(camera) else { return };

    let origin = transform.translation();
    let direction = transform.forward_vector();

    for entity in world.core.query_entities(RIGID_BODY | GLOBAL_TRANSFORM) {
        let Some(entity_transform) = world.core.get_global_transform(entity) else { continue };
        let to_entity = entity_transform.translation() - origin;
        let distance = to_entity.magnitude();
        let dot = direction.dot(&to_entity.normalize());

        if distance < 20.0 && dot > 0.95 {
            state.entity = Some(entity);
            state.distance = distance;
            break;
        }
    }
}

fn update_held_object(world: &mut World, state: &GrabState) {
    let Some(entity) = state.entity else { return };
    let Some(camera) = world.resources.active_camera else { return };
    let Some(camera_transform) = world.core.get_global_transform(camera) else { return };

    let target = camera_transform.translation() +
        camera_transform.forward_vector() * state.distance;

    if let Some(transform) = world.core.get_local_transform(entity) {
        let to_target = target - transform.translation;
        if let Some(body) = world.core.get_rigid_body_mut(entity) {
            body.linvel = [to_target.x * 20.0, to_target.y * 20.0, to_target.z * 20.0];
        }
    }
}

fn throw_object(world: &mut World, state: &mut GrabState) {
    if let Some(entity) = state.entity.take() {
        let Some(camera) = world.resources.active_camera else { return };
        let Some(transform) = world.core.get_global_transform(camera) else { return };
        let direction = transform.forward_vector();

        if let Some(body) = world.core.get_rigid_body_mut(entity) {
            body.linvel = [direction.x * 20.0, direction.y * 20.0, direction.z * 20.0];
        }
    }
}
}

I Want To... Create Materials

Make a glowing emissive material

#![allow(unused)]
fn main() {
let neon = Material {
    base_color: [0.2, 0.8, 1.0, 1.0],
    emissive_factor: [0.2, 0.8, 1.0],
    emissive_strength: 10.0,
    roughness: 0.8,
    ..Default::default()
};
}

Make glass

#![allow(unused)]
fn main() {
let glass = Material {
    base_color: [0.95, 0.95, 1.0, 1.0],
    roughness: 0.05,
    metallic: 0.0,
    transmission_factor: 0.95,
    ior: 1.5,
    ..Default::default()
};
}

Make a metallic surface

#![allow(unused)]
fn main() {
let gold = Material {
    base_color: [1.0, 0.84, 0.0, 1.0],
    roughness: 0.3,
    metallic: 1.0,
    ..Default::default()
};
}

Make a transparent ghost-like material

#![allow(unused)]
fn main() {
let ghost = Material {
    base_color: [0.9, 0.95, 1.0, 0.3],
    alpha_mode: AlphaMode::Blend,
    roughness: 0.1,
    ..Default::default()
};
}

I Want To... Show UI

Display an FPS counter

#![allow(unused)]
fn main() {
struct FpsCounter {
    samples: Vec<f32>,
    text_entity: Entity,
}

impl FpsCounter {
    fn update(&mut self, world: &mut World) {
        let fps = world.resources.window.timing.frames_per_second;
        self.samples.push(fps);

        if self.samples.len() > 60 {
            self.samples.remove(0);
        }

        let avg: f32 = self.samples.iter().sum::<f32>() / self.samples.len() as f32;

        if let Some(text) = world.core.get_text_mut(self.text_entity) {
            world.resources.text_cache.set_text(text.text_index, &format!("FPS: {:.0}", avg));
            text.dirty = true;
        }
    }
}
}

Display a health bar as HUD text

#![allow(unused)]
fn main() {
fn update_health_bar(world: &mut World, text_entity: Entity, current: f32, max: f32) {
    let bar_length = 20;
    let filled = ((current / max) * bar_length as f32) as usize;

    let bar = format!(
        "[{}{}] {}/{}",
        "|".repeat(filled.min(bar_length)),
        ".".repeat(bar_length - filled.min(bar_length)),
        current as u32,
        max as u32,
    );

    if let Some(text) = world.core.get_text_mut(text_entity) {
        world.resources.text_cache.set_text(text.text_index, &bar);
        text.dirty = true;
    }
}
}

Show a scoreboard with egui

#![allow(unused)]
fn main() {
fn ui(&mut self, _world: &mut World, ctx: &egui::Context) {
    egui::Window::new("Score")
        .anchor(egui::Align2::CENTER_TOP, [0.0, 10.0])
        .resizable(false)
        .collapsible(false)
        .title_bar(false)
        .show(ctx, |ui| {
            ui.heading(format!("{} - {}", self.left_score, self.right_score));
        });
}
}

I Want To... Handle Game States

Pause the game

#![allow(unused)]
fn main() {
fn on_keyboard_input(&mut self, world: &mut World, key: KeyCode, state: ElementState) {
    if state == ElementState::Pressed && key == KeyCode::Escape {
        self.paused = !self.paused;
        world.set_cursor_visible(self.paused);
        world.set_cursor_locked(!self.paused);
    }
}

fn run_systems(&mut self, world: &mut World) {
    if self.paused {
        return;
    }

    self.update_game_logic(world);
}
}

Build a state machine for player actions

#![allow(unused)]
fn main() {
#[derive(Clone, Copy, PartialEq, Eq)]
enum PlayerAction {
    Idle,
    Walking,
    Running,
    Attacking,
    Dodging,
}

struct ActionState {
    current: PlayerAction,
    timer: f32,
}

impl ActionState {
    fn transition(&mut self, new_state: PlayerAction) {
        if self.current != new_state {
            self.current = new_state;
            self.timer = 0.0;
        }
    }

    fn update(&mut self, dt: f32) {
        self.timer += dt;
    }

    fn can_interrupt(&self) -> bool {
        match self.current {
            PlayerAction::Attacking => self.timer > 0.5,
            PlayerAction::Dodging => self.timer > 0.3,
            _ => true,
        }
    }
}
}

I Want To... Use Timers

Cooldown timer

#![allow(unused)]
fn main() {
struct Cooldown {
    duration: f32,
    remaining: f32,
}

impl Cooldown {
    fn new(duration: f32) -> Self {
        Self { duration, remaining: 0.0 }
    }

    fn update(&mut self, dt: f32) {
        self.remaining = (self.remaining - dt).max(0.0);
    }

    fn ready(&self) -> bool {
        self.remaining <= 0.0
    }

    fn trigger(&mut self) {
        self.remaining = self.duration;
    }

    fn progress(&self) -> f32 {
        1.0 - (self.remaining / self.duration)
    }
}
}

Repeating timer

#![allow(unused)]
fn main() {
struct RepeatingTimer {
    interval: f32,
    elapsed: f32,
}

impl RepeatingTimer {
    fn new(interval: f32) -> Self {
        Self { interval, elapsed: 0.0 }
    }

    fn tick(&mut self, dt: f32) -> bool {
        self.elapsed += dt;

        if self.elapsed >= self.interval {
            self.elapsed -= self.interval;
            true
        } else {
            false
        }
    }
}
}

I Want To... Debug Things

Draw wireframe collision boxes

#![allow(unused)]
fn main() {
fn debug_draw_boxes(
    world: &mut World,
    lines_entity: Entity,
    entities: &[Entity],
    half_extents: Vec3,
) {
    let mut lines = vec![];

    for &entity in entities {
        let Some(transform) = world.core.get_global_transform(entity) else { continue };
        let pos = transform.translation();
        let color = Vec4::new(0.0, 1.0, 0.0, 1.0);
        let half = half_extents;

        let corners = [
            pos + Vec3::new(-half.x, -half.y, -half.z),
            pos + Vec3::new( half.x, -half.y, -half.z),
            pos + Vec3::new( half.x, -half.y,  half.z),
            pos + Vec3::new(-half.x, -half.y,  half.z),
            pos + Vec3::new(-half.x,  half.y, -half.z),
            pos + Vec3::new( half.x,  half.y, -half.z),
            pos + Vec3::new( half.x,  half.y,  half.z),
            pos + Vec3::new(-half.x,  half.y,  half.z),
        ];

        let edges = [
            (0,1), (1,2), (2,3), (3,0),
            (4,5), (5,6), (6,7), (7,4),
            (0,4), (1,5), (2,6), (3,7),
        ];

        for (a, b) in edges {
            lines.push(Line { start: corners[a], end: corners[b], color });
        }
    }

    world.core.set_lines(lines_entity, Lines { lines, version: 0 });
}
}

I Want To... Save and Load

Save game state to JSON

#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
struct SaveData {
    player_position: [f32; 3],
    player_health: f32,
    score: u32,
    level: u32,
}

fn save_game(data: &SaveData, path: &str) -> std::io::Result<()> {
    let json = serde_json::to_string_pretty(data)?;
    std::fs::write(path, json)?;
    Ok(())
}

fn load_game(path: &str) -> std::io::Result<SaveData> {
    let json = std::fs::read_to_string(path)?;
    let data: SaveData = serde_json::from_str(&json)?;
    Ok(data)
}
}

I Want To... Play Audio

Footstep sounds while moving

#![allow(unused)]
fn main() {
struct FootstepSystem {
    timer: f32,
    interval: f32,
    sounds: Vec<String>,
    last_index: usize,
    audio_entity: Entity,
}

impl FootstepSystem {
    fn update(&mut self, world: &mut World, is_moving: bool, is_running: bool, dt: f32) {
        if !is_moving {
            self.timer = 0.0;
            return;
        }

        let interval = if is_running { self.interval * 0.6 } else { self.interval };
        self.timer += dt;

        if self.timer >= interval {
            self.timer = 0.0;

            let mut index = rand::random::<usize>() % self.sounds.len();
            if index == self.last_index && self.sounds.len() > 1 {
                index = (index + 1) % self.sounds.len();
            }
            self.last_index = index;

            if let Some(audio) = world.core.get_audio_source_mut(self.audio_entity) {
                audio.audio_ref = Some(self.sounds[index].clone());
                audio.playing = true;
            }
        }
    }
}
}

I Want To... Pool Entities

Reuse entities instead of spawning/despawning

#![allow(unused)]
fn main() {
struct EntityPool {
    available: Vec<Entity>,
    active: Vec<Entity>,
    spawn_fn: fn(&mut World) -> Entity,
}

impl EntityPool {
    fn new(world: &mut World, initial_size: usize, spawn_fn: fn(&mut World) -> Entity) -> Self {
        let mut available = Vec::with_capacity(initial_size);

        for _ in 0..initial_size {
            let entity = spawn_fn(world);
            world.core.set_visibility(entity, Visibility { visible: false });
            available.push(entity);
        }

        Self { available, active: Vec::new(), spawn_fn }
    }

    fn acquire(&mut self, world: &mut World) -> Entity {
        let entity = self.available.pop().unwrap_or_else(|| (self.spawn_fn)(world));
        world.core.set_visibility(entity, Visibility { visible: true });
        self.active.push(entity);
        entity
    }

    fn release(&mut self, world: &mut World, entity: Entity) {
        if let Some(index) = self.active.iter().position(|&entity_in_pool| entity_in_pool == entity) {
            self.active.swap_remove(index);
            world.core.set_visibility(entity, Visibility { visible: false });
            self.available.push(entity);
        }
    }
}
}

I Want To... Attach Things to Other Things

Parent an object to another entity

#![allow(unused)]
fn main() {
world.core.set_parent(child, Parent(Some(parent)));
}

The child's LocalTransform becomes relative to the parent. The engine computes the GlobalTransform automatically via the transform hierarchy.

Attach a weapon to a camera

#![allow(unused)]
fn main() {
fn attach_weapon_to_camera(world: &mut World, camera: Entity) -> Entity {
    let weapon = spawn_cube_at(world, Vec3::zeros());

    world.core.set_local_transform(weapon, LocalTransform {
        translation: Vec3::new(0.3, -0.2, -0.5),
        rotation: nalgebra_glm::quat_angle_axis(std::f32::consts::PI, &Vec3::y()),
        scale: Vec3::new(0.05, 0.05, 0.3),
    });

    set_material_with_textures(world, weapon, Material {
        base_color: [0.2, 0.2, 0.2, 1.0],
        metallic: 0.9,
        ..Default::default()
    });

    world.core.set_parent(weapon, Parent(Some(camera)));
    weapon
}
}

Add weapon sway from mouse movement

#![allow(unused)]
fn main() {
fn weapon_sway(world: &mut World, weapon: Entity, rest_x: f32, rest_y: f32) {
    let dt = world.resources.window.timing.delta_time;
    let mouse_delta = world.resources.input.mouse.position_delta;

    if let Some(transform) = world.core.get_local_transform_mut(weapon) {
        let target_x = rest_x - mouse_delta.x * 0.001;
        let target_y = rest_y - mouse_delta.y * 0.001;

        transform.translation.x += (target_x - transform.translation.x) * dt * 10.0;
        transform.translation.y += (target_y - transform.translation.y) * dt * 10.0;
    }
    mark_local_transform_dirty(world, weapon);
}
}

Glossary

Common terms used in game development and Nightshade.

A

Alpha Blending Technique for rendering transparent objects by mixing colors based on alpha (transparency) values.

Alpha Cutoff Threshold for alpha testing. Pixels with alpha below this value are discarded entirely.

Ambient Light Constant, directionless light that illuminates all surfaces equally. Simulates indirect illumination.

Ambient Occlusion (AO) Technique that darkens creases and corners where ambient light would be blocked. See SSAO.

Animation Blending Smoothly transitioning between two animations by interpolating their transforms.

Animation Clip A single named animation (e.g., "walk", "run", "idle") containing keyframed transforms.

Aspect Ratio Width divided by height of the viewport (e.g., 16:9 = 1.777).

B

Billboard A sprite that always faces the camera, commonly used for particles and distant objects.

Bind Pose The default pose of a skeletal mesh before any animation is applied.

Bloom Post-processing effect that creates a glow around bright areas.

Bone A joint in a skeletal hierarchy used for animation. Also called a joint.

C

Cascaded Shadow Maps (CSM) Technique using multiple shadow maps at different resolutions for different distances.

CCD (Continuous Collision Detection) Physics technique to prevent fast-moving objects from passing through thin surfaces.

Character Controller A kinematic physics body designed for player movement with special handling for steps and slopes.

Collider A simplified shape used for physics collision detection (box, sphere, capsule, etc.).

Component Data attached to an entity in an ECS. Contains no logic, only state.

Culling Excluding objects from rendering if they're outside the view or occluded.

D

Delta Time (dt) Time elapsed since the previous frame. Used to make movement frame-rate independent.

Depth Buffer (Z-Buffer) Texture storing the distance of each pixel from the camera. Used for depth testing.

Diffuse The base color of a surface, independent of view angle.

Dynamic Body A physics body affected by forces, gravity, and collisions.

E

ECS (Entity Component System) Architecture where entities are IDs, components are data, and systems are logic.

Emission/Emissive Light that a surface produces itself, independent of external lighting.

Entity A unique identifier that groups related components together. Has no data itself.

Euler Angles Representation of rotation as three angles (pitch, yaw, roll). Can suffer from gimbal lock.

Exposure Brightness adjustment simulating camera exposure settings.

F

Far Plane Maximum distance from the camera at which objects are rendered.

FFT (Fast Fourier Transform) Algorithm to convert audio from time domain to frequency domain.

Field of View (FOV) Angle of the visible area. Typically 60-90 degrees for games.

Forward Rendering Rendering each object completely in one pass. Simple but expensive with many lights.

Frame One complete update and render cycle.

Frustum The 3D region visible to the camera, shaped like a truncated pyramid.

G

G-Buffer Textures storing geometry information (normals, depth, albedo) for deferred rendering.

Gimbal Lock Loss of one degree of freedom when two rotation axes align. Quaternions avoid this.

glTF/GLB Standard 3D model format. glTF is JSON + binary, GLB is single binary file.

Global Transform World-space transformation after parent transforms are applied.

H

HDR (High Dynamic Range) Color values exceeding 0-1 range, allowing for realistic lighting before tonemapping.

Heightfield A 2D grid of height values representing terrain or other surfaces.

Hierarchy Parent-child relationships between entities where child transforms are relative to parents.

I

Index Buffer List of vertex indices defining triangles. Allows vertex reuse.

Instancing Rendering many copies of the same mesh efficiently in a single draw call.

Interpolation Smoothly blending between two values. Linear interpolation (lerp) is most common.

J

Joint Connection point between physics bodies with constraints on movement.

K

Keyframe A specific value at a specific time in an animation. Values are interpolated between keyframes.

Kinematic Body A physics body moved by code that affects dynamic bodies but isn't affected by physics.

L

LDR (Low Dynamic Range) Standard 0-1 color range suitable for display.

Lerp (Linear Interpolation) Blending between two values: result = a + (b - a) * t where t is 0-1.

LOD (Level of Detail) Using simpler meshes for distant objects to improve performance.

Local Transform Position, rotation, scale relative to the parent entity (or world if no parent).

M

Material Defines how a surface looks: color, roughness, metallic, textures, etc.

Mesh Geometry defined by vertices and indices forming triangles.

Metallic PBR property indicating whether a surface is metal (1.0) or dielectric (0.0).

Mipmaps Pre-calculated, progressively smaller versions of a texture for efficient sampling at distance.

Morph Targets Vertex positions for blending between shapes (facial expressions, etc.). Also called blend shapes.

N

NavMesh (Navigation Mesh) Simplified geometry representing walkable areas for AI pathfinding.

Near Plane Minimum distance from camera at which objects are rendered. Objects closer are clipped.

Normal Map Texture encoding surface direction variations to simulate detail without geometry.

O

Occlusion When one object blocks another from view or light.

Orthographic Projection Parallel projection with no perspective. Objects don't get smaller with distance.

P

PBR (Physically Based Rendering) Material model based on real-world physics for consistent, realistic lighting.

Perspective Projection Projection where distant objects appear smaller, simulating human vision.

Pitch Rotation around the X (left-right) axis. Looking up/down.

Point Light Light emitting equally in all directions from a point.

Prefab Pre-configured entity template that can be instantiated multiple times.

Q

Quaternion 4D number representing rotation without gimbal lock. Used for smooth interpolation.

Query Finding entities that have specific components.

R

Raycast Tracing a line through space to find intersections with geometry.

Render Graph Declarative system for defining rendering passes and their dependencies.

Render Pass A single stage of rendering (shadow pass, color pass, post-processing pass).

Rigid Body Physics object that doesn't deform. Can be dynamic, kinematic, or static.

Roll Rotation around the Z (forward) axis. Tilting sideways.

Roughness PBR property controlling how scattered light reflections are. 0=mirror, 1=diffuse.

S

Skinning Deforming mesh vertices based on bone positions. Used for character animation.

Skybox Cubemap texture surrounding the scene representing distant environment.

Slerp (Spherical Linear Interpolation) Interpolation for quaternions that maintains constant angular velocity.

Specular Mirror-like reflection of light. Intensity depends on view angle.

Spot Light Light emitting in a cone shape, like a flashlight.

SSAO (Screen-Space Ambient Occlusion) Post-processing technique approximating ambient occlusion from depth buffer.

Static Body Physics body that never moves. Used for floors, walls, terrain.

System Logic that operates on entities with specific components.

T

Tessellation Subdividing geometry into smaller triangles for detail.

Texture 2D image mapped onto 3D geometry.

Tonemapping Converting HDR colors to displayable LDR range.

Transform Position, rotation, and scale of an object in 3D space.

Transparency See Alpha Blending.

Trimesh (Triangle Mesh) Collision shape using actual mesh geometry. Accurate but expensive.

U

UV Coordinates 2D texture coordinates mapping texture pixels to mesh vertices.

Uniform Shader constant that's the same for all vertices/pixels in a draw call.

V

Vertex Point in 3D space with position, normal, texture coordinates, etc.

Vertex Buffer GPU memory containing vertex data.

Vignette Post-processing effect darkening screen edges.

Vulkan Low-level graphics API. Used by wgpu on Windows/Linux.

W

WebGPU Modern web graphics API. Used by wgpu for cross-platform rendering.

World Space Global coordinate system. Contrast with local/object space.

wgpu Rust graphics library providing cross-platform GPU access.

Y

Yaw Rotation around the Y (up) axis. Looking left/right.

Z

Z-Buffer See Depth Buffer.

Z-Fighting Visual artifacts when two surfaces are at nearly the same depth.

Troubleshooting

Common issues and their solutions.

Compilation Errors

"feature X is not enabled"

You're using a feature that isn't enabled in your Cargo.toml. Add the required feature:

nightshade = { git = "...", features = ["engine", "wgpu", "physics", "audio"] }

See Feature Flags for the complete list.

"cannot find function spawn_cube_at"

Import the prelude:

#![allow(unused)]
fn main() {
use nightshade::prelude::*;
}

"the trait State is not implemented"

Ensure your game struct implements all required methods:

#![allow(unused)]
fn main() {
impl State for MyGame {
    fn title(&self) -> &str { "My Game" }
    fn initialize(&mut self, world: &mut World) {}
    fn run_systems(&mut self, world: &mut World) {}
}
}

"nalgebra_glm vs glam conflict"

Nightshade uses nalgebra_glm exclusively. Don't mix with glam:

#![allow(unused)]
fn main() {
// Correct
use nalgebra_glm::Vec3;

// Wrong - will cause type mismatches
use glam::Vec3;
}

Runtime Errors

"No suitable adapter found"

Your GPU doesn't support the required graphics API.

Windows:

  • Update graphics drivers
  • Install Vulkan Runtime from https://vulkan.lunarg.com/
  • Try forcing DX12: WGPU_BACKEND=dx12 ./game.exe

Linux:

  • Install Vulkan drivers: sudo apt install mesa-vulkan-drivers
  • Verify with vulkaninfo

macOS:

  • Ensure macOS 10.13+ (Metal required)
  • Check System Report > Graphics for Metal support

"Entity not found"

You're accessing an entity that was despawned or never existed:

#![allow(unused)]
fn main() {
// Check entity exists before access
if world.has_entity(entity) {
    if let Some(transform) = world.core.get_local_transform(entity) {
        // Safe to use
    }
}
}

"Texture not found"

The texture path is incorrect or the file doesn't exist:

#![allow(unused)]
fn main() {
import_gltf_from_path(std::path::Path::new("assets/models/character.glb"));  // Correct
import_gltf_from_path(std::path::Path::new("/home/user/game/assets/models/character.glb"));  // Avoid absolute paths
}

Physics objects fall through floor

Common causes:

  1. Missing collider on floor:
#![allow(unused)]
fn main() {
world.core.set_collider(floor, ColliderComponent::new_cuboid(50.0, 0.5, 50.0));
}
  1. Objects spawned inside each other:
#![allow(unused)]
fn main() {
// Spawn above the floor, not at y=0
transform.translation = Vec3::new(0.0, 2.0, 0.0);
}
  1. High velocity causing tunneling:
#![allow(unused)]
fn main() {
// Enable CCD for fast objects
let mut body = RigidBodyComponent::new_dynamic();
body.ccd_enabled = true;
world.core.set_rigid_body(entity, body);
}

Animation not playing

  1. Check animation name exists:
#![allow(unused)]
fn main() {
if let Some(player) = world.core.get_animation_player_mut(entity) {
    // List available animations
    for name in player.available_animations() {
        println!("Animation: {}", name);
    }
}
}
  1. Call update each frame:
#![allow(unused)]
fn main() {
fn run_systems(&mut self, world: &mut World) {
    update_animation_players(world, dt);
}
}
  1. Animation player is on child entity:
#![allow(unused)]
fn main() {
// glTF animations are often on child nodes
for child in world.resources.children_cache.get(&model_root).cloned().unwrap_or_default() {
    if let Some(player) = world.core.get_animation_player_mut(child) {
        player.play("idle");
    }
}
}

No audio output

  1. Create an AudioSource entity:
#![allow(unused)]
fn main() {
let sound = world.spawn_entities(AUDIO_SOURCE | LOCAL_TRANSFORM | GLOBAL_TRANSFORM, 1)[0];
world.core.set_audio_source(sound, AudioSource::new("shoot").playing());
}
  1. Check audio feature is enabled:
nightshade = { git = "...", features = ["engine", "wgpu", "audio"] }
  1. Verify file format: Supported formats are WAV, OGG, MP3, FLAC.

Performance Issues

Low frame rate

  1. Check entity count:
#![allow(unused)]
fn main() {
println!("Entities: {}", world.core.query_entities(RENDER_MESH).count());
}
  1. Disable expensive effects:
#![allow(unused)]
fn main() {
world.resources.graphics.ssao_enabled = false;
world.resources.graphics.bloom_enabled = false;
}
  1. Reduce shadow quality:
#![allow(unused)]
fn main() {
world.resources.graphics.shadow_map_size = 1024; // Default is 2048
}
  1. Use simpler colliders:
#![allow(unused)]
fn main() {
world.core.set_collider(entity, ColliderComponent::new_cuboid(1.0, 1.0, 1.0));
}

Memory usage high

  1. Despawn unused entities:
#![allow(unused)]
fn main() {
world.despawn_entities(&[entity]);
}
  1. Unload unused textures:
#![allow(unused)]
fn main() {
world.resources.texture_cache.clear_unused();
}
  1. Use smaller textures for distant objects.

Stuttering / hitching

  1. Avoid allocations in run_systems:
#![allow(unused)]
fn main() {
// Bad - allocates every frame
let entities: Vec<Entity> = world.core.query_entities(LOCAL_TRANSFORM).collect();

// Good - iterate directly
for entity in world.core.query_entities(LOCAL_TRANSFORM) {
    // ...
}
}
  1. Preload assets in initialize:
#![allow(unused)]
fn main() {
fn initialize(&mut self, world: &mut World) {
    spawn_cube_at(world, Vec3::new(0.0, 1.0, 0.0));
}
}

Visual Issues

Objects are black

Missing or incorrect lighting:

#![allow(unused)]
fn main() {
spawn_sun(world);
world.resources.graphics.ambient_light = [0.1, 0.1, 0.1, 1.0];
}

Objects are too bright / washed out

Adjust tonemapping and color grading:

#![allow(unused)]
fn main() {
world.resources.graphics.color_grading.tonemap_algorithm = TonemapAlgorithm::Aces;
world.resources.graphics.color_grading.brightness = 0.0;
}

Textures look wrong

  1. Normal maps inverted: Some tools export Y-flipped normals. Check your export settings.

  2. sRGB vs Linear: Base color textures should be sRGB. Normal/metallic/roughness should be linear.

  3. Texture coordinates flipped: glTF uses top-left origin. Some models may need UV adjustment.

Z-fighting (flickering surfaces)

Surfaces too close together:

#![allow(unused)]
fn main() {
// Increase near plane
camera.near = 0.1;  // Instead of 0.01

// Or separate surfaces more
floor_transform.translation.y = 0.0;
decal_transform.translation.y = 0.01;  // Slight offset
}

WebAssembly Issues

"WebGPU not supported"

  • Use Chrome 113+ or Edge 113+
  • Firefox requires enabling dom.webgpu.enabled in about:config
  • Safari support is limited

Assets fail to load

WASM can't access the filesystem. Serve assets via HTTP:

<script>
// Assets must be fetched, not loaded from disk
fetch('assets/model.glb')
    .then(response => response.arrayBuffer())
    .then(data => { /* use data */ });
</script>

Performance worse than native

Expected. WebGPU has overhead. Reduce quality settings:

#![allow(unused)]
fn main() {
#[cfg(target_arch = "wasm32")]
{
    world.resources.graphics.ssao_enabled = false;
    world.resources.graphics.shadow_map_size = 512;
}
}

Getting Help

If your issue isn't listed here:

  1. Check the GitHub Issues
  2. Search existing issues for similar problems
  3. Create a new issue with:
    • Nightshade version
    • Platform (OS, GPU)
    • Minimal code to reproduce
    • Error message or screenshot