Building a 3D Library with Three.js Nodejs Library and Interactive Elements

Build a reusable 3D library using Three.js and Node.js with Vite, GLTF model loading, OrbitControls, and mouse raycasting interactivity.

Building a 3D Library with Three.js

Interactive 3D experiences are no longer reserved for video games and standalone desktop software. From interactive e-commerce product configurators to data visualization dashboards and architectural walkthroughs, 3D rendering in the browser has become a standard requirement for modern web applications.

However, writing raw Three.js code directly in your application components quickly leads to boilerplate spaghetti: repetitive scene initialization, manual resize listeners, clunky loader callbacks, and fragmented event handling.

In this tutorial, you will build a modular, reusable 3D graphics library powered by Three.js and modern Node.js tooling. You’ll learn how to structure a clean, object-oriented scene manager, load GLTF/GLB 3D models with modern async/await promises, implement smooth orbit camera controls, add interactive pointer raycasting, and bundle your library for distribution via npm.


1. Introduction: Modern 3D Web Development with Three.js

Three.js is the most widely adopted WebGL abstraction layer in the JavaScript ecosystem. It provides an intuitive scene graph, camera abstractions, shader materials, and post-processing pipelines.

To make Three.js clean and maintainable across multiple projects, we will encapsulate its primitives into an extensible library with three core goals:

  1. Zero-Boilerplate Setup: Initialize a complete canvas with camera, lights, and responsive resizing in a single method call.
  2. Standardized Model Loading: Provide clean async/await loading for industry-standard GLTF/GLB assets.
  3. Event-Driven Interactivity: Seamlessly translate 2D mouse clicks into 3D raycasting collisions.

2. Setting Up the Node.js Development Environment with Vite

We will use Node.js (version 18 or higher) and Vite as our build tool. Vite provides lightning-fast Hot Module Replacement (HMR) during development and seamless library bundling via Rollup.

Open your terminal, create a new directory, and initialize a Node.js project:

mkdir threejs-3d-library
cd threejs-3d-library
npm init -y

Install Three.js as a runtime dependency:

npm install three

Next, install Vite as a development dependency:

npm install -D vite

Update your package.json to enable ES Modules and define our development and build scripts:

{
  "name": "threejs-3d-library",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/threejs-3d-library.umd.cjs",
  "module": "./dist/threejs-3d-library.js",
  "exports": {
    ".": {
      "import": "./dist/threejs-3d-library.js",
      "require": "./dist/threejs-3d-library.umd.cjs"
    }
  },
  "files": [
    "dist"
  ],
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  }
}

3. Creating the Core Three.js Scene Manager

Let’s build the foundation: a SceneManager class that creates the WebGL renderer, perspective camera, scene graph, and animation loop.

Create src/SceneManager.js:

import * as THREE from 'three';

export class SceneManager {
  constructor(containerElement, options = {}) {
    this.container = containerElement;
    this.options = {
      antialias: true,
      alpha: true,
      clearColor: 0x111827, // Dark slate
      ...options,
    };

    this.scene = new THREE.Scene();
    this.camera = null;
    this.renderer = null;
    this.animationCallbacks = [];
    this.isRunning = false;

    this.init();
  }

  init() {
    const width = this.container.clientWidth || window.innerWidth;
    const height = this.container.clientHeight || window.innerHeight;

    // 1. Perspective Camera (FOV, Aspect Ratio, Near, Far)
    this.camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 1000);
    this.camera.position.set(0, 2, 5);

    // 2. WebGL Renderer
    this.renderer = new THREE.WebGLRenderer({
      antialias: this.options.antialias,
      alpha: this.options.alpha,
      powerPreference: 'high-performance',
    });
    this.renderer.setSize(width, height);
    this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
    this.renderer.setClearColor(this.options.clearColor);
    this.renderer.shadowMap.enabled = true;
    this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;

    this.container.appendChild(this.renderer.domElement);

    // 3. Responsive Resize Listener
    window.addEventListener('resize', this.onWindowResize.bind(this));

