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
(WASDQE) Move
+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 };