Create a 3D Outline Animation with Three.js

Last Updated On 7 Feb 2020 by

In this tutorial, we create a 3D scene with an animated character and an outline post-processing effect.

Prepare the 3D Model

From Blender to Three via glTF

Frontend Setup

Stage3D - now we’re coding!

Model Animation

Lighting and Shadows

Post-Processing and Outline Effect

Prepare the 3D Model

For this tutorial we need an animated 3D model to play with. As the process of creating a model from scratch is a whole different tutorial, we will use an open source model designed by @quaternius from one of the most awesome creative communities on the Web: OpenGameArt.org

Next we need to use a 3D modeling software to edit and re-export the model to glTF Web format. So if you haven’t already go ahead and install Blender 3D - it’s insanely well made, super powerful and free! I’ll be using Blender 2.8 in this tutorial so be aware the UI might look a bit different when you read this.<

If it’s your first time using Blender and you’re feeling overwhelmed: don’t panic! Not only there are plenty of resources out there. You can also skip this entire step by just downloading the .glb file and go to the next section

So let’s go ahead and download the 3D model zip file. Once the archive is extracted, navigate to the blender directory and open the .blend.

  • First we open the workspace in animator mode by clicking the Animation button at the top of the screen:
Blender UI - workspace selection
  • Next at the bottom left of your screen click on the initially timeline icon and select Dope Sheet menu:

Blender UI - Dope Sheet Menu

  • On the right side of the previous drop down you should now see another drop down, go ahead and select Action Editor

Blender UI - Action Editor Menu

  • And yep, another dropdown just appeared on the right side, not confusing at all I know… You can now see the different animations available, select them and edit them as you like. What i did for my demo is deleting the other animations as I only need the Run one and would prefer a smaller file size.

Blender UI - Animations Menu

  • Now go back to the timeline menu again by clicking on the Dope Sheet icon on the bottom left of your screen and select the Timeline menu:

Blender UI - Timeline Menu

  • On the right side of this menu, we want to set the first and last frame number of our animation. This is imprtant as Blender needs to know how many animation farmes to export. The Run animation has 16 frames in total [0-15], but because the first and last frames are the same we will loop from 1 to 15.

Blender UI - Frame Range Menu

From Blender to Three via GLTF

glTF (GL Transmission Format) is a format commonly used on the web; it reduces the size of 3D models and the runtime processing needed to unpack and render those models.

  • In blender you can export the model to glTF format from the File menu:
Blender UI - glTF export
  • The default settings are good to go, just make sure the animation is enabled:
Blender UI - Animation Settings

That’s it, we now have a runner.glb ready to be loaded in our Three.js Web application.

Frontend Setup

Because I am used to work with Vue js and really enjoy it, i tend to use the vue-cli for other non-vue projects. It handles the webpack config for me, provides a great dev and release workflow. Feel free to use whatever workflow you are comfortable with or just follow along with my very opinionated version:

  • Let’s first install the CLI
npm i --global @vue/cli
  • We use vue-cli to generate the project base. Make sure to select SCSS post processor option (using the space bar)
vue create some-project-name
  • The project is created and vue cli already has installed the dependencies for us (no need to run npm install again), all we have left to do is enter the directory and add three.js to the project.
cd some-project-name
npm i three

Ready to dev!

Open the project in VisualStudioCode (i mean you don’t have to but it’s just a nice IDE if you haven’t tried it already) then start the dev server:

code .
npm run serve

Cleanup default files

Because we’re actually not going to be using Vue in this project we can delete the following:

  • components/ directory

  • App.vue

  • delete the code inside main.js

  • delete the vue logo in the assets directory (or feel free to keep it if you love Vue like i do)

Create main CSS layout file styles.scss.

We’re using a paper texture as a background image and resize it to fit the width and height of the viewport (using 100vw and 100vh). The canvas fills the screen while maintaining a 2% margin that matches the background image and makes the canvas fit right over the sheet of paper in a responsive manner.

Although it is based on the entire viewport, it could easily be refactored to be fitting within any container.

body {
  margin: 0;
  padding: 0;
  border: none;
  position: relative;
  width: 100vw;
  min-height: 100vh;
  background-image: url(./paper.jpg);
  background-repeat: no-repeat;
  background-position: center;
  background-size: 100% 100%;
  *,
  *:focus {
    outline: none;
  }
  canvas {
    margin: 0;
    padding: 0;
    border: none;
    width: 96vw;
    min-height: 96vh;
    position: absolute;
    top: 2%;
    left: 2%;
    background: transparent;
    user-select: none;
  }
}

Back to main.js.

This is the entry point of the app, when the page loads, the first thing we need to do is loading the 3D model using the GLTFLoader provided by Three.js examples. Once ready we launch a custom class Stage3D that we will use later on to manage the 3D scene.

