Skip to content

Raycast 3D Tiles using ThreeJS

Use maplibre-gl-three to raycast against 3D Tiles and display the impact location.

import * as maplibregl from 'maplibre-gl';
import * as THREE from 'three';
import { ThreeDManager } from 'maplibre-gl-three';

const style = {
    "version": 8,
    "terrain": {
        "source": "mapterhorn"
    },
    "projection": {
        "type": "globe"
    },
    "layers": [
        {
            "id": "background-map",
            "type": "raster",
            "source": "osm-tiles"
        }
    ],
    "sources": {
        "mapterhorn": {
            "type": "raster-dem",
            "url": "https://tiles.mapterhorn.com/tilejson.json"
        },
        "osm-tiles": {
            "type": "raster",
            "tiles": [
                "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
            ],
            "tileSize": 256,
            "minzoom": 0,
            "maxzoom": 19,
            "attribution": "© OpenStreetMap contributors"
        },
    }
}
const map = new maplibregl.Map({
    container: 'map',
    zoom: 18,
    center: [-75.5967, 40.0387],
    pitch: 55,
    bearing: -20,
    maxPitch: 60,
    style,
    canvasContextAttributes: {antialias: true}
});

map.on('load', async () => {
    const threeDManager = new ThreeDManager();
    await threeDManager.init();
    const layer = threeDManager.createLayer({id: 'agi-hq-3d'});
    const tilesAsset = await layer.load3dTiles({
        tilesetUrl: 'https://pelican-public.s3.amazonaws.com/3dtiles/agi-hq/tileset.json',
        offset: {east: 0, up: -234, south: 0}
    });
    map.addLayer(layer);

    const scene = layer.three.getScene();
    const raycaster = new THREE.Raycaster();
    const pointer = new THREE.Vector2();
    const camera = layer.three.getCamera();
    const renderer = layer.three.getRenderer();
    const statusElement = document.getElementById('status');

    if (!renderer || !statusElement) return;

    // Prepare the raycast arrow but do not show it yet.
    const rayArrow = new THREE.ArrowHelper(
        new THREE.Vector3(0, 0, -1), new THREE.Vector3(), 1, 0xffcc33
    );
    rayArrow.visible = false;
    scene.add(rayArrow);

    renderer.domElement.addEventListener('click', function (event) {
        const result = raycastFromClick(event);
        if (result) {
            const {ecef_cameraOrigin, ecef_impactPoint} = result;
            renderHit(ecef_cameraOrigin, ecef_impactPoint);
        }
    });

    function renderHit(ecef_origin, ecef_impactPoint) {
        const ecef_direction = ecef_impactPoint.clone().sub(ecef_origin);
        const ecef_rayLength = ecef_direction.length();
        ecef_direction.normalize();

        drawArrow(ecef_origin, ecef_direction, ecef_rayLength);

        // Display the more familiar lngLatAlt WGS84 format in the GUI along with ECEF.
        const lngLatAlt_impactPoint = threeDManager.ecefToLngLatAlt(ecef_impactPoint);
        updateHtmlImpactText(lngLatAlt_impactPoint, ecef_impactPoint);
    }

    function raycastFromOriginAndDirection(ecef_origin, ecef_direction) {
        ecef_direction = ecef_direction.clone();
        if (ecef_direction.lengthSq() === 0) {
            statusElement.textContent = 'Cannot cast a ray with a zero direction.';
            return;
        }
        ecef_direction.normalize();
        raycaster.set(ecef_origin, ecef_direction);
        // The raycaster works internally in LocalSpace; this converts ECEF to LocalSpace.
        raycaster.ray.applyMatrix4(threeDManager.getAnchorEcefToLocalMatrix());
        const intersections = raycaster.intersectObject(tilesAsset.getObject3D(), true);
        const hit = intersections.length === 0 ? null : intersections[0];
        if (!hit) return null;
        // Convert LocalSpace back to ECEF.
        hit.point.applyMatrix4(threeDManager.getAnchorLocalToEcefMatrix());
        return hit.point;
    }
    function raycastFromClick(event) {
        // From pixels (MapLibre) to normalized device coordinates as expected by Three.js.
        const bounds = renderer.domElement.getBoundingClientRect();
        pointer.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1;
        pointer.y = -((event.clientY - bounds.top) / bounds.height) * 2 + 1;

        raycaster.setFromCamera(pointer, camera);
        // The camera ray is in LocalSpace and our raycast function expects ECEF. Convert to ECEF.
        raycaster.ray.applyMatrix4(threeDManager.getAnchorLocalToEcefMatrix());

        const ecef_cameraOrigin = raycaster.ray.origin.clone();
        const ecef_impactPoint = raycastFromOriginAndDirection(raycaster.ray.origin, raycaster.ray.direction);
        if (!ecef_impactPoint) {
            return null;
        }
        return {
            ecef_cameraOrigin,
            ecef_impactPoint
        }
    }

    // Functions used by renderHit are below. 
    function drawArrow(origin, direction, length) {
        rayArrow.position.copy(origin);
        rayArrow.setDirection(direction);
        rayArrow.setLength(length, length * 0.1, length * 0.03);
        rayArrow.visible = true;
    }
    function updateHtmlImpactText(lngLatAlt_impactPoint, ecef_impactPoint) {
        const str = 
        `Raycasting impact Point (WGS84)\n\n` +
        `lngLatAlt EPSG:4326/EPSG:9707: (lon: ${lngLatAlt_impactPoint.point[0].toFixed(6)}°, lat: ${lngLatAlt_impactPoint.point[1].toFixed(6)}°, alt: ${lngLatAlt_impactPoint.height.toFixed(1)}m orthometric)\n` + 
        `ECEF EPSG:4978:                (${ecef_impactPoint.x.toFixed(1)}m, ${ecef_impactPoint.y.toFixed(1)}m, ${ecef_impactPoint.z.toFixed(1)}m)`;
        console.log(str);
        statusElement.textContent = str;
    }
});
<!DOCTYPE html>
<html lang="en">
<head>
    <title>Raycast 3D Tiles using ThreeJS</title>
    <meta property="og:description" content="Use maplibre-gl-three to raycast against 3D Tiles and display the impact location." />
    <meta property="og:category" content="3D Models & Buildings" />
    <meta property="og:created" content="2026-10-05" />
    <meta charset='utf-8'>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel='stylesheet' href='https://unpkg.com/maplibre-gl@6.13.0/dist/maplibre-gl.css' />

    <style>
        body { margin: 0; padding: 0; }
        html, body, #map { height: 100%; }
        #status {
            position: fixed;
            left: 0;
            top: 0;
            z-index: 1;
            max-width: 100%;
            box-sizing: border-box;
            padding: 4px;
            background-color: black;
            color: white;
            font-family: monospace;
            white-space: pre-wrap;
            pointer-events: none;
        }
    </style>
