├── .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 | [](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 |
--------------------------------------------------------------------------------