GTM No Script

Creating dynamic
digital experiences
with WebGL [Part 2]

Intro

This is Part II into our extended look into WebGL. If you haven’t already, go check out Part I in which we provide an intro on WebGL and briefly describe how to render some graphics in the browser using WebGL. 

Read more

In this article, we would like to talk more about common optimization approaches that might help you to create smooth interactive graphics in the browser. 

 

WebGL optimization techniques

There are many approaches to performance optimization in computer graphics. Some of them depend on specifics of the particular task, some are more generic.

The most frequently used optimization techniques in WebGL include the following: 

  1. Transfer of calculations from javascript code to shaders.
  2. Optimization of rendering of a large number of objects.

Now we would like to demonstrate these optimization techniques using the example of one of Star’s joint projects with the Design Institute for Health at The University of Texas at Austin, namely, the Interactive Health Tool. 

How to perform calculations on GPU

Animating objects is one of the common use-cases in computer graphics. For example, when you want to, move some objects or transform their geometry or color, etc.

Similar tasks are often solved directly in JavaScript due to the simplicity of implementation. However, this can be a weak point in the entire application performance.

We will now show you an example of a simple animation where objects are moving at different speeds along some paths in space.

Calculation of coordinates as a function of time looks quite simple:

const easingValue = (1 + Math.sin(Math.PI * (t - 0.5))) / 2

this.shardsGroup.children.forEach((mesh) => {
    const { initialPosition, explosionVector, rotationSpeed, rotationAxes } = mesh

    mesh.position.set(
        initialPosition.add(explosionVector.multiplyScalar(easingValue))
    )

    mesh.rotation[rotationAxes[0]] += rotationSpeed
    mesh.rotation[rotationAxes[1]] += rotationSpeed
})

However, we should keep in mind that all these calculations will be performed for each frame and they depend on the number of animated objects.

What does the animation look like before optimization? To demonstrate this, let’s increase objects count to about 2500 objects, each consisting of 4 vertices.

As you can see, the frame rate is quite low and the animation doesn’t look smooth. In the browser profiler, you can see that the CPU is almost completely loaded (green chart shows frames per second 0-60 and yellow one is the CPU load 0-100%).

In order to reduce the CPU load, we will transfer the animation to the graphics processing unit (GPU) using vertex attributes and uniform variables.

mat4 rotationMartix = makeRotationMatrixY(uRotation);

mat3 internalRotationMartix = makeInternalRotationMatrix(
    uInternalRotation * uInternalRotationFactor, rotationAxes
);

vec3 explosionDelta = explosionDestination * easeInOutSin(uExplosionElapsedTime);

vec4 vertexPositionTransformed =
    modelViewMatrix * vec4(
        internalRotationMartix * position + explosionDelta + initialPosition, 1.0
    );

gl_Position = projectionMatrix * vertexPositionTransformed;

The transformed object coordinates are calculated according to the following data:

  • explosionDelta – a translation vector used to determine the current position of an object in each moment of time.
  • initialPosition – initial position of the object in the world coordinates. 
  • internalRotationMatrix – a matrix used to rotate an object.

WebGL uses matrix calculus. This is not that difficult but may take some time to adapt. Here is an example of creating the rotation matrices that we used above to transform the coordinates of animated objects:

mat3 makeInternalRotationMatrix(float theta, vec3 axes) {

    float cX = cos(theta * axes.x);
    float cY = cos(theta * axes.y);
    float cZ = cos(theta * axes.z);
    float sX = sin(theta * axes.x);
    float sY = sin(theta * axes.y);
    float sZ = sin(theta * axes.z);

    return mat3(
        cY * cZ, cY * sZ, -sY,
        sX * sY * cZ - cX * sZ, sX * sY * sZ + cX * cZ, sX * cY,
        cX * sY * cZ + sX * sZ, cX * sY * sZ - sX * cZ, cX * cY
    );
}

mat4 makeRotationMatrixY(float theta) {

    float c = cos(theta);
    float s = sin(theta);

    return mat4(
        c, 0., -s, 0.,
        0., 1., 0., 0.,
        s, 0., c, 0.,
        0., 0., 0., 1.
    );
}

On this video you can see the result of our optimization after transferring calculations to the shaders.
Now the animation is much smoother, but the frame rate is still far from 60 FPS.

Optimize “Lots of Objects” rendering

Handing a large number of objects is a significant impact on performance. 

Render calls are performed for each object that needs to be drawn and are quite resource intensive. Therefore, it is always advisable to minimize the number of draw calls. This can be achieved through combining similar objects by merging their geometries.

This approach slightly changes the way data is conveyed to shaders, for example, in transforming the coordinates of each part of such an aggregate object. In particular, uniform variables are no longer suitable for this purpose since their values ​​are common to all vertices.

In this case, we use attributes that enable us to set data on each vertex separately. Let us compare the code that sets the coordinates of objects before and after merging geometries. Passing coordinates to shaders using uniform variables:

const setMeshVectorUniform = (mesh, vector, uniformName) => {
    mesh.material.uniforms[uniformName] = {
        value: vector
    }
}
And using attributes:
const setGeometryVectorAttribute = (geometry, attributeName, vector) => {
    const vertexAttributesCount = geometry.attributes.position.array.length

    const attributeValuesArray = new Float32Array(vertexAttributesCount).fill(0)

    for (let i = 0; i < vertexAttributesCount; i = i + 3) {
        attributeValuesArray[i] = vector[0]
        attributeValuesArray[i + 1] = vector[1]
        attributeValuesArray[i + 2] = vector[2]
    }

    geometry.setAttribute(
        attributeName,
        new BufferAttribute(attributeValuesArray, 3),
    )
}

The second method might look a bit complicated, but it provides a more efficient way to work with geometry data.

The result of the optimization described above can be seen in the video. The result is as follows. As you can see, we managed to achieve 60 FPS and the animation is now smooth enough. This also reduced CPU and GPU load drastically.

Conclusion

Using the approaches outlined in this article, you can use WebGL for a number of applications, including: 

  • Visualizing data, for example, cartographic, statistical or data obtained from various scanners used in medicine, robotics, etc.
  • Implementing complex design solutions.
  • Developing games.
  • Creating graphic editors.
  • Image processing.
  • Developing interactive presentations e.g. of manufacturers of automotive equipment or mobile devices.
  • Implementing virtual/augmented reality projects.

WebGL is a quite versatile tool which mainly requires a graphics processor and imagination.

See WebGL in action here on our recent collaboration with the Design Institute for Health in this case study.

Read more