5月29日 01:40
How is shadow implemented in WebGL?
WebGL Shadow Overview
Shadows are an important technique in 3D rendering for increasing realism. The main method for implementing shadows in WebGL is Shadow Mapping, which is an image-based shadow technique.
Shadow Mapping Principle
The core idea of shadow mapping:
- Step 1: Render the scene from the light's perspective to generate a Depth Map
- Step 2: Render the scene from the camera's perspective, transform each fragment to light space, and compare depth values
- Judgment: If the fragment's depth in light space is greater than the value in the depth map, the fragment is in shadow
shellLight perspective rendering → Depth Map ↓ Camera perspective rendering → Light space coordinates → Depth comparison → Shadow judgment
Shadow Mapping Implementation Steps
Step 1: Generate Depth Map
Vertex Shader (Depth Generation)
glsl// shadow_vertex.glsl attribute vec3 a_position; uniform mat4 u_lightSpaceMatrix; // Light projection matrix × Light view matrix × Model matrix void main() { gl_Position = u_lightSpaceMatrix * vec4(a_position, 1.0); }
Fragment Shader (Depth Generation)
glsl// shadow_fragment.glsl // WebGL 1.0 - Need to manually write depth value precision mediump float; varying vec4 v_position; void main() { // Manually calculate depth value and encode to color float depth = v_position.z / v_position.w; depth = depth * 0.5 + 0.5; // Convert to [0, 1] // Use RGBA to encode depth value (improve precision) vec4 encodedDepth; encodedDepth.r = depth; encodedDepth.g = fract(depth * 256.0); encodedDepth.b = fract(depth * 65536.0); encodedDepth.a = fract(depth * 16777216.0); gl_FragColor = encodedDepth; } // WebGL 2.0 - Can directly use depth attachment // Fragment shader can be empty, depth written automatically
JavaScript Code
javascript// Create depth map framebuffer function createDepthFramebuffer(gl, width, height) { const framebuffer = gl.createFramebuffer(); // Create depth texture const depthTexture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, depthTexture); gl.texImage2D( gl.TEXTURE_2D, 0, gl.DEPTH_COMPONENT, // WebGL 2.0 // gl.DEPTH_COMPONENT16, // WebGL 1.0 width, height, 0, gl.DEPTH_COMPONENT, gl.UNSIGNED_SHORT, null ); // Set texture parameters gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); // Bind to framebuffer gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer); gl.framebufferTexture2D( gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, depthTexture, 0 ); // WebGL 1.0 needs color attachment even if not writing color if (!isWebGL2) { const colorBuffer = gl.createRenderbuffer(); gl.bindRenderbuffer(gl.RENDERBUFFER, colorBuffer); gl.renderbufferStorage(gl.RENDERBUFFER, gl.RGBA4, width, height); gl.framebufferRenderbuffer( gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.RENDERBUFFER, colorBuffer ); } gl.bindFramebuffer(gl.FRAMEBUFFER, null); return { framebuffer, depthTexture }; } // Render depth map function renderDepthMap(gl, shadowProgram, scene, lightSpaceMatrix) { // Bind shadow framebuffer gl.bindFramebuffer(gl.FRAMEBUFFER, shadowFramebuffer); gl.viewport(0, 0, shadowMapSize, shadowMapSize); // Clear depth buffer gl.clear(gl.DEPTH_BUFFER_BIT); // Use depth shader gl.useProgram(shadowProgram); // Render each object in scene for (let object of scene.objects) { // Calculate light space matrix const modelMatrix = object.getModelMatrix(); const lightSpaceMatrix = mat4.create(); mat4.multiply(lightSpaceMatrix, lightProjectionMatrix, lightViewMatrix); mat4.multiply(lightSpaceMatrix, lightSpaceMatrix, modelMatrix); gl.uniformMatrix4fv( gl.getUniformLocation(shadowProgram, 'u_lightSpaceMatrix'), false, lightSpaceMatrix ); object.draw(gl); } // Unbind gl.bindFramebuffer(gl.FRAMEBUFFER, null); }
Step 2: Render Scene with Shadows
Vertex Shader
glslattribute vec3 a_position; attribute vec3 a_normal; attribute vec2 a_texCoord; uniform mat4 u_modelMatrix; uniform mat4 u_viewMatrix; uniform mat4 u_projectionMatrix; uniform mat4 u_lightSpaceMatrix; varying vec3 v_worldPos; varying vec3 v_normal; varying vec2 v_texCoord; varying vec4 v_lightSpacePos; void main() { vec4 worldPos = u_modelMatrix * vec4(a_position, 1.0); v_worldPos = worldPos.xyz; v_normal = mat3(u_modelMatrix) * a_normal; v_texCoord = a_texCoord; // Transform to light space v_lightSpacePos = u_lightSpaceMatrix * vec4(a_position, 1.0); gl_Position = u_projectionMatrix * u_viewMatrix * worldPos; }
Fragment Shader
glslprecision mediump float; varying vec3 v_worldPos; varying vec3 v_normal; varying vec2 v_texCoord; varying vec4 v_lightSpacePos; uniform sampler2D u_shadowMap; uniform vec3 u_lightPos; uniform vec3 u_viewPos; uniform vec3 u_lightColor; // Decode depth value (WebGL 1.0) float decodeDepth(vec4 rgba) { const vec4 bitShift = vec4(1.0, 1.0/256.0, 1.0/65536.0, 1.0/16777216.0); return dot(rgba, bitShift); } // Calculate shadow float calculateShadow(vec4 lightSpacePos) { // Perspective division vec3 projCoords = lightSpacePos.xyz / lightSpacePos.w; // Convert to [0, 1] range projCoords = projCoords * 0.5 + 0.5; // Get current fragment depth from light's perspective float currentDepth = projCoords.z; // Get depth value from depth map float closestDepth = texture2D(u_shadowMap, projCoords.xy).r; // Shadow bias (solves shadow acne problem) float bias = 0.005; // Compare depths float shadow = currentDepth - bias > closestDepth ? 1.0 : 0.0; // Check if outside shadow map range if (projCoords.z > 1.0) { shadow = 0.0; } return shadow; } // PCF soft shadow float calculateShadowPCF(vec4 lightSpacePos) { vec3 projCoords = lightSpacePos.xyz / lightSpacePos.w; projCoords = projCoords * 0.5 + 0.5; float currentDepth = projCoords.z; float bias = 0.005; float shadow = 0.0; // 3x3 PCF sampling vec2 texelSize = 1.0 / vec2(1024.0, 1024.0); for (int x = -1; x <= 1; ++x) { for (int y = -1; y <= 1; ++y) { float pcfDepth = texture2D(u_shadowMap, projCoords.xy + vec2(x, y) * texelSize).r; shadow += currentDepth - bias > pcfDepth ? 1.0 : 0.0; } } shadow /= 9.0; if (projCoords.z > 1.0) { shadow = 0.0; } return shadow; } void main() { vec3 color = texture2D(u_texture, v_texCoord).rgb; // Ambient vec3 ambient = 0.3 * color; // Diffuse vec3 lightDir = normalize(u_lightPos - v_worldPos); vec3 normal = normalize(v_normal); float diff = max(dot(normal, lightDir), 0.0); vec3 diffuse = diff * u_lightColor; // Calculate shadow float shadow = calculateShadowPCF(v_lightSpacePos); // Combine lighting (shadow areas only keep ambient) vec3 lighting = ambient + (1.0 - shadow) * (diffuse); gl_FragColor = vec4(lighting * color, 1.0); }
Common Shadow Problems and Solutions
1. Shadow Acne
Problem: Striped self-shadow artifacts on surfaces
Cause: Depth precision limitations and floating-point errors
Solution:
glsl// Add shadow bias float bias = max(0.05 * (1.0 - dot(normal, lightDir)), 0.005); float shadow = currentDepth - bias > closestDepth ? 1.0 : 0.0;
2. Peter Panning
Problem: Shadows separate from objects, appearing to float
Cause: Bias value too large
Solution:
- Only enable front face culling when rendering depth map
- Use smaller bias value
javascript// Enable front face culling when rendering depth map gl.enable(gl.CULL_FACE); gl.cullFace(gl.FRONT); // Cull front faces, only render back faces // Restore when rendering normal scene gl.cullFace(gl.BACK);
3. Shadow Aliasing
Problem: Jagged edges on shadows
Solution:
- Use PCF (Percentage Closer Filtering) soft shadows
- Increase shadow map resolution
- Use Cascaded Shadow Maps (CSM)
PCF Soft Shadow Implementation
glslfloat calculateShadowPCF(vec4 lightSpacePos, int sampleRadius) { vec3 projCoords = lightSpacePos.xyz / lightSpacePos.w; projCoords = projCoords * 0.5 + 0.5; float currentDepth = projCoords.z; float bias = 0.005; float shadow = 0.0; vec2 texelSize = 1.0 / vec2(textureSize(u_shadowMap, 0)); // Sample surrounding pixels for (int x = -sampleRadius; x <= sampleRadius; ++x) { for (int y = -sampleRadius; y <= sampleRadius; ++y) { vec2 offset = vec2(float(x), float(y)) * texelSize; float closestDepth = texture(u_shadowMap, projCoords.xy + offset).r; shadow += currentDepth - bias > closestDepth ? 1.0 : 0.0; } } int sampleCount = (sampleRadius * 2 + 1) * (sampleRadius * 2 + 1); shadow /= float(sampleCount); return shadow; } // Poisson Disk sampling (more natural soft shadows) vec2 poissonDisk[16] = vec2[]( vec2(-0.94201624, -0.39906216), vec2(0.94558609, -0.76890725), // ... more sample points ); float calculateShadowPoisson(vec4 lightSpacePos) { vec3 projCoords = lightSpacePos.xyz / lightSpacePos.w; projCoords = projCoords * 0.5 + 0.5; float currentDepth = projCoords.z; float bias = 0.005; float shadow = 0.0; for (int i = 0; i < 16; i++) { float closestDepth = texture(u_shadowMap, projCoords.xy + poissonDisk[i] / 700.0).r; shadow += currentDepth - bias > closestDepth ? 1.0 : 0.0; } return shadow / 16.0; }
Cascaded Shadow Maps
For large scenes, use multiple shadow maps with different resolutions:
javascript// Create multiple cascades const cascades = [ { near: 0.1, far: 10, resolution: 2048 }, { near: 10, far: 50, resolution: 1024 }, { near: 50, far: 200, resolution: 512 } ]; // Select appropriate cascade based on distance function getCascadeIndex(viewSpaceZ) { for (let i = 0; i < cascades.length; i++) { if (viewSpaceZ < cascades[i].far) { return i; } } return cascades.length - 1; }
Point Light Shadows (Cubemap Shadows)
javascript// Create cubemap const depthCubemap = gl.createTexture(); gl.bindTexture(gl.TEXTURE_CUBE_MAP, depthCubemap); for (let i = 0; i < 6; i++) { gl.texImage2D( gl.TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, gl.DEPTH_COMPONENT, shadowSize, shadowSize, 0, gl.DEPTH_COMPONENT, gl.UNSIGNED_SHORT, null ); } // Render 6 faces const shadowTransforms = [ // Projection matrices for +X, -X, +Y, -Y, +Z, -Z directions ];
Performance Optimization Suggestions
-
Shadow Map Resolution:
- Mobile: 512x512 or 1024x1024
- Desktop: 2048x2048 or higher
-
Update Frequency:
- Static light sources can generate shadow map only once
- Dynamic light sources update every frame
-
Shadow Distance:
- Limit shadow rendering distance
- Use cascaded shadow maps
-
Soft Shadow Performance:
- PCF 3x3 is a balance between performance and quality
- Poisson Disk sampling has better quality but higher performance cost