├── .DS_Store ├── .gitignore ├── .prettierrc ├── bundler ├── webpack.common.js ├── webpack.dev.js └── webpack.prod.js ├── package-lock.json ├── package.json ├── readme.md ├── src ├── index.html ├── js │ ├── cameras │ │ └── ThirdPersonCamera.js │ ├── fsm │ │ ├── CharacterFSM.js │ │ └── FiniteStateMachine.js │ ├── index.js │ ├── movements │ │ ├── BasicCharacterController.js │ │ ├── BasicCharacterControllerInput.js │ │ └── BasicCharacterControllerProxy.js │ └── states │ │ ├── DanceState.js │ │ ├── IdleState.js │ │ ├── RunState.js │ │ ├── State.js │ │ └── WalkState.js ├── styles │ └── index.css └── utils │ └── loader.js └── static ├── .DS_Store └── models └── girl ├── dance.fbx ├── eve_j_gonzales.fbx ├── idle.fbx ├── run.fbx └── walk.fbx /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Alex-DG/threejs-character-controls/31872aa369ac41280e94c90e0cbadc999692eec8/.DS_Store -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | */node_modules 5 | /node_modules 6 | /.pnp 7 | .pnp.js 8 | 9 | # testing 10 | /coverage 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | 23 | # enviroment variables 24 | .env.* 25 | 26 | .vercel 27 | 28 | #build files 29 | /dist 30 | /build 31 | .vscode/* -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "semi": false, 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "trailingComma": "es5" 7 | } -------------------------------------------------------------------------------- /bundler/webpack.common.js: -------------------------------------------------------------------------------- 1 | const CopyWebpackPlugin = require('copy-webpack-plugin') 2 | const HtmlWebpackPlugin = require('html-webpack-plugin') 3 | const MiniCSSExtractPlugin = require('mini-css-extract-plugin') 4 | const path = require('path') 5 | 6 | module.exports = { 7 | entry: path.resolve(__dirname, '../src/js/index.js'), 8 | output: { 9 | filename: 'bundle.[contenthash].js', 10 | path: path.resolve(__dirname, '../dist'), 11 | }, 12 | devtool: 'source-map', 13 | plugins: [ 14 | new CopyWebpackPlugin({ 15 | patterns: [{ from: path.resolve(__dirname, '../static') }], 16 | }), 17 | new HtmlWebpackPlugin({ 18 | template: path.resolve(__dirname, '../src/index.html'), 19 | minify: true, 20 | }), 21 | new MiniCSSExtractPlugin(), 22 | ], 23 | module: { 24 | rules: [ 25 | // HTML 26 | { 27 | test: /\.(html)$/, 28 | use: ['html-loader'], 29 | }, 30 | 31 | // JS 32 | { 33 | test: /\.js$/, 34 | exclude: /node_modules/, 35 | use: ['babel-loader'], 36 | }, 37 | 38 | // CSS 39 | { 40 | test: /\.css$/, 41 | use: [MiniCSSExtractPlugin.loader, 'css-loader'], 42 | }, 43 | 44 | // Images 45 | { 46 | test: /\.(jpg|png|gif|svg)$/, 47 | use: [ 48 | { 49 | loader: 'file-loader', 50 | options: { 51 | outputPath: 'assets/images/', 52 | }, 53 | }, 54 | ], 55 | }, 56 | 57 | // Fonts 58 | { 59 | test: /\.(ttf|eot|woff|woff2)$/, 60 | use: [ 61 | { 62 | loader: 'file-loader', 63 | options: { 64 | outputPath: 'assets/fonts/', 65 | }, 66 | }, 67 | ], 68 | }, 69 | ], 70 | }, 71 | } 72 | -------------------------------------------------------------------------------- /bundler/webpack.dev.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge') 2 | const commonConfiguration = require('./webpack.common.js') 3 | const ip = require('internal-ip') 4 | const portFinderSync = require('portfinder-sync') 5 | 6 | const infoColor = (_message) => { 7 | return `\u001b[1m\u001b[34m${_message}\u001b[39m\u001b[22m` 8 | } 9 | 10 | module.exports = merge(commonConfiguration, { 11 | mode: 'development', 12 | devServer: { 13 | host: '0.0.0.0', 14 | port: portFinderSync.getPort(8080), 15 | contentBase: './dist', 16 | watchContentBase: true, 17 | open: true, 18 | https: false, 19 | useLocalIp: true, 20 | disableHostCheck: true, 21 | overlay: true, 22 | noInfo: true, 23 | after: function (app, server, compiler) { 24 | const port = server.options.port 25 | const https = server.options.https ? 's' : '' 26 | const localIp = ip.v4.sync() 27 | const domain1 = `http${https}://${localIp}:${port}` 28 | const domain2 = `http${https}://localhost:${port}` 29 | 30 | console.log( 31 | `Project running at:\n - ${infoColor(domain1)}\n - ${infoColor( 32 | domain2 33 | )}` 34 | ) 35 | }, 36 | }, 37 | }) 38 | -------------------------------------------------------------------------------- /bundler/webpack.prod.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge') 2 | const commonConfiguration = require('./webpack.common.js') 3 | const { CleanWebpackPlugin } = require('clean-webpack-plugin') 4 | 5 | module.exports = merge(commonConfiguration, { 6 | mode: 'production', 7 | plugins: [new CleanWebpackPlugin()], 8 | }) 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "threejs-fbx-loader", 3 | "repository": "#", 4 | "license": "UNLICENSED", 5 | "scripts": { 6 | "build": "webpack --config ./bundler/webpack.prod.js", 7 | "dev": "webpack serve --config ./bundler/webpack.dev.js" 8 | }, 9 | "dependencies": { 10 | "@babel/core": "^7.14.3", 11 | "@babel/preset-env": "^7.14.2", 12 | "babel-loader": "^8.2.2", 13 | "clean-webpack-plugin": "^3.0.0", 14 | "copy-webpack-plugin": "^9.0.0", 15 | "css-loader": "^5.2.6", 16 | "dat.gui": "^0.7.7", 17 | "file-loader": "^6.2.0", 18 | "html-loader": "^2.1.2", 19 | "html-webpack-plugin": "^5.3.1", 20 | "mini-css-extract-plugin": "^1.6.0", 21 | "portfinder-sync": "0.0.2", 22 | "raw-loader": "^4.0.2", 23 | "style-loader": "^2.0.0", 24 | "three": "^0.129.0", 25 | "webpack": "^5.38.0", 26 | "webpack-cli": "^4.7.0", 27 | "webpack-dev-server": "^3.11.2", 28 | "webpack-merge": "^5.7.3" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Three.JS FBX Loader + Character controls 2 | 3 | - Load FBX character with animations. 4 | - The 3d model and animations can be found on [mixamo.com](https://www.mixamo.com/). 5 | - By pressing on your keyboard: w,a,s,d and shift (turn on/off running mode) or space (start dancing!), you will be able to control the character movements and one special animation!. 6 | - This demo is based on SimonDev 3rd Person Camera Series on [SimonDev Youtube Channel](https://www.youtube.com/@simondev758) 7 | 8 | movements 9 | macarena 10 | 11 | 12 | ## Setup 13 | 14 | Download [Node.js](https://nodejs.org/en/download/). 15 | Run this followed commands: 16 | 17 | ```bash 18 | # Install dependencies (only the first time) 19 | npm install 20 | 21 | # Run the local server at localhost:8080 22 | npm run dev 23 | 24 | # Build for production in the dist/ directory 25 | npm run build 26 | ``` 27 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Three.JS FBX Loader 7 | 8 | 9 | 10 |
11 |
12 | 13 |
14 | Use w,s,a,d to move, Shift to run, Space to dance. 15 |
16 | 17 |
18 |
19 | L O A D I N G 20 |
21 |
22 | 23 | 24 | -------------------------------------------------------------------------------- /src/js/cameras/ThirdPersonCamera.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | 3 | class ThirdPersonCamera { 4 | constructor(params) { 5 | this.target = params.target 6 | this.camera = params.camera 7 | 8 | this.currentPosition = new THREE.Vector3() 9 | this.currentLookat = new THREE.Vector3() 10 | } 11 | 12 | calculateIdealOffset(x, y, z) { 13 | const idealOffset = new THREE.Vector3(x, y, z) 14 | idealOffset.applyQuaternion(this.target.rotation) 15 | idealOffset.add(this.target.position) 16 | return idealOffset 17 | } 18 | 19 | calculateIdealLookat(x, y, z) { 20 | const idealLookat = new THREE.Vector3(x, y, z) 21 | idealLookat.applyQuaternion(this.target.rotation) 22 | idealLookat.add(this.target.position) 23 | return idealLookat 24 | } 25 | 26 | /** 27 | * Update camera position 28 | * 29 | * @param {Float} time - in second 30 | */ 31 | update(time, freeCamera = false) { 32 | if (freeCamera) { 33 | this.camera.lookAt(this.target.position) 34 | } else { 35 | const idealOffset = this.calculateIdealOffset(-15, 20, -30) 36 | const idealLookat = this.calculateIdealLookat(0, 10, 50) 37 | 38 | const a = 1.0 - Math.pow(0.001, time) 39 | 40 | this.currentPosition.lerp(idealOffset, a) 41 | this.currentLookat.lerp(idealLookat, a) 42 | 43 | this.camera.position.copy(this.currentPosition) 44 | this.camera.lookAt(this.currentLookat) 45 | } 46 | } 47 | } 48 | 49 | export default ThirdPersonCamera 50 | -------------------------------------------------------------------------------- /src/js/fsm/CharacterFSM.js: -------------------------------------------------------------------------------- 1 | import FiniteStateMachine from './FiniteStateMachine' 2 | 3 | import DanceState from '../states/DanceState' 4 | import RunState from '../states/RunState' 5 | import WalkState from '../states/WalkState' 6 | import IdleState from '../states/IdleState' 7 | 8 | class CharacterFSM extends FiniteStateMachine { 9 | constructor(proxy) { 10 | super() 11 | this.proxy = proxy 12 | 13 | this.addState('idle', IdleState) 14 | this.addState('walk', WalkState) 15 | this.addState('run', RunState) 16 | this.addState('dance', DanceState) 17 | } 18 | } 19 | 20 | export default CharacterFSM 21 | -------------------------------------------------------------------------------- /src/js/fsm/FiniteStateMachine.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Finite-State Machine 3 | * https://en.wikipedia.org/wiki/Finite-state_machine 4 | * 5 | * FSM manages animation's states from one to to another in response to keyboard inputs. 6 | * 7 | * ~ For example ~ 8 | * 9 | * idle -> 'forward key' -> walk 10 | * walk -> 'stop' -> idle 11 | * 12 | * walk -> 'shift key' -> run 13 | * run -> 'no shift key' -> walk 14 | * 15 | * idle -> 'space key' -> dance 16 | * dance -> 'no space key' -> idle 17 | * 18 | */ 19 | class FiniteStateMachine { 20 | constructor() { 21 | this.states = {} 22 | this.currentState = null 23 | } 24 | 25 | addState(name, type) { 26 | this.states[name] = type 27 | } 28 | 29 | setState(name) { 30 | const prevState = this.currentState 31 | 32 | if (prevState) { 33 | if (prevState.name == name) { 34 | return 35 | } 36 | prevState.exit() 37 | } 38 | 39 | const state = new this.states[name](this) 40 | 41 | this.currentState = state 42 | state.enter(prevState) 43 | } 44 | 45 | update(timeElapsed, input) { 46 | this.currentState?.update(timeElapsed, input) 47 | } 48 | } 49 | 50 | export default FiniteStateMachine 51 | -------------------------------------------------------------------------------- /src/js/index.js: -------------------------------------------------------------------------------- 1 | import '../styles/index.css' 2 | 3 | import * as THREE from 'three' 4 | import * as dat from 'dat.gui' 5 | 6 | import Stats from 'three/examples/jsm/libs/stats.module' 7 | import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js' 8 | import { TrackballControls } from 'three/examples/jsm/controls/TrackballControls.js' 9 | import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js' 10 | 11 | import BasicCharacterController from './movements/BasicCharacterController' 12 | import ThirdPersonCamera from './cameras/ThirdPersonCamera' 13 | 14 | export default class Demo { 15 | constructor() { 16 | this.gui = new dat.GUI({ width: 350 }) 17 | 18 | this.cameraFolder = this.gui.addFolder('Camera') 19 | this.ambientLightFolder = this.gui.addFolder('Ambient Light') 20 | this.directionalLightFolder = this.gui.addFolder('Directional Light') 21 | 22 | this.parameters = { 23 | thirdPersonCamera: true, 24 | } 25 | 26 | this.cameraFolder 27 | .add(this.parameters, 'thirdPersonCamera') 28 | .onChange((value) => { 29 | console.log({ value, cam: this.camera }) 30 | }) 31 | .name('Third Person Camera') 32 | 33 | // Init model 34 | this.init() 35 | } 36 | 37 | init() { 38 | this.stats = Stats() 39 | document.body.appendChild(this.stats.dom) 40 | 41 | this.time = new THREE.Clock() 42 | this.previousTime = 0 43 | 44 | this.mixers = [] 45 | this.currentAction = null 46 | 47 | this.container = document.getElementById('webgl-container') 48 | this.width = this.container.offsetWidth 49 | this.height = this.container.offsetHeight 50 | 51 | // Renderer 52 | this.renderer = new THREE.WebGLRenderer({ 53 | antialias: true, 54 | }) 55 | this.renderer.shadowMap.enabled = true 56 | this.renderer.shadowMap.type = THREE.PCFSoftShadowMap 57 | this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) 58 | this.renderer.setSize(this.width, this.height) 59 | 60 | this.container.appendChild(this.renderer.domElement) 61 | 62 | // Camera 63 | const fov = 60 64 | const aspect = this.width / this.height 65 | const near = 1.0 66 | const far = 2000.0 67 | this.camera = new THREE.PerspectiveCamera(fov, aspect, near, far) 68 | this.camera.position.set(25, 10, 35) 69 | 70 | // Scene 71 | this.scene = new THREE.Scene() 72 | this.scene.background = new THREE.Color(0xa0a0a0) 73 | this.scene.fog = new THREE.Fog(0xa0a0a0, 200, 1000) 74 | 75 | // Light 76 | const directionalLight = new THREE.DirectionalLight(0xffffff, 3.7) 77 | directionalLight.position.set(25, 80, 30) 78 | directionalLight.target.position.set(0, 0, 0) 79 | directionalLight.castShadow = true 80 | directionalLight.shadow.bias = -0.001 81 | directionalLight.shadow.mapSize.width = 2048 82 | directionalLight.shadow.mapSize.height = 2048 83 | directionalLight.shadow.camera.near = 0.1 84 | directionalLight.shadow.camera.far = 500.0 85 | directionalLight.shadow.camera.near = 0.5 86 | directionalLight.shadow.camera.far = 500.0 87 | directionalLight.shadow.camera.left = 100 88 | directionalLight.shadow.camera.right = -100 89 | directionalLight.shadow.camera.top = 100 90 | directionalLight.shadow.camera.bottom = -100 91 | this.scene.add(directionalLight) 92 | 93 | this.directionalLightFolder.add(directionalLight, 'castShadow') 94 | 95 | this.directionalLightFolder.add(directionalLight, 'visible') 96 | 97 | this.directionalLightFolder 98 | .add(directionalLight, 'intensity') 99 | .min(0) 100 | .max(100) 101 | .step(0.001) 102 | .name(`intensity`) 103 | this.directionalLightFolder 104 | .add(directionalLight.position, 'x') 105 | .min(0) 106 | .max(100) 107 | .step(0.001) 108 | .name(`light position X`) 109 | this.directionalLightFolder 110 | .add(directionalLight.position, 'y') 111 | .min(0) 112 | .max(100) 113 | .step(0.001) 114 | .name(`light position Y`) 115 | this.directionalLightFolder 116 | .add(directionalLight.position, 'z') 117 | .min(0) 118 | .max(100) 119 | .step(0.001) 120 | .name(`light position Z`) 121 | 122 | const ambientLight = new THREE.AmbientLight(0xffffff, 0.7) 123 | this.scene.add(ambientLight) 124 | this.ambientLightFolder 125 | .add(ambientLight, 'intensity') 126 | .min(0) 127 | .max(6) 128 | .step(0.1) 129 | 130 | // Orbit Controls 131 | this.orbitControls = new OrbitControls( 132 | this.camera, 133 | this.renderer.domElement 134 | ) 135 | // this.orbitControls.enableDamping = true 136 | this.orbitControls.target.set(0, 20, 0) 137 | this.orbitControls.update() 138 | 139 | // Ground 140 | const grid = new THREE.GridHelper(150, 10, 0x000000, 0x000000) 141 | grid.material.opacity = 0.2 142 | grid.material.transparent = true 143 | 144 | const plane = new THREE.Mesh( 145 | new THREE.PlaneGeometry(150, 150, 10, 10), 146 | new THREE.MeshStandardMaterial({ 147 | color: 'white', 148 | }) 149 | ) 150 | plane.castShadow = false 151 | plane.receiveShadow = true 152 | plane.rotation.x = -Math.PI / 2 153 | 154 | this.scene.add(plane, grid) 155 | 156 | this.setupResize() 157 | this.loadAnimatedModel() 158 | this.render() 159 | } 160 | 161 | resize() { 162 | this.width = this.container.offsetWidth 163 | this.height = this.container.offsetHeight 164 | 165 | this.camera.aspect = this.width / this.height 166 | this.camera.updateProjectionMatrix() 167 | 168 | this.renderer.setSize(this.width, this.height) 169 | } 170 | 171 | setupResize() { 172 | window.addEventListener('resize', this.resize.bind(this), false) 173 | } 174 | 175 | loadAnimatedModel() { 176 | this.controls = new BasicCharacterController({ 177 | camera: this.camera, 178 | scene: this.scene, 179 | path: '/models/girl/', 180 | }) 181 | 182 | this.thirdPersonCamera = new ThirdPersonCamera({ 183 | camera: this.camera, 184 | target: this.controls, 185 | }) 186 | } 187 | 188 | /** 189 | * Load any model and play first animation 190 | * from different path and position 191 | */ 192 | loadAnimatedModelAndPlay(path, modelFile, animFile, offset) { 193 | const loader = new FBXLoader() 194 | loader.setPath(path) 195 | 196 | loader.load(modelFile, (fbx) => { 197 | fbx.scale.setScalar(0.1) 198 | fbx.traverse((child) => { 199 | child.castShadow = true 200 | }) 201 | fbx.position.copy(offset) 202 | 203 | const anim = new FBXLoader() 204 | anim.setPath(path) 205 | 206 | anim.load(animFile, (anim) => { 207 | const m = new THREE.AnimationMixer(fbx) 208 | this.mixers.push(m) 209 | 210 | const idle = m.clipAction(anim.animations[0]) 211 | idle.play() 212 | }) 213 | 214 | this.scene.add(fbx) 215 | }) 216 | } 217 | 218 | modelUpdates(deltaTime) { 219 | // Animation 220 | this.mixers?.map((mix) => mix.update(deltaTime)) 221 | 222 | // Movements 223 | this.controls?.update(deltaTime) 224 | 225 | // Camera 226 | const isFreeCam = !this.parameters?.thirdPersonCamera 227 | this.thirdPersonCamera.update(deltaTime, isFreeCam) 228 | } 229 | 230 | render() { 231 | this.stats.begin() 232 | 233 | const elapsedTime = this.time.getElapsedTime() 234 | const deltaTime = elapsedTime - this.previousTime 235 | this.previousTime = elapsedTime 236 | 237 | this.modelUpdates(deltaTime) 238 | 239 | this.renderer.render(this.scene, this.camera) 240 | 241 | window.requestAnimationFrame(this.render.bind(this)) 242 | 243 | this.stats.end() 244 | } 245 | } 246 | 247 | // const env = process.env.NODE_ENV 248 | window.addEventListener('DOMContentLoaded', () => { 249 | const demo = new Demo() 250 | window.demo = demo 251 | }) 252 | -------------------------------------------------------------------------------- /src/js/movements/BasicCharacterController.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js' 3 | 4 | import CharacterFSM from '../fsm/CharacterFSM' 5 | 6 | import BasicCharacterControllerInput from './BasicCharacterControllerInput' 7 | import BasicCharacterControllerProxy from './BasicCharacterControllerProxy' 8 | 9 | import { hideLoader } from '../../utils/loader' 10 | 11 | class BasicCharacterController { 12 | constructor(params) { 13 | this.params = params // <=> { camera, scene, path } 14 | 15 | this.decceleration = new THREE.Vector3(-0.0005, -0.0001, -5.0) 16 | this.acceleration = new THREE.Vector3(1.0, 0.25, 50.0) 17 | this.velocity = new THREE.Vector3(0, 0, 0) 18 | this._position = new THREE.Vector3() 19 | 20 | this.animations = {} 21 | 22 | this.input = new BasicCharacterControllerInput() 23 | this.stateMachine = new CharacterFSM( 24 | new BasicCharacterControllerProxy(this.animations) 25 | ) 26 | 27 | this.loadModels() 28 | } 29 | 30 | loadModels() { 31 | const loader = new FBXLoader() 32 | loader.setPath(this.params.path) 33 | 34 | loader.load('eve_j_gonzales.fbx', (fbx) => { 35 | fbx.scale.setScalar(0.1) 36 | fbx.traverse((child) => { 37 | child.castShadow = true 38 | }) 39 | 40 | this.target = fbx 41 | 42 | this.params.scene.add(fbx) 43 | 44 | this.mixer = new THREE.AnimationMixer(fbx) 45 | 46 | this.manager = new THREE.LoadingManager() 47 | this.manager.onLoad = () => { 48 | this.stateMachine.setState('idle') 49 | } 50 | 51 | const onLoadAnimation = (name, data) => { 52 | const clip = data.animations[0] 53 | const action = this.mixer.clipAction(clip) 54 | 55 | // this.animations = { ...this.animations, [name]: { clip, action } } 56 | this.animations[name] = { 57 | clip, 58 | action, 59 | } 60 | } 61 | 62 | const loaderWithManager = new FBXLoader(this.manager) 63 | loaderWithManager.setPath(this.params.path) 64 | 65 | loaderWithManager.load('walk.fbx', (data) => { 66 | onLoadAnimation('walk', data) 67 | }) 68 | loaderWithManager.load('idle.fbx', (data) => { 69 | onLoadAnimation('idle', data) 70 | }) 71 | loaderWithManager.load('dance.fbx', (data) => { 72 | onLoadAnimation('dance', data) 73 | }) 74 | loaderWithManager.load('run.fbx', (data) => { 75 | onLoadAnimation('run', data) 76 | 77 | hideLoader() 78 | }) 79 | }) 80 | } 81 | 82 | /** 83 | * Apply movement to the character 84 | * 85 | * @param time - seconds 86 | */ 87 | update(time) { 88 | if (!this.target) return 89 | 90 | this.stateMachine.update(time, this.input) 91 | 92 | const velocity = this.velocity 93 | 94 | const frameDecceleration = new THREE.Vector3( 95 | velocity.x * this.decceleration.x, 96 | velocity.y * this.decceleration.y, 97 | velocity.z * this.decceleration.z 98 | ) 99 | 100 | frameDecceleration.multiplyScalar(time) 101 | frameDecceleration.z = 102 | Math.sign(frameDecceleration.z) * 103 | Math.min(Math.abs(frameDecceleration.z), Math.abs(velocity.z)) 104 | 105 | velocity.add(frameDecceleration) 106 | 107 | const controlObject = this.target 108 | const Q = new THREE.Quaternion() 109 | const A = new THREE.Vector3() 110 | const R = controlObject.quaternion.clone() 111 | 112 | const acc = this.acceleration.clone() 113 | 114 | if (this.input.keys.shift) { 115 | acc.multiplyScalar(2.0) 116 | } 117 | if (this.stateMachine.currentState?.name == 'dance') { 118 | acc.multiplyScalar(0.0) 119 | } 120 | if (this.input.keys.forward) { 121 | velocity.z += acc.z * time 122 | } 123 | if (this.input.keys.backward) { 124 | velocity.z -= acc.z * time 125 | } 126 | if (this.input.keys.left) { 127 | A.set(0, 1, 0) 128 | Q.setFromAxisAngle(A, 4.0 * Math.PI * time * this.acceleration.y) 129 | R.multiply(Q) 130 | } 131 | if (this.input.keys.right) { 132 | A.set(0, 1, 0) 133 | Q.setFromAxisAngle(A, 4.0 * -Math.PI * time * this.acceleration.y) 134 | R.multiply(Q) 135 | } 136 | 137 | controlObject.quaternion.copy(R) 138 | 139 | // const oldPosition = new THREE.Vector3() 140 | // oldPosition.copy(controlObject.position) 141 | 142 | const forward = new THREE.Vector3(0, 0, 1) 143 | forward.applyQuaternion(controlObject.quaternion) 144 | forward.normalize() 145 | 146 | const sideways = new THREE.Vector3(1, 0, 0) 147 | sideways.applyQuaternion(controlObject.quaternion) 148 | sideways.normalize() 149 | 150 | sideways.multiplyScalar(velocity.x * time) 151 | forward.multiplyScalar(velocity.z * time) 152 | 153 | controlObject.position.add(forward) 154 | controlObject.position.add(sideways) 155 | 156 | this._position.copy(controlObject.position) 157 | 158 | this.mixer?.update(time) 159 | } 160 | 161 | /** 162 | * Getter 163 | */ 164 | get rotation() { 165 | if (!this.target) return new THREE.Quaternion() 166 | return this.target.quaternion 167 | } 168 | 169 | get position() { 170 | return this._position 171 | } 172 | } 173 | 174 | export default BasicCharacterController 175 | -------------------------------------------------------------------------------- /src/js/movements/BasicCharacterControllerInput.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Keyboard listeners on press keys up and down 3 | */ 4 | class BasicCharacterControllerInput { 5 | constructor() { 6 | this.keys = { 7 | forward: false, 8 | backward: false, 9 | left: false, 10 | right: false, 11 | space: false, 12 | shift: false, 13 | } 14 | 15 | document.addEventListener( 16 | 'keydown', 17 | (event) => this.onKeyDown(event), 18 | false 19 | ) 20 | 21 | document.addEventListener('keyup', (event) => this.onKeyUp(event), false) 22 | } 23 | 24 | onKeyDown({ keyCode }) { 25 | switch (keyCode) { 26 | case 87: // w 27 | this.keys.forward = true 28 | break 29 | case 65: // a 30 | this.keys.left = true 31 | break 32 | case 83: // s 33 | this.keys.backward = true 34 | break 35 | case 68: // d 36 | this.keys.right = true 37 | break 38 | case 32: // SPACE 39 | this.keys.space = true 40 | break 41 | case 16: // SHIFT 42 | this.keys.shift = true 43 | break 44 | } 45 | } 46 | 47 | onKeyUp({ keyCode }) { 48 | switch (keyCode) { 49 | case 87: // w 50 | this.keys.forward = false 51 | break 52 | case 65: // a 53 | this.keys.left = false 54 | break 55 | case 83: // s 56 | this.keys.backward = false 57 | break 58 | case 68: // d 59 | this.keys.right = false 60 | break 61 | case 32: // SPACE 62 | this.keys.space = false 63 | break 64 | case 16: // SHIFT 65 | this.keys.shift = false 66 | break 67 | } 68 | } 69 | } 70 | 71 | export default BasicCharacterControllerInput 72 | -------------------------------------------------------------------------------- /src/js/movements/BasicCharacterControllerProxy.js: -------------------------------------------------------------------------------- 1 | class BasicCharacterControllerProxy { 2 | constructor(animations) { 3 | this._animations = animations 4 | } 5 | 6 | get animations() { 7 | return this._animations 8 | } 9 | } 10 | 11 | export default BasicCharacterControllerProxy 12 | -------------------------------------------------------------------------------- /src/js/states/DanceState.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | 3 | import State from './State' 4 | 5 | class DanceState extends State { 6 | constructor(parent) { 7 | super(parent) 8 | 9 | this.finishedCallback = () => { 10 | this.finished() 11 | } 12 | } 13 | 14 | get name() { 15 | return 'dance' 16 | } 17 | 18 | enter(prevState) { 19 | const currentAction = this.parent.proxy.animations['dance'].action 20 | 21 | const mixer = currentAction.getMixer() 22 | mixer.addEventListener('finished', this.finishedCallback) 23 | 24 | if (prevState) { 25 | const prevAction = this.parent.proxy.animations[prevState.name].action 26 | 27 | currentAction.reset() 28 | currentAction.setLoop(THREE.LoopOnce, 1) 29 | currentAction.clampWhenFinished = true 30 | currentAction.crossFadeFrom(prevAction, 0.2, true) 31 | currentAction.play() 32 | } else { 33 | currentAction.play() 34 | } 35 | } 36 | 37 | finished() { 38 | this.cleanup() 39 | this.parent.setState('idle') 40 | } 41 | 42 | cleanup() { 43 | const action = this.parent.proxy.animations['dance'].action 44 | action.getMixer().removeEventListener('finished', this.cleanupCallback) 45 | } 46 | 47 | exit() { 48 | this.cleanup() 49 | } 50 | 51 | update(_) {} 52 | } 53 | 54 | export default DanceState 55 | -------------------------------------------------------------------------------- /src/js/states/IdleState.js: -------------------------------------------------------------------------------- 1 | import State from './State' 2 | 3 | class IdleState extends State { 4 | constructor(parent) { 5 | super(parent) 6 | } 7 | 8 | get name() { 9 | return 'idle' 10 | } 11 | 12 | enter(prevState) { 13 | const idleAction = this.parent.proxy.animations['idle'].action 14 | 15 | if (prevState) { 16 | const prevAction = this.parent.proxy.animations[prevState.name].action 17 | 18 | idleAction.time = 0.0 19 | idleAction.enabled = true 20 | idleAction.setEffectiveTimeScale(1.0) 21 | idleAction.setEffectiveWeight(1.0) 22 | idleAction.crossFadeFrom(prevAction, 0.5, true) 23 | idleAction.play() 24 | } else { 25 | idleAction.play() 26 | } 27 | } 28 | 29 | exit() {} 30 | 31 | update(_, input) { 32 | if (input.keys.forward || input.keys.backward) { 33 | this.parent.setState('walk') 34 | } else if (input.keys.space) { 35 | this.parent.setState('dance') 36 | } 37 | } 38 | } 39 | 40 | export default IdleState 41 | -------------------------------------------------------------------------------- /src/js/states/RunState.js: -------------------------------------------------------------------------------- 1 | import State from './State' 2 | 3 | class RunState extends State { 4 | constructor(parent) { 5 | super(parent) 6 | } 7 | 8 | get name() { 9 | return 'run' 10 | } 11 | 12 | enter(prevState) { 13 | const currentAction = this.parent.proxy.animations['run'].action 14 | 15 | if (prevState) { 16 | const prevAction = this.parent.proxy.animations[prevState.name].action 17 | 18 | currentAction.enabled = true 19 | 20 | if (prevState.name == 'walk') { 21 | const ratio = 22 | currentAction.getClip().duration / prevAction.getClip().duration 23 | currentAction.time = prevAction.time * ratio 24 | } else { 25 | currentAction.time = 0.0 26 | currentAction.setEffectiveTimeScale(1.0) 27 | currentAction.setEffectiveWeight(1.0) 28 | } 29 | 30 | currentAction.crossFadeFrom(prevAction, 0.5, true) 31 | currentAction.play() 32 | } else { 33 | currentAction.play() 34 | } 35 | } 36 | 37 | exit() {} 38 | 39 | update(_, input) { 40 | if (input.keys.forward || input.keys.backward) { 41 | if (!input.keys.shift) { 42 | this.parent.setState('walk') 43 | } 44 | 45 | return 46 | } 47 | 48 | this.parent.setState('idle') 49 | } 50 | } 51 | 52 | export default RunState 53 | -------------------------------------------------------------------------------- /src/js/states/State.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Action State 3 | */ 4 | class State { 5 | constructor(parent) { 6 | this.parent = parent 7 | } 8 | 9 | enter() {} 10 | exit() {} 11 | update() {} 12 | } 13 | 14 | export default State 15 | -------------------------------------------------------------------------------- /src/js/states/WalkState.js: -------------------------------------------------------------------------------- 1 | import State from './State' 2 | 3 | class WalkState extends State { 4 | constructor(parent) { 5 | super(parent) 6 | } 7 | 8 | get name() { 9 | return 'walk' 10 | } 11 | 12 | enter(prevState) { 13 | const currentAction = this.parent.proxy.animations['walk'].action 14 | 15 | if (prevState) { 16 | const prevAction = this.parent.proxy.animations[prevState.name].action 17 | 18 | currentAction.enabled = true 19 | 20 | if (prevState.name == 'run') { 21 | const ratio = 22 | currentAction.getClip().duration / prevAction.getClip().duration 23 | currentAction.time = prevAction.time * ratio 24 | } else { 25 | currentAction.time = 0.0 26 | currentAction.setEffectiveTimeScale(1.0) 27 | currentAction.setEffectiveWeight(1.0) 28 | } 29 | 30 | currentAction.crossFadeFrom(prevAction, 0.5, true) 31 | currentAction.play() 32 | } else { 33 | currentAction.play() 34 | } 35 | } 36 | 37 | exit() {} 38 | 39 | update(_, input) { 40 | if (input.keys.forward || input.keys.backward) { 41 | if (input.keys.shift) { 42 | this.parent.setState('run') 43 | } 44 | 45 | return 46 | } 47 | 48 | this.parent.setState('idle') 49 | } 50 | } 51 | 52 | export default WalkState 53 | -------------------------------------------------------------------------------- /src/styles/index.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | } 5 | 6 | html, 7 | body { 8 | overflow: hidden; 9 | font-family: Arial, Helvetica, Arial, sans-serif; 10 | } 11 | 12 | #webgl-container { 13 | height: 100vh; 14 | width: 100vw; 15 | position: fixed; 16 | top: 0; 17 | left: 0; 18 | } 19 | 20 | #panel-controls { 21 | display: none; 22 | position: absolute; 23 | top: 10%; 24 | left: 50%; 25 | transform: translate(-50%, -50%); 26 | } 27 | 28 | #loading-container { 29 | display: flex; 30 | flex-direction: column; 31 | justify-content: center; 32 | align-items: center; 33 | 34 | background-color: black; 35 | 36 | position: absolute; 37 | 38 | width: 100%; 39 | height: 100vh; 40 | } 41 | 42 | .loader { 43 | -webkit-perspective: 120px; 44 | -moz-perspective: 120px; 45 | -ms-perspective: 120px; 46 | perspective: 120px; 47 | width: 100px; 48 | height: 100px; 49 | margin: 0 auto; 50 | } 51 | 52 | .loader-text { 53 | font-size: 1.5rem; 54 | color: white; 55 | margin: 1em; 56 | } 57 | 58 | .loader:before { 59 | border: 6px solid white; 60 | content: ''; 61 | position: absolute; 62 | left: 25px; 63 | top: 25px; 64 | width: 50px; 65 | height: 50px; 66 | background-color: black; 67 | animation: flip 1.5s infinite; 68 | } 69 | 70 | @keyframes flip { 71 | 0% { 72 | transform: rotate(0); 73 | } 74 | 75 | 50% { 76 | transform: rotateY(180deg); 77 | } 78 | 79 | 100% { 80 | transform: rotateY(180deg) rotateX(180deg); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/utils/loader.js: -------------------------------------------------------------------------------- 1 | export const hideLoader = () => { 2 | // Hide loader 3 | const loadingContainer = document.getElementById('loading-container') 4 | loadingContainer?.remove() 5 | 6 | // Show panel 7 | const panel = document.getElementById('panel-controls') 8 | if (panel) { 9 | panel.style.display = 'block' 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /static/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Alex-DG/threejs-character-controls/31872aa369ac41280e94c90e0cbadc999692eec8/static/.DS_Store -------------------------------------------------------------------------------- /static/models/girl/dance.fbx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Alex-DG/threejs-character-controls/31872aa369ac41280e94c90e0cbadc999692eec8/static/models/girl/dance.fbx -------------------------------------------------------------------------------- /static/models/girl/eve_j_gonzales.fbx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Alex-DG/threejs-character-controls/31872aa369ac41280e94c90e0cbadc999692eec8/static/models/girl/eve_j_gonzales.fbx -------------------------------------------------------------------------------- /static/models/girl/idle.fbx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Alex-DG/threejs-character-controls/31872aa369ac41280e94c90e0cbadc999692eec8/static/models/girl/idle.fbx -------------------------------------------------------------------------------- /static/models/girl/run.fbx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Alex-DG/threejs-character-controls/31872aa369ac41280e94c90e0cbadc999692eec8/static/models/girl/run.fbx -------------------------------------------------------------------------------- /static/models/girl/walk.fbx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Alex-DG/threejs-character-controls/31872aa369ac41280e94c90e0cbadc999692eec8/static/models/girl/walk.fbx --------------------------------------------------------------------------------