├── .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 |
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