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