Skip to content

Commit 93b42ca

Browse files
committed
feat(soba): add soft-shadows directive for PCSS
1 parent 00072a5 commit 93b42ca

File tree

2 files changed

+195
-0
lines changed

2 files changed

+195
-0
lines changed

libs/soba/misc/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ export * from './lib/intersect';
1212
export * from './lib/preload';
1313
export * from './lib/sampler';
1414
export * from './lib/scale-factor';
15+
export * from './lib/soft-shadows';
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
/*
2+
* Integration and compilation: @N8Programs
3+
* Inspired by:
4+
* https://github.com/mrdoob/three.js/blob/dev/examples/webgl_shadowmap_pcss.html
5+
* https://developer.nvidia.com/gpugems/gpugems2/part-ii-shading-lighting-and-shadows/chapter-17-efficient-soft-edged-shadows-using
6+
* https://developer.download.nvidia.com/whitepapers/2008/PCSS_Integration.pdf
7+
* https://github.com/mrdoob/three.js/blob/master/examples/webgl_shadowmap_pcss.html [spidersharma03]
8+
* https://spline.design/
9+
* Concept:
10+
* https://www.gamedev.net/tutorials/programming/graphics/contact-hardening-soft-shadows-made-fast-r4906/
11+
* Vogel Disk Implementation:
12+
* https://www.shadertoy.com/view/4l3yRM [ashalah]
13+
* High-Frequency Noise Implementation:
14+
* https://www.shadertoy.com/view/tt3fDH [spawner64]
15+
*/
16+
17+
import { Directive, effect, input } from '@angular/core';
18+
import { injectStore } from 'angular-three';
19+
import { mergeInputs } from 'ngxtension/inject-inputs';
20+
import * as THREE from 'three';
21+
22+
/**
23+
* Options for configuring soft shadows using PCSS (Percentage-Closer Soft Shadows).
24+
*/
25+
export interface NgtsSoftShadowsOptions {
26+
/** Size of the light source (the larger the softer the light), default: 25 */
27+
size: number;
28+
/** Number of samples (more samples less noise but more expensive), default: 10 */
29+
samples: number;
30+
/** Depth focus, use it to shift the focal point (where the shadow is the sharpest), default: 0 (the beginning) */
31+
focus: number;
32+
}
33+
34+
const defaultOptions: NgtsSoftShadowsOptions = {
35+
size: 25,
36+
samples: 10,
37+
focus: 0,
38+
};
39+
40+
function pcss(options: NgtsSoftShadowsOptions): string {
41+
const { focus, size, samples } = options;
42+
return `
43+
#define PENUMBRA_FILTER_SIZE float(${size})
44+
#define RGB_NOISE_FUNCTION(uv) (randRGB(uv))
45+
vec3 randRGB(vec2 uv) {
46+
return vec3(
47+
fract(sin(dot(uv, vec2(12.75613, 38.12123))) * 13234.76575),
48+
fract(sin(dot(uv, vec2(19.45531, 58.46547))) * 43678.23431),
49+
fract(sin(dot(uv, vec2(23.67817, 78.23121))) * 93567.23423)
50+
);
51+
}
52+
53+
vec3 lowPassRandRGB(vec2 uv) {
54+
// 3x3 convolution (average)
55+
// can be implemented as separable with an extra buffer for a total of 6 samples instead of 9
56+
vec3 result = vec3(0);
57+
result += RGB_NOISE_FUNCTION(uv + vec2(-1.0, -1.0));
58+
result += RGB_NOISE_FUNCTION(uv + vec2(-1.0, 0.0));
59+
result += RGB_NOISE_FUNCTION(uv + vec2(-1.0, +1.0));
60+
result += RGB_NOISE_FUNCTION(uv + vec2( 0.0, -1.0));
61+
result += RGB_NOISE_FUNCTION(uv + vec2( 0.0, 0.0));
62+
result += RGB_NOISE_FUNCTION(uv + vec2( 0.0, +1.0));
63+
result += RGB_NOISE_FUNCTION(uv + vec2(+1.0, -1.0));
64+
result += RGB_NOISE_FUNCTION(uv + vec2(+1.0, 0.0));
65+
result += RGB_NOISE_FUNCTION(uv + vec2(+1.0, +1.0));
66+
result *= 0.111111111; // 1.0 / 9.0
67+
return result;
68+
}
69+
vec3 highPassRandRGB(vec2 uv) {
70+
// by subtracting the low-pass signal from the original signal, we're being left with the high-pass signal
71+
// hp(x) = x - lp(x)
72+
return RGB_NOISE_FUNCTION(uv) - lowPassRandRGB(uv) + 0.5;
73+
}
74+
75+
76+
vec2 vogelDiskSample(int sampleIndex, int sampleCount, float angle) {
77+
const float goldenAngle = 2.399963f; // radians
78+
float r = sqrt(float(sampleIndex) + 0.5f) / sqrt(float(sampleCount));
79+
float theta = float(sampleIndex) * goldenAngle + angle;
80+
float sine = sin(theta);
81+
float cosine = cos(theta);
82+
return vec2(cosine, sine) * r;
83+
}
84+
float penumbraSize( const in float zReceiver, const in float zBlocker ) { // Parallel plane estimation
85+
return (zReceiver - zBlocker) / zBlocker;
86+
}
87+
float findBlocker(sampler2D shadowMap, vec2 uv, float compare, float angle) {
88+
float texelSize = 1.0 / float(textureSize(shadowMap, 0).x);
89+
float blockerDepthSum = float(${focus});
90+
float blockers = 0.0;
91+
92+
int j = 0;
93+
vec2 offset = vec2(0.);
94+
float depth = 0.;
95+
96+
#pragma unroll_loop_start
97+
for(int i = 0; i < ${samples}; i ++) {
98+
offset = (vogelDiskSample(j, ${samples}, angle) * texelSize) * 2.0 * PENUMBRA_FILTER_SIZE;
99+
depth = unpackRGBAToDepth( texture2D( shadowMap, uv + offset));
100+
if (depth < compare) {
101+
blockerDepthSum += depth;
102+
blockers++;
103+
}
104+
j++;
105+
}
106+
#pragma unroll_loop_end
107+
108+
if (blockers > 0.0) {
109+
return blockerDepthSum / blockers;
110+
}
111+
return -1.0;
112+
}
113+
114+
115+
float vogelFilter(sampler2D shadowMap, vec2 uv, float zReceiver, float filterRadius, float angle) {
116+
float texelSize = 1.0 / float(textureSize(shadowMap, 0).x);
117+
float shadow = 0.0f;
118+
int j = 0;
119+
vec2 vogelSample = vec2(0.0);
120+
vec2 offset = vec2(0.0);
121+
#pragma unroll_loop_start
122+
for (int i = 0; i < ${samples}; i++) {
123+
vogelSample = vogelDiskSample(j, ${samples}, angle) * texelSize;
124+
offset = vogelSample * (1.0 + filterRadius * float(${size}));
125+
shadow += step( zReceiver, unpackRGBAToDepth( texture2D( shadowMap, uv + offset ) ) );
126+
j++;
127+
}
128+
#pragma unroll_loop_end
129+
return shadow * 1.0 / ${samples}.0;
130+
}
131+
132+
float PCSS (sampler2D shadowMap, vec4 coords) {
133+
vec2 uv = coords.xy;
134+
float zReceiver = coords.z; // Assumed to be eye-space z in this code
135+
float angle = highPassRandRGB(gl_FragCoord.xy).r * PI2;
136+
float avgBlockerDepth = findBlocker(shadowMap, uv, zReceiver, angle);
137+
if (avgBlockerDepth == -1.0) {
138+
return 1.0;
139+
}
140+
float penumbraRatio = penumbraSize(zReceiver, avgBlockerDepth);
141+
return vogelFilter(shadowMap, uv, zReceiver, 1.25 * penumbraRatio, angle);
142+
}`;
143+
}
144+
145+
function reset(gl: THREE.WebGLRenderer, scene: THREE.Scene, camera: THREE.Camera): void {
146+
scene.traverse((object) => {
147+
if ((object as THREE.Mesh).material) {
148+
gl.properties.remove((object as THREE.Mesh).material);
149+
((object as THREE.Mesh).material as THREE.Material).dispose?.();
150+
}
151+
});
152+
gl.info.programs!.length = 0;
153+
gl.compile(scene, camera);
154+
}
155+
156+
/**
157+
* A directive that injects Percentage-Closer Soft Shadows (PCSS) into the scene.
158+
*
159+
* PCSS produces contact-hardening soft shadows where shadows are sharper near the
160+
* contact point and softer further away, creating more realistic shadow effects.
161+
*
162+
* @example
163+
* ```html
164+
* <ngts-soft-shadows [options]="{ size: 25, samples: 10, focus: 0 }" />
165+
* ```
166+
*/
167+
@Directive({ selector: 'ngts-soft-shadows' })
168+
export class NgtsSoftShadows {
169+
options = input(defaultOptions, { transform: mergeInputs(defaultOptions) });
170+
171+
constructor() {
172+
const store = injectStore();
173+
174+
effect((onCleanup) => {
175+
const { gl, scene, camera } = store.snapshot;
176+
const options = this.options();
177+
178+
const original = THREE.ShaderChunk.shadowmap_pars_fragment;
179+
THREE.ShaderChunk.shadowmap_pars_fragment = THREE.ShaderChunk.shadowmap_pars_fragment
180+
.replace('#ifdef USE_SHADOWMAP', '#ifdef USE_SHADOWMAP\n' + pcss(options))
181+
.replace(
182+
'#if defined( SHADOWMAP_TYPE_PCF )',
183+
'\nreturn PCSS(shadowMap, shadowCoord);\n#if defined( SHADOWMAP_TYPE_PCF )',
184+
);
185+
186+
reset(gl, scene, camera);
187+
188+
onCleanup(() => {
189+
THREE.ShaderChunk.shadowmap_pars_fragment = original;
190+
reset(gl, scene, camera);
191+
});
192+
});
193+
}
194+
}

0 commit comments

Comments
 (0)