├── .npmignore ├── .gitignore ├── demo ├── static │ ├── demo_dirlight.glb │ ├── demo_pointlight.glb │ └── index.html └── src │ ├── index.ts │ ├── BaseDemo.ts │ ├── sponzaDemo.ts │ ├── dirlightDemo.ts │ └── pointlightDemo.ts ├── src ├── godrays.vert ├── compositor.vert ├── bilateralFilter.ts ├── compositor.frag ├── bilateralFilter.frag ├── compositorPass.ts ├── illumPass.ts ├── godrays.frag └── index.ts ├── index.d.ts ├── .prettierrc ├── tsconfig.json ├── LICENSE ├── package.json ├── .github └── workflows │ └── cd.yml ├── esbuild.mjs └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | demo 2 | .github 3 | src 4 | public 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | public 4 | !public/.gitkeep 5 | yarn-error.log 6 | -------------------------------------------------------------------------------- /demo/static/demo_dirlight.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ameobea/three-good-godrays/HEAD/demo/static/demo_dirlight.glb -------------------------------------------------------------------------------- /demo/static/demo_pointlight.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ameobea/three-good-godrays/HEAD/demo/static/demo_pointlight.glb -------------------------------------------------------------------------------- /src/godrays.vert: -------------------------------------------------------------------------------- 1 | /* 2 | * Code taken from this demo: https://n8python.github.io/goodGodRays/ 3 | * By: https://github.com/n8python 4 | * 5 | * With cleanup and minor changes 6 | */ 7 | 8 | varying vec2 vUv; 9 | 10 | void main() { 11 | vUv = position.xy * 0.5 + 0.5; 12 | gl_Position = vec4(position.xy, 1.0, 1.0); 13 | } 14 | -------------------------------------------------------------------------------- /src/compositor.vert: -------------------------------------------------------------------------------- 1 | /* 2 | * Code taken from this demo: https://n8python.github.io/goodGodRays/ 3 | * By: https://github.com/n8python 4 | * 5 | * With cleanup and minor changes 6 | */ 7 | 8 | varying vec2 vUv; 9 | 10 | void main() { 11 | vUv = uv; 12 | gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); 13 | } 14 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.wgsl" { 2 | const value: string; 3 | export default value; 4 | } 5 | 6 | declare module "*.glsl" { 7 | const value: string; 8 | export default value; 9 | } 10 | 11 | declare module "*.frag" { 12 | const value: string; 13 | export default value; 14 | } 15 | 16 | declare module "*.vert" { 17 | const value: string; 18 | export default value; 19 | } 20 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "jsxSingleQuote": true, 3 | "printWidth": 100, 4 | "singleQuote": true, 5 | "trailingComma": "es5", 6 | "arrowParens": "avoid", 7 | "importOrder": ["", "", "src/(.*)", "^[./].*$"], 8 | "importOrderSeparation": false, 9 | "importOrderSortSpecifiers": true, 10 | "importOrderCaseInsensitive": true, 11 | "importOrderParserPlugins": ["typescript", "jsx", "decorators-legacy"], 12 | "importOrderBuiltinModulesToTop": true, 13 | "parser": "babel-ts" 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "isolatedModules": true, 4 | "skipLibCheck": true, 5 | "module": "es2020", 6 | "target": "es2018", 7 | "resolveJsonModule": true, 8 | "allowJs": true, 9 | "moduleResolution": "node", 10 | "outDir": "build", 11 | "lib": ["esnext", "dom"], 12 | "sourceMap": true, 13 | "rootDir": "src", 14 | "forceConsistentCasingInFileNames": true, 15 | "strictFunctionTypes": true, 16 | "strict": true, 17 | "allowSyntheticDefaultImports": true, 18 | "esModuleInterop": true, 19 | "suppressImplicitAnyIndexErrors": false, 20 | "noUnusedLocals": true, 21 | "typeRoots": ["./node_modules/@types", "./index.d.ts"], 22 | "baseUrl": ".", 23 | "paths": { 24 | "*": ["./node_modules/@types/*", "*"] 25 | }, 26 | "declaration": true 27 | }, 28 | "include": ["./index.d.ts", "src/**/*"], 29 | "exclude": ["node_modules", "build"] 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2022 Casey Primozic 2 | 3 | Adapted from original code (https://github.com/N8python/goodGodRays) 4 | by https://github.com/n8python 5 | 6 | This software is provided 'as-is', without any express or implied warranty. In 7 | no event will the authors be held liable for any damages arising from the use of 8 | this software. 9 | 10 | Permission is granted to anyone to use this software for any purpose, including 11 | commercial applications, and to alter it and redistribute it freely, subject to 12 | the following restrictions: 13 | 14 | 1. The origin of this software must not be misrepresented; you must not claim 15 | that you wrote the original software. If you use this software in a product, 16 | an acknowledgment in the product documentation would be appreciated but is 17 | not required. 18 | 19 | 2. Altered source versions must be plainly marked as such, and must not be 20 | misrepresented as being the original software. 21 | 22 | 3. This notice may not be removed or altered from any source distribution. 23 | -------------------------------------------------------------------------------- /src/bilateralFilter.ts: -------------------------------------------------------------------------------- 1 | import { type Disposable, KernelSize, Pass, type Resizable } from 'postprocessing'; 2 | import * as THREE from 'three'; 3 | 4 | import BilateralFilterFragmentShader from './bilateralFilter.frag'; 5 | import GodraysCompositorVertexShader from './compositor.vert'; 6 | import type { GodraysBlurParams } from './index'; 7 | 8 | export const GODRAYS_BLUR_RESOLUTION_SCALE = 1; 9 | 10 | class BilateralFilterMaterial extends THREE.ShaderMaterial { 11 | constructor(input: THREE.Texture) { 12 | super({ 13 | uniforms: { 14 | tInput: { value: input }, 15 | resolution: { 16 | value: new THREE.Vector2( 17 | input.image.width * GODRAYS_BLUR_RESOLUTION_SCALE, 18 | input.image.height * GODRAYS_BLUR_RESOLUTION_SCALE 19 | ), 20 | }, 21 | bSigma: { value: 0 }, 22 | }, 23 | defines: { 24 | KSIZE_ENUM: KernelSize.SMALL, 25 | }, 26 | vertexShader: GodraysCompositorVertexShader, 27 | fragmentShader: BilateralFilterFragmentShader, 28 | }); 29 | } 30 | } 31 | 32 | export class BilateralFilterPass extends Pass implements Resizable, Disposable { 33 | public material: BilateralFilterMaterial; 34 | 35 | constructor(input: THREE.Texture) { 36 | super('BilateralFilterPass'); 37 | this.needsSwap = false; 38 | this.material = new BilateralFilterMaterial(input); 39 | 40 | this.fullscreenMaterial = this.material; 41 | } 42 | 43 | override setSize(width: number, height: number): void { 44 | this.material.uniforms.resolution.value.set(width, height); 45 | } 46 | 47 | override render( 48 | renderer: THREE.WebGLRenderer, 49 | _inputBuffer: THREE.WebGLRenderTarget, 50 | outputBuffer: THREE.WebGLRenderTarget, 51 | _deltaTime?: number | undefined, 52 | _stencilTest?: boolean | undefined 53 | ): void { 54 | renderer.setRenderTarget(outputBuffer); 55 | renderer.render(this.scene, this.camera); 56 | } 57 | 58 | public updateUniforms(params: GodraysBlurParams) { 59 | this.material.uniforms.bSigma.value = params.variance; 60 | this.material.defines.KSIZE_ENUM = params.kernelSize; 61 | } 62 | 63 | public override dispose() { 64 | this.material.dispose(); 65 | super.dispose(); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/compositor.frag: -------------------------------------------------------------------------------- 1 | /* 2 | * Code taken from this demo: https://n8python.github.io/goodGodRays/ 3 | * By: https://github.com/n8python 4 | * 5 | * With cleanup and minor changes 6 | */ 7 | 8 | #include 9 | 10 | uniform sampler2D godrays; 11 | uniform sampler2D sceneDiffuse; 12 | uniform sampler2D sceneDepth; 13 | uniform float edgeStrength; 14 | uniform float edgeRadius; 15 | uniform vec2 resolution; 16 | uniform float near; 17 | uniform float far; 18 | uniform vec3 color; 19 | uniform bool gammaCorrection; 20 | varying vec2 vUv; 21 | 22 | #define DITHERING 23 | #include 24 | 25 | float linearize_depth(float depth, float zNear, float zFar) { 26 | #if defined( USE_LOGDEPTHBUF ) 27 | float d = pow(2.0, depth * log2(far + 1.0)) - 1.0; 28 | float a = far / (far - near); 29 | float b = far * near / (near - far); 30 | depth = a + b / d; 31 | #endif 32 | 33 | return zNear * zFar / (zFar + depth * (zNear - zFar)); 34 | } 35 | 36 | vec4 LinearTosRGB_(in vec4 value) { 37 | return vec4(mix(pow(value.rgb, vec3(0.41666)) * 1.055 - vec3(0.055), value.rgb * 12.92, vec3(lessThanEqual(value.rgb, vec3(0.0031308)))), value.a); 38 | } 39 | 40 | void main() { 41 | float rawDepth = texture2D(sceneDepth, vUv).x; 42 | float correctDepth = linearize_depth(rawDepth, near, far); 43 | 44 | vec2 pushDir = vec2(0.0); 45 | float count = 0.0; 46 | for (float x = -edgeRadius; x <= edgeRadius; x++) { 47 | for (float y = -edgeRadius; y <= edgeRadius; y++) { 48 | vec2 sampleUv = (vUv * resolution + vec2(x, y)) / resolution; 49 | float sampleDepth = texelFetch(sceneDepth, ivec2(sampleUv * resolution), 0).x; 50 | sampleDepth = linearize_depth(sampleDepth, near, far); 51 | if (abs(sampleDepth - correctDepth) < 0.05 * correctDepth) { 52 | pushDir += vec2(x, y); 53 | count += 1.0; 54 | } 55 | } 56 | } 57 | 58 | if (count == 0.0) { 59 | count = 1.0; 60 | } 61 | 62 | pushDir /= count; 63 | pushDir = normalize(pushDir); 64 | vec2 sampleUv = length(pushDir) > 0.0 ? vUv + edgeStrength * (pushDir / resolution) : vUv; 65 | float bestChoice = texture2D(godrays, sampleUv).x; 66 | 67 | vec3 diffuse = texture2D(sceneDiffuse, vUv).rgb; 68 | gl_FragColor = vec4(mix(diffuse, color, bestChoice), 1.0); 69 | 70 | #include 71 | 72 | if (gammaCorrection) { 73 | gl_FragColor = LinearTosRGB_(gl_FragColor); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "three-good-godrays", 3 | "version": "0.7.1", 4 | "description": "Screen-space raymarched godrays for three.js using the pmndrs postprocessing library", 5 | "main": "build/three-good-godrays.js", 6 | "module": "build/three-good-godrays.esm.js", 7 | "exports": { 8 | ".": { 9 | "import": "./build/three-good-godrays.esm.js", 10 | "require": "./build/three-good-godrays.js" 11 | }, 12 | "./module": "./build/three-good-godrays.mjs" 13 | }, 14 | "types": "build/three-good-godrays.d.ts", 15 | "sideEffects": false, 16 | "keywords": [ 17 | "three", 18 | "threejs", 19 | "godrays", 20 | "postprocessing", 21 | "raymarching" 22 | ], 23 | "contributors": [ 24 | { 25 | "name": "n8programs", 26 | "url": "https://github.com/n8python" 27 | }, 28 | { 29 | "name": "Casey Primozic", 30 | "url": "https://github.com/ameobea" 31 | } 32 | ], 33 | "repository": { 34 | "type": "git", 35 | "url": "git+https://github.com/ameobea/three-good-godrays.git" 36 | }, 37 | "bugs": { 38 | "url": "https://github.com/ameobea/three-good-godrays/issues" 39 | }, 40 | "files": [ 41 | "build", 42 | "types" 43 | ], 44 | "engines": { 45 | "node": ">= 0.13.2" 46 | }, 47 | "scripts": { 48 | "clean": "mkdir -p build && rimraf build types", 49 | "copy-files": "cp -r demo/static/* public/demo", 50 | "build:js": "node esbuild.mjs", 51 | "build:js:min": "node esbuild.mjs -m", 52 | "build:types": "tsc --declaration --emitDeclarationOnly && rm build/bluenoise.d.ts && mv build/index.d.ts build/three-good-godrays.d.ts", 53 | "prepublishOnly": "run-s clean build:types build:js:min copy-files", 54 | "prettier": "prettier --write \"src/**/*.{ts,js,tsx}\" && prettier --write \"demo/**/*.{ts,js,tsx}\"" 55 | }, 56 | "peerDependencies": { 57 | "postprocessing": "^6.33.4", 58 | "three": ">= 0.125.0 <= 0.168.0" 59 | }, 60 | "devDependencies": { 61 | "@ianvs/prettier-plugin-sort-imports": "^4.1.1", 62 | "@types/dat.gui": "^0.7.12", 63 | "@types/three": "^0.168.0", 64 | "dat.gui": "^0.7.9", 65 | "esbuild": "^0.19.11", 66 | "esbuild-plugin-glsl": "1.x.x", 67 | "eslint": "8.x.x", 68 | "npm-run-all": "^4.1.5", 69 | "postprocessing": "^6.34.1", 70 | "prettier": "^3.2.4", 71 | "rimraf": "^5.0.5", 72 | "three": "^0.168.0", 73 | "three-demo": "^5.1.3", 74 | "typescript": "^5.3.3" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /demo/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | three-good-godrays demo 9 | 10 | 11 | 12 | 13 | 90 | 91 | 92 | 93 |
94 |
95 |

