Surface Sampling in Three.js

One day I got lost in the Three.js documentation and I came across something called “MeshSurfaceSampler“. After reading the little information on the page, I opened the provided demo and was blown away!

What exactly does this class do? In short, it’s a tool you attach to a Mesh (any 3D object) then you can call it at any time to get a random point along the surface of your object.

The function works in two steps:

  1. Pick a random face from the geometry
  2. Pick a random point on that face

In this tutorial we will see how you can get started with the MeshSurfaceSampler class and explore some nice effects we can build with it.

💡 If you are the kind of person who wants to dig right away with the demos, please do! I’ve added comments in each CodePen to help you understand the process.

⚠ This tutorial assumes basic familiarity with Three.js

Creating a scene

The first step in (almost) any WebGL project is to first setup a basic scene with a cube.
In this step I will not go into much detail, you can check the comments in the code if needed.

We are aiming to render a scene with a wireframe cube that spins. This way we know our setup is ready.

⚠ Don’t forget to also load OrbitControls as it is not included in Three.js package.

// Create an empty scene, needed for the renderer
const scene = new THREE.Scene();
// Create a camera and translate it
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(1, 1, 2);

// Create a WebGL renderer and enable the antialias effect
const renderer = new THREE.WebGLRenderer({ antialias: true });
// Define the size and append the <canvas> in our document
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

// Add OrbitControls to allow the user to move in the scene
const controls = new THREE.OrbitControls(camera, renderer.domElement);

// Create a cube with basic geometry & material
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshBasicMaterial({
  color: 0x66ccff,
  wireframe: true
});
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);

/// Render the scene on each frame
function render () {  
  // Rotate the cube a little on each frame
  cube.rotation.y += 0.01;
  
  renderer.render(scene, camera);
}
renderer.setAnimationLoop(render);

See the Pen by Louis Hoebregts (@Mamboleoo) on CodePen.

Creating a sampler

For this step we will create a new sampler and use it to generate 300 spheres on the surface of our cube.

💡 Note that MeshSurfaceSampler is not built-in with Three.js. You can find it in the official repository, in the ‘examples’ folder.

Once you have added the file in your imported scripts, we can initiate a sampler for our cube.

const sampler = new THREE.MeshSurfaceSampler(cube).build();

This needs to be done only once in our code. If you want to get random coordinates on multiple meshes, you will need to store a new sampler for each object.

Because we will be displaying hundreds of the same geometry, we can use the InstancedMesh class to achieve better performance. Juste like a regular Mesh, we define the geometry (SphereGeometry for the demo) and a material (MeshBasicMaterial). After to have those two, you can pass them to a new InstancedMesh and define how many objects you need (300 in this case).

const sphereGeometry = new THREE.SphereGeometry(0.05, 6, 6);
const sphereMaterial = new THREE.MeshBasicMaterial({
 color: 0xffa0e6
});
const spheres = new THREE.InstancedMesh(sphereGeometry, sphereMaterial, 300);
scene.add(spheres);	

Now that our sampler is ready to be used, we can create a loop to define a random position and scale for each of our spheres.

Before we loop, we need two dummy variables for this step:

  • tempPosition is a 3D Vector that our sampler will update with the random coordinates
  • tempObject is a 3D Object used to define the position and scale of a sphere and generate a matrix from it

Inside the loop, we start by sampling a random point on the surface of our cube and store it into tempPosition.
Those coordinates are then applied to our tempObject.
We also define a random scale for the dummy object so that not every sphere will look the same.
Because we need the Matrix of the dummy object, we ask Three.js to update it.
Finally we add the updated Matrix of the object into our InstancedMesh’s own Matrix at the index of the sphere we want to move.

const tempPosition = new THREE.Vector3();
const tempObject = new THREE.Object3D();
for (let i = 0; i < 300; i++) {
  sampler.sample(tempPosition);
  tempObject.position.set(tempPosition.x, tempPosition.y, tempPosition.z);
  tempObject.scale.setScalar(Math.random() * 0.5 + 0.5);
  tempObject.updateMatrix();
  spheres.setMatrixAt(i, tempObject.matrix);
}	

See the Pen #1 Surface Sampling by Louis Hoebregts (@Mamboleoo) on CodePen.

Amazing isn’t it? With only a few steps we already have a working scene with random meshes along a surface.

Phew, let’s just take a breath before we move to more creative demos ✨

Playing with particles

Because everybody loves particles (I know you do), let’s see how we can generate thousands of them to create the feeling of volume only from tiny dots. For this demo, we will be using a Torus knot instead of a cube.

