Build an Interactive 3D Gallery with Blender, Three.js, and GSAP

Jul 07, 2026 392 views

Crafting a Scroll-Triggered 3D Gallery Using Blender, Three.js, and GSAP

Create an immersive, scroll-responsive 3D gallery featuring a Blender-crafted camera path, rendered via Three.js, and animated through GSAP.

In this guide, we’ll create a dynamic 3D gallery that conforms to a path designed in Blender. The gallery's images will be strategically placed along a curve, while the camera glides through the environment, responding to user scroll actions. As images approach the camera’s viewpoint, they will enlarge, creating a focus effect, while others remain at rest in the background.

The intended outcome resembles a fluid camera dolly shot. Each scroll moves the camera incrementally along its planned trajectory, fostering the sensation of navigating through a three-dimensional realm.

To accomplish this, we’ll deploy three main tools:

  • Blender: to design and export the camera path.
  • Three.js: to build the path within a 3D context and render the scene.
  • GSAP: to control the camera movements and ensure smooth image transitions.

Our inspiration stems from a remarkable digital piece produced by the creative studio BAXSTUDIO.

1. Crafting and Exporting the Camera Path in Blender

The journey begins in Blender, where we’ll create the curve for the camera’s path.

Start by inserting a curve (Add → Curve → Bezier), then switch to Edit Mode to modify its shape. The design of this curve is pivotal; whether it’s spiral, wavy, or sharply angled, each shape will provide a distinct experience. This creative choice is fundamental to our project.

Once satisfied with your design, export it as a JSON file for reconstruction within Three.js.

Exporting the Curve

Navigate to Blender’s Scripting workspace, create a new script (Scripting → New), and insert this Python code:

import bpy
import json

obj = bpy.context.active_object
depsgraph = bpy.context.evaluated_depsgraph_get()
obj_eval = obj.evaluated_get(depsgraph)

mesh = obj_eval.to_mesh()

points = []
for v in mesh.vertices:
    co = obj.matrix_world @ v.co
    points.append([round(co.x, 3), round(co.z, 3), round(-co.y, 3)])

obj_eval.to_mesh_clear()

path = "/your/path/path1.json"
with open(path, "w") as f:
    json.dump(points, f)

print("export completed")

One line in the script deserves special attention:

points.append([round(co.x, 3), round(co.z, 3), round(-co.y, 3)])

It's essential to recognize the different coordinate systems that Blender and Three.js utilize. While Blender has the Z axis aligned upward, Three.js employs the Y axis for vertical positioning. If we were to export the coordinates directly, the curve would be misaligned, leading to an erroneous camera movement through the gallery.

The following remapping adjusts Blender’s coordinates for compatibility with Three.js:

  • XX
  • ZY
  • Y-Z (the negative sign maintains proper orientation)

After the export, select the curve and run the script to create a JSON file that includes sampled points:

[[-27.559, 0.0, -0.0], [-27.56, 0.02, -0.022], [-27.56, 0.04, -0.044], ...]

Store the exported file in public/paths/path1.json. In the subsequent section, we’ll load this file into Three.js to recreate the curve.

2. Configuring the Scene and Reconstructing the Curve

Having exported the curve, it’s time to bring it to life in Three.js and initiate our scene.

Establishing the Scene

We’ll begin by creating the scene renderer, the environment, and the camera:

const renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.setSize(window.innerWidth, window.innerHeight)
document.getElementById('canvas-container').appendChild(renderer.domElement)

const scene = new THREE.Scene()
scene.background = new THREE.Color(0xffffff)
scene.fog = new THREE.Fog(0xffffff, 10, 40)

The fog isn't a mandatory feature, but it accentuates depth perception by subtly fading distant visuals into the backdrop.

Defining Scene Parameters

Next, we’ll set the parameters that will govern the scene:

const SCALE = 16 // Blender units multiplied by 16 for scene consistency
const TEX_VARIANTS = 12 // Count of images stored in /img folder
const textureLoader = new THREE.TextureLoader()
const textures = loadTextureVariants(TEX_VARIANTS, textureLoader)

const TOTAL = 500 // Overall number of planes along the curve
const CAM_Z = 10 // Z-axis camera offset
const FOCUS_DIST = 5.5 // Distance where scaling begins
const MAX_SCALE = 14 // The upper limit for scaling
const Z_GATE = 11 // Filters out distant planes to enhance performance