// import SCSS spritesheet for basic layout setup
import "./style.scss";
// The loader library is provided by Threejs
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader";
// This is a custom class we will create next to manage the 3D scene
import Stage3D from "./Stage3D";

/** Load a GLTF file for Threejs */
function loadGLTF(modelName) {
  const loader = new GLTFLoader().setPath(process.env.BASE_URL + "models/");
  return new Promise((accept) => {
    loader.load(
      modelName,
      (gltf) => {
        accept(gltf);
      },
      (xhr) => {
        // loadingProgress = xhr.loaded / xhr.total
      }
    );
  });
}

/** Once the assets are loaded we launch the app */
function start(gltf) {
  const container = document.querySelector("#app");
  const stage = new Stage3D(container, gltf);
}

/** Let's go! */
loadGLTF("running-man.glb").then(start);

Stage3D - now we’re coding!

This main class will contain the usual three js boilerplate code:

  • Import dependencies

  • Initialize

    • create a camera, a scene and a renderer

    • create a floor mesh to project the shadow using ShadowMaterial

    • create a human mesh and add to the scene

    • Add OrbitControls to be able to drag the scene with mouse handle scene resize event handle the loop

Today we will go the extra mile by adding the following:

  • Initialize a DirectionalLight to generate shadows

  • Setup a composer post-processing pipeline to draw the human outline

import {
  Clock,
  DirectionalLight,
  PerspectiveCamera,
  Scene,
  Vector2,
  WebGLRenderer,
  Mesh,
  PlaneGeometry,
  ShadowMaterial,
} from "three";
import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass";
import Human from "./Human";
import { OutlinePass } from "./OutlinePass";

export default class Stage3D {
  constructor(container, gltf) {
    this.container = container;

    this.createEngine();
    this.createLights();
    this.initPostProcessing();

    this.human = new Human(gltf);
    this.scene.add(this.human.mesh);
    // let the outline pass know which element to draw
    this.outlinePass.selectedObjects = [this.human.mesh];

    this.floor = new Mesh(
      new PlaneGeometry(1000, 1000, 1, 1),
      new ShadowMaterial({
        color: 0x000066,
      })
    );
    this.floor.rotateX(-Math.PI / 2);
    this.floor.castShadow = false;
    this.floor.receiveShadow = true;
    this.scene.add(this.floor);

    // Enable user controls
    this.controls = new OrbitControls(this.camera, this.renderer.domElement);

    // trigger resize event to make sure everything is resized and ready
    this.handleWindowResize();

    // start looping
    this.loop();
  }

  // here use arrow function to give the right scope to the callback
  loop = () => {
    window.requestAnimationFrame(this.loop);
    this.controls.update();
    const delta = Math.max(0, Math.min(1, this.clock.getDelta()));
    this.human.update(delta, this.clock.elapsedTime);
    this.composer.render();
  };

  createEngine() {
    this.scene = new Scene();

    const aspectRatio = this.stageWidth / this.stageHeight;
    const fieldOfView = 40;
    const nearPlane = 1;
    const farPlane = 1500;
    this.camera = new PerspectiveCamera(
      fieldOfView,
      aspectRatio,
      nearPlane,
      farPlane
    );

    this.renderer = new WebGLRenderer({
      antialias: true, // smoother edges
      alpha: true, // this setting allows transparency
    });
    // support higher res displays
    this.renderer.setPixelRatio(window.devicePixelRatio);
    // transparent background
    this.renderer.setClearColor(0xffffff, 0);
    // enable shadows
    this.renderer.shadowMap.enabled = true;
    // append the canvas element to the div#app created by vue-cli (container)
    this.container.appendChild(this.renderer.domElement);
    // watch for screen resize
    window.addEventListener("resize", this.handleWindowResize, false);
  }

  handleWindowResize = () => {
    const width = window.innerWidth * 0.96; // + 2% margin on both sides in CSS
    const height = window.innerHeight * 0.96; // + 2% margin on both sides in CSS
    // alternatively the canvas could be contained in an element
    // const {width, height} = this.renderer.domElement.getBoundingClientRect()
    this.stageWidth = width;
    this.stageHeight = height;
    this.renderer.setSize(this.stageWidth, this.stageHeight);
    this.camera.aspect = this.stageWidth / this.stageHeight;
    this.camera.updateProjectionMatrix();
    // update the size for the composer as well
    this.composer.setSize(this.stageWidth, this.stageHeight);
  };

  createLights() {
    // see - 6 - Lighting and Shadows
  }

  initPostProcessing() {
    // see - 7 - Post-Processing and Outline Effect
  }
}

Model Animation

The Human class is a container for the human mesh and its animation tracks. the code below gets initialized with the glTF payload and initiates the ’Run’ animation.

Here we have an challenging rendering problem because we want to generate a shadow from a transparent outline. The trick is to enable transparency on the mesh and set the opacity to 0, This way the model is not visible on the scene meanwhile the shadow is rendered properly.