This demo will work with a very similar logic as for the spheres before:

  • Sample 15000 coordinates and store them in an array
  • Create a geometry from the coordinates and a material for Points
  • Combine the geometry and material into a Points object
  • Add them to the scene
/* Sample the coordinates */
const vertices = [];
const tempPosition = new THREE.Vector3();
for (let i = 0; i < 15000; i ++) {
  sampler.sample(tempPosition);
  vertices.push(tempPosition.x, tempPosition.y, tempPosition.z);
}

/* Create a geometry from the coordinates */
const pointsGeometry = new THREE.BufferGeometry();
pointsGeometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));

/* Create a material */
const pointsMaterial = new THREE.PointsMaterial({
  color: 0xff61d5,
  size: 0.03
});
/* Create a Points object */
const points = new THREE.Points(pointsGeometry, pointsMaterial);

/* Add the points into the scene */
scene.add(points);		

Here is the result, a 3D Torus knot only made from particles ✨
Try adding more particles or play with another geometry!

See the Pen #3 Surface Sampling by Louis Hoebregts (@Mamboleoo) on CodePen.

💡 If you check the code of the demo, you will notice that I don’t add the torus knot into the scene anymore. MeshSurfaceSampler requires a Mesh, but it doesn’t even have to be rendered in your scene!

Using a 3D Model

So far we have only been playing with native geometries from Three.js. It was a good start but we can take a step further by using our code with a 3D model!

There are many websites that provide free or paid models online. For this demo I will use this elephant from poly.pizza.

See the Pen #4 Surface Sampling by Louis Hoebregts (@Mamboleoo) on CodePen.

#1 Loading the .obj file

Three.js doesn’t have built-in loaders for OBJ models but there are many loaders available on the official repository.

Once the file is loaded, we will update its material with wireframe activated and reduce the opacity so we can see easily through.

/* Create global variable we will need for later */
let elephant = null;
let sampler = null;
/* Load the .obj file */
new THREE.OBJLoader().load(
  "path/to/the/model.obj",
  (obj) => {
    /* The loaded object with my file being a group, I need to pick its first child */
    elephant = obj.children[0];
    /* Update the material of the object */
    elephant.material = new THREE.MeshBasicMaterial({
      wireframe: true,
      color: 0x000000,
      transparent: true,
      opacity: 0.05
    });
    /* Add the elephant in the scene */
    scene.add(obj);
    
    /* Create a surface sampler from the loaded model */
    sampler = new THREE.MeshSurfaceSampler(elephant).build();

    /* Start the rendering loop */ 
    renderer.setAnimationLoop(render);
  }
);	

#2 Setup the Points object

Before sampling points along our elephant we need to setup a Points object to store all our points.

This is very similar to what we did in the previous demo, except that this time we will define a custom color for each point. We are also using a texture of a circle to make our particles rounded instead of the default square.

/* Used to store each particle coordinates & color */
const vertices = [];
const colors = [];
/* The geometry of the points */
const sparklesGeometry = new THREE.BufferGeometry();
/* The material of the points */
const sparklesMaterial = new THREE.PointsMaterial({
  size: 3,
  alphaTest: 0.2,
  map: new THREE.TextureLoader().load("path/to/texture.png"),
  vertexColors: true // Let Three.js knows that each point has a different color
});
/* Create a Points object */
const points = new THREE.Points(sparklesGeometry, sparklesMaterial);
/* Add the points into the scene */
scene.add(points);	

#3 Sample a point on each frame

It is time to generate the particles on our model! But you know what? It works the same way as on a native geometry 😍

Since you already know how to do that, you can check the code below and notice the differences:

  • On each frame, we add a new point
  • Once the point is sampled, we update the position attribute of the geometry
  • We pick a color from an array of colors and add it to the color attribute of the geometry
/* Define the colors we want */
const palette = [new THREE.Color("#FAAD80"), new THREE.Color("#FF6767"), new THREE.Color("#FF3D68"), new THREE.Color("#A73489")];
/* Vector to sample a random point */
const tempPosition = new THREE.Vector3();

function addPoint() {
  /* Sample a new point */
  sampler.sample(tempPosition);
  /* Push the point coordinates */
  vertices.push(tempPosition.x, tempPosition.y, tempPosition.z);
  /* Update the position attribute with the new coordinates */
  sparklesGeometry.setAttribute("position", new THREE.Float32BufferAttribute(vertices, 3)  );
  
  /* Get a random color from the palette */
  const color = palette[Math.floor(Math.random() * palette.length)];
  /* Push the picked color */
  colors.push(color.r, color.g, color.b);
  /* Update the color attribute with the new colors */
  sparklesGeometry.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3));
}

