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