From 0f3688d4beb68859fba90f5c9ca64360785eb8f5 Mon Sep 17 00:00:00 2001 From: Jonathan Olson Date: Thu, 5 Nov 2020 15:35:39 -0700 Subject: [PATCH 1/2] Filter testing and using settings to remove differences between browsers/platforms/renderers, see https://github.com/phetsims/scenery/issues/707 --- js/display/CanvasBlock.js | 5 +- js/nodes/Node.js | 3 +- js/util/ColorMatrixFilter.js | 41 +++++--- js/util/Filter.js | 1 + js/util/Grayscale.js | 5 + tests/filter-tests.html | 183 +++++++++++++++++++++++++++++++++++ 6 files changed, 225 insertions(+), 13 deletions(-) create mode 100644 tests/filter-tests.html diff --git a/js/display/CanvasBlock.js b/js/display/CanvasBlock.js index e2881f416..0caa653d8 100644 --- a/js/display/CanvasBlock.js +++ b/js/display/CanvasBlock.js @@ -10,6 +10,7 @@ import Matrix3 from '../../../dot/js/Matrix3.js'; import Vector2 from '../../../dot/js/Vector2.js'; import Poolable from '../../../phet-core/js/Poolable.js'; import cleanArray from '../../../phet-core/js/cleanArray.js'; +import platform from '../../../phet-core/js/platform.js'; import scenery from '../scenery.js'; import CanvasContextWrapper from '../util/CanvasContextWrapper.js'; import Features from '../util/Features.js'; @@ -323,7 +324,9 @@ class CanvasBlock extends FittedBlock { bottomWrapper.context.setTransform( 1, 0, 0, 1, 0, 0 ); const filters = node._filters; - let canUseInternalFilter = Features.canvasFilter; + // We need to fall back to a different filter behavior with Chrome, since it over-darkens otherwise with the + // built-in feature. + let canUseInternalFilter = Features.canvasFilter && !platform.chromium; for ( let j = 0; j < filters.length; j++ ) { // If we use context.filter, it's equivalent to checking DOM compatibility on all of them. canUseInternalFilter = canUseInternalFilter && filters[ j ].isDOMCompatible(); diff --git a/js/nodes/Node.js b/js/nodes/Node.js index 79bd5d4b8..242f73c52 100644 --- a/js/nodes/Node.js +++ b/js/nodes/Node.js @@ -168,6 +168,7 @@ import arrayDifference from '../../../phet-core/js/arrayDifference.js'; import deprecationWarning from '../../../phet-core/js/deprecationWarning.js'; import inherit from '../../../phet-core/js/inherit.js'; import merge from '../../../phet-core/js/merge.js'; +import platform from '../../../phet-core/js/platform.js'; import PhetioObject from '../../../tandem/js/PhetioObject.js'; import BooleanIO from '../../../tandem/js/types/BooleanIO.js'; import IOType from '../../../tandem/js/types/IOType.js'; @@ -4464,7 +4465,7 @@ inherit( PhetioObject, Node, { if ( child._filters.length ) { // Filters shouldn't be too often, so less concerned about the GC here (and this is so much easier to read). - if ( Features.canvasFilter && _.every( child._filters, filter => filter.isDOMCompatible() ) ) { + if ( Features.canvasFilter && !platform.chromium && _.every( child._filters, filter => filter.isDOMCompatible() ) ) { wrapper.context.filter = child._filters.map( filter => filter.getCSSFilterString() ).join( ' ' ); } else { diff --git a/js/util/ColorMatrixFilter.js b/js/util/ColorMatrixFilter.js index cb40848b9..ee5fbeb3a 100644 --- a/js/util/ColorMatrixFilter.js +++ b/js/util/ColorMatrixFilter.js @@ -7,12 +7,14 @@ */ import toSVGNumber from '../../../dot/js/toSVGNumber.js'; +import platform from '../../../phet-core/js/platform.js'; import scenery from '../scenery.js'; import CanvasContextWrapper from './CanvasContextWrapper.js'; import Filter from './Filter.js'; import Utils from './Utils.js'; const isImageDataSupported = Utils.supportsImageDataCanvasFilter(); +const useFakeGamma = platform.chromium; class ColorMatrixFilter extends Filter { /** @@ -40,7 +42,7 @@ class ColorMatrixFilter extends Filter { constructor( m00, m01, m02, m03, m04, m10, m11, m12, m13, m14, m20, m21, m22, m23, m24, - m30, m31, m32, m33, m34 ) { + m30, m31, m32, m33, m34 ) { assert && assert( typeof m00 === 'number' && isFinite( m00 ), 'm00 should be a finite number' ); assert && assert( typeof m01 === 'number' && isFinite( m01 ), 'm01 should be a finite number' ); @@ -134,16 +136,33 @@ class ColorMatrixFilter extends Filter { for ( let i = 0; i < size; i++ ) { const index = i * 4; - const r = imageData.data[ index + 0 ]; - const g = imageData.data[ index + 1 ]; - const b = imageData.data[ index + 2 ]; - const a = imageData.data[ index + 3 ]; - - // Clamp/round should be done by the UInt8Array, we don't do it here for performance reasons. - imageData.data[ index + 0 ] = r * this.m00 + g * this.m01 + b * this.m02 + a * this.m03 + this.m04; - imageData.data[ index + 1 ] = r * this.m10 + g * this.m11 + b * this.m12 + a * this.m13 + this.m14; - imageData.data[ index + 2 ] = r * this.m20 + g * this.m21 + b * this.m22 + a * this.m23 + this.m24; - imageData.data[ index + 3 ] = r * this.m30 + g * this.m31 + b * this.m32 + a * this.m33 + this.m34; + if ( useFakeGamma ) { + // Gamma-corrected version, which seems to match SVG/DOM + // Eek, this seems required for chromium Canvas to have a standard behavior? + const gamma = 1.45; + const r = Math.pow( imageData.data[ index + 0 ] / 255, gamma ); + const g = Math.pow( imageData.data[ index + 1 ] / 255, gamma ); + const b = Math.pow( imageData.data[ index + 2 ] / 255, gamma ); + const a = Math.pow( imageData.data[ index + 3 ] / 255, gamma ); + + // Clamp/round should be done by the UInt8Array, we don't do it here for performance reasons. + imageData.data[ index + 0 ] = 255 * Math.pow( r * this.m00 + g * this.m01 + b * this.m02 + a * this.m03 + this.m04, 1 / gamma ); + imageData.data[ index + 1 ] = 255 * Math.pow( r * this.m10 + g * this.m11 + b * this.m12 + a * this.m13 + this.m14, 1 / gamma ); + imageData.data[ index + 2 ] = 255 * Math.pow( r * this.m20 + g * this.m21 + b * this.m22 + a * this.m23 + this.m24, 1 / gamma ); + imageData.data[ index + 3 ] = 255 * Math.pow( r * this.m30 + g * this.m31 + b * this.m32 + a * this.m33 + this.m34, 1 / gamma ); + } + else { + const r = imageData.data[ index + 0 ]; + const g = imageData.data[ index + 1 ]; + const b = imageData.data[ index + 2 ]; + const a = imageData.data[ index + 3 ]; + + // Clamp/round should be done by the UInt8Array, we don't do it here for performance reasons. + imageData.data[ index + 0 ] = r * this.m00 + g * this.m01 + b * this.m02 + a * this.m03 + this.m04; + imageData.data[ index + 1 ] = r * this.m10 + g * this.m11 + b * this.m12 + a * this.m13 + this.m14; + imageData.data[ index + 2 ] = r * this.m20 + g * this.m21 + b * this.m22 + a * this.m23 + this.m24; + imageData.data[ index + 3 ] = r * this.m30 + g * this.m31 + b * this.m32 + a * this.m33 + this.m34; + } } wrapper.context.putImageData( imageData, 0, 0 ); diff --git a/js/util/Filter.js b/js/util/Filter.js index bc8532719..09db98871 100644 --- a/js/util/Filter.js +++ b/js/util/Filter.js @@ -125,6 +125,7 @@ class Filter { feColorMatrix.setAttribute( 'type', 'matrix' ); feColorMatrix.setAttribute( 'values', matrixValues ); feColorMatrix.setAttribute( 'in', inName ); + feColorMatrix.setAttribute( 'color-interpolation-filters', 'sRGB' ); if ( resultName ) { feColorMatrix.setAttribute( 'result', resultName ); } diff --git a/js/util/Grayscale.js b/js/util/Grayscale.js index d5009163d..c3f4c4681 100644 --- a/js/util/Grayscale.js +++ b/js/util/Grayscale.js @@ -22,6 +22,11 @@ class Grayscale extends ColorMatrixFilter { const n = 1 - amount; + // https://drafts.fxtf.org/filter-effects/#grayscaleEquivalent + // (0.2126 + 0.7874 * [1 - amount]) (0.7152 - 0.7152 * [1 - amount]) (0.0722 - 0.0722 * [1 - amount]) 0 0 + // (0.2126 - 0.2126 * [1 - amount]) (0.7152 + 0.2848 * [1 - amount]) (0.0722 - 0.0722 * [1 - amount]) 0 0 + // (0.2126 - 0.2126 * [1 - amount]) (0.7152 - 0.7152 * [1 - amount]) (0.0722 + 0.9278 * [1 - amount]) 0 0 + // 0 0 0 1 0 super( 0.2126 + 0.7874 * n, 0.7152 - 0.7152 * n, 0.0722 - 0.0722 * n, 0, 0, 0.2126 - 0.2126 * n, 0.7152 + 0.2848 * n, 0.0722 - 0.0722 * n, 0, 0, diff --git a/tests/filter-tests.html b/tests/filter-tests.html new file mode 100644 index 000000000..b571fbee0 --- /dev/null +++ b/tests/filter-tests.html @@ -0,0 +1,183 @@ + + + + + + + + + + + + + Filter/clip testing for 707 + + + + + + + + + + + + + + + + + + + + + + + From 029a883bdc7a9e6732f36dde46c1c815f3cff8fa Mon Sep 17 00:00:00 2001 From: Jonathan Olson Date: Thu, 5 Nov 2020 16:14:15 -0700 Subject: [PATCH 2/2] Adding more filter documentation, see https://github.com/phetsims/scenery/issues/707 --- js/nodes/Node.js | 20 ++++++++++++++++++++ js/util/Brightness.js | 5 ++++- js/util/ColorMatrixFilter.js | 16 ++++++++++++++++ js/util/Contrast.js | 5 ++++- js/util/DropShadow.js | 2 ++ js/util/Filter.js | 27 +++++++++++++++++++++++++++ js/util/GaussianBlur.js | 2 ++ js/util/Grayscale.js | 2 +- js/util/HueRotate.js | 2 +- js/util/Invert.js | 5 ++++- js/util/Opacity.js | 4 +++- js/util/Saturate.js | 2 +- js/util/Sepia.js | 5 ++++- 13 files changed, 89 insertions(+), 8 deletions(-) diff --git a/js/nodes/Node.js b/js/nodes/Node.js index 242f73c52..15721f76b 100644 --- a/js/nodes/Node.js +++ b/js/nodes/Node.js @@ -3313,12 +3313,32 @@ inherit( PhetioObject, Node, { * Sets the non-opacity filters for this Node. * @public * + * The default is an empty array (no filters). It should be an array of Filter objects, which will be effectively + * applied in-order on this Node (and its subtree), and will be applied BEFORE opacity/clipping. + * + * NOTE: Some filters may decrease performance (and this may be platform-specific). Please read documentation for each + * filter before using. + * + * Typical filter types to use are: + * - Brightness + * - Contrast + * - DropShadow (EXPERIMENTAL) + * - GaussianBlur (EXPERIMENTAL) + * - Grayscale (Grayscale.FULL for the full effect) + * - HueRotate + * - Invert (Invert.FULL for the full effect) + * - Saturate + * - Sepia (Sepia.FULL for the full effect) + * + * Filter.js has more information in general on filters. + * * @param {Array.} filters */ setFilters: function( filters ) { assert && assert( Array.isArray( filters ), 'filters should be an array' ); assert && assert( _.every( filters, filter => filter instanceof Filter ), 'filters should consist of Filter objects only' ); + // We re-use the same array internally, so we don't reference a potentially-mutable array from outside. this._filters.length = 0; this._filters.push( ...filters ); diff --git a/js/util/Brightness.js b/js/util/Brightness.js index 8f882c6e2..5bc0fcdcb 100644 --- a/js/util/Brightness.js +++ b/js/util/Brightness.js @@ -12,7 +12,7 @@ import ColorMatrixFilter from './ColorMatrixFilter.js'; class Brightness extends ColorMatrixFilter { /** - * @param {number} amount + * @param {number} amount - How bright to be, from 0 (dark), 1 (normal), or larger values to brighten */ constructor( amount ) { assert && assert( typeof amount === 'number', 'Brightness amount should be a number' ); @@ -54,5 +54,8 @@ class Brightness extends ColorMatrixFilter { } } +// @public {Brightness} - Fully darkens the content +Brightness.BLACKEN = new Brightness( 0 ); + scenery.register( 'Brightness', Brightness ); export default Brightness; \ No newline at end of file diff --git a/js/util/ColorMatrixFilter.js b/js/util/ColorMatrixFilter.js index ee5fbeb3a..bcd16a62d 100644 --- a/js/util/ColorMatrixFilter.js +++ b/js/util/ColorMatrixFilter.js @@ -18,6 +18,22 @@ const useFakeGamma = platform.chromium; class ColorMatrixFilter extends Filter { /** + * NOTE: It is possible but not generally recommended to create custom ColorMatrixFilter types. They should be + * compatible with Canvas and SVG, HOWEVER any WebGL/DOM content cannot work with those custom filters, and any + * combination of multiple SVG or Canvas elements will ALSO not work (since there is no CSS filter function that can + * do arbitrary color matrix operations). This means that performance will likely be reduced UNLESS all content is + * within a single SVG block. + * + * Please prefer the named subtypes where possible. + * + * The resulting color is the result of the matrix multiplication: + * + * [ m00 m01 m02 m03 m04 ] [ r ] + * [ m10 m11 m12 m13 m14 ] [ g ] + * [ m20 m21 m22 m23 m24 ] * [ b ] + * [ m30 m31 m32 m33 m34 ] [ a ] + * [ 1 ] + * * @param {number} m00 * @param {number} m01 * @param {number} m02 diff --git a/js/util/Contrast.js b/js/util/Contrast.js index be0e06626..eb9f7cc19 100644 --- a/js/util/Contrast.js +++ b/js/util/Contrast.js @@ -12,7 +12,7 @@ import ColorMatrixFilter from './ColorMatrixFilter.js'; class Contrast extends ColorMatrixFilter { /** - * @param {number} amount + * @param {number} amount - The amount of the effect, from 0 (gray), 1 (normal), or above for high-contrast */ constructor( amount ) { assert && assert( typeof amount === 'number', 'Contrast amount should be a number' ); @@ -54,5 +54,8 @@ class Contrast extends ColorMatrixFilter { } } +// @public {Contrast} - Turns the content gray +Contrast.GRAY = new Contrast( 0 ); + scenery.register( 'Contrast', Contrast ); export default Contrast; \ No newline at end of file diff --git a/js/util/DropShadow.js b/js/util/DropShadow.js index d1548e698..71b4d1a74 100644 --- a/js/util/DropShadow.js +++ b/js/util/DropShadow.js @@ -3,6 +3,8 @@ /** * DropShadow filter * + * EXPERIMENTAL! DO not use in production code yet + * * TODO: preventFit OR handle bounds increase (or both) * * @author Jonathan Olson diff --git a/js/util/Filter.js b/js/util/Filter.js index 09db98871..6975d64b0 100644 --- a/js/util/Filter.js +++ b/js/util/Filter.js @@ -3,6 +3,27 @@ /** * Base type for filters * + * Filters have different ways of being applied, depending on what the platform supports AND what content is below. + * These different ways have potentially different performance characteristics, and potentially quality differences. + * + * The current ways are: + * - DOM element with CSS filter specified (can include mixed content and WebGL underneath, and this is used as a + * general fallback). NOTE: General color matrix support is NOT provided under this, we only have specific named + * filters that can be used. + * - SVG filter elements (which are very flexible, a combination of filters may be combined into SVG filter elements). + * This only works if ALL of the content under the filter(s) can be placed in one SVG element, so a layerSplit or + * non-SVG content can prevent this from being used. + * - Canvas filter attribute (similar to DOM CSS). Similar to DOM CSS, but not as accelerated (requires applying the + * filter by drawing into another Canvas). Chromium-based browsers seem to have issues with the color space used, + * so this can't be used on that platform. Additionally, this only works if ALL the content under the filter(s) can + * be placed in one Canvas, so a layerSplit or non-SVG content can prevent this from being used. + * - Canvas ImageData. This is a fallback where we directly get, manipulate, and set pixel data in a Canvas (with the + * corresponding performance hit that it takes to CPU-process every pixel). Additionally, this only works if ALL the + * content under the filter(s) can be placed in one Canvas, so a layerSplit or non-SVG content can prevent this from + * being used. + * + * Some filters may have slightly different appearances depending on the browser/platform/renderer. + * * @author Jonathan Olson */ @@ -113,6 +134,7 @@ class Filter { } /** + * Applies a color matrix effect into an existing SVG filter. * @public * * @param {string} matrixValues @@ -122,10 +144,15 @@ class Filter { */ static applyColorMatrix( matrixValues, svgFilter, inName, resultName ) { const feColorMatrix = document.createElementNS( svgns, 'feColorMatrix' ); + feColorMatrix.setAttribute( 'type', 'matrix' ); feColorMatrix.setAttribute( 'values', matrixValues ); feColorMatrix.setAttribute( 'in', inName ); + + // Since the DOM effects are done with sRGB and we can't manipulate that, we'll instead adjust SVG to apply the + // effects in sRGB so that we have consistency feColorMatrix.setAttribute( 'color-interpolation-filters', 'sRGB' ); + if ( resultName ) { feColorMatrix.setAttribute( 'result', resultName ); } diff --git a/js/util/GaussianBlur.js b/js/util/GaussianBlur.js index 7d2dc2776..8feb4a9c4 100644 --- a/js/util/GaussianBlur.js +++ b/js/util/GaussianBlur.js @@ -3,6 +3,8 @@ /** * GaussianBlur filter * + * EXPERIMENTAL! DO not use in production code yet + * * TODO: preventFit OR handle bounds increase (or both) * * @author Jonathan Olson diff --git a/js/util/Grayscale.js b/js/util/Grayscale.js index c3f4c4681..3de8ec23f 100644 --- a/js/util/Grayscale.js +++ b/js/util/Grayscale.js @@ -12,7 +12,7 @@ import ColorMatrixFilter from './ColorMatrixFilter.js'; class Grayscale extends ColorMatrixFilter { /** - * @param {number} [amount] + * @param {number} [amount] - The amount of the effect, from 0 (none) to 1 (full) */ constructor( amount = 1 ) { assert && assert( typeof amount === 'number', 'Grayscale amount should be a number' ); diff --git a/js/util/HueRotate.js b/js/util/HueRotate.js index f658287d8..91f77c70f 100644 --- a/js/util/HueRotate.js +++ b/js/util/HueRotate.js @@ -13,7 +13,7 @@ import ColorMatrixFilter from './ColorMatrixFilter.js'; class HueRotate extends ColorMatrixFilter { /** - * @param {number} amount - In radians + * @param {number} amount - In radians, the amount of hue to color-shift */ constructor( amount ) { assert && assert( typeof amount === 'number', 'HueRotate amount should be a number' ); diff --git a/js/util/Invert.js b/js/util/Invert.js index 666da9137..0616626fd 100644 --- a/js/util/Invert.js +++ b/js/util/Invert.js @@ -12,7 +12,7 @@ import Filter from './Filter.js'; class Invert extends Filter { /** - * @param {number} [amount] + * @param {number} [amount] - The amount of the effect, from 0 (none) to 1 (full) */ constructor( amount = 1 ) { assert && assert( typeof amount === 'number', 'Invert amount should be a number' ); @@ -50,5 +50,8 @@ class Invert extends Filter { } } +// @public {Invert} +Invert.FULL = new Invert( 1 ); + scenery.register( 'Invert', Invert ); export default Invert; \ No newline at end of file diff --git a/js/util/Opacity.js b/js/util/Opacity.js index 2961ecfa0..d4943c0d7 100644 --- a/js/util/Opacity.js +++ b/js/util/Opacity.js @@ -12,7 +12,9 @@ import Filter from './Filter.js'; class Opacity extends Filter { /** - * @param {number} amount + * NOTE: Generally prefer setting a Node's opacity, unless this is required for stacking of filters. + * + * @param {number} amount - The amount of opacity, from 0 (invisible) to 1 (fully visible) */ constructor( amount ) { assert && assert( typeof amount === 'number', 'Opacity amount should be a number' ); diff --git a/js/util/Saturate.js b/js/util/Saturate.js index 49fe2f449..ac1bd70c2 100644 --- a/js/util/Saturate.js +++ b/js/util/Saturate.js @@ -12,7 +12,7 @@ import ColorMatrixFilter from './ColorMatrixFilter.js'; class Saturate extends ColorMatrixFilter { /** - * @param {number} amount + * @param {number} amount - The amount of the effect, from 0 (no saturation), 1 (normal), or higher to over-saturate */ constructor( amount ) { assert && assert( typeof amount === 'number', 'Saturate amount should be a number' ); diff --git a/js/util/Sepia.js b/js/util/Sepia.js index b54e2adb3..3de8b6be1 100644 --- a/js/util/Sepia.js +++ b/js/util/Sepia.js @@ -12,7 +12,7 @@ import ColorMatrixFilter from './ColorMatrixFilter.js'; class Sepia extends ColorMatrixFilter { /** - * @param {number} [amount] + * @param {number} [amount] - The amount of the effect, from 0 (none) to 1 (full sepia) */ constructor( amount = 1 ) { assert && assert( typeof amount === 'number', 'Sepia amount should be a number' ); @@ -55,5 +55,8 @@ class Sepia extends ColorMatrixFilter { } } +// @public {Sepia} +Sepia.FULL = new Sepia( 1 ); + scenery.register( 'Sepia', Sepia ); export default Sepia; \ No newline at end of file