1. Basics of Three
  2. Simple rope with built-in classes
  3. Performant geometry for updates
  4. Animating
  5. Final result

I wanted to have some fun with Three JS so I started playing with the idea of having a way to animate ropes to demostrate how to tie knots.

Basics of Three

Three JS is an interface library to WebGL and allows to render scenes defined in JS. Three allows to control the data structures down to the level of the buffer that is sent to WebGL. Geometries abstract the definition of the shapes in the space by the means of coordinates of the vertices. Other than the vertices, it is necessary to define the edges and the faces that together make the mesh. A vertex is a point in space and is composed by three spatial coordinates, and three points define a face.

The flexibility of Three allows to use many different common shapes or build geometries from scratch, by defining vertices, faces and so on.

Simple rope with built-in classes

To draw a rope, it is possible to use the TubeGeometry. This kind of geometry builds a tube along a curve in the 3D space. It is easy and straightforward to create a shape from a curve, but the main limitation is that there is no easy way to animate the curve without instantiating a new geometry.

Allocation of new objects is expensive and the execution will stutter.

Performant geometry for updates

To address the performance problem with the out-of-the box geometry, it is possible to define a new type of geometry that operates in the same way as the TubeGeometry, with a difference: this new geometry should allow to be updated if the path changes, without having to be reinstantiated.

Introducing the AnimatedTubeGeometry: this geometry exposes a method updatePath that recalculates the vertices’ positions. In the naive implementation, the geometry computation logic is repeated every time the update method is called. The performance is still far from satisfactory, mostly because the vertex math makes use of dynamic arrays, that are very expensive in terms of resources. The issue can be tackled by using buffers, and the class BufferedAnimatedTubeGeometry does just that. The vertices’ update logic does not use dynamic memory in order to be performant, and we can instantiate the arrays (buffers) that contain the geometry information once in the constructor and update the elements afterwards. In fact, the number of the vertices and faces is defined by the inital call to the constructor of the geometry, and, afterwards, only the position will change. Moreover, between all the attributes that define the geometry, only vertices and normals will be affected by a curve update, whereas UVs and indices (face definitions) stay the same.

The following scene shows the comparison in terms of performance between the “naive” AnimatedTubeGeometry and BufferedAnimatedTubeGeometry. Click the button to switch geometry and see the difference in the rendering time.

One last details is to make sure to update the path in an optimized way. It does not make sense to redraw the path if the changes are not rendered. So the guidance would be to call updatePath only after the curve changes, but not more than once between two different visible frames.

Animating

Having prepared the framework for animated tubes in Three, we can then leverage Theatre JS to set up an animation studio in the browser. Theatre allows to build transitions of properties, and also set up a sequence with interpolations, easings and so on. The rope is build on top of the class CatmullRomCurve3, that interpolates a list of positions in the space to build a curve. Each one of this position is shown as a ball in the studio, and we can connect the spheres’ positions to properties of a Theatre JS object.

theatre.js props definition
1
2
3
4
5
const props = sheet.object(name, {
posX: i,
posY: 1,
posZ: 0,
});

We can use OrbitControls and TransformControls to interact with the objects in the scene. The following is the relevant part of the code that stores the positions in Theatre JS when they the objects are moved manually.

setting up interaction
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const orbitControls = new OrbitControls(camera, renderer.domElement);
const transformControls = new TransformControls(camera, renderer.domElement);

// We store an object name for each sphere so we can easily look up in a hash table
transformControls.addEventListener('objectChange', listener => {
const scrub = studio.debouncedScrub(1000);
const objName = listener.target.userData.selected;
if (nodes[objName] === undefined)
return;
const { props, mesh } = nodes[objName];
scrub.capture(({ set }) => {
set(props.props, {
posX: mesh.position.x,
posY: mesh.position.y,
posZ: mesh.position.z
})
});

// This is used in the rendering method to call the updatePath on the geometry
sceneState.geometryNeedsUpdate = true;
});

transformControls.addEventListener('dragging-changed', event => {
orbitControls.enabled = !event.value;
});

Object.entries(nodes).map(([, value]) => {
value.props.onValuesChange(newValues => {
value.mesh.position.set(newValues.posX, newValues.posY, newValues.posZ);
sceneState.geometryNeedsUpdate = true;
});
scene.add(value.mesh);
});

Final result

Here is the final result: use the button to toggle the animation.