Skip to content

ClusteredLightsNode: Fix point light distance attenuation#33679

Merged
sunag merged 2 commits into
devfrom
clustered
May 29, 2026
Merged

ClusteredLightsNode: Fix point light distance attenuation#33679
sunag merged 2 commits into
devfrom
clustered

Conversation

@mrdoob

@mrdoob mrdoob commented May 29, 2026

Copy link
Copy Markdown
Owner

Related issue: #33406

Description

Point lights through ClusteredLighting didn't match the forward renderer. The light's view position was computed as mat4 * vec3, leaving a stray w on the result; that w carried into the light vector, so directPointLight()'s length()/normalize() used sqrt(d² + w²) instead of d, distorting the falloff.

Before After
image image

The light's view-space position was computed as `mat4 * vec3`, leaving a stray w component on the result. That w propagated into the light vector, so `directPointLight()`'s length()/normalize() evaluated `sqrt(d² + w²)` instead of `d` — flattening and offsetting the falloff so clustered point lights didn't match the forward renderer. Transform an explicit homogeneous point and drop w: `mul( vec4( position, 1.0 ) ).xyz`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mrdoob mrdoob added this to the r185 milestone May 29, 2026
@mrdoob mrdoob requested review from Copilot and sunag May 29, 2026 06:39
@mrdoob mrdoob removed the request for review from Copilot May 29, 2026 06:40
@mrdoob

mrdoob commented May 29, 2026

Copy link
Copy Markdown
Owner Author

@sunag IBL doesn't work with ClusteredLight.

I asked Claude to add support for it (as seen in the screenshots) but I'm not sure it's the best solution.

Would you like to give it a go? Here's the example I'm using:

