diff --git a/examples/screenshots/webgl_postprocessing_pixel.jpg b/examples/screenshots/webgl_postprocessing_pixel.jpg index 6787a0f3ff817f..eb014505323c08 100644 Binary files a/examples/screenshots/webgl_postprocessing_pixel.jpg and b/examples/screenshots/webgl_postprocessing_pixel.jpg differ diff --git a/examples/webgl_postprocessing_pixel.html b/examples/webgl_postprocessing_pixel.html index b25e296e64c4ed..a2fe638ea6e81c 100644 --- a/examples/webgl_postprocessing_pixel.html +++ b/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 ) ); + + } +