|
| 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