Skip to content

Rendering Pipeline

Detailed architecture of the WebGPU rendering system.

Overview

The rendering pipeline transforms mesh data into pixels on screen:

flowchart TB subgraph Input["Input"] Meshes["Mesh Buffers"] Camera["Camera State"] Lights["Lighting"] end subgraph Prepare["Preparation"] Cull["Frustum Culling"] Sort["Depth Sorting"] Batch["Batching"] end subgraph GPU["GPU Pipeline"] Vertex["Vertex Shader"] Raster["Rasterization"] Fragment["Fragment Shader"] Depth["Depth Test"] Blend["Blending"] end subgraph Output["Output"] Canvas["Canvas"] end Input --> Prepare --> GPU --> Output

WebGPU Architecture

Device Initialization

sequenceDiagram participant App participant Navigator participant Adapter participant Device App->>Navigator: navigator.gpu Navigator-->>App: GPU object App->>Navigator: requestAdapter() Navigator-->>App: GPUAdapter App->>Adapter: requestDevice() Adapter-->>App: GPUDevice App->>App: Create pipelines

Resource Hierarchy

classDiagram class GPUDevice { +createBuffer() +createTexture() +createShaderModule() +createRenderPipeline() +createBindGroup() } class GPUBuffer { +GPUBufferUsage usage +number size +mapAsync() +getMappedRange() +unmap() } class GPURenderPipeline { +GPUVertexState vertex +GPUFragmentState fragment +GPUPrimitiveState primitive +GPUDepthStencilState depthStencil } class GPUBindGroup { +GPUBindGroupLayout layout +GPUBindGroupEntry[] entries } GPUDevice --> GPUBuffer GPUDevice --> GPURenderPipeline GPUDevice --> GPUBindGroup

Buffer Management

Buffer Types

flowchart LR subgraph Vertex["Vertex Data"] Positions["positions: Float32Array"] Normals["normals: Float32Array"] UVs["uvs: Float32Array"] end subgraph Index["Index Data"] Indices["indices: Uint32Array"] end subgraph Uniform["Uniform Data"] Camera["camera: mat4x4"] Model["model: mat4x4"] Color["color: vec4"] end subgraph GPU["GPU Buffers"] VBO["Vertex Buffers"] IBO["Index Buffer"] UBO["Uniform Buffers"] end Vertex --> VBO Index --> IBO Uniform --> UBO

Buffer Layout

// Vertex buffer layout
const vertexBufferLayout: GPUVertexBufferLayout = {
  arrayStride: 24, // 6 floats * 4 bytes
  attributes: [
    {
      // Position
      shaderLocation: 0,
      offset: 0,
      format: 'float32x3'
    },
    {
      // Normal
      shaderLocation: 1,
      offset: 12,
      format: 'float32x3'
    }
  ]
};

// Interleaved vertex data
// [px, py, pz, nx, ny, nz, px, py, pz, nx, ny, nz, ...]

Shader Architecture

Vertex Shader

struct Uniforms {
    viewProjection: mat4x4<f32>,
    model: mat4x4<f32>,
    normalMatrix: mat3x3<f32>,
}

struct VertexInput {
    @location(0) position: vec3<f32>,
    @location(1) normal: vec3<f32>,
}

struct VertexOutput {
    @builtin(position) position: vec4<f32>,
    @location(0) worldPos: vec3<f32>,
    @location(1) normal: vec3<f32>,
}

@group(0) @binding(0) var<uniform> uniforms: Uniforms;

@vertex
fn main(input: VertexInput) -> VertexOutput {
    var output: VertexOutput;

    let worldPos = uniforms.model * vec4(input.position, 1.0);
    output.position = uniforms.viewProjection * worldPos;
    output.worldPos = worldPos.xyz;
    output.normal = uniforms.normalMatrix * input.normal;

    return output;
}

Fragment Shader

struct Material {
    color: vec4<f32>,
    metallic: f32,
    roughness: f32,
}

struct Light {
    position: vec3<f32>,
    color: vec3<f32>,
    intensity: f32,
}

@group(0) @binding(1) var<uniform> material: Material;
@group(0) @binding(2) var<uniform> light: Light;

