├── .babelrc.json ├── .eslintrc.json ├── .gitignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── config ├── paths.js ├── webpack.common.js ├── webpack.dev.js └── webpack.prod.js ├── decs.d.ts ├── jsconfig.json ├── package-lock.json ├── package.json ├── postcss.config.js ├── src ├── app.ts ├── images │ ├── favicon.ico │ └── shoot.svg ├── index.ts ├── render │ ├── controllers │ │ ├── character-controller.ts │ │ ├── tick-manager.ts │ │ └── utils │ │ │ ├── math.ts │ │ │ ├── meshes.ts │ │ │ └── objects.ts │ ├── init.ts │ ├── loaders │ │ └── general-loader.ts │ └── physics │ │ ├── RAPIER.ts │ │ ├── physics.ts │ │ └── utils │ │ └── constants.ts ├── styles │ ├── _scaffolding.scss │ ├── _variables.scss │ └── index.scss └── template.html └── tsconfig.json /.babelrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"], 3 | "plugins": [ 4 | "@babel/plugin-proposal-class-properties", 5 | ["@babel/transform-runtime"] 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "prefer-template": "off", 4 | "no-var": 1, 5 | "no-unused-vars": 1, 6 | "camelcase": 1, 7 | "no-nested-ternary": 1, 8 | "no-console": 1, 9 | "no-template-curly-in-string": 1, 10 | "no-self-compare": 1, 11 | "import/prefer-default-export": 0, 12 | "arrow-body-style": 1, 13 | "import/no-extraneous-dependencies": ["off", { "devDependencies": false }] 14 | }, 15 | "ignorePatterns": ["dist", "node_modules", "webpack.*", "config/paths.js"], 16 | "env": { 17 | "browser": true, 18 | "es6": true 19 | }, 20 | "extends": ["eslint:recommended", "prettier"], 21 | "parserOptions": { 22 | "ecmaVersion": 2021, 23 | "sourceType": "module" 24 | }, 25 | "plugins": ["prettier"], 26 | "settings": { 27 | "import/resolver": { 28 | "webpack": { 29 | "config": "config/webpack.common.js" 30 | } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/.DS_Store 2 | /node_modules 3 | /dist -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "printWidth": 100, 6 | "semi": false 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Tania Rascia 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dynamic Character Controller 2 | ## https://youtu.be/ipW-DUyPYlk 3 | [![Dynamic Character Controller With Three.js](https://user-images.githubusercontent.com/64514807/235347853-9411d7d7-1508-42a7-82aa-232650b13ee7.png)](https://youtu.be/ipW-DUyPYlk) 4 | 5 | ## Installation 6 | 7 | Clone this repo and npm install. 8 | 9 | ```bash 10 | npm i 11 | ``` 12 | 13 | ## Usage 14 | 15 | ### Development server 16 | 17 | ```bash 18 | npm start 19 | ``` 20 | 21 | You can view the development server at `localhost:8080`. 22 | 23 | ### Production build 24 | 25 | ```bash 26 | npm run build 27 | ``` 28 | 29 | > Note: Install [http-server](https://www.npmjs.com/package/http-server) globally to deploy a simple server. 30 | 31 | ```bash 32 | npm i -g http-server 33 | ``` 34 | 35 | You can view the deploy by creating a server in `dist`. 36 | 37 | ```bash 38 | cd dist && http-server 39 | ``` 40 | 41 | ## Features 42 | 43 | - [Three](https://threejs.org) 44 | - [Webpack](https://webpack.js.org/) 45 | - [Babel](https://babeljs.io/) 46 | - [Sass](https://sass-lang.com/) 47 | - [PostCSS](https://postcss.org/) 48 | - [Gsap](https://greensock.com/gsap/) 49 | 50 | ## License 51 | 52 | This project is open source and available under the [MIT License](LICENSE). 53 | -------------------------------------------------------------------------------- /config/paths.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | // Source files 5 | src: path.resolve(__dirname, '../src'), 6 | 7 | // Production build files 8 | build: path.resolve(__dirname, '../dist'), 9 | 10 | // Static files that get copied to build folder 11 | public: path.resolve(__dirname, '../public'), 12 | } 13 | -------------------------------------------------------------------------------- /config/webpack.common.js: -------------------------------------------------------------------------------- 1 | const { CleanWebpackPlugin } = require('clean-webpack-plugin') 2 | const CopyWebpackPlugin = require('copy-webpack-plugin') 3 | const HtmlWebpackPlugin = require('html-webpack-plugin') 4 | 5 | const paths = require('./paths') 6 | 7 | module.exports = { 8 | // Where webpack looks to start building the bundle 9 | entry: [paths.src + '/index.ts'], 10 | 11 | // async WASM 12 | experiments: { 13 | asyncWebAssembly: true, 14 | }, 15 | 16 | // Where webpack outputs the assets and bundles 17 | output: { 18 | path: paths.build, 19 | filename: '[name].bundle.js', 20 | publicPath: '/', 21 | hashFunction: 'xxhash64', 22 | }, 23 | 24 | // Customize the webpack build process 25 | plugins: [ 26 | // Removes/cleans build folders and unused assets when rebuilding 27 | new CleanWebpackPlugin(), 28 | 29 | // Copies files from target to destination folder 30 | new CopyWebpackPlugin({ 31 | patterns: [ 32 | { 33 | from: paths.public, 34 | to: 'assets', 35 | globOptions: { 36 | ignore: ['*.DS_Store'], 37 | }, 38 | noErrorOnMissing: true, 39 | }, 40 | ], 41 | }), 42 | 43 | // Generates an HTML file from a template 44 | // Generates deprecation warning: https://github.com/jantimon/html-webpack-plugin/issues/1501 45 | new HtmlWebpackPlugin({ 46 | title: 'Three Webpack Boilerplate', 47 | favicon: paths.src + '/images/favicon.ico', 48 | template: paths.src + '/template.html', // template file 49 | filename: 'index.html', // output file 50 | }), 51 | ], 52 | 53 | // Determine how modules within the project are treated 54 | module: { 55 | rules: [ 56 | // JavaScript: Use Babel to transpile JavaScript files 57 | { test: /\.js$/, use: ['babel-loader'] }, 58 | 59 | // TypeScript 60 | { 61 | test: /\.(ts|tsx)?$/, 62 | use: 'ts-loader', 63 | exclude: /node_modules/, 64 | }, 65 | 66 | // Images: Copy image files to build folder 67 | { test: /\.(?:ico|gif|png|jpg|jpeg|mp3|wav|vrm)$/i, type: 'asset/resource' }, 68 | 69 | // Fonts and SVGs: Inline files 70 | { test: /\.(woff(2)?|eot|ttf|otf|svg|)$/, type: 'asset/inline' }, 71 | 72 | // GLSL: Raw loader 73 | { test: /\.(glsl|vs|fs|vert|frag)$/, exclude: /node_modules/, use: 'raw-loader' }, 74 | ], 75 | }, 76 | 77 | resolve: { 78 | modules: [paths.src, 'node_modules'], 79 | extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'], 80 | alias: { 81 | '@': paths.src, 82 | assets: paths.public, 83 | }, 84 | }, 85 | } 86 | -------------------------------------------------------------------------------- /config/webpack.dev.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge') 2 | 3 | const common = require('./webpack.common') 4 | 5 | module.exports = merge(common, { 6 | // Set the mode to development or production 7 | mode: 'development', 8 | 9 | // Control how source maps are generated 10 | devtool: 'inline-source-map', 11 | 12 | // Spin up a server for quick development 13 | devServer: { 14 | historyApiFallback: true, 15 | open: true, 16 | compress: true, 17 | hot: true, 18 | port: 8080, 19 | }, 20 | 21 | module: { 22 | rules: [ 23 | // Styles: Inject CSS into the head with source maps 24 | { 25 | test: /\.(sass|scss|css)$/, 26 | use: [ 27 | 'style-loader', 28 | { 29 | loader: 'css-loader', 30 | options: { sourceMap: true, importLoaders: 1, modules: false }, 31 | }, 32 | { loader: 'postcss-loader', options: { sourceMap: true } }, 33 | { loader: 'sass-loader', options: { sourceMap: true } }, 34 | ], 35 | }, 36 | ], 37 | }, 38 | }) 39 | -------------------------------------------------------------------------------- /config/webpack.prod.js: -------------------------------------------------------------------------------- 1 | const MiniCssExtractPlugin = require('mini-css-extract-plugin') 2 | const CssMinimizerPlugin = require('css-minimizer-webpack-plugin') 3 | const { merge } = require('webpack-merge') 4 | 5 | const paths = require('./paths') 6 | const common = require('./webpack.common') 7 | 8 | module.exports = merge(common, { 9 | mode: 'production', 10 | devtool: false, 11 | output: { 12 | path: paths.build, 13 | publicPath: '/', 14 | filename: 'js/[name].[contenthash].bundle.js', 15 | }, 16 | module: { 17 | rules: [ 18 | { 19 | test: /\.(sass|scss|css)$/, 20 | use: [ 21 | MiniCssExtractPlugin.loader, 22 | { 23 | loader: 'css-loader', 24 | options: { 25 | importLoaders: 2, 26 | sourceMap: false, 27 | modules: false, 28 | }, 29 | }, 30 | 'postcss-loader', 31 | 'sass-loader', 32 | ], 33 | }, 34 | ], 35 | }, 36 | plugins: [ 37 | // Extracts CSS into separate files 38 | new MiniCssExtractPlugin({ 39 | filename: 'styles/[name].[contenthash].css', 40 | chunkFilename: '[id].css', 41 | }), 42 | ], 43 | optimization: { 44 | minimize: true, 45 | minimizer: [new CssMinimizerPlugin(), '...'], 46 | runtimeChunk: { 47 | name: 'runtime', 48 | }, 49 | }, 50 | performance: { 51 | hints: false, 52 | maxEntrypointSize: 512000, 53 | maxAssetSize: 512000, 54 | }, 55 | }) 56 | -------------------------------------------------------------------------------- /decs.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.js" 2 | declare module "*.mp3" 3 | declare module "*.wav" 4 | declare module "*.vrm" 5 | declare module "*.png" 6 | declare module "*.jpg" 7 | declare module "*.glsl" -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "paths": { 5 | "@/*": ["./src/*"] 6 | }, 7 | "allowJs": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "three-rust-boilerplate", 3 | "version": "3.0.1", 4 | "description": "Three.js + Rust boilerplate using Babel and PostCSS and Gsap.", 5 | "main": "index.js", 6 | "author": "Arya", 7 | "license": "MIT", 8 | "scripts": { 9 | "start": "cross-env NODE_ENV=development webpack serve --config config/webpack.dev.js", 10 | "build": "cross-env NODE_ENV=production webpack --config config/webpack.prod.js", 11 | "lint": "eslint 'src/**/*.js' || true", 12 | "prettify": "prettier --write 'src/**/*.js'", 13 | "compile": "wasm-pack build src/wasm/rust --target web" 14 | }, 15 | "keywords": [ 16 | "three", 17 | "three webpack", 18 | "three webpack 5", 19 | "three webpack boilerplate", 20 | "webpack", 21 | "webpack 5" 22 | ], 23 | "devDependencies": { 24 | "@babel/core": "^7.15.8", 25 | "@babel/plugin-proposal-class-properties": "^7.14.5", 26 | "@babel/plugin-transform-runtime": "^7.19.6", 27 | "@babel/preset-env": "^7.15.8", 28 | "@total-typescript/ts-reset": "^0.4.2", 29 | "@types/three": "^0.148.0", 30 | "babel-loader": "^8.2.2", 31 | "clean-webpack-plugin": "^4.0.0", 32 | "copy-webpack-plugin": "^9.0.1", 33 | "cross-env": "^7.0.3", 34 | "css-loader": "^6.4.0", 35 | "css-minimizer-webpack-plugin": "^3.1.1", 36 | "eslint": "^7.28.0", 37 | "eslint-config-prettier": "^8.3.0", 38 | "eslint-import-resolver-webpack": "^0.13.1", 39 | "eslint-plugin-prettier": "^4.0.0", 40 | "html-webpack-plugin": "^5.3.2", 41 | "mini-css-extract-plugin": "^2.4.2", 42 | "postcss-loader": "^6.2.0", 43 | "postcss-preset-env": "^6.7.0", 44 | "prettier": "^2.4.1", 45 | "sass": "^1.43.5", 46 | "sass-loader": "^12.2.0", 47 | "style-loader": "^3.3.0", 48 | "ts-loader": "^9.4.2", 49 | "tsc-watch": "^6.0.0", 50 | "typescript": "^5.0.3", 51 | "webpack": "^5.58.2", 52 | "webpack-cli": "^4.9.0", 53 | "webpack-dev-server": "^4.3.1", 54 | "webpack-merge": "^5.8.0" 55 | }, 56 | "repository": { 57 | "type": "git", 58 | "url": "git@github.com:visionary-studio/three-boilerplate" 59 | }, 60 | "dependencies": { 61 | "@babel/runtime": "^7.20.7", 62 | "@dimforge/rapier3d": "^0.11.2", 63 | "caniuse-lite": "^1.0.30001441", 64 | "gsap": "^3.11.4", 65 | "three": "^0.148.0" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | 'postcss-preset-env': { 4 | browsers: 'last 2 versions', 5 | }, 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | import { 3 | addPass, 4 | useCamera, 5 | useControls, 6 | useGui, 7 | useLoader, 8 | useRenderSize, 9 | useScene, 10 | useTick, 11 | } from './render/init' 12 | 13 | // import postprocessing passes 14 | import { SavePass } from 'three/examples/jsm/postprocessing/SavePass.js' 15 | import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js' 16 | import { BlendShader } from 'three/examples/jsm/shaders/BlendShader.js' 17 | import { CopyShader } from 'three/examples/jsm/shaders/CopyShader.js' 18 | import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js' 19 | 20 | import { addPhysics } from './render/physics/physics' 21 | 22 | 23 | import { TickData } from './render/controllers/tick-manager' 24 | 25 | const MOTION_BLUR_AMOUNT = 0.5 26 | 27 | const startApp = async () => { 28 | // three 29 | const scene = useScene() 30 | const camera = useCamera() 31 | camera.position.x += 10 32 | camera.position.y += 10 33 | camera.lookAt(new THREE.Vector3(0)) 34 | const gui = useGui() 35 | const { width, height } = useRenderSize() 36 | 37 | const dirLight = new THREE.DirectionalLight('#ffffff', 1) 38 | dirLight.position.y += 1 39 | dirLight.position.x += 0.5 40 | 41 | const dirLightHelper = new THREE.DirectionalLightHelper(dirLight) 42 | // dirLight.add(dirLightHelper) 43 | 44 | const ambientLight = new THREE.AmbientLight('#ffffff', 0.5) 45 | scene.add(dirLight, ambientLight) 46 | 47 | 48 | // * APP 49 | 50 | const _addGroundMesh = () => { 51 | // * Settings 52 | const planeWidth = 100 53 | const planeHeight = 100 54 | 55 | // * Mesh 56 | const geometry = new THREE.PlaneGeometry(planeWidth, planeHeight) 57 | const material = new THREE.MeshPhysicalMaterial({ 58 | color: '#333', 59 | side: THREE.DoubleSide 60 | }) 61 | const plane = new THREE.Mesh(geometry, material) 62 | 63 | // * Physics 64 | const collider = addPhysics( 65 | plane, 66 | 'fixed', 67 | true, 68 | () => { 69 | plane.rotation.x -= Math.PI / 2 70 | }, 71 | 'cuboid', 72 | { 73 | width: planeWidth / 2, 74 | height: 0.001, 75 | depth: planeHeight / 2, 76 | } 77 | ).collider 78 | 79 | // * Add the mesh to the scene 80 | scene.add(plane) 81 | } 82 | 83 | _addGroundMesh() 84 | 85 | const _addCubeMesh = (pos: THREE.Vector3) => { 86 | // * Settings 87 | const size = 6 88 | 89 | // * Mesh 90 | const geometry = new THREE.BoxGeometry(size, size, size) 91 | const material = new THREE.MeshPhysicalMaterial({ 92 | color: new THREE.Color().setHex(Math.min(Math.random() + 0.15, 1) * 0xffffff), 93 | side: THREE.DoubleSide, 94 | }) 95 | const cube = new THREE.Mesh(geometry, material) 96 | 97 | cube.position.copy(pos) 98 | cube.position.y += 2 99 | 100 | // * Physics 101 | const collider = addPhysics(cube, 'dynamic', true, undefined, 'cuboid', { 102 | width: size / 2, 103 | height: size / 2, 104 | depth: size / 2, 105 | }).collider 106 | 107 | // * Add the mesh to the scene 108 | scene.add(cube) 109 | } 110 | 111 | const NUM_CUBES = 10 112 | for (let i = 0; i < NUM_CUBES; i++) { 113 | _addCubeMesh( 114 | new THREE.Vector3((Math.random() - 0.5) * 20, 10 + i * 5, (Math.random() - 0.5) * 20) 115 | ) 116 | } 117 | 118 | } 119 | 120 | export default startApp 121 | -------------------------------------------------------------------------------- /src/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/visionary-3d/advanced-character-controller/4de99a057de820a5d23905b7659d08089d4028ca/src/images/favicon.ico -------------------------------------------------------------------------------- /src/images/shoot.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // typescript fix by matt pocock 2 | import '@total-typescript/ts-reset' 3 | 4 | // styles 5 | import '@/styles/index.scss' 6 | 7 | // engine 8 | import { initEngine } from './render/init' 9 | 10 | // app 11 | import startApp from './app' 12 | 13 | (async () => { 14 | await initEngine() 15 | await startApp() 16 | })() 17 | -------------------------------------------------------------------------------- /src/render/controllers/character-controller.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | import { Object3D } from 'three' 3 | import { RAPIER, usePhysics, useRenderSize, useScene } from '../init' 4 | import { useRenderer } from './../init' 5 | import { PhysicsObject, addPhysics } from '../physics/physics' 6 | import Rapier from '@dimforge/rapier3d' 7 | import { GRAVITY } from '../physics/utils/constants' 8 | import { _calculateObjectSize } from './utils/objects' 9 | import { clamp, lerp, easeOutExpo, EaseOutCirc, UpDownCirc } from './utils/math' 10 | 11 | // * helpers 12 | const HALF_PI = Math.PI / 2 13 | const FORWARD = new THREE.Vector3(0, 0, -1) 14 | const LEFT = new THREE.Vector3(-1, 0, 0) 15 | const UP = new THREE.Vector3(0, 1, 0) 16 | const RIGHT = new THREE.Vector3(1, 0, 0) 17 | const DOWN = new THREE.Vector3(0, -1, 0) 18 | 19 | // * constants 20 | const MIN_ZOOM_LEVEL = 0.001 // needs to be slightly bigger than zero 21 | const MAX_ZOOM_LEVEL = 20 22 | const SCROLL_LEVEL_STEP = 1.5 23 | const SCROLL_ANIMATION_SPEED = 2 24 | const JUMP_DURATION = 0.5 25 | const JUMP_AMPLITUDE = 0.5 26 | 27 | // * local variables 28 | const quaternion_0 = new THREE.Quaternion() 29 | const quaternion_1 = new THREE.Quaternion() 30 | const vec3_0 = new THREE.Vector3() 31 | const vec3_1 = new THREE.Vector3() 32 | let ray_0: Rapier.Ray 33 | 34 | // * helper functions 35 | const ONE = () => { 36 | return 1 37 | } 38 | const FIVE = () => { 39 | return 5 40 | } 41 | const ZERO = () => { 42 | return 0 43 | } 44 | 45 | // * supported keyboard keys 46 | enum KEYS { 47 | a = 'KeyA', 48 | s = 'KeyS', 49 | w = 'KeyW', 50 | d = 'KeyD', 51 | space = 'Space', 52 | shiftL = 'ShiftLeft', 53 | shiftR = 'ShiftRight', 54 | } 55 | 56 | type MouseState = { 57 | leftButton: boolean 58 | rightButton: boolean 59 | mouseXDelta: number 60 | mouseYDelta: number 61 | mouseWheelDelta: number 62 | } 63 | 64 | // * Responsible for the inputs of the user 65 | class InputManager { 66 | target: Document 67 | currentMouse: MouseState 68 | currentKeys: Map 69 | pointerLocked: boolean 70 | 71 | constructor(target?: Document) { 72 | this.target = target || document 73 | this.currentMouse = { 74 | leftButton: false, 75 | rightButton: false, 76 | mouseXDelta: 0, 77 | mouseYDelta: 0, 78 | mouseWheelDelta: 0, 79 | } 80 | this.currentKeys = new Map() 81 | this.pointerLocked = false 82 | 83 | this.init() 84 | } 85 | 86 | init() { 87 | // mouse event handlers 88 | this.target.addEventListener('mousedown', (e) => this.onMouseDown(e), false) 89 | this.target.addEventListener('mousemove', (e) => this.onMouseMove(e), false) 90 | this.target.addEventListener('mouseup', (e) => this.onMouseUp(e), false) 91 | // mouse wheel 92 | addEventListener('wheel', (e) => this.onMouseWheel(e), false) 93 | 94 | // keyboard event handlers 95 | this.target.addEventListener('keydown', (e) => this.onKeyDown(e), false) 96 | this.target.addEventListener('keyup', (e) => this.onKeyUp(e), false) 97 | 98 | const renderer = useRenderer() 99 | 100 | // handling pointer lock 101 | const addPointerLockEvent = async () => { 102 | await renderer.domElement.requestPointerLock() 103 | } 104 | renderer.domElement.addEventListener('click', addPointerLockEvent) 105 | renderer.domElement.addEventListener('dblclick', addPointerLockEvent) 106 | renderer.domElement.addEventListener('mousedown', addPointerLockEvent) 107 | 108 | const setPointerLocked = () => { 109 | this.pointerLocked = document.pointerLockElement === renderer.domElement 110 | } 111 | document.addEventListener('pointerlockchange', setPointerLocked, false) 112 | } 113 | 114 | onMouseWheel(e: WheelEvent) { 115 | const changeMouseWheelLevel = () => { 116 | if (this.pointerLocked) { 117 | if (e.deltaY < 0) { 118 | // * scrolling up, zooming in 119 | this.currentMouse.mouseWheelDelta = Math.max( 120 | this.currentMouse.mouseWheelDelta - SCROLL_LEVEL_STEP, 121 | MIN_ZOOM_LEVEL 122 | ) 123 | } else if (e.deltaY > 0) { 124 | // * scrolling down, zooming out 125 | this.currentMouse.mouseWheelDelta = Math.min( 126 | this.currentMouse.mouseWheelDelta + SCROLL_LEVEL_STEP, 127 | MAX_ZOOM_LEVEL 128 | ) 129 | } 130 | } 131 | } 132 | 133 | changeMouseWheelLevel() 134 | } 135 | 136 | onMouseMove(e: MouseEvent) { 137 | if (this.pointerLocked) { 138 | this.currentMouse.mouseXDelta = e.movementX 139 | this.currentMouse.mouseYDelta = e.movementY 140 | } 141 | } 142 | 143 | onMouseDown(e: MouseEvent) { 144 | if (this.pointerLocked) { 145 | this.onMouseMove(e) 146 | 147 | // * right click, left click 148 | switch (e.button) { 149 | case 0: { 150 | this.currentMouse.leftButton = true 151 | break 152 | } 153 | case 2: { 154 | this.currentMouse.rightButton = true 155 | break 156 | } 157 | } 158 | } 159 | } 160 | 161 | onMouseUp(e: MouseEvent) { 162 | if (this.pointerLocked) { 163 | this.onMouseMove(e) 164 | 165 | // * right click, left click 166 | switch (e.button) { 167 | case 0: { 168 | this.currentMouse.leftButton = false 169 | break 170 | } 171 | case 2: { 172 | this.currentMouse.rightButton = false 173 | break 174 | } 175 | } 176 | } 177 | } 178 | 179 | onKeyDown(e: KeyboardEvent) { 180 | if (this.pointerLocked) { 181 | this.currentKeys.set(e.code, true) 182 | } 183 | } 184 | 185 | onKeyUp(e: KeyboardEvent) { 186 | if (this.pointerLocked) { 187 | this.currentKeys.set(e.code, false) 188 | } 189 | } 190 | 191 | isKeyDown(keyCode: string | number) { 192 | if (this.pointerLocked) { 193 | const hasKeyCode = this.currentKeys.get(keyCode as string) 194 | if (hasKeyCode) { 195 | return hasKeyCode 196 | } 197 | } 198 | 199 | return false 200 | } 201 | 202 | update() { 203 | this.currentMouse.mouseXDelta = 0 204 | this.currentMouse.mouseYDelta = 0 205 | } 206 | 207 | runActionByKey(key: string, action: Function, inAction?: Function) { 208 | // * run function if the key is pressed 209 | if (this.isKeyDown(key)) { 210 | return action() 211 | } else { 212 | return inAction && inAction() 213 | } 214 | } 215 | 216 | runActionByOneKey(keys: Array, action: Function, inAction?: Function) { 217 | // * run the function if one of the keys in the 'keys' array is pressed 218 | let check = false 219 | for (let i = 0; i < keys.length; i++) { 220 | const key = keys[i] 221 | check = this.isKeyDown(key) 222 | 223 | if (check) { 224 | break 225 | } 226 | } 227 | 228 | if (check) { 229 | return action() 230 | } else { 231 | return inAction && inAction() 232 | } 233 | } 234 | 235 | runActionByAllKeys(keys: Array, action: Function, inAction?: Function) { 236 | // * if all of the keys in the 'keys' array are pressed at the same time, run the function 237 | let check = true 238 | for (let i = 0; i < keys.length; i++) { 239 | const key = keys[i] 240 | check = this.isKeyDown(key) 241 | 242 | if (!check) { 243 | break 244 | } 245 | } 246 | 247 | if (check) { 248 | return action() 249 | } else { 250 | return inAction && inAction() 251 | } 252 | } 253 | } 254 | 255 | // * Responsible for the Head Bob (up and down) movement of the character (only works in first-person-mode) 256 | class HeadBobController { 257 | headBobTimer: number 258 | headBobAmount: number 259 | lastHeadBobDiff: number 260 | headBobActive: boolean 261 | 262 | constructor() { 263 | this.headBobTimer = 0 264 | this.lastHeadBobDiff = 0 265 | this.headBobAmount = 0 266 | this.headBobActive = false 267 | } 268 | 269 | getHeadBob(timeDiff: number, isMoving: boolean) { 270 | const HEAD_BOB_DURATION = 0.1 271 | const HEAD_BOB_FREQUENCY = 0.8 272 | const HEAD_BOB_AMPLITUDE = 0.3 273 | 274 | if (!this.headBobActive) { 275 | this.headBobActive = isMoving 276 | } 277 | 278 | if (this.headBobActive) { 279 | const STEP = Math.PI 280 | 281 | const currentAmount = this.headBobTimer * HEAD_BOB_FREQUENCY * (1 / HEAD_BOB_DURATION) 282 | const headBobDiff = currentAmount % STEP 283 | 284 | this.headBobTimer += timeDiff 285 | this.headBobAmount = Math.sin(currentAmount) * HEAD_BOB_AMPLITUDE 286 | 287 | if (headBobDiff < this.lastHeadBobDiff) { 288 | this.headBobActive = false 289 | } 290 | 291 | this.lastHeadBobDiff = headBobDiff 292 | } 293 | 294 | return this.headBobAmount 295 | } 296 | } 297 | 298 | // * Responsible for the camera zoom on the character (first-person-mode and third-person-mode) 299 | class ZoomController { 300 | zoom: number 301 | lastZoomLevel: number 302 | startZoomAnimation: number 303 | isAnimating: boolean 304 | startingZoom: number 305 | 306 | constructor() { 307 | this.zoom = MIN_ZOOM_LEVEL 308 | this.startingZoom = 0 309 | this.lastZoomLevel = 0 310 | this.startZoomAnimation = 0 311 | this.isAnimating = false 312 | } 313 | 314 | update(zoomLevel: number, timestamp: number, timeDiff: number) { 315 | const time = timestamp * SCROLL_ANIMATION_SPEED 316 | const zlClamped = clamp(zoomLevel, MIN_ZOOM_LEVEL, MAX_ZOOM_LEVEL) 317 | 318 | const zoomLevelHasChanged = this.lastZoomLevel !== zoomLevel 319 | if (zoomLevelHasChanged) { 320 | // restart the animation 321 | this.startingZoom = this.zoom 322 | this.startZoomAnimation = time 323 | this.isAnimating = true 324 | } 325 | 326 | // animating 327 | if (this.isAnimating) { 328 | const progress = time - this.startZoomAnimation 329 | this.zoom = lerp(this.startingZoom, zlClamped, easeOutExpo(progress)) 330 | 331 | if (progress >= 1) { 332 | // end the animation 333 | this.isAnimating = false 334 | } 335 | } 336 | 337 | this.lastZoomLevel = zoomLevel 338 | } 339 | } 340 | 341 | // * Responsible for controlling the vertical movement of the character (gravity, jump, etc...) 342 | class HeightController { 343 | height: number 344 | lastHeight: number 345 | movePerFrame: number 346 | lastGroundHeight: number 347 | startFallAnimation: number 348 | isAnimating: boolean 349 | grounded: boolean 350 | jumpFactor: number 351 | startJumpAnimation: number 352 | 353 | constructor() { 354 | this.height = 0 355 | this.lastHeight = this.height 356 | this.movePerFrame = 0 357 | this.lastGroundHeight = this.height 358 | this.startFallAnimation = 0 359 | this.startJumpAnimation = 0 360 | this.jumpFactor = 0 361 | this.isAnimating = false 362 | this.grounded = false 363 | } 364 | 365 | update(timestamp: number, timeDiff: number) { 366 | this.isAnimating = !this.grounded 367 | 368 | if (this.isAnimating) { 369 | const t = timestamp - this.startFallAnimation 370 | 371 | this.height = 0.5 * GRAVITY.y * t * t 372 | 373 | this.movePerFrame = this.height - this.lastHeight 374 | } else { 375 | // reset the animation 376 | this.height = 0 377 | this.lastHeight = 0 378 | this.movePerFrame = 0 379 | this.startFallAnimation = timestamp 380 | } 381 | 382 | const jt = timestamp - this.startJumpAnimation 383 | if (this.grounded && jt > JUMP_DURATION) { 384 | this.jumpFactor = 0 385 | this.startJumpAnimation = timestamp 386 | } else { 387 | this.movePerFrame += lerp( 388 | 0, 389 | this.jumpFactor * JUMP_AMPLITUDE, 390 | UpDownCirc(clamp(jt / JUMP_DURATION, 0, 1)) 391 | ) 392 | } 393 | 394 | this.lastHeight = this.height 395 | } 396 | 397 | setGrounded(grounded: boolean) { 398 | this.grounded = grounded 399 | } 400 | 401 | setJumpFactor(jumpFactor: number) { 402 | this.jumpFactor = jumpFactor 403 | } 404 | } 405 | 406 | // * Responsible for controlling the character movement, rotation and physics 407 | class CharacterController extends THREE.Mesh { 408 | camera: THREE.PerspectiveCamera 409 | inputManager: InputManager 410 | headBobController: HeadBobController 411 | heightController: HeightController 412 | phi: number 413 | theta: number 414 | objects: any 415 | isMoving2D: boolean 416 | startZoomAnimation: number 417 | zoomController: ZoomController 418 | physicsObject: PhysicsObject 419 | // characterController: Rapier.KinematicCharacterController 420 | avatar: AvatarController 421 | 422 | constructor(avatar: AvatarController, camera: THREE.PerspectiveCamera) { 423 | super() 424 | 425 | // init position 426 | this.position.copy(avatar.avatar.position) 427 | 428 | this.camera = camera 429 | this.avatar = avatar 430 | 431 | this.inputManager = new InputManager() 432 | this.headBobController = new HeadBobController() 433 | this.zoomController = new ZoomController() 434 | this.heightController = new HeightController() 435 | 436 | // physics 437 | this.physicsObject = this.initPhysics(avatar) 438 | 439 | // ! Rapier's character controller is bugged 440 | // // The gap the controller will leave between the character and its environment. 441 | // const OFFSET = 0.01 442 | // this.characterController = physics.createCharacterController(OFFSET) 443 | 444 | this.startZoomAnimation = 0 445 | 446 | this.phi = 0 447 | this.theta = 0 448 | 449 | this.isMoving2D = false 450 | } 451 | 452 | initPhysics(avatar: AvatarController) { 453 | // initialize ray 454 | ray_0 = new RAPIER.Ray(vec3_0, vec3_0) 455 | 456 | // physics object 457 | const radius = avatar.width / 2 458 | const halfHeight = avatar.height / 2 - radius 459 | const physicsObject = addPhysics(this, 'kinematicPositionBased', false, undefined, 'capsule', { 460 | halfHeight, 461 | radius, 462 | }) 463 | 464 | return physicsObject 465 | } 466 | 467 | detectGround() { 468 | const physics = usePhysics() 469 | const avatarHalfHeight = this.avatar.height / 2 470 | 471 | // set collider position 472 | const colliderPosition = vec3_0.copy(this.position) 473 | this.physicsObject.collider.setTranslation(colliderPosition) 474 | 475 | // hitting the ground 476 | const rayOrigin = vec3_1.copy(this.position) 477 | // ray origin is at the foot of the avatar 478 | rayOrigin.y -= avatarHalfHeight 479 | 480 | const ray = ray_0 481 | ray.origin = rayOrigin 482 | ray.dir = DOWN 483 | 484 | const groundUnderFootHit = physics.castRay( 485 | ray, 486 | 1000, 487 | true, 488 | RAPIER.QueryFilterFlags.EXCLUDE_DYNAMIC, 489 | undefined, 490 | this.physicsObject.collider, 491 | this.physicsObject.rigidBody 492 | ) 493 | 494 | if (groundUnderFootHit) { 495 | const hitPoint = ray.pointAt(groundUnderFootHit.toi) as THREE.Vector3 496 | const distance = rayOrigin.y - hitPoint.y 497 | if (distance <= 0) { 498 | // * Grounded 499 | this.heightController.setGrounded(true) 500 | } else { 501 | this.heightController.lastGroundHeight = hitPoint.y + avatarHalfHeight 502 | this.heightController.setGrounded(false) 503 | } 504 | } else { 505 | // * Shoot another ray up to see if we've passed the ground 506 | ray.dir = UP 507 | const groundAboveFootHit = physics.castRay( 508 | ray, 509 | this.avatar.height / 2, 510 | true, 511 | RAPIER.QueryFilterFlags.EXCLUDE_DYNAMIC, 512 | undefined, 513 | this.physicsObject.collider, 514 | this.physicsObject.rigidBody 515 | ) 516 | 517 | if (groundAboveFootHit) { 518 | // * passed the ground 519 | this.position.y = this.heightController.lastGroundHeight 520 | this.heightController.setGrounded(true) 521 | } else { 522 | this.heightController.setGrounded(false) 523 | } 524 | } 525 | 526 | // ! Rapier.js character controller is bugged 527 | { 528 | // this.characterController.computeColliderMovement( 529 | // this.physicsObject.collider, // The collider we would like to move. 530 | // this.position // The movement we would like to apply if there wasn’t any obstacle. 531 | // ) 532 | // // Read the result 533 | // const correctedMovement = this.characterController.computedMovement() 534 | // this.position.copy(correctedMovement as THREE.Vector3) 535 | } 536 | } 537 | 538 | update(timestamp: number, timeDiff: number) { 539 | this.updateRotation() 540 | this.updateTranslation(timeDiff) 541 | this.updateGravity(timestamp, timeDiff) 542 | this.detectGround() 543 | this.updateZoom(timestamp, timeDiff) 544 | this.updateCamera(timestamp, timeDiff) 545 | this.inputManager.update() 546 | } 547 | 548 | updateZoom(timestamp: number, timeDiff: number) { 549 | this.zoomController.update( 550 | this.inputManager.currentMouse.mouseWheelDelta, 551 | timestamp, 552 | timeDiff 553 | ) 554 | } 555 | 556 | updateGravity(timestamp: number, timeDiff: number) { 557 | this.heightController.update(timestamp, timeDiff) 558 | } 559 | 560 | updateCamera(timestamp: number, timeDiff: number) { 561 | this.camera.position.copy(this.position) 562 | // this.camera.position.y += this.avatar.height / 2 563 | 564 | // moving by the camera angle 565 | const circleRadius = this.zoomController.zoom 566 | const cameraOffset = vec3_0.set( 567 | circleRadius * Math.cos(-this.phi), 568 | circleRadius * Math.cos(this.theta + HALF_PI), 569 | circleRadius * Math.sin(-this.phi) 570 | ) 571 | this.camera.position.add(cameraOffset) 572 | this.camera.lookAt(this.position) 573 | 574 | // head bob 575 | const isFirstPerson = this.zoomController.zoom <= this.avatar.width 576 | if (isFirstPerson) { 577 | this.camera.position.y += this.headBobController.getHeadBob(timeDiff, this.isMoving2D) 578 | 579 | // keep looking at the same position in the object in front 580 | const physics = usePhysics() 581 | 582 | const rayOrigin = vec3_1.copy(this.camera.position) 583 | const rayDirection = vec3_0.set(0, 0, -1).applyQuaternion(this.camera.quaternion).normalize() 584 | const ray = ray_0 585 | ray.origin = rayOrigin 586 | ray.dir = rayDirection 587 | 588 | const hit = physics.castRay(ray, 1000, false) 589 | 590 | if (hit) { 591 | const point = ray.pointAt(hit.toi) 592 | const hitPoint = vec3_0.set(point.x, point.y, point.z) 593 | this.camera.lookAt(hitPoint) 594 | } 595 | } 596 | } 597 | 598 | updateTranslation(timeDiff: number) { 599 | const timeDiff_d10 = timeDiff * 10 600 | 601 | const shiftSpeedUpAction = () => 602 | this.inputManager.runActionByOneKey([KEYS.shiftL, KEYS.shiftR], FIVE, ONE) 603 | 604 | const forwardVelocity = 605 | this.inputManager.runActionByKey(KEYS.w, shiftSpeedUpAction, ZERO) - 606 | this.inputManager.runActionByKey(KEYS.s, shiftSpeedUpAction, ZERO) 607 | 608 | const sideVelocity = 609 | this.inputManager.runActionByKey(KEYS.a, shiftSpeedUpAction, ZERO) - 610 | this.inputManager.runActionByKey(KEYS.d, shiftSpeedUpAction, ZERO) 611 | 612 | const qx = quaternion_1 613 | qx.setFromAxisAngle(UP, this.phi + HALF_PI) 614 | 615 | vec3_0.copy(FORWARD) 616 | vec3_0.applyQuaternion(qx) 617 | vec3_0.multiplyScalar(forwardVelocity * timeDiff_d10) 618 | 619 | vec3_1.copy(LEFT) 620 | vec3_1.applyQuaternion(qx) 621 | vec3_1.multiplyScalar(sideVelocity * timeDiff_d10) 622 | 623 | this.position.add(vec3_0) 624 | this.position.add(vec3_1) 625 | 626 | // Height 627 | const elevationFactor = this.inputManager.runActionByKey(KEYS.space, ONE, ZERO) 628 | 629 | // Jump 630 | if (this.heightController.grounded) { 631 | this.heightController.setJumpFactor(elevationFactor) 632 | } 633 | 634 | this.position.y += this.heightController.movePerFrame 635 | 636 | this.isMoving2D = forwardVelocity != 0 || sideVelocity != 0 637 | } 638 | 639 | updateRotation() { 640 | const windowSize = useRenderSize() 641 | const xh = this.inputManager.currentMouse.mouseXDelta / windowSize.width 642 | const yh = this.inputManager.currentMouse.mouseYDelta / windowSize.height 643 | 644 | const PHI_SPEED = 2.5 645 | const THETA_SPEED = 2.5 646 | this.phi += -xh * PHI_SPEED 647 | this.theta = clamp(this.theta + -yh * THETA_SPEED, -Math.PI / 2, Math.PI / 2) 648 | 649 | const qx = quaternion_0 650 | qx.setFromAxisAngle(UP, this.phi) 651 | const qz = quaternion_1 652 | qz.setFromAxisAngle(RIGHT, this.theta) 653 | 654 | const q = qx.multiply(qz) 655 | 656 | this.quaternion.copy(q) 657 | } 658 | } 659 | 660 | // * Responsible for controlling the avatar mesh and the character controller 661 | class AvatarController { 662 | avatar: THREE.Mesh 663 | characterController: CharacterController 664 | height: number 665 | width: number 666 | 667 | constructor(avatar: THREE.Mesh, camera: THREE.PerspectiveCamera) { 668 | this.avatar = avatar 669 | 670 | const size = _calculateObjectSize(avatar) 671 | this.width = size.x 672 | this.height = size.y 673 | this.characterController = new CharacterController(this, camera) 674 | } 675 | 676 | update(timestamp: number, timeDiff: number) { 677 | this.characterController.update(timestamp, timeDiff) 678 | this.avatar.position.copy(this.characterController.position) 679 | } 680 | } 681 | 682 | export default AvatarController 683 | -------------------------------------------------------------------------------- /src/render/controllers/tick-manager.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | import { 3 | useComposer, 4 | useControls, 5 | usePhysics, 6 | usePhysicsObjects, 7 | useRenderer, 8 | useStats, 9 | } from '../init' 10 | 11 | // animation params 12 | type Frame = XRFrame | null 13 | 14 | export type TickData = { 15 | timestamp: number 16 | timeDiff: number 17 | fps: number 18 | frame: Frame 19 | } 20 | 21 | const localTickData: TickData = { 22 | timestamp: 0, 23 | timeDiff: 0, 24 | fps: 0, 25 | frame: null, 26 | } 27 | 28 | const localFrameOpts = { 29 | data: localTickData, 30 | } 31 | 32 | const frameEvent = new MessageEvent('tick', localFrameOpts) 33 | 34 | class TickManager extends EventTarget { 35 | timestamp: number 36 | timeDiff: number 37 | frame: Frame 38 | lastTimestamp: number 39 | fps: number 40 | 41 | constructor({ timestamp, timeDiff, frame } = localTickData) { 42 | super() 43 | 44 | this.timestamp = timestamp 45 | this.timeDiff = timeDiff 46 | this.frame = frame 47 | this.lastTimestamp = 0 48 | this.fps = 0 49 | } 50 | 51 | startLoop() { 52 | const composer = useComposer() 53 | const renderer = useRenderer() 54 | // const scene = useScene() 55 | // const camera = useCamera() 56 | const physics = usePhysics() 57 | const physicsObjects = usePhysicsObjects() 58 | const controls = useControls() 59 | const stats = useStats() 60 | 61 | if (!renderer) { 62 | throw new Error('Updating Frame Failed : Uninitialized Renderer') 63 | } 64 | 65 | const animate = (timestamp: number, frame: Frame) => { 66 | const now = performance.now() 67 | this.timestamp = timestamp ?? now 68 | this.timeDiff = timestamp - this.lastTimestamp 69 | 70 | const timeDiffCapped = Math.min(Math.max(this.timeDiff, 0), 100) 71 | 72 | // physics 73 | physics.step() 74 | 75 | for (let i = 0; i < physicsObjects.length; i++) { 76 | const po = physicsObjects[i] 77 | const autoAnimate = po.autoAnimate 78 | 79 | if (autoAnimate) { 80 | const mesh = po.mesh 81 | const collider = po.collider 82 | mesh.position.copy(collider.translation() as THREE.Vector3) 83 | mesh.quaternion.copy(collider.rotation() as THREE.Quaternion) 84 | } 85 | 86 | const fn = po.fn 87 | fn && fn() 88 | } 89 | 90 | // performance tracker start 91 | this.fps = 1000 / this.timeDiff 92 | this.lastTimestamp = this.timestamp 93 | 94 | controls.update(timestamp / 1000, timeDiffCapped / 1000) 95 | 96 | composer.render() 97 | // renderer.render(scene, camera); 98 | 99 | this.tick(timestamp, timeDiffCapped, this.fps, frame) 100 | 101 | stats.update() 102 | 103 | // performance tracker end 104 | } 105 | 106 | renderer.setAnimationLoop(animate) 107 | } 108 | 109 | tick(timestamp: number, timeDiff: number, fps: number, frame: Frame) { 110 | localTickData.timestamp = timestamp 111 | localTickData.frame = frame 112 | localTickData.timeDiff = timeDiff 113 | localTickData.fps = fps 114 | this.dispatchEvent(frameEvent) 115 | } 116 | } 117 | 118 | export default TickManager 119 | -------------------------------------------------------------------------------- /src/render/controllers/utils/math.ts: -------------------------------------------------------------------------------- 1 | export const lerp = (x: number, y: number, a: number) => x * (1 - a) + y * a 2 | 3 | export const easeOutExpo = (x: number) => { 4 | return x === 1 ? 1 : 1 - Math.pow(2, -10 * x) 5 | } 6 | 7 | export const EaseOutCirc = (x: number) => { 8 | return Math.sqrt(1.0 - Math.pow(x - 1.0, 2.0)) 9 | } 10 | 11 | export const UpDownCirc = (x: number) => { 12 | return Math.sin(EaseOutCirc(x) * Math.PI) 13 | } 14 | 15 | export const clamp = (x: number, a: number, b: number) => { 16 | return Math.min(Math.max(x, a), b) 17 | } 18 | -------------------------------------------------------------------------------- /src/render/controllers/utils/meshes.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | import { RAPIER, usePhysics, usePhysicsObjects, useScene, useTick } from '../../init' 3 | import { addPhysics } from '../../physics/physics' 4 | 5 | const _addCapsule = ( 6 | height: number, 7 | radius: number, 8 | capSegments: number, 9 | radialSegments: number 10 | ) => { 11 | const scene = useScene() 12 | const geometry = new THREE.CapsuleGeometry(radius, height, capSegments, radialSegments) 13 | const material = new THREE.MeshStandardMaterial({ color: 0xd60019, transparent: true }) 14 | const capsule = new THREE.Mesh(geometry, material) 15 | capsule.position.y += height / 2 + radius 16 | 17 | capsule.position.y += 10 18 | 19 | scene.add(capsule) 20 | 21 | return capsule 22 | } 23 | 24 | export { _addCapsule } 25 | -------------------------------------------------------------------------------- /src/render/controllers/utils/objects.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | 3 | const vec3_4 = new THREE.Vector3() 4 | 5 | const _calculateObjectSize = (object: THREE.Object3D) => { 6 | const bbox = new THREE.Box3() 7 | bbox.expandByObject(object) 8 | const size = bbox.getSize(vec3_4) 9 | 10 | return size 11 | } 12 | 13 | export { _calculateObjectSize } 14 | -------------------------------------------------------------------------------- /src/render/init.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | import Stats from 'three/examples/jsm/libs/stats.module.js' 3 | import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js' 4 | import { EffectComposer, Pass } from 'three/examples/jsm/postprocessing/EffectComposer.js' 5 | import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js' 6 | import TickManager from './controllers/tick-manager' 7 | 8 | // wasm 9 | import Rapier from '@dimforge/rapier3d' 10 | import AvatarController from './controllers/character-controller' 11 | import { _addCapsule } from './controllers/utils/meshes' 12 | import GeneralLoader from './loaders/general-loader' 13 | import InitRapier from './physics/RAPIER' 14 | import { PhysicsObject } from './physics/physics' 15 | import { GRAVITY } from './physics/utils/constants' 16 | 17 | const GUI = require('three/examples/jsm/libs/lil-gui.module.min.js').GUI 18 | 19 | let scene: THREE.Scene, 20 | camera: THREE.PerspectiveCamera, 21 | renderer: THREE.WebGLRenderer, 22 | renderTarget: THREE.WebGLRenderTarget, 23 | composer: EffectComposer, 24 | controls: AvatarController, 25 | stats: Stats, 26 | gui: typeof GUI, 27 | renderWidth: number, 28 | renderHeight: number, 29 | renderAspectRatio: number, 30 | gltfLoader: GLTFLoader, 31 | textureLoader: THREE.TextureLoader, 32 | generalLoader: GeneralLoader, 33 | RAPIER: typeof Rapier, 34 | physicsWorld: Rapier.World, 35 | physicsObjects: Array 36 | 37 | const renderTickManager = new TickManager() 38 | 39 | export const initEngine = async () => { 40 | // physics -> Rapier 41 | RAPIER = await InitRapier() 42 | physicsWorld = new RAPIER.World(GRAVITY) 43 | physicsObjects = [] // initializing physics objects array 44 | 45 | // rendering -> THREE.js 46 | scene = new THREE.Scene() 47 | 48 | renderWidth = window.innerWidth 49 | renderHeight = window.innerHeight 50 | 51 | renderAspectRatio = renderWidth / renderHeight 52 | 53 | camera = new THREE.PerspectiveCamera(75, renderAspectRatio, 0.01, 1000) 54 | camera.position.z = 5 55 | 56 | renderer = new THREE.WebGLRenderer({ antialias: true }) 57 | renderer.setSize(renderWidth, renderHeight) 58 | renderer.setPixelRatio(window.devicePixelRatio * 1.5) 59 | 60 | // shadow 61 | renderer.shadowMap.enabled = true 62 | renderer.shadowMap.type = THREE.PCFSoftShadowMap 63 | 64 | document.body.appendChild(renderer.domElement) 65 | 66 | renderTarget = new THREE.WebGLRenderTarget(renderWidth, renderHeight, { 67 | samples: 8, 68 | }) 69 | composer = new EffectComposer(renderer, renderTarget) 70 | composer.setSize(renderWidth, renderHeight) 71 | composer.setPixelRatio(renderer.getPixelRatio()) 72 | 73 | const renderPass = new RenderPass(scene, camera) 74 | composer.addPass(renderPass) 75 | 76 | stats = Stats() 77 | document.body.appendChild(stats.dom) 78 | 79 | gui = new GUI() 80 | 81 | window.addEventListener( 82 | 'resize', 83 | () => { 84 | renderWidth = window.innerWidth 85 | renderHeight = window.innerHeight 86 | renderAspectRatio = renderWidth / renderHeight 87 | 88 | renderer.setPixelRatio(window.devicePixelRatio) 89 | 90 | camera.aspect = renderAspectRatio 91 | camera.updateProjectionMatrix() 92 | 93 | renderer.setSize(renderWidth, renderHeight) 94 | composer.setSize(renderWidth, renderHeight) 95 | }, 96 | false 97 | ) 98 | 99 | // controls 100 | const capsule = _addCapsule(1.5, 0.5, 10, 10) 101 | controls = new AvatarController(capsule, camera) 102 | 103 | // config 104 | generalLoader = new GeneralLoader() 105 | 106 | gltfLoader = new GLTFLoader() 107 | textureLoader= new THREE.TextureLoader() 108 | 109 | renderTickManager.startLoop() 110 | } 111 | 112 | export const useRenderer = () => renderer 113 | 114 | export const useRenderSize = () => ({ width: renderWidth, height: renderHeight }) 115 | 116 | export const useScene = () => scene 117 | 118 | export const useCamera = () => camera 119 | 120 | export const useControls = () => controls 121 | 122 | export const useStats = () => stats 123 | 124 | export const useRenderTarget = () => renderTarget 125 | 126 | export const useComposer = () => composer 127 | 128 | export const useGui = () => gui 129 | 130 | export const addPass = (pass: Pass) => { 131 | composer.addPass(pass) 132 | } 133 | 134 | export const useTick = (fn: Function) => { 135 | if (renderTickManager) { 136 | const _tick = (e: any) => { 137 | fn(e.data) 138 | } 139 | renderTickManager.addEventListener('tick', _tick) 140 | } 141 | } 142 | 143 | export const useGltfLoader = () => gltfLoader 144 | export const useTextureLoader = () => textureLoader 145 | export const useLoader = () => generalLoader 146 | export const usePhysics = () => physicsWorld 147 | export const usePhysicsObjects = () => physicsObjects 148 | 149 | export { RAPIER } 150 | -------------------------------------------------------------------------------- /src/render/loaders/general-loader.ts: -------------------------------------------------------------------------------- 1 | import { useGltfLoader, useTextureLoader } from '../init' 2 | import * as THREE from 'three'; 3 | 4 | type LoaderProgress = ProgressEvent 5 | 6 | const _loadGltf = async (path: string) => { 7 | const gltfLoader = useGltfLoader() 8 | const gltf = await gltfLoader.loadAsync( 9 | // URL of the gltf you want to load 10 | path, 11 | 12 | // called while loading is progressing 13 | (progress: LoaderProgress) => 14 | console.log( 15 | `Loading gltf file from ${path} ...`, 16 | 100.0 * (progress.loaded / progress.total), 17 | '%' 18 | ) 19 | ) 20 | 21 | return gltf 22 | } 23 | const _loadVrm = async (path: string) => { 24 | const gltfLoader = useGltfLoader() 25 | const vrm = await gltfLoader.loadAsync( 26 | // URL of the VRM you want to load 27 | path, 28 | 29 | // called while loading is progressing 30 | (progress: LoaderProgress) => 31 | console.log( 32 | `Loading vrm file from ${path} ...`, 33 | 100.0 * (progress.loaded / progress.total), 34 | '%' 35 | ) 36 | ) 37 | 38 | return vrm 39 | } 40 | 41 | const _loadTexture = async (path: string) => { 42 | const textureLoader = useTextureLoader() 43 | const texture = await textureLoader.loadAsync( 44 | path, 45 | 46 | // called while loading is progressing 47 | (progress: LoaderProgress) => 48 | console.log(`Loading image from ${path} ...`, 100.0 * (progress.loaded / progress.total), '%') 49 | ) 50 | 51 | texture.wrapS = texture.wrapT = THREE.RepeatWrapping 52 | 53 | return texture 54 | } 55 | 56 | class GeneralLoader { 57 | constructor() {} 58 | 59 | async load(path: string) { 60 | const fileType = path.split('.').pop() 61 | 62 | let file = null 63 | 64 | switch (fileType) { 65 | case 'gltf': { 66 | file = await _loadGltf(path) 67 | return file?.scene 68 | } 69 | 70 | case 'vrm': { 71 | file = await _loadVrm(path) 72 | return file?.scene 73 | } 74 | 75 | case 'png': { 76 | file = await _loadTexture(path) 77 | return file 78 | } 79 | 80 | case 'png': { 81 | file = await _loadTexture(path) 82 | return file 83 | } 84 | 85 | default: { 86 | console.error(`GeneralLoader: File type ${fileType} is not supported.`) 87 | return file 88 | } 89 | } 90 | } 91 | } 92 | 93 | export default GeneralLoader 94 | -------------------------------------------------------------------------------- /src/render/physics/RAPIER.ts: -------------------------------------------------------------------------------- 1 | const InitRapier = async () => { 2 | // ! this way of importing the rapier module has not been documented anywhere 3 | // ! and it seems like a bug 4 | const mod = await import('@dimforge/rapier3d') 5 | const RAPIER = await mod.default 6 | 7 | return RAPIER 8 | } 9 | 10 | export default InitRapier -------------------------------------------------------------------------------- /src/render/physics/physics.ts: -------------------------------------------------------------------------------- 1 | import Rapier from '@dimforge/rapier3d' 2 | import { RAPIER, usePhysics, usePhysicsObjects } from '../init' 3 | 4 | export type PhysicsObject = { 5 | mesh: THREE.Mesh 6 | collider: Rapier.Collider 7 | rigidBody: Rapier.RigidBody 8 | fn?: Function 9 | autoAnimate: boolean 10 | } 11 | 12 | export const addPhysics = ( 13 | mesh: THREE.Mesh, 14 | rigidBodyType: string, 15 | autoAnimate: boolean = true, // update the mesh's position and quaternion based on the physics world every frame 16 | postPhysicsFn?: Function, 17 | colliderType?: string, 18 | colliderSettings?: any 19 | ) => { 20 | const physics = usePhysics() 21 | const physicsObjects = usePhysicsObjects() 22 | 23 | const rigidBodyDesc = (RAPIER.RigidBodyDesc as any)[rigidBodyType]() 24 | rigidBodyDesc.setTranslation(mesh.position.x, mesh.position.y, mesh.position.z) 25 | 26 | // * Responsible for collision response 27 | const rigidBody = physics.createRigidBody(rigidBodyDesc) 28 | 29 | let colliderDesc 30 | 31 | switch (colliderType) { 32 | case 'cuboid': 33 | { 34 | const { width, height, depth } = colliderSettings 35 | colliderDesc = RAPIER.ColliderDesc.cuboid(width, height, depth) 36 | } 37 | break 38 | 39 | case 'ball': 40 | { 41 | const { radius } = colliderSettings 42 | colliderDesc = RAPIER.ColliderDesc.ball(radius) 43 | } 44 | break 45 | 46 | case 'capsule': 47 | { 48 | const { halfHeight, radius } = colliderSettings 49 | colliderDesc = RAPIER.ColliderDesc.capsule(halfHeight, radius) 50 | } 51 | break 52 | 53 | default: 54 | { 55 | colliderDesc = RAPIER.ColliderDesc.trimesh( 56 | mesh.geometry.attributes.position.array as Float32Array, 57 | mesh.geometry.index?.array as Uint32Array 58 | ) 59 | } 60 | break 61 | } 62 | 63 | if (!colliderDesc) { 64 | console.error('Collider Mesh Error: convex mesh creation failed.') 65 | } 66 | 67 | // * Responsible for collision detection 68 | const collider = physics.createCollider(colliderDesc, rigidBody) 69 | 70 | const physicsObject: PhysicsObject = { mesh, collider, rigidBody, fn: postPhysicsFn, autoAnimate } 71 | 72 | physicsObjects.push(physicsObject) 73 | 74 | return physicsObject 75 | } 76 | -------------------------------------------------------------------------------- /src/render/physics/utils/constants.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | 3 | export const GRAVITY = new THREE.Vector3(0.0, -9.81, 0.0) 4 | -------------------------------------------------------------------------------- /src/styles/_scaffolding.scss: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | text-align: center; 5 | width: 100vw; 6 | height: 100vh; 7 | overflow: hidden; 8 | 9 | display: flex; 10 | justify-content: center; 11 | align-items: center; 12 | 13 | canvas { 14 | width: 100vw; 15 | height: 100vh; 16 | position: absolute; 17 | display: flex; 18 | justify-self: center; 19 | align-self: center; 20 | padding: 0; 21 | margin: 0; 22 | } 23 | 24 | .shooting-icon { 25 | margin-left: -20.5px; 26 | margin-top: -20.5px; 27 | pointer-events: none; 28 | z-index: 1; 29 | position: absolute; 30 | display: flex; 31 | align-self: center; 32 | justify-content: center; 33 | align-items: center; 34 | svg { 35 | display: flex; 36 | stroke: #f7f7f750 !important; 37 | fill: #f7f7f750 !important; 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/styles/_variables.scss: -------------------------------------------------------------------------------- 1 | $font-size: 1rem; 2 | $font-family: 'Inter', sans-serif; 3 | $background-color: #121212; 4 | $font-color: #dae0e0; 5 | -------------------------------------------------------------------------------- /src/styles/index.scss: -------------------------------------------------------------------------------- 1 | @import 'variables'; 2 | @import 'scaffolding'; 3 | -------------------------------------------------------------------------------- /src/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 12 |
13 |
14 | 20 | 21 | 29 | 37 | 45 | 53 | 59 | 60 | 61 |
62 |
63 | 64 | 65 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 22 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 26 | 27 | /* Modules */ 28 | "module": "commonjs" /* Specify what module code is generated. */, 29 | "rootDir": "./" /* Specify the root folder within your source files. */, 30 | "moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */, 31 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 32 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 33 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 34 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 35 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 36 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 37 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 38 | // "resolveJsonModule": true, /* Enable importing .json files. */ 39 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 40 | 41 | /* JavaScript Support */ 42 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 43 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 44 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 45 | 46 | /* Emit */ 47 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 48 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 49 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 50 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 51 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 52 | "outDir": "./dist" /* Specify an output folder for all emitted files. */, 53 | // "removeComments": true, /* Disable emitting comments. */ 54 | // "noEmit": true, /* Disable emitting files from a compilation. */ 55 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 56 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 57 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 58 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 59 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 60 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 61 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 62 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 63 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 64 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 65 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 66 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 67 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 68 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 69 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 70 | 71 | /* Interop Constraints */ 72 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 73 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 74 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, 75 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 76 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 77 | 78 | /* Type Checking */ 79 | "strict": true /* Enable all strict type-checking options. */, 80 | "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 81 | "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 82 | "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 83 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 84 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 85 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 86 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 87 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 88 | "noUnusedLocals": false, /* Enable error reporting when local variables aren't read. */ 89 | "noUnusedParameters": false, /* Raise an error when a function parameter isn't read. */ 90 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 91 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 92 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 93 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 94 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 95 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 96 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 97 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 98 | 99 | /* Completeness */ 100 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 101 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 102 | }, 103 | "include": ["**/*.ts", "**/*.tsx", "src/render/math/Color.js"], 104 | "exclude": ["node_modules"] 105 | } 106 | --------------------------------------------------------------------------------