Full 3D Scene Example with Three.js

Introduction

Welcome to the final part of our Three.js tutorial series! In this comprehensive tutorial, we’ll tie together everything we’ve learned about Three.js and build a complete interactive 3D scene with:

  • Character animation and movement
  • Physics simulation
  • Dynamic lighting
  • Environmental effects
  • Interactive controls

This example demonstrates how to combine various Three.js features into a cohesive application, showing how these individual components can work together to create an immersive 3D experience on the web. This is built upon the lessons of the previous tutorials:

Complete code for the series of blog posts is available on GitHub.

What We’re Going to Build

We’ll create a fully interactive 3D environment featuring:

  1. Animated Character: A 3D character that can walk, run, and jump with realistic animations
  2. Physics Simulation: Objects that fall, bounce, and collide realistically
  3. Dynamic Lighting: Configurable lights including ambient, directional, and spotlight
  4. Environmental Effects: Fog, shadows, and textured surfaces
  5. Interactive Controls: Camera controls and character movement
  6. User Interface: A control panel to modify scene parameters in real-time

This project builds on concepts from our previous tutorials:

  • Shadows and fog from our environmental effects tutorial
  • Skeletal animation from our character animation tutorial
  • 3D rendering basics from our introduction to Three.js

Let’s dive into the implementation!

Setting Up the Project

Our project uses Vue.js for the UI and component structure, but the Three.js concepts apply to any framework or vanilla JavaScript. We’ll use:

  • Three.js: Core 3D library
  • CANNON.js: Physics engine
  • GLTFLoader: For loading animated models
  • OrbitControls: For camera manipulation
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref, watch } from 'vue';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
import * as CANNON from 'cannon-es';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader';

// Component state and variables
const containerRef = ref<HTMLDivElement | null>(null);
const loadingProgress = ref(0);
const isLoading = ref(true);
const activeTab = ref('environment');
const showControls = ref(true);

// Scene variables
let scene: THREE.Scene;
let camera: THREE.PerspectiveCamera;
let renderer: THREE.WebGLRenderer;
let controls: OrbitControls;
let animationFrameId: number;

// Physics world
let world: CANNON.World;
const timeStep = 1/60;

// Environment settings, physics settings, lighting, etc.
// ... (Various ref variables for UI controls)

Initializing the 3D Scene

The initialization process establishes our 3D environment and sets up all necessary components:

const init = async () => {
  if (!containerRef.value) return;
  
  // Create scene
  scene = new THREE.Scene();
  
  // Create camera with appropriate field of view and position
  camera = new THREE.PerspectiveCamera(
    75,                                      // Field of view (degrees)
    window.innerWidth / window.innerHeight,  // Aspect ratio
    0.1,                                     // Near clipping plane
    1000                                     // Far clipping plane
  );
  camera.position.set(0, 5, 10);  // Position slightly above and behind initial character position
  
  // Configure renderer with shadows enabled
  renderer = new THREE.WebGLRenderer({ antialias: true });
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.shadowMap.enabled = true;
  renderer.shadowMap.type = THREE.PCFSoftShadowMap;  // Higher quality shadows
  containerRef.value.appendChild(renderer.domElement);
  
  // Add orbit controls for camera manipulation
  controls = new OrbitControls(camera, renderer.domElement);
  controls.enableDamping = true;  // Smooth camera movement
  
  // Setup physics world, environment, lights, ground, and load models
  setupPhysicsWorld();
  await loadTextures();
  setupEnvironment();
  setupLights();
  createGround();
  await loadModel();
  
  // Add initial physics objects to the scene
  addSphere(3, 8, 3, 1);
  addBox(5, 10, 0, 1, 1, 1);
  addBox(-5, 12, 3, 1, 1, 1);
  
  // Set up event handlers and start the animation loop
  window.addEventListener('resize', onWindowResize);
  animate();
  
  // Loading complete
  isLoading.value = false;
};

Physics World Integration

We use CANNON.js to handle physics simulation. This adds realistic movement and collisions to our objects:

const setupPhysicsWorld = () => {
  world = new CANNON.World({
    gravity: new CANNON.Vec3(0, gravityStrength.value, 0)  // Downward gravity
  });
  
  // Broad-phase collision detection algorithm for better performance
  world.broadphase = new CANNON.SAPBroadphase(world);
  
  // Allow objects to sleep when at rest (performance optimization)
  world.allowSleep = true;
};

This function creates the foundation for our physics simulation:

  1. Creating the World Object:
    • new CANNON.World() initializes the physics simulation environment
    • The gravity property sets the gravitational force as a 3D vector (0, -Y, 0), meaning gravity pulls objects downward along the Y-axis
    • gravityStrength.value is a reactive variable that allows users to adjust gravity intensity through the UI
  2. Collision Detection Optimization:
    • SAPBroadphase (Sweep and Prune algorithm) is set as the broadphase collision detection method
    • Broadphase collision detection is the first step in determining which objects might be colliding
    • This algorithm organizes objects along axes to quickly rule out pairs that cannot possibly collide, significantly improving performance in scenes with many objects
  3. Sleep States:
    • world.allowSleep = true enables objects to “sleep” (become inactive in physics calculations) when they’ve been stationary for a while
    • This is a crucial optimization that reduces CPU usage, as the physics engine doesn’t waste cycles calculating physics for non-moving objects

Adding Physics Objects

Here’s how we add physics-enabled objects to our scene:

const addSphere = (x: number, y: number, z: number, radius: number) => {
  // Three.js visual sphere
  const sphereGeometry = new THREE.SphereGeometry(radius, 32, 32);
  
  // Create material based on selected texture
  let sphereMaterial: THREE.MeshStandardMaterial;
  
  if (selectedObjectTexture.value !== 'none' && textures[selectedObjectTexture.value]) {
    sphereMaterial = new THREE.MeshStandardMaterial({ 
      map: textures[selectedObjectTexture.value],
      roughness: 0.7,
      metalness: 0.3
    });
  } else {
    sphereMaterial = new THREE.MeshStandardMaterial({
      color: Math.random() * 0xffffff,  // Random color if no texture
      roughness: 0.7,
      metalness: 0.3
    });
  }
  
  // Create the visual sphere
  const sphereMesh = new THREE.Mesh(sphereGeometry, sphereMaterial);
  sphereMesh.position.set(x, y, z);
  sphereMesh.castShadow = true;
  sphereMesh.receiveShadow = true;
  scene.add(sphereMesh);
  
  // Create the physics sphere body
  const sphereShape = new CANNON.Sphere(radius);
  const sphereBody = new CANNON.Body({
    mass: 1,  // Mass affects physics behavior
    shape: sphereShape,
    position: new CANNON.Vec3(x, y, z),
    material: new CANNON.Material({ restitution: bounciness.value })
  });
  world.addBody(sphereBody);
  
  // Track objects for updating and cleanup
  meshes.push(sphereMesh);
  bodies.push(sphereBody);
  objectCount.value++;
};

