├── src ├── vite-env.d.ts ├── helpers │ ├── responsiveness.ts │ ├── fullscreen.ts │ └── animations.ts ├── style.css └── scene.ts ├── docs └── preview.png ├── .gitignore ├── index.html ├── package.json ├── public └── threejs-icon.svg ├── tsconfig.json ├── .github ├── dependabot.yml └── workflows │ ├── build.yml │ └── deploy.yml └── README.md /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /docs/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pachoclo/vite-threejs-ts-template/HEAD/docs/preview.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Three.js + TypeScript + Vite 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/helpers/responsiveness.ts: -------------------------------------------------------------------------------- 1 | import { WebGLRenderer } from 'three' 2 | 3 | export function resizeRendererToDisplaySize(renderer: WebGLRenderer) { 4 | const canvas = renderer.domElement 5 | const width = canvas.clientWidth 6 | const height = canvas.clientHeight 7 | const needResize = canvas.width !== width || canvas.height !== height 8 | if (needResize) { 9 | renderer.setSize(width, height, false) 10 | } 11 | return needResize 12 | } 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "threejs-starter-vite", 3 | "private": true, 4 | "version": "1.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview" 10 | }, 11 | "devDependencies": { 12 | "@types/three": "^0.178.0", 13 | "typescript": "^5.8.2", 14 | "vite": "^6.3.5" 15 | }, 16 | "dependencies": { 17 | "lil-gui": "^0.20.0", 18 | "stats.js": "^0.17.0", 19 | "three": "^0.178.0" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/helpers/fullscreen.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | export function toggleFullScreen(canvas: HTMLElement) { 3 | if (document.fullscreenElement) { 4 | document.exitFullscreen() 5 | } else if (!document.fullscreenElement && canvas.requestFullscreen) { 6 | canvas.requestFullscreen() 7 | } 8 | 9 | // 👇 safari -> doesn't support the standard yet 10 | else if (document.webkitFullscreenElement) { 11 | document.webkitExitFullscreen() 12 | } else if (!document.webkitFullscreenElement && canvas.webkitRequestFullscreen) { 13 | canvas.webkitRequestFullscreen() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/helpers/animations.ts: -------------------------------------------------------------------------------- 1 | import { Clock, Object3D } from 'three' 2 | 3 | function rotate(object: Object3D, clock: Clock, radiansPerSecond: number = Math.PI * 2) { 4 | const rotationAngle = clock.getElapsedTime() * radiansPerSecond 5 | object.rotation.y = rotationAngle 6 | } 7 | 8 | function bounce( 9 | object: Object3D, 10 | clock: Clock, 11 | bounceSpeed: number = 1.5, 12 | amplitude: number = 0.4, 13 | yLowerBound: number = 0.5 14 | ) { 15 | const elapsed = clock.getElapsedTime() 16 | const yPos = Math.abs(Math.sin(elapsed * bounceSpeed) * amplitude) 17 | object.position.y = yPos + yLowerBound 18 | } 19 | 20 | export { rotate, bounce } 21 | -------------------------------------------------------------------------------- /public/threejs-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ESNext", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "sourceMap": true, 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "moduleDetection": "force", 16 | "noEmit": true, 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noImplicitReturns": true 24 | }, 25 | "include": ["src"] 26 | } 27 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Please see the documentation for all configuration options: 2 | # https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot 3 | # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem 4 | version: 2 5 | updates: 6 | - package-ecosystem: "npm" 7 | directory: "/" 8 | schedule: 9 | interval: "weekly" 10 | - package-ecosystem: github-actions 11 | directory: "/" 12 | groups: 13 | github-actions: 14 | patterns: 15 | - "*" # Group all Actions updates into a single larger pull request 16 | schedule: 17 | interval: "weekly" 18 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # ref: https://github.com/actions/runner-images 2 | name: Build 3 | 4 | on: [push, pull_request, workflow_dispatch] 5 | 6 | concurrency: 7 | group: ${{github.workflow}}-${{github.ref}} 8 | cancel-in-progress: true 9 | 10 | # Building using the github runner environement directly. 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v4 17 | - name: Install dependencies 18 | run: npm install 19 | - name: Configure vite 20 | run: | 21 | echo 'export default { 22 | base: "/${{github.event.repository.name}}/" 23 | }' > vite.config.js 24 | - name: Build project 25 | run: npm run build 26 | -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, Avenir, Helvetica, Arial, sans-serif; 3 | font-size: 16px; 4 | line-height: 24px; 5 | font-weight: 400; 6 | 7 | color-scheme: dark; 8 | color: rgba(255, 255, 255, 0.87); 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | -webkit-text-size-adjust: 100%; 15 | } 16 | 17 | body { 18 | margin: 0; 19 | display: flex; 20 | place-items: center; 21 | } 22 | 23 | body, 24 | html { 25 | height: 100%; 26 | } 27 | 28 | canvas { 29 | height: 100%; 30 | width: 100%; 31 | outline: none; 32 | 33 | background: rgb(34, 193, 195); 34 | background: linear-gradient( 35 | 0deg, 36 | rgb(8, 163, 166) 0%, 37 | rgba(79, 166, 167, 0.849) 8%, 38 | rgba(61, 79, 94, 0.885) 40%, 39 | rgb(17, 19, 23) 40 | ); 41 | } 42 | 43 | h1 { 44 | font-size: 3.2em; 45 | line-height: 1.1; 46 | } 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Github-CI: [![Build Status][build_status]][build_link] 2 | 3 | [build_status]: ./../../actions/workflows/build.yml/badge.svg 4 | [build_link]: ./../../actions/workflows/build.yml 5 | 6 | # Three.js Vite Template with TypeScript 7 | 8 | Three.js + Vite + TypeScript starter 9 | 10 | - [Demo](../../deployments/github-pages) 11 | - [Jump to CLI commands](#cli-commands) 12 | 13 | --- 14 | ![screenshot](docs/preview.png) 15 | 16 | --- 17 | 18 | ## Tech Stack 19 | 20 | - Three.js 21 | - TypeScript 22 | - Vite 23 | 24 | ## Stuff included in the `scene.ts` 25 | 26 | - [x] Geometry 27 | - [x] Material 28 | - [x] Mesh 29 | - [x] Ambient Light 30 | - [x] Point Light 31 | - [x] Camera 32 | - [x] Scene 33 | - [x] Canvas 34 | - [x] Renderer (WebGL) 35 | - [x] Loading Manager 36 | - [x] Orbit Controls 37 | - [x] Drag Controls 38 | - [x] Grid 39 | - [x] Antialias enabled 40 | - [x] Transparent canvas 41 | - [x] Responsive renderer and camera (to canvas size) 42 | - [x] Animation Loop 43 | - [x] Shadows 44 | - [x] Stats (FPS, ms) 45 | - [x] Full screen (double-click on canvas) 46 | - [x] Debug GUI 47 | 48 | ## CLI Commands 49 | 50 | Installation 51 | 52 | ```bash 53 | npm i 54 | ``` 55 | 56 | Run dev mode 57 | 58 | ```bash 59 | npm run dev 60 | ``` 61 | 62 | Build 63 | 64 | ```bash 65 | npm run build 66 | ``` 67 | 68 | Run build 69 | 70 | ```bash 71 | npm run preview 72 | ``` 73 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying to GitHub Pages 2 | name: Deploy to GitHub Pages 3 | 4 | on: 5 | push: 6 | branches: ["main"] 7 | # Allows you to run this workflow manually from the Actions tab 8 | workflow_dispatch: 9 | 10 | concurrency: 11 | group: "pages" 12 | cancel-in-progress: false 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v4 20 | - name: Install dependencies 21 | run: npm install 22 | - name: Configure vite 23 | run: | 24 | echo 'export default { 25 | base: "/${{ github.event.repository.name }}/" 26 | }' > vite.config.js 27 | - name: Build project 28 | run: npm run build 29 | - name: Upload pages artifact 30 | uses: actions/upload-pages-artifact@v3 31 | with: 32 | path: dist 33 | deploy: 34 | needs: build 35 | permissions: 36 | pages: write # to deploy to Pages 37 | id-token: write # to verify the deployment originates from an appropriate source 38 | environment: 39 | name: github-pages 40 | url: ${{ steps.deployment.outputs.page_url }} 41 | runs-on: ubuntu-latest 42 | steps: 43 | - name: Deploy to GitHub Pages 44 | id: deployment 45 | uses: actions/deploy-pages@v4 46 | -------------------------------------------------------------------------------- /src/scene.ts: -------------------------------------------------------------------------------- 1 | import GUI from 'lil-gui' 2 | import { 3 | AmbientLight, 4 | AxesHelper, 5 | BoxGeometry, 6 | Clock, 7 | GridHelper, 8 | LoadingManager, 9 | Mesh, 10 | MeshLambertMaterial, 11 | MeshStandardMaterial, 12 | PCFSoftShadowMap, 13 | PerspectiveCamera, 14 | PlaneGeometry, 15 | PointLight, 16 | PointLightHelper, 17 | Scene, 18 | WebGLRenderer, 19 | } from 'three' 20 | import { DragControls } from 'three/addons/controls/DragControls.js' 21 | import { OrbitControls } from 'three/addons/controls/OrbitControls.js' 22 | import Stats from 'stats.js' 23 | import * as animations from './helpers/animations' 24 | import { toggleFullScreen } from './helpers/fullscreen' 25 | import { resizeRendererToDisplaySize } from './helpers/responsiveness' 26 | import './style.css' 27 | 28 | const CANVAS_ID = 'scene' 29 | 30 | let canvas: HTMLElement 31 | let renderer: WebGLRenderer 32 | let scene: Scene 33 | let loadingManager: LoadingManager 34 | let ambientLight: AmbientLight 35 | let pointLight: PointLight 36 | let cube: Mesh 37 | let camera: PerspectiveCamera 38 | let cameraControls: OrbitControls 39 | let dragControls: DragControls 40 | let axesHelper: AxesHelper 41 | let pointLightHelper: PointLightHelper 42 | let clock: Clock 43 | let stats: Stats 44 | let gui: GUI 45 | 46 | const animation = { enabled: true, play: true } 47 | 48 | init() 49 | animate() 50 | 51 | function init() { 52 | // ===== 🖼️ CANVAS, RENDERER, & SCENE ===== 53 | { 54 | canvas = document.querySelector(`canvas#${CANVAS_ID}`)! 55 | renderer = new WebGLRenderer({ canvas, antialias: true, alpha: true }) 56 | renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) 57 | renderer.shadowMap.enabled = true 58 | renderer.shadowMap.type = PCFSoftShadowMap 59 | scene = new Scene() 60 | } 61 | 62 | // ===== 👨🏻‍💼 LOADING MANAGER ===== 63 | { 64 | loadingManager = new LoadingManager() 65 | 66 | loadingManager.onStart = () => { 67 | console.log('loading started') 68 | } 69 | loadingManager.onProgress = (url, loaded, total) => { 70 | console.log('loading in progress:') 71 | console.log(`${url} -> ${loaded} / ${total}`) 72 | } 73 | loadingManager.onLoad = () => { 74 | console.log('loaded!') 75 | } 76 | loadingManager.onError = () => { 77 | console.log('❌ error while loading') 78 | } 79 | } 80 | 81 | // ===== 💡 LIGHTS ===== 82 | { 83 | ambientLight = new AmbientLight('white', 0.4) 84 | pointLight = new PointLight('white', 20, 100) 85 | pointLight.position.set(-2, 2, 2) 86 | pointLight.castShadow = true 87 | pointLight.shadow.radius = 4 88 | pointLight.shadow.camera.near = 0.1 89 | pointLight.shadow.camera.far = 1000 90 | pointLight.shadow.mapSize.width = 2048 91 | pointLight.shadow.mapSize.height = 2048 92 | scene.add(ambientLight) 93 | scene.add(pointLight) 94 | } 95 | 96 | // ===== 📦 OBJECTS ===== 97 | { 98 | const sideLength = 1 99 | const cubeGeometry = new BoxGeometry(sideLength, sideLength, sideLength) 100 | const cubeMaterial = new MeshStandardMaterial({ 101 | color: '#f69f1f', 102 | metalness: 0.5, 103 | roughness: 0.7, 104 | }) 105 | cube = new Mesh(cubeGeometry, cubeMaterial) 106 | cube.castShadow = true 107 | cube.position.y = 0.5 108 | 109 | const planeGeometry = new PlaneGeometry(3, 3) 110 | const planeMaterial = new MeshLambertMaterial({ 111 | color: 'gray', 112 | emissive: 'teal', 113 | emissiveIntensity: 0.2, 114 | side: 2, 115 | transparent: true, 116 | opacity: 0.4, 117 | }) 118 | const plane = new Mesh(planeGeometry, planeMaterial) 119 | plane.rotateX(Math.PI / 2) 120 | plane.receiveShadow = true 121 | 122 | scene.add(cube) 123 | scene.add(plane) 124 | } 125 | 126 | // ===== 🎥 CAMERA ===== 127 | { 128 | camera = new PerspectiveCamera(75, canvas.clientWidth / canvas.clientHeight, 0.1, 1000) 129 | camera.position.set(2, 2, 5) 130 | } 131 | 132 | // ===== 🕹️ CONTROLS ===== 133 | { 134 | cameraControls = new OrbitControls(camera, canvas) 135 | cameraControls.target = cube.position.clone() 136 | cameraControls.enableDamping = true 137 | cameraControls.autoRotate = false 138 | cameraControls.update() 139 | 140 | dragControls = new DragControls([cube], camera, renderer.domElement) 141 | dragControls.addEventListener('hoveron', (event) => { 142 | const mesh = event.object as Mesh 143 | const material = mesh.material as MeshStandardMaterial 144 | material.emissive.set('green') 145 | }) 146 | dragControls.addEventListener('hoveroff', (event) => { 147 | const mesh = event.object as Mesh 148 | const material = mesh.material as MeshStandardMaterial 149 | material.emissive.set('black') 150 | }) 151 | dragControls.addEventListener('dragstart', (event) => { 152 | const mesh = event.object as Mesh 153 | const material = mesh.material as MeshStandardMaterial 154 | cameraControls.enabled = false 155 | animation.play = false 156 | material.emissive.set('orange') 157 | material.opacity = 0.7 158 | material.needsUpdate = true 159 | }) 160 | dragControls.addEventListener('dragend', (event) => { 161 | cameraControls.enabled = true 162 | animation.play = true 163 | const mesh = event.object as Mesh 164 | const material = mesh.material as MeshStandardMaterial 165 | material.emissive.set('black') 166 | material.opacity = 1 167 | material.needsUpdate = true 168 | }) 169 | dragControls.enabled = false 170 | 171 | // Full screen 172 | window.addEventListener('dblclick', (event) => { 173 | if (event.target === canvas) { 174 | toggleFullScreen(canvas) 175 | } 176 | }) 177 | } 178 | 179 | // ===== 🪄 HELPERS ===== 180 | { 181 | axesHelper = new AxesHelper(4) 182 | axesHelper.visible = false 183 | scene.add(axesHelper) 184 | 185 | pointLightHelper = new PointLightHelper(pointLight, undefined, 'orange') 186 | pointLightHelper.visible = false 187 | scene.add(pointLightHelper) 188 | 189 | const gridHelper = new GridHelper(20, 20, 'teal', 'darkgray') 190 | gridHelper.position.y = -0.01 191 | scene.add(gridHelper) 192 | } 193 | 194 | // ===== 📈 STATS & CLOCK ===== 195 | { 196 | clock = new Clock() 197 | stats = new Stats() 198 | document.body.appendChild(stats.dom) 199 | } 200 | 201 | // ==== 🐞 DEBUG GUI ==== 202 | { 203 | gui = new GUI({ title: '🐞 Debug GUI', width: 300 }) 204 | 205 | const cubeOneFolder = gui.addFolder('Cube one') 206 | 207 | cubeOneFolder.add(cube.position, 'x').min(-5).max(5).step(0.5).name('pos x') 208 | cubeOneFolder 209 | .add(cube.position, 'y') 210 | .min(-5) 211 | .max(5) 212 | .step(1) 213 | .name('pos y') 214 | .onChange(() => (animation.play = false)) 215 | .onFinishChange(() => (animation.play = true)) 216 | cubeOneFolder.add(cube.position, 'z').min(-5).max(5).step(0.5).name('pos z') 217 | 218 | cubeOneFolder.add(cube.material as MeshStandardMaterial, 'wireframe') 219 | cubeOneFolder.addColor(cube.material as MeshStandardMaterial, 'color') 220 | cubeOneFolder.add(cube.material as MeshStandardMaterial, 'metalness', 0, 1, 0.1) 221 | cubeOneFolder.add(cube.material as MeshStandardMaterial, 'roughness', 0, 1, 0.1) 222 | 223 | cubeOneFolder 224 | .add(cube.rotation, 'x', -Math.PI * 2, Math.PI * 2, Math.PI / 4) 225 | .name('rotate x') 226 | cubeOneFolder 227 | .add(cube.rotation, 'y', -Math.PI * 2, Math.PI * 2, Math.PI / 4) 228 | .name('rotate y') 229 | .onChange(() => (animation.play = false)) 230 | .onFinishChange(() => (animation.play = true)) 231 | cubeOneFolder 232 | .add(cube.rotation, 'z', -Math.PI * 2, Math.PI * 2, Math.PI / 4) 233 | .name('rotate z') 234 | 235 | cubeOneFolder.add(animation, 'enabled').name('animated') 236 | 237 | const controlsFolder = gui.addFolder('Controls') 238 | controlsFolder.add(dragControls, 'enabled').name('drag controls') 239 | 240 | const lightsFolder = gui.addFolder('Lights') 241 | lightsFolder.add(pointLight, 'visible').name('point light') 242 | lightsFolder.add(ambientLight, 'visible').name('ambient light') 243 | 244 | const helpersFolder = gui.addFolder('Helpers') 245 | helpersFolder.add(axesHelper, 'visible').name('axes') 246 | helpersFolder.add(pointLightHelper, 'visible').name('pointLight') 247 | 248 | const cameraFolder = gui.addFolder('Camera') 249 | cameraFolder.add(cameraControls, 'autoRotate') 250 | 251 | // persist GUI state in local storage on changes 252 | gui.onFinishChange(() => { 253 | const guiState = gui.save() 254 | localStorage.setItem('guiState', JSON.stringify(guiState)) 255 | }) 256 | 257 | // load GUI state if available in local storage 258 | const guiState = localStorage.getItem('guiState') 259 | if (guiState) gui.load(JSON.parse(guiState)) 260 | 261 | // reset GUI state button 262 | const resetGui = () => { 263 | localStorage.removeItem('guiState') 264 | gui.reset() 265 | } 266 | gui.add({ resetGui }, 'resetGui').name('RESET') 267 | 268 | gui.close() 269 | } 270 | } 271 | 272 | function animate() { 273 | requestAnimationFrame(animate) 274 | 275 | stats.begin() 276 | if (animation.enabled && animation.play) { 277 | animations.rotate(cube, clock, Math.PI / 3) 278 | animations.bounce(cube, clock, 1, 0.5, 0.5) 279 | } 280 | 281 | if (resizeRendererToDisplaySize(renderer)) { 282 | const canvas = renderer.domElement 283 | camera.aspect = canvas.clientWidth / canvas.clientHeight 284 | camera.updateProjectionMatrix() 285 | } 286 | 287 | cameraControls.update() 288 | 289 | renderer.render(scene, camera) 290 | stats.end() 291 | } 292 | --------------------------------------------------------------------------------