Recap

In the previous article, we explored how to design your own Avatar for ThreeJS Metaverse with Animations.

None
Design Avatar
  • In this article, we will import this avatar.glb to our ThreeJS project.
  • Boiler Plate for this article is in GitHub branch if you have been following along then please use your own asset.
None
Folder Structure
  • Proceed to modify the following.

index.js

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>DONTKILLME METAVERSE</title>
    <link rel="stylesheet" href="./style.css" />
  </head>
  <body>
    <canvas class="threejs"></canvas>
    <script type="module" src="./index.js"></script>
    <div class="overlay"></div>
    <h1 class="loading">
      Loading Experience... <span id="progressPercentage"></span>%
    </h1>
    <button class="start">START</button>
  </body>
</html>

Camera.js

import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { sizesStore } from "./Utils/Store.js";

import App from "./App.js";

export default class Camera {
  constructor() {
    this.app = new App();
    this.canvas = this.app.canvas;

    this.sizesStore = sizesStore;

    this.sizes = this.sizesStore.getState();

    this.setInstance();
    this.setControls();
    this.setResizeListner();
  }

  setInstance() {
    this.instance = new THREE.PerspectiveCamera(
      35,
      this.sizes.width / this.sizes.height,
      1,
      600
    );
    this.instance.position.z = 100;
    this.instance.position.y = 20;
  }

  setControls() {
    this.controls = new OrbitControls(this.instance, this.canvas);
    this.controls.enableDamping = true;
  }

  setResizeListner() {
    this.sizesStore.subscribe((sizes) => {
      this.instance.aspect = sizes.width / sizes.height;
      this.instance.updateProjectionMatrix();
    });
  }

  loop() {
    this.controls.update();
    this.characterController = this.app.world.characterController?.rigidBody;
    if (this.characterController) {
      const characterRotation = this.characterController.rotation();
      const characterPosition = this.characterController.translation();

      const cameraOffset = new THREE.Vector3(0, 5, 20);
      cameraOffset.applyQuaternion(characterRotation);
      cameraOffset.add(characterPosition);

      const targetOffset = new THREE.Vector3(0, 2, 0);
      targetOffset.applyQuaternion(characterRotation);
      targetOffset.add(characterPosition);

      this.instance.position.lerp(cameraOffset, 0.1);
      this.controls.target.lerp(targetOffset, 0.1);
    }
  }
}

Renderer.js

import * as THREE from "three";
import App from "./App";
import { sizesStore } from "./Utils/Store";

export default class Renderer {
  constructor() {
    console.log("Renderer Init");
    this.app = new App();
    this.canvas = this.app.canvas;
    this.camera = this.app.camera;
    this.scene = this.app.scene;
    // Code to resize
    this.sizesStore = sizesStore;
    this.sizes = this.sizesStore.getState();

    this.setInstance();
    this.setResizeListner();
  }

  setInstance() {
    console.log("Renderer setInstance Called");

    this.instance = new THREE.WebGLRenderer({
      canvas: this.canvas,
      antialias: true,
    });
    this.instance.setSize(this.sizes.width, this.sizes.height);
    this.instance.setPixelRatio(Math.min(this.sizes.pixelRatio, 2));
    this.instance.outputEncoding = THREE.sRGBEncoding;
  }

  setResizeListner() {
    this.sizesStore.subscribe((sizes) => {
      this.instance.setSize(sizes.width, sizes.height);
      this.instance.setPixelRatio(sizes.pixelRatio);
    });
  }

  loop() {
    this.instance.render(this.scene, this.camera.instance);
  }
}

Preloader.js

import assetStore from "../Utils/AssetStore";
import { appStateStore } from "../Utils/Store";

export default class Preloader {
  constructor() {
    this.assetStore = assetStore;

    // Hiding the Screen before everything gets loaded
    this.overlay = document.querySelector(".overlay");
    this.loading = document.querySelector(".loading");
    this.startButton = document.querySelector(".start");

    this.assetStore.subscribe((state) => {
      // console.log("STATE", state.loadedAssets);
      this.numberOfLoadedAssets = Object.keys(state.loadedAssets).length;
      this.numberOfAssetsToLoad = state.assetsToLoad.length;
      this.progress = this.numberOfLoadedAssets / this.numberOfAssetsToLoad;
      // console.log("progress", this.progress);

      document.getElementById("progressPercentage").innerHTML = Math.trunc(
        this.progress * 100
      );

      if (this.progress === 1) {
        appStateStore.setState({ assetsReady: true });
        this.loading.classList.add("fade");
        window.setTimeout(() => this.ready(), 1200);
      }
    });
  }

