Writing GLSL Shaders for WebGL

Updated June 2026
GLSL (OpenGL Shading Language) is the C-like programming language that runs on the GPU in WebGL applications. Every visual effect in a WebGL game, from basic color fills to complex lighting and post-processing, is implemented through vertex and fragment shaders written in GLSL. Learning to write shaders is the single most important skill for WebGL game development, because shaders control how every pixel on screen is computed.

Shaders are small, specialized programs that execute on the GPU's parallel processing cores. Unlike JavaScript, which runs sequentially on one CPU thread, a shader function runs simultaneously across hundreds or thousands of GPU cores, each processing a different vertex or pixel. This parallelism is what makes real-time rendering possible, but it also imposes constraints: shaders cannot access global state, cannot communicate with each other during execution, and must complete their work using only the data passed to them.

Step 1: Understand Shader Structure and Data Types

Every GLSL shader begins with a version directive. For WebGL 2, use #version 300 es at the very first line. For WebGL 1, omit the version directive (it defaults to GLSL ES 1.00). The version determines which language features are available, and mixing versions between vertex and fragment shaders will cause link errors.

After the version directive, declare a precision qualifier for floating-point operations. In fragment shaders, use precision highp float; for best quality, or precision mediump float; for better mobile performance at the cost of some precision. Vertex shaders default to highp, but fragment shaders have no default in WebGL, so the precision declaration is required.

GLSL provides vector and matrix types that map directly to GPU hardware. vec2, vec3, and vec4 represent 2D, 3D, and 4D floating-point vectors. mat2, mat3, and mat4 represent square matrices. sampler2D and samplerCube reference textures. Integer types (int, ivec2, etc.) are available in GLSL ES 3.00 with full arithmetic support.

Vector components can be accessed with .xyzw, .rgba, or .stpq notation, and they support swizzling: myVec.xy returns a vec2, myVec.zyx returns a vec3 with reversed component order. This flexibility makes GLSL code compact and expressive for the kind of math graphics programming requires.

Step 2: Work with Vertex Shader Inputs and Outputs

Vertex shaders receive per-vertex data through input variables (declared with in in GLSL 300 es, or attribute in GLSL 100). These correspond to the vertex attributes you configure on the JavaScript side with vertexAttribPointer. Common inputs include position (in vec3 aPosition), normal (in vec3 aNormal), texture coordinates (in vec2 aTexCoord), and vertex color (in vec4 aColor).

Uniform variables are constant across all vertices in a single draw call. They are set from JavaScript using gl.uniform* functions. Common uniforms include the model matrix (uniform mat4 uModel), view matrix (uniform mat4 uView), projection matrix (uniform mat4 uProjection), and time (uniform float uTime). Uniforms are the primary mechanism for communicating frame-level and object-level data to shaders.

Output variables (declared with out in GLSL 300 es, or varying in GLSL 100) pass data from the vertex shader to the fragment shader. The GPU automatically interpolates these values across the face of each triangle during rasterization. For example, if you output a texture coordinate from the vertex shader, the fragment shader receives a smoothly interpolated coordinate for each pixel, which is how texture mapping works.

The vertex shader's primary output is gl_Position, a built-in vec4 that specifies the vertex position in clip space. The standard transformation chain is: gl_Position = uProjection * uView * uModel * vec4(aPosition, 1.0). This multiplies the model-space position by three matrices to produce the final screen position.

Step 3: Write Fragment Shaders for Color and Texture

