UNPKG

@vrspace/babylonjs

Version:

vrspace.org babylonjs client

717 lines (654 loc) 22.6 kB
import { VRSPACE } from '../client/vrspace.js'; import { ScriptLoader } from '../client/script-loader.js'; import { AssetLoader } from '../core/asset-loader.js'; import { LoadProgressIndicator } from './load-progress-indicator.js'; import { HUD } from './hud.js'; import { ServerFolder } from '../core/server-folder.js'; /** Main UI class, provides utility methods and basic UI elements. @class */ export class VRSpaceUI { static instance = null; /** Creates UI with default LoadProgressIndicator */ constructor() { if (VRSpaceUI.instance) { throw "There can be only one"; } /** babylon scene*/ this.scene = null; /** content base (prefix), default empty (same host) */ this.contentBase = ''; /** Path to logo, null defaults to contentBase+/babylon (vrspace.org logo)*/ this.logoPath = null; /** Logo file name, defaults to logo.glb */ this.logoFile = "logo.glb"; /** vrspace.org logo mesh */ this.logo = null; /** Path to logo, null defaults to contentBase+/babylon/portal */ this.portalPath = null; /** Portal file name, defaults to scene.gltf */ this.portalFile = "scene.gltf"; /** portal mesh */ this.portal = null; /** debug output enabled */ this.debug = false; /** frames per second */ this.fps = 5; // CHECKME: reasonable default fps /** Pointer to function, defaults to this.loadProgressIndiciatorFactory */ this.loadProgressIndicator = (scene, camera) => this.loadProgressIndicatorFactory(scene, camera); /** Head-up display * @type {HUD} */ this.hud = null; this.selectables = []; /** babylon GUI manager - multiple instances may cause issues with transparency */ this.guiManager = null; /** Script loader */ this.scriptLoader = new ScriptLoader(); /** VR availability */ this.canVR = null; /** AR availability */ this.canAR = null; /** @private */ this.indicator = null; /** @private */ this.initialized = false; /** @private */ this.optimizingScene = false; /** reference to VRSpace singleton */ this.VRSPACE = VRSPACE; /** reference to AssetLoader singleton */ this.assetLoader = null; /** UI Material, created from init, defaults to color 0.2,0.2,0.3, with 0.7 alpha */ this.uiMaterial = null; VRSpaceUI.instance = this; } /** * Creates asset loader, preloads vrspace.org logo and portal for later use. @param scene babylon.js scene to operate with. */ async init(scene) { if (!this.initialized || this.scene !== scene) { this.scene = scene; try { if (!this.hud) { this.hud = new HUD(scene); this.guiManager = this.hud.guiManager; } } catch (exception) { console.log("WARNING: Can't create HUD - make sure to load babylon.gui.min.js", exception); } this.assetLoader = new AssetLoader(this.scene); this.canVR = await BABYLON.WebXRSessionManager.IsSessionSupportedAsync("immersive-vr"); this.canAR = await BABYLON.WebXRSessionManager.IsSessionSupportedAsync("immersive-ar"); // TODO figure out location of script if (this.logoDir() && this.logoFile) { var container = await BABYLON.SceneLoader.LoadAssetContainerAsync(this.logoDir(), this.logoFile, this.scene); this.logo = container.meshes[0]; for (var i = 0; i < container.meshes; i++) { container.meshes[i].checkCollisions = false; } this.logo.name = "VRSpace.org Logo"; } this.uiMaterial = new BABYLON.StandardMaterial("uiMaterial", this.scene); this.uiMaterial.alpha = 0.7; this.uiMaterial.diffuseColor = new BABYLON.Color3(.2, .2, .3); this.uiMaterial.specularColor = new BABYLON.Color3(.05, .05, .05); await this.loadPortal(scene); this.initialized = true; } return this; } /** Used in init, return logPath if exists, or default path to vrspace.org logo */ logoDir() { if (this.logoPath != null) { return this.logoPath; } return this.contentBase + "/babylon/"; } /** Returns portalPath if exists, defaults to contentBase+/babylon/portal */ portalDir() { if (this.portalPath) { return this.portalPath; } return this.contentBase + "/babylon/portal/"; } /** Creates default LoadProgressIndicator bound to given camera, if one does not already exist. @param scene @param camera @returns load progress indicator */ async loadProgressIndicatorFactory(scene, camera) { if (!this.indicator) { this.indicator = new LoadProgressIndicator(scene, camera); } return this.indicator; } /** Logs to js console if debug is enabled @param something to log */ log(something) { if (this.debug) { console.log(something); } } /** loads the portal @param scene */ async loadPortal(scene) { if (!this.portal) { var container = await BABYLON.SceneLoader.LoadAssetContainerAsync(this.portalDir(), this.portalFile, scene) container.materials[0].albedoColor = BABYLON.Color3.FromHexString('#B3EEF3'); container.materials[0].metallic = 0.85; this.portal = container.createRootMesh(); this.portal.rotation = new BABYLON.Vector3(0, Math.PI / 2, 0); this.portal.name = 'Portal'; //container.addAllToScene(); } return this.portal; } /** lists files on a server directory @param theUrl url to load from @param callback to call load, passing it XMLHttpRequest */ listFiles(theUrl, callback) { this.log("Fetching " + theUrl); var xmlHttp = new XMLHttpRequest(); xmlHttp.responseType = "document"; xmlHttp.onreadystatechange = function() { if (xmlHttp.readyState == 4 && xmlHttp.status == 200) { callback(xmlHttp); } } xmlHttp.open("GET", theUrl, true); // true for asynchronous xmlHttp.send(null); return xmlHttp; } /** lists files on a server directory @param theUrl url to load from @returns Promise with XMLHttpRequest */ async listFilesAsync(theUrl) { return new Promise((resolve, reject) => { this.log("Fetching " + theUrl); var xmlHttp = new XMLHttpRequest(); xmlHttp.responseType = "document"; xmlHttp.onreadystatechange = () => { if (xmlHttp.readyState == 4 && xmlHttp.status == 200) { resolve(xmlHttp); } // TODO error handling } xmlHttp.open("GET", theUrl, true); // true for asynchronous xmlHttp.send(null); }); } /** list folders with their jpg thumbnails (files ending with .jpg) @param dir directory to list @param callback to call */ listThumbnails(dir, callback) { this.listMatchingFiles(dir, callback, '.jpg'); } /** list character folders and their fix files @param dir directory to list @param callback to call */ listCharacters(dir, callback) { this.listMatchingFiles(dir, callback, '-fixes.json'); } /** list character folders and their fix files @param dir directory to list */ async listCharactersAsync(dir) { return this.listMatchingFilesAsync(dir, '-fixes.json'); } /** List files in a server folder @param dir directory to list @param callback receives string array with urls @param suffix optional suffix of listed files */ listDirectory(dir, callback, suffix) { // TODO we need sync version of this if (!dir.endsWith('/')) { dir += '/'; } var ui = this; return this.listFiles(dir, (xmlHttp) => { var links = xmlHttp.responseXML.links; var files = []; // first pass: // iterate all links, collect avatar directories and fixes for (var i = 0; i < links.length; i++) { var link = links[i]; var href = link.href; if (href.indexOf('?') > 0) { continue; } if (link.baseURI.length > link.href.length) { continue; } if (link.href.endsWith('/')) { continue; } if (!suffix || href.endsWith(suffix)) { ui.log(link.baseURI + ' ' + href); files.push(href); } } callback(files); }); } /** list server folders along with their matching files i.e. files with the same name, plus given suffix @param dir directory to list @param callback to call, receives ServerFolder array as argument @param suffix of related file */ listMatchingFiles(dir, callback, suffix) { if (!dir.endsWith('/')) { dir += '/'; } var ui = this; return this.listFiles(dir, (xmlHttp) => { var links = xmlHttp.responseXML.links; var files = []; var fixes = []; // first pass: // iterate all links, collect avatar directories and fixes for (var i = 0; i < links.length; i++) { var link = links[i]; var href = link.href; if (href.indexOf('?') > 0) { continue; } if (link.baseURI.length > link.href.length) { continue; } if (link.href.endsWith(suffix)) { fixes.push(href.substring(link.baseURI.length)); continue; } if (!link.href.endsWith('/')) { continue; } href = href.substring(link.baseURI.length); href = href.substring(0, href.indexOf('/')); ui.log(link.baseURI + ' ' + href); files.push(href); } // second pass: match folders with related files var folders = []; for (var i = 0; i < files.length; i++) { var fix = null; var fixName = files[i] + suffix; var index = fixes.indexOf(fixName); if (index >= 0) { fix = fixes[index]; } folders.push(new ServerFolder(dir, files[i], fix)); } ui.log(folders); callback(folders); }); } /** list server folders along with their matching files i.e. files with the same name, plus given suffix @param dir directory to list @param suffix of related file @returns Promise with ServerFolder array */ async listMatchingFilesAsync(dir, suffix) { if (!dir.endsWith('/')) { dir += '/'; } return this.listFilesAsync(dir).then(xmlHttp => { var links = xmlHttp.responseXML.links; var files = []; var fixes = []; // first pass: // iterate all links, collect avatar directories and fixes for (var i = 0; i < links.length; i++) { let link = links[i]; let href = link.href; let baseUri = link.baseURI; if (href.indexOf('?') > 0) { continue; } if (baseUri.length > link.href.length) { continue; } if (link.href.endsWith(suffix)) { fixes.push(href.substring(baseUri.length)); continue; } if (!link.href.endsWith('/')) { continue; } href = href.substring(baseUri.length); href = href.substring(0, href.indexOf('/')); this.log(baseUri + ' ' + href); files.push(href); } // second pass: match folders with related files var folders = []; for (var i = 0; i < files.length; i++) { var fix = null; var fixName = files[i] + suffix; var index = fixes.indexOf(fixName); if (index >= 0) { fix = fixes[index]; } folders.push(new ServerFolder(dir, files[i], fix)); } this.log(folders); return folders; }); } /** Utility method, should a node and its children receive shadows. @param node a babylonjs node @param shadows true ofr false */ receiveShadows(node, shadows) { node.receiveShadows = shadows; if (node.material) { if (node.material.getClassName() == "PBRMaterial") { // something to do with inverse square root of physical material node.material.usePhysicalLightFalloff = false; } } var children = node.getChildren(); for (var i = 0; i < children.length; i++) { this.receiveShadows(children[i], shadows); } } /** Utility method to instantiate if possible, or otherwise clone a mesh, including all children recursivelly. Both instance and clone use the same material, but only the clone has own lightning effects (e.g. shadows, environment). @param mesh to instantiate/clone @param parent optional, copy will have this parent @param replaceParent optional @returns copied mesh */ copyMesh(mesh, parent, replaceParent) { if (mesh.geometry) { var copy = mesh.createInstance(mesh.name + "-instance"); //var copy = mesh.clone( mesh.name+"-clone", parent, true, false ); copy.parent = parent; } else if (replaceParent && parent) { copy = parent; } else { var copy = mesh.clone(mesh.name + "-clone", parent, true, false); copy.parent = parent; } var children = mesh.getChildren(); for (var i = 0; i < children.length; i++) { this.copyMesh(children[i], copy, replaceParent); } return copy; } /** Utility method - create x,y,z animation of a mesh field. @param mesh to animate @param field name of field to animate, e.g. "position" or "rotation" @param fps frames per second, defaults to fps field value @returns babylonjs AnimationGroup */ createAnimation(mesh, field, fps) { if (!fps) { fps = this.fps; } var group = new BABYLON.AnimationGroup(field + " " + mesh.id); var xAnim = new BABYLON.Animation("xAnim " + mesh.id, field + ".x", fps, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE); var xKeys = []; xKeys.push({ frame: 0, value: 0 }); xKeys.push({ frame: 1, value: 0 }); xAnim.setKeys(xKeys); var yAnim = new BABYLON.Animation("yAnim " + mesh.id, field + ".y", fps, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE); var yKeys = []; yKeys.push({ frame: 0, value: 0 }); yKeys.push({ frame: 1, value: 0 }); yAnim.setKeys(yKeys); var zAnim = new BABYLON.Animation("zAnim " + mesh.id, field + ".z", fps, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE); var zKeys = []; zKeys.push({ frame: 0, value: 0 }); zKeys.push({ frame: 1, value: 0 }); zAnim.setKeys(zKeys); group.addTargetedAnimation(xAnim, mesh); group.addTargetedAnimation(yAnim, mesh); group.addTargetedAnimation(zAnim, mesh); return group; } _appendAnimation(group, from, to) { var xAnim = group.targetedAnimations[0].animation; xAnim.getKeys()[0].value = from.x; xAnim.getKeys()[1].value = to.x; var yAnim = group.targetedAnimations[1].animation; yAnim.getKeys()[0].value = from.y; yAnim.getKeys()[1].value = to.y; var zAnim = group.targetedAnimations[2].animation; zAnim.getKeys()[0].value = from.z; zAnim.getKeys()[1].value = to.z; group.play(false); } /** Utility method - update x,y,z animation of a mesh field. Add the animation to be played after the current animation ends. @param group AnimationGroup to update @param node Babylonjs node to animate @param {string} field node field to animate, e.g. position or rotation @param to Vector3 */ chainAnimation(group, node, field, to) { try { if (group.isPlaying) { group.onAnimationGroupEndObservable.add(() => { this._appendAnimation(group, node[field], to); }); group.loopAnimation = false; } else { this._appendAnimation(group, node[field], to); group.play(false); } } catch (err) { console.error("updateAnimation failed", err); } } /** Utility method - update x,y,z animation of a mesh field. If the animation group is playing, it is stopped first (may result in jumpy animation). After the update, starts to play, not looping. @param group AnimationGroup to update @param from Vector3 @param to Vector3 */ updateAnimation(group, from, to) { try { if (group.isPlaying) { group.stop(); } var xAnim = group.targetedAnimations[0].animation; xAnim.getKeys()[0].value = from.x; xAnim.getKeys()[1].value = to.x; var yAnim = group.targetedAnimations[1].animation; yAnim.getKeys()[0].value = from.y; yAnim.getKeys()[1].value = to.y; var zAnim = group.targetedAnimations[2].animation; zAnim.getKeys()[0].value = from.z; zAnim.getKeys()[1].value = to.z; group.play(false); } catch (err) { console.error("updateAnimation failed", err); } } /** Utility method - create quaternion animation of a mesh field @param mesh to animate @param field name of field to animate, e.g. "rotationQuaternion" @param fps frames per second, defaults to fps field value @returns babylonjs AnimationGroup */ createQuaternionAnimation(mesh, field, fps) { if (!fps) { fps = this.fps; } var group = new BABYLON.AnimationGroup(field + " " + mesh.id); var anim = new BABYLON.Animation("qAnim " + mesh.id, field, fps, BABYLON.Animation.ANIMATIONTYPE_QUATERNION, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE); var keys = []; keys.push({ frame: 0, value: 0 }); keys.push({ frame: 1, value: 0 }); anim.setKeys(keys); group.addTargetedAnimation(anim, mesh); return group; } /** Utility method - update quaternion animation of a mesh field around Y axis. @param group AnimationGroup to update @param from Vector3 @param to Vector3 */ updateQuaternionAnimationFromVec(group, from, to) { // 'to' is a Vector3, 'from' is current rotationQuaternion // we have to rotate around to.y axis let dest = new BABYLON.Quaternion.FromEulerAngles(0, to.y, 0); return this.updateQuaternionAnimation(group, from, dest); } /** Utility method - update quaternion animation of a mesh field around Y axis. @param group AnimationGroup to update @param from Quaternion @param to Quaternion */ updateQuaternionAnimation(group, from, to) { if (group.isPlaying) { group.stop(); } var anim = group.targetedAnimations[0].animation; anim.getKeys()[0].value = from; anim.getKeys()[1].value = to; group.play(false); } /** Optimize the scene for better frame rate */ optimizeScene(scene) { if (!this.optimizingScene) { this.optimizingScene = true; console.log("Running scene optimizer...") BABYLON.SceneOptimizer.OptimizeAsync(scene, //BABYLON.SceneOptimizerOptions.ModerateDegradationAllowed(), BABYLON.SceneOptimizerOptions.HighDegradationAllowed(), () => { this.optimizingScene = false; console.log("Scene optimized"); }, () => { this.optimizingScene = false; console.log("Scene optimization unsuccessfull"); }); } } /** Utility method - returns the top parent node in hierarchy */ findRootNode(mesh) { var parent = mesh; while (parent && parent.parent) { parent = parent.parent; } return parent; } /** Utility method to save a file with given name and file content. @param filename to save @param content of the file, typically some JSON string */ saveFile(filename, content) { var a = document.createElement('a'); var blob = new Blob([content], { 'type': 'application/octet-stream' }); a.href = window.URL.createObjectURL(blob); a.download = filename; a.click(); } /** * Save the entire scene as a GLB file. * @param fileName defaults to "scene" */ saveSceneGlb(fileName = "scene") { BABYLON.GLTF2Export.GLBAsync(this.scene, fileName).then((glb) => { glb.downloadFiles(); }); } /** * Save the entire scene as GLTF: fileName.gltf, fileName.bin, textures, etc. * @param fileName defaults to "scene" */ saveSceneGltf(fileName = "scene") { BABYLON.GLTF2Export.GLTFAsync(this.scene, fileName).then((gltf) => { gltf.downloadFiles(); }); } /** * Save scene as babylon json file. This only works for very simple scenes, * and is likely to fail for any vrspace world with TypeError: cyclic object value. * Furthermore, it does not save instances. * @param fileName defaults to "scene.babylon" */ saveSceneBabylon(fileName = "scene.babylon") { BABYLON.SceneSerializer.SerializeAsync(this.scene).then(serializedScene => { const strScene = JSON.stringify(serializedScene); this.saveFile(fileName, strScene); }); } hasTouchScreen() { return ('ontouchstart' in window) || (navigator.maxTouchPoints > 0) || (navigator.msMaxTouchPoints > 0); } /** * Add a selectable Form, Area, etc - something that may be interacted with in XR. * It must have isSelectableMesh(mesh) method. */ addSelectable(item) { if (typeof item.isSelectableMesh === "function") { let pos = this.selectables.indexOf(item); if (pos == -1) { this.selectables.push(item); } } else { throw "Object must have isSelectableMesh(mesh) method"; } } /** * Remove a selectable element. */ removeSelectable(item) { let pos = this.selectables.indexOf(item); if (pos > -1) { this.selectables.splice(pos, 1); } } /** * Returns for a mesh that belongs to a selectable UI element. */ isSelectableMesh(mesh) { return this.selectables.findIndex(s => s.isSelectableMesh(mesh)) > -1; } } // this does not ensure singleton in the browser // world scripts may be loaded from different contexts //export const VRSPACEUI = new VRSpaceUI(); /** * @type {VRSpaceUI} */ export let VRSPACEUI; if (typeof window !== 'undefined') { if (window.VRSPACEUI === undefined) { VRSPACEUI = new VRSpaceUI(); window.VRSPACEUI = VRSPACEUI; } else { VRSPACEUI = window.VRSPACEUI; } }