From 360b1bb64a252117c5bf58485107cb64d115579a Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Tue, 28 Apr 2026 03:27:53 +0200 Subject: [PATCH] feat: Implement Screen Space Global Illumination pass --- .../screen-space-global-illumination.ts | 329 ++++++++++++++++++ 1 file changed, 329 insertions(+) create mode 100644 src/rendering/screen-space-global-illumination.ts diff --git a/src/rendering/screen-space-global-illumination.ts b/src/rendering/screen-space-global-illumination.ts new file mode 100644 index 00000000..dae77826 --- /dev/null +++ b/src/rendering/screen-space-global-illumination.ts @@ -0,0 +1,329 @@ +import { + BasicDepthPacking, + Matrix4, + ShaderMaterial, + Texture, + Uniform, + Vector2, + type DepthPackingStrategies, + type PerspectiveCamera, + type WebGLRenderer, + type WebGLRenderTarget +} from "three"; +import { Pass } from "postprocessing"; + +import type { + AdvancedRenderingDynamicGlobalIlluminationQuality, + AdvancedRenderingDynamicGlobalIlluminationSettings +} from "../document/world-settings"; + +const MIN_DYNAMIC_GI_INTENSITY = 0; +const MAX_DYNAMIC_GI_INTENSITY = 4; +const MIN_DYNAMIC_GI_RADIUS = 0.25; +const MAX_DYNAMIC_GI_RADIUS = 8; +const DYNAMIC_GI_MAX_LUMINANCE = 7; + +export interface ResolvedDynamicGlobalIlluminationParameters { + enabled: boolean; + intensity: number; + radius: number; + quality: AdvancedRenderingDynamicGlobalIlluminationQuality; + sliceCount: number; + stepCount: number; + maxLuminance: number; +} + +function clampNumber(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +} + +export function resolveDynamicGlobalIlluminationParameters( + settings: AdvancedRenderingDynamicGlobalIlluminationSettings +): ResolvedDynamicGlobalIlluminationParameters { + const quality = settings.quality; + const intensity = clampNumber( + settings.intensity, + MIN_DYNAMIC_GI_INTENSITY, + MAX_DYNAMIC_GI_INTENSITY + ); + const radius = clampNumber( + settings.radius, + MIN_DYNAMIC_GI_RADIUS, + MAX_DYNAMIC_GI_RADIUS + ); + const enabled = settings.enabled && intensity > 0 && radius > 0; + + return { + enabled, + intensity, + radius, + quality, + sliceCount: quality === "medium" ? 2 : 1, + stepCount: quality === "medium" ? 8 : 6, + maxLuminance: DYNAMIC_GI_MAX_LUMINANCE + }; +} + +const vertexShader = ` +varying vec2 vUv; + +void main() { + vUv = uv; + gl_Position = vec4(position.xy, 1.0, 1.0); +} +`; + +const fragmentShader = ` +#include +#include + +#define MAX_SLICES 2 +#define MAX_STEPS 8 + +uniform sampler2D inputBuffer; +uniform sampler2D depthBuffer; +uniform sampler2D normalBuffer; +uniform mat4 cameraProjectionMatrix; +uniform mat4 cameraProjectionMatrixInverse; +uniform vec2 cameraNearFar; +uniform vec2 resolution; +uniform float intensity; +uniform float radius; +uniform int sliceCount; +uniform int stepCount; +uniform float maxLuminance; + +varying vec2 vUv; + +float saturateFloat(float value) { + return clamp(value, 0.0, 1.0); +} + +float readDepth(const in vec2 uv) { +#if DEPTH_PACKING == 3201 + return unpackRGBAToDepth(texture2D(depthBuffer, uv)); +#else + return texture2D(depthBuffer, uv).r; +#endif +} + +float readLuminance(const in vec3 color) { + return dot(color, vec3(0.2126, 0.7152, 0.0722)); +} + +vec3 readViewNormal(const in vec2 uv) { + return normalize(unpackRGBToNormal(texture2D(normalBuffer, uv).rgb)); +} + +float hash12(vec2 p) { + vec3 p3 = fract(vec3(p.xyx) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +float getViewZ(const in float depth) { + return perspectiveDepthToViewZ(depth, cameraNearFar.x, cameraNearFar.y); +} + +vec3 getViewPosition( + const in vec2 screenPosition, + const in float depth, + const in float viewZ +) { + vec4 clipPosition = vec4(vec3(screenPosition, depth) * 2.0 - 1.0, 1.0); + float clipW = + cameraProjectionMatrix[2][3] * viewZ + cameraProjectionMatrix[3][3]; + clipPosition *= clipW; + return (cameraProjectionMatrixInverse * clipPosition).xyz; +} + +vec3 clampLuminance(vec3 color, float limit) { + float luminance = readLuminance(color); + if (luminance > limit) { + color *= limit / max(luminance, 0.0001); + } + return color; +} + +void main() { + vec4 baseColor = texture2D(inputBuffer, vUv); + float depth = readDepth(vUv); + + if (depth >= 0.9999 || intensity <= 0.0 || radius <= 0.0) { + gl_FragColor = baseColor; + return; + } + + float viewZ = getViewZ(depth); + vec3 viewPosition = getViewPosition(vUv, depth, viewZ); + vec3 viewNormal = readViewNormal(vUv); + + vec2 safeResolution = max(resolution, vec2(1.0)); + float shortestDimension = min(safeResolution.x, safeResolution.y); + float noise = hash12(gl_FragCoord.xy); + float projectedRadius = radius * cameraProjectionMatrix[1][1] * 0.5; + projectedRadius /= max(-viewPosition.z, 0.05); + projectedRadius = clamp(projectedRadius, 2.0 / shortestDimension, 0.35); + + vec3 accumulatedLight = vec3(0.0); + float configuredSampleCount = max(float(sliceCount * stepCount * 2), 1.0); + + for (int sliceIndex = 0; sliceIndex < MAX_SLICES; ++sliceIndex) { + if (sliceIndex >= sliceCount) { + break; + } + + float sliceDenominator = max(float(sliceCount), 1.0); + float angle = (float(sliceIndex) + noise) * PI / sliceDenominator; + vec2 sliceDirection = vec2(cos(angle), sin(angle)); + vec2 uvDirection = normalize( + vec2(sliceDirection.x * safeResolution.y / safeResolution.x, sliceDirection.y) + ); + + for (int sideIndex = 0; sideIndex < 2; ++sideIndex) { + float side = sideIndex == 0 ? 1.0 : -1.0; + float sideJitter = fract(noise + float(sideIndex) * 0.37); + + for (int stepIndex = 0; stepIndex < MAX_STEPS; ++stepIndex) { + if (stepIndex >= stepCount) { + break; + } + + float stepT = (float(stepIndex) + 0.35 + sideJitter * 0.65); + stepT /= max(float(stepCount), 1.0); + float offsetT = pow(stepT, 1.65); + vec2 sampleUv = vUv + uvDirection * side * projectedRadius * offsetT; + + if ( + sampleUv.x <= 0.0 || + sampleUv.x >= 1.0 || + sampleUv.y <= 0.0 || + sampleUv.y >= 1.0 + ) { + continue; + } + + float sampleDepth = readDepth(sampleUv); + if (sampleDepth >= 0.9999) { + continue; + } + + float sampleViewZ = getViewZ(sampleDepth); + vec3 samplePosition = getViewPosition(sampleUv, sampleDepth, sampleViewZ); + vec3 offsetVector = samplePosition - viewPosition; + float distanceToSample = length(offsetVector); + + if (distanceToSample <= 0.015 || distanceToSample > radius) { + continue; + } + + vec3 lightDirection = offsetVector / distanceToSample; + vec3 sampleNormal = readViewNormal(sampleUv); + vec3 sampleColor = texture2D(inputBuffer, sampleUv).rgb; + float sampleLuminance = readLuminance(sampleColor); + + float receiveTerm = saturateFloat(dot(viewNormal, lightDirection)); + float emitTerm = max(saturateFloat(dot(sampleNormal, -lightDirection)), 0.12); + float rangeTerm = pow(saturateFloat(1.0 - distanceToSample / radius), 2.0); + float lumaTerm = smoothstep(0.015, 0.12, sampleLuminance); + float thicknessTerm = smoothstep(0.015, 0.08, distanceToSample); + float contribution = receiveTerm * emitTerm * rangeTerm * lumaTerm * thicknessTerm; + + accumulatedLight += sampleColor * contribution; + } + } + } + + vec3 indirectLight = accumulatedLight * (2.5 / configuredSampleCount) * intensity; + indirectLight = clampLuminance(indirectLight, maxLuminance); + + gl_FragColor = vec4(baseColor.rgb + indirectLight, baseColor.a); +} +`; + +export class ScreenSpaceGlobalIlluminationPass extends Pass { + private readonly camera: PerspectiveCamera; + private readonly material: ShaderMaterial; + private readonly parameters: ResolvedDynamicGlobalIlluminationParameters; + private readonly resolution = new Vector2(1, 1); + private readonly cameraNearFar = new Vector2(); + private readonly cameraProjectionMatrix = new Matrix4(); + private readonly cameraProjectionMatrixInverse = new Matrix4(); + + constructor( + camera: PerspectiveCamera, + normalBuffer: Texture, + parameters: ResolvedDynamicGlobalIlluminationParameters + ) { + super("ScreenSpaceGlobalIlluminationPass"); + + this.camera = camera; + this.parameters = parameters; + this.needsDepthTexture = true; + + this.material = new ShaderMaterial({ + name: "ScreenSpaceGlobalIlluminationMaterial", + defines: { + DEPTH_PACKING: BasicDepthPacking.toFixed(0) + }, + uniforms: { + inputBuffer: new Uniform(null), + depthBuffer: new Uniform(null), + normalBuffer: new Uniform(normalBuffer), + cameraProjectionMatrix: new Uniform(this.cameraProjectionMatrix), + cameraProjectionMatrixInverse: new Uniform( + this.cameraProjectionMatrixInverse + ), + cameraNearFar: new Uniform(this.cameraNearFar), + resolution: new Uniform(this.resolution), + intensity: new Uniform(parameters.intensity), + radius: new Uniform(parameters.radius), + sliceCount: new Uniform(parameters.sliceCount), + stepCount: new Uniform(parameters.stepCount), + maxLuminance: new Uniform(parameters.maxLuminance) + }, + vertexShader, + fragmentShader, + depthWrite: false, + depthTest: false + }); + this.fullscreenMaterial = this.material; + } + + override setDepthTexture( + depthTexture: Texture | null, + depthPacking: DepthPackingStrategies = BasicDepthPacking + ) { + this.material.uniforms.depthBuffer.value = depthTexture; + this.material.defines.DEPTH_PACKING = depthPacking.toFixed(0); + this.material.needsUpdate = true; + } + + override setSize(width: number, height: number) { + this.resolution.set(Math.max(width, 1), Math.max(height, 1)); + } + + override render( + renderer: WebGLRenderer, + inputBuffer: WebGLRenderTarget | null, + outputBuffer: WebGLRenderTarget | null + ) { + if (inputBuffer === null) { + return; + } + + this.camera.updateProjectionMatrix(); + this.cameraNearFar.set(this.camera.near, this.camera.far); + this.cameraProjectionMatrix.copy(this.camera.projectionMatrix); + this.cameraProjectionMatrixInverse.copy(this.camera.projectionMatrixInverse); + this.material.uniforms.inputBuffer.value = inputBuffer.texture; + this.material.uniforms.intensity.value = this.parameters.intensity; + this.material.uniforms.radius.value = this.parameters.radius; + this.material.uniforms.sliceCount.value = this.parameters.sliceCount; + this.material.uniforms.stepCount.value = this.parameters.stepCount; + this.material.uniforms.maxLuminance.value = this.parameters.maxLuminance; + + renderer.setRenderTarget(this.renderToScreen ? null : outputBuffer); + renderer.render(this.scene, this.camera); + } +}