@fragment
fn main(input: VertexOutput) -> @location(0) vec4<f32> {
    let N = normalize(input.normal);
    let L = normalize(light.position - input.worldPos);
    let V = normalize(-input.worldPos);
    let H = normalize(L + V);

    // Diffuse
    let NdotL = max(dot(N, L), 0.0);
    let diffuse = material.color.rgb * NdotL;

    // Specular (Blinn-Phong)
    let NdotH = max(dot(N, H), 0.0);
    let specular = pow(NdotH, 32.0) * (1.0 - material.roughness);

    // Ambient
    let ambient = material.color.rgb * 0.1;

    let finalColor = ambient + diffuse + specular * light.color;
    return vec4(finalColor, material.color.a);
}

Render Loop

Frame Structure

flowchart TB subgraph Frame["Frame"] Begin["Begin Frame"] Update["Update Uniforms"] Cull["Frustum Cull"] Encode["Encode Commands"] Submit["Submit"] Present["Present"] end Begin --> Update --> Cull --> Encode --> Submit --> Present

Command Encoding

render(): void {
  // Begin frame
  const commandEncoder = device.createCommandEncoder();

  const renderPass = commandEncoder.beginRenderPass({
    colorAttachments: [{
      view: context.getCurrentTexture().createView(),
      clearValue: { r: 0.95, g: 0.95, b: 0.95, a: 1.0 },
      loadOp: 'clear',
      storeOp: 'store'
    }],
    depthStencilAttachment: {
      view: depthTexture.createView(),
      depthClearValue: 1.0,
      depthLoadOp: 'clear',
      depthStoreOp: 'store'
    }
  });

  // Set pipeline
  renderPass.setPipeline(renderPipeline);

  // Draw meshes
  for (const mesh of visibleMeshes) {
    renderPass.setBindGroup(0, mesh.bindGroup);
    renderPass.setVertexBuffer(0, mesh.vertexBuffer);
    renderPass.setIndexBuffer(mesh.indexBuffer, 'uint32');
    renderPass.drawIndexed(mesh.indexCount);
  }

  renderPass.end();

  // Submit
  device.queue.submit([commandEncoder.finish()]);
}

Frustum Culling

Culling Pipeline

flowchart TB subgraph Input["Input"] Camera["Camera Frustum"] Meshes["All Meshes"] end subgraph Cull["Culling"] Extract["Extract Frustum Planes"] Test["Test AABB vs Planes"] Filter["Filter Visible"] end subgraph Output["Output"] Visible["Visible Meshes"] end Input --> Cull --> Output

AABB-Frustum Test

interface Frustum {
  planes: Plane[]; // 6 planes: near, far, left, right, top, bottom
}

interface AABB {
  min: Vector3;
  max: Vector3;
}

function isVisible(aabb: AABB, frustum: Frustum): boolean {
  for (const plane of frustum.planes) {
    // Get positive vertex (furthest along plane normal)
    const pVertex = {
      x: plane.normal.x > 0 ? aabb.max.x : aabb.min.x,
      y: plane.normal.y > 0 ? aabb.max.y : aabb.min.y,
      z: plane.normal.z > 0 ? aabb.max.z : aabb.min.z
    };

    // If positive vertex is behind plane, AABB is outside
    if (dot(plane.normal, pVertex) + plane.d < 0) {
      return false;
    }
  }
  return true;
}

Selection & Picking

ID Buffer Approach

flowchart TB subgraph Render["Render Passes"] Main["Main Pass<br/>(colors)"] ID["ID Pass<br/>(entity IDs)"] end subgraph Pick["Picking"] Click["Mouse Click"] Read["Read ID Pixel"] Decode["Decode Entity ID"] end subgraph Result["Result"] Entity["Selected Entity"] end Render --> Pick --> Result

ID Encoding

