const twgl = require('twgl.js'); const RenderConstants = require('./RenderConstants'); const Skin = require('./Skin'); const Rectangle = require('./Rectangle'); const ShaderManager = require('./ShaderManager'); /** * Attributes to use when drawing with the pen * @typedef {object} PenSkin#PenAttributes * @property {number} [diameter] - The size (diameter) of the pen. * @property {Array} [color4f] - The pen color as an array of [r,g,b,a], each component in the range [0,1]. */ /** * The pen attributes to use when unspecified. * @type {PenSkin#PenAttributes} * @memberof PenSkin * @private * @const */ const DefaultPenAttributes = { color4f: [0, 0, 1, 1], diameter: 1 }; /** * Reused memory location for projection matrices. * @type {FloatArray} */ const __projectionMatrix = twgl.m4.identity(); /** * Reused memory location for translation matrix for building a model matrix. * @type {FloatArray} */ const __modelTranslationMatrix = twgl.m4.identity(); /** * Reused memory location for rotation matrix for building a model matrix. * @type {FloatArray} */ const __modelRotationMatrix = twgl.m4.identity(); /** * Reused memory location for scaling matrix for building a model matrix. * @type {FloatArray} */ const __modelScalingMatrix = twgl.m4.identity(); /** * Reused memory location for a model matrix. * @type {FloatArray} */ const __modelMatrix = twgl.m4.identity(); /** * Reused memory location for a vector to create a translation matrix from. * @type {FloatArray} */ const __modelTranslationVector = twgl.v3.create(); /** * Reused memory location for a vector to create a scaling matrix from. * @type {FloatArray} */ const __modelScalingVector = twgl.v3.create(); class PenSkin extends Skin { /** * Create a Skin which implements a Scratch pen layer. * @param {int} id - The unique ID for this Skin. * @param {RenderWebGL} renderer - The renderer which will use this Skin. * @extends Skin * @listens RenderWebGL#event:NativeSizeChanged */ constructor (id, renderer) { super(id); /** * @private * @type {RenderWebGL} */ this._renderer = renderer; /** @type {HTMLCanvasElement} */ this._canvas = document.createElement('canvas'); /** @type {WebGLTexture} */ this._texture = null; /** @type {WebGLTexture} */ this._exportTexture = null; /** @type {WebGLFramebuffer} */ this._framebuffer = null; /** @type {WebGLFramebuffer} */ this._silhouetteBuffer = null; /** @type {boolean} */ this._canvasDirty = false; /** @type {boolean} */ this._silhouetteDirty = false; /** @type {object} */ this._lineOnBufferDrawRegionId = { enter: () => this._enterDrawLineOnBuffer(), exit: () => this._exitDrawLineOnBuffer() }; /** @type {object} */ this._toBufferDrawRegionId = { enter: () => this._enterDrawToBuffer(), exit: () => this._exitDrawToBuffer() }; /** @type {twgl.BufferInfo} */ this._lineBufferInfo = null; const NO_EFFECTS = 0; /** @type {twgl.ProgramInfo} */ this._stampShader = this._renderer._shaderManager.getShader(ShaderManager.DRAW_MODE.stamp, NO_EFFECTS); /** @type {twgl.ProgramInfo} */ this._lineShader = this._renderer._shaderManager.getShader(ShaderManager.DRAW_MODE.lineSample, NO_EFFECTS); this._createLineGeometry(); this.onNativeSizeChanged = this.onNativeSizeChanged.bind(this); this._renderer.on(RenderConstants.Events.NativeSizeChanged, this.onNativeSizeChanged); this._setCanvasSize(renderer.getNativeSize()); } /** * Dispose of this object. Do not use it after calling this method. */ dispose () { this._renderer.removeListener(RenderConstants.Events.NativeSizeChanged, this.onNativeSizeChanged); this._renderer.gl.deleteTexture(this._texture); this._renderer.gl.deleteTexture(this._exportTexture); this._texture = null; super.dispose(); } /** * @returns {boolean} true for a raster-style skin (like a BitmapSkin), false for vector-style (like SVGSkin). */ get isRaster () { return true; } /** * @return {Array} the "native" size, in texels, of this skin. [width, height] */ get size () { return [this._canvas.width, this._canvas.height]; } /** * @return {WebGLTexture} The GL texture representation of this skin when drawing at the given size. * @param {int} pixelsWide - The width that the skin will be rendered at, in GPU pixels. * @param {int} pixelsTall - The height that the skin will be rendered at, in GPU pixels. */ // eslint-disable-next-line no-unused-vars getTexture (pixelsWide, pixelsTall) { if (this._canvasDirty) { this._drawToBuffer(); } return this._exportTexture; } /** * Clear the pen layer. */ clear () { const gl = this._renderer.gl; twgl.bindFramebufferInfo(gl, this._framebuffer); gl.clearColor(1, 1, 1, 0); gl.clear(gl.COLOR_BUFFER_BIT); const ctx = this._canvas.getContext('2d'); ctx.clearRect(0, 0, this._canvas.width, this._canvas.height); this._silhouetteDirty = true; } /** * Draw a point on the pen layer. * @param {PenAttributes} penAttributes - how the point should be drawn. * @param {number} x - the X coordinate of the point to draw. * @param {number} y - the Y coordinate of the point to draw. */ drawPoint (penAttributes, x, y) { // Canvas renders a zero-length line as two end-caps back-to-back, which is what we want. this.drawLine(penAttributes, x, y, x, y); } /** * Draw a line on the pen layer. * @param {PenAttributes} penAttributes - how the line should be drawn. * @param {number} x0 - the X coordinate of the beginning of the line. * @param {number} y0 - the Y coordinate of the beginning of the line. * @param {number} x1 - the X coordinate of the end of the line. * @param {number} y1 - the Y coordinate of the end of the line. */ drawLine (penAttributes, x0, y0, x1, y1) { this._drawLineOnBuffer( penAttributes, this._rotationCenter[0] + x0, this._rotationCenter[1] - y0, this._rotationCenter[0] + x1, this._rotationCenter[1] - y1 ); this._silhouetteDirty = true; } /** * Create 2D geometry for drawing lines to a framebuffer. */ _createLineGeometry () { // Create a set of triangulated quads that break up a line into 3 parts: // 2 caps and a body. The y component of these position vertices are // divided to bring a value of 1 down to 0.5 to 0. The large y values // are set so they will still be at least 0.5 after division. The // divisor is scaled based on the length of the line and the lines // width. // // Texture coordinates are based on a "generated" texture whose general // shape is a circle. The line caps set their texture values to define // there roundedness with the texture. The body has all of its texture // values set to the center of the texture so it's a solid block. const quads = { a_position: { numComponents: 2, data: [ -0.5, 1, 0.5, 1, -0.5, 100000, -0.5, 100000, 0.5, 1, 0.5, 100000, -0.5, 1, 0.5, 1, -0.5, -1, -0.5, -1, 0.5, 1, 0.5, -1, -0.5, -100000, 0.5, -100000, -0.5, -1, -0.5, -1, 0.5, -100000, 0.5, -1 ] }, a_texCoord: { numComponents: 2, data: [ 1, 0.5, 0, 0.5, 1, 0, 1, 0, 0, 0.5, 0, 0, 0.5, 0, 0.5, 1, 0.5, 0, 0.5, 0, 0.5, 1, 0.5, 1, 1, 0, 0, 0, 1, 0.5, 1, 0.5, 0, 0, 0, 0.5 ] } }; this._lineBufferInfo = twgl.createBufferInfoFromArrays(this._renderer.gl, quads); } /** * Prepare to draw lines in the _lineOnBufferDrawRegionId region. */ _enterDrawLineOnBuffer () { const gl = this._renderer.gl; const bounds = this._bounds; const currentShader = this._lineShader; const projection = twgl.m4.ortho(0, bounds.width, 0, bounds.height, -1, 1, __projectionMatrix); twgl.bindFramebufferInfo(gl, this._framebuffer); // Needs a blend function that blends a destination that starts with // no alpha. gl.blendFuncSeparate( gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA ); gl.viewport(0, 0, bounds.width, bounds.height); gl.useProgram(currentShader.program); twgl.setBuffersAndAttributes(gl, currentShader, this._lineBufferInfo); const uniforms = { u_skin: this._texture, u_projectionMatrix: projection, u_fudge: 0 }; twgl.setUniforms(currentShader, uniforms); } /** * Return to a base state from _lineOnBufferDrawRegionId. */ _exitDrawLineOnBuffer () { const gl = this._renderer.gl; gl.blendFuncSeparate(gl.ONE, gl.ONE_MINUS_SRC_ALPHA, gl.ZERO, gl.ONE); twgl.bindFramebufferInfo(gl, null); } /** * Draw a line on the framebuffer. * @param {PenAttributes} penAttributes - how the line should be drawn. * @param {number} x0 - the X coordinate of the beginning of the line. * @param {number} y0 - the Y coordinate of the beginning of the line. * @param {number} x1 - the X coordinate of the end of the line. * @param {number} y1 - the Y coordinate of the end of the line. */ _drawLineOnBuffer (penAttributes, x0, y0, x1, y1) { const gl = this._renderer.gl; const currentShader = this._lineShader; this._renderer.enterDrawRegion(this._lineOnBufferDrawRegionId); const diameter = penAttributes.diameter || DefaultPenAttributes.diameter; const length = Math.hypot(Math.abs(x1 - x0) - 0.001, Math.abs(y1 - y0) - 0.001); const avgX = (x0 + x1) / 2; const avgY = (y0 + y1) / 2; const theta = Math.atan2(y0 - y1, x0 - x1); const alias = 1; // The line needs a bit of aliasing to look smooth. Add a small offset // and a small size boost to scaling to give a section to alias. const translationVector = __modelTranslationVector; translationVector[0] = avgX - (alias / 2); translationVector[1] = avgY + (alias / 4); const scalingVector = __modelScalingVector; // Dots tend to be ovals that are longer on the y-axis, so we use a smaller alias // for the y value than for the x value to make dots more circular. scalingVector[0] = diameter + (alias / 2); scalingVector[1] = length + diameter - (alias / 6); const radius = diameter / 2; const yScalar = (0.50001 - (radius / (length + diameter))); const uniforms = { u_positionScalar: yScalar, u_capScale: diameter, u_aliasAmount: alias, u_modelMatrix: twgl.m4.multiply( twgl.m4.multiply( twgl.m4.translation(translationVector, __modelTranslationMatrix), twgl.m4.rotationZ(theta - (Math.PI / 2), __modelRotationMatrix), __modelMatrix ), twgl.m4.scaling(scalingVector, __modelScalingMatrix), __modelMatrix ), u_lineColor: penAttributes.color4f || DefaultPenAttributes.color4f }; twgl.setUniforms(currentShader, uniforms); twgl.drawBufferInfo(gl, this._lineBufferInfo, gl.TRIANGLES); this._silhouetteDirty = true; } /** * Stamp an image onto the pen layer. * @param {HTMLCanvasElement|HTMLImageElement|HTMLVideoElement} stampElement - the element to use as the stamp. * @param {number} x - the X coordinate of the stamp to draw. * @param {number} y - the Y coordinate of the stamp to draw. */ drawStamp (stampElement, x, y) { const ctx = this._canvas.getContext('2d'); ctx.drawImage(stampElement, this._rotationCenter[0] + x, this._rotationCenter[1] - y); this._canvasDirty = true; this._silhouetteDirty = true; } /** * Enter a draw region to draw a rectangle. * * Multiple calls with the same regionId skip the callback reducing the * amount of GL state changes. * @param {twgl.ProgramInfo} currentShader - program info to draw rectangle * with * @param {Rectangle} bounds - viewport bounds to draw in * region */ _drawRectangleRegionEnter (currentShader, bounds) { const gl = this._renderer.gl; gl.viewport(0, 0, bounds.width, bounds.height); gl.useProgram(currentShader.program); twgl.setBuffersAndAttributes(gl, currentShader, this._renderer._bufferInfo); } /** * Draw a rectangle. * @param {twgl.ProgramInfo} currentShader - program info to draw rectangle * with * @param {WebGLTexture} texture - texture to draw * @param {Rectangle} bounds - bounded area to draw in * @param {number} x - centered at x * @param {number} y - centered at y */ _drawRectangle (currentShader, texture, bounds, x = -this._canvas.width / 2, y = this._canvas.height / 2) { const gl = this._renderer.gl; const projection = twgl.m4.ortho( bounds.left, bounds.right, bounds.top, bounds.bottom, -1, 1, __projectionMatrix ); const uniforms = { u_skin: texture, u_projectionMatrix: projection, u_modelMatrix: twgl.m4.multiply( twgl.m4.translation(twgl.v3.create( -x - (bounds.width / 2), -y + (bounds.height / 2), 0 ), __modelTranslationMatrix), twgl.m4.scaling(twgl.v3.create( bounds.width, bounds.height, 0 ), __modelScalingMatrix), __modelMatrix ), u_fudge: 0 }; twgl.setTextureParameters(gl, texture, {minMag: gl.NEAREST}); twgl.setUniforms(currentShader, uniforms); twgl.drawBufferInfo(gl, this._renderer._bufferInfo, gl.TRIANGLES); } /** * Prepare to draw a rectangle in the _toBufferDrawRegionId region. */ _enterDrawToBuffer () { const gl = this._renderer.gl; twgl.bindFramebufferInfo(gl, this._framebuffer); gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA); this._drawRectangleRegionEnter(this._stampShader, this._bounds); } /** * Return to a base state from _toBufferDrawRegionId. */ _exitDrawToBuffer () { const gl = this._renderer.gl; gl.blendFuncSeparate(gl.ONE, gl.ONE_MINUS_SRC_ALPHA, gl.ZERO, gl.ONE); twgl.bindFramebufferInfo(gl, null); } /** * Draw the input texture to the framebuffer. * @param {WebGLTexture} texture - input texture to draw * @param {number} x - texture centered at x * @param {number} y - texture centered at y */ _drawToBuffer (texture = this._texture, x = -this._canvas.width / 2, y = this._canvas.height / 2) { if (texture !== this._texture && this._canvasDirty) { this._drawToBuffer(); } const gl = this._renderer.gl; // If the input texture is the one that represents the pen's canvas // layer, update the texture with the canvas data. if (texture === this._texture) { gl.bindTexture(gl.TEXTURE_2D, texture); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, this._canvas); const ctx = this._canvas.getContext('2d'); ctx.clearRect(0, 0, this._canvas.width, this._canvas.height); this._canvasDirty = false; } const currentShader = this._stampShader; const bounds = this._bounds; this._renderer.enterDrawRegion(this._toBufferDrawRegionId); this._drawRectangle(currentShader, texture, bounds, x, y); this._silhouetteDirty = true; } /** * React to a change in the renderer's native size. * @param {object} event - The change event. */ onNativeSizeChanged (event) { this._setCanvasSize(event.newSize); } /** * Set the size of the pen canvas. * @param {Array} canvasSize - the new width and height for the canvas. * @private */ _setCanvasSize (canvasSize) { const [width, height] = canvasSize; const gl = this._renderer.gl; this._bounds = new Rectangle(); this._bounds.initFromBounds(width / 2, width / -2, height / 2, height / -2); this._canvas.width = width; this._canvas.height = height; this._rotationCenter[0] = width / 2; this._rotationCenter[1] = height / 2; this._texture = twgl.createTexture( gl, { auto: true, mag: gl.NEAREST, min: gl.NEAREST, wrap: gl.CLAMP_TO_EDGE, src: this._canvas } ); this._exportTexture = twgl.createTexture( gl, { auto: true, mag: gl.NEAREST, min: gl.NEAREST, wrap: gl.CLAMP_TO_EDGE, width, height } ); const attachments = [ { format: gl.RGBA, attachment: this._exportTexture } ]; if (this._framebuffer) { twgl.resizeFramebufferInfo(gl, this._framebuffer, attachments, width, height); twgl.resizeFramebufferInfo(gl, this._silhouetteBuffer, [{format: gl.RGBA}], width, height); } else { this._framebuffer = twgl.createFramebufferInfo(gl, attachments, width, height); this._silhouetteBuffer = twgl.createFramebufferInfo(gl, [{format: gl.RGBA}], width, height); } gl.clearColor(1, 1, 1, 0); gl.clear(gl.COLOR_BUFFER_BIT); this._silhouetteDirty = true; } /** * Set context state to match provided pen attributes. * @param {CanvasRenderingContext2D} context - the canvas rendering context to be modified. * @param {PenAttributes} penAttributes - the pen attributes to be used. * @private */ _setAttributes (context, penAttributes) { penAttributes = penAttributes || DefaultPenAttributes; const color4f = penAttributes.color4f || DefaultPenAttributes.color4f; const diameter = penAttributes.diameter || DefaultPenAttributes.diameter; const r = Math.round(color4f[0] * 255); const g = Math.round(color4f[1] * 255); const b = Math.round(color4f[2] * 255); const a = color4f[3]; // Alpha is 0 to 1 (not 0 to 255 like r,g,b) context.strokeStyle = `rgba(${r},${g},${b},${a})`; context.lineCap = 'round'; context.lineWidth = diameter; } /** * If there have been pen operations that have dirtied the canvas, update * now before someone wants to use our silhouette. */ updateSilhouette () { if (this._silhouetteDirty) { if (this._canvasDirty) { this._drawToBuffer(); } // Render export texture to another framebuffer const gl = this._renderer.gl; const bounds = this._bounds; this._renderer.enterDrawRegion(this._toBufferDrawRegionId); // Sample the framebuffer's pixels into the silhouette instance const skinPixels = new Uint8Array(Math.floor(this._canvas.width * this._canvas.height * 4)); gl.readPixels(0, 0, this._canvas.width, this._canvas.height, gl.RGBA, gl.UNSIGNED_BYTE, skinPixels); const skinCanvas = this._canvas; skinCanvas.width = bounds.width; skinCanvas.height = bounds.height; const skinContext = skinCanvas.getContext('2d'); const skinImageData = skinContext.createImageData(bounds.width, bounds.height); skinImageData.data.set(skinPixels); skinContext.putImageData(skinImageData, 0, 0); this._silhouette.update(this._canvas); this._silhouetteDirty = false; } } } module.exports = PenSkin;