diff --git a/src/rendering/screen-space-god-rays.ts b/src/rendering/screen-space-god-rays.ts new file mode 100644 index 00000000..f3d8aff9 --- /dev/null +++ b/src/rendering/screen-space-god-rays.ts @@ -0,0 +1,425 @@ +import { + BasicDepthPacking, + Color, + ShaderMaterial, + Texture, + Uniform, + Vector2, + Vector3, + type DepthPackingStrategies, + type PerspectiveCamera, + type WebGLRenderer, + type WebGLRenderTarget +} from "three"; +import { Pass } from "postprocessing"; + +import type { Vec3 } from "../core/vector"; +import type { + AdvancedRenderingGodRaysSettings, + AdvancedRenderingSettings, + WorldSunLightSettings +} from "../document/world-settings"; + +const MIN_CELESTIAL_LIGHT_INTENSITY = 1e-4; +const MAX_GOD_RAYS_INTENSITY = 3; +const MAX_GOD_RAYS_EXPOSURE = 2; +const MAX_GOD_RAYS_DENSITY = 1.5; +const MIN_GOD_RAYS_SAMPLES = 8; +const MAX_GOD_RAYS_SAMPLES = 64; +const LIGHT_OFFSCREEN_FADE_START = 1; +const LIGHT_OFFSCREEN_FADE_END = 1.35; + +export interface ResolvedGodRaysParameters { + enabled: boolean; + intensity: number; + decay: number; + exposure: number; + density: number; + samples: number; +} + +export interface ScreenSpaceGodRaysLightSource { + direction: Vec3 | null; + colorHex: string; + intensity: number; +} + +export interface ScreenSpaceGodRaysLightProjection { + screenPosition: { + x: number; + y: number; + }; + visibility: number; +} + +function clampNumber(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +} + +function finiteOr(value: number, fallback: number) { + return Number.isFinite(value) ? value : fallback; +} + +function smoothstep(edge0: number, edge1: number, value: number) { + const t = clampNumber((value - edge0) / (edge1 - edge0), 0, 1); + return t * t * (3 - 2 * t); +} + +function isFiniteVec3(vector: Vec3 | null): vector is Vec3 { + return ( + vector !== null && + Number.isFinite(vector.x) && + Number.isFinite(vector.y) && + Number.isFinite(vector.z) + ); +} + +export function createScreenSpaceGodRaysLightSource(): ScreenSpaceGodRaysLightSource { + return { + direction: null, + colorHex: "#ffffff", + intensity: 0 + }; +} + +export function syncScreenSpaceGodRaysLightSource( + target: ScreenSpaceGodRaysLightSource, + light: WorldSunLightSettings | null +) { + if ( + light === null || + light.intensity <= MIN_CELESTIAL_LIGHT_INTENSITY || + !isFiniteVec3(light.direction) + ) { + target.direction = null; + target.colorHex = "#ffffff"; + target.intensity = 0; + return; + } + + target.direction = { + x: light.direction.x, + y: light.direction.y, + z: light.direction.z + }; + target.colorHex = light.colorHex; + target.intensity = light.intensity; +} + +export function resolveGodRaysParameters( + settings: AdvancedRenderingGodRaysSettings +): ResolvedGodRaysParameters { + const intensity = clampNumber( + finiteOr(settings.intensity, 0), + 0, + MAX_GOD_RAYS_INTENSITY + ); + const decay = clampNumber(finiteOr(settings.decay, 0), 0, 1); + const exposure = clampNumber( + finiteOr(settings.exposure, 0), + 0, + MAX_GOD_RAYS_EXPOSURE + ); + const density = clampNumber( + finiteOr(settings.density, 0), + 0, + MAX_GOD_RAYS_DENSITY + ); + const samples = Math.round( + clampNumber( + finiteOr(settings.samples, MIN_GOD_RAYS_SAMPLES), + MIN_GOD_RAYS_SAMPLES, + MAX_GOD_RAYS_SAMPLES + ) + ); + + return { + enabled: + settings.enabled && + intensity > 0 && + exposure > 0 && + density > 0 && + samples > 0, + intensity, + decay, + exposure, + density, + samples + }; +} + +export function shouldApplyGodRays(settings: AdvancedRenderingSettings) { + return settings.enabled && resolveGodRaysParameters(settings.godRays).enabled; +} + +export function projectScreenSpaceGodRaysLight( + camera: PerspectiveCamera, + lightSource: ScreenSpaceGodRaysLightSource +): ScreenSpaceGodRaysLightProjection | null { + if ( + lightSource.intensity <= MIN_CELESTIAL_LIGHT_INTENSITY || + !isFiniteVec3(lightSource.direction) + ) { + return null; + } + + const direction = new Vector3( + lightSource.direction.x, + lightSource.direction.y, + lightSource.direction.z + ); + + if (direction.lengthSq() <= 1e-8) { + return null; + } + + direction.normalize(); + camera.updateMatrixWorld(); + camera.updateProjectionMatrix(); + + const cameraPosition = new Vector3().setFromMatrixPosition( + camera.matrixWorld + ); + const projectionDistance = Math.max( + camera.near + 1, + Math.min(camera.far * 0.5, 500) + ); + const worldPosition = cameraPosition + .clone() + .add(direction.multiplyScalar(projectionDistance)); + const viewPosition = worldPosition.clone().applyMatrix4( + camera.matrixWorldInverse + ); + + if (viewPosition.z >= -camera.near) { + return null; + } + + const ndcPosition = worldPosition.clone().project(camera); + + if ( + !Number.isFinite(ndcPosition.x) || + !Number.isFinite(ndcPosition.y) + ) { + return null; + } + + const maxAxisDistance = Math.max( + Math.abs(ndcPosition.x), + Math.abs(ndcPosition.y) + ); + const visibility = + 1 - + smoothstep( + LIGHT_OFFSCREEN_FADE_START, + LIGHT_OFFSCREEN_FADE_END, + maxAxisDistance + ); + + if (visibility <= 0) { + return null; + } + + return { + screenPosition: { + x: ndcPosition.x * 0.5 + 0.5, + y: ndcPosition.y * 0.5 + 0.5 + }, + visibility + }; +} + +const vertexShader = ` +varying vec2 vUv; + +void main() { + vUv = uv; + gl_Position = vec4(position.xy, 1.0, 1.0); +} +`; + +const fragmentShader = ` +#include + +#define MAX_GOD_RAYS_SAMPLES 64 + +uniform sampler2D inputBuffer; +uniform sampler2D depthBuffer; +uniform vec2 lightPosition; +uniform vec3 lightColor; +uniform float sourceIntensity; +uniform float intensity; +uniform float decay; +uniform float exposure; +uniform float density; +uniform int sampleCount; + +varying vec2 vUv; + +float readDepth(const in vec2 uv) { +#if DEPTH_PACKING == 3201 + return unpackRGBAToDepth(texture2D(depthBuffer, uv)); +#else + return texture2D(depthBuffer, uv).r; +#endif +} + +float readLuminance(vec3 color) { + return dot(color, vec3(0.2126, 0.7152, 0.0722)); +} + +void main() { + vec4 baseColor = texture2D(inputBuffer, vUv); + + if ( + sourceIntensity <= 0.0 || + intensity <= 0.0 || + exposure <= 0.0 || + density <= 0.0 || + sampleCount <= 0 + ) { + gl_FragColor = baseColor; + return; + } + + vec2 delta = (lightPosition - vUv) * density / max(float(sampleCount), 1.0); + vec2 sampleUv = vUv; + vec3 accumulatedLight = vec3(0.0); + float illuminationDecay = 1.0; + + for (int sampleIndex = 0; sampleIndex < MAX_GOD_RAYS_SAMPLES; ++sampleIndex) { + if (sampleIndex >= sampleCount) { + break; + } + + sampleUv += delta; + + if ( + sampleUv.x < 0.0 || + sampleUv.x > 1.0 || + sampleUv.y < 0.0 || + sampleUv.y > 1.0 + ) { + illuminationDecay *= decay; + continue; + } + + float depth = readDepth(sampleUv); + float backgroundMask = smoothstep(0.9975, 1.0, depth); + + if (backgroundMask <= 0.0) { + illuminationDecay *= decay; + continue; + } + + vec3 sampleColor = texture2D(inputBuffer, sampleUv).rgb; + float luminance = readLuminance(sampleColor); + float brightness = smoothstep(0.025, 0.75, luminance); + float contribution = backgroundMask * (0.32 + brightness * 0.68) * illuminationDecay; + accumulatedLight += mix(lightColor, sampleColor, 0.35) * contribution; + illuminationDecay *= decay; + } + + vec3 shaftColor = + accumulatedLight * + exposure * + intensity * + sourceIntensity / + max(float(sampleCount), 1.0); + + gl_FragColor = vec4(baseColor.rgb + shaftColor, baseColor.a); +} +`; + +export class ScreenSpaceGodRaysPass extends Pass { + private readonly sourceCamera: PerspectiveCamera; + private readonly lightSource: ScreenSpaceGodRaysLightSource; + private readonly parameters: ResolvedGodRaysParameters; + private readonly material: ShaderMaterial; + private readonly lightPosition = new Vector2(0.5, 0.5); + private readonly lightColor = new Color("#ffffff"); + + constructor( + camera: PerspectiveCamera, + lightSource: ScreenSpaceGodRaysLightSource, + parameters: ResolvedGodRaysParameters + ) { + super("ScreenSpaceGodRaysPass"); + + this.sourceCamera = camera; + this.lightSource = lightSource; + this.parameters = parameters; + this.needsDepthTexture = true; + + this.material = new ShaderMaterial({ + name: "ScreenSpaceGodRaysMaterial", + defines: { + DEPTH_PACKING: BasicDepthPacking.toFixed(0) + }, + uniforms: { + inputBuffer: new Uniform(null), + depthBuffer: new Uniform(null), + lightPosition: new Uniform(this.lightPosition), + lightColor: new Uniform(this.lightColor), + sourceIntensity: new Uniform(0), + intensity: new Uniform(parameters.intensity), + decay: new Uniform(parameters.decay), + exposure: new Uniform(parameters.exposure), + density: new Uniform(parameters.density), + sampleCount: new Uniform(parameters.samples) + }, + 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 render( + renderer: WebGLRenderer, + inputBuffer: WebGLRenderTarget | null, + outputBuffer: WebGLRenderTarget | null + ) { + if (inputBuffer === null) { + return; + } + + const projection = projectScreenSpaceGodRaysLight( + this.sourceCamera, + this.lightSource + ); + const sourceIntensity = + projection === null + ? 0 + : Math.min(this.lightSource.intensity, 4) * projection.visibility; + + if (projection !== null) { + this.lightPosition.set( + projection.screenPosition.x, + projection.screenPosition.y + ); + } + + this.lightColor.set(this.lightSource.colorHex); + this.material.uniforms.inputBuffer.value = inputBuffer.texture; + this.material.uniforms.sourceIntensity.value = sourceIntensity; + this.material.uniforms.intensity.value = this.parameters.intensity; + this.material.uniforms.decay.value = this.parameters.decay; + this.material.uniforms.exposure.value = this.parameters.exposure; + this.material.uniforms.density.value = this.parameters.density; + this.material.uniforms.sampleCount.value = this.parameters.samples; + + renderer.setRenderTarget(this.renderToScreen ? null : outputBuffer); + renderer.render(this.scene, this.camera); + } +}