1
0
Эх сурвалжийг харах

webgl_postprocessing_pixel: Snap Objects to Pixel Grid (#33908)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Johnathon Selstad 1 долоо хоног өмнө
parent
commit
a441be8da6

BIN
examples/screenshots/webgl_postprocessing_pixel.jpg


+ 145 - 9
examples/webgl_postprocessing_pixel.html

@@ -39,7 +39,7 @@
 		import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
 		import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
 
-		let camera, scene, renderer, composer, crystalMesh, timer;
+		let camera, scene, renderer, composer, controls, crystalMesh, timer;
 		let gui, params;
 
 		init();
@@ -74,13 +74,13 @@
 
 			window.addEventListener( 'resize', onWindowResize );
 
-			const controls = new OrbitControls( camera, renderer.domElement );
+			controls = new OrbitControls( camera, renderer.domElement );
 			controls.maxZoom = 2;
 
 			// gui
 
 			gui = new GUI();
-			params = { pixelSize: 6, normalEdgeStrength: .3, depthEdgeStrength: .4, pixelAlignedPanning: true };
+			params = { pixelSize: 6, normalEdgeStrength: .3, depthEdgeStrength: .4, snapping: true, pixelAlignedPanning: true, pixelAlignedObjects: true, rotationSnap: 15, cameraRotationSnap: 9, cameraZoomSnap: 0.1 };
 			gui.add( params, 'pixelSize' ).min( 1 ).max( 16 ).step( 1 )
 				.onChange( () => {
 
@@ -89,7 +89,15 @@
 				} );
 			gui.add( renderPixelatedPass, 'normalEdgeStrength' ).min( 0 ).max( 2 ).step( .05 );
 			gui.add( renderPixelatedPass, 'depthEdgeStrength' ).min( 0 ).max( 1 ).step( .05 );
-			gui.add( params, 'pixelAlignedPanning' );
+
+			const snappingControl = gui.add( params, 'snapping' ).name( 'Enable Pixel Snapping' );
+			const snappingFolder = gui.addFolder( 'Snapping' ).close();
+			snappingControl.onChange( enabled => snappingFolder.controllers.forEach( c => c.enable( enabled ) ) );
+			snappingFolder.add( params, 'pixelAlignedPanning' );
+			snappingFolder.add( params, 'pixelAlignedObjects' );
+			snappingFolder.add( params, 'rotationSnap' ).min( 0 ).max( 90 ).step( 1 ).name( 'rotationSnap (deg, 0 = off)' );
+			snappingFolder.add( params, 'cameraRotationSnap' ).min( 0 ).max( 90 ).step( 1 ).name( 'cameraRotationSnap (deg, 0 = off)' );
+			snappingFolder.add( params, 'cameraZoomSnap' ).min( 0 ).max( 1 ).step( .05 ).name( 'cameraZoomSnap (0 = off)' );
 
 			// textures
 
@@ -181,16 +189,37 @@
 
 			const t = timer.getElapsed();
 
-			crystalMesh.material.emissiveIntensity = Math.sin( t * 3 ) * .5 + .5;
+			// Quantize the pulsing glow to a few discrete brightness levels to match the pixelated aesthetic
+			const brightnessLevels = 6;
+			crystalMesh.material.emissiveIntensity =
+				Math.round( ( Math.sin( t * 3 ) * .5 + .5 ) * ( brightnessLevels - 1 ) ) / ( brightnessLevels - 1 );
 			crystalMesh.position.y = .7 + Math.sin( t * 2 ) * .05;
-			crystalMesh.rotation.y = stopGoEased( t, 2, 4 ) * 2 * Math.PI;
+			crystalMesh.rotation.y = stopGoEased( t, 6, 8 ) * 2 * Math.PI;
 
 			const rendererSize = renderer.getSize( new THREE.Vector2() );
 			const aspectRatio = rendererSize.x / rendererSize.y;
-			if ( params[ 'pixelAlignedPanning' ] ) {
+			const pixelsPerScreenWidth = Math.floor( rendererSize.x / params[ 'pixelSize' ] );
+			const pixelsPerScreenHeight = Math.floor( rendererSize.y / params[ 'pixelSize' ] );
+
+			// The top-level 'snapping' checkbox gates every individual snapping effect at once
+			const snapping = params[ 'snapping' ];
+
+			// Remember the camera's true (unsnapped) transform so OrbitControls interaction is unaffected by snapping
+			const cameraPosition = camera.position.clone();
+			const cameraQuaternion = camera.quaternion.clone();
+			const cameraZoom = camera.zoom;
+
+			// Snap the camera's rotation and scale first, so the translation snap below derives its pixel grid
+			// and screen-space basis from the already-snapped orientation and zoom
+			if ( snapping && ( params[ 'cameraRotationSnap' ] > 0 || params[ 'cameraZoomSnap' ] > 0 ) ) {
+
+				pixelAlignCamera( camera, controls.target, params[ 'cameraRotationSnap' ], params[ 'cameraZoomSnap' ] );
+
+			}
 
-				pixelAlignFrustum( camera, aspectRatio, Math.floor( rendererSize.x / params[ 'pixelSize' ] ),
-					Math.floor( rendererSize.y / params[ 'pixelSize' ] ) );
+			if ( snapping && params[ 'pixelAlignedPanning' ] ) {
+
+				pixelAlignFrustum( camera, aspectRatio, pixelsPerScreenWidth, pixelsPerScreenHeight );
 
 			} else if ( camera.left != - aspectRatio || camera.top != 1.0 ) {
 
@@ -203,8 +232,39 @@
 
 			}
 
+			// Remember the crystal's true (unsnapped) transform so its bulk movement is unaffected by snapping.
+			// We snapshot the euler rotation (not the quaternion) because that's what drives the spin: restoring
+			// the quaternion would let three.js re-derive a gimbal-equivalent euler and corrupt the y rotation.
+			const crystalPosition = crystalMesh.position.clone();
+			const crystalRotation = crystalMesh.rotation.clone();
+
+			if ( snapping && params[ 'pixelAlignedObjects' ] ) {
+
+				// Snap the moving objects to the screen-space pixel grid so they don't shimmer when pixelated
+				const worldScreenWidth = ( ( camera.right - camera.left ) / camera.zoom );
+				const worldScreenHeight = ( ( camera.top - camera.bottom ) / camera.zoom );
+				pixelAlignObject( crystalMesh, camera, worldScreenWidth / pixelsPerScreenWidth,
+					worldScreenHeight / pixelsPerScreenHeight );
+
+			}
+
+			if ( snapping && params[ 'rotationSnap' ] > 0 ) {
+
+				// Snap the moving objects' rotation to the nearest camera-relative euler increment
+				snapObjectRotation( crystalMesh, camera, params[ 'rotationSnap' ] );
+
+			}
+
 			composer.render();
 
+			// Restore the true transforms after rendering so the next frame's motion builds on the real trajectory
+			crystalMesh.position.copy( crystalPosition );
+			crystalMesh.rotation.copy( crystalRotation );
+			camera.position.copy( cameraPosition );
+			camera.quaternion.copy( cameraQuaternion );
+			camera.zoom = cameraZoom;
+			camera.updateProjectionMatrix();
+
 		}
 
 		// Helper functions
@@ -245,6 +305,36 @@
 
 		}
 