This function demonstrates the dual-representation approach needed when using a physics engine with Three.js:

  1. Visual Representation (Three.js):
    • Creates a sphere geometry and applies a material (textured or colored)
    • Sets up shadow properties for realistic rendering
    • Adds the mesh to the Three.js scene for visualization
  2. Physical Representation (CANNON.js):
    • Creates a corresponding sphere shape in the physics engine
    • Creates a physical body with properties like:
      • mass: Determines how the object responds to forces (0 would make it immovable)
      • shape: The collision geometry (a sphere matching the visual representation)
      • position: Initial position matching the visual sphere
      • material: Physics properties including restitution (bounciness)
    • Adds the physics body to the CANNON.js world
  3. Tracking Objects:
    • Stores references to both the mesh and body in arrays
    • These references are used to:
      • Synchronize positions between physics and visual objects
      • Clean up resources when objects are removed
      • Track how many objects exist in the scene

Character Animation and Control

The heart of our interactive experience is the animated character. It combines model loading, animation management, input handling, and collision detection to create lifelike character movement.

const loadModel = async () => {
  return new Promise<void>((resolve) => {
    // Set up DRACO loader for compressed models
    const dracoLoader = new DRACOLoader();
    dracoLoader.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.6/');
    
    // Set up GLTF loader with DRACO support
    const gltfLoader = new GLTFLoader();
    gltfLoader.setDRACOLoader(dracoLoader);
    
    // Load a glTF resource (soldier model with animations)
    gltfLoader.load(
      'https://threejs.org/examples/models/gltf/Soldier.glb',
      (gltf) => {
        character = gltf.scene;
        character.scale.set(1, 1, 1);
        character.position.set(characterPosition.value.x, characterPosition.value.y, characterPosition.value.z);
        
        // Set up shadows for all character mesh parts
        character.traverse((node) => {
          if (node instanceof THREE.Mesh) {
            node.castShadow = true;
            node.receiveShadow = true;
          }
        });
        scene.add(character);
        
        // Initialize animation system
        characterMixer = new THREE.AnimationMixer(character);
        mixers.push(characterMixer);
        
        // Map animation names to more readable ones and set up actions
        if (gltf.animations.length > 0) {
          const animationMap: Record<string, number> = {
            'idle': 0,  // Usually the first animation
            'walk': 1,  // Walking animation
            'run': 3    // Running animation
          };
          
          // Create actions for each animation
          Object.entries(animationMap).forEach(([name, index]) => {
            if (index < gltf.animations.length) {
              characterActions[name] = characterMixer.clipAction(gltf.animations[index]);
            }
          });
          
          // Play the idle animation by default
          if (characterActions['idle']) {
            characterActions['idle'].play();
          } else if (gltf.animations.length > 0) {
            const action = characterMixer.clipAction(gltf.animations[0]);
            action.play();
          }
        }
        
        // Set up keyboard controls
        setupCharacterControls();
        
        modelLoaded.value = true;
        loadingProgress.value += 20;
        resolve();
      },
      // Loading progress callback
      (xhr) => {
        const progress = (xhr.loaded / xhr.total) * 20;
        loadingProgress.value += progress - (loadingProgress.value % 20);
      },
      // Error callback
      (error) => {
        console.error('An error happened while loading the model:', error);
        resolve(); // Resolve anyway to continue
      }
    );
  });
};

This function handles several sophisticated processes:

  1. DRACO Decompression:
    • DRACO is a library for compressing 3D meshes to reduce file size
    • The DRACOLoader decompresses these models on the client side
    • This significantly reduces download times for complex character models
  2. GLTF Loading:
    • GLTF is the standard format for 3D models in web applications
    • It includes not just geometry, but also materials, animations, and skeleton data
    • The GLTFLoader processes this format, creating Three.js objects from the data
  3. Shadow Configuration:
    • The traverse() method walks through all nodes in the model hierarchy
    • It enables shadow casting and receiving on each mesh component of the character
    • This ensures the character both casts shadows on the environment and receives shadows from other objects
  4. Animation System Setup:
    • AnimationMixer is created to control animation playback
    • Each animation from the GLTF file is mapped to a named action for easier reference
    • The mapping system uses indices to identify specific animations in the file
    • This approach accommodates different character models with varying animation orders
  5. Initial Animation:
    • The idle animation is played by default
    • Fallback logic plays the first available animation if the expected idle animation isn’t found
    • This ensures the character is never in a static pose
  6. Error Handling and Progress Tracking:
    • Progress updates the UI loading indicator
    • Error handling ensures the application continues even if character loading fails
    • The Promise-based approach allows other initialization to continue in parallel

Character Movement System

The character movement system implements WASD-style controls with support for running, turning, and jumping:

const setupCharacterControls = () => {
  // Add keyboard event listeners
  window.addEventListener('keydown', handleKeyDown);
  window.addEventListener('keyup', handleKeyUp);
};

const handleKeyDown = (event: KeyboardEvent) => {
  switch (event.key.toLowerCase()) {
    case 'w':
      characterControls.value.moveForward = true;
      updateCharacterAnimation();
      break;
    case 's':
      characterControls.value.moveBackward = true;
      updateCharacterAnimation();
      break;
    case 'a':
      characterControls.value.moveLeft = true;
      updateCharacterAnimation();
      break;
    case 'd':
      characterControls.value.moveRight = true;
      updateCharacterAnimation();
      break;
    case 'q':
      characterControls.value.turnLeft = true;
      break;
    case 'e':
      characterControls.value.turnRight = true;
      break;
    case ' ': // Space bar
      if (!isJumping.value && isGrounded.value) {
        characterControls.value.jump = true;
        isJumping.value = true;
        isGrounded.value = false;
        jumpVelocity.value = jumpStrength.value;
        updateCharacterAnimation();
      }
      break;
    case 'shift':
      // Increase speed for running
      characterMovementSpeed.value = 0.15;
      updateCharacterAnimation();
      break;
  }
};

The movement system uses:

  1. State Tracking:
    • Boolean flags track which keys are currently pressed
    • This approach handles multiple simultaneous key presses correctly
    • State is maintained in a reactive object allowing components to react to changes
  2. Conditional Jumping:
    • Jump is only allowed when the character is grounded (not already in the air)
    • Jump velocity is set based on configurable jump strength
    • Physics state flags are updated to enable gravity calculations
  3. Speed Control:
    • Shift key toggles between walking and running speeds
    • Speed changes trigger animation updates to match the visual appearance with movement speed
  4. Animation Coupling:
    • Each movement change calls updateCharacterAnimation() to ensure animations match the movement state
    • This maintains synchronization between visual appearance and logical state

Character Animation Management

To provide realistic movement, we switch between animations based on character state:

const updateCharacterAnimation = () => {
  if (!character || !characterMixer || Object.keys(characterActions).length === 0) return;
  
  // Determine which animation to play based on movement
  let newAnimation = 'idle';
  
  // If jumping, keep current animation
  if (isJumping.value) {
    return; // Keep current animation during jump
  }
  
  // Check if any movement keys are pressed
  if (characterControls.value.moveForward || 
      characterControls.value.moveBackward || 
      characterControls.value.moveLeft || 
      characterControls.value.moveRight) {
    
    // Use run animation if shift is pressed (higher movement speed)
    newAnimation = characterMovementSpeed.value > 0.1 ? 'run' : 'walk';
  }
  
  // Only change animation if it's different from current
  if (newAnimation !== currentAnimation.value) {
    // Fade out current animation
    if (characterActions[currentAnimation.value]) {
      characterActions[currentAnimation.value].fadeOut(0.2);
    }
    
    // Fade in new animation
    if (characterActions[newAnimation]) {
      characterActions[newAnimation].reset().fadeIn(0.2).play();
      currentAnimation.value = newAnimation;
    }
  }
};