Also important when working with animation is to enable skinning!

The function fadeToAction allows transitioning from one animation track to another. You can find a more extended example in threejs skinning morph example page. In this demo we’re only using the Run animation track so all we got left to do is to set it once and loop it forever.

import { AnimationMixer, LoopOnce, LoopRepeat, MeshBasicMaterial } from "three";

export default class Human {
  constructor(gltf) {
    this.mesh = gltf.scenes[0].children[0];

    this.mesh.traverse((child) => {
      if (child.isMesh) {
        child.material = new MeshBasicMaterial({
          skinning: true,
          transparent: true,
          opacity: 0,
        });
        child.castShadow = true;
        child.receiveShadow = true;
      }
    });

    this.animationMixer = new AnimationMixer(this.mesh);
    this.actions = {};
    for (var i = 0; i < gltf.animations.length; i++) {
      var clip = gltf.animations[i];
      var action = this.animationMixer.clipAction(clip);
      this.actions[clip.name] = this.actions[clip.name] || action;
    }

    this.fadeToAction("Run", 0, LoopRepeat, 0.75, 1, true);
  }

  fadeToAction(
    name,
    duration,
    loop = LoopOnce,
    timeScale = 1,
    weight = 1,
    clampWhenFinished = true
  ) {
    this.previousAction = this.activeAction;
    this.activeAction = this.actions[name];
    if (this.previousAction && this.previousAction !== this.activeAction) {
      this.previousAction.fadeOut(duration);
    }
    this.activeAction.loop = loop;
    this.activeAction.reset();
    this.activeAction.clampWhenFinished = clampWhenFinished;
    this.activeAction.setEffectiveTimeScale(timeScale);
    this.activeAction.setEffectiveWeight(weight);
    this.activeAction.fadeIn(duration);
    this.activeAction.play();
  }

  update(delta, lifetime) {
    this.animationMixer.update(delta);
  }
}

Lighting and Shadows

Lighting a scene in Three.js would most often require light types such as HemisphereLight or AmbientLight. However in this project the canvas is transparent, all we care about really is to be able to generate a shadow from the running model.

So we use a DirectionalLight which shines in a specific direction and all its produced rays are parallel.

Depending on the size of your scene, you may need to adjust the shadowLight shadow camera settings and mapSize to adjust performance against higher resolution.

createLights () {
  this.shadowLight = new DirectionalLight(0xffffff, 1)

  this.shadowLight.position.set(15, 15, 10)
  this.shadowLight.lookAt(0,0,0)

  // Allow shadow casting
  this.shadowLight.castShadow = true

  // define the visible area of the projected shadow
  this.shadowLight.shadow.camera.left = -20
  this.shadowLight.shadow.camera.right = 20
  this.shadowLight.shadow.camera.top = 20
  this.shadowLight.shadow.camera.bottom = -20

  this.shadowLight.shadow.camera.near = 1
  this.shadowLight.shadow.camera.far = 100

  // while increasing the size will improve quality,
  // it will also decrease performance
  this.shadowLight.shadow.mapSize.width = 512
  this.shadowLight.shadow.mapSize.height = 512

  this.scene.add(this.shadowLight)
}

Post-Processing and Outline Effect

The code for this example is inspired from this three.js example

The composer initialization is pretty straightforward:

  • create the effect composer

  • create the main render pass and add it to the composer

  • create the outlinePass and add it to the composer

initPostProcessing () {
  // init the postprocessing pipeline
  this.composer = new EffectComposer(this.renderer)
  // main render pass for the 3d model and shadows
  const renderPass = new RenderPass(this.scene, this.camera)
  // add the render pass to composer pipeline
  this.composer.addPass(renderPass)
  // outline pass
  this.outlinePass = new OutlinePass(new Vector2(window.innerWidth * 0.96, window.innerHeight * 0.96), this.scene, this.camera)
  // we only want the outline on the human model, not the floor
  this.outlinePass.selectedObjects = [this.scene]
  // add the outline pass to composer pipeline
  this.composer.addPass(this.outlinePass)
}

If you’re looking into the original three.js example you may notice the MaskMaterial uses a custom ShaderManterial instance which must enable skinning for the animation to work, see it in the code

getPrepareMaskMaterial () {
  return new ShaderMaterial({
    skinning: true,
    uniforms: ...,
    fragmentShader: ...,
    vertexShader: ...,
  })
}

That’s it for now

Chekcout the demo or the source code on github

Hope you enjoyed this tutorial, if you have any question or comment, please use the section below or reach out on twitter. Enjoy!

About The Author

Headshot of Michael Iriarte aka Mika

Hi, I'm Michael aka Mika. I'm a software engineer with years of experience in frontend development. Thank you for visiting tips4devs.com I hope you learned something fun today! You can follow me on Twitter, see some of my work on GitHub, or read more about me on my website.