diff --git a/README.md b/README.md index a80f84b..01addce 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ This project provides you Three.js glTF loader/extension plugins even for such e * [EXT_texture_video](https://github.com/takahirox/EXT_texture_video) (Loader only) * [MSFT_lod](https://github.com/KhronosGroup/glTF/tree/master/extensions/2.0/Vendor/MSFT_lod) (Loader only, in progress) * [MSFT_texture_dds](https://github.com/KhronosGroup/glTF/tree/master/extensions/2.0/Vendor/MSFT_texture_dds) (Loader only) +* [MANYFOLD_mesh_progressive](https://github.com/manyfold3d/glTF/tree/MANYFOLD_mesh_progressive/extensions/2.0/Vendor/MANYFOLD_mesh_progressive#readme) (Loader only) ## Compatible Three.js revision diff --git a/examples/assets/gltf/Progressive/progressive.glb b/examples/assets/gltf/Progressive/progressive.glb new file mode 100644 index 0000000..1ddd348 Binary files /dev/null and b/examples/assets/gltf/Progressive/progressive.glb differ diff --git a/loaders/MANYFOLD_mesh_progressive/MANYFOLD_mesh_progressive.js b/loaders/MANYFOLD_mesh_progressive/MANYFOLD_mesh_progressive.js new file mode 100644 index 0000000..b73f2d3 --- /dev/null +++ b/loaders/MANYFOLD_mesh_progressive/MANYFOLD_mesh_progressive.js @@ -0,0 +1,19 @@ +import { + FileLoader, + LoaderUtils +} from 'three'; + +export default class GLTFManyfoldMeshProgressiveExtension { + constructor(parser, url, binChunkOffset) { + this.name = 'MANYFOLD_mesh_progressive'; + this.parser = parser; + this.url = url; + this.binChunkOffset = binChunkOffset; + console.log(`${this.name} loader created`) + } + + static load(url, loader, onLoad, onProgress, onError, fileLoader = null) { + console.log(`${this.name} falling back to original loader`) + loader.load(url, onLoad, onProgress, onError); + } +} diff --git a/loaders/MANYFOLD_mesh_progressive/README.md b/loaders/MANYFOLD_mesh_progressive/README.md new file mode 100644 index 0000000..1e45e6c --- /dev/null +++ b/loaders/MANYFOLD_mesh_progressive/README.md @@ -0,0 +1,5 @@ +# [Three.js](https://threejs.org) [GLTFLoader](https://threejs.org/docs/#examples/en/loaders/GLTFLoader) [EXT_texture_video](https://github.com/manyfold3d/glTF/tree/MANYFOLD_mesh_progressive/extensions/2.0/Vendor/MANYFOLD_mesh_progressive#readme) extension + +This extension implements a loader for [MANYFOLD_mesh_progressive](https://github.com/manyfold3d/glTF/tree/MANYFOLD_mesh_progressive/extensions/2.0/Vendor/MANYFOLD_mesh_progressive#readme) streams. + +Such streams load an initial base mesh in the usual way, then provide a stream of refinements which can be displayed incrementally. diff --git a/test/MANYFOLD_mesh_progressive.js b/test/MANYFOLD_mesh_progressive.js new file mode 100644 index 0000000..044d53b --- /dev/null +++ b/test/MANYFOLD_mesh_progressive.js @@ -0,0 +1,43 @@ +/* global QUnit */ + +import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; +import GLTFManyfoldMeshProgressiveExtension from '../loaders/MANYFOLD_mesh_progressive/MANYFOLD_mesh_progressive.js'; + +export default QUnit.module('MANYFOLD_mesh_progressive', () => { + QUnit.module('GLTFManyfoldMeshProgressiveExtension', () => { + QUnit.test('register', assert => { + const done = assert.async(); + new GLTFLoader() + .register(parser => new GLTFManyfoldMeshProgressiveExtension(parser)) + .parse('{"asset": {"version": "2.0"}}', null, result => { + assert.ok(true, 'can register'); + done(); + }, error => { + assert.ok(false, 'can register'); + done(); + }); + }); + }); + + QUnit.module('GLTFManyfoldMeshProgressiveExtension-webonly', () => { + QUnit.test('parse', assert => { + const done = assert.async(); + const assetPath = '../examples/assets/gltf/Progressive/progressive.glb'; + new GLTFLoader() + .register(parser => new GLTFManyfoldMeshProgressiveExtension(parser)) + .load(assetPath, gltf => { + let hasBaseMesh = false; + gltf.scene.traverse(object => { + if (object.isMesh) { + hasBaseMesh = true; + } + }); + assert.ok(hasBaseMesh, 'can parse base mesh'); + done(); + }, undefined, error => { + assert.ok(false, 'can load base mesh'); + done(); + }); + }); + }); +}); diff --git a/test/build/unit.js b/test/build/unit.js index b051391..ad58678 100644 --- a/test/build/unit.js +++ b/test/build/unit.js @@ -68488,215 +68488,231 @@ }); }); + const EXTENSION_NAME = 'MSFT_lod'; + const SCREENCOVERAGE_NAME = 'MSFT_screencoverage'; + const LOADING_MODES = { All: 'all', // Default Any: 'any', Progressive: 'progressive' }; - const removeLevel = (lod, obj) => { - const levels = lod.levels; - let readIndex = 0; + // LOD.clone() and copy() copies its children + // in the order of first objects not in the levels + // and then LOD objects in the levels order. + // This function ensures the children order follows + // that because it should be less problematic in case + // where lod object is cloned or copied and compared + // between source and cloned graph. + const _map = new Map(); + const sortChildrenOrder = (lod) => { + for (let i = 0; i < lod.levels.length; i++) { + _map.set(lod.levels[i].object); + } let writeIndex = 0; - for (readIndex = 0; readIndex < levels.length; readIndex++) { - if (levels[readIndex].object !== obj) { - levels[writeIndex++] = levels[readIndex]; + for (let readIndex = 0; readIndex < lod.children.length; readIndex++) { + if (!_map.has(lod.children[readIndex])) { + lod.children[writeIndex++] = lod.children[readIndex]; } } - if (writeIndex < readIndex) { - levels.length = writeIndex; - lod.remove(obj); + _map.clear(); + lod.children.length = writeIndex; + for (let i = 0; i < lod.levels.length; i++) { + lod.children.push(lod.levels[i].object); } }; const loadScreenCoverages = (def) => { const extras = def.extras; + const extensionsDef = def.extensions; - if (!extras) { + if (!extras || !extras[SCREENCOVERAGE_NAME] || + !extensionsDef || !extensionsDef[EXTENSION_NAME]) { return []; } - const screenCoverages = extras['MSFT_screencoverage']; + const screenCoverages = extras[SCREENCOVERAGE_NAME]; + const levelsLength = extensionsDef[EXTENSION_NAME].ids.length + 1; // extra field is free structure so validate more carefully - if (!screenCoverages || !Array.isArray(screenCoverages)) { + if (!screenCoverages || !Array.isArray(screenCoverages) || + screenCoverages.length !== levelsLength) { return []; } return screenCoverages; }; - /** - * LOD Extension - * - * Specification: https://github.com/KhronosGroup/glTF/tree/master/extensions/2.0/Vendor/MSFT_lod - */ - class GLTFLodExtension { - constructor(parser, options={}) { - this.name = 'MSFT_lod'; - this.parser = parser; - this.options = options; + // level: 0 is the highest level + const calculateDistance = (level, lowestLevel, def, options) => { + const coverages = loadScreenCoverages(def); + + // Use the distance set by users if calculateDistance callback is set + if (options.calculateDistance) { + return options.calculateDistance(level, lowestLevel, coverages); } - _hasLODMaterial(meshIndex) { - const parser = this.parser; - const json = parser.json; - const meshDef = json.meshes[meshIndex]; + if (level === 0) return 0; - // Ignore LOD + multiple primitives so far because - // it might be a bit too complicated. - // @TODO: Fix me - if (meshDef.primitives.length > 1) { - return null; - } + const levelsLength = def.extensions[EXTENSION_NAME].ids.length + 1; - for (const primitiveDef of meshDef.primitives) { - if (primitiveDef.material === undefined) { - continue; - } - const materialDef = json.materials[primitiveDef.material]; - if (materialDef.extensions && materialDef.extensions[this.name]) { - return true; - } - } - return false; - } - - _hasLODMaterialInNode(nodeIndex) { - const parser = this.parser; - const json = parser.json; - const nodeDef = json.nodes[nodeIndex]; + // 1.0 / Math.pow(2, level * 2) is just a number that seems to be heuristically good so far. + const c = levelsLength === coverages.length ? coverages[level - 1] : 1.0 / Math.pow(2, level * 2); - const nodeIndices = (nodeDef.extensions && nodeDef.extensions[this.name]) - ? nodeDef.extensions[this.name].ids.slice() : []; - nodeIndices.unshift(nodeIndex); + // This is just an easy approximation because it is not so easy to calculate the screen coverage + // (in Three.js) since it requires camera info, geometry info (boundary box/sphere), world scale. + // And also it needs to observe the change of them. + // If users want more accurate value, they are expected to use calculateDistance hook. + return 1.0 / c; + }; - for (const nodeIndex of nodeIndices) { - if (json.nodes[nodeIndex].mesh !== undefined && - this._hasLODMaterial(json.nodes[nodeIndex].mesh)) { - return true; - } - } + const getLODNodeDependency = (level, nodeIndices, parser) => { + const nodeIndex = nodeIndices[level]; - return false; + if (level > 0) { + return parser.getDependency('node', nodeIndex); } - // level: 0 is the highest level - _calculateDistance(level, lowestLevel, def) { - const coverages = loadScreenCoverages(def); - - // Use the distance set by users if calculateDistance callback is set - if (this.options.calculateDistance) { - return this.options.calculateDistance(level, lowestLevel, coverages); + // For the highest LOD GLTFLodExtension.loadNode() needs to be avoided + // to be called again + const extensions = Object.values(parser.plugins); + extensions.push(parser); + for (let i = 0; i < extensions.length; i++) { + const ext = extensions[i]; + if (ext.constructor === GLTFLodExtension) { + continue; + } + const result = ext.loadNode && ext.loadNode(nodeIndex); + if (result) { + return result; } - - if (level === 0) return 0; - - const levelsLength = def.extensions[this.name].ids.length + 1; - - // 1.0 / Math.pow(2, level * 2) is just a number that seems to be heuristically good so far. - const c = levelsLength === coverages.length ? coverages[level - 1] : 1.0 / Math.pow(2, level * 2); - - // This is just an easy approximation. If users want more accurate value, they are expected - // to use calculateDistance hook. - return 1.0 / c; } + throw new Error('Unreachable'); + }; - _assignOnBeforeRender(meshPending, clonedMesh, level, lowestLevel, def) { - const _this = this; - const currentOnBeforeRender = clonedMesh.onBeforeRender; - clonedMesh.onBeforeRender = function () { - const clonedMesh = this; - const lod = clonedMesh.parent; - meshPending.then(mesh => { - if (_this.options.onLoadMesh) { - mesh = _this.options.onLoadMesh(lod, mesh, level, lowestLevel); - } - removeLevel(lod, clonedMesh); - lod.addLevel(mesh, _this._calculateDistance(level, lowestLevel, def)); - if (_this.options.onUpdate) { - _this.options.onUpdate(lod, mesh, level, lowestLevel); - } - }); - clonedMesh.onBeforeRender = currentOnBeforeRender; - }; - } + const LOADING_STATES = { + NotStarted: 0, + Loading: 1, + Complete: 2 + }; - // For LOD in materials - loadMesh(meshIndex) { - if (!this._hasLODMaterial(meshIndex)) { - return null; + class GLTFProgressiveLOD extends LOD { + constructor(nodeIndices, parser, options) { + super(); + this._parser = parser; + this._options = options; + this._nodeIndices = nodeIndices; + this._lowestLevel = nodeIndices.length - 1; + this._states = []; + this._objectLevels = []; // Current object level set to this level + for (let i = 0; i < nodeIndices.length; i++) { + this._states[i] = LOADING_STATES.NotStarted; + this._objectLevels[i] = nodeIndices.length; } + } - const parser = this.parser; - const json = parser.json; - const meshDef = json.meshes[meshIndex]; - - const primitiveDef = meshDef.primitives[0]; - const materialIndex = primitiveDef.material; - const materialDef = json.materials[materialIndex]; - const extensionDef = materialDef.extensions[this.name]; - - const meshIndices = [meshIndex]; - // Very hacky solution. - // Clone the mesh def, replace the material index with a lower level one, - // add to json.meshes. - // @TODO: Fix me. Polluting json is a bad idea. - for (const materialIndex of extensionDef.ids) { - const clonedMeshDef = Object.assign({}, meshDef); - clonedMeshDef.primitives = [Object.assign({}, clonedMeshDef.primitives[0])]; - clonedMeshDef.primitives[0].material = materialIndex; - meshIndices.push(json.meshes.push(clonedMeshDef) - 1); - } + initialize() { + // Load only the lowest level as initialization. + // Progressively load the higher levels on demand. + // Assuming the lowest level LOD node has meshes for now. + // @TODO: Load the lowest visible node as initialization. + return this._loadLevel(this._lowestLevel).then(node => { + const nodeDef = this._parser.json.nodes[this._nodeIndices[0]]; + for (let level = 0; level < this._nodeIndices.length - 1; level++) { + this.addLevel(node.clone(), calculateDistance(level, this._lowestLevel, nodeDef, this._options)); + this._objectLevels[level] = this._lowestLevel; + } + this.addLevel(node, calculateDistance(this._lowestLevel, this._lowestLevel, nodeDef, this._options)); + this._objectLevels[this._lowestLevel] = this._lowestLevel; + if (this._options.onUpdate) { + this._options.onUpdate(this, node, this._lowestLevel, this._lowestLevel); + } + return this; + }); + } - const lod = new LOD(); - const lowestLevel = meshIndices.length - 1; + _loadLevel(level) { + this._states[level] = LOADING_STATES.Loading; + return getLODNodeDependency(level, this._nodeIndices, this._parser).then(node => { + this._states[level] = LOADING_STATES.Complete; + if (this._options.onLoadNode) { + node = this._options.onLoadNode(this, node, level, this._lowestLevel); + } + return node; + }); + } - if (this.options.loadingMode === LOADING_MODES.Progressive) { - const firstLoadLevel = meshIndices.length - 1; - return parser.loadMesh(meshIndices[firstLoadLevel]).then(mesh => { - if (this.options.onLoadMesh) { - mesh = this.options.onLoadMesh(lod, mesh, firstLoadLevel, lowestLevel); - } + _replaceLevelObject(object, levelNum) { + const level = this.levels[levelNum]; + const oldObject = level.object; + level.object = object; + this.remove(oldObject); + this.add(object); + sortChildrenOrder(this); + } - for (let level = 0; level < meshIndices.length - 1; level++) { - const clonedMesh = mesh.clone(); - this._assignOnBeforeRender(parser.loadMesh(meshIndices[level]), - clonedMesh, level, lowestLevel, materialDef); - lod.addLevel(clonedMesh, this._calculateDistance(level, lowestLevel, materialDef)); + update(camera) { + super.update(camera); + if (this._states[this._currentLevel] === LOADING_STATES.NotStarted) { + const level = this._currentLevel; + this._loadLevel(level).then(node => { + this._replaceLevelObject(node, level); + this._objectLevels[level] = level; + + // Replace the higher level objects with this level object + // if they are not loaded yet or if they are set lower level object + // than this level. + for (let i = 0; i < level; i++) { + if (this._states[i] !== LOADING_STATES.Complete && + this._objectLevels[i] > level) { + this._replaceLevelObject(node.clone(), i); + this._objectLevels[i] = level; + } } - lod.addLevel(mesh, this._calculateDistance(firstLoadLevel, lowestLevel, materialDef)); - if (this.options.onUpdate) { - this.options.onUpdate(lod, mesh, firstLoadLevel, lowestLevel); + if (this._options.onUpdate) { + this._options.onUpdate(this, node, level, this._lowestLevel); } - - return lod; }); - } else { - const pending = []; + } + } - for (let level = 0; level < meshIndices.length; level++) { - pending.push(parser.loadMesh(meshIndices[level]).then(mesh => { - if (this.options.onLoadMesh) { - mesh = this.options.onLoadMesh(lod, mesh, level, lowestLevel); - } - lod.addLevel(mesh, this._calculateDistance(level, lowestLevel, materialDef)); - if (this.options.onUpdate) { - this.options.onUpdate(lod, mesh, level, lowestLevel); - } - })); - } + clone(recursive) { + return new this.constructor(this._nodeIndices, this._parser, this._options).copy(this, recursive); + } - return (this.options.loadingMode === LOADING_MODES.Any - ? Promise.any(pending) - : Promise.all(pending) - ).then(() => lod); + copy(source, recursive = true) { + super.copy(source, recursive); + this._parser = source._parser; + this._options = source._options; + this._lowestLevel = source._lowestLevel; + for (let i = 0; i < source._nodeIndices.length; i++) { + this._nodeIndices[i] = source._nodeIndices[i]; + this._states[i] = source._states[i] === LOADING_STATES.Complete + ? LOADING_STATES.Complete : LOADING_STATES.NotStarted; + this._objectLevels[i] = source._objectLevels[i]; } + this._nodeIndices.length = source._nodeIndices.length; + this._states.length = source._states.length; + this._objectLevels.length = source._objectLevels.length; + return this; + } + } + + /** + * LOD Extension + * + * Specification: https://github.com/KhronosGroup/glTF/tree/master/extensions/2.0/Vendor/MSFT_lod + */ + // Note: This plugin doesn't support Material LOD for simplicity + class GLTFLodExtension { + constructor(parser, options={}) { + this.name = EXTENSION_NAME; + this.parser = parser; + this.options = options; } - // For LOD in nodes - createNodeMesh(nodeIndex) { + loadNode(nodeIndex) { const parser = this.parser; const json = parser.json; const nodeDef = json.nodes[nodeIndex]; @@ -68705,85 +68721,33 @@ return null; } - // If LODs are defined in both nodes and materials, - // ignore the ones in the nodes and use the ones in the materials. - // @TODO: Process correctly - // Refer to https://github.com/KhronosGroup/glTF/issues/1952 - if (this._hasLODMaterialInNode(nodeIndex)) { - return null; - } - const extensionDef = nodeDef.extensions[this.name]; // Node indices from high to low levels const nodeIndices = extensionDef.ids.slice(); nodeIndices.unshift(nodeIndex); - const lod = new LOD(); const lowestLevel = nodeIndices.length - 1; - for (let level = 0; level < nodeIndices.length; level++) { - const nodeDef = json.nodes[nodeIndices[level]]; - if (nodeDef.mesh === undefined) { - lod.addLevel(new Object3D(), this._calculateDistance(level, lowestLevel, nodeDef)); - } - } - if (this.options.loadingMode === LOADING_MODES.Progressive) { - let firstLoadLevel = null; - for (let level = nodeIndices.length - 1; level >= 0; level--) { - if (json.nodes[nodeIndices[level]].mesh !== undefined) { - firstLoadLevel = level; - break; - } - } - - if (firstLoadLevel === null) { - return Promise.resolve(lod); - } - - return parser.createNodeMesh(nodeIndices[firstLoadLevel]).then(mesh => { - if (this.options.onLoadMesh) { - mesh = this.options.onLoadMesh(lod, mesh, firstLoadLevel, lowestLevel); - } - - for (let level = 0; level < nodeIndices.length - 1; level++) { - if (json.nodes[nodeIndices[level]].mesh === undefined) { - continue; - } - const clonedMesh = mesh.clone(); - this._assignOnBeforeRender(parser.createNodeMesh(nodeIndices[level]), - clonedMesh, level, lowestLevel, nodeDef); - lod.addLevel(clonedMesh, this._calculateDistance(level, lowestLevel, nodeDef)); - } - lod.addLevel(mesh, this._calculateDistance(firstLoadLevel, lowestLevel, nodeDef)); - - if (this.options.onUpdate) { - this.options.onUpdate(lod, mesh, firstLoadLevel, lowestLevel); - } - - return lod; - }); + return new GLTFProgressiveLOD(nodeIndices, this.parser, this.options).initialize(); } else { + const lod = new LOD(); const pending = []; for (let level = 0; level < nodeIndices.length; level++) { - if (json.nodes[nodeIndices[level]].mesh === undefined) { - continue; - } - - pending.push(parser.createNodeMesh(nodeIndices[level]).then(mesh => { - lod.addLevel(mesh, this._calculateDistance(level, lowestLevel, nodeDef)); + pending.push(getLODNodeDependency(level, nodeIndices, parser).then(node => { + if (this.options.onLoadNode) { + node = this.options.onLoadNode(lod, node, level, lowestLevel); + } + lod.addLevel(node, calculateDistance(level, lowestLevel, nodeDef, this.options)); + sortChildrenOrder(lod); if (this.options.onUpdate) { - this.options.onUpdate(lod, mesh, level, lowestLevel); + this.options.onUpdate(lod, node, level, lowestLevel); } })); } - if (pending.length === 0) { - return Promise.resolve(lod); - } - return (this.options.loadingMode === LOADING_MODES.Any ? Promise.any(pending) : Promise.all(pending) @@ -69173,10 +69137,66 @@ }); }); + class GLTFManyfoldMeshProgressiveExtension { + constructor(parser, url, binChunkOffset) { + this.name = 'MANYFOLD_mesh_progressive'; + this.parser = parser; + this.url = url; + this.binChunkOffset = binChunkOffset; + console.log(`${this.name} loader created`); + } + + static load(url, loader, onLoad, onProgress, onError, fileLoader = null) { + console.log(`${this.name} falling back to original loader`); + loader.load(url, onLoad, onProgress, onError); + } + } + + /* global QUnit */ + + QUnit.module('MANYFOLD_mesh_progressive', () => { + QUnit.module('GLTFManyfoldMeshProgressiveExtension', () => { + QUnit.test('register', assert => { + const done = assert.async(); + new GLTFLoader() + .register(parser => new GLTFManyfoldMeshProgressiveExtension(parser)) + .parse('{"asset": {"version": "2.0"}}', null, result => { + assert.ok(true, 'can register'); + done(); + }, error => { + assert.ok(false, 'can register'); + done(); + }); + }); + }); + + QUnit.module('GLTFManyfoldMeshProgressiveExtension-webonly', () => { + QUnit.test('parse', assert => { + const done = assert.async(); + const assetPath = '../examples/assets/gltf/Progressive/progressive.glb'; + new GLTFLoader() + .register(parser => new GLTFManyfoldMeshProgressiveExtension(parser)) + .load(assetPath, gltf => { + let hasBaseMesh = false; + gltf.scene.traverse(object => { + if (object.isMesh) { + hasBaseMesh = true; + } + }); + assert.ok(hasBaseMesh, 'can parse base mesh'); + done(); + }, undefined, error => { + assert.ok(false, 'can load base mesh'); + done(); + }); + }); + }); + }); + // GLTFLoader accesses navigator.userAgent // but Node.js doesn't seems to have it so // defining it here as workaround. // @TODO: Fix the root issue in Three.js - global.navigator = {userAgent: ''}; + global.navigator = { userAgent: '' }; })); diff --git a/test/index.js b/test/index.js index cb614e9..f40cdbc 100644 --- a/test/index.js +++ b/test/index.js @@ -2,7 +2,7 @@ // but Node.js doesn't seems to have it so // defining it here as workaround. // @TODO: Fix the root issue in Three.js -global.navigator = {userAgent: ''}; +global.navigator = { userAgent: '' }; import './KHR_materials_variants.js' import './EXT_mesh_gpu_instancing.js' @@ -10,3 +10,4 @@ import './EXT_text.js' import './EXT_texture_video.js' import './MSFT_lod.js' import './MSFT_texture_dds.js' +import './MANYFOLD_mesh_progressive.js'