UNPKG

mdx-m3-viewer

Version:

A browser WebGL model viewer. Mainly focused on models of the games Warcraft 3 and Starcraft 2.

679 lines (556 loc) 19.1 kB
import { EventEmitter } from 'events'; import { FetchDataTypeName, FetchDataType, FetchResult, fetchDataType } from '../common/fetchdatatype'; import mapequals from '../common/mapequals'; import WebGL from './gl/gl'; import PromiseResource from './promiseresource'; import Scene from './scene'; import { Resource } from './resource'; import { PathSolver, HandlerResourceData, HandlerResource } from './handlerresource'; import GenericResource from './genericresource'; import ClientBuffer from './gl/clientbuffer'; import Model from './model'; import ModelInstance from './modelinstance'; import TextureMapper from './texturemapper'; import Texture from './texture'; import { isImageSource, isImageExtension, ImageTexture } from './imagetexture'; import CubeMap from './cubemap'; /** * The minimal structure of handlers. * * Additional data can be added to them for the purposes of the implementation. */ export interface Handler { extensions: string[][]; load?: (viewer: ModelViewer) => boolean; resource: new (resourceData: HandlerResourceData) => HandlerResource } /** * A model viewer. */ export default class ModelViewer extends EventEmitter { resources: Resource[] = []; /** * A cache of resources that were fetched. */ fetchCache: Map<string, Resource> = new Map(); resourcesLoading: Set<Resource> = new Set(); handlers: Set<Handler> = new Set(); frameTime: number = 1000 / 60; canvas: HTMLCanvasElement; webgl: WebGL; gl: WebGLRenderingContext; scenes: Scene[] = []; visibleCells: number = 0; visibleInstances: number = 0; updatedParticles: number = 0; frame: number = 0; /** * A resizeable buffer that can be used by any part of the library. * * The data it contains is temporary, and can be overwritten at any time. */ buffer: ClientBuffer; /** * A viewer-wide flag. * * If it is false, not only will audio not run, but in fact audio files won't even be fetched in the first place. * * If audio is desired, this should be set to true before loading models that use audio. * * Note that it is preferable to call enableAudio(), which checks for the existence of AudioContext. */ audioEnabled: boolean = false; textureMappers: Map<Model, TextureMapper[]> = new Map(); /** * A cache of arbitrary data, shared between all of the handlers. */ sharedCache: Map<any, any> = new Map(); constructor(canvas: HTMLCanvasElement, options?: object) { super(); this.canvas = canvas; this.webgl = new WebGL(canvas, options); this.gl = this.webgl.gl; this.buffer = new ClientBuffer(this.gl); // Track when resources start loading. this.on('loadstart', (target) => { this.resourcesLoading.add(target); }); // Track when resources end loading. this.on('loadend', (target) => { this.resourcesLoading.delete(target); // If there are currently no resources loading, dispatch the 'idle' event. if (this.resourcesLoading.size === 0) { // A timeout is used so that this event will arrive after the loadend event being processed. setTimeout(() => this.emit('idle'), 0); } }); } /** * Enable audio if AudioContext is available. */ enableAudio() { if (typeof AudioContext === 'function') { this.audioEnabled = true; return true; } return false; } /** * Add an handler. */ addHandler(handler: Handler) { if (handler) { let handlers = this.handlers; // Check to see if this handler was added already. if (!handlers.has(handler)) { // Check if the handler has a loader, and if so load it. if (handler.load && !handler.load(this)) { this.emit('error', this, `The handler's load function failed`, handler); return false; } handlers.add(handler); return true; } } return false; } /** * Add a scene. */ addScene() { let scene = new Scene(this); this.scenes.push(scene); return scene; } /** * Remove a scene. */ removeScene(scene: Scene) { let scenes = this.scenes; let index = scenes.indexOf(scene); if (index !== -1) { scenes.splice(index, 1); return true; } return false; } /** * Removes all of the scenes in the viewer. */ clear() { this.scenes.length = 0; } /** * Look for a handler matching the given extension. */ findHandler(ext: string) { for (let handler of this.handlers) { for (let extention of handler.extensions) { if (ext === extention[0]) { return [handler, extention[1]]; } } } } /** * Load something. * * If `src` is an image texture source (e.g. an image or a canvas), it will be loaded directly. * * Otherwise, `pathSolver` is called with `src` and `solverParams`, and must return an array of values: * * [finalSrc, extension, isFetch] * * If `finalSrc` is an image texture source, it will be loaded directly. * * If `extension` is an image texture source extension (.png/.jpg/.gif), it will be fetched and loaded directly. * * Otherwise, `extension` is used to select the handler.\ * If `isFetch` is true, `finalSrc` is the url from which to fetch.\ * If `isFetch` is false, `finalSrc` is whatever the handler expects, typically an ArrayBuffer or a string. * * If the resource being loaded has internal resources (e.g. a model that loads its own textures), `pathSolver` will be called for them as well. * * A resource is always returned, except for when `pathSolver` isn't given but `src` isn't an image texture source, or when the handler couldn't be resolved. */ load(src: any, pathSolver?: PathSolver, solverParams?: any) { // If given an image texture source, load an image texture directly. if (isImageSource(src)) { return this.loadImageTexture(() => [src]) } // Resolve the load. if (pathSolver) { let [finalSrc, extension, isFetch] = pathSolver(src, solverParams); if (typeof extension !== 'string') { this.emit('error', this, 'The path solver did not return an extension', pathSolver); return; } // Allow path solvers to use both ".ext" and "ext". if (extension[0] !== '.') { extension = '.' + extension; } // If the path solver returns an image texture source, load an image texture directly. if (isImageSource(finalSrc)) { return this.loadImageTexture(() => [finalSrc]); } // If the path solver wants to fetch an image texture source, load an image texture directly, but also cache the texture. if (isImageExtension(extension)) { let texture = this.fetchCache.get(finalSrc); if (texture) { return texture; } texture = this.loadImageTexture(() => [finalSrc]); this.fetchCache.set(finalSrc, texture); return texture; } let handlerAndDataType = this.findHandler(extension.toLowerCase()); // Is there an handler for this file type? if (handlerAndDataType) { if (isFetch) { let resource = this.fetchCache.get(finalSrc); if (resource) { return <HandlerResource>resource; } } let handler = <Handler>handlerAndDataType[0]; let resource = new handler.resource({ viewer: this, extension, pathSolver, fetchUrl: isFetch ? finalSrc : '' }); this.resources.push(resource); if (isFetch) { this.fetchCache.set(finalSrc, resource); } this.registerEvents(resource); resource.emit('loadstart', resource); if (isFetch) { let dataType = <FetchDataTypeName>handlerAndDataType[1]; fetchDataType(finalSrc, dataType) .then((response) => { let data = response.data; if (response.ok) { resource.loadData(data); } else { resource.error(<string>response.error, data); } }); } else { resource.loadData(finalSrc); } return resource; } else { this.emit('error', this, `Unknown extension "${extension}" for "${src}". Did you forget to add the handler?`); } } else { this.emit('error', this, `Could not resolve "${src}". Did you forget to pass a path solver?`); } } /** * Check whether the given string maps to a resource in the cache. */ has(key: string) { return this.fetchCache.has(key); } /** * Get a resource from the cache. */ get(key: string) { return this.fetchCache.get(key); } /** * Load something generic. * * Unlike load(), this does not use handlers or construct any internal objects. * * `dataType` can be one of: `"image"`, `"string"`, `"arrayBuffer"`, `"blob"`. * * If `callback` isn't given, the resource's `data` is the fetch data, according to `dataType`. * * If `callback` is given, the resource's `data` is the value returned by it when called with the fetch data. * * If `callback` returns a promise, the resource's `data` will be whatever the promise resolved to. */ loadGeneric(path: string, dataType: FetchDataTypeName, callback?: (data: FetchDataType) => any) { let cachedResource = this.fetchCache.get(path); if (cachedResource) { // Technically also non-generic resources can be returned here, since the fetch cache is shared. // That being said, this should be used for generic resources, and it makes the typing a lot easier. return <GenericResource>cachedResource; } let resource = new GenericResource({ viewer: this, fetchUrl: path }); this.resources.push(resource); this.fetchCache.set(path, resource); this.registerEvents(resource); resource.emit('loadstart', resource); fetchDataType(path, dataType) .then((response: FetchResult) => { let data = response.data; if (response.ok) { if (callback) { try { data = callback(<FetchDataType>data); } catch (e) { resource.emit('error', 'An exception was thrown while running the client callback', e); } if (data instanceof Promise) { data.then((data) => resource.loadData(data)); } else { resource.loadData(data); } } else { resource.loadData(data); } } else { resource.error(<string>response.error, data); } }); return resource; } /** * Load an image texture. * * `pathSolver` must return an array of values like all path solvers.\ * If the first array element is an image texture source, it will be used.\ * Otherwise, it will always try to fetch an image.\ * Any other array elements are ignored. * * Note that this is usually called by the viewer itself.\ * For example, if you want to load an image directly, the following is recommended: * * viewer.load(image) */ loadImageTexture(pathSolver: PathSolver) { let texture = new ImageTexture({ viewer: this, pathSolver }); this.resources.push(texture); this.registerEvents(texture); texture.emit('loadstart', texture); let finalSrc = <string | TexImageSource>pathSolver(undefined)[0]; if (!isImageSource(finalSrc)) { let path = <string>finalSrc; texture.extension = path.slice(path.lastIndexOf('.')); texture.fetchUrl = path; fetchDataType(path, 'image') .then((response: FetchResult) => { let data = response.data; if (response.ok) { texture.loadData(data); } else { texture.error(<string>response.error, data); } }); } else { texture.loadData(finalSrc); } return texture; } /** * Load a cube map texture. * * `pathSolver` will be called 6 times, each time with a number refering to one of the textures: * * 0 = Positive X * 1 = Negative X * 2 = Positive Y * 3 = Negative Y * 4 = Positive Z * 5 = Negative Z * * `pathSolver` must return an array of values like all path solvers.\ * If the first array element is an image texture source, it will be used.\ * Otherwise, it will always try to fetch an image.\ * Any other array elements are ignored. */ loadCubeMap(pathSolver: PathSolver) { let cubeMap = new CubeMap({ viewer: this, pathSolver }); this.resources.push(cubeMap); this.registerEvents(cubeMap); cubeMap.emit('loadstart', cubeMap); let fetchPromises: Promise<FetchResult>[] = []; for (let i = 0; i < 6; i++) { let finalSrc = pathSolver(i)[0]; if (isImageSource(finalSrc)) { fetchPromises[i] = new Promise((resolve) => resolve({ ok: true, data: finalSrc })); } else { fetchPromises[i] = fetchDataType(finalSrc, 'image'); } } Promise.all(fetchPromises) .then((fetchResults) => { let planes = []; for (let i = 0; i < 6; i++) { let result = fetchResults[i]; if (result.ok) { planes[i] = result.data; } else { cubeMap.error(<string>result.error, result.data); return; } } cubeMap.loadData(planes); }); return cubeMap; } /** * Unload a resource. * Note that this only removes the resource from the viewer's cache. * If it's being referenced and used e.g. by a scene, it will not be garbage collected. */ unload(resource: Resource) { let fetchUrl = resource.fetchUrl; if (fetchUrl !== '') { this.fetchCache.delete(fetchUrl); } let resources = this.resources; let index = resources.indexOf(resource); if (index !== -1) { resources.splice(index, 1); return true; } return false; } /** * Starts loading a new empty resource, and returns it. * This empty resource will block the "idle" event (and thus whenAllLoaded) until it's resolved. * This is used when a resource might get loaded in the future, but it is not known what it is yet. */ promise() { let resource = new PromiseResource({ viewer: this }); this.registerEvents(resource); resource.promise(); return resource; } /** * Wait for a group of resources to load. * If a callback is given, it will be called. * Otherwise a promise is returned. */ whenLoaded(resources: Iterable<Resource>, callback?: (resources: Resource[]) => void) { let promises = []; for (let resource of resources) { // Only process actual resources. if (resource instanceof Resource) { promises.push(<Promise<Resource>>resource.whenLoaded()); } } let all = Promise.all(promises); if (callback) { all.then((loaded) => callback(loaded)); } else { return all; } } /** * Wait for all of the resources to load. * * If a callback is given, it will be called, otherwise, a promise is returned. */ whenAllLoaded(callback?: (viewer: ModelViewer) => void) { let promise = new Promise((resolve: (viewer: ModelViewer) => void) => { if (this.resourcesLoading.size === 0) { resolve(this); } else { this.once('idle', () => resolve(this)); } }); if (callback) { promise.then(() => callback(this)); } else { return promise; } } /** * Get a blob representing the contents of the viewer's canvas. * * If a callback is given, it will be called, otherwise, a promise is returned. */ toBlob(callback?: BlobCallback) { let promise = new Promise((resolve: BlobCallback) => this.canvas.toBlob((blob) => resolve(blob))); if (callback) { promise.then((blob) => callback(blob)); } else { return promise; } } /** * Update and render a frame. */ updateAndRender() { this.update(); this.startFrame(); this.render(); } /** * Update all of the scenes, which includes updating their cameras, audio context if one exists, and all of the instances they hold. */ update() { let dt = this.frameTime * 0.001; this.frame += 1; this.visibleCells = 0; this.visibleInstances = 0; this.updatedParticles = 0; for (let scene of this.scenes) { scene.update(dt); this.visibleCells += scene.visibleCells; this.visibleInstances += scene.visibleInstances; this.updatedParticles += scene.updatedParticles; } } /** * Clears the WebGL buffer. * * Called automatically by updateAndRender(). * * Call this at some point before render() if you need more control. */ startFrame() { let gl = this.gl; // See https://www.opengl.org/wiki/FAQ#Masking gl.depthMask(true); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); } /** * Render. */ render() { for (let scene of this.scenes) { scene.render(); } } /** * Clear all of the emitted objects in this viewer. */ clearEmittedObjects() { for (let scene of this.scenes) { scene.clearEmittedObjects(); } } registerEvents(resource: Resource) { ['loadstart', 'load', 'error', 'loadend'].map((e) => resource.once(e, (...data) => this.emit(e, ...data))); } baseTextureMapper(model: Model) { let textureMappers = this.textureMappers; if (!textureMappers.has(model)) { textureMappers.set(model, []); } let mappers = <TextureMapper[]>textureMappers.get(model); if (!mappers.length) { mappers[0] = new TextureMapper(model); } return mappers[0]; } changeTextureMapper(instance: ModelInstance, key: any, texture?: Texture) { let map = new Map(instance.textureMapper.textures); if (texture instanceof Texture) { map.set(key, texture); } else { map.delete(key); } let model = instance.model; let mappers = <TextureMapper[]>this.textureMappers.get(model); for (let mapper of mappers) { if (mapequals(mapper.textures, map)) { return mapper; } } let mapper = new TextureMapper(model, map); mappers.push(mapper); return mapper; } }