  ready() {
    // remove the loading elelemnt from DOM
    this.loading.remove();

    this.startButton.style.display = "inline";
    this.startButton.classList.add("fadeIn");

    this.startButton.addEventListener(
      "click",
      () => {
        this.overlay.classList.add("fade");
        this.startButton.classList.add("fadeOut");

        // remove the overlay  and startButton elelemnt from DOM
        window.setTimeout(() => {
          this.overlay.remove();
          this.startButton.remove();
        }, 2000);
      },
      { once: true }
    );
  }
}

AssetStore.js

import { createStore } from "zustand/vanilla";

const assetsToLoad = [
  {
    id: "avatar",
    path: "/models/avatar.glb",
    type: "model",
  },
];

// addLoad function is just adding assets to loadedAssets object once they are loaded
const assetStore = createStore((set) => ({
  assetsToLoad,
  loadedAssets: {},
  addLoadedAsset: (asset, id) =>
    set((state) => ({
      loadedAssets: {
        ...state.loadedAssets,
        [id]: asset,
      },
    })),
}));
export default assetStore;

Store.js

import { createStore } from "zustand";

export const sizesStore = createStore(() => ({
  width: window.innerWidth,
  height: window.innerHeight,
  pixelRatio: Math.min(window.devicePixelRatio, 2),
}));

export const appStateStore = createStore(() => ({
  physicsReady: false,
  assetsReady: false,
}));

export const inputStore = createStore(() => ({
  forward: false,
  backward: false,
  left: false,
  right: false,
}));

AnimationController.js

import * as THREE from 'three';

import App from '../App';

export default class AnimationController {
    constructor() {
        this.app = new App();
        this.scene = this.app.scene;
        this.avatar = this.app.world.character.avatar;
    }
}

Character.js

import * as THREE from "three";
import assetStore from "../Utils/AssetStore.js";

import App from "../App.js";
export default class Character {
  constructor() {
    this.app = new App();
    this.scene = this.app.scene;
    this.assetStore = assetStore.getState();
    this.avatar = this.assetStore.loadedAssets.avatar;

    this.instantiateCharacter();
  }

  instantiateCharacter() {
    // create character and add to scene
    const geometry = new THREE.BoxGeometry(2, 5, 2);
    const material = new THREE.MeshStandardMaterial({
      color: 0x00ff00,
      wireframe: true,
      visible: false,
    });
    this.instance = new THREE.Mesh(geometry, material);
    this.instance.position.set(0, 4, 0);
    this.scene.add(this.instance);

    // add avatar to character
    const avatar = this.avatar.scene;
    avatar.rotation.y = Math.PI;
    avatar.position.y = -2.5;
    avatar.scale.setScalar(3);
    this.instance.add(avatar);
  }
}

CharacterController.js

// Import necessary modules
import * as THREE from "three";
import App from "../App.js";
import { inputStore } from "../Utils/Store.js";

/**
 * Class representing a character controller.
 */
export default class CharacterController {
  /**
   * Create a character controller.
   */
  constructor() {
    // Initialize app, scene, physics, and character properties
    this.app = new App();
    this.scene = this.app.scene;
    this.physics = this.app.world.physics;
    this.character = this.app.world.character.instance;

    // Subscribe to input store and update movement values
    inputStore.subscribe((state) => {
      this.forward = state.forward;
      this.backward = state.backward;
      this.left = state.left;
      this.right = state.right;
    });

    // Instantiate controller and create rigid body and collider
    this.instantiateController();
  }

