How to Create a Three.js Mini-Map Using Viewports

Rendering a picture-in-picture (PiP) mini-map in Three.js is a powerful way to provide users with spatial orientation in 3D environments. This article explains how to configure multiple viewports using a single WebGLRenderer and two separate cameras—one for the main view and another positioned overhead to capture the mini-map. You will learn how to set up the cameras, define the viewport and scissor dimensions, and render both views efficiently in a single animation loop.

The Dual Viewport Concept

Instead of creating two separate canvas elements or running multiple renderer instances, you can partition a single HTML5 canvas. This is achieved using the setViewport and setScissor methods of the Three.js WebGLRenderer.

By enabling the scissor test, you instruct the renderer to clear and draw only within a specific boundary. This allows you to render the main 3D scene across the entire screen, and then render an overhead “map” view in a smaller, isolated corner of the same canvas.

Step 1: Set Up the Cameras

You need two cameras to make this work: 1. Main Camera: Usually a PerspectiveCamera representing the user’s field of view. 2. Mini-Map Camera: Usually an OrthographicCamera positioned directly above the player or scene, pointing downwards.

// Main camera for the primary view
const mainCamera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
mainCamera.position.set(0, 5, 10);

// Mini-map camera looking top-down
const mapWidth = 200;
const mapHeight = 200;
const mapCamera = new THREE.OrthographicCamera(-10, 10, 10, -10, 1, 1000);
mapCamera.position.set(0, 50, 0); // Position high above the scene
mapCamera.lookAt(0, 0, 0);       // Look straight down

Step 2: Configure the Renderer for Scissoring

By default, Three.js renders to the entire canvas size. To render a smaller picture-in-picture view without clearing the main scene, you must enable the scissor test.

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

// Enable the scissor test
renderer.setScissorTest(true);

Step 3: Implement the Render Loop

In your animation loop, you will define two distinct rendering passes. First, define the viewport and scissor region for the main screen. Second, define the viewport and scissor region for the mini-map in the top-right corner, then render the scene again with the top-down camera.

function animate() {
  requestAnimationFrame(animate);

  // Update object positions, controls, or physics here
  // e.g., keep the mini-map camera centered over the player
  // mapCamera.position.x = player.position.x;
  // mapCamera.position.z = player.position.z;

  const width = window.innerWidth;
  const height = window.innerHeight;

  // 1. Render the Main View (Full Screen)
  renderer.setViewport(0, 0, width, height);
  renderer.setScissor(0, 0, width, height);
  renderer.setClearColor(0x111111, 1); // Dark background for main scene
  renderer.clear(); // Clear color and depth buffers
  renderer.render(scene, mainCamera);

  // 2. Render the Mini-Map View (Top-Right Corner)
  const miniMapSize = 200; // Size of the mini-map in pixels
  const margin = 20;       // Offset from the screen edges
  
  const mapX = width - miniMapSize - margin;
  const mapY = height - miniMapSize - margin;

  renderer.setViewport(mapX, mapY, miniMapSize, miniMapSize);
  renderer.setScissor(mapX, mapY, miniMapSize, miniMapSize);
  renderer.setClearColor(0x222222, 1); // Slightly lighter background for the map
  renderer.clearDepth(); // Clear only the depth buffer so the map draws on top
  renderer.render(scene, mapCamera);
}

animate();

Step 4: Handling Window Resizing

When the window resizes, you must update the main camera’s aspect ratio and the renderer’s size. Because the mini-map viewport calculations in the render loop use dynamic window.innerWidth and window.innerHeight values, the mini-map will automatically adjust its position to stay pinned to the corner.

window.addEventListener('resize', () => {
  const width = window.innerWidth;
  const height = window.innerHeight;

  mainCamera.aspect = width / height;
  mainCamera.updateProjectionMatrix();

  renderer.setSize(width, height);
});