This animation management system provides:

  1. Context-Aware Animation Selection:
    • Automatically determines the appropriate animation based on character state
    • Idle when stationary, walk or run when moving, based on speed
  2. Animation Persistence During Jumps:
    • During jumps, the current animation is preserved
    • This avoids animation conflicts during vertical movement
  3. Smooth Transitions:
    • Uses Cross-fading between animations (0.2 seconds)
    • Prevents jarring visual changes when movement state changes
    • reset() ensures the new animation starts from the beginning
    • fadeIn(0.2) gradually increases the influence of the new animation
  4. Optimization:
    • Only changes animations when necessary (skips if the new animation is the same as current)
    • Performs early validation to avoid errors with missing components

Character Position Updates and Collision Detection

The character movement system includes collision detection to prevent walking through objects. First we’ll look at collision detection.

const checkCollision = (position: THREE.Vector3): boolean => {
  if (!character) return false;
  
  // Check collision with physics objects
  for (let i = 0; i < meshes.length; i++) {
    const mesh = meshes[i];
    const distance = position.distanceTo(mesh.position);
    
    // Simple sphere-sphere collision detection
    // Get the approximate radius of the object (assuming it's roughly spherical)
    let objectRadius = 0;
    
    if (mesh.geometry instanceof THREE.SphereGeometry) {
      objectRadius = mesh.geometry.parameters.radius;
    } else if (mesh.geometry instanceof THREE.BoxGeometry) {
      // For boxes, use half the maximum dimension as an approximation
      const params = mesh.geometry.parameters;
      objectRadius = Math.max(params.width, params.height, params.depth) / 2;
    } else {
      // Default radius for other geometries
      objectRadius = 0.5;
    }
    
    // Check if the distance is less than the sum of the radii
    if (distance < (characterCollider.radius + objectRadius)) {
      return true; // Collision detected
    }
  }
  
  return false; // No collision
};

This function implements a sphere-based collision detection approach:

  1. Distance Calculation:
    • Measures the distance between the character’s position and each object in the scene
    • Uses Three.js built-in distanceTo method for vector distance calculation
  2. Adaptive Radius Determination:
    • Dynamically determines each object’s collision radius based on its geometry type
    • For spheres, uses the exact sphere radius
    • For boxes, uses half the maximum dimension (a sphere that would encompass the box)
    • Falls back to a default radius for other geometry types
  3. Collision Testing:
    • A collision occurs when the distance between objects is less than the sum of their radii
    • This approach is known as “sphere-sphere collision detection”
    • It’s a common technique that balances accuracy and performance

Now, let’s examine the movement system:

const updateCharacterPosition = (delta: number) => {
  if (!character) return;
  
  // Get character's forward direction (based on its rotation)
  const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(character.quaternion);
  const right = new THREE.Vector3(1, 0, 0).applyQuaternion(character.quaternion);
  
  // Calculate movement vector based on key presses
  const moveVector = new THREE.Vector3(0, 0, 0);
  
  if (characterControls.value.moveForward) {
    moveVector.add(forward.clone().multiplyScalar(characterMovementSpeed.value));
  }
  if (characterControls.value.moveBackward) {
    moveVector.add(forward.clone().multiplyScalar(-characterMovementSpeed.value));
  }
  if (characterControls.value.moveRight) {
    moveVector.add(right.clone().multiplyScalar(characterMovementSpeed.value));
  }
  if (characterControls.value.moveLeft) {
    moveVector.add(right.clone().multiplyScalar(-characterMovementSpeed.value));
  }
  
  // Check if character is grounded
  isGrounded.value = checkGrounded();
  
  // Handle jumping and gravity
  if (isJumping.value) {
    // Apply jump velocity
    moveVector.y = jumpVelocity.value;
    
    // Apply gravity to jump velocity
    jumpVelocity.value -= gravity.value;
    
    // Check if we've landed
    if (character.position.y <= 0 && jumpVelocity.value < 0) {
      character.position.y = 0;
      isJumping.value = false;
      jumpVelocity.value = 0;
      isGrounded.value = true;
      updateCharacterAnimation();
    }
    
    // Check for ceiling collisions
    if (jumpVelocity.value > 0) {
      const ceilingCheckPosition = character.position.clone();
      ceilingCheckPosition.y += characterCollider.height/2 + 0.2;
      
      if (checkCollision(ceilingCheckPosition)) {
        jumpVelocity.value = 0;
      }
    }
  } else if (!isGrounded.value) {
    // Apply gravity when not jumping but in the air
    moveVector.y = -gravity.value * 2; // Falling is faster than jumping
  }
  
  // Apply movement if there's no collision
  if (moveVector.length() > 0) {
    // Calculate the new position
    const newPosition = character.position.clone().add(moveVector);
    
    // Check for collisions at the new position
    const collision = checkCollision(newPosition);
    
    if (!collision) {
      // No collision, safe to move
      character.position.copy(newPosition);
      characterPosition.value = { 
        x: character.position.x, 
        y: character.position.y, 
        z: character.position.z 
      };
    } else {
      // Implement sliding along walls by trying movement components separately
      const xMovement = new THREE.Vector3(moveVector.x, 0, 0);
      const zMovement = new THREE.Vector3(0, 0, moveVector.z);
      
      // Try X movement
      const newXPosition = character.position.clone().add(xMovement);
      if (!checkCollision(new THREE.Vector3(newXPosition.x, character.position.y, character.position.z))) {
        character.position.x = newXPosition.x;
      }
      
      // Try Z movement
      const newZPosition = character.position.clone().add(zMovement);
      if (!checkCollision(new THREE.Vector3(character.position.x, character.position.y, newZPosition.z))) {
        character.position.z = newZPosition.z;
      }
      
      // Apply vertical movement with collision checks
      const verticalPosition = character.position.clone();
      verticalPosition.y += moveVector.y;
      
      if (!checkCollision(verticalPosition)) {
        character.position.y = verticalPosition.y;
        isGrounded.value = checkGrounded();
      } else {
        // Handle collisions during jumps
        if (moveVector.y > 0) {
          jumpVelocity.value = 0;
        } else if (moveVector.y < 0) {
          isJumping.value = false;
          jumpVelocity.value = 0;
          isGrounded.value = true;
          updateCharacterAnimation();
        }
      }
      
      // Update position values for UI display
      characterPosition.value = { 
        x: character.position.x, 
        y: character.position.y, 
        z: character.position.z 
      };
    }
  }
  
  // Handle rotation
  if (characterControls.value.turnLeft) {
    character.rotateY(characterRotationSpeed.value);
  }
  if (characterControls.value.turnRight) {
    character.rotateY(-characterRotationSpeed.value);
  }
};

This section establishes how the character’s movement direction is calculated:

  1. Direction Vectors:
    • Computes the character’s local forward and right directions based on its current rotation
    • Uses quaternion rotation to properly transform the base direction vectors
  2. Movement Vector Calculation:
    • Starts with a zero vector (no movement)
    • Adds direction vectors based on which movement keys are pressed
    • Scales each direction by the movement speed
    • This approach allows for diagonal movement by combining directions
  3. Character-Relative Movement:
    • The movement is always relative to the character’s facing direction
    • When the character rotates, all movement directions rotate with it
    • This creates an intuitive control scheme where “forward” always moves in the direction the character is facing

