├── .eslintrc.json ├── .gitignore ├── .prettierrc ├── LICENSE ├── index.html ├── package.json ├── public ├── island_fbx_gltf.glb ├── jungle-merged.glb └── mushroom-boi.glb ├── src ├── app.css ├── app.tsx ├── camera │ ├── camera-controller.tsx │ └── stores │ │ └── camera-store.ts ├── character │ ├── bounding-volume │ │ ├── use-bounding-volume.ts │ │ └── volume-debug.tsx │ ├── character-controller.tsx │ ├── contexts │ │ └── character-controller-context.ts │ ├── machines │ │ └── movement-machine.ts │ ├── modifiers │ │ ├── air-collision.ts │ │ ├── falling.ts │ │ ├── gravity.ts │ │ ├── jump.ts │ │ ├── use-modifiers.ts │ │ └── walking.ts │ └── stores │ │ └── character-store.ts ├── collider │ ├── SimplifyModifier.js │ ├── collider.tsx │ └── stores │ │ └── collider-store.tsx ├── index.css ├── input │ ├── input-controller.tsx │ └── input-system.tsx ├── main.tsx ├── player │ └── player-controller.tsx ├── test-assets │ ├── fauna.tsx │ ├── low-poly-island.tsx │ ├── mushroom-boi.tsx │ ├── player.tsx │ ├── simple-plane.tsx │ ├── space.tsx │ ├── terrain.tsx │ └── test-extension-terrain.tsx ├── utilities │ ├── quatDamp.ts │ ├── unity.ts │ ├── use-box-debug.ts │ ├── use-line-debug.ts │ └── use-measure.tsx └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:react/recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "plugin:react-hooks/recommended" 11 | ], 12 | "parser": "@typescript-eslint/parser", 13 | "parserOptions": { 14 | "ecmaFeatures": { 15 | "jsx": true 16 | }, 17 | "ecmaVersion": "latest", 18 | "sourceType": "module" 19 | }, 20 | "plugins": ["react", "@typescript-eslint"], 21 | "rules": { 22 | "react/display-name": "off", 23 | "@typescript-eslint/no-non-null-assertion": "off", 24 | "@typescript-eslint/no-explicit-any": "off", 25 | "@typescript-eslint/ban-ts-comment": "off", 26 | "react/jsx-uses-react": "off", 27 | "react/react-in-jsx-scope": "off" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | dist/ 4 | types/ 5 | yarn-error.log 6 | .size-snapshot.json 7 | Thumbs.db 8 | ehthumbs.db 9 | Desktop.ini 10 | $RECYCLE.BIN/ 11 | .DS_Store 12 | .vscode 13 | .docz/ 14 | package-lock.json 15 | coverage/ 16 | .idea 17 | .rpt2_cache/ -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "all", 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "printWidth": 120, 7 | "jsxBracketSameLine": true 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Kristopher Baumgartner 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 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite App 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "r3f-character-controller", 3 | "private": true, 4 | "version": "0.0.0", 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "tsc && vite build", 8 | "preview": "vite preview" 9 | }, 10 | "dependencies": { 11 | "@gsimone/smoothdamp": "^0.1.0", 12 | "@hmans/controlfreak": "^0.2.0", 13 | "@react-three/drei": "^9.13.0", 14 | "@react-three/fiber": "^9.0.0-alpha.1", 15 | "@xstate/react": "^3.0.0", 16 | "react": "^18.0.0", 17 | "react-dom": "^18.0.0", 18 | "three": "^0.141.0", 19 | "three-mesh-bvh": "^0.5.15", 20 | "three-stdlib": "^2.14.3", 21 | "xstate": "^4.32.1", 22 | "zustand": "^4.1.0" 23 | }, 24 | "devDependencies": { 25 | "@types/react": "^18.0.0", 26 | "@types/react-dom": "^18.0.0", 27 | "@types/three": "^0.141.0", 28 | "@vitejs/plugin-react": "^2.0.1", 29 | "eslint": "^8.21.0", 30 | "eslint-config-react-app": "^7.0.1", 31 | "typescript": "^4.6.3", 32 | "vite": "^3.0.7", 33 | "vite-tsconfig-paths": "^3.5.0" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /public/island_fbx_gltf.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krispya/r3f-character-controller/c8023c37bbcf6e8ad75d08aa77e54c712e4fb055/public/island_fbx_gltf.glb -------------------------------------------------------------------------------- /public/jungle-merged.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krispya/r3f-character-controller/c8023c37bbcf6e8ad75d08aa77e54c712e4fb055/public/jungle-merged.glb -------------------------------------------------------------------------------- /public/mushroom-boi.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krispya/r3f-character-controller/c8023c37bbcf6e8ad75d08aa77e54c712e4fb055/public/mushroom-boi.glb -------------------------------------------------------------------------------- /src/app.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | html, 6 | body, 7 | #root { 8 | width: 100%; 9 | height: 100%; 10 | margin: 0; 11 | padding: 0; 12 | } 13 | 14 | body { 15 | background-color: #2b263d; 16 | } 17 | -------------------------------------------------------------------------------- /src/app.tsx: -------------------------------------------------------------------------------- 1 | import './app.css'; 2 | import { Canvas, Stages } from '@react-three/fiber'; 3 | import { StrictMode, Suspense, useLayoutEffect } from 'react'; 4 | import { CameraController } from 'camera/camera-controller'; 5 | import { Fauna } from 'test-assets/fauna'; 6 | import { Terrain } from 'test-assets/terrain'; 7 | import { Collider } from 'collider/collider'; 8 | import Space from 'test-assets/space'; 9 | import { PlayerController } from 'player/player-controller'; 10 | import { MushroomBoi } from 'test-assets/mushroom-boi'; 11 | import { TestExtenstionTerrain } from 'test-assets/test-extension-terrain'; 12 | import { InputSystem } from 'input/input-system'; 13 | 14 | const FIXED_STEP = 1 / 60; 15 | 16 | function Game() { 17 | // Set fixed step size. 18 | useLayoutEffect(() => { 19 | Stages.Fixed.fixedStep = FIXED_STEP; 20 | }, []); 21 | 22 | return ( 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 58 | 59 | ); 60 | } 61 | 62 | export default function App() { 63 | return ( 64 | 65 | 66 | 67 | 68 | 69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /src/camera/camera-controller.tsx: -------------------------------------------------------------------------------- 1 | import { OrbitControls } from '@react-three/drei'; 2 | import { useLayoutEffect, useRef, useState } from 'react'; 3 | import * as THREE from 'three'; 4 | import { useUpdate, useThree, Stages } from '@react-three/fiber'; 5 | import { useCameraController } from './stores/camera-store'; 6 | 7 | // TODO: Implement the PerspectiveCamera with portaling 8 | 9 | export function CameraController() { 10 | const [camera] = useState(() => { 11 | const _camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); 12 | _camera.position.set(2, 3, -2); 13 | return _camera; 14 | }); 15 | const controlsRef = useRef(null!); 16 | const target = useCameraController((state) => state.target); 17 | const set = useThree(({ set }) => set); 18 | 19 | useLayoutEffect(() => { 20 | const oldCam = camera; 21 | set(() => ({ camera: camera! })); 22 | return () => set(() => ({ camera: oldCam })); 23 | }, [camera, set]); 24 | 25 | useUpdate(() => { 26 | if (!target) return; 27 | camera.position.sub(controlsRef.current.target); 28 | controlsRef.current.target.copy(target.position); 29 | camera.position.add(target.position); 30 | }, Stages.Update); 31 | 32 | return ( 33 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/camera/stores/camera-store.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import create from 'zustand'; 3 | 4 | interface CameraState { 5 | camera: THREE.PerspectiveCamera | null; 6 | target: THREE.Object3D | null; 7 | setCamera: (camera: THREE.PerspectiveCamera | null) => void; 8 | setTarget: (target: THREE.Object3D | null) => void; 9 | } 10 | 11 | export const useCameraController = create((set) => ({ 12 | camera: null, 13 | target: null, 14 | setTarget: (target) => set({ target }), 15 | setCamera: (camera) => set({ camera }), 16 | })); 17 | -------------------------------------------------------------------------------- /src/character/bounding-volume/use-bounding-volume.ts: -------------------------------------------------------------------------------- 1 | import { MeasureHandler, useMeasure } from 'utilities/use-measure'; 2 | import { useCallback, useEffect, useState } from 'react'; 3 | import * as THREE from 'three'; 4 | import { Vector3 } from '@react-three/fiber'; 5 | 6 | export type Capsule = { 7 | radius: number; 8 | height: number; 9 | line: THREE.Line3; 10 | }; 11 | 12 | export type CapsuleConfig = { radius?: number; height?: number; center?: Vector3 } | 'auto'; 13 | 14 | export class BoundingVolume extends THREE.Object3D { 15 | public isBoundingVolume: boolean; 16 | public boundingCapsule: Capsule; 17 | public boundingBox: THREE.Box3; 18 | 19 | constructor() { 20 | super(); 21 | this.type = 'BoundingVolume'; 22 | this.isBoundingVolume = true; 23 | this.boundingCapsule = { radius: 0, height: 0, line: new THREE.Line3() }; 24 | this.boundingBox = new THREE.Box3(); 25 | } 26 | 27 | computeBoundingVolume() { 28 | const { line, height } = this.boundingCapsule; 29 | const box = this.boundingBox; 30 | 31 | const offset = height / 2; 32 | 33 | line.end.set(0, -offset, 0); 34 | line.start.set(0, offset, 0); 35 | 36 | line.start.applyMatrix4(this.matrixWorld); 37 | line.end.applyMatrix4(this.matrixWorld); 38 | box.makeEmpty(); 39 | box.setFromPoints([line.start, line.end]); 40 | box.min.addScalar(-this.boundingCapsule.radius); 41 | box.max.addScalar(this.boundingCapsule.radius); 42 | } 43 | } 44 | 45 | export function useBoundingVolume(config: CapsuleConfig, ref: React.MutableRefObject) { 46 | const [bounding] = useState(() => new BoundingVolume()); 47 | 48 | const handleMeasure = useCallback( 49 | (size) => { 50 | if (config === 'auto') { 51 | const capsule = bounding.boundingCapsule; 52 | capsule.radius = size.x / 2; 53 | const height = size.y - capsule.radius * 2; 54 | capsule.height = height > 0 ? height : 0; 55 | } 56 | }, 57 | [bounding.boundingCapsule, config], 58 | ); 59 | 60 | useMeasure(ref, handleMeasure, { precise: true }); 61 | 62 | useEffect(() => { 63 | const capsule = bounding.boundingCapsule; 64 | 65 | if (config !== 'auto') { 66 | capsule.radius = config.radius || 0; 67 | capsule.height = config.height || 0; 68 | } 69 | 70 | const offset = capsule.height / 2; 71 | 72 | capsule.line.end.copy(new THREE.Vector3(0, -offset, 0)); 73 | capsule.line.start.copy(new THREE.Vector3(0, offset, 0)); 74 | 75 | // Copy the target transforms to the bounding volume. 76 | ref.current.matrix.decompose(bounding.position, bounding.quaternion, bounding.scale); 77 | }, [bounding, config, ref]); 78 | 79 | return bounding; 80 | } 81 | -------------------------------------------------------------------------------- /src/character/bounding-volume/volume-debug.tsx: -------------------------------------------------------------------------------- 1 | import { Color, createPortal, extend, Object3DNode, Stages, useThree, useUpdate } from '@react-three/fiber'; 2 | import { BoundingVolume, Capsule } from 'character/bounding-volume/use-bounding-volume'; 3 | import { MutableRefObject, useRef, useState } from 'react'; 4 | import * as THREE from 'three'; 5 | import { Line as LineThree } from 'three'; 6 | import { LineGeometry, LineMaterial, Line2 } from 'three-stdlib'; 7 | 8 | extend({ Line2, LineGeometry, LineMaterial, LineThree }); 9 | 10 | declare module '@react-three/fiber' { 11 | interface ThreeElements { 12 | lineGeometry: Object3DNode; 13 | lineMaterial: Object3DNode; 14 | line2: Object3DNode; 15 | lineThree: Object3DNode; 16 | } 17 | } 18 | 19 | type VolumeDebugProps = { 20 | bounding: BoundingVolume; 21 | showLine?: boolean; 22 | showBox?: boolean; 23 | showCollider?: boolean; 24 | showForce?: boolean; 25 | }; 26 | 27 | function createCapsulePoints(radius = 1, length = 1, degrees = 30) { 28 | const points = []; 29 | 30 | // Left half circle 31 | for (let i = 0; i <= degrees; i++) { 32 | points.push(Math.cos(i * (Math.PI / degrees)) * radius, Math.sin(i * (Math.PI / degrees)) * radius + length / 2, 0); 33 | } 34 | 35 | // Right half circle 36 | for (let i = 0; i <= degrees; i++) { 37 | points.push( 38 | -Math.cos(i * (Math.PI / degrees)) * radius, 39 | -Math.sin(i * (Math.PI / degrees)) * radius - length / 2, 40 | 0, 41 | ); 42 | } 43 | 44 | // Closing point 45 | points.push(points[0], points[1], points[2]); 46 | 47 | return points; 48 | } 49 | 50 | function createBoxGeometry() { 51 | const indices = new Uint16Array([0, 1, 1, 2, 2, 3, 3, 0, 4, 5, 5, 6, 6, 7, 7, 4, 0, 4, 1, 5, 2, 6, 3, 7]); 52 | const positions = new Float32Array(8 * 3); 53 | 54 | const geometry = new THREE.BufferGeometry(); 55 | geometry.setIndex(new THREE.BufferAttribute(indices, 1)); 56 | geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); 57 | 58 | return geometry; 59 | } 60 | 61 | function updateBox(box: THREE.Box3, boxRef: MutableRefObject) { 62 | const min = box.min; 63 | const max = box.max; 64 | 65 | const position = boxRef.current.geometry.attributes.position; 66 | const array = position.array as number[]; 67 | 68 | array[0] = max.x; 69 | array[1] = max.y; 70 | array[2] = max.z; 71 | array[3] = min.x; 72 | array[4] = max.y; 73 | array[5] = max.z; 74 | array[6] = min.x; 75 | array[7] = min.y; 76 | array[8] = max.z; 77 | array[9] = max.x; 78 | array[10] = min.y; 79 | array[11] = max.z; 80 | array[12] = max.x; 81 | array[13] = max.y; 82 | array[14] = min.z; 83 | array[15] = min.x; 84 | array[16] = max.y; 85 | array[17] = min.z; 86 | array[18] = min.x; 87 | array[19] = min.y; 88 | array[20] = min.z; 89 | array[21] = max.x; 90 | array[22] = min.y; 91 | array[23] = min.z; 92 | 93 | position.needsUpdate = true; 94 | 95 | boxRef.current.geometry.computeBoundingSphere(); 96 | } 97 | 98 | function CapsuleLine({ capsule, color = 'yellow' }: { capsule: Capsule; color?: Color }) { 99 | return ( 100 | <> 101 | 102 | 103 | 104 | 107 | 108 | 109 | 113 | 114 | 115 | 119 | 120 | 121 | 122 | ); 123 | } 124 | 125 | export function VolumeDebug({ 126 | bounding, 127 | showLine = false, 128 | showBox = false, 129 | showCollider = true, 130 | showForce = false, 131 | }: VolumeDebugProps) { 132 | const colliderRef = useRef(null!); 133 | const forceRef = useRef(null!); 134 | const boxRef = useRef(null!); 135 | const [vec] = useState(() => new THREE.Vector3()); 136 | const [forceCapsule] = useState({ ...bounding.boundingCapsule }); 137 | const scene = useThree((state) => state.scene); 138 | 139 | useUpdate(() => { 140 | if (showForce) { 141 | bounding.boundingBox.getCenter(vec); 142 | forceRef.current.position.copy(vec); 143 | } 144 | 145 | bounding.updateMatrixWorld(); 146 | bounding.computeBoundingVolume(); 147 | bounding.boundingBox.getCenter(vec); 148 | colliderRef.current.position.copy(vec); 149 | 150 | if (showBox) updateBox(bounding.boundingBox, boxRef); 151 | }, Stages.Late); 152 | 153 | return ( 154 | <> 155 | {createPortal( 156 | <> 157 | {/* Force visualization */} 158 | {showForce && ( 159 | 160 | 161 | 162 | )} 163 | 164 | 165 | {/* Capsule collider visualization */} 166 | {showCollider && } 167 | 168 | {/* Collision line visualization */} 169 | {showLine && ( 170 | 175 | 176 | 177 | )} 178 | 179 | 180 | {/* Bounding box visualization */} 181 | {showBox && ( 182 | 183 | 184 | 185 | )} 186 | , 187 | scene, 188 | )} 189 | 190 | ); 191 | } 192 | -------------------------------------------------------------------------------- /src/character/character-controller.tsx: -------------------------------------------------------------------------------- 1 | import { Stages, useUpdate, Vector3 } from '@react-three/fiber'; 2 | import { useCallback, useLayoutEffect, useRef, useState } from 'react'; 3 | import * as THREE from 'three'; 4 | import { useCollider } from 'collider/stores/collider-store'; 5 | import { CapsuleConfig, useBoundingVolume } from './bounding-volume/use-bounding-volume'; 6 | import { useCharacterController } from './stores/character-store'; 7 | import { useModifiers } from './modifiers/use-modifiers'; 8 | import { CharacterControllerContext } from './contexts/character-controller-context'; 9 | import { useInterpret } from '@xstate/react'; 10 | import { movementMachine } from './machines/movement-machine'; 11 | import { AirCollision } from './modifiers/air-collision'; 12 | import { VolumeDebug } from './bounding-volume/volume-debug'; 13 | import { SmoothDamp } from '@gsimone/smoothdamp'; 14 | 15 | export type CharacterControllerProps = { 16 | children: React.ReactNode; 17 | debug?: boolean | { showCollider?: boolean; showLine?: boolean; showBox?: boolean; showForce?: boolean }; 18 | position?: Vector3; 19 | iterations?: number; 20 | groundDetectionOffset?: number; 21 | capsule?: CapsuleConfig; 22 | rotateTime?: number; 23 | slopeLimit?: number; 24 | }; 25 | 26 | // For reasons unknown, an additional iteration is required every 15 units of force to prevent tunneling. 27 | // This isn't affected by the length of the character's body. I'll automate this once I do more testing. 28 | const ITERATIONS = 5; 29 | 30 | export function CharacterController({ 31 | children, 32 | debug = false, 33 | position, 34 | iterations = ITERATIONS, 35 | groundDetectionOffset = 0.1, 36 | capsule = 'auto', 37 | rotateTime = 0.1, 38 | slopeLimit = 45, 39 | }: CharacterControllerProps) { 40 | const meshRef = useRef(null!); 41 | const [character, setCharacter] = useCharacterController((state) => [state.character, state.setCharacter]); 42 | 43 | const _debug = debug === true ? { showCollider: true, showLine: false, showBox: false, showForce: false } : debug; 44 | 45 | const [store] = useState({ 46 | vec: new THREE.Vector3(), 47 | vec2: new THREE.Vector3(), 48 | deltaVector: new THREE.Vector3(), 49 | velocity: new THREE.Vector3(), 50 | box: new THREE.Box3(), 51 | line: new THREE.Line3(), 52 | prevLine: new THREE.Line3(), 53 | raycaster: new THREE.Raycaster(), 54 | toggle: true, 55 | timer: 0, 56 | isGrounded: false, 57 | isGroundedMovement: false, 58 | isFalling: false, 59 | groundNormal: new THREE.Vector3(), 60 | direction: new THREE.Vector3(), 61 | targetAngle: 0, 62 | currentAngle: 0, 63 | currentQuat: new THREE.Quaternion(), 64 | targetQuat: new THREE.Quaternion(), 65 | smoothDamp: new SmoothDamp(rotateTime, 100), 66 | }); 67 | 68 | // Get movement modifiers. 69 | const { modifiers, addModifier, removeModifier } = useModifiers(); 70 | 71 | // Get fininte state machine. 72 | const fsm = useInterpret( 73 | movementMachine, 74 | { 75 | actions: { 76 | onFall: () => { 77 | clearTimeout(store.timer); 78 | store.toggle = false; 79 | store.timer = setTimeout(() => (store.toggle = true), 100); 80 | }, 81 | onWalk: () => { 82 | clearTimeout(store.timer); 83 | store.toggle = false; 84 | store.timer = setTimeout(() => (store.toggle = true), 100); 85 | }, 86 | }, 87 | }, 88 | (state) => { 89 | store.isGroundedMovement = state.matches('walking'); 90 | store.isFalling = state.matches('falling'); 91 | }, 92 | ); 93 | 94 | // Get world collider BVH. 95 | const collider = useCollider((state) => state.collider); 96 | 97 | // Build bounding volume. Right now it can only be a capsule. 98 | const bounding = useBoundingVolume(capsule, meshRef); 99 | useLayoutEffect(() => setCharacter(bounding), [bounding, setCharacter]); 100 | 101 | const moveCharacter = useCallback( 102 | (velocity: THREE.Vector3, delta: number) => { 103 | character?.position.addScaledVector(velocity, delta); 104 | character?.updateMatrixWorld(); 105 | }, 106 | [character], 107 | ); 108 | 109 | const detectGround = useCallback((): [boolean, THREE.Face | null] => { 110 | if (!character || !collider) return [false, null]; 111 | 112 | const { raycaster, vec2 } = store; 113 | const { boundingCapsule: capsule } = character; 114 | 115 | raycaster.set(character.position, vec2.set(0, -1, 0)); 116 | raycaster.far = capsule.height / 2 + capsule.radius + groundDetectionOffset; 117 | raycaster.firstHitOnly = true; 118 | const res = raycaster.intersectObject(collider, false); 119 | 120 | return [res.length !== 0, res[0]?.face ?? null]; 121 | }, [character, collider, groundDetectionOffset, store]); 122 | 123 | const syncMeshToBoundingVolume = () => { 124 | if (!character) return; 125 | meshRef.current.position.copy(character.position); 126 | }; 127 | 128 | const calculateVelocity = () => { 129 | const { velocity, direction } = store; 130 | velocity.set(0, 0, 0); 131 | direction.set(0, 0, 0); 132 | 133 | for (const modifier of modifiers) { 134 | velocity.add(modifier.value); 135 | 136 | if (modifier.name === 'walking' || modifier.name === 'falling') { 137 | direction.add(modifier.value); 138 | } 139 | } 140 | 141 | direction.normalize().negate(); 142 | }; 143 | 144 | // Applies forces to the character, then checks for collision. 145 | // If one is detected then the character is moved to no longer collide. 146 | const step = useCallback( 147 | (delta: number) => { 148 | if (!collider?.geometry.boundsTree || !character) return; 149 | 150 | const { line, vec, vec2, box, velocity, deltaVector, groundNormal, prevLine } = store; 151 | const { boundingCapsule: capsule, boundingBox } = character; 152 | let collisionSlopeCheck = false; 153 | 154 | // Start by moving the character. 155 | moveCharacter(velocity, delta); 156 | 157 | // Update bounding volume. 158 | character.computeBoundingVolume(); 159 | line.copy(capsule.line); 160 | box.copy(boundingBox); 161 | 162 | // Check for collisions. 163 | collider.geometry.boundsTree.shapecast({ 164 | intersectsBounds: (bounds) => bounds.intersectsBox(box), 165 | intersectsTriangle: (tri) => { 166 | const triPoint = vec; 167 | const capsulePoint = vec2; 168 | const distance = tri.closestPointToSegment(line, triPoint, capsulePoint); 169 | 170 | // If the distance is less than the radius of the character, we have a collision. 171 | if (distance < capsule.radius) { 172 | const depth = capsule.radius - distance; 173 | const direction = capsulePoint.sub(triPoint).normalize(); 174 | 175 | // Check if the tri we collide with is within our slope limit. 176 | const dot = direction.dot(vec.set(0, 1, 0)); 177 | const angle = THREE.MathUtils.radToDeg(Math.acos(dot)); 178 | collisionSlopeCheck = angle <= slopeLimit && angle >= 0; 179 | 180 | // We zero out the y component of the direction so that we don't slide up slopes. 181 | // This is an approximation that works because of small step sizes. 182 | if (!collisionSlopeCheck && store.isGroundedMovement) direction.y = 0; 183 | 184 | // Move the line segment so there is no longer an intersection. 185 | line.start.addScaledVector(direction, depth); 186 | line.end.addScaledVector(direction, depth); 187 | } 188 | }, 189 | }); 190 | 191 | const newPosition = vec; 192 | deltaVector.set(0, 0, 0); 193 | // Bounding volume origin is calculated. This might lose percision. 194 | line.getCenter(newPosition); 195 | deltaVector.subVectors(newPosition, character.position); 196 | 197 | // Discard values smaller than our tolerance. 198 | const offset = Math.max(0, deltaVector.length() - 1e-7); 199 | deltaVector.normalize().multiplyScalar(offset); 200 | 201 | character.position.add(deltaVector); 202 | 203 | const [isGrounded, face] = detectGround(); 204 | // If collision slope check is passed, see if our character is over ground so we don't hover. 205 | // If we fail the collision slop check, double check it by casting down. 206 | // Can definitely clean this up. 207 | if (collisionSlopeCheck) { 208 | if (isGrounded && face) { 209 | store.isGrounded = true; 210 | groundNormal.copy(face.normal); 211 | } else { 212 | store.isGrounded = false; 213 | groundNormal.set(0, 0, 0); 214 | } 215 | } else { 216 | face ? groundNormal.copy(face.normal) : groundNormal.set(0, 0, 0); 217 | 218 | const dot = groundNormal.dot(vec.set(0, 1, 0)); 219 | const angle = THREE.MathUtils.radToDeg(Math.acos(dot)); 220 | 221 | if (isGrounded && angle <= slopeLimit) { 222 | store.isGrounded = true; 223 | } else { 224 | store.isGrounded = false; 225 | } 226 | } 227 | 228 | // Set character movement state. We have a cooldown to prevent false positives. 229 | if (store.toggle) { 230 | if (store.isGrounded) fsm.send('WALK'); 231 | if (!store.isGrounded) fsm.send('FALL'); 232 | } 233 | 234 | prevLine.copy(line); 235 | }, 236 | [character, collider?.geometry.boundsTree, detectGround, fsm, moveCharacter, slopeLimit, store], 237 | ); 238 | 239 | // Run physics simulation in fixed loop. 240 | useUpdate((_, delta) => { 241 | calculateVelocity(); 242 | 243 | for (let i = 0; i < iterations; i++) { 244 | step(delta / iterations); 245 | } 246 | }, Stages.Fixed); 247 | 248 | // Sync mesh so movement is visible. 249 | useUpdate(() => { 250 | syncMeshToBoundingVolume(); 251 | }, Stages.Update); 252 | 253 | // Rotate the mesh to point in the direction of movement. 254 | // TODO: Try using a quaternion slerp instead. 255 | useUpdate((_, delta) => { 256 | if (!meshRef.current || !character) return; 257 | const { direction, vec, smoothDamp } = store; 258 | smoothDamp.smoothTime = rotateTime; 259 | 260 | if (direction.length() !== 0) { 261 | store.targetAngle = Math.atan2(direction.x, direction.z); 262 | } else { 263 | store.targetAngle = store.currentAngle; 264 | } 265 | 266 | const angleDelta = store.targetAngle - store.currentAngle; 267 | // If the angle delta is greater than PI radians, we need to rotate the other way. 268 | // This stops the character from rotating the long way around. 269 | if (Math.abs(angleDelta) > Math.PI) { 270 | store.targetAngle = store.targetAngle - Math.sign(angleDelta) * Math.PI * 2; 271 | } 272 | 273 | store.currentAngle = smoothDamp.get(store.currentAngle, store.targetAngle, delta); 274 | // Make sure our character's angle never exceeds 2PI radians. 275 | if (store.currentAngle > Math.PI) store.currentAngle -= Math.PI * 2; 276 | if (store.currentAngle < -Math.PI) store.currentAngle += Math.PI * 2; 277 | 278 | meshRef.current.setRotationFromAxisAngle(vec.set(0, 1, 0), store.currentAngle); 279 | }, Stages.Late); 280 | 281 | const getVelocity = useCallback(() => store.velocity, [store]); 282 | const getDeltaVector = useCallback(() => store.deltaVector, [store]); 283 | const getIsGroundedMovement = useCallback(() => store.isGroundedMovement, [store]); 284 | const getIsWalking = useCallback(() => store.isGroundedMovement, [store]); 285 | const getIsFalling = useCallback(() => store.isFalling, [store]); 286 | const getGroundNormal = useCallback(() => store.groundNormal, [store]); 287 | 288 | return ( 289 | 301 | 302 | {children} 303 | 304 | 305 | 306 | 307 | {character && _debug && ( 308 | 315 | )} 316 | 317 | ); 318 | } 319 | -------------------------------------------------------------------------------- /src/character/contexts/character-controller-context.ts: -------------------------------------------------------------------------------- 1 | import { movementMachine } from 'character/machines/movement-machine'; 2 | import { Modifier } from 'character/modifiers/use-modifiers'; 3 | import { createContext } from 'react'; 4 | import { InterpreterFrom } from 'xstate'; 5 | 6 | type CharacterControllerState = { 7 | addModifier: (modifier: Modifier) => void; 8 | removeModifier: (modifier: Modifier) => void; 9 | fsm: InterpreterFrom; 10 | getDeltaVector: () => THREE.Vector3; 11 | getVelocity: () => THREE.Vector3; 12 | getIsGroundedMovement: () => boolean; 13 | getIsFalling: () => boolean; 14 | getIsWalking: () => boolean; 15 | getGroundNormal: () => THREE.Vector3; 16 | }; 17 | 18 | export const CharacterControllerContext = createContext(null!); 19 | -------------------------------------------------------------------------------- /src/character/machines/movement-machine.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-empty-function */ 2 | import { createMachine } from 'xstate'; 3 | 4 | export const movementMachine = createMachine( 5 | { 6 | id: 'character', 7 | predictableActionArguments: true, 8 | initial: 'walking', 9 | states: { 10 | walking: { 11 | on: { FALL: 'falling' }, 12 | entry: 'onWalk', 13 | }, 14 | falling: { 15 | on: { WALK: 'walking' }, 16 | entry: 'onFall', 17 | }, 18 | }, 19 | }, 20 | { 21 | actions: { 22 | onWalk: () => {}, 23 | onFall: () => {}, 24 | }, 25 | }, 26 | ); 27 | -------------------------------------------------------------------------------- /src/character/modifiers/air-collision.ts: -------------------------------------------------------------------------------- 1 | import { useUpdate } from '@react-three/fiber'; 2 | import { CharacterControllerContext } from 'character/contexts/character-controller-context'; 3 | import { useContext, useLayoutEffect } from 'react'; 4 | import { createModifier } from './use-modifiers'; 5 | 6 | export function AirCollision() { 7 | const { addModifier, removeModifier, getDeltaVector, getVelocity, getIsGroundedMovement } = 8 | useContext(CharacterControllerContext); 9 | const modifier = createModifier('air-collision'); 10 | 11 | useLayoutEffect(() => { 12 | addModifier(modifier); 13 | return () => removeModifier(modifier); 14 | }, [addModifier, removeModifier, modifier]); 15 | 16 | useUpdate(() => { 17 | const isGrounded = getIsGroundedMovement(); 18 | // Reflect velocity if character collides while airborne. 19 | if (!isGrounded) { 20 | const velocity = getVelocity(); 21 | const deltaVector = getDeltaVector(); 22 | 23 | deltaVector.normalize(); 24 | deltaVector.multiplyScalar(-deltaVector.dot(velocity)); 25 | modifier.value.copy(deltaVector); 26 | } else { 27 | modifier.value.set(0, 0, 0); 28 | } 29 | }); 30 | 31 | return null; 32 | } 33 | -------------------------------------------------------------------------------- /src/character/modifiers/falling.ts: -------------------------------------------------------------------------------- 1 | import { useUpdate } from '@react-three/fiber'; 2 | import { CharacterControllerContext } from 'character/contexts/character-controller-context'; 3 | import { useContext, useLayoutEffect, useState } from 'react'; 4 | import * as THREE from 'three'; 5 | import { createModifier } from './use-modifiers'; 6 | import { WALK_SPEED } from './walking'; 7 | 8 | export type FallingProps = { 9 | speed?: number; 10 | movement?: () => THREE.Vector3; 11 | boostVelocityThreshold?: number; 12 | boostVelocity?: number; 13 | }; 14 | 15 | export function Falling({ 16 | speed = WALK_SPEED * 0.5, 17 | movement, 18 | boostVelocity = 2, 19 | boostVelocityThreshold = 1, 20 | }: FallingProps) { 21 | const { addModifier, removeModifier, getIsFalling, getVelocity } = useContext(CharacterControllerContext); 22 | const modifier = createModifier('falling'); 23 | const [store] = useState({ 24 | velocity: new THREE.Vector3(), 25 | magnitude: 0, 26 | initialVelocity: new THREE.Vector3(), 27 | initialMagnitude: 0, 28 | }); 29 | 30 | useLayoutEffect(() => { 31 | addModifier(modifier); 32 | return () => removeModifier(modifier); 33 | }, [addModifier, modifier, removeModifier]); 34 | 35 | useUpdate(() => { 36 | if (!movement) return; 37 | const input = movement(); 38 | const isFalling = getIsFalling(); 39 | 40 | if (isFalling) { 41 | if (input.length() > 0) { 42 | if (store.initialVelocity.length() < boostVelocityThreshold) { 43 | store.magnitude = store.initialMagnitude + 1 * boostVelocity; 44 | } else { 45 | store.magnitude = store.initialMagnitude; 46 | } 47 | const scaledInput = input.multiplyScalar(speed); 48 | store.velocity.copy(store.initialVelocity).add(scaledInput); 49 | store.velocity.clampLength(0, store.magnitude); 50 | modifier.value.copy(store.velocity); 51 | } 52 | } else { 53 | modifier.value.set(0, 0, 0); 54 | store.initialVelocity.copy(getVelocity()); 55 | store.initialVelocity.y = 0; 56 | store.initialMagnitude = store.initialVelocity.length(); 57 | } 58 | }); 59 | 60 | return null; 61 | } 62 | -------------------------------------------------------------------------------- /src/character/modifiers/gravity.ts: -------------------------------------------------------------------------------- 1 | import { useUpdate } from '@react-three/fiber'; 2 | import { CharacterControllerContext } from 'character/contexts/character-controller-context'; 3 | import { useContext, useLayoutEffect, useState } from 'react'; 4 | import { createModifier } from './use-modifiers'; 5 | 6 | export type GravityProps = { 7 | gravity?: number; 8 | groundedGravity?: number; 9 | alwaysOn?: boolean; 10 | maxFallSpeed?: number; 11 | }; 12 | 13 | export const GRAVITY = -9.81; 14 | 15 | export function Gravity({ 16 | gravity = GRAVITY, 17 | groundedGravity = 0, 18 | alwaysOn = false, 19 | maxFallSpeed = -50, 20 | }: GravityProps) { 21 | const { addModifier, removeModifier, getIsGroundedMovement } = useContext(CharacterControllerContext); 22 | const modifier = createModifier('gravity'); 23 | const [store] = useState({ prevIsGrounded: false }); 24 | 25 | useLayoutEffect(() => { 26 | addModifier(modifier); 27 | return () => removeModifier(modifier); 28 | }, [addModifier, gravity, modifier, removeModifier]); 29 | 30 | useUpdate((_, delta) => { 31 | const isGrounded = getIsGroundedMovement(); 32 | 33 | // Our isGrounded detection has an offset so the state sets early when falling. 34 | // We check the previous isGrounded so we get an extra frame of falling to make sure we touch the ground. 35 | if (store.prevIsGrounded) { 36 | modifier.value.y = alwaysOn ? gravity : groundedGravity; 37 | } else { 38 | modifier.value.y = Math.max(modifier.value.y + gravity * delta, maxFallSpeed); 39 | } 40 | 41 | store.prevIsGrounded = isGrounded; 42 | }); 43 | 44 | return null; 45 | } 46 | -------------------------------------------------------------------------------- /src/character/modifiers/jump.ts: -------------------------------------------------------------------------------- 1 | import { useUpdate } from '@react-three/fiber'; 2 | import { CharacterControllerContext } from 'character/contexts/character-controller-context'; 3 | import { useCallback, useContext, useLayoutEffect, useState } from 'react'; 4 | import { GRAVITY } from './gravity'; 5 | import { createModifier } from './use-modifiers'; 6 | 7 | export type JumpProps = { 8 | jumpSpeed?: number; 9 | jump?: () => boolean; 10 | jumpDuration?: number; 11 | comebackAcceleration?: number; 12 | coyoteTime?: number; 13 | }; 14 | 15 | export function Jump({ 16 | jumpSpeed = 6, 17 | jump, 18 | jumpDuration = 300, 19 | comebackAcceleration = GRAVITY * 2, 20 | coyoteTime = 0.2, 21 | }: JumpProps) { 22 | const { addModifier, removeModifier, getDeltaVector, getIsGroundedMovement, fsm } = 23 | useContext(CharacterControllerContext); 24 | const modifier = createModifier('jump'); 25 | const [store] = useState({ 26 | isRising: false, 27 | jumpStartTime: 0, 28 | prevInput: false, 29 | inputReleased: true, 30 | groundedTime: 0, 31 | }); 32 | 33 | useLayoutEffect(() => { 34 | addModifier(modifier); 35 | return () => removeModifier(modifier); 36 | }, [addModifier, modifier, removeModifier]); 37 | 38 | const performJump = useCallback(() => { 39 | fsm.send('FALL'); 40 | store.isRising = true; 41 | store.jumpStartTime = performance.now(); 42 | modifier.value.set(0, jumpSpeed, 0); 43 | }, [fsm, jumpSpeed, modifier.value, store]); 44 | 45 | useUpdate((_, delta) => { 46 | if (!jump) return; 47 | const jumpInput = jump(); 48 | const deltaVector = getDeltaVector(); 49 | const isGrounded = getIsGroundedMovement(); 50 | if (store.prevInput && !jumpInput) store.inputReleased = true; 51 | 52 | if (isGrounded) { 53 | store.isRising = false; 54 | modifier.value.set(0, 0, 0); 55 | store.groundedTime = performance.now(); 56 | } 57 | 58 | if (performance.now() - store.groundedTime <= coyoteTime) { 59 | if (jumpInput && isGrounded) { 60 | if (store.inputReleased) performJump(); 61 | store.inputReleased = false; 62 | } 63 | } 64 | 65 | if (store.isRising && !jumpInput) store.isRising = false; 66 | 67 | if (store.isRising && performance.now() > store.jumpStartTime + jumpDuration) store.isRising = false; 68 | 69 | if (!store.isRising && !isGrounded) modifier.value.y += comebackAcceleration * delta; 70 | 71 | if (deltaVector.normalize().y < -0.9) modifier.value.y = 0; 72 | 73 | store.prevInput = jumpInput; 74 | }); 75 | 76 | return null; 77 | } 78 | -------------------------------------------------------------------------------- /src/character/modifiers/use-modifiers.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from 'react'; 2 | import * as THREE from 'three'; 3 | 4 | export class Modifier { 5 | private _value: THREE.Vector3; 6 | private _name: string; 7 | 8 | constructor(name?: string) { 9 | this._value = new THREE.Vector3(); 10 | this._name = name ?? 'modifier'; 11 | } 12 | 13 | get value(): THREE.Vector3 { 14 | return this._value; 15 | } 16 | 17 | set value(value: THREE.Vector3) { 18 | this._value = value; 19 | } 20 | 21 | get name(): string { 22 | return this._name; 23 | } 24 | } 25 | 26 | export const createModifier = (name?: string) => new Modifier(name); 27 | 28 | export function useModifiers() { 29 | const [modifiers] = useState([]); 30 | 31 | const addModifier = (modifier: Modifier) => modifiers.push(modifier); 32 | 33 | const removeModifier = useCallback( 34 | (modifier: Modifier) => { 35 | const index = modifiers.indexOf(modifier); 36 | if (index !== -1) modifiers.splice(index, 1); 37 | }, 38 | [modifiers], 39 | ); 40 | 41 | return { modifiers, addModifier, removeModifier }; 42 | } 43 | -------------------------------------------------------------------------------- /src/character/modifiers/walking.ts: -------------------------------------------------------------------------------- 1 | import { useUpdate } from '@react-three/fiber'; 2 | import { CharacterControllerContext } from 'character/contexts/character-controller-context'; 3 | import { useContext, useLayoutEffect } from 'react'; 4 | import * as THREE from 'three'; 5 | import { createModifier } from './use-modifiers'; 6 | 7 | export const WALK_SPEED = 5; 8 | 9 | export type WalkingProps = { 10 | speed?: number; 11 | movement?: () => THREE.Vector3; 12 | }; 13 | 14 | export function Walking({ speed = WALK_SPEED, movement }: WalkingProps) { 15 | const { addModifier, removeModifier, getIsWalking, getGroundNormal } = useContext(CharacterControllerContext); 16 | const modifier = createModifier('walking'); 17 | 18 | useLayoutEffect(() => { 19 | addModifier(modifier); 20 | return () => removeModifier(modifier); 21 | }, [addModifier, modifier, removeModifier]); 22 | 23 | const adjustVelocityToSlope = (velocity: THREE.Vector3) => { 24 | const normal = getGroundNormal(); 25 | const slopeRotation = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), normal); 26 | const adjustedVelocity = new THREE.Vector3().copy(velocity).applyQuaternion(slopeRotation); 27 | 28 | if (adjustedVelocity.y < 0) { 29 | return adjustedVelocity; 30 | } 31 | 32 | return velocity; 33 | }; 34 | 35 | useUpdate(() => { 36 | if (!movement) return; 37 | 38 | const input = movement(); 39 | const isWalking = getIsWalking(); 40 | 41 | if (isWalking && input.length() > 0) { 42 | const velocity = input.multiplyScalar(speed); 43 | velocity.copy(adjustVelocityToSlope(velocity)); 44 | modifier.value.copy(velocity); 45 | } else { 46 | modifier.value.set(0, 0, 0); 47 | } 48 | }); 49 | 50 | return null; 51 | } 52 | -------------------------------------------------------------------------------- /src/character/stores/character-store.ts: -------------------------------------------------------------------------------- 1 | import { BoundingVolume } from 'character/bounding-volume/use-bounding-volume'; 2 | import create from 'zustand'; 3 | 4 | type CharacterState = { 5 | character: BoundingVolume | null; 6 | setCharacter: (character: BoundingVolume) => void; 7 | }; 8 | 9 | export const useCharacterController = create((set) => ({ 10 | character: null, 11 | setCharacter: (character) => set({ character }), 12 | })); 13 | -------------------------------------------------------------------------------- /src/collider/SimplifyModifier.js: -------------------------------------------------------------------------------- 1 | import { BufferGeometry, Float32BufferAttribute, Vector3 } from 'three'; 2 | import * as BufferGeometryUtils from 'three/examples/jsm//utils/BufferGeometryUtils.js'; 3 | 4 | /** 5 | * Simplification Geometry Modifier 6 | * - based on code and technique 7 | * - by Stan Melax in 1998 8 | * - Progressive Mesh type Polygon Reduction Algorithm 9 | * - http://www.melax.com/polychop/ 10 | */ 11 | 12 | const _cb = new Vector3(), 13 | _ab = new Vector3(); 14 | 15 | class SimplifyModifier { 16 | modify(geometry, count) { 17 | geometry = geometry.clone(); 18 | const attributes = geometry.attributes; 19 | 20 | // this modifier can only process indexed and non-indexed geomtries with a position attribute 21 | 22 | for (const name in attributes) { 23 | if (name !== 'position') geometry.deleteAttribute(name); 24 | } 25 | 26 | geometry = BufferGeometryUtils.mergeVertices(geometry); 27 | 28 | // 29 | // put data of original geometry in different data structures 30 | // 31 | 32 | const vertices = []; 33 | const faces = []; 34 | 35 | // add vertices 36 | 37 | const positionAttribute = geometry.getAttribute('position'); 38 | 39 | for (let i = 0; i < positionAttribute.count; i++) { 40 | const v = new Vector3().fromBufferAttribute(positionAttribute, i); 41 | 42 | const vertex = new Vertex(v); 43 | vertices.push(vertex); 44 | } 45 | 46 | // add faces 47 | 48 | let index = geometry.getIndex(); 49 | 50 | if (index !== null) { 51 | for (let i = 0; i < index.count; i += 3) { 52 | const a = index.getX(i); 53 | const b = index.getX(i + 1); 54 | const c = index.getX(i + 2); 55 | 56 | const triangle = new Triangle(vertices[a], vertices[b], vertices[c], a, b, c); 57 | faces.push(triangle); 58 | } 59 | } else { 60 | for (let i = 0; i < positionAttribute.count; i += 3) { 61 | const a = i; 62 | const b = i + 1; 63 | const c = i + 2; 64 | 65 | const triangle = new Triangle(vertices[a], vertices[b], vertices[c], a, b, c); 66 | faces.push(triangle); 67 | } 68 | } 69 | 70 | // compute all edge collapse costs 71 | 72 | for (let i = 0, il = vertices.length; i < il; i++) { 73 | computeEdgeCostAtVertex(vertices[i]); 74 | } 75 | 76 | let nextVertex; 77 | 78 | let z = count; 79 | 80 | while (z--) { 81 | nextVertex = minimumCostEdge(vertices); 82 | 83 | if (!nextVertex) { 84 | console.log('THREE.SimplifyModifier: No next vertex'); 85 | break; 86 | } 87 | 88 | collapse(vertices, faces, nextVertex, nextVertex.collapseNeighbor); 89 | } 90 | 91 | // 92 | 93 | const simplifiedGeometry = new BufferGeometry(); 94 | const position = []; 95 | 96 | index = []; 97 | 98 | // 99 | 100 | for (let i = 0; i < vertices.length; i++) { 101 | const vertex = vertices[i].position; 102 | position.push(vertex.x, vertex.y, vertex.z); 103 | // cache final index to GREATLY speed up faces reconstruction 104 | vertices[i].id = i; 105 | } 106 | 107 | // 108 | 109 | for (let i = 0; i < faces.length; i++) { 110 | const face = faces[i]; 111 | index.push(face.v1.id, face.v2.id, face.v3.id); 112 | } 113 | 114 | // 115 | 116 | simplifiedGeometry.setAttribute('position', new Float32BufferAttribute(position, 3)); 117 | simplifiedGeometry.setIndex(index); 118 | 119 | return simplifiedGeometry; 120 | } 121 | } 122 | 123 | function pushIfUnique(array, object) { 124 | if (array.indexOf(object) === -1) array.push(object); 125 | } 126 | 127 | function removeFromArray(array, object) { 128 | const k = array.indexOf(object); 129 | if (k > -1) array.splice(k, 1); 130 | } 131 | 132 | function computeEdgeCollapseCost(u, v) { 133 | // if we collapse edge uv by moving u to v then how 134 | // much different will the model change, i.e. the "error". 135 | 136 | const edgelength = v.position.distanceTo(u.position); 137 | let curvature = 0; 138 | 139 | const sideFaces = []; 140 | 141 | // find the "sides" triangles that are on the edge uv 142 | for (let i = 0, il = u.faces.length; i < il; i++) { 143 | const face = u.faces[i]; 144 | 145 | if (face.hasVertex(v)) { 146 | sideFaces.push(face); 147 | } 148 | } 149 | 150 | // use the triangle facing most away from the sides 151 | // to determine our curvature term 152 | for (let i = 0, il = u.faces.length; i < il; i++) { 153 | let minCurvature = 1; 154 | const face = u.faces[i]; 155 | 156 | for (let j = 0; j < sideFaces.length; j++) { 157 | const sideFace = sideFaces[j]; 158 | // use dot product of face normals. 159 | const dotProd = face.normal.dot(sideFace.normal); 160 | minCurvature = Math.min(minCurvature, (1.001 - dotProd) / 2); 161 | } 162 | 163 | curvature = Math.max(curvature, minCurvature); 164 | } 165 | 166 | // crude approach in attempt to preserve borders 167 | // though it seems not to be totally correct 168 | const borders = 0; 169 | 170 | if (sideFaces.length < 2) { 171 | // we add some arbitrary cost for borders, 172 | // borders += 10; 173 | curvature = 1; 174 | } 175 | 176 | const amt = edgelength * curvature + borders; 177 | 178 | return amt; 179 | } 180 | 181 | function computeEdgeCostAtVertex(v) { 182 | // compute the edge collapse cost for all edges that start 183 | // from vertex v. Since we are only interested in reducing 184 | // the object by selecting the min cost edge at each step, we 185 | // only cache the cost of the least cost edge at this vertex 186 | // (in member variable collapse) as well as the value of the 187 | // cost (in member variable collapseCost). 188 | 189 | if (v.neighbors.length === 0) { 190 | // collapse if no neighbors. 191 | v.collapseNeighbor = null; 192 | v.collapseCost = -0.01; 193 | 194 | return; 195 | } 196 | 197 | v.collapseCost = 100000; 198 | v.collapseNeighbor = null; 199 | 200 | // search all neighboring edges for "least cost" edge 201 | for (let i = 0; i < v.neighbors.length; i++) { 202 | const collapseCost = computeEdgeCollapseCost(v, v.neighbors[i]); 203 | 204 | if (!v.collapseNeighbor) { 205 | v.collapseNeighbor = v.neighbors[i]; 206 | v.collapseCost = collapseCost; 207 | v.minCost = collapseCost; 208 | v.totalCost = 0; 209 | v.costCount = 0; 210 | } 211 | 212 | v.costCount++; 213 | v.totalCost += collapseCost; 214 | 215 | if (collapseCost < v.minCost) { 216 | v.collapseNeighbor = v.neighbors[i]; 217 | v.minCost = collapseCost; 218 | } 219 | } 220 | 221 | // we average the cost of collapsing at this vertex 222 | v.collapseCost = v.totalCost / v.costCount; 223 | // v.collapseCost = v.minCost; 224 | } 225 | 226 | function removeVertex(v, vertices) { 227 | console.assert(v.faces.length === 0); 228 | 229 | while (v.neighbors.length) { 230 | const n = v.neighbors.pop(); 231 | removeFromArray(n.neighbors, v); 232 | } 233 | 234 | removeFromArray(vertices, v); 235 | } 236 | 237 | function removeFace(f, faces) { 238 | removeFromArray(faces, f); 239 | 240 | if (f.v1) removeFromArray(f.v1.faces, f); 241 | if (f.v2) removeFromArray(f.v2.faces, f); 242 | if (f.v3) removeFromArray(f.v3.faces, f); 243 | 244 | // TODO optimize this! 245 | const vs = [f.v1, f.v2, f.v3]; 246 | 247 | for (let i = 0; i < 3; i++) { 248 | const v1 = vs[i]; 249 | const v2 = vs[(i + 1) % 3]; 250 | 251 | if (!v1 || !v2) continue; 252 | 253 | v1.removeIfNonNeighbor(v2); 254 | v2.removeIfNonNeighbor(v1); 255 | } 256 | } 257 | 258 | function collapse(vertices, faces, u, v) { 259 | // u and v are pointers to vertices of an edge 260 | 261 | // Collapse the edge uv by moving vertex u onto v 262 | 263 | if (!v) { 264 | // u is a vertex all by itself so just delete it.. 265 | removeVertex(u, vertices); 266 | return; 267 | } 268 | 269 | const tmpVertices = []; 270 | 271 | for (let i = 0; i < u.neighbors.length; i++) { 272 | tmpVertices.push(u.neighbors[i]); 273 | } 274 | 275 | // delete triangles on edge uv: 276 | for (let i = u.faces.length - 1; i >= 0; i--) { 277 | if (u.faces[i]?.hasVertex(v)) { 278 | removeFace(u.faces[i], faces); 279 | } 280 | } 281 | 282 | // update remaining triangles to have v instead of u 283 | for (let i = u.faces.length - 1; i >= 0; i--) { 284 | u.faces[i].replaceVertex(u, v); 285 | } 286 | 287 | removeVertex(u, vertices); 288 | 289 | // recompute the edge collapse costs in neighborhood 290 | for (let i = 0; i < tmpVertices.length; i++) { 291 | computeEdgeCostAtVertex(tmpVertices[i]); 292 | } 293 | } 294 | 295 | function minimumCostEdge(vertices) { 296 | // O(n * n) approach. TODO optimize this 297 | 298 | let least = vertices[0]; 299 | 300 | for (let i = 0; i < vertices.length; i++) { 301 | if (vertices[i].collapseCost < least.collapseCost) { 302 | least = vertices[i]; 303 | } 304 | } 305 | 306 | return least; 307 | } 308 | 309 | // we use a triangle class to represent structure of face slightly differently 310 | 311 | class Triangle { 312 | constructor(v1, v2, v3, a, b, c) { 313 | this.a = a; 314 | this.b = b; 315 | this.c = c; 316 | 317 | this.v1 = v1; 318 | this.v2 = v2; 319 | this.v3 = v3; 320 | 321 | this.normal = new Vector3(); 322 | 323 | this.computeNormal(); 324 | 325 | v1.faces.push(this); 326 | v1.addUniqueNeighbor(v2); 327 | v1.addUniqueNeighbor(v3); 328 | 329 | v2.faces.push(this); 330 | v2.addUniqueNeighbor(v1); 331 | v2.addUniqueNeighbor(v3); 332 | 333 | v3.faces.push(this); 334 | v3.addUniqueNeighbor(v1); 335 | v3.addUniqueNeighbor(v2); 336 | } 337 | 338 | computeNormal() { 339 | const vA = this.v1.position; 340 | const vB = this.v2.position; 341 | const vC = this.v3.position; 342 | 343 | _cb.subVectors(vC, vB); 344 | _ab.subVectors(vA, vB); 345 | _cb.cross(_ab).normalize(); 346 | 347 | this.normal.copy(_cb); 348 | } 349 | 350 | hasVertex(v) { 351 | return v === this.v1 || v === this.v2 || v === this.v3; 352 | } 353 | 354 | replaceVertex(oldv, newv) { 355 | if (oldv === this.v1) this.v1 = newv; 356 | else if (oldv === this.v2) this.v2 = newv; 357 | else if (oldv === this.v3) this.v3 = newv; 358 | 359 | removeFromArray(oldv.faces, this); 360 | newv.faces.push(this); 361 | 362 | oldv.removeIfNonNeighbor(this.v1); 363 | this.v1.removeIfNonNeighbor(oldv); 364 | 365 | oldv.removeIfNonNeighbor(this.v2); 366 | this.v2.removeIfNonNeighbor(oldv); 367 | 368 | oldv.removeIfNonNeighbor(this.v3); 369 | this.v3.removeIfNonNeighbor(oldv); 370 | 371 | this.v1.addUniqueNeighbor(this.v2); 372 | this.v1.addUniqueNeighbor(this.v3); 373 | 374 | this.v2.addUniqueNeighbor(this.v1); 375 | this.v2.addUniqueNeighbor(this.v3); 376 | 377 | this.v3.addUniqueNeighbor(this.v1); 378 | this.v3.addUniqueNeighbor(this.v2); 379 | 380 | this.computeNormal(); 381 | } 382 | } 383 | 384 | class Vertex { 385 | constructor(v) { 386 | this.position = v; 387 | 388 | this.id = -1; // external use position in vertices list (for e.g. face generation) 389 | 390 | this.faces = []; // faces vertex is connected 391 | this.neighbors = []; // neighbouring vertices aka "adjacentVertices" 392 | 393 | // these will be computed in computeEdgeCostAtVertex() 394 | this.collapseCost = 0; // cost of collapsing this vertex, the less the better. aka objdist 395 | this.collapseNeighbor = null; // best candinate for collapsing 396 | } 397 | 398 | addUniqueNeighbor(vertex) { 399 | pushIfUnique(this.neighbors, vertex); 400 | } 401 | 402 | removeIfNonNeighbor(n) { 403 | const neighbors = this.neighbors; 404 | const faces = this.faces; 405 | 406 | const offset = neighbors.indexOf(n); 407 | 408 | if (offset === -1) return; 409 | 410 | for (let i = 0; i < faces.length; i++) { 411 | if (faces[i].hasVertex(n)) return; 412 | } 413 | 414 | neighbors.splice(offset, 1); 415 | } 416 | } 417 | 418 | export { SimplifyModifier }; 419 | -------------------------------------------------------------------------------- /src/collider/collider.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useRef, useState } from 'react'; 2 | // import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUtils.js'; 3 | import { useCollider } from 'collider/stores/collider-store'; 4 | import { 5 | acceleratedRaycast, 6 | computeBoundsTree, 7 | disposeBoundsTree, 8 | MeshBVH, 9 | MeshBVHVisualizer, 10 | StaticGeometryGenerator, 11 | } from 'three-mesh-bvh'; 12 | import * as THREE from 'three'; 13 | // @ts-ignore // Using our own SimplifyModifier to fix a bug. 14 | // import { SimplifyModifier } from './SimplifyModifier'; 15 | import { useUpdate } from '@react-three/fiber'; 16 | 17 | type ColliderProps = { 18 | children: React.ReactNode; 19 | debug?: boolean | { collider?: boolean; bvh?: boolean }; 20 | // simplify?: number; 21 | autoUpdate?: boolean; 22 | }; 23 | 24 | export function Collider({ 25 | children, 26 | debug = { collider: false, bvh: false }, 27 | // simplify, 28 | autoUpdate = false, 29 | }: ColliderProps) { 30 | const ref = useRef(null!); 31 | const [collider, setCollider] = useCollider((state) => [state.collider, state.setCollider]); 32 | const [bvhVisualizer, setBvhVisualizer] = useState(undefined); 33 | const [store] = useState({ 34 | init: true, 35 | boxMap: {} as Record, 36 | prevBoxMap: {} as Record, 37 | matrixMap: {} as Record, 38 | prevMatrixMap: {} as Record, 39 | generator: null as unknown as StaticGeometryGenerator, 40 | }); 41 | const _debug = debug === true ? { collider: true, bvh: false } : debug; 42 | 43 | const updateMaps = useCallback( 44 | (object: THREE.Object3D) => { 45 | if (object instanceof THREE.Group) { 46 | store.matrixMap[object.uuid] = object.matrix.clone(); 47 | } 48 | if (object instanceof THREE.Mesh && object.geometry) { 49 | if (object.geometry.boundingBox === null) object.geometry.computeBoundingBox(); 50 | store.boxMap[object.uuid] = object.geometry.boundingBox; 51 | store.matrixMap[object.uuid] = object.matrix.clone(); 52 | } 53 | }, 54 | [store], 55 | ); 56 | 57 | const buildColliderGeometry = useCallback(() => { 58 | ref.current.updateMatrixWorld(); 59 | 60 | ref.current.traverse((c) => { 61 | if (autoUpdate) updateMaps(c); 62 | }); 63 | 64 | if (autoUpdate) { 65 | store.prevBoxMap = { ...store.boxMap }; 66 | store.prevMatrixMap = { ...store.matrixMap }; 67 | } 68 | 69 | store.generator = new StaticGeometryGenerator(ref.current); 70 | const geometry = store.generator.generate(); 71 | 72 | // Simplify the geometry for better performance. 73 | // if (simplify) { 74 | // const modifier = new SimplifyModifier(); 75 | // const count = Math.floor(merged.attributes.position.count * simplify); 76 | // merged = modifier.modify(merged, count); 77 | // } 78 | 79 | return geometry; 80 | }, [autoUpdate, store, updateMaps]); 81 | 82 | const rebuildBVH = useCallback(() => { 83 | const geometry = buildColliderGeometry(); 84 | collider?.geometry.dispose(); 85 | collider?.geometry.copy(geometry); 86 | collider?.geometry.computeBoundsTree(); 87 | }, [buildColliderGeometry, collider?.geometry]); 88 | 89 | const refitBVH = useCallback(() => { 90 | if (!collider) return; 91 | store.generator.generate(collider.geometry); 92 | collider.geometry.boundsTree?.refit(); 93 | }, [collider, store.generator]); 94 | 95 | // Initialization of BVH collider. 96 | useEffect(() => { 97 | if (!ref.current || !store.init) return; 98 | 99 | const geometry = buildColliderGeometry(); 100 | geometry.boundsTree = new MeshBVH(geometry); 101 | 102 | const collider = new THREE.Mesh( 103 | geometry, 104 | new THREE.MeshBasicMaterial({ 105 | wireframe: true, 106 | transparent: true, 107 | opacity: 0.5, 108 | depthWrite: false, 109 | }), 110 | ); 111 | collider.raycast = acceleratedRaycast; 112 | collider.geometry.computeBoundsTree = computeBoundsTree; 113 | collider.geometry.disposeBoundsTree = disposeBoundsTree; 114 | 115 | setCollider(collider); 116 | 117 | store.init = false; 118 | }, [buildColliderGeometry, setCollider, store]); 119 | 120 | // Initialization of BVH visualizer. 121 | useEffect(() => { 122 | if (collider) { 123 | const visualizer = new MeshBVHVisualizer(collider, 10); 124 | setBvhVisualizer(visualizer); 125 | } 126 | }, [collider]); 127 | 128 | // Dispose of the BVH if we unmount. 129 | useEffect(() => { 130 | return () => { 131 | if (!collider) return; 132 | collider?.geometry.dispose(); 133 | const material = collider?.material as THREE.Material; 134 | material.dispose(); 135 | }; 136 | }, [collider]); 137 | 138 | useUpdate(() => { 139 | if (!collider || !autoUpdate) return; 140 | 141 | store.boxMap = {}; 142 | ref.current.traverse((c) => { 143 | if (c instanceof THREE.Group) { 144 | store.matrixMap[c.uuid] = c.matrix.clone(); 145 | } 146 | if (c instanceof THREE.Mesh && c.geometry) { 147 | if (c.geometry.boundingBox === null) c.geometry.computeBoundingBox(); 148 | store.boxMap[c.uuid] = c.geometry.boundingBox; 149 | store.matrixMap[c.uuid] = c.matrix.clone(); 150 | } 151 | }); 152 | 153 | if (Object.keys(store.boxMap).length !== Object.keys(store.prevBoxMap).length) { 154 | rebuildBVH(); 155 | store.prevBoxMap = { ...store.boxMap }; 156 | return; 157 | } 158 | 159 | for (const uuid in store.boxMap) { 160 | const current = store.boxMap[uuid]; 161 | const prev = store.prevBoxMap[uuid]; 162 | 163 | if (current.equals(prev)) continue; 164 | 165 | rebuildBVH(); 166 | store.prevBoxMap = { ...store.boxMap }; 167 | break; 168 | } 169 | 170 | for (const uuid in store.matrixMap) { 171 | const current = store.matrixMap[uuid]; 172 | const prev = store.prevMatrixMap[uuid]; 173 | 174 | if (current.equals(prev)) continue; 175 | 176 | refitBVH(); 177 | store.prevMatrixMap = { ...store.matrixMap }; 178 | break; 179 | } 180 | 181 | store.prevMatrixMap = { ...store.matrixMap }; 182 | store.prevBoxMap = { ...store.boxMap }; 183 | }); 184 | 185 | return ( 186 | <> 187 | {children} 188 | {_debug && collider && } 189 | {_debug && bvhVisualizer && } 190 | 191 | ); 192 | } 193 | -------------------------------------------------------------------------------- /src/collider/stores/collider-store.tsx: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import create from 'zustand'; 3 | 4 | interface ColliderState { 5 | collider: THREE.Mesh | null; 6 | setCollider: (collider: THREE.Mesh) => void; 7 | } 8 | 9 | export const useCollider = create((set) => ({ 10 | collider: null, 11 | setCollider: (collider) => set({ collider }), 12 | })); 13 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /src/input/input-controller.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | BooleanControl, 3 | Controller as CFController, 4 | GamepadDevice, 5 | KeyboardDevice, 6 | TouchDevice, 7 | VectorControl, 8 | processors, 9 | } from '@hmans/controlfreak'; 10 | import { Stages, useUpdate } from '@react-three/fiber'; 11 | import { useLayoutEffect, useRef, useState } from 'react'; 12 | import create from 'zustand'; 13 | 14 | type ControllerState = { 15 | controller: CFController; 16 | }; 17 | 18 | type Devices = 'keyboard' | 'gamepad' | 'touch'; 19 | type ActionDevices = { 20 | keyboard?: KeyboardDevice; 21 | gamepad?: GamepadDevice; 22 | touch?: TouchDevice; 23 | processors?: typeof processors; 24 | }; 25 | 26 | type InputControllerProps = { 27 | devices?: Devices | Devices[]; 28 | actions: (devices: ActionDevices) => { 29 | [key: string]: { 30 | type: 'vector' | 'boolean'; 31 | steps: any[]; 32 | }; 33 | }; 34 | pause?: boolean; 35 | }; 36 | 37 | const useStore = create(() => ({ 38 | controller: new CFController(), 39 | })); 40 | 41 | export function InputController({ 42 | devices = ['keyboard', 'gamepad', 'touch'], 43 | actions: createActions, 44 | pause = false, 45 | }: InputControllerProps) { 46 | const deviceMap = useRef>(new Map()); 47 | const controller = useStore((state) => state.controller); 48 | 49 | // Add devices 50 | useLayoutEffect(() => { 51 | const _devices = !Array.isArray(devices) ? [devices] : devices; 52 | const _deviceMap = deviceMap.current; 53 | for (const device of _devices) { 54 | switch (device) { 55 | case 'keyboard': 56 | _deviceMap.set('keyboard', new KeyboardDevice()); 57 | controller.addDevice(_deviceMap.get('keyboard')!); 58 | break; 59 | case 'gamepad': 60 | _deviceMap.set('gamepad', new GamepadDevice()); 61 | controller.addDevice(_deviceMap.get('gamepad')!); 62 | break; 63 | case 'touch': 64 | _deviceMap.set('touch', new TouchDevice()); 65 | controller.addDevice(_deviceMap.get('touch')!); 66 | break; 67 | default: 68 | throw new Error(`Unknown device: ${device}`); 69 | } 70 | } 71 | 72 | return () => { 73 | for (const device of _deviceMap.values()) { 74 | controller.removeDevice(device); 75 | } 76 | }; 77 | }, [controller, devices]); 78 | 79 | // Create actions and bind them 80 | useLayoutEffect(() => { 81 | const _devices = { ...Object.fromEntries(deviceMap.current), processors }; 82 | const actions = createActions(_devices); 83 | 84 | for (const [key, value] of Object.entries(actions)) { 85 | let type; 86 | switch (value.type) { 87 | case 'vector': 88 | type = VectorControl; 89 | break; 90 | case 'boolean': 91 | type = BooleanControl; 92 | break; 93 | default: 94 | throw new Error(`Unknown action type: ${value.type}`); 95 | } 96 | const control = controller.addControl(key, type); 97 | 98 | for (const step of value.steps) { 99 | control.addStep(step); 100 | } 101 | } 102 | }, [controller, createActions]); 103 | 104 | // Update the controller on an early loop 105 | useUpdate(() => { 106 | // Loop this to work around HMR bug. 107 | controller.start(); 108 | if (!pause) controller.update(); 109 | }, Stages.Early); 110 | 111 | return null; 112 | } 113 | 114 | export function useInputs() { 115 | const controller = useStore((state) => state.controller); 116 | const [inputs] = useState<{ [key: string]: any }>({}); 117 | 118 | // This is a hack to demonstrate a cleaner API 119 | useUpdate(() => { 120 | for (const [key, value] of Object.entries(controller.controls)) { 121 | inputs[key] = value.value; 122 | } 123 | }, Stages.Early); 124 | 125 | return inputs; 126 | } 127 | -------------------------------------------------------------------------------- /src/input/input-system.tsx: -------------------------------------------------------------------------------- 1 | import { InputController } from 'input/input-controller'; 2 | 3 | export function InputSystem() { 4 | return ( 5 | ({ 8 | move: { 9 | type: 'vector', 10 | steps: [ 11 | keyboard?.compositeVector('KeyW', 'KeyS', 'KeyA', 'KeyD'), 12 | gamepad?.axisVector(0, 1), 13 | processors?.deadzone(0.15), 14 | ], 15 | }, 16 | look: { 17 | type: 'vector', 18 | steps: [ 19 | keyboard?.compositeVector('ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'), 20 | gamepad?.axisVector(2, 3), 21 | processors?.deadzone(0.15), 22 | ], 23 | }, 24 | jump: { type: 'boolean', steps: [keyboard?.whenKeyPressed('Space'), gamepad?.whenButtonPressed(0)] }, 25 | })} 26 | /> 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import App from './app'; 4 | import './index.css'; 5 | 6 | ReactDOM.createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ); 11 | -------------------------------------------------------------------------------- /src/player/player-controller.tsx: -------------------------------------------------------------------------------- 1 | import { Stages, useUpdate } from '@react-three/fiber'; 2 | import { useCameraController } from 'camera/stores/camera-store'; 3 | import { CharacterController, CharacterControllerProps } from 'character/character-controller'; 4 | import { Falling, FallingProps } from 'character/modifiers/falling'; 5 | import { Gravity, GravityProps } from 'character/modifiers/gravity'; 6 | import { Jump, JumpProps } from 'character/modifiers/jump'; 7 | import { Walking, WalkingProps, WALK_SPEED } from 'character/modifiers/walking'; 8 | import { useCharacterController } from 'character/stores/character-store'; 9 | import { useInputs } from 'input/input-controller'; 10 | import { useEffect, useState } from 'react'; 11 | import * as THREE from 'three'; 12 | 13 | type PlayerControllerProps = CharacterControllerProps & 14 | Omit & 15 | Omit & 16 | Omit & 17 | Omit & { 18 | gravityAlwaysOn?: boolean; 19 | walkSpeed?: number; 20 | airControl?: number; 21 | }; 22 | 23 | export function PlayerController({ 24 | children, 25 | walkSpeed = WALK_SPEED, 26 | airControl = 0.5, 27 | ...props 28 | }: PlayerControllerProps) { 29 | const [store] = useState(() => ({ 30 | forward: new THREE.Vector3(), 31 | right: new THREE.Vector3(), 32 | walk: new THREE.Vector3(), 33 | move: new THREE.Vector2(), 34 | })); 35 | 36 | const character = useCharacterController((state) => state.character); 37 | const setTarget = useCameraController((state) => state.setTarget); 38 | const inputs = useInputs(); 39 | 40 | useEffect(() => setTarget(character), [character, setTarget]); 41 | 42 | // Reset if we fall off the level. 43 | useUpdate(() => { 44 | if (character && character.position.y < -10) { 45 | if (props.position) { 46 | if (Array.isArray(props.position)) character.position.set(...props.position); 47 | if (props.position instanceof THREE.Vector3) character.position.copy(props.position); 48 | if (typeof props.position === 'number') character.position.set(props.position, props.position, props.position); 49 | } else { 50 | character.position.set(0, 0, 0); 51 | } 52 | } 53 | }); 54 | 55 | // Update the player's movement vector based on camera direction. 56 | useUpdate((state) => { 57 | const { move: moveInput } = inputs; 58 | const { forward, right, walk, move } = store; 59 | 60 | move.set(moveInput.x, moveInput.y); 61 | const magnitude = Math.min(move.length(), 1); 62 | move.normalize(); 63 | 64 | forward.set(0, 0, -1).applyQuaternion(state.camera.quaternion); 65 | forward.normalize().multiplyScalar(move.y); 66 | forward.y = 0; 67 | 68 | right.set(1, 0, 0).applyQuaternion(state.camera.quaternion); 69 | right.normalize().multiplyScalar(move.x); 70 | right.y = 0; 71 | 72 | walk.addVectors(forward, right).multiplyScalar(magnitude); 73 | }, Stages.Early); 74 | 75 | return ( 76 | 84 | {children} 85 | store.walk} speed={walkSpeed} /> 86 | store.walk} speed={walkSpeed * airControl} /> 87 | inputs.jump} jumpSpeed={props.jumpSpeed} /> 88 | 94 | 95 | ); 96 | } 97 | -------------------------------------------------------------------------------- /src/test-assets/fauna.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | Auto-generated by: https://github.com/pmndrs/gltfjsx 3 | */ 4 | 5 | import { useGLTF } from '@react-three/drei'; 6 | 7 | export function Fauna(props: JSX.IntrinsicElements['group']) { 8 | const { nodes, materials } = useGLTF('/jungle-merged.glb') as any; 9 | return ( 10 | 11 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | ); 28 | } 29 | 30 | useGLTF.preload('/jungle-merged.glb'); 31 | -------------------------------------------------------------------------------- /src/test-assets/low-poly-island.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | Auto-generated by: https://github.com/pmndrs/gltfjsx 3 | */ 4 | 5 | import * as THREE from 'three'; 6 | import { useGLTF } from '@react-three/drei'; 7 | import { GLTF } from 'three-stdlib'; 8 | 9 | type GLTFResult = GLTF & { 10 | nodes: { 11 | floating_island_11: THREE.Mesh; 12 | }; 13 | materials: Record; 14 | }; 15 | 16 | export function LowPolyIslands(props: JSX.IntrinsicElements['group']) { 17 | const { nodes } = useGLTF('/island_fbx_gltf.glb') as GLTFResult; 18 | return ( 19 | 20 | 21 | 22 | 23 | 24 | ); 25 | } 26 | 27 | useGLTF.preload('/island_fbx_gltf.glb'); 28 | -------------------------------------------------------------------------------- /src/test-assets/mushroom-boi.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | Auto-generated by: https://github.com/pmndrs/gltfjsx 3 | */ 4 | 5 | import * as THREE from 'three'; 6 | import { useEffect, useRef } from 'react'; 7 | import { useGLTF, useAnimations } from '@react-three/drei'; 8 | import { GLTF } from 'three-stdlib'; 9 | 10 | type GLTFResult = GLTF & { 11 | nodes: { 12 | Cylinder: THREE.SkinnedMesh; 13 | Plane: THREE.SkinnedMesh; 14 | Sphere: THREE.SkinnedMesh; 15 | Sphere001: THREE.SkinnedMesh; 16 | Bone: THREE.Bone; 17 | }; 18 | materials: { 19 | ['Material.008']: THREE.MeshStandardMaterial; 20 | ['Material.007']: THREE.MeshStandardMaterial; 21 | ['Material.006']: THREE.MeshStandardMaterial; 22 | ['Material.005']: THREE.MeshStandardMaterial; 23 | }; 24 | }; 25 | 26 | // type ActionName = 'Armature|Armature|ArmatureAction|Armature|ArmatureAction'; 27 | // type GLTFActions = Record; 28 | 29 | export function MushroomBoi(props: JSX.IntrinsicElements['group']) { 30 | const group = useRef(null!); 31 | const { nodes, materials, animations } = useGLTF('/mushroom-boi.glb') as GLTFResult; 32 | const { actions } = useAnimations(animations, group); 33 | 34 | useEffect(() => { 35 | actions?.Idle?.play(); 36 | }, [actions?.Idle]); 37 | 38 | return ( 39 | 40 | 41 | 42 | 43 | 44 | 45 | 53 | 61 | 69 | 77 | 78 | 79 | ); 80 | } 81 | 82 | useGLTF.preload('/mushroom-boi.glb'); 83 | -------------------------------------------------------------------------------- /src/test-assets/player.tsx: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react'; 2 | import * as THREE from 'three'; 3 | 4 | type PlayerProps = { 5 | radius: number; 6 | height: number; 7 | }; 8 | 9 | export function Player({ radius = 0.5, height: length = 0.65 }: PlayerProps) { 10 | const playerRef = useRef(null!); 11 | 12 | return ( 13 | <> 14 | 15 | 16 | 17 | 18 | {/* */} 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/test-assets/simple-plane.tsx: -------------------------------------------------------------------------------- 1 | export function SimplePlane() { 2 | return ( 3 | 4 | 5 | 6 | 7 | ); 8 | } 9 | -------------------------------------------------------------------------------- /src/test-assets/space.tsx: -------------------------------------------------------------------------------- 1 | import { Stages, useUpdate } from '@react-three/fiber'; 2 | import { useRef } from 'react'; 3 | import { Stars } from '@react-three/drei'; 4 | 5 | export default function Space() { 6 | const depthRef = useRef(null!); 7 | 8 | useUpdate(({ clock }) => { 9 | if (depthRef.current) { 10 | depthRef.current.alpha = Math.sin(clock.elapsedTime * 0.1) * 0.4 + 0.4; 11 | } 12 | }, Stages.Update); 13 | 14 | return ( 15 | <> 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/test-assets/terrain.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | Auto-generated by: https://github.com/pmndrs/gltfjsx 3 | */ 4 | 5 | import { useGLTF } from '@react-three/drei'; 6 | import { useEffect } from 'react'; 7 | 8 | export function Terrain(props: JSX.IntrinsicElements['group']) { 9 | const { nodes, materials } = useGLTF('/jungle-merged.glb') as any; 10 | 11 | useEffect(() => { 12 | for (const material in materials) { 13 | materials[material].envMapIntensity = 0.3; 14 | materials[material].normalMap = null; 15 | } 16 | }, [materials]); 17 | 18 | return ( 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | ); 35 | } 36 | 37 | useGLTF.preload('/jungle-merged.glb'); 38 | -------------------------------------------------------------------------------- /src/test-assets/test-extension-terrain.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@react-three/drei'; 2 | import { Stages, useUpdate, Vector3 } from '@react-three/fiber'; 3 | import { useRef } from 'react'; 4 | import * as THREE from 'three'; 5 | 6 | function Steps({ stepHeight = 0.1, position = [0, 0, 0] }: { stepHeight?: number; position?: Vector3 }) { 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | } 21 | 22 | export function TestExtenstionTerrain() { 23 | const platformARef = useRef(null!); 24 | const platformBRef = useRef(null!); 25 | 26 | useUpdate((state) => { 27 | const time = state.clock.getElapsedTime(); 28 | platformARef.current.position.x = Math.sin(time); 29 | platformBRef.current.position.y = Math.sin(time) + 1.2; 30 | }, Stages.Fixed); 31 | 32 | return ( 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /src/utilities/quatDamp.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import { SmoothDamp } from '@gsimone/smoothdamp'; 3 | import { Unity } from './unity'; 4 | 5 | export function quatDamp(current: THREE.Quaternion, target: THREE.Quaternion, lambda: number, delta: number) { 6 | const angleTo = current.angleTo(target); 7 | 8 | if (angleTo > 0) { 9 | const t = THREE.MathUtils.damp(0, angleTo, lambda, delta); 10 | current = current.slerp(target, t); 11 | } 12 | } 13 | 14 | export function quatSmoothDamp(current: THREE.Quaternion, target: THREE.Quaternion, smoothTime: number, delta: number) { 15 | const angleTo = current.angleTo(target); 16 | const smoothDamp = new SmoothDamp(smoothTime / 10, 50); 17 | console.log(angleTo); 18 | 19 | if (angleTo > 0) { 20 | const t = smoothDamp.get(0, angleTo, delta); 21 | current = current.slerp(target, t); 22 | } 23 | } 24 | 25 | function projectOnVector(a: THREE.Vector4, b: THREE.Vector4, target: THREE.Vector4) { 26 | const denominator = a.lengthSq(); 27 | 28 | if (denominator === 0) return b.set(0, 0, 0, 0); 29 | 30 | const scalar = a.dot(b) / denominator; 31 | 32 | return target.copy(a).multiplyScalar(scalar); 33 | } 34 | 35 | // https://gist.github.com/maxattack/4c7b4de00f5c1b95a33b#file-quaternionutil-cs-L38 36 | // Dirty conversion that may not work right. 37 | const deriv = new THREE.Vector4(); 38 | export function quatSmoothDamp2( 39 | current: THREE.Quaternion, 40 | target: THREE.Quaternion, 41 | smoothTime: number, 42 | delta: number, 43 | ) { 44 | if (delta < Number.EPSILON) return current; 45 | // account for double-cover 46 | const dot = current.dot(target); 47 | const multi = dot > 0 ? 1 : -1; 48 | target.x *= multi; 49 | target.y *= multi; 50 | target.z *= multi; 51 | target.w *= multi; 52 | // smooth damp (nlerp approx) 53 | const result = new THREE.Vector4( 54 | Unity.smoothDamp(current.x, target.x, deriv.x, smoothTime, Infinity, delta), 55 | Unity.smoothDamp(current.y, target.y, deriv.y, smoothTime, Infinity, delta), 56 | Unity.smoothDamp(current.z, target.z, deriv.z, smoothTime, Infinity, delta), 57 | Unity.smoothDamp(current.w, target.w, deriv.w, smoothTime, Infinity, delta), 58 | ).normalize(); 59 | 60 | // ensure deriv is tangent 61 | // const derivError = Vector4.Project(new Vector4(deriv.x, deriv.y, deriv.z, deriv.w), result); 62 | const derivError = new THREE.Vector4(); 63 | projectOnVector(new THREE.Vector4(deriv.x, deriv.y, deriv.z, deriv.w), result, derivError); 64 | deriv.x -= derivError.x; 65 | deriv.y -= derivError.y; 66 | deriv.z -= derivError.z; 67 | deriv.w -= derivError.w; 68 | 69 | return new THREE.Quaternion(result.x, result.y, result.z, result.w); 70 | } 71 | -------------------------------------------------------------------------------- /src/utilities/unity.ts: -------------------------------------------------------------------------------- 1 | function clamp(value: number, min: number, max: number) { 2 | return Math.min(Math.max(value, min), max); 3 | } 4 | 5 | // Loops the value t, so that it is never larger than length and never smaller than 0. 6 | function repeat(t: number, length: number) { 7 | return clamp(t - Math.floor(t / length) * length, 0, length); 8 | } 9 | 10 | // Calculates the shortest difference between two given angles. 11 | function deltaAngle(current: number, target: number) { 12 | let delta = repeat(target - current, 360); 13 | if (delta > 180) delta -= 360; 14 | return delta; 15 | } 16 | 17 | function deltaAngleRad(current: number, target: number) { 18 | let delta = repeat(target - current, 360 * (Math.PI / 180)); 19 | if (delta > 180 * (Math.PI / 180)) delta -= 360 * (Math.PI / 180); 20 | return delta; 21 | } 22 | 23 | function moveTowards(current: number, target: number, maxDelta: number) { 24 | if (Math.abs(current - target) <= maxDelta) return target; 25 | return current + Math.sign(target - current) * maxDelta; 26 | } 27 | 28 | // Gradually changes a value towards a desired goal over time. 29 | function smoothDamp( 30 | current: number, 31 | target: number, 32 | currentVelocity: number, 33 | smoothTime: number, 34 | maxSpeed: number, 35 | deltaTime: number, 36 | ) { 37 | // Based on Game Programming Gems 4 Chapter 1.10 38 | smoothTime = Math.max(0.0001, smoothTime); 39 | const omega = 2 / smoothTime; 40 | 41 | const x = omega * deltaTime; 42 | const exp = 1 / (1 + x + 0.48 * x * x + 0.235 * x * x * x); 43 | let change = current - target; 44 | const originalTo = target; 45 | 46 | // Clamp maximum speed 47 | const maxChange = maxSpeed * smoothTime; 48 | change = Math.min(Math.max(change, -maxChange), maxChange); 49 | target = current - change; 50 | 51 | const temp = (currentVelocity + omega * change) * deltaTime; 52 | currentVelocity = (currentVelocity - omega * temp) * exp; 53 | let output = target + (change + temp) * exp; 54 | 55 | // Prevent overshooting 56 | if (originalTo - current > 0.0 === output > originalTo) { 57 | output = originalTo; 58 | currentVelocity = (output - originalTo) / deltaTime; 59 | } 60 | 61 | return output; 62 | } 63 | 64 | function smoothDampAngle( 65 | current: number, 66 | target: number, 67 | currentVelocity: number, 68 | smoothTime: number, 69 | maxSpeed: number, 70 | deltaTime: number, 71 | ) { 72 | target = current + deltaAngle(current, target); 73 | return smoothDamp(current, target, currentVelocity, smoothTime, maxSpeed, deltaTime); 74 | } 75 | 76 | export const Unity = { 77 | clamp, 78 | repeat, 79 | deltaAngle, 80 | deltaAngleRad, 81 | moveTowards, 82 | smoothDamp, 83 | smoothDampAngle, 84 | }; 85 | -------------------------------------------------------------------------------- /src/utilities/use-box-debug.ts: -------------------------------------------------------------------------------- 1 | import { useUpdate, useThree, Stages } from '@react-three/fiber'; 2 | import { useEffect, useRef } from 'react'; 3 | import * as THREE from 'three'; 4 | 5 | export function useBoxDebug(box3: THREE.Box3 | null = null) { 6 | const boxRef = useRef(null!); 7 | const scene = useThree((state) => state.scene); 8 | 9 | useEffect(() => { 10 | if (!box3) return; 11 | 12 | const indices = new Uint16Array([0, 1, 1, 2, 2, 3, 3, 0, 4, 5, 5, 6, 6, 7, 7, 4, 0, 4, 1, 5, 2, 6, 3, 7]); 13 | const positions = new Float32Array(8 * 3); 14 | 15 | const geometry = new THREE.BufferGeometry(); 16 | geometry.setIndex(new THREE.BufferAttribute(indices, 1)); 17 | geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); 18 | 19 | const line = new THREE.LineSegments(geometry, new THREE.LineBasicMaterial({ color: 'yellow', toneMapped: false })); 20 | line.matrixAutoUpdate = false; 21 | scene.add(line); 22 | 23 | boxRef.current = line; 24 | 25 | return () => { 26 | scene.remove(line); 27 | }; 28 | }, [box3, scene]); 29 | 30 | useUpdate(() => { 31 | if (!box3) return; 32 | 33 | const min = box3.min; 34 | const max = box3.max; 35 | 36 | const position = boxRef.current.geometry.attributes.position; 37 | const array = position.array as number[]; 38 | 39 | array[0] = max.x; 40 | array[1] = max.y; 41 | array[2] = max.z; 42 | array[3] = min.x; 43 | array[4] = max.y; 44 | array[5] = max.z; 45 | array[6] = min.x; 46 | array[7] = min.y; 47 | array[8] = max.z; 48 | array[9] = max.x; 49 | array[10] = min.y; 50 | array[11] = max.z; 51 | array[12] = max.x; 52 | array[13] = max.y; 53 | array[14] = min.z; 54 | array[15] = min.x; 55 | array[16] = max.y; 56 | array[17] = min.z; 57 | array[18] = min.x; 58 | array[19] = min.y; 59 | array[20] = min.z; 60 | array[21] = max.x; 61 | array[22] = min.y; 62 | array[23] = min.z; 63 | 64 | position.needsUpdate = true; 65 | 66 | boxRef.current.geometry.computeBoundingSphere(); 67 | }, Stages.Late); 68 | } 69 | -------------------------------------------------------------------------------- /src/utilities/use-line-debug.ts: -------------------------------------------------------------------------------- 1 | import { useUpdate, useThree, Stages } from '@react-three/fiber'; 2 | import { useEffect, useRef } from 'react'; 3 | import * as THREE from 'three'; 4 | 5 | export function useLineDebug(line3: THREE.Line3 | null = null) { 6 | const lineRef = useRef(null!); 7 | const scene = useThree((state) => state.scene); 8 | 9 | useEffect(() => { 10 | if (!line3) return; 11 | const points = []; 12 | points.push(line3.start); 13 | points.push(line3.end); 14 | 15 | const geometry = new THREE.BufferGeometry().setFromPoints(points); 16 | const line = new THREE.Line(geometry, new THREE.LineBasicMaterial({ color: 'red', depthTest: false })); 17 | scene.add(line); 18 | lineRef.current = line; 19 | 20 | return () => { 21 | scene.remove(line); 22 | }; 23 | }, [line3, scene]); 24 | 25 | useUpdate(() => { 26 | if (lineRef.current && line3) { 27 | const points = []; 28 | points.push(line3.start); 29 | points.push(line3.end); 30 | const geometry = new THREE.BufferGeometry().setFromPoints(points); 31 | lineRef.current.geometry = geometry; 32 | } 33 | }, Stages.Late); 34 | } 35 | -------------------------------------------------------------------------------- /src/utilities/use-measure.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import * as THREE from 'three'; 3 | 4 | export type MeasureHandler = (size: THREE.Vector3, box: THREE.Box3) => void; 5 | type MeasureOptions = { precise?: boolean }; 6 | 7 | export function useMeasure( 8 | ref: React.MutableRefObject, 9 | callback: MeasureHandler, 10 | options?: MeasureOptions, 11 | ) { 12 | const [size] = useState(() => new THREE.Vector3(0, 0, 0)); 13 | const [box] = useState(() => new THREE.Box3(new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, 0))); 14 | 15 | useEffect(() => { 16 | if (!ref.current) return; 17 | box.setFromObject(ref.current, options?.precise); 18 | box.getSize(size); 19 | callback(size, box); 20 | }, [box, callback, options?.precise, ref, size]); 21 | } 22 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "baseUrl": "./src" 19 | }, 20 | "exclude": ["node_modules", ".turbo"], 21 | "include": ["src"], 22 | "references": [{ "path": "./tsconfig.node.json" }] 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "esnext", 5 | "moduleResolution": "node" 6 | }, 7 | "include": ["vite.config.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | import tsconfigPaths from 'vite-tsconfig-paths'; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | // This changes the out put dir from dist to build 8 | // comment this out if that isn't relevant for your project 9 | build: { 10 | outDir: 'build', 11 | }, 12 | plugins: [react(), tsconfigPaths()], 13 | }); 14 | --------------------------------------------------------------------------------