Core Concepts

Understanding the key abstractions in ralph-gpu.

Overview

ralph-gpu provides a small set of composable primitives that cover most GPU graphics needs:

ctx
GPU Context
pass
Fullscreen Shader
material
Custom Vertex
target
Render Target
pingPong
Double Buffer
compute
GPU Compute
storage
Storage Buffer
sampler
Texture Sampler

Typical Data Flow

Storage Buffer
Compute Shader
Material / Pass
Target / Screen

GPUContext (ctx)

The context is your entry point to ralph-gpu. It manages the WebGPU device, canvas, and provides factory methods for all other primitives.

TypeScript
import { gpu } from "ralph-gpu";
 
const ctx = await gpu.init(canvas, {
  dpr: Math.min(window.devicePixelRatio, 2),
  debug: true,
});
 
// Properties
ctx.width       // Canvas width in pixels
ctx.height      // Canvas height in pixels
ctx.time        // Elapsed time in seconds
ctx.timeScale   // Time multiplier (default: 1)
ctx.paused      // Pause time updates
ctx.autoClear   // Auto-clear before draw (default: true)

Context Responsibilities

  • • Manages the WebGPU device and canvas
  • • Tracks time and updates the globals uniform
  • • Creates shaders, targets, and compute pipelines
  • • Handles resource cleanup with dispose()

Pass (Fullscreen Shader)

A pass is the simplest way to draw something — just write a fragment shader and it renders fullscreen. Perfect for backgrounds, post-processing, and visual effects.

TypeScript
const gradient = ctx.pass(`
  @fragment
  fn main(@builtin(position) pos: vec4f) -> @location(0) vec4f {
    let uv = pos.xy / globals.resolution;
    return vec4f(uv, 0.5, 1.0);
  }
`);
 
gradient.draw(); // Render to screen

Note

Behind the scenes: ralph-gpu creates an internal full-screen quad and runs your fragment shader for every pixel. You only write the fragment code.

Material (Custom Geometry)

A material gives you full control over the vertex and fragment stages. Use it for particles, instanced geometry, or any custom rendering.

TypeScript
const particles = ctx.material(`
  struct Particle { pos: vec2f, color: vec3f }
  @group(1) @binding(0) var<storage, read> particles: array<Particle>;
 
  @vertex
  fn vs_main(
    @builtin(vertex_index) vid: u32,
    @builtin(instance_index) iid: u32
  ) -> @builtin(position) vec4f {
    var quad = array<vec2f, 6>(
      vec2f(-1, -1), vec2f(1, -1), vec2f(-1, 1),
      vec2f(-1, 1), vec2f(1, -1), vec2f(1, 1),
    );
    return vec4f(particles[iid].pos + quad[vid] * 0.01, 0.0, 1.0);
  }
 
  @fragment
  fn fs_main() -> @location(0) vec4f {
    return vec4f(1.0);
  }
`, {
  vertexCount: 6,      // Vertices per instance (quad)
  instances: 10000,    // Number of particles
  blend: "additive",   // Blending mode
});
 
particles.storage("particles", particleBuffer);
particles.draw();

Render Target

A target is an offscreen texture you can render to. Use it for multi-pass effects, blur, reflections, or any technique that needs intermediate results.

TypeScript
// Create a 512x512 render target
const buffer = ctx.target(512, 512, {
  format: "rgba16float", // High precision
  filter: "linear",      // Smooth sampling
  wrap: "clamp",         // Edge handling
});
 
// Render to the target
ctx.setTarget(buffer);
scene.draw();
 
// Use as texture in another shader
const displayUniforms = {
  inputTex: { value: buffer.texture },
};
ctx.setTarget(null); // Back to screen
display.draw();

Ping-Pong Buffers

Ping-pong is a pair of render targets used for iterative effects. You read from one while writing to the other, then swap them. Essential for fluid simulation, blur, and any feedback effect.

TypeScript
// Create ping-pong buffers for iterative effects
const velocity = ctx.pingPong(128, 128, { format: "rg16float" });
 
// Simulation loop
function simulate() {
  // Read from .read, write to .write
  advectionUniforms.source.value = velocity.read.texture;
  ctx.setTarget(velocity.write);
  advection.draw();
  
  // Swap buffers for next iteration
  velocity.swap();
}

Why ping-pong?