Loading...

96 |
97 |
98 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | # Aapted from `postprocessing` library: https://github.com/pmndrs/postprocessing/blob/main/esbuild.mjs 2 | # 3 | # Zlib license: 4 | # 5 | # Copyright © 2015 Raoul van Rüschen 6 | # 7 | # This software is provided 'as-is', without any express or implied warranty. In no event will the authors be held liable for any damages arising from the use of this software. 8 | # 9 | # Permission is granted to anyone to use this software for any purpose, including commercial applications, and to alter it and redistribute it freely, subject to the following restrictions: 10 | # 11 | # The origin of this software must not be misrepresented; you must not claim that you wrote the original software. If you use this software in a product, an acknowledgment in the product documentation would be appreciated but is not required. 12 | # 13 | # Altered source versions must be plainly marked as such, and must not be misrepresented as being the original software. 14 | # 15 | # This notice may not be removed or altered from any source distribution. 16 | 17 | name: CD 18 | 19 | on: 20 | workflow_dispatch: 21 | push: 22 | branches: 23 | - main 24 | 25 | env: 26 | INPUT_TOKEN: ${{ secrets.NPM_TOKEN }} 27 | 28 | jobs: 29 | deploy: 30 | name: Deploy 31 | runs-on: ubuntu-latest 32 | steps: 33 | - name: Git checkout 34 | uses: actions/checkout@v3 35 | - name: Install Node 36 | uses: actions/setup-node@v3 37 | - name: Install PNPM 38 | run: npm install -g pnpm 39 | - name: Install dependencies 40 | run: pnpm install --frozen-lockfile=false 41 | - name: Publish 42 | id: publish 43 | uses: JS-DevTools/npm-publish@v1 44 | with: 45 | token: ${{ secrets.NPM_TOKEN }} 46 | - if: steps.publish.outputs.type != 'none' 47 | run: | 48 | echo "Version changed: ${{ steps.publish.outputs.old-version }} → ${{ steps.publish.outputs.version }}" 49 | 50 | # demo site 51 | - name: Build demo 52 | run: npm run prepublishOnly 53 | - name: Install `phost` 54 | run: pip3 install --user setuptools wheel && pip3 install --user "git+https://github.com/Ameobea/phost.git#egg=phost&subdirectory=client" 55 | - name: Add `phost` to the `PATH` 56 | run: echo "$HOME/.local/bin" >> $GITHUB_PATH 57 | - name: Extract `phost` config from secrets 58 | env: # Or as an environment variable 59 | PHOST_CONFIG_BASE64: ${{ secrets.PHOST_CONFIG_BASE64 }} 60 | run: mkdir ~/.phost; echo "$PHOST_CONFIG_BASE64" | base64 -d > ~/.phost/conf.toml 61 | - name: Deploy demo 62 | run: phost update three-good-godrays patch ./public/demo 63 | -------------------------------------------------------------------------------- /src/bilateralFilter.frag: -------------------------------------------------------------------------------- 1 | /** 2 | * Adapted from: https://www.shadertoy.com/view/4dfGDH 3 | */ 4 | 5 | uniform sampler2D tInput; 6 | uniform vec2 resolution; 7 | uniform float bSigma; 8 | 9 | varying vec2 vUv; 10 | 11 | #if (KSIZE_ENUM == 0) 12 | #define KSIZE 2 13 | #define MSIZE 5 14 | const float kernel[MSIZE] = float[MSIZE](0., 0.24196934138575799, 0.39894, 0.24196934138575799, 0.); 15 | #elif (KSIZE_ENUM == 1) 16 | #define KSIZE 3 17 | #define MSIZE 7 18 | const float kernel[MSIZE] = float[MSIZE](0., 0.39104045872899694, 0.3969502784491287, 0.39894, 0.3969502784491287, 0.39104045872899694, 0.); 19 | #elif (KSIZE_ENUM == 2) 20 | #define KSIZE 4 21 | #define MSIZE 9 22 | const float kernel[MSIZE] = float[MSIZE](0., 0.3813856354024969, 0.39104045872899694, 0.3969502784491287, 0.39894, 0.3969502784491287, 0.39104045872899694, 0.3813856354024969, 0.); 23 | #elif (KSIZE_ENUM == 3) 24 | #define KSIZE 5 25 | #define MSIZE 11 26 | const float kernel[MSIZE] = float[MSIZE](0., 0.03682680352274845, 0.03813856354024969, 0.039104045872899694, 0.03969502784491287, 0.039894, 0.03969502784491287, 0.039104045872899694, 0.03813856354024969, 0.03682680352274845, 0.); 27 | #elif (KSIZE_ENUM == 4) 28 | #define KSIZE 6 29 | #define MSIZE 13 30 | const float kernel[MSIZE] = float[MSIZE](0., 0.035206331431709856, 0.03682680352274845, 0.03813856354024969, 0.039104045872899694, 0.03969502784491287, 0.039894, 0.03969502784491287, 0.039104045872899694, 0.03813856354024969, 0.03682680352274845, 0.035206331431709856, 0.); 31 | #elif (KSIZE_ENUM == 5) 32 | #define KSIZE 7 33 | #define MSIZE 15 34 | const float kernel[MSIZE] = float[MSIZE](0.031225216, 0.033322271, 0.035206333, 0.036826804, 0.038138565, 0.039104044, 0.039695028, 0.039894000, 0.039695028, 0.039104044, 0.038138565, 0.036826804, 0.035206333, 0.033322271, 0.031225216); 35 | #endif 36 | 37 | float normpdf(in float x, in float sigma) { 38 | return 0.39894 * exp(-0.5 * x * x / (sigma * sigma)) / sigma; 39 | } 40 | 41 | float normpdf3(in vec3 v, in float sigma) { 42 | return 0.39894 * exp(-0.5 * dot(v, v) / (sigma * sigma)) / sigma; 43 | } 44 | 45 | void main() { 46 | vec3 c = texture(tInput, vUv).rgb; 47 | ivec2 fragCoord = ivec2(vUv * resolution); 48 | vec3 finalColor = vec3(0.); 49 | 50 | float bZ = 1.0 / normpdf(0.0, bSigma); 51 | float totalFactor = 0.; 52 | for (int i = -KSIZE; i <= KSIZE; ++i) { 53 | for (int j = -KSIZE; j <= KSIZE; ++j) { 54 | vec3 cc = texelFetch(tInput, fragCoord + ivec2(i, j), 0).rgb; 55 | float factor = normpdf3(cc - c, bSigma) * bZ * kernel[KSIZE + j] * kernel[KSIZE + i]; 56 | totalFactor += factor; 57 | finalColor += factor * cc; 58 | } 59 | } 60 | 61 | gl_FragColor = vec4(finalColor / totalFactor, 1.); 62 | } 63 | -------------------------------------------------------------------------------- /demo/src/index.ts: -------------------------------------------------------------------------------- 1 | import { EffectComposer } from 'postprocessing'; 2 | import * as THREE from 'three'; 3 | import { calculateVerticalFoV, DemoManager, DemoManagerEvent } from 'three-demo'; 4 | 5 | import DirlightDemo from './dirlightDemo'; 6 | import PointlightDemo from './pointlightDemo'; 7 | import { SponzaDemo } from './sponzaDemo'; 8 | 9 | window.addEventListener('load', () => { 10 | const renderer = new THREE.WebGLRenderer({ 11 | powerPreference: 'high-performance', 12 | antialias: false, 13 | }); 14 | 15 | const viewport = document.getElementById('viewport'); 16 | if (!viewport) { 17 | throw new Error('No viewport element found'); 18 | } 19 | 20 | renderer.setSize(viewport.clientWidth, viewport.clientHeight); 21 | 22 | renderer.shadowMap.enabled = true; 23 | renderer.shadowMap.type = THREE.PCFSoftShadowMap; 24 | renderer.shadowMap.autoUpdate = true; 25 | renderer.shadowMap.needsUpdate = true; 26 | 27 | const manager = new DemoManager(viewport, { 28 | aside: document.getElementById('aside') ?? undefined, 29 | renderer, 30 | }); 31 | 32 | manager.addEventListener('change', (_event: DemoManagerEvent) => { 33 | renderer.shadowMap.needsUpdate = true; 34 | document.querySelector('.loading')?.classList.remove('hidden'); 35 | }); 36 | 37 | manager.addEventListener('load', (_event: DemoManagerEvent) => { 38 | document.querySelector('.loading')?.classList.add('hidden'); 39 | }); 40 | 41 | const composer = new EffectComposer(renderer, { frameBufferType: THREE.HalfFloatType }); 42 | 43 | if (!window.location.hash) { 44 | window.location.hash = 'dirlight'; 45 | } 46 | 47 | manager.addDemo(new DirlightDemo(composer)); 48 | manager.addDemo(new PointlightDemo(composer)); 49 | manager.addDemo(new SponzaDemo(composer)); 50 | 51 | requestAnimationFrame(function render(timestamp) { 52 | requestAnimationFrame(render); 53 | manager.render(timestamp); 54 | }); 55 | 56 | window.addEventListener('resize', event => { 57 | const width = window.innerWidth; 58 | const height = window.innerHeight; 59 | const demo = manager.getCurrentDemo(); 60 | 61 | if (demo !== null) { 62 | const camera = demo.getCamera() as THREE.PerspectiveCamera; 63 | 64 | if (camera !== null) { 65 | const aspect = Math.max(width / height, 16 / 9); 66 | const vFoV = calculateVerticalFoV(90, aspect); 67 | camera.fov = vFoV; 68 | } 69 | } 70 | 71 | manager.setSize(width, height, true); 72 | composer.setSize(width, height); 73 | }); 74 | }); 75 | 76 | document.addEventListener('DOMContentLoaded', (event: Event) => { 77 | const img = document.querySelector('.info img'); 78 | const div = document.querySelector('.info div'); 79 | 80 | if (img !== null && div !== null) { 81 | img.addEventListener('click', () => { 82 | div.classList.toggle('hidden'); 83 | }); 84 | } 85 | }); 86 | -------------------------------------------------------------------------------- /demo/src/BaseDemo.ts: -------------------------------------------------------------------------------- 1 | import { type GUI } from 'dat.gui'; 2 | import { EffectComposer, KernelSize } from 'postprocessing'; 3 | import * as THREE from 'three'; 4 | import { Demo } from 'three-demo'; 5 | import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; 6 | 7 | import { GodraysPass, GodraysPassParams } from '../../src/index'; 8 | 9 | THREE.ColorManagement.enabled = true; 10 | 11 | interface GodraysPassParamsState extends Omit { 12 | color: number; 13 | enableBlur: boolean; 14 | blurVariance: number; 15 | blurKernelSize: KernelSize; 16 | } 17 | 18 | export class BaseDemo extends Demo { 19 | public controls: OrbitControls; 20 | public godraysPass: GodraysPass; 21 | public params: GodraysPassParamsState = { 22 | density: 0.006, 23 | maxDensity: 2 / 3, 24 | distanceAttenuation: 2, 25 | color: new THREE.Color(0xffffff).getHex(), 26 | edgeStrength: 2, 27 | edgeRadius: 2, 28 | raymarchSteps: 60, 29 | enableBlur: true, 30 | blurVariance: 0.1, 31 | blurKernelSize: KernelSize.SMALL, 32 | gammaCorrection: false, 33 | }; 34 | 35 | public composer: EffectComposer; 36 | 37 | constructor(name: string, composer: EffectComposer) { 38 | super(name); 39 | this.composer = composer; 40 | } 41 | 42 | public onParamChange = (key: string, value: any) => { 43 | this.params[key] = value; 44 | 45 | this.godraysPass.setParams({ 46 | ...this.params, 47 | color: new THREE.Color(this.params.color), 48 | blur: this.params.enableBlur 49 | ? { variance: this.params.blurVariance, kernelSize: this.params.blurKernelSize } 50 | : false, 51 | }); 52 | }; 53 | 54 | registerOptions(menu: GUI) { 55 | const mkOnChange = (key: string) => (value: any) => this.onParamChange(key, value); 56 | 57 | menu 58 | .add(this.params, 'density', 0, this.id === 'sponza' ? 0.15 : 0.03) 59 | .onChange(mkOnChange('density')); 60 | menu.add(this.params, 'maxDensity', 0, 1).onChange(mkOnChange('maxDensity')); 61 | menu.add(this.params, 'distanceAttenuation', 0, 5).onChange(mkOnChange('distanceAttenuation')); 62 | menu.addColor(this.params, 'color').onChange(mkOnChange('color')); 63 | menu.add(this.params, 'edgeStrength', 0, 10, 1).onChange(mkOnChange('edgeStrength')); 64 | menu.add(this.params, 'edgeRadius', 0, 10, 1).onChange(mkOnChange('edgeRadius')); 65 | menu.add(this.params, 'raymarchSteps', 1, 200, 1).onChange(mkOnChange('raymarchSteps')); 66 | menu.add(this.params, 'enableBlur', true).onChange(mkOnChange('enableBlur')); 67 | menu.add(this.params, 'blurVariance', 0.001, 0.5, 0.001).onChange(mkOnChange('blurVariance')); 68 | menu 69 | .add(this.params, 'blurKernelSize', { 70 | VERY_SMALL: KernelSize.VERY_SMALL, 71 | SMALL: KernelSize.SMALL, 72 | MEDIUM: KernelSize.MEDIUM, 73 | LARGE: KernelSize.LARGE, 74 | VERY_LARGE: KernelSize.VERY_LARGE, 75 | HUGE: KernelSize.HUGE, 76 | }) 77 | .onChange(mkOnChange('blurKernelSize')); 78 | 79 | if (window.innerWidth < 720) { 80 | menu.close(); 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /esbuild.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Adapted from `postprocessing` library: https://github.com/pmndrs/postprocessing/blob/main/esbuild.mjs 3 | * 4 | * Zlib license: 5 | * 6 | * Copyright © 2015 Raoul van Rüschen 7 | * 8 | * This software is provided 'as-is', without any express or implied warranty. In no event will the authors be held liable for any damages arising from the use of this software. 9 | * 10 | * Permission is granted to anyone to use this software for any purpose, including commercial applications, and to alter it and redistribute it freely, subject to the following restrictions: 11 | * 12 | * The origin of this software must not be misrepresented; you must not claim that you wrote the original software. If you use this software in a product, an acknowledgment in the product documentation would be appreciated but is not required. 13 | * 14 | * Altered source versions must be plainly marked as such, and must not be misrepresented as being the original software. 15 | * 16 | * This notice may not be removed or altered from any source distribution. 17 | */ 18 | 19 | import { createRequire } from 'module'; 20 | import { glsl } from 'esbuild-plugin-glsl'; 21 | import esbuild from 'esbuild'; 22 | 23 | const require = createRequire(import.meta.url); 24 | const pkg = require('./package'); 25 | const external = Object.keys(pkg.peerDependencies || {}); 26 | const minify = process.argv.includes('-m'); 27 | const plugins = [glsl({ minify })]; 28 | 29 | await esbuild 30 | .build({ 31 | entryPoints: ['demo/src/index.ts'], 32 | outdir: 'public/demo', 33 | target: 'es6', 34 | logLevel: 'info', 35 | format: 'iife', 36 | bundle: true, 37 | plugins, 38 | minify, 39 | }) 40 | .catch(() => process.exit(1)); 41 | 42 | await esbuild 43 | .build({ 44 | entryPoints: ['src/index.ts'], 45 | outfile: `build/${pkg.name}.esm.js`, 46 | logLevel: 'info', 47 | format: 'esm', 48 | target: 'es2019', 49 | bundle: true, 50 | external, 51 | plugins, 52 | }) 53 | .catch(() => process.exit(1)); 54 | 55 | await esbuild 56 | .build({ 57 | entryPoints: ['src/index.ts'], 58 | outfile: `build/${pkg.name}.mjs`, 59 | logLevel: 'info', 60 | format: 'esm', 61 | target: 'es2019', 62 | bundle: true, 63 | external, 64 | plugins, 65 | }) 66 | .catch(() => process.exit(1)); 67 | 68 | // @todo Remove in next major release. 69 | const globalName = pkg.name.replace(/-/g, '').toUpperCase(); 70 | // const requireShim = `if(typeof window==="object"&&!window.require)window.require=()=>window.THREE;`; 71 | const footer = `if(typeof module==="object"&&module.exports)module.exports=${globalName};`; 72 | 73 | await esbuild 74 | .build({ 75 | entryPoints: ['src/index.ts'], 76 | outfile: `build/${pkg.name}.js`, 77 | footer: { js: footer }, 78 | logLevel: 'info', 79 | format: 'iife', 80 | target: 'es6', 81 | bundle: true, 82 | globalName, 83 | external, 84 | plugins, 85 | }) 86 | .catch(() => process.exit(1)); 87 | 88 | await esbuild 89 | .build({ 90 | entryPoints: ['src/index.ts'], 91 | outfile: `build/${pkg.name}.min.js`, 92 | footer: { js: footer }, 93 | logLevel: 'info', 94 | format: 'iife', 95 | target: 'es6', 96 | bundle: true, 97 | globalName, 98 | external, 99 | plugins, 100 | minify, 101 | }) 102 | .catch(() => process.exit(1)); 103 | -------------------------------------------------------------------------------- /demo/src/sponzaDemo.ts: -------------------------------------------------------------------------------- 1 | import { EffectComposer, EffectPass, RenderPass, SMAAEffect } from 'postprocessing'; 2 | import * as THREE from 'three'; 3 | import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; 4 | import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; 5 | 6 | import { GodraysPass } from '../../src'; 7 | import { BaseDemo } from './BaseDemo'; 8 | 9 | export class SponzaDemo extends BaseDemo { 10 | private setLightY: (y: number) => void; 11 | 12 | constructor(composer: EffectComposer) { 13 | super('sponza', composer); 14 | } 15 | 16 | override async load(): Promise { 17 | const loader = new GLTFLoader(this.loadingManager); 18 | const gltf = await loader.loadAsync('https://ameo.dev/static/sponza.glb'); 19 | this.scene.add(...gltf.scene.children); 20 | } 21 | 22 | initialize() { 23 | this.camera = new THREE.PerspectiveCamera(90, 16 / 9, 0.1, 1000); 24 | 25 | this.controls = new OrbitControls(this.camera, this.renderer.domElement); 26 | this.camera.position.set(4.5, 4.5, 4.5); 27 | this.controls.target.set(0, 0, 0); 28 | this.controls.update(); 29 | 30 | const ambientLight = new THREE.AmbientLight(0xcccccc, 0.1); 31 | this.scene.add(ambientLight); 32 | 33 | const lightPos = new THREE.Vector3(0, 5, 0); 34 | const lightSphereMaterial = new THREE.MeshBasicMaterial({ 35 | color: 0xffffff, 36 | }); 37 | const lightSphere = new THREE.Mesh(new THREE.SphereGeometry(0.2, 16, 16), lightSphereMaterial); 38 | lightSphere.position.copy(lightPos); 39 | this.scene.add(lightSphere); 40 | 41 | const pointLight = new THREE.PointLight(0xffffff, 2.3, 25, 0.5); 42 | pointLight.castShadow = true; 43 | pointLight.shadow.bias = -0.00005; 44 | pointLight.shadow.mapSize.width = 1024 * 2; 45 | pointLight.shadow.mapSize.height = 1024 * 2; 46 | pointLight.shadow.autoUpdate = true; 47 | pointLight.shadow.camera.near = 0.1; 48 | pointLight.shadow.camera.far = 1; 49 | pointLight.shadow.camera.updateProjectionMatrix(); 50 | pointLight.position.copy(lightPos); 51 | this.scene.add(pointLight); 52 | 53 | this.scene.traverse(obj => { 54 | if (obj instanceof THREE.Mesh) { 55 | obj.castShadow = true; 56 | obj.receiveShadow = true; 57 | } 58 | }); 59 | lightSphere.castShadow = false; 60 | lightSphere.receiveShadow = false; 61 | 62 | const renderPass = new RenderPass(this.scene, this.camera); 63 | renderPass.renderToScreen = false; 64 | this.composer.addPass(renderPass); 65 | 66 | this.renderer.shadowMap.enabled = true; 67 | this.renderer.shadowMap.type = THREE.PCFSoftShadowMap; 68 | this.renderer.shadowMap.autoUpdate = true; 69 | this.renderer.shadowMap.needsUpdate = true; 70 | 71 | this.params.density = 0.07; 72 | this.godraysPass = new GodraysPass(pointLight, this.camera as THREE.PerspectiveCamera, { 73 | ...this.params, 74 | gammaCorrection: false, 75 | color: new THREE.Color(this.params.color), 76 | }); 77 | this.godraysPass.renderToScreen = false; 78 | this.composer.addPass(this.godraysPass); 79 | 80 | this.onParamChange('density', 0.07); 81 | 82 | const smaaEffect = new SMAAEffect(); 83 | const smaaPass = new EffectPass(this.camera, smaaEffect); 84 | smaaPass.encodeOutput = false; 85 | smaaPass.renderToScreen = true; 86 | this.composer.addPass(smaaPass); 87 | 88 | this.setLightY = (y: number) => { 89 | pointLight.position.y = y; 90 | lightSphere.position.y = y; 91 | }; 92 | } 93 | 94 | render(deltaTime: number, rawTs?: number | undefined): void { 95 | const curTime = rawTs !== undefined ? rawTs : performance.now(); 96 | this.setLightY(Math.sin(curTime * 0.0005) * 3 + 5); 97 | this.controls.update(); 98 | this.composer.render(deltaTime); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `three-good-godrays` 2 | 3 | [![CI](https://github.com/ameobea/three-good-godrays/actions/workflows/cd.yml/badge.svg)](https://github.com/ameobea/three-good-godrays/actions/workflows/ci.yml) 4 | [![Version](https://badgen.net/npm/v/three-good-godrays?color=green)](https://www.npmjs.com/package/three-good-godrays) 5 | 6 | Good godrays effect for three.js using the [pmndrs `postprocessing` library](https://github.com/pmndrs/postprocessing) 7 | 8 | Adapted from [original implementation](https://github.com/n8python/goodGodRays) by [@n8python](https://github.com/n8python) 9 | 10 | **Demo**: 11 | 12 | ![A screenshot showing the three-good-godrays effect in action within the sponza demo scene. A white sphere in the middle of a terrace with pillars has white godrays emanating from it along with prominent shadows.](https://ameo.link/u/al8.jpg) 13 | 14 | ## Install 15 | 16 | `npm install three-good-godrays` 17 | 18 | Or import from unpkg as a module: 19 | 20 | ```ts 21 | import { GodraysPass } from 'https://unpkg.com/three-good-godrays@0.7.1/build/three-good-godrays.esm.js'; 22 | ``` 23 | 24 | ## Supported Three.JS Version 25 | 26 | This library was tested to work with Three.JS versions `>= 0.125.0 <= 0.168.0`. Although it might work with versions outside that range, support is not guaranteed. 27 | 28 | ## Usage 29 | 30 | ```ts 31 | import { EffectComposer, RenderPass } from 'postprocessing'; 32 | import * as THREE from 'three'; 33 | import { GodraysPass } from 'three-good-godrays'; 34 | 35 | const { scene, camera, renderer } = initYourScene(); 36 | 37 | // shadowmaps are needed for this effect 38 | renderer.shadowMap.enabled = true; 39 | renderer.shadowMap.type = THREE.PCFSoftShadowMap; 40 | renderer.shadowMap.autoUpdate = true; 41 | 42 | // Make sure to set applicable objects in your scene to cast + receive shadows 43 | // so that this effect will work 44 | scene.traverse(obj => { 45 | if (obj instanceof THREE.Mesh) { 46 | obj.castShadow = true; 47 | obj.receiveShadow = true; 48 | } 49 | }); 50 | 51 | // godrays can be cast from either `PointLight`s or `DirectionalLight`s 52 | const lightPos = new THREE.Vector3(0, 20, 0); 53 | const pointLight = new THREE.PointLight(0xffffff, 1, 10000); 54 | pointLight.castShadow = true; 55 | pointLight.shadow.mapSize.width = 1024; 56 | pointLight.shadow.mapSize.height = 1024; 57 | pointLight.shadow.autoUpdate = true; 58 | pointLight.shadow.camera.near = 0.1; 59 | pointLight.shadow.camera.far = 1000; 60 | pointLight.shadow.camera.updateProjectionMatrix(); 61 | pointLight.position.copy(lightPos); 62 | scene.add(pointLight); 63 | 64 | // set up rendering pipeline and add godrays pass at the end 65 | const composer = new EffectComposer(renderer, { frameBufferType: THREE.HalfFloatType }); 66 | 67 | const renderPass = new RenderPass(scene, camera); 68 | renderPass.renderToScreen = false; 69 | composer.addPass(renderPass); 70 | 71 | // Default values are shown. You can supply a sparse object or `undefined`. 72 | const params = { 73 | density: 1 / 128, 74 | maxDensity: 0.5, 75 | edgeStrength: 2, 76 | edgeRadius: 2, 77 | distanceAttenuation: 2, 78 | color: new THREE.Color(0xffffff), 79 | raymarchSteps: 60, 80 | blur: true, 81 | gammaCorrection: true, 82 | }; 83 | 84 | const godraysPass = new GodraysPass(pointLight, camera, params); 85 | // If this is the last pass in your pipeline, set `renderToScreen` to `true` 86 | godraysPass.renderToScreen = true; 87 | composer.addPass(godraysPass); 88 | 89 | function animate() { 90 | requestAnimationFrame(animate); 91 | composer.render(); 92 | } 93 | requestAnimationFrame(animate); 94 | ``` 95 | 96 | ### Gamma Correction 97 | 98 | Gamma correction is enabled by this effect by default, matching expectations of sRGB buffers from `postprocessing`. However, you can disable this by setting `gammaCorrection: false` in the configuration object for the pass. 99 | 100 | This may be necessary if you use other effect passes after `GodraysPass` that perform their own output encoding. If you see artifacts similar to these: 101 | 102 | ![Screenshot of artifacts caused by double encoding in a Three.Js pmndrs postprocessing pipeline. There is a grainy pattern of colorful pixels appearing over an otherwise blank black background.](https://i.ameo.link/bto.png) 103 | 104 | Try setting `gammaCorrection: false` on the `GodraysPass` or setting `encodeOutput = false` on any `EffectPass` that is added after the `GodraysPass`. 105 | 106 | ## Develop + Run Demos Locally 107 | 108 | - Clone repo 109 | - `npm install` 110 | - `npm run prepublishOnly` to run initial builds 111 | - `npm install -g serve` 112 | - Run `node esbuild.mjs` whenever files are chnaged to re-build 113 | - Run `serve public/demo -p 5001` and visit http://localhost:5001 in your browser 114 | -------------------------------------------------------------------------------- /demo/src/dirlightDemo.ts: -------------------------------------------------------------------------------- 1 | import { EffectComposer, EffectPass, RenderPass, SMAAEffect } from 'postprocessing'; 2 | import * as THREE from 'three'; 3 | import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; 4 | import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; 5 | 6 | import { GodraysPass } from '../../src/index'; 7 | import { BaseDemo } from './BaseDemo'; 8 | 9 | export default class DirlightDemo extends BaseDemo { 10 | constructor(composer: EffectComposer) { 11 | super('dirlight', composer); 12 | } 13 | 14 | override async load(): Promise { 15 | const loader = new GLTFLoader(this.loadingManager); 16 | const gltf = await loader.loadAsync('/demo_dirlight.glb'); 17 | this.scene.add(...gltf.scene.children); 18 | } 19 | 20 | initialize() { 21 | const pillars = this.scene.getObjectByName('pillars') as THREE.Mesh; 22 | pillars.material = new THREE.MeshStandardMaterial({ 23 | color: 0x222222, 24 | }); 25 | 26 | const base = this.scene.getObjectByName('base') as THREE.Mesh; 27 | base.material = new THREE.MeshStandardMaterial({ 28 | color: 0x222222, 29 | }); 30 | 31 | const lightSphere = this.scene.getObjectByName('light_sphere') as THREE.Mesh; 32 | lightSphere.material = new THREE.MeshBasicMaterial({ 33 | color: 0xffffff, 34 | }); 35 | const lightPos = new THREE.Vector3(); 36 | lightSphere.getWorldPosition(lightPos); 37 | 38 | this.scene.add(new THREE.AmbientLight(0xcccccc, 0.4)); 39 | 40 | const backdropDistance = 200; 41 | // Add backdrop walls `backdropDistance` units away from the origin 42 | const backdropGeometry = new THREE.PlaneGeometry(400, 400); 43 | const backdropMaterial = new THREE.MeshBasicMaterial({ 44 | color: 0x200808, 45 | side: THREE.DoubleSide, 46 | }); 47 | const backdropLeft = new THREE.Mesh(backdropGeometry, backdropMaterial); 48 | backdropLeft.position.set(-backdropDistance, 200, 0); 49 | backdropLeft.rotateY(Math.PI / 2); 50 | this.scene.add(backdropLeft); 51 | const backdropRight = new THREE.Mesh(backdropGeometry, backdropMaterial); 52 | backdropRight.position.set(backdropDistance, 200, 0); 53 | backdropRight.rotateY(Math.PI / 2); 54 | this.scene.add(backdropRight); 55 | // const backdropFront = new THREE.Mesh(backdropGeometry, backdropMaterial); 56 | // backdropFront.position.set(0, 200, -backdropDistance); 57 | // this.scene.add(backdropFront); 58 | const backdropBack = new THREE.Mesh(backdropGeometry, backdropMaterial); 59 | backdropBack.position.set(0, 200, backdropDistance); 60 | this.scene.add(backdropBack); 61 | 62 | this.scene.traverse(obj => { 63 | if (obj instanceof THREE.Mesh) { 64 | obj.castShadow = true; 65 | obj.receiveShadow = true; 66 | } 67 | }); 68 | 69 | lightSphere.castShadow = false; 70 | lightSphere.receiveShadow = false; 71 | 72 | this.camera = new THREE.PerspectiveCamera( 73 | 75, 74 | window.innerWidth / window.innerHeight, 75 | 0.1, 76 | 1000 77 | ); 78 | 79 | this.controls = new OrbitControls(this.camera, this.renderer.domElement); 80 | this.controls.enableDamping = true; 81 | this.controls.dampingFactor = 0.1; 82 | 83 | this.camera.position.set(-140, 110, -200); 84 | this.controls.target.set(0, 0, 0); 85 | this.controls.update(); 86 | 87 | const renderPass = new RenderPass(this.scene, this.camera); 88 | this.composer.addPass(renderPass); 89 | 90 | const dirLight = new THREE.DirectionalLight(0xffffff, 0.3); 91 | dirLight.castShadow = true; 92 | dirLight.shadow.mapSize.width = 1024; 93 | dirLight.shadow.mapSize.height = 1024; 94 | dirLight.shadow.camera.near = 0.1; 95 | dirLight.shadow.camera.far = 500; 96 | dirLight.shadow.camera.left = -150; 97 | dirLight.shadow.camera.right = 190; 98 | dirLight.shadow.camera.top = 200; 99 | dirLight.shadow.camera.bottom = -110; 100 | dirLight.shadow.camera.updateProjectionMatrix(); 101 | dirLight.shadow.autoUpdate = true; 102 | dirLight.position.copy(lightPos).add(new THREE.Vector3(0, 0, 10)); 103 | dirLight.target.position.set(0, 0, -500); 104 | dirLight.target.updateMatrixWorld(); 105 | this.scene.add(dirLight.target); 106 | this.scene.add(dirLight); 107 | 108 | const dirLightHelper = new THREE.DirectionalLightHelper(dirLight, 5); 109 | this.scene.add(dirLightHelper); 110 | const dirLightCameraHelper = new THREE.CameraHelper(dirLight.shadow.camera); 111 | this.scene.add(dirLightCameraHelper); 112 | 113 | this.godraysPass = new GodraysPass(dirLight, this.camera as THREE.PerspectiveCamera, { 114 | ...this.params, 115 | gammaCorrection: false, 116 | color: new THREE.Color(this.params.color), 117 | }); 118 | this.composer.addPass(this.godraysPass); 119 | 120 | const smaaEffect = new SMAAEffect(); 121 | const smaaPass = new EffectPass(this.camera, smaaEffect); 122 | smaaPass.encodeOutput = false; 123 | this.composer.addPass(smaaPass); 124 | } 125 | 126 | render(deltaTime: number, timestamp?: number | undefined): void { 127 | this.controls.update(); 128 | this.composer.render(deltaTime); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /demo/src/pointlightDemo.ts: -------------------------------------------------------------------------------- 1 | import { EffectComposer, EffectPass, RenderPass, SMAAEffect } from 'postprocessing'; 2 | import * as THREE from 'three'; 3 | import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; 4 | import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; 5 | 6 | import { GodraysPass } from '../../src/index'; 7 | import { BaseDemo } from './BaseDemo'; 8 | 9 | export default class PointlightDemo extends BaseDemo { 10 | constructor(composer: EffectComposer) { 11 | super('pointlight', composer); 12 | } 13 | 14 | override async load(): Promise { 15 | const loader = new GLTFLoader(this.loadingManager); 16 | const gltf = await loader.loadAsync('/demo_pointlight.glb'); 17 | this.scene.add(...gltf.scene.children); 18 | } 19 | 20 | initialize() { 21 | const pillars = this.scene.getObjectByName('concrete') as THREE.Mesh; 22 | pillars.material = new THREE.MeshStandardMaterial({ 23 | color: 0x333333, 24 | }); 25 | 26 | const base = this.scene.getObjectByName('base') as THREE.Mesh; 27 | base.material = new THREE.MeshStandardMaterial({ 28 | color: 0x333333, 29 | side: THREE.DoubleSide, 30 | }); 31 | 32 | const lightPos = new THREE.Vector3(0, 50, 0); 33 | const lightSphereMaterial = new THREE.MeshBasicMaterial({ 34 | color: 0xffffff, 35 | }); 36 | const lightSphere = new THREE.Mesh(new THREE.SphereGeometry(0.5, 16, 16), lightSphereMaterial); 37 | lightSphere.position.copy(lightPos); 38 | this.scene.add(lightSphere); 39 | 40 | this.scene.add(new THREE.AmbientLight(0xcccccc, 0.4)); 41 | 42 | const backdropDistance = 200; 43 | // Add backdrop walls `backdropDistance` units away from the origin 44 | const backdropGeometry = new THREE.PlaneGeometry(400, 200); 45 | const backdropMaterial = new THREE.MeshBasicMaterial({ 46 | color: 0x200808, 47 | side: THREE.DoubleSide, 48 | }); 49 | const backdropLeft = new THREE.Mesh(backdropGeometry, backdropMaterial); 50 | backdropLeft.position.set(-backdropDistance, 100, 0); 51 | backdropLeft.rotateY(Math.PI / 2); 52 | this.scene.add(backdropLeft); 53 | const backdropRight = new THREE.Mesh(backdropGeometry, backdropMaterial); 54 | backdropRight.position.set(backdropDistance, 100, 0); 55 | backdropRight.rotateY(Math.PI / 2); 56 | this.scene.add(backdropRight); 57 | const backdropFront = new THREE.Mesh(backdropGeometry, backdropMaterial); 58 | backdropFront.position.set(0, 100, -backdropDistance); 59 | this.scene.add(backdropFront); 60 | const backdropBack = new THREE.Mesh(backdropGeometry, backdropMaterial); 61 | backdropBack.position.set(0, 100, backdropDistance); 62 | this.scene.add(backdropBack); 63 | const backdropTop = new THREE.Mesh(backdropGeometry, backdropMaterial); 64 | backdropTop.position.set(0, 200, 0); 65 | backdropTop.rotateX(Math.PI / 2); 66 | backdropTop.scale.set(3, 6, 1); 67 | this.scene.add(backdropTop); 68 | 69 | this.scene.traverse(obj => { 70 | if (obj instanceof THREE.Mesh) { 71 | obj.castShadow = true; 72 | obj.receiveShadow = true; 73 | } 74 | }); 75 | 76 | lightSphere.castShadow = false; 77 | lightSphere.receiveShadow = false; 78 | 79 | this.camera = new THREE.PerspectiveCamera(90, 16 / 9, 0.1, 1000); 80 | 81 | this.controls = new OrbitControls(this.camera, this.renderer.domElement); 82 | this.controls.enableZoom = false; 83 | this.controls.enablePan = false; 84 | this.controls.enableDamping = true; 85 | this.controls.dampingFactor = 0.1; 86 | 87 | this.camera.position.set(-140 * 0.7, 110 * 0.7, -200 * 0.7); 88 | this.controls.target.set(0, 0, 0); 89 | this.controls.update(); 90 | 91 | const renderPass = new RenderPass(this.scene, this.camera); 92 | renderPass.renderToScreen = false; 93 | this.composer.addPass(renderPass); 94 | 95 | this.renderer.shadowMap.enabled = true; 96 | this.renderer.shadowMap.type = THREE.PCFSoftShadowMap; 97 | this.renderer.shadowMap.autoUpdate = true; 98 | this.renderer.shadowMap.needsUpdate = true; 99 | 100 | const pointLight = new THREE.PointLight(0xffffff, 0.3, 1000, 0.5); 101 | pointLight.castShadow = true; 102 | pointLight.shadow.bias = 0.001; 103 | pointLight.shadow.mapSize.width = 1024; 104 | pointLight.shadow.mapSize.height = 1024; 105 | pointLight.shadow.autoUpdate = true; 106 | pointLight.shadow.camera.near = 0.1; 107 | pointLight.shadow.camera.far = 500; 108 | pointLight.shadow.camera.updateProjectionMatrix(); 109 | pointLight.position.copy(lightPos); 110 | this.scene.add(pointLight); 111 | 112 | this.godraysPass = new GodraysPass(pointLight, this.camera as THREE.PerspectiveCamera, { 113 | ...this.params, 114 | gammaCorrection: false, 115 | color: new THREE.Color(this.params.color), 116 | }); 117 | this.godraysPass.renderToScreen = false; 118 | this.composer.addPass(this.godraysPass); 119 | 120 | const smaaEffect = new SMAAEffect(); 121 | const smaaPass = new EffectPass(this.camera, smaaEffect); 122 | smaaPass.encodeOutput = false; 123 | smaaPass.renderToScreen = true; 124 | this.composer.addPass(smaaPass); 125 | } 126 | 127 | render(deltaTime: number, timestamp?: number | undefined): void { 128 | this.controls.update(); 129 | this.composer.render(deltaTime); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/compositorPass.ts: -------------------------------------------------------------------------------- 1 | import { CopyPass, Pass, type Resizable } from 'postprocessing'; 2 | import * as THREE from 'three'; 3 | import type { PerspectiveCamera } from 'three'; 4 | 5 | import GodraysCompositorFragmentShader from './compositor.frag'; 6 | import GodraysCompositorVertexShader from './compositor.vert'; 7 | import type { GodraysPassParams } from './index'; 8 | 9 | interface GodraysCompositorMaterialProps { 10 | godrays: THREE.Texture; 11 | edgeStrength: number; 12 | edgeRadius: number; 13 | color: THREE.Color; 14 | camera: THREE.PerspectiveCamera; 15 | gammaCorrection: boolean; 16 | } 17 | 18 | export class GodraysCompositorMaterial extends THREE.ShaderMaterial implements Resizable { 19 | constructor({ 20 | godrays, 21 | edgeStrength, 22 | edgeRadius, 23 | color, 24 | camera, 25 | gammaCorrection, 26 | }: GodraysCompositorMaterialProps) { 27 | const uniforms = { 28 | godrays: { value: godrays }, 29 | sceneDiffuse: { value: null }, 30 | sceneDepth: { value: null }, 31 | edgeStrength: { value: edgeStrength }, 32 | edgeRadius: { value: edgeRadius }, 33 | near: { value: 0.1 }, 34 | far: { value: 1000.0 }, 35 | color: { value: color }, 36 | resolution: { value: new THREE.Vector2(1, 1) }, 37 | gammaCorrection: { value: 1 }, 38 | }; 39 | 40 | super({ 41 | name: 'GodraysCompositorMaterial', 42 | uniforms, 43 | depthWrite: false, 44 | depthTest: false, 45 | fragmentShader: GodraysCompositorFragmentShader, 46 | vertexShader: GodraysCompositorVertexShader, 47 | }); 48 | 49 | this.updateUniforms(edgeStrength, edgeRadius, color, gammaCorrection, camera.near, camera.far); 50 | } 51 | 52 | public updateUniforms( 53 | edgeStrength: number, 54 | edgeRadius: number, 55 | color: THREE.Color, 56 | gammaCorrection: boolean, 57 | near: number, 58 | far: number 59 | ): void { 60 | this.uniforms.edgeStrength.value = edgeStrength; 61 | this.uniforms.edgeRadius.value = edgeRadius; 62 | this.uniforms.color.value = color; 63 | this.uniforms.near.value = near; 64 | this.uniforms.far.value = far; 65 | this.uniforms.gammaCorrection.value = gammaCorrection ? 1 : 0; 66 | } 67 | 68 | setSize(width: number, height: number): void { 69 | this.uniforms.resolution.value.set(width, height); 70 | } 71 | } 72 | 73 | export class GodraysCompositorPass extends Pass { 74 | sceneCamera: PerspectiveCamera; 75 | private depthCopyRenderTexture: THREE.WebGLRenderTarget | null = null; 76 | private depthTextureCopyPass: CopyPass | null = null; 77 | 78 | constructor(props: GodraysCompositorMaterialProps) { 79 | super('GodraysCompositorPass'); 80 | this.fullscreenMaterial = new GodraysCompositorMaterial(props); 81 | this.sceneCamera = props.camera; 82 | } 83 | 84 | public updateUniforms(params: GodraysPassParams): void { 85 | (this.fullscreenMaterial as GodraysCompositorMaterial).updateUniforms( 86 | params.edgeStrength, 87 | params.edgeRadius, 88 | params.color, 89 | params.gammaCorrection, 90 | this.sceneCamera.near, 91 | this.sceneCamera.far 92 | ); 93 | } 94 | 95 | override render( 96 | renderer: THREE.WebGLRenderer, 97 | inputBuffer: THREE.WebGLRenderTarget, 98 | outputBuffer: THREE.WebGLRenderTarget | null, 99 | _deltaTime?: number | undefined, 100 | _stencilTest?: boolean | undefined 101 | ): void { 102 | (this.fullscreenMaterial as GodraysCompositorMaterial).uniforms.sceneDiffuse.value = 103 | inputBuffer.texture; 104 | 105 | // There is a limitation in the pmndrs postprocessing library that causes rendering issues when 106 | // the depth texture provided to the effect is the same as the one bound to the output buffer. 107 | // 108 | // To work around this, we copy the depth texture to a new render target and use that instead 109 | // if it's found to be the same. 110 | const sceneDepth = (this.fullscreenMaterial as GodraysCompositorMaterial).uniforms.sceneDepth 111 | .value; 112 | if ( 113 | sceneDepth && 114 | outputBuffer && 115 | outputBuffer.depthTexture && 116 | sceneDepth === outputBuffer.depthTexture 117 | ) { 118 | if (!this.depthCopyRenderTexture) { 119 | this.depthCopyRenderTexture = new THREE.WebGLRenderTarget( 120 | outputBuffer.depthTexture.image.width, 121 | outputBuffer.depthTexture.image.height, 122 | { 123 | minFilter: outputBuffer.depthTexture.minFilter, 124 | magFilter: outputBuffer.depthTexture.magFilter, 125 | format: outputBuffer.depthTexture.format, 126 | generateMipmaps: outputBuffer.depthTexture.generateMipmaps, 127 | } 128 | ); 129 | } 130 | if (!this.depthTextureCopyPass) { 131 | this.depthTextureCopyPass = new CopyPass(); 132 | } 133 | 134 | this.depthTextureCopyPass.render( 135 | renderer, 136 | (this.fullscreenMaterial as GodraysCompositorMaterial).uniforms.sceneDepth.value, 137 | this.depthCopyRenderTexture 138 | ); 139 | (this.fullscreenMaterial as GodraysCompositorMaterial).uniforms.sceneDepth.value = 140 | this.depthCopyRenderTexture.texture; 141 | } 142 | 143 | renderer.setRenderTarget(outputBuffer); 144 | renderer.render(this.scene, this.camera); 145 | 146 | (this.fullscreenMaterial as GodraysCompositorMaterial).uniforms.sceneDepth.value = sceneDepth; 147 | } 148 | 149 | override setDepthTexture( 150 | depthTexture: THREE.Texture, 151 | depthPacking?: THREE.DepthPackingStrategies | undefined 152 | ): void { 153 | if (depthPacking && depthPacking !== THREE.BasicDepthPacking) { 154 | throw new Error('Only BasicDepthPacking is supported'); 155 | } 156 | (this.fullscreenMaterial as GodraysCompositorMaterial).uniforms.sceneDepth.value = depthTexture; 157 | } 158 | 159 | override setSize(width: number, height: number): void { 160 | (this.fullscreenMaterial as GodraysCompositorMaterial).setSize(width, height); 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/illumPass.ts: -------------------------------------------------------------------------------- 1 | import { Pass, type Resizable } from 'postprocessing'; 2 | import * as THREE from 'three'; 3 | 4 | import { getBlueNoiseTexture } from './bluenoise'; 5 | import GodraysFragmentShader from './godrays.frag'; 6 | import GodraysVertexShader from './godrays.vert'; 7 | import type { GodraysPassParams } from './index'; 8 | 9 | export const GODRAYS_RESOLUTION_SCALE = 1 / 2; 10 | 11 | interface GodRaysDefines { 12 | IS_POINT_LIGHT?: string; 13 | IS_DIRECTIONAL_LIGHT?: string; 14 | } 15 | 16 | class GodraysMaterial extends THREE.ShaderMaterial { 17 | constructor(light: THREE.PointLight | THREE.DirectionalLight) { 18 | const uniforms = { 19 | density: { value: 1 / 128 }, 20 | maxDensity: { value: 0.5 }, 21 | distanceAttenuation: { value: 2 }, 22 | sceneDepth: { value: null }, 23 | lightPos: { value: new THREE.Vector3(0, 0, 0) }, 24 | cameraPos: { value: new THREE.Vector3(0, 0, 0) }, 25 | resolution: { value: new THREE.Vector2(1, 1) }, 26 | premultipliedLightCameraMatrix: { value: new THREE.Matrix4() }, 27 | cameraProjectionMatrixInv: { value: new THREE.Matrix4() }, 28 | cameraMatrixWorld: { value: new THREE.Matrix4() }, 29 | shadowMap: { value: null }, 30 | texelSizeY: { value: 1 }, 31 | lightCameraNear: { value: 0.1 }, 32 | lightCameraFar: { value: 1000 }, 33 | near: { value: 0.1 }, 34 | far: { value: 1000.0 }, 35 | blueNoise: { value: null as THREE.Texture | null }, 36 | noiseResolution: { value: new THREE.Vector2(1, 1) }, 37 | fNormals: { value: DIRECTIONS.map(() => new THREE.Vector3()) }, 38 | fConstants: { value: DIRECTIONS.map(() => 0) }, 39 | raymarchSteps: { value: 60 }, 40 | }; 41 | 42 | const defines: GodRaysDefines = {}; 43 | if (light instanceof THREE.PointLight || (light as any).isPointLight) { 44 | defines.IS_POINT_LIGHT = ''; 45 | } else if (light instanceof THREE.DirectionalLight || (light as any).isDirectionalLight) { 46 | defines.IS_DIRECTIONAL_LIGHT = ''; 47 | } 48 | 49 | super({ 50 | name: 'GodraysMaterial', 51 | uniforms, 52 | fragmentShader: GodraysFragmentShader, 53 | vertexShader: GodraysVertexShader, 54 | defines, 55 | }); 56 | 57 | getBlueNoiseTexture().then(blueNoiseTexture => { 58 | uniforms.blueNoise.value = blueNoiseTexture; 59 | uniforms.noiseResolution.value.set( 60 | blueNoiseTexture.image.width, 61 | blueNoiseTexture.image.height 62 | ); 63 | }); 64 | } 65 | } 66 | 67 | const DIRECTIONS = [ 68 | new THREE.Vector3(1, 0, 0), 69 | new THREE.Vector3(-1, 0, 0), 70 | new THREE.Vector3(0, 1, 0), 71 | new THREE.Vector3(0, -1, 0), 72 | new THREE.Vector3(0, 0, 1), 73 | new THREE.Vector3(0, 0, -1), 74 | ]; 75 | const PLANES = DIRECTIONS.map(() => new THREE.Plane()); 76 | const SCRATCH_VECTOR = new THREE.Vector3(); 77 | const SCRATCH_FRUSTUM = new THREE.Frustum(); 78 | const SCRATCH_MAT4 = new THREE.Matrix4(); 79 | 80 | export interface GodraysIllumPassProps { 81 | light: THREE.PointLight | THREE.DirectionalLight; 82 | camera: THREE.PerspectiveCamera; 83 | } 84 | 85 | export class GodraysIllumPass extends Pass implements Resizable { 86 | private material: GodraysMaterial; 87 | private shadowMapSet = false; 88 | private props: GodraysIllumPassProps; 89 | private lastParams: GodraysPassParams; 90 | private lightWorldPos = new THREE.Vector3(); 91 | 92 | constructor(props: GodraysIllumPassProps, params: GodraysPassParams) { 93 | super('GodraysPass'); 94 | 95 | this.props = props; 96 | this.lastParams = params; 97 | this.material = new GodraysMaterial(props.light); 98 | 99 | this.updateUniforms(props, params); 100 | 101 | this.fullscreenMaterial = this.material; 102 | } 103 | 104 | override setSize(width: number, height: number): void { 105 | this.material.uniforms.resolution.value.set( 106 | Math.ceil(width * GODRAYS_RESOLUTION_SCALE), 107 | Math.ceil(height * GODRAYS_RESOLUTION_SCALE) 108 | ); 109 | this.material.uniforms.near.value = this.props.camera.near; 110 | this.material.uniforms.far.value = this.props.camera.far; 111 | } 112 | 113 | override render( 114 | renderer: THREE.WebGLRenderer, 115 | _inputBuffer: THREE.WebGLRenderTarget, 116 | outputBuffer: THREE.WebGLRenderTarget, 117 | _deltaTime?: number | undefined, 118 | _stencilTest?: boolean | undefined 119 | ): void { 120 | if (!this.shadowMapSet && this.props.light.shadow.map?.texture) { 121 | this.updateUniforms(this.props, this.lastParams); 122 | this.shadowMapSet = true; 123 | } 124 | this.updateLightParams(this.props); 125 | renderer.setRenderTarget(outputBuffer); 126 | renderer.render(this.scene, this.camera); 127 | } 128 | 129 | override setDepthTexture( 130 | depthTexture: THREE.Texture, 131 | depthPacking?: THREE.DepthPackingStrategies | undefined 132 | ): void { 133 | this.material.uniforms.sceneDepth.value = depthTexture; 134 | if (depthPacking && depthPacking !== THREE.BasicDepthPacking) { 135 | throw new Error('Only BasicDepthPacking is supported'); 136 | } 137 | } 138 | 139 | private updateLightParams({ light }: GodraysIllumPassProps) { 140 | light.getWorldPosition(this.lightWorldPos); 141 | 142 | const uniforms = this.material.uniforms; 143 | (uniforms.premultipliedLightCameraMatrix.value as THREE.Matrix4).multiplyMatrices( 144 | light.shadow.camera.projectionMatrix, 145 | light.shadow.camera.matrixWorldInverse 146 | ); 147 | 148 | if (light instanceof THREE.PointLight || (light as any).isPointLight) { 149 | for (let i = 0; i < DIRECTIONS.length; i += 1) { 150 | const direction = DIRECTIONS[i]; 151 | const plane = PLANES[i]; 152 | 153 | SCRATCH_VECTOR.copy(light.position); 154 | SCRATCH_VECTOR.addScaledVector(direction, uniforms.lightCameraFar.value); 155 | plane.setFromNormalAndCoplanarPoint(direction, SCRATCH_VECTOR); 156 | 157 | uniforms.fNormals.value[i].copy(plane.normal); 158 | uniforms.fConstants.value[i] = plane.constant; 159 | } 160 | } else if (light instanceof THREE.DirectionalLight || (light as any).isDirectionalLight) { 161 | SCRATCH_MAT4.multiplyMatrices( 162 | light.shadow.camera.projectionMatrix, 163 | light.shadow.camera.matrixWorldInverse 164 | ); 165 | SCRATCH_FRUSTUM.setFromProjectionMatrix(SCRATCH_MAT4); 166 | 167 | for (let planeIx = 0; planeIx < 6; planeIx += 1) { 168 | const plane = SCRATCH_FRUSTUM.planes[planeIx]; 169 | uniforms.fNormals.value[planeIx].copy(plane.normal).multiplyScalar(-1); 170 | uniforms.fConstants.value[planeIx] = plane.constant * -1; 171 | } 172 | } 173 | } 174 | 175 | public updateUniforms({ light, camera }: GodraysIllumPassProps, params: GodraysPassParams): void { 176 | const shadow = light.shadow; 177 | if (!shadow) { 178 | throw new Error('Light used for godrays must have shadow'); 179 | } 180 | 181 | const shadowMap = shadow.map?.texture ?? null; 182 | const mapSize = shadow.map?.height ?? 1; 183 | 184 | const uniforms = this.material.uniforms; 185 | uniforms.density.value = params.density; 186 | uniforms.maxDensity.value = params.maxDensity; 187 | uniforms.lightPos.value = this.lightWorldPos; 188 | uniforms.cameraPos.value = camera.position; 189 | uniforms.cameraProjectionMatrixInv.value = camera.projectionMatrixInverse; 190 | uniforms.cameraMatrixWorld.value = camera.matrixWorld; 191 | uniforms.shadowMap.value = shadowMap; 192 | uniforms.texelSizeY.value = 1 / (mapSize * 2); 193 | uniforms.lightCameraNear.value = shadow?.camera.near ?? 0.1; 194 | uniforms.lightCameraFar.value = shadow?.camera.far ?? 1000; 195 | uniforms.near.value = camera.near; 196 | uniforms.far.value = camera.far; 197 | uniforms.density.value = params.density; 198 | uniforms.maxDensity.value = params.maxDensity; 199 | uniforms.distanceAttenuation.value = params.distanceAttenuation; 200 | uniforms.raymarchSteps.value = params.raymarchSteps; 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /src/godrays.frag: -------------------------------------------------------------------------------- 1 | /* 2 | * Code taken from this demo: https://n8python.github.io/goodGodRays/ 3 | * By: https://github.com/n8python 4 | * 5 | * With cleanup and minor changes 6 | */ 7 | 8 | varying vec2 vUv; 9 | 10 | uniform sampler2D sceneDepth; 11 | uniform sampler2D blueNoise; 12 | uniform vec3 lightPos; 13 | uniform vec3 cameraPos; 14 | uniform vec2 resolution; 15 | uniform mat4 cameraProjectionMatrixInv; 16 | uniform mat4 cameraMatrixWorld; 17 | uniform sampler2D shadowMap; 18 | uniform vec2 noiseResolution; 19 | uniform float texelSizeY; 20 | uniform float lightCameraNear; 21 | uniform float lightCameraFar; 22 | uniform float near; 23 | uniform float far; 24 | uniform float density; 25 | uniform float maxDensity; 26 | uniform float distanceAttenuation; 27 | uniform vec3[6] fNormals; 28 | uniform float[6] fConstants; 29 | uniform float raymarchSteps; 30 | uniform mat4 premultipliedLightCameraMatrix; 31 | 32 | #include 33 | 34 | vec3 WorldPosFromDepth(float depth, vec2 coord) { 35 | #if defined( USE_LOGDEPTHBUF ) 36 | float d = pow(2.0, depth * log2(far + 1.0)) - 1.0; 37 | float a = far / (far - near); 38 | float b = far * near / (near - far); 39 | depth = a + b / d; 40 | #endif 41 | 42 | float z = depth * 2.0 - 1.0; 43 | vec4 clipSpacePosition = vec4(coord * 2.0 - 1.0, z, 1.0); 44 | vec4 viewSpacePosition = cameraProjectionMatrixInv * clipSpacePosition; 45 | // Perspective division 46 | viewSpacePosition /= viewSpacePosition.w; 47 | vec4 worldSpacePosition = cameraMatrixWorld * viewSpacePosition; 48 | return worldSpacePosition.xyz; 49 | } 50 | 51 | /** 52 | * Converts angle between light and a world position to a coordinate 53 | * in a point light cube shadow map 54 | */ 55 | vec2 cubeToUV(vec3 v) { 56 | // Number of texels to avoid at the edge of each square 57 | vec3 absV = abs(v); 58 | // Intersect unit cube 59 | float scaleToCube = 1.0 / max(absV.x, max(absV.y, absV.z)); 60 | absV *= scaleToCube; 61 | // Apply scale to avoid seams 62 | // two texels less per square (one texel will do for NEAREST) 63 | v *= scaleToCube * (1.0 - 2.0 * texelSizeY); 64 | // Unwrap 65 | // space: -1 ... 1 range for each square 66 | // 67 | // #X## dim := ( 4 , 2 ) 68 | // # # center := ( 1 , 1 ) 69 | vec2 planar = v.xy; 70 | float almostATexel = 1.5 * texelSizeY; 71 | float almostOne = 1.0 - almostATexel; 72 | if (absV.z >= almostOne) { 73 | if (v.z > 0.0) 74 | planar.x = 4.0 - v.x; 75 | } else if (absV.x >= almostOne) { 76 | float signX = sign(v.x); 77 | planar.x = v.z * signX + 2.0 * signX; 78 | } else if (absV.y >= almostOne) { 79 | float signY = sign(v.y); 80 | planar.x = v.x + 2.0 * signY + 2.0; 81 | planar.y = v.z * signY - 2.0; 82 | } 83 | // Transform to UV space 84 | // scale := 0.5 / dim 85 | // translate := ( center + 0.5 ) / dim 86 | return vec2(0.125, 0.25) * planar + vec2(0.375, 0.75); 87 | } 88 | 89 | /** 90 | * Projects `worldPos` onto the shadow map of a directional light and returns 91 | * that position in UV space. 92 | */ 93 | vec3 projectToShadowMap(vec3 worldPos) { 94 | // vec4 lightSpacePos = lightCameraProjectionMatrix * lightCameraMatrixWorldInverse * vec4(worldPos, 1.0); 95 | // use pre-multiplied matrix to transform to light space 96 | vec4 lightSpacePos = premultipliedLightCameraMatrix * vec4(worldPos, 1.0); 97 | lightSpacePos /= lightSpacePos.w; 98 | lightSpacePos = lightSpacePos * 0.5 + 0.5; 99 | return lightSpacePos.xyz; 100 | } 101 | 102 | vec2 inShadow(vec3 worldPos) { 103 | #if defined(IS_POINT_LIGHT) 104 | vec2 shadowMapUV = cubeToUV(normalize(worldPos - lightPos)); 105 | #elif defined(IS_DIRECTIONAL_LIGHT) 106 | vec3 shadowMapUV = projectToShadowMap(worldPos); 107 | bool isOutsideShadowMap = shadowMapUV.x < 0.0 || shadowMapUV.x > 1.0 || shadowMapUV.y < 0.0 || shadowMapUV.y > 1.0 || shadowMapUV.z < 0.0 || shadowMapUV.z > 1.0; 108 | if (isOutsideShadowMap) { 109 | return vec2(1.0, 0.0); 110 | } 111 | #endif 112 | 113 | vec4 packedDepth = texture2D(shadowMap, shadowMapUV.xy); 114 | float depth = unpackRGBAToDepth(packedDepth); 115 | depth = lightCameraNear + (lightCameraFar - lightCameraNear) * depth; 116 | #if defined(IS_POINT_LIGHT) 117 | float lightDist = distance(worldPos, lightPos); 118 | #elif defined(IS_DIRECTIONAL_LIGHT) 119 | float lightDist = (lightCameraNear + (lightCameraFar - lightCameraNear) * shadowMapUV.z); 120 | #endif 121 | float difference = lightDist - depth; 122 | return vec2(float(difference > 0.0), lightDist); 123 | } 124 | 125 | /** 126 | * Calculates the signed distance from point `p` to a plane defined by 127 | * normal `n` and distance `h` from the origin. 128 | * 129 | * `n` must be normalized. 130 | */ 131 | float sdPlane(vec3 p, vec3 n, float h) { 132 | return dot(p, n) + h; 133 | } 134 | 135 | /** 136 | * Calculates the intersection of a ray defined by `rayOrigin` and `rayDirection` 137 | * with a plane defined by normal `planeNormal` and distance `planeDistance` 138 | * 139 | * Returns the distance from the ray origin to the intersection point. 140 | * 141 | * The return value will be negative if the ray does not intersect the plane. 142 | */ 143 | float intersectRayPlane(vec3 rayOrigin, vec3 rayDirection, vec3 planeNormal, float planeDistance) { 144 | float denom = dot(planeNormal, rayDirection); 145 | return -(sdPlane(rayOrigin, planeNormal, planeDistance) / denom); 146 | } 147 | 148 | void main() { 149 | float depth = texture2D(sceneDepth, vUv).x; 150 | 151 | vec3 worldPos = WorldPosFromDepth(depth, vUv); 152 | float inBoxDist = -10000.0; 153 | for (int i = 0; i < 6; i++) { 154 | inBoxDist = max(inBoxDist, sdPlane(cameraPos, fNormals[i], fConstants[i])); 155 | } 156 | bool cameraIsInBox = inBoxDist < 0.0; 157 | vec3 startPos = cameraPos; 158 | if (cameraIsInBox) { 159 | // If the ray target is outside the shadow box, move it to the nearest 160 | // point on the box to avoid marching through unlit space 161 | for (int i = 0; i < 6; i++) { 162 | if (sdPlane(worldPos, fNormals[i], fConstants[i]) > 0.0) { 163 | vec3 direction = normalize(worldPos - cameraPos); 164 | float t = intersectRayPlane(cameraPos, direction, fNormals[i], fConstants[i]); 165 | worldPos = cameraPos + t * direction; 166 | } 167 | } 168 | } else { 169 | // Find the first point where the ray intersects the shadow box (`startPos`) 170 | vec3 direction = normalize(worldPos - cameraPos); 171 | float minT = 10000.0; 172 | for (int i = 0; i < 6; i++) { 173 | float t = intersectRayPlane(cameraPos, direction, fNormals[i], fConstants[i]); 174 | if (t < minT && t > 0.0) { 175 | minT = t; 176 | } 177 | } 178 | if (minT == 10000.0) { 179 | gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0); 180 | return; 181 | } 182 | startPos = cameraPos + (minT + 0.001) * direction; 183 | 184 | // If the ray target is outside the shadow box, move it to the nearest 185 | // point on the box to avoid marching through unlit space 186 | float endInBoxDist = -10000.0; 187 | for (int i = 0; i < 6; i++) { 188 | endInBoxDist = max(endInBoxDist, sdPlane(worldPos, fNormals[i], fConstants[i])); 189 | } 190 | bool endInBox = false; 191 | if (endInBoxDist < 0.0) { 192 | endInBox = true; 193 | } 194 | if (!endInBox) { 195 | float minT = 10000.0; 196 | for (int i = 0; i < 6; i++) { 197 | if (sdPlane(worldPos, fNormals[i], fConstants[i]) > 0.0) { 198 | float t = intersectRayPlane(startPos, direction, fNormals[i], fConstants[i]); 199 | if (t < minT && t > 0.0) { 200 | minT = t; 201 | } 202 | } 203 | } 204 | 205 | if (minT < distance(worldPos, startPos)) { 206 | worldPos = startPos + minT * direction; 207 | } 208 | } 209 | } 210 | float illum = 0.0; 211 | 212 | vec4 blueNoiseSample = texture2D(blueNoise, vUv * (resolution / noiseResolution)); 213 | float samplesFloat = round(raymarchSteps + ((raymarchSteps / 8.) + 2.) * blueNoiseSample.x); 214 | int samples = int(samplesFloat); 215 | for (int i = 0; i < samples; i++) { 216 | vec3 samplePos = mix(startPos, worldPos, float(i) / samplesFloat); 217 | vec2 shadowInfo = inShadow(samplePos); 218 | float shadowAmount = 1.0 - shadowInfo.x; 219 | illum += shadowAmount * (distance(startPos, worldPos) * density) * pow(1.0 - shadowInfo.y / lightCameraFar, distanceAttenuation);// * exp(-distanceAttenuation * shadowInfo.y); 220 | } 221 | illum /= samplesFloat; 222 | gl_FragColor = vec4(vec3(clamp(1.0 - exp(-illum), 0.0, maxDensity)), depth); 223 | } 224 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Code taken + adapted from this demo: https://n8python.github.io/goodGodRays/ 3 | * By: https://github.com/n8python 4 | * 5 | * With cleanup and minor changes 6 | */ 7 | import { type Disposable, KernelSize, Pass } from 'postprocessing'; 8 | import * as THREE from 'three'; 9 | 10 | import { BilateralFilterPass, GODRAYS_BLUR_RESOLUTION_SCALE } from './bilateralFilter'; 11 | import { GodraysCompositorMaterial, GodraysCompositorPass } from './compositorPass'; 12 | import { 13 | GODRAYS_RESOLUTION_SCALE, 14 | GodraysIllumPass, 15 | type GodraysIllumPassProps, 16 | } from './illumPass'; 17 | 18 | export interface GodraysBlurParams { 19 | /** 20 | * The sigma factor used by the bilateral filter for the blur. Higher values result in more blur, but 21 | * can cause artifacts. 22 | * 23 | * Default: 0.1 24 | */ 25 | variance: number; 26 | /** 27 | * The kernel size for the bilateral filter. Higher values blur more neighboring pixels and can smooth over higher amounts of noise, 28 | * but require exponentially more texture samples and thus can be slower. 29 | * 30 | * Default: `KernelSize.SMALL` 31 | */ 32 | kernelSize: KernelSize; 33 | } 34 | 35 | export interface GodraysPassParams { 36 | /** 37 | * The rate of accumulation for the godrays. Higher values roughly equate to more humid air/denser fog. 38 | * 39 | * Default: 1 / 128 40 | */ 41 | density: number; 42 | /** 43 | * The maximum density of the godrays. Limits the maximum brightness of the godrays. 44 | * 45 | * Default: 0.5 46 | */ 47 | maxDensity: number; 48 | /** 49 | * Default: 2 50 | */ 51 | edgeStrength: number; 52 | /** 53 | * Edge radius used for depth-aware upsampling of the godrays. Higher values can yield better edge quality at the cost of performance, as 54 | * each level higher of this requires two additional texture samples. 55 | * 56 | * Default: 2 57 | */ 58 | edgeRadius: number; 59 | /** 60 | * Higher values decrease the accumulation of godrays the further away they are from the light source. 61 | * 62 | * Default: 2 63 | */ 64 | distanceAttenuation: number; 65 | /** 66 | * The color of the godrays. 67 | * 68 | * Default: `new THREE.Color(0xffffff)` 69 | */ 70 | color: THREE.Color; 71 | /** 72 | * The number of raymarching steps to take per pixel. Higher values increase the quality of the godrays at the cost of performance. 73 | * 74 | * Default: 60 75 | */ 76 | raymarchSteps: number; 77 | /** 78 | * Whether or not to apply a bilateral blur to the godrays. This can be used to reduce artifacts that can occur when using a low number of raymarching steps. 79 | * 80 | * It costs a bit of extra performance, but can allow for a lower number of raymarching steps to be used with similar quality. 81 | * 82 | * Default: false 83 | */ 84 | blur: boolean | Partial; 85 | gammaCorrection: boolean; 86 | } 87 | 88 | const defaultParams: GodraysPassParams = { 89 | density: 1 / 128, 90 | maxDensity: 0.5, 91 | edgeStrength: 2, 92 | edgeRadius: 2, 93 | distanceAttenuation: 2, 94 | color: new THREE.Color(0xffffff), 95 | raymarchSteps: 60, 96 | blur: true, 97 | gammaCorrection: true, 98 | }; 99 | 100 | const populateParams = (partialParams: Partial): GodraysPassParams => { 101 | return { 102 | ...defaultParams, 103 | ...partialParams, 104 | color: new THREE.Color(partialParams.color ?? defaultParams.color), 105 | }; 106 | }; 107 | 108 | const defaultGodraysBlurParams: GodraysBlurParams = { 109 | variance: 0.1, 110 | kernelSize: KernelSize.SMALL, 111 | }; 112 | 113 | const populateGodraysBlurParams = ( 114 | blur: boolean | Partial 115 | ): GodraysBlurParams => { 116 | if (typeof blur === 'boolean') { 117 | return { ...defaultGodraysBlurParams }; 118 | } 119 | return { ...defaultGodraysBlurParams, ...blur }; 120 | }; 121 | 122 | export class GodraysPass extends Pass implements Disposable { 123 | private props: GodraysIllumPassProps; 124 | private depthTexture: THREE.Texture | null = null; 125 | private depthPacking: THREE.DepthPackingStrategies | null | undefined = null; 126 | private lastParams: GodraysPassParams; 127 | 128 | private godraysRenderTarget = new THREE.WebGLRenderTarget(1, 1, { 129 | minFilter: THREE.LinearFilter, 130 | magFilter: THREE.LinearFilter, 131 | format: THREE.RGBAFormat, 132 | type: THREE.HalfFloatType, 133 | generateMipmaps: false, 134 | }); 135 | private illumPass: GodraysIllumPass; 136 | private enableBlurPass = true; 137 | private blurPass: BilateralFilterPass | null = null; 138 | private blurRenderTarget: THREE.WebGLRenderTarget | null = null; 139 | private compositorPass: GodraysCompositorPass; 140 | 141 | /** 142 | * Constructs a new GodraysPass. Casts godrays from a point light source. Add to your scene's composer like this: 143 | * 144 | * ```ts 145 | * import { EffectComposer, RenderPass } from 'postprocessing'; 146 | * import { GodraysPass } from 'three-good-godrays'; 147 | * 148 | * const composer = new EffectComposer(renderer, { frameBufferType: THREE.HalfFloatType }); 149 | * const renderPass = new RenderPass(scene, camera); 150 | * renderPass.renderToScreen = false; 151 | * composer.addPass(renderPass); 152 | * 153 | * const godraysPass = new GodraysPass(pointLight, camera); 154 | * godraysPass.renderToScreen = true; 155 | * composer.addPass(godraysPass); 156 | * 157 | * function animate() { 158 | * composer.render(scene, camera); 159 | * } 160 | * ``` 161 | * 162 | * @param light The light source to use for the godrays. 163 | * @param camera The camera used to render the scene. 164 | * @param partialParams The parameters to use for the godrays effect. Will use default values for any parameters not specified. 165 | */ 166 | constructor( 167 | light: THREE.PointLight | THREE.DirectionalLight, 168 | camera: THREE.PerspectiveCamera, 169 | partialParams: Partial = {} 170 | ) { 171 | super('GodraysPass'); 172 | 173 | this.props = { 174 | light: light, 175 | camera, 176 | }; 177 | const params = populateParams(partialParams); 178 | this.lastParams = params; 179 | 180 | this.illumPass = new GodraysIllumPass(this.props, params); 181 | this.illumPass.needsDepthTexture = true; 182 | 183 | this.compositorPass = new GodraysCompositorPass({ 184 | godrays: this.godraysRenderTarget.texture, 185 | edgeStrength: params.edgeStrength, 186 | edgeRadius: params.edgeRadius, 187 | color: params.color, 188 | camera, 189 | gammaCorrection: params.gammaCorrection, 190 | }); 191 | this.compositorPass.needsDepthTexture = true; 192 | 193 | // Indicate to the composer that this pass needs depth information from the previous pass 194 | this.needsDepthTexture = true; 195 | 196 | this.setParams(params); 197 | } 198 | 199 | /** 200 | * Updates the parameters used for the godrays effect. Will use default values for any parameters not specified. 201 | */ 202 | public setParams(partialParams: Partial): void { 203 | const params = populateParams(partialParams); 204 | this.lastParams = params; 205 | this.illumPass.updateUniforms(this.props, params); 206 | this.compositorPass.updateUniforms(params); 207 | 208 | this.enableBlurPass = !!params.blur; 209 | if (params.blur && this.blurPass) { 210 | const blurParams = populateGodraysBlurParams(params.blur); 211 | 212 | if (this.blurPass.material.defines.KSIZE_ENUM !== blurParams.kernelSize) { 213 | this.blurPass.dispose(); 214 | this.maybeInitBlur(this.godraysRenderTarget.texture); 215 | } 216 | 217 | this.blurPass.updateUniforms(blurParams); 218 | } 219 | } 220 | 221 | private maybeInitBlur(input: THREE.Texture) { 222 | if (!this.blurPass) { 223 | this.blurPass = new BilateralFilterPass(input); 224 | const blurParams = populateGodraysBlurParams(this.lastParams.blur); 225 | this.blurPass.updateUniforms(blurParams); 226 | if (this.depthTexture) { 227 | this.blurPass.setDepthTexture(this.depthTexture, this.depthPacking ?? undefined); 228 | } 229 | } 230 | if (!this.blurRenderTarget) { 231 | this.blurRenderTarget = new THREE.WebGLRenderTarget( 232 | Math.ceil(this.godraysRenderTarget.width * GODRAYS_BLUR_RESOLUTION_SCALE), 233 | Math.ceil(this.godraysRenderTarget.height * GODRAYS_BLUR_RESOLUTION_SCALE), 234 | { 235 | minFilter: THREE.LinearFilter, 236 | magFilter: THREE.LinearFilter, 237 | format: THREE.RGBAFormat, 238 | type: THREE.HalfFloatType, 239 | generateMipmaps: false, 240 | } 241 | ); 242 | } 243 | } 244 | 245 | override render( 246 | renderer: THREE.WebGLRenderer, 247 | inputBuffer: THREE.WebGLRenderTarget, 248 | outputBuffer: THREE.WebGLRenderTarget, 249 | _deltaTime?: number | undefined, 250 | _stencilTest?: boolean | undefined 251 | ): void { 252 | this.illumPass.render(renderer, inputBuffer, this.godraysRenderTarget); 253 | 254 | if (this.enableBlurPass) { 255 | this.maybeInitBlur(this.godraysRenderTarget.texture); 256 | 257 | this.blurPass!.render(renderer, this.godraysRenderTarget, this.blurRenderTarget!); 258 | (this.compositorPass.fullscreenMaterial as GodraysCompositorMaterial).uniforms.godrays.value = 259 | this.blurRenderTarget!.texture; 260 | } else { 261 | (this.compositorPass.fullscreenMaterial as GodraysCompositorMaterial).uniforms.godrays.value = 262 | this.godraysRenderTarget.texture; 263 | } 264 | 265 | this.compositorPass.render(renderer, inputBuffer, this.renderToScreen ? null : outputBuffer); 266 | } 267 | 268 | override setDepthTexture( 269 | depthTexture: THREE.Texture, 270 | depthPacking?: THREE.DepthPackingStrategies | undefined 271 | ): void { 272 | this.illumPass.setDepthTexture(depthTexture, depthPacking); 273 | this.compositorPass.setDepthTexture(depthTexture, depthPacking); 274 | this.depthTexture = depthTexture; 275 | this.depthPacking = depthPacking; 276 | } 277 | 278 | override setSize(width: number, height: number): void { 279 | this.godraysRenderTarget.setSize( 280 | Math.ceil(width * GODRAYS_RESOLUTION_SCALE), 281 | Math.ceil(height * GODRAYS_RESOLUTION_SCALE) 282 | ); 283 | this.illumPass.setSize(width, height); 284 | this.compositorPass.setSize(width, height); 285 | this.blurPass?.setSize( 286 | Math.ceil(width * GODRAYS_RESOLUTION_SCALE), 287 | Math.ceil(height * GODRAYS_RESOLUTION_SCALE) 288 | ); 289 | this.blurRenderTarget?.setSize( 290 | Math.ceil(width * GODRAYS_RESOLUTION_SCALE * GODRAYS_BLUR_RESOLUTION_SCALE), 291 | Math.ceil(height * GODRAYS_RESOLUTION_SCALE * GODRAYS_BLUR_RESOLUTION_SCALE) 292 | ); 293 | } 294 | 295 | override dispose(): void { 296 | this.godraysRenderTarget.dispose(); 297 | this.illumPass.dispose(); 298 | this.compositorPass.dispose(); 299 | this.blurPass?.dispose; 300 | super.dispose(); 301 | } 302 | } 303 | --------------------------------------------------------------------------------