+		function pixelAlignCamera( camera, target, rotationIncrementDegrees, zoomIncrement ) {
+
+			// Snap the camera's orbit to the nearest increment of azimuth/polar angle around the target. Working
+			// in spherical coordinates (rather than snapping the euler angles) keeps the orbit translation locked
+			// onto the target and guarantees a roll-free orientation, since we re-aim with lookAt and world up.
+			if ( rotationIncrementDegrees > 0 ) {
+
+				const increment = THREE.MathUtils.degToRad( rotationIncrementDegrees );
+				const offset = new THREE.Vector3().subVectors( camera.position, target );
+				const spherical = new THREE.Spherical().setFromVector3( offset );
+				spherical.theta = Math.round( spherical.theta / increment ) * increment;
+				spherical.phi = Math.round( spherical.phi / increment ) * increment;
+				spherical.makeSafe();
+				offset.setFromSpherical( spherical );
+				camera.position.copy( target ).add( offset );
+				camera.up.set( 0.0, 1.0, 0.0 );
+				camera.lookAt( target );
+
+			}
+
+			// Snap the camera's zoom to the nearest increment so the pixel grid stays a stable size while scaling
+			if ( zoomIncrement > 0 ) {
+
+				camera.zoom = Math.max( zoomIncrement, Math.round( camera.zoom / zoomIncrement ) * zoomIncrement );
+				camera.updateProjectionMatrix();
+
+			}
+
+		}
+
 		function pixelAlignFrustum( camera, aspectRatio, pixelsPerScreenWidth, pixelsPerScreenHeight ) {
 
 			// 0. Get Pixel Grid Units
@@ -278,6 +368,52 @@
 
 		}
 
+		function pixelAlignObject( object, camera, pixelWidth, pixelHeight ) {
+
+			// 1. Build the camera's orthonormal screen-space basis
+			const camRot = new THREE.Quaternion(); camera.getWorldQuaternion( camRot );
+			const camRight = new THREE.Vector3( 1.0, 0.0, 0.0 ).applyQuaternion( camRot );
+			const camUp = new THREE.Vector3( 0.0, 1.0, 0.0 ).applyQuaternion( camRot );
+			const camForward = new THREE.Vector3( 0.0, 0.0, - 1.0 ).applyQuaternion( camRot );
+
+			// 2. Decompose the object's world position onto that basis
+			const pos = object.position;
+			const posRight = pos.dot( camRight );
+			const posUp = pos.dot( camUp );
+			const posForward = pos.dot( camForward );
+
+			// 3. Snap the screen-space (right/up) components to the pixel grid, leaving depth untouched
+			const snappedRight = Math.round( posRight / pixelWidth ) * pixelWidth;
+			const snappedUp = Math.round( posUp / pixelHeight ) * pixelHeight;
+
+			// 4. Recompose the world position from the snapped basis components
+			pos.set( 0, 0, 0 );
+			pos.addScaledVector( camRight, snappedRight );
+			pos.addScaledVector( camUp, snappedUp );
+			pos.addScaledVector( camForward, posForward );
+
+		}
+
+		function snapObjectRotation( object, camera, incrementDegrees ) {
+
+			const increment = THREE.MathUtils.degToRad( incrementDegrees );
+
+			// 1. Express the object's rotation relative to the camera
+			const camRot = new THREE.Quaternion(); camera.getWorldQuaternion( camRot );
+			const relative = camRot.clone().invert().multiply( object.quaternion );
+
+			// 2. Snap each euler axis (in camera space) to the nearest increment
+			const euler = new THREE.Euler().setFromQuaternion( relative );
+			euler.x = Math.round( euler.x / increment ) * increment;
+			euler.y = Math.round( euler.y / increment ) * increment;
+			euler.z = Math.round( euler.z / increment ) * increment;
+
+			// 3. Convert back to a world-space rotation
+			relative.setFromEuler( euler );
+			object.quaternion.copy( camRot.multiply( relative ) );
+
+		}
+
 	</script>
 </body>
 

粤ICP备19079148号