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:
Typical Data Flow
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.
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.
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 screenNote
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.
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.
// 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.
// 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.
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(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.
// 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.
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.
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:
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:
Do NOT redefine these in your shader — use them directly. Redefining will cause duplicate function errors.
Texture Formats
Default formats differ between targets:
| Target | Default Format |
|---|---|
| Canvas (screen) | bgra8unorm |
| RenderTarget | rgba8unorm |