├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── docs ├── CNAME ├── examples │ ├── assets │ │ ├── grid.png │ │ └── navmesh.glb │ ├── nav-mesh-basic.md │ ├── basic.md │ ├── head-occlusion.md │ ├── vignette.md │ └── rc.md ├── .overrides │ └── example.html ├── reference │ ├── nav-mesh │ │ ├── nav-mesh.component.md │ │ └── nav-mesh-strategy.component.md │ ├── auxiliary │ │ ├── al-head-occlusion-fade.primitive.md │ │ ├── al-vignette.primitive.md │ │ └── al-snap-turn-fade.primitive.md │ └── movement │ │ ├── gravity.component.md │ │ ├── smooth-turn.component.md │ │ ├── snap-turn.component.md │ │ └── smooth-locomotion.component.md └── index.md ├── .gitignore ├── dev ├── grid.png └── index.html ├── .vscode └── settings.json ├── .yarnrc.yml ├── src ├── main.ts ├── nav-mesh │ ├── index.ts │ ├── strategy │ │ ├── utils.ts │ │ ├── strategy.interface.ts │ │ ├── simple-strategy.ts │ │ └── scan-strategy.ts │ ├── nav-mesh.component.ts │ ├── nav-mesh.system.ts │ └── nav-mesh-strategy.component.ts ├── movement │ ├── index.ts │ ├── locomotion.system.ts │ ├── turn.ts │ ├── gravity.component.ts │ ├── smooth-turn.component.ts │ ├── snap-turn.component.ts │ └── smooth-locomotion.component.ts ├── auxiliary │ ├── index.ts │ ├── head-occlusion.component.ts │ ├── motion-input.component.ts │ ├── vignette.primitive.ts │ ├── nav-mesh-constrained.component.ts │ ├── rotation-input.component.ts │ └── fade.primitive.ts └── events.ts ├── typedoc.json ├── rollup.config.dev.js ├── tsconfig.json ├── CHANGELOG.md ├── mkdocs.yml ├── LICENSE ├── rollup.config.prod.js ├── package.json └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: fernsolutions -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | aframe-locomotion.fern.solutions -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .yarn/ 3 | dist/ 4 | site/ 5 | temp/ -------------------------------------------------------------------------------- /dev/grid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrxz/aframe-locomotion/HEAD/dev/grid.png -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | compressionLevel: mixed 2 | 3 | enableGlobalCache: false 4 | 5 | nodeLinker: node-modules 6 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import './auxiliary'; 2 | import './movement'; 3 | import './nav-mesh'; 4 | import './events'; 5 | -------------------------------------------------------------------------------- /docs/examples/assets/grid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrxz/aframe-locomotion/HEAD/docs/examples/assets/grid.png -------------------------------------------------------------------------------- /docs/examples/assets/navmesh.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrxz/aframe-locomotion/HEAD/docs/examples/assets/navmesh.glb -------------------------------------------------------------------------------- /src/nav-mesh/index.ts: -------------------------------------------------------------------------------- 1 | import './nav-mesh-strategy.component'; 2 | import './nav-mesh.component'; 3 | import './nav-mesh.system'; 4 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://typedoc.org/schema.json", 3 | "blockTags": [ "@emits" ], 4 | "excludeInternal": true 5 | } 6 | -------------------------------------------------------------------------------- /src/movement/index.ts: -------------------------------------------------------------------------------- 1 | import './gravity.component'; 2 | import './locomotion.system'; 3 | import './smooth-locomotion.component'; 4 | import './smooth-turn.component'; 5 | import './snap-turn.component'; -------------------------------------------------------------------------------- /src/auxiliary/index.ts: -------------------------------------------------------------------------------- 1 | import './fade.primitive'; 2 | import './head-occlusion.component'; 3 | import './motion-input.component'; 4 | import './nav-mesh-constrained.component'; 5 | import './rotation-input.component'; 6 | import './vignette.primitive'; -------------------------------------------------------------------------------- /src/nav-mesh/strategy/utils.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from "three"; 2 | 3 | export const castRay = (function() { 4 | const raycaster = new THREE.Raycaster(); 5 | return function(position: THREE.Vector3, direction: THREE.Vector3, meshes: Array) { 6 | raycaster.set(position, direction); 7 | return raycaster.intersectObjects(meshes, true); 8 | } 9 | })(); -------------------------------------------------------------------------------- /src/events.ts: -------------------------------------------------------------------------------- 1 | import type { DetailEvent } from 'aframe'; 2 | 3 | declare module "aframe" { 4 | export interface EntityEvents { 5 | "rotation": DetailEvent<{degrees: number, source: Entity}>, 6 | "prerotation": DetailEvent<{progress: number, source: Entity}>, 7 | "postrotation": DetailEvent<{progress: number, source: Entity}>, 8 | "motion": DetailEvent<{inputMagnitude: number, inAir: boolean, source: Entity}> 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/nav-mesh/strategy/strategy.interface.ts: -------------------------------------------------------------------------------- 1 | import type { THREE } from 'aframe'; 2 | 3 | type StrategyResult = { 4 | result: false, 5 | position: THREE.Vector3, 6 | ground: undefined, 7 | } | { 8 | result: true, 9 | position: THREE.Vector3, 10 | ground: THREE.Vector3 11 | }; 12 | 13 | export type CandidateValidator = (candidate: THREE.Vector3, ground: THREE.Vector3) => boolean; 14 | 15 | export interface NavMeshStrategy { 16 | approveMovement( 17 | oldPosition: THREE.Vector3, 18 | newPosition: THREE.Vector3, 19 | navMeshes: Array, 20 | candidateValidator: CandidateValidator): StrategyResult; 21 | }; 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | push: 4 | branches: 5 | - main 6 | permissions: 7 | contents: write 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-python@v4 14 | with: 15 | python-version: 3.x 16 | - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV 17 | - uses: actions/cache@v3 18 | with: 19 | key: mkdocs-material-${{ env.cache_id }} 20 | path: .cache 21 | restore-keys: | 22 | mkdocs-material- 23 | - run: pip install mkdocs-material 24 | - run: mkdocs gh-deploy --force 25 | -------------------------------------------------------------------------------- /rollup.config.dev.js: -------------------------------------------------------------------------------- 1 | import typescript from '@rollup/plugin-typescript'; 2 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 3 | import pkg from './package.json'; 4 | 5 | export default [ 6 | { 7 | input: 'src/main.ts', 8 | plugins: [ 9 | nodeResolve({ resolveOnly: ['aframe-typescript'] }), 10 | typescript(), 11 | ], 12 | external: ['aframe', 'three'], 13 | output: [ 14 | { 15 | name: 'aframe-locomotion', 16 | file: pkg.browser, 17 | format: 'umd', 18 | globals: { 19 | aframe: 'AFRAME', 20 | three: 'THREE' 21 | } 22 | } 23 | ], 24 | } 25 | ] -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "useDefineForClassFields": true, 5 | "module": "esnext", 6 | "lib": ["esnext", "dom"], 7 | "moduleResolution": "node", 8 | "strict": true, 9 | "resolveJsonModule": true, 10 | "isolatedModules": true, 11 | "esModuleInterop": true, 12 | "noEmit": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "noImplicitReturns": true, 16 | "noImplicitAny": true, 17 | "skipLibCheck": true, 18 | "declaration": false, 19 | "rootDir": "src", 20 | "paths": { 21 | "aframe": ["./node_modules/aframe-types/"] 22 | } 23 | }, 24 | "include": ["src"], 25 | "exclude": ["node_modules", "**/dist/*"] 26 | } 27 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.2.0 (2022-05-08) 2 | * Falling and fall-prevention (`smooth-locomotion` and `gravity`) 3 | * NavMesh strategy that scans multiple alternatives (allows sliding across walls) 4 | * Fading during snap-turns (`snap-turn` and `al-snap-turn-fade` primitive) 5 | * Detect head inside objects and blank out the screen (`al-head-occlusion-fade` primitive) 6 | 7 | # 0.1.1 (2022-05-04) 8 | * Smooth turning component `smooth-turn` 9 | * Input mode property for switching between analog and binary input handling (`smooth-turn` and `smooth-locomotion`) 10 | * New RC car example 11 | * Vignette primitive `al-vignette` and corresponding example 12 | * Initial nav-mesh support (`nav-mesh-strategy` component) 13 | 14 | # 0.1.0 (2022-05-03) 15 | * Smooth locomotion component `smooth-locomotion` 16 | * Snap turning component `snap-turn` -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: A-Frame Locomotion Docs 2 | site_url: https://aframe-locomotion.fern.solutions/ 3 | theme: 4 | name: material 5 | features: 6 | - content.code.copy 7 | - navigation.sections 8 | - navigation.expand 9 | - navigation.tabs 10 | custom_dir: docs/.overrides 11 | 12 | markdown_extensions: 13 | - pymdownx.highlight: 14 | anchor_linenums: true 15 | line_spans: __span 16 | pygments_lang_class: true 17 | - pymdownx.inlinehilite 18 | - pymdownx.snippets 19 | - pymdownx.superfences 20 | 21 | extra: 22 | social: 23 | - icon: fontawesome/brands/twitter 24 | link: https://twitter.com/noerihuisman 25 | - icon: fontawesome/brands/mastodon 26 | link: https://arvr.social/@noerihuisman 27 | - icon: fontawesome/brands/github 28 | link: https://github.com/mrxz 29 | 30 | repo_url: https://github.com/mrxz/aframe-locomotion -------------------------------------------------------------------------------- /docs/examples/nav-mesh-basic.md: -------------------------------------------------------------------------------- 1 | --- 2 | template: example.html 3 | --- 4 | 5 | ### Code 6 | ```HTML 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | ``` 21 | 22 | ### See Also 23 | - [nav-mesh-strategy component](../reference/nav-mesh/nav-mesh-strategy.component.md) 24 | - [nav-mesh component](../reference/nav-mesh/nav-mesh.component.md) 25 | -------------------------------------------------------------------------------- /dev/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /docs/examples/basic.md: -------------------------------------------------------------------------------- 1 | --- 2 | template: example.html 3 | --- 4 | 5 | ### Code 6 | ```HTML 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | ``` 22 | 23 | ### See Also 24 | - [smooth-locomotion component](../reference/movement/smooth-locomotion.component.md) 25 | - [snap-turn component](../reference/movement/snap-turn.component.md) 26 | -------------------------------------------------------------------------------- /docs/examples/head-occlusion.md: -------------------------------------------------------------------------------- 1 | --- 2 | template: example.html 3 | --- 4 | 5 | ### Code 6 | ```HTML 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | ``` 23 | 24 | ### See Also 25 | 26 | - [<al-head-occlusion-fade> primitive](../reference/auxiliary/al-head-occlusion-fade.primitive.md) 27 | 28 | -------------------------------------------------------------------------------- /docs/examples/vignette.md: -------------------------------------------------------------------------------- 1 | --- 2 | template: example.html 3 | --- 4 | 5 | ### Code 6 | ```HTML 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | ``` 24 | 25 | ### See Also 26 | - [<al-vignette> primitive](../reference/auxiliary/al-vignette.primitive.md) 27 | - [snap-turn component](../reference/movement/snap-turn.component.md) 28 | -------------------------------------------------------------------------------- /docs/.overrides/example.html: -------------------------------------------------------------------------------- 1 | {% extends "main.html" %} 2 | 3 | {% block libs %} 4 | {{ super() }} 5 | 6 | 7 | {% endblock %} 8 | 9 | {% block content %} 10 | 11 | 15 | {% if "\x3ch1" not in page.content %} 16 |

