Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. Generate bounding volume lines (compute shader, 64 threads per workgroup, 12 lines per bounding volume).
  2. Generate normal visualization lines (compute shader, 256 threads per workgroup, 1 line per vertex).
  3. Frustum cull all lines (compute shader, 256 threads per workgroup, outputs indirect draw commands).
  4. Render visible lines (instanced LineList primitive with alpha blending and GreaterEqual depth test for reversed-Z).

The render pipeline uses wgpu::PrimitiveTopology::LineList, draws indices 0 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.