GLSL Shader Basics for Games

Updated June 2026
GLSL (OpenGL Shading Language) is the programming language used to write shaders for WebGL games. It has C-like syntax with specialized types and functions designed for graphics math. This guide walks through the core concepts you need to start writing custom shaders for web game projects.

GLSL runs on the GPU, not the CPU, which means it follows different rules than the JavaScript or TypeScript you use for game logic. Every shader program has a strict structure, a limited set of data types optimized for parallel execution, and a built-in function library that maps directly to GPU hardware instructions. Understanding these fundamentals is the foundation for every shader effect you will ever write.

Understand the GLSL Program Structure

Every GLSL shader is a self-contained program with a main() function as its entry point. WebGL 2.0 uses GLSL ES 3.0, which requires a version directive as the first line: #version 300 es. WebGL 1.0 uses GLSL ES 1.0, which does not require a version directive. Most new web game projects should target WebGL 2.0 and GLSL ES 3.0 for its improved features, including integer support, multiple render targets, and more flexible variable declarations.

A vertex shader must write to the built-in output gl_Position, a vec4 representing the vertex's position in clip space. This is the one mandatory operation, everything else is optional. The vertex shader reads per-vertex input data (position, normal, UV coordinates) through in variables, applies transformations using uniform matrices, and passes results to the fragment shader through out variables.

A fragment shader must write to an output color variable declared as out vec4. In GLSL ES 1.0, you write to the built-in gl_FragColor instead. The fragment shader receives interpolated data from the vertex shader through matching in variables. Variable names and types must match between the vertex shader's out declarations and the fragment shader's in declarations, as this is how the GPU connects the two stages.

Shaders do not have loops that run indefinitely, recursive function calls, or dynamic memory allocation. These restrictions exist because the GPU executes thousands of shader instances in lockstep, and unbounded execution would break the parallel processing model. You can use for-loops with compile-time-known iteration counts, and you can define helper functions, but the overall program must be predictable in its execution path.

Master GLSL Data Types and Operations

GLSL's type system is built around the needs of graphics math. The scalar types are float, int, uint, and bool. From these, GLSL constructs vector types: vec2, vec3, vec4 for float vectors, ivec2/3/4 for integer vectors, and bvec2/3/4 for boolean vectors. Matrix types include mat2, mat3, and mat4, which represent 2x2, 3x3, and 4x4 matrices respectively.

Vectors are the workhorse of shader programming. A vec3 can represent a position, a color (RGB), a direction, or a normal. A vec4 can represent a position in homogeneous coordinates (XYZW) or a color with alpha (RGBA). Vector arithmetic works component-wise: adding two vec3 values adds each corresponding pair of components. Multiplying a vec3 by a float scales all three components. This component-wise behavior makes shader math concise and readable.

Swizzling lets you access vector components using .xyzw, .rgba, or .stpq notation, and you can reorder or repeat components freely. color.rgb extracts a vec3 from a vec4. position.xz extracts a vec2 from a vec3 or vec4. normal.yyy creates a vec3 where all three components equal the y component. Swizzling is not just syntactic sugar, it compiles to efficient GPU instructions for component selection and replication.

Constructors create vectors and matrices from components or other vectors. vec3(1.0, 0.0, 0.0) creates a red color or an X-axis direction. vec4(someVec3, 1.0) extends a vec3 to a vec4 by appending a fourth component. mat4(1.0) creates an identity matrix. These constructors are used constantly in shader code, so getting comfortable with them is essential.

Use Variable Qualifiers for Data Flow

Data flows through the shader pipeline via three categories of variables, each with a specific qualifier that defines where the data comes from and how it behaves.

Uniform variables are set from JavaScript and remain constant across an entire draw call. They represent global values like transformation matrices, time, camera position, light direction, and material properties. Both the vertex and fragment shaders can read the same uniform values. You declare them as uniform mat4 modelViewMatrix; or uniform float time; and update them from JavaScript before each draw.

Input variables (formerly called attributes in GLSL ES 1.0) are per-vertex data that the vertex shader reads from GPU buffers. Position, normal, UV coordinates, vertex colors, and bone weights are typical inputs. In GLSL ES 3.0, you declare them with the in qualifier in the vertex shader: in vec3 position;. Each invocation of the vertex shader receives the data for one specific vertex.

