diff --git a/examples/src/examples/gaussian-splatting/first-person.controls.mjs b/examples/src/examples/gaussian-splatting/first-person.controls.mjs new file mode 100644 index 00000000000..13971c01405 --- /dev/null +++ b/examples/src/examples/gaussian-splatting/first-person.controls.mjs @@ -0,0 +1,63 @@ +/** + * @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, SelectInput, SliderInput, Label } = ReactPCUI; + return fragment( + jsx( + Panel, + { headerText: 'Settings' }, + jsx( + LabelGroup, + { text: 'Renderer' }, + jsx(SelectInput, { + type: 'number', + binding: new BindingTwoWay(), + link: { observer, path: 'renderer' }, + value: observer.get('renderer') ?? 0, + options: [ + { v: 0, t: 'Auto' }, + { v: 1, t: 'Raster (CPU Sort)' }, + { v: 2, t: 'Raster (GPU Sort)' }, + { v: 3, t: 'Compute' } + ] + }) + ), + jsx( + LabelGroup, + { text: 'Splat Budget (M)' }, + jsx(SliderInput, { + binding: new BindingTwoWay(), + link: { observer, path: 'splatBudget' }, + min: 0, + max: 10, + precision: 2, + step: 0.05 + }) + ) + ), + jsx( + Panel, + { headerText: 'Stats' }, + jsx( + LabelGroup, + { text: 'Resolution' }, + jsx(Label, { + binding: new BindingTwoWay(), + link: { observer, path: 'data.stats.resolution' }, + value: observer.get('data.stats.resolution') + }) + ), + jsx( + LabelGroup, + { text: 'GSplat Count' }, + jsx(Label, { + binding: new BindingTwoWay(), + link: { observer, path: 'data.stats.gsplats' }, + value: observer.get('data.stats.gsplats') + }) + ) + ) + ); +}; diff --git a/examples/src/examples/gaussian-splatting/first-person.example.mjs b/examples/src/examples/gaussian-splatting/first-person.example.mjs new file mode 100644 index 00000000000..e1022db78cb --- /dev/null +++ b/examples/src/examples/gaussian-splatting/first-person.example.mjs @@ -0,0 +1,204 @@ +// @config DESCRIPTION
(WASD) Move
(Space) Jump
(Mouse) Look
+// +// Scene attribution: +// Title: Sunnyvale Heritage Park Museum +// Author: zeitgeistarchivescans +// Source: https://superspl.at/scene/d5d397aa +// License: CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/) + +import { data } from 'examples/observer'; +import { deviceType, rootPath, fileImport } from 'examples/utils'; +import * as pc from 'playcanvas'; + +const { FirstPersonController } = await fileImport(`${rootPath}/static/scripts/esm/first-person-controller.mjs`); + +const canvas = /** @type {HTMLCanvasElement} */ (document.getElementById('application-canvas')); +window.focus(); + +pc.WasmModule.setConfig('Ammo', { + glueUrl: `${rootPath}/static/lib/ammo/ammo.wasm.js`, + wasmUrl: `${rootPath}/static/lib/ammo/ammo.wasm.wasm`, + fallbackUrl: `${rootPath}/static/lib/ammo/ammo.js` +}); + +// the collision GLB uses Draco-compressed meshes, so the Draco decoder is required +pc.WasmModule.setConfig('DracoDecoderModule', { + glueUrl: `${rootPath}/static/lib/draco/draco.wasm.js`, + wasmUrl: `${rootPath}/static/lib/draco/draco.wasm.wasm`, + fallbackUrl: `${rootPath}/static/lib/draco/draco.js` +}); + +await Promise.all([ + new Promise((resolve) => { + pc.WasmModule.getInstance('Ammo', () => resolve(true)); + }), + new Promise((resolve) => { + pc.WasmModule.getInstance('DracoDecoderModule', () => resolve(true)); + }) +]); + +const gfxOptions = { + deviceTypes: [deviceType], + + // disable antialiasing as gaussian splats do not benefit from it and it's expensive + antialias: false +}; + +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.gamepads = new pc.GamePads(); +createOptions.keyboard = new pc.Keyboard(window); + +createOptions.componentSystems = [ + pc.RenderComponentSystem, + pc.CameraComponentSystem, + pc.LightComponentSystem, + pc.ScriptComponentSystem, + pc.CollisionComponentSystem, + pc.RigidBodyComponentSystem, + pc.GSplatComponentSystem +]; +createOptions.resourceHandlers = [ + pc.TextureHandler, + pc.ContainerHandler, + pc.ScriptHandler, + pc.GSplatHandler +]; + +const app = new pc.AppBase(canvas); +app.init(createOptions); + +app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW); +app.setCanvasResolution(pc.RESOLUTION_AUTO); + +// Ensure canvas is resized when window changes size +const resize = () => app.resizeCanvas(); +window.addEventListener('resize', resize); +app.on('destroy', () => { + window.removeEventListener('resize', resize); +}); + +const assets = { + splat: new pc.Asset('sunnyvale-splat', 'gsplat', { url: 'https://s3.eu-west-1.amazonaws.com/code.playcanvas.com/examples_data/example_sunnyvale/sunnyvale.sog' }), + collision: new pc.Asset('sunnyvale-collision', 'container', { url: 'https://s3.eu-west-1.amazonaws.com/code.playcanvas.com/examples_data/example_sunnyvale/sunnyvale.glb' }) +}; + +await new Promise((resolve) => { + new pc.AssetListLoader(Object.values(assets), app.assets).load(resolve); +}); + +app.start(); + +// Initial control values +data.set('renderer', pc.GSPLAT_RENDERER_AUTO); +data.set('splatBudget', 4); +data.set('data.stats.gsplats', '—'); +data.set('data.stats.resolution', '—'); + +// Renderer selection +data.on('renderer:set', () => { + app.scene.gsplat.renderer = data.get('renderer'); + const current = app.scene.gsplat.currentRenderer; + if (current !== data.get('renderer')) { + setTimeout(() => data.set('renderer', current), 0); + } +}); + +// Splat budget (in millions) +const applySplatBudget = () => { + const millions = data.get('splatBudget'); + app.scene.gsplat.splatBudget = Math.round(millions * 1000000); +}; +applySplatBudget(); +data.on('splatBudget:set', applySplatBudget); + +// Gravity +app.systems.rigidbody?.gravity.set(0, -10, 0); + +// Camera (attached to the character controller below) +const camera = new pc.Entity('camera'); +camera.addComponent('camera', { + clearColor: new pc.Color(0.1, 0.1, 0.1), + farClip: 1000, + fov: 75, + toneMapping: pc.TONEMAP_LINEAR +}); +camera.setLocalPosition(0, 0.9, 0); + +// Parent that holds both the splat and the collision mesh, keeping them aligned. +// The splat data is authored upside-down relative to PlayCanvas's Y-up convention, +// so a 180° rotation around Z flips both the visual and the collision together. +const sceneRoot = new pc.Entity('sunnyvale'); +sceneRoot.setLocalEulerAngles(0, 0, 180); +app.root.addChild(sceneRoot); + +// Gaussian splat (visual) +const splat = new pc.Entity('sunnyvale-gsplat'); +splat.addComponent('gsplat', { + asset: assets.splat, + unified: true +}); +sceneRoot.addChild(splat); + +// Collision mesh instantiated from the GLB; attached to each render component as +// a static rigidbody using the actual triangle mesh. The mesh itself is hidden - +// it is only used for collision. +const collisionRoot = assets.collision.resource.instantiateRenderEntity(); +collisionRoot.findComponents('render').forEach((/** @type {pc.RenderComponent} */ render) => { + const entity = render.entity; + entity.addComponent('rigidbody', { + type: 'static', + friction: 0.5, + restitution: 0 + }); + entity.addComponent('collision', { + type: 'mesh', + renderAsset: render.asset + }); + render.enabled = false; +}); +sceneRoot.addChild(collisionRoot); + +// First-person character controller +const characterController = new pc.Entity('character-controller'); +characterController.setPosition(0, 2, 0); +characterController.addChild(camera); +characterController.addComponent('collision', { + type: 'capsule', + radius: 0.5, + height: 2 +}); +characterController.addComponent('rigidbody', { + type: 'dynamic', + mass: 100, + linearDamping: 0, + angularDamping: 0, + linearFactor: pc.Vec3.ONE, + angularFactor: pc.Vec3.ZERO, + friction: 0.5, + restitution: 0 +}); +characterController.addComponent('script'); +characterController.script.create(FirstPersonController, { + properties: { + camera, + jumpForce: 420, + speedGround: 65, + sprintMult: 1.73 + } +}); +app.root.addChild(characterController); + +// Stats +app.on('update', () => { + data.set('data.stats.gsplats', app.stats.frame.gsplats.toLocaleString()); + const bb = app.graphicsDevice.backBufferSize; + data.set('data.stats.resolution', `${bb.x} x ${bb.y}`); +}); + +export { app }; diff --git a/examples/src/examples/gaussian-splatting/third-person.controls.mjs b/examples/src/examples/gaussian-splatting/third-person.controls.mjs new file mode 100644 index 00000000000..795acfe692a --- /dev/null +++ b/examples/src/examples/gaussian-splatting/third-person.controls.mjs @@ -0,0 +1,115 @@ +/** + * @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, SelectInput, SliderInput, Label } = ReactPCUI; + return fragment( + jsx( + Panel, + { headerText: 'Settings' }, + jsx( + LabelGroup, + { text: 'Renderer' }, + jsx(SelectInput, { + type: 'number', + binding: new BindingTwoWay(), + link: { observer, path: 'renderer' }, + value: observer.get('renderer') ?? 0, + options: [ + { v: 0, t: 'Auto' }, + { v: 1, t: 'Raster (CPU Sort)' }, + { v: 2, t: 'Raster (GPU Sort)' }, + { v: 3, t: 'Compute' } + ] + }) + ), + jsx( + LabelGroup, + { text: 'Splat Budget (M)' }, + jsx(SliderInput, { + binding: new BindingTwoWay(), + link: { observer, path: 'splatBudget' }, + min: 0, + max: 10, + precision: 2, + step: 0.05 + }) + ) + ), + jsx( + Panel, + { headerText: 'Camera' }, + jsx( + LabelGroup, + { text: 'Distance' }, + jsx(SliderInput, { + binding: new BindingTwoWay(), + link: { observer, path: 'cameraDistance' }, + min: 1, + max: 15, + precision: 2, + step: 0.1 + }) + ), + jsx( + LabelGroup, + { text: 'Height' }, + jsx(SliderInput, { + binding: new BindingTwoWay(), + link: { observer, path: 'cameraHeight' }, + min: 0, + max: 4, + precision: 2, + step: 0.05 + }) + ), + jsx( + LabelGroup, + { text: 'Smoothing' }, + jsx(SliderInput, { + binding: new BindingTwoWay(), + link: { observer, path: 'cameraSmoothing' }, + min: 0, + max: 0.01, + precision: 5, + step: 0.0001 + }) + ), + jsx( + LabelGroup, + { text: 'Look Sens' }, + jsx(SliderInput, { + binding: new BindingTwoWay(), + link: { observer, path: 'lookSens' }, + min: 0.01, + max: 0.5, + precision: 3, + step: 0.005 + }) + ) + ), + jsx( + Panel, + { headerText: 'Stats' }, + jsx( + LabelGroup, + { text: 'Resolution' }, + jsx(Label, { + binding: new BindingTwoWay(), + link: { observer, path: 'data.stats.resolution' }, + value: observer.get('data.stats.resolution') + }) + ), + jsx( + LabelGroup, + { text: 'GSplat Count' }, + jsx(Label, { + binding: new BindingTwoWay(), + link: { observer, path: 'data.stats.gsplats' }, + value: observer.get('data.stats.gsplats') + }) + ) + ) + ); +}; diff --git a/examples/src/examples/gaussian-splatting/third-person.example.mjs b/examples/src/examples/gaussian-splatting/third-person.example.mjs new file mode 100644 index 00000000000..36b063dcf52 --- /dev/null +++ b/examples/src/examples/gaussian-splatting/third-person.example.mjs @@ -0,0 +1,432 @@ +// @config DESCRIPTION
(WASD) Move
(Shift) Sprint
(Space) Jump
(Q) Dance
(Mouse) Orbit camera
(Wheel) Zoom
+// +// Scene attribution: +// Title: Sunnyvale Heritage Park Museum +// Author: zeitgeistarchivescans +// Source: https://superspl.at/scene/d5d397aa +// License: CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/) + +import { data } from 'examples/observer'; +import { deviceType, rootPath, fileImport } from 'examples/utils'; +import * as pc from 'playcanvas'; + +const { ThirdPersonController } = await fileImport(`${rootPath}/static/scripts/esm/third-person-controller.mjs`); +const { ShadowCatcher } = await fileImport(`${rootPath}/static/scripts/esm/shadow-catcher.mjs`); + +const canvas = /** @type {HTMLCanvasElement} */ (document.getElementById('application-canvas')); +window.focus(); + +pc.WasmModule.setConfig('Ammo', { + glueUrl: `${rootPath}/static/lib/ammo/ammo.wasm.js`, + wasmUrl: `${rootPath}/static/lib/ammo/ammo.wasm.wasm`, + fallbackUrl: `${rootPath}/static/lib/ammo/ammo.js` +}); + +// the collision GLB uses Draco-compressed meshes, so the Draco decoder is required +pc.WasmModule.setConfig('DracoDecoderModule', { + glueUrl: `${rootPath}/static/lib/draco/draco.wasm.js`, + wasmUrl: `${rootPath}/static/lib/draco/draco.wasm.wasm`, + fallbackUrl: `${rootPath}/static/lib/draco/draco.js` +}); + +await Promise.all([ + new Promise((resolve) => { + pc.WasmModule.getInstance('Ammo', () => resolve(true)); + }), + new Promise((resolve) => { + pc.WasmModule.getInstance('DracoDecoderModule', () => resolve(true)); + }) +]); + +const gfxOptions = { + deviceTypes: [deviceType], + + // disable antialiasing as gaussian splats do not benefit from it and it's expensive + antialias: false +}; + +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.gamepads = new pc.GamePads(); +createOptions.keyboard = new pc.Keyboard(window); + +createOptions.componentSystems = [ + pc.RenderComponentSystem, + pc.CameraComponentSystem, + pc.LightComponentSystem, + pc.ScriptComponentSystem, + pc.AnimComponentSystem, + pc.CollisionComponentSystem, + pc.RigidBodyComponentSystem, + pc.GSplatComponentSystem +]; +createOptions.resourceHandlers = [ + pc.TextureHandler, + pc.ContainerHandler, + pc.ScriptHandler, + pc.AnimClipHandler, + pc.AnimStateGraphHandler, + pc.GSplatHandler +]; + +const app = new pc.AppBase(canvas); +app.init(createOptions); + +app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW); +app.setCanvasResolution(pc.RESOLUTION_AUTO); + +const resize = () => app.resizeCanvas(); +window.addEventListener('resize', resize); +app.on('destroy', () => { + window.removeEventListener('resize', resize); +}); + +const assets = { + splat: new pc.Asset('sunnyvale-splat', 'gsplat', { url: 'https://s3.eu-west-1.amazonaws.com/code.playcanvas.com/examples_data/example_sunnyvale/sunnyvale.sog' }), + collision: new pc.Asset('sunnyvale-collision', 'container', { url: 'https://s3.eu-west-1.amazonaws.com/code.playcanvas.com/examples_data/example_sunnyvale/sunnyvale.glb' }), + character: new pc.Asset('character', 'container', { url: `${rootPath}/static/assets/models/bitmoji.glb` }), + idleAnim: new pc.Asset('idleAnim', 'container', { url: `${rootPath}/static/assets/animations/bitmoji/idle.glb` }), + walkAnim: new pc.Asset('walkAnim', 'container', { url: `${rootPath}/static/assets/animations/bitmoji/walk.glb` }), + jogAnim: new pc.Asset('jogAnim', 'container', { url: `${rootPath}/static/assets/animations/bitmoji/run.glb` }), + jumpAnim: new pc.Asset('jumpAnim', 'container', { url: `${rootPath}/static/assets/animations/bitmoji/jump-flip.glb` }), + danceAnim: new pc.Asset('danceAnim', 'container', { url: `${rootPath}/static/assets/animations/bitmoji/win-dance.glb` }), + envAtlas: new pc.Asset( + 'env-atlas', + 'texture', + { url: `${rootPath}/static/assets/cubemaps/morning-env-atlas.png` }, + { type: pc.TEXTURETYPE_RGBP, mipmaps: false } + ) +}; + +await new Promise((resolve) => { + new pc.AssetListLoader(Object.values(assets), app.assets).load(resolve); +}); + +app.start(); + +// Use the env atlas for image-based lighting only (the character will be lit +// by it as a soft ambient). Disable the visible Skybox layer so the actual +// skydome geometry is never drawn - the camera's clear color remains the +// visible background. +app.scene.envAtlas = assets.envAtlas.resource; +app.scene.skyboxIntensity = 0.5; +app.scene.layers.getLayerById(pc.LAYERID_SKYBOX).enabled = false; + +// Initial control values +data.set('renderer', pc.GSPLAT_RENDERER_AUTO); +data.set('splatBudget', 4); +data.set('cameraDistance', 5); +data.set('cameraHeight', 1.2); +data.set('cameraSmoothing', 0.0005); +data.set('lookSens', 0.15); +data.set('data.stats.gsplats', '—'); +data.set('data.stats.resolution', '—'); + +data.on('renderer:set', () => { + app.scene.gsplat.renderer = data.get('renderer'); + const current = app.scene.gsplat.currentRenderer; + if (current !== data.get('renderer')) { + setTimeout(() => data.set('renderer', current), 0); + } +}); + +const applySplatBudget = () => { + const millions = data.get('splatBudget'); + app.scene.gsplat.splatBudget = Math.round(millions * 1000000); +}; +applySplatBudget(); +data.on('splatBudget:set', applySplatBudget); + +// Gravity +app.systems.rigidbody?.gravity.set(0, -10, 0); + +// Directional light - both lights the character and feeds the shadow catcher +const light = new pc.Entity('light'); +light.addComponent('light', { + type: 'directional', + color: new pc.Color(1, 1, 1), + castShadows: true, + intensity: 2, + shadowBias: 0.2, + shadowDistance: 50, + normalOffsetBias: 0.05, + shadowResolution: 2048, + shadowIntensity: 0.65 +}); +light.setLocalEulerAngles(60, -20, 0); +app.root.addChild(light); + +// Shadow catcher: a transparent plane that receives the character's shadow +// from the directional light, multiplied onto the gsplat ground behind it. +// It lives at the world root and is repositioned each frame to follow the +// character on the ground (raycast down to find ground Y). +const shadowCatcher = new pc.Entity('shadow-catcher'); +shadowCatcher.addComponent('script').create(ShadowCatcher, { + properties: { + scale: new pc.Vec3(12, 12, 12), + // drawBucket 0 makes the catcher render AFTER the gsplat ground so + // its shadow can darken the gsplat + drawBucket: 0 + } +}); + +// Camera (standalone - not parented to the character; positioned by the controller) +const camera = new pc.Entity('camera'); +camera.addComponent('camera', { + clearColor: new pc.Color(0.1, 0.1, 0.1), + farClip: 1000, + fov: 60, + toneMapping: pc.TONEMAP_LINEAR +}); +app.root.addChild(camera); + +// Parent that holds both the splat and the collision mesh, keeping them aligned. +// The splat data is authored upside-down relative to PlayCanvas's Y-up convention, +// so a 180° rotation around Z flips both the visual and the collision together. +const sceneRoot = new pc.Entity('sunnyvale'); +sceneRoot.setLocalEulerAngles(0, 0, 180); +app.root.addChild(sceneRoot); + +const splat = new pc.Entity('sunnyvale-gsplat'); +splat.addComponent('gsplat', { + asset: assets.splat, + unified: true +}); +sceneRoot.addChild(splat); + +const collisionRoot = assets.collision.resource.instantiateRenderEntity(); +collisionRoot.findComponents('render').forEach((/** @type {pc.RenderComponent} */ render) => { + const entity = render.entity; + entity.addComponent('rigidbody', { + type: 'static', + friction: 0.5, + restitution: 0 + }); + entity.addComponent('collision', { + type: 'mesh', + renderAsset: render.asset + }); + render.enabled = false; +}); +sceneRoot.addChild(collisionRoot); + +// ---- Character ---- +// The third-person controller acts on `characterController` (capsule + dynamic +// rigidbody). The visible bitmoji mesh is a child entity (`characterModel`) so +// the controller can rotate it independently from the capsule. +const characterController = new pc.Entity('character-controller'); +characterController.setPosition(0, 1.2, 0); +characterController.addComponent('collision', { + type: 'capsule', + radius: 0.5, + height: 2 +}); +characterController.addComponent('rigidbody', { + type: 'dynamic', + mass: 100, + linearDamping: 0, + angularDamping: 0, + linearFactor: pc.Vec3.ONE, + angularFactor: pc.Vec3.ZERO, + friction: 0.5, + restitution: 0 +}); +app.root.addChild(characterController); + +// Shadow catcher lives at the world root and is repositioned each frame to +// follow the character horizontally while staying glued to the ground (raycast +// down from above the character to find ground Y). This keeps the shadow on +// the ground when the character jumps rather than rising with them. +app.root.addChild(shadowCatcher); + +const _scRayStart = new pc.Vec3(); +const _scRayEnd = new pc.Vec3(); +const _scPos = new pc.Vec3(); +let _scLastY = 0; +// Exclude the character's own collision so the raycast always hits actual +// ground geometry, never the character's capsule. +const _scRayOpts = { + filterCallback: (/** @type {pc.Entity} */ entity) => entity !== characterController +}; +const updateShadowCatcher = () => { + const cp = characterController.getPosition(); + // Start the ray AT the character (not above) so we never hit roofs, + // overhangs or any geometry that lives between the character and the sky. + // The filterCallback below skips the character's own capsule, so starting + // inside it is safe and always finds the ground below. + _scRayStart.set(cp.x, cp.y, cp.z); + _scRayEnd.set(cp.x, cp.y - 100, cp.z); + const hit = app.systems.rigidbody?.raycastFirst(_scRayStart, _scRayEnd, _scRayOpts); + const groundY = hit ? hit.point.y : _scLastY; + _scLastY = groundY; + _scPos.set(cp.x, groundY + 0.01, cp.z); + shadowCatcher.setPosition(_scPos); +}; + +// Visible character (rotated by the controller). The bitmoji model's pivot is +// at the feet, so offset down by half capsule height (1.0) to align feet with +// the bottom of the capsule. +const characterModel = new pc.Entity('character-model'); +characterModel.setLocalPosition(0, -1, 0); +characterModel.setLocalScale(1.5, 1.5, 1.5); +characterController.addChild(characterModel); + +const characterRender = assets.character.resource.instantiateRenderEntity({ + castShadows: true +}); +characterModel.addChild(characterRender); + +// Anim state graph (same shape as the locomotion example: idle/walk/jog/jump +// driven by an integer `speed` parameter and a `jump` trigger). +characterModel.addComponent('anim', { activate: true }); +characterModel.anim.loadStateGraph({ + layers: [ + { + name: 'locomotion', + states: [ + { name: 'START' }, + { name: 'Idle', speed: 1 }, + { name: 'Walk', speed: 1 }, + { name: 'Jog', speed: 1 }, + { name: 'Jump', speed: 1 }, + { name: 'Dance', speed: 1 }, + { name: 'END' } + ], + transitions: [ + { from: 'START', to: 'Idle', time: 0, priority: 0 }, + { + from: 'Idle', + to: 'Walk', + time: 0.1, + priority: 0, + conditions: [{ parameterName: 'speed', predicate: pc.ANIM_GREATER_THAN, value: 0 }] + }, + { + from: 'Walk', + to: 'Idle', + time: 0.1, + priority: 0, + conditions: [{ parameterName: 'speed', predicate: pc.ANIM_LESS_THAN_EQUAL_TO, value: 0 }] + }, + { + from: 'Walk', + to: 'Jog', + time: 0.1, + priority: 0, + conditions: [{ parameterName: 'speed', predicate: pc.ANIM_GREATER_THAN, value: 1 }] + }, + { + from: 'Jog', + to: 'Walk', + time: 0.1, + priority: 0, + conditions: [{ parameterName: 'speed', predicate: pc.ANIM_LESS_THAN, value: 2 }] + }, + { + from: 'ANY', + to: 'Jump', + time: 0.1, + priority: 0, + conditions: [{ parameterName: 'jump', predicate: pc.ANIM_EQUAL_TO, value: true }] + }, + { from: 'Jump', to: 'Idle', time: 0.2, priority: 0, exitTime: 0.8 }, + { from: 'Jump', to: 'Walk', time: 0.2, priority: 0, exitTime: 0.8 }, + { + from: 'ANY', + to: 'Dance', + time: 0.2, + priority: 0, + conditions: [{ parameterName: 'dance', predicate: pc.ANIM_EQUAL_TO, value: true }] + }, + // exit dance as soon as the player starts moving again + { + from: 'Dance', + to: 'Walk', + time: 0.2, + priority: 0, + conditions: [{ parameterName: 'speed', predicate: pc.ANIM_GREATER_THAN, value: 0 }] + } + ] + } + ], + parameters: { + speed: { name: 'speed', type: pc.ANIM_PARAMETER_INTEGER, value: 0 }, + jump: { name: 'jump', type: pc.ANIM_PARAMETER_TRIGGER, value: false }, + dance: { name: 'dance', type: pc.ANIM_PARAMETER_TRIGGER, value: false } + } +}); + +const layer = characterModel.anim.baseLayer; +layer.assignAnimation('Idle', assets.idleAnim.resource.animations[0].resource); +layer.assignAnimation('Walk', assets.walkAnim.resource.animations[0].resource); +layer.assignAnimation('Jog', assets.jogAnim.resource.animations[0].resource); +layer.assignAnimation('Jump', assets.jumpAnim.resource.animations[0].resource); +layer.assignAnimation('Dance', assets.danceAnim.resource.animations[0].resource); + +// Wire the third-person controller +characterController.addComponent('script'); +const tpc = /** @type {ThirdPersonController} */ (characterController.script.create(ThirdPersonController, { + properties: { + camera, + characterModel, + jumpForce: 420, + speedGround: 65, + sprintMult: 1.73, + cameraDistance: data.get('cameraDistance'), + cameraHeight: data.get('cameraHeight'), + cameraPositionDamping: data.get('cameraSmoothing'), + lookSens: data.get('lookSens'), + invertLookY: true, + initialPitch: 17, + walkSpeedThreshold: 0.5, + jogSpeedThreshold: 4 + } +})); + +// Drive animation parameters from controller events +tpc.on('speed', (/** @type {number} */ bucket) => { + characterModel.anim.setInteger('speed', bucket); +}); +tpc.on('jump', () => { + if (characterModel.anim.baseLayer.activeState !== 'Jump') { + characterModel.anim.setTrigger('jump'); + } +}); + +// Q triggers the dance animation. It exits as soon as the player moves again +// (via the `speed > 0` transition in the state graph). +app.keyboard.on(pc.EVENT_KEYDOWN, (/** @type {pc.KeyboardEvent} */ evt) => { + if (evt.key === pc.KEY_Q) { + characterModel.anim.setTrigger('dance'); + } +}); + +// Hook controls +data.on('cameraDistance:set', () => { + tpc.cameraDistance = data.get('cameraDistance'); +}); +data.on('cameraHeight:set', () => { + tpc.cameraHeight = data.get('cameraHeight'); +}); +data.on('cameraSmoothing:set', () => { + tpc.cameraPositionDamping = data.get('cameraSmoothing'); +}); +data.on('lookSens:set', () => { + tpc.lookSens = data.get('lookSens'); +}); + +// Stats + shadow catcher follow +app.on('update', () => { + updateShadowCatcher(); + + data.set('data.stats.gsplats', app.stats.frame.gsplats.toLocaleString()); + const bb = app.graphicsDevice.backBufferSize; + data.set('data.stats.resolution', `${bb.x} x ${bb.y}`); +}); + +export { app }; diff --git a/examples/thumbnails/gaussian-splatting_first-person_large.webp b/examples/thumbnails/gaussian-splatting_first-person_large.webp new file mode 100644 index 00000000000..2f5ac3dfae0 Binary files /dev/null and b/examples/thumbnails/gaussian-splatting_first-person_large.webp differ diff --git a/examples/thumbnails/gaussian-splatting_first-person_small.webp b/examples/thumbnails/gaussian-splatting_first-person_small.webp new file mode 100644 index 00000000000..d1b93063642 Binary files /dev/null and b/examples/thumbnails/gaussian-splatting_first-person_small.webp differ diff --git a/examples/thumbnails/gaussian-splatting_third-person_large.webp b/examples/thumbnails/gaussian-splatting_third-person_large.webp new file mode 100644 index 00000000000..9110d673e0f Binary files /dev/null and b/examples/thumbnails/gaussian-splatting_third-person_large.webp differ diff --git a/examples/thumbnails/gaussian-splatting_third-person_small.webp b/examples/thumbnails/gaussian-splatting_third-person_small.webp new file mode 100644 index 00000000000..37e0edaab27 Binary files /dev/null and b/examples/thumbnails/gaussian-splatting_third-person_small.webp differ diff --git a/scripts/esm/shadow-catcher.mjs b/scripts/esm/shadow-catcher.mjs index beb9ed1d909..b412c471972 100644 --- a/scripts/esm/shadow-catcher.mjs +++ b/scripts/esm/shadow-catcher.mjs @@ -47,6 +47,22 @@ class ShadowCatcher extends Script { */ geometry; + /** + * Draw bucket applied to the shadow catcher's mesh instances. Used to + * coarsely control where the catcher sits in the transparent render order + * relative to other transparent objects (in `SORTMODE_BACK2FRONT` mode, + * higher buckets render first). The default `250` puts the catcher very + * early in the transparent pass so its shadow can darken the skybox / + * background. Lower it (e.g. to `0`) when the catcher needs to render + * AFTER a transparent object that would otherwise overwrite it - for + * example a Gaussian Splat ground. + * + * @attribute + * @title Draw Bucket + * @type {number} + */ + drawBucket = 250; + /** * @type {boolean} * @private @@ -92,10 +108,6 @@ class ShadowCatcher extends Script { this.geometry?.render?.meshInstances.forEach((mi) => { - // set up the geometry to render very early during the transparent pass, before other transparent objects - // use drawBucket for coarse sorting - higher bucket renders first in back-to-front mode - mi.drawBucket = 250; - // if geometry was provided, set the material if (!this._geometryCreated) { mi.material = shadowCatcherMaterial; @@ -112,6 +124,11 @@ class ShadowCatcher extends Script { update() { this.geometry?.setLocalScale(this.scale); + + // apply drawBucket every frame so runtime changes take effect + this.geometry?.render?.meshInstances.forEach((mi) => { + mi.drawBucket = this.drawBucket; + }); } } diff --git a/scripts/esm/third-person-controller.mjs b/scripts/esm/third-person-controller.mjs new file mode 100644 index 00000000000..6137cfd729a --- /dev/null +++ b/scripts/esm/third-person-controller.mjs @@ -0,0 +1,966 @@ +import { + math, + InputFrame, + KeyboardMouseSource, + DualGestureSource, + GamepadSource, + Quat, + Script, + Vec3 +} from 'playcanvas'; + +/** @import { Entity, RigidBodyComponent, RigidBodyComponentSystem } from 'playcanvas' */ + +/** + * @typedef {object} ThirdPersonControllerState + * @property {Vec3} axis - The movement axis. + * @property {number[]} mouse - The mouse delta. + * @property {number} a - The 'A' button state. + * @property {number} space - The space key state. + * @property {number} shift - The shift key state. + * @property {number} ctrl - The ctrl key state. + */ + +const EPSILON = 0.0001; + +// scratch +const v = new Vec3(); +const v2 = new Vec3(); +const v3 = new Vec3(); +const v4 = new Vec3(); +const forward = new Vec3(); +const right = new Vec3(); +const offset = new Vec3(); +const tmpQ = new Quat(); + +const frame = new InputFrame({ + move: [0, 0, 0], + rotate: [0, 0, 0], + jump: [0] +}); + +/** + * Calculate the damp rate. + * + * @param {number} damping - The damping factor (smaller = snappier). + * @param {number} dt - The delta time. + * @returns {number} - The lerp factor in 0..1. + */ +export const damp = (damping, dt) => 1 - Math.pow(damping, dt * 1000); + +/** + * @param {number[]} stick - The stick. + * @param {number} low - The low dead zone. + * @param {number} high - The high dead zone. + */ +const applyDeadZone = (stick, low, high) => { + const mag = Math.sqrt(stick[0] * stick[0] + stick[1] * stick[1]); + if (mag < low) { + stick.fill(0); + return; + } + const scale = (mag - low) / (high - low); + stick[0] *= scale / mag; + stick[1] *= scale / mag; +}; + +/** + * Lerp between two angles taking the shortest arc. + * + * @param {number} from - Source angle in degrees. + * @param {number} to - Target angle in degrees. + * @param {number} alpha - Lerp factor in 0..1. + * @returns {number} The interpolated angle in degrees. + */ +const lerpAngle = (from, to, alpha) => { + const delta = (((to - from) % 360) + 540) % 360 - 180; + return from + delta * alpha; +}; + +/** + * A reusable third-person character controller. + * + * The script is attached to a character controller entity (a dynamic rigidbody + * with a capsule collision shape). It reads keyboard / mouse / touch / gamepad + * input and: + * + * - Moves the character along the ground using a velocity-based capsule. + * - Rotates an optional {@link characterModel} child entity to face the + * movement direction. + * - Orbits a separate {@link camera} entity around the character at a + * configurable distance and elevation, with smooth follow and wall collision + * avoidance via raycast. + * - Fires script events that consumers can use to drive animations: + * - `speed` (integer 0/1/2): idle / walk / jog buckets when the bucket changes + * - `jump`: fired once each time the character jumps + */ +class ThirdPersonController extends Script { + static scriptName = 'thirdPersonController'; + + /** + * @type {boolean} + * @private + */ + _ready = false; + + /** + * @type {RigidBodyComponent} + * @private + */ + // @ts-ignore + _rigidbody; + + /** + * @type {KeyboardMouseSource} + * @private + */ + _desktopInput = new KeyboardMouseSource({ pointerLock: true }); + + /** + * @type {DualGestureSource} + * @private + */ + _mobileInput = new DualGestureSource(); + + /** + * @type {GamepadSource} + * @private + */ + _gamepadInput = new GamepadSource(); + + /** + * @type {ThirdPersonControllerState} + * @private + */ + _state = { + axis: new Vec3(), + mouse: [0, 0, 0], + a: 0, + space: 0, + shift: 0, + ctrl: 0 + }; + + /** + * Current camera yaw in degrees (around character). + * + * @type {number} + * @private + */ + _yaw = 0; + + /** + * Current camera pitch in degrees (around character). Positive looks down + * at the character from above; the default starts the camera elevated and + * angled down at the character from behind. + * + * @type {number} + * @private + */ + _pitch = 35; + + /** + * Current smoothed character model yaw in degrees. + * + * @type {number} + * @private + */ + _modelYaw = 0; + + /** + * Smoothed camera world position. + * + * @type {Vec3} + * @private + */ + _camPos = new Vec3(); + + /** + * Smoothed look-at point. + * + * @type {Vec3} + * @private + */ + _camLookAt = new Vec3(); + + /** + * @type {boolean} + * @private + */ + _camInitialized = false; + + /** + * @type {boolean} + * @private + */ + _grounded = false; + + /** + * @type {boolean} + * @private + */ + _jumping = false; + + /** + * @type {number} + * @private + */ + _lastSpeedBucket = 0; + + /** + * @type {number} + * @private + */ + _mobileDeadZone = 0.3; + + /** + * @type {number} + * @private + */ + _mobileTurnSpeed = 30; + + /** + * @type {number} + * @private + */ + _mobileRadius = 50; + + /** + * @type {number} + * @private + */ + _mobileDoubleTapInterval = 300; + + /** + * @type {number} + * @private + */ + _gamePadDeadZoneLow = 0.1; + + /** + * @type {number} + * @private + */ + _gamePadDeadZoneHigh = 0.1; + + /** + * @type {number} + * @private + */ + _gamePadTurnSpeed = 30; + + /** + * @attribute + * @title Camera + * @description The camera entity that is orbited around the character. Must + * be a top-level entity (not parented to the character controller) so the + * controller can position it freely each frame. + * @type {Entity} + */ + // @ts-ignore + camera; + + /** + * @attribute + * @title Character Model + * @description Optional child entity that holds the visible character mesh. + * If provided, the controller will smoothly rotate this entity around Y to + * face the movement direction. + * @type {Entity} + */ + // @ts-ignore + characterModel; + + /** + * @attribute + * @title Look Sensitivity + * @description The mouse sensitivity for orbiting the camera. + * @type {number} + */ + lookSens = 0.15; + + /** + * @attribute + * @title Invert Look Y + * @description When true, vertical mouse movement is inverted (mouse up + * tilts the camera down and vice versa). + * @type {boolean} + */ + invertLookY = false; + + /** + * @attribute + * @title Ground Speed + * @description The speed of the character when on the ground. + * @type {number} + */ + speedGround = 50; + + /** + * @attribute + * @title Air Speed + * @description The speed of the character when in the air. + * @type {number} + */ + speedAir = 5; + + /** + * @attribute + * @title Sprint Multiplier + * @description The multiplier applied to the speed when sprinting. + * @type {number} + */ + sprintMult = 1.5; + + /** + * @attribute + * @title Velocity Damping Ground + * @description The damping applied to the velocity when on the ground. + * @type {number} + */ + velocityDampingGround = 0.99; + + /** + * @attribute + * @title Velocity Damping Air + * @description The damping applied to the velocity when in the air. + * @type {number} + */ + velocityDampingAir = 0.99925; + + /** + * @attribute + * @title Jump Force + * @description The vertical impulse applied when the character jumps. + * @type {number} + */ + jumpForce = 600; + + /** + * @attribute + * @title Initial Pitch + * @description Camera pitch (degrees) used on the first frame, clamped to + * `[pitchMin, pitchMax]`. Positive values position the camera higher, + * looking down at the character. + * @type {number} + */ + initialPitch = 35; + + /** + * @attribute + * @title Pitch Min + * @description Lowest pitch angle (degrees). Negative looks up at character + * from below, positive looks down. + * @type {number} + */ + pitchMin = -30; + + /** + * @attribute + * @title Pitch Max + * @description Highest pitch angle (degrees, looking down at character). + * @type {number} + */ + pitchMax = 75; + + /** + * @attribute + * @title Camera Distance + * @description Distance from the character to the camera, in world units. + * @type {number} + */ + cameraDistance = 5; + + /** + * @attribute + * @title Camera Distance Min + * @description Closest the camera can zoom in to (mouse scroll wheel + * controls zoom within `[cameraDistanceMin, cameraDistanceMax]`). + * @type {number} + */ + cameraDistanceMin = 1.5; + + /** + * @attribute + * @title Camera Distance Max + * @description Farthest the camera can zoom out to. + * @type {number} + */ + cameraDistanceMax = 15; + + /** + * @attribute + * @title Zoom Speed + * @description Mouse scroll wheel sensitivity. Larger values zoom faster + * per notch of the wheel. + * @type {number} + */ + zoomSpeed = 0.01; + + /** + * @attribute + * @title Zoom Damping + * @description Damping factor used to smooth scroll-wheel zoom. This is + * the fraction of "distance to target" retained per millisecond, so values + * close to `1` are smooth/laggy and values close to `0` are snappy. `0` + * disables smoothing entirely (instant zoom). + * @range [0, 1] + * @type {number} + */ + zoomDamping = 0.997; + + /** + * Target camera distance the actual `cameraDistance` lerps toward each + * frame; mutated by the scroll wheel. + * + * @type {number} + * @private + */ + _targetCameraDistance = 5; + + /** + * @attribute + * @title Camera Height + * @description Vertical offset of the camera target above the character + * pivot, in world units. The camera looks at the character position raised + * by this amount, and orbits around that point. + * @type {number} + */ + cameraHeight = 1.2; + + /** + * @attribute + * @title Camera Min Height Above Character + * @description Minimum Y offset (in world units) the camera is allowed to + * sit relative to the character entity. The final camera Y is clamped to + * `characterY + cameraMinHeightAboveCharacter`, so for the default value + * of `0` the camera will never drop below the character's pivot Y. Use + * positive values to keep the camera above head height, negative values + * to allow it to dip toward the feet. + * @type {number} + */ + cameraMinHeightAboveCharacter = 0; + + /** + * @attribute + * @title Camera Min Distance + * @description When the camera collides with geometry, it is clamped no + * closer than this distance from the character. + * @type {number} + */ + cameraMinDistance = 0.4; + + /** + * @attribute + * @title Camera Collision Padding + * @description Extra clearance applied to the camera position when a wall + * is detected, to keep the camera from sitting flush against geometry. + * @type {number} + */ + cameraCollisionPadding = 0.25; + + /** + * @attribute + * @title Camera Collision Damping + * @description Damping factor used to smooth the camera's reaction to + * walls coming between the camera and the character. This is the fraction + * of "distance to target" retained per millisecond, so values close to `1` + * are smooth/laggy and values close to `0` snap immediately. `0` disables + * smoothing for the collision response. + * @range [0, 1] + * @type {number} + */ + cameraCollisionDamping = 0.99; + + /** + * Smoothed effective camera distance (lerps toward the raycast-clamped + * distance each frame). Initialised in `initialize()`. + * + * @type {number} + * @private + */ + _clampedDistance = 5; + + /** + * @attribute + * @title Camera Position Damping + * @description Damping factor for camera follow smoothing. Smaller values + * produce a snappier camera; larger values produce a laggier camera. A + * value of 0 disables smoothing entirely. + * @range [0, 1] + * @type {number} + */ + cameraPositionDamping = 0.0005; + + /** + * @attribute + * @title Camera Look-At Damping + * @description Damping factor for the smoothed look-at point follow. + * @range [0, 1] + * @type {number} + */ + cameraLookAtDamping = 0.00001; + + /** + * @attribute + * @title Model Turn Smoothing + * @description Damping factor for the character model's rotation toward the + * movement direction. Smaller values produce a snappier turn. + * @range [0, 1] + * @type {number} + */ + modelTurnSmoothing = 0.000005; + + /** + * @attribute + * @title Model Yaw Offset + * @description Yaw offset in degrees applied to the character model's + * facing direction. Use this to correct for models whose forward axis is + * not -Z (e.g. set to 180 for a model whose forward is +Z). + * @type {number} + */ + modelYawOffset = 0; + + /** + * @attribute + * @title Walk Speed Threshold + * @description Horizontal speed (units/sec) above which the controller + * reports `speed` bucket 1 (walk) via the `speed` event. + * @type {number} + */ + walkSpeedThreshold = 0.5; + + /** + * @attribute + * @title Jog Speed Threshold + * @description Horizontal speed (units/sec) above which the controller + * reports `speed` bucket 2 (jog/run) via the `speed` event. + * @type {number} + */ + jogSpeedThreshold = 4; + + /** + * The joystick event name for the UI position for the base and stick elements. + * The event name is appended with the side: ':left' or ':right'. + * + * @attribute + * @title Joystick Base Event Name + * @type {string} + */ + joystickEventName = 'joystick'; + + initialize() { + if (!this.camera) { + throw new Error('ThirdPersonController: Camera entity is required.'); + } + + // collision and rigidbody defaults + if (!this.entity.collision) { + this.entity.addComponent('collision', { + type: 'capsule', + radius: 0.5, + height: 2 + }); + } + if (!this.entity.rigidbody) { + this.entity.addComponent('rigidbody', { + type: 'dynamic', + mass: 100, + linearDamping: 0, + angularDamping: 0, + linearFactor: Vec3.ONE, + angularFactor: Vec3.ZERO, + friction: 0.5, + restitution: 0 + }); + } + this._rigidbody = /** @type {RigidBodyComponent} */ (this.entity.rigidbody); + + // attach input + this._desktopInput.attach(this.app.graphicsDevice.canvas); + this._mobileInput.attach(this.app.graphicsDevice.canvas); + this._gamepadInput.attach(this.app.graphicsDevice.canvas); + + // expose ui events for mobile virtual joysticks + this._mobileInput.on('joystick:position:left', ([bx, by, sx, sy]) => { + this.app.fire(`${this.joystickEventName}:left`, bx, by, sx, sy); + }); + this._mobileInput.on('joystick:position:right', ([bx, by, sx, sy]) => { + this.app.fire(`${this.joystickEventName}:right`, bx, by, sx, sy); + }); + + // initial camera yaw from the camera entity, if it already faces character + const camEuler = this.camera.getEulerAngles(); + this._yaw = camEuler.y; + this._pitch = math.clamp(this.initialPitch, this.pitchMin, this.pitchMax); + this._targetCameraDistance = this.cameraDistance; + this._clampedDistance = this.cameraDistance; + + // initial character model yaw - face away from the camera (so the + // camera starts behind the character looking at its back). This is + // computed from the camera yaw using the same atan2 formula that the + // running update uses on velocity, so it produces a value that won't + // jump when the character first starts moving forward. + if (this.characterModel) { + tmpQ.setFromEulerAngles(0, this._yaw, 0); + tmpQ.transformVector(Vec3.FORWARD, forward); + this._modelYaw = Math.atan2(forward.x, forward.z) * math.RAD_TO_DEG + this.modelYawOffset; + this.characterModel.setLocalEulerAngles(0, this._modelYaw, 0); + } + + this.on('destroy', this.destroy, this); + + this._ready = true; + } + + /** + * @attribute + * @title Mobile Dead Zone + * @description Radial thickness of inner dead zone of the virtual joysticks. + * @type {number} + * @range [0, 0.4] + * @default 0.3 + */ + set mobileDeadZone(value) { + this._mobileDeadZone = value ?? this._mobileDeadZone; + } + + get mobileDeadZone() { + return this._mobileDeadZone; + } + + /** + * @attribute + * @title Mobile Turn Speed + * @description Maximum turn speed in degrees per second. + * @type {number} + * @default 30 + */ + set mobileTurnSpeed(value) { + this._mobileTurnSpeed = value ?? this._mobileTurnSpeed; + } + + get mobileTurnSpeed() { + return this._mobileTurnSpeed; + } + + /** + * @attribute + * @title Mobile Radius + * @description The radius of the virtual joystick in CSS pixels. + * @type {number} + * @default 50 + */ + set mobileRadius(value) { + this._mobileRadius = value ?? this._mobileRadius; + } + + get mobileRadius() { + return this._mobileRadius; + } + + /** + * @attribute + * @title Mobile Double Tap Interval + * @description Milliseconds between two taps of the right virtual joystick + * for a double-tap to register as a jump. + * @type {number} + * @default 300 + */ + set mobileDoubleTapInterval(value) { + this._mobileDoubleTapInterval = value ?? this._mobileDoubleTapInterval; + } + + get mobileDoubleTapInterval() { + return this._mobileDoubleTapInterval; + } + + /** + * @attribute + * @title GamePad Dead Zone Low + * @description Inner dead zone of pad joysticks. + * @type {number} + * @range [0, 0.4] + * @default 0.1 + */ + set gamePadDeadZoneLow(value) { + this._gamePadDeadZoneLow = value ?? this._gamePadDeadZoneLow; + } + + get gamePadDeadZoneLow() { + return this._gamePadDeadZoneLow; + } + + /** + * @attribute + * @title GamePad Dead Zone High + * @description Outer dead zone of pad joysticks. + * @type {number} + * @range [0, 0.4] + * @default 0.1 + */ + set gamePadDeadZoneHigh(value) { + this._gamePadDeadZoneHigh = value ?? this._gamePadDeadZoneHigh; + } + + get gamePadDeadZoneHigh() { + return this._gamePadDeadZoneHigh; + } + + /** + * @attribute + * @title GamePad Turn Speed + * @description Maximum gamepad turn speed in degrees per second. + * @type {number} + * @default 30 + */ + set gamePadTurnSpeed(value) { + this._gamePadTurnSpeed = value ?? this._gamePadTurnSpeed; + } + + get gamePadTurnSpeed() { + return this._gamePadTurnSpeed; + } + + /** + * @param {InputFrame<{ move: number[], rotate: number[], jump: number[] }>} frame - The input frame. + * @param {number} dt - The delta time. + * @private + */ + _updateController(frame, dt) { + const { move, rotate, jump } = frame.read(); + + // jump + if (this._rigidbody.linearVelocity.y < 0) { + this._jumping = false; + } + if (jump[0] && !this._jumping && this._grounded) { + this._jumping = true; + this._rigidbody.applyImpulse(0, this.jumpForce, 0); + this.fire('jump'); + } + + // camera orbit (mouse rotates camera around character; W/S becomes + // forward/back along camera's flat yaw) + this._yaw -= rotate[0]; + this._pitch -= (this.invertLookY ? -1 : 1) * rotate[1]; + this._pitch = math.clamp(this._pitch, this.pitchMin, this.pitchMax); + + // movement direction relative to camera yaw only + tmpQ.setFromEulerAngles(0, this._yaw, 0); + tmpQ.transformVector(Vec3.FORWARD, forward); + tmpQ.transformVector(Vec3.RIGHT, right); + offset.set(0, 0, 0); + offset.add(forward.mulScalar(move[2])); + offset.add(right.mulScalar(move[0])); + + const velocity = this._rigidbody.linearVelocity.add(offset); + const alpha = damp(this._grounded ? this.velocityDampingGround : this.velocityDampingAir, dt); + velocity.x = math.lerp(velocity.x, 0, alpha); + velocity.z = math.lerp(velocity.z, 0, alpha); + this._rigidbody.linearVelocity = velocity; + + // character model yaw: smoothly turn toward horizontal velocity + const horizSpeed = Math.sqrt(velocity.x * velocity.x + velocity.z * velocity.z); + if (this.characterModel) { + if (horizSpeed > this.walkSpeedThreshold * 0.5) { + // movement direction in world space; convert to yaw, plus a + // per-model offset so models with a different forward axis can + // be aligned without rotating the mesh data. + const targetYaw = Math.atan2(velocity.x, velocity.z) * math.RAD_TO_DEG + this.modelYawOffset; + const a = damp(this.modelTurnSmoothing, dt); + this._modelYaw = lerpAngle(this._modelYaw, targetYaw, a); + } + this.characterModel.setLocalEulerAngles(0, this._modelYaw, 0); + } + + // animation speed bucket + let bucket = 0; + if (horizSpeed >= this.jogSpeedThreshold) { + bucket = 2; + } else if (horizSpeed >= this.walkSpeedThreshold) { + bucket = 1; + } + if (bucket !== this._lastSpeedBucket) { + this._lastSpeedBucket = bucket; + this.fire('speed', bucket); + } + + // ---- camera positioning ---- + const charPos = this.entity.getPosition(); + + // pivot = character + cameraHeight on Y; camera orbits this point + const pivot = v.copy(charPos); + pivot.y += this.cameraHeight; + + // smooth wheel zoom: lerp the actual cameraDistance toward the target + const zoomA = damp(this.zoomDamping, dt); + this.cameraDistance = math.lerp(this.cameraDistance, this._targetCameraDistance, zoomA); + + // desired camera offset from pivot using current yaw/pitch. + // Negative pitch in the rotation so the convention is: positive pitch + // raises the camera above the pivot looking down; negative pitch drops + // the camera below the pivot looking up. + const orbitQ = tmpQ.setFromEulerAngles(-this._pitch, this._yaw, 0); + const back = orbitQ.transformVector(Vec3.BACK, v2); + + // wall collision: raycast from pivot to where the camera WOULD sit at + // its full desired distance, then smoothly track the resulting clamped + // distance so that the camera eases in/out behind walls rather than + // snapping when geometry comes between the camera and the character. + const sys = /** @type {RigidBodyComponentSystem} */ (this._rigidbody.system); + const probePos = v3.copy(pivot).add(v4.copy(back).mulScalar(this.cameraDistance)); + const hit = sys.raycastFirst(pivot, probePos); + let targetClamped = this.cameraDistance; + if (hit) { + const hitDist = v4.copy(hit.point).sub(pivot).length(); + const safeDist = Math.max(hitDist - this.cameraCollisionPadding, this.cameraMinDistance); + targetClamped = Math.min(targetClamped, safeDist); + } + const collA = damp(this.cameraCollisionDamping, dt); + this._clampedDistance = math.lerp(this._clampedDistance, targetClamped, collA); + + const finalPos = v3.copy(pivot).add(back.mulScalar(this._clampedDistance)); + + // clamp the camera Y so it never drops below a configurable offset + // relative to the character (prevents the orbit from putting the + // camera lower than the character itself when the player pitches down). + const minY = charPos.y + this.cameraMinHeightAboveCharacter; + if (finalPos.y < minY) { + finalPos.y = minY; + } + + if (!this._camInitialized) { + this._camPos.copy(finalPos); + this._camLookAt.copy(pivot); + this._camInitialized = true; + } else { + const posA = damp(this.cameraPositionDamping, dt); + this._camPos.lerp(this._camPos, finalPos, posA); + const lookA = damp(this.cameraLookAtDamping, dt); + this._camLookAt.lerp(this._camLookAt, pivot, lookA); + } + + this.camera.setPosition(this._camPos); + this.camera.lookAt(this._camLookAt); + } + + /** + * @param {number} dt - The delta time. + */ + update(dt) { + if (!this._ready) { + return; + } + + const { keyCode } = KeyboardMouseSource; + const { buttonCode } = GamepadSource; + + const { key, button, mouse, wheel } = this._desktopInput.read(); + const { leftInput, rightInput, doubleTap } = this._mobileInput.read(); + const { buttons, leftStick, rightStick } = this._gamepadInput.read(); + + applyDeadZone(leftStick, this.gamePadDeadZoneLow, this.gamePadDeadZoneHigh); + applyDeadZone(rightStick, this.gamePadDeadZoneLow, this.gamePadDeadZoneHigh); + + // mouse scroll wheel zoom (wheel[0] is deltaY: positive when scrolling + // down -> zoom out; negative when scrolling up -> zoom in). The wheel + // mutates a TARGET distance which the actual cameraDistance smoothly + // lerps toward each frame in _updateController, giving a glide-zoom. + if (wheel[0] !== 0) { + this._targetCameraDistance = math.clamp( + this._targetCameraDistance + wheel[0] * this.zoomSpeed, + this.cameraDistanceMin, + this.cameraDistanceMax + ); + } + + // update state + this._state.axis.add(v.set( + (key[keyCode.D] - key[keyCode.A]) + (key[keyCode.RIGHT] - key[keyCode.LEFT]), + 0, + (key[keyCode.W] - key[keyCode.S]) + (key[keyCode.UP] - key[keyCode.DOWN]) + )); + for (let i = 0; i < this._state.mouse.length; i++) { + this._state.mouse[i] += button[i]; + } + this._state.a += buttons[buttonCode.A]; + this._state.space += key[keyCode.SPACE]; + this._state.shift += key[keyCode.SHIFT]; + this._state.ctrl += key[keyCode.CTRL]; + + // grounded raycast (matches FPS controller convention) + const start = this.entity.getPosition(); + const end = v.copy(start).add(Vec3.DOWN); + end.y -= 0.1; + const sys = /** @type {RigidBodyComponentSystem} */ (this._rigidbody.system); + this._grounded = !!sys.raycastFirst(start, end); + + const moveMult = (this._grounded ? this.speedGround : this.speedAir) * dt; + const rotateMult = this.lookSens * 60 * dt; + const rotateTouchMult = this._mobileTurnSpeed * dt; + const rotateJoystickMult = this.gamePadTurnSpeed * dt; + + const { deltas } = frame; + + // desktop move + v.set(0, 0, 0); + const keyMove = this._state.axis.clone().normalize(); + v.add(keyMove.mulScalar(moveMult * (this._state.shift ? this.sprintMult : 1))); + deltas.move.append([v.x, v.y, v.z]); + + // desktop rotate + v.set(0, 0, 0); + const mouseRotate = new Vec3(mouse[0], mouse[1], 0); + v.add(mouseRotate.mulScalar(rotateMult)); + deltas.rotate.append([v.x, v.y, v.z]); + + // desktop jump + deltas.jump.append([this._state.space]); + + // mobile move + v.set(0, 0, 0); + const flyMove = new Vec3(leftInput[0], 0, -leftInput[1]); + flyMove.mulScalar(2); + const mag = flyMove.length(); + if (mag > 1) { + flyMove.normalize(); + } + v.add(flyMove.mulScalar(moveMult * (mag > 2 - EPSILON ? this.sprintMult : 1))); + deltas.move.append([v.x, v.y, v.z]); + + // mobile rotate + v.set(0, 0, 0); + const mobileRotate = new Vec3(rightInput[0], rightInput[1], 0); + v.add(mobileRotate.mulScalar(rotateTouchMult)); + deltas.rotate.append([v.x, v.y, v.z]); + + // mobile jump + deltas.jump.append([doubleTap[0]]); + + // gamepad move + v.set(0, 0, 0); + const stickMove = new Vec3(leftStick[0], 0, -leftStick[1]); + v.add(stickMove.mulScalar(moveMult)); + deltas.move.append([v.x, v.y, v.z]); + + // gamepad rotate + v.set(0, 0, 0); + const stickRotate = new Vec3(rightStick[0], rightStick[1], 0); + v.add(stickRotate.mulScalar(rotateJoystickMult)); + deltas.rotate.append([v.x, v.y, v.z]); + + // gamepad jump + deltas.jump.append([this._state.a]); + + this._updateController(frame, dt); + } + + destroy() { + this._desktopInput.destroy(); + this._mobileInput.destroy(); + this._gamepadInput.destroy(); + } +} + +export { ThirdPersonController };