Writing WGSL Shaders for WebGPU

Updated June 2026
WGSL (WebGPU Shading Language) is the shader language for WebGPU, designed with Rust-influenced syntax, strict type checking, and explicit resource bindings. This guide covers the language fundamentals you need to write vertex, fragment, and compute shaders for game rendering, from basic types and entry points through texture sampling and lighting calculations.

WGSL replaces GLSL for WebGPU development. If you have written GLSL shaders before, the core concepts transfer directly: you still work with vectors, matrices, texture sampling, and mathematical operations. The difference is that WGSL requires you to be explicit about types, bindings, and data flow, which eliminates an entire class of bugs that plague GLSL shader development.

Understand WGSL Types and Variables

WGSL provides scalar types f32 (32-bit float), i32 (32-bit signed integer), u32 (32-bit unsigned integer), and bool. Vector types combine scalars: vec2f, vec3f, and vec4f for float vectors, vec2i, vec3i, vec4i for integer vectors, and vec2u, vec3u, vec4u for unsigned integer vectors. Matrix types follow the pattern mat2x2f, mat3x3f, mat4x4f for square matrices, with non-square variants like mat2x3f available when needed.

Variable declarations come in three forms. The "let" keyword creates an immutable binding whose value cannot change after initialization, similar to const in JavaScript. The "var" keyword creates a mutable variable that can be reassigned. The "const" keyword creates a compile-time constant that must be evaluable without runtime data. In practice, use let for most local variables (function temporaries, intermediate calculations), var for values that change (loop counters, accumulated results), and const for fixed values like pi or material constants.

Every variable requires a type annotation or an initializer from which the type can be inferred. WGSL does not perform implicit type conversions. Adding an f32 to an i32 requires an explicit cast: f32(myInt) + myFloat. This strictness prevents the subtle precision bugs that GLSL's implicit conversions frequently cause, where an integer division silently truncates a value that was intended to be floating-point.

Arrays use the syntax array<ElementType, Count> for fixed-size arrays and array<ElementType> for runtime-sized arrays (which can only appear as the last member of a storage buffer struct). Constructors build values inline: vec3f(1.0, 0.0, 0.0) creates a red color vector, mat4x4f(1.0, 0.0, 0.0, 0.0, ...) creates a matrix, though helper functions for identity and rotation matrices are typically defined in utility code.

Write Vertex and Fragment Entry Points

A vertex shader entry point is a function marked with the @vertex attribute. It receives per-vertex input data through parameters decorated with @location(n) attributes, where n matches the shader location specified in the pipeline's vertex buffer layout. The function returns data that flows to the rasterizer and then to the fragment shader. The returned position must be marked with @builtin(position) and be a vec4f in clip space.

