Setup three.js

This commit is contained in:
Nettika 2026-01-28 23:39:01 -08:00
parent 2105d05749
commit f0fece522d
No known key found for this signature in database
7 changed files with 201 additions and 36 deletions

12
TODO.md
View file

@ -28,12 +28,12 @@ A step-by-step checklist for porting MatterControl's design features to a Vue +
## Phase 2: 3D Viewport Foundation
### three.js Setup
- [ ] Install three.js and `@types/three`
- [ ] Create `Viewport.vue` component with canvas element
- [ ] Initialize three.js Scene, Camera, and Renderer
- [ ] Add resize observer for responsive canvas
- [ ] Implement render loop with `requestAnimationFrame`
- [ ] Add basic lighting (ambient + two directional)
- [x] Install three.js and `@types/three`
- [x] Create `Viewport.vue` component with canvas element
- [x] Initialize three.js Scene, Camera, and Renderer
- [x] Add resize observer for responsive canvas
- [x] Implement render loop with `requestAnimationFrame`
- [x] Add basic lighting (ambient + two directional)
### Scene Helpers
- [ ] Add ground grid helper (XZ plane)

View file

@ -14,11 +14,13 @@
},
"dependencies": {
"pinia": "^3.0.4",
"three": "^0.182.0",
"vue": "^3.5.24"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@types/node": "^24.10.1",
"@types/three": "^0.182.0",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1",

59
pnpm-lock.yaml generated
View file

@ -11,6 +11,9 @@ importers:
pinia:
specifier: ^3.0.4
version: 3.0.4(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3))
three:
specifier: ^0.182.0
version: 0.182.0
vue:
specifier: ^3.5.24
version: 3.5.27(typescript@5.9.3)
@ -21,6 +24,9 @@ importers:
'@types/node':
specifier: ^24.10.1
version: 24.10.9
'@types/three':
specifier: ^0.182.0
version: 0.182.0
'@vitejs/plugin-vue':
specifier: ^6.0.1
version: 6.0.3(vite@7.3.1(@types/node@24.10.9))(vue@3.5.27(typescript@5.9.3))
@ -80,6 +86,9 @@ packages:
resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==}
engines: {node: '>=6.9.0'}
'@dimforge/rapier3d-compat@0.12.0':
resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==}
'@esbuild/aix-ppc64@0.27.2':
resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==}
engines: {node: '>=18'}
@ -435,6 +444,9 @@ packages:
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@tweenjs/tween.js@23.1.3':
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@ -450,6 +462,15 @@ packages:
'@types/node@24.10.9':
resolution: {integrity: sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==}
'@types/stats.js@0.17.4':
resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==}
'@types/three@0.182.0':
resolution: {integrity: sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q==}
'@types/webxr@0.5.24':
resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==}
'@types/whatwg-mimetype@3.0.2':
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
@ -615,6 +636,9 @@ packages:
vue:
optional: true
'@webgpu/types@0.1.69':
resolution: {integrity: sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==}
abbrev@2.0.0:
resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
@ -854,6 +878,9 @@ packages:
picomatch:
optional: true
fflate@0.8.2:
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
file-entry-cache@8.0.0:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'}
@ -984,6 +1011,9 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
meshoptimizer@0.22.0:
resolution: {integrity: sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==}
minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
@ -1181,6 +1211,9 @@ packages:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
three@0.182.0:
resolution: {integrity: sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@ -1388,6 +1421,8 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@dimforge/rapier3d-compat@0.12.0': {}
'@esbuild/aix-ppc64@0.27.2':
optional: true
@ -1618,6 +1653,8 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
'@tweenjs/tween.js@23.1.3': {}
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
@ -1633,6 +1670,20 @@ snapshots:
dependencies:
undici-types: 7.16.0
'@types/stats.js@0.17.4': {}
'@types/three@0.182.0':
dependencies:
'@dimforge/rapier3d-compat': 0.12.0
'@tweenjs/tween.js': 23.1.3
'@types/stats.js': 0.17.4
'@types/webxr': 0.5.24
'@webgpu/types': 0.1.69
fflate: 0.8.2
meshoptimizer: 0.22.0
'@types/webxr@0.5.24': {}
'@types/whatwg-mimetype@3.0.2': {}
'@types/ws@8.18.1':
@ -1879,6 +1930,8 @@ snapshots:
typescript: 5.9.3
vue: 3.5.27(typescript@5.9.3)
'@webgpu/types@0.1.69': {}
abbrev@2.0.0: {}
acorn-jsx@5.3.2(acorn@8.15.0):
@ -2120,6 +2173,8 @@ snapshots:
optionalDependencies:
picomatch: 4.0.3
fflate@0.8.2: {}
file-entry-cache@8.0.0:
dependencies:
flat-cache: 4.0.1
@ -2247,6 +2302,8 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
meshoptimizer@0.22.0: {}
minimatch@3.1.2:
dependencies:
brace-expansion: 1.1.12
@ -2434,6 +2491,8 @@ snapshots:
dependencies:
has-flag: 4.0.0
three@0.182.0: {}
tinybench@2.9.0: {}
tinyexec@1.0.2: {}

View file

@ -1,30 +1,16 @@
<script setup lang="ts">
import HelloWorld from './components/HelloWorld.vue'
import Viewport from './components/Viewport.vue'
</script>
<template>
<div>
<a href="https://vite.dev" target="_blank">
<img src="/vite.svg" class="logo" alt="Vite logo" />
</a>
<a href="https://vuejs.org/" target="_blank">
<img src="./assets/vue.svg" class="logo vue" alt="Vue logo" />
</a>
<div class="app">
<Viewport />
</div>
<HelloWorld msg="Vite + Vue" />
</template>
<style scoped>
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.vue:hover {
filter: drop-shadow(0 0 2em #42b883aa);
.app {
width: 100vw;
height: 100vh;
}
</style>

124
src/components/Viewport.vue Normal file
View file

@ -0,0 +1,124 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import * as THREE from 'three'
const containerRef = ref<HTMLDivElement | null>(null)
let scene: THREE.Scene
let camera: THREE.PerspectiveCamera
let renderer: THREE.WebGLRenderer
let animationFrameId: number
let resizeObserver: ResizeObserver
function initScene() {
if (!containerRef.value) return
// Create scene
scene = new THREE.Scene()
scene.background = new THREE.Color(0x1a1a2e)
// Create camera
const { clientWidth: width, clientHeight: height } = containerRef.value
camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000)
camera.position.set(5, 5, 5)
camera.lookAt(0, 0, 0)
// Create renderer
renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setSize(width, height)
renderer.setPixelRatio(window.devicePixelRatio)
containerRef.value.appendChild(renderer.domElement)
// Add lighting
addLighting()
// Add a test cube to verify rendering
addTestCube()
// Setup resize observer
setupResizeObserver()
// Start render loop
animate()
}
function addLighting() {
// Ambient light for base illumination
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4)
scene.add(ambientLight)
// Primary directional light (top-front-right)
const directionalLight1 = new THREE.DirectionalLight(0xffffff, 0.8)
directionalLight1.position.set(5, 10, 7)
scene.add(directionalLight1)
// Secondary directional light (back-left, softer)
const directionalLight2 = new THREE.DirectionalLight(0xffffff, 0.3)
directionalLight2.position.set(-5, 5, -5)
scene.add(directionalLight2)
}
function addTestCube() {
const geometry = new THREE.BoxGeometry(1, 1, 1)
const material = new THREE.MeshStandardMaterial({ color: 0x4a90d9 })
const cube = new THREE.Mesh(geometry, material)
scene.add(cube)
}
function setupResizeObserver() {
if (!containerRef.value) return
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect
if (width > 0 && height > 0) {
camera.aspect = width / height
camera.updateProjectionMatrix()
renderer.setSize(width, height)
}
}
})
resizeObserver.observe(containerRef.value)
}
function animate() {
animationFrameId = requestAnimationFrame(animate)
renderer.render(scene, camera)
}
function cleanup() {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId)
}
if (resizeObserver) {
resizeObserver.disconnect()
}
if (renderer) {
renderer.dispose()
renderer.domElement.remove()
}
}
onMounted(() => {
initScene()
})
onBeforeUnmount(() => {
cleanup()
})
</script>
<template>
<div ref="containerRef" class="viewport"></div>
</template>
<style scoped>
.viewport {
width: 100%;
height: 100%;
overflow: hidden;
}
</style>

View file

@ -24,10 +24,7 @@ a:hover {
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
overflow: hidden;
}
h1 {
@ -59,10 +56,8 @@ button:focus-visible {
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
width: 100%;
height: 100vh;
}
@media (prefers-color-scheme: light) {

View file

@ -1,5 +1,4 @@
/// <reference types="vitest" />
import { defineConfig } from 'vite'
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/