Textures and Lighting in WebGL

Updated June 2026
Textures and lighting are the two techniques that transform flat-shaded geometry into convincing visual scenes. Textures wrap images onto surfaces to provide color detail, material appearance, and visual complexity that would be impossible to achieve with vertex colors alone. Lighting simulates how real-world illumination interacts with surfaces, giving objects depth, shape, and spatial context. Together, they form the visual foundation of every 3D browser game.

In WebGL, textures and lighting are both implemented through shaders. Texture data is stored in GPU memory and sampled in the fragment shader to determine surface color. Lighting calculations use surface normals, light positions, and material properties to compute how bright each pixel should be. Combining the two means multiplying the texture color by the computed light intensity for each pixel, producing a lit, textured surface.

Step 1: Load and Create WebGL Textures

WebGL textures start as JavaScript Image objects loaded from files. Create an Image, set its src to the texture file path, and handle the onload event. Because image loading is asynchronous, you need a placeholder texture (typically a single-pixel solid color) to use while the real texture downloads. This prevents rendering errors during the loading phase.

Once the image loads, create a WebGL texture object with gl.createTexture(), bind it to the TEXTURE_2D target with gl.bindTexture(), and upload the image data with gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image). The GPU converts the image into its internal texture format and stores it in video memory.

Configure texture filtering with gl.texParameteri(). Magnification filtering (TEXTURE_MAG_FILTER) controls how the texture looks when rendered larger than its native size. Use LINEAR for smooth interpolation or NEAREST for pixel-art style sharp edges. Minification filtering (TEXTURE_MIN_FILTER) controls how the texture looks when rendered smaller. Use LINEAR_MIPMAP_LINEAR (trilinear filtering) for the best quality, which requires generating mipmaps with gl.generateMipmap().

Wrapping modes (TEXTURE_WRAP_S and TEXTURE_WRAP_T) determine what happens when texture coordinates exceed the 0-to-1 range. REPEAT tiles the texture, CLAMP_TO_EDGE stretches the edge pixels, and MIRRORED_REPEAT tiles with alternating mirroring. For game textures that tile (grass, brick, water), use REPEAT. For UI elements or sprites, use CLAMP_TO_EDGE.

In WebGL 1, textures must have power-of-two dimensions (256, 512, 1024, etc.) to use mipmaps and repeat wrapping. WebGL 2 removes this restriction, allowing any texture dimensions with full filtering support.

Step 2: Set Up UV Mapping and Texture Coordinates

Texture coordinates (commonly called UVs) define how a 2D texture image maps onto 3D geometry. Each vertex in your mesh carries a UV coordinate that specifies which point on the texture corresponds to that vertex. The U axis maps horizontally (0 is the left edge, 1 is the right edge) and the V axis maps vertically (0 is the bottom in OpenGL convention, 1 is the top).

Add texture coordinates to your vertex data alongside positions and normals. If your vertex buffer stores position (vec3) followed by texture coordinate (vec2), your vertex attribute setup needs two attribute pointers: one for position at offset 0 with a stride of 20 bytes (3 floats + 2 floats = 5 floats * 4 bytes), and one for texcoord at offset 12 bytes (after the 3 position floats). Getting the stride and offset wrong is a common source of rendering artifacts where textures appear stretched, rotated, or garbled.

In the vertex shader, declare the texture coordinate as an input attribute (in vec2 aTexCoord) and pass it through to the fragment shader as an output (out vec2 vTexCoord). The assignment is straightforward: vTexCoord = aTexCoord. The GPU interpolates this value across each triangle face during rasterization, so each pixel in the fragment shader receives the correct texture coordinate for its position on the surface.

For 3D models loaded from files (glTF, OBJ), texture coordinates are baked into the model by the artist in their 3D modeling software. For procedural geometry (terrain, generated meshes), you calculate UVs programmatically. A common approach for terrain is to use the world-space XZ position scaled by a tiling factor, which produces naturally tiling textures across the landscape.

Step 3: Implement Texture Sampling in Fragment Shaders

In the fragment shader, declare the texture sampler as a uniform (uniform sampler2D uDiffuseMap) and receive the interpolated texture coordinate from the vertex shader (in vec2 vTexCoord). Sample the texture with vec4 texColor = texture(uDiffuseMap, vTexCoord). The returned vec4 contains the RGBA color at that point on the texture, filtered according to the texture parameters you configured.