This implements an advanced collision resolution technique that dramatically improves the user experience:

  1. Collision-Free Movement:
    • First attempts to move in the combined direction (all axes at once)
    • If no collision is detected, performs the movement directly
  2. Component-Based Sliding:
    • When a collision is detected, breaks the movement into its component directions (X, Z, Y)
    • Tries each horizontal component (X and Z) separately
    • This allows characters to “slide” along walls rather than stopping completely
    • This technique is used in many professional games to create smooth movement around obstacles
  3. Specialized Vertical Collision Handling:
    • Vertical movement (Y-axis) is handled separately from horizontal movement
    • For upward collisions (hitting a ceiling while jumping), jump velocity is set to zero
    • For downward collisions (landing on a surface), jumping state is reset and ground contact is established
    • This approach creates realistic jumping and landing behavior
  4. State Synchronization:
    • After all movement calculations, the character’s position values are synchronized with the UI
    • This keeps the displayed character position information accurate

This movement and collision system forms the core of the character controller, enabling responsive, intuitive character movement within our 3D world while preventing impossible movements like walking through walls or falling through floors.

Environment Setup: Creating a Rich Visual Foundation

The environment setup is crucial for establishing the physical space where our character and objects will interact. The code below creates a visually appealing environment with a textured ground and clear boundary indicators:

const createGround = () => {
  // Three.js ground plane
  const groundGeometry = new THREE.PlaneGeometry(30, 30);
  
  // Create material based on selected texture
  let groundMaterial: THREE.MeshStandardMaterial;
  
  if (selectedGroundTexture.value !== 'none' && textures[selectedGroundTexture.value]) {
    groundMaterial = new THREE.MeshStandardMaterial({ 
      map: textures[selectedGroundTexture.value],
      roughness: 0.8,
      metalness: 0.2
    });
  } else {
    groundMaterial = new THREE.MeshStandardMaterial({ 
      color: new THREE.Color(groundColor.value),
      roughness: 0.8,
      metalness: 0.2
    });
  }
  
  const groundMesh = new THREE.Mesh(groundGeometry, groundMaterial);
  groundMesh.rotation.x = -Math.PI / 2;  // Rotate to be horizontal
  groundMesh.receiveShadow = true;       // Ground receives shadows
  scene.add(groundMesh);
  
  // Cannon.js ground body (infinite plane)
  const groundShape = new CANNON.Plane();
  const groundBody = new CANNON.Body({
    mass: 0,  // Mass of 0 makes it static (immovable)
    shape: groundShape
  });
  groundBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0);  // Match rotation
  world.addBody(groundBody);
  
  // Add ground boundaries for visual reference
  const groundSize = 30;  // Same as the plane geometry size
  const edgeGeometry = new THREE.BoxGeometry(groundSize, 0.2, 0.2);
  const edgeMaterial = new THREE.MeshStandardMaterial({ color: 0xff0000 });
  
  // Create edges for the ground perimeter
  const edges = [
    { position: [0, 0, groundSize/2], rotation: [0, 0, 0], scale: [1, 1, 1] },
    { position: [0, 0, -groundSize/2], rotation: [0, 0, 0], scale: [1, 1, 1] },
    { position: [groundSize/2, 0, 0], rotation: [0, Math.PI/2, 0], scale: [1, 1, 1] },
    { position: [-groundSize/2, 0, 0], rotation: [0, Math.PI/2, 0], scale: [1, 1, 1] }
  ];
  
  edges.forEach(edge => {
    const edgeMesh = new THREE.Mesh(edgeGeometry, edgeMaterial);
    edgeMesh.position.set(edge.position[0], edge.position[1], edge.position[2]);
    edgeMesh.rotation.set(edge.rotation[0], edge.rotation[1], edge.rotation[2]);
    edgeMesh.scale.set(edge.scale[0], edge.scale[1], edge.scale[2]);
    edgeMesh.castShadow = true;
    edgeMesh.receiveShadow = true;
    scene.add(edgeMesh);
  });
};

This function creates a complete environment foundation with several important components:

1. Creating the Visual Ground

The first section creates the visible ground plane that objects will appear to rest on:

// Three.js ground plane
const groundGeometry = new THREE.PlaneGeometry(30, 30);

This creates a flat square with dimensions of 30×30 units. The size is deliberately chosen to:

  • Provide enough space for character movement and object placement
  • Define clear boundaries for the playable area
  • Balance performance with visual quality

2. Implementing Texture Support

The ground’s appearance is enhanced with textures or color:

// Create material based on selected texture
let groundMaterial: THREE.MeshStandardMaterial;

if (selectedGroundTexture.value !== 'none' && textures[selectedGroundTexture.value]) {
  groundMaterial = new THREE.MeshStandardMaterial({ 
    map: textures[selectedGroundTexture.value],
    roughness: 0.8,
    metalness: 0.2
  });
} else {
  groundMaterial = new THREE.MeshStandardMaterial({ 
    color: new THREE.Color(groundColor.value),
    roughness: 0.8,
    metalness: 0.2
  });
}

This code:

  • Checks if a texture is selected and available in the textures object
  • Uses the selected texture if available, otherwise falls back to a solid color
  • Sets physical material properties:
    • roughness: 0.8: Makes the surface mostly matte (non-reflective)
    • metalness: 0.2: Gives a slight non-metallic quality (like concrete or dirt)

The texture system supports dynamically changing the ground appearance through the UI, allowing users to experiment with different environments.

3. Positioning and Shadow Setup

The ground plane is positioned and configured to interact with lighting:

const groundMesh = new THREE.Mesh(groundGeometry, groundMaterial);
groundMesh.rotation.x = -Math.PI / 2;  // Rotate to be horizontal
groundMesh.receiveShadow = true;       // Ground receives shadows
scene.add(groundMesh);

Key aspects:

  • The plane is rotated 90 degrees (π/2 radians) to lie flat on the XZ plane
  • receiveShadow = true enables the ground to display shadows cast by other objects
  • The mesh is added to the scene to make it visible

4. Physics Ground Integration

To support physics interactions, we create a corresponding CANNON.js ground plane:

// Cannon.js ground body (infinite plane)
const groundShape = new CANNON.Plane();
const groundBody = new CANNON.Body({
  mass: 0,  // Mass of 0 makes it static (immovable)
  shape: groundShape
});
groundBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0);  // Match rotation
world.addBody(groundBody);

Important physics properties:

  • mass: 0 makes the ground immovable (static)
  • Unlike the visual ground, the physics plane is infinite in size
  • The rotation is matched to the visual ground using quaternions
  • The body is added to the physics world so other objects can collide with it

Physics-based objects will react to this ground plane by:

  • Bouncing or coming to rest when they fall
  • Maintaining proper friction when they slide
  • Preventing objects from falling through the “floor” of the scene

5. Visual Boundary Indicators

To help users perceive the limits of the ground plane (which is visually finite but physically infinite), we add clear border markers:

// Add ground boundaries for visual reference
const groundSize = 30;  // Same as the plane geometry size
const edgeGeometry = new THREE.BoxGeometry(groundSize, 0.2, 0.2);
const edgeMaterial = new THREE.MeshStandardMaterial({ color: 0xff0000 });

