3D Graphics with WebGL
The WebGL Rendering Pipeline
WebGL is a low-level graphics API that maps almost 1:1 to OpenGL ES 2.0. From Rust/Wasm, you control every stage of the rendering pipeline:
┌────────────────────────────────────────────────────────────────┐
│ WebGL Rendering Pipeline │
│ │
│ ┌──────────┐ ┌──────────────┐ ┌────────────────────────┐ │
│ │ Vertex │──►│ Vertex │──►│ Primitive Assembly │ │
│ │ Buffer │ │ Shader │ │ (triangles/lines) │ │
│ │ (data) │ │ (per vertex) │ │ │ │
│ └──────────┘ └──────────────┘ └───────────┬────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────┐ ┌──────────────┐ ┌────────────────────────┐ │
│ │ Frame │◄──│ Fragment │◄──│ Rasterization │ │
│ │ Buffer │ │ Shader │ │ (triangles → pixels) │ │
│ │ (screen) │ │ (per pixel) │ │ │ │
│ └──────────┘ └──────────────┘ └────────────────────────┘ │
└────────────────────────────────────────────────────────────────┘Setting Up WebGL from Rust
[dependencies.web-sys]
version = "0.3"
features = [
"HtmlCanvasElement",
"WebGlRenderingContext",
"WebGlProgram",
"WebGlShader",
"WebGlBuffer",
"WebGlUniformLocation",
]use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::{WebGlRenderingContext as GL, WebGlProgram, WebGlShader};
#[wasm_bindgen]
pub fn init_webgl(canvas_id: &str) -> Result<(), JsValue> {
let document = web_sys::window().unwrap().document().unwrap();
let canvas = document.get_element_by_id(canvas_id).unwrap()
.dyn_into::<web_sys::HtmlCanvasElement>()?;
let gl: GL = canvas.get_context("webgl")?
.unwrap().dyn_into()?;
gl.viewport(0, 0, canvas.width() as i32, canvas.height() as i32);
gl.clear_color(0.0, 0.0, 0.0, 1.0);
gl.enable(GL::DEPTH_TEST);
gl.clear(GL::COLOR_BUFFER_BIT | GL::DEPTH_BUFFER_BIT);
Ok(())
}Shaders
Shaders are small programs that run on the GPU. WebGL requires two types:
Vertex Shader
Runs once per vertex. Transforms 3D coordinates to screen coordinates:
attribute vec3 aPosition;
attribute vec3 aNormal;
uniform mat4 uModel;
uniform mat4 uView;
uniform mat4 uProjection;
varying vec3 vNormal;
varying vec3 vFragPos;
void main() {
vec4 worldPos = uModel * vec4(aPosition, 1.0);
vFragPos = worldPos.xyz;
vNormal = mat3(uModel) * aNormal; // transform normal
gl_Position = uProjection * uView * worldPos;
}Fragment Shader
Runs once per pixel. Determines the final color:
precision mediump float;
varying vec3 vNormal;
varying vec3 vFragPos;
uniform vec3 uLightDir;
uniform vec3 uLightColor;
uniform vec3 uObjectColor;
void main() {
// Ambient
float ambient = 0.1;
// Diffuse
vec3 norm = normalize(vNormal);
float diff = max(dot(norm, uLightDir), 0.0);
vec3 result = (ambient + diff) * uLightColor * uObjectColor;
gl_FragColor = vec4(result, 1.0);
}Compiling Shaders from Rust
fn compile_shader(gl: &GL, shader_type: u32, source: &str) -> Result<WebGlShader, String> {
let shader = gl.create_shader(shader_type)
.ok_or("Unable to create shader")?;
gl.shader_source(&shader, source);
gl.compile_shader(&shader);
if gl.get_shader_parameter(&shader, GL::COMPILE_STATUS).as_bool().unwrap_or(false) {
Ok(shader)
} else {
Err(gl.get_shader_info_log(&shader).unwrap_or_default())
}
}
fn link_program(gl: &GL, vert: &WebGlShader, frag: &WebGlShader) -> Result<WebGlProgram, String> {
let program = gl.create_program().ok_or("Unable to create program")?;
gl.attach_shader(&program, vert);
gl.attach_shader(&program, frag);
gl.link_program(&program);
if gl.get_program_parameter(&program, GL::LINK_STATUS).as_bool().unwrap_or(false) {
Ok(program)
} else {
Err(gl.get_program_info_log(&program).unwrap_or_default())
}
}Matrix Math: The Foundation of 3D
Every object in a 3D scene goes through three matrix transformations:
Model View Projection
Local Space ──────────► World ─────────► Camera ──────────► Screen
(object) Space Space (NDC)
MVP = Projection * View * Model| Matrix | Purpose | Example |
|---|---|---|
| Model | Position/rotate/scale the object | Rotate cube 45 degrees |
| View | Position the camera | Camera at (0, 2, 5) looking at origin |
| Projection | 3D → 2D (perspective) | 60 degree field of view |
Rotation Matrices
Rotation around each axis uses sine and cosine:
Rotate around Y-axis by θ:
┌ cos θ 0 sin θ 0 ┐
│ 0 1 0 0 │
│ -sin θ 0 cos θ 0 │
└ 0 0 0 1 ┘Perspective Projection
The perspective matrix creates the illusion of depth — objects further away appear smaller:
Frustum (viewing volume):
Near plane
┌───────┐
/│ │\
/ │ │ \
/ │ │ \ Far plane
/ │ │ \ ┌─────────────┐
/ FOV│ │ \ │ │
/ │ eye │ \ │ │
/ │ * │ \ │ │
\ │ │ / │ │
\ │ │ / │ │
\ │ │ / │ │
\ └───────┘ / └─────────────┘
\ /
near=0.1 far=100.0Cube Geometry
A cube has 8 vertices, 6 faces, and 12 triangles (2 per face):
// Cube vertices (position + normal for each face vertex)
const CUBE_VERTICES: &[f32] = &[
// Front face (z = 1)
-1.0, -1.0, 1.0, 0.0, 0.0, 1.0, // pos, normal
1.0, -1.0, 1.0, 0.0, 0.0, 1.0,
1.0, 1.0, 1.0, 0.0, 0.0, 1.0,
-1.0, 1.0, 1.0, 0.0, 0.0, 1.0,
// Back face (z = -1) ...
// Top face (y = 1) ...
// etc. for all 6 faces
];
const CUBE_INDICES: &[u16] = &[
0, 1, 2, 0, 2, 3, // front
4, 5, 6, 4, 6, 7, // back
8, 9, 10, 8, 10, 11, // top
12, 13, 14, 12, 14, 15, // bottom
16, 17, 18, 16, 18, 19, // right
20, 21, 22, 20, 22, 23, // left
];Uploading Data to the GPU
fn create_buffer(gl: &GL, data: &[f32]) -> web_sys::WebGlBuffer {
let buffer = gl.create_buffer().unwrap();
gl.bind_buffer(GL::ARRAY_BUFFER, Some(&buffer));
// Convert f32 slice to raw bytes for WebGL
unsafe {
let view = js_sys::Float32Array::view(data);
gl.buffer_data_with_array_buffer_view(
GL::ARRAY_BUFFER, &view, GL::STATIC_DRAW,
);
}
buffer
}The Render Loop
use std::cell::RefCell;
use std::rc::Rc;
fn start_render_loop(gl: GL, program: WebGlProgram) {
let f = Rc::new(RefCell::new(None));
let g = f.clone();
let angle = Rc::new(RefCell::new(0.0f64));
*g.borrow_mut() = Some(Closure::wrap(Box::new(move || {
let mut a = angle.borrow_mut();
*a += 0.01; // rotate
// Clear
gl.clear(GL::COLOR_BUFFER_BIT | GL::DEPTH_BUFFER_BIT);
// Update model matrix with new rotation
let model = multiply(&rotate_y(*a), &rotate_x(*a * 0.7));
let model_flat: Vec<f32> = model.iter().flatten().map(|&x| x as f32).collect();
let loc = gl.get_uniform_location(&program, "uModel");
gl.uniform_matrix4fv_with_f32_array(loc.as_ref(), false, &model_flat);
// Draw
gl.draw_elements_with_i32(GL::TRIANGLES, 36, GL::UNSIGNED_SHORT, 0);
// Request next frame
request_animation_frame(f.borrow().as_ref().unwrap());
}) as Box<dyn FnMut()>));
request_animation_frame(g.borrow().as_ref().unwrap());
}Diffuse Lighting
The simplest lighting model computes brightness based on the angle between the surface normal and the light direction:
Light ──────────►
θ \ │ Normal
\ │
\ │
\ │
────────────\│────────── Surface
*
intensity = max(dot(normal, light_dir), 0.0)
= max(cos(θ), 0.0)
When θ = 0° → intensity = 1.0 (fully lit)
When θ = 90° → intensity = 0.0 (edge-on, dark)
When θ > 90° → intensity = 0.0 (facing away)Add ambient light to prevent completely black shadows:
final_color = (ambient + diffuse_intensity) * object_color * light_colorPerformance Tips for Wasm + WebGL
| Technique | Benefit |
|---|---|
| Compute matrices in Rust | Faster than JS for complex math |
| Batch draw calls | Fewer GPU state changes |
Use Float32Array::view |
Zero-copy data transfer to GPU |
| Minimize uniform updates | Only update what changed per frame |
| Use index buffers | Reduces vertex duplication |
| Cull back faces | gl.enable(GL::CULL_FACE) — skip invisible triangles |
Key Takeaways
- WebGL is a low-level API — Rust/Wasm excels at the math-heavy parts (matrix math, physics, procedural generation)
- Vertex shaders transform 3D points; fragment shaders color pixels — both run on the GPU
- MVP matrix = Projection * View * Model — transforms local coordinates to screen coordinates
- Perspective projection creates depth illusion; LookAt positions the camera
- Diffuse lighting =
dot(normal, light_direction)— simple but effective - Use
js_sys::Float32Array::viewfor zero-copy data uploads from Wasm to WebGL buffers - The render loop uses
requestAnimationFramevia closures — store theClosureto prevent it from being dropped