乐闻世界logo
搜索文章和话题

WebGL 中的阴影(Shadow)是如何实现的?

3月6日 21:57

WebGL 阴影概述

阴影是 3D 渲染中增加真实感的重要技术。WebGL 中实现阴影的主要方法是阴影贴图(Shadow Mapping),它是一种基于图像的阴影技术。

阴影贴图原理

阴影贴图的核心思想:

  1. 第一步:从光源视角渲染场景,生成深度贴图(Depth Map)
  2. 第二步:从相机视角渲染场景,将每个片段转换到光源空间,比较深度值
  3. 判断:如果片段在光源空间的深度大于深度贴图中的值,则该片段在阴影中
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); }

第二步:渲染带阴影的场景

顶点着色器

glsl
attribute 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; }

片段着色器

glsl
precision 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 软阴影实现

glsl
float 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 方向的投影矩阵 ];

性能优化建议

  1. 阴影贴图分辨率

    • 移动端:512x512 或 1024x1024
    • 桌面端:2048x2048 或更高
  2. 更新频率

    • 静态光源可以只生成一次阴影贴图
    • 动态光源每帧更新
  3. 阴影距离

    • 限制阴影渲染距离
    • 使用级联阴影贴图
  4. 软阴影性能

    • PCF 3x3 是性能和质量的平衡
    • Poisson Disk 采样质量更好但性能开销大
标签:WebGL