// Create edges for the ground perimeter
const edges = [
  { position: [0, 0, groundSize/2], rotation: [0, 0, 0], scale: [1, 1, 1] },
  { position: [0, 0, -groundSize/2], rotation: [0, 0, 0], scale: [1, 1, 1] },
  { position: [groundSize/2, 0, 0], rotation: [0, Math.PI/2, 0], scale: [1, 1, 1] },
  { position: [-groundSize/2, 0, 0], rotation: [0, Math.PI/2, 0], scale: [1, 1, 1] }
];

The boundaries are created using:

  • Thin red box geometries that outline the perimeter of the ground plane
  • Four edge segments positioned at the midpoints of each side of the ground square
  • Rotations to correctly orient each edge along the perimeter

6. Boundary Creation Loop

We use a data-driven approach to create all four boundary indicators:

edges.forEach(edge => {
  const edgeMesh = new THREE.Mesh(edgeGeometry, edgeMaterial);
  edgeMesh.position.set(edge.position[0], edge.position[1], edge.position[2]);
  edgeMesh.rotation.set(edge.rotation[0], edge.rotation[1], edge.rotation[2]);
  edgeMesh.scale.set(edge.scale[0], edge.scale[1], edge.scale[2]);
  edgeMesh.castShadow = true;
  edgeMesh.receiveShadow = true;
  scene.add(edgeMesh);
});

This loop:

  • Creates a mesh for each edge using the same geometry and material
  • Sets the position, rotation, and scale based on the data in the edges array
  • Enables both casting and receiving shadows for realistic lighting
  • Adds each edge to the scene

The resulting red border provides:

  • A clear visual indicator of the usable area
  • Feedback when objects or the character approach the edge
  • Context for the spatial boundaries of the environment

Design Benefits of the Environment Approach

This environment implementation offers several important benefits:

  1. Visual-Physical Separation:
    • Visual elements (the finite ground plane) can be different from physics elements (the infinite physics plane)
    • This allows for artistic flexibility while maintaining accurate physics
  2. Texture Customization:
    • The reactive texture system allows changing the ground appearance without recreating the entire environment
    • Users can experiment with different visual styles through the UI
  3. Clear Boundaries:
    • The red border provides intuitive feedback about the limits of the playable area
    • Character and object positions can be checked against these boundaries
  4. Performance Optimization:
    • Using a simple plane for the ground minimizes polygon count
    • The physics ground being infinite simplifies collision calculations
  5. Shadow Support:
    • The ground’s shadow receiving capability enhances the realism of the scene
    • Objects appear properly grounded rather than floating

This environment setup creates a foundation that is both functional and visually coherent, providing the spatial context needed for character movement, physics interactions, and object placement in our 3D scene.

Lighting Systems: Creating Dynamic Illumination

Let’s explore the lighting implementation that brings life and depth to our 3D scene:

const setupLights = () => {
  // Ambient light for global illumination
  ambientLight = new THREE.AmbientLight(0xffffff, ambientIntensity.value);
  scene.add(ambientLight);
  
  // Directional light (sunlight)
  directionalLight = new THREE.DirectionalLight(0xffffff, directionalIntensity.value);
  directionalLight.position.set(5, 10, 7.5);
  directionalLight.castShadow = shadowsEnabled.value;
  
  // Optimize shadow settings for the directional light
  directionalLight.shadow.mapSize.width = 1024;
  directionalLight.shadow.mapSize.height = 1024;
  directionalLight.shadow.camera.near = 0.5;
  directionalLight.shadow.camera.far = 50;
  directionalLight.shadow.camera.left = -10;
  directionalLight.shadow.camera.right = 10;
  directionalLight.shadow.camera.top = 10;
  directionalLight.shadow.camera.bottom = -10;
  
  scene.add(directionalLight);
  
  // Spotlight for dramatic focused lighting
  spotLight = new THREE.SpotLight(
    new THREE.Color(spotlightColor.value),
    spotlightIntensity.value,
    50,                              // Distance
    spotlightAngle.value,           // Cone angle
    0.5,                             // Penumbra (softness)
    2                                // Decay
  );
  spotLight.position.set(
    spotlightPosition.value.x,
    spotlightPosition.value.y,
    spotlightPosition.value.z
  );
  spotLight.castShadow = true;
  spotLight.visible = spotlightEnabled.value;
  
  // Add spotlight target
  const targetObject = new THREE.Object3D();
  targetObject.position.set(0, 0, 0);  // Point at center
  scene.add(targetObject);
  spotLight.target = targetObject;
  
  scene.add(spotLight);
};

This function establishes a comprehensive lighting system with three distinct light types, each serving a different purpose in our scene. Let’s examine each component:

1. Ambient Light: The Base Illumination

// Ambient light for global illumination
ambientLight = new THREE.AmbientLight(0xffffff, ambientIntensity.value);
scene.add(ambientLight);

Ambient light provides non-directional, global illumination that affects all objects equally:

  • Purpose: Ensures no part of the scene is completely black, simulating light bouncing and filling shadows
  • Color: Pure white (0xffffff) provides neutral illumination that doesn’t tint the scene
  • Intensity: Controlled via ambientIntensity.value, a reactive property that users can adjust through the UI
  • Characteristics:
    • No direction, so it creates no shadows
    • Illuminates all surfaces equally, including those facing away from other light sources
    • Simulates indirect lighting and light scattering in a simplified way

The ambient light serves as a foundation layer that ensures visibility and prevents unrealistically dark shadows.

2. Directional Light: Simulating Sunlight

// Directional light (sunlight)
directionalLight = new THREE.DirectionalLight(0xffffff, directionalIntensity.value);
directionalLight.position.set(5, 10, 7.5);
directionalLight.castShadow = shadowsEnabled.value;

Directional light mimics distant light sources like the sun, where all light rays are parallel:

  • Purpose: Creates the primary illumination with clear highlights and shadows
  • Position: Set to (5, 10, 7.5) to create light coming from above and slightly to the side
  • Color: White for neutral daylight-like illumination
  • Intensity: Controlled via directionalIntensity.value for user adjustment
  • Shadow Casting: Enabled based on shadowsEnabled.value, allowing users to toggle shadows

Shadow Configuration

// Optimize shadow settings for the directional light
directionalLight.shadow.mapSize.width = 1024;
directionalLight.shadow.mapSize.height = 1024;
directionalLight.shadow.camera.near = 0.5;
directionalLight.shadow.camera.far = 50;
directionalLight.shadow.camera.left = -10;
directionalLight.shadow.camera.right = 10;
directionalLight.shadow.camera.top = 10;
directionalLight.shadow.camera.bottom = -10;

This section configures the directional light’s shadow properties:

  • Shadow Map Resolution1024×1024 pixels provides good quality shadows without excessive performance cost
  • Shadow Camera Frustum: Defines the volume within which shadows will be calculated
    • near and far define the depth range (0.5 to 50 units)
    • leftrighttop, and bottom define the width and height (20×20 units)
  • Optimization Balance: These values are carefully chosen to:
    • Cover the entire playable area
    • Maintain shadow detail
    • Avoid wasting resolution on areas outside the scene

The shadow camera works similarly to a regular camera, but it’s used to render the scene from the light’s perspective to calculate shadows.

3. Spotlight: Dynamic Focused Illumination

