instanced_InstancedTileLoader.js

import * as THREE from 'three';
import { LinkedHashMap } from 'js-utils-z';
import { B3DMDecoder } from "../../decoder/B3DMDecoder";
import { MeshTile } from './MeshTile';
import { JsonTile } from './JsonTile';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
import { KTX2Loader } from "three/addons/loaders/KTX2Loader";
import { resolveImplicite } from '../implicit/ImplicitTileResolver.js';
import { InstancedOGC3DTile } from './InstancedOGC3DTile';
import { MeshoptDecoder } from 'meshoptimizer';

let concurrentDownloads = 0;


/**
 * A Tile loader that manages caching and load order for instanced tiles.
 * The cache is an LRU cache and is defined by the number of items it can hold.
 * The actual number of cached items might grow beyond max if all items are in use.
 * 
 * The load order is designed for optimal perceived loading speed (nearby tiles are refined first).
 *
 */
class InstancedTileLoader {
    /**
     * Creates a tile loader with a maximum number of cached items and callbacks.
     * The only required property is a renderer that will be used to visualize the tiles.
     * The maxCachedItems property is the size of the cache in number of objects, mesh tile and tileset.json files.
     * The mesh and point callbacks will be called for every incoming mesh or points.
     *
     * @param {scene} [scene] - a threejs scene.
     * @param {Object} [options] - Optional configuration object.
     * @param {number} [options.maxCachedItems=100] - the cache size.
     * @param {number} [options.maxInstances=1] - the cache size.
     * @param {function} [options.meshCallback] - A callback to call on newly decoded meshes.
     * @param {function} [options.pointsCallback] - A callback to call on newly decoded points.
     * @param {sring} [options.proxy] - An optional proxy that tile requests will be directed too as POST requests with the actual tile url in the body of the request.
     * @param {KTX2Loader} [options.ktx2Loader = undefined] - A KTX2Loader (three/addons)
     * @param {DRACOLoader} [options.dracoLoader = undefined] - A DRACOLoader (three/addons)
     * @param {renderer} [options.renderer = undefined] - optional the renderer, this is required only for on the fly ktx2 support. not needed if you pass a ktx2Loader manually
     
     */
    constructor(scene, options) {
        this.zUpToYUpMatrix = new THREE.Matrix4();
        this.zUpToYUpMatrix.set(1, 0, 0, 0,
            0, 0, -1, 0,
            0, 1, 0, 0,
            0, 0, 0, 1);
        this.maxCachedItems = 100;
        this.maxInstances = 1;
        this.proxy = options.proxy;
        if (!!options) {
            this.meshCallback = options.meshCallback;
            this.pointsCallback = options.pointsCallback;
            if (options.maxCachedItems) this.maxCachedItems = options.maxCachedItems;
            if (options.maxInstances) this.maxInstances = options.maxInstances;

        }


        this.gltfLoader = new GLTFLoader();
        if (!!options && !!options.dracoLoader) {
            this.gltfLoader.setDRACOLoader(options.dracoLoader);
            this.hasDracoLoader = true;
        } else {
            const dracoLoader = new DRACOLoader();
            dracoLoader.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.4.3/');
            this.gltfLoader.setDRACOLoader(dracoLoader);
            this.gltfLoader.hasDracoLoader = true;
        }

        if (!!options && !!options.ktx2Loader) {
            this.gltfLoader.setKTX2Loader(options.ktx2Loader);
            this.hasKTX2Loader = true;
        } else if (!!options && !!options.renderer) {
            const ktx2Loader = new KTX2Loader();
            ktx2Loader.setTranscoderPath('https://storage.googleapis.com/ogc-3d-tiles/basis/').detectSupport(options.renderer);
            this.gltfLoader.setKTX2Loader(ktx2Loader);
            this.gltfLoader.hasKTX2Loader = true;
        }

        this.gltfLoader.setMeshoptDecoder(MeshoptDecoder);
        this.hasMeshOptDecoder = true;

        this.b3dmDecoder = new B3DMDecoder(this.gltfLoader);


        this.cache = new LinkedHashMap();
        this.scene = scene;

        this.ready = [];
        this.downloads = [];
        this.nextReady = [];
        this.nextDownloads = [];

    }