{{ page.title | d(config.site_name, true)}}

17 | {% endif %} 18 | 19 |
20 | 21 | {{ page.content }} 22 | 23 | 28 | {% endblock %} -------------------------------------------------------------------------------- /docs/reference/nav-mesh/nav-mesh.component.md: -------------------------------------------------------------------------------- 1 | # nav-mesh 2 | This component marks an object as a nav mesh, which can be used in motion. 3 | This component only has an effect when the nav-mesh system is activated by 4 | picking a [`nav-mesh-strategy`](nav-mesh-strategy.component.md). 5 | 6 | ## Properties 7 | | Property | Description | Type | Default Value | 8 | |----------|-------------|------|---------------| 9 | | This component has no properties |||| 10 | 11 | 12 | 13 | ## Example 14 | Mark any entity that holds a navigation mesh: 15 | ```HTML 16 | 17 | ``` 18 | 19 | > **Note:** In many cases the navigation meshes should not be visible. 20 | The `nav-mesh` component doesn't handle this for you, so make sure to manually add 21 | `material="visible: false"` or hide the mesh in some other way. 22 | 23 | 24 | ## Source 25 | [`src/nav-mesh/nav-mesh.component.ts:19`](https://github.com/mrxz/aframe-locomotion/blob/2c33638c/src/nav-mesh/nav-mesh.component.ts#L19) 26 | -------------------------------------------------------------------------------- /docs/examples/rc.md: -------------------------------------------------------------------------------- 1 | --- 2 | template: example.html 3 | --- 4 | 5 | ### Code 6 | ```HTML 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | ``` 25 | 26 | ### See Also 27 | - [smooth-locomotion component](../reference/movement/smooth-locomotion.component.md) 28 | - [smooth-turn component](../reference/movement/smooth-turn.component.md) 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Noeri Huisman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/movement/locomotion.system.ts: -------------------------------------------------------------------------------- 1 | import * as AFRAME from 'aframe'; 2 | 3 | export interface PostMotionCallback { 4 | postMotion(): void 5 | }; 6 | 7 | /** 8 | * @internal 9 | */ 10 | export const LocomotionSystem = AFRAME.registerSystem('locomotion', { 11 | schema: {}, 12 | __fields: {} as { 13 | postMotionCallbacks: Array 14 | }, 15 | init: function() { 16 | this.postMotionCallbacks = []; 17 | }, 18 | tick: function() { 19 | // Post motion handle 20 | this.postMotionCallbacks.forEach(c => c.postMotion()); 21 | }, 22 | addPostMotionCallback(postMotionCallback: PostMotionCallback) { 23 | this.postMotionCallbacks.push(postMotionCallback); 24 | }, 25 | removePostMotionCallback(postMotionCallback: PostMotionCallback) { 26 | const index = this.postMotionCallbacks.indexOf(postMotionCallback); 27 | if(index !== -1) { 28 | this.postMotionCallbacks.splice(index, 1); 29 | } 30 | } 31 | }); 32 | 33 | declare module "aframe" { 34 | export interface Systems { 35 | "locomotion": InstanceType, 36 | } 37 | } -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # A-Frame Locomotion 2 | A-Frame Locomotion is a collection of components, systems and primitives for all forms of locomotion in VR. It aims to be simple, modular, flexible and 'just work'. You can freely pick out the things you need or combine them in new and creative ways. 3 | 4 | Here are some quick examples of the locomotion schemes you can achieve: 5 | 6 | ## Quick examples 7 | |Example|Components| 8 | |-------|----------| 9 | |Smooth locomotion with snap turning|`smooth-locomotion` and `snap-turn`| 10 | |Snap turning with fade transitions|`snap-turn="delay: 0.1"` and ``| 11 | |Smooth turning|`smooth-turning`| 12 | |Vignette when moving|`al-vignette`| 13 | |Smooth locomotion on a nav-mesh|`smooth-locomotion` with `nav-mesh-strategy` and `nav-mesh`| 14 | |Smooth locomotion without falling of edges|`smooth-locomotion="fallMode: prevent"` with `nav-mesh-strategy` and `nav-mesh`| 15 | |Remote controlling an actor|`smooth-locomotion="target: #actor; reference: #actor"`| 16 | 17 | > **Note:** The above examples assume a [camera rig](https://aframe.io/docs/1.3.0/components/camera.html#examples) to be used and omits corresponding property values for brevity. See reference documentation for the mentioned components or explore the full examples. -------------------------------------------------------------------------------- /docs/reference/auxiliary/al-head-occlusion-fade.primitive.md: -------------------------------------------------------------------------------- 1 | # al-head-occlusion-fade 2 | Primitive that fades the view in/out when the head is placed inside an wall or object. 3 | 4 | ## Attributes 5 | | Attribute | Maps to | Description | Type | Default Value | 6 | |-----------|---------|-------------|------|---------------| 7 | | objects | head-occlusion.objects | Selector for all the objects to check head occlusion for | `selectorAll` | | 8 | 9 | 10 | 11 | ## Example 12 | The `al-head-occlusion-fade` primitive should be a direct child of the camera. The following shows 13 | the primitive being used. 14 | ```HTML 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | ``` 23 | 24 | > **Note:** The head occlusion detection uses the XR camera, meaning it won't activate when vr-mode isn't 25 | > active. If this is something you need/want, please open a new issue on GitHub and indicate your use case. 26 | 27 | 28 | ## Source 29 | [`src/auxiliary/fade.primitive.ts:63`](https://github.com/mrxz/aframe-locomotion/blob/2c33638c/src/auxiliary/fade.primitive.ts#L63) 30 | -------------------------------------------------------------------------------- /docs/reference/movement/gravity.component.md: -------------------------------------------------------------------------------- 1 | # gravity 2 | This component is a 'velocity' component and can be used to influence 3 | motion based components like [`smooth-locomotion`](smooth-locomotion.component.md). 4 | On its own the component won't do anything. 5 | 6 | ## Properties 7 | | Property | Description | Type | Default Value | 8 | |----------|-------------|------|---------------| 9 | | strength | The gravitational acceleration in m/s^2 | `number` | 9.81 | 10 | 11 | 12 | ## Remarks 13 | The `gravity` component should be applied to the target of motion. 14 | When using [`smooth-locomotion`](smooth-locomotion.component.md) with the default camera rig, 15 | the rig element is the target and should have the `gravity` component on it. 16 | 17 | ## Example 18 | ```HTML 19 | 20 | 21 | 22 | 25 | 26 | 27 | ``` 28 | 29 | 30 | ## Source 31 | [`src/movement/gravity.component.ts:28`](https://github.com/mrxz/aframe-locomotion/blob/2c33638c/src/movement/gravity.component.ts#L28) 32 | -------------------------------------------------------------------------------- /rollup.config.prod.js: -------------------------------------------------------------------------------- 1 | import { terser } from "rollup-plugin-terser"; 2 | import typescript from '@rollup/plugin-typescript'; 3 | import dts from "rollup-plugin-dts"; 4 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 5 | import pkg from './package.json'; 6 | 7 | export default [ 8 | { 9 | input: 'src/main.ts', 10 | plugins: [ 11 | nodeResolve({ resolveOnly: ['aframe-typescript'] }), 12 | typescript({ compilerOptions: { declaration: true, declarationDir: 'typings' }}), 13 | terser(), 14 | ], 15 | external: ['aframe', 'three'], 16 | output: [ 17 | { 18 | name: 'aframe-locomotion', 19 | file: pkg.browser, 20 | format: 'umd', 21 | globals: { 22 | aframe: 'AFRAME', 23 | three: 'THREE' 24 | } 25 | }, 26 | { 27 | file: pkg.module, 28 | format: 'es' 29 | }, 30 | ], 31 | }, 32 | { 33 | input: './dist/typings/main.d.ts', 34 | output: [{ file: "dist/aframe-locomotion.d.ts", format: "es" }], 35 | external: ['aframe', 'three'], 36 | plugins: [dts()], 37 | } 38 | ] -------------------------------------------------------------------------------- /docs/reference/auxiliary/al-vignette.primitive.md: -------------------------------------------------------------------------------- 1 | # al-vignette 2 | Primitive that shows a vignette when moving. 3 | 4 | ## Attributes 5 | | Attribute | Maps to | Description | Type | Default Value | 6 | |-----------|---------|-------------|------|---------------| 7 | | motion-source | motion-input.source | Selector for the entity that is the target of a moving component (like [`smooth-locomotion`](../movement/smooth-locomotion.component.md)). | `selector` | | 8 | | intensity | motion-input.maxOutput | | `number` | 2 | 9 | 10 | 11 | 12 | ## Example 13 | The `al-vignette` primitive should be a direct child of the camera. The following shows 14 | the primitive being used with smooth locomotion. 15 | ```HTML 16 | 17 | 18 | 19 | 20 | 21 | 25 | 26 | 27 | ``` 28 | 29 | 30 | ## Source 31 | [`src/auxiliary/vignette.primitive.ts:46`](https://github.com/mrxz/aframe-locomotion/blob/2c33638c/src/auxiliary/vignette.primitive.ts#L46) 32 | -------------------------------------------------------------------------------- /src/nav-mesh/nav-mesh.component.ts: -------------------------------------------------------------------------------- 1 | import * as AFRAME from "aframe"; 2 | import type { NavMeshStrategyComponent } from "./nav-mesh-strategy.component"; 3 | 4 | /** 5 | * This component marks an object as a nav mesh, which can be used in motion. 6 | * This component only has an effect when the nav-mesh system is activated by 7 | * picking a {@link NavMeshStrategyComponent}. 8 | * 9 | * @example 10 | * Mark any entity that holds a navigation mesh: 11 | * ```HTML 12 | * 13 | * ``` 14 | * 15 | * > **Note:** In many cases the navigation meshes should not be visible. 16 | * The `nav-mesh` component doesn't handle this for you, so make sure to manually add 17 | * `material="visible: false"` or hide the mesh in some other way. 18 | */ 19 | export const NavMeshComponent = AFRAME.registerComponent('nav-mesh', { 20 | schema: {}, 21 | init: function() { 22 | this.el.addEventListener('model-loaded', (_) => { 23 | this.system.updateNavMeshes(); 24 | }); 25 | this.system.registerNavMesh(this.el); 26 | }, 27 | 28 | remove: function() { 29 | this.system.unregisterNavMesh(this.el); 30 | } 31 | }); 32 | 33 | declare module "aframe" { 34 | export interface Components { 35 | "nav-mesh": InstanceType, 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /docs/reference/auxiliary/al-snap-turn-fade.primitive.md: -------------------------------------------------------------------------------- 1 | # al-snap-turn-fade 2 | Primitive that fades the view in/out when snap-turning. Requires the usage of [`snap-turn`](../movement/snap-turn.component.md). 3 | 4 | ## Attributes 5 | | Attribute | Maps to | Description | Type | Default Value | 6 | |-----------|---------|-------------|------|---------------| 7 | | rotation-source | rotation-input.source | Selector for the entity that is rotated. This should be the target of [`snap-turn`](../movement/snap-turn.component.md) | `selector` | | 8 | 9 | 10 | 11 | ## Example 12 | The `al-snap-turn-fade` primitive should be a direct child of the camera. The following shows 13 | the primitive being used with snap-turning. Notice the configured delay, without it the snap 14 | is instant and no fade will take place: 15 | ```HTML 16 | 17 | 18 | 19 | 20 | 21 | 24 | 25 | 26 | ``` 27 | 28 | 29 | ## Source 30 | [`src/auxiliary/fade.primitive.ts:31`](https://github.com/mrxz/aframe-locomotion/blob/2c33638c/src/auxiliary/fade.primitive.ts#L31) 31 | -------------------------------------------------------------------------------- /docs/reference/nav-mesh/nav-mesh-strategy.component.md: -------------------------------------------------------------------------------- 1 | # nav-mesh-strategy 2 | Selects the nav mesh strategy that is used by all aframe-locomotion components. 3 | This component should be added to the scene if you want to make use of nav mesh based functionality. 4 | Once configured, nav meshes need to be marked using the [`nav-mesh`](nav-mesh.component.md) component. 5 | 6 | ## Properties 7 | | Property | Description | Type | Default Value | 8 | |----------|-------------|------|---------------| 9 | | strategy | Either `simple` or `scan`. The `simple` strategy allows to check if movement is valid based on the nav-mesh in a binary fashion (movement is either valid or not). The `scan` strategy falls back to alternatives that are slightly to the side of the movement. This allows sliding across walls. | `string` | scan | 10 | 11 | 12 | 13 | ## Example 14 | Add the `nav-mesh-strategy` component to the scene 15 | ```HTML 16 | 17 | 18 | 19 | ``` 20 | 21 | > **Note:** In many cases the navigation meshes should not be visible. The `nav-mesh` component 22 | doesn't handle this for you, so make sure to manually add `material="visible: false"` or hide 23 | the mesh in some other way. 24 | 25 | 26 | ## Source 27 | [`src/nav-mesh/nav-mesh-strategy.component.ts:29`](https://github.com/mrxz/aframe-locomotion/blob/2c33638c/src/nav-mesh/nav-mesh-strategy.component.ts#L29) 28 | -------------------------------------------------------------------------------- /src/nav-mesh/strategy/simple-strategy.ts: -------------------------------------------------------------------------------- 1 | import { NavMeshStrategy } from "./strategy.interface"; 2 | import { castRay } from "./utils"; 3 | import * as THREE from "three"; 4 | 5 | const STEP_SIZE = 0.5; 6 | 7 | export const simpleNavMeshStrategy: NavMeshStrategy = (function() { 8 | const castPoint = new THREE.Vector3(); 9 | const castOffset = new THREE.Vector3(0, STEP_SIZE, 0); 10 | const castDirection = new THREE.Vector3(0, -1, 0); 11 | 12 | return { 13 | approveMovement: function(oldPosition, newPosition, navMeshes, candidateValidator) { 14 | castPoint.copy(newPosition).add(castOffset); 15 | const intersections = castRay(castPoint, castDirection, navMeshes); 16 | 17 | // No nav mesh below the new position, so disapprove 18 | if(intersections.length === 0 || !candidateValidator(newPosition, intersections[0].point)) { 19 | return { 20 | result: false, 21 | position: oldPosition 22 | }; 23 | } 24 | 25 | // Check if new position is a step up. 26 | const intersectionPoint = intersections[0].point; 27 | if(intersectionPoint.y > newPosition.y) { 28 | newPosition.y = intersectionPoint.y; 29 | } 30 | 31 | return { 32 | result: true, 33 | position: newPosition, 34 | ground: intersectionPoint 35 | }; 36 | } 37 | } 38 | })(); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aframe-locomotion", 3 | "version": "0.2.0", 4 | "description": "A-Frame components for smooth locomotion and snap turning", 5 | "module": "dist/aframe-locomotion.esm.min.js", 6 | "browser": "dist/aframe-locomotion.umd.min.js", 7 | "main": "dist/aframe-locomotion.esm.min.js", 8 | "types": "dist/aframe-locomotion.d.ts", 9 | "author": "Noeri Huisman", 10 | "license": "MIT", 11 | "scripts": { 12 | "dev": "concurrently \"rollup -c rollup.config.dev.js -w\" \"live-server --port=4000 --no-browser ./dev --mount=/js:./dist\"", 13 | "build": "rollup -c rollup.config.prod.js", 14 | "docs": "rimraf docs/reference/ && aframe-typescript-docs-generator -o docs/reference/" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/mrxz/aframe-locomotion" 19 | }, 20 | "bugs": { 21 | "url": "https://github.com/mrxz/aframe-locomotion/issues" 22 | }, 23 | "homepage": "https://github.com/mrxz/aframe-locomotion#readme", 24 | "keywords": [ 25 | "aframe", 26 | "vr", 27 | "xr", 28 | "webxr" 29 | ], 30 | "files": [ 31 | "dist" 32 | ], 33 | "devDependencies": { 34 | "@compodoc/live-server": "1.2.3", 35 | "@rollup/plugin-node-resolve": "^15.0.2", 36 | "@rollup/plugin-typescript": "^11.1.1", 37 | "@types/animejs": "3.1.0", 38 | "@types/three": "0.164.0", 39 | "aframe-types": "^0.9.5", 40 | "aframe-typescript-docs-generator": "^0.9.2", 41 | "concurrently": "^7.1.0", 42 | "handlebars": "^4.7.7", 43 | "rimraf": "^5.0.1", 44 | "rollup": "^2.71.1", 45 | "rollup-plugin-dts": "^5.3.0", 46 | "rollup-plugin-terser": "^7.0.2", 47 | "tsup": "^6.7.0", 48 | "typedoc": "^0.24.7", 49 | "typescript": "^5.4.0" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/nav-mesh/nav-mesh.system.ts: -------------------------------------------------------------------------------- 1 | import * as AFRAME from 'aframe'; 2 | import type { Entity, THREE } from "aframe"; 3 | import { NavMeshStrategy } from "./strategy/strategy.interface"; 4 | 5 | /** @internal */ 6 | export const NavMeshSystem = AFRAME.registerSystem('nav-mesh', { 7 | schema: {}, 8 | active: false as boolean, 9 | 10 | __fields: {} as { 11 | navMeshEntities: Array; 12 | navMeshes: Array; 13 | navMeshStrategy?: NavMeshStrategy; 14 | }, 15 | 16 | init: function() { 17 | this.navMeshEntities = []; 18 | this.navMeshes = []; 19 | this.navMeshStrategy = undefined; 20 | }, 21 | 22 | registerNavMesh: function(el: Entity) { 23 | this.navMeshEntities.push(el); 24 | this.updateNavMeshes(); 25 | }, 26 | 27 | unregisterNavMesh: function(el: Entity) { 28 | var index = this.navMeshEntities.indexOf(el); 29 | this.navMeshEntities.splice(index, 1); 30 | this.updateNavMeshes(); 31 | }, 32 | 33 | updateNavMeshes: function() { 34 | this.navMeshes = this.navMeshEntities 35 | .map(el => el.getObject3D('mesh') as THREE.Mesh) 36 | .filter(mesh => mesh); 37 | }, 38 | 39 | switchStrategy: function(strategy: any) { 40 | this.navMeshStrategy = strategy; 41 | }, 42 | 43 | approveMovement: function(oldPosition: THREE.Vector3, newPosition: THREE.Vector3, candidateValidator: any) { 44 | return this.navMeshStrategy!.approveMovement(oldPosition, newPosition, this.navMeshes, candidateValidator || (() => true)); 45 | } 46 | }); 47 | 48 | 49 | declare module "aframe" { 50 | export interface Systems { 51 | "nav-mesh": InstanceType, 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/movement/turn.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from "three"; 2 | 3 | export const rotateAroundWorldUp = (function() { 4 | const v3 = new THREE.Vector3(); 5 | const m4A = new THREE.Matrix4(); 6 | const m4B = new THREE.Matrix4(); 7 | 8 | const referenceMatrix = new THREE.Matrix4(); 9 | const desiredReferenceMatrix = new THREE.Matrix4(); 10 | const inverseTargetMatrix = new THREE.Matrix4(); 11 | const referenceRelativeToTarget = new THREE.Matrix4(); 12 | const inverseReferenceMatrix = new THREE.Matrix4(); 13 | const newTargetMatrix = new THREE.Matrix4(); 14 | 15 | return function(target: THREE.Object3D, reference: THREE.Object3D, degrees: number) { 16 | if(!degrees || !target || !reference) { 17 | return; 18 | } 19 | 20 | target.updateMatrixWorld(); 21 | 22 | // Compute the desired rotation of the reference. 23 | referenceMatrix.copy(reference.matrixWorld); 24 | inverseTargetMatrix.copy(target.matrixWorld).invert(); 25 | 26 | const rotationMatrix = m4A.makeRotationY(degrees * Math.PI / 180).multiply(m4B.extractRotation(referenceMatrix)); 27 | desiredReferenceMatrix.copy(rotationMatrix) 28 | .scale(v3.setFromMatrixScale(referenceMatrix)) 29 | .setPosition(v3.setFromMatrixPosition(referenceMatrix)); 30 | 31 | referenceRelativeToTarget.multiplyMatrices(inverseTargetMatrix, referenceMatrix); 32 | inverseReferenceMatrix.copy(referenceRelativeToTarget).invert(); 33 | newTargetMatrix.multiplyMatrices(desiredReferenceMatrix, inverseReferenceMatrix); 34 | 35 | // Take possible parents into account 36 | if(target.parent) { 37 | const inverseParentMatrix = inverseTargetMatrix.copy(target.parent.matrixWorld).invert(); 38 | newTargetMatrix.multiplyMatrices(inverseParentMatrix, newTargetMatrix); 39 | } 40 | 41 | newTargetMatrix.decompose(target.position, target.quaternion, target.scale); 42 | } 43 | })() -------------------------------------------------------------------------------- /docs/reference/movement/smooth-turn.component.md: -------------------------------------------------------------------------------- 1 | # smooth-turn 2 | Component for reading the input of a thumbstick and using that to effectively 3 | rotate the reference in place. This is accomplished by rotating and moving the target. 4 | It's assumed that the reference is a descendant of the target. This can be used on the camera rig 5 | to achieve smooth turning. 6 | 7 | ## Properties 8 | | Property | Description | Type | Default Value | 9 | |----------|-------------|------|---------------| 10 | | enabled | Whether the smooth turn is active or not | `string` | true | 11 | | target | Selector for the target to apply rotation and translation to | `selector` | | 12 | | reference | Selector for the reference to 'effectively' rotate | `selector` | | 13 | | turnSpeed | The (max) rotation speed (degrees/s) | `number` | 20 | 14 | | inputMode | The mode for interpreting the input. With the `binary` mode even small inputs will result in maximum speed being applied. The `analog` mode will scale the applied speed between 0 and `turnSpeed` based on the input magnitude | `string` | binary | 15 | 16 | ## Events 17 | | Event Name | Description | 18 | |------------|--------------| 19 | | rotation | Target was rotated through this component. No movement is also signalled through the `motion` event | 20 | 21 | 22 | ## Example 23 | The `smooth-turn` component needs to be applied to an entity that will emit the `axismove` event, 24 | commonly one of the hands. Below is an example using a camera rig: 25 | ```HTML 26 | 27 | 28 | 29 | 32 | 33 | 34 | ``` 35 | 36 | 37 | ## Source 38 | [`src/movement/smooth-turn.component.ts:30`](https://github.com/mrxz/aframe-locomotion/blob/2c33638c/src/movement/smooth-turn.component.ts#L30) 39 | -------------------------------------------------------------------------------- /src/auxiliary/head-occlusion.component.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | 3 | const raycaster = new THREE.Raycaster(); 4 | const origin = new THREE.Vector3(); 5 | const direction = new THREE.Vector3(); 6 | 7 | /** @internal */ 8 | export const HeadOcclusionComponent = AFRAME.registerComponent('head-occlusion', { 9 | schema: { 10 | objects: { type: 'selectorAll' }, 11 | property: { type: 'string' }, 12 | 13 | depth: { type: 'number', default: 10, min: 1, max: 100 }, 14 | }, 15 | tick: function(_t: number, dt: number) { 16 | if(!dt || !this.data.property) { 17 | return; 18 | } 19 | 20 | const meshes = this.data.objects 21 | .map(el => el.getObject3D('mesh')) 22 | .filter(mesh => mesh); 23 | if(meshes.length == 0) { 24 | return; 25 | } 26 | 27 | const camera = this.el.sceneEl.renderer.xr.getCamera(); 28 | if((camera.cameras as Array).length === 0) { 29 | return; 30 | } 31 | 32 | // Cast ray from above down to the head 33 | origin.setFromMatrixPosition(camera.matrixWorld); 34 | direction.set(0, -1, 0); 35 | 36 | origin.addScaledVector(direction, -this.data.depth); 37 | raycaster.set(origin, direction); 38 | raycaster.far = this.data.depth; 39 | 40 | const intersectionsD = raycaster.intersectObjects(meshes, true); 41 | 42 | // Cast ray from in the head upwards 43 | origin.setFromMatrixPosition(camera.matrixWorld); 44 | direction.multiplyScalar(-1); 45 | raycaster.set(origin, direction); 46 | 47 | const intersectionsU = raycaster.intersectObjects(meshes, true); 48 | 49 | const value = intersectionsD.length > intersectionsU.length ? 1: 0; 50 | AFRAME.utils.entity.setComponentProperty(this.el, this.data.property, value); 51 | } 52 | }); 53 | 54 | declare module "aframe" { 55 | export interface Components { 56 | "head-occlusion": InstanceType, 57 | } 58 | } -------------------------------------------------------------------------------- /src/auxiliary/motion-input.component.ts: -------------------------------------------------------------------------------- 1 | import * as AFRAME from "aframe"; 2 | import type { ListenerFor } from "aframe"; 3 | 4 | /** @internal */ 5 | export const MotionInputComponent = AFRAME.registerComponent('motion-input', { 6 | schema: { 7 | source: { type: 'selector' }, 8 | property: { type: 'string' }, 9 | minOutput: { default: 0.0 }, 10 | maxOutput: { default: 1.0 }, 11 | ease: { default: 0.9, min: 0.0, max: 1.0 }, 12 | inputMode: { default: 'binary' } 13 | }, 14 | __fields: {} as { 15 | input: number; 16 | motionEventHandler: ListenerFor<'motion'>; 17 | 18 | lastOutput: number; 19 | }, 20 | init: function() { 21 | this.input = 0; 22 | this.motionEventHandler = (event) => { 23 | this.input = event.detail.inputMagnitude; 24 | }; 25 | this.data.source?.addEventListener('motion', this.motionEventHandler); 26 | 27 | this.lastOutput = 0; 28 | }, 29 | update: function(oldData) { 30 | if(oldData.source !== this.data.source) { 31 | oldData.source?.removeEventListener('motion', this.motionEventHandler); 32 | this.data.source?.addEventListener('motion', this.motionEventHandler); 33 | 34 | this.input = 0; 35 | } 36 | }, 37 | tick: function(_t: number, dt: number) { 38 | if(!dt || !this.data.property) { 39 | return; 40 | } 41 | 42 | // Compute output value 43 | let input = this.input; 44 | if(this.data.inputMode === 'binary') { 45 | input = input > 0 ? 1.0 : 0.0; 46 | } 47 | const rawOutput = input * (this.data.maxOutput - this.data.minOutput) + this.data.minOutput; 48 | 49 | // Ease the output (FIXME: not frame-rate independent) 50 | const output = rawOutput * (1 - this.data.ease) + this.lastOutput * this.data.ease; 51 | 52 | // Update property 53 | AFRAME.utils.entity.setComponentProperty(this.el, this.data.property, output); 54 | this.lastOutput = output; 55 | } 56 | }); 57 | 58 | declare module "aframe" { 59 | export interface Components { 60 | "motion-input": InstanceType, 61 | } 62 | } -------------------------------------------------------------------------------- /src/movement/gravity.component.ts: -------------------------------------------------------------------------------- 1 | import * as AFRAME from "aframe"; 2 | import * as THREE from "three"; 3 | import type { EntityEvents } from "aframe"; 4 | import type { SmoothLocomotionComponent } from "../movement/smooth-locomotion.component"; 5 | 6 | /** 7 | * This component is a 'velocity' component and can be used to influence 8 | * motion based components like {@link SmoothLocomotionComponent}. 9 | * On its own the component won't do anything. 10 | * 11 | * @remarks 12 | * The `gravity` component should be applied to the target of motion. 13 | * When using {@link SmoothLocomotionComponent} with the default camera rig, 14 | * the rig element is the target and should have the `gravity` component on it. 15 | * 16 | * @example 17 | * ```HTML 18 | * 19 | * 20 | * 21 | * 24 | * 25 | * 26 | * ``` 27 | */ 28 | export const GravityComponent = AFRAME.registerComponent('gravity', { 29 | schema: { 30 | /** The gravitational acceleration in m/s^2 */ 31 | strength: { default: 9.81 } 32 | }, 33 | __fields: {} as { 34 | inAir: boolean; 35 | velocity: THREE.Vector3; 36 | motionEventHandler: AFRAME.ListenerFor<'motion'>; 37 | }, 38 | init: function() { 39 | this.inAir = false; 40 | this.velocity = new THREE.Vector3(); 41 | this.motionEventHandler = (event: EntityEvents['motion']) => { 42 | this.inAir = event.detail.inAir; 43 | if(!this.inAir) { 44 | this.velocity.set(0, 0, 0); 45 | } 46 | }; 47 | this.el.addEventListener('motion', this.motionEventHandler); 48 | }, 49 | getVelocity: function() { 50 | return this.velocity; 51 | }, 52 | tick: function(_t, dt) { 53 | if(this.inAir) { 54 | this.velocity.y -= this.data.strength * dt/1000; 55 | } 56 | }, 57 | remove: function() { 58 | this.el.removeEventListener('motion', this.motionEventHandler); 59 | } 60 | }); 61 | 62 | declare module "aframe" { 63 | export interface Components { 64 | "gravity": InstanceType, 65 | } 66 | } -------------------------------------------------------------------------------- /src/auxiliary/vignette.primitive.ts: -------------------------------------------------------------------------------- 1 | import * as AFRAME from "aframe"; 2 | import type { SmoothLocomotionComponent } from "../movement/smooth-locomotion.component"; 3 | 4 | export const VignetteShader = AFRAME.registerShader('vignette', { 5 | schema: { 6 | 'intensity': { type: "number", default: 2, max: 10, min: 0, is: 'uniform' } 7 | }, 8 | vertexShader: 9 | 'out vec2 coord;' + 10 | 'void main() {' + 11 | 'vec3 newPosition = position * 2.0;' + 12 | 'coord = vec2(newPosition.x, newPosition.y);' + 13 | 'gl_Position = vec4(newPosition, 1.0);' + 14 | '}', 15 | fragmentShader: 16 | 'uniform float intensity;' + 17 | 'in vec2 coord;' + 18 | 'void main() {' + 19 | 'float distance = length(coord);'+ 20 | 'distance *= distance;' + 21 | 'distance *= intensity;' + 22 | 'gl_FragColor = vec4(0.0, 0.0, 0.0, distance);' + 23 | '}', 24 | }); 25 | 26 | /** 27 | * Primitive that shows a vignette when moving. 28 | * 29 | * @example 30 | * The `al-vignette` primitive should be a direct child of the camera. The following shows 31 | * the primitive being used with smooth locomotion. 32 | * ```HTML 33 | * 34 | * 35 | * 36 | * 37 | * 38 | * 42 | * 43 | * 44 | * ``` 45 | */ 46 | export const AlVignettePrimitive = AFRAME.registerPrimitive('al-vignette', { 47 | defaultComponents: { 48 | material: { shader: 'vignette', transparent: true }, 49 | geometry: { primitive: 'plane' }, 50 | 'motion-input': { property: 'material.intensity', minOutput: 0, maxOutput: 2 } 51 | }, 52 | mappings: { 53 | /** Selector for the entity that is the target of a moving component (like {@link SmoothLocomotionComponent}). */ 54 | 'motion-source': 'motion-input.source', 55 | 'intensity': 'motion-input.maxOutput', 56 | } 57 | }); 58 | 59 | declare module "aframe" { 60 | export interface Primitives { 61 | "al-vignette": typeof AlVignettePrimitive, 62 | } 63 | export interface Shaders { 64 | "vignette": InstanceType, 65 | } 66 | } -------------------------------------------------------------------------------- /src/auxiliary/nav-mesh-constrained.component.ts: -------------------------------------------------------------------------------- 1 | import * as AFRAME from "aframe"; 2 | import * as THREE from "three"; 3 | import type { Systems } from "aframe"; 4 | import { CandidateValidator } from "../nav-mesh/strategy/strategy.interface"; 5 | 6 | const tempV3 = new THREE.Vector3(); 7 | 8 | /** @internal */ 9 | export const NavMeshConstrainedComponent = AFRAME.registerComponent('nav-mesh-constrained', { 10 | schema: { 11 | /** 12 | * Offset for the raycasting to take place, commonly used to offset the raycast upwards. 13 | */ 14 | offset: { type: 'vec3' }, 15 | fallMode: { default: 'snap' } 16 | }, 17 | __fields: {} as { 18 | locomotionSystem: Systems['locomotion']; 19 | navMeshSystem: Systems['nav-mesh']; 20 | 21 | lastPosition: THREE.Vector3; 22 | }, 23 | init: function() { 24 | this.locomotionSystem = this.el.sceneEl.systems['locomotion']; 25 | this.navMeshSystem = this.el.sceneEl.systems['nav-mesh']; 26 | this.locomotionSystem.addPostMotionCallback(this); 27 | 28 | this.lastPosition = new THREE.Vector3(); 29 | }, 30 | postMotion: function() { 31 | if(!this.navMeshSystem || !this.navMeshSystem.active) { 32 | return; 33 | } 34 | 35 | const lastPosition = this.lastPosition; 36 | const newPosition = this.el.object3D.getWorldPosition(tempV3); 37 | newPosition.sub(this.data.offset as THREE.Vector3); 38 | 39 | const candidateValidator: CandidateValidator = this.data.fallMode === 'prevent' ? 40 | (candidate, ground) => candidate.y - ground.y < 0.5 : 41 | (_candidate, _ground) => true; 42 | const navResult = this.navMeshSystem.approveMovement(lastPosition, newPosition, candidateValidator); 43 | const suggestedPosition = navResult.result ? navResult.ground : navResult.position; 44 | suggestedPosition.add(this.data.offset as THREE.Vector3); 45 | 46 | this.el.object3D.parent!.worldToLocal(suggestedPosition); 47 | this.el.object3D.position.copy(suggestedPosition); 48 | 49 | this.el.object3D.getWorldPosition(lastPosition); 50 | lastPosition.sub(this.data.offset as THREE.Vector3); 51 | }, 52 | remove: function() { 53 | this.locomotionSystem.removePostMotionCallback(this); 54 | } 55 | }); 56 | 57 | declare module "aframe" { 58 | export interface Components { 59 | "nav-mesh-constrained": InstanceType, 60 | } 61 | } -------------------------------------------------------------------------------- /docs/reference/movement/snap-turn.component.md: -------------------------------------------------------------------------------- 1 | # snap-turn 2 | Component for reading the input of a thumbstick and using that to effectively rotate the reference 3 | in place in discrete steps. This is accomplished by rotating and moving the target. It's assumed that 4 | the reference is a descendant of the target. This can be used on the camera rig to achieve snap turning. 5 | 6 | ## Properties 7 | | Property | Description | Type | Default Value | 8 | |----------|-------------|------|---------------| 9 | | enabled | Whether or not the snapturning is enabled | `string` | true | 10 | | target | Selector for the target to apply rotation and translation to | `selector` | | 11 | | reference | Selector for the reference to 'effectively' rotate | `selector` | | 12 | | turnSize | The rotation per snap (degrees) | `number` | 45 | 13 | | activateThreshold | The amount the thumbstick needs to be pushed to activate a snap turn | `number` | 0.9 | 14 | | deactivateThreshold | The threshold the thumbstick needs to cross before a new activation may take place | `number` | 0.8 | 15 | | delay | Optional delay applied before and after the actual snap rotation takes place | `number` | 0 | 16 | 17 | ## Events 18 | | Event Name | Description | 19 | |------------|--------------| 20 | | rotation | Target was rotated through this component. No movement is also signalled through the `motion` event | 21 | | prerotation | Target is about to rotate (only when a `delay` is configured) | 22 | | postrotation | Target has just rotated (only when a `delay` is configured) | 23 | 24 | 25 | ## Example 26 | The `snap-turn` component needs to be applied to an entity that will emit the `axismove` event, 27 | commonly one of the hands. Below is an example using a camera rig: 28 | ```HTML 29 | 30 | 31 | 32 | 35 | 36 | 37 | ``` 38 | 39 | In case a transition needs to be shown a delay can be configured. This delay is applied twice: before and 40 | after the actual snap rotation. This can be used to make a quick fade transition for each snap turn, 41 | see [`al-snap-turn-fade`](../auxiliary/al-snap-turn-fade.primitive.md) 42 | 43 | 44 | ## Source 45 | [`src/movement/snap-turn.component.ts:43`](https://github.com/mrxz/aframe-locomotion/blob/2c33638c/src/movement/snap-turn.component.ts#L43) 46 | -------------------------------------------------------------------------------- /src/nav-mesh/nav-mesh-strategy.component.ts: -------------------------------------------------------------------------------- 1 | import * as AFRAME from 'aframe'; 2 | import { simpleNavMeshStrategy } from "./strategy/simple-strategy"; 3 | import { scanNavMeshStrategy } from "./strategy/scan-strategy"; 4 | import { NavMeshStrategy } from "./strategy/strategy.interface"; 5 | import type { NavMeshComponent } from "./nav-mesh.component"; 6 | 7 | const STRATEGIES: {[key: string]: NavMeshStrategy} = { 8 | 'simple': simpleNavMeshStrategy, 9 | 'scan': scanNavMeshStrategy 10 | } 11 | 12 | /** 13 | * Selects the nav mesh strategy that is used by all aframe-locomotion components. 14 | * This component should be added to the scene if you want to make use of nav mesh based functionality. 15 | * Once configured, nav meshes need to be marked using the {@link NavMeshComponent} component. 16 | * 17 | * @example 18 | * Add the `nav-mesh-strategy` component to the scene 19 | * ```HTML 20 | * 21 | * 22 | * 23 | * ``` 24 | * 25 | * > **Note:** In many cases the navigation meshes should not be visible. The `nav-mesh` component 26 | * doesn't handle this for you, so make sure to manually add `material="visible: false"` or hide 27 | * the mesh in some other way. 28 | */ 29 | export const NavMeshStrategyComponent = AFRAME.registerComponent('nav-mesh-strategy', { 30 | schema: { 31 | /** 32 | * Either `simple` or `scan`. The `simple` strategy allows to check if movement is valid based on 33 | * the nav-mesh in a binary fashion (movement is either valid or not). The `scan` strategy falls 34 | * back to alternatives that are slightly to the side of the movement. This allows sliding across walls. 35 | */ 36 | strategy: { type: 'string', default: 'scan' } 37 | }, 38 | __fields: {} as { 39 | navMeshSystem: AFRAME.Systems['nav-mesh']; 40 | updateStrategy: () => void; 41 | }, 42 | init: function() { 43 | this.navMeshSystem = this.el.sceneEl.systems['nav-mesh']; 44 | this.navMeshSystem.active = true; 45 | this.updateStrategy = () => { 46 | const strategy = STRATEGIES[this.data.strategy] || simpleNavMeshStrategy; 47 | this.navMeshSystem.switchStrategy(strategy); 48 | }; 49 | this.updateStrategy(); 50 | }, 51 | update: function(oldData) { 52 | if(oldData.strategy !== this.data.strategy) { 53 | this.updateStrategy(); 54 | } 55 | }, 56 | }); 57 | 58 | declare module "aframe" { 59 | export interface Components { 60 | "nav-mesh-strategy": InstanceType, 61 | } 62 | } -------------------------------------------------------------------------------- /src/auxiliary/rotation-input.component.ts: -------------------------------------------------------------------------------- 1 | import * as AFRAME from "aframe"; 2 | import { ListenerFor } from "aframe"; 3 | 4 | /** @internal */ 5 | export const RotationInputComponent = AFRAME.registerComponent('rotation-input', { 6 | schema: { 7 | source: { type: 'selector' }, 8 | property: { type: 'string' }, 9 | minOutput: { default: 0.0 }, 10 | maxOutput: { default: 1.0 }, 11 | ease: { default: 0.0, min: 0.0, max: 1.0 }, 12 | inputMode: { default: 'analog' } 13 | }, 14 | __fields: {} as { 15 | input: number; 16 | preRotationEventHandler: ListenerFor<'prerotation'>; 17 | postRotationEventHandler: ListenerFor<'postrotation'>; 18 | 19 | lastOutput: number; 20 | }, 21 | init: function() { 22 | this.input = 0; 23 | this.preRotationEventHandler = (event) => { 24 | this.input = event.detail.progress; // Rising 25 | }; 26 | this.postRotationEventHandler = (event) => { 27 | this.input = 1.0 - event.detail.progress; // Falling 28 | }; 29 | this.data.source?.addEventListener('prerotation', this.preRotationEventHandler); 30 | this.data.source?.addEventListener('postrotation', this.postRotationEventHandler); 31 | 32 | this.lastOutput = 0; 33 | }, 34 | update: function(oldData) { 35 | if(oldData.source !== this.data.source) { 36 | oldData.source?.removeEventListener('prerotation', this.preRotationEventHandler); 37 | oldData.source?.removeEventListener('postrotation', this.postRotationEventHandler); 38 | this.data.source?.addEventListener('prerotation', this.preRotationEventHandler); 39 | this.data.source?.addEventListener('postrotation', this.postRotationEventHandler); 40 | 41 | this.input = 0; 42 | } 43 | }, 44 | tick: function(_t: number, dt: number) { 45 | if(!dt || !this.data.property) { 46 | return; 47 | } 48 | 49 | // Compute output value 50 | let input = this.input; 51 | if(this.data.inputMode === 'binary') { 52 | input = input > 0 ? 1.0 : 0.0; 53 | } 54 | const rawOutput = input * (this.data.maxOutput - this.data.minOutput) + this.data.minOutput; 55 | 56 | // Ease the output (FIXME: not frame-rate independent) 57 | const output = rawOutput * (1 - this.data.ease) + this.lastOutput * this.data.ease; 58 | 59 | // Update property 60 | AFRAME.utils.entity.setComponentProperty(this.el, this.data.property, output); 61 | this.lastOutput = output; 62 | } 63 | }); 64 | 65 | declare module "aframe" { 66 | export interface Components { 67 | "rotation-input": InstanceType 68 | } 69 | } -------------------------------------------------------------------------------- /src/nav-mesh/strategy/scan-strategy.ts: -------------------------------------------------------------------------------- 1 | import { NavMeshStrategy } from "./strategy.interface"; 2 | import { castRay } from "./utils"; 3 | import * as THREE from "three"; 4 | 5 | const STEP_SIZE = 0.5; 6 | // Based on https://github.com/AdaRoseCannon/aframe-xr-boilerplate/blob/glitch/simple-navmesh-constraint.js 7 | const SCAN_PATTERN = [ 8 | [0, 1], // Default the next location 9 | [0, 0.5], // Check that the path to that location was fine 10 | [30, 0.4], // A little to the side shorter range 11 | [-30, 0.4], // A little to the side shorter range 12 | [60, 0.2], // Moderately to the side short range 13 | [-60, 0.2], // Moderately to the side short range 14 | [80, 0.06], // Perpendicular very short range 15 | [-80, 0.06], // Perpendicular very short range 16 | ]; 17 | 18 | export const scanNavMeshStrategy: NavMeshStrategy = (function() { 19 | const castPoint = new THREE.Vector3(); 20 | const castOffset = new THREE.Vector3(0, STEP_SIZE, 0); 21 | const castDirection = new THREE.Vector3(0, -1, 0); 22 | 23 | const scanPoint = new THREE.Vector3(); 24 | const direction = new THREE.Vector3(); 25 | 26 | return { 27 | approveMovement: function(oldPosition, newPosition, navMeshes, candidateValidator) { 28 | const intersectionPointFor = (position: THREE.Vector3) => { 29 | castPoint.addVectors(position, castOffset); 30 | const intersections = castRay(castPoint, castDirection, navMeshes); 31 | if(intersections.length !== 0) { 32 | return intersections[0].point; 33 | } 34 | return null; 35 | } 36 | 37 | direction.subVectors(newPosition, oldPosition); 38 | for(const [angle, distance] of SCAN_PATTERN) { 39 | scanPoint.copy(direction); 40 | scanPoint.applyAxisAngle(castDirection, angle * Math.PI/180); 41 | scanPoint.multiplyScalar(distance); 42 | scanPoint.add(oldPosition); 43 | 44 | const intersectionPoint = intersectionPointFor(scanPoint); 45 | if(!intersectionPoint) { 46 | continue; 47 | } 48 | 49 | if(!candidateValidator(scanPoint, intersectionPoint)) { 50 | continue; 51 | } 52 | 53 | newPosition.copy(scanPoint) 54 | 55 | // Check if new position is a step up. 56 | if(intersectionPoint.y > newPosition.y) { 57 | newPosition.y = intersectionPoint.y; 58 | } 59 | 60 | return { 61 | result: true, 62 | position: newPosition, 63 | ground: intersectionPoint 64 | }; 65 | } 66 | 67 | // No suitable position found 68 | return { 69 | result: false, 70 | position: oldPosition 71 | }; 72 | } 73 | } 74 | })(); -------------------------------------------------------------------------------- /docs/reference/movement/smooth-locomotion.component.md: -------------------------------------------------------------------------------- 1 | # smooth-locomotion 2 | Component for reading the input of a thumbstick and converting that into motion on a target entity. 3 | The rotation of the reference is used to determine the direction to move in. This can be used on a 4 | camera rig to move around the world using either head orientation or controller orientation. 5 | 6 | ## Properties 7 | | Property | Description | Type | Default Value | 8 | |----------|-------------|------|---------------| 9 | | enabled | Whether the smooth locomotion is active or not | `string` | true | 10 | | target | Selector for the target of the motion | `selector` | | 11 | | reference | Selector for the reference to determine world position and rotation | `selector` | | 12 | | moveSpeed | The (max) speed for the target (m/s) | `number` | 1.5 | 13 | | forward | Whether or not forward movement should be applied | `string` | true | 14 | | backward | Whether or not backward movement should be applied | `string` | true | 15 | | sideways | Whether or not sideways movement should be applied | `string` | true | 16 | | inputMode | The mode for interpreting the input. With the `binary` mode even small inputs will result in maximum speed being applied. The `analog` mode will scale the applied speed between 0 and moveSpeed based on the input magnitude | `string` | binary | 17 | | fallMode | The mode for how falling should be handled in case the reference is moving off an edge. With `snap` the reference will always snap to the ground, instantly dropping down. With `prevent` the reference won't be moved over the edge. With `fall` the reference is moved over the edge, but not forced/snapped to the ground, allowing it to fall down. (Only applies when using the [`nav-mesh`](../nav-mesh/nav-mesh.system.md)) | `string` | fall | 18 | 19 | ## Events 20 | | Event Name | Description | 21 | |------------|--------------| 22 | | motion | Target was moved through this component. No movement is also signalled through the `motion` event | 23 | 24 | 25 | ## Example 26 | The `smooth-locomotion` component needs to be applied to an entity that will emit the `axismove` event, 27 | commonly one of the hands. Below is an example using a camera rig to enable smooth locomotion using the 28 | thumbstick on the left controller and using head orientation: 29 | ```HTML 30 | 31 | 32 | 33 | 37 | 38 | 39 | ``` 40 | 41 | To use controller orientation instead, change the reference to the controller, as such: 42 | ```HTML 43 | 47 | 48 | ``` 49 | 50 | 51 | ## Source 52 | [`src/movement/smooth-locomotion.component.ts:55`](https://github.com/mrxz/aframe-locomotion/blob/2c33638c/src/movement/smooth-locomotion.component.ts#L55) 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A-Frame locomotion 2 | [![npm version](https://img.shields.io/npm/v/aframe-locomotion.svg?style=flat-square)](https://www.npmjs.com/package/aframe-locomotion) 3 | [![npm version](https://img.shields.io/npm/l/aframe-locomotion.svg?style=flat-square)](https://www.npmjs.com/package/aframe-locomotion) 4 | [![github](https://flat.badgen.net/badge/icon/github?icon=github&label)](https://github.com/mrxz/aframe-locomotion/) 5 | [![twitter](https://flat.badgen.net/twitter/follow/noerihuisman)](https://twitter.com/noerihuisman) 6 | [![ko-fi](https://img.shields.io/badge/ko--fi-buy%20me%20a%20coffee-ff5f5f?style=flat-square)](https://ko-fi.com/fernsolutions) 7 | 8 | A collection of A-Frame components, systems and primitives that enable all sorts of locomotion in VR. It't built to be modular, flexible and easy to use. Currently supports smooth locomotion, snap turning and smooth turning. Besides the actual modes of locomotion, there are auxiliary components to improve the locomotion experience like a vignette when moving, fading when snap turning and more. 9 | 10 | 11 | Aframe locomotion example
12 | Try the online examples 13 |
14 | 15 | Read the docs 16 | 17 | 18 | Blog post describing the implementation: [A-Frame Adventures 01 - Smooth locomotion and snap turning](https://fern.solutions/dev-logs/aframe-adventures-01/) 19 | 20 | Buy Me a Coffee at ko-fi.com 21 | 22 | # Quick start 23 | To add `aframe-locomotion` to your A-Frame project, all you have to do is load the aframe-locomotion javascript: 24 | ```html 25 | 26 | ``` 27 | 28 | This will automatically register the components `smooth-locomotion` and `snap-turn`. These need to be attached to the controllers as part of a camera rig, as follows: 29 | ```html 30 | 31 | 32 | 33 | 34 | 35 | 38 | 39 | 42 | 43 | 44 | 45 | ``` 46 | 47 | Both `smooth-locomotion` and `snap-turn` have more properties that can be used to tweak the behaviour. Check the Documentation to learn more or explore the examples. 48 | 49 | # Features 50 | * Smooth locomotion 51 | * Snap turning (with optional fade transitions) 52 | * Smooth turning 53 | * Vignette when moving 54 | * Nav-mesh support 55 | 56 | # Planned features 57 | * [ ] Velocity effectors (e.g. conveyor belts, moving platforms) 58 | * [ ] Smooth snap turning 59 | * [ ] Momentum preservation 60 | * [ ] Teleport 61 | * [ ] Flying 62 | * [ ] Head-collision prevention 63 | 64 | # Questions 65 | If you've got any questions, feedback, suggestions or even want to help out, feel free to reach out to me. 66 | -------------------------------------------------------------------------------- /src/auxiliary/fade.primitive.ts: -------------------------------------------------------------------------------- 1 | import * as AFRAME from "aframe"; 2 | import type { SnapTurnComponent } from '../movement/snap-turn.component'; 3 | 4 | export const FadeShader = AFRAME.registerShader('fade', { 5 | schema: { 6 | 'color': { type: "color", is: 'uniform' }, 7 | 'intensity': { type: "number", default: 0.0, max: 1.0, min: 0.0, is: 'uniform' } 8 | }, 9 | }); 10 | 11 | /** 12 | * Primitive that fades the view in/out when snap-turning. Requires the usage of {@link SnapTurnComponent}. 13 | * 14 | * @example 15 | * The `al-snap-turn-fade` primitive should be a direct child of the camera. The following shows 16 | * the primitive being used with snap-turning. Notice the configured delay, without it the snap 17 | * is instant and no fade will take place: 18 | * ```HTML 19 | * 20 | * 21 | * 22 | * 23 | * 24 | * 27 | * 28 | * 29 | * ``` 30 | */ 31 | export const AlSnapTurnFadePrimitive = AFRAME.registerPrimitive('al-snap-turn-fade', { 32 | defaultComponents: { 33 | material: { shader: 'fade', transparent: true, depthTest: false }, 34 | geometry: { primitive: 'plane' }, 35 | 'rotation-input': { property: 'material.intensity' }, 36 | "nav-mesh-constrained": {} 37 | }, 38 | mappings: { 39 | /** Selector for the entity that is rotated. This should be the target of {@link SnapTurnComponent} */ 40 | 'rotation-source': 'rotation-input.source', 41 | } 42 | }); 43 | 44 | /** 45 | * Primitive that fades the view in/out when the head is placed inside an wall or object. 46 | * 47 | * @example 48 | * The `al-head-occlusion-fade` primitive should be a direct child of the camera. The following shows 49 | * the primitive being used. 50 | * ```HTML 51 | * 52 | * 53 | * 54 | * 55 | * 56 | * 57 | * 58 | * ``` 59 | * 60 | * > **Note:** The head occlusion detection uses the XR camera, meaning it won't activate when vr-mode isn't 61 | * > active. If this is something you need/want, please open a new issue on GitHub and indicate your use case. 62 | */ 63 | export const AlHeadOcclusionFadePrimitive = AFRAME.registerPrimitive('al-head-occlusion-fade', { 64 | defaultComponents: { 65 | material: { shader: 'fade', transparent: true, depthTest: false }, 66 | geometry: { primitive: 'plane' }, 67 | 'head-occlusion': { property: 'material.intensity' } 68 | }, 69 | mappings: { 70 | /** Selector for all the objects to check head occlusion for */ 71 | objects: 'head-occlusion.objects' 72 | } 73 | }); 74 | 75 | declare module "aframe" { 76 | export interface Primitives { 77 | "al-snap-turn-fade": typeof AlSnapTurnFadePrimitive, 78 | "al-head-occlusion-fade": typeof AlHeadOcclusionFadePrimitive, 79 | } 80 | 81 | export interface Shaders { 82 | "fade": InstanceType 83 | } 84 | } -------------------------------------------------------------------------------- /src/movement/smooth-turn.component.ts: -------------------------------------------------------------------------------- 1 | import * as AFRAME from 'aframe'; 2 | import type { ListenerFor } from 'aframe'; 3 | import { rotateAroundWorldUp } from './turn'; 4 | 5 | const EPSILON = 0.00001; 6 | 7 | /** 8 | * Component for reading the input of a thumbstick and using that to effectively 9 | * rotate the reference in place. This is accomplished by rotating and moving the target. 10 | * It's assumed that the reference is a descendant of the target. This can be used on the camera rig 11 | * to achieve smooth turning. 12 | * 13 | * @emits rotation Target was rotated through this component. No movement is also signalled through 14 | * the `motion` event 15 | * 16 | * @example 17 | * The `smooth-turn` component needs to be applied to an entity that will emit the `axismove` event, 18 | * commonly one of the hands. Below is an example using a camera rig: 19 | * ```HTML 20 | * 21 | * 22 | * 23 | * 26 | * 27 | * 28 | * ``` 29 | */ 30 | export const SmoothTurnComponent = AFRAME.registerComponent('smooth-turn', { 31 | schema: { 32 | /** Whether the smooth turn is active or not */ 33 | enabled: { default: true }, 34 | /** Selector for the target to apply rotation and translation to */ 35 | target: { type: 'selector' }, 36 | /** Selector for the reference to 'effectively' rotate */ 37 | reference: { type: 'selector' }, 38 | /** The (max) rotation speed (degrees/s) */ 39 | turnSpeed: { default: 20 }, 40 | /** 41 | * The mode for interpreting the input. With the `binary` mode even small inputs will 42 | * result in maximum speed being applied. The `analog` mode will scale the applied speed 43 | * between 0 and `turnSpeed` based on the input magnitude 44 | */ 45 | inputMode: { default: 'binary' } 46 | }, 47 | __fields: {} as { 48 | input: number; 49 | axisMoveListener: ListenerFor<'axismove'>; 50 | }, 51 | init: function() { 52 | this.input = 0; 53 | this.axisMoveListener = (e) => { 54 | const axis = e.detail.axis; 55 | this.input = axis.length > 2 ? axis[2] : axis[0]; 56 | }; 57 | this.el.addEventListener('axismove', this.axisMoveListener); 58 | }, 59 | tick: function(_t, dt) { 60 | if(!dt || !this.data.enabled || !this.data.reference || !this.data.target) { 61 | return; 62 | } 63 | 64 | if(Math.abs(this.input) <= EPSILON) { 65 | return; 66 | } 67 | 68 | let input = this.input; 69 | if(this.data.inputMode === 'binary') { 70 | if(input < 0) { 71 | input = -1; 72 | } else { 73 | input = 1; 74 | } 75 | } 76 | 77 | const degrees = -input * this.data.turnSpeed * dt / 1000; 78 | this.data.target.emit('rotation', { degrees, source: this.el }); 79 | rotateAroundWorldUp(this.data.target.object3D, this.data.reference.object3D, degrees); 80 | }, 81 | remove: function() { 82 | this.el.removeEventListener('axismove', this.axisMoveListener); 83 | } 84 | }); 85 | 86 | declare module "aframe" { 87 | export interface Components { 88 | "smooth-turn": InstanceType, 89 | } 90 | } -------------------------------------------------------------------------------- /src/movement/snap-turn.component.ts: -------------------------------------------------------------------------------- 1 | import * as AFRAME from 'aframe'; 2 | import { ListenerFor } from 'aframe'; 3 | import { rotateAroundWorldUp } from './turn'; 4 | import type { AlSnapTurnFadePrimitive } from "../auxiliary/fade.primitive"; 5 | 6 | const NONE = 0; 7 | const LEFT = 1; 8 | const RIGHT = 2; 9 | const DONE = 3; 10 | // States in case of delay 11 | const PRE = 4; 12 | const POST = 5; 13 | type State = typeof NONE | typeof LEFT | typeof RIGHT | typeof DONE | typeof PRE | typeof POST; 14 | 15 | /** 16 | * Component for reading the input of a thumbstick and using that to effectively rotate the reference 17 | * in place in discrete steps. This is accomplished by rotating and moving the target. It's assumed that 18 | * the reference is a descendant of the target. This can be used on the camera rig to achieve snap turning. 19 | * 20 | * @emits rotation Target was rotated through this component. No movement is also signalled through 21 | * the `motion` event 22 | * @emits prerotation Target is about to rotate (only when a `delay` is configured) 23 | * @emits postrotation Target has just rotated (only when a `delay` is configured) 24 | * 25 | * @example 26 | * The `snap-turn` component needs to be applied to an entity that will emit the `axismove` event, 27 | * commonly one of the hands. Below is an example using a camera rig: 28 | * ```HTML 29 | * 30 | * 31 | * 32 | * 35 | * 36 | * 37 | * ``` 38 | * 39 | * In case a transition needs to be shown a delay can be configured. This delay is applied twice: before and 40 | * after the actual snap rotation. This can be used to make a quick fade transition for each snap turn, 41 | * see {@link AlSnapTurnFadePrimitive} 42 | */ 43 | export const SnapTurnComponent = AFRAME.registerComponent('snap-turn', { 44 | schema: { 45 | /** Whether or not the snapturning is enabled */ 46 | enabled: { default: true }, 47 | /** Selector for the target to apply rotation and translation to */ 48 | target: { type: 'selector' }, 49 | /** Selector for the reference to 'effectively' rotate */ 50 | reference: { type: 'selector' }, 51 | /** The rotation per snap (degrees) */ 52 | turnSize: { default: 45 }, 53 | /** The amount the thumbstick needs to be pushed to activate a snap turn */ 54 | activateThreshold: { default: 0.9 }, 55 | /** The threshold the thumbstick needs to cross before a new activation may take place */ 56 | deactivateThreshold: { default: 0.8 }, 57 | /** Optional delay applied before and after the actual snap rotation takes place */ 58 | delay: { default: 0, min: 0 } 59 | }, 60 | __fields: {} as { 61 | /** 62 | * The internal State of the snap turning process. 63 | */ 64 | state: State, 65 | action: State, 66 | timer: number, 67 | nextAction: State, 68 | axisMoveListener: ListenerFor<'axismove'> 69 | }, 70 | init: function() { 71 | this.state = NONE; 72 | this.action = NONE; 73 | this.timer = 0; 74 | this.nextAction = NONE; 75 | 76 | this.axisMoveListener = (e) => { 77 | const axis = e.detail.axis; 78 | const amount = axis.length > 2 ? axis[2] : axis[0]; 79 | 80 | if(Math.abs(amount) > this.data.activateThreshold) { 81 | this.state = amount < 0 ? LEFT : RIGHT; 82 | } else if(Math.abs(amount) < this.data.deactivateThreshold) { 83 | this.state = NONE; 84 | if(this.action === DONE) { 85 | this.action = NONE; 86 | } 87 | } 88 | 89 | if(this.state !== NONE && this.action === NONE) { 90 | if(this.data.delay) { 91 | this.nextAction = this.state; 92 | this.timer = this.data.delay; 93 | this.action = PRE; 94 | } else { 95 | this.action = this.state; 96 | } 97 | } 98 | }; 99 | this.el.addEventListener('axismove', this.axisMoveListener); 100 | }, 101 | tick: function(_t, dt) { 102 | if(!dt || !this.data.enabled || !this.data.reference || !this.data.target) { 103 | return; 104 | } 105 | 106 | if(this.action === PRE || this.action === POST) { 107 | this.timer -= dt/1000; 108 | if(this.timer < 0) { 109 | this.action = this.nextAction; 110 | } else { 111 | const event = this.action === PRE ? 'prerotation' : 'postrotation'; 112 | const progress = (this.data.delay - this.timer) / this.data.delay; 113 | this.data.target.emit(event, { progress, source: this.el }); 114 | } 115 | } 116 | 117 | let degrees = 0; 118 | if(this.action === LEFT) { 119 | degrees = this.data.turnSize; 120 | } else if(this.action === RIGHT) { 121 | degrees = -this.data.turnSize; 122 | } else { 123 | return; 124 | } 125 | 126 | this.data.target.emit('rotation', { degrees, source: this.el }); 127 | rotateAroundWorldUp(this.data.target.object3D, this.data.reference.object3D, degrees); 128 | 129 | if(this.action === LEFT || this.action === RIGHT) { 130 | if(this.data.delay) { 131 | this.action = POST; 132 | this.nextAction = DONE; 133 | this.timer = this.data.delay; 134 | } else { 135 | this.action = DONE; 136 | } 137 | } 138 | }, 139 | remove: function() { 140 | this.el.removeEventListener('axismove', this.axisMoveListener); 141 | } 142 | }); 143 | 144 | declare module "aframe" { 145 | export interface Components { 146 | "snap-turn": InstanceType, 147 | } 148 | } -------------------------------------------------------------------------------- /src/movement/smooth-locomotion.component.ts: -------------------------------------------------------------------------------- 1 | import * as AFRAME from 'aframe'; 2 | import * as THREE from 'three'; 3 | import type { EntityComponents, ListenerFor } from 'aframe'; 4 | import { CandidateValidator } from '../nav-mesh/strategy/strategy.interface'; 5 | import type { NavMeshSystem } from '../nav-mesh/nav-mesh.system'; 6 | 7 | interface VelocityComponent { 8 | getVelocity(): THREE.Vector3 9 | }; 10 | const VELOCITY_COMPONENTS: Array = ['gravity']; 11 | 12 | // Temporary variables 13 | const direction = new THREE.Vector3(); 14 | const referenceWorldRot = new THREE.Quaternion(); 15 | const newPosition = new THREE.Vector3(); 16 | const movement = new THREE.Vector3(); 17 | const velocity = new THREE.Vector3(); 18 | 19 | const oldRefPosition = new THREE.Vector3(); 20 | const newRefPosition = new THREE.Vector3(); 21 | 22 | 23 | /** 24 | * Component for reading the input of a thumbstick and converting that into motion on a target entity. 25 | * The rotation of the reference is used to determine the direction to move in. This can be used on a 26 | * camera rig to move around the world using either head orientation or controller orientation. 27 | * 28 | * @emits motion Target was moved through this component. No movement is also signalled through the `motion` event 29 | * 30 | * @example 31 | * The `smooth-locomotion` component needs to be applied to an entity that will emit the `axismove` event, 32 | * commonly one of the hands. Below is an example using a camera rig to enable smooth locomotion using the 33 | * thumbstick on the left controller and using head orientation: 34 | * ```HTML 35 | * 36 | * 37 | * 38 | * 42 | * 43 | * 44 | * ``` 45 | * 46 | * To use controller orientation instead, change the reference to the controller, as such: 47 | * ```HTML 48 | * 52 | * 53 | * ``` 54 | */ 55 | export const SmoothLocomotionComponent = AFRAME.registerComponent('smooth-locomotion', { 56 | schema: { 57 | /** Whether the smooth locomotion is active or not */ 58 | enabled: { default: true }, 59 | /** Selector for the target of the motion */ 60 | target: { type: 'selector' }, 61 | /** Selector for the reference to determine world position and rotation */ 62 | reference: { type: 'selector' }, 63 | /** The (max) speed for the target (m/s) */ 64 | moveSpeed: { default: 1.5 }, 65 | /** Whether or not forward movement should be applied */ 66 | forward: { default: true }, 67 | /** Whether or not backward movement should be applied */ 68 | backward: { default: true }, 69 | /** Whether or not sideways movement should be applied */ 70 | sideways: { default: true }, 71 | /** 72 | * The mode for interpreting the input. With the `binary` mode even small inputs will result in 73 | * maximum speed being applied. The `analog` mode will scale the applied speed between 0 and moveSpeed 74 | * based on the input magnitude 75 | */ 76 | inputMode: { default: 'binary' }, 77 | /** 78 | * The mode for how falling should be handled in case the reference is moving off an edge. With `snap` 79 | * the reference will always snap to the ground, instantly dropping down. With `prevent` the reference 80 | * won't be moved over the edge. With `fall` the reference is moved over the edge, but not forced/snapped 81 | * to the ground, allowing it to fall down. (Only applies when using the {@link NavMeshSystem}) 82 | */ 83 | fallMode: { default: 'fall' } 84 | }, 85 | __fields: {} as { 86 | inputDirection: { x: number, y: number }; 87 | axisMoveListener: ListenerFor<'axismove'>; 88 | }, 89 | init: function() { 90 | this.inputDirection = { x: 0, y: 0 }; 91 | this.axisMoveListener = (e) => { 92 | const axis = e.detail.axis; 93 | if(axis.length > 2) { 94 | // Oculus 95 | this.inputDirection.x = axis[2]; 96 | this.inputDirection.y = axis[3]; 97 | } else { 98 | // Vive/Index 99 | this.inputDirection.x = axis[0]; 100 | this.inputDirection.y = axis[1]; 101 | } 102 | }; 103 | this.el.addEventListener('axismove', this.axisMoveListener); 104 | }, 105 | tick: function(_t: number, dt: number) { 106 | if(!dt || !this.data.enabled || !this.el.sceneEl.is('vr-mode')) { 107 | return; 108 | } 109 | 110 | // Handle input 111 | direction.set(this.inputDirection.x, 0, this.inputDirection.y); 112 | if(!this.data.sideways) { 113 | direction.x = 0; 114 | } 115 | if(direction.z < 0 && !this.data.backward) { 116 | direction.z = 0; 117 | } else if(!this.data.forward) { 118 | direction.z = 0; 119 | } 120 | 121 | // Determine the magnitude of the input 122 | const binaryInputMode = this.data.inputMode === 'binary'; 123 | const inputMagnitude = binaryInputMode ? Math.ceil(direction.length()) : Math.min(direction.length(), 1.0); 124 | 125 | // Handle velocity (falling, moving platforms, conveyors, etc...) 126 | velocity.set(0, 0, 0); 127 | for(let component of VELOCITY_COMPONENTS) { 128 | if(this.data.target!.hasAttribute(component)) { 129 | velocity.add((this.data.target!.components[component]! as unknown as VelocityComponent).getVelocity()); 130 | } 131 | } 132 | 133 | // Direction is relative to the reference's rotation 134 | this.data.reference!.object3D.getWorldQuaternion(referenceWorldRot); 135 | direction.applyQuaternion(referenceWorldRot); 136 | 137 | // Ignore vertical component 138 | direction.y = 0; 139 | direction.normalize(); 140 | 141 | const oldPosition = this.data.target!.object3D.position; 142 | movement.set(0, 0, 0) 143 | .addScaledVector(velocity, dt / 1000) 144 | .addScaledVector(direction, inputMagnitude * this.data.moveSpeed * dt / 1000); 145 | 146 | let inAir = false; 147 | 148 | // Check if the nav-mesh system allows the movement 149 | const navMeshSystem = this.el.sceneEl.systems['nav-mesh']; 150 | if(navMeshSystem && navMeshSystem.active) { 151 | // NavMeshSystem needs the old and new world position of the reference. 152 | this.data.reference!.object3D.getWorldPosition(oldRefPosition); 153 | // Project the position onto the 'floor' of the target 154 | oldRefPosition.y -= oldRefPosition.y - oldPosition.y; 155 | newRefPosition.copy(oldRefPosition).add(movement); 156 | 157 | const candidateValidator: CandidateValidator = this.data.fallMode === 'prevent' ? 158 | (candidate, ground) => candidate.y - ground.y < 0.5 : 159 | (_candidate, _ground) => true; 160 | const navResult = navMeshSystem.approveMovement(oldRefPosition, newRefPosition, candidateValidator); 161 | const height = navResult.result ? navResult.position.y - navResult.ground.y : 100; 162 | 163 | if(this.data.fallMode === 'fall') { 164 | if(height < 0.5) { 165 | movement.copy(navResult.ground!); 166 | } else { 167 | inAir = true; 168 | movement.copy(navResult.position); 169 | } 170 | } else if(this.data.fallMode === 'snap') { 171 | if(navResult.ground) { 172 | movement.copy(navResult.ground); 173 | } 174 | } else if(this.data.fallMode === 'prevent') { 175 | movement.copy(navResult.result ? navResult.ground : navResult.position); 176 | } 177 | 178 | // Compute adjusted movement 179 | movement.sub(oldRefPosition); 180 | } 181 | 182 | newPosition.copy(oldPosition).add(movement); 183 | this.data.target!.object3D.position.copy(newPosition); 184 | 185 | // Emit event on the target, so others interested in the target's movement can react 186 | this.data.target!.emit('motion', { inputMagnitude, inAir, source: this.el }, false) 187 | }, 188 | remove: function() { 189 | this.el.removeEventListener('axismove', this.axisMoveListener); 190 | } 191 | }); 192 | 193 | declare module "aframe" { 194 | export interface Components { 195 | "smooth-locomotion": InstanceType, 196 | } 197 | } --------------------------------------------------------------------------------