// Spotlight for dramatic focused lighting
spotLight = new THREE.SpotLight(
  new THREE.Color(spotlightColor.value),
  spotlightIntensity.value,
  50,                              // Distance
  spotlightAngle.value,           // Cone angle
  0.5,                             // Penumbra (softness)
  2                                // Decay
);

The spotlight provides concentrated, cone-shaped illumination with multiple configurable properties:

  • Color: User-defined via spotlightColor.value, allowing for creative lighting effects
  • Intensity: Controlled through spotlightIntensity.value
  • Distance: 50 units, defining how far the light reaches before fading completely
  • Angle: Controlled via spotlightAngle.value, determining the width of the light cone
  • Penumbra: 0.5 creates soft edges by gradually fading light intensity at the cone’s edges
  • Decay: 2 creates physically accurate quadratic light falloff with distance

Spotlight Positioning and Targeting

spotLight.position.set(
  spotlightPosition.value.x,
  spotlightPosition.value.y,
  spotlightPosition.value.z
);
spotLight.castShadow = true;
spotLight.visible = spotlightEnabled.value;

// Add spotlight target
const targetObject = new THREE.Object3D();
targetObject.position.set(0, 0, 0);  // Point at center
scene.add(targetObject);
spotLight.target = targetObject;

This section configures the spotlight’s position and direction:

  • Position: Set via reactive coordinates, allowing users to reposition the spotlight through the UI
  • Shadows: Enabled by default but toggled alongside other shadows
  • Visibility: Controlled via spotlightEnabled.value, allowing users to turn it on/off
  • Target: An invisible object at the scene center that the spotlight points toward
    • This approach allows for easily adjusting where the spotlight aims
    • The target could be animated or attached to scene objects for dynamic lighting effects

Lighting Design Principles

Our lighting system follows several important design principles:

  1. Three-Point Lighting Adaptation:
    • Ambient light serves as fill light
    • Directional light functions as the key light
    • Spotlight can be positioned as a rim/accent light
  2. Layered Approach:
    • Base layer: ambient light ensures minimum visibility
    • Primary layer: directional light creates the main lighting direction
    • Accent layer: spotlight adds drama and emphasis
  3. Interactive Control:
    • All key lighting parameters are exposed to the UI
    • Users can experiment with different lighting setups
    • Real-time updates allow immediate feedback
  4. Performance Considerations:
    • Shadow map resolution balances quality and performance
    • Spotlight distance limit prevents unnecessary calculations
    • Option to disable shadows completely for performance-critical situations

Shadow Quality Optimization

Shadows are one of the most computationally expensive lighting features, so we’ve implemented several optimizations:

  1. Appropriate Shadow Map Size:
    • 1024×1024 provides good quality without excessive memory usage
    • For higher-end devices, this could be increased to 2048×2048
  2. Constrained Shadow Camera:
    • The shadow camera frustum is tightly configured to the scene dimensions
    • This maximizes the available resolution within the relevant area
  3. Soft Shadows:
    • THREE.PCFSoftShadowMap (set in the renderer) provides filtered shadows
    • This reduces aliasing artifacts and creates more realistic soft edges
  4. Toggle Option:
    • The ability to disable shadows provides an easy performance boost when needed
    • Shadow calculations can be selectively enabled for important objects only

Reactive Lighting Controls

The lighting system integrates with Vue’s reactivity system:

// Examples of reactive lighting controls
watch(ambientIntensity, () => { 
  ambientLight.intensity = ambientIntensity.value; 
});

watch(directionalIntensity, () => { 
  directionalLight.intensity = directionalIntensity.value; 
});

watch(spotlightColor, () => {
  spotLight.color.set(spotlightColor.value);
});

watch(shadowsEnabled, () => { 
  directionalLight.castShadow = shadowsEnabled.value;
  spotLight.castShadow = shadowsEnabled.value;
});

This reactive approach enables:

  • Real-time lighting adjustments through the UI
  • Synchronization of visual state with control values
  • Easy experimentation with different lighting scenarios
  • Performance tuning based on the device capabilities

Animation Loop and Physics Update

The animation loop ties everything together, responsible for updating the scene state and rendering each frame. Let’s explore how our animation loop integrates physics simulation, character movement, and scene rendering:

const animate = () => {
  animationFrameId = requestAnimationFrame(animate);
  
  // Get delta time for smooth animations
  const delta = clock.getDelta();
  
  // Update physics if enabled
  if (physicsEnabled.value) {
    world.step(timeStep);
    
    // Update mesh positions based on physics bodies
    for (let i = 0; i < meshes.length; i++) {
      meshes[i].position.copy(bodies[i].position as any);
      meshes[i].quaternion.copy(bodies[i].quaternion as any);
    }
    
    // Check if objects have fallen off the ground and remove them
    checkObjectBoundaries();
  }
  
  // Update character position based on controls
  updateCharacterPosition(delta);
  
  // Check if character has fallen off the edge and reset if needed
  if (character && character.position.y < -10) {
    character.position.set(-5, 0, 0);
    characterPosition.value = { x: -5, y: 0, z: 0 };
    isJumping.value = false;
    jumpVelocity.value = 0;
    isGrounded.value = true;
    updateCharacterAnimation();
  }
  
  // Update collision status for UI
  if (character) {
    collisionDetected.value = checkCollision(character.position);
  }
  
  // Update animation mixers
  mixers.forEach((mixer) => {
    mixer.update(delta * animationSpeed.value);
  });
  
  // Update orbit controls (for damping effect)
  controls.update();
  
  // Render scene
  renderer.render(scene, camera);
};

This function forms the core loop that drives our entire 3D application. Let’s break down its components and understand how they work together:

1. Frame Timing Management

animationFrameId = requestAnimationFrame(animate);
const delta = clock.getDelta();

This section establishes the foundation for smooth animation:

  • requestAnimationFrame: Schedules the next execution of the animation loop to synchronize with the browser’s refresh rate (typically 60Hz)
  • clock.getDelta(): Calculates the time elapsed since the last frame in seconds
    • This “delta time” ensures animations run at the same speed regardless of frame rate
    • Using delta time prevents animations from running faster on high-refresh-rate displays
    • The THREE.Clock instance tracks time automatically

2. Physics Simulation Update

// Update physics if enabled
if (physicsEnabled.value) {
  world.step(timeStep);
  
  // Update mesh positions based on physics bodies
  for (let i = 0; i < meshes.length; i++) {
    meshes[i].position.copy(bodies[i].position as any);
    meshes[i].quaternion.copy(bodies[i].quaternion as any);
  }
  
  // Check if objects have fallen off the ground and remove them
  checkObjectBoundaries();
}

This section advances the physics simulation:

  • Conditional Execution: The physics update only runs if enabled (allows toggling physics for performance)
  • Fixed Time Stepworld.step(timeStep) advances the physics simulation by a constant time increment (1/60 second)
    • Using a fixed time step (rather than delta time) ensures stable, predictable physics calculations
    • This is a standard approach in physics engines to prevent simulation instability
  • Visual-Physical Synchronization:
    • After the physics world updates, visual meshes must be updated to match their physical counterparts
    • Both position (translation) and quaternion (rotation) are synchronized
    • This two-way representation (visual and physical) is central to physics-based rendering
  • Boundary Cleanup:
    • checkObjectBoundaries() removes objects that have fallen far below the ground
    • This prevents performance degradation from tracking objects that are no longer relevant
    • It’s an important memory management technique for long-running simulations