    /**
     * To be called in the render loop or at regular intervals.
     * launches tile downloading and loading in an orderly fashion.
     */
    update() {
        const self = this;
        self._checkSize();
        self.cache._data.forEach(v => {
            v.update();
        })

        if (concurrentDownloads < 8) {
            self._download();
        }
        self._loadBatch();
    }


    _download() {
        const self = this;
        if (self.nextDownloads.length == 0) {
            self._getNextDownloads();
            if (self.nextDownloads.length == 0) return;
        }
        while (self.nextDownloads.length > 0) {
            const nextDownload = self.nextDownloads.shift();
            if (!!nextDownload) {//} && nextDownload.shouldDoDownload()) {
                //nextDownload.doDownload();
                if (nextDownload.path.includes(".b3dm")) {
                    var fetchFunction;
                    if (!self.proxy) {
                        fetchFunction = () => {
                            return fetch(nextDownload.path);
                        }
                    } else {
                        fetchFunction = () => {
                            return fetch(self.proxy,
                                {
                                    method: 'POST',
                                    body: nextDownload.path
                                }
                            );
                        }
                    }
                    concurrentDownloads++;
                    fetchFunction().then(result => {
                        if (!result.ok) {
                            console.error("could not load tile with path : " + nextDownload.path)
                            throw new Error(`couldn't load "${nextDownload.path}". Request failed with status ${result.status} : ${result.statusText}`);
                        }
                        return result.arrayBuffer();

                    }).then(resultArrayBuffer => {
                        return this.b3dmDecoder.parseB3DMInstanced(resultArrayBuffer, (mesh) => { self.meshCallback(mesh, nextDownload.geometricError) }, self.maxInstances, nextDownload.sceneZupToYup, nextDownload.meshZupToYup);
                    }).then(mesh => {
                        mesh.frustumCulled = false;
                        nextDownload.tile.setObject(mesh);
                        self.ready.unshift(nextDownload);

                    }).catch(e => console.error(e))
                        .finally(() => {
                            concurrentDownloads--;
                        });
                } if (nextDownload.path.includes(".glb") || (nextDownload.path.includes(".gltf"))) {
                    var fetchFunction;
                    if (!self.proxy) {
                        fetchFunction = () => {
                            return fetch(nextDownload.path);
                        }
                    } else {
                        fetchFunction = () => {
                            return fetch(self.proxy,
                                {
                                    method: 'POST',
                                    body: nextDownload.path
                                }
                            );
                        }
                    }
                    concurrentDownloads++;
                    fetchFunction().then(result => {
                        if (!result.ok) {
                            throw new Error("missing content");
                        }
                        return result.arrayBuffer();
                    }).then(async arrayBuffer => {
                        await _checkLoaderInitialized(this.gltfLoader);
                        this.gltfLoader.parse(arrayBuffer, null, gltf => {
                            gltf.scene.asset = gltf.asset;

                            if (nextDownload.sceneZupToYup) {
                                gltf.scene.applyMatrix4(this.zUpToYUpMatrix);
                            }
                            gltf.scene.traverse((o) => {
                                o.geometricError = nextDownload.geometricError;
                                if (o.isMesh) {
                                    if (nextDownload.meshZupToYup) {
                                        o.applyMatrix4(this.zUpToYUpMatrix);
                                    }
                                    if (!!self.meshCallback) {
                                        self.meshCallback(o, o.geometricError);
                                    }
                                }
                                if (o.isPoints) {
                                    console.error("instanced point cloud is not supported");
                                }
                            });
                            let instancedMesh;
                            gltf.scene.updateWorldMatrix(false, true)
                            gltf.scene.traverse(child => {
                                //TODO several meshes in a single gltf
                                if (child.isMesh) {
                                    instancedMesh = new THREE.InstancedMesh(child.geometry, child.material, self.maxInstances);
                                    instancedMesh.baseMatrix = child.matrixWorld;
                                }

                            });
                            self.ready.unshift(nextDownload);
                            if (!instancedMesh) {
                                gltf.scene.traverse(c => {
                                    if (c.dispose) c.dispose();
                                    if (c.material) c.material.dispose();
                                });
                            } else {
                                instancedMesh.frustumCulled = false;
                                nextDownload.tile.setObject(instancedMesh);
                            }
                        });
                    }, e => {
                        console.error("could not load tile : " + nextDownload.path)
                    }).finally(() => {
                        concurrentDownloads--;
                    });



                } else if (nextDownload.path.includes(".json")) {
                    var fetchFunction;
                    if (!self.proxy) {
                        fetchFunction = () => {
                            return fetch(nextDownload.path);
                        }
                    } else {
                        fetchFunction = () => {
                            return fetch(self.proxy,
                                {
                                    method: 'POST',
                                    body: nextDownload.path
                                }
                            );
                        }
                    }
                    concurrentDownloads++;
                    fetchFunction().then(result => {
                        if (!result.ok) {
                            console.error("could not load tile with path : " + nextDownload.path)
                            throw new Error(`couldn't load "${nextDownload.path}". Request failed with status ${result.status} : ${result.statusText}`);
                        }
                        return result.json();

                    }).then(json => {
                        return resolveImplicite(json, nextDownload.path)
                    }).then(json => {
                        nextDownload.tile.setObject(json, nextDownload.path);
                        self.ready.unshift(nextDownload);
                    })
                        .catch(e => console.error(e)).finally(() => {
                            concurrentDownloads--;
                        });
                }
            }
        }
        return;
    }