    // 4. Start Render Loop
    this.start();
  }

  onWindowResize() {
    if (!this.container || !this.renderer || !this.camera) return;

    const width = this.container.clientWidth || window.innerWidth;
    const height = this.container.clientHeight || window.innerHeight;

    this.camera.aspect = width / height;
    this.camera.updateProjectionMatrix();
    this.renderer.setSize(width, height);
  }

  add(object) {
    this.scene.add(object);
  }

  remove(object) {
    this.scene.remove(object);
  }

  onAnimate(callback) {
    this.animationCallbacks.push(callback);
  }

  start() {
    if (this.isRunning) return;
    this.isRunning = true;

    const animate = (time) => {
      if (!this.isRunning) return;
      requestAnimationFrame(animate);

      // Execute custom frame callbacks
      for (const callback of this.animationCallbacks) {
        callback(time);
      }

      this.renderer.render(this.scene, this.camera);
    };

    requestAnimationFrame(animate);
  }

  stop() {
    this.isRunning = false;
  }

  destroy() {
    this.stop();
    window.removeEventListener('resize', this.onWindowResize.bind(this));
    this.renderer.dispose();
    if (this.renderer.domElement.parentNode) {
      this.renderer.domElement.parentNode.removeChild(this.renderer.domElement);
    }
  }
}

4. Lighting and Environment Setup

Without proper illumination, 3D meshes render as flat, solid silhouettes. Let’s create a helper module to configure physically realistic lighting.

Create src/Lighting.js:

import * as THREE from 'three';

export class LightingSetup {
  static createDefault(scene) {
    // Soft ambient fill light
    const ambientLight = new THREE.AmbientLight(0xffffff, 0.7);
    scene.add(ambientLight);

    // Primary directional key light (casts soft shadows)
    const dirLight = new THREE.DirectionalLight(0xffffff, 1.2);
    dirLight.position.set(5, 10, 7);
    dirLight.castShadow = true;
    dirLight.shadow.mapSize.width = 2048;
    dirLight.shadow.mapSize.height = 2048;
    dirLight.shadow.camera.near = 0.5;
    dirLight.shadow.camera.far = 25;
    dirLight.shadow.bias = -0.0001;

    scene.add(dirLight);

    // Subtle blue rim light for contrast
    const rimLight = new THREE.DirectionalLight(0x60a5fa, 0.5);
    rimLight.position.set(-5, 5, -5);
    scene.add(rimLight);

    return { ambientLight, dirLight, rimLight };
  }
}

5. Loading 3D Models (GLTF/GLB) with GLTFLoader

The GLTF (GL Transmission Format) standard is known as the “JPEG of 3D”. It packs geometry, textures, animations, and materials into an efficient binary format (.glb).

In modern Three.js, add-on modules like loaders are bundled directly inside the official three package under three/addons/ (or three/examples/jsm/).

Create src/ModelLoader.js:

import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

export class ModelLoader {
  constructor() {
    this.loader = new GLTFLoader();
  }

  /**
   * Asynchronously loads a GLTF or GLB model.
   * @param {string} url - Path or URL to the .gltf / .glb asset
   * @param {Function} [onProgress] - Optional progress callback
   * @returns {Promise<THREE.Group>}
   */
  async loadModel(url, onProgress = null) {
    return new Promise((resolve, reject) => {
      this.loader.load(
        url,
        (gltf) => {
          const model = gltf.scene;

          // Enable shadows on all child meshes
          model.traverse((child) => {
            if (child.isMesh) {
              child.castShadow = true;
              child.receiveShadow = true;
            }
          });

          // Center model at local origin (0, 0, 0)
          const box = new THREE.Box3().setFromObject(model);
          const center = box.getCenter(new THREE.Vector3());
          model.position.sub(center);

          resolve(model);
        },
        (xhr) => {
          if (onProgress && xhr.total > 0) {
            onProgress(xhr.loaded / xhr.total);
          }
        },
        (error) => {
          reject(new Error(`Failed to load 3D model at [${url}]: ${error.message}`));
        }
      );
    });
  }
}

6. Implementing Smooth Camera Controls with OrbitControls

