mdx-m3-viewer
Version:
A browser WebGL model viewer. Mainly focused on models of the games Warcraft 3 and Starcraft 2.
513 lines (426 loc) • 14.6 kB
text/typescript
import { EventEmitter } from 'events';
import { FetchDataTypeName, FetchDataType, FetchResult, fetchDataType } from '../common/fetchdatatype';
import WebGL from './gl/gl';
import Scene from './scene';
import { Resource } from './resource';
import { PathSolver, HandlerResourceData, HandlerResource } from './handlerresource';
import GenericResource from './genericresource';
import ClientBuffer from './gl/clientbuffer';
import { isImageSource, ImageTexture, detectMime } from './imagetexture';
import { blobToImage } from '../common/canvas';
/**
* The minimal structure of handlers.
*
* Additional data can be added to them for the purposes of the implementation.
*/
export interface Handler {
load?: (viewer: ModelViewer, ...args: any[]) => void;
isValidSource: (src: any) => boolean;
resource: new (src: any, resourceData: HandlerResourceData) => HandlerResource
}
/**
* A model viewer.
*/
export default class ModelViewer extends EventEmitter {
resources: Resource[] = [];
/**
* A map from resource keys, typically urls, to their resources.
*/
resourceMap: Map<string, Resource> = new Map();
promiseMap: Map<string, Promise<Resource | undefined>> = new Map();
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;
/**
* A cache of arbitrary data, shared between all of the handlers.
*/
sharedCache: Map<any, any> = new Map();
directLoadId: number = 0;
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);
}
/**
* Enable audio if AudioContext is available.
*/
enableAudio() {
if (typeof AudioContext === 'function') {
this.audioEnabled = true;
return true;
}
return false;
}
/**
* Add an handler.
*/
addHandler(handler: Handler, ...args: any[]) {
if (handler) {
let handlers = this.handlers;
// Check to see if this handler was added already.
if (!handlers.has(handler)) {
if (!handler.isValidSource) {
this.emit('error', { viewer: this, error: 'Handler missing the isValidSource function', handler });
return false;
}
// Check if the handler has a loader, and if so load it.
if (handler.load) {
try {
handler.load(this, ...args);
} catch (e) {
this.emit('error', { viewer: this, error: `Handler failed to load`, handler, reason: e });
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;
}
/**
* Given a source and an optional path solver, loads a resource and returns a promise to it.
*/
async load(src: any, pathSolver?: PathSolver, solverParams?: any) {
let finalSrc: any;
let fetchUrl = '';
let promise;
// Run the path solver if there is one.
if (pathSolver) {
try {
finalSrc = pathSolver(src, solverParams);
} catch (e) {
this.emit('error', { viewer: this, error: `Path solver failed`, src, reason: e, pathSolver, solverParams });
return;
}
} else {
finalSrc = src;
}
if (!finalSrc) {
return;
}
// Allow path solvers to return promises.
if (finalSrc instanceof Promise) {
finalSrc = await finalSrc;
}
// Give path solvers the option to inject resources.
if (finalSrc instanceof Resource) {
return finalSrc;
}
// If the final source is a string, and doesn't match any handler, it is assumed to be an URL to fetch.
if (typeof finalSrc === 'string' && !this.detectFormat(finalSrc)) {
fetchUrl = finalSrc;
// Check the promise cache and return a promise if one exists.
promise = this.promiseMap.get(fetchUrl);
if (promise) {
return promise;
}
// Check the fetch cache and return a resource if one exists.
let resource = this.resourceMap.get(fetchUrl);
if (resource) {
return resource;
}
// Otherwise promise to fetch the data and construct a resource.
promise = fetchDataType(fetchUrl, 'arrayBuffer')
.then((value) => {
if (value.ok) {
return value.data;
} else {
this.emit('error', { viewer: this, error: `Failed to fetch a resource: ${value.error}`, fetchUrl, reason: value.data });
}
});
} else {
fetchUrl = `__DIRECT_LOAD_${this.directLoadId++}`;
promise = Promise.resolve(finalSrc);
}
promise = promise
.then(async (actualSrc) => {
// finalSrc will be undefined if this is a fetch and the fetch failed.
if (actualSrc) {
// If the source is an image source, load it directly.
if (isImageSource(actualSrc)) {
return new ImageTexture(actualSrc, { viewer: this, fetchUrl, pathSolver });
}
// If the source is a buffer of an image, convert it to an image, and load it directly.
if (actualSrc instanceof ArrayBuffer) {
let type = detectMime(actualSrc);
if (type.length) {
return new ImageTexture(await blobToImage(new Blob([actualSrc], { type })), { viewer: this, fetchUrl, pathSolver });
}
}
// Attempt to match the source to a handler.
let handler = this.detectFormat(actualSrc);
if (handler) {
try {
let resource = new handler.resource(actualSrc, { viewer: this, fetchUrl, pathSolver });
// If the resource is blocked by internal resources being loaded, wait for them and then clear them.
await Promise.all(resource.blockers);
resource.blockers.length = 0;
return resource;
} catch (e) {
this.emit('error', { viewer: this, error: 'Failed to create a resource', fetchUrl, src, reason: e });
}
} else {
this.emit('error', { viewer: this, error: 'Source has no matching handler', fetchUrl, src });
}
}
})
.then((resource) => {
this.promiseMap.delete(fetchUrl);
if (resource) {
this.resourceMap.set(fetchUrl, resource);
this.resources.push(resource);
this.emit('load', { viewer: this, fetchUrl, resource });
}
this.emit('loadend', { viewer: this, fetchUrl, resource });
this.checkLoadingStatus();
return resource;
});
this.promiseMap.set(fetchUrl, promise);
this.emit('loadstart', { viewer: this, fetchUrl, promise });
return promise;
}
detectFormat(src: any) {
for (let handler of this.handlers) {
if (handler.isValidSource(src)) {
return handler;
}
}
}
/**
* Check whether the given string maps to a resource in the cache.
*/
has(key: string) {
return this.resourceMap.has(key);
}
/**
* Get a resource from the cache.
*/
get(key: string) {
return this.resourceMap.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.
*/
async loadGeneric(fetchUrl: string, dataType: FetchDataTypeName, callback?: (data: FetchDataType) => any) {
// Check the promise cache and return a promise if one exists.
let promise = this.promiseMap.get(fetchUrl);
if (promise) {
return <Promise<GenericResource>>promise;
}
// Check the fetch cache and return a resource if one exists.
let resource = this.resourceMap.get(fetchUrl);
if (resource) {
return <GenericResource>resource;
}
let fetchPromise = fetchDataType(fetchUrl, dataType)
.then(async (value: FetchResult) => {
// Once the resource finished loading (successfully or not), the promise can be removed from the promise cache.
this.promiseMap.delete(fetchUrl);
let resource;
if (value.ok) {
let data = value.data;
if (callback) {
data = await callback(<FetchDataType>data);
}
resource = new GenericResource(data, { viewer: this, fetchUrl });
this.resourceMap.set(fetchUrl, resource);
this.resources.push(resource);
this.emit('load', { viewer: this, fetchUrl, resource });
} else {
this.emit('error', { viewer: this, error: 'Failed to fetch a generic resource', fetchUrl });
}
this.emit('loadend', { viewer: this, fetchUrl, resource });
this.checkLoadingStatus();
return resource;
});
this.promiseMap.set(fetchUrl, fetchPromise);
this.emit('loadstart', { viewer: this, fetchUrl });
return fetchPromise;
}
/**
* 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.resourceMap.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 promise = Promise.resolve(undefined);
let key = `${performance.now()}`;
this.promiseMap.set(key, promise);
return () => {
this.promiseMap.delete(key);
this.checkLoadingStatus();
};
}
checkLoadingStatus() {
if (this.promiseMap.size === 0) {
// A timeout is used so that this event will arrive after the current frame to let everything settle.
setTimeout(() => this.emit('idle'), 0);
}
}
/**
* 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.promiseMap.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.clearColor(0, 0, 0, 1);
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();
}
}
}