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
text/typescript
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;
}
}