diff --git a/examples/src/examples/camera/fly.controls.mjs b/examples/src/examples/camera/fly.controls.mjs
new file mode 100644
index 00000000000..a74a626ce4b
--- /dev/null
+++ b/examples/src/examples/camera/fly.controls.mjs
@@ -0,0 +1,88 @@
+/**
+ * @param {import('../../app/components/Example.mjs').ControlOptions} options - The options.
+ * @returns {JSX.Element} The returned JSX Element.
+ */
+export const controls = ({ observer, ReactPCUI, jsx, fragment }) => {
+ const { BindingTwoWay, LabelGroup, Panel, SliderInput, VectorInput } = ReactPCUI;
+
+ return fragment(
+ jsx(
+ Panel,
+ { headerText: 'Attributes' },
+ jsx(
+ LabelGroup,
+ { text: 'Look sensitivity' },
+ jsx(SliderInput, {
+ binding: new BindingTwoWay(),
+ link: { observer, path: 'attr.lookSensitivity' },
+ min: 0.1,
+ max: 1,
+ step: 0.01
+ })
+ ),
+ jsx(
+ LabelGroup,
+ { text: 'Look damping' },
+ jsx(SliderInput, {
+ binding: new BindingTwoWay(),
+ link: { observer, path: 'attr.lookDamping' },
+ min: 0,
+ max: 0.999,
+ step: 0.001,
+ precision: 3
+ })
+ ),
+ jsx(
+ LabelGroup,
+ { text: 'Move damping' },
+ jsx(SliderInput, {
+ binding: new BindingTwoWay(),
+ link: { observer, path: 'attr.moveDamping' },
+ min: 0,
+ max: 0.999,
+ step: 0.001,
+ precision: 3
+ })
+ ),
+ jsx(
+ LabelGroup,
+ { text: 'Pitch range' },
+ jsx(VectorInput, {
+ binding: new BindingTwoWay(),
+ link: { observer, path: 'attr.pitchRange' },
+ dimensions: 2
+ })
+ ),
+ jsx(
+ LabelGroup,
+ { text: 'Move speed' },
+ jsx(SliderInput, {
+ binding: new BindingTwoWay(),
+ link: { observer, path: 'attr.moveSpeed' },
+ min: 1,
+ max: 10
+ })
+ ),
+ jsx(
+ LabelGroup,
+ { text: 'Sprint speed' },
+ jsx(SliderInput, {
+ binding: new BindingTwoWay(),
+ link: { observer, path: 'attr.sprintSpeed' },
+ min: 1,
+ max: 10
+ })
+ ),
+ jsx(
+ LabelGroup,
+ { text: 'Crouch speed' },
+ jsx(SliderInput, {
+ binding: new BindingTwoWay(),
+ link: { observer, path: 'attr.crouchSpeed' },
+ min: 1,
+ max: 10
+ })
+ )
+ )
+ );
+};
diff --git a/examples/src/examples/camera/fly.example.mjs b/examples/src/examples/camera/fly.example.mjs
index 406267320d5..15c113f9cf1 100644
--- a/examples/src/examples/camera/fly.example.mjs
+++ b/examples/src/examples/camera/fly.example.mjs
@@ -1,7 +1,14 @@
-import { deviceType, rootPath } from 'examples/utils';
+// @config DESCRIPTION
+import { data } from 'examples/observer';
+import { deviceType, rootPath, fileImport } from 'examples/utils';
import * as pc from 'playcanvas';
-const canvas = /** @type {HTMLCanvasElement} */ (document.getElementById('application-canvas'));
+const { CameraControls } = await fileImport(`${rootPath}/static/scripts/camera-controls.mjs`);
+
+const canvas = document.getElementById('application-canvas');
+if (!(canvas instanceof HTMLCanvasElement)) {
+ throw new Error('No canvas found');
+}
window.focus();
const gfxOptions = {
@@ -10,13 +17,21 @@ const gfxOptions = {
twgslUrl: `${rootPath}/static/lib/twgsl/twgsl.js`
};
+const assets = {
+ helipad: new pc.Asset(
+ 'helipad-env-atlas',
+ 'texture',
+ { url: `${rootPath}/static/assets/cubemaps/helipad-env-atlas.png` },
+ { type: pc.TEXTURETYPE_RGBP, mipmaps: false }
+ ),
+ statue: new pc.Asset('statue', 'container', { url: `${rootPath}/static/assets/models/statue.glb` })
+};
+
const device = await pc.createGraphicsDevice(canvas, gfxOptions);
device.maxPixelRatio = Math.min(window.devicePixelRatio, 2);
const createOptions = new pc.AppOptions();
createOptions.graphicsDevice = device;
-createOptions.mouse = new pc.Mouse(document.body);
-createOptions.keyboard = new pc.Keyboard(window);
createOptions.componentSystems = [
pc.RenderComponentSystem,
@@ -24,7 +39,7 @@ createOptions.componentSystems = [
pc.LightComponentSystem,
pc.ScriptComponentSystem
];
-createOptions.resourceHandlers = [pc.ScriptHandler];
+createOptions.resourceHandlers = [pc.TextureHandler, pc.ContainerHandler, pc.ScriptHandler];
const app = new pc.AppBase(canvas);
app.init(createOptions);
@@ -40,105 +55,112 @@ app.on('destroy', () => {
window.removeEventListener('resize', resize);
});
-const assets = {
- script: new pc.Asset('script', 'script', { url: `${rootPath}/static/scripts/camera/fly-camera.js` })
-};
+await new Promise((resolve) => {
+ new pc.AssetListLoader(Object.values(assets), app.assets).load(resolve);
+});
/**
- * @param {pc.Asset[] | number[]} assetList - The asset list.
- * @param {pc.AssetRegistry} assetRegistry - The asset registry.
- * @returns {Promise} The promise.
+ * Calculate the bounding box of an entity.
+ *
+ * @param {pc.BoundingBox} bbox - The bounding box.
+ * @param {pc.Entity} entity - The entity.
+ * @returns {pc.BoundingBox} The bounding box.
*/
-function loadAssets(assetList, assetRegistry) {
- return new Promise((resolve) => {
- const assetListLoader = new pc.AssetListLoader(assetList, assetRegistry);
- assetListLoader.load(resolve);
+const calcEntityAABB = (bbox, entity) => {
+ bbox.center.set(0, 0, 0);
+ bbox.halfExtents.set(0, 0, 0);
+ entity.findComponents('render').forEach((render) => {
+ render.meshInstances.forEach((/** @type {pc.MeshInstance} */ mi) => {
+ bbox.add(mi.aabb);
+ });
});
-}
-await loadAssets(Object.values(assets), app.assets);
-app.scene.ambientLight = new pc.Color(0.2, 0.2, 0.2);
-app.start();
-
-// *********** Helper functions *******************
-/**
- * @param {pc.Color} color - The color.
- * @returns {pc.StandardMaterial} The material.
- */
-function createMaterial(color) {
- const material = new pc.StandardMaterial();
- material.diffuse = color;
- // we need to call material.update when we change its properties
- material.update();
- return material;
-}
+ return bbox;
+};
/**
- * @param {pc.Vec3} position - The position.
- * @param {pc.Vec3} size - The size.
- * @param {pc.Material} material - The material.
+ * @param {pc.Entity} focus - The entity to focus the camera on.
+ * @returns {CameraControls} The camera-controls script.
*/
-function createBox(position, size, material) {
- // create an entity and add a model component of type 'box'
- const box = new pc.Entity();
- box.addComponent('render', {
- type: 'box',
- material: material
+const createFlyCamera = (focus) => {
+ const camera = new pc.Entity();
+ camera.addComponent('camera');
+ camera.addComponent('script');
+ camera.setPosition(0, 20, 30);
+ app.root.addChild(camera);
+
+ const bbox = calcEntityAABB(new pc.BoundingBox(), focus);
+
+ /** @type {CameraControls} */
+ const script = camera.script.create(CameraControls, {
+ attributes: {
+ enableOrbit: false,
+ enablePan: false,
+ focusPoint: bbox.center,
+ sceneSize: bbox.halfExtents.length(),
+ pitchRange: new pc.Vec2(-90, 90)
+ }
});
- // move the box
- box.setLocalPosition(position);
- box.setLocalScale(size);
-
- // add the box to the hierarchy
- app.root.addChild(box);
-}
-
-// *********** Create Boxes *******************
+ return script;
+};
-// create a few boxes in our scene
-const red = createMaterial(pc.Color.RED);
-for (let i = 0; i < 3; i++) {
- for (let j = 0; j < 2; j++) {
- createBox(new pc.Vec3(i * 2, 0, j * 4), pc.Vec3.ONE, red);
- }
-}
+app.start();
-// create a floor
-const white = createMaterial(pc.Color.WHITE);
-createBox(new pc.Vec3(0, -0.5, 0), new pc.Vec3(10, 0.1, 10), white);
+app.scene.ambientLight.set(0.4, 0.4, 0.4);
-// *********** Create lights *******************
+app.scene.skyboxMip = 1;
+app.scene.skyboxIntensity = 0.4;
+app.scene.envAtlas = assets.helipad.resource;
-// make our scene prettier by adding a directional light
+// Create a directional light
const light = new pc.Entity();
-light.addComponent('light', {
- type: 'omni',
- color: new pc.Color(1, 1, 1),
- range: 100
-});
-light.setLocalPosition(0, 0, 2);
-
-// add the light to the hierarchy
+light.addComponent('light');
+light.setLocalEulerAngles(45, 30, 0);
app.root.addChild(light);
-// *********** Create camera *******************
-
-// Create an Entity with a camera component
-const camera = new pc.Entity();
-camera.addComponent('camera', {
- clearColor: new pc.Color(0.5, 0.5, 0.8),
- nearClip: 0.3,
- farClip: 30
-});
+const statue = assets.statue.resource.instantiateRenderEntity();
+statue.setLocalPosition(0, -0.5, 0);
+app.root.addChild(statue);
+
+const multiCameraScript = createFlyCamera(statue);
+
+data.set('attr', [
+ 'lookSensitivity',
+ 'lookDamping',
+ 'moveDamping',
+ 'pitchRange',
+ 'moveSpeed',
+ 'sprintSpeed',
+ 'crouchSpeed'
+].reduce((/** @type {Record} */ obj, key) => {
+ const value = multiCameraScript[key];
+
+ if (value instanceof pc.Vec2) {
+ obj[key] = [value.x, value.y];
+ return obj;
+ }
-// add the fly camera script to the camera
-camera.addComponent('script');
-camera.script.create('flyCamera');
+ obj[key] = multiCameraScript[key];
+ return obj;
+}, {}));
-// add the camera to the hierarchy
-app.root.addChild(camera);
+const tmpVa = new pc.Vec2();
+data.on('*:set', (/** @type {string} */ path, /** @type {any} */ value) => {
+ const [category, key, index] = path.split('.');
+ if (category !== 'attr') {
+ return;
+ }
-// Move the camera a little further away
-camera.translate(2, 0.8, 9);
+ if (Array.isArray(value)) {
+ multiCameraScript[key] = tmpVa.set(value[0], value[1]);
+ return;
+ }
+ if (index !== undefined) {
+ const arr = data.get(`${category}.${key}`);
+ multiCameraScript[key] = tmpVa.set(arr[0], arr[1]);
+ return;
+ }
+ multiCameraScript[key] = value;
+});
export { app };
diff --git a/examples/src/examples/camera/multi.controls.mjs b/examples/src/examples/camera/multi.controls.mjs
index eb302779c3d..d3f31415a6b 100644
--- a/examples/src/examples/camera/multi.controls.mjs
+++ b/examples/src/examples/camera/multi.controls.mjs
@@ -3,7 +3,7 @@
* @returns {JSX.Element} The returned JSX Element.
*/
export const controls = ({ observer, ReactPCUI, React, jsx, fragment }) => {
- const { BindingTwoWay, LabelGroup, Panel, BooleanInput, SliderInput } = ReactPCUI;
+ const { BindingTwoWay, LabelGroup, Panel, BooleanInput, SliderInput, VectorInput } = ReactPCUI;
return fragment(
jsx(
@@ -15,17 +15,43 @@ export const controls = ({ observer, ReactPCUI, React, jsx, fragment }) => {
link: { observer, path: 'example.zoomReset' }
})
),
+ jsx(
+ LabelGroup,
+ { text: 'Smoothed focus' },
+ jsx(BooleanInput, {
+ type: 'toggle',
+ binding: new BindingTwoWay(),
+ link: { observer, path: 'example.smoothedFocus' }
+ })
+ ),
jsx(
Panel,
{ headerText: 'Attributes' },
jsx(
LabelGroup,
- { text: 'Focus FOV' },
- jsx(SliderInput, {
+ { text: 'Enable Orbit' },
+ jsx(BooleanInput, {
+ type: 'toggle',
binding: new BindingTwoWay(),
- link: { observer, path: 'attr.focusFov' },
- min: 30,
- max: 120
+ link: { observer, path: 'attr.enableOrbit' }
+ })
+ ),
+ jsx(
+ LabelGroup,
+ { text: 'Enable Pan' },
+ jsx(BooleanInput, {
+ type: 'toggle',
+ binding: new BindingTwoWay(),
+ link: { observer, path: 'attr.enablePan' }
+ })
+ ),
+ jsx(
+ LabelGroup,
+ { text: 'Enable Fly' },
+ jsx(BooleanInput, {
+ type: 'toggle',
+ binding: new BindingTwoWay(),
+ link: { observer, path: 'attr.enableFly' }
})
),
jsx(
@@ -46,8 +72,9 @@ export const controls = ({ observer, ReactPCUI, React, jsx, fragment }) => {
binding: new BindingTwoWay(),
link: { observer, path: 'attr.lookDamping' },
min: 0,
- max: 0.99,
- step: 0.01
+ max: 0.999,
+ step: 0.001,
+ precision: 3
})
),
jsx(
@@ -57,18 +84,18 @@ export const controls = ({ observer, ReactPCUI, React, jsx, fragment }) => {
binding: new BindingTwoWay(),
link: { observer, path: 'attr.moveDamping' },
min: 0,
- max: 0.99,
- step: 0.01
+ max: 0.999,
+ step: 0.001,
+ precision: 3
})
),
jsx(
LabelGroup,
- { text: 'Pinch speed' },
- jsx(SliderInput, {
+ { text: 'Pitch range' },
+ jsx(VectorInput, {
binding: new BindingTwoWay(),
- link: { observer, path: 'attr.pinchSpeed' },
- min: 1,
- max: 10
+ link: { observer, path: 'attr.pitchRange' },
+ dimensions: 2
})
),
jsx(
@@ -77,9 +104,10 @@ export const controls = ({ observer, ReactPCUI, React, jsx, fragment }) => {
jsx(SliderInput, {
binding: new BindingTwoWay(),
link: { observer, path: 'attr.wheelSpeed' },
- min: 0.001,
- max: 0.01,
- step: 0.001
+ min: 0,
+ max: 10,
+ step: 0.001,
+ precision: 3
})
),
jsx(
@@ -88,9 +116,8 @@ export const controls = ({ observer, ReactPCUI, React, jsx, fragment }) => {
jsx(SliderInput, {
binding: new BindingTwoWay(),
link: { observer, path: 'attr.zoomMin' },
- min: 0.001,
- max: 0.01,
- step: 0.001
+ min: 0,
+ max: 10
})
),
jsx(
@@ -99,8 +126,8 @@ export const controls = ({ observer, ReactPCUI, React, jsx, fragment }) => {
jsx(SliderInput, {
binding: new BindingTwoWay(),
link: { observer, path: 'attr.zoomMax' },
- min: 1,
- max: 10
+ min: 0,
+ max: 20
})
),
jsx(
@@ -109,9 +136,10 @@ export const controls = ({ observer, ReactPCUI, React, jsx, fragment }) => {
jsx(SliderInput, {
binding: new BindingTwoWay(),
link: { observer, path: 'attr.zoomScaleMin' },
- min: 0.001,
- max: 0.01,
- step: 0.001
+ min: 0,
+ max: 1,
+ step: 0.001,
+ precision: 3
})
),
jsx(
diff --git a/examples/src/examples/camera/multi.example.mjs b/examples/src/examples/camera/multi.example.mjs
index 18123ffbef8..fad31097b55 100644
--- a/examples/src/examples/camera/multi.example.mjs
+++ b/examples/src/examples/camera/multi.example.mjs
@@ -1,10 +1,9 @@
-// @config DESCRIPTION (WASDQE) Move
(LMB) Orbit, (RMB) Fly
(Scroll Wheel) zoom
(MMB / Hold Shift) Pan
(F) Focus
-// @config HIDDEN
+// @config DESCRIPTION (WASDQE) Move (Fly enabled)
(LMB) Orbit, (LMB (Orbit disabled) / RMB) Fly
(Hold Shift / MMB / RMB (Fly or Orbit disabled)) Pan
(Scroll Wheel (Orbit or Pan enabled)) Zoom
(F) Focus
import { data } from 'examples/observer';
import { deviceType, rootPath, fileImport } from 'examples/utils';
import * as pc from 'playcanvas';
-const { MultiCamera } = await fileImport(`${rootPath}/static/scripts/camera/multi-camera.mjs`);
+const { CameraControls } = await fileImport(`${rootPath}/static/scripts/camera-controls.mjs`);
const canvas = document.getElementById('application-canvas');
if (!(canvas instanceof HTMLCanvasElement)) {
@@ -80,20 +79,22 @@ const calcEntityAABB = (bbox, entity) => {
/**
* @param {pc.Entity} focus - The entity to focus the camera on.
- * @returns {MultiCamera} The multi-camera script.
+ * @returns {CameraControls} The camera-controls script.
*/
const createMultiCamera = (focus) => {
const camera = new pc.Entity();
camera.addComponent('camera');
camera.addComponent('script');
+ camera.setPosition(0, 20, 30);
+ app.root.addChild(camera);
- const start = new pc.Vec3(0, 20, 30);
const bbox = calcEntityAABB(new pc.BoundingBox(), focus);
- const cameraDist = start.distance(bbox.center);
+ const cameraDist = camera.getPosition().distance(bbox.center);
- /** @type {MultiCamera} */
- const script = camera.script.create(MultiCamera, {
+ /** @type {CameraControls} */
+ const script = camera.script.create(CameraControls, {
attributes: {
+ focusPoint: bbox.center,
sceneSize: bbox.halfExtents.length()
}
});
@@ -101,10 +102,12 @@ const createMultiCamera = (focus) => {
// focus on entity when 'f' key is pressed
const onKeyDown = (/** @type {KeyboardEvent} */ e) => {
if (e.key === 'f') {
- if (data.get('example.zoomReset')) {
- script.resetZoom(cameraDist);
- }
- script.focus(bbox.center);
+ script.refocus(
+ bbox.center,
+ null,
+ data.get('example.zoomReset') ? cameraDist : null,
+ data.get('example.smoothedFocus')
+ );
}
};
window.addEventListener('keydown', onKeyDown);
@@ -112,13 +115,6 @@ const createMultiCamera = (focus) => {
window.removeEventListener('keydown', onKeyDown);
});
- // wait until after canvas resized to focus on entity
- const resize = new ResizeObserver(() => {
- resize.disconnect();
- script.focus(bbox.center, start);
- });
- resize.observe(canvas);
-
return script;
};
@@ -144,27 +140,54 @@ const multiCameraScript = createMultiCamera(statue);
// Bind controls to camera attributes
data.set('example', {
- zoomReset: true
-});
-data.set('attr', {
- focusFov: 75,
- lookSensitivity: 0.2,
- lookDamping: 0.97,
- moveDamping: 0.98,
- pinchSpeed: 5,
- wheelSpeed: 0.005,
- zoomMin: 0.001,
- zoomMax: 10,
- zoomScaleMin: 0.01,
- moveSpeed: 2,
- sprintSpeed: 4,
- crouchSpeed: 1
+ zoomReset: true,
+ smoothedFocus: true
});
+
+data.set('attr', [
+ 'enableOrbit',
+ 'enablePan',
+ 'enableFly',
+ 'lookSensitivity',
+ 'lookDamping',
+ 'moveDamping',
+ 'pitchRange',
+ 'pinchSpeed',
+ 'wheelSpeed',
+ 'zoomMin',
+ 'zoomMax',
+ 'zoomScaleMin',
+ 'moveSpeed',
+ 'sprintSpeed',
+ 'crouchSpeed'
+].reduce((/** @type {Record} */ obj, key) => {
+ const value = multiCameraScript[key];
+
+ if (value instanceof pc.Vec2) {
+ obj[key] = [value.x, value.y];
+ return obj;
+ }
+
+ obj[key] = multiCameraScript[key];
+ return obj;
+}, {}));
+
+const tmpVa = new pc.Vec2();
data.on('*:set', (/** @type {string} */ path, /** @type {any} */ value) => {
- const [category, key] = path.split('.');
+ const [category, key, index] = path.split('.');
if (category !== 'attr') {
return;
}
+
+ if (Array.isArray(value)) {
+ multiCameraScript[key] = tmpVa.set(value[0], value[1]);
+ return;
+ }
+ if (index !== undefined) {
+ const arr = data.get(`${category}.${key}`);
+ multiCameraScript[key] = tmpVa.set(arr[0], arr[1]);
+ return;
+ }
multiCameraScript[key] = value;
});
diff --git a/examples/src/examples/camera/orbit.controls.mjs b/examples/src/examples/camera/orbit.controls.mjs
new file mode 100644
index 00000000000..8a278d249e1
--- /dev/null
+++ b/examples/src/examples/camera/orbit.controls.mjs
@@ -0,0 +1,129 @@
+/**
+ * @param {import('../../app/components/Example.mjs').ControlOptions} options - The options.
+ * @returns {JSX.Element} The returned JSX Element.
+ */
+export const controls = ({ observer, ReactPCUI, React, jsx, fragment }) => {
+ const { BindingTwoWay, LabelGroup, Panel, BooleanInput, SliderInput, VectorInput } = ReactPCUI;
+
+ return fragment(
+ jsx(
+ LabelGroup,
+ { text: 'Zoom reset' },
+ jsx(BooleanInput, {
+ type: 'toggle',
+ binding: new BindingTwoWay(),
+ link: { observer, path: 'example.zoomReset' }
+ })
+ ),
+ jsx(
+ LabelGroup,
+ { text: 'Smoothed focus' },
+ jsx(BooleanInput, {
+ type: 'toggle',
+ binding: new BindingTwoWay(),
+ link: { observer, path: 'example.smoothedFocus' }
+ })
+ ),
+ jsx(
+ Panel,
+ { headerText: 'Attributes' },
+ jsx(
+ LabelGroup,
+ { text: 'Enable Pan' },
+ jsx(BooleanInput, {
+ type: 'toggle',
+ binding: new BindingTwoWay(),
+ link: { observer, path: 'attr.enablePan' }
+ })
+ ),
+ jsx(
+ LabelGroup,
+ { text: 'Look sensitivity' },
+ jsx(SliderInput, {
+ binding: new BindingTwoWay(),
+ link: { observer, path: 'attr.lookSensitivity' },
+ min: 0.1,
+ max: 1,
+ step: 0.01
+ })
+ ),
+ jsx(
+ LabelGroup,
+ { text: 'Look damping' },
+ jsx(SliderInput, {
+ binding: new BindingTwoWay(),
+ link: { observer, path: 'attr.lookDamping' },
+ min: 0,
+ max: 0.999,
+ step: 0.001,
+ precision: 3
+ })
+ ),
+ jsx(
+ LabelGroup,
+ { text: 'Move damping' },
+ jsx(SliderInput, {
+ binding: new BindingTwoWay(),
+ link: { observer, path: 'attr.moveDamping' },
+ min: 0,
+ max: 0.999,
+ step: 0.001,
+ precision: 3
+ })
+ ),
+ jsx(
+ LabelGroup,
+ { text: 'Pitch range' },
+ jsx(VectorInput, {
+ binding: new BindingTwoWay(),
+ link: { observer, path: 'attr.pitchRange' },
+ dimensions: 2
+ })
+ ),
+ jsx(
+ LabelGroup,
+ { text: 'Wheel speed' },
+ jsx(SliderInput, {
+ binding: new BindingTwoWay(),
+ link: { observer, path: 'attr.wheelSpeed' },
+ min: 0,
+ max: 10,
+ step: 0.001,
+ precision: 3
+ })
+ ),
+ jsx(
+ LabelGroup,
+ { text: 'Zoom min' },
+ jsx(SliderInput, {
+ binding: new BindingTwoWay(),
+ link: { observer, path: 'attr.zoomMin' },
+ min: 0,
+ max: 10
+ })
+ ),
+ jsx(
+ LabelGroup,
+ { text: 'Zoom max' },
+ jsx(SliderInput, {
+ binding: new BindingTwoWay(),
+ link: { observer, path: 'attr.zoomMax' },
+ min: 0,
+ max: 20
+ })
+ ),
+ jsx(
+ LabelGroup,
+ { text: 'Zoom scale min' },
+ jsx(SliderInput, {
+ binding: new BindingTwoWay(),
+ link: { observer, path: 'attr.zoomScaleMin' },
+ min: 0,
+ max: 1,
+ step: 0.001,
+ precision: 3
+ })
+ )
+ )
+ );
+};
diff --git a/examples/src/examples/camera/orbit.example.mjs b/examples/src/examples/camera/orbit.example.mjs
index e09c98916a3..869caf47438 100644
--- a/examples/src/examples/camera/orbit.example.mjs
+++ b/examples/src/examples/camera/orbit.example.mjs
@@ -1,7 +1,14 @@
-import { deviceType, rootPath } from 'examples/utils';
+// @config DESCRIPTION (LMB) Orbit
(Hold Shift / MMB / RMB ) Pan
(Scroll Wheel) Zoom
(F) Focus
+import { data } from 'examples/observer';
+import { deviceType, rootPath, fileImport } from 'examples/utils';
import * as pc from 'playcanvas';
-const canvas = /** @type {HTMLCanvasElement} */ (document.getElementById('application-canvas'));
+const { CameraControls } = await fileImport(`${rootPath}/static/scripts/camera-controls.mjs`);
+
+const canvas = document.getElementById('application-canvas');
+if (!(canvas instanceof HTMLCanvasElement)) {
+ throw new Error('No canvas found');
+}
window.focus();
const gfxOptions = {
@@ -10,13 +17,21 @@ const gfxOptions = {
twgslUrl: `${rootPath}/static/lib/twgsl/twgsl.js`
};
+const assets = {
+ helipad: new pc.Asset(
+ 'helipad-env-atlas',
+ 'texture',
+ { url: `${rootPath}/static/assets/cubemaps/helipad-env-atlas.png` },
+ { type: pc.TEXTURETYPE_RGBP, mipmaps: false }
+ ),
+ statue: new pc.Asset('statue', 'container', { url: `${rootPath}/static/assets/models/statue.glb` })
+};
+
const device = await pc.createGraphicsDevice(canvas, gfxOptions);
device.maxPixelRatio = Math.min(window.devicePixelRatio, 2);
const createOptions = new pc.AppOptions();
createOptions.graphicsDevice = device;
-createOptions.mouse = new pc.Mouse(document.body);
-createOptions.touch = new pc.TouchDevice(document.body);
createOptions.componentSystems = [
pc.RenderComponentSystem,
@@ -29,11 +44,6 @@ createOptions.resourceHandlers = [pc.TextureHandler, pc.ContainerHandler, pc.Scr
const app = new pc.AppBase(canvas);
app.init(createOptions);
-const assets = {
- statue: new pc.Asset('statue', 'container', { url: `${rootPath}/static/assets/models/statue.glb` }),
- script: new pc.Asset('script', 'script', { url: `${rootPath}/static/scripts/camera/orbit-camera.js` })
-};
-
// Set the canvas to fill the window and automatically change resolution to be the same as the canvas size
app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW);
app.setCanvasResolution(pc.RESOLUTION_AUTO);
@@ -45,47 +55,136 @@ app.on('destroy', () => {
window.removeEventListener('resize', resize);
});
+await new Promise((resolve) => {
+ new pc.AssetListLoader(Object.values(assets), app.assets).load(resolve);
+});
+
/**
- * @param {pc.Asset[] | number[]} assetList - The asset list.
- * @param {pc.AssetRegistry} assetRegistry - The asset registry.
- * @returns {Promise} The promise.
+ * Calculate the bounding box of an entity.
+ *
+ * @param {pc.BoundingBox} bbox - The bounding box.
+ * @param {pc.Entity} entity - The entity.
+ * @returns {pc.BoundingBox} The bounding box.
*/
-function loadAssets(assetList, assetRegistry) {
- return new Promise((resolve) => {
- const assetListLoader = new pc.AssetListLoader(assetList, assetRegistry);
- assetListLoader.load(resolve);
+const calcEntityAABB = (bbox, entity) => {
+ bbox.center.set(0, 0, 0);
+ bbox.halfExtents.set(0, 0, 0);
+ entity.findComponents('render').forEach((render) => {
+ render.meshInstances.forEach((/** @type {pc.MeshInstance} */ mi) => {
+ bbox.add(mi.aabb);
+ });
+ });
+ return bbox;
+};
+
+/**
+ * @param {pc.Entity} focus - The entity to focus the camera on.
+ * @returns {CameraControls} The camera-controls script.
+ */
+const createOrbitCamera = (focus) => {
+ const camera = new pc.Entity();
+ camera.addComponent('camera');
+ camera.addComponent('script');
+ camera.setPosition(0, 20, 30);
+ app.root.addChild(camera);
+
+ const bbox = calcEntityAABB(new pc.BoundingBox(), focus);
+ const cameraDist = camera.getPosition().distance(bbox.center);
+
+ /** @type {CameraControls} */
+ const script = camera.script.create(CameraControls, {
+ attributes: {
+ enableFly: false,
+ focusPoint: bbox.center,
+ sceneSize: bbox.halfExtents.length()
+ }
+ });
+
+ // focus on entity when 'f' key is pressed
+ const onKeyDown = (/** @type {KeyboardEvent} */ e) => {
+ if (e.key === 'f') {
+ script.refocus(
+ bbox.center,
+ null,
+ data.get('example.zoomReset') ? cameraDist : null,
+ data.get('example.smoothedFocus')
+ );
+ }
+ };
+ window.addEventListener('keydown', onKeyDown);
+ app.on('destroy', () => {
+ window.removeEventListener('keydown', onKeyDown);
});
-}
-await loadAssets(Object.values(assets), app.assets);
-// Create an entity hierarchy representing the statue
-const statueEntity = assets.statue.resource.instantiateRenderEntity();
-statueEntity.setLocalScale(0.07, 0.07, 0.07);
-statueEntity.setLocalPosition(0, -0.5, 0);
-app.root.addChild(statueEntity);
-
-// Create a camera with an orbit camera script
-const camera = new pc.Entity();
-camera.addComponent('camera', {
- clearColor: new pc.Color(0.4, 0.45, 0.5)
-});
-camera.addComponent('script');
-camera.script.create('orbitCamera', {
- attributes: {
- inertiaFactor: 0.2 // Override default of 0 (no inertia)
- }
-});
-camera.script.create('orbitCameraInputMouse');
-camera.script.create('orbitCameraInputTouch');
-app.root.addChild(camera);
+
+ return script;
+};
+
+app.start();
+
+app.scene.ambientLight.set(0.4, 0.4, 0.4);
+
+app.scene.skyboxMip = 1;
+app.scene.skyboxIntensity = 0.4;
+app.scene.envAtlas = assets.helipad.resource;
// Create a directional light
const light = new pc.Entity();
-light.addComponent('light', {
- type: 'directional'
-});
-app.root.addChild(light);
+light.addComponent('light');
light.setLocalEulerAngles(45, 30, 0);
+app.root.addChild(light);
-app.start();
+const statue = assets.statue.resource.instantiateRenderEntity();
+statue.setLocalPosition(0, -0.5, 0);
+app.root.addChild(statue);
+
+const multiCameraScript = createOrbitCamera(statue);
+
+// Bind controls to camera attributes
+data.set('example', {
+ zoomReset: true,
+ smoothedFocus: true
+});
+
+data.set('attr', [
+ 'enablePan',
+ 'lookSensitivity',
+ 'lookDamping',
+ 'moveDamping',
+ 'pitchRange',
+ 'pinchSpeed',
+ 'wheelSpeed',
+ 'zoomMin',
+ 'zoomMax',
+ 'zoomScaleMin'
+].reduce((/** @type {Record} */ obj, key) => {
+ const value = multiCameraScript[key];
+
+ if (value instanceof pc.Vec2) {
+ obj[key] = [value.x, value.y];
+ return obj;
+ }
+
+ obj[key] = multiCameraScript[key];
+ return obj;
+}, {}));
+
+const tmpVa = new pc.Vec2();
+data.on('*:set', (/** @type {string} */ path, /** @type {any} */ value) => {
+ const [category, key, index] = path.split('.');
+ if (category !== 'attr') {
+ return;
+ }
+
+ if (Array.isArray(value)) {
+ multiCameraScript[key] = tmpVa.set(value[0], value[1]);
+ return;
+ }
+ if (index !== undefined) {
+ const arr = data.get(`${category}.${key}`);
+ multiCameraScript[key] = tmpVa.set(arr[0], arr[1]);
+ return;
+ }
+ multiCameraScript[key] = value;
+});
export { app };
diff --git a/examples/src/static/styles.css b/examples/src/static/styles.css
index 594025ea066..2304318ef5d 100644
--- a/examples/src/static/styles.css
+++ b/examples/src/static/styles.css
@@ -337,7 +337,7 @@ body {
position: absolute;
right: 8px;
top: 8px;
- width: 250px;
+ width: 280px;
z-index: 9999;
background-color: rgba(54, 67, 70, 0.64);
backdrop-filter: blur(32px);
diff --git a/examples/thumbnails/camera_fly_large.webp b/examples/thumbnails/camera_fly_large.webp
index 6de72e9cef2..bf473ebfc05 100644
Binary files a/examples/thumbnails/camera_fly_large.webp and b/examples/thumbnails/camera_fly_large.webp differ
diff --git a/examples/thumbnails/camera_fly_small.webp b/examples/thumbnails/camera_fly_small.webp
index e9f1cc1a291..7c6d2c51a2e 100644
Binary files a/examples/thumbnails/camera_fly_small.webp and b/examples/thumbnails/camera_fly_small.webp differ
diff --git a/examples/thumbnails/camera_orbit_large.webp b/examples/thumbnails/camera_orbit_large.webp
index cff9a30be9f..bf473ebfc05 100644
Binary files a/examples/thumbnails/camera_orbit_large.webp and b/examples/thumbnails/camera_orbit_large.webp differ
diff --git a/examples/thumbnails/camera_orbit_small.webp b/examples/thumbnails/camera_orbit_small.webp
index 1f4781bf611..7c6d2c51a2e 100644
Binary files a/examples/thumbnails/camera_orbit_small.webp and b/examples/thumbnails/camera_orbit_small.webp differ
diff --git a/scripts/camera-controls.mjs b/scripts/camera-controls.mjs
new file mode 100644
index 00000000000..42aa17194e9
--- /dev/null
+++ b/scripts/camera-controls.mjs
@@ -0,0 +1,965 @@
+import { Vec2, Vec3, Ray, Plane, Entity, Script, math } from 'playcanvas';
+
+/** @import { CameraComponent } from 'playcanvas' */
+
+const tmpVa = new Vec2();
+const tmpV1 = new Vec3();
+const tmpV2 = new Vec3();
+const tmpR1 = new Ray();
+const tmpP1 = new Plane();
+
+const PASSIVE = { passive: false };
+const ZOOM_SCALE_SCENE_MULT = 10;
+
+/**
+ * Calculate the lerp rate.
+ *
+ * @param {number} damping - The damping.
+ * @param {number} dt - The delta time.
+ * @returns {number} - The lerp rate.
+ */
+const lerpRate = (damping, dt) => 1 - Math.pow(damping, dt * 1000);
+
+class CameraControls extends Script {
+ /**
+ * @private
+ * @type {CameraComponent}
+ */
+ _camera = null;
+
+ /**
+ * @private
+ * @type {Vec3}
+ */
+ _origin = new Vec3();
+
+ /**
+ * @private
+ * @type {Vec3}
+ */
+ _position = new Vec3();
+
+ /**
+ * @private
+ * @type {Vec2}
+ */
+ _dir = new Vec2();
+
+ /**
+ * @private
+ * @type {Vec3}
+ */
+ _angles = new Vec3();
+
+ /**
+ * @private
+ * @type {Vec2}
+ */
+ _pitchRange = new Vec2(-360, 360);
+
+ /**
+ * @private
+ * @type {number}
+ */
+ _zoomMin = 0;
+
+ /**
+ * @private
+ * @type {number}
+ */
+ _zoomMax = 0;
+
+ /**
+ * @type {number}
+ * @private
+ */
+ _zoomDist = 0;
+
+ /**
+ * @type {number}
+ * @private
+ */
+ _cameraDist = 0;
+
+ /**
+ * @type {Map}
+ * @private
+ */
+ _pointerEvents = new Map();
+
+ /**
+ * @type {number}
+ * @private
+ */
+ _lastPinchDist = -1;
+
+ /**
+ * @type {Vec2}
+ * @private
+ */
+ _lastPosition = new Vec2();
+
+ /**
+ * @type {boolean}
+ * @private
+ */
+ _orbiting = false;
+
+ /**
+ * @type {boolean}
+ * @private
+ */
+ _panning = false;
+
+ /**
+ * @type {boolean}
+ * @private
+ */
+ _flying = false;
+
+ /**
+ * @type {Record}
+ * @private
+ */
+ _key = {
+ forward: false,
+ backward: false,
+ left: false,
+ right: false,
+ up: false,
+ down: false,
+ sprint: false,
+ crouch: false
+ };
+
+ /**
+ * @type {HTMLElement}
+ */
+ _element;
+
+ /**
+ * @type {Entity}
+ */
+ root;
+
+ /**
+ * The scene size. The zoom, pan and fly speeds are relative to this size.
+ *
+ * @attribute
+ * @type {number}
+ */
+ sceneSize = 100;
+
+ /**
+ * The look sensitivity.
+ *
+ * @attribute
+ * @type {number}
+ */
+ lookSensitivity = 0.2;
+
+ /**
+ * The look damping. A higher value means less damping. A value of 1 means no damping.
+ *
+ * @attribute
+ * @type {number}
+ */
+ lookDamping = 0.97;
+
+ /**
+ * The move damping. A higher value means less damping. A value of 1 means no damping.
+ *
+ * @attribute
+ * @type {number}
+ */
+ moveDamping = 0.98;
+
+ /**
+ * Enable orbit camera controls.
+ *
+ * @attribute
+ * @type {boolean}
+ */
+ enableOrbit = true;
+
+ /**
+ * Enable pan camera controls.
+ *
+ * @attribute
+ * @type {boolean
+ */
+ enablePan = true;
+
+ /**
+ * Enable fly camera controls.
+ *
+ * @attribute
+ * @type {boolean}
+ */
+ enableFly = true;
+
+ /**
+ * The touch pinch speed.
+ *
+ * @attribute
+ * @type {number}
+ */
+ pinchSpeed = 5;
+
+ /**
+ * The mouse wheel speed.
+ *
+ * @attribute
+ * @type {number}
+ */
+ wheelSpeed = 0.005;
+
+ /**
+ * The minimum scale the camera can zoom (absolute value).
+ *
+ * @attribute
+ * @type {number}
+ */
+ zoomScaleMin = 0;
+
+ /**
+ * The fly move speed relative to the scene size.
+ *
+ * @attribute
+ * @type {number}
+ */
+ moveSpeed = 2;
+
+ /**
+ * The fly sprint speed relative to the scene size.
+ *
+ * @attribute
+ * @type {number}
+ */
+ sprintSpeed = 4;
+
+ /**
+ * The fly crouch speed relative to the scene size.
+ *
+ * @attribute
+ * @type {number}
+ */
+ crouchSpeed = 1;
+
+ /**
+ * @param {object} args - The script arguments.
+ */
+ constructor(args) {
+ super(args);
+ const {
+ name,
+ element,
+ enableOrbit,
+ enablePan,
+ enableFly,
+ focusPoint,
+ sceneSize,
+ lookSensitivity,
+ lookDamping,
+ moveDamping,
+ pitchRange,
+ pinchSpeed,
+ wheelSpeed,
+ zoomMin,
+ zoomMax,
+ moveSpeed,
+ sprintSpeed,
+ crouchSpeed
+ } = args.attributes;
+
+ this.root = new Entity(name ?? 'camera-controls');
+ this.app.root.addChild(this.root);
+
+ this._element = element ?? this.app.graphicsDevice.canvas;
+
+ this.enableOrbit = enableOrbit ?? this.enableOrbit;
+ this.enablePan = enablePan ?? this.enablePan;
+ this.enableFly = enableFly ?? this.enableFly;
+ this.sceneSize = sceneSize ?? this.sceneSize;
+ this.lookSensitivity = lookSensitivity ?? this.lookSensitivity;
+ this.lookDamping = lookDamping ?? this.lookDamping;
+ this.moveDamping = moveDamping ?? this.moveDamping;
+ this.pinchSpeed = pinchSpeed ?? this.pinchSpeed;
+ this.wheelSpeed = wheelSpeed ?? this.wheelSpeed;
+
+ this.moveSpeed = moveSpeed ?? this.moveSpeed;
+ this.sprintSpeed = sprintSpeed ?? this.sprintSpeed;
+ this.crouchSpeed = crouchSpeed ?? this.crouchSpeed;
+
+ this._onWheel = this._onWheel.bind(this);
+ this._onKeyDown = this._onKeyDown.bind(this);
+ this._onKeyUp = this._onKeyUp.bind(this);
+ this._onPointerDown = this._onPointerDown.bind(this);
+ this._onPointerMove = this._onPointerMove.bind(this);
+ this._onPointerUp = this._onPointerUp.bind(this);
+ this._onContextMenu = this._onContextMenu.bind(this);
+
+ if (!this.entity.camera) {
+ throw new Error('CameraControls script requires a camera component');
+ }
+ this.attach(this.entity.camera);
+
+ this.focusPoint = focusPoint ?? this._origin;
+ this.pitchRange = pitchRange ?? this.pitchRange;
+ this.zoomMin = zoomMin ?? this.zoomMin;
+ this.zoomMax = zoomMax ?? this.zoomMax;
+ }
+
+ /**
+ * The element to attach the camera controls to.
+ *
+ * @type {HTMLElement}
+ */
+ set element(value) {
+ this._element = value;
+
+ const camera = this._camera;
+ this.detach();
+ this.attach(camera);
+ }
+
+ get element() {
+ return this._element;
+ }
+
+ /**
+ * The camera's focus point.
+ *
+ * @param {Vec3} point - The focus point.
+ */
+ set focusPoint(point) {
+ if (!this._camera) {
+ return;
+ }
+ this.focus(point, this._camera.entity.getPosition(), false);
+ }
+
+ get focusPoint() {
+ if (this._flying) {
+ return tmpV1.copy(this.root.forward).mulScalar(this._zoomDist).add(this._origin);
+ }
+ return this._origin;
+ }
+
+ /**
+ * The camera's pitch range. Having a value of -360 means no minimum pitch and 360 means no
+ * maximum pitch.
+ *
+ * @attribute
+ * @type {Vec2}
+ */
+ set pitchRange(value) {
+ this._pitchRange.copy(value);
+ this._dir.x = this._clampPitch(this._dir.x);
+ this._smoothLook(-1);
+ }
+
+ get pitchRange() {
+ return this._pitchRange;
+ }
+
+ /**
+ * The minimum zoom distance relative to the scene size.
+ *
+ * @attribute
+ * @type {number}
+ */
+ set zoomMin(value) {
+ this._zoomMin = value;
+ this._zoomDist = this._clampZoom(this._zoomDist);
+ this._smoothZoom(-1);
+ }
+
+ get zoomMin() {
+ return this._zoomMin;
+ }
+
+ /**
+ * The maximum zoom distance relative to the scene size. Having a value less than or equal to
+ * zoomMin means no maximum zoom.
+ *
+ * @attribute
+ * @type {number}
+ */
+ set zoomMax(value) {
+ this._zoomMax = value;
+ this._zoomDist = this._clampZoom(this._zoomDist);
+ this._smoothZoom(-1);
+
+ }
+
+ get zoomMax() {
+ return this._zoomMax;
+ }
+
+ /**
+ * @private
+ * @param {number} value - The value to clamp.
+ * @returns {number} - The clamped value.
+ */
+ _clampPitch(value) {
+ const min = this._pitchRange.x === -360 ? -Infinity : this._pitchRange.x;
+ const max = this._pitchRange.y === 360 ? Infinity : this._pitchRange.y;
+ return math.clamp(value, min, max);
+ }
+
+ /**
+ * @private
+ * @param {number} value - The value to clamp.
+ * @returns {number} - The clamped value.
+ */
+ _clampZoom(value) {
+ const min = (this._camera?.nearClip ?? 0) + this.zoomMin * this.sceneSize;
+ const max = this.zoomMax <= this.zoomMin ? Infinity : this.zoomMax * this.sceneSize;
+ return math.clamp(value, min, max);
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} event - The mouse event.
+ */
+ _onContextMenu(event) {
+ event.preventDefault();
+ }
+
+ /**
+ * @private
+ * @param {PointerEvent} event - The pointer event.
+ * @returns {boolean} Whether the mouse pan should start.
+ */
+ _isStartMousePan(event) {
+ if (!this.enablePan) {
+ return false;
+ }
+ if (event.shiftKey) {
+ return true;
+ }
+ if (!this.enableOrbit && !this.enableFly) {
+ return event.button === 0 || event.button === 1 || event.button === 2;
+ }
+ if (!this.enableOrbit || !this.enableFly) {
+ return event.button === 1 || event.button === 2;
+ }
+ return event.button === 1;
+ }
+
+ /**
+ * @private
+ * @param {PointerEvent} event - The pointer event.
+ * @returns {boolean} Whether the fly should start.
+ */
+ _isStartFly(event) {
+ if (!this.enableFly) {
+ return false;
+ }
+ if (!this.enableOrbit && !this.enablePan) {
+ return event.button === 0 || event.button === 1 || event.button === 2;
+ }
+ if (!this.enableOrbit) {
+ return event.button === 0;
+ }
+ return event.button === 2;
+ }
+
+ /**
+ * @param {PointerEvent} event - The pointer event.
+ * @returns {boolean} Whether the orbit should start.
+ * @private
+ */
+ _isStartOrbit(event) {
+ if (!this.enableOrbit) {
+ return false;
+ }
+ if (!this.enableFly && !this.enablePan) {
+ return event.button === 0 || event.button === 1 || event.button === 2;
+ }
+ return event.button === 0;
+ }
+
+ /**
+ * @private
+ * @param {PointerEvent} event - The pointer event.
+ */
+ _onPointerDown(event) {
+ if (!this._camera) {
+ return;
+ }
+ this._element.setPointerCapture(event.pointerId);
+ this._pointerEvents.set(event.pointerId, event);
+
+ const startTouchPan = this.enablePan && this._pointerEvents.size === 2;
+ const startMousePan = this._isStartMousePan(event);
+ const startFly = this._isStartFly(event);
+ const startOrbit = this._isStartOrbit(event);
+
+ if (startTouchPan) {
+ // start touch pan
+ this._lastPinchDist = this._getPinchDist();
+ this._getMidPoint(this._lastPosition);
+ this._panning = true;
+ }
+ if (startMousePan) {
+ // start mouse pan
+ this._lastPosition.set(event.clientX, event.clientY);
+ this._panning = true;
+ }
+ if (startFly) {
+ // start fly
+ this._zoomDist = this._cameraDist;
+ this._origin.copy(this._camera.entity.getPosition());
+ this._position.copy(this._origin);
+ this._camera.entity.setLocalPosition(0, 0, 0);
+ this._flying = true;
+ }
+ if (startOrbit) {
+ // start orbit
+ this._orbiting = true;
+ }
+ }
+
+ /**
+ * @private
+ * @param {PointerEvent} event - The pointer event.
+ */
+ _onPointerMove(event) {
+ if (this._pointerEvents.size === 0) {
+ return;
+ }
+ this._pointerEvents.set(event.pointerId, event);
+
+ if (this._pointerEvents.size === 1) {
+ if (this._panning) {
+ // mouse pan
+ this._pan(tmpVa.set(event.clientX, event.clientY));
+ } else if (this._orbiting || this._flying) {
+ this._look(event);
+ }
+ return;
+ }
+
+ if (this._pointerEvents.size === 2) {
+ // touch pan
+ if (this._panning) {
+ this._pan(this._getMidPoint(tmpVa));
+ }
+
+ // pinch zoom
+ const pinchDist = this._getPinchDist();
+ if (this._lastPinchDist > 0) {
+ this._zoom((this._lastPinchDist - pinchDist) * this.pinchSpeed);
+ }
+ this._lastPinchDist = pinchDist;
+ }
+
+ }
+
+ /**
+ * @private
+ * @param {PointerEvent} event - The pointer event.
+ */
+ _onPointerUp(event) {
+ this._element.releasePointerCapture(event.pointerId);
+ this._pointerEvents.delete(event.pointerId);
+ if (this._pointerEvents.size < 2) {
+ this._lastPinchDist = -1;
+ this._panning = false;
+ }
+ if (this._orbiting) {
+ this._orbiting = false;
+ }
+ if (this._panning) {
+ this._panning = false;
+ }
+ if (this._flying) {
+ tmpV1.copy(this.root.forward).mulScalar(this._zoomDist);
+ this._origin.add(tmpV1);
+ this._position.add(tmpV1);
+ this._flying = false;
+ }
+ }
+
+ /**
+ * @private
+ * @param {WheelEvent} event - The wheel event.
+ */
+ _onWheel(event) {
+ event.preventDefault();
+ this._zoom(event.deltaY);
+ }
+
+ /**
+ * @private
+ * @param {KeyboardEvent} event - The keyboard event.
+ */
+ _onKeyDown(event) {
+ event.stopPropagation();
+ switch (event.key.toLowerCase()) {
+ case 'w':
+ this._key.forward = true;
+ break;
+ case 's':
+ this._key.backward = true;
+ break;
+ case 'a':
+ this._key.left = true;
+ break;
+ case 'd':
+ this._key.right = true;
+ break;
+ case 'q':
+ this._key.up = true;
+ break;
+ case 'e':
+ this._key.down = true;
+ break;
+ case 'shift':
+ this._key.sprint = true;
+ break;
+ case 'control':
+ this._key.crouch = true;
+ break;
+ }
+ }
+
+ /**
+ * @private
+ * @param {KeyboardEvent} event - The keyboard event.
+ */
+ _onKeyUp(event) {
+ event.stopPropagation();
+ switch (event.key.toLowerCase()) {
+ case 'w':
+ this._key.forward = false;
+ break;
+ case 's':
+ this._key.backward = false;
+ break;
+ case 'a':
+ this._key.left = false;
+ break;
+ case 'd':
+ this._key.right = false;
+ break;
+ case 'q':
+ this._key.up = false;
+ break;
+ case 'e':
+ this._key.down = false;
+ break;
+ case 'shift':
+ this._key.sprint = false;
+ break;
+ case 'control':
+ this._key.crouch = false;
+ break;
+ }
+ }
+
+ /**
+ * @private
+ * @param {PointerEvent} event - The pointer event.
+ */
+ _look(event) {
+ if (event.target !== this.app.graphicsDevice.canvas) {
+ return;
+ }
+ const movementX = event.movementX || 0;
+ const movementY = event.movementY || 0;
+ this._dir.x = this._clampPitch(this._dir.x - movementY * this.lookSensitivity);
+ this._dir.y -= movementX * this.lookSensitivity;
+ }
+
+ /**
+ * @param {number} dt - The delta time.
+ */
+ _move(dt) {
+ if (!this.enableFly) {
+ return;
+ }
+
+ tmpV1.set(0, 0, 0);
+ if (this._key.forward) {
+ tmpV1.add(this.root.forward);
+ }
+ if (this._key.backward) {
+ tmpV1.sub(this.root.forward);
+ }
+ if (this._key.left) {
+ tmpV1.sub(this.root.right);
+ }
+ if (this._key.right) {
+ tmpV1.add(this.root.right);
+ }
+ if (this._key.up) {
+ tmpV1.add(this.root.up);
+ }
+ if (this._key.down) {
+ tmpV1.sub(this.root.up);
+ }
+ tmpV1.normalize();
+ const speed = this._key.crouch ? this.crouchSpeed : this._key.sprint ? this.sprintSpeed : this.moveSpeed;
+ tmpV1.mulScalar(this.sceneSize * speed * dt);
+ this._origin.add(tmpV1);
+ }
+
+ /**
+ * @private
+ * @param {Vec2} out - The output vector.
+ * @returns {Vec2} The mid point.
+ */
+ _getMidPoint(out) {
+ const [a, b] = this._pointerEvents.values();
+ const dx = a.clientX - b.clientX;
+ const dy = a.clientY - b.clientY;
+ return out.set(b.clientX + dx * 0.5, b.clientY + dy * 0.5);
+ }
+
+ /**
+ * @private
+ * @returns {number} The pinch distance.
+ */
+ _getPinchDist() {
+ const [a, b] = this._pointerEvents.values();
+ const dx = a.clientX - b.clientX;
+ const dy = a.clientY - b.clientY;
+ return Math.sqrt(dx * dx + dy * dy);
+ }
+
+ /**
+ * @private
+ * @param {Vec2} pos - The screen position.
+ * @param {Vec3} point - The output point.
+ */
+ _screenToWorldPan(pos, point) {
+ const mouseW = this._camera.screenToWorld(pos.x, pos.y, 1);
+ const cameraPos = this._camera.entity.getPosition();
+
+ const focusDirScaled = tmpV1.copy(this.root.forward).mulScalar(this._zoomDist);
+ const focalPos = tmpV2.add2(cameraPos, focusDirScaled);
+ const planeNormal = focusDirScaled.mulScalar(-1).normalize();
+
+ const plane = tmpP1.setFromPointNormal(focalPos, planeNormal);
+ const ray = tmpR1.set(cameraPos, mouseW.sub(cameraPos).normalize());
+
+ plane.intersectsRay(ray, point);
+ }
+
+ /**
+ * @private
+ * @param {Vec2} pos - The screen position.
+ */
+ _pan(pos) {
+ if (!this.enablePan) {
+ return;
+ }
+
+ const start = new Vec3();
+ const end = new Vec3();
+
+ this._screenToWorldPan(this._lastPosition, start);
+ this._screenToWorldPan(pos, end);
+
+ tmpV1.sub2(start, end);
+ this._origin.add(tmpV1);
+
+ this._lastPosition.copy(pos);
+ }
+
+ /**
+ * @private
+ * @param {number} delta - The delta.
+ */
+ _zoom(delta) {
+ if (!this.enableOrbit && !this.enablePan) {
+ return;
+ }
+
+ if (!this._camera) {
+ return;
+ }
+ const distNormalized = this._zoomDist / (ZOOM_SCALE_SCENE_MULT * this.sceneSize);
+ const scale = math.clamp(distNormalized, this.zoomScaleMin, 1);
+ this._zoomDist += (delta * this.wheelSpeed * this.sceneSize * scale);
+ this._zoomDist = this._clampZoom(this._zoomDist);
+ }
+
+ /**
+ * @private
+ * @param {number} dt - The delta time.
+ */
+ _smoothZoom(dt) {
+ const a = dt === -1 ? 1 : lerpRate(this.moveDamping, dt);
+ this._cameraDist = math.lerp(this._cameraDist, this._zoomDist, a);
+ this._camera.entity.setLocalPosition(0, 0, this._cameraDist);
+ }
+
+ /**
+ * @private
+ * @param {number} dt - The delta time.
+ */
+ _smoothLook(dt) {
+ const a = dt === -1 ? 1 : lerpRate(this.lookDamping, dt);
+ this._angles.x = math.lerp(this._angles.x, this._dir.x, a);
+ this._angles.y = math.lerp(this._angles.y, this._dir.y, a);
+ this.root.setEulerAngles(this._angles);
+ }
+
+ /**
+ * @private
+ * @param {number} dt - The delta time.
+ */
+ _smoothMove(dt) {
+ const a = dt === -1 ? 1 : lerpRate(this.moveDamping, dt);
+ this._position.lerp(this._position, this._origin, a);
+ this.root.setPosition(this._position);
+ }
+
+ /**
+ * Focus the camera on a point.
+ *
+ * @param {Vec3} point - The point.
+ * @param {Vec3} [start] - The start.
+ * @param {boolean} [smooth] - Whether to smooth the focus.
+ */
+ focus(point, start, smooth = true) {
+ if (!this._camera) {
+ return;
+ }
+ if (!start) {
+ this._origin.copy(point);
+ if (!smooth) {
+ this._position.copy(point);
+ }
+ return;
+ }
+
+ tmpV1.sub2(start, point);
+ const elev = Math.atan2(tmpV1.y, Math.sqrt(tmpV1.x * tmpV1.x + tmpV1.z * tmpV1.z)) * math.RAD_TO_DEG;
+ const azim = Math.atan2(tmpV1.x, tmpV1.z) * math.RAD_TO_DEG;
+ this._dir.set(-elev, -azim);
+
+ this._origin.copy(point);
+ this._camera.entity.setPosition(start);
+ this._camera.entity.setLocalEulerAngles(0, 0, 0);
+
+ this._zoomDist = tmpV1.length();
+
+ if (!smooth) {
+ this._angles.set(this._dir.x, this._dir.y, 0);
+ this._position.copy(point);
+ this._cameraDist = this._zoomDist;
+ }
+ }
+
+ /**
+ * Reset the zoom. For orbit and panning only.
+ *
+ * @param {number} [zoomDist] - The zoom distance.
+ * @param {boolean} [smooth] - Whether to smooth the zoom.
+ */
+ resetZoom(zoomDist = 0, smooth = true) {
+ this._zoomDist = zoomDist;
+ if (!smooth) {
+ this._cameraDist = zoomDist;
+ }
+ }
+
+ /**
+ * Refocus the camera.
+ *
+ * @param {Vec3} point - The point.
+ * @param {Vec3} [start] - The start.
+ * @param {number} [zoomDist] - The zoom distance.
+ * @param {boolean} [smooth] - Whether to smooth the refocus.
+ */
+ refocus(point, start = null, zoomDist = null, smooth = true) {
+ if (zoomDist !== null) {
+ this.resetZoom(zoomDist, smooth);
+ }
+ this.focus(point, start, smooth);
+ }
+
+ /**
+ * @param {CameraComponent} camera - The camera component.
+ */
+ attach(camera) {
+ this._camera = camera;
+ this._camera.entity.setLocalEulerAngles(0, 0, 0);
+
+ // Attach events to canvas instead of window
+ this._element.addEventListener('wheel', this._onWheel, PASSIVE);
+ this._element.addEventListener('pointerdown', this._onPointerDown);
+ this._element.addEventListener('pointermove', this._onPointerMove);
+ this._element.addEventListener('pointerup', this._onPointerUp);
+ this._element.addEventListener('contextmenu', this._onContextMenu);
+
+ // These can stay on window since they're keyboard events
+ window.addEventListener('keydown', this._onKeyDown, false);
+ window.addEventListener('keyup', this._onKeyUp, false);
+
+ this.root.addChild(camera.entity);
+ }
+
+ detach() {
+ // Remove from canvas instead of window
+ this._element.removeEventListener('wheel', this._onWheel, PASSIVE);
+ this._element.removeEventListener('pointermove', this._onPointerMove);
+ this._element.removeEventListener('pointerdown', this._onPointerDown);
+ this._element.removeEventListener('pointerup', this._onPointerUp);
+ this._element.removeEventListener('contextmenu', this._onContextMenu);
+
+ // Remove keyboard events from window
+ window.removeEventListener('keydown', this._onKeyDown, false);
+ window.removeEventListener('keyup', this._onKeyUp, false);
+
+ this.root.removeChild(this._camera.entity);
+ this._camera = null;
+
+ this._dir.x = this._angles.x;
+ this._dir.y = this._angles.y;
+ this._origin.copy(this._position);
+
+ this._pointerEvents.clear();
+ this._lastPinchDist = -1;
+ this._panning = false;
+ this._key = {
+ forward: false,
+ backward: false,
+ left: false,
+ right: false,
+ up: false,
+ down: false,
+ sprint: false,
+ crouch: false
+ };
+ }
+
+ /**
+ * @param {number} dt - The delta time.
+ */
+ update(dt) {
+ if (!this._camera) {
+ return;
+ }
+
+ if (!this._flying) {
+ this._smoothZoom(dt);
+ }
+
+ this._move(dt);
+
+ this._smoothLook(dt);
+ this._smoothMove(dt);
+ }
+
+ destroy() {
+ this.detach();
+ }
+}
+
+export { CameraControls };
diff --git a/scripts/camera/base-camera.mjs b/scripts/camera/base-camera.mjs
deleted file mode 100644
index d1d6bafdfa2..00000000000
--- a/scripts/camera/base-camera.mjs
+++ /dev/null
@@ -1,203 +0,0 @@
-import { Entity, Script, Vec3, Vec2, math } from 'playcanvas';
-
-/** @import { CameraComponent } from 'playcanvas' */
-
-const LOOK_MAX_ANGLE = 90;
-
-class BaseCamera extends Script {
- /**
- * @type {CameraComponent}
- * @protected
- */
- _camera = null;
-
- /**
- * @type {Vec3}
- * @protected
- */
- _origin = new Vec3(0, 1, 0);
-
- /**
- * @type {Vec3}
- * @protected
- */
- _position = new Vec3();
-
- /**
- * @type {Vec2}
- * @protected
- */
- _dir = new Vec2();
-
- /**
- * @type {Vec3}
- * @protected
- */
- _angles = new Vec3();
-
- /**
- * @type {Entity}
- */
- root;
-
- /**
- * @attribute
- * @type {number}
- */
- sceneSize = 100;
-
- /**
- * @attribute
- * @type {number}
- */
- lookSensitivity = 0.2;
-
- /**
- * @attribute
- * @type {number}
- */
- lookDamping = 0.97;
-
- /**
- * @attribute
- * @type {number}
- */
- moveDamping = 0.98;
-
- /**
- * @param {object} args - The script arguments.
- */
- constructor(args) {
- super(args);
- const { name, sceneSize, lookSensitivity, lookDamping, moveDamping } = args.attributes;
-
- this.root = new Entity(name ?? 'base-camera');
- this.sceneSize = sceneSize ?? this.sceneSize;
- this.lookSensitivity = lookSensitivity ?? this.lookSensitivity;
- this.lookDamping = lookDamping ?? this.lookDamping;
- this.moveDamping = moveDamping ?? this.moveDamping;
-
- this._onPointerDown = this._onPointerDown.bind(this);
- this._onPointerMove = this._onPointerMove.bind(this);
- this._onPointerUp = this._onPointerUp.bind(this);
-
- this.app.root.addChild(this.root);
- }
-
- /**
- * @private
- * @param {number} dt - The delta time.
- */
- _smoothLook(dt) {
- const lerpRate = 1 - Math.pow(this.lookDamping, dt * 1000);
- this._angles.x = math.lerp(this._angles.x, this._dir.x, lerpRate);
- this._angles.y = math.lerp(this._angles.y, this._dir.y, lerpRate);
- this.root.setEulerAngles(this._angles);
- }
-
- /**
- * @private
- * @param {number} dt - The delta time.
- */
- _smoothMove(dt) {
- this._position.lerp(this._position, this._origin, 1 - Math.pow(this.moveDamping, dt * 1000));
- this.root.setPosition(this._position);
- }
-
- /**
- * @private
- * @param {MouseEvent} event - The mouse event.
- */
- _onContextMenu(event) {
- event.preventDefault();
- }
-
- /**
- * @protected
- * @abstract
- * @param {PointerEvent} event - The pointer event.
- */
- _onPointerDown(event) {
- throw new Error('Method not implemented.');
- }
-
- /**
- * @protected
- * @abstract
- * @param {PointerEvent} event - The pointer event.
- */
- _onPointerMove(event) {
- throw new Error('Method not implemented.');
- }
-
- /**
- * @protected
- * @abstract
- * @param {PointerEvent} event - The pointer event.
- */
- _onPointerUp(event) {
- throw new Error('Method not implemented.');
- }
-
- /**
- * @protected
- * @param {PointerEvent} event - The pointer event.
- */
- _look(event) {
- if (event.target !== this.app.graphicsDevice.canvas) {
- return;
- }
- const movementX = event.movementX || 0;
- const movementY = event.movementY || 0;
- this._dir.x = math.clamp(this._dir.x - movementY * this.lookSensitivity, -LOOK_MAX_ANGLE, LOOK_MAX_ANGLE);
- this._dir.y -= movementX * this.lookSensitivity;
- }
-
- /**
- * @param {CameraComponent} camera - The camera component.
- */
- attach(camera) {
- this._camera = camera;
- this._camera.entity.setLocalEulerAngles(0, 0, 0);
-
- window.addEventListener('pointerdown', this._onPointerDown);
- window.addEventListener('pointermove', this._onPointerMove);
- window.addEventListener('pointerup', this._onPointerUp);
- window.addEventListener('contextmenu', this._onContextMenu);
-
- this.root.addChild(camera.entity);
- }
-
- detach() {
- window.removeEventListener('pointermove', this._onPointerMove);
- window.removeEventListener('pointerdown', this._onPointerDown);
- window.removeEventListener('pointerup', this._onPointerUp);
- window.removeEventListener('contextmenu', this._onContextMenu);
-
- this.root.removeChild(this._camera.entity);
- this._camera = null;
-
- this._dir.x = this._angles.x;
- this._dir.y = this._angles.y;
-
- this._origin.copy(this._position);
- }
-
- /**
- * @param {number} dt - The delta time.
- */
- update(dt) {
- if (!this._camera) {
- return;
- }
-
- this._smoothLook(dt);
- this._smoothMove(dt);
- }
-
- destroy() {
- this.detach();
- }
-}
-
-export { BaseCamera };
diff --git a/scripts/camera/multi-camera.mjs b/scripts/camera/multi-camera.mjs
deleted file mode 100644
index 775ee5a444a..00000000000
--- a/scripts/camera/multi-camera.mjs
+++ /dev/null
@@ -1,511 +0,0 @@
-import { Vec2, Vec3, Ray, Plane, math } from 'playcanvas';
-
-import { BaseCamera } from './base-camera.mjs';
-
-/** @import { CameraComponent } from 'playcanvas' */
-
-const tmpVa = new Vec2();
-const tmpV1 = new Vec3();
-const tmpV2 = new Vec3();
-const tmpR1 = new Ray();
-const tmpP1 = new Plane();
-
-const PASSIVE = { passive: false };
-
-class MultiCamera extends BaseCamera {
- /**
- * @type {number}
- * @private
- */
- _zoomDist = 0;
-
- /**
- * @type {number}
- * @private
- */
- _cameraDist = 0;
-
- /**
- * @type {Map}
- * @private
- */
- _pointerEvents = new Map();
-
- /**
- * @type {number}
- * @private
- */
- _lastPinchDist = -1;
-
- /**
- * @type {Vec2}
- * @private
- */
- _lastPosition = new Vec2();
-
- /**
- * @type {boolean}
- * @private
- */
- _panning = false;
-
- /**
- * @type {boolean}
- * @private
- */
- _flying = false;
-
- /**
- * @type {Record}
- * @private
- */
- _key = {
- forward: false,
- backward: false,
- left: false,
- right: false,
- up: false,
- down: false,
- sprint: false,
- crouch: false
- };
-
- /**
- * @attribute
- * @type {number}
- */
- lookSensitivity = 0.2;
-
- /**
- * @attribute
- * @type {number}
- */
- lookDamping = 0.97;
-
- /**
- * @attribute
- * @type {number}
- */
- moveDamping = 0.98;
-
- /**
- * @attribute
- * @type {number}
- */
- pinchSpeed = 5;
-
- /**
- * @attribute
- * @type {number}
- */
- wheelSpeed = 0.005;
-
- /**
- * @attribute
- * @type {number}
- */
- zoomMin = 0.001;
-
- /**
- * @attribute
- * @type {number}
- */
- zoomMax = 10;
-
- /**
- * @attribute
- * @type {number}
- */
- zoomScaleMin = 0.01;
-
- /**
- * @attribute
- * @type {number}
- */
- moveSpeed = 2;
-
- /**
- * @attribute
- * @type {number}
- */
- sprintSpeed = 4;
-
- /**
- * @attribute
- * @type {number}
- */
- crouchSpeed = 1;
-
- /**
- * @param {object} args - The script arguments.
- */
- constructor(args) {
- super(args);
- const { pinchSpeed, wheelSpeed, zoomMin, zoomMax, moveSpeed, sprintSpeed, crouchSpeed } = args.attributes;
-
- this.pinchSpeed = pinchSpeed ?? this.pinchSpeed;
- this.wheelSpeed = wheelSpeed ?? this.wheelSpeed;
- this.zoomMin = zoomMin ?? this.zoomMin;
- this.zoomMax = zoomMax ?? this.zoomMax;
- this.moveSpeed = moveSpeed ?? this.moveSpeed;
- this.sprintSpeed = sprintSpeed ?? this.sprintSpeed;
- this.crouchSpeed = crouchSpeed ?? this.crouchSpeed;
-
- this._onWheel = this._onWheel.bind(this);
- this._onKeyDown = this._onKeyDown.bind(this);
- this._onKeyUp = this._onKeyUp.bind(this);
-
- if (!this.entity.camera) {
- throw new Error('MultiCamera script requires a camera component');
- }
- this.attach(this.entity.camera);
- }
-
- /**
- * @param {PointerEvent} event - The pointer event.
- * @protected
- */
- _onPointerDown(event) {
- if (!this._camera) {
- return;
- }
- this._pointerEvents.set(event.pointerId, event);
- if (this._pointerEvents.size === 2) {
- this._lastPinchDist = this._getPinchDist();
- this._getMidPoint(this._lastPosition);
- this._panning = true;
- }
- if (event.shiftKey || event.button === 1) {
- this._lastPosition.set(event.clientX, event.clientY);
- this._panning = true;
- }
- if (event.button === 2) {
- this._zoomDist = this._cameraDist;
- this._origin.copy(this._camera.entity.getPosition());
- this._position.copy(this._origin);
- this._camera.entity.setLocalPosition(0, 0, 0);
- this._flying = true;
- }
- }
-
- /**
- * @param {PointerEvent} event - The pointer event.
- * @protected
- */
- _onPointerMove(event) {
- if (this._pointerEvents.size === 0) {
- return;
- }
-
- this._pointerEvents.set(event.pointerId, event);
-
- if (this._pointerEvents.size === 1) {
- if (this._panning) {
- // mouse pan
- this._pan(tmpVa.set(event.clientX, event.clientY));
- } else {
- super._look(event);
- }
- return;
- }
-
- if (this._pointerEvents.size === 2) {
- // touch pan
- this._pan(this._getMidPoint(tmpVa));
-
- // pinch zoom
- const pinchDist = this._getPinchDist();
- if (this._lastPinchDist > 0) {
- this._zoom((this._lastPinchDist - pinchDist) * this.pinchSpeed);
- }
- this._lastPinchDist = pinchDist;
- }
-
- }
-
- /**
- * @param {PointerEvent} event - The pointer event.
- * @protected
- */
- _onPointerUp(event) {
- this._pointerEvents.delete(event.pointerId);
- if (this._pointerEvents.size < 2) {
- this._lastPinchDist = -1;
- this._panning = false;
- }
- if (this._panning) {
- this._panning = false;
- }
- if (this._flying) {
- tmpV1.copy(this.root.forward).mulScalar(this._zoomDist);
- this._origin.add(tmpV1);
- this._position.add(tmpV1);
- this._flying = false;
- }
- }
-
- /**
- * @param {WheelEvent} event - The wheel event.
- * @private
- */
- _onWheel(event) {
- event.preventDefault();
- this._zoom(event.deltaY);
- }
-
- /**
- * @param {KeyboardEvent} event - The keyboard event.
- * @private
- */
- _onKeyDown(event) {
- event.stopPropagation();
- switch (event.key.toLowerCase()) {
- case 'w':
- this._key.forward = true;
- break;
- case 's':
- this._key.backward = true;
- break;
- case 'a':
- this._key.left = true;
- break;
- case 'd':
- this._key.right = true;
- break;
- case 'q':
- this._key.up = true;
- break;
- case 'e':
- this._key.down = true;
- break;
- case 'shift':
- this._key.sprint = true;
- break;
- case 'control':
- this._key.crouch = true;
- break;
- }
- }
-
- /**
- * @param {KeyboardEvent} event - The keyboard event.
- * @private
- */
- _onKeyUp(event) {
- event.stopPropagation();
- switch (event.key.toLowerCase()) {
- case 'w':
- this._key.forward = false;
- break;
- case 's':
- this._key.backward = false;
- break;
- case 'a':
- this._key.left = false;
- break;
- case 'd':
- this._key.right = false;
- break;
- case 'q':
- this._key.up = false;
- break;
- case 'e':
- this._key.down = false;
- break;
- case 'shift':
- this._key.sprint = false;
- break;
- case 'control':
- this._key.crouch = false;
- break;
- }
- }
-
- /**
- * @param {number} dt - The delta time.
- */
- _move(dt) {
- tmpV1.set(0, 0, 0);
- if (this._key.forward) {
- tmpV1.add(this.root.forward);
- }
- if (this._key.backward) {
- tmpV1.sub(this.root.forward);
- }
- if (this._key.left) {
- tmpV1.sub(this.root.right);
- }
- if (this._key.right) {
- tmpV1.add(this.root.right);
- }
- if (this._key.up) {
- tmpV1.add(this.root.up);
- }
- if (this._key.down) {
- tmpV1.sub(this.root.up);
- }
- tmpV1.normalize();
- const speed = this._key.crouch ? this.crouchSpeed : this._key.sprint ? this.sprintSpeed : this.moveSpeed;
- tmpV1.mulScalar(this.sceneSize * speed * dt);
- this._origin.add(tmpV1);
- }
-
- /**
- * @param {Vec2} out - The output vector.
- * @returns {Vec2} The mid point.
- * @private
- */
- _getMidPoint(out) {
- const [a, b] = this._pointerEvents.values();
- const dx = a.clientX - b.clientX;
- const dy = a.clientY - b.clientY;
- return out.set(b.clientX + dx * 0.5, b.clientY + dy * 0.5);
- }
-
- /**
- * @private
- * @returns {number} The pinch distance.
- */
- _getPinchDist() {
- const [a, b] = this._pointerEvents.values();
- const dx = a.clientX - b.clientX;
- const dy = a.clientY - b.clientY;
- return Math.sqrt(dx * dx + dy * dy);
- }
-
- /**
- * @param {Vec2} pos - The screen position.
- * @param {Vec3} point - The output point.
- * @private
- */
- _screenToWorldPan(pos, point) {
- const mouseW = this._camera.screenToWorld(pos.x, pos.y, 1);
- const cameraPos = this._camera.entity.getPosition();
-
- const focusDirScaled = tmpV1.copy(this.root.forward).mulScalar(this._zoomDist);
- const focalPos = tmpV2.add2(cameraPos, focusDirScaled);
- const planeNormal = focusDirScaled.mulScalar(-1).normalize();
-
- const plane = tmpP1.setFromPointNormal(focalPos, planeNormal);
- const ray = tmpR1.set(cameraPos, mouseW.sub(cameraPos).normalize());
-
- plane.intersectsRay(ray, point);
- }
-
- /**
- * @param {Vec2} pos - The screen position.
- * @private
- */
- _pan(pos) {
- const start = new Vec3();
- const end = new Vec3();
-
- this._screenToWorldPan(this._lastPosition, start);
- this._screenToWorldPan(pos, end);
-
- tmpV1.sub2(start, end);
- this._origin.add(tmpV1);
-
- this._lastPosition.copy(pos);
- }
-
- /**
- * @param {number} delta - The delta.
- * @private
- */
- _zoom(delta) {
- if (!this._camera) {
- return;
- }
- const min = this._camera.nearClip + this.zoomMin * this.sceneSize;
- const max = this.zoomMax * this.sceneSize;
- const scale = math.clamp(this._zoomDist / (max - min), this.zoomScaleMin, 1);
- this._zoomDist += (delta * this.wheelSpeed * this.sceneSize * scale);
- this._zoomDist = math.clamp(this._zoomDist, min, max);
- }
-
- /**
- * @param {Vec3} point - The point.
- * @param {Vec3} [start] - The start.
- */
- focus(point, start) {
- if (!this._camera) {
- return;
- }
- if (!start) {
- this._origin.copy(point);
- return;
- }
-
- tmpV1.sub2(start, point);
- const elev = Math.atan2(tmpV1.y, tmpV1.z) * math.RAD_TO_DEG;
- const azim = Math.atan2(tmpV1.x, tmpV1.z) * math.RAD_TO_DEG;
- this._dir.set(-elev, -azim);
-
- this._origin.copy(point);
- this._camera.entity.setPosition(start);
- this._camera.entity.setLocalEulerAngles(0, 0, 0);
-
- this._zoomDist = tmpV1.length();
- }
-
- /**
- * @param {number} [zoomDist] - The zoom distance.
- */
- resetZoom(zoomDist = 0) {
- this._zoomDist = zoomDist;
- }
-
- /**
- * @param {CameraComponent} camera - The camera component.
- */
- attach(camera) {
- super.attach(camera);
-
- window.addEventListener('wheel', this._onWheel, PASSIVE);
- window.addEventListener('keydown', this._onKeyDown, false);
- window.addEventListener('keyup', this._onKeyUp, false);
- }
-
- detach() {
- super.detach();
-
- window.removeEventListener('wheel', this._onWheel, PASSIVE);
- window.removeEventListener('keydown', this._onKeyDown, false);
- window.removeEventListener('keyup', this._onKeyUp, false);
-
- this._pointerEvents.clear();
- this._lastPinchDist = -1;
- this._panning = false;
- this._key = {
- forward: false,
- backward: false,
- left: false,
- right: false,
- up: false,
- down: false,
- sprint: false,
- crouch: false
- };
- }
-
- /**
- * @param {number} dt - The delta time.
- */
- update(dt) {
- if (!this._camera) {
- return;
- }
-
- if (!this._flying) {
- this._cameraDist = math.lerp(this._cameraDist, this._zoomDist, 1 - Math.pow(this.moveDamping, dt * 1000));
- this._camera.entity.setLocalPosition(0, 0, this._cameraDist);
- }
-
- this._move(dt);
-
- super.update(dt);
- }
-}
-
-export { MultiCamera };