3. Character Movement and Safety Checks

// Update character position based on controls
updateCharacterPosition(delta);

// Check if character has fallen off the edge and reset if needed
if (character && character.position.y < -10) {
  character.position.set(-5, 0, 0);
  characterPosition.value = { x: -5, y: 0, z: 0 };
  isJumping.value = false;
  jumpVelocity.value = 0;
  isGrounded.value = true;
  updateCharacterAnimation();
}

This section handles character movement and safety measures:

  • Delta-Based Character Movement: Character position is updated with time-based movement for smooth motion
  • Fall Recovery System:
    • Checks if the character has fallen off the edge (y < -10)
    • Resets the character to a safe starting position (-5, 0, 0)
    • Resets all character state variables (jumping, velocity, grounded)
    • Updates animation to match the new state
    • This prevents the character from becoming permanently lost if it falls off the level

4. UI State Updates

// Update collision status for UI
if (character) {
  collisionDetected.value = checkCollision(character.position);
}

This section updates reactive state for UI feedback:

  • Collision Indication: Checks and updates the collision status for UI display
  • User Feedback: This information appears in the control panel, providing valuable feedback
  • Reactive Integration: Using Vue’s reactivity system, this state change automatically updates the UI

5. Animation System Updates

// Update animation mixers
mixers.forEach((mixer) => {
  mixer.update(delta * animationSpeed.value);
});

This section advances all skeletal animations:

  • Animation Mixer Updates: Each animation mixer is updated based on the elapsed time
  • Speed Control: The delta time is multiplied by a user-controlled speed factor
    • This allows users to slow down or speed up animations without affecting other systems
    • Particularly useful for debugging or creating slow-motion effects
  • Multiple Mixers Support: The loop can handle multiple animated objects simultaneously

6. Camera Controls and Rendering

// Update orbit controls (for damping effect)
controls.update();

// Render scene
renderer.render(scene, camera);

This final section completes the frame:

  • Camera Control Update: Updates the OrbitControls to process any user input
    • The .update() call is required for the damping effect (smooth camera movement)
    • This creates the inertia-like behavior when orbiting the camera
  • Scene Rendering: Finally renders the current state of the scene from the camera’s perspective
    • This is where all the visual output is generated
    • The renderer applies materials, lighting, shadows, and other visual effects

Technical Advantages of This Approach

This animation loop design offers several important benefits:

  1. Separation of Concerns:
    • Physics, character control, animation, and rendering are kept separate
    • Each system can be enabled/disabled independently
    • Modular design makes debugging and extending the application easier
  2. Performance Optimization Opportunities:
    • Physics can be toggled for performance-critical situations
    • Object cleanup prevents unbounded growth in the number of tracked objects
    • Animation speed can be adjusted without affecting physics simulation
  3. State Synchronization:
    • Visual and physical representations stay in sync
    • UI state is updated each frame for accurate feedback
    • Character state correctly influences animation state
  4. Safety Mechanisms:
    • Character reset prevents “falling forever” scenarios
    • Collision detection provides user feedback
    • Object cleanup prevents memory leaks
  5. Frame Rate Independence:
    • Delta time ensures consistent animation speed regardless of device performance
    • Fixed physics timestep ensures simulation stability
    • Speed controls allow for adjustments without breaking the simulation

The animation loop is where all systems come together to create a seamless, interactive experience. By carefully orchestrating these updates in the correct order and managing frame timing, we create a fluid, responsive 3D application that maintains consistent behavior across different devices and frame rates.

Interactive UI Controls

A key feature of our demo is the ability to modify scene parameters in real-time through a UI panel:

<template>
  <div class="scene-container">
    <div ref="containerRef" class="three-container"></div>
    
    <!-- Loading screen -->
    <div v-if="isLoading" class="loading-screen">
      <div class="loading-content">
        <h2>Loading Three.js Scene</h2>
        <div class="progress-bar">
          <div class="progress-fill" :style="{ width: `${loadingProgress}%` }"></div>
        </div>
        <div class="loading-text">{{ Math.round(loadingProgress) }}%</div>
      </div>
    </div>
    
    <!-- Controls panel -->
    <div class="controls-toggle" @click="showControls = !showControls">
      {{ showControls ? '« Hide Controls' : 'Show Controls »' }}
    </div>
    
    <div class="controls-container" :class="{ 'controls-hidden': !showControls }">
      <div class="control-panel">
        <h3>Three.js Comprehensive Demo</h3>
        
        <!-- Tabs for different control categories -->
        <div class="tabs">
          <button 
            @click="activeTab = 'environment'" 
            :class="{ active: activeTab === 'environment' }"
            class="tab-button"
          >
            Environment
          </button>
          <button 
            @click="activeTab = 'physics'" 
            :class="{ active: activeTab === 'physics' }"
            class="tab-button"
          >
            Physics
          </button>
          <button 
            @click="activeTab = 'lighting'" 
            :class="{ active: activeTab === 'lighting' }"
            class="tab-button"
          >
            Lighting
          </button>
          <button 
            @click="activeTab = 'models'" 
            :class="{ active: activeTab === 'models' }"
            class="tab-button"
          >
            Models
          </button>
        </div>
        
        <!-- Tab content panels with various controls -->
        <!-- Environment tab -->
        <div v-if="activeTab === 'environment'" class="tab-content">
          <!-- Environment controls: sky color, ground color, textures, fog -->
        </div>
        
        <!-- Physics tab -->
        <div v-if="activeTab === 'physics'" class="tab-content">
          <!-- Physics controls: add objects, gravity, bounciness -->
        </div>
        
        <!-- Lighting tab -->
        <div v-if="activeTab === 'lighting'" class="tab-content">
          <!-- Lighting controls: shadows, ambient/directional/spotlight settings -->
        </div>
        
        <!-- Models tab -->
        <div v-if="activeTab === 'models'" class="tab-content">
          <!-- Character controls and information -->
          <div v-if="modelLoaded" class="control-group">
            <!-- Animation speed slider -->
            
            <div class="character-controls">
              <h4>Character Controls</h4>
              <div class="control-info">
                <div class="control-key">W</div>
                <div class="control-desc">Move Forward</div>
              </div>
              <!-- Other control key explanations -->
            </div>
            
            <!-- Character movement parameters -->
            
            <div class="character-position">
              <h4>Character Position</h4>
              <div>X: {{ characterPosition.x.toFixed(2) }}</div>
              <div>Y: {{ characterPosition.y.toFixed(2) }}</div>
              <div>Z: {{ characterPosition.z.toFixed(2) }}</div>
              <div>Animation: {{ currentAnimation }}</div>
              <div>Jumping: {{ isJumping ? 'Yes' : 'No' }}</div>
              <div>Grounded: {{ isGrounded ? 'Yes' : 'No' }}</div>
              <div class="collision-status" :class="{ 'collision-active': collisionDetected }">
                {{ collisionDetected ? 'Collision Detected!' : 'No Collision' }}
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

Reactive Properties and Resource Management

We use Vue’s reactivity system to update the scene when UI controls change:

