Nuxt 4 + Three.js WebGPU — Build a Real-Time Particle Galaxy

In this tutorial, we’ll build a 3D, real-time particle galaxy in Nuxt 4 using Three.js’ new WebGPU renderer — with an automatic WebGL fallback for browsers that don’t support WebGPU yet.
Why this now? Three.js’ WebGPURenderer has matured significantly in recent releases and browser support for WebGPU is broad (Chrome/Edge, Safari 17+, and behind a flag in Firefox). It’s a perfect moment to try modern GPU rendering in a Nuxt + TypeScript stack.
Tags: Nuxt 4, Three.js, WebGPU, TypeScript
Time to read: 10 min
Prerequisites
- Familiarity with the command line
- Node.js 20+ installed
- A modern browser (WebGPU works in Chrome/Edge, Safari 17+; Firefox requires a flag)
- Some Vue/Nuxt basics
1) Create a fresh Nuxt 4 app
npx nuxi init nuxt4-webgpu-galaxy
cd nuxt4-webgpu-galaxy
npm install
Run it:
npm run dev
Open http://localhost:3000 to verify it works.
2) Install Three.js
npm i three
That’s all we need dependency-wise.
3) Add a client-only WebGPU canvas component
We’ll render exclusively on the client (no SSR) and pick WebGPU if available; otherwise we’ll use WebGL.
Create components/GalaxyCanvas.client.vue:
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref } from 'vue'
import * as THREE from 'three'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
import { WebGPURenderer } from 'three/addons/renderers/webgpu/WebGPURenderer.js'
type Renderer = THREE.WebGLRenderer | WebGPURenderer
const container = ref<HTMLDivElement | null>(null)
const rendererType = ref<'webgpu' | 'webgl' | 'none'>('none')
let renderer: Renderer | null = null
let scene: THREE.Scene | null = null
let camera: THREE.PerspectiveCamera | null = null
let controls: OrbitControls | null = null
let animationHandle = 0
let resizeObserver: ResizeObserver | null = null
// Scene objects
let points: THREE.Points | null = null
let galaxyGroup: THREE.Group | null = null
function supportsWebGPU() {
return typeof navigator !== 'undefined' && 'gpu' in navigator
}
function createGalaxy(count: number) {
const geometry = new THREE.BufferGeometry()
const positions = new Float32Array(count * 3)
const colors = new Float32Array(count * 3)
// Spiral galaxy parameters
const arms = 4
const radius = 20
const randomness = 0.4
const spin = 1.2
const colorInside = new THREE.Color('#ffb3ff') // soft magenta
const colorOutside = new THREE.Color('#00ccff') // cyan
for (let i = 0; i < count; i++) {
const i3 = i * 3
const r = Math.random() ** 0.6 * radius
const arm = i % arms
const armAngle = (arm / arms) * Math.PI * 2
// base angle plus spin factor dependent on radius
const angle = armAngle + r * spin
// add some noise to scatter
const randomX = (Math.random() - 0.5) * randomness * r
const randomY = (Math.random() - 0.5) * randomness * 0.6 * r
const randomZ = (Math.random() - 0.5) * randomness * r
positions[i3 + 0] = Math.cos(angle) * r + randomX
positions[i3 + 1] = randomY * 0.5
positions[i3 + 2] = Math.sin(angle) * r + randomZ
const mixed = colorInside.clone().lerp(colorOutside, r / radius)
colors[i3 + 0] = mixed.r
colors[i3 + 1] = mixed.g
colors[i3 + 2] = mixed.b
}
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3))
const material = new THREE.PointsMaterial({
size: 0.04,
sizeAttenuation: true,
vertexColors: true,
transparent: true,
opacity: 0.95,
depthWrite: false,
blending: THREE.AdditiveBlending
})
const pts = new THREE.Points(geometry, material)
return pts
}
async function init() {
if (!container.value) return
scene = new THREE.Scene()
scene.background = new THREE.Color('#060608')
scene.fog = new THREE.Fog(scene.background, 50, 140)
camera = new THREE.PerspectiveCamera(60, 1, 0.1, 500)
camera.position.set(0, 6, 26)
const isWebGPU = supportsWebGPU()
rendererType.value = isWebGPU ? 'webgpu' : 'webgl'
if (isWebGPU) {
renderer = new WebGPURenderer({ antialias: true })
// WebGPU requires an explicit init before first render
await renderer.init()
} else {
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
}
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.setAnimationLoop(null) // we drive rAF manually
container.value.appendChild(renderer.domElement)
// Controls
controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
controls.enablePan = false
controls.minDistance = 5
controls.maxDistance = 80
// Lights (minimal; PointsMaterial is unlit, but lights help with any helpers)
const ambient = new THREE.AmbientLight(0xffffff, 0.2)
scene.add(ambient)
// A subtle starfield background
const stars = new THREE.Points(
new THREE.BufferGeometry().setAttribute(
'position',
new THREE.BufferAttribute(
new Float32Array(
Array.from({ length: 2000 * 3 }, () => (Math.random() - 0.5) * 400)
),
3
)
),
new THREE.PointsMaterial({ size: 0.6, color: 0xffffff, opacity: 0.5, transparent: true, depthWrite: false })
)
scene.add(stars)
// Galaxy
galaxyGroup = new THREE.Group()
scene.add(galaxyGroup)
// Higher particle counts with WebGPU, lower for WebGL for stability
const particleCount = isWebGPU ? 1_000_000 : 200_000
points = createGalaxy(particleCount)
galaxyGroup.add(points)
// Resize handling
const resize = () => {
if (!container.value || !camera || !renderer) return
const { clientWidth, clientHeight } = container.value
camera.aspect = Math.max(clientWidth, 1) / Math.max(clientHeight, 1)
camera.updateProjectionMatrix()
renderer.setSize(clientWidth, clientHeight, false)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
}
resize()
resizeObserver = new ResizeObserver(resize)
resizeObserver.observe(container.value)
// Animation loop
const clock = new THREE.Clock()
const tick = () => {
const elapsed = clock.getElapsedTime()
if (galaxyGroup) {
galaxyGroup.rotation.y = elapsed * 0.06
galaxyGroup.rotation.x = Math.sin(elapsed * 0.15) * 0.05
}
controls?.update()
if (renderer && scene && camera) {
renderer.render(scene, camera)
}
animationHandle = requestAnimationFrame(tick)
}
animationHandle = requestAnimationFrame(tick)
}
onMounted(() => {
init()
})
onBeforeUnmount(() => {
cancelAnimationFrame(animationHandle)
resizeObserver?.disconnect()
resizeObserver = null
controls?.dispose()
controls = null
if (points) {
const geometry = points.geometry as THREE.BufferGeometry
geometry.dispose()
const mat = points.material as THREE.Material
mat.dispose()
points.removeFromParent()
points = null
}
galaxyGroup?.clear()
galaxyGroup = null
if (renderer) {
renderer.dispose()
if (renderer.domElement && renderer.domElement.parentElement) {
renderer.domElement.parentElement.removeChild(renderer.domElement)
}
renderer = null
}
scene?.clear()
scene = null
camera = null
})
</script>
<template>
<div class="wrap">
<div ref="container" class="canvas-wrap" />
<p v-if="rendererType === 'webgl'" class="fallback-note">
WebGPU not available — running WebGL fallback. For best performance, try Chrome/Edge or Safari 17+.
</p>
</div>
</template>
<style scoped>
.wrap {
position: relative;
width: 100%;
height: 100%;
}
.canvas-wrap {
width: 100%;
height: 70vh;
max-height: 900px;
border-radius: 12px;
overflow: hidden;
background: #060608;
box-shadow: 0 10px 30px rgba(0,0,0,0.35);
}
.fallback-note {
margin-top: 8px;
font-size: 0.9rem;
color: #9aa0a6;
}
</style>
Key points:
- WebGPURenderer is used when navigator.gpu exists.
- We await renderer.init() for WebGPU before rendering.
- Particle count adapts to the renderer for stable performance.
4) Use the component on the home page
Create or edit pages/index.vue:
<script setup lang="ts">
</script>
<template>
<section style="padding: 24px;">
<h1>Nuxt 4 + Three.js WebGPU Galaxy</h1>
<p>Spiral galaxy rendered with WebGPU (or WebGL fallback).</p>
<GalaxyCanvas />
</section>
</template>
Nuxt auto-imports components from components/, so no import is necessary.
5) Dev run and verify
npm run dev
- Open http://localhost:3000
- If your browser supports WebGPU, you’ll get the WebGPU path and up to 1M particles.
- Otherwise you’ll see the fallback note and ~200k particles.
Tip: To verify which renderer is active, open DevTools > Console and run:
- document.querySelector('canvas')?.getContext('webgpu') // likely null (Three manages it internally)
- Or rely on the on-screen note in the component.
6) Production build and preview
npm run build
npm run preview
Open the preview URL and confirm rendering and performance. Production builds run faster thanks to Vite optimizations.
7) Optional tweaks
- Increase or decrease particleCount for your GPU. On WebGPU-capable machines you can often push 1.5–2 million particles.
- Tweak material.size for different visual densities.
- Add postprocessing (e.g., three/addons/postprocessing) — WebGPU supports many effects. Keep it client-only to avoid SSR import issues.
Browser support notes
- Chrome/Edge: WebGPU is enabled by default on supported hardware.
- Safari 17+: WebGPU enabled.
- Firefox: Enable webgpu in about:config (as of writing, still experimental).
If you consistently get the fallback on a supported browser, check:
- In Chrome: chrome://gpu (WebGPU section) and ensure it isn’t blocked by drivers.
- In private windows or strict modes, some features may be disabled.
Conclusion
You built a modern, GPU-accelerated galaxy renderer in Nuxt 4 with TypeScript, powered by Three.js’ WebGPURenderer and a clean WebGL fallback. This pattern lets you ship cutting-edge visuals while staying compatible with today’s browsers.
Have fun pushing this further — try multiple galaxies, animated arm noise, or compute-driven particle movement as WebGPU features evolve!