Fragment shaders compute the final color of each pixel. They receive interpolated values from the vertex shader through matching in variables (same name and type as the vertex shader's out variables). In GLSL 300 es, the fragment shader outputs color through a declared out vec4 fragColor variable. In GLSL 100, it writes to the built-in gl_FragColor.

For solid color rendering, set the output directly: fragColor = vec4(1.0, 0.0, 0.0, 1.0) for opaque red. For vertex-colored geometry, pass the vertex color through from the vertex shader and use the interpolated value: fragColor = vColor. The GPU's interpolation produces smooth color gradients across each triangle face.

Texture sampling uses the texture() function (or texture2D() in GLSL 100). Declare a sampler uniform in the fragment shader (uniform sampler2D uTexture), receive interpolated texture coordinates from the vertex shader (in vec2 vTexCoord), and sample: fragColor = texture(uTexture, vTexCoord). The sampler reads the texture image at the interpolated coordinate and returns the color value, with filtering applied based on the texture's magnification and minification settings.

Combining texture color with lighting is the foundation of most game rendering. Multiply the texture sample by a lighting factor: fragColor = texture(uTexture, vTexCoord) * vec4(lightColor * lightIntensity, 1.0). More complex lighting models (Phong, Blinn-Phong, PBR) expand on this principle by computing the light contribution from surface normals, light direction, view direction, and material properties.

Step 4: Use Built-in Functions for Common Operations

GLSL includes a rich library of built-in functions optimized for GPU execution. These run in hardware and are significantly faster than equivalent manual implementations.

mix(a, b, t) linearly interpolates between values a and b using factor t (0.0 returns a, 1.0 returns b). This is essential for blending colors, crossfading effects, and terrain texture transitions. clamp(x, min, max) restricts a value to a range. smoothstep(edge0, edge1, x) produces a smooth Hermite interpolation between 0.0 and 1.0, useful for soft transitions and anti-aliasing.

dot(a, b) computes the dot product of two vectors, fundamental to all lighting calculations. normalize(v) scales a vector to unit length. reflect(I, N) computes the reflection direction for specular highlights. length(v) returns the magnitude of a vector, used for distance calculations and attenuation.

step(edge, x) returns 0.0 if x is less than edge, 1.0 otherwise, replacing branching with math. abs(x), sign(x), floor(x), ceil(x), fract(x), mod(x, y), min(x, y), and max(x, y) provide standard math operations. sin(x), cos(x), pow(x, y), exp(x), and log(x) handle trigonometry and exponentials, all executing at full GPU speed.

Step 5: Implement Common Game Shader Patterns

Diffuse lighting (Lambertian) computes brightness based on the angle between the surface normal and the light direction: float diffuse = max(dot(normal, lightDir), 0.0). This single line is the core of most real-time lighting systems. Add an ambient term to prevent fully dark surfaces: vec3 color = ambient + diffuse * lightColor.

Specular highlights (Blinn-Phong) add shiny reflections. Compute the half-vector between the view direction and light direction: vec3 halfDir = normalize(lightDir + viewDir). The specular factor is pow(max(dot(normal, halfDir), 0.0), shininess), where higher shininess values produce tighter, more focused highlights.

Distance fog blends geometry color toward a fog color based on distance from the camera. Calculate the distance in the vertex shader or fragment shader, then apply: float fogFactor = smoothstep(fogNear, fogFar, distance); fragColor = mix(objectColor, fogColor, fogFactor). This adds atmospheric depth to outdoor scenes with minimal performance cost.

Sprite animation for 2D games uses texture coordinate manipulation. Given a sprite sheet with rows and columns, compute the UV offset for the current frame: divide the sheet into a grid and offset the texture coordinates by the current frame index. Pass the frame index as a uniform and update it in JavaScript based on elapsed time.

Color grading adjusts the final rendered image's color balance, contrast, and saturation. Apply it in a post-processing pass: render your scene to a framebuffer texture, then draw a full-screen quad with a shader that samples the texture and applies color adjustments. Simple saturation control uses the luminance: float lum = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722)); color.rgb = mix(vec3(lum), color.rgb, saturation).

Key Takeaway

GLSL shaders are the programmable heart of WebGL rendering. Master the data flow from JavaScript through uniforms and attributes, through the vertex shader's coordinate transformations, into the fragment shader's per-pixel color computation. Every visual technique in browser games is built from these fundamentals.