Prechádzať zdrojové kódy

PerspectiveCamera: Add Kooima's Generalized Projection Formulation (#21825)

* Add Kooima's Generalized Projection Matrix Formulation

Addresses Issue #5381

This PR adds an implementation for Kooima's Generalized Projection Matrix Formulation, better known as "the way CAVE rendering works".

In a nutshell, it sets the `projectionMatrix` frustum to exactly frame an arbitrary rectangle.   This is a key operation in rendering portals, CAVEs, and certain kinds of projection based effects.

I find myself porting and reporting this function wherever I go because it's so darn useful.   I'm not certain that this is the right place in the codebase for it, but I'd really love for three.js to have a working implementation for people to use.

Here's a video I made years ago that utilizes the formulation:
https://www.youtube.com/watch?v=90kHhOUzeQc&t=1s

* Switch to const

* Add Portal Example
Johnathon Selstad 4 rokov pred
rodič
commit
b67a3f705f

+ 6 - 0
docs/api/en/cameras/PerspectiveCamera.html

@@ -186,6 +186,12 @@ camera.setViewOffset( fullWidth, fullHeight, w * 2, h * 1, w, h );
 		Updates the camera projection matrix. Must be called after any change of parameters.
 		Updates the camera projection matrix. Must be called after any change of parameters.
 		</p>
 		</p>
 
 
+		<h3>[method:null frameCorners]( [param:Vector3 bottomLeftCorner], [param:Vector3 bottomRightCorner], [param:Vector3 topLeftCorner], [param:boolean estimateViewFrustum] )</h3>
+		<p>
+		Set this PerspectiveCamera's projectionMatrix and quaternion to exactly frame the corners of an arbitrary rectangle using [link:https://web.archive.org/web/20191110002841/http://csc.lsu.edu/~kooima/articles/genperspective/index.html Kooima's Generalized Perspective Projection formulation].
+		NOTE: This function ignores the standard parameters; do not call updateProjectionMatrix() after this! toJSON will also not capture the off-axis matrix generated by this function.
+		</p>
+
 		<h3>[method:Object toJSON]([param:Object meta])</h3>
 		<h3>[method:Object toJSON]([param:Object meta])</h3>
 		<p>
 		<p>
 		meta -- object containing metadata such as textures or images in objects' descendants.<br />
 		meta -- object containing metadata such as textures or images in objects' descendants.<br />

+ 1 - 0
examples/files.json

@@ -173,6 +173,7 @@
 		"webgl_math_obb",
 		"webgl_math_obb",
 		"webgl_math_orientation_transform",
 		"webgl_math_orientation_transform",
 		"webgl_mirror",
 		"webgl_mirror",
+		"webgl_portal",
 		"webgl_modifier_curve",
 		"webgl_modifier_curve",
 		"webgl_modifier_curve_instanced",
 		"webgl_modifier_curve_instanced",
 		"webgl_modifier_edgesplit",
 		"webgl_modifier_edgesplit",

BIN
examples/screenshots/webgl_portal.jpg


+ 1 - 0
examples/tags.json

@@ -49,6 +49,7 @@
 	"webgl_math_obb": [ "intersection", "bounding" ],
 	"webgl_math_obb": [ "intersection", "bounding" ],
 	"webgl_math_orientation_transform": [ "rotation" ],
 	"webgl_math_orientation_transform": [ "rotation" ],
 	"webgl_mirror": [ "reflection" ],
 	"webgl_mirror": [ "reflection" ],
+	"webgl_portal": [ "portal", "frameCorners", "renderTarget" ],
 	"webgl_morphtargets_horse": [ "animation" ],
 	"webgl_morphtargets_horse": [ "animation" ],
 	"webgl_multiple_elements": [ "differential equations", "physics" ],
 	"webgl_multiple_elements": [ "differential equations", "physics" ],
 	"webgl_multiple_elements_text": [ "font" ],
 	"webgl_multiple_elements_text": [ "font" ],

+ 252 - 0
examples/webgl_portal.html