On the JavaScript side, assign texture units to sampler uniforms. Activate a texture unit with gl.activeTexture(gl.TEXTURE0), bind your texture to it, and set the uniform to the unit number: gl.uniform1i(samplerLocation, 0). WebGL guarantees at least 8 texture units (most GPUs support 16 or 32), allowing you to bind multiple textures simultaneously for multi-texture effects like normal mapping, specular maps, or terrain blending.

Multi-texturing combines several texture samples in the fragment shader. For terrain, you might blend between grass, rock, and sand textures based on slope and altitude. For material detail, combine a diffuse color map with a normal map and a roughness map. Each texture gets its own sampler uniform and texture unit. The fragment shader samples all of them and combines the results according to the rendering technique.

Step 4: Build a Diffuse Lighting Model

Diffuse lighting simulates how matte surfaces scatter incoming light equally in all directions. The brightness depends only on the angle between the surface normal and the direction to the light source. This is the Lambertian reflection model, and it forms the base of nearly all real-time lighting in games.

To implement diffuse lighting, you need surface normals at every pixel. Add a normal attribute (in vec3 aNormal) to your vertex data, pass it through the vertex shader as an output (out vec3 vNormal), and use the interpolated value in the fragment shader. Transform the normal by the model matrix's inverse transpose (or the normal matrix) to handle non-uniform scaling correctly: vNormal = mat3(uNormalMatrix) * aNormal.

In the fragment shader, normalize both the surface normal and the light direction vector, then compute the dot product: float NdotL = max(dot(normalize(vNormal), normalize(uLightDir)), 0.0). The max() clamps negative values to zero, preventing surfaces facing away from the light from receiving negative illumination. Multiply the texture color by the diffuse factor and the light color: fragColor = texColor * vec4(uLightColor * NdotL, 1.0).

For multiple lights, compute the diffuse contribution of each light separately and add them together. Each light has its own direction (or position, for point lights), color, and intensity. Point lights require an additional attenuation calculation based on distance: float attenuation = 1.0 / (1.0 + 0.09 * distance + 0.032 * distance * distance).

Step 5: Add Specular Highlights and Ambient Light

Ambient light is a constant illumination that prevents surfaces in shadow from being completely black. In real life, light bounces off walls, floors, and objects to illuminate surfaces that face away from direct light sources. Simulating this accurately requires global illumination, which is too expensive for real time. Instead, add a constant ambient term: vec3 ambient = uAmbientColor * uAmbientStrength. Typical ambient strength values range from 0.05 to 0.2.

Specular highlights simulate the bright reflection you see on shiny surfaces when the viewing angle closely aligns with the reflection of the light source. The Blinn-Phong model computes this efficiently. Calculate the half-vector between the light direction and the view direction: vec3 halfDir = normalize(lightDir + viewDir). The specular factor is pow(max(dot(normal, halfDir), 0.0), uShininess). Higher shininess values (32, 64, 128) produce tighter highlights for glossy materials, while lower values (4, 8, 16) produce broad highlights for rough surfaces.

Combine all three components for the final color: vec3 result = (ambient + diffuse * uLightColor + specular * uLightColor) * texColor.rgb. This is the complete Blinn-Phong lighting model with texture mapping, and it is the standard lighting approach for WebGL games that do not use physically-based rendering.

The view direction requires the camera position as a uniform. Calculate it per pixel in the fragment shader: vec3 viewDir = normalize(uCameraPos - vWorldPos), where vWorldPos is the world-space fragment position passed from the vertex shader. This ensures specular highlights move correctly as the camera orbits around objects.

For games targeting WebGL 2 with deferred rendering, these lighting calculations happen in a separate pass after geometry is written to the G-buffer. Forward rendering (the approach described here) computes lighting during the main geometry pass and is simpler to implement, suitable for scenes with a moderate number of lights.

Key Takeaway

Textures provide surface detail and lighting provides depth and shape. The combination of texture sampling and Blinn-Phong lighting (ambient + diffuse + specular) is the standard rendering approach for WebGL games, giving you visually convincing scenes at real-time frame rates on both desktop and mobile browsers.