November 16, 202510 min

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

Use Three.js’ new WebGPURenderer in a Nuxt 4 + TypeScript app to render a million-particle galaxy with a seamless WebGL fallback.

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

nuxt-three-webgpu

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!