WebGL 阴影概述
阴影是 3D 渲染中增加真实感的重要技术。WebGL 中实现阴影的主要方法是阴影贴图(Shadow Mapping),它是一种基于图像的阴影技术。
阴影贴图原理
阴影贴图的核心思想:
- 第一步:从光源视角渲染场景,生成深度贴图(Depth Map)
- 第二步:从相机视角渲染场景,将每个片段转换到光源空间,比较深度值
- 判断:如果片段在光源空间的深度大于深度贴图中的值,则该片段在阴影中
shell光源视角渲染 → 深度贴图 ↓ 相机视角渲染 → 光源空间坐标 → 深度比较 → 阴影判断
阴影贴图实现步骤
第一步:生成深度贴图
顶点着色器(深度生成)
glsl// shadow_vertex.glsl attribute vec3 a_position; uniform mat4 u_lightSpaceMatrix; // 光源投影矩阵 × 光源视图矩阵 × 模型矩阵 void main() { gl_Position = u_lightSpaceMatrix * vec4(a_position, 1.0); }
片段着色器(深度生成)
glsl// shadow_fragment.glsl // WebGL 1.0 - 需要手动写入深度值 precision mediump float; varying vec4 v_position; void main() { // 手动计算深度值并编码到颜色 float depth = v_position.z / v_position.w; depth = depth * 0.5 + 0.5; // 转换到 [0, 1] // 使用 RGBA 编码深度值(提高精度) 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 - 可以直接使用深度附件 // 片段着色器可以为空,深度自动写入
JavaScript 代码
javascript// 创建深度贴图帧缓冲区 function createDepthFramebuffer(gl, width, height) { const framebuffer = gl.createFramebuffer(); // 创建深度纹理 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 ); // 设置纹理参数 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); // 绑定到帧缓冲区 gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer); gl.framebufferTexture2D( gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, depthTexture, 0 ); // WebGL 1.0 需要颜色附件,即使不写入颜色 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 }; } // 渲染深度贴图 function renderDepthMap(gl, shadowProgram, scene, lightSpaceMatrix) { // 绑定阴影帧缓冲区 gl.bindFramebuffer(gl.FRAMEBUFFER, shadowFramebuffer); gl.viewport(0, 0, shadowMapSize, shadowMapSize); // 清除深度缓冲区 gl.clear(gl.DEPTH_BUFFER_BIT); // 使用深度着色器 gl.useProgram(shadowProgram); // 渲染场景中的每个物体 for (let object of scene.objects) { // 计算光源空间矩阵 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); } // 解绑 gl.bindFramebuffer(gl.FRAMEBUFFER, null); }
第二步:渲染带阴影的场景
顶点着色器
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; // 转换到光源空间 v_lightSpacePos = u_lightSpaceMatrix * vec4(a_position, 1.0); gl_Position = u_projectionMatrix * u_viewMatrix * worldPos; }
片段着色器
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; // 解码深度值(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); } // 计算阴影 float calculateShadow(vec4 lightSpacePos) { // 透视除法 vec3 projCoords = lightSpacePos.xyz / lightSpacePos.w; // 转换到 [0, 1] 范围 projCoords = projCoords * 0.5 + 0.5; // 获取当前片段在光源视角的深度 float currentDepth = projCoords.z; // 获取深度贴图中的深度值 float closestDepth = texture2D(u_shadowMap, projCoords.xy).r; // 阴影偏移(解决阴影痤疮问题) float bias = 0.005; // 比较深度 float shadow = currentDepth - bias > closestDepth ? 1.0 : 0.0; // 检查是否在阴影贴图范围外 if (projCoords.z > 1.0) { shadow = 0.0; } return shadow; } // PCF 软阴影 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 采样 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; // 环境光 vec3 ambient = 0.3 * color; // 漫反射 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; // 计算阴影 float shadow = calculateShadowPCF(v_lightSpacePos); // 组合光照(阴影区域只保留环境光) vec3 lighting = ambient + (1.0 - shadow) * (diffuse); gl_FragColor = vec4(lighting * color, 1.0); }
阴影常见问题及解决方案
1. 阴影痤疮(Shadow Acne)
问题:表面出现条纹状自阴影伪影
原因:深度精度限制和浮点数误差
解决方案:
glsl// 添加阴影偏移 float bias = max(0.05 * (1.0 - dot(normal, lightDir)), 0.005); float shadow = currentDepth - bias > closestDepth ? 1.0 : 0.0;
2. 彼得潘效应(Peter Panning)
问题:阴影与物体分离,看起来像在漂浮
原因:偏移值过大
解决方案:
- 只在渲染深度贴图时启用正面剔除
- 使用较小的偏移值
javascript// 渲染深度贴图时启用正面剔除 gl.enable(gl.CULL_FACE); gl.cullFace(gl.FRONT); // 剔除正面,只渲染背面 // 渲染正常场景时恢复 gl.cullFace(gl.BACK);
3. 阴影锯齿
问题:阴影边缘出现锯齿
解决方案:
- 使用 PCF(Percentage Closer Filtering)软阴影
- 增加阴影贴图分辨率
- 使用级联阴影贴图(CSM)
PCF 软阴影实现
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)); // 采样周围像素 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 采样(更自然的软阴影) vec2 poissonDisk[16] = vec2[]( vec2(-0.94201624, -0.39906216), vec2(0.94558609, -0.76890725), // ... 更多采样点 ); 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)
对于大场景,使用多个不同分辨率的阴影贴图:
javascript// 创建多个级联 const cascades = [ { near: 0.1, far: 10, resolution: 2048 }, { near: 10, far: 50, resolution: 1024 }, { near: 50, far: 200, resolution: 512 } ]; // 根据距离选择合适的级联 function getCascadeIndex(viewSpaceZ) { for (let i = 0; i < cascades.length; i++) { if (viewSpaceZ < cascades[i].far) { return i; } } return cascades.length - 1; }
点光源阴影(立方体贴图阴影)
javascript// 创建立方体贴图 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 ); } // 渲染 6 个面 const shadowTransforms = [ // +X, -X, +Y, -Y, +Z, -Z 方向的投影矩阵 ];
性能优化建议
-
阴影贴图分辨率:
- 移动端:512x512 或 1024x1024
- 桌面端:2048x2048 或更高
-
更新频率:
- 静态光源可以只生成一次阴影贴图
- 动态光源每帧更新
-
阴影距离:
- 限制阴影渲染距离
- 使用级联阴影贴图
-
软阴影性能:
- PCF 3x3 是性能和质量的平衡
- Poisson Disk 采样质量更好但性能开销大