diff --git a/src/BitmapSkin.js b/src/BitmapSkin.js new file mode 100644 index 00000000..8db20048 --- /dev/null +++ b/src/BitmapSkin.js @@ -0,0 +1,103 @@ +const twgl = require('twgl.js'); + +const Skin = require('./Skin'); + +class BitmapSkin extends Skin { + /** + * Create a new Bitmap Skin. + * @param {!int} id - The ID for this Skin. + * @param {!RenderWebGL} renderer - The renderer which will use this skin. + */ + constructor (id, renderer) { + super(id); + + /** @type {!int} */ + this._costumeResolution = 1; + + /** @type {!RenderWebGL} */ + this._renderer = renderer; + + /** @type {WebGLTexture} */ + this._texture = null; + + /** @type {[int, int]} */ + this._textureSize = [0, 0]; + } + + /** + * Dispose of this object. Do not use it after calling this method. + */ + dispose () { + if (this._texture) { + this._renderer.gl.deleteTexture(this._texture); + this._texture = null; + } + super.dispose(); + } + + /** + * @return {[number,number]} the "native" size, in texels, of this skin. + */ + get size () { + return [this._textureSize[0] / this._costumeResolution, this._textureSize[1] / this._costumeResolution]; + } + + /** + * @param {[number,number]} scale - The scaling factors to be used. + * @return {WebGLTexture} The GL texture representation of this skin when drawing at the given scale. + */ + // eslint-disable-next-line no-unused-vars + getTexture (scale) { + return this._texture; + } + + /** + * Set the contents of this skin to a snapshot of the provided bitmap data. + * @param {ImageData|HTMLImageElement|HTMLCanvasElement|HTMLVideoElement} bitmapData - new contents for this skin. + * @param {int} [costumeResolution=1] - The resolution to use for this bitmap. + */ + setBitmap (bitmapData, costumeResolution) { + const gl = this._renderer.gl; + + if (this._texture) { + gl.bindTexture(gl.TEXTURE_2D, this._texture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bitmapData); + } else { + const textureOptions = { + auto: true, + mag: gl.NEAREST, + min: gl.NEAREST, // TODO: mipmaps, linear (except pixelate) + wrap: gl.CLAMP_TO_EDGE, + src: bitmapData + }; + + this._texture = twgl.createTexture(gl, textureOptions); + } + + // Do these last in case any of the above throws an exception + this._costumeResolution = costumeResolution || 1; + this._textureSize = BitmapSkin._getBitmapSize(bitmapData); + + this.emit(Skin.Events.WasAltered); + } + + /** + * @param {ImageData|HTMLImageElement|HTMLCanvasElement|HTMLVideoElement} bitmapData - bitmap data to inspect. + * @returns {[int,int]} the width and height of the bitmap data, in pixels. + * @private + */ + static _getBitmapSize (bitmapData) { + if (bitmapData instanceof HTMLImageElement) { + return [bitmapData.naturalWidth || bitmapData.width, bitmapData.naturalHeight || bitmapData.height]; + } + + if (bitmapData instanceof HTMLVideoElement) { + return [bitmapData.videoWidth || bitmapData.width, bitmapData.videoHeight || bitmapData.height]; + } + + // ImageData or HTMLCanvasElement + return [bitmapData.width, bitmapData.height]; + } +} + +module.exports = BitmapSkin; diff --git a/src/Drawable.js b/src/Drawable.js index 00fc3700..999108d4 100644 --- a/src/Drawable.js +++ b/src/Drawable.js @@ -1,9 +1,9 @@ const twgl = require('twgl.js'); -const xhr = require('xhr'); const Rectangle = require('./Rectangle'); -const SvgRenderer = require('./svg-quirks-mode/svg-renderer'); +const RenderConstants = require('./RenderConstants'); const ShaderManager = require('./ShaderManager'); +const Skin = require('./Skin'); /** * @callback Drawable~idFilterFunc @@ -16,15 +16,12 @@ class Drawable { /** * An object which can be drawn by the renderer. * TODO: double-buffer all rendering state (position, skin, effects, etc.) - * @param {WebGLRenderingContext} gl The OpenGL context. + * @param {!int} id - This Drawable's unique ID. * @constructor */ - constructor (gl) { - this._id = Drawable._nextDrawable++; - Drawable._allDrawables[this._id] = this; - - /** @type {WebGLRenderingContext} */ - this._gl = gl; + constructor (id) { + /** @type {!int} */ + this._id = id; /** * The uniforms to be used by the vertex and pixel shaders. @@ -39,19 +36,6 @@ class Drawable { */ u_modelMatrix: twgl.m4.identity(), - /** - * The nominal (not necessarily current) size of the current skin. - * This is scaled by _costumeResolution. - * @type {number[]} - */ - u_skinSize: [0, 0], - - /** - * The actual WebGL texture object for the skin. - * @type {WebGLTexture} - */ - u_skin: null, - /** * The color to use in the silhouette draw mode. * @type {number[]} @@ -69,48 +53,24 @@ class Drawable { this._position = twgl.v3.create(0, 0); this._scale = twgl.v3.create(100, 100); - this._rotationCenter = twgl.v3.create(0, 0); this._direction = 90; this._transformDirty = true; this._visible = true; this._effectBits = 0; + // TODO: move convex hull functionality, maybe bounds functionality overall, to Skin classes this._convexHullPoints = null; this._convexHullDirty = true; - // Create a transparent 1x1 texture for temporary use - const tempTexture = twgl.createTexture(gl, {src: [0, 0, 0, 0]}); - this._useSkin(tempTexture, 0, 0, 1, true); - - // Load a real skin - this.setSkin(Drawable._DEFAULT_SKIN); - } - - /** - * An invalid Drawable ID which can be used to signify absence, etc. - * @type {int} - */ - static get NONE () { - return -1; - } - - /** - * Fetch a Drawable by its ID number. - * @param {int} drawableID The ID of the Drawable to fetch. - * @returns {?Drawable} The specified Drawable if found, otherwise null. - */ - static getDrawableByID (drawableID) { - return Drawable._allDrawables[drawableID]; + this._skinWasAltered = this._skinWasAltered.bind(this); } /** * Dispose of this Drawable. Do not use it after calling this method. */ dispose () { - this.setSkin(null); - if (this._id >= 0) { - delete Drawable[this._id]; - } + // Use the setter: disconnect events + this.skin = null; } /** @@ -122,60 +82,40 @@ class Drawable { } /** - * Retrieve the ID for this Drawable. * @returns {number} The ID for this Drawable. */ - getID () { + get id () { return this._id; } /** - * Set this Drawable's skin. - * The Drawable will continue using the existing skin until the new one loads. - * If there is no existing skin, the Drawable will use a 1x1 transparent image. - * @param {string} skinUrl The URL of the skin. - * @param {number=} optCostumeResolution Optionally, a resolution for the skin. + * @returns {Skin} the current skin for this Drawable. */ - setSkin (skinUrl, optCostumeResolution) { - // TODO: cache Skins instead of loading each time. Ref count them? - // TODO: share Skins across Drawables - see also destroy() - if (skinUrl) { - const ext = skinUrl.substring(skinUrl.lastIndexOf('.') + 1); - switch (ext) { - case 'svg': - case 'svg/get/': - case 'svgz': - case 'svgz/get/': - this._setSkinSVG(skinUrl); - break; - default: - this._setSkinBitmap(skinUrl, optCostumeResolution); - break; + get skin () { + return this._skin; + } + + /** + * @param {Skin} newSkin - A new Skin for this Drawable. + */ + set skin (newSkin) { + if (this._skin !== newSkin) { + if (this._skin) { + this._skin.removeListener(Skin.Events.WasAltered, this._skinWasAltered); } - } else { - this._useSkin(null, 0, 0, 1, true); + this._skin = newSkin; + if (this._skin) { + this._skin.addListener(Skin.Events.WasAltered, this._skinWasAltered); + } + this._skinWasAltered(); } } /** - * Use a skin if it is the currently-pending skin, or if skipPendingCheck==true. - * If the passed skin is used (for either reason) _pendingSkin will be cleared. - * @param {WebGLTexture} skin The skin to use. - * @param {int} width The width of the skin. - * @param {int} height The height of the skin. - * @param {int} costumeResolution The resolution to use for this skin. - * @param {boolean} [skipPendingCheck] If true, don't compare to _pendingSkin. - * @private + * @returns {[number,number]} the current scaling percentages applied to this Drawable. [100,100] is normal size. */ - _useSkin (skin, width, height, costumeResolution, skipPendingCheck) { - if (skipPendingCheck || (skin === this._pendingSkin)) { - this._pendingSkin = null; - if (this._uniforms.u_skin && (this._uniforms.u_skin !== skin)) { - this._gl.deleteTexture(this._uniforms.u_skin); - } - this._setSkinSize(width, height, costumeResolution); - this._uniforms.u_skin = skin; - } + get scale () { + return [this._scale[0], this._scale[1]]; } /** @@ -185,79 +125,6 @@ class Drawable { return this._effectBits; } - /** - * Load a bitmap skin. Supports the same formats as the Image element. - * @param {string} skinMd5ext The MD5 and file extension of the bitmap skin. - * @param {number=} optCostumeResolution Optionally, a resolution for the skin. - * @private - */ - _setSkinBitmap (skinMd5ext, optCostumeResolution) { - const url = skinMd5ext; - this._setSkinCore(url, optCostumeResolution); - } - - /** - * Load an SVG-based skin. This still needs quite a bit of work to match the - * level of quality found in Scratch 2.0: - * - We should detect when a skin is being scaled up and render the SVG at a - * higher resolution in those cases. - * - Colors seem a little off. This may be browser-specific. - * - This method works in Chrome, Firefox, Safari, and Edge but causes a - * security error in IE. - * @param {string} skinMd5ext The MD5 and file extension of the SVG skin. - * @private - */ - _setSkinSVG (skinMd5ext) { - const url = skinMd5ext; - - const svgCanvas = document.createElement('canvas'); - const svgRenderer = new SvgRenderer(svgCanvas); - - const gotSVG = (err, response, body) => { - if (!err) { - svgRenderer.fromString(body, () => { - this._setSkinCore(svgCanvas, svgRenderer.getDrawRatio()); - }); - } - }; - xhr.get({ - useXDR: true, - url: url - }, gotSVG); - // TODO: if there's no current u_skin, install *something* before returning - } - - /** - * Common code for setting all skin types. - * @param {string|Image} source The source of image data for the skin. - * @param {int} costumeResolution The resolution to use for this skin. - * @private - */ - _setSkinCore (source, costumeResolution) { - const callback = (err, texture, sourceInCallback) => { - if (!err && (this._pendingSkin === texture)) { - this._useSkin(texture, sourceInCallback.width, sourceInCallback.height, costumeResolution); - } - }; - - const gl = this._gl; - const options = { - auto: true, - mag: gl.NEAREST, - min: gl.NEAREST, // TODO: mipmaps, linear (except pixelate) - wrap: gl.CLAMP_TO_EDGE, - src: source - }; - const willCallCallback = typeof source === 'string'; - this._pendingSkin = twgl.createTexture(gl, options, willCallCallback ? callback : null); - - // If we won't get a callback, start using the skin immediately. - // This will happen if the data is already local. - if (!willCallCallback) { - callback(null, this._pendingSkin, source); - } - } - /** * @returns {object.} the shader uniforms to be used when rendering this Drawable. */ @@ -281,10 +148,6 @@ class Drawable { */ updateProperties (properties) { let dirty = false; - if ('skin' in properties) { - this.setSkin(properties.skin, properties.costumeResolution); - this.setConvexHullDirty(); - } if ('position' in properties && ( this._position[0] !== properties.position[0] || this._position[1] !== properties.position[1])) { @@ -303,13 +166,6 @@ class Drawable { this._scale[1] = properties.scale[1]; dirty = true; } - if ('rotationCenter' in properties && ( - this._rotationCenter[0] !== properties.rotationCenter[0] || - this._rotationCenter[1] !== properties.rotationCenter[1])) { - this._rotationCenter[0] = properties.rotationCenter[0]; - this._rotationCenter[1] = properties.rotationCenter[1]; - dirty = true; - } if ('visible' in properties) { this._visible = properties.visible; this.setConvexHullDirty(); @@ -337,33 +193,6 @@ class Drawable { } } - /** - * Set the dimensions of this Drawable's skin. - * @param {int} width The width of the new skin. - * @param {int} height The height of the new skin. - * @param {int} [costumeResolution] The resolution to use for this skin. - * @private - */ - _setSkinSize (width, height, costumeResolution) { - costumeResolution = costumeResolution || 1; - width /= costumeResolution; - height /= costumeResolution; - if (this._uniforms.u_skinSize[0] !== width || this._uniforms.u_skinSize[1] !== height) { - this._uniforms.u_skinSize[0] = width; - this._uniforms.u_skinSize[1] = height; - this.setTransformDirty(); - } - this.setConvexHullDirty(); - } - - /** - * Get the size of the Drawable's current skin. - * @return {Array.} Skin size, width and height. - */ - getSkinSize () { - return this._uniforms.u_skinSize.slice(); - } - /** * Calculate the transform to use when rendering this Drawable. * @private @@ -377,18 +206,14 @@ class Drawable { const rotation = (270 - this._direction) * Math.PI / 180; twgl.m4.rotateZ(modelMatrix, rotation, modelMatrix); - // Adjust rotation center relative to the skin. - const rotationAdjusted = twgl.v3.subtract( - this._rotationCenter, - twgl.v3.divScalar(this._uniforms.u_skinSize, 2) - ); + const rotationAdjusted = twgl.v3.subtract(this.skin.rotationCenter, twgl.v3.divScalar(this.skin.size, 2)); rotationAdjusted[1] *= -1; // Y flipped to Scratch coordinate. rotationAdjusted[2] = 0; // Z coordinate is 0. twgl.m4.translate(modelMatrix, rotationAdjusted, modelMatrix); - const scaledSize = twgl.v3.divScalar(twgl.v3.multiply(this._uniforms.u_skinSize, this._scale), 100); + const scaledSize = twgl.v3.divScalar(twgl.v3.multiply(this.skin.size, this._scale), 100); scaledSize[2] = 0; // was NaN because the vectors have only 2 components. twgl.m4.scale(modelMatrix, scaledSize, modelMatrix); @@ -438,7 +263,7 @@ class Drawable { // transform. This allows us to skip recalculating the convex hull // for many Drawable updates, including translation, rotation, scaling. const projection = twgl.m4.ortho(-1, 1, -1, 1, -1, 1); - const skinSize = this._uniforms.u_skinSize; + const skinSize = this.skin.size; const tm = twgl.m4.multiply(this._uniforms.u_modelMatrix, projection); const transformedHullPoints = []; for (let i = 0; i < this._convexHullPoints.length; i++) { @@ -494,14 +319,23 @@ class Drawable { return this.getAABB(); } + /** + * Respond to an internal change in the current Skin. + * @private + */ + _skinWasAltered () { + this.setConvexHullDirty(); + this.setTransformDirty(); + } + /** * Calculate a color to represent the given ID number. At least one component of - * the resulting color will be non-zero if the ID is not Drawable.NONE. + * the resulting color will be non-zero if the ID is not RenderConstants.ID_NONE. * @param {int} id The ID to convert. * @returns {number[]} An array of [r,g,b,a], each component in the range [0,1]. */ static color4fFromID (id) { - id -= Drawable.NONE; + id -= RenderConstants.ID_NONE; const r = ((id >> 0) & 255) / 255.0; const g = ((id >> 8) & 255) / 255.0; const b = ((id >> 16) & 255) / 255.0; @@ -510,7 +344,7 @@ class Drawable { /** * Calculate the ID number represented by the given color. If all components of - * the color are zero, the result will be Drawable.NONE; otherwise the result + * the color are zero, the result will be RenderConstants.ID_NONE; otherwise the result * will be a valid ID. * @param {int} r The red value of the color, in the range [0,255]. * @param {int} g The green value of the color, in the range [0,255]. @@ -522,30 +356,8 @@ class Drawable { id = (r & 255) << 0; id |= (g & 255) << 8; id |= (b & 255) << 16; - return id + Drawable.NONE; + return id + RenderConstants.ID_NONE; } } -/** - * The ID to be assigned next time the Drawable constructor is called. - * @type {number} - * @private - */ -Drawable._nextDrawable = 0; - -/** - * All current Drawables, by ID. - * @type {Object.} - * @private - */ -Drawable._allDrawables = {}; - -// TODO: fall back on a built-in skin to protect against network problems -Drawable._DEFAULT_SKIN = { - squirrel: 'https://cdn.assets.scratch.mit.edu/internalapi/asset/7e24c99c1b853e52f8e7f9004416fa34.png/get/', - bus: 'https://cdn.assets.scratch.mit.edu/internalapi/asset/66895930177178ea01d9e610917f8acf.png/get/', - scratch_cat: 'https://cdn.assets.scratch.mit.edu/internalapi/asset/09dc888b0b7df19f70d81588ae73420e.svg/get/', - gradient: 'https://cdn.assets.scratch.mit.edu/internalapi/asset/a49ff276b9b8f997a1ae163992c2c145.png/get/' -}.squirrel; - module.exports = Drawable; diff --git a/src/RenderConstants.js b/src/RenderConstants.js new file mode 100644 index 00000000..a76497c6 --- /dev/null +++ b/src/RenderConstants.js @@ -0,0 +1,31 @@ +const DEFAULT_SKIN = { + squirrel: 'https://cdn.assets.scratch.mit.edu/internalapi/asset/7e24c99c1b853e52f8e7f9004416fa34.png/get/', + bus: 'https://cdn.assets.scratch.mit.edu/internalapi/asset/66895930177178ea01d9e610917f8acf.png/get/', + scratch_cat: 'https://cdn.assets.scratch.mit.edu/internalapi/asset/09dc888b0b7df19f70d81588ae73420e.svg/get/', + gradient: 'https://cdn.assets.scratch.mit.edu/internalapi/asset/a49ff276b9b8f997a1ae163992c2c145.png/get/' +}.squirrel; + +/** + * Various constants meant for use throughout the renderer. + * @type {object} + */ +module.exports = { + /** + * The ID value to use for "no item" or when an object has been disposed. + * @type {int} + */ + ID_NONE: -1, + + /** + * The URL to use as the default skin for a Drawable. + * TODO: Remove this in favor of falling back on a built-in skin. + * @type {string} + */ + DEFAULT_SKIN: DEFAULT_SKIN, + + /** + * Optimize for fewer than this number of Drawables sharing the same Skin. + * Going above this may cause middleware warnings or a performance penalty but should otherwise behave correctly. + */ + SKIN_SHARE_SOFT_LIMIT: 300 +}; diff --git a/src/RenderWebGL.js b/src/RenderWebGL.js index b0ff4fbf..2caa6ec0 100644 --- a/src/RenderWebGL.js +++ b/src/RenderWebGL.js @@ -1,8 +1,12 @@ const hull = require('hull.js'); const twgl = require('twgl.js'); +const xhr = require('xhr'); +const BitmapSkin = require('./BitmapSkin'); const Drawable = require('./Drawable'); +const RenderConstants = require('./RenderConstants'); const ShaderManager = require('./ShaderManager'); +const SVGSkin = require('./SVGSkin'); /** * Maximum touch size for a picking check. @@ -37,13 +41,30 @@ class RenderWebGL { * @constructor */ constructor (canvas, xLeft, xRight, yBottom, yTop) { - // TODO: remove? - twgl.setDefaults({crossOrigin: true}); + /** @type {Drawable[]} */ + this._allDrawables = []; + /** @type {Skin[]} */ + this._allSkins = []; + + /** @type {int[]} */ + this._drawList = []; + + /** @type {WebGLRenderingContext} */ this._gl = twgl.getWebGLContext(canvas, {alpha: false, stencil: true}); - this._drawables = []; + + /** @type {int} */ + this._nextDrawableId = RenderConstants.ID_NONE + 1; + + /** @type {int} */ + this._nextSkinId = RenderConstants.ID_NONE + 1; + + /** @type {module:twgl/m4.Mat4} */ this._projection = twgl.m4.identity(); + /** @type {Object.} */ + this._skinUrlMap = {}; + this._createGeometry(); this.setBackgroundColor(1, 1, 1); @@ -58,6 +79,13 @@ class RenderWebGL { this._shaderManager = new ShaderManager(gl); } + /** + * @returns {WebGLRenderingContext} the WebGL rendering context associated with this renderer. + */ + get gl () { + return this._gl; + } + /** * Set the physical size of the stage in device-independent pixels. * This will be multiplied by the device's pixel ratio on high-DPI displays. @@ -106,30 +134,134 @@ class RenderWebGL { this._projection = twgl.m4.ortho(xLeft, xRight, yBottom, yTop, -1, 1); } + /** + * Create a skin by loading a bitmap or vector image from a URL, or reuse an existing skin created this way. + * WARNING: This method is deprecated and will be removed in the near future. + * Use `createBitmapSkin` or `createSVGSkin` instead. + * @param {!string} skinUrl The URL of the skin. + * @param {!int} [costumeResolution] Optional: resolution for the skin. Ignored unless creating a new Bitmap skin. + * @returns {!int} The ID of the Skin. + * @deprecated + */ + createSkinFromURL (skinUrl, costumeResolution) { + if (this._skinUrlMap.hasOwnProperty(skinUrl)) { + const existingId = this._skinUrlMap[skinUrl]; + + // Make sure the "existing" skin hasn't been destroyed + if (this._allSkins[existingId]) { + return existingId; + } + } + + const skinId = this._nextSkinId++; + this._skinUrlMap[skinUrl] = skinId; + + let newSkin; + let isVector; + + const ext = skinUrl.substring(skinUrl.lastIndexOf('.') + 1); + switch (ext) { + case 'svg': + case 'svg/get/': + case 'svgz': + case 'svgz/get/': + isVector = true; + break; + default: + isVector = false; + break; + } + + if (isVector) { + newSkin = new SVGSkin(skinId, this); + xhr.get({ + useXDR: true, + url: skinUrl + }, (err, response, body) => { + if (!err) { + newSkin.setSVG(body); + } + }); + } else { + newSkin = new BitmapSkin(skinId, this); + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => { + newSkin.setBitmap(img, costumeResolution); + }; + img.src = skinUrl; + } + + this._allSkins[skinId] = newSkin; + return skinId; + } + + /** + * Create a new bitmap skin from a snapshot of the provided bitmap data. + * @param {ImageData|HTMLImageElement|HTMLCanvasElement|HTMLVideoElement} bitmapData - new contents for this skin. + * @param {!int} [costumeResolution=1] - The resolution to use for this bitmap. + * @returns {!int} the ID for the new skin. + */ + createBitmapSkin (bitmapData, costumeResolution) { + const skinId = this._nextSkinId++; + const newSkin = new BitmapSkin(skinId, this); + newSkin.setBitmap(bitmapData, costumeResolution); + this._allSkins[skinId] = newSkin; + return skinId; + } + + /** + * Create a new SVG skin. + * @param {!string} svgData - new SVG to use. + * @returns {!int} the ID for the new skin. + */ + createSVGSkin (svgData) { + const skinId = this._nextSkinId++; + const newSkin = new SVGSkin(skinId, this); + newSkin.setSVG(svgData); + this._allSkins[skinId] = newSkin; + return skinId; + } + + /** + * Destroy an existing skin. Do not use the skin or its ID after calling this. + * @param {!int} skinId - The ID of the skin to destroy. + */ + destroySkin (skinId) { + const oldSkin = this._allSkins[skinId]; + oldSkin.dispose(); + delete this._allSkins[skinId]; + } + /** * Create a new Drawable and add it to the scene. * @returns {int} The ID of the new Drawable. */ createDrawable () { - const drawable = new Drawable(this._gl); - const drawableID = drawable.getID(); - this._drawables.push(drawableID); + const drawableID = this._nextDrawableId++; + const drawable = new Drawable(drawableID, this); + this._allDrawables[drawableID] = drawable; + this._drawList.push(drawableID); + + const defaultSkinId = this.createSkinFromURL(RenderConstants.DEFAULT_SKIN); + drawable.skin = this._allSkins[defaultSkinId]; + return drawableID; } /** * Destroy a Drawable, removing it from the scene. * @param {int} drawableID The ID of the Drawable to remove. - * @returns {boolean} True iff the drawable was found and removed. */ destroyDrawable (drawableID) { - const index = this._drawables.indexOf(drawableID); - if (index >= 0) { - Drawable.getDrawableByID(drawableID).dispose(); - this._drawables.splice(index, 1); - return true; + const drawable = this._allDrawables[drawableID]; + drawable.dispose(); + delete this._allDrawables[drawableID]; + + let index; + while ((index = this._drawList.indexOf(drawableID)) >= 0) { + this._drawList.splice(index, 1); } - return false; } /** @@ -146,10 +278,10 @@ class RenderWebGL { * @return {?number} New order if changed, or null. */ setDrawableOrder (drawableID, order, optIsRelative, optMin) { - const oldIndex = this._drawables.indexOf(drawableID); + const oldIndex = this._drawList.indexOf(drawableID); if (oldIndex >= 0) { // Remove drawable from the list. - const drawable = this._drawables.splice(oldIndex, 1)[0]; + const drawable = this._drawList.splice(oldIndex, 1)[0]; // Determine new index. let newIndex = order; if (optIsRelative) { @@ -160,8 +292,8 @@ class RenderWebGL { } newIndex = Math.max(newIndex, 0); // Insert at new index. - this._drawables.splice(newIndex, 0, drawable); - return this._drawables.indexOf(drawable); + this._drawList.splice(newIndex, 0, drawable); + return this._drawList.indexOf(drawable); } return null; } @@ -177,7 +309,7 @@ class RenderWebGL { gl.clearColor.apply(gl, this._backgroundColor); gl.clear(gl.COLOR_BUFFER_BIT); - this._drawThese(this._drawables, ShaderManager.DRAW_MODE.default, this._projection); + this._drawThese(this._drawList, ShaderManager.DRAW_MODE.default, this._projection); } /** @@ -186,7 +318,7 @@ class RenderWebGL { * @return {object} Bounds for a tight box around the Drawable. */ getBounds (drawableID) { - const drawable = Drawable.getDrawableByID(drawableID); + const drawable = this._allDrawables[drawableID]; // Tell the Drawable about its updated convex hull, if necessary. if (drawable.needsConvexHullPoints()) { const points = this._getConvexHullPointsForDrawable(drawableID); @@ -218,8 +350,8 @@ class RenderWebGL { * @return {Array.} Skin size, width and height. */ getSkinSize (drawableID) { - const drawable = Drawable.getDrawableByID(drawableID); - return drawable.getSkinSize(); + const drawable = this._allDrawables[drawableID]; + return drawable.skin.size; } /** @@ -237,7 +369,7 @@ class RenderWebGL { if (!bounds) { return; } - const candidateIDs = this._filterCandidatesTouching(drawableID, this._drawables, bounds); + const candidateIDs = this._filterCandidatesTouching(drawableID, this._drawList, bounds); if (!candidateIDs) { return; } @@ -321,7 +453,7 @@ class RenderWebGL { * @returns {boolean} True iff the Drawable is touching one of candidateIDs. */ isTouchingDrawables (drawableID, candidateIDs) { - candidateIDs = candidateIDs || this._drawables; + candidateIDs = candidateIDs || this._drawList; const gl = this._gl; @@ -341,7 +473,7 @@ class RenderWebGL { gl.viewport(0, 0, bounds.width, bounds.height); const projection = twgl.m4.ortho(bounds.left, bounds.right, bounds.bottom, bounds.top, -1, 1); - const noneColor = Drawable.color4fFromID(Drawable.NONE); + const noneColor = Drawable.color4fFromID(RenderConstants.ID_NONE); gl.clearColor.apply(gl, noneColor); gl.clear(gl.COLOR_BUFFER_BIT | gl.STENCIL_BUFFER_BIT); @@ -383,7 +515,7 @@ class RenderWebGL { pixels[pixelBase], pixels[pixelBase + 1], pixels[pixelBase + 2]); - if (pixelID > Drawable.NONE) { + if (pixelID > RenderConstants.ID_NONE) { return true; } } @@ -399,14 +531,14 @@ class RenderWebGL { * @param {int} touchHeight The client height of the touch event (optional). * @param {int[]} candidateIDs The Drawable IDs to pick from, otherwise all. * @returns {int} The ID of the topmost Drawable under the picking location, or - * Drawable.NONE if there is no Drawable at that location. + * RenderConstants.ID_NONE if there is no Drawable at that location. */ pick (centerX, centerY, touchWidth, touchHeight, candidateIDs) { const gl = this._gl; touchWidth = touchWidth || 1; touchHeight = touchHeight || 1; - candidateIDs = candidateIDs || this._drawables; + candidateIDs = candidateIDs || this._drawList; const clientToGLX = gl.canvas.width / gl.canvas.clientWidth; const clientToGLY = gl.canvas.height / gl.canvas.clientHeight; @@ -427,7 +559,7 @@ class RenderWebGL { twgl.bindFramebufferInfo(gl, this._pickBufferInfo); gl.viewport(0, 0, touchWidth, touchHeight); - const noneColor = Drawable.color4fFromID(Drawable.NONE); + const noneColor = Drawable.color4fFromID(RenderConstants.ID_NONE); gl.clearColor.apply(gl, noneColor); gl.clear(gl.COLOR_BUFFER_BIT); @@ -467,9 +599,9 @@ class RenderWebGL { } // Bias toward selecting anything over nothing - hits[Drawable.NONE] = 0; + hits[RenderConstants.ID_NONE] = 0; - let hit = Drawable.NONE; + let hit = RenderConstants.ID_NONE; for (const hitID in hits) { if (hits.hasOwnProperty(hitID) && (hits[hitID] > hits[hit])) { hit = hitID; @@ -485,7 +617,11 @@ class RenderWebGL { * @return {?Rectangle} Rectangle bounds for touching query, or null. */ _touchingBounds (drawableID) { - const drawable = Drawable.getDrawableByID(drawableID); + const drawable = this._allDrawables[drawableID]; + + // TODO: remove this once URL-based skin setting is removed. + if (!drawable.skin || !drawable.skin.getTexture([100, 100])) return null; + const bounds = drawable.getFastBounds(); // Limit queries to the stage size. @@ -517,7 +653,7 @@ class RenderWebGL { candidateIDs = candidateIDs.filter(testID => { if (testID === drawableID) return false; // Only draw items which could possibly overlap target Drawable. - const candidate = Drawable.getDrawableByID(testID); + const candidate = this._allDrawables[testID]; const candidateBounds = candidate.getFastBounds(); return bounds.intersects(candidateBounds); }); @@ -534,7 +670,25 @@ class RenderWebGL { * @param {object.} properties The new property values to set. */ updateDrawableProperties (drawableID, properties) { - const drawable = Drawable.getDrawableByID(drawableID); + const drawable = this._allDrawables[drawableID]; + if (!drawable) { + // TODO: fix whatever's wrong in the VM which causes this, then add a warning or throw here. + // Right now this happens so much on some projects that a warning or exception here can hang the browser. + return; + } + // TODO: remove this after fully deprecating URL-based skin paths + if ('skin' in properties) { + const {skin, costumeResolution} = properties; + const skinId = this.createSkinFromURL(skin, costumeResolution); + drawable.skin = this._allSkins[skinId]; + } + if ('skinId' in properties) { + drawable.skin = this._allSkins[properties.skinId]; + } + if ('rotationCenter' in properties) { + const newRotationCenter = properties.rotationCenter; + drawable.skin.setRotationCenter(newRotationCenter[0], newRotationCenter[1]); + } drawable.updateProperties(properties); } @@ -597,7 +751,7 @@ class RenderWebGL { /** * Draw all Drawables, with the possible exception of - * @param {int[]} drawables The Drawable IDs to draw, possibly this._drawables. + * @param {int[]} drawables The Drawable IDs to draw, possibly this._drawList. * @param {ShaderManager.DRAW_MODE} drawMode Draw normally, silhouette, etc. * @param {module:twgl/m4.Mat4} projection The projection matrix to use. * @param {Drawable~idFilterFunc} [filter] An optional filter function. @@ -615,12 +769,17 @@ class RenderWebGL { // If we have a filter, check whether the ID fails if (filter && !filter(drawableID)) continue; - const drawable = Drawable.getDrawableByID(drawableID); + const drawable = this._allDrawables[drawableID]; // TODO: check if drawable is inside the viewport before anything else // Hidden drawables (e.g., by a "hide" block) are never drawn. if (!drawable.getVisible()) continue; + const drawableScale = drawable.scale; + + // If the texture isn't ready yet, skip it. + if (!drawable.skin.getTexture(drawableScale)) continue; + const effectBits = drawable.getEnabledEffects(); const newShader = this._shaderManager.getShader(drawMode, effectBits); if (currentShader !== newShader) { @@ -631,6 +790,7 @@ class RenderWebGL { twgl.setUniforms(currentShader, {u_fudge: window.fudge || 0}); } + twgl.setUniforms(currentShader, drawable.skin.getUniforms(drawableScale)); twgl.setUniforms(currentShader, drawable.getUniforms()); // Apply extra uniforms after the Drawable's, to allow overwriting. @@ -651,7 +811,7 @@ class RenderWebGL { * @return {Array.>} points Convex hull points, as [[x, y], ...] */ _getConvexHullPointsForDrawable (drawableID) { - const drawable = Drawable.getDrawableByID(drawableID); + const drawable = this._allDrawables[drawableID]; const [width, height] = drawable._uniforms.u_skinSize; // No points in the hull if invisible or size is 0. if (!drawable.getVisible() || width === 0 || height === 0) { @@ -663,8 +823,8 @@ class RenderWebGL { twgl.bindFramebufferInfo(gl, this._queryBufferInfo); gl.viewport(0, 0, width, height); - // Clear the canvas with Drawable.NONE. - const noneColor = Drawable.color4fFromID(Drawable.NONE); + // Clear the canvas with RenderConstants.ID_NONE. + const noneColor = Drawable.color4fFromID(RenderConstants.ID_NONE); gl.clearColor.apply(gl, noneColor); gl.clear(gl.COLOR_BUFFER_BIT); @@ -692,7 +852,7 @@ class RenderWebGL { * Helper method to look up a pixel. * @param {int} x X coordinate of the pixel in `pixels`. * @param {int} y Y coordinate of the pixel in `pixels`. - * @return {int} Known ID at that pixel, or Drawable.NONE. + * @return {int} Known ID at that pixel, or RenderConstants.ID_NONE. */ const _getPixel = (x, y) => { const pixelBase = ((width * y) + x) * 4; @@ -704,14 +864,14 @@ class RenderWebGL { for (let y = 0; y <= height; y++) { // Scan from left. for (let x = 0; x < width; x++) { - if (_getPixel(x, y) > Drawable.NONE) { + if (_getPixel(x, y) > RenderConstants.ID_NONE) { boundaryPoints.push([x, y]); break; } } // Scan from right. for (let x = width - 1; x >= 0; x--) { - if (_getPixel(x, y) > Drawable.NONE) { + if (_getPixel(x, y) > RenderConstants.ID_NONE) { boundaryPoints.push([x, y]); break; } diff --git a/src/SVGSkin.js b/src/SVGSkin.js new file mode 100644 index 00000000..345c61f8 --- /dev/null +++ b/src/SVGSkin.js @@ -0,0 +1,79 @@ +const twgl = require('twgl.js'); + +const Skin = require('./Skin'); +const SvgRenderer = require('./svg-quirks-mode/svg-renderer'); + +class SVGSkin extends Skin { + /** + * Create a new SVG skin. + * @param {!int} id - The ID for this Skin. + * @param {!RenderWebGL} renderer - The renderer which will use this skin. + */ + constructor (id, renderer) { + super(id); + + /** @type {RenderWebGL} */ + this._renderer = renderer; + + /** @type {SvgRenderer} */ + this._svgRenderer = new SvgRenderer(); + + /** @type {WebGLTexture} */ + this._texture = null; + } + + /** + * Dispose of this object. Do not use it after calling this method. + */ + dispose () { + if (this._texture) { + this._renderer.gl.deleteTexture(this._texture); + this._texture = null; + } + super.dispose(); + } + + /** + * @return {[number,number]} the "native" size, in texels, of this skin. + */ + get size () { + return [this._svgRenderer.canvas.width, this._svgRenderer.canvas.height]; + } + + /** + * @param {[number,number]} scale - The scaling factors to be used. + * @return {WebGLTexture} The GL texture representation of this skin when drawing at the given scale. + */ + // eslint-disable-next-line no-unused-vars + getTexture (scale) { + // TODO: re-render a scaled version if the requested scale is significantly larger than the current render + return this._texture; + } + + /** + * Set the contents of this skin to a snapshot of the provided SVG data. + * @param {string} svgData - new SVG to use. + */ + setSVG (svgData) { + this._svgRenderer.fromString(svgData, () => { + const gl = this._renderer.gl; + if (this._texture) { + gl.bindTexture(gl.TEXTURE_2D, this._texture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, this._svgRenderer.canvas); + } else { + const textureOptions = { + auto: true, + mag: gl.NEAREST, + min: gl.NEAREST, // TODO: mipmaps, linear (except pixelate) + wrap: gl.CLAMP_TO_EDGE, + src: this._svgRenderer.canvas + }; + + this._texture = twgl.createTexture(gl, textureOptions); + } + this.emit(Skin.Events.WasAltered); + }); + } +} + +module.exports = SVGSkin; diff --git a/src/Skin.js b/src/Skin.js new file mode 100644 index 00000000..a307134a --- /dev/null +++ b/src/Skin.js @@ -0,0 +1,119 @@ +const EventEmitter = require('events'); + +const twgl = require('twgl.js'); + +const RenderConstants = require('./RenderConstants'); + +class Skin extends EventEmitter { + /** + * Create a Skin, which stores and/or generates textures for use in rendering. + * @param {int} id - The unique ID for this Skin. + */ + constructor (id) { + super(); + + /** @type {int} */ + this._id = id; + + /** @type {Vec3} */ + this._rotationCenter = twgl.v3.create(0, 0); + + /** + * The uniforms to be used by the vertex and pixel shaders. + * Some of these are used by other parts of the renderer as well. + * @type {Object.} + * @private + */ + this._uniforms = { + /** + * The nominal (not necessarily current) size of the current skin. + * @type {number[]} + */ + u_skinSize: [0, 0], + + /** + * The actual WebGL texture object for the skin. + * @type {WebGLTexture} + */ + u_skin: null + }; + + this.setMaxListeners(RenderConstants.SKIN_SHARE_SOFT_LIMIT); + } + + /** + * Dispose of this object. Do not use it after calling this method. + */ + dispose () { + this._id = RenderConstants.ID_NONE; + } + + /** + * @return {int} the unique ID for this Skin. + */ + get id () { + return this._id; + } + + /** + * @returns {Vec3} the origin, in object space, about which this Skin should rotate. + */ + get rotationCenter () { + return this._rotationCenter; + } + + /** + * @abstract + * @return {[number,number]} the "native" size, in texels, of this skin. + */ + get size () { + return [0, 0]; + } + + /** + * Set the origin, in object space, about which this Skin should rotate. + * @param {number} x - The x coordinate of the new rotation center. + * @param {number} y - The y coordinate of the new rotation center. + */ + setRotationCenter (x, y) { + if (x !== this._rotationCenter[0] || y !== this._rotationCenter[1]) { + this._rotationCenter[0] = x; + this._rotationCenter[1] = y; + this.emit(Skin.Events.WasAltered); + } + } + + /** + * @abstract + * @param {[number,number]} scale - The scaling factors to be used. + * @return {WebGLTexture} The GL texture representation of this skin when drawing at the given size. + */ + // eslint-disable-next-line no-unused-vars + getTexture (scale) { + return null; + } + + /** + * Update and returns the uniforms for this skin. + * @param {[number,number]} scale - The scaling factors to be used. + * @returns {object.} the shader uniforms to be used when rendering with this Skin. + */ + getUniforms (scale) { + this._uniforms.u_skin = this.getTexture(scale); + this._uniforms.u_skinSize = this.size; + return this._uniforms; + } +} + +/** + * These are the events which can be emitted by instances of this class. + * @type {object.} + */ +Skin.Events = { + /** + * Emitted when anything about the Skin has been altered, such as the appearance or rotation center. + */ + WasAltered: 'WasAltered' +}; + +module.exports = Skin; diff --git a/src/svg-quirks-mode/svg-renderer.js b/src/svg-quirks-mode/svg-renderer.js index ac7d8fb0..9198af8a 100644 --- a/src/svg-quirks-mode/svg-renderer.js +++ b/src/svg-quirks-mode/svg-renderer.js @@ -30,12 +30,20 @@ document.body.insertBefore(documentStyleTag, document.body.firstChild); class SvgRenderer { /** * Create a quirks-mode SVG renderer for a particular canvas. - * @param {!HTMLCanvasElement} canvas A canvas element to draw to. + * @param {HTMLCanvasElement} [canvas] An optional canvas element to draw to. If this is not provided, the renderer + * will create a new canvas. * @constructor */ constructor (canvas) { - this._canvas = canvas; - this._context = canvas.getContext('2d'); + this._canvas = canvas || document.createElement('canvas'); + this._context = this._canvas.getContext('2d'); + } + + /** + * @returns {!HTMLCanvasElement} this renderer's target canvas. + */ + get canvas () { + return this._canvas; } /**