  /**
   * Instantiate the character controller, rigid body, and collider.
   */
  instantiateController() {
    // Create a kinematic rigid body
    this.rigidBodyType =
      this.physics.rapier.RigidBodyDesc.kinematicPositionBased();
    this.rigidBody = this.physics.world.createRigidBody(this.rigidBodyType);

    // Create a cuboid collider
    this.colliderType = this.physics.rapier.ColliderDesc.cuboid(1, 2.5, 1);
    this.collider = this.physics.world.createCollider(
      this.colliderType,
      this.rigidBody
    );

    // Set rigid body position to character position
    const worldPosition = this.character.getWorldPosition(new THREE.Vector3());
    const worldRotation = this.character.getWorldQuaternion(
      new THREE.Quaternion()
    );
    this.rigidBody.setTranslation(worldPosition);
    this.rigidBody.setRotation(worldRotation);

    // Create character controller, set properties, and enable autostepping
    this.characterController =
      this.physics.world.createCharacterController(0.01);
    this.characterController.setApplyImpulsesToDynamicBodies(true);
    this.characterController.enableAutostep(5, 0.1, false);
    this.characterController.enableSnapToGround(1);
  }

  /**
   * Loop function that updates the character's position and movement.
   */
  loop() {
    // Initialize movement vector based on input values
    const movement = new THREE.Vector3();
    if (this.forward) {
      movement.z -= 1;
    }
    if (this.backward) {
      movement.z += 1;
    }
    if (this.left) {
      movement.x -= 1;
    }
    if (this.right) {
      movement.x += 1;
    }

    // Rotate character based on movement vector
    if (movement.length() !== 0) {
      const angle = Math.atan2(movement.x, movement.z) + Math.PI;
      const characterRotation = new THREE.Quaternion().setFromAxisAngle(
        new THREE.Vector3(0, 1, 0),
        angle
      );
      this.character.quaternion.slerp(characterRotation, 0.1);
    }

    // Normalize and scale movement vector and set y component to -1
    movement.normalize().multiplyScalar(0.3);
    movement.y = -1;

    // Update collider movement and get new position of rigid body
    this.characterController.computeColliderMovement(this.collider, movement);
    const newPosition = new THREE.Vector3()
      .copy(this.rigidBody.translation())
      .add(this.characterController.computedMovement());

    // Set next kinematic translation of rigid body and update character position
    this.rigidBody.setNextKinematicTranslation(newPosition);
    this.character.position.lerp(this.rigidBody.translation(), 0.1);
  }
}

World.js

import * as THREE from "three";

import App from "../App.js";
import Physics from "./Physics.js";
import Environment from "./Environment.js";
import Character from "./Character.js";
import CharacterController from "./CharacterController.js";
import AnimationController from "./AnimationController.js";

import { appStateStore } from "../Utils/Store.js";

export default class World {
  constructor() {
    this.app = new App();
    this.scene = this.app.scene;

    this.physics = new Physics();

    // create world classes
    const unsub = appStateStore.subscribe((state) => {
      if (state.physicsReady && state.assetsReady) {
        this.environment = new Environment();
        this.character = new Character();
        this.characterController = new CharacterController();
        this.animationController = new AnimationController();
        unsub();
      }
    });

    this.loop();
  }

  loop(deltaTime, elapsedTime) {
    this.physics.loop();
    if (this.characterController) this.characterController.loop();
  }
}

Run your App

None
OutPut
  • We removed the commented-out loading screen, as models now need to load during the initial loading phase.
  • Updated the character variable to characterController in the camera.js file.
  • Added sRGBEncoding to Renderer.js to improve outputEncoding efficiency.
  • Set assetsReady to true in the Preloader file once loading is complete.
  • Specified the models' path in AssetStore.js.
  • Introduced a new assetsReady key in the appStateStore.
  • Created an animationController file for use in the upcoming lesson.
  • Split the old character file into two separate files: character for defining mesh and avatar, and characterController for character control functions.
  • Updated world.js to import characterController and animationController, subscribing to them when both physics and assets are ready.

You can review the above description as you compare it with the changes in your VSCode.

None
Review Changes
  • You can push your character for a pull request. I'd love to see how it looks and interacts with it!
  • Compare the final code from this GitHub branch.

I hope this article has given you valuable insights and a solid starting point for your upcoming projects. If you found it helpful, consider showing your support by clapping and starring the GitHub repo. Your involvement in this learning journey truly means a lot!

Integrate Animations

None
Animations

Are you ready to elevate your Three.js skills? I've put together a series of articles that break down its core concepts. Initially crafted for my own learning, I'm thrilled to now share these resources with you. Happy exploring!