← Back to Lessons Lesson 33 of 48
Advanced graphics

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.0

Cube 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_color

Performance 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

  1. WebGL is a low-level API — Rust/Wasm excels at the math-heavy parts (matrix math, physics, procedural generation)
  2. Vertex shaders transform 3D points; fragment shaders color pixels — both run on the GPU
  3. MVP matrix = Projection * View * Model — transforms local coordinates to screen coordinates
  4. Perspective projection creates depth illusion; LookAt positions the camera
  5. Diffuse lighting = dot(normal, light_direction) — simple but effective
  6. Use js_sys::Float32Array::view for zero-copy data uploads from Wasm to WebGL buffers
  7. The render loop uses requestAnimationFrame via closures — store the Closure to prevent it from being dropped

Try It