Skip to content

Commit b67a3f7

Browse files
authored
PerspectiveCamera: Add Kooima's Generalized Projection Formulation (mrdoob#21825)
* Add Kooima's Generalized Projection Matrix Formulation Addresses Issue mrdoob#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
1 parent 5be5267 commit b67a3f7

File tree

6 files changed

+327
-0
lines changed

6 files changed

+327
-0
lines changed

docs/api/en/cameras/PerspectiveCamera.html

+6
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,12 @@ <h3>[method:null updateProjectionMatrix]()</h3>
186186
Updates the camera projection matrix. Must be called after any change of parameters.
187187
</p>
188188

189+
<h3>[method:null frameCorners]( [param:Vector3 bottomLeftCorner], [param:Vector3 bottomRightCorner], [param:Vector3 topLeftCorner], [param:boolean estimateViewFrustum] )</h3>
190+
<p>
191+
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].
192+
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.
193+
</p>
194+
189195
<h3>[method:Object toJSON]([param:Object meta])</h3>
190196
<p>
191197
meta -- object containing metadata such as textures or images in objects' descendants.<br />

examples/files.json

+1
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@
173173
"webgl_math_obb",
174174
"webgl_math_orientation_transform",
175175
"webgl_mirror",
176+
"webgl_portal",
176177
"webgl_modifier_curve",
177178
"webgl_modifier_curve_instanced",
178179
"webgl_modifier_edgesplit",

examples/screenshots/webgl_portal.jpg

22.3 KB
Loading

examples/tags.json

+1
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
"webgl_math_obb": [ "intersection", "bounding" ],
5050
"webgl_math_orientation_transform": [ "rotation" ],
5151
"webgl_mirror": [ "reflection" ],
52+
"webgl_portal": [ "portal", "frameCorners", "renderTarget" ],
5253
"webgl_morphtargets_horse": [ "animation" ],
5354
"webgl_multiple_elements": [ "differential equations", "physics" ],
5455
"webgl_multiple_elements_text": [ "font" ],

examples/webgl_portal.html