<!DOCTYPE html>
<html lang="en">
	<head>
		<title>three.js webgpu - clustered lighting + ibl</title>
		<meta charset="utf-8">
		<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
		<meta property="og:title" content="three.js webgpu - clustered lighting + ibl">
		<meta property="og:type" content="website">
		<meta property="og:url" content="https://threejs.org/examples/webgpu_lights_clustered_ibl.html">
		<meta property="og:image" content="https://threejs.org/examples/screenshots/webgpu_lights_clustered_ibl.jpg">
		<link type="text/css" rel="stylesheet" href="example.css">
	</head>
	<body>

		<div id="info">
			<a href="https://threejs.org/" target="_blank" rel="noopener" class="logo-link"></a>

			<div class="title-wrapper">
				<a href="https://threejs.org/" target="_blank" rel="noopener">three.js</a><span>Clustered Lighting + IBL</span>
			</div>

			<small>
				128 point lights (Forward+ clustered) layered on top of a SkyMesh PMREM environment.
			</small>
		</div>

		<script type="importmap">
			{
				"imports": {
					"three": "../build/three.webgpu.js",
					"three/webgpu": "../build/three.webgpu.js",
					"three/tsl": "../build/three.tsl.js",
					"three/addons/": "./jsm/"
				}
			}
		</script>

		<script type="module">

			import * as THREE from 'three/webgpu';

			import { ClusteredLighting } from 'three/addons/lighting/ClusteredLighting.js';
			import { SkyMesh } from 'three/addons/objects/SkyMesh.js';
			import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
			import Stats from 'three/addons/libs/stats.module.js';
			import { GUI } from 'three/addons/libs/lil-gui.module.min.js';

			const LIGHT_COUNT = 128;

			let camera, scene, renderer, controls, stats;
			let sky, pmremGenerator, envScene, envRenderTarget;
			let lightMarkers;

			const lights = [];
			const lightData = [];
			const dummy = new THREE.Object3D();

			const params = {
				count: 10,
				environment: true,
				lightPower: 250
			};

			const LIGHT_DISTANCE = 8; // keep each light localized so clusters don't overflow

			init();

			function init() {

				camera = new THREE.PerspectiveCamera( 55, window.innerWidth / window.innerHeight, 0.1, 5000 );
				camera.position.set( 0, 22, 48 );

				scene = new THREE.Scene();

				// dusk sky used both as the visible background and the IBL source

				sky = new SkyMesh();
				sky.scale.setScalar( 1000 );
				sky.material.fog = false;
				sky.turbidity.value = 10;
				sky.rayleigh.value = 2;
				sky.mieCoefficient.value = 0.005;
				sky.mieDirectionalG.value = 0.85;

				const phi = THREE.MathUtils.degToRad( 90 - 3 ); // sun just above the horizon
				const theta = THREE.MathUtils.degToRad( 180 );
				sky.sunPosition.value.setFromSphericalCoords( 1, phi, theta );
				scene.add( sky );

				// floor

				const floor = new THREE.Mesh(
					new THREE.PlaneGeometry( 120, 120 ),
					new THREE.MeshStandardMaterial( { color: 0x222428, roughness: 0.6, metalness: 0.0 } )
				);
				floor.rotation.x = - Math.PI / 2;
				scene.add( floor );

				// a field of objects to catch both the sky IBL and the colored point lights

				const fieldMaterial = new THREE.MeshStandardMaterial( { color: 0x999999, roughness: 0.45, metalness: 0.0 } );
				const sphereGeometry = new THREE.SphereGeometry( 2, 32, 16 );

				for ( let x = - 2; x <= 2; x ++ ) {

					for ( let z = - 2; z <= 2; z ++ ) {

						const sphere = new THREE.Mesh( sphereGeometry, fieldMaterial );
						sphere.position.set( x * 11, 2, z * 11 );
						scene.add( sphere );

					}

				}

				const knot = new THREE.Mesh(
					new THREE.TorusKnotGeometry( 4, 1.2, 160, 24 ),
					new THREE.MeshStandardMaterial( { color: 0xbbbbbb, roughness: 0.3, metalness: 0.1 } )
				);
				knot.position.set( 0, 8, 0 );
				scene.add( knot );

				// point lights + emissive markers

				const sphereMarker = new THREE.SphereGeometry( 0.18, 8, 8 );
				lightMarkers = new THREE.InstancedMesh( sphereMarker, new THREE.MeshBasicMaterial(), LIGHT_COUNT );
				lightMarkers.instanceMatrix.setUsage( THREE.DynamicDrawUsage );
				scene.add( lightMarkers );

				const color = new THREE.Color();

				for ( let i = 0; i < LIGHT_COUNT; i ++ ) {

					color.setHSL( Math.random(), 1.0, 0.5 );

					const light = new THREE.PointLight( color.getHex(), 1, LIGHT_DISTANCE, 2 );
					light.power = params.lightPower;
					scene.add( light );
					lights.push( light );

					lightData.push( {
						radius: 6 + Math.random() * 28,
						theta: Math.random() * Math.PI * 2,
						speed: ( 0.1 + Math.random() * 0.3 ) * ( Math.random() < 0.5 ? - 1 : 1 ),
						yBase: 0.8 + Math.random() * 1.0, // near the floor
						yAmp: 0.15 + Math.random() * 0.35,
						yFreq: 0.3 + Math.random() * 0.8,
						phase: Math.random() * Math.PI * 2
					} );

					lightMarkers.setColorAt( i, color );

				}

				lightMarkers.instanceColor.needsUpdate = true;

				// renderer + clustered lighting

				renderer = new THREE.WebGPURenderer( { antialias: true } );
				renderer.setPixelRatio( window.devicePixelRatio );
				renderer.setSize( window.innerWidth, window.innerHeight );
				renderer.setAnimationLoop( animate );
				renderer.toneMapping = THREE.ACESFilmicToneMapping;
				renderer.toneMappingExposure = 0.5;
				renderer.lighting = new ClusteredLighting();
				document.body.appendChild( renderer.domElement );

				// bake the sky into a PMREM environment for image-based lighting

				renderer.init().then( () => {

					pmremGenerator = new THREE.PMREMGenerator( renderer );
					envScene = new THREE.Scene();

					scene.remove( sky );
					envScene.add( sky );
					envRenderTarget = pmremGenerator.fromScene( envScene );
					scene.add( sky );

					scene.environment = envRenderTarget.texture;

				} );

				controls = new OrbitControls( camera, renderer.domElement );
				controls.target.set( 0, 8, 0 );
				controls.maxDistance = 120;
				controls.update();

				stats = new Stats();
				// document.body.appendChild( stats.dom );

				// gui

				const gui = new GUI();
				gui.add( params, 'count', 0, LIGHT_COUNT, 1 ).name( 'light count' );
				gui.add( params, 'lightPower', 0, 1500 ).name( 'light power' ).onChange( ( value ) => {

					for ( const light of lights ) light.power = value;

				} );
				gui.add( params, 'environment' ).name( 'environment (IBL)' ).onChange( ( value ) => {

					scene.environment = value && envRenderTarget ? envRenderTarget.texture : null;

				} );

				window.addEventListener( 'resize', onWindowResize );

			}

			function onWindowResize() {

				camera.aspect = window.innerWidth / window.innerHeight;
				camera.updateProjectionMatrix();

				renderer.setSize( window.innerWidth, window.innerHeight );

			}

			function animate() {

				const t = performance.now() * 0.001;

				for ( let i = 0; i < LIGHT_COUNT; i ++ ) {

					const d = lightData[ i ];
					const angle = d.theta + t * d.speed;
					const x = Math.cos( angle ) * d.radius;
					const z = Math.sin( angle ) * d.radius;
					const y = d.yBase + Math.sin( t * d.yFreq + d.phase ) * d.yAmp;

					const visible = i < params.count;

					lights[ i ].position.set( x, y, z );
					lights[ i ].visible = visible;

					dummy.position.set( x, y, z );
					dummy.scale.setScalar( visible ? 1 : 0 );
					dummy.updateMatrix();
					lightMarkers.setMatrixAt( i, dummy.matrix );

				}

				lightMarkers.instanceMatrix.needsUpdate = true;

				controls.update();
				renderer.render( scene, camera );

				stats.update();

			}

		</script>

	</body>
</html>

@sunag

sunag commented May 29, 2026

Copy link
Copy Markdown
Collaborator

Would you like to give it a go? Here's the example I'm using:

I'm checking this, I'll have to refactor some components.

@sunag sunag merged commit 69fe335 into dev May 29, 2026
9 checks passed
@sunag

sunag commented May 29, 2026

Copy link
Copy Markdown
Collaborator

I think it's better to continue on another PR.

@mrdoob mrdoob deleted the clustered branch May 30, 2026 02:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants