Source

TerrainViewer.js

/**
 * @file TerrainViewer.js
 * @author Emma Gould and Cody Smith
 * @date 2022
 *
 * @description An adaptive level of detail terrain viewer. It uses a given Digital Elevation Model in slippy tiles to render a terrain view with minimal wasted resources.
 *
 */

import * as THREE from 'three'

import { Evented } from '../ModelingCore/Evented.js'
import { debounced } from '../utils/debounced.js'
import { latLonFromAltAzDistance } from '../camera/latLong.js'

import { XYZTileNode } from './XYZTileNode.js'
import { GlobeReference } from './utils/GlobeReference.js'
import { CalibratedCamera } from './objects/CalibratedCamera.js'

import { getQuaternion } from './utils/utils.js'
import { latLonToSlippyXYZ } from './utils/utils.js'
import { a, ecef_lla, lla_ecef } from './utils/ECEF.js'
import { rotateQuaternionToECEF } from './utils/geoTools.js'
import {
    unpackPixel,
    renderToUint8Array,
    DepthShaderMaterial,
    TilePickingMaterial,
    TileIndexColorMaterial,
    ElevationShaderMaterial,
    RidgeLineShaderMaterial,
    TilesNeedUpdateMaterial,
    DepthColorShaderMaterial,
    RadialDistortionMaterial,
} from './utils/materialUtils.js'

/**
 * @typedef {Object} TerrainViewerOptions Describe the camera.
 * @property {number} s - Shear
 * @property {number} x0 - Principal point
 * @property {number} y0 - Principal point
 * @property {number} fov - Field of View in degrees
 * @property {number} width - width of the viewport in pixels
 * @property {number} height - height of the viewport in pixels
 * @property {number} Latitude - Latitude of the camera
 * @property {number} Longitude - Longitude of the camera
 * @property {number} Elevation - Elevation of the camera
 * @property {number} roll - Roll of the camera
 * @property {number} azimuth - Azimuth of the camera
 * @property {number} altitude - Altitude of the camera
 * @property {number} fov - Field of view in degrees
 */

/**
 * @class TerrainiViewer
 * Terrain viewer with adaptive level of detail.
 * <br/>
 * <a href="../test/test2.html">DEMO</a>
 *
 *
 *
 * @fires @event doneSplitting  When the viewer is done splitting tiles. This usually means that the level of detail is good.
 * @fires @event donePruning When the viewer is done pruning tiles.
 *
 * @param {TerrainViewerOptions} options
 */
export class TerrainViewer extends Evented {
    constructor(options = {}) {
        super()
        this.far = 100
        this.near = 0.001
        this.zoom = 0
        this.scale = 3000
        this.martiniError = 0.1

        this.debouncedSplitFire = debounced((argument) => {
            this.fire('doneSplitting', argument)
        }, 500)

        this.debouncedPruneFire = debounced((argument) => {
            this.fire('donePruning', argument)
        }, 500)

        // set up scenes
        this.scene = new THREE.Scene()
        this.rdScene = new THREE.Scene()

        // set up bounding box group
        //this.bboxGroup = new THREE.Group()
        //this.scene.add(this.bboxGroup)

        // set up renderer
        this.renderer = new THREE.WebGLRenderer({ alpha: true })
        this.renderer.setSize(this.width, this.height)
        this.renderer.domElement.onWheel = function (e) {
            e.preventDefault()
        }

        // set up radial distortion render target
        this.rdTarget = new THREE.WebGLRenderTarget(this.width, this.height)

        // set up depth render target
        this.depthTarget = new THREE.WebGLRenderTarget(this.width, this.height)

        // set up camera, globeReference, tileTree, etc.
        let rotationOptions = {}
        if (
            options.roll !== undefined ||
            options.azimuth !== undefined ||
            options.altitude !== undefined
        ) {
            rotationOptions.roll = options.roll !== undefined ? options.roll : 0
            rotationOptions.azimuth =
                options.azimuth !== undefined ? options.azimuth : 0
            rotationOptions.altitude =
                options.altitude !== undefined ? options.altitude : 0
        } else {
            rotationOptions.alpha =
                options.alpha !== undefined ? options.alpha : 270
            rotationOptions.beta = options.beta !== undefined ? options.beta : 0
            rotationOptions.gamma =
                options.gamma !== undefined ? options.gamma : 90
        }

        this.update({
            s: options.s || 0,
            r0: options.r0 || 0,
            r1: options.r1 || 0,
            r2: options.r2 || 0,
            r3: options.r3 || 0,
            x0: options.x0 || 0,
            y0: options.y0 || 0,
            fov: options.fov || 60,
            color: options.color || 0xffaa00,
            width: options.width || 640,
            height: options.height || 480,
            Latitude: options.Latitude || 35.210147,
            Longitude: options.Longitude || -106.449822,
            Elevation: options.Elevation || 3252,
            ...rotationOptions,
        })
    }

    /**
     *
     * @returns {Canvas} Return the DOM element of the renderer
     */
    getView() {
        return this.renderer.domElement
    }

    /**
     * Update the viewer's parameters. Basically updates everything...eventually.
     *
     * @param {TerrainViewerOptions} options
     */
    update(options = {}) {
        this.needsUpdate(options)
        //this.updateZoom()
        this.updateGlobeReference()
        this.updateTileTree()
        this.updateTileMeshes()
        this.updateMaterials()
        this.updateCamera()
    }

    /**
     * @private
     * @param {TerrainViewerOptions} options
     */
    needsUpdate(options = {}) {
        this.setRadialDistortion(options.r0, options.r1, options.r2, options.r3)
        this.setFOV(options.fov)
        this.setColor(options.color)
        this.setShear(options.s)
        this.setPosition(options.Latitude, options.Longitude, options.Elevation)
        this.setDimensions(options.width, options.height)
        this.setPrincipalPoint(options.x0, options.y0)
        this.setDeviceOrientation(options.alpha, options.beta, options.gamma)
        this.setAltitudeAzimuthRoll(
            options.altitude,
            options.azimuth,
            options.roll
        )
    }

    /**
     *
     * @param {Number} fov Field of View in degrees
     */
    setFOV(fov) {
        if (fov && fov > 0) {
            this.originalFOV = fov
        }
        this.fov =
            (180 / Math.PI) *
            2 *
            Math.atan(
                this.textureScale *
                    Math.tan((this.originalFOV * (Math.PI / 180)) / 2)
            )
        this.cameraNeedsUpdate = true
        this.materialsNeedUpdate = true
    }

    /**
     *
     * @param {String} color A color used by THREE.Color
     */
    setColor(color) {
        if (color) {
            this.color = color
            this.materialsNeedUpdate = true
        }
    }

    /**
     *
     * @param {Number} s Shear value.
     */
    setShear(s) {
        if (s !== undefined) {
            this.s = s
            this.cameraNeedsUpdate = true
        }
    }

    /**
     *
     * @param {Number} Latitude Degrees
     * @param {Number} Longitude Degrees
     * @param {Number} Elevation Elevatioin above the elpsoid in meters
     */
    setPosition(Latitude, Longitude, Elevation) {
        if (Latitude !== undefined && Latitude <= 90 && Latitude >= -90) {
            this.Latitude = Latitude
            this.cameraNeedsUpdate = true
            this.positionNeedsUpdate = true
        }

        if (Longitude !== undefined && Longitude >= -180 && Longitude <= 180) {
            this.Longitude = Longitude
            this.cameraNeedsUpdate = true
            this.positionNeedsUpdate = true
        }

        if (Elevation !== undefined && Elevation >= 0) {
            // TODO TODO TODO check that elevation value is not below terrain
            this.Elevation = Elevation
            //this.zoomNeedsUpdate = true
            this.cameraNeedsUpdate = true
            this.positionNeedsUpdate = true
        }
    }

    /**
     * Set the dimensions of the view port
     *
     * @param {Number} width Width in pixels
     * @param {Number} height Height in pixels
     */
    setDimensions(width, height) {
        if (width !== undefined && width > 0) {
            this.width = width
            this.cameraNeedsUpdate = true
            this.materialsNeedUpdate = true
        }

        if (height !== undefined && height > 0) {
            this.height = height
            this.cameraNeedsUpdate = true
            this.materialsNeedUpdate = true
        }

        if (this.cameraNeedsUpdate) {
            this.resizeRenderer()
        }
    }

    /**
     * @private
     */
    resizeRenderer() {
        // resize renderer
        //this.renderer.setSize(this.width * 1.5, this.height * 1.5)

        // resize render targets
        this.rdTarget.setSize(
            this.width * this.textureScale,
            this.height * this.textureScale
        )
        this.depthTarget.setSize(
            this.width * this.textureScale,
            this.height * this.textureScale
        )
    }

    /**
     *
     * @param {Number} x0
     * @param {Number} y0
     */
    setPrincipalPoint(x0, y0) {
        if (x0 !== undefined && x0 <= this.width && x0 >= -this.width) {
            this.x0_0 = x0
        }
        if (y0 !== undefined && y0 <= this.width && y0 >= -this.width) {
            this.y0_0 = y0
        }

        this.x0 = this.x0_0 / this.textureScale
        this.y0 = this.y0_0 / this.textureScale
        this.cameraNeedsUpdate = true
        this.materialsNeedUpdate = true
    }

    /**
     * Radial distortion coefficients.
     * let radius = distance from uv to principal point
     * uvᵣ = (1 + r0*radius + r1*radius² + r2*radius³ + r3*radius⁴) * uv
     *
     * @param {Number} r0
     * @param {Number} r1
     * @param {Number} r2
     * @param {Number} r3
     */
    setRadialDistortion(r0, r1, r2, r3) {
        if (r0 !== undefined) {
            this.r0 = r0
            this.cameraNeedsUpdate = true
            this.materialsNeedUpdate = true
        }

        if (r1 !== undefined) {
            this.r1 = r1
            this.cameraNeedsUpdate = true
            this.materialsNeedUpdate = true
        }

        if (r2 !== undefined) {
            this.r2 = r2
            this.cameraNeedsUpdate = true
            this.materialsNeedUpdate = true
        }

        if (r3 !== undefined) {
            this.r3 = r3
            this.cameraNeedsUpdate = true
            this.materialsNeedUpdate = true
        }

        if (this.materialsNeedUpdate) {
            this.updateTextureScale()
        }
    }

    /**
     * Eularian rotations.In devices coordinate system.
     * https://www.w3.org/TR/orientation-event/
     *
     * @param {Number} alpha
     * @param {Number} beta
     * @param {Number} gamma
     */
    setDeviceOrientation(alpha, beta, gamma) {
        if (alpha !== undefined) {
            this.alpha = alpha
            this.cameraNeedsUpdate = true
            this.orientationNeedsUpdate = true
        }

        if (beta !== undefined) {
            this.beta = beta
            this.cameraNeedsUpdate = true
            this.orientationNeedsUpdate = true
        }

        if (gamma !== undefined) {
            this.gamma = gamma
            this.cameraNeedsUpdate = true
            this.orientationNeedsUpdate = true
        }
    }

    /**
     * Rotation in the world reference frame.
     *
     * @param {Degrees} altitude
     * @param {Degrees} azimuth
     * @param {Degrees} roll
     */
    setAltitudeAzimuthRoll(altitude, azimuth, roll) {
        if (altitude !== undefined && altitude >= -90 && altitude <= 90) {
            this.altitude = altitude
            this.cameraNeedsUpdate = true
            this.navOrientationNeedsUpdate = true
        }

        if (azimuth !== undefined && azimuth >= 0 && azimuth <= 360) {
            this.azimuth = azimuth
            this.cameraNeedsUpdate = true
            this.navOrientationNeedsUpdate = true
        }

        if (roll !== undefined && roll >= -90 && roll <= 90) {
            this.roll = roll
            this.cameraNeedsUpdate = true
            this.navOrientationNeedsUpdate = true
        }
    }

    /**
     * Called internally
     * @private
     */
    updateZoom() {
        /**
         * We compute the area of interest by simplifying the Earth
         * to a sphere (circle in two dimensions) and finding the
         * tangent lines from the camera's elevation above the earth
         * to the circle approximating mean sea level
         *
         * (longer ellipse radius = a = 6378137 meters)
         * r - the radius of the circle
         * O - the center/origin of the circle
         * P₀ - the camera's position
         * P₁ and P₂ - the intersection with the circle
         *
         * Because we are approximating a cross section of the Earth
         * as a circle rather than an ellipse, we can assume that P₁
         * and P₂ will be the same distance regardless of what point
         * we choose. Therefore, we can simplify further by setting
         * P₀ along the x-axis, making the y coordinate 0
         *
         * This gives us:
         * P₀ = ( x₀, y₀ ) = ( x₀, 0 ) = ( elevation_in_ecef_coords, 0 )
         * P₁ = ( x₁, y₁ ) = ( r² / x₀², (r / x₀) * √(x₀² - r₀²) )
         * P₂ is P₁ reflected across the x axis, so:
         * P₂ = ( x₂, y₂ ) = ( x₁, -y₁ )
         */

        if (this.zoomNeedsUpdate) {
            let [x, y, z] = lla_ecef(0, 0, this.Elevation)

            let P1 = new THREE.Vector2(
                a ** 2 / x ** 2,
                (a / x) * Math.sqrt(x ** 2 - a ** 2)
            )
            let P2 = new THREE.Vector2(P1.x, -P1.y)

            // we can now compute the straight-line distance between P1 and P2 and
            // use it to calculate the zoom level of the initial tiles to bring in
            let distance = P1.distanceTo(P2)

            this.zoom = Math.floor(Math.log2((2 * Math.PI * a) / distance))

            // make sure tile zoom level brings in area around center
            if (!this.zoomLevelCoversAllTiles(distance / 2)) {
                if (this.zoom > 0) {
                    this.zoom -= 1
                    if (!this.zoomLevelCoversAllTiles(distance / 2)) {
                        if (this.zoom > 0) {
                            this.zoom -= 1
                        }
                    }
                }
            }
        }

        if (this.globeReference && this.globeReference.zoom !== this.zoom + 2) {
            this.tileTreeNeedsUpdate = true
            this.globeReferenceNeedsUpdate = true
        }
        this.zoomNeedsUpdate = false
    }

    zoomLevelCoversAllTiles(distance) {
        let tileX = []
        let tileY = []
        for (let az = 0; az < 2 * Math.PI; az += Math.PI / 2) {
            let [lat, lon, elev] = latLonFromAltAzDistance(
                [this.Latitude, this.Longitude, this.Elevation],
                0,
                az,
                distance
            )
            let [x, y, z] = latLonToSlippyXYZ(lat, lon, this.zoom)
            tileX.push(x)
            tileY.push(y)
        }

        if (
            tileX.every((v) => {
                v === tileX[0]
            }) &&
            tileY.every((v) => {
                v === tileY[0]
            })
        ) {
            return true
        }
        return false
    }

    updateCamera() {
        if (!this.camera) {
            this.camera = new CalibratedCamera({
                near: this.near,
                far: this.far,
            })
            this.camera.up.set(0, 0, 1)
        }

        if (this.cameraNeedsUpdate) {
            this.camera.s = this.s

            let f =
                this.width / (2 * Math.tan((this.fov / 2) * (Math.PI / 180)))
            this.camera.fx = f
            this.camera.fy = f

            this.camera.x0 = this.x0
            this.camera.y0 = this.y0

            this.camera.width = this.width
            this.camera.height = this.height

            if (this.orientationNeedsUpdate) {
                // camera orientation variables
                // georeference with respect to globeReference matrix
                // position of the camera should be at origin (0, 0, 0)
                let { x, y, z, w } = getQuaternion(
                    -this.alpha,
                    this.beta + 90,
                    this.gamma
                )

                this.camera.setRotationFromQuaternion(
                    new THREE.Quaternion(x, y, z, w)
                )

                // rotate camera with respect to its position on the Earth
                rotateQuaternionToECEF(
                    this.camera.quaternion,
                    this.globeReference.object3D,
                    this.Latitude,
                    this.Longitude
                )
            } else if (this.navOrientationNeedsUpdate) {
                // orientation in altitude, azimuth, and roll
                let lookVec = new THREE.Vector3(0, 1, 0)
                    .applyAxisAngle(
                        new THREE.Vector3(1, 0, 0),
                        this.altitude * (Math.PI / 180)
                    )
                    .applyAxisAngle(
                        new THREE.Vector3(0, 0, 1),
                        (90 - this.azimuth) * (Math.PI / 180)
                    )
                this.camera.lookAt(
                    new THREE.Vector3().addVectors(
                        this.camera.position,
                        lookVec
                    )
                )
                this.camera.updateMatrix()
                this.camera.updateProjectionMatrix()
                this.camera.matrix.multiply(
                    new THREE.Matrix4().makeRotationAxis(
                        new THREE.Vector3(0, 0, 1),
                        this.roll * (Math.PI / 180)
                    )
                )
                this.camera.rotation.setFromRotationMatrix(this.camera.matrix)

                // rotate camera with respect to its position on the Earth
                rotateQuaternionToECEF(
                    this.camera.quaternion,
                    this.globeReference.object3D,
                    this.Latitude,
                    this.Longitude
                )
            }

            if (this.positionNeedsUpdate) {
                let pos = new THREE.Vector3(
                    ...lla_ecef(this.Latitude, this.Longitude, this.Elevation)
                ).applyMatrix4(this.globeReference.getMatrix())
                this.camera.position.set(pos.x, pos.y, pos.z)
            }

            this.camera.updateMatrix()
            this.camera.updateProjectionMatrix()
            this._tilesNeedUpdate = true
        }

        this.cameraNeedsUpdate = false
        this.positionNeedsUpdate = false
        this.orientationNeedsUpdate = false
        this.navOrientationNeedsUpdate = false
    }

    updateTileTree() {
        if (!this.tileTree) {
            this.tileTree = new XYZTileNode(
                ...latLonToSlippyXYZ(this.Latitude, this.Longitude, this.zoom),
                null
            )
            this.tileTree
                .getThreeGroup(
                    this.martiniError,
                    this.globeReference.getMatrix(),
                    this.elevationMaterial
                )
                .then((threeGroup) => {
                    this.elevationMaterial.setMin(
                        threeGroup.children[0].geometry.attributes.elevation.min
                    )
                    this.elevationMaterial.setMax(
                        threeGroup.children[0].geometry.attributes.elevation.max
                    )
                    this.scene.add(threeGroup)
                    //this.bboxGroup.add(this.tileTree.bbox)
                    this._tilesNeedUpdate = true
                })
        } else if (this.tileTreeNeedsUpdate) {
            // tile tree root needs to get parent if new zoom is smaller than root zoom
            if (this.zoom < this.tileTree.getRoot().z) {
                this.tileTree.getRoot().createParent()
                this.tileTree = this.tileTree.getRoot()
                for (let c of this.tileTree.getChildren()) {
                    c.getThreeGroup(
                        this.martiniError,
                        this.globeReference.getMatrix(),
                        this.elevationMaterial
                    ).then(() => {
                        if (c.isLeaf() && !c.threeGroup.parent) {
                            this.scene.add(c.threeGroup)
                            //this.bboxGroup.add(c.bbox)
                        }
                    })
                }
            }
        }

        this.tileTreeNeedsUpdate = false
    }

    updateMaterials() {
        if (this.materialsNeedUpdate) {
            if (!this.depthMaterial) {
                this.depthMaterial = new DepthShaderMaterial()
            }
            if (!this.tileIndexMaterial) {
                this.tileIndexMaterial = new TilePickingMaterial()
            }
            if (!this.elevationMaterial) {
                this.elevationMaterial = new ElevationShaderMaterial()
            }
            if (!this.ridgeLineMaterial) {
                this.ridgeLineMaterial = new RidgeLineShaderMaterial({
                    width: this.width * this.textureScale,
                    height: this.height * this.textureScale,
                    depthTexture: this.depthTarget.texture,
                })
                this.overrideMaterial = this.ridgeLineMaterial
            }
            if (!this.depthColorMaterial) {
                this.depthColorMaterial = new DepthColorShaderMaterial({
                    depthTexture: this.depthTarget.texture,
                })
            }
            if (!this.tileIndexColorMaterial) {
                this.tileIndexColorMaterial = new TileIndexColorMaterial()
            }
            if (!this.radialDistortionMaterial) {
                // set up radial distortion 'lens'
                this.radialDistortionMaterial = new RadialDistortionMaterial({
                    r0: this.r0,
                    r1: this.r1,
                    r2: this.r2,
                    r3: this.r3,
                    texture: this.rdTarget.texture,
                    textureScale: this.textureScale,
                })
                let geometry = new THREE.BufferGeometry()
                geometry.setIndex(
                    new THREE.BufferAttribute(
                        new Uint8Array([0, 1, 2, 2, 3, 0]),
                        1
                    )
                )
                geometry.setAttribute(
                    'position',
                    new THREE.BufferAttribute(
                        new Float32Array([
                            -1, 1, 1, 1, 1, 1, 1, -1, 1, -1, -1, 1,
                        ]),
                        3
                    )
                )
                geometry.setAttribute(
                    'uv',
                    new THREE.BufferAttribute(
                        new Float32Array([0, 1, 1, 1, 1, 0, 0, 0]),
                        2
                    )
                )
                this.rdLens = new THREE.Mesh(
                    geometry,
                    this.radialDistortionMaterial
                )
                this.rdLens.frustumCulled = false
                this.rdLens.renderOrder = 1000
                this.rdLens.material.depthTest = false
                this.rdLens.material.depthWrite = false
                this.rdScene.add(this.rdLens)
            }
            if (!this.tilesNeedUpdateMaterial) {
                this.tilesNeedUpdateMaterial = new TilesNeedUpdateMaterial({
                    fov: this.fov * (Math.PI / 180),
                    scale: 1 / this.globeReference.getScale(),
                    height: this.height * this.textureScale,
                    depthTexture: this.depthTarget.texture,
                })
            }

            this.elevationMaterial.setColor(this.color)

            this.ridgeLineMaterial.setWidth(this.width * this.textureScale)
            this.ridgeLineMaterial.setHeight(this.height * this.textureScale)

            this.tilesNeedUpdateMaterial.setFov(this.fov * (Math.PI / 180))
            this.tilesNeedUpdateMaterial.setScale(
                1 / this.globeReference.getScale()
            )
            this.tilesNeedUpdateMaterial.setHeight(
                this.height * this.textureScale
            )

            this.radialDistortionMaterial.setTextureScale(this.textureScale)
            this.radialDistortionMaterial.setPrincipalPoint(
                new THREE.Vector2(this.x0 / this.width, this.y0 / this.height)
            )
            this.radialDistortionMaterial.setRadialDistortionCoefficients(
                this.r0,
                this.r1,
                this.r2,
                this.r3
            )

            this._tilesNeedUpdate = true
        }

        this.materialsNeedUpdate = false
    }

    updateTileMeshes() {
        if (this.meshesNeedUpdate) {
            for (let t of this.tileTree.getLeafNodes()) {
                t.getThreeGroup(
                    this.martiniError,
                    this.globeReference.getMatrix(),
                    this.elevationMaterial
                )
            }
        }
        this.meshesNeedUpdate = false
    }

    updateTextureScale() {
        if (this.r0 > 0 || this.r1 > 0 || this.r2 > 0 || this.r3 > 0) {
            this.textureScale = 1.5
        } else {
            this.textureScale = 1
        }
    }

    updateGlobeReference() {
        if (!this.globeReference) {
            this.globeReference = new GlobeReference({
                zoom: this.zoom + 2,
                scale: this.scale,
                Latitude: this.Latitude,
                Longitude: this.Longitude,
                Elevation: this.Elevation,
            })
        } else if (this.globeReferenceNeedsUpdate) {
            this.globeReference.setPosition(
                this.Latitude,
                this.Longitude,
                this.Elevation,
                this.zoom + 2,
                this.scale
            )
            this.meshesNeedUpdate = true
        }

        this.globeReferenceNeedsUpdate = false
    }

    updateTiles(indexData, zoomData) {
        if (!this._busySplitting && !this._busyPruning) {
            const tileData = this.readTileData(indexData, zoomData)

            const isSplitDone = Object.entries(tileData.tooLow).every(
                ([key, value]) => {
                    return !value.tile.canSplit()
                }
            )

            if (Object.keys(tileData.tooLow).length === 0 || isSplitDone) {
                this.debouncedSplitFire(tileData.tooLow)
            }

            this.splitTiles(tileData)
            this.pruneTiles(tileData)
        }
    }

    readTileData(indexData, zoomData) {
        let tiles = {}
        for (let j = this.height - 1; j >= 0; j--) {
            for (let i = this.width - 1; i >= 0; i--) {
                let index = unpackPixel(
                    i / this.width,
                    j / this.height,
                    indexData,
                    this.width,
                    this.height
                )
                let zoom = unpackPixel(
                    i / this.width,
                    j / this.height,
                    zoomData,
                    this.width,
                    this.height
                )

                if (zoom !== 256 * 255 && index !== 255 * 256) {
                    if (tiles[index] !== undefined) {
                        if (tiles[index] < zoom) {
                            tiles[index] = zoom
                        }
                    } else {
                        tiles[index] = zoom
                    }
                }
            }
        }

        const tileResults = { ids: [], tooHigh: {}, tooLow: {}, justRight: {} }
        for (let i in tiles) {
            let tile = this.tileTree.getNodeByID(i)

            if (!tile) {
                // console.log('tile not found', i)
            } else if (!tile.isLeaf()) {
                console.log('not a leaf', tile.toString())
            } else if (tile.z > tiles[i]) {
                tileResults.ids.push(Number(i))
                tileResults.tooHigh[i] = { tile, zoom: tiles[i] }
            } else if (tile.z < tiles[i]) {
                tileResults.ids.push(Number(i))
                tileResults.tooLow[i] = { tile, zoom: tiles[i] }
            } else {
                tileResults.ids.push(Number(i))
                tileResults.justRight[i] = { tile }
            }
        }
        return tileResults
    }

    /**
     * @private
     * @param {Object} tileData - pased from updateTiles
     */
    splitTiles(tileData) {
        if (!this._busySplitting) {
            this._busySplitting = true

            const promises = []

            // split
            const tooLow = tileData.tooLow
            for (let t in tooLow) {
                let result = tooLow[t]
                if (!result.tile.isBusy() && result.tile.canSplit()) {
                    promises.push(
                        this.splitNode(result.tile).then(() => {
                            this._tilesNeedUpdate = true
                        })
                    )
                }
            }

            Promise.all(promises).then(() => {
                this._busySplitting = false
                this.fire('split', this.tileTree.getAllNodesBelow().length)
            })
        }
    }

    /**
     * @private
     * @param {Object} tileData - passed from updateTiles
     * @param {number} leafNodeThreshold - number of leaf leaf nodes to keep before pruning. This helps with the thrashing; which is when a node is added then removed immediatly over and over.
     */
    pruneTiles(tileData) {
        if (!this._busyPruning) {
            this._busyPruning = true

            const promises = []
            const leaves = this.tileTree.getLeafNodes()
            const tilesThatAreTooLow = tileData.tooLow
            const tilesThatAreJustRight = tileData.justRight
            const tilesWithTooMuchDetail = tileData.tooHigh
            // prune
            for (let t of leaves) {
                // if all siblings have too much detail then they are prunable
                const siblings = t.getSiblings()
                const canBePruned =
                    siblings.length > 0 &&
                    siblings.every((s) => {
                        return (
                            s.isLeaf() &&
                            !tilesThatAreTooLow[s.id] &&
                            !tilesThatAreJustRight[s.id] &&
                            (!tileData.ids.includes(s.id) ||
                                tilesWithTooMuchDetail[s.id] !== undefined)
                        )
                    })
                // make sure no siblings are busy
                const siblingsNotBusy = siblings.every((s) => !s.isBusy())
                if (canBePruned && siblingsNotBusy) {
                    // remove siblings and add parent's mesh
                    promises.push(
                        this.combineNode(t.id).then(() => {
                            this._tilesNeedUpdate = true
                        })
                    )
                }
            }

            if (promises.length > 1) {
                Promise.all(promises).then(() => {
                    this._busyPruning = false
                    this.fire('prune', this.tileTree.getAllNodesBelow().length)
                    if (
                        Object.keys(tilesThatAreTooLow).length === 0 ||
                        Object.entries(tileData.tooLow).every(
                            ([key, value]) => {
                                return !value.tile.canSplit()
                            }
                        )
                    ) {
                        this.debouncedPruneFire('donePruning')
                    }
                })
            } else if (promises.length === 1) {
                console.log('flickering?')
            } else {
                this._busyPruning = false
            }
        }
    }

    async splitNode(node) {
        node.split()
        const promises = node.getChildren().map(async (child) => {
            await child.getThreeGroup(
                this.martiniError,
                this.globeReference.getMatrix(),
                this.elevationMaterial
            )
            return child
        })
        try {
            const results = await Promise.all(promises)
            this.scene.remove(node.threeGroup)
            //this.bboxGroup.remove(node.bbox)
            for (let i in results) {
                this.scene.add(results[i].threeGroup)
                //this.bboxGroup.add(results[i].bbox)
            }
            return results
        } catch (err) {
            node.MAX_ZOOM = node.z
            node.getChildren().forEach((child) => {
                child.removeNode()
            })
            console.warn(err)
        }
    }

    async combineNode(id) {
        // make sure that node exists, and that parent exists, because
        // we don't want to remove the parent if it is the first tile
        let node = this.tileTree.getNodeByID(id)
        let parent = node && node.parent
        if (node && parent) {
            await parent.getThreeGroup(
                this.martiniError,
                this.globeReference.getMatrix(),
                this.elevationMaterial
            )

            let siblings = node.getSiblings()
            for (let i in siblings) {
                this.scene.remove(siblings[i].threeGroup)
                //this.bboxGroup.remove(siblings[i].bbox)
                this.tileTree.removeNode(siblings[i])
            }

            this.scene.add(parent.threeGroup)
            //this.bboxGroup.add(parent.bbox)
        }
    }

    /**
     * <b> Update the scene.</b>
     *
     * This is usually called in an animation render loop.
     */
    render() {
        if (
            //this.zoomNeedsUpdate ||
            this.meshesNeedUpdate ||
            this.cameraNeedsUpdate ||
            this.materialsNeedUpdate ||
            this.positionNeedsUpdate ||
            this.tileTreeNeedsUpdate ||
            this.orientationNeedsUpdate ||
            this.globeReferenceNeedsUpdate
        ) {
            this.update()
        }

        // don't show bounding boxes during render
        /*let reset
        if (this.bboxGroup.visible) {
            this.bboxGroup.visible = false
            reset = true
        }*/

        this.renderer.setSize(
            this.textureScale * this.width,
            this.textureScale * this.height
        )
        if (this._tilesNeedUpdate) {
            // render to depth
            this.scene.overrideMaterial = this.depthMaterial
            this.renderer.setRenderTarget(this.depthTarget)
            this.renderer.render(this.scene, this.camera)

            // reset render target
            this.renderer.setRenderTarget(null)

            // render tile indices
            this.scene.overrideMaterial = this.tileIndexMaterial
            let indexData = renderToUint8Array(
                this.renderer,
                this.scene,
                this.camera
            )

            // render zoom correction data
            this.scene.overrideMaterial = this.tilesNeedUpdateMaterial
            let zoomData = renderToUint8Array(
                this.renderer,
                this.scene,
                this.camera
            )

            // reset override material
            this.scene.overrideMaterial = null

            // test
            this.updateTiles(indexData, zoomData)

            // set flag to false
            this._tilesNeedUpdate = false
        }

        // regular render without radial distortion
        this.scene.overrideMaterial = this.overrideMaterial
        this.renderer.setRenderTarget(this.rdTarget)
        this.renderer.render(this.scene, this.camera)
        this.renderer.setRenderTarget(null)
        this.scene.overrideMaterial = null

        this.renderer.setSize(this.width, this.height)

        // regular render with radial distortion
        this.renderer.render(this.rdScene, this.camera)

        /*if (reset) {
            this.bboxGroup.visible = true
        }*/
    }
}