// Watch for changes to update the scene
watch(fogEnabled, updateFog);
watch(fogDensity, updateFog);
watch(skyColor, () => {
  scene.background = new THREE.Color(skyColor.value);
  if (scene.fog) {
    (scene.fog as THREE.FogExp2).color.set(skyColor.value);
  }
});
watch(groundColor, updateGroundTexture);
watch(ambientIntensity, () => { ambientLight.intensity = ambientIntensity.value; });
watch(directionalIntensity, () => { directionalLight.intensity = directionalIntensity.value; });
watch(shadowsEnabled, () => { 
  directionalLight.castShadow = shadowsEnabled.value;
  spotLight.castShadow = shadowsEnabled.value;
});
// ... more watch handlers for other properties

Proper resource management is crucial for web-based 3D applications:

const cleanup = () => {
  // Cancel animation frame
  if (animationFrameId) {
    cancelAnimationFrame(animationFrameId);
  }
  
  // Remove event listeners
  window.removeEventListener('resize', onWindowResize);
  window.removeEventListener('keydown', handleKeyDown);
  window.removeEventListener('keyup', handleKeyUp);
  
  // Remove renderer from DOM
  if (containerRef.value && renderer) {
    containerRef.value.removeChild(renderer.domElement);
  }
  
  // Dispose of controls
  if (controls) {
    controls.dispose();
  }
  
  // Clean up all meshes and materials
  meshes.forEach(mesh => {
    if (mesh.geometry) mesh.geometry.dispose();
    if (mesh.material) {
      if (Array.isArray(mesh.material)) {
        mesh.material.forEach(m => m.dispose());
      } else {
        mesh.material.dispose();
      }
    }
  });
  
  // Clean up textures
  Object.values(textures).forEach(texture => {
    texture.dispose();
  });
};

// Initialize on mount, clean up on unmount
onMounted(() => {
  init();
});

onBeforeUnmount(() => {
  cleanup();
});

CSS Styling for UI Controls

The UI styling completes our application:

<style scoped>
.scene-container {
  position: relative;
  width: 100%;
  height: 100vh;
  overflow: hidden;
}

.three-container {
  width: 100%;
  height: 100%;
}

.loading-screen {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.8);
  display: flex;
  justify-content: center;
  align-items: center;
  z-index: 100;
}

/* ... more styling for UI elements ... */

.control-desc {
  flex: 1;
}

.character-position {
  background-color: rgba(255, 255, 255, 0.1);
  padding: 10px;
  border-radius: 4px;
  margin-top: 16px;
  text-align: center;
}

.character-position h4 {
  margin-top: 0;
  margin-bottom: 10px;
  color: #42b883;
}

.character-position div {
  margin-bottom: 5px;
}

.collision-status {
  margin-top: 8px;
  padding: 5px;
  border-radius: 4px;
  font-weight: bold;
  background-color: rgba(0, 255, 0, 0.2);
  transition: all 0.3s ease;
}

.collision-active {
  background-color: rgba(255, 0, 0, 0.4);
  color: white;
}
</style>

Key Technical Challenges

1. Character Movement and Collision Detection

Implementing character movement with collision detection requires a custom approach. We used a hybrid solution:

  1. Raycasting for Ground Detection: We detect whether the character is grounded by casting a ray downward.
  2. Sphere-Based Collision: We use a simplified sphere collider for character-object collision detection.
  3. Sliding Algorithm: When a collision is detected, we try to slide along walls by separating movement into x, y, and z components.
// Check if character is grounded
const checkGrounded = (): boolean => {
  if (!character) return true;
  
  // Check if we're at ground level (y=0)
  if (character.position.y <= 0.01) {
    // Also check if we're within the ground boundaries
    const groundSize = 30;
    const halfGroundSize = groundSize / 2;
    
    if (Math.abs(character.position.x) < halfGroundSize && 
        Math.abs(character.position.z) < halfGroundSize) {
      return true;
    }
    return false; // On ground level but outside boundaries
  }
  
  // Check if there's an object below us
  const groundCheckPosition = character.position.clone();
  groundCheckPosition.y -= 0.1; // Check just below the character's feet
  return checkCollision(groundCheckPosition);
};

2. Animation Transitions

Smooth transitions between animations require proper fade-in/fade-out timing:

// Only change animation if it's different from current
if (newAnimation !== currentAnimation.value) {
  // Fade out current animation
  if (characterActions[currentAnimation.value]) {
    characterActions[currentAnimation.value].fadeOut(0.2);
  }
  
  // Fade in new animation
  if (characterActions[newAnimation]) {
    characterActions[newAnimation].reset().fadeIn(0.2).play();
    currentAnimation.value = newAnimation;
  }
}

3. Physics-Visuals Synchronization

Ensuring physics bodies and their visual counterparts stay synchronized requires updating positions every frame:

// Update mesh positions based on physics bodies
for (let i = 0; i < meshes.length; i++) {
  meshes[i].position.copy(bodies[i].position as any);
  meshes[i].quaternion.copy(bodies[i].quaternion as any);
}

Performance Optimization Techniques

  1. Object Pooling: Reuse objects rather than creating/destroying them.
  2. Level of Detail (LOD): Use simpler meshes for distant objects.
  3. Physics Sleep: Allow inactive physics objects to “sleep” to save CPU.
  4. Texture Atlasing: Combine textures to reduce draw calls.
  5. Frustum Culling: Skip rendering objects outside the camera’s view.
  6. Boundary Checks: Remove objects that fall out of bounds to prevent unbounded growth.
// Check if objects are outside the ground boundaries
const checkObjectBoundaries = () => {
  const groundSize = 30;
  const halfGroundSize = groundSize / 2;
  const fallThreshold = -50; // How far objects can fall before being removed
  
  // Check each physics object
  for (let i = meshes.length - 1; i >= 0; i--) {
    const position = bodies[i].position;
    
    // Check if object has fallen off the ground
    if (Math.abs(position.x) > halfGroundSize || 
        Math.abs(position.z) > halfGroundSize || 
        position.y < fallThreshold) {
      
      // Remove the object from the scene and dispose resources
      scene.remove(meshes[i]);
      world.removeBody(bodies[i]);
      
      // Dispose of resources
      if (meshes[i].geometry) meshes[i].geometry.dispose();
      if (meshes[i].material) {
        if (Array.isArray(meshes[i].material)) {
          meshes[i].material.forEach(m => m.dispose());
        } else {
          meshes[i].material.dispose();
        }
      }
      
      // Remove from arrays
      meshes.splice(i, 1);
      bodies.splice(i, 1);
      objectCount.value--;
    }
  }
};

Conclusion

In this comprehensive tutorial, we’ve built a complete 3D interactive scene that combines many Three.js features:

  • Character animation with skeletal meshes
  • Physics simulation with CANNON.js
  • Dynamic lighting and shadows
  • Environmental effects like fog and textures
  • Interactive UI controls for real-time parameter adjustment
  • Collision detection and response

This example demonstrates how the individual components we’ve explored in previous tutorials can be combined to create an engaging 3D web application. The modular structure allows you to adapt and extend this foundation for your own projects, whether you’re building games, visualizations, or interactive experiences.

Key takeaways:

  1. Integration is Crucial: The power of Three.js comes from combining its various systems.
  2. Performance Matters: Always consider optimization, especially for web-based 3D.
  3. User Controls Add Value: Interactive parameters make your 3D applications more engaging.
  4. Resource Management: Proper cleanup prevents memory leaks in long-running applications.