GPUs can't read from and write to the same texture in a single pass. Ping-pong gives you two textures that trade roles each frame: one is the source, one is the destination.

Compute Shaders

Compute shaders run parallel computations on the GPU without rendering anything. Use them for physics, particle updates, or any data processing.

TypeScript
const simulation = ctx.compute(`
  struct Particle { pos: vec2f, vel: vec2f }
  @group(1) @binding(0) var<storage, read_write> particles: array<Particle>;
 
  @compute @workgroup_size(64)
  fn main(@builtin(global_invocation_id) id: vec3u) {
    let i = id.x;
    particles[i].pos += particles[i].vel * globals.deltaTime;
    
    // Wrap around edges
    particles[i].pos = fract(particles[i].pos);
  }
`);
 
// Bind storage and dispatch
simulation.storage("particles", particleBuffer);
simulation.dispatch(numParticles / 64);

Warning

Workgroup size: The @workgroup_size(64) annotation defines how many threads run together. When dispatching, divide your total count by the workgroup size.

Storage Buffers

Storage buffers hold large amounts of data on the GPU. Unlike uniforms (which are limited in size), storage buffers can hold millions of items.

TypeScript
// Create a storage buffer (4 floats per particle × 1000 particles)
const particleBuffer = ctx.storage(4 * 4 * 1000);
 
// Write initial data
const initialData = new Float32Array(4 * 1000);
for (let i = 0; i < 1000; i++) {
  initialData[i * 4 + 0] = Math.random(); // x
  initialData[i * 4 + 1] = Math.random(); // y
  initialData[i * 4 + 2] = Math.random() * 0.01; // vx
  initialData[i * 4 + 3] = Math.random() * 0.01; // vy
}
particleBuffer.write(initialData);
 
// Use in compute shader
compute.storage("particles", particleBuffer);

Auto-Injected Globals

Every shader automatically has access to the globals uniform. You don't need to declare it — ralph-gpu injects it for you.

WGSL
struct Globals {
  resolution: vec2f,  // Render target size in pixels
  time: f32,          // Elapsed time (seconds, affected by timeScale)
  deltaTime: f32,     // Time since last frame (seconds)
  frame: u32,         // Frame count since init
  aspect: f32,        // resolution.x / resolution.y
}
@group(0) @binding(0) var<uniform> globals: Globals;

Custom Uniforms

Pass custom data to your shaders using the { value: X } pattern. Changes to .value are automatically uploaded to the GPU.

TypeScript
const uniforms = {
  amplitude: { value: 0.5 },
  color: { value: [1.0, 0.2, 0.5] },
};
 
const wave = ctx.pass(`
  struct Params { amplitude: f32, color: vec3f }
  @group(1) @binding(0) var<uniform> u: Params;
 
  @fragment
  fn main(@builtin(position) pos: vec4f) -> @location(0) vec4f {
    let uv = pos.xy / globals.resolution;
    let y = sin(uv.x * 10.0 + globals.time) * u.amplitude;
    return vec4f(u.color, 1.0);
  }
`, { uniforms });
 
// Update at any time - changes reflected automatically
uniforms.amplitude.value = 0.8;
uniforms.color.value = [0.2, 1.0, 0.5];

Important Notes

A few things to keep in mind when working with ralph-gpu:

Reading Pixels from Screen

You cannot read pixels from the screen (swap chain texture). For pixel readback, render to a RenderTarget first:

// ❌ Won't work - screen can't be read
ctx.setTarget(null);
await ctx.readPixels(); // Returns zeros!
// ✅ Works - render to a RenderTarget
const target = ctx.target(256, 256);
ctx.setTarget(target);
await target.readPixels(); // Actual data!

Globals Binding

The globals struct is auto-injected at @group(0). User uniforms are always at @group(1). If your shader doesn't use globals.time, globals.resolution, etc., the WGSL optimizer may remove unused bindings internally — the library handles this automatically.

Particles Helper Functions

When using ctx.particles(), these WGSL functions are auto-injected:

fn quadOffset(vid: u32) -> vec2f // -0.5 to 0.5
fn quadUV(vid: u32) -> vec2f // 0 to 1

Do NOT redefine these in your shader — use them directly. Redefining will cause duplicate function errors.

Texture Formats

Default formats differ between targets:

TargetDefault Format
Canvas (screen)bgra8unorm
RenderTargetrgba8unorm

Next Steps