Output/input variables between stages (formerly called varyings) carry data from the vertex shader to the fragment shader. The vertex shader declares them as out: out vec2 vUv;. The fragment shader declares matching variables as in: in vec2 vUv;. The rasterizer automatically interpolates these values across each triangle's surface, so the fragment shader receives a smoothly blended value that reflects its position within the triangle. This interpolation is what makes smooth lighting gradients and texture mapping possible.

A common naming convention prefixes varying variables with "v" (vUv, vNormal, vWorldPosition) to distinguish them from other variables at a glance. This is not enforced by the language but is widely adopted in Three.js, Babylon.js, and shader tutorials.

Apply Built-in Functions

GLSL provides dozens of built-in functions that map to optimized GPU hardware operations. Using these is always faster than writing equivalent logic manually, because the GPU has dedicated circuitry for common graphics math.

mix(a, b, t) performs linear interpolation: a * (1.0 - t) + b * t. This function is essential for blending colors, positions, and any other values. When t is 0.0, the result is a. When t is 1.0, the result is b. Values between produce a smooth blend. Use it for fog blending, color transitions, material mixing, and smooth animations.

smoothstep(edge0, edge1, x) returns 0.0 when x is below edge0, 1.0 when x is above edge1, and a smooth Hermite curve between them. Unlike a linear transition, smoothstep produces an S-curve that eases in and out, creating softer, more natural transitions. It is invaluable for anti-aliased edges, soft shadow boundaries, glow falloffs, and terrain blending.

clamp(x, minVal, maxVal) constrains a value to a range. clamp(color, 0.0, 1.0) ensures color components stay within valid bounds. step(edge, x) returns 0.0 if x is less than edge, 1.0 otherwise, acting as a hard threshold without branching.

dot(a, b) computes the dot product of two vectors. This is the single most important function in lighting math, because the dot product of a surface normal and a light direction gives the cosine of the angle between them, which directly determines how much light the surface receives. normalize(v) returns a unit-length version of a vector, ensuring that dot products produce correct cosine values. reflect(I, N) computes the reflection of an incident vector around a normal, used for specular highlights and environment mapping.

texture(sampler, uv) samples a texture at the given UV coordinates and returns the color (or data) stored there. This function handles filtering (bilinear, trilinear), mipmapping, and wrapping modes automatically based on the texture's configuration. It is the primary way shaders read image data, whether for color textures, normal maps, or any other per-pixel lookup table.

Write a Basic Shader Pair

A practical starting point is a shader that transforms geometry to screen space, applies basic diffuse lighting, and samples a texture. The vertex shader transforms the position and normal into world space, passes them to the fragment shader along with UV coordinates, and outputs the clip-space position for rasterization.

The vertex shader multiplies the position by the model-view-projection matrix to get clip-space coordinates, multiplies the normal by the normal matrix (the inverse transpose of the model-view matrix) to correctly transform the surface direction, and copies the UV coordinates through as a varying. These three operations cover the vast majority of vertex shader use cases in game development.

The fragment shader normalizes the interpolated normal (interpolation can denormalize vectors), calculates the dot product with a light direction uniform, clamps the result to prevent negative lighting values, and multiplies by the sampled texture color. The final output combines the texture's color with the lighting intensity, producing a surface that responds to light direction while displaying the texture pattern.

From this foundation, you can add complexity incrementally. Adding a specular term introduces shiny highlights. Adding a second texture for normal mapping creates surface detail. Adding a time uniform and some sine functions creates animation. Each addition modifies the shader in a predictable way, and understanding the basic structure makes it clear where each new feature plugs in.

When working in Three.js or Babylon.js, you do not need to handle the WebGL boilerplate of compiling and linking shaders. You pass your GLSL source strings to a ShaderMaterial, define your uniforms, and the engine handles the rest. This lets you focus entirely on the shader logic, which is where the creative and technical challenge lies.

Key Takeaway

GLSL is a specialized language optimized for parallel GPU execution. Mastering its vector types, variable qualifiers, and built-in functions gives you the vocabulary to express any visual effect, and the foundation to understand every shader example you encounter in game development.