const LATERAL_OFFSET_RANGE = [-1, 1]
const DEPTH_OFFSET_RANGE = [-0.75, 0.75]
const SIZE_RANGE = [0.18, 0.4]

In the following steps, we will load the exported JSON and reconstruct the curve using THREE.CatmullRomCurve3:

function toScaledVector3([x, y, z], scale) {
  return new THREE.Vector3(x * scale, y * scale, z * scale)
}

function buildCurve(raw) {
  const points = raw.map(p => toScaledVector3(p, SCALE))
  return new THREE.CatmullRomCurve3(points, true, 'catmullrom', 0.5)
}

The THREE.CatmullRomCurve3 function accepts an array of THREE.Vector3 points, creating a smooth curve that traverses through each specified point. Key parameters include:

  • true - closes the curve, enabling a continuous loop for the camera.
  • 'catmullrom' - chooses the curve's interpolation method, ensuring smooth passing through all points.
  • 0.5 - adjusts the curve's tension, controlling the bend between points.

The exported JSON will be loaded asynchronously when initiating the application:

async function init() {
  const raw = await fetch('/paths/path1.json').then(r => r.json())
  const curve = buildCurve(raw)

Retrieving the Curve Data

To accurately position objects along the curve, we need the specific location and direction at any point along its path. This can be achieved using a utility function:

function getCurveFrame(curve, t) {
  const pos = curve.getPoint(t)
  const tangent = curve.getTangent(t)
  return { pos, nx: -tangent.y, ny: tangent.x }
}

The t variable, which ranges from 0 to 1, symbolizes progress along our curve. The method curve.getPoint(t) provides the specific position as a THREE.Vector3, while curve.getTangent(t) offers the directional flow of the curve.

To scatter the planes alongside the path effectively, we also have to determine a vector that’s perpendicular to the tangent. This normal, computed by shifting the tangent 90° within the XY plane, allows planes to track the curve's bends rather than remaining fixed along a single world axis.

3. Positioning Planes Along the Curve

With the curve defined, we can start filling the scene. We’ll generate 500 image planes, arrange them uniformly along the path, and randomly assign textures to each one.

Texture Management

Initially, we’ll load all texture variants:

const textureLoader = new THREE.TextureLoader()
const textures = loadTextureVariants(TEX_VARIANTS, textureLoader)

function loadTextureVariants(count, loader) {
  return Array.from({ length: count }, (_, i) => loader.load(`/img/picture${i + 1}.webp`))
}

By loading all textures once during startup, we can utilize them across all 500 planes without generating new textures each time. Each plane randomly selects a texture from this pool, which minimizes GPU memory consumption and loading times.

Constructing and Positioning the Planes

const planes = []

for (let i = 0; i < TOTAL; i++) {
  const t = i / TOTAL // Even distribution of planes along the curve

  const { pos, nx, ny } = getCurveFrame(curve, t) // Determine position and local normal

  // Apply random offsets for a natural distribution of objects
  const lateralOffset = randomBetween(...LATERAL_OFFSET_RANGE)
  const depthOffset = randomBetween(...DEPTH_OFFSET_RANGE)
  const size = randomBetween(...SIZE_RANGE)

  const mesh = new THREE.Mesh(
    new THREE.PlaneGeometry(size, size),
    new THREE.MeshBasicMaterial({
      map: textures[Math.floor(Math.random() * TEX_VARIANTS)], // Randomly assign texture
      side: THREE.DoubleSide,
    })
  )

  mesh.position.set(
    pos.x + nx * lateralOffset, // Lateral offset in the XY plane
    pos.y + ny * lateralOffset, // Lateral offset in the XY plane
    pos.z + depthOffset // Depth offset
  )

  mesh.userData.t = t
  mesh.userData.lateralOffset = lateralOffset
  mesh.userData.depthOffset = depthOffset
  mesh.userData.setScale = createScaleAnimator(mesh) // Attach GSAP animator for smooth scaling

  planes.push(mesh)
  scene.add(mesh)
}

These planes are not placed haphazardly along the curve; their initial positioning relies on t = i / TOTAL, ensuring systematic distribution from start to finish. Random elements are incorporated only for lateral and depth offsets, in addition to size, creating an organic layout without noticeable gaps or clumping.

Using the curve’s local normal (nx, ny) keeps plane distribution perpendicular to the path, preserving natural alignment through all curve bends.

Establishing the Scale Animator

Each plane is assigned a dedicated scaling animator, built utilizing gsap.quickTo():

function createScaleAnimator(mesh) {
  const proxy = { value: 1 }
  return gsap.quickTo(proxy, 'value', {
    duration: 0.4,
    ease: 'power3.out',
    onUpdate: () => mesh.scale.setScalar(proxy.value),
  })
}

The gsap.quickTo() function provides a reusable mechanism to adjust properties to specified values. This design prevents the overhead of creating a new tween during every scaling event, facilitating smooth transitions for each plane without overloading system resources.

The proxy serves as an intermediary value for GSAP’s animation. As updates occur, we copy proxy.value to the mesh’s scale using mesh.scale.setScalar(). This setup ensures that our animation logic is decoupled from the Three.js object while attaining seamless scaling.

4. Scroll-Responsive Camera Movement

Once the scene is established, all that remains is to animate the camera. Rather than handling it in absolute world coordinates, we’ll have it traverse along the curve based on user scroll input.

Concept Overview

The camera’s position is tracked by a single variable, t, ranging from 0 at the start of the curve to 1, facilitating a continuous loop given the closed nature of the curve.

Instead of altering t directly in response to scroll actions, we maintain two separate variables:

  • targetT: updated immediately with every scroll.
  • camProxy.t: a smoothed variant of targetT, animated with GSAP.

This approach allows for immediate camera response to user input while curtailing abrupt movements through the scene.

const camProxy = { t: 0 }
const setCamT = gsap.quickTo(camProxy, 't', { duration: 1, ease: 'power3.out' })

let targetT = 0
const SENSITIVITY = 1 / (window.innerHeight * 4)

SENSITIVITY determines how much camera movement corresponds to a scroll action. Modulating by window.innerHeight ensures uniform interaction across different display sizes, allowing a full viewport scroll to yield consistent camera movement irrespective of the device.

Utilizing GSAP Observer for Scroll Input

We’ll implement GSAP’s Observer plugin to capture and respond to user scroll actions:

Observer.create({
  target: window,
  type: 'wheel,touch,pointer',
  onChange: (self) => {
    targetT += self.deltaY * SENSITIVITY
    setCamT(targetT)
  },
})

The Observer simplifies input handling across various devices, normalizing events such as wheel, touch, and pointer. Instead of coding distinct responses for these inputs, we receive a consistent deltaY value, streamlining our scroll logic for both desktops and touch screens alike.

Whenever input events occur, we update targetT and call setCamT(). Instead of making direct jumps, the GSAP function enables smooth transitions of camProxy.t, producing a natural camera glide along the curve.

Animating the Camera

Within the animate() loop, transforming the current camProxy.t value into a 3D position happens here:

Final Thoughts on the Interactive Experience

This project showcases that the backbone of dynamic visual storytelling rests in simple, yet powerful principles. A singular curve, meticulously crafted in Blender, becomes the heart of an entire interactive gallery. As you navigate through these spaces, you'll see how each component—Three.js for rendering, GSAP for animation—plays its part like a finely-tuned orchestra.

What stands out here is the emphasis on creative control. For anyone working in this space, the ability to modify just the path in Blender and witness a completely transformed viewer experience in real-time is a testament to the flexibility of modern web technologies. It’s not just about technical execution; it's about harnessing available tools to bring visions to life in vivid, engaging ways.

That said, the reliance on the curve also raises questions. How often can a single design be adjusted before it feels repetitive? The potential for variation exists, but creativity will be the ultimate driver pushing the boundaries of what can be achieved within this framework. Your experience will hinge on how imaginatively the path is conceived and exploited, hinting at a future where interactivity and storytelling converge more seamlessly than ever.

If you’re keen on crafting your own interactive experiences, consider how foundational elements like these can shift the entire narrative. With tools at your disposal, the possibilities are only as limited as your imagination. As the boundaries between creativity and technology blur, expect to see more of these immersive experiences that challenge traditional storytelling forms.

Source: Gaspard Hedde · tympanus.net

Comments

Sign in to comment.
No comments yet. Be the first to comment.

Related Articles

Building a Scroll-Driven 3D Gallery Using a Blender Camer...