</head>
<body>
<script src="https://cdn.jsdelivr.net/npm/proj4@2.22.0/dist/proj4.js"></script>
<script src="https://cdn.jsdelivr.net/npm/geotiff@3.0.5/dist-browser/geotiff.js"></script>
<script type="importmap">
    {
        "imports": {
            "three": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.module.js",
            "three/examples/jsm/": "https://cdn.jsdelivr.net/npm/three@0.186.1/examples/jsm/",
            "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.186.1/examples/jsm/",
            "3d-tiles-renderer": "https://cdn.jsdelivr.net/npm/3d-tiles-renderer@0.5.3/build/index.three.js",
            "3d-tiles-renderer/plugins": "https://cdn.jsdelivr.net/npm/3d-tiles-renderer@0.5.3/build/index.plugins.js",
            "maplibre-gl": "https://unpkg.com/maplibre-gl@6.13.0/dist/maplibre-gl.mjs",
            "proj4": "https://cdn.jsdelivr.net/npm/maplibre-gl-three@3.0.0/dist/type-wrappers/proj4-wrapper.js",
            "geotiff": "https://cdn.jsdelivr.net/npm/maplibre-gl-three@3.0.0/dist/type-wrappers/geotiff-wrapper.js",
            "maplibre-gl-three": "https://cdn.jsdelivr.net/npm/maplibre-gl-three@3.0.0/dist/maplibre-gl-three.js"
        }
    }
</script>
<div id="status">Click the 3D model to raycast</div>
<div id="map"></div>