To let users orbit, pan, and zoom around the 3D model, we integrate OrbitControls from three/addons/controls/OrbitControls.js.

Create src/Controls.js:

import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

export class ControlsManager {
  constructor(camera, domElement, options = {}) {
    this.controls = new OrbitControls(camera, domElement);

    // Enable smooth physics damping (inertia)
    this.controls.enableDamping = true;
    this.controls.dampingFactor = 0.05;

    // Configure distance limits
    this.controls.minDistance = 1;
    this.controls.maxDistance = 20;

    // Constrain vertical rotation (prevent flipping under ground)
    this.controls.maxPolarAngle = Math.PI / 2 + 0.1;

    // Apply any custom overrides
    Object.assign(this.controls, options);
  }

  update() {
    this.controls.update();
  }

  destroy() {
    this.controls.dispose();
  }
}

7. Adding Raycasting and Pointer Interactivity

To enable user interaction—such as clicking on a 3D object to inspect it or hovering over parts—we use Raycasting.

In Three.js, raycasting requires transforming screen pixel coordinates into Normalized Device Coordinates (NDC), where $X$ ranges from $-1$ (left) to $+1$ (right), and $Y$ ranges from $-1$ (bottom) to $+1$ (top):

(clientX, clientY) ===> NDC: x = (x / width) * 2 - 1, y = -(y / height) * 2 + 1

Create src/InteractionManager.js:

import * as THREE from 'three';

export class InteractionManager {
  constructor(scene, camera, domElement) {
    this.scene = scene;
    this.camera = camera;
    this.domElement = domElement;

    this.raycaster = new THREE.Raycaster();
    this.mouse = new THREE.Vector2();
    this.clickableObjects = [];

    this.onObjectClick = null;
    this.onObjectHover = null;
    this.hoveredObject = null;

    this.bindEvents();
  }

  registerInteractiveObject(object) {
    this.clickableObjects.push(object);
  }

  bindEvents() {
    this.domElement.addEventListener('pointermove', this.onPointerMove.bind(this));
    this.domElement.addEventListener('click', this.onClick.bind(this));
  }

  updateMouseCoordinates(event) {
    const rect = this.domElement.getBoundingClientRect();
    this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
    this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
  }

  onPointerMove(event) {
    this.updateMouseCoordinates(event);
    this.raycaster.setFromCamera(this.mouse, this.camera);

    const intersects = this.raycaster.intersectObjects(this.clickableObjects, true);

    if (intersects.length > 0) {
      const topIntersect = intersects[0];
      if (this.hoveredObject !== topIntersect.object) {
        this.hoveredObject = topIntersect.object;
        this.domElement.style.cursor = 'pointer';
        if (this.onObjectHover) this.onObjectHover(this.hoveredObject, topIntersect);
      }
    } else {
      if (this.hoveredObject !== null) {
        this.hoveredObject = null;
        this.domElement.style.cursor = 'default';
        if (this.onObjectHover) this.onObjectHover(null, null);
      }
    }
  }

  onClick(event) {
    this.updateMouseCoordinates(event);
    this.raycaster.setFromCamera(this.mouse, this.camera);

    const intersects = this.raycaster.intersectObjects(this.clickableObjects, true);

    if (intersects.length > 0 && this.onObjectClick) {
      this.onObjectClick(intersects[0].object, intersects[0]);
    }
  }

  destroy() {
    this.domElement.removeEventListener('pointermove', this.onPointerMove.bind(this));
    this.domElement.removeEventListener('click', this.onClick.bind(this));
  }
}

8. Assembling and Testing the Complete 3D Library

Now, unite all the modules into the primary entry point: src/index.js.

import { SceneManager } from './SceneManager.js';
import { LightingSetup } from './Lighting.js';
import { ModelLoader } from './ModelLoader.js';
import { ControlsManager } from './Controls.js';
import { InteractionManager } from './InteractionManager.js';

export {
  SceneManager,
  LightingSetup,
  ModelLoader,
  ControlsManager,
  InteractionManager,
};

