Skip to content
➜cat blog/threejs-terrain-hero-lessons.md

Three.js on the Web: Lessons from Building a Terrain Hero

What I learned shipping a real-time 3D terrain as a page hero: GPU budgets, mobile fallbacks, and when WebGL is worth it.

11 min

The hero on this portfolio is a Three.js terrain, a procedurally generated mesh that responds to mouse movement and animates on load. Building it was straightforward. Shipping it without destroying performance on mid-range phones was the actual challenge.

Why a 3D Hero

I wanted the landing page to feel distinct. Most developer portfolios use either a text-heavy hero or a CSS gradient. A real-time 3D element creates a sense of craft that's hard to replicate with 2D techniques.

The brief I gave myself: a subtle terrain that suggests depth without dominating the page. No loading screens, no "rotate your device" warnings, no framerate drops that make the rest of the site feel sluggish.

The Setup

The terrain is a PlaneGeometry with displaced vertices. The displacement uses layered Perlin noise:

const geometry = new THREE.PlaneGeometry(10, 10, 128, 128)
const vertices = geometry.attributes.position.array

for (let i = 0; i < vertices.length; i += 3) {
  const x = vertices[i]
  const z = vertices[i + 1]

  vertices[i + 2] =
    noise2D(x * 0.3, z * 0.3) * 1.2 +
    noise2D(x * 0.8, z * 0.8) * 0.4 +
    noise2D(x * 2.0, z * 2.0) * 0.1
}

Three octaves of noise: large features for the overall shape, medium for ridges, small for surface detail. The multipliers control amplitude at each scale.

The material is a custom ShaderMaterial with a gradient based on height, darker valleys and lighter peaks. No textures, no complex shading. The visual impact comes from the geometry, not the material.

The Performance Problem

On my M2 MacBook, it ran at 120fps. On a Pixel 6, it managed 24fps and made the page scroll feel like it was underwater.

The issues, in order of impact:

  1. Vertex count. 128×128 segments = 16,641 vertices. On mobile GPUs, displacing this many vertices per frame was too expensive.
  2. Render resolution. Rendering at device pixel ratio 3 (common on modern phones) meant 3x the pixels.
  3. Animation loop. requestAnimationFrame was running at full speed even when the terrain wasn't visible.

The Fixes

Adaptive geometry. Detect GPU capability and reduce segments:

const isMobile = /Android|iPhone|iPad/.test(navigator.userAgent)
const segments = isMobile ? 64 : 128
const geometry = new THREE.PlaneGeometry(10, 10, segments, segments)

64×64 is 4,225 vertices, 75% fewer. The visual difference on a phone screen is imperceptible.

Capped pixel ratio. Never render above 2:

renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))

The quality difference between 2x and 3x is invisible to most people. The performance difference is not.

Intersection Observer for the render loop. Only run the animation when the hero is visible:

const observer = new IntersectionObserver(
  ([entry]) => {
    isVisible = entry.isIntersecting
  },
  { threshold: 0 }
)
observer.observe(canvas)

function animate() {
  requestAnimationFrame(animate)
  if (!isVisible) return
  renderer.render(scene, camera)
}

Once the user scrolls past the hero, the render loop becomes a no-op. GPU usage drops to zero.

Resize debouncing. The renderer doesn't need to resize on every pixel of a window drag:

let resizeTimeout: number
window.addEventListener('resize', () => {
  clearTimeout(resizeTimeout)
  resizeTimeout = window.setTimeout(() => {
    camera.aspect = container.clientWidth / container.clientHeight
    camera.updateProjectionMatrix()
    renderer.setSize(container.clientWidth, container.clientHeight)
  }, 150)
})

Mouse Interaction

The terrain tilts slightly toward the cursor, a subtle parallax effect that adds life without feeling gimmicky.

document.addEventListener('mousemove', (e) => {
  const x = (e.clientX / window.innerWidth - 0.5) * 2
  const y = (e.clientY / window.innerHeight - 0.5) * 2

  targetRotation.x = y * 0.05
  targetRotation.y = x * 0.05
})

function animate() {
  mesh.rotation.x += (targetRotation.x - mesh.rotation.x) * 0.03
  mesh.rotation.y += (targetRotation.y - mesh.rotation.y) * 0.03
}

The lerp factor (0.03) controls how quickly the terrain follows the mouse. Lower values feel smoother but less responsive. I landed on 0.03 after trying everything from 0.01 (too laggy) to 0.1 (too twitchy).

On touch devices, I disable mouse interaction entirely. Tilting a terrain with touch events feels wrong. The metaphor doesn't translate.

When WebGL Is Worth It

After building this, my honest assessment: WebGL on a portfolio is worth it only if the 3D element serves the design. A terrain hero works because it creates atmosphere without requiring interaction. A 3D model viewer, a particle system, or a full scene would be overkill for a portfolio because they demand attention and create loading overhead.

The rule I'd give to other developers: if you have to explain what the 3D element adds, it probably doesn't add enough. It should feel like a natural part of the page, not a tech demo.

Cleanup in Vue

Three.js doesn't garbage collect itself. In a Nuxt app, unmounting the component without cleanup leaks GPU memory:

onUnmounted(() => {
  renderer.dispose()
  geometry.dispose()
  material.dispose()
  observer.disconnect()
  scene.clear()
})

Every dispose() matters. I've seen portfolios that leak 200MB+ of GPU memory across page navigations because the Three.js scene was never torn down. In an SPA, this accumulates until the tab crashes.