@@ -0,0 +1,252 @@
+<!DOCTYPE html>
+<html lang="en">
+	<head>
+		<title>three.js webgl - portal</title>
+		<meta charset="utf-8">
+		<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
+		<link type="text/css" rel="stylesheet" href="main.css">
+		<style>
+			body {
+				color: #444;
+			}
+			a {
+				color: #08f;
+			}
+		</style>
+	</head>
+	<body>
+
+		<div id="container"></div>
+		<div id="info"><a href="https://threejs.org" target="_blank" rel="noopener">three.js</a> - portal
+		</div>
+
+		<script type="module">
+
+			import * as THREE from '../build/three.module.js';
+
+			import { OrbitControls } from './jsm/controls/OrbitControls.js';
+
+			let camera, scene, renderer;
+
+			let cameraControls;
+
+			let smallSphereOne, smallSphereTwo;
+
+			let portalCamera, leftPortal, rightPortal, leftPortalTexture, reflectedPosition,
+				rightPortalTexture, bottomLeftCorner, bottomRightCorner, topLeftCorner, frustumHelper;
+
+			init();
+			animate();
+
+			function init() {
+
+				const container = document.getElementById( 'container' );
+
+				// renderer
+				renderer = new THREE.WebGLRenderer( { antialias: true } );
+				renderer.setPixelRatio( window.devicePixelRatio );
+				renderer.setSize( window.innerWidth, window.innerHeight );
+				container.appendChild( renderer.domElement );
+				renderer.localClippingEnabled = true;
+
+				// scene
+				scene = new THREE.Scene();
+
+				// camera
+				camera = new THREE.PerspectiveCamera( 45, window.innerWidth / window.innerHeight, 1, 5000 );
+				camera.position.set( 0, 75, 160 );
+
+				cameraControls = new OrbitControls( camera, renderer.domElement );
+				cameraControls.target.set( 0, 40, 0 );
+				cameraControls.maxDistance = 400;
+				cameraControls.minDistance = 10;
+				cameraControls.update();
+
+				//
+
+				const planeGeo = new THREE.PlaneGeometry( 100.1, 100.1 );
+
+				let geometry, material;
+				geometry = new THREE.CylinderGeometry( 0.1, 15 * Math.cos( Math.PI / 180 * 30 ), 0.1, 24, 1 );
+				material = new THREE.MeshPhongMaterial( { color: 0xffffff, emissive: 0x444444 } );
+
+				// bouncing icosphere
+				const portalPlane = new THREE.Plane( new THREE.Vector3( 0, 0, 1 ), 0.0 );
+				geometry = new THREE.IcosahedronGeometry( 5, 0 );
+				material = new THREE.MeshPhongMaterial( {
+					color: 0xffffff, emissive: 0x333333, flatShading: true,
+					clippingPlanes: [ portalPlane ], clipShadows: true } );
+				smallSphereOne = new THREE.Mesh( geometry, material );
+				scene.add( smallSphereOne );
+				smallSphereTwo = new THREE.Mesh( geometry, material );
+				scene.add( smallSphereTwo );
+
+				// portals
+				portalCamera = new THREE.PerspectiveCamera( 45, 1.0, 0.1, 500.0 );
+				scene.add( portalCamera );
+				//frustumHelper = new THREE.CameraHelper( portalCamera );
+				//scene.add( frustumHelper );
+				bottomLeftCorner = new THREE.Vector3();
+				bottomRightCorner = new THREE.Vector3();
+				topLeftCorner = new THREE.Vector3();
+				reflectedPosition = new THREE.Vector3();
+
+				leftPortalTexture = new THREE.WebGLRenderTarget( 256, 256, {
+					minFilter: THREE.LinearFilter,
+					magFilter: THREE.LinearFilter,
+					format: THREE.RGBFormat
+				} );
+				leftPortal = new THREE.Mesh( planeGeo, new THREE.MeshBasicMaterial( { map: leftPortalTexture.texture } ) );
+				leftPortal.position.x = - 30;
+				leftPortal.position.y = 20;
+				leftPortal.scale.set( 0.35, 0.35, 0.35 );
+				scene.add( leftPortal );
+
+				rightPortalTexture = new THREE.WebGLRenderTarget( 256, 256, {
+					minFilter: THREE.LinearFilter,
+					magFilter: THREE.LinearFilter,
+					format: THREE.RGBFormat
+				} );
+				rightPortal = new THREE.Mesh( planeGeo, new THREE.MeshBasicMaterial( { map: rightPortalTexture.texture } ) );
+				rightPortal.position.x = 30;
+				rightPortal.position.y = 20;
+				rightPortal.scale.set( 0.35, 0.35, 0.35 );
+				scene.add( rightPortal );
+
+				// walls
+				const planeTop = new THREE.Mesh( planeGeo, new THREE.MeshPhongMaterial( { color: 0xffffff } ) );
+				planeTop.position.y = 100;
+				planeTop.rotateX( Math.PI / 2 );
+				scene.add( planeTop );
+
+				const planeBottom = new THREE.Mesh( planeGeo, new THREE.MeshPhongMaterial( { color: 0xffffff } ) );
+				planeBottom.rotateX( - Math.PI / 2 );
+				scene.add( planeBottom );
+
+				const planeFront = new THREE.Mesh( planeGeo, new THREE.MeshPhongMaterial( { color: 0x7f7fff } ) );
+				planeFront.position.z = 50;
+				planeFront.position.y = 50;
+				planeFront.rotateY( Math.PI );
+				scene.add( planeFront );
+
+				const planeBack = new THREE.Mesh( planeGeo, new THREE.MeshPhongMaterial( { color: 0xff7fff } ) );
+				planeBack.position.z = - 50;
+				planeBack.position.y = 50;
+				//planeBack.rotateY( Math.PI );
+				scene.add( planeBack );
+
+				const planeRight = new THREE.Mesh( planeGeo, new THREE.MeshPhongMaterial( { color: 0x00ff00 } ) );
+				planeRight.position.x = 50;
+				planeRight.position.y = 50;
+				planeRight.rotateY( - Math.PI / 2 );
+				scene.add( planeRight );
+
+				const planeLeft = new THREE.Mesh( planeGeo, new THREE.MeshPhongMaterial( { color: 0xff0000 } ) );
+				planeLeft.position.x = - 50;
+				planeLeft.position.y = 50;
+				planeLeft.rotateY( Math.PI / 2 );
+				scene.add( planeLeft );
+
+				// lights
+				const mainLight = new THREE.PointLight( 0xcccccc, 1.5, 250 );
+				mainLight.position.y = 60;
+				scene.add( mainLight );
+
+				const greenLight = new THREE.PointLight( 0x00ff00, 0.25, 1000 );
+				greenLight.position.set( 550, 50, 0 );
+				scene.add( greenLight );
+
+				const redLight = new THREE.PointLight( 0xff0000, 0.25, 1000 );
+				redLight.position.set( - 550, 50, 0 );
+				scene.add( redLight );
+
+				const blueLight = new THREE.PointLight( 0x7f7fff, 0.25, 1000 );
+				blueLight.position.set( 0, 50, 550 );
+				scene.add( blueLight );
+
+				window.addEventListener( 'resize', onWindowResize, false );
+
+			}
+
+			function onWindowResize() {
+
+				camera.aspect = window.innerWidth / window.innerHeight;
+				camera.updateProjectionMatrix();
+
+				renderer.setSize( window.innerWidth, window.innerHeight );
+
+			}
+
+			function renderPortal( thisPortalMesh, otherPortalMesh, thisPortalTexture ) {
+
+				// set the portal camera position to be reflected about the portal plane
+				thisPortalMesh.worldToLocal( reflectedPosition.copy( camera.position ) );
+				reflectedPosition.x *= - 1.0; reflectedPosition.z *= - 1.0;
+				otherPortalMesh.localToWorld( reflectedPosition );
+				portalCamera.position.copy( reflectedPosition );
+
+				// grab the corners of the other portal
+				// - note: the portal is viewed backwards; flip the left/right coordinates
+				otherPortalMesh.localToWorld( bottomLeftCorner.set( 50.05, - 50.05, 0.0 ) );
+				otherPortalMesh.localToWorld( bottomRightCorner.set( - 50.05, - 50.05, 0.0 ) );
+				otherPortalMesh.localToWorld( topLeftCorner.set( 50.05, 50.05, 0.0 ) );
+				// set the projection matrix to encompass the portal's frame
+				portalCamera.frameCorners( bottomLeftCorner, bottomRightCorner, topLeftCorner, false );
+
+				// render the portal
+				thisPortalTexture.texture.encoding = renderer.outputEncoding;
+				renderer.setRenderTarget( thisPortalTexture );
+				renderer.state.buffers.depth.setMask( true ); // make sure the depth buffer is writable so it can be properly cleared, see #18897
+				if ( renderer.autoClear === false ) renderer.clear();
+				renderer.render( scene, portalCamera );
+
+			}
+
+			function animate() {
+
+				requestAnimationFrame( animate );
+
+				// move the bouncing sphere(s)
+				const timerOne = Date.now() * 0.01;
+				const timerTwo = timerOne + Math.PI * 10.0;
+
+				smallSphereOne.position.set(
+					Math.cos( timerOne * 0.1 ) * 30,
+					Math.abs( Math.cos( timerOne * 0.2 ) ) * 20 + 5,
+					Math.sin( timerOne * 0.1 ) * 30
+				);
+				smallSphereOne.rotation.y = ( Math.PI / 2 ) - timerOne * 0.1;
+				smallSphereOne.rotation.z = timerOne * 0.8;
+
+				smallSphereTwo.position.set(
+					Math.cos( timerTwo * 0.1 ) * 30,
+					Math.abs( Math.cos( timerTwo * 0.2 ) ) * 20 + 5,
+					Math.sin( timerTwo * 0.1 ) * 30
+				);
+				smallSphereTwo.rotation.y = ( Math.PI / 2 ) - timerTwo * 0.1;
+				smallSphereTwo.rotation.z = timerTwo * 0.8;
+
+				// save the original camera properties
+				let currentRenderTarget = renderer.getRenderTarget();
+				let currentXrEnabled = renderer.xr.enabled;
+				let currentShadowAutoUpdate = renderer.shadowMap.autoUpdate;
+				renderer.xr.enabled = false; // Avoid camera modification
+				renderer.shadowMap.autoUpdate = false; // Avoid re-computing shadows
+
+				// render the portal effect
+				renderPortal( leftPortal, rightPortal, leftPortalTexture );
+				renderPortal( rightPortal, leftPortal, rightPortalTexture );
+
+				// restore the original rendering properties
+				renderer.xr.enabled = currentXrEnabled;
+				renderer.shadowMap.autoUpdate = currentShadowAutoUpdate;
+				renderer.setRenderTarget( currentRenderTarget );
+
+				// render the main scene
+				renderer.render( scene, camera );
+
+			}
+
+		</script>
+	</body>
+</html>