export class ThreeApp {
  constructor(container, options = {}) {
    this.sceneManager = new SceneManager(container, options);
    this.lights = LightingSetup.createDefault(this.sceneManager.scene);
    this.controls = new ControlsManager(this.sceneManager.camera, this.sceneManager.renderer.domElement);
    this.loader = new ModelLoader();
    this.interactions = new InteractionManager(
      this.sceneManager.scene,
      this.sceneManager.camera,
      this.sceneManager.renderer.domElement
    );

    // Update orbit controls on every animation frame
    this.sceneManager.onAnimate(() => {
      this.controls.update();
    });
  }

  async loadModel(url) {
    const model = await this.loader.loadModel(url);
    this.sceneManager.add(model);
    this.interactions.registerInteractiveObject(model);
    return model;
  }

  destroy() {
    this.interactions.destroy();
    this.controls.destroy();
    this.sceneManager.destroy();
  }
}

To test during development, create index.html:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>3D Library Demo</title>
    <style>
      body, html { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; }
      #canvas-container { width: 100vw; height: 100vh; }
    </style>
  </head>
  <body>
    <div id="canvas-container"></div>
    <script type="module">
      import { ThreeApp } from './src/index.js';
      import * as THREE from 'three';

      const container = document.getElementById('canvas-container');
      const app = new ThreeApp(container);

      // Create a test cube to verify rendering immediately
      const geometry = new THREE.BoxGeometry(1.5, 1.5, 1.5);
      const material = new THREE.MeshStandardMaterial({ color: 0x3b82f6, roughness: 0.3 });
      const cube = new THREE.Mesh(geometry, material);
      cube.castShadow = true;
      app.sceneManager.add(cube);
      app.interactions.registerInteractiveObject(cube);

      app.interactions.onObjectClick = (obj) => {
        obj.material.color.setHex(Math.random() * 0xffffff);
      };
    </script>
  </body>
</html>

Run Vite’s local dev server:

npm run dev

Open the displayed localhost URL in your browser. You will see an interactive 3D cube with soft lighting and orbit controls. Clicking the cube dynamically randomizes its color via raycasting!


9. Packaging and Publishing the Library as an npm Module

To package your library for distribution, create vite.config.js:

import { resolve } from 'path';
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    lib: {
      entry: resolve(__dirname, 'src/index.js'),
      name: 'ThreeJs3DLibrary',
      fileName: (format) => `threejs-3d-library.\${format}.js`,
    },
    rollupOptions: {
      // Ensure peer dependency 'three' is not bundled into the output
      external: ['three'],
      output: {
        globals: {
          three: 'THREE',
        },
      },
    },
  },
});

Compile the distribution bundles:

npm run build

This outputs clean ESM (dist/threejs-3d-library.es.js) and UMD bundles into dist/. Other developers can now install your package via npm and import your 3D library in any React, Vue, Angular, or vanilla JavaScript application:

import { ThreeApp } from 'threejs-3d-library';

const app = new ThreeApp(document.getElementById('product-view'));
await app.loadModel('/models/sneaker.glb');

Frequently Asked Questions

How do I install OrbitControls in modern Three.js?

OrbitControls is included in the official three package under three/addons/controls/OrbitControls.js. You do not need to install third-party packages like @three/orbit-controls; simply install three and import OrbitControls directly.

Why should I use GLTFLoader instead of THREE.Loader?

THREE.Loader is an abstract base class that cannot load assets directly. GLTFLoader is a concrete loader designed for GLTF/GLB files, the industry standard format that bundles geometry, PBR materials, animations, and textures.

How does Raycasting work in Three.js?

Raycasting converts 2D screen coordinates into Normalized Device Coordinates (NDC) between -1 and +1, then casts an invisible 3D ray from the camera through the scene. Intersected 3D objects are returned in order of distance.

Why externalize Three.js in the Vite library build config?

Marking Three.js as an external dependency in rollupOptions prevents bundling the entire Three.js library into your package distribution, avoiding duplicate bundle overhead and version conflicts in consuming applications.

How can I test my npm package locally before publishing?

You can run npm link inside your library root directory, and then run npm link threejs-3d-library in a consumer test application to test the packaged module without publishing to the public registry.

Comments

comments