function render(a) {
  /* If there are less than 10,000 points, add a new one*/
  if (vertices.length < 30000) {
    addPoint();
  }
  renderer.render(scene, camera);
}		

Animate a growing path

A cool effect we can create using the MeshSurfaceSampler class is to create a line that will randomly grow along the surface of our mesh. Here are the steps to generate the effect:

  1. Create an array to store the coordinates of the vertices of the line
  2. Pick a random point on the surface to start and add it to your array
  3. Pick another random point and check its distance from the previous point
    1. If the distance is short enough, go to step 4
    2. If the distance is too far, repeat step 3 until you find a point close enough
  4.  Add the coordinates of the new point in the array
  5. Update the line geometry and render it
  6. Repeat steps 3-5 to make the line grow on each frame

The key here is the step 3 where we will pick random points until we find one that is close enough. This way we won’t have two points across the mesh. This could work for a simple object (like a sphere or a cube) as all the lines will stay inside the object. But think about our elephant, what if we have a point connected from the trunk to one of the back legs. You will end up with lines where there should be ’empty’ spaces.

Check the demo below to see the line coming to life!

See the Pen #5 Surface Sampling by Louis Hoebregts (@Mamboleoo) on CodePen.

For this animation, I’m creating a class Path as I find it a cleaner way if we want to create multiple lines. The first step is to setup the constructor of that Path. Similar to what we have done before, each path will require 4 properties:

  1. An array to store the vertices of the line
  2. The final geometry of the line
  3. A material specific for Line objects
  4. A Line object combining the geometry and the material
  5. The previous point Vector
/* Vector to sample the new point */
const tempPosition = new THREE.Vector3();
class Path {
  constructor () {
    /* The array with all the vertices of the line */
    this.vertices = [];
    /* The geometry of the line */
    this.geometry = new THREE.BufferGeometry();
    /* The material of the line */
    this.material = new THREE.LineBasicMaterial({color: 0x14b1ff});
    /* The Line object combining the geometry & the material */
    this.line = new THREE.Line(this.geometry, this.material);
    
    /* Sample the first point of the line */
    sampler.sample(tempPosition);
    /* Store the sampled point so we can use it to calculate the distance */
    this.previousPoint = tempPosition.clone();
  }
}		

The second step is to create a function we can call on each frame to add a new vertex at the end of our line. Within that function we will execute a loop to find the next point for the path.
When that next point is found, we can store it in the vertices array and in the previousPoint variable.
Finally, we need to update the line geometry with the updated vertices array.

class Path {
  constructor () {...}
  update () {
    /* Variable used to exit the while loop when we find a point */
    let pointFound = false;
    /* Loop while we haven't found a point */
    while (!pointFound) {
      /* Sample a random point */
      sampler.sample(tempPosition);
      /* If the new point is less 30 units from the previous point */
      if (tempPosition.distanceTo(this.previousPoint) < 30) {
        /* Add the new point in the vertices array */
        this.vertices.push(tempPosition.x, tempPosition.y, tempPosition.z);
        /* Store the new point vector */
        this.previousPoint = tempPosition.clone();
        /* Exit the loop */
        pointFound = true;
      }
    }
    /* Update the geometry */
    this.geometry.setAttribute("position", new THREE.Float32BufferAttribute(this.vertices, 3));
  }
}

function render() {
  /* Stop the progression once we have reached 10,000 points */
  if (path.vertices.length < 30000) {
    /* Make the line grow */
    path.update();
  }
  renderer.render(scene, camera);
}		

💡 The value of how short the distance between the previous point and the new one depends on your 3D model. If you have a very small object, that distance could be ‘1’, with the elephant model we are using ’30’.

Now what?

Now that you know how to use MeshSurfaceSampler with particles and lines, it is your turn to create funky demos with it!
What about animating multiple lines together or starting a line from each leg of the elephant, or even popping particles from each new point of the line. The sky is the limit ⛅

See the Pen #6 Surface Sampling by Louis Hoebregts (@Mamboleoo) on CodePen.

This article does not show all the available features from MeshSurfaceSampler. There is still the weight property that allows you to have more or less chance to have a point on some faces. When we sample a point, we could also use the normal or the color of that point for other creative ideas. This could be part of a future article one day… 😊

Until next time, I hope you learned something today and that you can’t wait to use that new knowledge!

If you have questions, let me know on Twitter.