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.