// ID buffer fragment shader
@fragment
fn main_id(@location(0) entityId: u32) -> @location(0) vec4<u32> {
    // Encode entity ID as RGBA
    return vec4<u32>(
        entityId & 0xFF,
        (entityId >> 8) & 0xFF,
        (entityId >> 16) & 0xFF,
        (entityId >> 24) & 0xFF
    );
}
async function pick(x: number, y: number): Promise<number | null> {
  // Render ID buffer
  await renderIdBuffer();

  // Read pixel
  const buffer = device.createBuffer({
    size: 4,
    usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST
  });

  commandEncoder.copyTextureToBuffer(
    { texture: idTexture, origin: { x, y, z: 0 } },
    { buffer, bytesPerRow: 256 },
    { width: 1, height: 1, depthOrArrayLayers: 1 }
  );

  await buffer.mapAsync(GPUMapMode.READ);
  const data = new Uint8Array(buffer.getMappedRange());

  // Decode entity ID
  const entityId = data[0] | (data[1] << 8) | (data[2] << 16) | (data[3] << 24);

  buffer.unmap();
  return entityId > 0 ? entityId : null;
}

Section Planes

Clipping Implementation

flowchart LR subgraph Input["Input"] Plane["Clip Plane"] Fragment["Fragment Position"] end subgraph Test["Distance Test"] Dot["dot(normal, position)"] Compare["distance < 0?"] end subgraph Output["Output"] Discard["discard"] Keep["keep"] end Input --> Test Compare -->|Yes| Discard Compare -->|No| Keep

Section Shader

struct SectionPlane {
    point: vec3<f32>,
    normal: vec3<f32>,
    enabled: u32,
}

@group(1) @binding(0) var<uniform> sectionPlane: SectionPlane;

@fragment
fn main(input: VertexOutput) -> @location(0) vec4<f32> {
    // Clip against section plane
    if (sectionPlane.enabled != 0u) {
        let distance = dot(sectionPlane.normal, input.worldPos - sectionPlane.point);
        if (distance < 0.0) {
            discard;
        }
    }

    // Normal shading...
    return color;
}

Transparency

Order-Independent Transparency

flowchart TB subgraph Sort["Depth Sorting"] Opaque["Render Opaque<br/>(front to back)"] Trans["Render Transparent<br/>(back to front)"] end subgraph Blend["Blending"] Src["Source: SrcAlpha"] Dst["Dest: OneMinusSrcAlpha"] end Opaque --> Trans --> Blend

Blend State

const transparentPipeline = device.createRenderPipeline({
  // ...
  fragment: {
    targets: [{
      format: 'bgra8unorm',
      blend: {
        color: {
          srcFactor: 'src-alpha',
          dstFactor: 'one-minus-src-alpha',
          operation: 'add'
        },
        alpha: {
          srcFactor: 'one',
          dstFactor: 'one-minus-src-alpha',
          operation: 'add'
        }
      }
    }]
  }
});

Performance Optimization

Batching Strategy

flowchart TB subgraph Before["Before Batching"] D1["Draw 1"] D2["Draw 2"] D3["Draw 3"] D4["..."] DN["Draw N"] end subgraph After["After Batching"] B1["Batch 1<br/>(similar meshes)"] B2["Batch 2"] B3["Batch 3"] end Before -->|"Combine"| After

Instancing

// Instance buffer layout
const instanceBufferLayout: GPUVertexBufferLayout = {
  arrayStride: 80, // mat4 (64) + color (16)
  stepMode: 'instance',
  attributes: [
    { shaderLocation: 2, offset: 0, format: 'float32x4' },
    { shaderLocation: 3, offset: 16, format: 'float32x4' },
    { shaderLocation: 4, offset: 32, format: 'float32x4' },
    { shaderLocation: 5, offset: 48, format: 'float32x4' },
    { shaderLocation: 6, offset: 64, format: 'float32x4' }
  ]
};

// Draw instanced
renderPass.drawIndexed(indexCount, instanceCount);

Frame Statistics

gantt title Frame Timeline (60 FPS = 16.67ms) dateFormat X axisFormat %Lms section CPU Update Camera :a1, 0, 1 Frustum Cull :a2, 1, 2 Sort Transparent :a3, 2, 3 Encode Commands :a4, 3, 5 section GPU Vertex Processing :b1, 5, 8 Rasterization :b2, 8, 11 Fragment Processing :b3, 11, 14 Present :b4, 14, 17

Performance Metrics

Metric Target Notes
FPS 60 Minimum for smooth
Draw calls < 1000 Per frame
Triangles < 5M Visible
GPU memory < 512 MB Total

Next Steps