UNPKG

tangram

Version:
1,487 lines (1,390 loc) 1.1 MB
// define() gets called for each chunk generated by the first Rollup pass. // The order the chunks are called in is controlled by the imports in bundle.js: // // shared.js: shared dependencies between main and worker threads // scene_worker.js: worker thread code // index.js: main thread code // Once all chunks have been provided, the worker thread code is assembled, // incorporating the shared chunk code, then turned into a blob URL which // can be used to instantiate the worker. var shared, worker, Tangram = {}; function define(_, chunk) { if (!shared) { shared = chunk; } else if (!worker) { worker = chunk; } else { var worker_bundle = 'var shared_chunk = {}; (' + shared + ')(shared_chunk); (' + worker + ')(shared_chunk);' var shared_chunk = {}; shared(shared_chunk); Tangram = chunk(shared_chunk); Tangram.workerURL = window.URL.createObjectURL(new Blob([worker_bundle], { type: 'text/javascript' })); } } define(['exports'], (function (exports) { 'use strict'; /*jshint worker: true*/ // Mark thread as main or worker const Thread = {}; try { if (window instanceof Window && window.document instanceof HTMLDocument) { // jshint ignore:line Thread.is_worker = false; Thread.is_main = true; } } catch (e) { Thread.is_worker = true; Thread.is_main = false; // Patch for 3rd party libs that require these globals to be present. Specifically, FontFaceObserver. // Brittle solution but allows that library to load on worker threads. self.window = { document: {} }; self.document = self.window.document; } function _typeof(o) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (o) { return typeof o; } : function (o) { return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o; }, _typeof(o); } function toPrimitive(t, r) { if ("object" != _typeof(t) || !t) return t; var e = t[Symbol.toPrimitive]; if (void 0 !== e) { var i = e.call(t, r || "default"); if ("object" != _typeof(i)) return i; throw new TypeError("@@toPrimitive must return a primitive value."); } return ("string" === r ? String : Number)(t); } function toPropertyKey(t) { var i = toPrimitive(t, "string"); return "symbol" == _typeof(i) ? i : i + ""; } function _defineProperty(e, r, t) { return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, { value: t, enumerable: !0, configurable: !0, writable: !0 }) : e[r] = t, e; } var version$1 = "0.22.0"; var version = 'v' + version$1; /*jshint worker: true*/ var WorkerBroker; var WorkerBroker$1 = WorkerBroker = {}; // Global list of all worker messages // Uniquely tracks every call made between main thread and a worker var message_id = 0; var messages = {}; // Register an object to receive calls from other thread WorkerBroker.targets = {}; WorkerBroker.addTarget = function (name, target) { WorkerBroker.targets[name] = target; }; WorkerBroker.removeTarget = function (name) { if (name) { delete WorkerBroker.targets[name]; } }; // Given a dot-notation-style method name, e.g. 'Object.object.method', // find the object to call the method on from the list of registered targets function findTarget(method) { var chain = []; if (typeof method === 'string') { chain = method.split('.'); method = chain.pop(); } var target = WorkerBroker.targets; for (let m = 0; m < chain.length; m++) { if (target[chain[m]]) { target = target[chain[m]]; } else { return []; } } return [method, target]; } // Main thread: // - Send messages to workers, and optionally receive an async response as a promise // - Receive messages from workers, and optionally send an async response back as a promise function setupMainThread() { // Send a message to a worker, and optionally get an async response // Arguments: // - worker: one or more web worker instances to send the message to (single value or array) // - method: the method with this name, specified with dot-notation, will be invoked in the worker // - message: spread of arguments to call the method with // Returns: // - a promise that will be fulfilled if the worker method returns a value (could be immediately, or async) // WorkerBroker.postMessage = function (worker, method, ...message) { // If more than one worker specified, post to multiple if (Array.isArray(worker)) { return Promise.all(worker.map(w => WorkerBroker.postMessage(w, method, ...message))); } // Parse options let options = {}; if (typeof method === 'object') { options = method; method = method.method; } // Track state of this message var promise = new Promise((resolve, reject) => { messages[message_id] = { method, message, resolve, reject }; }); let payload, transferables = []; if (message && message.length === 1 && message[0] instanceof WorkerBroker.withTransferables) { transferables = message[0].transferables; message = message[0].value; } payload = { type: 'main_send', // mark message as method invocation from main thread message_id, // unique id for this message, for life of program method, // will dispatch to a function of this name within the worker message // message payload }; if (options.stringify) { payload = JSON.stringify(payload); } worker.postMessage(payload, transferables.map(t => t.object)); freeTransferables(transferables); if (transferables.length > 0) { log('trace', `'${method}' transferred ${transferables.length} objects to worker thread`); } message_id++; return promise; }; // Add a worker to communicate with - each worker must be registered from the main thread WorkerBroker.addWorker = function (worker) { if (!(worker instanceof Worker)) { throw Error('Worker broker could not add non-Worker object', worker); } worker.addEventListener('message', function WorkerBrokerMainThreadHandler(event) { let data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data; let id = data.message_id; // Listen for messages coming back from the worker, and fulfill that message's promise if (data.type === 'worker_reply') { // Pass the result to the promise if (messages[id]) { if (data.error) { messages[id].reject(data.error); } else { messages[id].resolve(data.message); } delete messages[id]; } } // Listen for messages initiating a call from the worker, dispatch them, // and send any return value back to the worker // Unique id for this message & return call to main thread else if (data.type === 'worker_send' && id != null) { // Call the requested method and save the return value let result, error, target, method_name, method; try { [method_name, target] = findTarget(data.method); if (!target) { throw Error(`Worker broker could not dispatch message type ${data.method} on target ${data.target} because no object with that name is registered on main thread`); } method = typeof target[method_name] === 'function' && target[method_name]; if (!method) { throw Error(`Worker broker could not dispatch message type ${data.method} on target ${data.target} because object has no method with that name`); } result = method.apply(target, data.message); } catch (e) { // Thrown errors will be passed back (in string form) to worker error = e; } // Send return value to worker let payload, transferables = []; // Async result if (result instanceof Promise) { result.then(value => { if (value instanceof WorkerBroker.withTransferables) { transferables = value.transferables; value = value.value[0]; } payload = { type: 'main_reply', message_id: id, message: value }; worker.postMessage(payload, transferables.map(t => t.object)); freeTransferables(transferables); if (transferables.length > 0) { log('trace', `'${method_name}' transferred ${transferables.length} objects to worker thread`); } }, error => { worker.postMessage({ type: 'main_reply', message_id: id, error: error instanceof Error ? `${error.message}: ${error.stack}` : error }); }); } // Immediate result else { if (result instanceof WorkerBroker.withTransferables) { transferables = result.transferables; result = result.value[0]; } payload = { type: 'main_reply', message_id: id, message: result, error: error instanceof Error ? `${error.message}: ${error.stack}` : error }; worker.postMessage(payload, transferables.map(t => t.object)); freeTransferables(transferables); if (transferables.length > 0) { log('trace', `'${method_name}' transferred ${transferables.length} objects to worker thread`); } } } }); }; // Expose for debugging WorkerBroker.getMessages = function () { return messages; }; WorkerBroker.getMessageId = function () { return message_id; }; } // Worker threads: // - Receive messages from main thread, and optionally send an async response back as a promise // - Send messages to main thread, and optionally receive an async response as a promise function setupWorkerThread() { // Send a message to the main thread, and optionally get an async response as a promise // Arguments: // - method: the method with this name, specified with dot-notation, will be invoked on the main thread // - message: array of arguments to call the method with // Returns: // - a promise that will be fulfilled if the main thread method returns a value (could be immediately, or async) // WorkerBroker.postMessage = function (method, ...message) { // Parse options let options = {}; if (typeof method === 'object') { options = method; method = method.method; } // Track state of this message var promise = new Promise((resolve, reject) => { messages[message_id] = { method, message, resolve, reject }; }); let payload, transferables = []; if (message && message.length === 1 && message[0] instanceof WorkerBroker.withTransferables) { transferables = message[0].transferables; message = message[0].value; } payload = { type: 'worker_send', // mark message as method invocation from worker message_id, // unique id for this message, for life of program method, // will dispatch to a method of this name on the main thread message // message payload }; if (options.stringify) { payload = JSON.stringify(payload); } self.postMessage(payload, transferables.map(t => t.object)); freeTransferables(transferables); if (transferables.length > 0) { log('trace', `'${method}' transferred ${transferables.length} objects to main thread`); } message_id++; return promise; }; self.addEventListener('message', function WorkerBrokerWorkerThreadHandler(event) { let data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data; let id = data.message_id; // Listen for messages coming back from the main thread, and fulfill that message's promise if (data.type === 'main_reply') { // Pass the result to the promise if (messages[id]) { if (data.error) { messages[id].reject(data.error); } else { messages[id].resolve(data.message); } delete messages[id]; } } // Receive messages from main thread, dispatch them, and send back a reply // Unique id for this message & return call to main thread else if (data.type === 'main_send' && id != null) { // Call the requested worker method and save the return value let result, error, target, method_name, method; try { [method_name, target] = findTarget(data.method); if (!target) { throw Error(`Worker broker could not dispatch message type ${data.method} on target ${data.target} because no object with that name is registered on main thread`); } method = typeof target[method_name] === 'function' && target[method_name]; if (!method) { throw Error(`Worker broker could not dispatch message type ${data.method} because worker has no method with that name`); } result = method.apply(target, data.message); } catch (e) { // Thrown errors will be passed back (in string form) to main thread error = e; } // Send return value to main thread let payload, transferables = []; // Async result if (result instanceof Promise) { result.then(value => { if (value instanceof WorkerBroker.withTransferables) { transferables = value.transferables; value = value.value[0]; } payload = { type: 'worker_reply', message_id: id, message: value }; self.postMessage(payload, transferables.map(t => t.object)); freeTransferables(transferables); if (transferables.length > 0) { log('trace', `'${method_name}' transferred ${transferables.length} objects to main thread`); } }, error => { self.postMessage({ type: 'worker_reply', message_id: id, error: error instanceof Error ? `${error.message}: ${error.stack}` : error }); }); } // Immediate result else { if (result instanceof WorkerBroker.withTransferables) { transferables = result.transferables; result = result.value[0]; } payload = { type: 'worker_reply', message_id: id, message: result, error: error instanceof Error ? `${error.message}: ${error.stack}` : error }; self.postMessage(payload, transferables.map(t => t.object)); freeTransferables(transferables); if (transferables.length > 0) { log('trace', `'${method_name}' transferred ${transferables.length} objects to main thread`); } } } }); } // Special value wrapper, to indicate that we want to find and include transferable objects in the message WorkerBroker.withTransferables = function (...value) { if (!(this instanceof WorkerBroker.withTransferables)) { return new WorkerBroker.withTransferables(...value); } this.value = value; this.transferables = findTransferables(this.value); }; // Build a list of transferable objects from a source object // Returns a list of info about each transferable: // - object: the actual transferable object // - parent: the parent object that the transferable is a property of (if any) // - property: the property name of the transferable on the parent object (if any) // TODO: add option in case you DON'T want to transfer objects function findTransferables(source, parent = null, property = null, list = []) { if (!source) { return list; } if (Array.isArray(source)) { // Check each array element source.forEach((x, i) => findTransferables(x, source, i, list)); } else if (typeof source === 'object') { // Is the object a transferable array buffer? if (source instanceof ArrayBuffer) { list.push({ object: source, parent, property }); } // Or looks like a typed array (has an array buffer property)? else if (source.buffer instanceof ArrayBuffer) { list.push({ object: source.buffer, parent, property }); } // Otherwise check each property else { for (let prop in source) { findTransferables(source[prop], source, prop, list); } } } return list; } // Remove neutered transferables from parent objects, as they should no longer be accessed after transfer function freeTransferables(transferables) { if (!Array.isArray(transferables)) { return; } transferables.filter(t => t.parent && t.property).forEach(t => delete t.parent[t.property]); } // Setup this thread as appropriate if (Thread.is_main) { setupMainThread(); } if (Thread.is_worker) { setupWorkerThread(); } const LEVELS = { silent: -1, error: 0, warn: 1, info: 2, debug: 3, trace: 4 }; const methods = {}; let logged_once = {}; function methodForLevel(level) { if (Thread.is_main) { methods[level] = methods[level] || (console[level] ? console[level] : console.log).bind(console); // eslint-disable-line no-console return methods[level]; } } // Logs message, proxying any log requests from worker threads back to the main thread. // Returns (asynchronously, due to proxying) a boolean indicating if the message was logged. // Option `once: true` can be used to only log each unique log message once (e.g. for warnings // that would otherwise be repetitive or possibly logged thousands of times, such as per feature). function log(opts, ...msg) { let level = typeof opts === 'object' ? opts.level : opts; if (LEVELS[level] <= LEVELS[log.level]) { if (Thread.is_worker) { // Proxy to main thread return WorkerBroker$1.postMessage({ method: '_logProxy', stringify: true }, opts, ...msg); } else { // Only log message once? if (typeof opts === 'object' && opts.once === true) { if (logged_once[JSON.stringify(msg)]) { return Promise.resolve(false); } logged_once[JSON.stringify(msg)] = true; } // Write to console (on main thread) let logger = methodForLevel(level); if (msg.length > 1) { logger(`Tangram ${version} [${level}]: ${msg[0]}`, ...msg.slice(1)); } else { logger(`Tangram ${version} [${level}]: ${msg[0]}`); } } return Promise.resolve(true); } return Promise.resolve(false); } log.level = 'info'; log.workers = null; log.setLevel = function (level) { log.level = level; if (Thread.is_main && Array.isArray(log.workers)) { WorkerBroker$1.postMessage(log.workers, '_logSetLevelProxy', level); } }; if (Thread.is_main) { log.setWorkers = function (workers) { log.workers = workers; }; log.reset = function () { logged_once = {}; }; } WorkerBroker$1.addTarget('_logProxy', log); // proxy log messages from worker to main thread WorkerBroker$1.addTarget('_logSetLevelProxy', log.setLevel); // proxy log level setting from main to worker thread // Miscellaneous utilities /*jshint worker: true*/ const Utils = {}; WorkerBroker$1.addTarget('Utils', Utils); // Basic Safari detection // http://stackoverflow.com/questions/7944460/detect-safari-browser Utils.isSafari = function () { return /^((?!chrome|android).)*safari/i.test(navigator.userAgent); }; // Basic IE11 or Edge detection Utils.isMicrosoft = function () { return /(Trident\/7.0|Edge[ /](\d+[.\d]+))/i.test(navigator.userAgent); }; Utils._requests = {}; // XHR requests on current thread Utils._proxy_requests = {}; // XHR requests proxied to main thread // `request_key` is a user-provided key that can be later used to cancel the request Utils.io = function (url, timeout = 60000, responseType = 'text', method = 'GET', headers = {}, request_key = null, proxy = false) { if (Thread.is_worker && Utils.isMicrosoft()) { // Some versions of IE11 and Edge will hang web workers when performing XHR requests // These requests can be proxied through the main thread // https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/9545866/ log('debug', 'Proxying request for URL to worker', url); if (request_key) { Utils._proxy_requests[request_key] = true; // mark as proxied } return WorkerBroker$1.postMessage('Utils.io', url, timeout, responseType, method, headers, request_key, true); } else { var request = new XMLHttpRequest(); var promise = new Promise((resolve, reject) => { request.open(method, url, true); request.timeout = timeout; request.responseType = responseType; // Attach optional request headers if (headers && typeof headers === 'object') { for (let key in headers) { request.setRequestHeader(key, headers[key]); } } request.onload = () => { if (request.status === 200) { if (['text', 'json'].indexOf(request.responseType) > -1) { resolve({ body: request.responseText, status: request.status }); } else { resolve({ body: request.response, status: request.status }); } } else if (request.status === 204) { // No Content resolve({ body: null, status: request.status }); } else { reject(Error('Request error with a status of ' + request.statusText)); } }; request.onerror = evt => { reject(Error('There was a network error' + evt.toString())); }; request.ontimeout = evt => { reject(Error('timeout ' + evt.toString())); }; request.send(); }); promise = promise.then(response => { if (request_key) { delete Utils._requests[request_key]; } if (proxy) { return WorkerBroker$1.withTransferables(response); } return response; }); if (request_key) { Utils._requests[request_key] = request; } return promise; } }; // Çancel a pending network request by user-provided request key Utils.cancelRequest = function (key) { // Check for a request that was proxied to the main thread if (Thread.is_worker && Utils._proxy_requests[key]) { return WorkerBroker$1.postMessage('Utils.cancelRequest', key); // forward to main thread } let req = Utils._requests[key]; if (req) { log('trace', `Cancelling network request key '${key}'`); Utils._requests[key].abort(); delete Utils._requests[key]; } else { log('trace', `Could not find network request key '${key}'`); } }; // Stringify an object into JSON, but convert functions to strings Utils.serializeWithFunctions = function (obj) { if (typeof obj === 'function') { return obj.toString(); } let serialized = JSON.stringify(obj, function (k, v) { // Convert functions to strings if (typeof v === 'function') { return v.toString(); } return v; }); return serialized; }; // Default to allowing high pixel density // Returns true if display density changed Utils.use_high_density_display = true; Utils.updateDevicePixelRatio = function () { let prev = Utils.device_pixel_ratio; Utils.device_pixel_ratio = Utils.use_high_density_display && window.devicePixelRatio || 1; return Utils.device_pixel_ratio !== prev; }; if (Thread.is_main) { Utils.updateDevicePixelRatio(); } // Used for differentiating between power-of-2 and non-power-of-2 textures // Via: http://stackoverflow.com/questions/19722247/webgl-wait-for-texture-to-load Utils.isPowerOf2 = function (value) { return (value & value - 1) === 0; }; // Interpolate 'x' along a series of control points // 'points' is an array of control points in the form [x, y] // // Example: // Control points: // [0, 5]: when x=0, y=5 // [4, 10]: when x=4, y=10 // // Utils.interpolate(2, [[0, 5], [4, 10]]); // -> computes x=2, halfway between x=0 and x=4: (10 - 5) / 2 +5 // -> returns 7.5 // // TODO: add other interpolation methods besides linear // Utils.interpolate = function (x, points, transform) { // If this doesn't resemble a list of control points, just return the original value if (!Array.isArray(points) || !Array.isArray(points[0])) { return points; } else if (points.length < 1) { return points; } var x1, x2, d, y, y1, y2; // Min bounds if (x <= points[0][0]) { y = points[0][1]; if (typeof transform === 'function') { y = transform(y); } } // Max bounds else if (x >= points[points.length - 1][0]) { y = points[points.length - 1][1]; if (typeof transform === 'function') { y = transform(y); } } // Find which control points x is between else { for (var i = 0; i < points.length - 1; i++) { if (x >= points[i][0] && x < points[i + 1][0]) { // Linear interpolation x1 = points[i][0]; x2 = points[i + 1][0]; // Multiple values if (Array.isArray(points[i][1])) { y = []; for (var c = 0; c < points[i][1].length; c++) { if (typeof transform === 'function') { y1 = transform(points[i][1][c]); y2 = transform(points[i + 1][1][c]); d = y2 - y1; y[c] = d * (x - x1) / (x2 - x1) + y1; } else { d = points[i + 1][1][c] - points[i][1][c]; y[c] = d * (x - x1) / (x2 - x1) + points[i][1][c]; } } } // Single value else { if (typeof transform === 'function') { y1 = transform(points[i][1]); y2 = transform(points[i + 1][1]); d = y2 - y1; y = d * (x - x1) / (x2 - x1) + y1; } else { d = points[i + 1][1] - points[i][1]; y = d * (x - x1) / (x2 - x1) + points[i][1]; } } break; } } } return y; }; Utils.toCSSColor = function (color) { if (color != null) { if (color[3] === 1) { // full opacity return `rgb(${color.slice(0, 3).map(c => Math.round(c * 255)).join(', ')})`; } // RGB is between [0, 255] opacity is between [0, 1] return `rgba(${color.map((c, i) => i < 3 && Math.round(c * 255) || c).join(', ')})`; } }; let debugSettings; var debugSettings$1 = debugSettings = { // draws a blue rectangle border around the collision box of a label draw_label_collision_boxes: false, // draws a green rectangle border within the texture box of a label draw_label_texture_boxes: false, // suppreses fade-in of labels suppress_label_fade_in: false, // suppress animaton of label snap to pixel grid suppress_label_snap_animation: false, // show hidden labels for debugging show_hidden_labels: false, // collect feature/geometry stats on styling layers layer_stats: false, // draw scene in wireframe mode wireframe: false }; function mergeDebugSettings(settings) { Object.assign(debugSettings, settings); } // Adds a base origin to relative URLs function addBaseURL(url, base) { if (!url || !isRelativeURL(url)) { return url; } var relative_path = url[0] !== '/'; var base_info; if (base) { base_info = document.createElement('a'); // use a temporary element to parse URL base_info.href = base; } else { base_info = window.location; } if (relative_path) { let path = pathForURL(base_info.href); url = path + url; } else { let origin = base_info.origin; if (!origin) { origin = base_info.protocol + '//' + base_info.host; // IE11 doesn't have origin property } url = origin + url; } return url; } function pathForURL(url) { if (typeof url === 'string' && url.search(/^(data|blob):/) === -1) { let qs = url.indexOf('?'); if (qs > -1) { url = url.substr(0, qs); } let hash = url.indexOf('#'); if (hash > -1) { url = url.substr(0, hash); } return url.substr(0, url.lastIndexOf('/') + 1) || ''; } return ''; } function extensionForURL(url) { url = url.split('/').pop(); let last_dot = url.lastIndexOf('.'); if (last_dot > -1) { return url.substring(last_dot + 1); } } function isLocalURL(url) { if (typeof url !== 'string') { return; } return url.search(/^(data|blob):/) > -1; } function isRelativeURL(url) { if (typeof url !== 'string') { return; } return !(url.search(/^(http|https|data|blob):/) > -1 || url.substr(0, 2) === '//'); } // Resolves './' and '../' components from relative path, to get a "flattened" path function flattenRelativeURL(url) { let dirs = (url || '').split('/'); for (let d = 1; d < dirs.length; d++) { if (dirs[d] === '.') { dirs.splice(d, 1); d--; } else if (dirs[d] === '..') { d = d + 0; dirs.splice(d - 1, 2); d--; } } return dirs.join('/'); } // Add a set of query string params to a URL // params: hash of key/value pairs of query string parameters // returns array of: [modified URL, array of duplicate param name and values] function addParamsToURL(url, params) { if (!params || Object.keys(params).length === 0) { return [url, []]; } var qs_index = url.indexOf('?'); var hash_index = url.indexOf('#'); // Save and trim hash var hash = ''; if (hash_index > -1) { hash = url.slice(hash_index); url = url.slice(0, hash_index); } // Start query string if (qs_index === -1) { qs_index = url.length; url += '?'; } qs_index++; // advanced past '?' // Build query string params var url_params = ''; var dupes = []; for (var p in params) { if (getURLParameter(p, url) !== '') { dupes.push([p, params[p]]); continue; } url_params += `${p}=${params[p]}&`; } // Insert new query string params and restore hash url = url.slice(0, qs_index) + url_params + url.slice(qs_index) + hash; return [url, dupes]; } // Polyfill (for Safari compatibility) let _createObjectURL; function createObjectURL(url) { if (_createObjectURL === undefined) { _createObjectURL = window.URL && window.URL.createObjectURL || window.webkitURL && window.webkitURL.createObjectURL; if (typeof _createObjectURL !== 'function') { _createObjectURL = null; log('warn', 'window.URL.createObjectURL (or vendor prefix) not found, unable to create local blob URLs'); } } if (_createObjectURL) { return _createObjectURL(url); } else { return url; } } // Via https://davidwalsh.name/query-string-javascript function getURLParameter(name, url) { name = name.replace(/[[]/, '\\[').replace(/[\]]/, '\\]'); var regex = new RegExp('[\\?&]' + name + '=([^&#]*)'); var results = regex.exec(url); return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' ')); } // import log from './log'; const Task = { id: 0, // unique id per task queue: [], // current queue of outstanding tasks max_time: 20, // default time in which all tasks should complete per frame start_time: null, // start time for tasks in current frame state: {}, // track flags about environment state (ex: whether user is currently moving the view) add(task) { task.id = Task.id++; task.max_time = task.max_time || Task.max_time; // allow task to run for this much time (tasks have a global collective limit per frame, too) task.pause_factor = task.pause_factor || 1; // pause tasks by this many frames when they run too long let promise = new Promise((resolve, reject) => { task.resolve = resolve; task.reject = reject; }); task.promise = promise; task.elapsed = 0; task.total_elapsed = 0; task.stats = { calls: 0 }; this.queue.push(task); // Run task immediately if under total frame time this.start_time = this.start_time || performance.now(); // start frame timer if necessary this.elapsed = performance.now() - this.start_time; if (this.elapsed < Task.max_time || task.immediate) { this.process(task); } return task.promise; }, remove(task) { let idx = this.queue.indexOf(task); if (idx > -1) { this.queue.splice(idx, 1); } }, process(task) { // Skip task while user is moving the view, if the task requests it // (for intensive tasks that lock the UI, like canvas rasterization) if (this.state.user_moving_view && task.user_moving_view === false) { // log('debug', `*** SKIPPING task id ${task.id}, ${task.type} while user is moving view`); return; } // Skip task if it's currently paused if (task.pause) { // log('debug', `*** PAUSING task id ${task.id}, ${task.type} (${task.pause})`); task.pause--; return true; } task.stats.calls++; task.start_time = performance.now(); // start task timer return task.run(task); }, processAll() { this.start_time = this.start_time || performance.now(); // start frame timer if necessary for (let i = 0; i < this.queue.length; i++) { // Exceeded either total task time, or total frame time let task = this.queue[i]; if (this.process(task) !== true) { // If the task didn't complete, pause it for a task-specific number of frames // (can be disabled by setting pause_factor to 0) if (!task.pause) { task.pause = task.elapsed > task.max_time ? task.pause_factor : 0; } task.total_elapsed += task.elapsed; } // Check total frame time this.elapsed = performance.now() - this.start_time; if (this.elapsed >= Task.max_time) { this.start_time = null; // reset frame timer break; } } }, finish(task, value) { task.elapsed = performance.now() - task.start_time; task.total_elapsed += task.elapsed; // log('debug', `task type ${task.type}, tile ${task.id}, finish after ${task.stats.calls} calls, ${task.total_elapsed.toFixed(2)} elapsed`); this.remove(task); task.resolve(value); return task.promise; }, cancel(task) { let val; if (task.cancel instanceof Function) { val = task.cancel(task); // optional cancel function } task.resolve(val); }, shouldContinue(task) { // Suspend task if it runs over its specific per-frame limit, or the global limit task.elapsed = performance.now() - task.start_time; this.elapsed = performance.now() - this.start_time; return task.elapsed < task.max_time && this.elapsed < Task.max_time; }, removeForTile(tile_id) { for (let idx = this.queue.length - 1; idx >= 0; idx--) { if (this.queue[idx].tile_id === tile_id) { // log('trace', `Task: remove tasks for tile ${tile_id}`); this.cancel(this.queue[idx]); this.queue.splice(idx, 1); } } }, setState(state) { this.state = state; } }; function subscribeMixin(target) { let listeners = []; return Object.assign(target, { subscribe(listener) { if (listeners.indexOf(listener) === -1) { listeners.push(listener); } }, unsubscribe(listener) { let index = listeners.indexOf(listener); if (index > -1) { listeners.splice(index, 1); } }, unsubscribeAll() { listeners = []; }, trigger(event, ...data) { listeners.forEach(listener => { if (typeof listener[event] === 'function') { try { listener[event](...data); } catch (e) { log('warn', `Caught exception in listener for event '${event}':`, e); } } }); }, hasSubscribersFor(event) { let has = false; listeners.forEach(listener => { if (typeof listener[event] === 'function') { has = true; } }); return has; } }); } function sliceObject(obj, keys) { let sliced = {}; keys.forEach(k => sliced[k] = obj[k]); return sliced; } // Texture management // GL texture wrapper object for keeping track of a global set of textures, keyed by a unique user-defined name class Texture { constructor(gl, name, options = {}) { options = Texture.sliceOptions(options); // exclude any non-texture-specific props this.gl = gl; this.texture = gl.createTexture(); if (this.texture) { this.valid = true; } this.bind(); this.name = name; this.retain_count = 0; this.config_type = null; this.loading = null; // a Promise object to track the loading state of this texture this.loaded = false; // successfully loaded as expected this.filtering = options.filtering; this.density = options.density || 1; // native pixel density of texture this.sprites = options.sprites; this.texcoords = {}; // sprite UVs ([0, 1] range) this.sizes = {}; // sprite sizes (pixel size) this.css_sizes = {}; // sprite sizes, adjusted for native texture pixel density this.aspects = {}; // sprite aspect ratios // Default to a 1-pixel transparent black texture so we can safely render while we wait for an image to load // See: http://stackoverflow.com/questions/19722247/webgl-wait-for-texture-to-load this.setData(1, 1, new Uint8Array([0, 0, 0, 0]), { filtering: 'nearest' }); this.loaded = false; // don't consider loaded when only placeholder data is present // Destroy previous texture if present if (Texture.textures[this.name]) { // Preserve previous retain count this.retain_count = Texture.textures[this.name].retain_count; Texture.textures[this.name].retain_count = 0; // allow to be freed Texture.textures[this.name].destroy(); } // Cache texture instance and definition Texture.textures[this.name] = this; Texture.texture_configs[this.name] = JSON.stringify(Object.assign({ name }, options)); this.load(options); log('trace', `creating Texture ${this.name}`); } // Destroy a single texture instance destroy({ force } = {}) { if (this.retain_count > 0 && !force) { log('error', `Texture '${this.name}': destroying texture with retain count of '${this.retain_count}'`); return; } if (!this.valid) { return; } this.gl.deleteTexture(this.texture); this.texture = null; if (Texture.textures[this.name] === this) { delete Texture.textures[this.name]; delete Texture.texture_configs[this.name]; } this.valid = false; log('trace', `destroying Texture ${this.name}`); } retain() { this.retain_count++; } release() { if (this.retain_count <= 0) { log('error', `Texture '${this.name}': releasing texture with retain count of '${this.retain_count}'`); } this.retain_count--; if (this.retain_count <= 0) { this.destroy(); } } bind(unit = 0) { if (!this.valid) { return; } if (Texture.activeUnit !== unit) { this.gl.activeTexture(this.gl.TEXTURE0 + unit); Texture.activeUnit = unit; Texture.boundTexture = null; // texture must be re-bound when unit changes } if (Texture.boundTexture !== this.texture) { this.gl.bindTexture(this.gl.TEXTURE_2D, this.texture); Texture.boundTexture = this.texture; } } load(options) { if (!options) { return this.loading || Promise.resolve(this); } this.loading = null; if (typeof options.url === 'string') { this.config_type = 'url'; this.setUrl(options.url, options); } else if (options.element) { this.config_type = 'element'; this.setElement(options.element, options); } else if (options.data && options.width && options.height) { this.config_type = 'data'; this.setData(options.width, options.height, options.data, options); } this.loading = this.loading && this.loading.then(() => { this.calculateSprites(); return this; }) || Promise.resolve(this); return this.loading; } // Sets texture from an url setUrl(url, options = {}) { if (!this.valid) { return; } this.url = url; // save URL reference (will be overwritten when element is loaded below) this.loading = new Promise(resolve => { let image = new Image(); image.onload = () => { try { // For data URL images, first draw the image to a separate canvas element. Workaround for // obscure bug seen with small (<28px) SVG images encoded as data URLs in Chrome and Safari. if (this.url.slice(0, 5) === 'data:') { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); canvas.width = image.width; canvas.height = image.height; ctx.drawImage(image, 0, 0); this.setElement(canvas, options); } else { this.setElement(image, options); } } catch (e) { this.loaded = false; log('warn', `Texture '${this.name}': failed to load url: '${this.url}'`, e, options); Texture.trigger('warning', { message: `Failed to load texture from ${this.url}`, error: e, texture: options }); } this.loaded = true; resolve(this); }; image.onerror = e => { // Warn and resolve on error this.loaded = false; log('warn', `Texture '${this.name}': failed to load url: '${this.url}'`, e, options); Texture.trigger('warning', { message: `Failed to load texture from ${this.url}`, error: e, texture: options }); resolve(this); }; // Safari has a bug loading data-URL images with CORS enabled, so it must be disabled in that case // https://bugs.webkit.org/show_bug.cgi?id=123978 if (!(Utils.isSafari() && this.url.slice(0, 5) === 'data:')) { image.crossOrigin = 'anonymous'; } image.src = this.url; }); return this.loading; } // Sets texture to a raw image buffer setData(width, height, data, options = {}) { this.width = width; this.height = height; // Convert regular array to typed array if (Array.isArray(data)) { data = new Uint8Array(data); } this.update(data, options); this.setFiltering(options); this.loaded = true; this.loading = Promise.resolve(this); return this.loading; } // Sets the texture to track a element (canvas/image) setElement(element, options) { let el = element; // a string element is interpeted as a CSS selector if (typeof element === 'string') { element = document.querySelector(element); } if (element instanceof HTMLCanvasElement || element instanceof HTMLImageElement || element instanceof HTMLVideoElement) { this.update(element, options); this.setFiltering(options); } else { this.loaded = false; let msg = `the 'element' parameter (\`element: ${JSON.stringify(el)}\`) must be a CSS `; msg += 'selector string, or a <canvas>, <image> or <video> object'; log('warn', `Texture '${this.name}': ${msg}`, options); Texture.trigger('warning', { message: `Failed to load texture because ${msg}`, texture: options }); } this.loaded = true; this.loading = Promise.resolve(this); return this.loading; } // Uploads current image or buffer to the GPU (can be used to update animated textures on the fly) update(source, options = {}) { if (!this.valid) { return; } this.bind(); // Image or Canvas element if (source instanceof HTMLCanvasElement || source instanceof HTMLVideoElement || source instanceof HTMLImageElement && source.complete) { this.width = source.width; this.height = source.height; this.gl.pixelStorei(this.gl.UNPACK_FLIP_Y_WEBGL, options.UNPACK_FLIP_Y_WEBGL === false ? false : true); this.gl.pixelStorei(this.gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, options.UNPACK_PREMULTIPLY_ALPHA_WEBGL || false); this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.RGBA, this.gl.RGBA, this.gl.UNSIGNED_BYTE, source); } // Raw image buffer else { // these pixel store params are deprecated for non-DOM element uploads // (e.g. when creating texture from raw data) // setting them to null avoids a Firefox warning this.gl.pixelStorei(this.gl.UNPACK_FLIP_Y_WEBGL, null); this.gl.pixelStorei(this.gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, null); this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.RGBA, this.width, this.height, 0, this.gl.RGBA, this.gl.UNSIGNED_BYTE, source); } Texture.trigger('update', this); } // Determines appropriate filtering mode setFiltering(options = {}) { if (!this.valid) { return; } options.filtering = options.filtering || 'linear'; var gl = this.gl; this.bind(); // For power-of-2 textures, the following presets are available: // mipmap: linear blend from nearest mip // linear: linear blend from original image (no mips) // nearest: nearest pixel from original image (no mips, 'blocky' look) if (Utils.isPowerOf2(this.width) && Utils.isPowerOf2(this.height)) { this.power_of_2 = true; gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, options.TEXTURE_WRAP_S || options.repeat && gl.REPEAT || gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, options.TEXTURE_WRAP_T || options.repeat && gl.REPEAT || gl.CLAMP_TO_EDGE); if (options.filtering === 'mipmap') { this.filtering = 'mipmap'; gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR); // TODO: use trilinear filtering by defualt instead? gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); gl.generateMipmap(gl.TEXTURE_2D); } else if (options.filtering === 'linear') { this.filtering = 'linear'; gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); } else if (options.filtering === 'nearest') { this.filtering = 'nearest'; gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); } } else { // WebGL has strict requirements on non-power-of-2 textures: // No mipmaps and must clamp to edge this.power_of_2 = false; gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); if (options.filtering === 'nearest') { this.filtering = 'nearest'; gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); } else { // default to linear for non-power-of-2 textures this.filtering = 'linear'; gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); } } Texture.trigger('update', this); } // Pre-calc sprite regions for a texture sprite in UV [0, 1] space calculateSprites() { if (this.sprites) { for (let s in this.sprites) { let sprite = this.sprites[s]; // Map [0, 0] to [1, 1] coords to the appropriate sprite sub-area of the texture this.texcoords[s] = Texture.getTexcoordsForSprite([sprite[0], sprite[1]], [sprite[2], sprite[3]], [this.width, this.height]); // Pixel size of sprite // Divide by native texture density to get correct CSS pixels this.sizes[s] = [sprite[2], sprite[3]]; this.css_sizes[s] = [sprite[2] / this.density, sprite[3] / this.density]; this.aspects[s] = sprite[2] / sprite[3]; } } } // Get the tetxure size in bytes byteSize() { // mipmaps use 33% additional memory return Math.round(this.width * this.height * 4 * (this.filtering == 'mipmap' ? 1.33 : 1)); } } // Static/class methods and state Texture.create = function (gl, name, options) { return new Texture(gl, name, options); }; Texture.retain = function (name) { if (Texture.textures[name]) { Texture.textures[name].retain(); } }; Texture.release = function (name) { if (Texture.textures[name]) { Texture.textures[name].release(); } }; // Destroy all texture instances for a given GL context Texture.destroy = function (gl) { var textures = Object.keys(Texture.textures); textures.forEach(t => { var texture = Texture.textures[t]; if (texture.gl === gl) { texture.destroy({ force: true }); } }); }; // Get sprite pixel size and UVs Texture.getSpriteInfo = function (texname, sprite) { let texture = Texture.textures[texname]; return texture && { size: texture.sizes[sprite], css_size: texture.css_sizes[sprite], aspect: texture.aspects[sprite], texcoords: texture.texcoords[sprite] }; }; // Re-scale UVs from [0, 1] range to a smaller area within the image Texture.getTexcoordsForSprite = function (area_origin, area_size, tex_size) { var area_origin_y = tex_size[1] - area_origin[1] - area_size[1]; return [area_origin[0] / tex_size[0], area_origin_y / tex_size[1], (area_size[0] + area_origin[0]) / tex_size[0], (area_size[1] + area_origin_y) / tex_size[1]];