A fragment shader entry point uses the @fragment attribute. It receives interpolated data from the vertex shader (the rasterizer interpolates vertex outputs across each triangle's surface) and returns color values marked with @location(0) targeting the first color attachment. Additional color attachments use @location(1), @location(2), and so on for multi-render-target setups in deferred rendering.

Entry points can return individual values or structs. For a simple shader that only outputs position and color, you might return @builtin(position) vec4f from the vertex shader and receive @builtin(position) vec4f in the fragment shader. For complex shaders with multiple outputs (position, normal, UV, tangent), returning a struct is cleaner and more maintainable.

Built-in inputs available to shaders include @builtin(vertex_index) for the vertex index in the draw call, @builtin(instance_index) for instanced rendering, @builtin(front_facing) in fragment shaders to determine triangle orientation, and @builtin(sample_index) for multi-sampled rendering. These built-ins provide the same information as GLSL's gl_VertexID, gl_InstanceID, gl_FrontFacing, and gl_SampleID, just with explicit naming.

Define Structs for Inter-Stage Data

Structs in WGSL group related fields into named types. For vertex-to-fragment data, you define a struct with each field decorated by either @builtin or @location. A typical game vertex output struct includes @builtin(position) for the clip-space position, @location(0) for the world-space normal, @location(1) for texture coordinates, and @location(2) for the world-space position (needed for per-pixel lighting calculations in the fragment shader).

The vertex shader returns an instance of this struct, and the fragment shader receives it as a parameter. The rasterizer interpolates all @location fields across the triangle surface, so the fragment shader receives smoothly varying values between the three vertex values. The @builtin(position) field in the fragment shader contains the screen-space pixel coordinate rather than the clip-space value the vertex shader wrote.

Structs used for uniform buffer data have alignment requirements that WGSL enforces at compile time. Each field must be aligned to its natural alignment boundary: f32 to 4 bytes, vec2f to 8 bytes, vec3f to 16 bytes, vec4f to 16 bytes, and mat4x4f to 16 bytes. The compiler will reject a struct layout that does not satisfy these requirements, preventing the silent data corruption that misaligned uniform buffers cause in GLSL. Padding fields may be necessary to achieve correct alignment.

Bind Uniform and Storage Buffers

Resource bindings connect CPU-side data to shader-side variables through the bind group system. Each binding is declared with @group(n) @binding(m), where the group number maps to a bind group index in the API and the binding number maps to an entry within that bind group.

Uniform buffers use the syntax @group(0) @binding(0) var<uniform> camera: CameraUniforms, where CameraUniforms is a struct containing the view matrix, projection matrix, and camera position. Uniform buffers are read-only, optimized for small amounts of data that every invocation reads (camera parameters, lighting data, time values), and limited to 64 KB by default.

Storage buffers use var<storage, read> for read-only access or var<storage, read_write> for read-write access. Storage buffers support much larger data sets (up to the device's maxStorageBufferBindingSize, typically 128 MB or more) and allow random access patterns. They are the correct choice for large arrays of instance data, particle positions, terrain heightmaps, or any data that compute shaders need to write to.

A common bind group layout for games assigns group 0 to per-frame uniforms (camera, lighting, time), group 1 to per-material resources (textures, samplers, material parameters), and group 2 to per-object uniforms (model matrix, object ID). This hierarchy minimizes rebinding, as group 0 is set once per frame, group 1 changes per material, and group 2 changes per draw call.

Sample Textures and Use Samplers

Textures and samplers are declared as separate bindings in WGSL. A 2D texture uses the type texture_2d<f32>, and a sampler uses the type sampler. You bind them with @group and @binding attributes, then sample the texture in the fragment shader with the textureSample() function, which takes the texture, sampler, and UV coordinates as arguments and returns a vec4f color value.

The separation of textures and samplers is a deliberate design choice that differs from GLSL's combined sampler2D type. In WGSL, a single texture can be sampled with different samplers (nearest for pixel art, linear for smooth surfaces, anisotropic for oblique viewing angles) without duplicating the texture data. This flexibility is particularly useful for games that render the same texture at different quality levels depending on distance or surface angle.

Texture dimensions can be queried with textureDimensions(), which returns the width and height as a vec2u. This is useful for effects that need to know the texture resolution, such as calculating texel offsets for blur kernels or converting between UV coordinates and pixel coordinates. The textureLoad() function reads a specific texel by integer coordinates without filtering, which is needed for post-processing effects that require exact pixel access.

For cube maps (used in skyboxes and environment reflections), the type is texture_cube<f32> and you sample with a vec3f direction vector instead of vec2f UV coordinates. Depth textures use texture_depth_2d and are sampled with a comparison sampler (sampler_comparison) for shadow mapping, where the hardware performs a depth comparison and returns a 0.0 or 1.0 shadow factor.

Apply Lighting and Material Calculations

Lighting calculations in WGSL use the same mathematical models as any other shading language. The Blinn-Phong model, still widely used for its simplicity and predictable appearance, computes diffuse intensity as the dot product of the surface normal and light direction, and specular intensity as the dot product of the half vector (between the view and light directions) and the surface normal, raised to a shininess power.

WGSL provides built-in functions for the common math operations: dot() for dot products, normalize() for unit vectors, reflect() for reflection vectors, cross() for cross products, clamp() for value clamping, mix() for linear interpolation, and pow() for exponentiation. These functions work on vector types, so normalize(worldNormal) returns a unit-length vec3f, and dot(normal, lightDir) returns an f32 scalar.

Physically-based rendering (PBR) models require more complex calculations but follow the same pattern. The Cook-Torrance BRDF combines a distribution function (GGX/Trowbridge-Reitz), a geometry function (Smith's method), and a Fresnel term (Schlick's approximation) to produce realistic material appearance that responds correctly to different lighting conditions. These calculations involve only standard math operations available in WGSL.

For games with multiple light sources, the fragment shader loops over a light array stored in a storage or uniform buffer, accumulating each light's contribution to the final pixel color. Directional lights, point lights, and spot lights each have their own attenuation and direction calculations, but all feed into the same diffuse-plus-specular accumulation. The accumulated color is combined with ambient lighting and the material's albedo texture to produce the final output.

Key Takeaway

WGSL's strict type system and explicit bindings require more upfront annotation than GLSL, but in return you get shaders that compile consistently across every platform, surface errors immediately, and map cleanly to WebGPU's resource binding model.