DEV Community

Cover image for Adding Trackball Controls to a Three.js Scene with Sprites
Patrick Hund
Patrick Hund

Posted on • Edited on

Adding Trackball Controls to a Three.js Scene with Sprites

I'm building a social media network and collaboration tool based on mind maps, documenting my work in this series of blog posts. Follow me if you're interested in what I've learned along the way about building web apps with React, Tailwind CSS, Firebase, Apollo/GraphQL, three.js and TypeScript.

Today's Goal

In the previous parts of this series, I've built a mind map with three.js and React using a force-directed graph.

Some commenters have (justifiably) asked why I have to use a fully-fledged 3D rendering library to just draw a mind map – I might have just as well drawn it on a 2D canvas, or just used SVG or even DOM nodes to achieve the same without jumping through the hoops of texturing, calculating camera angles, rendering sprites, etc.

Well, my vision for the project is to have a fluid, animated user experience – my mind map should always be in motion, just like the thoughts in our minds never stop moving.

Today I want to add the ability to zoom, rotate and pan my mind map to take an important step in that direction and actually achieve a level of interactiveness that only a 3D model can provide.

Three.js Trackball Controls

Most of the examples on the three.js use a library called “Trackball Controls” to add zooming, rotating and panning to the demos. It is not really a library, more a plain old JavaScript program that someone wrote that gets copied around all over the place. The “official file” is found in the examples directory of the three.js source code.

This is “old school” JavaScript, using global variables without any support for module import, but luckily, someone wrapped it up in an npm package and added some helpful info on how to use it:

I add this library to my mind map demo and “hook it up” by adding some code to my initializeScene function:

function initializeScene(div) {
  const canvas = createCanvas(window.innerWidth, window.innerHeight);
  const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
  div.appendChild(renderer.domElement);

  const scene = new THREE.Scene();
  scene.background = new THREE.Color(0xffffff);

  const camera = new THREE.PerspectiveCamera(
    50,
    window.innerWidth / window.innerHeight,
    0.1,
    500000
  );
  camera.position.z = 1.5;

  const controls = new TrackballControls(camera, renderer.domElement);

  return { scene, renderer, camera, controls };
}
Enter fullscreen mode Exit fullscreen mode

In addition to scene, renderer and camera, the function now also exports the controls object.

I'm using this in my renderMindMap function to update the camera position inside my animate loop, like so:

(function animate() {
  graph.tickFrame();
  controls.update();
  renderer.render(scene, camera);
  requestAnimationFrame(animate);
})();
Enter fullscreen mode Exit fullscreen mode

As a result, I can now zoom, rotate and pan my 3D mind map – that was easy!

Try it out yourself here:

  • To zoom, use the mouse wheel (or two fingers trackpad swipe up/down on Macs)
  • To pan (move the viewport), right-click and drag the mouse
  • To rotate, left-click and drag the mouse

Looks Good, but Not Great!

What still bothers me at this point is that the connecting lines between the mind map nodes overlap the nodes when I rotate the mind map, making the text labels hard to read and just looking wonky overall.

The Fix

StackOverflow to the rescue:

If you want some objects to render "on top", or "in front", one trick is to create two scenes -- the first scene is your regular scene, and the second scene contains the objects that you want to have on top.

First, set:

renderer.autoClear = false

Then create two scenes:

The trick is to manipulate the renderOrder property of the mind map node sprites (kinda like the Z-index in CSS), then call clearDepth on the renderer before each sprite is rendered.

Here is my updated code that renders the mind map nodes in my function renderMindMap:

data.nodes = await Promise.all(
  data.nodes.map((node) =>
    renderToSprite(<MindMapNode label={node.name} level={node.level} />, {
      width: 128,
      height: 64
    }).then((sprite) => {
      sprite.renderOrder = 999;
      sprite.onBeforeRender = (renderer) => renderer.clearDepth();
      return { ...node, sprite };
    })
  )
);
Enter fullscreen mode Exit fullscreen mode

This has the desired effect – I can rotate my mind map to my heart's content and the connecting lines never overlap the mind map nodes:

Try it out:

To Be Continued…

I'm planning to turn my mind map into a social media network and collaboration tool and will continue to blog about my progress in follow-up articles. Stay tuned!

Top comments (0)