    _loadBatch() {
        if (this.nextReady.length == 0) {
            this._getNextReady();
            if (this.nextReady.length == 0) return 0;
        }
        const download = this.nextReady.shift();
        if (!download) return 0;

        //if (!!download.tile.addToScene) download.tile.addToScene();
        return 1;
    }

    _getNextReady() {
        let smallestDistance = Number.MAX_VALUE;
        let closest = -1;
        for (let i = this.ready.length - 1; i >= 0; i--) {

            if (!this.ready[i].distanceFunction) {// if no distance function, must be a json, give absolute priority!
                this.nextReady.push(this.ready.splice(i, 1)[0]);
            }
        }
        if (this.nextReady.length > 0) return;
        for (let i = this.ready.length - 1; i >= 0; i--) {
            const dist = this.ready[i].distanceFunction() * this.ready[i].level;
            if (dist < smallestDistance) {
                smallestDistance = dist;
                closest = i
            }
        }
        if (closest >= 0) {
            const closestItem = this.ready.splice(closest, 1).pop();
            this.nextReady.push(closestItem);
            const siblings = closestItem.getSiblings();
            for (let i = this.ready.length - 1; i >= 0; i--) {
                if (siblings.includes(this.ready[i].uuid)) {
                    this.nextready.push(this.ready.splice(i, 1).pop());
                }
            }
        }
    }

    /**
     * Schedules a tile content to be downloaded
     * 
     * @param {AbortController} abortController 
     * @param {string} path path or url to tile content 
     * @param {string|Number} uuid tile id
     * @param {InstancedOGC3DTile} instancedOGC3DTile 
     * @param {Function} distanceFunction 
     * @param {Function} getSiblings 
     * @param {Number} level 
     * @param {Boolean} sceneZupToYup 
     * @param {Boolean} meshZupToYup 
     * @param {Number} geometricError 
     */
    get(abortController, path, uuid, instancedOGC3DTile, distanceFunction, getSiblings, level, sceneZupToYup, meshZupToYup, geometricError) {
        const self = this;
        const key = _simplifyPath(path);

        if (!path.includes(".b3dm") && !path.includes(".json") && !path.includes(".glb") && !path.includes(".gltf")) {
            console.error("the 3DTiles cache can only be used to load B3DM, gltf and json data");
            return;
        }

        const cachedTile = self.cache.get(key);
        if (!!cachedTile) {
            cachedTile.addInstance(instancedOGC3DTile);
            return;
        } else {

            if (path.includes(".b3dm") || path.includes(".glb") || path.includes(".gltf")) {
                const tile = new MeshTile(self.scene);
                tile.addInstance(instancedOGC3DTile);

                self.cache.put(key, tile);
                //self._checkSize();
                const realAbortController = new AbortController();
                abortController.signal.addEventListener("abort", () => {
                    if (tile.getCount() == 0) {
                        realAbortController.abort();
                    }
                })
                this.downloads.push({
                    abortController: realAbortController,
                    tile: tile,
                    key: key,
                    path: path,
                    distanceFunction: distanceFunction,
                    getSiblings: getSiblings,
                    level: level,
                    uuid: uuid,
                    sceneZupToYup: sceneZupToYup,
                    meshZupToYup: meshZupToYup,
                    geometricError: geometricError,
                    shouldDoDownload: () => {
                        return true;
                    },
                })
            } else if (path.includes(".json")) {
                const tile = new JsonTile();
                tile.addInstance(instancedOGC3DTile);
                self.cache.put(key, tile);
                //self._checkSize();
                const realAbortController = new AbortController();
                abortController.signal.addEventListener("abort", () => {
                    if (tile.getCount() == 0) {
                        realAbortController.abort();
                    }
                })
                this.downloads.push({
                    abortController: realAbortController,
                    tile: tile,
                    key: key,
                    path: path,
                    distanceFunction: distanceFunction,
                    getSiblings: getSiblings,
                    level: level,
                    shouldDoDownload: () => {
                        return true;
                    },
                })

            }
        }
    }



    _getNextDownloads() {
        let smallestDistance = Number.MAX_VALUE;
        let closest = -1;
        for (let i = this.downloads.length - 1; i >= 0; i--) {
            const download = this.downloads[i];
            if (!download.shouldDoDownload()) {
                this.downloads.splice(i, 1);
                continue;
            }
            if (!download.distanceFunction) { // if no distance function, must be a json, give absolute priority!
                this.nextDownloads.push(this.downloads.splice(i, 1)[0]);
            }
        }
        if (this.nextDownloads.length > 0) return;
        for (let i = this.downloads.length - 1; i >= 0; i--) {
            const download = this.downloads[i];
            const dist = download.distanceFunction() * download.level;
            if (dist < smallestDistance) {
                smallestDistance = dist;
                closest = i;
            }
        }
        if (closest >= 0) {
            const closestItem = this.downloads.splice(closest, 1).pop();
            this.nextDownloads.push(closestItem);
            const siblings = closestItem.getSiblings();
            for (let i = this.downloads.length - 1; i >= 0; i--) {
                if (siblings.includes(this.downloads[i].uuid)) {
                    this.nextDownloads.push(this.downloads.splice(i, 1).pop());
                }
            }
        }
    }

    _checkSize() {
        const self = this;

        let i = 0;

        while (self.cache.size() > self.maxCachedItems && i < self.cache.size()) {
            i++;
            const entry = self.cache.head();
            self.cache.remove(entry.key);
            if (!entry.value.dispose()) {
                self.cache.put(entry.key, entry.value);
            } else {
                //console.log("disposed and removed")
            }

        }
    }
}

async function _checkLoaderInitialized(loader) {

    const self = this;
    return new Promise((resolve) => {
        const interval = setInterval(() => {
            if ((!loader.hasDracoLoader || loader.dracoLoader) && (!loader.hasKTX2Loader || loader.ktx2Loader)) {
                clearInterval(interval);
                resolve();
            }
        }, 10); // check every 100ms
    });
};

function _simplifyPath(main_path) {

    var parts = main_path.split('/'),
        new_path = [],
        length = 0;
    for (var i = 0; i < parts.length; i++) {
        var part = parts[i];
        if (part === '.' || part === '' || part === '..') {
            if (part === '..' && length > 0) {
                length--;
            }
            continue;
        }
        new_path[length++] = part;
    }

    if (length === 0) {
        return '/';
    }

    var result = '';
    for (var i = 0; i < length; i++) {
        result += '/' + new_path[i];
    }

    return result;
}

export { InstancedTileLoader };