Interactive 3D Elevation Maps with Svelte and Three.js: Bringing Terrain Models to the Web for Accessible and Engaging Visualizations

Let’s visualize the Zion National Park (UTAH) in 3D using Svelte and ThreeJs while allowing us to interactively rotate, pan, and zoom to uncover the park’s stunning terrain.

Loading

Creating the 3D Model for Zion National Park

The R programming language ecosystem offers a vast array of packages designed for working with spatial data, making it an excellent choice for creating 3D maps. One such package is rayshader, an open-source R package that enables the production of 2D and 3D visualizations from elevation data.

To create the 3D model of Zion National Park, I used the rayshader package from R. Detailed steps on how to achieve this can be found in the blog post A Step-by-Step Guide to Making 3D Maps with Satellite Imagery in R by Tyler Morgan-Wall. The guide walks through the process of obtaining elevation data, processing it with rayshader, and visualizing it in R in 3D. Here is a link for the final data for you to work with directly.

Advantages of Visualizing the 3D Model in a Web Application with Svelte and Three.js

Bringing the 3D model of Zion National Park into a web application with Svelte and Three.js offers key advantages over visualizing it in R. While rayshader in R creates detailed 3D visualizations, they are often limited to static or local interactions. Three.js allows the model to be embedded on a web page, making it accessible to a broader audience without specialized software. Users can interact with the model in real-time, rotating, zooming, and panning within their browser, enhancing engagement and making complex terrain data more accessible and immersive for everyone.

Exporting the Model

Rayshader also allows exporting 3D models in various formats, including the .OBJ file format, which can be used in 3D applications. For this project, I exported the terrain model of Zion National Park as an .OBJ file using rayshader’s built-in save_obj() functionality.

Conversion to GLB Using Blender

After exporting the model to .OBJ format, I used Blender, a popular open-source 3D software, to convert the .OBJ file into a .GLB file format. The .GLB format is more suitable for web applications, as it is compact and optimized for loading in browsers.

Using Threlte

Although I used Three.js directly in this project, there is a library called Threlte that simplifies integrating Three.js with Svelte by providing a more Svelte-friendly API and reducing boilerplate code. Threlte is particularly good for managing 3D scenes and objects declaratively within Svelte, making it easier to work with Three.js models and animations. However, since I am using Svelte 5 and Threlte currently has compatibility issues with it, I chose to publish this project using the Three.js version. Despite this, I used the @threlte/gltf package from Threlte to transform my model with the command npx @threlte/gltf@latest model.gltf —transform, which created a compressed, optimized version of the model for use in the application.

Final Code using the 3D Model in Svelte with Three.js

Basic ThreeJS Components

Three.js is a popular JavaScript library that helps you create 3D graphics in web browsers. It lets you build 3D scenes by arranging shapes, lights, and cameras, much like setting up a photo shoot. With Three.js, you can make anything from simple 3D shapes to complex, interactive animations that run directly in the browser, making it a great tool for adding immersive 3D content to websites.

Here is the full code how it works together in one piece:

<script>
  import { onMount, onDestroy } from 'svelte';
  import * as THREE from 'three';
  import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
  import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
  import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
  import { tweened } from 'svelte/motion';
  import { cubicOut } from 'svelte/easing';
  import { loadingProgress } from '$lib/stores/common';

  // DOM container for the Three.js scene and initial dimensions
  let container;
  let width = 800; // Fixed width for the renderer
  let height = 420; // Fixed height for the renderer

  // Core Three.js components
  let scene, camera, renderer, controls;
  let model, mixer; // Variables for the 3D model and animations
  let clock = new THREE.Clock(); // Clock for managing animations
  let modelLoaded = false; // Flag to indicate whether the model has been loaded

  // Create a tweened store to animate the progress
  let tweenedProgress = tweened(0, { duration: 2000, easing: cubicOut });

  // Reactive declaration to update tweened progress based on loading progress
  $: if ($loadingProgress === 100) {
    tweenedProgress.set(100);
  }

  function initializeScene() {
    if (!container) {
      console.error('Container element is not available');
      return;
    }

    // Set up the scene, camera, and renderer
    scene = new THREE.Scene();

    // Create a perspective camera with specific field of view, aspect ratio, and viewing frustum
    camera = new THREE.PerspectiveCamera(25, width / height, 0.1, 1000);
    camera.position.set(10, 8, 12); // Position the camera to frame the model nicely
    scene.background = new THREE.Color(0x090b1f); // Set the background color of the scene

    // Initialize the WebGL renderer
    renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
    renderer.setSize(width, height); // Set the renderer's size to match the container dimensions
    container.appendChild(renderer.domElement); // Attach the renderer to the container DOM element

    // Add ambient light to softly illuminate the scene from all directions
    const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
    scene.add(ambientLight);

    // Add directional light to simulate sunlight
    const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
    directionalLight.position.set(5, 10, 7.5).normalize(); // Position and normalize the light direction
    scene.add(directionalLight);

    // Add a point light for additional illumination
    const pointLight = new THREE.PointLight(0xffffff, 0.8);
    pointLight.position.set(-5, 5, 5); // Position the point light
    scene.add(pointLight);

    // Enable shadow maps in the renderer for realistic lighting and shadows
    renderer.shadowMap.enabled = true;
    renderer.shadowMap.type = THREE.PCFSoftShadowMap; // Use soft shadows
    directionalLight.castShadow = true; // Enable shadow casting for the directional light
    pointLight.castShadow = true; // Enable shadow casting for the point light

    // Set up OrbitControls to allow user interaction with the model (rotation, zoom, pan)
    controls = new OrbitControls(camera, renderer.domElement);
    controls.enableDamping = true; // Enable damping for smoother control experience
    controls.dampingFactor = 0.25; // Set damping factor
    controls.screenSpacePanning = false; // Disable panning in screen space
    controls.minDistance = 0.5; // Set minimum zoom distance
    controls.maxDistance = 10; // Set maximum zoom distance
    controls.update();

    // Load the 3D model if it hasn't been loaded yet
    if (!modelLoaded) {
      loadModel();
    }

    // Start the animation loop
    animate();
  }

  function loadModel() {
    // Initialize the Draco loader for handling compressed models
    const dracoLoader = new DRACOLoader();
    dracoLoader.setDecoderPath('https://www.gstatic.com/draco/v1/decoders/'); // Set path for Draco decoder

    // Initialize GLTFLoader and attach the Draco loader
    const loader = new GLTFLoader();
    loader.setDRACOLoader(dracoLoader);

    // URL of the GLB file to load
    const url = 'zion2-transformed.glb';

    // Load the GLTF model
    loader.load(
      url,
      (gltf) => {
        // Add the loaded model to the scene and scale it
        model = gltf.scene;
        model.scale.set(0.005, 0.005, 0.005);
        scene.add(model);

        // Play animations if the model contains any
        if (gltf.animations.length > 0) {
          mixer = new THREE.AnimationMixer(model);
          gltf.animations.forEach((clip) => {
            mixer.clipAction(clip).play(); // Play each animation clip
          });
        }

        modelLoaded = true; // Set the model loaded flag to true
        loadingProgress.set(100); // Update loading progress to 100%
      },
      (xhr) => {
        // Update loading progress based on loaded bytes
        const progress = (xhr.loaded / xhr.total) * 100;
        loadingProgress.set(progress); // Update the progress store
      },
      (error) => {
        // Log any errors encountered while loading the model
        console.error('An error occurred while loading the GLTF model:', error);
      }
    );
  }

  function animate() {
    // Function to animate the scene
    if (renderer && scene && camera) {
      requestAnimationFrame(animate); // Request the next animation frame
      controls.update(); // Update the controls for any changes
      const delta = clock.getDelta(); // Get the time elapsed since the last frame
      if (mixer) mixer.update(delta); // Update the animations based on elapsed time
      if (model) {
        model.rotation.y += 0.0035; // Rotate the model slightly for a dynamic view
      }
      renderer.render(scene, camera); // Render the scene from the perspective of the camera
    }
  }

  onMount(() => {
    // Set up the scene when the component mounts
    if (container) {
      initializeScene();
    }
  });

  onDestroy(() => {
    // Cleanup function to dispose of renderer resources when component is destroyed
    if (renderer) {
      renderer.dispose();
    }
  });
</script>

<!-- Container for the 3D scene -->
<div bind:this={container} class="scene-container">
  {#if $tweenedProgress < 100}
    <!-- Loading screen displayed while the model is loading -->
    <div class="loading-wrapper">
      <p class="loading-text">Loading</p>
      <div class="loading-bar-wrapper">
        <div class="loading-bar" style="width: {$tweenedProgress}%;" />
      </div>
    </div>
  {/if}
</div>

<style>
  .scene-container {
    position: relative;
    width: 100%;
    min-height: 420px; /* Set an initial minimum height */
    height: 100%; /* Maintain full height */
  }

  .loading-wrapper {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background-color: rgba(9, 11, 31, 0.9);
    display: flex;
    flex-direction: column;
    gap: 0.25rem;
    align-items: center;
    justify-content: center;
    color: #ffffff;
    z-index: 10;
  }

  .loading-text {
    font-size: 1.5rem;
    font-weight: bold;
    color: #ffffff;
  }

  .loading-bar-wrapper {
    width: 33.333333%;
    height: 10px;
    border: 1px solid #ffffff;
    background-color: #1c1e24;
    border-radius: 5px;
  }

  .loading-bar {
    height: 100%;
    background-color: #4caf50;
    transition: width 0.2s ease;
    border-radius: 5px;
  }
</style>
// common.ts
import { writable, type Writable } from 'svelte/store';

export let loadingProgress = writable(0);

By leveraging the powerful tools in the R ecosystem for data preparation and combining them with Svelte and Three.js for web visualization, this project allow us to see how advanced spatial data can be brought to life in an interactive 3D environment and enable a much better understanding of a location’s topography.

Thanks for reading! In case you have a question or comment please join us on Discord!