<script type="module">
    import * as maplibregl from 'maplibre-gl';
    import * as THREE from 'three';
    import { ThreeDManager } from 'maplibre-gl-three';

    const style = {
        "version": 8,
        "terrain": {
            "source": "mapterhorn"
        },
        "projection": {
            "type": "globe"
        },
        "layers": [
            {
                "id": "background-map",
                "type": "raster",
                "source": "osm-tiles"
            }
        ],
        "sources": {
            "mapterhorn": {
                "type": "raster-dem",
                "url": "https://tiles.mapterhorn.com/tilejson.json"
            },
            "osm-tiles": {
                "type": "raster",
                "tiles": [
                    "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
                ],
                "tileSize": 256,
                "minzoom": 0,
                "maxzoom": 19,
                "attribution": "&copy; OpenStreetMap contributors"
            },
        }
    }
    const map = new maplibregl.Map({
        container: 'map',
        zoom: 18,
        center: [-75.5967, 40.0387],
        pitch: 55,
        bearing: -20,
        maxPitch: 60,
        style,
        canvasContextAttributes: {antialias: true}
    });

    map.on('load', async () => {
        const threeDManager = new ThreeDManager();
        await threeDManager.init();
        const layer = threeDManager.createLayer({id: 'agi-hq-3d'});
        const tilesAsset = await layer.load3dTiles({
            tilesetUrl: 'https://pelican-public.s3.amazonaws.com/3dtiles/agi-hq/tileset.json',
            offset: {east: 0, up: -234, south: 0}
        });
        map.addLayer(layer);

        const scene = layer.three.getScene();
        const raycaster = new THREE.Raycaster();
        const pointer = new THREE.Vector2();
        const camera = layer.three.getCamera();
        const renderer = layer.three.getRenderer();
        const statusElement = document.getElementById('status');

        if (!renderer || !statusElement) return;

        // Prepare the raycast arrow but do not show it yet.
        const rayArrow = new THREE.ArrowHelper(
            new THREE.Vector3(0, 0, -1), new THREE.Vector3(), 1, 0xffcc33
        );
        rayArrow.visible = false;
        scene.add(rayArrow);

        renderer.domElement.addEventListener('click', function (event) {
            const result = raycastFromClick(event);
            if (result) {
                const {ecef_cameraOrigin, ecef_impactPoint} = result;
                renderHit(ecef_cameraOrigin, ecef_impactPoint);
            }
        });

        function renderHit(ecef_origin, ecef_impactPoint) {
            const ecef_direction = ecef_impactPoint.clone().sub(ecef_origin);
            const ecef_rayLength = ecef_direction.length();
            ecef_direction.normalize();

            drawArrow(ecef_origin, ecef_direction, ecef_rayLength);

            // Display the more familiar lngLatAlt WGS84 format in the GUI along with ECEF.
            const lngLatAlt_impactPoint = threeDManager.ecefToLngLatAlt(ecef_impactPoint);
            updateHtmlImpactText(lngLatAlt_impactPoint, ecef_impactPoint);
        }

        function raycastFromOriginAndDirection(ecef_origin, ecef_direction) {
            ecef_direction = ecef_direction.clone();
            if (ecef_direction.lengthSq() === 0) {
                statusElement.textContent = 'Cannot cast a ray with a zero direction.';
                return;
            }
            ecef_direction.normalize();
            raycaster.set(ecef_origin, ecef_direction);
            // The raycaster works internally in LocalSpace; this converts ECEF to LocalSpace.
            raycaster.ray.applyMatrix4(threeDManager.getAnchorEcefToLocalMatrix());
            const intersections = raycaster.intersectObject(tilesAsset.getObject3D(), true);
            const hit = intersections.length === 0 ? null : intersections[0];
            if (!hit) return null;
            // Convert LocalSpace back to ECEF.
            hit.point.applyMatrix4(threeDManager.getAnchorLocalToEcefMatrix());
            return hit.point;
        }
        function raycastFromClick(event) {
            // From pixels (MapLibre) to normalized device coordinates as expected by Three.js.
            const bounds = renderer.domElement.getBoundingClientRect();
            pointer.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1;
            pointer.y = -((event.clientY - bounds.top) / bounds.height) * 2 + 1;

            raycaster.setFromCamera(pointer, camera);
            // The camera ray is in LocalSpace and our raycast function expects ECEF. Convert to ECEF.
            raycaster.ray.applyMatrix4(threeDManager.getAnchorLocalToEcefMatrix());

            const ecef_cameraOrigin = raycaster.ray.origin.clone();
            const ecef_impactPoint = raycastFromOriginAndDirection(raycaster.ray.origin, raycaster.ray.direction);
            if (!ecef_impactPoint) {
                return null;
            }
            return {
                ecef_cameraOrigin,
                ecef_impactPoint
            }
        }

        // Functions used by renderHit are below. 
        function drawArrow(origin, direction, length) {
            rayArrow.position.copy(origin);
            rayArrow.setDirection(direction);
            rayArrow.setLength(length, length * 0.1, length * 0.03);
            rayArrow.visible = true;
        }
        function updateHtmlImpactText(lngLatAlt_impactPoint, ecef_impactPoint) {
            const str = 
            `Raycasting impact Point (WGS84)\n\n` +
            `lngLatAlt EPSG:4326/EPSG:9707: (lon: ${lngLatAlt_impactPoint.point[0].toFixed(6)}°, lat: ${lngLatAlt_impactPoint.point[1].toFixed(6)}°, alt: ${lngLatAlt_impactPoint.height.toFixed(1)}m orthometric)\n` + 
            `ECEF EPSG:4978:                (${ecef_impactPoint.x.toFixed(1)}m, ${ecef_impactPoint.y.toFixed(1)}m, ${ecef_impactPoint.z.toFixed(1)}m)`;
            console.log(str);
            statusElement.textContent = str;
        }
    });
</script>
</body>
</html>