+252
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<title>three.js webgl - portal</title>
5+
<meta charset="utf-8">
6+
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
7+
<link type="text/css" rel="stylesheet" href="main.css">
8+
<style>
9+
body {
10+
color: #444;
11+
}
12+
a {
13+
color: #08f;
14+
}
15+
</style>
16+
</head>
17+
<body>
18+
19+
<div id="container"></div>
20+
<div id="info"><a href="https://threejs.org" target="_blank" rel="noopener">three.js</a> - portal
21+
</div>
22+
23+
<script type="module">
24+
25+
import * as THREE from '../build/three.module.js';
26+
27+
import { OrbitControls } from './jsm/controls/OrbitControls.js';
28+
29+
let camera, scene, renderer;
30+
31+
let cameraControls;
32+
33+
let smallSphereOne, smallSphereTwo;
34+
35+
let portalCamera, leftPortal, rightPortal, leftPortalTexture, reflectedPosition,
36+
rightPortalTexture, bottomLeftCorner, bottomRightCorner, topLeftCorner, frustumHelper;
37+
38+
init();
39+
animate();
40+
41+
function init() {
42+
43+
const container = document.getElementById( 'container' );
44+
45+
// renderer
46+
renderer = new THREE.WebGLRenderer( { antialias: true } );
47+
renderer.setPixelRatio( window.devicePixelRatio );
48+
renderer.setSize( window.innerWidth, window.innerHeight );
49+
container.appendChild( renderer.domElement );
50+
renderer.localClippingEnabled = true;
51+
52+
// scene
53+
scene = new THREE.Scene();
54+
55+
// camera
56+
camera = new THREE.PerspectiveCamera( 45, window.innerWidth / window.innerHeight, 1, 5000 );
57+
camera.position.set( 0, 75, 160 );
58+
59+
cameraControls = new OrbitControls( camera, renderer.domElement );
60+
cameraControls.target.set( 0, 40, 0 );
61+
cameraControls.maxDistance = 400;
62+
cameraControls.minDistance = 10;
63+
cameraControls.update();
64+
65+
//
66+
67+
const planeGeo = new THREE.PlaneGeometry( 100.1, 100.1 );
68+
69+
let geometry, material;
70+
geometry = new THREE.CylinderGeometry( 0.1, 15 * Math.cos( Math.PI / 180 * 30 ), 0.1, 24, 1 );
71+
material = new THREE.MeshPhongMaterial( { color: 0xffffff, emissive: 0x444444 } );
72+
73+
// bouncing icosphere
74+
const portalPlane = new THREE.Plane( new THREE.Vector3( 0, 0, 1 ), 0.0 );
75+
geometry = new THREE.IcosahedronGeometry( 5, 0 );
76+
material = new THREE.MeshPhongMaterial( {
77+
color: 0xffffff, emissive: 0x333333, flatShading: true,
78+
clippingPlanes: [ portalPlane ], clipShadows: true } );
79+
smallSphereOne = new THREE.Mesh( geometry, material );
80+
scene.add( smallSphereOne );
81+
smallSphereTwo = new THREE.Mesh( geometry, material );
82+
scene.add( smallSphereTwo );
83+
84+
// portals
85+
portalCamera = new THREE.PerspectiveCamera( 45, 1.0, 0.1, 500.0 );
86+
scene.add( portalCamera );
87+
//frustumHelper = new THREE.CameraHelper( portalCamera );
88+
//scene.add( frustumHelper );
89+
bottomLeftCorner = new THREE.Vector3();
90+
bottomRightCorner = new THREE.Vector3();
91+
topLeftCorner = new THREE.Vector3();
92+
reflectedPosition = new THREE.Vector3();
93+
94+
leftPortalTexture = new THREE.WebGLRenderTarget( 256, 256, {
95+
minFilter: THREE.LinearFilter,
96+
magFilter: THREE.LinearFilter,
97+
format: THREE.RGBFormat
98+
} );
99+
leftPortal = new THREE.Mesh( planeGeo, new THREE.MeshBasicMaterial( { map: leftPortalTexture.texture } ) );
100+
leftPortal.position.x = - 30;
101+
leftPortal.position.y = 20;
102+
leftPortal.scale.set( 0.35, 0.35, 0.35 );
103+
scene.add( leftPortal );
104+
105+
rightPortalTexture = new THREE.WebGLRenderTarget( 256, 256, {
106+
minFilter: THREE.LinearFilter,
107+
magFilter: THREE.LinearFilter,
108+
format: THREE.RGBFormat
109+
} );
110+
rightPortal = new THREE.Mesh( planeGeo, new THREE.MeshBasicMaterial( { map: rightPortalTexture.texture } ) );
111+
rightPortal.position.x = 30;
112+
rightPortal.position.y = 20;
113+
rightPortal.scale.set( 0.35, 0.35, 0.35 );
114+
scene.add( rightPortal );
115+
116+
// walls
117+
const planeTop = new THREE.Mesh( planeGeo, new THREE.MeshPhongMaterial( { color: 0xffffff } ) );
118+
planeTop.position.y = 100;
119+
planeTop.rotateX( Math.PI / 2 );
120+
scene.add( planeTop );
121+
122+
const planeBottom = new THREE.Mesh( planeGeo, new THREE.MeshPhongMaterial( { color: 0xffffff } ) );
123+
planeBottom.rotateX( - Math.PI / 2 );
124+
scene.add( planeBottom );
125+
126+
const planeFront = new THREE.Mesh( planeGeo, new THREE.MeshPhongMaterial( { color: 0x7f7fff } ) );
127+
planeFront.position.z = 50;
128+
planeFront.position.y = 50;
129+
planeFront.rotateY( Math.PI );
130+
scene.add( planeFront );
131+
132+
const planeBack = new THREE.Mesh( planeGeo, new THREE.MeshPhongMaterial( { color: 0xff7fff } ) );
133+
planeBack.position.z = - 50;
134+
planeBack.position.y = 50;
135+
//planeBack.rotateY( Math.PI );
136+
scene.add( planeBack );
137+
138+
const planeRight = new THREE.Mesh( planeGeo, new THREE.MeshPhongMaterial( { color: 0x00ff00 } ) );
139+
planeRight.position.x = 50;
140+
planeRight.position.y = 50;
141+
planeRight.rotateY( - Math.PI / 2 );
142+
scene.add( planeRight );
143+
144+
const planeLeft = new THREE.Mesh( planeGeo, new THREE.MeshPhongMaterial( { color: 0xff0000 } ) );
145+
planeLeft.position.x = - 50;
146+
planeLeft.position.y = 50;
147+
planeLeft.rotateY( Math.PI / 2 );
148+
scene.add( planeLeft );
149+
150+
// lights
151+
const mainLight = new THREE.PointLight( 0xcccccc, 1.5, 250 );
152+
mainLight.position.y = 60;
153+
scene.add( mainLight );
154+
155+
const greenLight = new THREE.PointLight( 0x00ff00, 0.25, 1000 );
156+
greenLight.position.set( 550, 50, 0 );
157+
scene.add( greenLight );
158+
159+
const redLight = new THREE.PointLight( 0xff0000, 0.25, 1000 );
160+
redLight.position.set( - 550, 50, 0 );
161+
scene.add( redLight );
162+
163+
const blueLight = new THREE.PointLight( 0x7f7fff, 0.25, 1000 );
164+
blueLight.position.set( 0, 50, 550 );
165+
scene.add( blueLight );
166+
167+
window.addEventListener( 'resize', onWindowResize, false );
168+
169+
}
170+
171+
function onWindowResize() {
172+
173+
camera.aspect = window.innerWidth / window.innerHeight;
174+
camera.updateProjectionMatrix();
175+
176+
renderer.setSize( window.innerWidth, window.innerHeight );
177+
178+
}
179+
180+
function renderPortal( thisPortalMesh, otherPortalMesh, thisPortalTexture ) {
181+
182+
// set the portal camera position to be reflected about the portal plane
183+
thisPortalMesh.worldToLocal( reflectedPosition.copy( camera.position ) );
184+
reflectedPosition.x *= - 1.0; reflectedPosition.z *= - 1.0;
185+
otherPortalMesh.localToWorld( reflectedPosition );
186+
portalCamera.position.copy( reflectedPosition );
187+
188+
// grab the corners of the other portal
189+
// - note: the portal is viewed backwards; flip the left/right coordinates
190+
otherPortalMesh.localToWorld( bottomLeftCorner.set( 50.05, - 50.05, 0.0 ) );
191+
otherPortalMesh.localToWorld( bottomRightCorner.set( - 50.05, - 50.05, 0.0 ) );
192+
otherPortalMesh.localToWorld( topLeftCorner.set( 50.05, 50.05, 0.0 ) );
193+
// set the projection matrix to encompass the portal's frame
194+
portalCamera.frameCorners( bottomLeftCorner, bottomRightCorner, topLeftCorner, false );
195+
196+
// render the portal
197+
thisPortalTexture.texture.encoding = renderer.outputEncoding;
198+
renderer.setRenderTarget( thisPortalTexture );
199+
renderer.state.buffers.depth.setMask( true ); // make sure the depth buffer is writable so it can be properly cleared, see #18897
200+
if ( renderer.autoClear === false ) renderer.clear();
201+
renderer.render( scene, portalCamera );
202+
203+
}
204+
205+
function animate() {
206+
207+
requestAnimationFrame( animate );
208+
209+
// move the bouncing sphere(s)
210+
const timerOne = Date.now() * 0.01;
211+
const timerTwo = timerOne + Math.PI * 10.0;
212+
213+
smallSphereOne.position.set(
214+
Math.cos( timerOne * 0.1 ) * 30,
215+
Math.abs( Math.cos( timerOne * 0.2 ) ) * 20 + 5,
216+
Math.sin( timerOne * 0.1 ) * 30
217+
);
218+
smallSphereOne.rotation.y = ( Math.PI / 2 ) - timerOne * 0.1;
219+
smallSphereOne.rotation.z = timerOne * 0.8;
220+
221+
smallSphereTwo.position.set(
222+
Math.cos( timerTwo * 0.1 ) * 30,
223+
Math.abs( Math.cos( timerTwo * 0.2 ) ) * 20 + 5,
224+
Math.sin( timerTwo * 0.1 ) * 30
225+
);
226+
smallSphereTwo.rotation.y = ( Math.PI / 2 ) - timerTwo * 0.1;
227+
smallSphereTwo.rotation.z = timerTwo * 0.8;
228+
229+
// save the original camera properties
230+
let currentRenderTarget = renderer.getRenderTarget();
231+
let currentXrEnabled = renderer.xr.enabled;
232+
let currentShadowAutoUpdate = renderer.shadowMap.autoUpdate;
233+
renderer.xr.enabled = false; // Avoid camera modification
234+
renderer.shadowMap.autoUpdate = false; // Avoid re-computing shadows
235+
236+
// render the portal effect
237+
renderPortal( leftPortal, rightPortal, leftPortalTexture );
238+
renderPortal( rightPortal, leftPortal, rightPortalTexture );
239+
240+
// restore the original rendering properties
241+
renderer.xr.enabled = currentXrEnabled;
242+
renderer.shadowMap.autoUpdate = currentShadowAutoUpdate;
243+
renderer.setRenderTarget( currentRenderTarget );
244+
245+
// render the main scene
246+
renderer.render( scene, camera );
247+
248+
}
249+
250+
</script>
251+
</body>
252+
</html>

