Introduction
View the Gallery - Live demos of Nightshade's features running in your browser.
Nightshade is a 3D game engine written in Rust. It handles rendering, physics, audio, animation, input, and windowing, and exposes them through a single trait the game implements. The engine is the kernel that runs every frame. The game is the data and the per-frame logic that runs alongside it.
The runtime is built from four libraries. wgpu handles cross-platform GPU access, targeting Vulkan, Metal, DirectX 12, and WebGPU from a single API. Rapier3D runs the physics at a fixed timestep with interpolation for smooth rendering. Kira drives audio playback and spatial sound. glTF is the model format, with skeletal animation, materials, and scene hierarchy loaded out of the box. The rest of the engine is the glue that ties those pieces to an entity component system and a frame schedule.
How a Nightshade application is shaped
A game implements the State trait. Three methods carry most of the load. initialize runs once at startup and sets up the scene, the camera, the lights, and any entities the game needs. run_systems runs every frame and is where game logic lives. Input handlers like on_keyboard_input fire when events arrive.
The engine owns the World. The World holds every entity, every component, and a Resources struct of singletons grouped by domain (physics, input, assets, text, loading, transform_state, cleanup, entities, commands, schedules). Game code reads and writes the World to drive what happens on screen.
┌─────────────────────────────────────────────────────────────┐
│ 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 frame loop
Each frame proceeds in a fixed order. The engine processes window and input events from winit. It calls run_systems on the game state. The frame schedule runs the engine systems (physics step, animation sampling, transform propagation, text sync, cleanup). The render graph executes its passes against the resulting ECS state and submits the command buffer. The swapchain presents.
The game owns the contents of run_systems. Everything else happens automatically. The schedule itself is data, stored at world.resources.schedules.frame, so a game can insert, remove, or reorder engine systems from initialize when it needs to.
What the engine ships with
Rendering is PBR with a metallic-roughness workflow. Directional, point, and spot lights with shadow maps. Post-processing covers SSAO, SSGI, SSR, bloom, depth of field, and tonemapping. Skeletal animation samples bone transforms straight into the ECS, where the renderer reads them. There is a particle system, a procedural terrain pass, and a grass pass that draws thousands of blades.
Physics covers rigid bodies, character controllers, and the usual collider shapes (box, sphere, capsule, cylinder, convex hull, trimesh, heightfield). Joints cover fixed, revolute, prismatic, spherical, rope, and spring. Raycasting is the primary query.
Audio plays WAV, OGG, MP3, and FLAC, with 3D positioned sources and distance attenuation. Input covers keyboard, mouse, and gamepad (analog sticks, triggers, rumble), with cursor locking for first-person games. Pathfinding goes through a Recast navigation mesh. Debug rendering, HUD text, screenshot capture, and an in-game developer console are part of the standard kit. Steam integration sits behind a feature flag.
The same code runs on Windows, macOS, Linux, and WebAssembly. The graphics backend is picked at runtime.
What this book covers
The next chapters walk through installation, the first application, the project layout, and the architecture in detail. The runnable demo at the end of this section is a single-file Rust program that builds for the browser through WebAssembly and renders the same scene you see in the gallery.
The reference for engine code lives at docs/USAGE.md inside the repository. The API docs are at docs.rs/nightshade. The source is at github.com/matthewjberger/nightshade.
Interactive Demo
The demo below is a Nightshade application built for WebGPU and embedded in this page. It is the same engine binary that runs natively, compiled to WebAssembly.
Controls
- Mouse drag: orbit the camera around the scene.
- Scroll wheel: zoom in and out.
What is in the scene
Three primitives, a cube, a sphere, and a torus, sit on a procedural nebula background. The cube and the torus carry emissive material parameters, which the bloom post-process spreads into a glow around the bright pixels. The sphere is a polished chrome metal that reflects the image-based lighting captured from the procedural sky. An infinite grid sits at y = 0 for reference. A pan-orbit camera tracks the scene origin. Each object rotates and bobs every frame with smooth sinusoidal interpolation.
Browser requirements
The demo needs WebGPU.
- Chrome and Edge: 113 or newer (enabled by default).
- Firefox: 141 or newer (enabled by default).
- Safari: 18 or newer (Technology Preview).
A blank frame means the browser does not yet expose WebGPU.
Source code
The whole demo is two files. The Cargo.toml declares the dependency on Nightshade and adds the wasm-bindgen plumbing for the web build. The src/main.rs is the entire game.
Cargo.toml
[package]
name = "hello-nightshade"
version = "0.1.0"
edition = "2024"
[dependencies]
nightshade = { git = "https://github.com/matthewjberger/nightshade" }
[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 initialize(&mut self, world: &mut World) { world.resources.window.title = "Hello Nightshade".to_string(); 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); } } }
The initialize method sets up the window, the atmosphere, IBL capture, bloom, the grid, the camera, the sun, and three meshes with their materials. The run_systems method drives the camera controller and advances per-entity rotation and bobbing. Every transform mutation is followed by a mark_local_transform_dirty call so the frame schedule's transform propagation pass picks up the change.
Installation
Nightshade requires Rust 1.90 or newer with the 2024 edition, and a graphics driver supporting Vulkan 1.2, Metal, or DirectX 12.
Starting from the template
The fastest path is the template repository. It is a working project with the build scripts, CI, and WASM configuration already in place.
git clone https://github.com/matthewjberger/nightshade-template my-game
cd my-game
just run
The window opens onto a 3D scene with a nebula skybox, a ground grid, and a pan-orbit camera. That is the starting point. Edit src/main.rs to make it your game.
What the template contains
The template ships with the following files:
src/main.rsis a minimalStateimplementation with a camera and a sun.Cargo.tomlhas the Nightshade dependency with default features enabled.justfiledefinesrun,lint,test, and deployment commands for native, WASM, and Steam Deck.index.htmlandTrunk.tomlconfigure the WASM web build..github/workflows/runs clippy, tests, and the WASM build on CI, and deploys to GitHub Pages.rust-toolchainpins the Rust version and adds the WASM target.
The starter src/main.rs looks like this:
use nightshade::prelude::*; fn main() -> Result<(), Box<dyn std::error::Error>> { launch(Template)?; Ok(()) } #[derive(Default)] struct Template; impl State for Template { fn initialize(&mut self, world: &mut World) { world.resources.window.title = "Template".to_string(); 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 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
| Command | Description |
|---|---|
just run | Build and run in release mode |
just run-wasm | Build for the web and open the browser |
just lint | Run clippy with warnings as errors |
just test | Run the test suite |
just build-wasm | Build the WASM release without serving |
Use just run rather than cargo run directly. The justfile carries the flags the build needs.
Feature flags
Subsystems are opt-in through Cargo features. Add them in Cargo.toml:
[dependencies]
nightshade = { version = "0.13", features = ["physics", "audio"] }
| Feature | Description |
|---|---|
physics | Rapier3D physics simulation |
audio | Kira audio playback |
gamepad | Gamepad input via gilrs |
navmesh | Recast navigation mesh |
grass | GPU grass rendering |
steam | Steamworks integration |
The full list is in the Feature Flags appendix.
Platform notes
On Windows, DirectX 12 is the default backend. Update the graphics drivers if shaders fail to compile.
On macOS, Metal is selected automatically. Nothing else is required.
On Linux, Vulkan is required. Install the runtime and headers through the system package manager:
# 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
On the web, WebGPU support is required. Chrome 113+, Edge 113+, and Firefox 121+ work out of the box. The template's just run-wasm uses Trunk to compile and serve the page.
Your First Application
A Nightshade application is a struct that implements the State trait. The engine calls initialize once at startup and run_systems every frame. The rest of the methods on the trait are input handlers and lifecycle hooks. That is the whole interface.
The minimum viable application
use nightshade::prelude::*; fn main() -> Result<(), Box<dyn std::error::Error>> { nightshade::launch(MyGame::default()) } #[derive(Default)] struct MyGame; impl State for MyGame { fn initialize(&mut self, world: &mut World) { world.resources.window.title = "My First Game".to_string(); } fn run_systems(&mut self, world: &mut World) { } }
This compiles and runs. It opens a window and renders nothing useful. There is no camera, no light, no geometry. The rest of this chapter fills in those pieces, one at a time, before assembling them into a single example.
A camera
The camera determines what gets drawn. Spawn one and mark it active.
#![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); } }
spawn_camera returns an Entity handle. The renderer reads from whichever entity is stored in world.resources.active_camera. A scene with no active camera renders the clear color and nothing else.
A light
A directional light models the sun. Without one, every PBR surface in the scene is black.
#![allow(unused)] fn main() { fn initialize(&mut self, world: &mut World) { spawn_sun(world); } }
spawn_sun creates a directional light entity with sensible defaults and parents it to the world root. Replace it with spawn_point_light or spawn_spot_light for those flavors.
The grid and the sky
The grid is a development aid. The atmosphere fills the background and feeds image-based lighting.
#![allow(unused)] fn main() { fn initialize(&mut self, world: &mut World) { world.resources.graphics.show_grid = true; world.resources.graphics.atmosphere = Atmosphere::Sky; } }
Both fields live on world.resources.graphics. Toggling show_grid adds an infinite ground grid drawn at y = 0. The Atmosphere enum selects the procedural skybox (sky, nebula, or one of the other variants).
Geometry
spawn_cube_at is the simplest way to put something in the scene. It places a unit cube at the given position with default material properties.
#![allow(unused)] fn main() { fn initialize(&mut self, world: &mut World) { spawn_cube_at(world, Vec3::new(0.0, 1.0, 0.0)); } }
The prelude exposes equivalent helpers for spheres and other primitives. For real geometry, load a glTF file.
Camera controls
The fly camera reads mouse and keyboard input and moves the active camera each frame. Add it to run_systems.
#![allow(unused)] fn main() { fn run_systems(&mut self, world: &mut World) { fly_camera_system(world); escape_key_exit_system(world); } }
escape_key_exit_system sets world.resources.window.should_exit = true when the escape key is pressed. The engine reads that flag at the top of each frame and shuts the window down cleanly.
The whole 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 initialize(&mut self, world: &mut World) { world.resources.window.title = "My First Game".to_string(); 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
The fly camera binds the following inputs.
| Key | Action |
|---|---|
| W/A/S/D | Move forward/left/back/right |
| Space | Move up |
| Shift | Move down |
| Mouse | Look around |
| Escape | Exit |
Next steps
The next chapters cover loading real meshes from glTF, customizing materials, and adding physics simulation.
Project Structure
A Nightshade project is a regular Cargo binary crate. The engine is a single dependency, and the project layout is whatever shape the game needs. The recommendations below are the conventions Nightshade's own apps follow.
Recommended layout
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
main.rs stays tiny. The State implementation lives in game.rs. Per-frame logic gets split into systems/<name>.rs files, one per concern. Game logic belongs in the systems directory, not lumped into state.rs.
Cargo.toml
[package]
name = "my_game"
version = "0.1.0"
edition = "2024"
[dependencies]
nightshade = { git = "https://github.com/matthewjberger/nightshade.git", features = ["engine", "wgpu"] }
The engine and wgpu features are the minimum. Add physics, audio, and other features as the game needs them. Each one pulls in its dependency tree, so omit anything unused.
Entry point
main.rs does two things. It declares the game module and calls nightshade::launch with the game state.
mod game; use nightshade::prelude::*; fn main() -> Result<(), Box<dyn std::error::Error>> { nightshade::launch(game::MyGame::default()) }
That is the whole entry point. Anything more belongs in game.rs or a system module.
Game state
The State implementation owns whatever game data outlives a single frame. Entity handles for the player, score counters, level identifiers, and similar bookkeeping go on the struct.
#![allow(unused)] fn main() { use nightshade::prelude::*; #[derive(Default)] pub struct MyGame { player: Option<Entity>, score: u32, } impl State for MyGame { fn initialize(&mut self, world: &mut World) { world.resources.window.title = "My Game".to_string(); } fn run_systems(&mut self, world: &mut World) { } } }
State stored on the MyGame struct is per-game data. State stored in world.resources is per-engine data. The split is intentional. The engine never touches the game struct, and the game does not need to extend the engine's resource list to track its own bookkeeping.
Embedding assets
For distribution as a single binary, embed assets at compile time with include_bytes!.
#![allow(unused)] fn main() { const MODEL_BYTES: &[u8] = include_bytes!("../assets/models/character.glb"); const SKY_HDR: &[u8] = include_bytes!("../assets/textures/sky.hdr"); }
The bytes live in the binary and load synchronously. For larger games, leave the assets on disk and stream them at runtime through the asset cache.
A second ECS for game data
For non-trivial games, define a second freecs::ecs! world alongside the engine's World. The engine world handles rendering, physics, and transforms. The game world handles game-specific components like inventories, AI states, and stats. This is the dual-world pattern that apps/game/ and the example games in nightshade-examples use.
#![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, } }
The two worlds are linked through the engine's EngineEntity(Entity) component (at nightshade::ecs::sync::EngineEntity). Game-side entities store the engine-side entity they correspond to, and a render-sync pass copies positions or transforms from the game world to the engine world each frame. The helpers sync_engine_translation, sync_engine_transform, and despawn_linked cover the common cases. The pattern is documented in detail in the ECS chapters.
Splitting systems into modules
Once run_systems grows past a screen of code, break it apart by concern.
#![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); } } }
Each system file is a flat set of free functions over (&mut GameWorld, &mut World) or whichever subset it needs. No traits, no inheritance, no manager objects. The naming follows the engine's own convention. Behavior lives in systems/<name>.rs, not in state.rs.
Architecture Overview
Nightshade is a layered dependency graph. Each layer builds on the one below it, and no layer references anything above it. This chapter walks the layers from the bottom up.
Feature Layer: Terrain, Particles, NavMesh, Grass
|
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 bottom of the graph is three independent systems that everything else depends on.
freecs is the ECS. Its ecs! macro generates a World struct, the per-component storage in struct-of-arrays layout, the query methods, and the entity allocator. The whole thing compiles down to typed accessors over packed Vecs with no virtual dispatch and no unsafe. The result is the kind of cache-coherent access that a hand-rolled archetype storage gives.
nalgebra_glm is the math library. Vectors (Vec2, Vec3, Vec4), matrices (Mat4), quaternions (Quat), and the linear algebra operations that go with them. Nightshade uses nalgebra_glm exclusively. There is no glam anywhere in the codebase.
wgpu is the GPU API. One Rust surface targets Vulkan, Metal, DirectX 12, and WebGPU. Every rendering pass in Nightshade is built against wgpu directly.
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 of global singletons grouped by domain (timing, input, graphics settings, caches, physics world, audio engine, and the rest).
The transform hierarchy propagates LocalTransform through parent-child relationships to compute GlobalTransform matrices each frame. A dirty-flag system ensures only modified subtrees recompute. Manually setting a transform requires a mark_local_transform_dirty(world, entity) call so the next propagation pass picks up the change.
Input aggregates keyboard, mouse, and gamepad state each frame into world.resources.input. Both polling (is_key_pressed) and event-driven (on_keyboard_input) access patterns are available.
Time lives at world.resources.window.timing. The fields are delta_time, frames_per_second, uptime_milliseconds, frame_counter, and raw vs. speed-adjusted variants.
Windowing wraps winit. Window creation, event handling, and surface management all flow through it. On native platforms, secondary windows are supported through world.resources.secondary_windows.
Simulation layer
Systems that update world state every frame, independent of rendering.
Rapier3D runs the physics at a fixed 60 Hz timestep, with interpolation so rendering stays smooth between physics ticks. Rigid bodies, colliders, character controllers, joints, and raycasting are exposed through the physics feature flag.
Animation samples skeletal animations loaded from glTF files. Blending, crossfading, speed control, and looping are all part of the runtime. Bone transforms are written directly into the ECS each frame so the renderer reads them the same way it reads any other transform.
Kira drives audio. Sound playback, spatial positioning, and distance attenuation sit behind the audio feature flag.
Rendering layer
The rendering layer turns ECS state into pixels.
The render graph is a dependency-driven frame graph built on petgraph. Passes declare which resources they read and write through named slots. The graph builds the dependency edges, topologically sorts the passes, computes resource lifetimes, aliases transient GPU memory between non-overlapping passes, and picks the load and store operations that fit.
Passes implement the PassNode trait. Each pass owns its GPU pipelines and bind group layouts. The built-in passes cover shadow mapping, PBR mesh rendering, skeletal animation, grass, particles, text, UI, and post-processing (SSAO, SSGI, SSR, bloom, depth of field, tonemapping, and effects).
Materials use a PBR metallic-roughness workflow stored in a MaterialRegistry. Materials reference textures by name and attach to entities via MaterialRef.
Textures live in a TextureCache that handles GPU upload and format conversion. The way to load a texture is to push a WorldCommand::LoadTexture onto the command queue.
Shaders are written in WGSL and embedded into the binary at compile time via include_str!.
Application layer
The interface between the engine and game code.
The State trait is what the game struct implements. Its methods (initialize, run_systems, configure_render_graph, the input handlers) are called by the engine at fixed points in the frame lifecycle.
The main loop drives the lifecycle. It processes events, updates input, calls run_systems, dispatches events, runs the frame schedule, renders, and presents.
The frame schedule is a data-driven ordered list of engine system functions stored at world.resources.schedules.frame. It dispatches audio, camera, physics, animation, transform propagation, and cleanup systems each frame. A game can insert, remove, or reorder systems from initialize.
The event bus at world.resources.event_bus provides decoupled communication between systems. Typed app events and input messages flow through it.
Feature layer
High-level gameplay systems built on everything below.
| Feature | Dependencies |
|---|---|
| Terrain | Rendering (mesh generation, tessellation), Physics (heightfield collider) |
| Particles | Rendering (GPU billboard pass), ECS (emitter component) |
| NavMesh | Physics (geometry), ECS (agent component), Core (transforms) |
| Grass | Rendering (instanced GPU pass), ECS (region component) |
Data flow per frame
A frame's data flow runs the layers top to bottom and then renders.
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)
|-- 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 retained UI state)
|-- Blit Pass (writes to swapchain)
|
v
Present (wgpu surface present)
The game touches the top of this graph (input state, game logic, ECS mutations). The engine handles the rest.
Feature flags
Most subsystems are opt-in through Cargo features.
| Flag | What it enables |
|---|---|
engine | Core rendering, ECS, transforms, input |
physics | Rapier3D physics simulation |
audio | Kira audio playback |
gamepad | Gamepad input via gilrs |
assets | Image and HDR loading via the image crate |
steam | Steamworks integration |
scene_graph | Scene serialization |
Entity Component System
Nightshade's ECS is freecs. The whole World struct, every per-component accessor, the archetype tables, the query plumbing, and the resource layout are stamped out by one declarative macro at compile time. No proc macros, no trait objects, no unsafe.
An ECS separates the data from the work. An entity is a small handle, a generational id with no fields and no methods. A component is a plain struct attached to that handle, like LocalTransform or Light. A system is a function that reads or writes components on the entities matching a query, like "every entity with RENDER_MESH and LOCAL_TRANSFORM." The data lives in components, the work happens in systems, and entities are the keys that line them up.
The point of doing it this way instead of with object-oriented game objects is layout. An OOP game object packs position, velocity, health, and mesh into a single class instance, with the fields of each instance scattered across the heap. A loop that wants to advance physics has to touch every instance's position and velocity, paying a cache miss for each one. The fix is to flip the storage. Group entities by which components they have, lay out each component type as its own dense Vec, and the physics loop becomes a stride-1 walk through packed memory.
Archetype storage
Entities with the same set of components live in the same archetype table. Inside each table, every component type gets its own contiguous Vec in slot-aligned order. The position at index 7 and the velocity at index 7 belong to the same entity.
Table A (mask: LOCAL_TRANSFORM | GLOBAL_TRANSFORM)
local_transforms: [t0, t1, t2]
global_transforms: [g0, g1, g2]
Table B (mask: LOCAL_TRANSFORM | GLOBAL_TRANSFORM | RENDER_MESH)
local_transforms: [t3, t4]
global_transforms: [g3, g4]
render_meshes: [m3, m4]
A query for LOCAL_TRANSFORM | GLOBAL_TRANSFORM checks each table's mask with a single table.mask & query_mask == query_mask. Both tables match. A query for RENDER_MESH only matches Table B. Tables whose masks miss any requested bit are skipped without looking at a single entity inside them.
This is "structure of arrays" instead of "array of structures." Iteration through the inner loop is a stride-1 read on dense arrays the CPU prefetcher can predict. There are no per-entity hash lookups and no v-table dispatch. The cost is paid once at structural-change time when an entity has to migrate between archetypes, not on every access.
Component flags
Each component is assigned a bit position at compile time. An entity's archetype is identified by a ComponentFlags integer where bit N is set if the entity has component N.
LOCAL_TRANSFORM = 1 << 0
GLOBAL_TRANSFORM = 1 << 1
RENDER_MESH = 1 << 2
MATERIAL_REF = 1 << 3
Combine flags with | to describe a set of components. A query for LOCAL_TRANSFORM | GLOBAL_TRANSFORM | RENDER_MESH | MATERIAL_REF is one integer that names the archetypes the renderer cares about.
#![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 flags is in the Components chapter.
Quick tour
#![allow(unused)] fn main() { use nightshade::prelude::*; let entity = world.spawn_entities( LOCAL_TRANSFORM | GLOBAL_TRANSFORM | RENDER_MESH | MATERIAL_REF, 1, )[0]; 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")); 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(); } }
Spawn returns a Vec<Entity> of length count. The set methods write a component value into the slot. The query returns an iterator over every entity whose archetype has at least the requested bits.
A second ECS for game logic
The engine World holds rendering, physics, transforms, and the rest of the engine state. Game logic that does not belong in the engine, things like player state, inventory, AI, and score, lives in a second freecs::ecs! block the game defines on its own.
#![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, } } }
The game holds both worlds and runs systems against each.
#![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); } } }
The dual-world pattern is covered in detail in apps/game/src/systems/props.rs. The link from a game entity to its engine-side render entity is an EngineEntity(Entity) component that the engine ships.
Chapter guide
- The freecs Macro. Macro syntax and what gets generated.
- Entities. Spawning, despawning, and the entity handle.
- Components. The 45 built-in core components.
- Queries and Iteration. Querying and walking entities.
- Resources. Global singleton state.
- Tags, Events, and Commands. Cross-system messaging and deferred operations.
The freecs Macro
freecs::ecs! is one declarative macro that takes a list of components, tags, events, and resources and emits the entire World. The struct, the per-component fields on each archetype table, the typed accessors, the spawn and despawn code, the query iterator, the archetype graph, the query cache, the change-detection tick stamps, and the Resources block all come out of a single invocation.
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, } } Resources { window: Window, input: Input, graphics: Graphics, active_camera: Option<Entity>, } } }
Nightshade splits its components across two sub-worlds. Core holds the 3D engine components. Ui holds the retained UI components. Accessors are scoped to their sub-world. The light getter is world.core.get_light(entity). The UI node getter is world.ui.get_ui_layout_node(entity).
Each line in a component block has three parts.
- The field name (snake_case). Used to generate the accessor methods.
- The component type. The Rust struct stored in the archetype tables.
- The flag constant (UPPER_SNAKE_CASE). The bit position used in queries.
What the macro generates
The World struct
#![allow(unused)] fn main() { pub struct World { entities: EntityStorage, pub resources: Resources, } }
EntityStorage holds the archetype tables, the entity location map, the allocator, and the caches. The resources field holds whatever was declared in the Resources block.
Per-component accessors
For every foo: Foo => FOO, the macro stamps out the three accessors a system needs.
#![allow(unused)] fn main() { world.core.get_foo(entity) -> Option<&Foo> world.core.get_foo_mut(entity) -> Option<&mut Foo> world.core.set_foo(entity, value: Foo) }
get_foo and get_foo_mut return None for a stale or invalid handle, and for a live entity whose archetype does not contain Foo. set_foo writes the value if the slot exists. The setter does not add the component to entities that did not have it at spawn time. To add a component to a live entity, see add_components below.
Entity management
#![allow(unused)] fn main() { world.spawn_entities(flags: ComponentFlags, count: usize) -> Vec<Entity> world.despawn_entities(entities: &[Entity]) world.core.entity_has_components(entity: Entity, flags: ComponentFlags) -> bool world.core.add_components(entity: Entity, flags: ComponentFlags) }
spawn_entities allocates count entities into the archetype identified by flags and returns their handles. despawn_entities removes a batch of entities and recycles their ids with a bumped generation. add_components migrates an entity to the archetype current_mask | flags, leaving its existing components in place.
Query methods
#![allow(unused)] fn main() { world.core.query_entities(flags: ComponentFlags) -> impl Iterator<Item = Entity> }
Yields every entity whose archetype is a superset of flags. See Queries and Iteration for the closure-form and structural-change patterns.
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 constant per component, ascending bit positions }
ComponentFlags is a u64, so a world holds at most 64 component types per sub-world. Nightshade declares 45 in Core and 10 in Ui, well under the ceiling.
The Resources struct
#![allow(unused)] fn main() { pub struct Resources { pub window: Window, pub input: Input, pub graphics: Graphics, pub active_camera: Option<Entity>, } }
Every resource is a public field with Default initialization. Systems read and write directly. world.resources.graphics.bloom_enabled = true is the API.
Nightshade's declaration
The engine declares 45 core components and 10 UI components across the two sub-worlds, plus 30-odd resources. The core component table follows.
Components
| Flag | Field | Type | Category |
|---|---|---|---|
ANIMATION_PLAYER | animation_player | AnimationPlayer | Animation |
NAME | name | Name | Identity |
LOCAL_TRANSFORM | local_transform | LocalTransform | Transform |
GLOBAL_TRANSFORM | global_transform | GlobalTransform | Transform |
LOCAL_TRANSFORM_DIRTY | local_transform_dirty | LocalTransformDirty | Transform |
PARENT | parent | Parent | Transform |
IGNORE_PARENT_SCALE | ignore_parent_scale | IgnoreParentScale | Transform |
AUDIO_SOURCE | audio_source | AudioSource | Audio |
AUDIO_LISTENER | audio_listener | AudioListener | Audio |
CAMERA | camera | Camera | Camera |
PAN_ORBIT_CAMERA | pan_orbit_camera | PanOrbitCamera | Camera |
LIGHT | light | Light | Lighting |
LINES | lines | Lines | Debug |
VISIBILITY | visibility | Visibility | Rendering |
DECAL | decal | Decal | Rendering |
RENDER_MESH | render_mesh | RenderMesh | Rendering |
MATERIAL_REF | material_ref | MaterialRef | Rendering |
RENDER_LAYER | render_layer | RenderLayer | Rendering |
TEXT | text | Text | Text |
TEXT_CHARACTER_COLORS | text_character_colors | TextCharacterColors | Text |
TEXT_CHARACTER_BACKGROUND_COLORS | text_character_background_colors | TextCharacterBackgroundColors | Text |
BOUNDING_VOLUME | bounding_volume | BoundingVolume | Spatial |
HOVERED | hovered | Hovered | Input |
ROTATION | rotation | Rotation | Transform |
CASTS_SHADOW | casts_shadow | CastsShadow | Rendering |
RIGID_BODY | rigid_body | RigidBodyComponent | Physics |
COLLIDER | collider | ColliderComponent | Physics |
CHARACTER_CONTROLLER | character_controller | CharacterControllerComponent | Physics |
COLLISION_LISTENER | collision_listener | CollisionListener | Physics |
PHYSICS_INTERPOLATION | physics_interpolation | PhysicsInterpolation | Physics |
INSTANCED_MESH | instanced_mesh | InstancedMesh | Rendering |
PARTICLE_EMITTER | particle_emitter | ParticleEmitter | Particles |
PREFAB_SOURCE | prefab_source | PrefabSource | Prefabs |
PREFAB_INSTANCE | prefab_instance | PrefabInstance | Prefabs |
SKIN | skin | Skin | Animation |
JOINT | joint | Joint | Animation |
MORPH_WEIGHTS | morph_weights | MorphWeights | Animation |
NAVMESH_AGENT | navmesh_agent | NavMeshAgent | Navigation |
GRASS_REGION | grass_region | GrassRegion | Rendering |
GRASS_INTERACTOR | grass_interactor | GrassInteractor | Rendering |
Resources
| Field | Type | Feature Gate |
|---|---|---|
window | Window | always |
secondary_windows | SecondaryWindows | always |
user_interface | UserInterface | always |
graphics | Graphics | always |
input | Input | always |
audio | AudioEngine | audio |
physics | PhysicsWorld | physics |
navmesh | NavMeshWorld | always |
text_cache | TextCache | always |
mesh_cache | MeshCache | always |
animation_cache | AnimationCache | always |
prefab_cache | PrefabCache | always |
material_registry | MaterialRegistry | always |
texture_cache | TextureCache | always |
active_camera | Option<Entity> | always |
event_bus | EventBus | always |
command_queue | Vec<WorldCommand> | always |
entity_names | HashMap<String, Entity> | always |
Feature-gated resources are wrapped in #[cfg(feature = "...")] inside the macro invocation. They simply do not exist on the Resources struct when the feature is off.
Entities
An entity is a handle. It owns nothing. It is a generational id the storage uses to locate the components that belong to it.
#![allow(unused)] fn main() { pub use freecs::Entity; }
Entity is a Copy struct holding an index and a generation counter. The index points at a slot in the entity location map. The generation is bumped every time that slot is recycled, which is how stale handles get rejected. If you despawn entity 42 and the allocator hands 42 back out for a new entity, the old handle still has the old generation and will fail to resolve. Stale lookups return None instead of silently pointing at the wrong entity.
Spawning
spawn_entities takes a component flag mask and a count, and returns a Vec<Entity> of newly-created entities sitting in the archetype defined by that mask. Components start at Default::default().
#![allow(unused)] fn main() { let entity = world.spawn_entities( LOCAL_TRANSFORM | GLOBAL_TRANSFORM | RENDER_MESH | MATERIAL_REF, 1, )[0]; }
Set the component values after spawn.
#![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
The count parameter is the speed-versus-individual-handling trade-off. Spawning a hundred entities in one call writes the archetype table once and grows each component vec once, instead of a hundred separate spawns each doing the same work.
#![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
Common entity shapes have spawn helpers in the prelude. The helpers pick the right component mask, spawn the entity, and write sensible defaults.
#![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)); let camera = spawn_camera(world, Vec3::new(0.0, 5.0, 10.0), "Main Camera".to_string()); world.resources.active_camera = Some(camera); let sun = spawn_sun(world); let (player_entity, camera_entity) = spawn_first_person_player(world, Vec3::new(0.0, 2.0, 0.0)); }
Loading models
Models from glTF and GLB files come through the prefab pipeline. The importer extracts textures, meshes, and prefab definitions, and the game inserts each piece into the right cache before spawning instances.
#![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), ); } }
Textures go through the command queue rather than the cache directly because texture uploads require GPU access and have to wait for the render phase. See Tags, Events, and Commands for the rest of the deferred-operation API.
Despawning
#![allow(unused)] fn main() { world.despawn_entities(&[entity]); despawn_recursive_immediate(world, entity); world.queue_command(WorldCommand::DespawnRecursive { entity }); }
The three calls do different things. despawn_entities removes a single entity and recycles its handle. despawn_recursive_immediate walks the entity's transform hierarchy and despawns every descendant alongside it, immediately. The queued DespawnRecursive command does the same recursive walk but waits until the next command-processing point, which is the safe choice when the iteration borrowing the world cannot yet release it.
Adding components after spawn
add_components migrates an entity to a new archetype that includes the requested bits. The existing components are moved across, the newly-added components start at Default::default(), and the entity's location is updated.
#![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); }
The cost of add_components is one archetype migration. Every existing component on the entity gets moved between tables. For markers that flip every frame, that cost is wasted and a tag is the better fit. For one-time additions like "give this camera an audio listener," it is fine.
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 } }
The check is a single bitwise AND against the archetype mask. It does not touch the entity's component data.
Parent-child relationships
Children are entities with a Parent component pointing at the parent's handle. The parent does not need to know about its children up front. The transform system walks the dirty-flag chain to propagate matrices, and the children cache (world.resources.children_cache) is rebuilt incrementally from the parent links.
#![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() }); }
Full coverage of hierarchical transforms is in Transform Hierarchy.
Spawning Entities
Every entity in Nightshade comes out of spawn_entities. The call takes a component flag mask and a count, and returns a Vec<Entity> of newly-created entities living in the archetype defined by that mask.
#![allow(unused)] fn main() { let entity = world.spawn_entities( LOCAL_TRANSFORM | GLOBAL_TRANSFORM | RENDER_MESH | MATERIAL_REF, 1 )[0]; }
Components start at their Default values. Write the actual values with the per-component setters.
#![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 many at once
count > 1 batches the work. The archetype table is located once, every component vec grows once, and the entity allocator hands back a packed range of handles.
#![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() }); } }
For thousands of entities the batch form is meaningfully faster than a loop of individual spawns.
Helper functions
Common entity shapes have spawn helpers in the prelude. Each helper picks the right component mask, spawns the entity, and writes sensible defaults so the caller does not have to know which components a given object actually needs.
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(), ); }
spawn_camera makes a perspective camera at the given position. spawn_pan_orbit_camera adds the PAN_ORBIT_CAMERA component so the pan-orbit controller can drive it. The active-camera resource holds the entity the renderer pulls from each frame.
Lights
#![allow(unused)] fn main() { let sun = spawn_sun(world); }
spawn_sun creates a directional light with reasonable defaults. Point and spot lights are spawned by hand because the Light struct carries the type-discriminating fields.
#![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
#![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)); }
Each primitive helper picks up the matching mesh from the built-in mesh cache and applies the default material.
Physics objects
Physics spawn helpers live in nightshade::ecs::physics::commands rather than the prelude, because they are only available with the physics feature on.
#![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(), ); }
The full set is spawn_static_physics_cube_with_material, spawn_dynamic_physics_cube_with_material, spawn_dynamic_physics_sphere_with_material, and spawn_dynamic_physics_cylinder_with_material.
To roll a physics entity by hand, combine the rendering flags with RIGID_BODY and COLLIDER directly.
#![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 returns the player entity and the camera entity attached to it. The player owns the character controller. The camera is a child entity at eye height.
Loading 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), ); } }
Textures are queued as LoadTexture commands because GPU uploads cannot run inline. Meshes go straight into the mesh cache because they are CPU-side data. spawn_prefab_with_animations walks the prefab tree, spawns one entity per node, wires up the Parent links, and attaches the animations to the root.
Adding components after spawn
#![allow(unused)] fn main() { world.core.add_components(entity, AUDIO_SOURCE); world.core.set_audio_source(entity, AudioSource::new("music").playing()); }
add_components migrates the entity to the archetype current_mask | AUDIO_SOURCE. The existing components ride along. The audio source starts at Default::default() and the next line writes the real value.
Despawning
#![allow(unused)] fn main() { world.despawn_entities(&[entity]); despawn_recursive_immediate(world, entity); }
The first removes a single entity. The second removes the entity and every descendant in its transform hierarchy. For a deferred variant that waits until the next command-processing point, queue WorldCommand::DespawnRecursive instead.
Spawning a child
A child is an entity with a Parent component pointing at the parent's handle. The transform system handles the matrix propagation.
#![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 ships 55 built-in components. 45 live in the Core sub-world and cover the 3D engine. The remaining 10 live in Ui and are documented in Retained UI. This chapter catalogues the core set by category.
Transform components
The transform components hold each entity's position in the scene graph, both relative to its parent and in world space.
| Component | Description |
|---|---|
LocalTransform | Position, rotation, and scale relative to the parent |
GlobalTransform | Computed world-space transformation matrix |
LocalTransformDirty | Marker flagging that the transform needs propagation |
Parent | Reference to the parent entity |
IgnoreParentScale | Excludes the parent's scale from the propagated transform |
Rotation | Additional rotation component used by spinning props |
#![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>); }
The dirty-marker pattern is the speed trick. Instead of rebuilding every global matrix every frame, the transform system only touches entities that carry LOCAL_TRANSFORM_DIRTY, plus their descendants. After a manual write to a LocalTransform, call mark_local_transform_dirty(world, entity) so the propagation system picks it up.
Rendering components
These are the components the renderer reads when it iterates renderable archetypes.
| Component | Description |
|---|---|
RenderMesh | Names a mesh in the mesh cache |
MaterialRef | Names a material in the material registry |
RenderLayer | Depth and layer used for ordering |
CastsShadow | Marks the entity for shadow-map rendering |
Visibility | Per-entity visibility toggle |
BoundingVolume | Bounding shape used for culling and picking |
InstancedMesh | GPU-instanced mesh data, used for crowds of identical meshes |
#![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 the call is the natural one.
#![allow(unused)] fn main() { MaterialRef::new("Default") }
The id fields are resolved lazily. The first access hashes the name to a cache id and caches the result on the component.
Camera components
| Component | Description |
|---|---|
Camera | Projection mode and optional smoothing |
PanOrbitCamera | Pan-orbit controller state |
There is a single CAMERA flag. The projection mode (perspective or orthographic) is decided by the Projection enum inside the Camera struct, not by a separate component.
#![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, } }
aspect_ratio: None means "follow the window aspect." z_far: None means "no far plane," which is what reverse-Z infinite projection wants.
Lighting
| Component | Description |
|---|---|
Light | Directional, 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, } }
Like cameras, one flag covers all three light types. inner_cone_angle and outer_cone_angle are zero for non-spot lights.
Physics components
Physics is feature-gated. These components only exist with the physics feature enabled.
| Component | Description |
|---|---|
RigidBodyComponent | Dynamic, fixed, or kinematic body |
ColliderComponent | Collision shape |
CharacterControllerComponent | Kinematic player controller |
PhysicsInterpolation | Smooths the rendered transform across physics ticks |
CollisionListener | Receives 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, } }
Constructors cover the common cases.
RigidBodyComponent::new_dynamic()RigidBodyComponent::new_static()builds aFixedbodyRigidBodyComponent::new_kinematic()builds aKinematicPositionBasedbody
The flag is RIGID_BODY, not RIGID_BODY_COMPONENT. The Component suffix is on the type only.
Animation components
| Component | Description |
|---|---|
AnimationPlayer | Playback state for skeletal and morph animation |
Skin | Skeleton definition |
Joint | A bone in a skeleton |
MorphWeights | Blend-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, } }
blend_from_clip and blend_factor drive crossfades between two clips. When blend_from_clip is Some, the player produces the weighted blend of both clips at the same time index.
Audio components
| Component | Description |
|---|---|
AudioSource | A playable sound attached to an entity |
AudioListener | The receiver for 3D audio, usually the camera |
Text components
| Component | Description |
|---|---|
Text | 3D world text and screen-space UI text |
TextCharacterColors | Per-character foreground colors |
TextCharacterBackgroundColors | Per-character background colors |
The per-character color components are optional sidecars. Without them, every character uses the Text struct's main color.
Geometry
| Component | Description |
|---|---|
Lines | Debug 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, } }
Lines is rebuilt per frame by gizmo code. It is the cheap way to draw rays, bounding boxes, and navmesh edges without authoring a mesh.
Advanced components
| Component | Description |
|---|---|
ParticleEmitter | GPU particle system |
GrassRegion | Procedural grass field |
GrassInteractor | Bends grass blades around the entity |
NavMeshAgent | AI pathfinding agent |
Decal | Projected texture on nearby geometry |
PrefabSource | The prefab a spawned instance was built from |
PrefabInstance | An instance node inside a spawned prefab |
Hovered | Marks the entity under the mouse cursor |
Name | String identifier used for entity lookup |
Queries and Iteration
A query is "give me every entity that has at least this set of components." Because component presence is a bit in the archetype mask, the per-table check is a single table.mask & query_mask == query_mask. Tables that fail the check are skipped without looking at a single entity inside them.
Basic queries
#![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 yields every entity whose archetype mask is a superset of the requested mask. An entity with LOCAL_TRANSFORM | GLOBAL_TRANSFORM | RENDER_MESH matches a query for LOCAL_TRANSFORM | GLOBAL_TRANSFORM.
The cost of this form is one location-map lookup per get_local_transform call. For read-only loops it is the most ergonomic API. For tight inner loops there are closure-form alternatives that hand the closure raw access to the archetype table, no per-entity lookup. See Querying Entities for that pattern.
Common patterns
Renderable entities
The renderer iterates one combined mask each frame.
#![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 (the player, the active camera, the world origin), pull the first match off the iterator.
#![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
query_entities filters at the table level. A finer per-entity filter is just a normal if inside the loop body.
#![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 { // point lights only } } }
The cost of this shape is that the iterator visits every entity with the requested bits, then filters in Rust. If the secondary filter is hot and selective, splitting the data into two archetypes (a separate flag per variant) is the faster shape.
Entity counts
#![allow(unused)] fn main() { let renderable_count = world.core.query_entities(RENDER_MESH).count(); let light_count = world.core.query_entities(LIGHT).count(); }
count consumes the iterator and walks every matching table's entities.len(). It is O(number of matching tables), not O(number of matching entities).
Looking entities up by name
If entities carry the Name component, the engine keeps a HashMap<String, Entity> in world.resources.entity_names. A direct hash lookup is the fast path.
#![allow(unused)] fn main() { let player = world.resources.entity_names.get("Player").copied(); }
A scan-based fallback is also available if you do not trust the cache or you are doing case-insensitive matching.
#![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"); }
Walking children
Parent-to-child links are not stored on the parent. The engine maintains a HashMap<Entity, Vec<Entity>> cache so the children of a given entity can be found without scanning every PARENT component.
#![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 for deferred work
Mutating the world during iteration invalidates the iterator. The fix is to collect the matching entity handles first, release the borrow, then operate.
#![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); } }
The cost is one allocation per collected query. For structural mutations during iteration that should happen at a defined point in the frame, the better fit is the command queue described in Tags, Events, and Commands.
Iteration with index
#![allow(unused)] fn main() { for (index, entity) in world.core.query_entities(RENDER_MESH).enumerate() { // index is the position within the iteration, not the entity id } }
enumerate gives the iteration position, not the entity's internal id. For the id itself, read entity.id directly.
Querying Entities
query_entities(mask) is the basic shape. It returns an iterator over every entity whose archetype is a superset of mask.
#![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; } } }
Each get_local_transform(entity) inside the loop is a location-map lookup followed by an archetype-mask check. That overhead is fine for read-only outer loops. For inner loops the closure-form world.core.query() / world.core.query_mut() API in the prelude hands the closure raw table access, see the engine's query docs for that pattern.
Common 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 inside the loop
query_entities filters at the table level. A finer per-entity check goes in a normal if inside the loop body.
#![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 { } } }
If the secondary filter is hot and selective, splitting the data into two archetypes is faster than filtering in Rust.
Checking components
#![allow(unused)] fn main() { if world.core.entity_has_components(entity, RENDER_MESH) { } if world.core.entity_has_components(entity, ANIMATION_PLAYER | SKIN) { } }
The check is one bitwise AND against the archetype mask. It does not read the component data.
First match
For singleton entities, next() pulls the first off the iterator.
#![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); } }
Counts
#![allow(unused)] fn main() { let renderable_count = world.core.query_entities(RENDER_MESH).count(); let light_count = world.core.query_entities(LIGHT).count(); }
count is O(number of matching tables), not O(number of matching entities). The iterator stride is the per-table entities.len().
Named lookup
world.resources.entity_names is a HashMap<String, Entity> populated for every entity carrying the Name component. Direct hash lookup is the fast path.
#![allow(unused)] fn main() { let player = world.resources.entity_names.get("Player").copied(); }
The scan fallback is also fine.
#![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 of a parent
The engine keeps a HashMap<Entity, Vec<Entity>> of parent-to-children in world.resources.children_cache.
#![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
#![allow(unused)] fn main() { for (index, entity) in world.core.query_entities(RENDER_MESH).enumerate() { } }
enumerate gives the iteration position. For the entity's internal id, read entity.id directly.
Collecting
Mutating the world during iteration invalidates the iterator. Collect the matching handles first, release the borrow, then operate.
#![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); } }
The cost is one allocation per collected query. For deferred structural mutations that should happen at a defined point in the frame, the command queue in Tags, Events, and Commands is the right tool.
Resources
A resource is global state attached to the world, not to any entity. Delta time. The input snapshot for this frame. The list of loaded materials. The active camera. Each resource type exists exactly once in the world and lives on world.resources.
Systems read and write resources directly. There are no accessor functions, no per-resource fan-out. The freecs macro generates the Resources struct from the declaration block and gives every field public access.
#![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 catalogue
Time and window
| Resource | Type | Description |
|---|---|---|
window | Window | Window handle, timing data, and display info |
secondary_windows | SecondaryWindows | State for additional windows |
window.timing | WindowTiming | delta_time, frames_per_second, uptime_milliseconds |
Input
| Resource | Type | Description |
|---|---|---|
input | Input | Aggregated keyboard, mouse, and gamepad state |
input.keyboard | Keyboard | Per-key state, is_key_pressed, just_pressed |
input.mouse | Mouse | Position, delta, button state, scroll |
Graphics
| Resource | Type | Description |
|---|---|---|
graphics | Graphics | All rendering settings |
graphics.atmosphere | Atmosphere | Sky mode (None, Color, Sky) |
graphics.bloom_enabled | bool | Bloom toggle |
graphics.ssao_enabled | bool | SSAO toggle |
graphics.color_grading | ColorGrading | Tonemapping, gamma, saturation, brightness, contrast |
Caches
| Resource | Type | Description |
|---|---|---|
mesh_cache | MeshCache | Loaded mesh data by name |
material_registry | MaterialRegistry | Registered materials |
texture_cache | TextureCache | GPU textures |
animation_cache | AnimationCache | Animation clip data |
prefab_cache | PrefabCache | Loaded prefab templates |
text_cache | TextCache | Font atlas and glyph data |
Scene
| Resource | Type | Description |
|---|---|---|
active_camera | Option<Entity> | The camera the renderer pulls from |
children_cache | HashMap<Entity, Vec<Entity>> | Parent-to-children mapping |
entity_names | HashMap<String, Entity> | Name-to-entity lookup |
transform_dirty_entities | Vec<Entity> | Entities that need transform propagation |
Simulation
| Resource | Type | Feature |
|---|---|---|
physics | PhysicsWorld | physics |
audio | AudioEngine | audio |
navmesh | NavMeshWorld | always |
Communication
| Resource | Type | Description |
|---|---|---|
event_bus | EventBus | Cross-system message queue |
command_queue | Vec<WorldCommand> | Deferred GPU and scene operations |
frame_schedule | FrameSchedule | Ordered list of engine systems dispatched each frame |
Platform
| Resource | Type | Feature |
|---|---|---|
steam | SteamResources | steam |
Feature-gated resources
Resources whose subsystems are optional only exist when the relevant feature flag is on. Guard access with cfg.
#![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 need GPU access, or that have to happen at a defined point in the frame, go through world.queue_command. The command queue is a Vec<WorldCommand> that the renderer drains at frame setup.
#![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 }); }
The render-time commands are GPU uploads and screenshot captures. The ECS-time commands (DespawnRecursive, ReloadMaterial) are drained by the process_commands_system in the frame schedule. The full breakdown of immediate-versus-deferred operations is in Tags, Events, and Commands.
Tags, Events, and Commands
The three patterns in this chapter all solve the same problem from different angles. One system needs to tell another system something, or one system needs to make a structural change that cannot happen while the iteration that prompted it still holds the world.
The event bus is for cross-system messages. World commands are for deferred mutations that need GPU access or have to wait for a specific point in the frame. Tags are not a separate subsystem in Nightshade today, they are markers expressed as zero-size components and queried through the normal flag-mask machinery.
The event bus
world.resources.event_bus is a VecDeque<Message> that systems push into and the game pops out of. The message variants cover input events and arbitrary app-defined payloads.
#![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>, }, } }
The trade-off is the boxed Any. Each app event allocates and pays a downcast at the consumer. The win is that the bus does not need to know about the app's event types at compile time. For games where event volume is low and latency is high (death notifications, door opens, inventory pickups), this cost is irrelevant. For high-frequency telemetry, the better fit is a dedicated typed queue inside Resources.
Publishing
Define a plain struct for each event type. Send it through publish_app_event.
#![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
The game drains the bus during its frame loop and dispatches each message by type.
#![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); } } } } }
The downcast pattern means several handlers can react to the same event without coupling. One system reads a DoorOpened to trigger a cutscene, another reads it to play a sound, neither knows the other exists.
#![allow(unused)] fn main() { publish_app_event(world, DoorOpened { door_id: 42 }); 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. They go into world.resources.command_queue and are drained by the right subsystem at the right point in the frame. The two main reasons to defer are GPU access (texture uploads, HDR skybox loads, screenshot capture) and iteration safety (despawning entities mid-loop).
#![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
| Command | Description |
|---|---|
LoadTexture | Upload RGBA texture data to the GPU |
DespawnRecursive | Remove an entity and every descendant in its hierarchy |
LoadHdrSkybox | Load an HDR environment map |
CaptureScreenshot | Save the next frame to a PNG |
Immediate vs deferred
Not every structural change needs to be deferred. Operations that do not touch the GPU and are not running inside a borrowing iteration can run inline.
#![allow(unused)] fn main() { // inline, happens immediately world.despawn_entities(&[entity]); despawn_recursive_immediate(world, entity); // deferred, happens at the next command drain point world.queue_command(WorldCommand::DespawnRecursive { entity }); }
The rule is the simple one. If the call would invalidate an iteration the current code is inside, queue it. If it would touch the GPU from a non-render system, queue it. Otherwise call directly.
The State trait event hook
The State trait has a dedicated handle_event method called once per message after run_systems. It is the cleanest place to handle events that drive state transitions.
#![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); } } _ => {} } } }
The engine dispatches each message exactly once, in order, then drains the bus.
Input events
Input events travel on the same bus.
#![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, } }
For polling-style input (held keys, mouse position) the snapshot in world.resources.input is the right shape. The event variants are for transitions that matter exactly once. Gamepad connect and disconnect, key repeat, IME input.
Practical notes
- Keep event payloads small. The
Box<dyn Any>allocation cost is per-message. - Drain the bus every frame. The queue is unbounded and will accumulate stale messages otherwise.
- Avoid circular events. System A reacting to event B by emitting event B will not terminate.
- Prefer immediate calls for pure ECS work that does not need to be deferred. The command queue exists for GPU access and iteration safety, not as the default.
Math & Coordinates
Nightshade uses nalgebra_glm for every piece of linear algebra. There is no in-house math library. Vectors, matrices, and quaternions all come from nalgebra_glm, and the prelude re-exports the core types.
Core Types
| Type | Description | Example |
|---|---|---|
Vec2 | 2D vector | Screen positions, UV coordinates |
Vec3 | 3D vector | Positions, directions, colors |
Vec4 | 4D vector | Homogeneous coordinates, RGBA colors |
Mat4 | 4x4 matrix | Transform matrices |
Quat | Quaternion | Rotations |
#![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
The coordinate system is right-handed Y-up. Positive X is right, positive Y is up, positive Z points out of the screen toward the camera, and negative Z points into the scene.
+Y (up)
|
|
+--- +X (right)
/
/
+Z (forward, toward camera)
This matches the glTF convention and nalgebra_glm's default handedness, which is why no flips or swaps are needed when loading glTF assets.
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
+, -, and scalar * work as expected. The catch is element-wise multiplication, where * between two vectors does scalar multiplication, not per-component. For element-wise, use component_mul.
#![allow(unused)] fn main() { let sum = a + b; let difference = a - b; let scaled = a * 2.0; let element_wise = a.component_mul(&b); }
This trips up newcomers from glsl, where vec3 * vec3 means per-component multiplication. In nalgebra_glm, * follows linear-algebra conventions, and component_mul is the explicit form.
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); }
Quaternions
Rotations are always quaternions. They avoid gimbal lock, compose cleanly, and interpolate smoothly with slerp. Euler angles are converted to quaternions when needed.
#![allow(unused)] fn main() { let rotation = nalgebra_glm::quat_angle_axis( std::f32::consts::FRAC_PI_4, &Vec3::y(), ); let forward = nalgebra_glm::normalize(&(target - position)); let rotation = nalgebra_glm::quat_look_at(&forward, &Vec3::y()); let blended = rotation_a.slerp(&rotation_b, 0.5); let rotated = nalgebra_glm::quat_rotate_vec3(&rotation, &direction); }
quat_angle_axis builds a rotation of a given angle around a given axis. quat_look_at builds a rotation that orients the local forward toward a target direction with a specified up vector. slerp is spherical linear interpolation, the right way to blend two rotations. quat_rotate_vec3 applies a quaternion to a vector.
Transform Matrices
GlobalTransform stores a single 4x4 matrix. LocalTransform stores the decomposed translation, rotation, and scale.
#![allow(unused)] fn main() { pub struct LocalTransform { pub translation: Vec3, pub rotation: Quat, pub scale: Vec3, } pub struct GlobalTransform(pub Mat4); }
The split is deliberate. Local transforms are easy to edit because translation, rotation, and scale are separate fields. Global transforms are easy to use because they bake the entire parent chain into one matrix multiplied through clip space by the renderer.
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; }
Order matters. translation * rotation * scale is the standard TRS order, applied to a vector right-to-left. The scale is applied first, then the rotation, then the translation.
Extracting Position
The translation column of a 4x4 transform matrix is the fourth column. xyz() extracts the first three components.
#![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 to and from degrees explicitly.
#![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's depth range is [0, 1], not OpenGL's [-1, 1]. Nightshade goes one step further. The depth buffer is reversed-Z, where 0.0 is the far plane and 1.0 is the near plane, the opposite of the traditional convention.
The reason is floating-point precision. Floats 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 zero (high precision) and the far plane maps to one (low precision). Perspective projection is nonlinear, and most of the [0, 1] range is already consumed by geometry near the near plane. Combine the two distortions and there is almost no precision left for distant objects, which is what produces z-fighting in the middle distance of large scenes.
Reversed-Z flips the mapping. The far plane goes to zero (where float precision is highest) and the near plane goes to one. The perspective nonlinearity and the floating-point precision curve partially cancel, and the result is nearly uniform depth precision across the entire view range. The difference is dramatic for large outdoor scenes, where z-fighting at the horizon vanishes.
The practical consequences are three. The depth clear value is 0.0, since that is the far plane. The depth comparison function is Greater or GreaterEqual, since closer objects have larger depth values. Projection matrices are constructed with reversed_infinite_perspective_rh_zo rather than the standard perspective_rh_zo.
Time
Timing values are on world.resources.window.timing. There is no separate Time resource. Putting timing on the window struct matches the way the engine treats it, since timing is updated by the same code that updates the window each frame.
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, } }
delta_time is the scaled time in seconds since the previous frame. raw_delta_time is the unscaled value, before time_speed is applied. frames_per_second is the running rate. frame_counter is the count of frames since startup. uptime_milliseconds is wall-clock time since startup. The three Instant fields are the underlying timestamps used to compute the others.
Reading 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 value to multiply by when computing per-frame change. Movement, rotation, animation, anything that should happen at a consistent rate regardless of frame rate.
#![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 ignores time_speed. Use it for things that should not respect time scaling. UI animations should keep going during a slow-motion gameplay moment, so the UI uses raw_delta_time. Gameplay physics that should slow down uses delta_time.
Time Speed
time_speed scales delta_time. The relation is delta_time = raw_delta_time * time_speed, so setting time_speed to zero freezes all delta_time-driven logic without stopping the render loop.
#![allow(unused)] fn main() { world.resources.window.timing.time_speed = 0.5; world.resources.window.timing.time_speed = 2.0; world.resources.window.timing.time_speed = 0.0; }
The render loop keeps running at full rate. The frame counter still advances. Animations or UI driven by raw_delta_time still play. Only the values keyed off delta_time freeze.
Periodic Actions
For an action that fires every N seconds, accumulate delta_time into an f32 and reset when the accumulator crosses the threshold.
#![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; } } }
For very long intervals, the simpler approach is to check frame_counter or uptime_milliseconds directly.
Uptime
uptime_milliseconds is total wall-clock time since the application started. The standard use is driving shader animations that should loop continuously.
#![allow(unused)] fn main() { let time = world.resources.window.timing.uptime_milliseconds as f32 / 1000.0; let wave = (time * 2.0).sin(); }
uptime_milliseconds is wall-clock and ignores time_speed, so a slow-motion effect does not affect a uniform shader wave.
Web Compatibility
The Instant fields use web_time::Instant, not std::time::Instant. std::time::Instant does not compile to wasm32 because the underlying system call is not available in the browser. web_time::Instant is a drop-in replacement that uses performance.now() on the web and falls back to std::time::Instant on native, so the same code runs on both targets without conditional compilation.
The State Trait
State is the interface between a game and the engine. A game is a type that implements State, and the engine drives it. The trait has five methods, all with defaults, so a minimal game implements none of them and gets a blank window.
Trait Definition
#![allow(unused)] fn main() { pub trait State { fn initialize(&mut self, _world: &mut World) {} 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 run_systems(&mut self, _world: &mut World) {} fn pre_render(&mut self, _renderer: &mut WgpuRenderer, _world: &mut World) {} fn update_render_graph(&mut self, _graph: &mut RenderGraph<World>, _world: &World) {} } }
Window state (title, icon, log config, next state) lives on the world.resources.window resource. Input, file-drop, gamepad, and lifecycle events arrive on world.resources.input.events as AppEvent values, and the application drains them in run_systems.
Window Configuration
Title
The window title is a String on the Window resource. Set it in initialize and a sync system propagates the value to the OS window each frame, so writes at runtime also take effect.
#![allow(unused)] fn main() { fn initialize(&mut self, world: &mut World) { world.resources.window.title = "My Awesome Game".to_string(); } }
Icon
The icon is Option<&'static [u8]> of PNG bytes. The default is the built-in Nightshade icon. Override it with include_bytes!, or set it to None to remove the icon entirely.
#![allow(unused)] fn main() { fn initialize(&mut self, world: &mut World) { world.resources.window.icon_bytes = Some(include_bytes!("../assets/icon.png")); } }
The sync runs on desktop only and requires the assets feature.
Logging
File-based logging is configured through log_config: LogConfig on the Window resource. Logging is initialized before initialize runs, so the customization happens when building the initial Window, not inside the trait method.
#![allow(unused)] fn main() { let mut window = Window::default(); window.log_config = LogConfig { directory: "logs".to_string(), rotation: LogRotation::Daily, default_filter: "info".to_string(), timestamp_format: "%Y-%m-%d_%H-%M-%S".to_string(), }; }
LogRotation is one of PerSession (new file each launch), Daily, or Never (append to a single file). Desktop only, requires the tracing feature.
initialize
Called once at startup. This is where the initial scene, the active camera, and the application's own state get built.
#![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, after the engine has updated input and timing but before the frame schedule runs. Game logic goes here.
#![allow(unused)] fn main() { fn run_systems(&mut self, world: &mut World) { player_movement_system(world); enemy_ai_system(world); collision_response_system(world); } }
Input and Lifecycle Events
The engine pushes input, file-drop, gamepad, and lifecycle events onto world.resources.input.events as AppEvent values. Drain the queue in run_systems. Events not drained on the frame they arrive are lost, since the engine clears the queue after run_systems returns.
#![allow(unused)] fn main() { fn run_systems(&mut self, world: &mut World) { let events = std::mem::take(&mut world.resources.input.events); for event in events { match event { AppEvent::Keyboard { key, state } if state == KeyState::Pressed => match key { KeyCode::Escape => self.paused = !self.paused, KeyCode::F11 => toggle_fullscreen(world), _ => {} }, AppEvent::Mouse { button, state } if state == ElementState::Pressed => match button { MouseButton::Left => self.shoot(world), MouseButton::Right => self.aim(world), _ => {} }, AppEvent::Gamepad(gp_event) => { if let gilrs::EventType::ButtonPressed(button, _) = gp_event.event { match button { gilrs::Button::Start => self.paused = !self.paused, gilrs::Button::South => self.player_jump(), _ => {} } } } AppEvent::FileDropped(file) => self.process_dropped_data(world, &file.name, &file.data), AppEvent::FileDroppedPath(path) => self.load_model(world, &path), AppEvent::FileHovered(_) => self.show_drop_indicator = true, AppEvent::FileHoverCancelled => self.show_drop_indicator = false, AppEvent::Suspended | AppEvent::Resumed => {} } } } }
configure_render_graph
Called once during initialization to build the render graph. The default configures bloom, post-processing, and a swapchain blit. Override it to add custom passes or replace the 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 every frame. Use it to toggle passes or update graph state in response to runtime changes.
#![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 render graph execution each frame. The hook into the renderer is for custom GPU uploads and direct renderer state changes that need to happen before the passes run.
#![allow(unused)] fn main() { fn pre_render(&mut self, renderer: &mut WgpuRenderer, world: &mut World) { renderer.update_custom_buffer(world, &self.custom_data); } }
State Transitions
Setting world.resources.window.next_state inside run_systems switches the game to a new state at the end of the frame. The field holds a builder closure that receives &mut World, so the next state can be constructed against the live engine resources.
#![allow(unused)] fn main() { fn run_systems(&mut self, world: &mut World) { if self.transition_to_gameplay { world.resources.window.next_state = Some(Box::new(|world| Box::new(GameplayState::from_world(world)))); } } }
Event Bus Messages
Custom EventBus messages live on world.resources.event_bus.messages. Drain that VecDeque in run_systems to process them. See the event system chapter for details.
Launching the Game
nightshade::launch runs the game. It takes a value of any type that implements State.
fn main() -> Result<(), Box<dyn std::error::Error>> { nightshade::launch(MyGame::default()) }
A common pattern is to set up the initial scene inside initialize, including HDR skybox loads.
#![allow(unused)] fn main() { fn initialize(&mut self, world: &mut World) { load_hdr_skybox(world, include_bytes!("../assets/sky.hdr").to_vec()); } }
Main Loop
The frame is the unit of work in Nightshade. Every frame runs the same sequence of steps in the same order, and almost all engine behavior is one of those steps. Understanding the order matters because anything you write either fits between two steps or replaces one.
Frame Execution Order
Each frame runs the following steps, in order:
1. Process window/input events (winit)
2. Update input state from events
3. Calculate delta time
4. Call State::run_systems(), which runs game logic
5. Dispatch EventBus messages
6. Run FrameSchedule, dispatching engine systems in order:
a. Initialize and update audio (if audio feature)
b. Update camera aspect ratios
c. Step physics simulation (if physics feature)
d. Update tweens
e. Update animation players
f. Apply animations to transforms
g. Propagate transform hierarchy
h. Update instanced mesh caches
i. Run retained UI systems (input sync, picking, layout, rendering)
j. Reset mouse, keyboard, and touch input state
k. Process deferred commands
l. Cleanup unused resources
7. Execute render graph passes
8. Present to swapchain
run_systems runs before the frame schedule. That means game logic sees the freshly updated input state, but it sees physics, animation, and transforms from the previous frame. Reads from the active camera's global transform inside run_systems will see last frame's value, and writes to local transforms will be propagated by step 6g of this frame.
Customizing the Schedule
The frame schedule is a resource at world.resources.frame_schedule. It can be edited from State::initialize to insert custom systems between engine systems, remove systems that the game does not 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); } }
The full list of engine system name constants is in system_names in the prelude.
Timing
All timing information lives on 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; } }
delta_time is the scaled, time-speed-aware seconds since the previous frame. raw_delta_time is the same value before time_speed is applied. The two are different only when something has changed time_speed away from 1.0.
Fixed Timestep Physics
Variable frame rates produce jittery physics. The fix is to step physics at a fixed timestep regardless of the render frame rate, accumulating leftover time and stepping again on later frames if more than one step has elapsed.
#![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); } }
The render is then drawn at the interpolated point between the previous and current physics states. The cost is one frame of input lag on collisions. The benefit is that a 240 Hz render and a 60 Hz render see the same physics behavior.
Physics Interpolation
Each physics body that needs smooth rendering carries a PhysicsInterpolation component holding the previous and current transforms, and the renderer lerps between them by alpha.
#![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 Within run_systems
Within run_systems, the order in which the application calls its own systems is the order they run. Input handling and movement code go before any system that depends on their results.
#![allow(unused)] fn main() { fn run_systems(&mut self, world: &mut World) { handle_input(world); player_movement_system(world); ai_decision_system(world); } }
Reading physics contacts happens in run_systems of the next frame, since the physics step ran in step 6c of the previous frame.
#![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
Movement, animation, and any value that changes over time must be scaled by delta_time. Otherwise the speed of the change is tied to the frame rate, and the game runs faster on a 240 Hz monitor than on a 60 Hz one.
#![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; } } }
For periodic events, accumulate delta time into a per-game f32 and fire when the accumulator crosses a threshold.
#![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
The desktop entry point is nightshade::launch called from fn main.
fn main() -> Result<(), Box<dyn std::error::Error>> { nightshade::launch(MyGame::default()) }
The WASM entry point is the same call, awaited inside an async function exported with #[wasm_bindgen(start)].
#![allow(unused)] fn main() { #[wasm_bindgen(start)] pub async fn start() { nightshade::launch(MyGame::default()).await; } }
Diagnosing Frame Issues
A frame spike shows up as a single large delta_time. Logging the spike inside run_systems gives a coarse signal that something blocked the main thread.
#![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); } } }
For consistent slowdowns, wrap individual systems in Instant::now to see which one is eating the budget. The 16 ms target for 60 Hz divides across every system in run_systems plus the engine schedule plus the render graph, so finding which slice owns the time is the first step before optimizing anything.
#![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); } } }
World & Resources
The World is the single value that holds every piece of state in a Nightshade game. It contains the ECS storage (entities and components) and the resources (global singletons). Almost every function in the engine takes &mut World or &World as its first argument.
World Structure
The World is generated by the freecs::ecs! macro and reduces to two fields.
#![allow(unused)] fn main() { pub struct World { pub entities: EntityStorage, pub resources: Resources, } }
entities is the archetype-based component storage. resources is a flat struct of singleton values shared across every system.
Resources
Resources are the global state of the engine. Each field is a domain. Window state, input state, the physics world, the asset caches, the active camera, the deferred command queue, and so on.
#![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(all(feature = "steam", not(target_arch = "wasm32")))] pub steam: SteamResources, #[cfg(feature = "physics")] pub picking_world: PickingWorld, pub gpu_picking: GpuPicking, 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_blueprints: HashMap<Entity, Vec<EntityBlueprint>>, pub pending_particle_textures: Vec<ParticleTextureUpload>, pub ibl_views: IblViews, pub retained_ui: RetainedUiState, pub frame_schedule: FrameSchedule, } }
There is no separate Time resource. Timing values live on world.resources.window.timing alongside the rest of the window state.
Accessing Resources
Resources are reached through world.resources.<field>. The shape is consistent across the engine, and the borrow rules are the standard Rust ones. Two systems cannot mutably borrow the same resource at the same time.
#![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
Timing
world.resources.window.timing is a WindowTiming struct. The relevant fields for game logic are delta_time, frames_per_second, frame_counter, and uptime_milliseconds.
#![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; }
#![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
The input resource holds keyboard, mouse, gamepad, and touch state. Polling-style queries read flags. Event-style queries drain the events vec.
#![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
world.resources.graphics controls the global rendering toggles. Atmosphere, bloom, SSAO, tonemapping, debug grid.
#![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
world.resources.active_camera: Option<Entity> is the camera that drives the main viewport. Setting it changes what the renderer draws. Reading from it after rendering tells you which entity was used.
#![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
Some operations cannot run synchronously from inside a system. GPU resource creation, texture uploads, recursive despawns, and screenshot capture all need to be deferred to a safe point in the frame. These go through world.queue_command, which pushes a WorldCommand onto world.resources.command_queue.
#![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 }); }
The queue is drained during the render phase. Commands run in submission order.
For a recursive despawn that has to happen immediately rather than at the end of the frame, call the helper directly:
#![allow(unused)] fn main() { despawn_recursive_immediate(world, entity); }
The cost of despawn_recursive_immediate is that the change is visible to every system that runs after it in the current frame, including the engine schedule. Use it when the deferred version would let invalid data leak through to physics or rendering.
Scene Hierarchy
A scene hierarchy is a parent-child tree of entities where each child's transform is interpreted relative to its parent. Moving the parent moves every descendant with it. This is how a robot arm holds together as one piece, how a sword stays in a character's hand during an animation, and how an entire scene can be translated by editing a single root entity.
Parent-Child Relationships
Setting a Parent
Setting the parent attaches a child to its parent and marks the local transform dirty so the next propagation pass picks up the change.
#![allow(unused)] fn main() { world.core.set_parent(child_entity, Parent(Some(parent_entity))); world.core.set_local_transform_dirty(child_entity, LocalTransformDirty); }
After the attach, the child's LocalTransform.translation, rotation, and scale are measured relative to the parent. A child at translation (0, 1, 0) with a parent at translation (5, 0, 0) renders at world-space (5, 1, 0).
Getting Children
The children_cache resource maps each parent entity to a Vec<Entity> of its direct children. The cache is rebuilt lazily when stale, so the lookup is a constant-time HashMap query.
#![allow(unused)] fn main() { if let Some(children) = world.resources.children_cache.get(&parent_entity) { for child in children { } } }
Detaching
Setting the parent to None detaches the child. Its local transform is then measured in world space.
#![allow(unused)] fn main() { world.core.set_parent(child_entity, Parent(None)); }
Transform Propagation
The engine runs propagate_transforms once per frame as part of the frame schedule. The pass walks every dirty entity, recomputes its global transform, and clears the dirty flag. Doing this manually is unnecessary, the schedule handles it.
#![allow(unused)] fn main() { propagate_transforms(world); }
The propagation pass does five things in order. First, it gathers every entity marked LocalTransformDirty. Second, it walks descendants of each dirty entity and marks them dirty too, because a parent change invalidates the children's world positions. Third, it sorts the dirty set so parents are processed before children. Fourth, it computes each entity's global transform as either parent.GlobalTransform * LocalTransform if the entity has a parent, or LocalTransform.to_matrix() if it does not. Fifth, it clears the dirty flag.
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 is the editable form. Position, rotation, scale, all relative to the parent or to the world if there is no parent. Writing to a LocalTransform requires marking the entity dirty so the next propagation pass picks it up.
GlobalTransform is the read-only output. A single 4x4 matrix that bakes in the entire chain of parents. The renderer reads from GlobalTransform. Physics reads from GlobalTransform. Game logic that needs world-space coordinates reads from GlobalTransform.
Scene Serialization
Scenes can be saved to disk and reloaded. This is the format used by the level editor and the runtime loader.
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
world_to_scene walks the world and serializes every entity and the hierarchy that connects them. The result is a Scene value that can be written to disk.
#![allow(unused)] fn main() { let scene = world_to_scene(world); save_scene(&scene, "level1.scene")?; }
Loading
#![allow(unused)] fn main() { let scene = load_scene("level1.scene")?; spawn_scene(world, &scene); }
spawn_scene allocates fresh entity ids and rebuilds the hierarchy. The ids in the saved file are not preserved across loads, since they have to coexist with whatever entities the live world already contains.
Binary Format
For larger scenes, the binary form is faster to load and smaller on disk.
#![allow(unused)] fn main() { let bytes = serialize_scene_binary(&scene)?; let scene = deserialize_scene_binary(&bytes)?; }
Recursive Operations
Despawning with Children
A parent and all its descendants can be despawned in a single call. Use the deferred command queue when calling from inside a system, or the immediate helper when the entity must be gone before the next system runs.
#![allow(unused)] fn main() { despawn_recursive_immediate(world, parent_entity); }
Cloning Hierarchy
clone_entity_recursive produces a deep copy of an entity and every descendant. The clones get fresh ids and a fresh hierarchy that mirrors the original.
#![allow(unused)] fn main() { let clone = clone_entity_recursive(world, original_entity); }
Example: A Robot Arm
The robot arm illustrates the hierarchy pattern. Four entities chained by parent pointers. Rotating the lower arm rotates everything above it.
#![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); } }
The lower arm's translation (0, 1.5, 0) is relative to the base. The upper arm's translation (0, 2.0, 0) is relative to the lower arm. The hand's translation (0, 1.5, 0) is relative to the upper arm. Rotating the lower arm by setting its local rotation propagates through the chain on the next frame, and the upper arm and hand swing with it.
Input System
Input state is aggregated each frame into world.resources.input. Keyboard, mouse, gamepad, and touch all funnel into the same resource. There are two ways to read it. Polling, where a system checks "is this key currently held," and event-driven, where the system drains an event queue of state transitions.
Polling Input
Polling reads the current state. It tells you what is true right now, without history. Polling is the right choice for continuous controls like WASD movement, mouse look, or holding a fire button.
#![allow(unused)] fn main() { fn run_systems(&mut self, world: &mut World) { if world.resources.input.keyboard.is_key_pressed(KeyCode::KeyW) { move_forward(world); } let mouse_pos = world.resources.input.mouse.position; if world.resources.input.mouse.state.contains(MouseState::LEFT_JUST_PRESSED) { shoot(world); } } }
The JUST_PRESSED and JUST_RELEASED flags fire on the single frame that the transition happens. They are how polling-style code detects edges without keeping its own history.
Event-Driven Input
Events are state transitions. The engine pushes them onto world.resources.input.events as AppEvent values, and the application drains the queue inside run_systems. Use events for discrete actions like toggling a menu, selecting a weapon slot, or any input where the transition matters more than the held state.
#![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
Three input-driven systems ship with the engine. None of them run automatically. Call them from run_systems when the game wants the behavior.
#![allow(unused)] fn main() { fn run_systems(&mut self, world: &mut World) { fly_camera_system(world); escape_key_exit_system(world); pan_orbit_camera_system(world); } }
fly_camera_system reads WASD plus mouse look and writes to the active camera's transform. escape_key_exit_system exits the application when Escape is pressed. pan_orbit_camera_system reads middle-mouse drag and scroll wheel to orbit and zoom the active camera. The fly camera and pan-orbit camera are mutually exclusive on the same entity, and fly_camera_system skips entities that also have the PAN_ORBIT_CAMERA marker.
Chapters
- Keyboard & Mouse. Key detection, mouse tracking, cursor control.
- Gamepad Support. Controller input, analog sticks, button mapping.
Keyboard & Mouse
Keyboard and mouse state live on world.resources.input.keyboard and world.resources.input.mouse. Both can be polled directly inside run_systems or consumed as events through the AppEvent queue.
Keyboard
Polling
is_key_pressed returns whether a key is currently held. It is the right call for continuous actions like movement or sprint.
#![allow(unused)] fn main() { fn run_systems(&mut self, world: &mut World) { let keyboard = &world.resources.input.keyboard; 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
| Key | Code |
|---|---|
| Letters | KeyCode::KeyA through KeyCode::KeyZ |
| Numbers | KeyCode::Digit0 through KeyCode::Digit9 |
| Arrow keys | KeyCode::ArrowUp, ArrowDown, ArrowLeft, ArrowRight |
| Space | KeyCode::Space |
| Shift | KeyCode::ShiftLeft, KeyCode::ShiftRight |
| Control | KeyCode::ControlLeft, KeyCode::ControlRight |
| Alt | KeyCode::AltLeft, KeyCode::AltRight |
| Escape | KeyCode::Escape |
| Enter | KeyCode::Enter |
| Tab | KeyCode::Tab |
| F keys | KeyCode::F1 through KeyCode::F12 |
Event Handling
For discrete actions where the press is more important than the held state, match on the keyboard AppEvent. The example below pauses on Escape, toggles fullscreen on F11, and selects a weapon slot on the digit keys.
#![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
Position
position is the cursor location in screen coordinates. Origin is the top-left of the window.
#![allow(unused)] fn main() { let mouse = &world.resources.input.mouse; let position = mouse.position; }
Movement Delta
position_delta is the per-frame change in cursor position, the right value for mouse-look or anything that wants relative motion rather than absolute coordinates.
#![allow(unused)] fn main() { let delta = world.resources.input.mouse.position_delta; camera_yaw += delta.x * sensitivity; camera_pitch += delta.y * sensitivity; }
Buttons
Mouse buttons expose three states each. CLICKED is held, JUST_PRESSED fires on the transition into pressed, JUST_RELEASED fires on the transition out. The JUST_* variants only see one frame of true.
#![allow(unused)] fn main() { let mouse = &world.resources.input.mouse; if mouse.state.contains(MouseState::LEFT_CLICKED) { fire_weapon(); } if mouse.state.contains(MouseState::LEFT_JUST_PRESSED) { start_drag(); } if mouse.state.contains(MouseState::LEFT_JUST_RELEASED) { end_drag(); } if mouse.state.contains(MouseState::RIGHT_CLICKED) { aim_down_sights(); } if mouse.state.contains(MouseState::MIDDLE_CLICKED) { pan_camera(); } }
Scroll Wheel
wheel_delta is the scroll delta since the previous frame. y is vertical scroll, x is horizontal scroll on mice that support it.
#![allow(unused)] fn main() { let scroll = world.resources.input.mouse.wheel_delta; if scroll.y != 0.0 { zoom_camera(scroll.y); } }
Event Handling
Match on the mouse AppEvent when the press and release transitions both matter, such as aim-down-sights that holds while the button is down.
#![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(), _ => {} } } }
WASD Movement
The standard WASD pattern. Each direction key sets a component of a movement vector, then the vector is normalized so diagonals are not 1.41x faster than cardinals.
#![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
First-person camera control. Mouse delta drives yaw on the world Y axis and pitch on the local X axis. Composing them as yaw * rotation * pitch keeps yaw global and pitch relative to the camera's current orientation, which is the convention that produces the expected behavior. A real implementation also clamps pitch so the camera does not flip over.
#![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) { let yaw = nalgebra_glm::quat_angle_axis( -delta.x * sensitivity, &Vec3::y(), ); let pitch = nalgebra_glm::quat_angle_axis( -delta.y * sensitivity, &Vec3::x(), ); transform.rotation = yaw * transform.rotation * pitch; } } } }
Cursor Visibility
For first-person games, lock the cursor to the window and hide it so the system pointer does not interfere with mouse-look.
#![allow(unused)] fn main() { fn initialize(&mut self, world: &mut World) { world.set_cursor_locked(true); world.set_cursor_visible(false); } }
Rebindable Controls
A KeyBindings struct decouples action names from physical keys. The struct holds one KeyCode per action and the input system reads from those fields rather than from hard-coded constants. The user can then write to the struct to remap.
#![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 Buffering
An input buffer accepts an input slightly before the action becomes available and replays it when the action becomes valid. The most common use is jump. A player who presses jump a few frames before landing still gets the jump, which feels more responsive than rejecting the press outright. The buffer is a countdown timer that decays each frame and is refreshed on press.
#![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
Gamepad input is provided by the gilrs crate. gilrs handles the platform differences across Windows, macOS, Linux, and the web, and Nightshade wraps the live state on world.resources.input.gamepad.
Enabling Gamepad
Gamepad is gated behind the gamepad cargo feature. Add it to the engine dependency.
[dependencies]
nightshade = { git = "...", features = ["engine", "gamepad"] }
Gamepad Resource
The resource wraps the gilrs library and tracks which gamepad is currently active.
#![allow(unused)] fn main() { pub struct Gamepad { pub gilrs: Option<gilrs::Gilrs>, pub gamepad: Option<gilrs::GamepadId>, pub events: Vec<gilrs::Event>, } }
The engine initializes gilrs at startup and updates the active gamepad as controllers are connected or disconnected.
Polling the Active Gamepad
query_active_gamepad returns a gilrs::Gamepad handle for the currently active controller. The handle exposes value for axes and triggers and is_pressed for buttons.
#![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(); } } } }
Buttons
#![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 uses position names (South, East, West, North) so the same code works across controller layouts. The face buttons map to different labels on each platform's controller, summarized below.
| gilrs::Button | Xbox | PlayStation | Nintendo |
|---|---|---|---|
South | A | Cross | B |
East | B | Circle | A |
West | X | Square | Y |
North | Y | Triangle | X |
LeftTrigger | LB | L1 | L |
RightTrigger | RB | R1 | R |
Select | View | Share | - |
Start | Menu | Options | + |
DPadUp/Down/Left/Right | D-Pad | D-Pad | D-Pad |
Analog Sticks
Axis values are f32 in the range [-1.0, 1.0]. Center is zero. LeftStickX is positive right, LeftStickY is positive up.
#![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); } }
A deadzone of 0.15 to 0.20 is standard. Below that, treat the stick as centered. Cheap controllers drift, and reading raw values means the camera slowly rotates while the player is not touching the stick.
Triggers
Triggers are axes, not buttons. They report [0.0, 1.0] for how far pulled. LeftZ and RightZ are the analog values. The LeftTrigger and RightTrigger buttons in the table above are the digital shoulder buttons on most controllers, not the analog 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 Handling
Gamepad events arrive as gilrs::Event values in the AppEvent queue. Match on the event type for transition-based logic.
#![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
Player input often comes from whichever device is being held. The pattern is to read both, prefer the gamepad when its sticks are out of the deadzone, and fall back to the keyboard otherwise. Action buttons OR across devices so either input fires the action.
#![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
The event bus is a FIFO queue of messages shared across systems. One system publishes, another consumes, and neither has to know about the other. Events are the alternative to direct function calls when the producer and consumer should not be coupled, when one event should trigger several handlers, or when the action needs to be deferred to a later point in the frame.
EventBus
The bus lives on world.resources.event_bus. It is a VecDeque<Message> plus the Message enum, which has two variants. Input is reserved for engine-generated input messages. App is a type-erased payload for application events.
#![allow(unused)] fn main() { pub struct EventBus { pub messages: VecDeque<Message>, } pub enum Message { Input(InputMessage), App(Box<dyn Any + Send + Sync>), } }
Defining Events
An event is any struct that implements Send + Sync. There is no trait to derive and no registration step. The struct holds the data the consumer will need.
#![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
publish_app_event boxes the event and pushes it onto the queue. The example below detects death in a combat system, publishes EnemyDied with the relevant context, then despawns the entity.
#![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
Consumers pop messages off the queue and match on the variant. For Message::App, downcast against each event type that the consumer cares about. The order is FIFO. The first event published is the first one popped.
#![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; } }
Patterns
One Publisher, Many Consumers
A single published event can be matched against by any number of consumers, each on its own pass through the queue. The publisher does not know how many listeners exist.
#![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); } }
The downside is that the queue is drained once per frame, so consumers that share a drain loop see each event exactly once. For one-to-many fan-out, the consumers need to live inside the same drain or the event has to be republished.
Deferred Actions
Events delay work to a later frame. The struct stores its own countdown, and the system retains pending events until the countdown reaches zero, at which point it spawns the actual thing.
#![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
The event bus is fire-and-forget. For request-response, where the caller needs the result of the work, use a pair of structs and pass them through a shared buffer rather than the event bus.
#![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 bus also carries engine-generated input messages. These are the same input transitions exposed through the polling API, available as events for code that prefers the event style.
#![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 }, } }
Constraints
Keep events small. The payload is boxed and downcast, so a large struct costs more to publish and more to read. Process events every frame, since the queue grows unbounded if drained slowly. Watch for cycles. An event A whose handler publishes event B whose handler publishes event A is an infinite loop that fills memory and stalls the frame.
Rendering Architecture
This chapter is about how ECS state becomes pixels. The renderer is built on wgpu and the GPU work is orchestrated by a dependency-driven render graph. Game code writes to components, the renderer reads those components every frame, packs the data into GPU buffers, and submits a graph of passes that ends with a swapchain present.
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
The Render trait is the abstraction over the GPU backend. Game code never touches wgpu directly. It calls trait methods on whatever renderer the platform layer constructed.
#![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. It owns the wgpu device, queue, surface, and the render graph.
WgpuRenderer
WgpuRenderer is the bag of GPU state the engine carries across frames. The fields are:
- Instance, Adapter, Device, Queue are the wgpu initialization chain.
- Surface is the window's swapchain.
- RenderGraph is the dependency-driven frame graph with every pass.
- Resource IDs are handles to transient and external textures.
- Texture Cache is the set of uploaded GPU textures keyed by handle.
- Font Atlas is the glyph texture for text rendering.
- Camera Viewports are render-to-texture targets for editor viewports.
None of this is exposed to game code. Game code mutates the ECS, the renderer reads it.
Initialization
The startup chain is the standard wgpu bring-up plus some graph construction at the end.
- The instance is created with
wgpu::Instance. The backend is Vulkan on Linux and Windows, Metal on macOS, DX12 on Windows, WebGPU on WASM. The instance is the entry point to the GPU API. - The instance enumerates available GPUs and picks one. The chosen adapter exposes the GPU's supported texture formats, limits, and features.
- The adapter opens a logical device and a command queue. The device creates GPU resources. The queue is where command buffers go to execute. All GPU work flows through the queue.
- The window surface is configured with the GPU's preferred format (usually
Bgra8UnormSrgb) and a present mode (Fifofor vsync,Mailboxfor low latency). - Every built-in pass is constructed. Each pass owns its shader modules, pipeline layouts, render pipelines, bind group layouts, and any persistent GPU buffers. None of this is rebuilt per frame.
- A
RenderGraph<World>is constructed with the transient textures and passes registered. - The game's
State::configure_render_graph()runs. The game can add custom passes, declare custom textures, or replace the default post-process chain. - The graph compiles. Compilation builds dependency edges, topologically sorts the passes, computes resource lifetimes, decides which transients can alias, and works out per-attachment load and store operations.
Transient Textures
The renderer declares every intermediate texture at initialization time. The list is fixed, the sizes track the window, and the graph decides which ones can share memory.
| Texture | Format | Description |
|---|---|---|
depth | Depth32Float | Main depth buffer (reversed-Z, 0.0 = far) |
scene_color | Rgba16Float | HDR color accumulation buffer |
compute_output | Surface format | Post-processed output before swapchain blit |
shadow_depth | Depth32Float | Cascaded shadow map (8192x8192 native, 4096 WASM) |
spotlight_shadow_atlas | Depth32Float | Spotlight shadow atlas (4096 native, 1024 WASM) |
entity_id | R32Float | Entity ID buffer for GPU picking |
view_normals | Rgba16Float | View-space normals for SSAO/SSGI |
selection_mask | R8Unorm | Selection mask for editor outlines |
ssao_raw | R8Unorm | Raw SSAO before blur |
ssao | R8Unorm | Blurred SSAO |
ssgi_raw | Rgba16Float | Raw SSGI (half resolution) |
ssgi | Rgba16Float | Blurred SSGI (half resolution) |
ssr_raw | Rgba16Float | Raw screen-space reflections |
ssr | Rgba16Float | Blurred SSR |
ui_depth | Depth32Float | Separate depth for UI rendering |
External textures are provided fresh each frame. swapchain is the window surface texture. viewport_output is the editor viewport render target.
Per-Frame Rendering
render_frame() does seven things, in order.
- Sync data. Transform matrices, material uniforms, light data, and animation bone matrices are uploaded to GPU buffers.
- Process commands. Queued
WorldCommandvalues run (texture loads, screenshots, anything else queued during the frame). - Set the swapchain texture. The next swapchain image is acquired and bound as the external
swapchainresource. - Call
State::update_render_graph()so the game can flip per-frame graph state (enabling or disabling passes, updating parameters). - Execute the graph. Every enabled, non-culled pass runs in topological order. Each pass writes one command buffer.
- Submit. The command buffers go to the GPU queue.
- Present. The frame appears on the window.
Resize Handling
When the window resizes, the surface is reconfigured with the new dimensions, every transient texture is resized to match, the graph recomputes aliasing, and any pass that cached bind groups invalidates them. SSGI textures resize to half the new dimensions because they run at half resolution.
Custom Rendering
Two State methods let the game change the pipeline. configure_render_graph() runs once at startup. The game adds custom passes, declares custom textures, and changes the pipeline shape. update_render_graph() runs each frame. The game enables or disables passes and updates per-pass parameters.
For the details on the graph itself, see The Render Graph. For end-to-end custom-pass examples, see Custom Passes.
The Render Graph
Live Demos: Custom Pass | Custom Multipass | Render Layers
The render graph is the system that schedules GPU work. Passes declare what they read and what they write, and the graph figures out the ordering, the memory layout, and the load and store operations. The motivation is that hand-ordering a real renderer does not scale.
The Problem: Manual Pass Ordering
A real renderer has dozens of passes. Shadow maps, geometry, SSAO, SSR, bloom, tonemapping, UI. Each one reads from and writes to intermediate textures. Doing this without automation means four kinds of pain.
The first is ordering. Shadow maps before geometry, geometry before SSAO, SSAO before compositing. Adding one pass means figuring out where it fits in the chain, and reordering one pass means re-checking three others.
The second is texture lifecycle. Allocate intermediate textures, track which ones are alive when, decide when to clear versus load and when to store versus discard. Getting this wrong shows up as black screens or stale data from a previous frame.
The third is memory. SSAO's intermediate texture and SSR's intermediate texture might never be alive at the same time. Without aliasing, both live in VRAM whether they need to or not.
The fourth is dynamic passes. Disabling bloom should not require rewriting the compositing pass's inputs. With hardcoded ordering, every conditional pass becomes an if statement threaded through the entire pipeline.
The render graph (also called a frame graph, after the Frostbite GDC 2017 talk "FrameGraph: Extensible Rendering Architecture in Frostbite") replaces all four with declared dependencies. Passes describe what they need. The graph handles ordering, memory, and lifecycle.
How It Works
The frame is modeled as a directed acyclic graph. Nodes are passes. Edges are resource dependencies. An edge from pass A to pass 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 edges, a topological sort produces a valid execution order. Once the order exists, the graph can analyze resource lifetimes, alias memory, compute load and store operations, and cull passes that do not contribute to any external output.
The key property is that dependencies are declarative, not imperative. A pass says "I read scene_color, I write bloom," not "run me after MeshPass and before PostProcessPass." Adding a new pass means declaring its slots, not editing every other pass that touches the same resources.
What This Buys
Five things fall out of the graph automatically.
Pass ordering is topologically sorted from read and write dependencies. Transient textures with non-overlapping lifetimes share GPU memory. The graph picks LoadOp::Clear, LoadOp::Load, StoreOp::Store, and StoreOp::Discard per attachment based on who reads what. Passes that do not contribute to any external output are culled. Passes can be enabled and 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 directly.
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
| Method | Description |
|---|---|
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
compile() runs seven steps in sequence.
- Build dependency edges. For each resource, an edge is created from the writer to every reader.
- Topological sort. The passes are ordered so every pass executes after its dependencies.
- Compute store ops. Each resource write is marked
StoreorDiscardbased on whether any later pass reads it. - Compute clear ops. The first pass that writes a resource with a clear value gets
Clear. The rest getLoad. - Compute resource lifetimes. Each transient gets a
first_useandlast_usepass index. - Compute resource aliasing. Transient resources with non-overlapping lifetimes are assigned to the same pool slot.
- Dead pass culling. Passes that do not contribute to any external output are marked for skipping.
Sub-Chapters
- Resources & Textures covers resource types, builders, and the difference between external and transient.
- Passes & the PassNode Trait covers how to implement a custom pass.
- Dependency Resolution & Scheduling covers how the order is computed.
- Resource Aliasing & Memory covers how transients share memory.
- Custom Passes walks through end-to-end examples.
Resources & Textures
Render graph resources are the GPU textures and buffers that passes read from and write to. Every resource is referenced through a ResourceId handle. The graph hands you the handle when you declare the resource, and you pass that handle to add_pass() to wire it into slot bindings.
ResourceId
#![allow(unused)] fn main() { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct ResourceId(pub u32); }
ResourceId is an opaque integer handle. It survives across frames and across graph recompilations. The actual GPU texture or buffer the handle points to can change (aliasing, resize), but the handle does not.
Resource Types
There are six variants. The split is along two axes: color versus depth versus buffer, and external versus transient.
| Type | Description |
|---|---|
ExternalColor | Color texture provided externally each frame (e.g. swapchain) |
TransientColor | Color texture managed by the graph (allocated, aliased, freed automatically) |
ExternalDepth | Depth texture provided externally |
TransientDepth | Depth texture managed by the graph |
ExternalBuffer | GPU buffer provided externally |
TransientBuffer | GPU buffer managed by the graph |
External vs Transient
External resources are owned by the caller. The graph never creates or destroys them. Every frame, the application provides them via set_external_texture(). The swapchain texture is the canonical example.
Transient resources are owned by the graph. The graph creates the underlying GPU texture or buffer, tracks its lifetime across the frame, and can alias it with other transients to reduce VRAM. A transient with first_use at pass 1 and last_use at pass 3 has no GPU object outside that window, so its memory can be reused by another transient whose lifetime falls outside the same range.
Creating Color Textures
The fluent builder is the entry point.
#![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
| Method | Description |
|---|---|
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
| Method | Description |
|---|---|
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
A template captures the shared shape of a set of textures. Useful when you need a bunch of textures with the same format, size, and usage and only the name differs.
#![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
| Method | Description |
|---|---|
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
A pool batches the allocation of several transients from one 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 before execute() runs.
#![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, every transient that tracks the window size needs to be told.
#![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)?; }
Resizing invalidates the aliasing info. Reallocation happens on the next execution.
Passes & the PassNode Trait
A pass is a PassNode. The trait declares the pass's resource dependencies, gives the graph a chance to upload per-pass uniforms during the prepare phase, and records GPU commands during execute.
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 because the WebGPU types are not Send.
Slot-Based Resource Binding
Passes declare slot names. Slot names are strings that match the keys in the add_pass() bindings. The names are local to the pass.
#![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![] } // ... } }
The slot-to-resource binding happens at add_pass().
#![allow(unused)] fn main() { graph.add_pass( Box::new(my_pass), &[("input", scene_color_id), ("output", swapchain_id)], )?; }
The pass's shader code refers to bind groups, not to slot names. The slot names are only the graph's vocabulary. Inside the pass, you ask the execution context for the view bound to a slot.
PassExecutionContext
The context is what the pass uses to look up resources, get attachment descriptors, and record commands.
#![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
| Method | Returns | Description |
|---|---|---|
get_texture_view(slot) | &TextureView | Get 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) | &Buffer | Get a GPU buffer |
get_texture_size(slot) | (u32, u32) | Get texture dimensions |
is_pass_enabled() | bool | Check if this pass is currently enabled |
run_sub_graph(name, inputs) | - | Execute a sub-graph |
Automatic Load/Store Operations
The graph picks the right load and store op for each attachment based on who else touches the resource.
LoadOp::Clearis used when this pass is the first writer and the resource has a clear value.LoadOp::Loadis used when a previous pass already wrote to this resource.StoreOp::Storeis used when any later pass reads this resource.StoreOp::Discardis used when no subsequent pass reads this resource.
You do not choose these. get_color_attachment() and get_depth_attachment() return the right ones.
Prepare Phase
prepare() runs before execution for every non-culled pass. The right work for prepare is anything that uploads uniforms or per-frame data.
#![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 a resource (resize, aliasing change), invalidate_bind_groups() is called on every pass that references that resource. The pass clears its cached bind groups so they get rebuilt against the new GPU texture.
#![allow(unused)] fn main() { fn invalidate_bind_groups(&mut self) { self.bind_group = None; } }
The graph tracks a version per resource. Only passes that actually reference a changed resource get invalidated.
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![]) } } }
The pattern is the same for every pass. Pull the views out of the context, build a bind group if the cached one was invalidated, begin a render pass with the load and store ops the graph computed, set the pipeline, draw.
Sub-Graph Execution
A pass can trigger a sub-graph for multi-pass effects like a bloom mip chain.
#![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()) } }
The sub-graph is a separate RenderGraph registered against a name. The parent pass passes in slot values and the sub-graph executes with those as its external inputs.
Dependency Resolution & Scheduling
Pass ordering falls out of the resource dependencies. This chapter is about how that calculation works, where it can fail, and how runtime toggling fits in.
Dependency Edge Construction
compile() builds edges by walking the passes and the resources they touch.
- Iterate every pass.
- For each resource a pass reads, find the pass that last wrote it.
- Add 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 counts as both reader and writer, so it sits between the previous writer and the next reader. Optional reads create edges only when a writer exists. If no pass writes the slot, the optional reader simply does not get the edge and the pass is free to schedule wherever else its other dependencies allow.
Topological Sort
The graph performs a topological sort using petgraph. A topological sort of a DAG produces a linear ordering where every edge A -> B puts A before B. That ordering guarantees every pass runs after the passes that produce its inputs.
The cost is O(V + E) where V is the number of passes and E is the number of dependency edges. Kahn's algorithm (iteratively remove nodes with no incoming edges) and depth-first search both produce the same result. petgraph picks one implementation.
A cycle in the graph means compilation fails with RenderGraphError::CyclicDependency. Two passes that each depend on the other's output have no valid ordering. The fix is in the graph definition: one of the passes is over-declared and should not actually depend on the other's resource.
Dead Pass Culling
Not every pass contributes to the final image. The graph uses backward reachability from external resources to keep only the passes that matter.
- Start with every external resource marked "required."
- Walk the execution order backward.
- A pass is required if it writes a required resource, or if it has no writes and no reads_writes (which the graph treats as a side-effecting pass).
- If a pass is required, every resource it reads is also required.
- Any pass not marked required is 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
The culling is what lets update_render_graph() toggle effects on and off without leaving dead passes in the pipeline. A disabled pass whose output nothing else reads is invisible to the rest of the graph.
Runtime Pass Toggling
A pass can be turned on or off without recompiling.
#![allow(unused)] fn main() { graph.set_pass_enabled("bloom_pass", false)?; }
Disabling does not remove the pass from the graph. execute() is still called, but is_pass_enabled() returns false. The pass's execute() checks the flag and skips its 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 } }
The trade-off is that the pass's outputs still exist as dependencies in the graph. Disabling a pass that downstream passes depend on (and that does not have an optional_reads declaration covering it) leaves the downstream passes reading whatever was last in the attachment, which is usually the clear value.
Recompilation
The graph tracks a needs_recompile flag. Adding or removing a pass sets it. On the next execute(), the graph drops every existing edge, rebuilds the dependency edges, re-sorts topologically, and recomputes store ops, clear ops, lifetimes, and aliasing. The call site does not have to call compile() again.
Store and Clear Operations
Store Operations
On tile-based GPU architectures (mobile, Apple Silicon), render pass attachments live in fast on-chip tile memory during the pass. At the end of the pass, the driver decides whether to write that tile memory back out to main VRAM. The write-back is the store operation, and it costs real bandwidth.
The graph picks the store op per resource write.
Storeis used when any later pass reads the resource, or when the resource is external and hasforce_storeset. The data has to survive.Discardis used when no later pass reads the resource. The GPU skips the write-back entirely. On tile-based architectures this is a significant win.
Clear Operations
The other end of the pass has a parallel decision. The GPU decides what to do with the existing attachment contents.
Clearwrites a known value (black, zero depth) into the attachment. This is cheap because tile memory can be initialized without reading from VRAM.Loadreads the existing contents from VRAM into tile memory. This is required when a previous pass wrote data that this pass needs to preserve.
The first pass that writes a resource with a clear value (clear_color or clear_depth) gets LoadOp::Clear. Every subsequent writer gets LoadOp::Load.
The cost of getting these wrong is real. Picking Clear when Load was correct erases the previous pass's work. Picking Load when Clear was correct wastes bandwidth loading garbage data. The graph computes both automatically, and get_color_attachment() and get_depth_attachment() return the chosen ops.
Resource Aliasing & Memory
Transient resources with non-overlapping lifetimes share GPU memory. The render graph works this out automatically. The point is to keep the VRAM footprint bounded as the pipeline grows.
The VRAM Problem
A real pipeline uses fifteen-plus intermediate textures: shadow maps, SSAO buffers, bloom mip chains, SSR buffers, selection masks, and so on. A single Rgba16Float texture at 1080p is about 16 MB. At 4K it is 64 MB. Without aliasing, every one of those textures sits in VRAM whether it is in use or not.
Resource aliasing is the GPU equivalent of stack allocation. The same memory region gets reused by different variables (textures) whose lifetimes do not overlap. The graph's execution order gives a total ordering of passes, which is exactly what is needed to compute precise lifetimes and find aliasing opportunities.
The technique comes from the Frostbite frame graph (GDC 2017) and is standard practice in production engines. The savings run 30 to 50 percent of transient VRAM depending on the pipeline.
How It Works
After the topological sort, the graph knows the execution order. For every transient it computes two pass indices.
first_useis the pass where the resource is first written.last_useis the pass where the resource is last read.
If resource A's last_use is before resource B's first_use, and they have compatible descriptors, 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
A's lifetime is [0, 1]. C's lifetime is [2, 3]. They do not overlap. If the descriptors match, the graph assigns them to the same pool slot.
Compatibility Requirements
Two textures alias when every one of the following matches.
- 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 alias when the pool's size is at least the reuser's size and the usage flags match exactly.
If a reuser needs usage flags the pool texture does not have, the pool texture is recreated with the combined flags. The reuser still gets to share the slot.
Pool-Based Allocation
The allocator is a pool keyed by a BinaryHeap ordered by lifetime_end. The heap gives constant-time access to the slot that frees up earliest.
- Sort transient resources by
first_use. - For each resource, check whether any pool slot has a
lifetime_endstrictly before this resource'sfirst_use. If a compatible slot is found, reuse it. Otherwise allocate a new slot. - Each pool slot holds at most one GPU texture or buffer at any moment.
Min-heap on lifetime_end means the earliest-expiring slot is the one checked first, which is the right order for greedy reuse.
Bind Group Invalidation
When a transient gets a new GPU texture (the pool slot was reallocated), every pass that references that resource needs to rebuild its bind groups.
The graph keeps a version number per resource. When a resource's version changes between frames:
- Find every pass that reads, writes, or
reads_writesthe resource. - Call
invalidate_bind_groups()on those passes. - Update the stored version.
This is what keeps passes from accidentally binding the old GPU handle after an aliasing change or a window resize.
Memory Savings
A scene with fifteen-plus transient textures can shrink dramatically once aliasing is applied. Some concrete examples:
ssao_rawandssgi_raware usually never live at the same time.- Shadow depth maps are only live during shadow passes. After that, their memory is free.
- Intermediate blur textures from bloom can share memory with SSR blur textures.
The exact savings depend on the pass ordering and the resource sizes.
External Resources
External resources are never aliased. They are owned outside the graph and provided fresh each frame. The swapchain texture is the obvious case. Editor viewport outputs are the other.
Custom Passes
Custom rendering hooks into the graph through two State methods. One runs at startup. The other runs every frame.
configure_render_graph()
This is the setup hook. It runs once when the renderer is constructed. The game adds passes, declares textures, and changes the pipeline shape.
#![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
RenderResources is the bundle of ResourceIds the engine already declared. Anything custom usually reads from scene_color and writes to compute_output or swapchain.
| Field | Description |
|---|---|
scene_color | HDR color buffer (Rgba16Float) |
depth | Main depth buffer (Depth32Float) |
compute_output | Post-processed output before swapchain blit |
swapchain | Final swapchain output |
view_normals | View-space normals |
ssao_raw / ssao | Raw and blurred SSAO |
ssgi_raw / ssgi | Raw and blurred SSGI |
ssr_raw / ssr | Raw and blurred SSR |
surface_width / surface_height | Current window dimensions in pixels |
update_render_graph()
The per-frame hook. Use it for runtime state flips, not for adding passes (use the setup hook for that).
#![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
The built-in pass types are reusable. A custom graph can pick and choose which built-ins to include.
#![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
pass() is an alternative to add_pass() that reads more like an expression. The builder adds the pass to the graph when it goes out of scope.
#![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); }
Conditional Passes
There are two ways to make a pass conditional. The first is to leave it out of the graph entirely at setup time.
#![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), ], ); } } }
The second is to add the pass unconditionally and toggle it 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); } }
The toggle path is the right choice when the setting changes during play. The conditional-construction path is the right choice when the decision is fixed at startup, because it skips the per-frame check entirely.
Default Pipeline
If the game does not override configure_render_graph(), the default implementation adds three passes:
BloomPassruns HDR bloom at half resolution.PostProcessPassdoes tonemapping and compositing.BlitPasscopies to the swapchain.
The engine always adds the core passes (clear, sky, shadow, mesh, skinned mesh, grass, grid, lines, selection) regardless of what the custom configuration does. Overriding configure_render_graph() only changes the post-process tail.
The Default Pipeline
The default render graph is what the engine constructs when the game does not override configure_render_graph(). This chapter is the inventory: the pass list, the order they end up in after topological sort, and the data flow between them.
Pass Execution Order
The graph sorts these passes from their declared read and write dependencies. The order below is the one that comes out the other side for a typical configuration.
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. GrassPass - Render GPU-instanced grass
8. GridPass - Render infinite ground grid
9. LinesPass - Render debug lines
10. SelectionMaskPass - Generate selection mask for editor
11. OutlinePass - Render selection outline
--- User passes (from configure_render_graph) ---
12. BloomPass - HDR bloom (default)
13. PostProcessPass - Tonemapping and compositing (default)
14. BlitPass - Copy to swapchain (default)
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
|
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. They can be disabled at runtime.
ClearPass
Clears scene_color to black and depth to 0.0. Reversed-Z is the convention everywhere in the renderer, so 0.0 is the far plane.
SkyPass
Renders the procedural atmosphere or a solid background. Controlled by world.resources.graphics.atmosphere.
ScenePass
A simpler scene path for basic objects, kept around alongside the PBR mesh pass.
ShadowDepthPass
Renders the shadow map. The depth target is shadow_depth (cascaded sun shadows) plus spotlight_shadow_atlas (per-spotlight shadows packed into one atlas). See Shadow Mapping.
MeshPass
The main PBR mesh pass. Multi-target output: scene_color for HDR shaded color, depth for depth, entity_id for GPU picking, and view_normals for SSAO and SSR. One fragment invocation writes to all four. See Geometry Passes.
SkinnedMeshPass
The same idea as MeshPass but with GPU skinning for animated skeletal meshes.
Selection Passes
SelectionMaskPass writes a mask of selected entities into selection_mask. OutlinePass reads the mask and renders the outline.
User-Configurable Passes (Default)
These three are what the default configure_render_graph() adds. Override the 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 ships bloom and tonemapping. SSAO, SSGI, and SSR are not in the default graph because they cost real GPU time and not every game wants them. To enable any of them, override configure_render_graph() and add the relevant passes. See Post-Processing for the full list of available post-process passes, and Custom Passes for the wiring.
Shadow Mapping
Live Demos: Shadows | Spotlight Shadows
Nightshade uses cascaded shadow mapping for directional lights and a single shadow atlas for every spotlight in the scene.
How shadow mapping works
Shadow mapping is two passes. The first pass renders the scene from the light's point of view into a depth-only texture, the shadow map. The second pass is the regular geometry pass. Every fragment projects itself into the light's coordinate space and compares its depth against the value stored in the shadow map. If the fragment is farther from the light than the shadow map records, something closer to the light is blocking it, and the fragment is in shadow.
The shadow map records "closest surface to the light" at every texel. Anything behind that closest surface must be occluded. The cost of the lookup is one texture sample per fragment per light.
The resolution problem
A single shadow map has a fixed resolution. A directional light like the sun illuminates the entire scene, so the shadow map has to cover all of it. Objects near the camera want high-resolution shadows because the eye can see the edges clearly. Distant objects can tolerate much lower resolution because they cover a few pixels of screen space anyway. One global shadow map wastes resolution on geometry that is far away and starves the geometry that is close.
Cascaded shadow maps (CSM)
The fix is to split the camera's view frustum into depth ranges and give each range its own shadow map. Near cascades cover a small area at high texel density. Far cascades cover a large area at lower density. The total memory cost is fixed, but the texels go where they are seen.
The ShadowDepthPass renders four shadow cascades (NUM_SHADOW_CASCADES = 4) into a single large depth texture, one per quadrant.
- Cascade 0 is the near range, highest detail, covering roughly 0 to 10 percent of the view distance.
- Cascade 1 is the mid-near range, covering roughly 10 to 30 percent.
- Cascade 2 is the mid-far range, covering roughly 30 to 60 percent.
- Cascade 3 is the far range, lowest detail, covering roughly 60 to 100 percent.
Shadow map resolution
| Platform | Resolution |
|---|---|
| Native | 8192 x 8192 |
| WASM | 4096 x 4096 |
Each cascade takes one quadrant of the texture, so each cascade gets an effective 4096 x 4096 on native and 2048 x 2048 on the web.
How cascades work
Every frame the engine does four things.
The first is frustum computation. The camera's view frustum is the truncated pyramid defined by the near plane, the far plane, and the field of view. The engine pulls the eight corners of that pyramid out of the camera matrices.
The second is frustum splitting. The frustum gets sliced into four depth ranges using a logarithmic-linear blend. Logarithmic splitting gives more resolution to near cascades. Linear splitting distributes resolution evenly. A blend of 0.5 to 0.8 toward logarithmic produces good results across most scenes.
The third is tight projection fitting. For each cascade, the engine takes the eight corners of that frustum slice, transforms them into light space, and builds an orthographic projection matrix that just encloses those points. The matrix is as tight as the slice allows, which keeps texels from being wasted on empty space outside the cascade's actual coverage.
The fourth is shadow rendering. Every shadow-casting mesh is drawn from the directional light's perspective into each cascade's viewport region of the shadow texture.
During the mesh pass, each fragment decides which cascade to sample based on its distance from the camera. The shader picks the highest-resolution cascade that still contains the fragment, projects into that cascade's light-space coordinates, and does the depth comparison.
Cascade selection and blending
At cascade boundaries, shadows can show visible seams because the resolution changes abruptly across the boundary. The fragment shader compares the fragment's view-space depth against the cascade split distances to pick a cascade. Implementations that need smoother transitions blend between adjacent cascades in a small band around the boundary.
Spotlight shadow atlas
Spotlights share one atlas instead of getting their own textures.
| Platform | Atlas Size |
|---|---|
| Native | 4096 x 4096 |
| WASM | 1024 x 1024 |
Every spotlight with cast_shadows: true gets a slot in the atlas. The atlas is subdivided to fit multiple spotlights at the cost of per-light resolution.
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 configure manually:
#![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 comes from the shadow map's finite 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 lets the surface report itself as in shadow. The result is a moire-like pattern of alternating lit and shadowed stripes on flat surfaces.
The fix is shadow bias. A small depth offset is added during the comparison, pushing the comparison point slightly toward the light so a surface cannot self-shadow. The tradeoff is peter-panning. Too much bias detaches the shadow from the base of the object because the comparison point gets pushed past the surface entirely.
shadow_bias controls the 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 at higher effective resolution, so the acne is less severe to begin with.
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 the depth buffer. Each pass handles a different category of geometry and writes a subset of the available render targets.
ClearPass
The ClearPass clears scene_color to black and depth to 0.0, which is the reversed-Z far plane. It always runs first so every other pass sees a known starting state.
Writes: scene_color, depth
SkyPass
The SkyPass renders the sky or atmosphere background. The mode is selected by world.resources.graphics.atmosphere.
Atmosphere::Noneis a solid background color and is the default.Atmosphere::Skyis a procedural clear-sky gradient.Atmosphere::CloudySkyis a procedural sky with volumetric clouds.Atmosphere::Spaceis a procedural starfield.Atmosphere::Nebulais a procedural nebula with stars.Atmosphere::Sunsetis a procedural sunset gradient.Atmosphere::DayNightis a procedural day-night cycle driven by an hour parameter.Atmosphere::Hdris an HDR environment cubemap.
Writes: scene_color
ScenePass
The ScenePass is a basic scene rendering pass for simple objects that do not need the full PBR pipeline.
Reads/Writes: scene_color, depth
MeshPass
The MeshPass is the main PBR mesh rendering pass. It renders all entities with RENDER_MESH | MATERIAL_REF | GLOBAL_TRANSFORM. The fragment shader does PBR shading with the metallic-roughness workflow, samples the shadow depth texture for cascaded shadow maps, samples the spotlight shadow atlas, applies normal mapping from a per-material normal texture, and supports opaque, mask, and blend alpha modes. The same shader output also writes entity IDs for GPU picking and view-space normals for SSAO and SSGI, all in a single fragment invocation.
Reads: shadow_depth, spotlight_shadow_atlas
Writes: scene_color, depth, entity_id, view_normals
SkinnedMeshPass
The SkinnedMeshPass renders animated skeletal meshes. It reads bone matrices from the skinning buffer and transforms vertices on the GPU.
Reads: shadow_depth, spotlight_shadow_atlas
Writes: scene_color, depth
GrassPass
The GrassPass is GPU-instanced grass rendering. It renders thousands of grass blades using instance data from GrassRegion components, with wind animation and interactive bending driven by GrassInteractor components.
Reads/Writes: scene_color, depth
DecalPass
The DecalPass renders projected decals onto scene geometry. Each decal samples the depth buffer to project its texture onto whatever surface is behind it.
Reads/Writes: scene_color, depth
ParticlePass
The ParticlePass is GPU billboard particle rendering. It reads ParticleEmitter component data and renders camera-facing quads, one per particle.
Reads/Writes: scene_color, depth
TextPass
The TextPass renders 3D world-space text using the font atlas.
Reads/Writes: scene_color, depth
HudPass
The HudPass renders screen-space HUD text. Unlike the TextPass, HUD text is positioned in screen coordinates with configurable anchoring rather than placed in the world.
Reads/Writes: scene_color, depth
LinesPass
The LinesPass renders debug lines from Lines components. The pass is the workhorse for visualization, bounding boxes, and any other debug geometry.
Reads/Writes: scene_color, depth
GridPass
The GridPass renders an infinite ground grid. It is gated on world.resources.graphics.show_grid.
Reads/Writes: scene_color, depth
UiRectPass
The UiRectPass renders UI rectangles for the immediate-mode UI system.
SelectionMaskPass
The SelectionMaskPass generates a selection mask texture for selected entities. The editor uses it to drive selection outlines.
Reads: depth
Writes: selection_mask
OutlinePass
The OutlinePass reads the selection mask and renders outlines around selected entities by detecting edges in the mask.
Reads: selection_mask
Writes: scene_color
ProjectionPass / HiZPass
The ProjectionPass and HiZPass build a hierarchical-Z buffer for occlusion culling.
Post-Processing
Live Demos: Bloom | SSAO | Depth of Field
Post-processing passes read the HDR scene color, the depth buffer, and the view-space normals and produce the final image. They are added to the graph in configure_render_graph().
Available passes
| Pass | Description | Reads | Writes |
|---|---|---|---|
SsaoPass | Screen-space ambient occlusion | depth, normals | ssao_raw |
SsaoBlurPass | Bilateral blur for SSAO | ssao_raw | ssao |
SsgiPass | Screen-space global illumination (half-res) | scene_color, depth, normals | ssgi_raw |
SsgiBlurPass | Bilateral blur for SSGI | ssgi_raw | ssgi |
SsrPass | Screen-space reflections | scene_color, depth, normals | ssr_raw |
SsrBlurPass | Blur for SSR | ssr_raw | ssr |
BloomPass | HDR bloom with mip chain | scene_color | bloom |
DepthOfFieldPass | Bokeh depth of field | scene_color, depth | scene_color |
PostProcessPass | Final tonemapping and compositing | scene_color, bloom, ssao | output |
EffectsPass | Custom shader effects | scene_color | scene_color |
OutlinePass | Selection outline | selection_mask | scene_color |
BlitPass | Simple texture copy | input | output |
Enabling effects
Post-processing is controlled 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)
Corners, crevices, and enclosed spaces receive less ambient light in the real world because surrounding geometry occludes incoming light from many directions. SSAO approximates that effect in screen space by reading 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 and checked against the depth buffer. If the stored depth is closer than the sample point's depth, that direction is considered occluded. The ratio of occluded samples to total samples is the occlusion factor.
The three inputs are the depth buffer for the 3D position of each pixel, the view-space normals for the hemisphere orientation, and a random noise texture that rotates the sample kernel per-pixel to avoid banding patterns.
The raw output is noisy because the sample count is small. Typical SSAO uses 16 to 64 samples per pixel. A bilateral blur pass smooths the result while preserving edges, which means it avoids blurring across depth discontinuities. Without the bilateral guard the blur would produce halos around silhouettes.
#![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 is the hemisphere radius in world units. Larger values detect occlusion from farther geometry but can over-darken broad regions. ssao_bias is a small depth offset that prevents self-occlusion artifacts on flat surfaces. ssao_intensity is the multiplier on the final occlusion factor.
SSGI (Screen-Space Global Illumination)
Light bounces between surfaces in the real world. A red wall next to a white floor tints the floor red. Traditional rasterization computes direct lighting only, which is light source to surface to camera. Global illumination adds the indirect bounces.
SSGI approximates one bounce of indirect light using only screen-space data. For each pixel, the shader traces short rays through the depth buffer to find nearby surfaces, then reads the color at those hit points as incoming indirect light. It is conceptually the same trick as SSAO, except it samples color instead of just occlusion.
The pass runs at half resolution. The indirect illumination is low-frequency, the bilateral blur cleans up the noise, and the upsample reintroduces full resolution at the composite step.
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's depth exceeds the depth buffer value, the ray has hit a surface. The color at that screen position is used as the reflection.
The technique works well for reflections of on-screen geometry. The limits are inherent. Off-screen objects cannot be reflected because there is no data for them. Reflections at grazing angles stretch across large screen areas. The blur pass hides the worst of the artifacts, and a fallback to environment maps or IBL fills in where SSR has no data at all.
Bloom
Bloom simulates the light scattering that happens in real cameras and in the human eye when bright light sources bleed into surrounding pixels. In HDR rendering, pixels can hold values above 1.0, which is the displayable range. Bloom extracts those bright pixels and spreads their light outward.
How bloom works
The bloom pipeline uses a progressive downsample-then-upsample chain, the same approach described in the Call of Duty Advanced Warfare presentation.
The first step thresholds the HDR scene color, keeping only pixels above a configurable cutoff. The second step is the downsample chain. Resolution is progressively halved through multiple mip levels, for example 1920x1080 to 960x540 to 480x270 and so on, with a blur applied at each step. Blurring at low mip levels is far cheaper than blurring at full resolution because each level has a quarter of the pixels of the level above. The third step is the upsample chain. The renderer walks back up the mip chain, additively blending each level with the one above it. The result is a smooth, wide blur that spans many pixels of the final image without ever using a large blur kernel directly. The fourth step is the composite. The bloom result is added to the scene color during the final post-process pass.
The mip-chain approach produces natural-looking bloom because tight glow comes out of the high-resolution mips while wide glow comes out of the low-resolution mips, both at once.
#![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, and objects nearer or farther than the focal plane appear blurred. The amount of blur is called the circle of confusion (CoC), and it grows with distance from the focal plane. Aperture size sets the maximum.
How DoF works
The first step is CoC computation. For each pixel, the shader pulls the depth value out of the depth buffer, combines it with the focus distance and aperture, and produces a CoC diameter in pixels.
The second step is the blur. A variable-radius blur is applied where the kernel size scales with the CoC. Pixels far from focus get heavy blur. Pixels near the focal plane stay sharp.
The third step is the bokeh accent. Bright out-of-focus highlights form the characteristic disc or hexagon shapes called bokeh. The shader emphasizes bright pixels during the blur to reproduce the optical effect.
#![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. Displays can only show values between 0 and 1. Tonemapping compresses the HDR range into the displayable LDR range while preserving the perception of brightness differences and color relationships.
Different curves make different tradeoffs. Reinhard is the simple color / (color + 1) mapping. It preserves highlights but tends to look washed out. ACES is the film industry curve. It produces good contrast and a slight warm tint and is what most games ship with. AgX is a more recent curve designed to handle highly saturated colors better than ACES, which can produce hue shifts in bright saturated regions. Neutral does minimal color manipulation and is useful when color grading is handled externally.
The PostProcessPass does the HDR-to-LDR conversion.
#![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. The standard set includes color grading presets, chromatic aberration, film grain, and any custom shader the application wants to inject.
See Effects Pass for details.
Custom post-processing
Custom passes are added through the render graph. See Custom Passes for the implementation pattern.
Performance
| Effect | Cost | Notes |
|---|---|---|
| Bloom | Medium | Multiple blur passes at half resolution |
| SSAO | High | Many depth samples per pixel |
| SSGI | High | Half resolution helps, but still expensive |
| SSR | High | Ray tracing through depth buffer |
| DoF | Medium | Gaussian blur |
| Tonemapping | Low | Per-pixel math |
| Color Grading | Low | Per-pixel math |
Disable expensive effects for lower-end hardware.
#![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
A camera defines the viewpoint and projection used to render the scene. Nightshade uses reversed-Z depth buffers for both perspective and orthographic projections, supports infinite far planes, frame-rate-independent input smoothing, and an arc-ball orbit controller for editor-style navigation.
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 is perspective with a 45 degree FOV, an infinite far plane, a 0.01 near plane, and smoothing on.
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, } }
| Field | Default | Description |
|---|---|---|
aspect_ratio | None | Width/height ratio. None uses the viewport aspect ratio |
y_fov_rad | 0.7854 (45 deg) | Vertical field of view in radians |
z_far | None | Far plane distance. None uses an infinite far plane |
z_near | 0.01 | Near plane distance |
Reversed-Z projection
Nightshade uses reversed-Z depth buffers. The near plane maps to depth 1.0 and the far plane maps to 0.0. The reason is precision. Floating-point depth has its highest precision near 0.0, and reversing the mapping puts that precision where the eye notices it most, on distant objects. Z-fighting at long range drops dramatically without changing anything else about the pipeline.
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, } }
| Field | Default | Description |
|---|---|---|
x_mag | 10.0 | Half-width of the view volume (horizontal extent is ±x_mag) |
y_mag | 10.0 | Half-height of the view volume (vertical extent is ±y_mag) |
z_far | 1000.0 | Far clipping plane distance |
z_near | 0.01 | Near clipping plane distance |
The orthographic projection uses reversed-Z too, 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
A 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
The 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 camera input. The smoothing factor is computed as:
smoothing_factor = 1.0 - smoothness^7 ^ delta_time
smoothness is the per-device smoothness parameter. A smoothness of 0 gives instant response. Values approaching 1 make the input progressively more sluggish. The powi(7) exponent is there to make the parameter feel linear when you adjust it in an inspector.
#![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, } }
| Field | Default | Description |
|---|---|---|
mouse_sensitivity | 0.5 | Mouse look speed multiplier |
mouse_smoothness | 0.05 | Mouse input smoothing (0 = instant, 1 = no change) |
mouse_dpi_scale | 1.0 | DPI scaling factor for mouse input |
keyboard_smoothness | 0.08 | Keyboard movement smoothing |
gamepad_sensitivity | 1.5 | Gamepad stick look speed |
gamepad_smoothness | 0.06 | Gamepad input smoothing |
gamepad_deadzone | 0.15 | Gamepad 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 is 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
| Action | Mouse | Gamepad | Touch |
|---|---|---|---|
| Orbit | Middle button | Right stick | Single finger drag |
| Pan | Shift + Middle button | Left stick | Two finger drag |
| Zoom (drag) | Ctrl + Middle button | Triggers | Pinch |
| 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.
| Parameter | Default | Description |
|---|---|---|
orbit_sensitivity | 1.0 | Mouse orbit speed |
pan_sensitivity | 1.0 | Mouse pan speed |
zoom_sensitivity | 1.0 | Scroll zoom speed |
orbit_smoothness | 0.1 | Orbit interpolation smoothness |
pan_smoothness | 0.02 | Pan interpolation smoothness |
zoom_smoothness | 0.1 | Zoom interpolation smoothness |
gamepad_orbit_sensitivity | 2.0 | Gamepad orbit speed |
gamepad_pan_sensitivity | 10.0 | Gamepad pan speed |
gamepad_zoom_sensitivity | 5.0 | Gamepad zoom speed |
gamepad_deadzone | 0.15 | Stick deadzone |
gamepad_smoothness | 0.06 | Gamepad smoothing |
Target values (target_yaw, target_pitch, target_focus, target_radius) are set by user input. The current values interpolate toward the targets every frame using the smoothing formula. The system snaps to the target when the difference falls below 0.001 to avoid an infinite tail.
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 plus or minus 90 degrees. When the camera goes upside down, the yaw direction is automatically reversed so mouse control still feels right.
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 sits at focus + rotate(yaw, pitch) * (0, 0, radius). The rotation is composed as yaw around Y first and 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
Switching between cameras is a single field write.
#![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
A material defines the visual appearance of a mesh using physically based rendering. Nightshade follows the glTF 2.0 metallic-roughness workflow and supports several glTF extensions on top of it: 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
| Field | Default | Description |
|---|---|---|
base_color | [0.7, 0.7, 0.7, 1.0] | RGBA albedo color, multiplied with base_texture |
roughness | 0.5 | Surface roughness (0 = mirror, 1 = fully diffuse) |
metallic | 0.0 | Metalness (0 = dielectric, 1 = conductor) |
emissive_factor | [0.0, 0.0, 0.0] | RGB emissive color, multiplied with emissive_texture |
emissive_strength | 1.0 | HDR intensity multiplier for emissive output |
alpha_mode | Opaque | Transparency handling mode |
alpha_cutoff | 0.5 | Alpha threshold for AlphaMode::Mask |
unlit | false | Skip lighting calculations (flat shaded) |
double_sided | false | Render both sides of faces |
uv_scale | [1.0, 1.0] | UV coordinate scale multiplier |
Normal map options
| Field | Default | Description |
|---|---|---|
normal_scale | 1.0 | Normal map intensity multiplier |
normal_map_flip_y | false | Flip the Y (green) channel for DirectX-style normal maps |
normal_map_two_component | false | Two-component normal map (RG only, B reconstructed) |
occlusion_strength | 1.0 | Ambient occlusion effect strength (0 = none, 1 = full) |
Transmission and volume
The transmission and volume fields implement light transmission through surfaces, which covers glass, water, and thin-shell materials.
| Field | Default | Description |
|---|---|---|
transmission_factor | 0.0 | Fraction of light transmitted through the surface (0 = opaque, 1 = fully transmissive) |
thickness | 0.0 | Volume thickness for refraction (0 = thin-wall) |
attenuation_color | [1.0, 1.0, 1.0] | Color of light absorbed inside the volume |
attenuation_distance | 0.0 | Distance at which light is attenuated to attenuation_color |
ior | 1.5 | Index of refraction (1.0 = air, 1.33 = water, 1.5 = glass, 2.42 = diamond) |
Specular
The specular fields override the default Fresnel reflectance for dielectric materials.
| Field | Default | Description |
|---|---|---|
specular_factor | 1.0 | Specular 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 scalar roughness and metallic values are multiplied with the texture's green and blue channels respectively. The scalars become per-material multipliers on top of the per-texel data.
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, including most output from Substance or older authoring 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
Procedural textures are generated at runtime and uploaded the same way as any other texture.
#![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
The engine ships with a small set of 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
A common preprocessing step is to remove root motion from animations so the gameplay code can drive the root transform itself.
#![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
Meshes can be built programmatically. The vertex format is fixed across the engine.
#![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
Assigning a mesh to an entity is two writes plus the component mask.
#![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, the instanced path is one draw call instead of N.
#![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
The cache is keyed by name and shared across the whole world.
#![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 a skeleton, spawn through the skin-aware path.
#![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
Whether a mesh casts shadows is controlled by the CASTS_SHADOW component.
#![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.
Light types
Directional light (sun)
A directional light illuminates the entire scene from a single direction. It is the standard way to model distant light sources like the sun, where the rays are effectively parallel.
#![allow(unused)] fn main() { use nightshade::prelude::*; let sun = spawn_sun(world); }
spawn_sun returns the Entity for the directional light. The light can be reconfigured at any time.
#![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
A point light emits in all directions from a single position.
#![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
A spot light is a cone-shaped emitter. Flashlights, stage lighting, and headlights all fit this model.
#![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
| Property | Description |
|---|---|
color | RGB color of the light |
intensity | Brightness multiplier |
range | Maximum distance for point/spot lights |
cast_shadows | Whether this light creates shadows |
shadow_bias | Offset to reduce shadow acne |
inner_cone_angle | Spot light inner cone (full intensity) |
outer_cone_angle | Spot light outer cone (falloff edge) |
Dynamic lighting
Flickering light
A flickering fire or torch effect is three out-of-phase sine waves summed onto a base intensity.
#![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
Three phase-offset sines on the RGB channels cycle through the color wheel.
#![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)
A flashlight is a spotlight whose transform tracks the active camera every frame.
#![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 with a cap of 256 lights per cluster. During the mesh pass, each fragment looks up its cluster and only evaluates the lights assigned to it. The cost is bounded by the lights that actually reach the pixel rather than the total light count in the scene.
PBR lighting model
All lights are evaluated using the Cook-Torrance microfacet BRDF. The model has three parts.
The Normal Distribution Function (D) is Trowbridge-Reitz GGX. It models the statistical distribution of microfacet orientations. The squared roughness parameter (a = roughness * roughness) controls how concentrated the specular highlight is.
The Geometry Function (G) is the Schlick-Beckmann approximation combined with Smith's method. It accounts for self-shadowing between microfacets. Two terms get combined, one for the view direction and one for the light direction.
The Fresnel term (F) is Schlick's approximation. It 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. The irradiance map is a pre-convolved diffuse environment, sampled in the surface normal direction. The prefiltered environment map is five mip levels of increasingly blurred specular reflections, sampled in the reflection direction at a mip level chosen by roughness.
A 2D BRDF lookup texture (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 many lights in a scene. Combine the spawn helpers above to build out an environment.
#![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 from bytes, asynchronously from disk or HTTP, or generated procedurally at runtime.
Texture cache
The TextureCache stores every loaded texture as a TextureEntry (a wgpu texture plus its view plus its sampler) in a GenerationalRegistry. Each texture is identified by a TextureId containing an index and a generation counter. Stale references are detected because the generation increments when a slot is recycled, so an old TextureId will not match the slot's current contents.
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 drains the command queue, uploads the RGBA data to the GPU, and stores the texture in the cache under the given name.
Procedural textures
The engine provides three built-in procedural textures loaded at startup via load_procedural_textures().
#![allow(unused)] fn main() { load_procedural_textures(world); }
| Name | Description |
|---|---|
"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. The cache will not free a texture until every holder has released it.
#![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() frees it.
Dummy textures
If a texture is missing, texture_cache_ensure_dummy() creates a 64x64 purple-and-black checkerboard placeholder. The fallback keeps the renderer from erroring out and makes missing assets obvious in the viewport.
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 progress for a loading screen.
#![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
| Platform | Loading Method |
|---|---|
| Desktop | Synchronous file read from disk |
| WASM | Async HTTP fetch via ehttp |
Asset search paths
Configure the directories 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 }
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.
Decals
Live Demo: Decals
A decal is a texture projected onto scene geometry using the depth buffer. Decals cover bullet holes, blood splatters, footprints, scorch marks, and environmental details without modifying the underlying mesh. The geometry stays static and the decal layer is purely cosmetic.
How decal rendering works
Each decal is drawn 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 inside the decal's projection volume (plus or minus 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 (computed from depth buffer gradients) against the decal's forward direction. Surfaces angled past the threshold are rejected. The result is that decals stop projecting around sharp edges instead of wrapping over them like a sticker.
Distance fade is a smoothstep between fade_start and fade_end based on the camera-to-decal distance. Far decals fade out cheaply and stay culled rather than producing aliased projections at long range.
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, } }
| Field | Default | Description |
|---|---|---|
texture | None | Texture name in the texture cache |
emissive_texture | None | Optional emissive texture for glowing decals |
emissive_strength | 1.0 | HDR 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 |
depth | 1.0 | Projection depth (how far the decal penetrates into surfaces) |
normal_threshold | 0.5 | Surface angle cutoff (0 = accept all, 1 = perpendicular only) |
fade_start | 50.0 | Distance where fade begins |
fade_end | 100.0 | Distance 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 for chained configuration.
#![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 } }
Physics Overview
Live Demo: Physics
Physics in nightshade is Rapier3D wrapped in ECS components. The engine owns the RigidBodySet, ColliderSet, and ImpulseJointSet on a single PhysicsResources struct, and runs the simulation at a fixed timestep on the frame schedule. Components on entities (RigidBodyComponent, ColliderComponent, CharacterControllerComponent) hold the parameters and the Rapier handles. Transforms sync into Rapier before the step and back out after.
Enabling Physics
The physics feature flag pulls in Rapier3D and the components that drive it.
[dependencies]
nightshade = { git = "...", features = ["engine", "physics"] }
Physics World
The physics world is a resource on world.resources.physics. Configuration sits on the same struct.
#![allow(unused)] fn main() { let physics = &mut world.resources.physics; physics.gravity = Vec3::new(0.0, -9.81, 0.0); physics.fixed_timestep = 1.0 / 60.0; }
Gravity is a vector, not a magnitude, so a top-down game can set it to zero and a side-scroller can rotate it. The fixed timestep is the rate the simulation actually steps at. 60 Hz is the default and what most demos assume.
Core Concepts
Rigid Bodies
A rigid body is the dynamic state Rapier integrates each step. There are three kinds.
Dynamic. Mass and inertia tensor, integrated under gravity and applied forces.Kinematic. Position is set by code each frame, dynamic bodies see it as an immovable wall that happens to move.Fixed. Infinite mass, never moves. Floors, walls, terrain.
Colliders
A collider is the shape Rapier uses for contact and intersection tests. Shapes available are Ball, Cuboid, Capsule, Cylinder, Cone, TriMesh, HeightField, and compound combinations.
Character Controllers
A character controller is a kinematic body with a small state machine on top that resolves contacts itself instead of letting the constraint solver do it. The result is movement that respects walls but does not get pushed around by stacked boxes.
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)); } } }
The floor is one fixed cube. The stack is ten dynamic cubes spawned at increasing height. The physics schedule does the rest.
Physics Synchronization
The simulation runs at the fixed timestep, the renderer runs at the display rate, and the two are not the same. Each frame works like this:
- Game logic mutates entity transforms.
- The physics step runs zero or more times depending on how much real time has passed since the last step.
- Rapier writes the new positions and orientations back into the entity transforms.
- Interpolation between the previous and current physics positions smooths the visual.
The interpolation step is what hides the discrete simulation rate from the eye. Without it, fast-moving bodies appear to stutter at 60 Hz regardless of the frame rate.
Querying Physics
Picking (Raycasting)
Picking casts a ray from the cursor pixel into the world and returns the closest hit.
#![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; } }
pick_closest_entity hits the bounding volume of each candidate. That is fast enough to run every frame for hover and selection, and inaccurate enough that the hit point can be off the actual surface by the radius of the bounding sphere.
For per-triangle accuracy, use the trimesh variant.
#![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; } }
The trimesh path is slower per pick but lands on the actual mesh. Use it when the hit point matters, like for decal placement or precise interaction.
Physics Materials
Friction, restitution, and density live directly on ColliderComponent. There is no separate material asset.
#![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)); }
Friction controls sliding resistance, restitution controls bounciness, density combines with shape volume to give the body its mass.
Debug Visualization
The debug draw flag dumps collider shapes, contact points, and collision normals as line geometry.
#![allow(unused)] fn main() { world.resources.physics.debug_draw = true; physics_debug_draw_system(world); }
Useful while building levels to see where collision actually is versus where the visual mesh sits. The two diverge more than you would expect.
Performance Tips
Five things move the needle, roughly in this order.
- Use primitive shapes (boxes, spheres, capsules) wherever possible. Trimesh collision is an order of magnitude slower per pair.
- Filter collision groups so unrelated objects do not even broadphase against each other.
- Combine shapes into compound colliders on one body instead of parenting many small bodies.
- Let idle bodies sleep. Rapier does this automatically when linear and angular velocity stay below a threshold for long enough.
- Keep the fixed timestep at 60 Hz unless something specifically needs more.
Joints
Joints constrain the relative motion of two bodies. Doors are revolute joints, drawers are prismatic, ropes are rope joints, chains are spherical. See Physics Joints for the full set.
Rigid Bodies
A rigid body is the chunk of state Rapier integrates each step. Position, orientation, linear and angular velocity, mass, the inertia tensor. The body type controls how it responds to forces. The collider attached to it controls what it collides with.
Body Types
Dynamic Bodies
Dynamic bodies are fully simulated. Gravity, applied forces, impulses, contacts all push them around.
#![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)); }
Use dynamic for physics props, ragdolls, anything that should react to the world.
Kinematic Bodies
Kinematic bodies move by code, not by the integrator. Other dynamic bodies treat them as immovable walls, but the kinematic body itself ignores collision response.
#![allow(unused)] fn main() { world.core.set_rigid_body(entity, RigidBodyComponent::new_kinematic()); }
Drive a kinematic body by writing its transform directly each frame.
#![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); }
The mark_local_transform_dirty call is required. Without it the transform sync system does not know the transform changed and the body will not move in Rapier.
Static Bodies
Static bodies never move. Floors, walls, environment geometry. Infinite mass, no integration cost.
#![allow(unused)] fn main() { world.core.set_rigid_body(entity, RigidBodyComponent::new_static()); }
Helper Functions
The physics module ships two helpers for the common case of spawning a dynamic primitive with a matching collider.
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)); }
Both spawn a dynamic body with a unit-sized collider. Use the longer form when you need a custom size or material.
Applying Forces
Forces and impulses are not exposed through the component directly. Reach into Rapier through the handle on RigidBodyComponent.
#![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, ); } } }
The boolean is the wake flag. Sleeping bodies ignore forces unless explicitly woken, which is almost always what you want when applying force.
Applying Impulses
An impulse is an instantaneous change in velocity. Use it for explosions, jumps, hit reactions, anything that should not integrate over time.
#![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, ); } } }
The difference from a force is units. Force is mass times acceleration, applied over the timestep. Impulse is mass times velocity, applied all at once.
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, ); } } }
Setting velocity directly stomps whatever Rapier had. Useful for snapping a character to a target speed. Forces and impulses are better when you want the body to ease into motion.
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); } } }
set_additional_mass adds to the mass Rapier computed from the collider's density and volume. Use density on the collider for typical materials. Use this for things like a hat that should not change the wearer's inertia.
Locking Axes
Some bodies should not be free to rotate or translate on every axis. A first-person character should not tip over. A 2.5D platformer body should not drift on Z.
#![allow(unused)] fn main() { if let Some(rigid_body) = world.resources.physics.rigid_body_set.get_mut(handle.into()) { rigid_body.lock_rotations(true, true); // rigid_body.lock_translations(true, true); } }
Damping
Damping is the air-drag-style velocity decay applied each step.
#![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); rigid_body.set_angular_damping(0.5); } }
Linear damping bleeds off translation, angular damping bleeds off spin. Values around 0.5 to 2.0 give a noticeable "thick air" feel. Zero is vacuum.
Sleeping
Rapier puts bodies to sleep when their velocity stays under a threshold for long enough. Sleeping bodies skip integration entirely. Wake them when you want them to react to something that does not generate a contact, like a teleport or a manual velocity change.
#![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
A collider is the shape Rapier uses for contact detection. It is separate from the visual mesh, and the two should usually disagree. Visual meshes have thousands of triangles. Colliders are the simplest shape that approximates the visual closely enough.
Collider Shapes
Ball (Sphere)
#![allow(unused)] fn main() { world.core.set_collider(entity, ColliderComponent::new_ball(0.5)); }
Cheapest shape. A single radius and a position. Use it for projectiles, simple props, and anything that can roll without looking wrong.
Cuboid (Box)
#![allow(unused)] fn main() { world.core.set_collider(entity, ColliderComponent::new_cuboid(1.0, 0.5, 1.0)); }
The three arguments are half-extents, not full widths. The cuboid above is 2 by 1 by 2 meters.
Capsule
A capsule is a cylinder with hemispheres on both ends. The hemispheres prevent the character from snagging on small steps and crease lines, which a flat-bottomed cylinder is prone to do.
#![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() }); }
Cylinders make good barrels and pillars. They are more expensive than boxes at contact time.
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
Triangle mesh is the escape hatch for static geometry that does not fit a primitive shape.
#![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() }); }
Rapier builds a BVH over the triangles on insert, so the construction cost is real but one-time. Use trimesh on static bodies only. Dynamic bodies with trimesh colliders run into the limitation that Rapier cannot compute mass properties from a non-closed mesh, and contact resolution between two trimesh dynamic bodies is not well-defined.
Heightfield
A heightfield is a grid of height samples. It is the right shape 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() }); }
The scale vector is [x, height, z]. The grid above covers a 100 by 100 meter area with vertical scale 50.
Compound
Compound colliders are multiple shapes attached to one body. Cheaper than parenting multiple bodies and keeps the mass properties coherent.
#![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 on ColliderComponent itself.
#![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)); }
Friction is the sliding-resistance coefficient. 0 is frictionless, 1 is sticky. Restitution is the bounce coefficient. 0 absorbs impact, 1 returns it perfectly. Density is mass per unit volume. Rapier computes the body's mass from collider density and shape volume at insertion.
Material Examples
#![allow(unused)] fn main() { let ice = ColliderComponent::new_cuboid(0.5, 0.5, 0.5) .with_friction(0.05) .with_restitution(0.1) .with_density(0.9); let rubber = ColliderComponent::new_ball(0.5) .with_friction(0.9) .with_restitution(0.8) .with_density(1.1); let metal = ColliderComponent::new_cuboid(0.5, 0.5, 0.5) .with_friction(0.4) .with_restitution(0.2) .with_density(7.8); }
Density 7.8 for metal is realistic (steel). Density 1.0 is water. These values matter for the mass ratio between bodies in contact, which controls how much each one moves on collision.
Collision Groups
Collision groups filter which pairs of colliders are even considered for contact testing. A player should not collide with the projectiles it fired. Enemy AI should not interpenetrate.
#![allow(unused)] fn main() { use rapier3d::prelude::*; 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; let player_filter = CollisionGroups::new( GROUP_PLAYER, GROUP_ENEMY | GROUP_WORLD, ); }
The first arg is the group this collider belongs to. The second is the bitmask of groups it collides with. A pair collides only if each side's membership intersects the other side's filter.
Sensor Colliders
A sensor reports overlap without applying contact response. Trigger volumes, damage zones, pickup proximity.
#![allow(unused)] fn main() { if let Some(collider) = world.resources.physics.collider_set.get_mut(handle.into()) { collider.set_sensor(true); } }
Sensor pairs come through the collision event stream alongside regular contacts, distinguished by the is_sensor flag.
Collision Events
Rapier queues collision events each step. The engine surfaces them on world.resources.physics.collision_events(), returning a &[CollisionEvent] slice.
#![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 event carries the two entities, the kind (Started or Stopped), and the sensor flag.
#![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 dynamic bodies that need to approximate a concave shape, convex decomposition breaks the mesh into a set of convex pieces and attaches them as a compound collider.
#![allow(unused)] fn main() { use rapier3d::prelude::*; let decomposed = SharedShape::convex_decomposition(&vertices, &indices); }
The result is more expensive than a single primitive but works as a dynamic collider, which a trimesh cannot.
Performance Tips
| Shape | Performance | Use Case |
|---|---|---|
| Ball | Fastest | Rolling objects |
| Cuboid | Fast | Crates, buildings |
| Capsule | Fast | Characters |
| Cylinder | Medium | Barrels, pillars |
| Convex | Medium | Simple props |
| Trimesh | Slow | Static terrain only |
| Compound | Varies | Complex dynamic objects |
Primitives are always cheaper than meshes. Trimesh is for static collision only. Compound on one body beats many small entities connected by joints. The collision mesh almost always wants to be simpler than the visual mesh.
Character Controllers
A character controller is a kinematic body with a custom contact resolver on top. The constraint solver is what makes a stack of boxes stable, and it is also what makes a player character feel mushy when standing on those boxes. Character controllers bypass it. Movement happens in code, contacts are resolved by sweeping and sliding the capsule against the world, and the result is movement that snaps to walls without bouncing off them.
First-Person Player
The fastest way to get a working player is spawn_first_person_player. It creates the controller entity, a child camera at eye height, and wires up input.
#![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; } } }
max_speed is in meters per second. jump_impulse is the instantaneous upward velocity applied when the jump input fires.
Custom Character Controller
For third-person, NPCs, or anything that needs custom dimensions, build the entity by hand.
#![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 } }
new_capsule(0.5, 0.3) is half-height 0.5 and radius 0.3. The full character is 1 meter tall and 0.6 meters wide, roughly humanoid proportions.
Controller Properties
| Property | Description | Default |
|---|---|---|
max_speed | Walking speed | 5.0 |
is_sprinting | Sprint active | false |
acceleration | Speed up rate | 20.0 |
jump_impulse | Jump strength | 5.0 |
can_jump | Allow jumping | true |
is_crouching | Crouch active | false |
Movement Input
The built-in character_controller_input_system(world) reads WASD, space, shift, and control, and writes the resulting motion into the controller each frame. Call it from run_systems.
#![allow(unused)] fn main() { fn run_systems(&mut self, world: &mut World) { character_controller_input_system(world); } }
Skip this call and the controller will only move when something else writes to it. That is the right choice when input comes from a network packet or an AI script.
Ground Detection
The controller flags whether the capsule is currently in contact with a surface below it.
#![allow(unused)] fn main() { if let Some(controller) = world.core.get_character_controller(player) { if controller.grounded { // On ground - can jump } else { // In air } } }
Use this for jump gating, footstep timing, and switching between ground and air movement.
Slope Handling
The controller has two slope angles. Anything below max_slope_climb_angle is walkable. Anything above min_slope_slide_angle will slide the character down. Values are radians.
#![allow(unused)] fn main() { if let Some(controller) = world.core.get_character_controller_mut(player) { controller.config.max_slope_climb_angle = 0.8; controller.config.min_slope_slide_angle = 0.5; } }
0.8 rad is about 45 degrees. 0.5 rad is about 30 degrees. Tune for the world's geometry.
Camera Integration
First-Person Camera
The camera is a child entity of the player, offset to eye height.
#![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), ..Default::default() }); world.resources.active_camera = Some(camera); }
Parenting means the transform hierarchy handles position. Mouse look writes to the camera's local rotation, body rotation goes on the player.
Third-Person Camera
Third-person follows the character with an offset and looks at a target point on or near the body.
#![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; }; 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
Automatic step climbing lets the controller walk over small ledges without jumping. The two parameters bound what counts as a step.
#![allow(unused)] fn main() { if let Some(controller) = world.core.get_character_controller_mut(player) { controller.config.autostep_max_height = Some(0.3); controller.config.autostep_min_width = Some(0.2); } }
30 cm is roughly a typical stair height. Anything taller than autostep_max_height is treated as a wall.
Interaction Cooldowns
Player actions that fire on a button press need cooldowns so a long key press does not fire the action every frame.
#![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; } }
A just_pressed check on the input also works and is simpler. Use cooldowns when the action should repeat at a fixed rate while the key is held, like an auto-fire weapon.
Physics Joints
A joint is a constraint between two rigid bodies. The constraint solver enforces the relationship every step. Different joint types constrain different degrees of freedom. A fixed joint constrains all six (three translation, three rotation). A revolute joint constrains five and leaves one rotation axis free. A prismatic joint constrains five and leaves one translation axis free.
Joint Types
| Joint | Description | Use Cases |
|---|---|---|
| Fixed | Rigid connection | Welded objects |
| Spherical | Ball-and-socket | Pendulums, ragdolls |
| Revolute | Hinge | Doors, wheels |
| Prismatic | Slider | Drawers, pistons |
| Rope | Max distance | Ropes, chains |
| Spring | Elastic | Suspension, bouncy connections |
Fixed Joint
A fixed joint welds two bodies into one. They move and rotate together as if they were a single body, but mass and collision stay separate.
#![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)), ); }
The two local anchors are the attachment points in each body's local space. The solver keeps those points coincident.
Spherical Joint (Ball-and-Socket)
A spherical joint locks position but allows all three rotation axes. The two bodies pivot freely around the joint point.
#![allow(unused)] fn main() { 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)), ); }
The example above is a pendulum. The anchor is fixed, the ball swings.
Revolute Joint (Hinge)
A revolute joint allows rotation around a single axis. Everything else is constrained.
#![allow(unused)] fn main() { 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) .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)), ); }
The Y-axis revolute joint above is a door hinge. The limits (in radians) prevent it from swinging through the wall behind it.
Adding Motor
A joint motor drives the joint toward a target position or velocity. For a position motor on the door, that turns the hinge into something that can open and close on command.
#![allow(unused)] fn main() { RevoluteJoint::new(JointAxisDirection::Y) .with_motor(JointMotor::position(0.0, 5.0, 100.0)) }
The three arguments are target position, stiffness, and damping. Stiffness controls how hard the motor pushes toward the target. Damping resists the resulting velocity.
Prismatic Joint (Slider)
A prismatic joint allows translation along a single axis and locks everything else.
#![allow(unused)] fn main() { 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) .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)), ); }
The limits are in meters. 0 is fully closed, 0.8 is fully open.
Rope Joint
A rope joint enforces a maximum distance between two bodies. Within that distance the bodies move freely. At the limit they stop separating.
#![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) .with_local_anchor1(Vec3::new(0.0, -0.15, 0.0)) .with_local_anchor2(Vec3::new(0.0, 0.0, 0.0)), ); }
Use rope joints in series for a chain. Each link is a small body connected to the next by a rope joint with a short max distance.
Spring Joint
A spring joint applies a Hooke's-law restoring force toward a rest length. The body oscillates around that length and damping bleeds off the oscillation over time.
#![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) .with_local_anchor1(Vec3::new(0.0, -0.15, 0.0)) .with_local_anchor2(Vec3::new(0.0, 0.2, 0.0)), ); }
Arguments are rest length, stiffness, damping. Higher stiffness gives a stiffer spring. Higher damping settles the bounce faster.
Joint Limits
Limits clamp the free degree of freedom to a range.
#![allow(unused)] fn main() { RevoluteJoint::new(JointAxisDirection::Z) .with_limits(JointLimits::new(-1.57, 1.57)) PrismaticJoint::new(JointAxisDirection::X) .with_limits(JointLimits::new(-2.0, 2.0)) }
Revolute limits are radians. Prismatic limits are meters. Without limits the joint runs to its full mechanical range, which for a revolute is unbounded rotation and for a prismatic is unbounded translation.
Breaking Joints
Joints can break under excessive force. Set a maximum impulse and Rapier will dissolve the joint when contact forces push past it.
#![allow(unused)] fn main() { if let Some(joint) = world.resources.physics.get_joint_mut(joint_handle) { joint.set_max_force(1000.0); } }
Chain Example
A chain is a string of spherical joints. Each link's bottom anchor is the next link's top anchor.
#![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; } } }
The first link anchors to the spawn cube. Each subsequent link anchors below the previous one.
Interactive Door Example
A real door is a revolute joint with locked rotations on every axis except the hinge axis. Locking rotations on the body itself, in addition to the joint constraint, makes the door behave like a door instead of a wobbling flap.
#![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)); 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); } } } 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 } }
The door body keeps the dynamic mass that gives it momentum when pushed. The joint limits define how far it swings open.
Loading Animated Models
Animated models in nightshade are glTF or GLB files with a skin, a skeleton, and one or more animation clips. The loader pulls all four (mesh, textures, skin, animations) out of the file and the spawner attaches them to entities. Skeleton joints become entities in the hierarchy. Animation clips become channels that target those joint entities by index.
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()?; 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); } result.prefabs.first().map(|prefab| { spawn_prefab_with_animations( world, prefab, &result.animations, Vec3::zeros(), ) }) } }
The textures go through the command queue because GPU uploads happen at frame setup, not during loading. The meshes go straight into the mesh cache. spawn_prefab_with_animations is the version of the spawner that attaches an AnimationPlayer with the loaded clips.
Animation Data Structure
A loaded animation is a duration and a list of channels. Each channel targets one property on one joint and stores keyframes as flat arrays of times and values.
#![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, } }
target_node indexes into the skin's joint array, not into the world entity list. The animation system resolves the index to an entity at sample time.
Filtering Animation Channels
Mocap-derived animations often include translation on the root joint, which moves the character through the scene as the clip plays. That works when the engine drives movement from the animation (root motion) and breaks when game code drives movement and the animation should stay in place.
#![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| { if channel.target_property == AnimationProperty::Translation { return false; } if root_bone_indices.contains(&channel.target_node) && channel.target_property == AnimationProperty::Rotation { return false; } true }) .cloned() .collect(), }) .collect() } }
The filter above drops all translation channels and root-bone rotation. Rotation on the spine and limbs survives, so the character animates in place.
Storing Animation Indices
The animation player holds clips by index, not by name. Resolve names once at load time and store the indices.
#![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 } }
Substring matching is forgiving against clip names like "Armature.Idle" or "idle_loop". A strict equality check fails on the first asset that renames its clips.
Skeleton Structure
A skin is the binding between the mesh and the skeleton. Joints are entities, and the inverse bind matrices are the bind-pose transforms used to skin the mesh.
#![allow(unused)] fn main() { pub struct Skin { pub joints: Vec<Entity>, pub inverse_bind_matrices: Vec<Mat4>, } }
The joints array is what animation channels target. Bone index 7 in a channel resolves to skin.joints[7] in the world.
Attaching Objects to Bones
Attaching an item to a bone is the same as parenting any entity. Set the parent to the joint entity, then place the item in local space relative to the bone.
#![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); transform.scale = Vec3::new(1.0, 1.0, 1.0); } } fn attach_hat(world: &mut World, character: Entity, hat: Entity) { 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; } } } } } }
The transform hierarchy does the rest. As the head bone moves through the animation, the hat moves with it.
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 } }
Bone names come from the source file. They are typically capitalized in glTF (Head, LeftHand, Spine_2) but DCC tool conventions vary, so substring matching survives more files than equality does.
Multiple Animated Characters
Loading the same model file repeatedly is wasted work. Decode once, then spawn from the cached prefab as many times as needed.
#![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, ) } } }
The prefab and animations are shared by reference. Each spawn produces an independent set of entities with its own AnimationPlayer, so two characters can play different clips at different times from the same source data.
Animation Playback
Live Demo: Dance
AnimationPlayer is the component that tracks playback state for one animated entity. It holds the loaded clips, the index of the current clip, the playback time within that clip, the speed multiplier, and the flags that control looping and pausing. The animation system on the frame schedule advances the time each frame and writes the sampled pose into the joint entities.
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, } }
blend_from_clip and blend_factor are the crossfade state used by blend_to. When blend_from_clip is Some, the system samples both clips and lerps the result by blend_factor. See the blending chapter for details.
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; } } }
play sets current_clip, resets time to zero, and sets playing to true. Setting looping separately controls whether the clip wraps or stops at the end.
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; } } }
Speed is a multiplier on the time advance. 1.0 is normal, 0.5 is half, 2.0 is double. Negative values play in reverse.
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(); } } }
Pause stops the time from advancing but leaves the current pose frozen. Resume picks up where it left off.
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; } } }
A non-looping clip stops at its duration and stays on the final pose. A looping clip wraps the time modulo duration.
Checking Animation State
For one-shot animations the caller often needs to know when the clip has finished playing.
#![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 } }
get_animation_progress returns a normalized [0, 1] value useful for driving UI like an attack windup bar.
Animation 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 } }
Name lookup is O(N) over the clips array. For tight loops, resolve indices once at load and store them on a per-character struct.
State-Based Animation
A character typically has an animation state machine driven by gameplay state. The cleanest pattern is to map each state to a clip index and call blend_to whenever the state changes.
#![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, }; 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); *current = Some(index); } } } } }
The if target != *current guard is important. Calling blend_to every frame restarts the crossfade and the result is a stuck pose.
Speed Based on Movement
Walk and run clips are authored at a fixed speed. Scaling the playback rate to match the character's actual velocity prevents foot sliding.
#![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) { let base_speed = 3.0; player.speed = (velocity / base_speed).clamp(0.5, 2.0); } } }
The clamp keeps the animation from playing absurdly fast or slow. A character creeping at 0.1 m/s should not freeze, and a character sprinting at 30 m/s should not blur.
One-Shot Animations
#![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
Animation events are gameplay triggers fired at specific times within a clip. Footsteps, weapon swing sounds, hit detection windows. The technique is to compare the current time against an event time and the previous frame's time, and fire when the threshold is crossed.
#![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; 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; } } } }
The cross-the-threshold check fires exactly once per pass. A naive time >= 0.3 check would fire every frame after the threshold and play the footstep on every subsequent frame.
Blending & Transitions
Live Demos: Dance | Morph Targets
A hard cut between two animation clips snaps the skeleton from one pose to another in a single frame and looks broken. Crossfading samples both clips and lerps the joint transforms by a weight that ramps from 0 to 1 over a short window. The result is a continuous motion that takes the skeleton from one clip's pose to the other's without visible discontinuity.
Cross-Fade Transition
blend_to is the entry point. It records the current clip as the blend source, sets the new clip as the active clip, and ramps the blend factor across the requested duration.
#![allow(unused)] fn main() { if let Some(player) = world.core.get_animation_player_mut(entity) { player.blend_to(new_animation_index, 0.2); } }
While the blend is active, the animation system samples both clips at their respective times and interpolates each joint's translation, rotation, and scale by the current weight. When the weight reaches 1, the source clip is dropped and the new clip becomes the sole active animation.
Blend Duration
Different transitions want different durations. A startle reaction needs to snap. A stop-walking transition wants time to settle.
| Transition | Duration | Notes |
|---|---|---|
| Idle → Walk | 0.2s | Natural start |
| Walk → Run | 0.15s | Quick acceleration |
| Run → Idle | 0.3s | Gradual stop |
| Any → Jump | 0.1s | Responsive |
| Attack | 0.05s | Immediate |
The 0.05s attack blend is short enough to read as instant while still avoiding the visible snap of a hard cut.
Movement State Machine
A state machine is the right abstraction for character animation. The state is the gameplay-level intent (idle, walking, jumping). The animation player tracks which clip is currently playing. A transition is a request to blend from the current clip to the one mapped to the new state.
#![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, CharacterState::Landing => self.indices.idle, } } } }
The from, to pair gates the blend time, which is how transitions like "running stop" can use a longer duration than "running to jump."
Speed-Based Blending
Choosing between walk and run by speed threshold is a state-machine approach. A pure crossfade between walk and run blended by speed gives a continuous gait that interpolates smoothly across the threshold. Both work. The threshold version is shown below.
#![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); } player.speed = match state { MovementState::Idle => 1.0, MovementState::Walking => speed / walk_threshold, MovementState::Running => speed / run_threshold, }; } } } }
Tying playback speed to actual velocity inside each band keeps the feet from sliding. A character moving at 1.5 m/s plays walk at 0.75 speed, not at the authored 1.0.
Interrupt Handling
Attacks are non-looping clips that interrupt locomotion. They blend in fast, play once, then the state machine has to decide what comes next.
#![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) { 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 } }
duration * 0.9 is the trick that gives the next animation room to blend in. Waiting for the full duration leaves no time for the crossfade and the attack appears to snap back to idle.
Additive Blending
Additive layers stack a delta animation on top of a base. A breathing layer on top of idle. An aim offset on top of walk. The base provides the bulk of the motion, the additive provides per-joint offsets that add to whatever the base produced.
#![allow(unused)] fn main() { struct LayeredAnimation { base_animation: usize, additive_animations: Vec<(usize, f32)>, } }
The struct above is the data layout. The sampling math depends on the engine support and is not part of the default AnimationPlayer.
Root Motion
Root motion is the technique of letting the animation drive movement. The character moves through the world by the same amount the root bone moves in the clip. This produces tightly synchronized footplant but ties movement speed to the authored clip and complicates collision response.
#![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]; } }
The alternative is to strip root translation out of every clip (see the filter in the loading chapter) and drive movement from game code. That gives less footplant accuracy but more control. nightshade examples use the strip-and-drive approach.
Transition Rules
Not every state should be reachable from every other state. A jump cannot start from mid-air. An attack should not be interruptible by walking.
#![allow(unused)] fn main() { fn can_transition(from: CharacterState, to: CharacterState) -> bool { match (from, to) { (_, CharacterState::Idle) => true, (_, CharacterState::Falling) => true, (CharacterState::Attacking, _) => false, (CharacterState::Falling, CharacterState::Jumping) => false, (CharacterState::Jumping, CharacterState::Jumping) => false, _ => true, } } }
The rule table lives outside the animation system and gates state changes before they ever reach blend_to. Keeping the rules data instead of scattering them through gameplay code makes the state machine easy to audit.
Audio System
Live Demo: Audio
Audio in nightshade is Kira behind an ECS facade. Sounds are decoded once and stored in a name-keyed cache on world.resources.audio. Playback happens through the AudioSource component, which holds a reference to the cached sound by name plus per-source state like volume, looping, and spatial flag. An AudioListener component on the camera (or wherever else) gives spatial sources a reference point.
Enabling Audio
The audio feature pulls in Kira and the audio components.
[dependencies]
nightshade = { git = "...", features = ["engine", "audio"] }
Loading Sounds
Sound loading is two steps. Decode the bytes into a StaticSoundData, then cache that data by name. The cache is shared, so any number of AudioSource components can reference the same sound without duplicating the decoded samples.
#![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 is the decoder. It accepts WAV, OGG, MP3, and FLAC and returns the same StaticSoundData regardless of source format.
Playing Sounds
Entity-Based Playback
Playback is driven by AudioSource. The component references a cached sound by name and the audio system polls for active sources each frame.
#![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(), ); }
Music does not need a transform. The audio system treats a source with no transform as non-spatial and routes it to the master bus directly.
Audio Source Component
#![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, } }
audio_ref is the lookup key into the sound cache. volume is a per-source multiplier. looping, playing, spatial, and reverb are flags that gate playback behavior.
Builder methods chain the same fields.
#![allow(unused)] fn main() { AudioSource::new("name") .with_volume(0.8) .with_looping(true) .with_spatial(true) .with_reverb(true) .playing() }
Audio Listener
A spatial audio source needs to know where the listener is. Mark the listener with the AUDIO_LISTENER component, usually on the active camera.
#![allow(unused)] fn main() { world.core.add_components(camera_entity, AUDIO_LISTENER); world.core.set_audio_listener(camera_entity, AudioListener); }
The audio system reads the listener's global transform each frame and uses it as the reference point for distance attenuation and panning.
Spatial Audio Sources
A spatial source attenuates by distance and pans by direction relative to the listener. The flag is spatial = true.
#![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(), ); } }
The source's transform is the position in world space. Move the transform and the sound moves.
Sound Variations
Repeating the same audio sample every time a footstep fires is audibly mechanical. Pick from a small pool of variations to make the result feel less canned.
#![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; } } }
Four variations is the practical minimum to break the loop perception. Eight is better.
Triggering Sounds on Events
Physics collision events are a natural source of impact sounds. Read the event queue and flip the appropriate source's playing flag.
#![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
Two ways to stop a sound. Through the audio resource, or by setting the flag.
#![allow(unused)] fn main() { world.resources.audio.stop_sound(entity); }
#![allow(unused)] fn main() { if let Some(audio) = world.core.get_audio_source_mut(entity) { audio.playing = false; } }
The resource call stops Kira's playback handle immediately. The flag flips on the next frame when the audio system processes sources. Use the flag for game-logic-driven stops and the resource for clean shutdown.
Supported Formats
| Format | Extension | Notes |
|---|---|---|
| WAV | .wav | Uncompressed, fast loading |
| OGG | .ogg | Compressed, good for music |
| MP3 | .mp3 | Compressed, widely supported |
| FLAC | .flac | Lossless compression |
WAV is the right format for short effects where decode time matters. OGG and MP3 are right for music where file size matters. FLAC sits in between with lossless compression at a moderate decode cost.
Spatial Audio
Spatial audio is positional sound. A source at a location in the world, a listener at another location, and an audio signal that is attenuated by distance and panned by direction. The result is the listener can tell where a sound is coming from without seeing it. Footsteps behind, gunfire to the left, a waterfall in the distance.
Audio Listener
The listener is the entity whose position and orientation define "the ears." Almost always 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); } }
There is one active listener. If two entities both have the component, the audio system picks one and ignores the other.
Spatial Audio Source
A spatial source is an AudioSource with the spatial flag set, attached to an entity with a transform. Position comes from the transform, attenuation comes from the distance to the listener.
#![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
The audio system attenuates the source by distance to the listener. The volume falls off as the listener moves away.
#![allow(unused)] fn main() { world.core.set_audio_source(entity, AudioSource::new("waterfall") .with_spatial(true) .with_looping(true) .playing(), ); }
Rolloff Modes
| Mode | Description |
|---|---|
Linear | Linear falloff between min/max distance |
Inverse | Realistic 1/distance falloff |
Exponential | Steep falloff, good for small sounds |
Linear is the most predictable for gameplay tuning. Inverse matches how real sound behaves and is the right pick for outdoor ambient. Exponential drops off fast and keeps a small sound from leaking past its source.
Moving Sound Sources
A spatial source's position is wherever its transform happens to be each frame. Move the transform and the sound moves with it.
#![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); } }
The dirty flag is what tells the transform sync system to update the global transform that the audio system reads.
Non-Spatial (2D) Audio
UI sounds, music, and narration should not be spatialized. They want to play at full volume regardless of where the camera is pointing.
#![allow(unused)] fn main() { world.core.set_audio_source(entity, AudioSource::new("ui_click").playing()); }
Without the with_spatial(true) call, the source routes straight to the master bus.
Directional Audio Sources
A directional source emits more strongly along its forward axis than to the sides. PA speakers, megaphones, anything with a cone of audibility.
#![allow(unused)] fn main() { world.core.set_audio_source(entity, AudioSource::new("announcement") .with_spatial(true) .playing(), ); }
Manual Volume Control
The built-in attenuation works for most cases. When a specific source needs a custom curve, drive the volume from a system that runs each frame.
#![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; } } } }
The closed-form falls off linearly to zero at 50 meters. Replace the formula with whatever shape the source needs.
Common Patterns
Ambient Soundscape
Layering several looping sources at different positions builds a convincing outdoor scene. The layers do not need to be synchronized. Their independence is what makes the scene feel alive instead of looped.
#![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
Spawning a one-shot source per footstep is the cheapest way to get positional footsteps. The source disappears with the sound.
#![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(), ); } }
For high footstep rates, a pool of reusable sources is cheaper than spawning and despawning every step. The pool form keeps the entity count flat and avoids the per-spawn allocation churn.
Retained UI
Live Demos: Retained UI | Widget Gallery
The retained UI is a tree of widgets stored as ECS entities. Every widget is an entity with components for layout, content, color, interaction, and (where applicable) widget-specific data. The tree is built once at startup and persists across frames. Per-frame work is reacting to events and mutating components. Layout, hit testing, and rendering run inside the retained-UI sub-schedule.
Two construction surfaces are available. UiTreeBuilder and UiNodeBuilder give explicit control over every component. The Ui scope, opened with tree.build_ui(parent, |ui| { ... }), wraps common widget shapes in one-call shortcuts. The scope is sugar over the builder and the two compose freely.
Enabling retained UI
#![allow(unused)] fn main() { fn initialize(&mut self, world: &mut World) { world.resources.retained_ui.enabled = true; let camera = spawn_ortho_camera(world, vec2(0.0, 0.0)); world.resources.active_camera = Some(camera); let mut tree = UiTreeBuilder::new(world); // ... build widgets ... tree.finish(); } }
tree.finish() is the canonical end-of-build call. The retained-UI schedule runs every frame after that.
Two construction styles
Low-level builder
Each tree.add_node() returns a UiNodeBuilder that takes a chain of setters. The chain ends in .entity(), which produces the constructed Entity. Parent scope is managed with tree.in_parent(entity, |tree| { ... }). The closure form pushes the entity as the active parent for the body and restores the previous parent on return, which makes mismatched push/pop impossible.
#![allow(unused)] fn main() { let mut tree = UiTreeBuilder::new(world); let root = tree .add_node() .boundary(Rl(vec2(0.0, 0.0)), Rl(vec2(100.0, 100.0))) .fg(ThemeColor::Background) .entity(); tree.in_parent(root, |tree| { let card = tree .add_node() .size(100.pct(), 200.px()) .card() .entity(); tree.in_parent(card, |tree| { tree.add_node() .size(100.pct(), 24.px()) .label("Hello", 16.0, ThemeColor::Text); }); }); tree.finish(); }
High-level scope
tree.build_ui(parent, |ui| ...) opens a Ui scope rooted at parent. Each widget call (ui.button, ui.label, ui.slider, and so on) returns the constructed Entity. Containers like ui.row, ui.column, and ui.collapsing_header accept a closure for their children.
#![allow(unused)] fn main() { let mut tree = UiTreeBuilder::new(world); let root = tree.add_node().fill().entity(); tree.build_ui(root, |ui| { ui.heading("Settings"); ui.separator(); let volume = ui.slider(0.0, 1.0, 0.8); let muted = ui.toggle(false); ui.row(|ui| { ui.button("Cancel"); ui.button("Apply"); }); }); tree.finish(); }
The two styles mix. Drop into the low-level builder with ui.tree() when one node needs fine control, then return to the scope for the rest.
Sizing
A node's flow-child size is set with .size(width, height). Both arguments are Length values built from the LengthExt trait on numeric literals.
| Length | Meaning |
|---|---|
100.pct() | 100% of parent's content axis |
24.px() | 24 logical pixels |
1.5.em() | 1.5 times the current font size |
50.vw() / 50.vh() | 50% of the viewport width or height |
Length::Auto | Content-driven, measured by the engine and fed back |
#![allow(unused)] fn main() { .size(100.pct(), 24.px()) // full width, fixed height .size(200.px(), Length::Auto) // fixed width, content height .fill_width() // shorthand for size(100.pct(), 0.px()) .fill() // shorthand for size(100.pct(), 100.pct()) .size_px(48.0, 48.0) // shorthand for size(48.px(), 48.px()) }
Absolute positioning (window-anchored or boundary-anchored layouts) still uses the unit constructors Ab, Rl, Rw, Rh, Em, Vp, Vw, Vh directly, often combined with +.
#![allow(unused)] fn main() { .window_at(Ab(vec2(16.0, 16.0)), Ab(vec2(220.0, 18.0))) .window(Rl(vec2(50.0, 50.0)), Ab(vec2(120.0, 36.0)), Anchor::Center) .boundary(Ab(vec2(20.0, 20.0)), Ab(vec2(-20.0, -20.0)) + Rl(vec2(100.0, 100.0))) }
window_at defaults to Anchor::TopLeft. Pass window directly for a different anchor.
Theme-bound colors
Theme-aware colors track the active theme and crossfade smoothly when the user switches themes. They live on UiNodeColor-bearing entities and are set per-state.
#![allow(unused)] fn main() { node.fg(ThemeColor::Text) // base color .bg(ThemeColor::Panel) // alias for fg, semantic only .fg_hover(ThemeColor::TextStrong) // hover state .fg_pressed(ThemeColor::TextStrong) .fg_focused(ThemeColor::Accent) .with_theme_border_color(ThemeColor::Border) .with_theme_effect_role(ThemeEffect::PanelEffect) }
Raw (non-theme-tracking) colors use .color_raw::<S>(vec4). The _raw suffix flags theme-broken usage at the call site so it stands out in review.
#![allow(unused)] fn main() { .color_raw::<UiBase>(vec4(0.0, 0.0, 0.0, 0.5)) // backdrop tint .color_raw::<UiHover>(vec4(0.7, 0.5, 0.0, 1.0)) // accent override }
Roles live in ThemeColor: Background, BackgroundHover, BackgroundActive, Panel, PanelHover, Border, Text, TextDisabled, TextStrong, Accent, AccentHover, AccentActive, Success, Warning, Error, plus a handful more.
Composite shortcuts
#![allow(unused)] fn main() { .card() // rect + border + Panel bg + PanelEffect .label(text, size, role) // with_text + text_left + fg(role) .rect(8.0) // with_rect with no border, transparent border .fill_width() // size(100.pct(), 0.px()) .auto_height() // size(_, Length::Auto) .text_left()/.text_center()/.text_right() }
Use them when they fit. Drop to the explicit setters when they do not.
Pointer events default off
pointer_events defaults to false. Static labels, decorative rects, and panel children do not intercept clicks. Interaction is opt-in.
#![allow(unused)] fn main() { node.with_interaction() // enables pointer_events automatically .with_tooltip("Save") .with_cursor_icon(CursorIcon::Pointer) .fg_hover(ThemeColor::TextStrong) }
A node that blocks pointer events without an interaction component (the rare case is a modal backdrop) calls .with_pointer_events() directly.
Flow containers
A node becomes a flow container as soon as any flow setter is called on it.
#![allow(unused)] fn main() { .flow_vertical() // direction: vertical, padding: 0, gap: 0 .flow_horizontal() .padding(8.0) // pad inside the container .gap(4.0) // gap between children .align_main(FlowAlignment::Start | Center | End) .align_cross(FlowAlignment::Start | Center | End) .flow_wrap() // wrap to a new line when children overflow }
Children take their main-axis size from their own .size(...) (or from flex_grow).
#![allow(unused)] fn main() { let row = tree.add_node() .fill_width() .flow_horizontal() .padding(0.0) .gap(12.0) .align_cross(FlowAlignment::Center) .entity(); tree.in_parent(row, |tree| { tree.add_node().size(60.px(), 18.px()).label("Volume", 14.0, ThemeColor::TextDisabled); let slider = tree.add_node().fill_width().flex_grow(1.0).entity(); tree.in_parent(slider, |tree| { tree.add_slider(0.0, 1.0, 0.5); }); }); }
The "static label on the left, slot on the right" row is common enough that tree.label_row(label, label_width, body) is a one-call shortcut for it.
Reading widget state
Widget state is read directly from world resources via free functions.
#![allow(unused)] fn main() { fn run_systems(&mut self, world: &mut World) { if let Some(value) = ui_slider_value(world, self.volume_slider) { self.volume = value; } if ui_clicked(world, self.save_button) { self.save(); } } }
Event-style consumption drains ui_events.
#![allow(unused)] fn main() { for event in ui_events(world).to_vec() { match event { UiEvent::ButtonClicked(entity) if entity == self.save_button => self.save(), UiEvent::SliderChanged { entity, value } if entity == self.volume_slider => { self.volume = value; } UiEvent::TextInputSubmitted { entity, .. } if entity == self.search => { self.run_search(world); } _ => {} } } }
UiEvent covers buttons, sliders, range sliders, drag values, toggles, checkboxes, radios, tabs, dropdowns, multi-selects, text submits, modal close results, virtual list interactions, context menu picks, command palette picks, drag-and-drop, and tile rearrangement.
Mutating widgets
#![allow(unused)] fn main() { ui_set_text(world, label_entity, "New label"); ui_set_visible(world, panel_entity, true); ui_set_disabled(world, slider_entity, true); ui_focus(world, search_input); ui_progress_bar_set_value(world, progress_entity, 0.75); ui_show_toast(world, "Saved", ToastSeverity::Success, 2.0); ui_show_modal(world, dialog_entity); ui_show_command_palette(world, palette_entity); }
Text-cursor and text-content updates on input widgets use the dedicated helpers (ui_text_input_set_value, ui_text_area_set_value, and so on).
Named entities
Instead of carrying an Option<Entity> field for every widget that will later be mutated, register a name during construction and look it up later.
#![allow(unused)] fn main() { let progress = tree.add_progress_bar(0.0).named("loading_progress"); // ...elsewhere: if let Some(progress) = ui_named_entity(world, "loading_progress") { ui_progress_bar_set_value(world, progress, 0.5); } }
Names are unique strings stored in RetainedUiState::accessibility.named_entities. They cost nothing to add and the registry stays small.
Container scoping
Three ways to enter and leave a parent. Pick whichever fits the call site.
| Use | API | Notes |
|---|---|---|
| Add many children to a single parent | `tree.in_parent(entity, | tree |
| RAII guard form | let _g = tree.children_of(entity); | Restores on drop |
| Inside a node-builder chain | `.with_children( | child |
The legacy tree.push_parent(entity) and tree.pop_parent() calls still exist. New code uses the closure forms.
Responsive layout
Three engine primitives drive responsiveness.
- The breakpoint resource at
world.resources.retained_ui.viewport.breakpointis recomputed each frame from the logical viewport width.Compactis below 720,Mediumis 720 to 1024, andWideis 1024 and above. - Wrap-aware text measurement. Text nodes with
.with_text_wrap()get measured against their available width during layout, so containers can size to fit them. - Declarative responsive bindings. Per-entity overrides for size, boundary corners, and visibility, applied automatically when the breakpoint changes.
#![allow(unused)] fn main() { let sidebar = tree.add_node() .boundary(Ab(vec2(0.0, 0.0)), Ab(vec2(220.0, 0.0)) + Rl(vec2(0.0, 100.0))) .responsive_position_2_at( UiBreakpoint::Compact, Ab(vec2(64.0, 0.0)) + Rl(vec2(0.0, 100.0)), ) .fg(ThemeColor::Panel) .entity(); let nav_label = tree.add_node() .label("Inspector", 14.0, ThemeColor::Text) .responsive_visible_at(UiBreakpoint::Compact, false) .entity(); }
ui_responsive_apply_system runs before layout each frame, captures the construction-time values as the wide baseline on first apply, and swaps to overrides as the breakpoint changes.
Layout primitives
Three base layouts are mutually exclusive on a node.
- Boundary,
.boundary(p1, p2). A two-corner box computed against the parent rect. The standard fill-with-padding pattern. - Window,
.window(pos, size, anchor)or.window_at(pos, size). Anchored placement at an explicit position with an explicit size. Suited to absolute overlays. - Solid,
.solid(aspect, mode, alignment). Aspect-ratio-fitted, useful for images.
A node that is a child of a flow container omits all three. It is positioned by the parent's flow logic and sized by .size(width, height).
Floating panels and docked panels
#![allow(unused)] fn main() { let palette = tree.add_floating_panel( "command_palette", "Commands", Rect { min: vec2(40.0, 60.0), max: vec2(440.0, 380.0) }, ); let inspector = tree.add_docked_panel_right("inspector", "Inspector", 320.0); let status_bar = tree.add_docked_panel_bottom("status", "", 24.0); }
super::panel_content(tree, panel) returns the content child of a panel for appending into. Panel chrome (header, title, close button) is created automatically.
Dialogs and command palette
#![allow(unused)] fn main() { let dialog = tree.add_modal_dialog("Custom Modal", 380.0, 220.0); let content = widget::<UiModalDialogData>(tree.world_mut(), dialog) .map(|d| d.content_entity) .unwrap(); tree.build_ui(content, |inner| { inner.heading("Are you sure?"); inner.label("This cannot be undone."); }); ui_show_modal(world, dialog); let confirm = tree.add_confirm_dialog("Quit", "Save changes before quitting?"); // ConfirmDialog fires UiEvent::ModalClosed { entity, confirmed: bool } when dismissed. let palette = tree.add_command_palette(8); ui_command_palette_register(world, palette, "Save", "Ctrl+S", "File"); ui_show_command_palette(world, palette); // Fires UiEvent::CommandPaletteSelected { entity, command_index }. }
Layout units in detail
Position and size values are UiValue<Vec2> (or UiValue<f32> for a single axis), built from unit wrappers and combined with +.
| Unit | Meaning |
|---|---|
Ab(value) | Absolute pixels |
Rl(value) | Relative to parent, both axes, 0 to 100 |
Rw(value) / Rh(value) | Relative to parent width or height only |
Em(value) | Multiples of the current font size |
Vp(value) | Viewport percentage, square |
Vw(value) / Vh(value) | Viewport-width or viewport-height percentage |
#![allow(unused)] fn main() { Rl(vec2(100.0, 0.0)) + Ab(vec2(-32.0, 24.0)) }
Theming
Themes are first-class. Select the active theme, switch at runtime, drop in the built-in dropdown.
#![allow(unused)] fn main() { world.resources.retained_ui.theme_state.select_theme(2); ui.theme_dropdown(); }
A 250 ms crossfade animates every theme-bound color when the active theme changes. Raw colors set through color_raw::<S>(...) do not track the change. That is the cost of opting out.
Frame schedule
The retained-UI sub-schedule (world.resources.schedules.retained_ui) runs once per frame and contains, in order: input sync, gamepad navigation, layout picking, widget interaction, event bubble, click events, layout state update, docked panel layout, responsive apply, layout compute, theme transition tick, theme apply, color blend, transform blend, render sync. Apps do not call these manually. The main schedule's RETAINED_UI entry runs the whole sub-schedule.
Patterns to follow
- Build the tree once in
initialize, not per frame. UI state persists across frames. - Read state via free functions (
ui_clicked,ui_slider_value). Do not hand-track. - Prefer
.named(name)over anOption<Entity>field for cross-function lookup. - Prefer theme-bound colors (
.fg,.bg,.fg_hover) over raw colors. - Use
tree.in_parent(entity, |tree| { ... })for parent scope. The manual push and pop pair is legacy. pointer_eventsdefaults tofalse. Opt in with.with_interaction()(which enables them) on anything clickable.
See also
- Automated UI Testing for
UiTestDriverand integration-test patterns. - The widget gallery at
apps/galleryfor a comprehensive working example of every widget.
Screen-Space Text
Live Demo: HUD Text
Screen-space text covers HUD labels, scores, and on-screen debug output. Every text entity carries a Text component plus an entry in the TextCache resource that holds the rendered string. The same component drives 3D world-space text and billboard text, with the difference being whether a transform and a billboard flag are attached.
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)); } }
That call spawns a screen-space text entity at the given pixel offset with default TextProperties.
Text properties
TextProperties is the appearance struct. Pass it through spawn_ui_text_with_properties to customize size, color, alignment, outline, and spacing.
#![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, ); }
Horizontal alignment is TextAlignment::{Left, Center, Right}. Vertical alignment is VerticalAlignment::{Top, Middle, Bottom, Baseline}.
Positioning
The position argument is a Vec2 in screen-space pixels.
#![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 lives in TextCache, keyed by the text_index on the Text component. Updating means writing the new string into the cache and marking the component dirty. The text pass picks up the change on the next frame.
#![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; } } } }
Outlines
A black outline around white text reads well over any background. outline_width is the outline thickness in screen pixels and outline_color is the outline RGBA.
#![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 input string produce multiple lines. TextProperties::line_height is the multiplier on the font's natural 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 TTF or OTF 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
World-space text uses the same Text component plus a transform, so it exists at a position in world coordinates rather than screen pixels.
#![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 always faces the camera. It is the same world-space text plus the billboard flag set.
#![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
#![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
Terrain is a regular mesh generated from a noise-based heightmap. The engine builds a grid of vertices at configurable resolution, samples a noise function at each vertex for its height, computes per-vertex normals from the surrounding triangle faces, and registers the result in the mesh cache. The terrain entity then renders through the standard mesh pipeline, with full PBR material support, shadow casting, and physics collision.
Mesh generation
generate_terrain_mesh builds the terrain in four steps.
-
Vertex generation. A grid of
resolution_x * resolution_zvertices, centered at the origin. X coordinates run from-width/2to+width/2and Z from-depth/2to+depth/2. Each vertex's Y coordinate is the noise sample multiplied byheight_scale. UV coordinates are the normalized grid position multiplied byuv_scale, which controls how textures tile across the surface. -
Index generation. Two triangles per grid cell, counter-clockwise. The two triangles for cell
(x, z)use indices[top_left, bottom_left, top_right]and[top_right, bottom_left, bottom_right]. Total indices:(resolution_x - 1) * (resolution_z - 1) * 6. -
Normal calculation. Per-vertex normals are accumulated from the face normals of every adjacent triangle. Each face normal is the cross product of two triangle edges. After accumulation, normals are normalized to unit length.
-
Bounding volume. An oriented bounding box is computed from the min and max heights, used for frustum culling during rendering.
Noise sampling
The terrain uses the noise crate with four algorithms.
| NoiseType | Algorithm | Character |
|---|---|---|
Perlin | Fbm | Smooth rolling hills |
Simplex | Fbm | Similar to Perlin with fewer directional artifacts |
Billow | Billow | Rounded, cloud-like features |
RidgedMulti | RidgedMulti | Sharp ridges, suited to mountains |
All four are wrapped in multi-octave fractional Brownian motion. fBm layers multiple noise samples at increasing frequency and decreasing amplitude. octaves is the number of layers. More octaves add finer detail at higher evaluation cost. 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 attaches a static rigid body with a heightfield collider. The heightfield shape stores a 2D grid of height values plus 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 friction 0.9, restitution 0.0, and every collision group 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
sample_terrain_height evaluates the noise function directly, without consulting the mesh. The cost is bounded by the octave count.
#![allow(unused)] fn main() { let height = sample_terrain_height(x, z, &config); }
Place objects on the terrain surface by sampling the height at their XZ position and writing it back into the transform.
#![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 renders through MeshPass, the same standard mesh pipeline used for every other mesh. That means terrain gets the full PBR pipeline.
- Directional and point light shadows from cascaded shadow maps
- Screen-space ambient occlusion (SSAO)
- Screen-space global illumination (SSGI)
- Image-based lighting (IBL)
- Normal mapping when a normal texture is supplied on the material
- The full Cook-Torrance BRDF with metallic-roughness workflow
The mesh is uploaded to GPU vertex and index buffers once at creation. During rendering, MeshPass performs frustum culling against the terrain's bounding volume, then draws it with the material's textures bound.
Entity components
spawn_terrain creates an entity with the following components.
| Component | Purpose |
|---|---|
NAME | "Terrain" |
LOCAL_TRANSFORM | Position in world |
GLOBAL_TRANSFORM | Computed world matrix |
LOCAL_TRANSFORM_DIRTY | Triggers transform update |
RENDER_MESH | References cached mesh |
MATERIAL_REF | PBR material |
BOUNDING_VOLUME | OBB for frustum culling |
CASTS_SHADOW | Enabled by default |
RIGID_BODY | Static physics body |
COLLIDER | HeightField shape |
VISIBILITY | Visible by default |
Particle Systems
Live Demo: Fireworks
The particle system is entirely GPU-driven. Up to 1,000,000 particles are simulated and rendered without per-frame CPU involvement. Three compute shaders (reset, update, spawn) handle simulation. A fourth render pass draws the alive particles as camera-facing billboards. All four stages operate on GPU storage buffers.
GPU simulation pipeline
Each frame runs four stages.
-
Reset. One workgroup. Clears the alive count and the draw command's instance count to zero.
-
Update.
max_particles / 256workgroups. For each alive particle, the shader addsdelta_timeto its age, marks it dead and pushes its index onto the free list onceageexceedslifetime, applies gravity (velocity += gravity * delta_time) and drag (velocity *= (1 - drag * delta_time)), applies turbulence from a curl-noise field built fromsimplex_noise_3d()spatial derivatives (a divergence-free vector field that swirls particles without breaking volume), integrates the position, interpolates size and color between start and end values byage / lifetime, and pushes the particle onto the alive list with an atomic increment of the draw counter. -
Spawn. One workgroup per emitter with 256 threads each. Each thread atomically decrements the free list to allocate a slot, seeds an RNG from
particle_index * 1973 + time * 10000 + spawn_index * 7919 + emitter_index * 6997, generates a spawn offset from the emitter shape (point, sphere, cone, or box), applies the velocity spread as a random cone angle around the emission direction, samples the color gradient att=0.15andt=0.9for the lifetime interpolation endpoints, and writes the initial position, velocity, color, lifetime, size range, gravity, drag, turbulence, and texture index. -
Render. Camera-facing billboard quads drawn through
draw_indirectwith the alive count. The vertex shader generates 6 vertices per particle (two triangles) using camera right and up basis vectors extracted from the inverse view matrix. The fragment shader either runs a procedural shape or samples a texture.
Procedural particle shapes
The fragment shader generates several built-in shapes mathematically.
| Shape | Algorithm |
|---|---|
| Firework glow | Stacked exponential falloffs with coefficients 120, 40, 15, 6, 2.5 |
| Fire | Vertically stretched (y *= 0.65) with core, flame, and outer glow layers |
| Smoke | Gaussian soft circle with coefficient 4.0 |
| Spark | Tight bright core with a steep exponential falloff |
| Star | Cosine-based pointiness with adjustable sharpness |
Blending modes
Two render pipelines handle the two particle classes.
Alpha blending (SrcAlpha, OneMinusSrcAlpha) is for standard particles like smoke. Additive blending (SrcAlpha, One) is for emissive particles like fire and sparks. The additive path accumulates brightness and interacts with HDR bloom. The additive fragment shader boosts color through hdr_color + hdr_color^2 * 0.3.
Both pipelines disable depth writes (particles are transparent) but keep depth testing on with GreaterEqual for reversed-Z.
Memory management
Particle slots are managed with a GPU-side free list. Dead particles push their index onto the free list with an atomic. Spawning particles pop indices off the free list with an atomic. The lock-free pattern handles millions of spawn and 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
A color gradient is a sorted list of normalized-time color stops. The update shader interpolates between them by age / 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
ParticleEmitter provides over 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 so spawn counts accumulate.
#![allow(unused)] fn main() { fn run_systems(&mut self, world: &mut World) { update_particle_emitters(world); } }
For continuous emitters, the system adds spawn_rate * delta_time to an accumulator. For one-shot bursts, it writes 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. The array has 64 slots at 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 or higher) to use a custom texture instead of a procedural shape.
Capacity
| Limit | Value |
|---|---|
| Maximum particles | 1,000,000 |
| Maximum emitters | 512 |
| Texture slots | 64 |
| Texture slot size | 512 x 512 |
| Compute workgroup size | 256 |
Navigation Mesh
Live Demo: NavMesh
A navigation mesh is a set of convex polygons (usually triangles) covering the walkable surfaces of a level. Instead of testing every world point for walkability, AI agents pathfind through connected triangles and then smooth the resulting path through shared edges. The nightshade navmesh combines Recast generation, three pathfinding algorithms, funnel-based path smoothing, and an agent movement system with local avoidance.
NavMesh generation
Generation runs the Recast pipeline through the rerecast crate. The pipeline has 13 steps.
- Build trimesh. Collect every mesh vertex and index from the scene.
- Mark walkable triangles. Classify each triangle by slope against the walkable threshold.
- Create heightfield. Rasterize the scene into a voxel grid at the configured cell size and height.
- Rasterize triangles. Project each walkable triangle into the heightfield.
- Filter obstacles. Remove low-hanging obstacles, ledge spans, and low-height spans agents cannot traverse.
- Compact heightfield. Convert to a more efficient representation for region building.
- Erode walkable area. Shrink walkable areas by the agent radius so agents do not clip walls.
- Build distance field. Compute the distance from each cell to the nearest boundary.
- Create regions. Group connected cells into regions with watershed partitioning.
min_region_size(default 8) filters tiny regions andmerge_region_size(default 20) combines small adjacent ones. - Build contours. Trace region boundaries into simplified contour polygons controlled by
max_simplification_error. - Convert to polygon mesh. Triangulate contours into convex polygons up to
max_vertices_per_polygon(default 6). - Generate detail mesh. Add interior detail vertices for height accuracy, controlled by
detail_sample_distanddetail_sample_max_error. - 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 straight-line distance to the goal. It finds the optimal path efficiently by prioritizing nodes closer to the destination.
Dijkstra explores nodes ordered by g only, ignoring direction to the goal. It explores more nodes but guarantees the shortest path even with complex cost functions.
Greedy best-first explores nodes ordered by h only, ignoring path cost. It is the fastest of the three and the only one of the three that can miss the optimal path. The failure case is concave obstacles.
All three operate on the triangle adjacency graph. Edges connect triangles that share an edge, and costs are the distances between triangle centers.
Funnel algorithm
Raw paths through the navmesh are sequences of triangle centers, which zig-zag unnecessarily. The funnel algorithm produces straight, natural-looking paths in three steps.
- Portal collection. Extract the shared edges (portals) between consecutive triangles in the path.
- Funnel narrowing. Maintain a funnel of left and right boundaries that starts wide at the first portal. As portals are consumed, the funnel narrows. When a portal would flip the funnel inside-out, emit the funnel apex as a waypoint and restart from there.
- Simplification. Remove waypoints that do not improve the path within an epsilon tolerance (collinear-point removal).
The result is the shortest path through the triangle corridor that does not cross any triangle boundaries.
Agent movement
Five systems run each frame inside run_navmesh_systems.
- Triangle tracking. Find which navmesh triangle each agent occupies using point-in-triangle tests with barycentric coordinates.
- Path processing. Agents in
PathPendingstate get their path computed, smoothed, and simplified. The state transitions toMovingorNoPath. - Local avoidance. Repulsive forces between nearby agents (within
agent_radius * 2.5) prevent crowding. The avoidance velocity is blended at 25% with the primary movement direction. - Movement. Advance agents along waypoints at their configured speed. Sample the navmesh height at each new position using barycentric interpolation for vertical alignment. The maximum step height is 1.0 unit, which prevents teleporting through terrain.
- 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); } }
NavMesh configuration
#![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 the surface), agent paths as yellow lines between waypoints, the current path segment as a cyan line from agent to next waypoint, waypoints as orange crosses, and the destination as a magenta diamond marker with a vertical pole.
NavMeshWorld resource
The generated navmesh lives 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, used for portal detection.
- Adjacency.
HashMap<usize, Vec<NavMeshConnection>>mapping each triangle to its neighbors with traversal costs. - Spatial hash. A grid-based spatial index for fast point-in-triangle queries.
Grass System
Live Demo: Grass
The grass system renders up to 500,000 blades through a multi-stage compute and render pipeline. Each blade is a 7-vertex triangle strip generated in the vertex shader from per-instance data held in GPU storage buffers. Wind animation, character interaction, distance LOD, and Kajiya-Kay anisotropic specular all run on the GPU.
Rendering pipeline
Each frame runs five stages.
-
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 through
strength * (1 + velocity_length * 0.3). Previous-frame bend values decay at a configurable rate, which produces persistent trails. The map is double-buffered (ping-pong) to avoid read-write hazards. -
Bend sampling. Compute,
instances / 256workgroups. Each grass instance samples the bend map at its world position to get an XZ displacement, written into the instance buffer'sbendfield. -
Reset. Compute, one workgroup. Atomically resets the indirect draw command's instance count to zero.
-
Culling. Compute,
instances / 256workgroups. 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 four configurable thresholds. Statistical culling uses a hash of the instance ID:hash(id) > density_scaleskips the blade. Surviving blades are appended to a visible-index buffer via atomic operations, capped at 200,000. -
Render. Triangle-strip rendering with an indirect draw against the culled instance count. Each blade produces 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
A 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, which gives a natural forward lean.
Wind animation
Wind is a multi-layered sine wave in the vertex shader.
- Base wave:
sin(position.x * frequency + time * speed)at the configured strength. - Gust layer: a 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 that covers the grass region. When an interactor (an entity carrying the GrassInteractor component) moves through the grass, three things happen each frame.
- The compute shader samples each texel's distance to each interactor.
- Within the interactor's radius, a smooth-step falloff produces a bend direction away from the interactor.
- The bend accumulates with the existing value (from previous frames). A decay rate gradually returns it to zero, producing a visible recovery trail.
In the vertex shader, the bend displacement is applied with quadratic falloff against the height factor. The base barely moves while the tip receives full displacement.
Kajiya-Kay specular
The fragment shader uses Kajiya-Kay anisotropic specular, originally developed for hair. Instead of a standard Phong or GGX highlight, the model uses the blade's tangent direction to produce elongated highlights that run perpendicular to the blade, which is how light actually reflects off thin strands.
Subsurface scattering
Light passing through a grass blade creates a bright rim when the sun is behind the blade relative to the camera. The fragment shader computes a subsurface contribution from the dot product between view direction and negated sun direction, with edge fade for natural falloff. The sss_color and sss_intensity per-species parameters control the appearance.
Distance fade
Blades past 180 m begin alpha fading and reach full transparency at 200 m (smoothstep). Blade tips have a separate fade (smoothstep 0.9 to 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
GrassSpecies carries the visual parameters for a blade type.
#![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
A single grass region accepts multiple species with weighted distribution.
#![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
Attach a grass interactor to an entity (a player or NPC) and the bend map picks up its motion.
#![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 with distance.
| LOD Level | Default Distance | Default Density |
|---|---|---|
| 0 | 0-20m | 100% |
| 1 | 20-50m | 60% |
| 2 | 50-100m | 30% |
| 3 | 100-200m | 10% |
Transitions are smooth because culling compares a per-instance hash against the density threshold. No popping.
Shadow casting
Grass casts shadows through a separate shadow depth shader (grass_shadow_depth.wgsl). The shadow shader generates simplified blades at fixed curvature 0.3, with the same wind animation, projected into light space. It writes only depth, not color.
Capacity
| Limit | Value |
|---|---|
| Maximum blades | 500,000 |
| Maximum visible per frame | 200,000 |
| Vertices per blade | 7 (triangle strip) |
| Maximum species | 8 |
| Maximum interactors | 16 |
| Heightmap resolution | 256 x 256 |
| Bend map resolution | 128 x 128 |
Lines Rendering
Live Demo: Lines
The lines system draws debug lines and wireframes. It is a GPU-driven pipeline that uses instanced rendering and compute-based frustum culling. Instead of submitting geometry per line, every line's data lives in a GPU storage buffer and the renderer draws one instance per line using a two-vertex line primitive.
The two-vertex trick
The vertex buffer has exactly two vertices: position [0, 0, 0] and position [1, 0, 0]. Every line in the scene reuses the same two vertices through instancing. The vertex shader uses instance_index to look up the actual line data (start, end, color) from the storage buffer, then uses the vertex'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;
Rendering 100,000 lines costs 2 vertices in GPU memory regardless of count. All line data lives in a storage buffer that grows dynamically: starting at 1,024 lines, doubling on overflow, capped at 1,000,000.
GPU frustum culling
A compute shader (line_culling_gpu.wgsl) runs before the render pass to decide 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 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 and WASM, 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. Each bounding volume produces 12 edge lines. The shader computes the 8 OBB corners using quaternion rotation in local space, transforms all corners to world space via the entity's model matrix, and writes 12 edge lines (the box wireframe) into the line buffer at a pre-allocated offset.
Normal visualization lines
A third compute shader (normal_lines.wgsl) draws mesh surface normals. For each vertex, it transforms the vertex position to world space using the model matrix, transforms the normal using the upper-left 3x3 of the model matrix (the normal matrix), computes the endpoint by extending along the normal by the configured length, and writes one 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 every entity with LINES | GLOBAL_TRANSFORM | VISIBILITY, 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 through 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, } }
version is a dirty counter. push(), clear(), and mark_dirty() all increment it. The renderer reads the counter to skip re-uploading line data that has not changed.
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 through a compute shader. Toggle it with the resource flag.
#![allow(unused)] fn main() { world.resources.graphics.gpu_culling_enabled = true; }
With culling enabled, only visible lines are drawn. The compute shader writes 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 a factor of 2 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 slots into the render graph as a geometry pass that writes to scene_color and depth. Each frame the execution order is:
- Generate bounding volume lines (compute shader, 64 threads per workgroup, 12 lines per bounding volume).
- Generate normal visualization lines (compute shader, 256 threads per workgroup, 1 line per vertex).
- Frustum cull all lines (compute shader, 256 threads per workgroup, outputs indirect draw commands).
- Render visible lines (instanced
LineListprimitive with alpha blending andGreaterEqualdepth test for reversed-Z).
The render pipeline uses wgpu::PrimitiveTopology::LineList, draws indices 0 and 1 per instance, and routes each instance to the correct line data through first_instance in the indirect draw command. The fragment shader is a simple color passthrough.
Picking System
Live Demo: Picking
Picking is the problem of answering "what entity is under the cursor, and where on its surface is the cursor hitting." The CPU answer is to raycast through the scene. Build a ray from the camera through the cursor pixel, intersect it against every entity's bounding volume to get candidates, then against every triangle of every candidate for the closest hit. The math is well understood and the cost scales with scene size and triangle count.
Nightshade ships two picking paths. The fast one tests rays against bounding volumes. The precise one rasterizes triangle meshes through Rapier's SharedShape::trimesh and casts rays against them. Both produce a PickingResult with entity, distance, and world position. There is also a GPU-driven path that reads the entity-id and depth textures the renderer is already writing, covered in the picking article and in the renderer source.
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 of the active camera, and unprojects through it.
For a perspective camera, the ray origin is the camera position. A clip-space point at z = 1.0 (the reversed-Z near plane) unprojects to the world direction.
For an orthographic camera, both near (z = 1.0) and far (z = 0.0) clip-space points are unprojected. The ray origin is the near point and 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
The fast path tests the ray against every entity's bounding volume. For each entity with a BoundingVolume component, the routine transforms the bounding volume by the entity's global transform, early-rejects with a bounding-sphere test (project center onto ray, check distance), then tests the OBB for a precise intersection distance. Invisible entities are skipped via the Visibility component by default.
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
#![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
For pixel-precise picking, register entities for trimesh picking. The registration 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 lives in the PickingWorld resource (a Rapier ColliderSet with entity-to-handle mappings).
#![allow(unused)] fn main() { use nightshade::ecs::picking::commands::*; register_entity_for_trimesh_picking(world, entity); }
For hierarchies (a parent with child meshes), one call walks the subtree.
#![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; } }
The call casts a Rapier ray against every registered trimesh collider using shape.cast_ray(), returning the time of impact for each intersection.
Updating transforms
A pickable entity that moves needs its collider repositioned.
#![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
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. Each entity's bounding-sphere center is projected into clip space and tested against NDC bounds, accounting for the sphere radius in NDC space.
#![allow(unused)] fn main() { let visible = pick_entities_in_frustum(world, &entity_list); }
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; } } } }
Choosing between paths
Bounding volume picking is the right default. Its cost is bounded by the number of entities with bounding volumes, the math per entity is a sphere reject plus an OBB intersect, and there is no GPU dependency. It is fine for selection where the hit only needs to identify the entity, not the precise surface point.
Trimesh picking is the right call when the hit needs to be pixel-accurate against the rendered geometry. The cost is a Rapier raycast against every registered collider, so it scales with registration count rather than entity count. Entities have to be explicitly registered and their colliders updated when they move.
The GPU picking path (see the picking demo and the renderer source) is the right call when the renderer is already drawing the things to pick and the application can tolerate one frame of readback latency. It reuses the entity-id and depth textures the mesh pass already produces and gives world position plus surface normal in one readback. Lines, decals, and post-process effects are not drawn through the mesh shader, so the GPU path cannot pick them. The bounding-volume and trimesh paths cover those cases.
Effects Pass
Live Demo: PSX Retro Effects
The EffectsPass is a configurable post-processing pass with 38 shader parameters. It covers distortions, color grading, raymarched overlays, retro effects, and pattern overlays. Effects can be combined and animated for music visualizers, stylized games, and creative applications.
Overview
The EffectsPass is a render graph node that runs the rendered scene through a fullscreen shader. The shader reads from the input slot, applies whatever effects are enabled in the uniform block, and writes to the output slot. Defaults are "input" and "output".
Setup
Create the shared state handle, then add the pass to the 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, ) { 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)); } }
Modifying effects
The state handle lets you mutate effect parameters from anywhere in the application.
#![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() { state.uniforms.chromatic_aberration = 0.02; state.uniforms.vignette = 0.3; state.enabled = true; 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
Color grading applies a preset LUT-style transform.
#![allow(unused)] fn main() { uniforms.color_grade_mode = ColorGradeMode::Cyberpunk as f32; }
Available modes.
| Mode | Value | Description |
|---|---|---|
None | 0 | No color grading |
Cyberpunk | 1 | Teal and magenta, high contrast |
Sunset | 2 | Warm orange and purple tones |
Grayscale | 3 | Black and white |
Sepia | 4 | Vintage brown tones |
Matrix | 5 | Green tinted, digital look |
HotMetal | 6 | Heat 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
Raymarched 3D effects blend over the scene at a configurable opacity.
#![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.
| Mode | Value | Description |
|---|---|---|
Off | 0 | No raymarching |
Tunnel | 1 | Infinite tunnel flythrough |
Fractal | 2 | 2D fractal pattern |
Mandelbulb | 3 | 3D mandelbulb fractal |
PlasmaVortex | 4 | Swirling plasma effect |
Geometric | 5 | Repeating 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 layer freely. A few cohesive 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
Wire AudioAnalyzer outputs straight into effect uniforms.
#![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() { state.uniforms.chromatic_aberration = self.analyzer.smoothed_bass * 0.05; state.uniforms.glitch_intensity = self.analyzer.snare_decay * 0.5; state.uniforms.zoom_pulse = self.analyzer.kick_decay * 0.3; state.uniforms.hue_rotation = self.time * self.analyzer.intensity * 0.2; if self.analyzer.is_dropping { state.uniforms.screen_shake = self.analyzer.drop_intensity * 0.1; } else { state.uniforms.screen_shake *= 0.9; } 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 and output slots
The default slots are "input" and "output". Override them at construction.
#![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
The enabled flag bypasses the shader. When disabled, the pass performs a simple blit with no effects applied.
#![allow(unused)] fn main() { if let Ok(mut state) = state_handle.write() { state.enabled = false; } }
Auto-animate hue
animate_hue rotates the hue uniform continuously from elapsed time.
#![allow(unused)] fn main() { if let Ok(mut state) = state_handle.write() { state.animate_hue = true; } }
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; self.analyzer.analyze_at_time(self.time); if let Some(state_handle) = &self.effects_state { if let Ok(mut state) = state_handle.write() { state.uniforms.vignette = 0.3; state.uniforms.crt_scanlines = 0.15; 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; 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... 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
| Parameter | Range | Default | Description |
|---|---|---|---|
time | 0.0+ | 0.0 | Elapsed time (auto-updated) |
chromatic_aberration | 0.0-0.1 | 0.0 | RGB channel offset |
wave_distortion | 0.0-1.0 | 0.0 | Sinusoidal screen warp |
color_shift | 0.0-1.0 | 0.0 | Global color offset |
kaleidoscope_segments | 0-16 | 0.0 | Mirror segment count |
crt_scanlines | 0.0-1.0 | 0.0 | Scanline intensity |
vignette | 0.0-1.0 | 0.0 | Edge darkening |
plasma_intensity | 0.0-1.0 | 0.0 | Plasma overlay strength |
glitch_intensity | 0.0-1.0 | 0.0 | Digital glitch amount |
mirror_mode | 0.0-1.0 | 0.0 | Screen mirroring |
invert | 0.0-1.0 | 0.0 | Color inversion |
hue_rotation | 0.0-1.0 | 0.0 | Hue shift amount |
raymarch_mode | 0-5 | 0.0 | Raymarch effect type |
raymarch_blend | 0.0-1.0 | 0.0 | Raymarch overlay blend |
film_grain | 0.0-1.0 | 0.0 | Noise grain intensity |
sharpen | 0.0-1.0 | 0.0 | Edge sharpening |
pixelate | 0-64 | 0.0 | Pixel size (0=off) |
color_posterize | 0-16 | 0.0 | Color quantization |
radial_blur | 0.0-1.0 | 0.0 | Center blur amount |
tunnel_speed | 0.0-5.0 | 1.0 | Tunnel animation speed |
fractal_iterations | 1-8 | 4.0 | Fractal detail level |
glow_intensity | 0.0-1.0 | 0.0 | Bloom-like glow |
screen_shake | 0.0-0.5 | 0.0 | Camera shake offset |
zoom_pulse | 0.0-1.0 | 0.0 | Rhythmic zoom amount |
speed_lines | 0.0-1.0 | 0.0 | Motion line intensity |
color_grade_mode | 0-6 | 0.0 | Color grading preset |
vhs_distortion | 0.0-1.0 | 0.0 | VHS tape wobble |
lens_flare | 0.0-1.0 | 0.0 | Light flare intensity |
edge_glow | 0.0-1.0 | 0.0 | Edge highlight amount |
saturation | 0.0-2.0 | 1.0 | Color saturation |
warp_speed | 0.0-1.0 | 0.0 | Hyperspace stretch |
pulse_rings | 0.0-1.0 | 0.0 | Expanding ring effect |
heat_distortion | 0.0-1.0 | 0.0 | Heat shimmer amount |
digital_rain | 0.0-1.0 | 0.0 | Matrix rain effect |
strobe | 0.0-1.0 | 0.0 | Flash intensity |
color_cycle_speed | 0.0-5.0 | 1.0 | Auto color animation rate |
feedback_amount | 0.0-1.0 | 0.0 | Frame feedback blend |
ascii_mode | 0.0-1.0 | 0.0 | ASCII art conversion |
Steam Integration
Requires feature:
steam
The Steam integration covers achievements, stats, friends, peer-to-peer messages, rich presence, and overlays. The whole surface lives on world.resources.steam. It is desktop only, gated behind the steam feature so wasm and headless builds do not have to pull in steamworks-sys.
Feature flag
[dependencies]
nightshade = { version = "...", features = ["steam"] }
Enabling steam pulls in steamworks and steamworks-sys. The crate links against the prebuilt Steamworks libraries those crates bundle, so there is no extra system dependency to install.
Initialization
initialize connects to the running Steam client and primes the internal state. Call it once at startup, from the State::initialize hook or wherever your game does setup:
#![allow(unused)] fn main() { fn initialize(&mut self, world: &mut World) { world.resources.steam.initialize(); } }
Steam runs its own callback pump. The engine does not poll it for you. run_callbacks drains pending events such as friend list updates and incoming P2P messages, and it has to run every frame to keep those queues from backing up:
#![allow(unused)] fn main() { fn run_systems(&mut self, world: &mut World) { world.resources.steam.run_callbacks(); } }
If Steam is not running or the user is not signed in, initialize records the failure and is_initialized() returns false. Subsequent calls become no-ops rather than panicking. Check is_initialized() before showing Steam-specific UI.
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; } }
unlock_achievement and clear_achievement take the API name configured in the Steamworks partner portal. refresh_achievements fetches the current achievement list from Steam into steam.achievements. Read the field, do not call out per achievement, because each call to Steam has a fixed overhead and the engine keeps the cached list in sync.
Stats
Stats are typed. Integer counters use set_stat_int, floating-point values use set_stat_float. Writes stay client-side until store_stats flushes them to Steam:
#![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) => { /* ... */ } } } }
The pair of store_stats and refresh_stats is the I/O boundary. Set the values you care about, flush once, then refresh when you need to read them back.
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; } }
persona_state is one of Online, Offline, Busy, Away, Snooze, LookingToTrade, LookingToPlay. The same caching rule applies. Call refresh_friends when you want to repopulate the list, then iterate the cached steam.friends vector from your UI code.
P2P networking
The networking surface mirrors Steamworks. Each message carries a sender, a payload, and a channel number, and channels are independent queues:
#![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; } }
setup_networking_callbacks wires up the Steam callbacks that let other peers connect to you. Call it once at startup, after initialize. receive_messages drains the queue for a single channel and returns whatever has arrived since the last call.
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 are None, Connecting, Connected, ClosedByPeer, ProblemDetected, and Failed. close_session is the polite hang-up. The other side observes ClosedByPeer on its next refresh. ProblemDetected and Failed are terminal and indicate the connection cannot be recovered without rebuilding it.
Rich presence
#![allow(unused)] fn main() { steam.set_rich_presence("status", "In Battle - Level 5"); steam.clear_rich_presence(); }
Rich presence keys are arbitrary strings. The status key is the conventional name for the line that appears next to a friend in their friend list.
Overlays
#![allow(unused)] fn main() { steam.open_invite_dialog(); steam.open_overlay_to_user(friend_steam_id); }
These functions ask the Steam client to draw its overlay on top of the game window. The overlay is part of the Steam client process, not the engine, so it requires the user to have the overlay enabled in their Steam settings.
Platform notes
The Steam client has to be running on the user's machine. The integration is desktop only and not exposed on wasm. When Steam is unavailable, every method on the resource becomes a no-op rather than a panic, so a build that ships with the feature enabled still runs on a machine without Steam installed. Guard Steam-specific UI on is_initialized() so the game stays usable in that case.
File System
The nightshade::filesystem module is a thin cross-platform I/O surface. On native it wraps the rfd crate for file dialogs and std::fs for reading and writing. On WebAssembly it routes through the browser, using Blob anchors for saves and <input type="file"> for loads. The function signatures are the same on both targets so calling code can avoid #[cfg] gates.
The trade-off is that the WASM side cannot expose a PathBuf. The browser sandbox does not give the page a filesystem path, only a name and a byte buffer. The cross-platform functions return LoadedFile (name plus bytes) and the path-based functions are native-only.
Feature requirements
| Function | Native | WASM |
|---|---|---|
save_file | file_dialog feature | always available |
request_file_load | file_dialog feature | always available |
pick_file | file_dialog feature | not available |
pick_folder | file_dialog feature | not available |
save_file_dialog | file_dialog feature | not available |
read_file | file_dialog feature | not available |
write_file | file_dialog feature | not available |
The engine aggregate feature includes file_dialog by default.
Types
FileFilter
A filter entry describes a single named extension group. The dialog renders one entry per filter in its type dropdown. On WASM the filters become the accept attribute on the underlying <input type="file">.
#![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
The error type returned by the path-based read and write functions:
FileError::NotFound(String)is a missing file at the given path.FileError::ReadError(String)is a read that failed after open.FileError::WriteError(String)is a write that failed after open.
FileError implements Display, so the error string formats cleanly for log output or UI.
LoadedFile
The shape of a file that has been read into memory. name is the original filename. bytes is the raw contents. The WASM target cannot supply a path, which is why the cross-platform load surface returns this struct rather than PathBuf plus Vec<u8>.
#![allow(unused)] fn main() { pub struct LoadedFile { pub name: String, pub bytes: Vec<u8>, } }
PendingFileLoad
A handle for an in-flight load. On native the file is read synchronously inside request_file_load and the handle is ready by the time you receive it. On WASM the browser hands the bytes back after the user chooses a file, which is one or more frames later, so the handle is initially empty and becomes ready when the read completes.
#![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>; } }
take returns the file once and only once. Subsequent calls return None.
Cross-platform functions
These two functions compile on both native and WASM. Use them when a single code path is the goal.
save_file
save_file opens a save dialog on native and triggers a browser download on WASM. The first argument is the suggested filename, the second is the byte buffer to write, the third is the filter list:
#![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
request_file_load opens a file picker and returns a PendingFileLoad. On native the read happens before the handle returns. On WASM the read happens after the user selects a file. The polling loop is the same in both cases:
#![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); if let Some(loaded) = pending.take() { println!("Loaded {} ({} bytes)", loaded.name, loaded.bytes.len()); } }
Native-only functions
These are available only when targeting native with the file_dialog feature. They return PathBuf so workflows that track filesystem paths (loading assets by path, building a recent-files list, opening adjacent files) can use them directly.
pick_file
Opens a file picker and returns the chosen path, or None if the dialog was cancelled.
#![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. Returns the chosen folder path, or None if cancelled.
#![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 and returns the path the user picked, without writing anything to it. The caller writes the bytes themselves. The second argument is an optional default filename suggestion.
#![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 at the given path into a byte vector. Returns FileError::NotFound if the file does not exist, FileError::ReadError if the read fails after open.
#![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 at the given path. Parent directories are created if they do not exist, so callers do not have to mkdir before writing into a fresh subtree.
#![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
The cross-platform load is asynchronous on WASM, so the natural shape is to store the PendingFileLoad in your state and poll it each frame. This pattern is identical on native and WASM and needs no #[cfg] gates:
#![allow(unused)] fn main() { struct MyApp { pending_load: Option<nightshade::filesystem::PendingFileLoad>, } self.pending_load = Some(nightshade::filesystem::request_file_load(&filters)); 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); } } }
The native path resolves on the first poll. The WASM path resolves whenever the browser finishes reading the file. Caller code is the same in both cases.
Tutorial: Building a 3D Game
This tutorial builds a 3D Pong game from an empty project. The end state has two paddles, a bouncing ball, an AI opponent, scoring, pause and unpause, and a game-over screen, all rendered in 3D with PBR materials. The point of going through it is not Pong. It is to see how a complete game is structured in Nightshade, from window setup to game logic to syncing visuals from logic back into the ECS.
The pattern that emerges is the dual-world pattern. Game state lives in your own struct. The ECS holds rendered entities. Each frame, game logic mutates the struct, then a sync pass writes the new positions back to the ECS transforms. This separation keeps the ECS surface small and keeps game logic in plain Rust where it is easiest to reason about.
Project setup
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"
The engine aggregate feature pulls in transforms, materials, lighting, and audio. wgpu selects the wgpu backend.
Step 1: The empty window
A Nightshade application is a struct that implements the State trait and a single call to launch:
use nightshade::prelude::*; struct PongGame; impl State for PongGame { fn initialize(&mut self, world: &mut World) { world.resources.window.title = "Pong".to_string(); 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, runs initialize once, then drives the game loop and calls run_systems every frame. The camera sits at (0, 0, 15) looking toward the origin. A directional light is spawned so that the PBR materials have something to react to. The result is an empty scene with the engine's debug grid floor.
Step 2: Game constants and state
The game data goes in your state struct. The engine does not own any of it. Constants describe the arena and the speed of moving objects:
#![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 fields fall into two groups. The first is the simulation state, positions and velocities and scores, all plain f32. The second is the bag of Option<Entity> handles that link game objects to their ECS-side render entity. The struct knows where the ball is mathematically. The ECS knows where the ball mesh is drawn. The sync pass is what closes the gap.
Step 3: Spawning game objects
The paddles, the ball, and the walls are each a mesh entity with a material. The helper spawn_colored_mesh wraps the engine's spawn_mesh with material registration so each call yields a single ready-to-render entity:
#![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. The material is inserted into the global registry under a unique name derived from the entity id, then linked to the entity via MaterialRef. Material names are global, so using the entity id as the suffix avoids collisions when many entities share the spawn path.
Step 4: Ball movement and reset
The ball moves in a straight line until it hits something. Reset assigns a random angle within ninety degrees so the serve direction varies:
#![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; } } }
world.resources.window.timing.delta_time is the duration of the last frame in seconds. Multiplying velocity by delta time before adding it to position is what makes movement frame-rate independent. A faster machine renders more frames with smaller deltas. A slower machine renders fewer frames with larger deltas. The ball moves the same distance per second on either.
Step 5: Input and AI
The player owns the left paddle. W and S, or the up and down arrow keys, move it. The AI tracks the ball's vertical position with a bit of dead zone so it does not jitter when the ball is already aligned:
#![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); } } }
is_key_pressed reports whether a key is currently held down. It is the polling form, not the event form, which is what we want for continuous motion. The AI runs at three quarters of the player's speed, which is enough handicap that a competent player can win.
Step 6: Collision detection
Three kinds of collision happen. The ball hits the top or bottom wall, in which case the Y velocity flips. The ball hits a paddle, in which case the X velocity flips and the Y velocity gets nudged based on where on the paddle the ball landed. The ball passes a paddle entirely, in which case the other side scores:
#![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; } } } } }
The hit offset modifies the bounce angle so the player can steer the ball. A center hit barely changes the angle. An edge hit sends the ball off at a steeper angle. After modifying Y velocity, normalize_ball_speed rescales the velocity vector back to BALL_SPEED so the ball does not gradually accelerate.
The wall and paddle collisions overwrite the X or Y component to its absolute value rather than negating the current sign. The reason is that two-frame fast-moving balls can end up inside a wall on a single tick. Clamping the sign in one direction guarantees forward progress out of the wall on the next frame.
Step 7: Syncing visuals
Game state has changed. The ECS does not know yet. The sync pass writes the new positions into the local transform of each render entity and marks the transform dirty so the engine recomputes the global transform hierarchy:
#![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 is the call that wires the local transform change into the engine's transform propagation pass. Without it, the global transform stays stale and the rendered mesh does not move. This is the only piece of the sync pass that is not a plain field write.
Step 8: The game loop
The trait implementation pulls all the systems together. initialize sets the window title, configures the camera, and spawns the scene. run_systems runs each frame in order. on_keyboard_input handles one-shot key events:
#![allow(unused)] fn main() { impl State for PongGame { fn initialize(&mut self, world: &mut World) { world.resources.window.title = "Pong".to_string(); 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(), _ => {} } } } } }
The order inside run_systems is input, AI, ball movement, collision, visual sync. The pause and game-over flags gate the simulation half but not the visual sync, so the game keeps rendering the static scene while paused. is_key_pressed is the right tool for held keys like W and S. on_keyboard_input is the right tool for one-shot keys like space and R, where the state should change once per press and not repeatedly while the key is held.
Step 9: 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(); } } }
Reset is trivial because the game state lives in plain fields. There is no ECS bookkeeping to undo. The render entities stay where they are. The next visual sync writes the zeroed positions into their transforms.
Key patterns demonstrated
| Pattern | Where Used |
|---|---|
| State trait lifecycle | initialize, run_systems, on_keyboard_input |
| Entity spawning | spawn_mesh plus material registration |
| Frame-rate independent movement | velocity * delta_time |
| Input polling | keyboard.is_key_pressed() for held keys |
| One-shot input events | on_keyboard_input for press and release |
| Transform updates | get_local_transform_mut and mark_local_transform_dirty |
| Game state separation | Logic in struct fields, visuals in ECS |
Where to go next
The foundation here generalizes. The same shape of state struct, systems list, and visual sync works for any small game. The next steps that extend it:
- Physics. Replace the hand-written collision with Rapier rigid bodies and colliders. See Physics Overview.
- Audio. Add sound effects on paddle hits and scores with
AudioSourceentities. See Audio System. - 3D models. Replace cubes with glTF models loaded via
import_gltf_from_bytes. See Meshes & Models. - Particles. Add a spark burst on each paddle collision. See Particle Systems.
- Materials. Give the ball an emissive material so it glows. See Materials.
Minimal Example
The smallest Nightshade application is a struct that implements State, a single call to launch from main, and three lines of setup in initialize. Anything beyond that is a feature the application chose to add.
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) }
That is the whole program. A window with a cube, a sun, a fly camera, and an empty game loop.
Step-by-step breakdown
1. Import the prelude
#![allow(unused)] fn main() { use nightshade::prelude::*; }
The prelude re-exports the common surface. The State trait, the World struct, the Entity type, math types (Vec3, Vec4, Mat4), component flag constants (LOCAL_TRANSFORM, RENDER_MESH, and friends), and the helper spawn functions used in this file. Importing the prelude is the convention.
2. Define the state struct
#![allow(unused)] fn main() { struct MinimalGame; }
The state struct holds the game's own data. It can be empty for a demo with no state, like this one. For a real game it would hold whatever the game needs:
#![allow(unused)] fn main() { struct MinimalGame { score: u32, player: Option<Entity>, enemies: Vec<Entity>, } }
The engine does not own this data. It owns the World. The state struct lives on the user side of the boundary.
3. Implement the State trait
#![allow(unused)] fn main() { impl State for MinimalGame { fn initialize(&mut self, world: &mut World) { // Called once at startup } } }
Every method on State is optional. The methods present on the trait:
| Method | Purpose |
|---|---|
initialize | One-shot setup at startup |
run_systems | Game logic every frame |
on_keyboard_input | Key press and release events |
on_mouse_input | Mouse button events |
on_gamepad_event | Gamepad events |
configure_render_graph | Custom rendering hook |
next_state | State transition hook |
Implement the ones you need. Skip the rest.
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); } }
spawn_camera creates a camera entity at the given position with a default perspective projection. Setting it as the active camera tells the renderer which one to draw from. spawn_cube_at and spawn_sun are convenience wrappers that produce a cube mesh entity and a directional light entity respectively.
5. Run the application
fn main() -> Result<(), Box<dyn std::error::Error>> { nightshade::launch(MinimalGame) }
launch does the rest. It creates the window, initializes the wgpu renderer, calls initialize on the state once, then enters the game loop. Each frame, it polls input, calls run_systems, and renders. The function does not return until the window closes.
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 meaningful here. Debug builds spend most of their frame time inside math routines that the optimizer collapses to nothing in release.
Controls
The fly camera is the engine's default debug camera. Standard controls:
- WASD moves horizontally.
- Space and Shift move up and down.
- Mouse looks around.
- Escape releases the cursor.
Extending the example
Add more objects
A loop is enough to fill the scene with cubes:
#![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
Add a field to track time, then rotate an entity each frame based on its accumulated value:
#![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(), ); } } } } }
The cube spins around the Y axis at one radian per second.
Add input handling
on_keyboard_input fires on press and release. Match on the key and the state:
#![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), _ => {} } } } } }
This is the event form. For continuous input (movement keys held down), use world.resources.input.keyboard.is_key_pressed() inside run_systems instead.
What's next
From this foundation, the natural next steps:
- Add physics with rigid bodies and colliders.
- Load 3D models with
import_gltf_from_pathandspawn_prefab_with_animations. - Add skeletal animation.
- Add audio with Kira.
The other examples in this section show each of these in a complete program.
First Person Game
A first-person template with a physics character controller, mouse look, weapon attachment, footsteps, and a simple hit-and-push interaction with dynamic crates. Every piece is in the single source file below. The text after the code explains the load-bearing parts.
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 is a capsule with a velocity that the physics step pushes around. Configure the shape and the kinematic parameters once at spawn:
#![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; } }
Each frame, the movement code writes the desired horizontal velocity into controller.velocity.x and controller.velocity.z. The physics step resolves collisions against the level geometry and updates the entity's transform.
Camera setup
The first-person camera is parented to the player body:
#![allow(unused)] fn main() { world.core.set_parent(camera, Parent(Some(player))); }
The parent relationship is what makes the camera follow the player. There is no per-frame copy. The transform propagation pass walks the hierarchy once and the camera's global transform falls out of the player's translation plus the camera's local offset.
Weapon attachment
The weapon is parented to the camera, not the player, so it stays in the lower-right corner of the view regardless of where the player is looking:
#![allow(unused)] fn main() { world.core.set_parent(weapon, Parent(Some(camera))); }
Mouse look
Yaw rotation goes on the player body. Pitch goes on the camera. Splitting the two axes onto different entities is what prevents gimbal-lock-looking artifacts when the camera tilts up or down:
#![allow(unused)] fn main() { transform.rotation = yaw * transform.rotation; transform.rotation = transform.rotation * pitch; }
The player body rotates around its own Y axis, which is always world-vertical. The camera then pitches relative to the body. The order of multiplication is significant. Yaw is left-multiplied (world-space rotation around vertical), pitch is right-multiplied (local-space rotation around the camera's own right axis).
Physics spawning
Two helpers cover the common cases. Static objects (floors, walls) never move, never accumulate forces, and are cheap. Dynamic objects (crates, props) participate in the full physics simulation and need a mass.
#![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); }
The fire-weapon path uses RigidBodyType::Dynamic as the gate before applying impulse, so static geometry shrugs off shots and only the crates respond.
Cargo.toml
[package]
name = "fps-game"
version = "0.1.0"
edition = "2024"
[dependencies]
nightshade = { git = "...", features = ["engine", "wgpu", "physics"] }
Third Person Game
A third-person template with an orbit camera, character animation hooks, melee combat, a dodge, and camera-relative movement. The shape is the same as the first-person example, with a player entity for physics and a camera entity that follows. The differences live in the camera positioning, the player rotation, and the player state machine.
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 position is computed from spherical coordinates around the player. Yaw and pitch are the two angles. Distance is the radius:
#![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 drives yaw. Mouse Y drives pitch. Scroll wheel drives distance. Pitch is clamped to roughly plus or minus seventy degrees so the camera never flips upside down at the poles.
Camera-relative movement
The player's WASD input is interpreted relative to the camera, not the world. The camera's forward and right vectors get projected to the horizontal plane, then the input vector is rotated by them:
#![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; }
W always moves the player away from the camera regardless of which way the camera is facing. This is the standard third-person convention and is what players expect.
Character rotation
The character does not snap to the new direction. It slerps toward the target rotation over multiple frames:
#![allow(unused)] fn main() { transform.rotation = nalgebra_glm::quat_slerp( &transform.rotation, &target_rotation, dt * 10.0, ); }
The factor dt * 10.0 gives a turn time that feels responsive without looking robotic. The smaller it gets, the more the character drifts before turning. The larger, the closer to instant snap.
Animation blending
State transitions cross-fade into the new animation rather than cutting:
#![allow(unused)] fn main() { animation_player.blend_to(animation_name, 0.2); }
0.2 is the blend duration in seconds. Most action-game transitions land somewhere between 0.1 and 0.3. Shorter feels jarring, longer feels floaty.
State machine
A few input handlers gate on the current state so input cannot interrupt mid-action. Attacks and dodges run to completion before movement resumes:
#![allow(unused)] fn main() { if self.player_state == PlayerState::Attacking || self.player_state == PlayerState::Dodging { return; } }
The timers in update_player_state flip the state back to Idle when the action duration elapses. Movement input is then accepted again.
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. The scene is a floor, four walls, a sun, and whatever the user spawns into it. Left-click spawns the current primitive (cube, sphere, cylinder, chain, ragdoll). Right-click grabs an object and throws it on release. Middle-click deletes the object under the crosshair. F detonates a radial explosion. G toggles gravity. R clears every dynamic body.
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
The single-shape spawners cover cubes, spheres, and cylinders. Each takes a position, a shape parameter (size for cubes, radius for spheres, half-height and radius for cylinders), a mass, and a material:
#![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); }
Dynamic bodies fall under gravity and respond to forces. Static bodies are immovable and serve as level geometry.
Joint systems
The chain is a column of small capsules connected by spherical joints, with the top capsule pinned to the world. Gravity pulls the bottom links down. The joint constraints stop them from drifting apart.
The ragdoll is six box body parts (torso, head, two arms, two legs) connected to the torso by spherical joints. Drop it and it crumples in a believable way because each joint resolves its own angular constraint while the bodies fall.
Joints are created through the SphericalJoint builder, with one local anchor on each end:
#![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))); }
The anchor on each side is the point on that body's local frame where the joint attaches.
Object manipulation
Right-click grabs the object the camera is pointed at. While held, update_held_object drives the body's linear velocity toward a point five units in front of the camera, so the object follows the cursor without snapping. Releasing the button hands the object a forward impulse, which throws it.
Middle-click deletes the closest object under the crosshair via despawn_entities.
Physics effects
The explosion is a radial loop. Every dynamic body within ten units of the explosion center receives an impulse along the vector from the center to the body, scaled by distance falloff so closer bodies fly farther. The gravity toggle flips between Earth gravity and zero gravity by writing into world.resources.physics.gravity. The reset path queries every dynamic body and despawns it in a single batch.
Cargo.toml
[package]
name = "physics-playground"
version = "0.1.0"
edition = "2024"
[dependencies]
nightshade = { git = "...", features = ["engine", "wgpu", "physics"] }
Feature Flags
Nightshade is split into Cargo features so you can compile only the parts you use. Turning a feature off drops its dependencies and shortens build times.
Default Features
The default set is ["engine", "wgpu"]:
[dependencies]
nightshade = { git = "https://github.com/matthewjberger/nightshade.git" }
That gives you the engine with the wgpu backend. You only need to set features explicitly when adding optional ones or stripping the build down.
Aggregate Features
engine (default)
The everything-included build for making a game.
nightshade = { git = "...", features = ["engine"] }
Includes: runtime, assets, scene_graph, picking, file_dialog, terrain, screenshot, plus rand, rayon, ehttp, futures, and the WASM support crates (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
Rendering without asset loading. Use this for small apps that do not need glTF or image decoding.
nightshade = { default-features = false, features = ["runtime", "wgpu"] }
Includes: core, text, behaviors.
full
engine plus every optional feature.
nightshade = { git = "...", features = ["full"] }
Includes: engine, wgpu, shell, audio, physics, gamepad, navmesh.
Granular Features
These let you pick exactly which dependencies come along.
core
The foundation. ECS (freecs), math (nalgebra, nalgebra-glm), windowing (winit), time (web-time), graph (petgraph), tracing, UUIDs, and JSON serialization.
text
Text rendering via cosmic-text and swash. Requires core.
assets
Asset loading: gltf, image, half, bincode, serde_json, lz4 compression. Requires core.
scene_graph
Scene hierarchy with save and load. Requires assets.
terrain
Procedural terrain generation using noise and rand. Requires core.
file_dialog
Native file dialogs via rfd and dirs. Turns on 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.
screenshot
PNG screenshot writing via the image crate.
picking
Ray-based entity picking with bounding box intersection. Trimesh picking also requires physics.
file_watcher
File-watch support via notify. Provides FileWatcher for hot reloads. Native only, not available on WASM.
behaviors
Built-in behavior components and systems.
Rendering
wgpu (default)
The wgpu rendering backend, which targets DirectX 12, Metal, Vulkan, and WebGPU through one API.
Optional Features
shell
Marker feature for the developer console and its command registration.
nightshade = { git = "...", features = ["shell"] }
audio
Audio playback via Kira.
nightshade = { git = "...", features = ["audio"] }
Provides:
- Sound loading (WAV, OGG, MP3, FLAC)
- Volume, pitch, and panning controls
- Spatial 3D audio with distance attenuation
- Audio listener and source components
- Looping and one-shot playback
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 between fixed steps
- Trimesh picking when combined with
picking
Additional dependencies: rapier3d
gamepad
Gamepad and 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 in the 0.0 to 1.0 range
- Rumble and vibration
- Multiple connected gamepads
navmesh
AI navigation meshes 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 the mesh
- Debug visualization
Additional dependencies: rerecast, glam
grass
GPU grass rendering with wind, distance fade, and interaction.
nightshade = { git = "...", features = ["grass"] }
Platform Features
steam
Steamworks integration for achievements, stats, multiplayer, and friends.
nightshade = { git = "...", features = ["steam"] }
Additional dependencies: steamworks, steamworks-sys
windows-app-icon
Embeds 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
Trace files for chrome://tracing. Implies tracing.
Feature Combinations
Minimal Rendering App
nightshade = { default-features = false, features = ["runtime", "wgpu"] }
A small runtime build with no asset loading.
Standard Game
nightshade = { git = "...", features = ["physics", "audio", "gamepad"] }
The engine plus physics, audio, and gamepad input.
Open World Game
nightshade = { git = "...", features = [
"physics",
"audio",
"gamepad",
"navmesh",
"grass",
] }
Adds AI pathfinding and GPU grass on top of the standard set.
Feature Dependencies
Several features pull others in implicitly.
| Feature | Depends On |
|---|---|
engine | runtime, assets, scene_graph, picking, terrain, file_dialog, screenshot |
runtime | core, text, behaviors |
full | engine, wgpu, shell, audio, physics, gamepad, navmesh |
scene_graph | assets |
assets | core |
text | core |
terrain | core |
tracy | tracing |
chrome | tracing |
Checking Enabled Features
Use cfg attributes to gate code on a feature being on:
#![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 targets the platforms wgpu targets. Native Windows, Linux, and macOS, plus the web through WebGPU.
Supported Platforms
| Platform | Status | Backend | Notes |
|---|---|---|---|
| Windows 10/11 | Full Support | Vulkan, DX12 | Primary development platform |
| Linux | Full Support | Vulkan | X11 and Wayland |
| macOS | Full Support | Metal | Requires macOS 10.13+ |
| Web (WASM) | Full Support | WebGPU | Modern browsers only |
Windows
Requirements
- Windows 10 version 1903 or later for DX12
- Windows 10 version 1607 or later for Vulkan
- A GPU with Vulkan 1.1 or DirectX 12 support
Graphics Backends
Windows picks a backend in this order:
- Vulkan for the broadest feature coverage
- DirectX 12 as the native Windows path
- DirectX 11 as the fallback for older hardware
Building
cargo build --release
Distribution
The executable is self-contained. Ship the assets folder next to it:
game/
├── game.exe
└── assets/
├── models/
├── textures/
└── audio/
Linux
Requirements
- X11 or Wayland
- A GPU and driver supporting Vulkan 1.1
- Ubuntu 20.04+, Fedora 33+, or Arch Linux are common starting points
Dependencies
Install the 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
winit picks between X11 and Wayland based on the environment. You can force one:
# Force X11
WINIT_UNIX_BACKEND=x11 ./game
# Force Wayland
WINIT_UNIX_BACKEND=wayland ./game
Distribution
Bundle as an AppImage or wrap the binary in a launcher script:
#!/bin/bash
cd "$(dirname "$0")"
./game
macOS
Requirements
- macOS 10.13 (High Sierra) or later
- A Metal-capable GPU. Most Macs from 2012 onwards qualify.
Building
cargo build --release
Code Signing
Sign the binary before distribution:
codesign --deep --force --sign "Developer ID Application: Your Name" target/release/game
App Bundle
The expected layout for a macOS app:
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
- A browser with WebGPU
- Chrome 113+, Edge 113+, or Firefox 141+
Building
Install wasm-pack:
cargo install wasm-pack
Build the web bundle:
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
The web build is constrained by what browsers expose.
| Feature | Status |
|---|---|
| Rendering | Supported |
| Input | Supported |
| Audio | Supported (Web Audio) |
| Gamepad | Supported (Gamepad API) |
| Physics | Supported |
| File System | Limited, no direct disk access |
| Threads | Limited, requires SharedArrayBuffer |
Asset Loading
Assets have to be served over HTTP and fetched at runtime:
#![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 and target:
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 a Docker image:
# In WSL2
cargo build --release --target x86_64-unknown-linux-gnu
macOS Cross-Compilation
Cross-compiling to macOS needs the Apple SDK, which is awkward to source legally outside a Mac. The practical path is GitHub Actions macOS runners.
GPU Requirements
Minimum Requirements
| Feature | Requirement |
|---|---|
| API | Vulkan 1.1 / DX12 / Metal |
| VRAM | 2 GB |
| Shader Model | 5.0 |
Recommended Requirements
| Feature | Requirement |
|---|---|
| API | Vulkan 1.2+ |
| VRAM | 4+ GB |
| Shader Model | 6.0 |
Feature Support by GPU Generation
| GPU | Basic Rendering | Tessellation | Compute Culling |
|---|---|---|---|
| Intel HD 4000+ | Yes | Yes | Yes |
| NVIDIA GTX 600+ | Yes | Yes | Yes |
| AMD GCN 1.0+ | Yes | Yes | Yes |
| Apple M1+ | Yes | Yes | Yes |
Performance by Platform
Rough relative performance, higher is better.
| Platform | Performance | Notes |
|---|---|---|
| Windows (Vulkan) | 100% | Best overall |
| Windows (DX12) | 95% | A little more driver overhead |
| Linux (Vulkan) | 98% | Matches Windows with good drivers |
| macOS (Metal) | 90% | Different cost profile than Vulkan |
| Web (WebGPU) | 70% | Browser and JS bridge overhead |
Troubleshooting
Windows: "No suitable adapter found"
- Update GPU drivers
- Install the Vulkan Runtime
- Force the DX12 backend with
WGPU_BACKEND=dx12 ./game.exe
Linux: "Failed to create Vulkan instance"
- Install Vulkan drivers for your GPU
- Check that
vulkaninforuns - Verify the ICD loader sees them:
ls /usr/share/vulkan/icd.d/
macOS: "Metal not available"
- Update macOS to 10.13 or later
- Check Apple Menu > About This Mac > System Report > Graphics for Metal support
Web: "WebGPU not supported"
- Use Chrome 113+ or Edge 113+
- Enable the WebGPU flag in browser settings if needed
- Verify
navigator.gpuexists in the browser console
API Quick Reference
Lookup for the API names you reach for most often.
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 }
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 }
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, } }
Navigation
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 initialize(&mut self, world: &mut World) {} fn run_systems(&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 WgpuRenderer, world: &mut World) {} } }
Input, file-drop, gamepad, and lifecycle events arrive on world.resources.input.events as AppEvent values. Set world.resources.window.next_state from run_systems to switch states.
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
Recipes grouped by the thing you want to do. Each one stands on its own 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
Yaw on the player body, pitch on the camera. Parenting the camera to the player makes the camera follow without an extra system.
#![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; } } }
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 and despawning
Spawning and despawning has overhead. Pre-spawn a set of entities, hide them, and reuse them as needed.
#![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 is now expressed relative to the parent. The engine computes the GlobalTransform through 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
Terms that show up across the book and across game development generally.
A
Alpha Blending Rendering transparent surfaces by mixing the source color with the destination color, weighted by alpha.
Alpha Cutoff A threshold for alpha testing. Pixels with alpha below the threshold are discarded entirely.
Ambient Light A constant, directionless light applied to every surface. Stands in for indirect illumination.
Ambient Occlusion (AO) A darkening of creases and corners where ambient light would be blocked. See SSAO.
Animation Blending Interpolating transforms between two animations to produce a smooth transition.
Animation Clip A named animation like "walk", "run", or "idle" made of keyframed transforms.
Aspect Ratio Viewport width divided by viewport height. 16:9 is 1.777.
B
Billboard A flat quad oriented to face the camera each frame. Used for particles and distant objects.
Bind Pose The default pose of a skinned mesh before any animation is applied.
Bloom A post-processing effect that adds a glow around bright pixels.
Bone A joint in a skeletal hierarchy. Also called a joint.
C
Cascaded Shadow Maps (CSM) Several shadow maps at different resolutions covering different ranges of view distance.
CCD (Continuous Collision Detection) A physics technique that prevents fast-moving objects from passing through thin surfaces between simulation steps.
Character Controller A kinematic physics body specialized for player movement, with handling for stairs and slopes.
Collider A simplified shape used for collision detection. Box, sphere, capsule, and so on.
Component Data attached to an entity in an ECS. Holds state, never logic.
Culling Skipping objects during rendering because they are outside the view or hidden by other geometry.
D
Delta Time (dt)
The time between the previous frame and this one. Multiplying by dt keeps motion the same speed at any frame rate.
Depth Buffer (Z-Buffer) A texture storing the distance of each pixel from the camera. Used to decide which surface is closest.
Diffuse The base color of a surface, independent of view angle.
Dynamic Body A physics body that responds to forces, gravity, and collisions.
E
ECS (Entity Component System) An architecture where entities are IDs, components are data, and systems are functions over the components.
Emission/Emissive Light a surface produces on its own, independent of any external light source.
Entity An identifier that groups components together. Holds no data on its own.
Euler Angles Rotation expressed as three angles (pitch, yaw, roll). Subject to gimbal lock.
Exposure A brightness multiplier that mimics camera exposure settings.
F
Far Plane The maximum distance from the camera at which geometry is rendered. Anything past it is clipped.
Field of View (FOV) The angle of the visible area. Most games sit between 60 and 90 degrees.
Forward Rendering Rendering each object fully in one pass. Direct but expensive when many lights affect each object.
Frame One full update plus render cycle.
Frustum The truncated pyramid of space visible to a perspective camera.
G
G-Buffer A set of textures holding geometry data (normals, depth, albedo) used by deferred rendering.
Gimbal Lock Losing a rotation axis when two of three Euler axes line up. Quaternions sidestep the problem.
glTF/GLB A standard 3D model format. glTF is JSON with a separate binary blob. GLB packs both into a single file.
Global Transform The world-space transform after every parent's transform has been applied.
H
HDR (High Dynamic Range) Color values outside the 0 to 1 range. Lets lighting math go above white before tonemapping brings it back down.
Heightfield A 2D grid of height values representing terrain or any heightmap-shaped surface.
Hierarchy A parent-child tree of entities. Each child's transform is expressed relative to its parent.
I
Index Buffer A list of vertex indices that defines the triangles of a mesh. Lets a vertex be reused in many triangles.
Instancing Drawing many copies of the same mesh in a single draw call, varying per-instance data like transform.
Interpolation Blending between two values. Linear interpolation (lerp) is the simplest form.
J
Joint A constraint between two physics bodies that limits their relative motion.
K
Keyframe A value at a specific time in an animation. Values between keyframes are interpolated.
Kinematic Body A physics body moved by code. Pushes dynamic bodies but is not pushed back.
L
LDR (Low Dynamic Range) The standard 0 to 1 color range that displays understand.
Lerp (Linear Interpolation)
result = a + (b - a) * t for t between 0 and 1.
LOD (Level of Detail) Lower-detail meshes used for objects far from the camera.
Local Transform A transform expressed relative to the parent entity, or world space when there is no parent.
M
Material The data that describes how a surface looks. Color, roughness, metallic, textures, and so on.
Mesh Geometry stored as vertices and indices that form triangles.
Metallic A PBR parameter that goes between dielectric (0) and metal (1).
Mipmaps Smaller pre-filtered copies of a texture used when sampling at distance.
Morph Targets Per-vertex offsets that blend a mesh between shapes. Used for facial animation. Also called blend shapes.
N
NavMesh (Navigation Mesh) Simplified geometry that represents walkable areas for AI pathfinding.
Near Plane The minimum distance from the camera at which geometry is rendered. Closer geometry is clipped.
Normal Map A texture that encodes per-pixel surface direction. Fakes geometric detail without adding triangles.
O
Occlusion One object blocking another from being seen or lit.
Orthographic Projection A parallel projection. Distance does not change apparent size.
P
PBR (Physically Based Rendering) A material model derived from real-world light behavior. Produces consistent results across lighting setups.
Perspective Projection A projection in which distant objects appear smaller. Matches the way human vision works.
Pitch Rotation around the X axis. Looking up and down.
Point Light A light emitting outward equally in every direction from a point.
Prefab A pre-configured entity template that can be instantiated as many times as needed.
Q
Quaternion A four-component number that represents a rotation without gimbal lock. Interpolates smoothly.
Query A request for all entities that have a specific set of components.
R
Raycast Tracing a ray through space to find the first surface it hits.
Render Graph A declarative description of rendering passes and their dependencies.
Render Pass A single stage of rendering, such as a shadow pass, the main color pass, or a post-processing pass.
Rigid Body A non-deformable physics object. Can be dynamic, kinematic, or static.
Roll Rotation around the Z axis. Tilting sideways.
Roughness A PBR parameter that scatters specular reflection. 0 is a mirror. 1 is fully diffuse.
S
Skinning Deforming mesh vertices according to bone transforms. The mechanism behind skeletal animation.
Skybox A cubemap texture that surrounds the scene and stands in for the distant environment.
Slerp (Spherical Linear Interpolation) Interpolation between two quaternions at constant angular speed.
Specular Mirror-like reflection of light. Strength depends on view angle.
Spot Light A light that emits in a cone, like a flashlight.
SSAO (Screen-Space Ambient Occlusion) An approximation of ambient occlusion computed from the depth buffer in screen space.
Static Body A physics body that never moves. Used for floors, walls, and terrain.
System A function that operates on entities matching a given component query.
T
Tessellation Subdividing geometry into more triangles, usually at runtime on the GPU.
Texture A 2D image sampled across a 3D surface.
Tonemapping Mapping HDR values down to the LDR range a display can show.
Transform The position, rotation, and scale of an object in space.
Transparency See Alpha Blending.
Trimesh (Triangle Mesh) A collision shape that uses the actual mesh triangles. Accurate but costly.
U
UV Coordinates 2D texture coordinates that map texture pixels to mesh vertices.
Uniform A shader constant that holds the same value for every vertex or pixel in a draw call.
V
Vertex A point in 3D space carrying position, normal, texture coordinates, and similar attributes.
Vertex Buffer GPU memory that holds vertex data.
Vignette A post-processing effect that darkens the edges of the screen.
Vulkan A low-level graphics API. wgpu uses it on Windows and Linux.
W
WebGPU A graphics API designed for browsers. wgpu uses it for web targets and exposes a similar shape across native APIs.
World Space The single global coordinate system shared by every entity. Compare local space.
wgpu A Rust graphics library that gives a single API across Vulkan, DX12, Metal, and WebGPU.
Y
Yaw Rotation around the Y axis. Looking left and right.
Z
Z-Buffer See Depth Buffer.
Z-Fighting Flickering between two surfaces at nearly the same depth, caused by precision loss in the depth buffer.
Troubleshooting
Things that go wrong and how to fix them.
Compilation Errors
"feature X is not enabled"
A feature you depend on is missing from Cargo.toml. Add it to the feature list:
nightshade = { git = "...", features = ["engine", "wgpu", "physics", "audio"] }
See Feature Flags for the full set.
"cannot find function spawn_cube_at"
The free function is in the prelude. Import it:
#![allow(unused)] fn main() { use nightshade::prelude::*; }
"the trait State is not implemented"
Your game struct is missing one of the required State methods. The minimum is initialize and run_systems:
#![allow(unused)] fn main() { impl State for MyGame { fn initialize(&mut self, world: &mut World) { world.resources.window.title = "My Game".to_string(); } fn run_systems(&mut self, world: &mut World) {} } }
"nalgebra_glm vs glam conflict"
The engine uses nalgebra_glm everywhere. Mixing in glam produces type mismatches:
#![allow(unused)] fn main() { // Correct use nalgebra_glm::Vec3; // Wrong, glam::Vec3 is a different type use glam::Vec3; }
Runtime Errors
"No suitable adapter found"
The GPU does not expose any backend wgpu can talk to.
Windows:
- Update GPU drivers
- Install the Vulkan Runtime from https://vulkan.lunarg.com/
- Try forcing DX12 with
WGPU_BACKEND=dx12 ./game.exe
Linux:
- Install Vulkan drivers, for example
sudo apt install mesa-vulkan-drivers - Verify with
vulkaninfo
macOS:
- macOS 10.13 or later is required for Metal
- Check System Report > Graphics for Metal capability
"Entity not found"
You are reading or writing through a handle for an entity that was despawned or never existed. Check before using:
#![allow(unused)] fn main() { if world.has_entity(entity) { if let Some(transform) = world.core.get_local_transform(entity) { // Safe to use } } }
"Texture not found"
The path does not resolve to a file. Use relative paths under the asset directory rather than absolute paths:
#![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
A few common causes.
- No collider on the floor:
#![allow(unused)] fn main() { world.core.set_collider(floor, ColliderComponent::new_cuboid(50.0, 0.5, 50.0)); }
- Objects spawned overlapping the floor:
#![allow(unused)] fn main() { // Spawn above the floor, not at y=0 transform.translation = Vec3::new(0.0, 2.0, 0.0); }
- High velocity causing tunneling between simulation steps:
#![allow(unused)] fn main() { let mut body = RigidBodyComponent::new_dynamic(); body.ccd_enabled = true; world.core.set_rigid_body(entity, body); }
Animation not playing
- Animation name does not match anything on the clip:
#![allow(unused)] fn main() { if let Some(player) = world.core.get_animation_player_mut(entity) { for name in player.available_animations() { println!("Animation: {}", name); } } }
- The update is not being called:
#![allow(unused)] fn main() { fn run_systems(&mut self, world: &mut World) { update_animation_players(world, dt); } }
- The
AnimationPlayerlives on a child entity, not the model root:
#![allow(unused)] fn main() { 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
- No
AudioSourceentity has been spawned:
#![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()); }
- The
audiofeature is not enabled:
nightshade = { git = "...", features = ["engine", "wgpu", "audio"] }
- File format is unsupported. Use WAV, OGG, MP3, or FLAC.
Performance Issues
Low frame rate
- Entity count is high:
#![allow(unused)] fn main() { println!("Entities: {}", world.core.query_entities(RENDER_MESH).count()); }
- Disable expensive effects:
#![allow(unused)] fn main() { world.resources.graphics.ssao_enabled = false; world.resources.graphics.bloom_enabled = false; }
- Reduce shadow resolution:
#![allow(unused)] fn main() { world.resources.graphics.shadow_map_size = 1024; // Default is 2048 }
- Use a primitive collider instead of a trimesh:
#![allow(unused)] fn main() { world.core.set_collider(entity, ColliderComponent::new_cuboid(1.0, 1.0, 1.0)); }
Memory usage high
- Despawn entities you no longer need:
#![allow(unused)] fn main() { world.despawn_entities(&[entity]); }
- Drop unused textures:
#![allow(unused)] fn main() { world.resources.texture_cache.clear_unused(); }
- Use smaller textures on distant objects.
Stuttering or hitching
- Per-frame allocations cause garbage and cache misses. Iterate directly rather than collecting:
#![allow(unused)] fn main() { // Bad, allocates every frame let entities: Vec<Entity> = world.core.query_entities(LOCAL_TRANSFORM).collect(); // Good, iterates without allocation for entity in world.core.query_entities(LOCAL_TRANSFORM) { // ... } }
- Move asset and prefab loading into
initializeso the first frame already has everything ready:
#![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
The scene has no lighting or the ambient term is zero:
#![allow(unused)] fn main() { spawn_sun(world); world.resources.graphics.ambient_light = [0.1, 0.1, 0.1, 1.0]; }
Objects are too bright or washed out
Tonemapping or color grading is off. ACES is a good default:
#![allow(unused)] fn main() { world.resources.graphics.color_grading.tonemap_algorithm = TonemapAlgorithm::Aces; world.resources.graphics.color_grading.brightness = 0.0; }
Textures look wrong
-
Normal maps appear inverted. Some tools export Y-flipped normals. Check the export settings.
-
Wrong color space. Base color textures should be sRGB. Normal, metallic, and roughness textures should be linear.
-
UV origin mismatch. glTF uses top-left UV origin. Models authored against a different convention may need a UV flip.
Z-fighting (flickering surfaces)
Two surfaces sit too close together for the depth buffer to separate them.
#![allow(unused)] fn main() { // Pull the near plane out camera.near = 0.1; // Instead of 0.01 // Or separate the surfaces along the conflict axis floor_transform.translation.y = 0.0; decal_transform.translation.y = 0.01; }
WebAssembly Issues
"WebGPU not supported"
- Use Chrome 113+ or Edge 113+
- Firefox needs
dom.webgpu.enabledset inabout:config - Safari support is limited
Assets fail to load
WASM cannot read the filesystem directly. Assets have to be served over HTTP and fetched at runtime:
<script>
fetch('assets/model.glb')
.then(response => response.arrayBuffer())
.then(data => { /* use data */ });
</script>
Performance worse than native
This is normal. WebGPU and the JS bridge add overhead. Turn down quality settings for web builds:
#![allow(unused)] fn main() { #[cfg(target_arch = "wasm32")] { world.resources.graphics.ssao_enabled = false; world.resources.graphics.shadow_map_size = 512; } }
Getting Help
If you are stuck on something not covered here:
- Check the GitHub Issues
- Search for similar reports
- Open a new issue including:
- Nightshade version
- Platform (OS and GPU)
- The smallest code that reproduces the problem
- The error message or a screenshot