Soft shadow
Implement soft shadow using PCF and PCSS
Set up
Operating & compiling environment:
- Visual studio code (Windows 11) & Live server
Library:
- WebGL
Code
Implementation
Hard Shadow Mapping
Two pass approaches:
The 1st Pass: render a depth map from the light source which is also known as ShadowMap.
The 2nd Pass: render the real scene from the current camera and transform pixel point to the light source space, take its depth in the light source space with the same uv coordinates recorded in ShadowMap for comparison. If the depth is is greater than the depth in ShadowMap, the point is in the shadow.
// WebGLRenderer.js
// Shadow pass
// Create a shadow map stored in shadowMeshes[]
if (this.lights[l].entity.hasShadowMap == true) {
for (let i = 0; i < this.shadowMeshes.length; i++) {
this.shadowMeshes[i].draw(this.camera);
}
}
// Camera pass
for (let i = 0; i < this.meshes.length; i++) {
this.gl.useProgram(this.meshes[i].shader.program.glShaderProgram);
this.gl.uniform3fv(this.meshes[i].shader.program.uniforms.uLightPos, this.lights[l].entity.lightPos);
this.meshes[i].draw(this.camera);
}
This is a problem caused by the limited resolution of the shadow map. And we introduce the bias to move pixels closer to the light source during the shadow test. Since simple bias can also cause Peter-panning problem, we use the adaptive shadow bias algorithm.
\[A=(1+ceil(R))\frac{frustumSize}{shadowMapSize * 2}\] \[B = 1-dot(lightDir,normal)\]
// phongFragment.glsl
#define SHADOW_MAP_SIZE 2048.
#define FRUSTUM_SIZE 400.
float getShadowBias(float c, float filterRadiusUV){
vec3 normal = normalize(vNormal);
vec3 lightDir = normalize(uLightPos - vFragPos);
float fragSize = (1. + ceil(filterRadiusUV)) * (FRUSTUM_SIZE / SHADOW_MAP_SIZE / 2.);
return max(fragSize, fragSize * (1.0 - dot(normal, lightDir))) * c;
}
Use the shadow map:
// phongFragment.glsl
// Use shadow map
float useShadowMap(sampler2D shadowMap, vec4 shadowCoord, float biasC, float filterRadiusUV){
// The pack function can encode a [0,1) float value into the four RGBA channels.
// The purpose of unpack function is to decode
float depth = unpack(texture2D(shadowMap, shadowCoord.xy));
float cur_depth = shadowCoord.z;
float bias = getShadowBias(biasC, filterRadiusUV);
if(cur_depth - bias >= depth + EPS) return 0.;
else return 1.0;
}
void main(void) {
vec3 shadowCoord = vPositionFromLight.xyz / vPositionFromLight.w;
//Transfrom the NDC coordinate to coordinate[0,1]
shadowCoord.xyz = (shadowCoord.xyz + 1.0) / 2.0;
float visibility;
float bias = .4;
visibility = useShadowMap(uShadowMap, vec4(shadowCoord, 1.0), bias, 0.);
vec3 phongColor = blinnPhong();
gl_FragColor = vec4(phongColor * visibility, 1.0);
}
Percentage Closer Filtering (PCF)
PCF can sum up the results of depth comparison after multiple ShadowMap sampling and averages them to get a result, and uses it as a new visibility item, which can make the shadow boundary become soft.
float PCF(sampler2D shadowMap, vec4 coords, float biasC, float filterRadiusUV) {
// poissonDiskSamples function is used for random sampling
poissonDiskSamples(coords.xy);
float visibility = 0.0;
for(int i = 0; i < NUM_SAMPLES; i++) {
vec2 offset = poissonDisk[i] * filterRadiusUV;
float shadowDepth = useShadowMap(shadowMap, coords + vec4(offset, 0., 0.), biasC, filterRadiusUV);
if(coords.z > shadowDepth + EPS) {
visibility++;
}
}
return 1.0 - visibility / float(NUM_SAMPLES);
}
Percentage Closer Soft Shadows (PCSS)
In PCSS, the further a point is from the blocker, the fainter the shadow appears, and based on similar triangles, we can derive the formula:
\[w_{Penumbra} = (d_{Receiver}-d_{Blocker}) \cdot w_{Light} / d_{Blocker}\]For \(d_{Blocker}\), we use the approach in the following picture to get the average blocker depth.
Result
Hard Shadow with bias | Hard Shadow without bias | |
---|---|---|
Hard Shadow | PCF | PCSS |
---|---|---|
Final result |
---|