+ 67 - 0
src/cameras/PerspectiveCamera.js

@@ -1,4 +1,6 @@
 import { Camera } from './Camera.js';
 import { Camera } from './Camera.js';
+import { Vector3 } from '../math/Vector3.js';
+import { Quaternion } from '../math/Quaternion.js';
 import * as MathUtils from '../math/MathUtils.js';
 import * as MathUtils from '../math/MathUtils.js';
 
 
 class PerspectiveCamera extends Camera {
 class PerspectiveCamera extends Camera {
@@ -204,6 +206,62 @@ class PerspectiveCamera extends Camera {
 
 
 	}
 	}
 
 
+	/** Set this PerspectiveCamera's projectionMatrix and quaternion
+	 * to exactly frame the corners of an arbitrary rectangle.
+	 * NOTE: This function ignores the standard parameters;
+	 * do not call updateProjectionMatrix() after this!
+	 * @param {Vector3} bottomLeftCorner
+	 * @param {Vector3} bottomRightCorner
+	 * @param {Vector3} topLeftCorner
+	 * @param {boolean} estimateViewFrustum */
+	frameCorners( bottomLeftCorner, bottomRightCorner, topLeftCorner, estimateViewFrustum = false ) {
+
+		const pa = bottomLeftCorner, pb = bottomRightCorner, pc = topLeftCorner;
+		const pe = this.position; // eye position
+		const n = this.near; // distance of near clipping plane
+		const f = this.far; //distance of far clipping plane
+
+		_vr.copy( pb ).sub( pa ).normalize();
+		_vu.copy( pc ).sub( pa ).normalize();
+		_vn.crossVectors( _vr, _vu ).normalize();
+
+		_va.copy( pa ).sub( pe ); // from pe to pa
+		_vb.copy( pb ).sub( pe ); // from pe to pb
+		_vc.copy( pc ).sub( pe ); // from pe to pc
+
+		const d = - _va.dot( _vn );	// distance from eye to screen
+		const l = _vr.dot( _va ) * n / d; // distance to left screen edge
+		const r = _vr.dot( _vb ) * n / d; // distance to right screen edge
+		const b = _vu.dot( _va ) * n / d; // distance to bottom screen edge
+		const t = _vu.dot( _vc ) * n / d; // distance to top screen edge
+
+		// Set the camera rotation to match the focal plane to the corners' plane
+		_quat.setFromUnitVectors( _vec.set( 0, 1, 0 ), _vu );
+		this.quaternion.setFromUnitVectors( _vec.set( 0, 0, 1 ).applyQuaternion( _quat ), _vn ).multiply( _quat );
+
+		// Set the off-axis projection matrix to match the corners
+		this.projectionMatrix.set( 2.0 * n / ( r - l ), 0.0,
+			( r + l ) / ( r - l ), 0.0, 0.0,
+			2.0 * n / ( t - b ),
+			( t + b ) / ( t - b ), 0.0, 0.0, 0.0,
+			( f + n ) / ( n - f ),
+			2.0 * f * n / ( n - f ), 0.0, 0.0, - 1.0, 0.0 );
+		this.projectionMatrixInverse.copy( this.projectionMatrix ).invert();
+
+		// FoV estimation to fix frustum culling
+		if ( estimateViewFrustum ) {
+
+			// Set fieldOfView to a conservative estimate
+			// to make frustum tall/wide enough to encompass it
+			this.fov =
+				MathUtils.RAD2DEG / Math.min( 1.0, this.aspect ) *
+				Math.atan( ( _vec.copy( pb ).sub( pa ).length() +
+						   ( _vec.copy( pc ).sub( pa ).length() ) ) / _va.length() );
+
+		}
+
+	}
+
 	toJSON( meta ) {
 	toJSON( meta ) {
 
 
 		const data = super.toJSON( meta );
 		const data = super.toJSON( meta );
@@ -230,4 +288,13 @@ class PerspectiveCamera extends Camera {
 
 
 PerspectiveCamera.prototype.isPerspectiveCamera = true;
 PerspectiveCamera.prototype.isPerspectiveCamera = true;
 
 
+const _va = /*@__PURE__*/ new Vector3(), // from pe to pa
+	_vb = /*@__PURE__*/ new Vector3(), // from pe to pb
+	_vc = /*@__PURE__*/ new Vector3(), // from pe to pc
+	_vr = /*@__PURE__*/ new Vector3(), // right axis of screen
+	_vu = /*@__PURE__*/ new Vector3(), // up axis of screen
+	_vn = /*@__PURE__*/ new Vector3(), // normal vector of screen
+	_vec = /*@__PURE__*/ new Vector3(), // temporary vector
+	_quat = /*@__PURE__*/ new Quaternion(); // temporary quaternion
+
 export { PerspectiveCamera };
 export { PerspectiveCamera };