diff --git a/editor/index.html b/editor/index.html
index 8c45220c640e59..866b956d1f1757 100644
--- a/editor/index.html
+++ b/editor/index.html
@@ -195,6 +195,7 @@
const signals = editor.signals;
+ signals.cameraResetted.add( saveState );
signals.geometryChanged.add( saveState );
signals.objectAdded.add( saveState );
signals.objectChanged.add( saveState );
diff --git a/editor/js/Config.js b/editor/js/Config.js
index 63d4bf4425bdb3..620596d1dd62bd 100644
--- a/editor/js/Config.js
+++ b/editor/js/Config.js
@@ -15,6 +15,8 @@ function Config() {
'project/editable': false,
'project/vr': false,
+ 'project/camera': 'perspective',
+
'project/renderer/type': 'WebGLRenderer',
'project/renderer/antialias': true,
'project/renderer/shadows': true,
@@ -28,7 +30,9 @@ function Config() {
'settings/shortcuts/rotate': 'e',
'settings/shortcuts/scale': 'r',
'settings/shortcuts/undo': 'z',
- 'settings/shortcuts/focus': 'f'
+ 'settings/shortcuts/focus': 'f',
+ 'settings/shortcuts/perspective': 'p',
+ 'settings/shortcuts/orthographic': 'o'
};
if ( window.localStorage[ name ] === undefined ) {
diff --git a/editor/js/Editor.js b/editor/js/Editor.js
index 98873d47289b2e..baa5077221e7f9 100644
--- a/editor/js/Editor.js
+++ b/editor/js/Editor.js
@@ -11,6 +11,7 @@ var _DEFAULT_CAMERA = new THREE.PerspectiveCamera( 50, 1, 0.001, 1e10 );
_DEFAULT_CAMERA.name = 'Camera';
_DEFAULT_CAMERA.position.set( 0, 5, 10 );
_DEFAULT_CAMERA.lookAt( new THREE.Vector3() );
+const _ORTHOGRAPHIC_FRUSTUM_SIZE = 100;
function Editor() {
@@ -551,6 +552,68 @@ Editor.prototype = {
},
+ setCameraType: function ( type ) {
+
+ const oldCamera = this.camera;
+
+ const isOrthographic = oldCamera.isOrthographicCamera === true;
+
+ if ( ( type === 'orthographic' && isOrthographic ) || ( type === 'perspective' && ! isOrthographic ) ) return;
+
+ // the orbit point the framing should be preserved around
+
+ const center = this.controls ? this.controls.center : new THREE.Vector3();
+ const distance = oldCamera.position.distanceTo( center );
+
+ let newCamera;
+
+ if ( type === 'orthographic' ) {
+
+ const halfSize = _ORTHOGRAPHIC_FRUSTUM_SIZE / 2;
+ newCamera = new THREE.OrthographicCamera( - halfSize, halfSize, halfSize, - halfSize, 0, 10000 );
+ newCamera.position.copy( oldCamera.position );
+ newCamera.quaternion.copy( oldCamera.quaternion );
+
+ // derive the zoom so the orthographic framing matches the perspective view at the orbit center
+
+ const halfFOV = THREE.MathUtils.DEG2RAD * oldCamera.fov / 2;
+ newCamera.zoom = ( newCamera.top - newCamera.bottom ) / ( 2 * Math.max( distance, 0.0001 ) * Math.tan( halfFOV ) );
+
+ } else {
+
+ newCamera = new THREE.PerspectiveCamera( 50, 1, 0.001, 1e10 );
+ newCamera.quaternion.copy( oldCamera.quaternion );
+
+ // reposition along the view direction so the perspective framing matches the orthographic view
+
+ const halfFOV = THREE.MathUtils.DEG2RAD * newCamera.fov / 2;
+ const targetDistance = ( oldCamera.top - oldCamera.bottom ) / ( 2 * oldCamera.zoom * Math.tan( halfFOV ) );
+
+ const offset = new THREE.Vector3().subVectors( oldCamera.position, center );
+ if ( offset.lengthSq() === 0 ) offset.set( 0, 0, 1 ).applyQuaternion( oldCamera.quaternion );
+ offset.normalize().multiplyScalar( targetDistance );
+
+ newCamera.position.copy( center ).add( offset );
+
+ }
+
+ newCamera.name = oldCamera.name;
+ newCamera.uuid = oldCamera.uuid;
+ newCamera.updateProjectionMatrix();
+
+ this.camera = newCamera;
+ this.cameras[ newCamera.uuid ] = newCamera;
+
+ if ( this.viewportCamera === oldCamera ) this.viewportCamera = newCamera;
+
+ this.signals.cameraResetted.dispatch();
+
+ // keep the selection (and thus the sidebar) in sync with the new camera instance
+
+ if ( this.selected === oldCamera ) this.select( newCamera );
+
+ },
+
setViewportCamera: function ( uuid ) {
this.viewportCamera = this.cameras[ uuid ] || this.camera;
@@ -629,6 +692,7 @@ Editor.prototype = {
this.history.clear();
this.storage.clear();
+ this.setCameraType( 'perspective' );
this.camera.copy( _DEFAULT_CAMERA );
this.signals.cameraResetted.dispatch();
@@ -676,6 +740,8 @@ Editor.prototype = {
var loader = new THREE.ObjectLoader();
var camera = await loader.parseAsync( json.camera );
+ this.setCameraType( camera.isOrthographicCamera ? 'orthographic' : 'perspective' );
+
const existingUuid = this.camera.uuid;
const incomingUuid = camera.uuid;
diff --git a/editor/js/EditorControls.js b/editor/js/EditorControls.js
index c8a66add08a0cb..7ad14feb1fc3fb 100644
--- a/editor/js/EditorControls.js
+++ b/editor/js/EditorControls.js
@@ -40,6 +40,12 @@ class EditorControls extends THREE.EventDispatcher {
var changeEvent = { type: 'change' };
+ this.setCamera = function ( camera ) {
+
+ object = camera;
+
+ };
+
this.focus = function ( target ) {
var distance;
@@ -66,13 +72,22 @@ class EditorControls extends THREE.EventDispatcher {
object.position.copy( center ).add( delta );
+ if ( object.isOrthographicCamera ) {
+
+ object.zoom = ( object.top - object.bottom ) / ( distance * 2 );
+ object.updateProjectionMatrix();
+
+ }
+
scope.dispatchEvent( changeEvent );
};
this.pan = function ( delta ) {
- var distance = object.position.distanceTo( center );
+ var distance = object.isOrthographicCamera
+ ? ( object.top - object.bottom ) / object.zoom
+ : object.position.distanceTo( center );
delta.multiplyScalar( distance * scope.panSpeed );
delta.applyMatrix3( normalMatrix.getNormalMatrix( object.matrix ) );
@@ -86,15 +101,24 @@ class EditorControls extends THREE.EventDispatcher {
this.zoom = function ( delta ) {
- var distance = object.position.distanceTo( center );
+ if ( object.isOrthographicCamera ) {
- delta.multiplyScalar( distance * scope.zoomSpeed );
+ object.zoom = Math.max( 0.0001, object.zoom * Math.pow( 0.95, delta.z ) );
+ object.updateProjectionMatrix();
- if ( delta.length() > distance ) return;
+ } else {
- delta.applyMatrix3( normalMatrix.getNormalMatrix( object.matrix ) );
+ var distance = object.position.distanceTo( center );
- object.position.add( delta );
+ delta.multiplyScalar( distance * scope.zoomSpeed );
+
+ if ( delta.length() > distance ) return;
+
+ delta.applyMatrix3( normalMatrix.getNormalMatrix( object.matrix ) );
+
+ object.position.add( delta );
+
+ }
scope.dispatchEvent( changeEvent );
diff --git a/editor/js/Menubar.Add.js b/editor/js/Menubar.Add.js
index b097e502416d9e..f9e9ba6d347686 100644
--- a/editor/js/Menubar.Add.js
+++ b/editor/js/Menubar.Add.js
@@ -526,7 +526,9 @@ function MenubarAdd( editor ) {
option.setTextContent( strings.getKey( 'menubar/add/camera/orthographic' ) );
option.onClick( function () {
- const aspect = editor.camera.aspect;
+ const aspect = editor.camera.isPerspectiveCamera
+ ? editor.camera.aspect
+ : ( editor.camera.right - editor.camera.left ) / ( editor.camera.top - editor.camera.bottom );
const camera = new THREE.OrthographicCamera( - aspect, aspect );
camera.name = 'OrthographicCamera';
diff --git a/editor/js/Menubar.Render.js b/editor/js/Menubar.Render.js
index 0902f7c8232410..3fef5d1a698fe5 100644
--- a/editor/js/Menubar.Render.js
+++ b/editor/js/Menubar.Render.js
@@ -202,13 +202,28 @@ class RenderImageDialog {
const loader = new THREE.ObjectLoader();
const camera = await loader.parseAsync( json.camera );
- camera.aspect = imageWidth.getValue() / imageHeight.getValue();
+
+ const aspect = imageWidth.getValue() / imageHeight.getValue();
+
+ if ( camera.isPerspectiveCamera ) {
+
+ camera.aspect = aspect;
+
+ } else {
+
+ const frustumHeight = camera.top - camera.bottom;
+
+ camera.left = - frustumHeight * aspect / 2;
+ camera.right = frustumHeight * aspect / 2;
+
+ }
+
camera.updateProjectionMatrix();
camera.updateMatrixWorld();
const scene = await loader.parseAsync( json.scene );
- const renderer = new THREE.WebGLRenderer( { antialias: true, logarithmicDepthBuffer: true } );
+ const renderer = new THREE.WebGLRenderer( { antialias: true, reversedDepthBuffer: true } );
renderer.setSize( imageWidth.getValue(), imageHeight.getValue() );
renderer.setClearColor( editor.viewportColor );
diff --git a/editor/js/Sidebar.Project.Renderer.js b/editor/js/Sidebar.Project.Renderer.js
index 8961ed04846aa7..6f6a834601e4d5 100644
--- a/editor/js/Sidebar.Project.Renderer.js
+++ b/editor/js/Sidebar.Project.Renderer.js
@@ -15,6 +15,30 @@ function SidebarProjectRenderer( editor ) {
const container = new UIPanel();
container.setBorderTop( '0px' );
+ // Camera
+
+ const cameraRow = new UIRow();
+ container.add( cameraRow );
+
+ cameraRow.add( new UIText( strings.getKey( 'sidebar/project/camera' ) ).setClass( 'Label' ) );
+
+ const cameraTypeSelect = new UISelect().setOptions( {
+ 'perspective': 'Perspective',
+ 'orthographic': 'Orthographic'
+ } ).setWidth( '150px' ).onChange( function () {
+
+ editor.setCameraType( this.getValue() );
+
+ } );
+ cameraTypeSelect.setValue( config.getKey( 'project/camera' ) );
+ cameraRow.add( cameraTypeSelect );
+
+ if ( config.getKey( 'project/camera' ) === 'orthographic' ) {
+
+ editor.setCameraType( 'orthographic' );
+
+ }
+
// Renderer
const rendererRow = new UIRow();
@@ -111,12 +135,12 @@ function SidebarProjectRenderer( editor ) {
if ( rendererType === 'WebGPURenderer' ) {
- currentRenderer = new WebGPURenderer( { antialias: antialias, logarithmicDepthBuffer: true } );
+ currentRenderer = new WebGPURenderer( { antialias: antialias, reversedDepthBuffer: true } );
await currentRenderer.init();
} else {
- currentRenderer = new THREE.WebGLRenderer( { antialias: antialias, logarithmicDepthBuffer: true } );
+ currentRenderer = new THREE.WebGLRenderer( { antialias: antialias, reversedDepthBuffer: true } );
}
@@ -135,6 +159,15 @@ function SidebarProjectRenderer( editor ) {
// Signals
+ signals.cameraResetted.add( function () {
+
+ const type = editor.camera.isOrthographicCamera ? 'orthographic' : 'perspective';
+
+ cameraTypeSelect.setValue( type );
+ config.setKey( 'project/camera', type );
+
+ } );
+
signals.editorCleared.add( function () {
currentRenderer.shadowMap.enabled = true;
diff --git a/editor/js/Sidebar.Scene.js b/editor/js/Sidebar.Scene.js
index efdad601e95a95..d0d31973666c22 100644
--- a/editor/js/Sidebar.Scene.js
+++ b/editor/js/Sidebar.Scene.js
@@ -503,6 +503,8 @@ function SidebarScene( editor ) {
signals.sceneGraphChanged.add( refreshUI );
+ signals.cameraResetted.add( refreshUI );
+
signals.objectChanged.add( function ( object ) {
const options = outliner.options;
diff --git a/editor/js/Sidebar.Settings.Shortcuts.js b/editor/js/Sidebar.Settings.Shortcuts.js
index f09e0a9e9ee486..20b92a5ba0a513 100644
--- a/editor/js/Sidebar.Settings.Shortcuts.js
+++ b/editor/js/Sidebar.Settings.Shortcuts.js
@@ -24,7 +24,7 @@ function SidebarSettingsShortcuts( editor ) {
headerRow.add( new UIText( strings.getKey( 'sidebar/settings/shortcuts' ).toUpperCase() ) );
container.add( headerRow );
- const shortcuts = [ 'translate', 'rotate', 'scale', 'undo', 'focus' ];
+ const shortcuts = [ 'translate', 'rotate', 'scale', 'undo', 'focus', 'perspective', 'orthographic' ];
function createShortcutInput( name ) {
@@ -175,6 +175,18 @@ function SidebarSettingsShortcuts( editor ) {
break;
+ case config.getKey( 'settings/shortcuts/perspective' ):
+
+ editor.setCameraType( 'perspective' );
+
+ break;
+
+ case config.getKey( 'settings/shortcuts/orthographic' ):
+
+ editor.setCameraType( 'orthographic' );
+
+ break;
+
}
} );
diff --git a/editor/js/Strings.js b/editor/js/Strings.js
index 3246f8cb513fe5..7627143f21731a 100644
--- a/editor/js/Strings.js
+++ b/editor/js/Strings.js
@@ -815,6 +815,7 @@ function Strings( config ) {
'sidebar/script/remove': 'Remove',
'sidebar/project': 'Project',
+ 'sidebar/project/camera': 'Camera',
'sidebar/project/renderer': 'Renderer',
'sidebar/project/antialias': 'Antialias',
'sidebar/project/shadows': 'Shadows',
@@ -849,6 +850,8 @@ function Strings( config ) {
'sidebar/settings/shortcuts/scale': 'Scale',
'sidebar/settings/shortcuts/undo': 'Undo',
'sidebar/settings/shortcuts/focus': 'Focus',
+ 'sidebar/settings/shortcuts/perspective': 'Perspective',
+ 'sidebar/settings/shortcuts/orthographic': 'Orthographic',
'sidebar/history': 'History',
'sidebar/history/clear': 'Clear',
diff --git a/editor/js/Viewport.XR.js b/editor/js/Viewport.XR.js
index 83b59b7cbc8f5b..3aea6446d2d381 100644
--- a/editor/js/Viewport.XR.js
+++ b/editor/js/Viewport.XR.js
@@ -20,7 +20,18 @@ class XR {
const onSessionStarted = async ( session ) => {
- camera.copy( editor.camera );
+ if ( editor.camera.isPerspectiveCamera ) {
+
+ camera.copy( editor.camera );
+
+ } else {
+
+ // an orthographic default camera can't be mirrored into a perspective XR camera
+
+ camera.position.copy( editor.camera.position );
+ camera.quaternion.copy( editor.camera.quaternion );
+
+ }
const sidebar = document.getElementById( 'sidebar' );
sidebar.style.width = '350px';
diff --git a/editor/js/Viewport.js b/editor/js/Viewport.js
index a4b0eb12cc047d..f399da7235bf74 100644
--- a/editor/js/Viewport.js
+++ b/editor/js/Viewport.js
@@ -39,7 +39,7 @@ function Viewport( editor ) {
let pmremGenerator = null;
let pathtracer = null;
- const camera = editor.camera;
+ let camera = editor.camera;
const scene = editor.scene;
const sceneHelpers = editor.sceneHelpers;
@@ -166,8 +166,10 @@ function Viewport( editor ) {
} else {
- camera.left = - aspect;
- camera.right = aspect;
+ const frustumHeight = camera.top - camera.bottom;
+
+ camera.left = - frustumHeight * aspect / 2;
+ camera.right = frustumHeight * aspect / 2;
}
@@ -812,7 +814,22 @@ function Viewport( editor ) {
} );
- signals.cameraResetted.add( updateAspectRatio );
+ signals.cameraResetted.add( function () {
+
+ if ( camera !== editor.camera ) {
+
+ camera = editor.camera;
+
+ controls.setCamera( camera );
+ transformControls.camera = camera;
+ viewHelper.camera = camera;
+
+ }
+
+ updateAspectRatio();
+ render();
+
+ } );
// animations
diff --git a/editor/js/libs/app.js b/editor/js/libs/app.js
index 7f26dd0afaeeb5..50ed374818080e 100644
--- a/editor/js/libs/app.js
+++ b/editor/js/libs/app.js
@@ -34,12 +34,12 @@ const APP = {
if ( project.renderer === 'WebGPURenderer' ) {
const { WebGPURenderer } = await import( 'three/webgpu' );
- renderer = new WebGPURenderer( { antialias: true, logarithmicDepthBuffer: true } );
+ renderer = new WebGPURenderer( { antialias: true, reversedDepthBuffer: true } );
await renderer.init();
} else {
- renderer = new THREE.WebGLRenderer( { antialias: true, logarithmicDepthBuffer: true } );
+ renderer = new THREE.WebGLRenderer( { antialias: true, reversedDepthBuffer: true } );
}
@@ -125,8 +125,7 @@ const APP = {
this.setCamera = function ( value ) {
camera = value;
- camera.aspect = this.width / this.height;
- camera.updateProjectionMatrix();
+ setCameraAspect( camera, this.width / this.height );
};
@@ -155,8 +154,7 @@ const APP = {
if ( camera ) {
- camera.aspect = this.width / this.height;
- camera.updateProjectionMatrix();
+ setCameraAspect( camera, this.width / this.height );
}
@@ -168,6 +166,25 @@ const APP = {
};
+ function setCameraAspect( camera, aspect ) {
+
+ if ( camera.isPerspectiveCamera ) {
+
+ camera.aspect = aspect;
+
+ } else {
+
+ const frustumHeight = camera.top - camera.bottom;
+
+ camera.left = - frustumHeight * aspect / 2;
+ camera.right = frustumHeight * aspect / 2;
+
+ }
+
+ camera.updateProjectionMatrix();
+
+ }
+
function dispatch( array, event ) {
for ( let i = 0, l = array.length; i < l; i ++ ) {
diff --git a/examples/jsm/helpers/ViewHelper.js b/examples/jsm/helpers/ViewHelper.js
index ef8b89482b3800..c4e6dbaf773c1a 100644
--- a/examples/jsm/helpers/ViewHelper.js
+++ b/examples/jsm/helpers/ViewHelper.js
@@ -49,6 +49,14 @@ class ViewHelper extends Object3D {
*/
this.isViewHelper = true;
+ /**
+ * The camera whose transformation is visualized. It can be reassigned at
+ * any time to rebind the helper to a different camera.
+ *
+ * @type {Camera}
+ */
+ this.camera = camera;
+
/**
* Whether the helper is currently animating or not.
*
@@ -86,6 +94,8 @@ class ViewHelper extends Object3D {
const options = {};
+ const scope = this;
+
const interactiveObjects = [];
const raycaster = new Raycaster();
const mouse = new Vector2();
@@ -163,11 +173,11 @@ class ViewHelper extends Object3D {
*/
this.render = function ( renderer ) {
- this.quaternion.copy( camera.quaternion ).invert();
+ this.quaternion.copy( this.camera.quaternion ).invert();
this.updateMatrixWorld();
point.set( 0, 0, 1 );
- point.applyQuaternion( camera.quaternion );
+ point.applyQuaternion( this.camera.quaternion );
//
@@ -325,11 +335,11 @@ class ViewHelper extends Object3D {
// animate position by doing a slerp and then scaling the position on the unit sphere
q1.rotateTowards( q2, step );
- camera.position.set( 0, 0, 1 ).applyQuaternion( q1 ).multiplyScalar( radius ).add( this.center );
+ this.camera.position.set( 0, 0, 1 ).applyQuaternion( q1 ).multiplyScalar( radius ).add( this.center );
// animate orientation
- camera.quaternion.rotateTowards( targetQuaternion, step );
+ this.camera.quaternion.rotateTowards( targetQuaternion, step );
if ( q1.angleTo( q2 ) === 0 ) {
@@ -408,12 +418,12 @@ class ViewHelper extends Object3D {
//
- radius = camera.position.distanceTo( focusPoint );
+ radius = scope.camera.position.distanceTo( focusPoint );
targetPosition.multiplyScalar( radius ).add( focusPoint );
dummy.position.copy( focusPoint );
- dummy.lookAt( camera.position );
+ dummy.lookAt( scope.camera.position );
q1.copy( dummy.quaternion );
dummy.lookAt( targetPosition );
diff --git a/examples/webgl_gpgpu_water.html b/examples/webgl_gpgpu_water.html
index c89b4402df30be..89f19de21e9630 100644
--- a/examples/webgl_gpgpu_water.html
+++ b/examples/webgl_gpgpu_water.html
@@ -151,7 +151,7 @@
import { SimplexNoise } from 'three/addons/math/SimplexNoise.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { HDRLoader } from 'three/addons/loaders/HDRLoader.js';
- import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
+ import { DRACOLoader, DRACO_GLTF_CONFIG } from 'three/addons/loaders/DRACOLoader.js';
// Texture width for simulation
const WIDTH = 128;
@@ -242,7 +242,7 @@
const hdrLoader = new HDRLoader().setPath( './textures/equirectangular/' );
const glbloader = new GLTFLoader().setPath( 'models/gltf/' );
- glbloader.setDRACOLoader( new DRACOLoader().setDecoderPath( 'jsm/libs/draco/gltf/' ) );
+ glbloader.setDRACOLoader( new DRACOLoader().setDecoderPath( DRACO_GLTF_CONFIG ) );
const [ env, model ] = await Promise.all( [ hdrLoader.loadAsync( 'blouberg_sunrise_2_1k.hdr' ), glbloader.loadAsync( 'duck.glb' ) ] );
env.mapping = THREE.EquirectangularReflectionMapping;
diff --git a/examples/webgl_loader_3dtiles.html b/examples/webgl_loader_3dtiles.html
index e168c6aa2918d6..74d4d09867ee9a 100644
--- a/examples/webgl_loader_3dtiles.html
+++ b/examples/webgl_loader_3dtiles.html
@@ -73,7 +73,7 @@