src/cameras/PerspectiveCamera.js

+67
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { Camera } from './Camera.js';
2+
import { Vector3 } from '../math/Vector3.js';
3+
import { Quaternion } from '../math/Quaternion.js';
24
import * as MathUtils from '../math/MathUtils.js';
35

46
class PerspectiveCamera extends Camera {
@@ -204,6 +206,62 @@ class PerspectiveCamera extends Camera {
204206

205207
}
206208

209+
/** Set this PerspectiveCamera's projectionMatrix and quaternion
210+
* to exactly frame the corners of an arbitrary rectangle.
211+
* NOTE: This function ignores the standard parameters;
212+
* do not call updateProjectionMatrix() after this!
213+
* @param {Vector3} bottomLeftCorner
214+
* @param {Vector3} bottomRightCorner
215+
* @param {Vector3} topLeftCorner
216+
* @param {boolean} estimateViewFrustum */
217+
frameCorners( bottomLeftCorner, bottomRightCorner, topLeftCorner, estimateViewFrustum = false ) {
218+
219+
const pa = bottomLeftCorner, pb = bottomRightCorner, pc = topLeftCorner;
220+
const pe = this.position; // eye position
221+
const n = this.near; // distance of near clipping plane
222+
const f = this.far; //distance of far clipping plane
223+
224+
_vr.copy( pb ).sub( pa ).normalize();
225+
_vu.copy( pc ).sub( pa ).normalize();
226+
_vn.crossVectors( _vr, _vu ).normalize();
227+
228+
_va.copy( pa ).sub( pe ); // from pe to pa
229+
_vb.copy( pb ).sub( pe ); // from pe to pb
230+
_vc.copy( pc ).sub( pe ); // from pe to pc
231+
232+
const d = - _va.dot( _vn ); // distance from eye to screen
233+
const l = _vr.dot( _va ) * n / d; // distance to left screen edge
234+
const r = _vr.dot( _vb ) * n / d; // distance to right screen edge
235+
const b = _vu.dot( _va ) * n / d; // distance to bottom screen edge
236+
const t = _vu.dot( _vc ) * n / d; // distance to top screen edge
237+
238+
// Set the camera rotation to match the focal plane to the corners' plane
239+
_quat.setFromUnitVectors( _vec.set( 0, 1, 0 ), _vu );
240+
this.quaternion.setFromUnitVectors( _vec.set( 0, 0, 1 ).applyQuaternion( _quat ), _vn ).multiply( _quat );
241+
242+
// Set the off-axis projection matrix to match the corners
243+
this.projectionMatrix.set( 2.0 * n / ( r - l ), 0.0,
244+
( r + l ) / ( r - l ), 0.0, 0.0,
245+
2.0 * n / ( t - b ),
246+
( t + b ) / ( t - b ), 0.0, 0.0, 0.0,
247+
( f + n ) / ( n - f ),
248+
2.0 * f * n / ( n - f ), 0.0, 0.0, - 1.0, 0.0 );
249+
this.projectionMatrixInverse.copy( this.projectionMatrix ).invert();
250+
251+
// FoV estimation to fix frustum culling
252+
if ( estimateViewFrustum ) {
253+
254+
// Set fieldOfView to a conservative estimate
255+
// to make frustum tall/wide enough to encompass it
256+
this.fov =
257+
MathUtils.RAD2DEG / Math.min( 1.0, this.aspect ) *
258+
Math.atan( ( _vec.copy( pb ).sub( pa ).length() +
259+
( _vec.copy( pc ).sub( pa ).length() ) ) / _va.length() );
260+
261+
}
262+
263+
}
264+
207265
toJSON( meta ) {
208266

209267
const data = super.toJSON( meta );
@@ -230,4 +288,13 @@ class PerspectiveCamera extends Camera {
230288

231289
PerspectiveCamera.prototype.isPerspectiveCamera = true;
232290

291+
const _va = /*@__PURE__*/ new Vector3(), // from pe to pa
292+
_vb = /*@__PURE__*/ new Vector3(), // from pe to pb
293+
_vc = /*@__PURE__*/ new Vector3(), // from pe to pc
294+
_vr = /*@__PURE__*/ new Vector3(), // right axis of screen
295+
_vu = /*@__PURE__*/ new Vector3(), // up axis of screen
296+
_vn = /*@__PURE__*/ new Vector3(), // normal vector of screen
297+
_vec = /*@__PURE__*/ new Vector3(), // temporary vector
298+
_quat = /*@__PURE__*/ new Quaternion(); // temporary quaternion
299+
233300
export { PerspectiveCamera };

0 commit comments

Comments
 (0)