diff --git a/examples/jsm/effects/AnaglyphEffect.js b/examples/jsm/effects/AnaglyphEffect.js index a33bf044fb37b3..740d4261fff2cf 100644 --- a/examples/jsm/effects/AnaglyphEffect.js +++ b/examples/jsm/effects/AnaglyphEffect.js @@ -1,16 +1,38 @@ import { LinearFilter, + MathUtils, Matrix3, NearestFilter, + PerspectiveCamera, RGBAFormat, ShaderMaterial, - StereoCamera, + Vector3, WebGLRenderTarget } from 'three'; import { FullScreenQuad } from '../postprocessing/Pass.js'; +import { frameCorners } from '../utils/CameraUtils.js'; + +const _cameraL = /*@__PURE__*/ new PerspectiveCamera(); +const _cameraR = /*@__PURE__*/ new PerspectiveCamera(); + +// Reusable vectors for screen corner calculations +const _eyeL = /*@__PURE__*/ new Vector3(); +const _eyeR = /*@__PURE__*/ new Vector3(); +const _screenCenter = /*@__PURE__*/ new Vector3(); +const _screenBottomLeft = /*@__PURE__*/ new Vector3(); +const _screenBottomRight = /*@__PURE__*/ new Vector3(); +const _screenTopLeft = /*@__PURE__*/ new Vector3(); +const _right = /*@__PURE__*/ new Vector3(); +const _up = /*@__PURE__*/ new Vector3(); +const _forward = /*@__PURE__*/ new Vector3(); /** - * A class that creates an anaglyph effect. + * A class that creates an anaglyph effect using physically-correct + * off-axis stereo projection. + * + * This implementation uses CameraUtils.frameCorners() to align stereo + * camera frustums to a virtual screen plane, providing accurate depth + * perception with zero parallax at the screen distance. * * Note that this class can only be used with {@link WebGLRenderer}. * When using {@link WebGPURenderer}, use {@link AnaglyphPassNode}. @@ -42,13 +64,38 @@ class AnaglyphEffect { - 0.00155529, - 0.0184503, 1.2264 ] ); - const _stereo = new StereoCamera(); + /** + * The interpupillary distance (eye separation) in world units. + * Typical human IPD is 0.064 meters (64mm). + * + * @type {number} + * @default 0.064 + */ + this.eyeSep = 0.064; + + /** + * The distance from the viewer to the virtual screen plane + * where zero parallax (screen depth) occurs. + * Objects at this distance appear at the screen surface. + * Objects closer appear in front of the screen (negative parallax). + * Objects further appear behind the screen (positive parallax). + * + * The screen dimensions are derived from the camera's FOV and aspect ratio + * at this distance, ensuring the stereo view matches the camera's field of view. + * + * @type {number} + * @default 0.5 + */ + this.screenDistance = 0.5; const _params = { minFilter: LinearFilter, magFilter: NearestFilter, format: RGBAFormat }; const _renderTargetL = new WebGLRenderTarget( width, height, _params ); const _renderTargetR = new WebGLRenderTarget( width, height, _params ); + _cameraL.layers.enable( 1 ); + _cameraR.layers.enable( 2 ); + const _material = new ShaderMaterial( { uniforms: { @@ -141,16 +188,64 @@ class AnaglyphEffect { if ( camera.parent === null && camera.matrixWorldAutoUpdate === true ) camera.updateMatrixWorld(); - _stereo.update( camera ); - + // Get the camera's local coordinate axes from its world matrix + camera.matrixWorld.extractBasis( _right, _up, _forward ); + _right.normalize(); + _up.normalize(); + _forward.normalize(); + + // Calculate eye positions + const halfSep = this.eyeSep / 2; + _eyeL.copy( camera.position ).addScaledVector( _right, - halfSep ); + _eyeR.copy( camera.position ).addScaledVector( _right, halfSep ); + + // Calculate screen center (at screenDistance in front of the camera center) + _screenCenter.copy( camera.position ).addScaledVector( _forward, - this.screenDistance ); + + // Calculate screen dimensions from camera FOV and aspect ratio + const halfHeight = this.screenDistance * Math.tan( MathUtils.DEG2RAD * camera.fov / 2 ); + const halfWidth = halfHeight * camera.aspect; + + // Calculate screen corners + _screenBottomLeft.copy( _screenCenter ) + .addScaledVector( _right, - halfWidth ) + .addScaledVector( _up, - halfHeight ); + + _screenBottomRight.copy( _screenCenter ) + .addScaledVector( _right, halfWidth ) + .addScaledVector( _up, - halfHeight ); + + _screenTopLeft.copy( _screenCenter ) + .addScaledVector( _right, - halfWidth ) + .addScaledVector( _up, halfHeight ); + + // Set up left eye camera + _cameraL.position.copy( _eyeL ); + _cameraL.near = camera.near; + _cameraL.far = camera.far; + frameCorners( _cameraL, _screenBottomLeft, _screenBottomRight, _screenTopLeft, true ); + _cameraL.matrixWorld.compose( _cameraL.position, _cameraL.quaternion, _cameraL.scale ); + _cameraL.matrixWorldInverse.copy( _cameraL.matrixWorld ).invert(); + + // Set up right eye camera + _cameraR.position.copy( _eyeR ); + _cameraR.near = camera.near; + _cameraR.far = camera.far; + frameCorners( _cameraR, _screenBottomLeft, _screenBottomRight, _screenTopLeft, true ); + _cameraR.matrixWorld.compose( _cameraR.position, _cameraR.quaternion, _cameraR.scale ); + _cameraR.matrixWorldInverse.copy( _cameraR.matrixWorld ).invert(); + + // Render left eye renderer.setRenderTarget( _renderTargetL ); renderer.clear(); - renderer.render( scene, _stereo.cameraL ); + renderer.render( scene, _cameraL ); + // Render right eye renderer.setRenderTarget( _renderTargetR ); renderer.clear(); - renderer.render( scene, _stereo.cameraR ); + renderer.render( scene, _cameraR ); + // Composite anaglyph renderer.setRenderTarget( null ); _quad.render( renderer ); diff --git a/examples/jsm/tsl/display/AnaglyphPassNode.js b/examples/jsm/tsl/display/AnaglyphPassNode.js index 9751f88eea4178..41955ac58c5922 100644 --- a/examples/jsm/tsl/display/AnaglyphPassNode.js +++ b/examples/jsm/tsl/display/AnaglyphPassNode.js @@ -1,6 +1,17 @@ -import { Matrix3, NodeMaterial } from 'three/webgpu'; +import { Matrix3, NodeMaterial, Vector3 } from 'three/webgpu'; import { clamp, nodeObject, Fn, vec4, uv, uniform, max } from 'three/tsl'; import StereoCompositePassNode from './StereoCompositePassNode.js'; +import { frameCorners } from '../../utils/CameraUtils.js'; + +const _eyeL = /*@__PURE__*/ new Vector3(); +const _eyeR = /*@__PURE__*/ new Vector3(); +const _screenBottomLeft = /*@__PURE__*/ new Vector3(); +const _screenBottomRight = /*@__PURE__*/ new Vector3(); +const _screenTopLeft = /*@__PURE__*/ new Vector3(); +const _right = /*@__PURE__*/ new Vector3(); +const _up = /*@__PURE__*/ new Vector3(); +const _forward = /*@__PURE__*/ new Vector3(); +const _screenCenter = /*@__PURE__*/ new Vector3(); /** * Anaglyph algorithm types. @@ -259,7 +270,12 @@ const ANAGLYPH_MATRICES = { }; /** - * A render pass node that creates an anaglyph effect. + * A render pass node that creates an anaglyph effect using physically-correct + * off-axis stereo projection. + * + * This implementation uses CameraUtils.frameCorners() to align stereo + * camera frustums to a virtual screen plane, providing accurate depth + * perception with zero parallax at the screen distance. * * @augments StereoCompositePassNode * @three_import import { anaglyphPass, AnaglyphAlgorithm, AnaglyphColorMode } from 'three/addons/tsl/display/AnaglyphPassNode.js'; @@ -291,6 +307,30 @@ class AnaglyphPassNode extends StereoCompositePassNode { */ this.isAnaglyphPassNode = true; + /** + * The interpupillary distance (eye separation) in world units. + * Typical human IPD is 0.064 meters (64mm). + * + * @type {number} + * @default 0.064 + */ + this.eyeSep = 0.064; + + /** + * The distance from the viewer to the virtual screen plane + * where zero parallax (screen depth) occurs. + * Objects at this distance appear at the screen surface. + * Objects closer appear in front of the screen (negative parallax). + * Objects further appear behind the screen (positive parallax). + * + * The screen dimensions are derived from the camera's FOV and aspect ratio + * at this distance, ensuring the stereo view matches the camera's field of view. + * + * @type {number} + * @default 0.5 + */ + this.screenDistance = 0.5; + /** * The current anaglyph algorithm. * @@ -398,6 +438,69 @@ class AnaglyphPassNode extends StereoCompositePassNode { } + /** + * Updates the internal stereo camera using frameCorners for + * physically-correct off-axis projection. + * + * @param {number} coordinateSystem - The current coordinate system. + */ + updateStereoCamera( coordinateSystem ) { + + const { stereo, camera } = this; + + stereo.cameraL.coordinateSystem = coordinateSystem; + stereo.cameraR.coordinateSystem = coordinateSystem; + + // Get the camera's local coordinate axes from its world matrix + camera.matrixWorld.extractBasis( _right, _up, _forward ); + _right.normalize(); + _up.normalize(); + _forward.normalize(); + + // Calculate eye positions + const halfSep = this.eyeSep / 2; + _eyeL.copy( camera.position ).addScaledVector( _right, - halfSep ); + _eyeR.copy( camera.position ).addScaledVector( _right, halfSep ); + + // Calculate screen center (at screenDistance in front of the camera center) + _screenCenter.copy( camera.position ).addScaledVector( _forward, - this.screenDistance ); + + // Calculate screen dimensions from camera FOV and aspect ratio + const DEG2RAD = Math.PI / 180; + const halfHeight = this.screenDistance * Math.tan( DEG2RAD * camera.fov / 2 ); + const halfWidth = halfHeight * camera.aspect; + + // Calculate screen corners + _screenBottomLeft.copy( _screenCenter ) + .addScaledVector( _right, - halfWidth ) + .addScaledVector( _up, - halfHeight ); + + _screenBottomRight.copy( _screenCenter ) + .addScaledVector( _right, halfWidth ) + .addScaledVector( _up, - halfHeight ); + + _screenTopLeft.copy( _screenCenter ) + .addScaledVector( _right, - halfWidth ) + .addScaledVector( _up, halfHeight ); + + // Set up left eye camera + stereo.cameraL.position.copy( _eyeL ); + stereo.cameraL.near = camera.near; + stereo.cameraL.far = camera.far; + frameCorners( stereo.cameraL, _screenBottomLeft, _screenBottomRight, _screenTopLeft, true ); + stereo.cameraL.matrixWorld.compose( stereo.cameraL.position, stereo.cameraL.quaternion, stereo.cameraL.scale ); + stereo.cameraL.matrixWorldInverse.copy( stereo.cameraL.matrixWorld ).invert(); + + // Set up right eye camera + stereo.cameraR.position.copy( _eyeR ); + stereo.cameraR.near = camera.near; + stereo.cameraR.far = camera.far; + frameCorners( stereo.cameraR, _screenBottomLeft, _screenBottomRight, _screenTopLeft, true ); + stereo.cameraR.matrixWorld.compose( stereo.cameraR.position, stereo.cameraR.quaternion, stereo.cameraR.scale ); + stereo.cameraR.matrixWorldInverse.copy( stereo.cameraR.matrixWorld ).invert(); + + } + /** * This method is used to setup the effect's TSL code. * diff --git a/examples/screenshots/webgl_effects_anaglyph.jpg b/examples/screenshots/webgl_effects_anaglyph.jpg index cfdd68cfab2223..610dbaa0f4eeae 100644 Binary files a/examples/screenshots/webgl_effects_anaglyph.jpg and b/examples/screenshots/webgl_effects_anaglyph.jpg differ diff --git a/examples/screenshots/webgpu_display_stereo.jpg b/examples/screenshots/webgpu_display_stereo.jpg index b697bab0c0ef06..edf608b85fc4b0 100644 Binary files a/examples/screenshots/webgpu_display_stereo.jpg and b/examples/screenshots/webgpu_display_stereo.jpg differ diff --git a/examples/webgl_effects_anaglyph.html b/examples/webgl_effects_anaglyph.html index b10f89f2d97b25..2c54043fd08bd8 100644 --- a/examples/webgl_effects_anaglyph.html +++ b/examples/webgl_effects_anaglyph.html @@ -94,6 +94,12 @@ effect = new AnaglyphEffect( renderer ); effect.setSize( width, height ); + // Configure stereo parameters for physically-correct rendering + // eyeSep: interpupillary distance (default 0.064m / 64mm for humans) + // screenDistance: distance to the zero-parallax plane (objects here appear at screen depth) + effect.eyeSep = 0.064; + effect.screenDistance = 3; // Match camera distance to origin for zero parallax at scene center + // window.addEventListener( 'resize', onWindowResize ); diff --git a/examples/webgpu_display_stereo.html b/examples/webgpu_display_stereo.html index c437186134681c..17b4474f915327 100644 --- a/examples/webgpu_display_stereo.html +++ b/examples/webgpu_display_stereo.html @@ -54,6 +54,7 @@ const params = { effect: 'stereo', eyeSep: 0.064, + screenDistance: 3, anaglyphAlgorithm: 'dubois', anaglyphColorMode: 'redCyan' }; @@ -101,7 +102,7 @@ mesh = new THREE.InstancedMesh( geometry, material, 500 ); mesh.instanceMatrix.setUsage( THREE.DynamicDrawUsage ); - + dummy = new THREE.Mesh(); for ( let i = 0; i < 500; i ++ ) { @@ -110,7 +111,7 @@ dummy.position.y = Math.random() * 10 - 5; dummy.position.z = Math.random() * 10 - 5; dummy.scale.x = dummy.scale.y = dummy.scale.z = Math.random() * 3 + 1; - + dummy.updateMatrix(); mesh.setMatrixAt( i, dummy.matrix ); @@ -133,6 +134,10 @@ anaglyph = anaglyphPass( scene, camera ); parallaxBarrier = parallaxBarrierPass( scene, camera ); + // Configure anaglyph for physically-correct stereo with zero parallax at scene center + anaglyph.eyeSep = params.eyeSep; + anaglyph.screenDistance = params.screenDistance; + renderPipeline.outputNode = stereo; const gui = renderer.inspector.createParameters( 'Stereo Settings' ); @@ -140,8 +145,7 @@ gui.add( params, 'eyeSep', 0.001, 0.15, 0.001 ).onChange( function ( value ) { stereo.stereo.eyeSep = value; - - anaglyph.stereo.eyeSep = value; + anaglyph.eyeSep = value; // Anaglyph has direct eyeSep property parallaxBarrier.stereo.eyeSep = value; } ); @@ -157,6 +161,11 @@ anaglyph.colorMode = value; + } ); + anaglyphFolder.add( params, 'screenDistance', 0.5, 10, 0.1 ).name( 'Screen Distance' ).onChange( function ( value ) { + + anaglyph.screenDistance = value; + } ); anaglyphFolder.paramList.domElement.style.display = 'none';