Three.jsWebGLJavaScriptPortfolio3D

Building an Isometric 3D Portfolio Gallery with Three.js

How I built the interactive 3D portfolio grid on this site using Three.js — covering scene setup, texture optimization, raycasting for hover/click, and the performance patterns that keep it smooth.

Peter Csipkay · · 4 min read

The portfolio section on this site isn’t a carousel or a grid of images — it’s a real-time 3D scene rendered via WebGL. Cards float in an isometric layout, lift on hover, and fly toward the camera on click. Here’s how it’s built.

Scene Overview

The gallery runs on a 10×12 grid of planes (120 cards total), each showing a project screenshot as a WebGL texture. Because there are only 17 unique projects, each card repeats ~7 times across the grid — giving the scene depth and visual richness without additional assets.

The stack:

  • Three.js for scene, geometry, materials and lighting
  • GSAP for smooth animations (hover lift, click-to-center, entry wave)
  • IntersectionObserver to initialize and pause rendering based on visibility
  • Custom ShaderMaterial for each card with diffuse lighting and environment reflection

Geometry: Shared Rounded Rectangles

All 120 cards share a single ShapeGeometry instance — this is important. Creating 120 separate geometries would waste memory and GPU bandwidth. Instead:

const geometry = createRoundedRectGeometry(width, height, 0.15)
// All meshes use the same geometry reference
const mesh = new THREE.Mesh(geometry, material)

The rounded corner shape is built with THREE.Shape using quadraticCurveTo for each corner, then UV coordinates are remapped manually so textures fill the card correctly.

Texture Loading: The Stutter Problem

The naive approach — calling new THREE.TextureLoader().load(url) inside forEach — creates 120 separate load requests and 120 GPU uploads. Each upload causes a frame drop as the GPU stalls to process the new texture.

The fix is a texture cache: pre-load all 17 unique images first, then build meshes only after everything is in GPU memory.

const textureCache = new Map()
const uniqueImages = [...new Set(projects.map(p => p.image))]
let loaded = 0

uniqueImages.forEach(url => {
  const texture = textureLoader.load(url, () => {
    if (++loaded === uniqueImages.length) buildMeshes()
  })
  texture.minFilter = THREE.LinearFilter
  texture.magFilter = THREE.LinearFilter
  textureCache.set(url, texture)
})

buildMeshes() only runs when all 17 textures are decoded and uploaded. The scroll-in animation starts from this callback — so the first frame the user sees already has all images loaded.

Custom Shader: Lighting Without a PBR Pipeline

Each card uses a ShaderMaterial rather than MeshStandardMaterial. This gives full control over the lighting equation at a fraction of the GPU cost, since PBR materials run expensive BRDF calculations that aren’t needed for flat image planes.

The fragment shader combines:

  • Diffuse term from a directional light (softened with pow(diff, 0.8))
  • Environment reflection from a Cloudinary-hosted HDR equirectangular map
  • Fresnel edge highlight for a subtle glass-like edge glow
  • Back-face brightening so cards seen from behind (during the click animation) stay visible

Hover and Click with Raycasting

Three.js raycasting converts the mouse position (in NDC space) to a ray in 3D, then finds intersections with the card meshes:

this.raycaster.setFromCamera(mouse, camera)
const hits = this.raycaster.intersectObjects(this.items.children)

On hover: the hovered card lifts 0.3 units on Y with a GSAP tween (0.8s, power2.out).

On click: the card flies toward the camera using a calculated target position, rotates to face the viewer, and scales to 2×. All other cards fade to 20% opacity. A GSAP animation drives each transform simultaneously.

Performance: Idle Rendering

The render loop only runs when needed. When there’s no user interaction, no animation in progress, and no camera movement, the loop pauses after a 1.2-second idle timeout:

if (!stillAnimating && !cameraMoving && !holdActive 
    && now - this.lastActive > this.idleTimeout) {
  this.stopRendering()
  return
}

IntersectionObserver handles viewport visibility — rendering stops when the section scrolls out of view and resumes when it comes back. This matters on pages with long scroll depth where the portfolio section might be far from the active viewport.

Entry Animation: Wave from Center

When the portfolio section first scrolls into view, cards rise from below in a wave pattern radiating from the grid center. The delay for each card is proportional to its Manhattan distance from center:

const distanceFromCenter = Math.abs(col - centerCol) + Math.abs(row - centerRow)
const delay = distanceFromCenter * 0.12

Cards closer to the center appear first, creating a ripple effect outward. Each card also gets an elastic scale-in (elastic.out(1, 0.4)) for a physical feel.


The full source is part of this portfolio site. If you’re building something similar and have questions, feel free to reach out.

Written by Peter Csipkay — Creative Frontend Developer, Munich-Starnberg. Get in touch →

← Back to Blog

© 2015 - 2026 All rights reserved.