HTML5 Canvas blendmode - javascript

I'm new to HTML5 canvas and I want to reproduce the result of BlendMode.ADD in ActionScript 3.
According to the documentation, here's what BlendMode.ADD does:
Adds the values of the constituent colors of the display object to the
colors of its background, applying a ceiling of 0xFF. This setting is
commonly used for animating a lightening dissolve between two objects.
For example, if the display object has a pixel with an RGB value of
0xAAA633, and the background pixel has an RGB value of 0xDD2200, the
resulting RGB value for the displayed pixel is 0xFFC833 (because 0xAA
+ 0xDD > 0xFF, 0xA6 + 0x22 = 0xC8, and 0x33 + 0x00 = 0x33).
Source: http://help.adobe.com/en_US/FlashPlatform/reference/actionscript/3/flash/display/BlendMode.html#ADD
How can I do the same thing to an image in HTML5 Canvas?

The specification of the 2D canvas has implemented the blending mode with the name "lighter" (not to be confused with "lighten" which is a different mode) that will do "add".
var ctx = c.getContext("2d");
ctx.fillStyle = "#037";
ctx.fillRect(0, 0, 130, 130);
ctx.globalCompositeOperation = "lighter"; // AKA add / linear-dodge
ctx.fillStyle = "#777";
ctx.fillRect(90, 20, 130, 130);
<canvas id=c></canvas>
(update: I was remembering (incorrectly) lighten as the name for it, so sorry for the extra manual step in the original version of the answer).

Related

"saturation" globalCompositeOperation without changing transparency?

I have a canvas containing art on a transparent background. I desaturate it like this:
boardCtx.fillStyle = "rgba(0, 0, 0, 1.0)";
boardCtx.globalCompositeOperation = 'saturation';
boardCtx.fillRect(0, 0, boardCanvas.width, boardCanvas.height);
and find that the transparent background has turned opaque black. I wouldn't expect the saturation blend mode to change the alpha channel... am I doing something wrong? My current solution is to copy the canvas before desaturation and use it to mask the black background away from the desaturated copy, but that involves another canvas and a big draw... not ideal.
You can use ctx.filter
The 2D context filter can be used to apply various filters to the canvas.
ctx.filter = "saturate(0%)";
ctx.drawImage(ctx.canvas,0,0);
But this will add to the alpha if there is anti-aliasing / transparency, reducing quality.
Fix Alpha
To fix you need to use the ctx.globalCompositeOperation = "copy" operation.
ctx.filter = "saturate(0%)";
ctx.globalCompositeOperation = "copy";
ctx.drawImage(ctx.canvas,0,0);
// restore defaults;
ctx.filter = "";
ctx.globalCompositeOperation = "source-over";
This will stop the alpha channel from being modified.
Check support.
Warning. Check browser support at bottom of filter page. If no support you will have to use a copy of the canvas to restore the alpha if you use ctx.globalCompositeOperation = "saturation"
Blending modes will work only on the foreground (source) layer without respect to the alpha channel, while the regular composite operations only use alpha channels - this is why you see the opaque result.
To solve simply add a "clipping call" to the existing content after de-saturation process using composition mode "destination-out", then redraw the image:
// draw image 1. time
boardCtx.fillStyle = "#000";
boardCtx.globalCompositeOperation = 'saturation';
boardCtx.fillRect(0, 0, boardCanvas.width, boardCanvas.height);
boardCtx.globalCompositeOperation = 'destination-out';
// draw image again 2. time
This will also restore the original alpha channel.
If the art is not an image source then you can take a snapshot by drawing the canvas to a temporary canvas, then use that temporary canvas as image source when drawing back using the same steps as above.
You can also use filters as in the other answer (there is also a filter "grayscale" which is slightly more efficient than "saturate") but currently only Chrome (from v52) and Firefox (from v49) supports filter, as well as Webview on Android (from v52).
/*
CanvasRenderingContext2D.filter (EXPERIMENTAL, On Standard Track)
https://developer.mozilla.org/docs/Web/API/CanvasRenderingContext2D/filter
DESKTOP:
Chrome | Edge | Firefox | IE | Opera | Safari
----------+-----------+-----------+-----------+-----------+-----------
52 | ? | 49° | - | - | -
°) 35-48: Behind flag canvas.filters.enabled set to true.
MOBILE:
Chrome/A | Edge/mob | Firefox/A | Opera/A |Safari/iOS | Webview/A
----------+-----------+-----------+-----------+-----------+-----------
52 | ? | 49° | - | - | 52
°) 35-48: Behind flag canvas.filters.enabled set to true.
*/
A third approach is to iterate over the pixels and do the desaturation. This would only be necessary if you intend to support older browsers which do not support the blending modes.
var ctx = c.getContext("2d"), i = new Image;
i.onload = function() {
ctx.drawImage(this, 0, 0); // draw image normally
ctx.globalCompositeOperation = "saturation"; // desaturate (blending removes alpha)
ctx.fillRect(0, 0, c.width, c.height);
ctx.globalCompositeOperation = "destination-in"; // knock out the alpha channel
ctx.drawImage(this, 0, 0); // by redrawing image using this mode
};
i.src = "//i.stack.imgur.com/F4ukA.png";
<canvas id=c></canvas>

Subtract opacity instead of multiplying

I'm trying to make it appear as though movement on my <canvas> creates motion trails. In order to do this, instead of clearing the canvas between frames I reduce the opacity of the existing content by replacing a clearRect call with something like this:
// Redraw the canvas's contents at lower opacity. The 'copy' blend
// mode keeps only the new content, discarding what was previously
// there. That way we don't have to use a second canvas when copying
// data
ctx.globalCompositeOperation = 'copy';
ctx.globalAlpha = 0.98;
ctx.drawImage(canvas, 0, 0);
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
However, since setting globalAlpha multiplies alpha values, the alpha values of the trail can approach zero but will never actually reach it. This means that graphics never quite fade, leaving traces like these on the canvas that do not fade even after thousands of frames have passed over several minutes:
To combat this, I've been subtracting alpha values pixel-by-pixel instead of using globalAlpha. Subtraction guarantees that the pixel opacity will reach zero.
// Reduce opacity of each pixel in canvas
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
// Iterates, hitting only the alpha values of each pixel.
for (let i = 3; i < data.length; i += 4) {
// Use 0 if the result of subtraction would be less than zero.
data[i] = Math.max(data[i] - (0.02 * 255), 0);
}
ctx.putImageData(imageData, 0, 0);
This fixes the problem, but it's extremely slow since I'm manually changing each pixel value and then using the expensive putImageData() method.
Is there a more performant way to subtract, rather than multiplying, the opacity of pixels being drawn on the canvas?
Unfortunately there is nothing we can do about it except from manually iterating over the pixels to clear low-value alpha pixels like you do already.
The problem is related to integer math and rounding (more details at this link, from the answer).
There are blending modes such as "luminosity" (and to a certain degree "multiply") which can be used to subtract luma, the problem is it works on the entire surface contrary to composite modes which only works on alpha - there is no equivalent in composite operations. So this won't help here.
There is also a new luma mask via CSS but the problem is that the image source (which in theory could've been manipulated using for example contrast) has to be updated every frame and basically, the performance would be very bad.
Workaround
One workaround is to use "particles". That is, instead of using a feedback-loop instead log and store the path points, then redraw all logged points every frame. Using a max value and reusing that to set alpha can work fine in many cases.
This simple example is just a proof-of-concept and can be implemented in various ways in regards to perhaps pre-populated arrays, order of drawing, alpha value calculations and so forth. But I think you'll get the idea.
var ctx = c.getContext("2d");
var cx = c.width>>1, cy = c.height>>1, r = c.width>>2, o=c.width>>3;
var particles = [], max = 50;
ctx.fillStyle = "#fff";
(function anim(t) {
var d = t * 0.002, x = cx + r * Math.cos(d), y = cy + r * Math.sin(d);
// store point and trim array when reached max
particles.push({x: x, y: y});
if (particles.length > max) particles.shift();
// clear frame as usual
ctx.clearRect(0,0,c.width,c.height);
// redraw all particles at a log. alpha, except last which is drawn full
for(var i = 0, p, a; p = particles[i++];) {
a = i / max * 0.6;
ctx.globalAlpha = i === max ? 1 : a*a*a;
ctx.fillRect(p.x-o, p.y-o, r, r); // or image etc.
}
requestAnimationFrame(anim);
})();
body {background:#037}
<canvas id=c width=400 height=400></canvas>

Chrome canvas 2d context measureText giving me weird results

Here's a compact version of my problem
let canvas = document.createElement('canvas')
let ctx = canvas.getContext('2d')
ctx.font = '11pt Calibri'
ctx.fillStyle = '#000000'
let temp = ctx.font
console.log(ctx.font)
console.log(ctx.measureText('M').width)
ctx.font = 'bold ' + ctx.font
console.log(ctx.font)
console.log(ctx.measureText('M').width)
ctx.font = 'italic ' + ctx.font
console.log(ctx.font)
console.log(ctx.measureText('M').width)
ctx.font = temp
console.log(ctx.font)
console.log(ctx.measureText('M').width)
Running this code in chrome produces incorrect numbers, at least at the end. First I'm setting the font to '11pt Calibri', but chrome immediately changes it to '15px Calibri' for some reason, and because of that it's producing text that's slightly bigger than correct. I read that the canvas runs at 96dpi so the correct px should be 14.6.
After that, I'm measuring the width of a text M, which comes out at 12.53401184 for me, this number is important.
After that, I've modified the font to add bold and italic, and then I roll it back to what the font originally was. Now when I measure it, it gives me 12.824707, which is a massive 0.3px off. I'm drawing text on a canvas with anywhere from 600px to 800px width, and I need it to wrap correctly, so I need it to be precise to 1px over the line, so individual letters need to have at least 0.02px accuracy, which worked decently until I started using bolds and italics.
None of the above problems exist on firefox, and disabling canvas hardware acceleration on chrome doesn't seem to have any effect. I'm using chrome 52.0 which is the current latest version.
Edit: I figured out you don't even need to do any of that to get the incorrect number, simply doing this is enough.
let canvas = document.createElement('canvas')
let ctx = canvas.getContext('2d')
ctx.font = '11pt Calibri'
ctx.fillStyle = '#000000'
console.log(ctx.font)
console.log(ctx.measureText('M').width)
let temp = ctx.font
ctx.font = temp
console.log(ctx.font)
console.log(ctx.measureText('M').width)
Don't use "pt" for font sizing on canvas.
CSS Absolute and Magic Units
Using pt for font sizing is not recommended as it has no real meaning for media that represent visual information in terms of pixels (discrete indivisible image units) and is displayed on screens which have no fixed pixel density.
pt is an absolute measuring unit, the same as cm while px is a "magic unit" and only ever has an absolute meaning when the media type is print.
OP: "I read that the canvas runs at 96dpi so the correct px should be 14.6."
This is not correct the canvas does not have a absolute measuring unit. A pixel as a CSS unit only has an absolute dimension when the media type is print in which case 1px = 1/96 of an inch. The canvas is not considered as a printed media.
Why does the Width change?
The apparent problem
ctx.font = '11pt Calibri'
console.log(ctx.font); // 15px Calibri
console.log(ctx.measureText('M').width); // 12.534011840820312
ctx.font = ctx.font
console.log(ctx.font); // 15px Calibri
console.log(ctx.measureText('M').width); // 12.82470703125
Though the ctx.font values are the same the measured font widths are different
The simple solution
ctx.font = ctx.font = '11pt Calibri';
Will avoid the measured size discrepancies, but i am sure that nobody would consider this as anything but an ugly work around to an "apparent" browser specific bug.
The solution
Don't use pt units when setting canvas fonts.
What is going on.
The issue is a misunderstanding of what the ctx.font property actually is. It does not represent the current font's actual internal representation but rather an abstract human readable form.
W3C 2D Canvas: "On getting, the font attribute must return the serialized form of the current font of the context."
The process of serialization will lose precision. Serialising CSS values.The W3C standard specifies that the font-size be in px units which in this case further amplifies the apparent "bug"
The font properties set function takes the CSS font string, parses it. If valid then sets the canvas internal font and writes the serialized CSS font value to context.font The two do not have to match and the standard does not specify that they should.
Summary
The behaviour as described in the question is not a "bug". Inconsistency between browsers though (as always) is a concern. If we are to follow the standards, one could consider that browsers not showing the measurement inconsistency have interpreted the standard incorrectly and filled a ambiguity with their own interpretation (though this is speculative on my part).
The simple solution to the problem is to follow the standard's guidelines and not use pt when setting font-size values for anything but printed media.
As with all computer media "dpi" only ever has meaning when printing and is not defined until then. Nor do pixels necessarily equate to dots when printing. Always use resolution when referring to pixels rather than dp1 (my pet hate)
I realized why it's broken. Chrome does something internally to compensate for pt values, even though the font gets highjacked to 15px. So when I get the font value from the ctx.font to modify it, I'm getting the modified px value, instead of the original pt so I'm actually giving it a raw 15px value, so when this happens chrome doesn't compensate. A workaround is to keep the original font somewhere else, like ctx.originalFont and then use it for modifying instead of ctx.font
For example this works
let canvas = document.createElement('canvas')
let ctx = canvas.getContext('2d')
ctx.font = '11pt Calibri'
ctx.originalFont = '11pt Calibri'
ctx.fillStyle = '#000000'
console.log(ctx.font)
console.log(ctx.measureText('M').width)
let temp = ctx.originalFont
ctx.font = temp
console.log(ctx.font)
console.log(ctx.measureText('M').width)

Merge canvas image and canvas alpha mask into dataurl generated png

given two canvas with the same pixel size, where canvas1 contains an arbitrary image (jpg, png or such) and canvas2 contains black and non-black pixels.
what i want to achive: using a third canvas3 i want to clone canvas1 and have every black canvas2 pixel (may including a threshold of blackness) be transparent in canvas3
i already have a working solution like this:
canvas3context.drawImage(canvas1,0,0);
var c3img = canvas3context.getImageData(0,0,canvas3.width,canvas3.height);
var c2img = canvas2context.getImageData(0,0,canvas2.width,canvas2.height);
loop(){
if(c2img i-th,i+1-th,i+2-th pixel is lower than threshold)
set c3img.data[i]=c3img.data[i+1]=c3img.data[i+2]=c3img.data[i+3]=0
}
the problem with above (pseudo) code is, that it is slow
so my question is: anyone can share an idea how to speed this up significantly?
i thought about webgl but i never worked with it - so i have no idea about shaders or the tools or terms needed for this. another idea was that maybe i could convert canvas2 to black&white somehow very fast (not just modifieng every pixel in a loop like above) and work with blend modes to generate the transparent pixels
any help is highly appreciated
answering my own question, i provide a solution for merging an arbitrary image with a black&white image. what im still missing is how to set the alpha channel for just one color of a canvas.
I seperate the question in pieces and answer them each.
Question 1: How to convert a canvas into grayscale without iterating every pixel?
Answer: draw the image on to a white canvas with blend mode 'luminosity'
function convertCanvasToGrayscale(canvas){
var tmp = document.createElement('canvas');
tmp.width = canvas.width;
tmp.height = canvas.height;
var tmpctx = tmp.getContext('2d');
// conversion
tmpctx.globalCompositeOperation="source-over"; // default composite value
tmpctx.fillStyle="#FFFFFF";
tmpctx.fillRect(0,0,canvas.width,canvas.height);
tmpctx.globalCompositeOperation="luminosity";
tmpctx.drawImage(canvas,0,0);
// write converted back to canvas
ctx = canvas.getContext('2d');
ctx.globalCompositeOperation="source-over";
ctx.drawImage(tmp, 0, 0);
}
Question 2: How to convert a grayscale canvas into black&white without iterating every pixel?
Answer: two times color-dodge blend mode with color #FEFEFE will do the job
function convertGrayscaleCanvasToBlackNWhite(canvas){
var ctx = canvas.getContext('2d');
// in case the grayscale conversion is to bulky for ya
// darken the canvas bevore further black'nwhite conversion
//for(var i=0;i<3;i++){
// ctx.globalCompositeOperation = 'multiply';
// ctx.drawImage(canvas, 0, 0);
//}
ctx.globalCompositeOperation = 'color-dodge';
ctx.fillStyle = "rgba(253, 253, 253, 1)";
ctx.beginPath();
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fill();
ctx.globalCompositeOperation = 'color-dodge';
ctx.fillStyle = "rgba(253, 253, 253, 1)";
ctx.beginPath();
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fill();
}
Note: this function assumes that you want black areas left black and every non-black pixel become white! thus a grayscale image which has no black pixel will become completely white
the reason i choose this operation is that it worked better in my case and using only two blend operations means its pretty fast - if you want that more dark pixel be left black and more white pixel become white you can use the commented for loop to darken the image beforehand. thus dark pixel will become black and brighter pixel become darker. as you increase the amount of black pixel's using color-dodge will again do the rest of the job
Question 3: How to merge a Black&White canvas with another canvas without iterating every pixel?
Answer: use 'multiply' blend mode
function getBlendedImageWithBlackNWhite(canvasimage, canvasbw){
var tmp = document.createElement('canvas');
tmp.width = canvasimage.width;
tmp.height = canvasimage.height;
var tmpctx = tmp.getContext('2d');
tmpctx.globalCompositeOperation = 'source-over';
tmpctx.drawImage(canvasimage, 0, 0);
// multiply means, that every white pixel gets replaced by canvasimage pixel
// and every black pixel will be left black
tmpctx.globalCompositeOperation = 'multiply';
tmpctx.drawImage(canvasbw, 0, 0);
return tmp;
}
Question 4: How to invert a Black&White canvas without iterating every pixel?
Answer: use 'difference' blend mode
function invertCanvas(canvas){
var ctx = canvas.getContext("2d");
ctx.globalCompositeOperation = 'difference';
ctx.fillStyle = "rgba(255, 255, 255, 1)";
ctx.beginPath();
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fill();
}
now to 'merge' an canvasimage with a canvasmask one can do
convertCanvasToGrayscale(canvasmask);
convertGrayscaleCanvasToBlackNWhite(canvasmask);
result = getBlendedImageWithBlackNWhite(canvasimage, canvasmask);
regarding performance: obviously those blend modes are much faster than modifieng every pixel and to get a bit faster one can pack all functions together as needed into one function and recycle only one tmpcanvas - but thats left to the reader ^^
as a sidenote: i tested how the size of the resulting png differs when you compare above's getBlendedImageWithBlackNWhite result with the same image but the black areas are made transparent by iterating every pixel and setting the alpha channel
the difference in size is nearly nothing and thus if you dont really need the alpha-mask the information that every black pixel is meant to be transparent may be enough for futher processing
note: one can invert the meaning of black and white using the invertCanvas() function
if you want to know more of why i use those blend modes or how blend modes really work
you should check the math behind them - imho you're not able to develop such functions if ya dont know how they really work:
math behind blend modes
canvas composition standard including a bit math
need an example - spot the difference: http://jsfiddle.net/C3fp4/1/

How to scale alpha values in a canvas?

What's the best way to scale alpha values in a canvas?
The first problem I'm trying to solve is drawing a sprite that has intrinsic low alpha values. I want to draw it 3-4 times brighter than it really is. Currently I'm just drawing it 4 times in the same spot. (I cannot edit the image file and globalAlpha doesn't go above 1)
The second problem I'm trying to solve is drawing the boundary of multiple overlapping sprites. The sprites are circular but with squiggles. I figured I'd use this method combined with globalCompositeOperation = 'destination-out', but for that I need to maximize the alpha values for the second drawing.
As an option to markE's answer - you can simply scale the alpha channel directly.
I would only recommend this approach as a part of a pre-processing stage and not for use every time you need to use a sprite as iterating the buffer this way is a relatively slow process.
LIVE DEMO HERE
Assuming you already have the sprite in a canvas and know its position:
/// get the image data and cache its pixel buffer and length
var imageData = context.getImageData(x, y, width, height);
var data = imageData.data;
var length = data.length;
var i = 0;
var scale = 4; /// scale values 4 times. This may be a fractional value
/// scale only alpha channel
for(; i < length; i += 4) {
data[i + 3] *= scale;
}
context.putImageData(imageData, x, y);
The good thing with the Uint8ClampedArray which the canvas is using clamps and rounds the values for you so you do not need to check lower or upper bounds, nor convert the value to integer - the internal code do all this for you.
You can "brighten" an rgba color by flattening it to rgb and then increasing the rgb component values.
Convert the rgba value to rgb, also taking the background color into effect.
Increase the resulting red,green,blue values by a percentage to "brighten" the color.
Here's a function to do that (disclaimer: untested code here!):
function brighten(RGBA,bg,pct){
// convert rgba to rgb
alpha = 1 - RGBA.alpha/255;
red = Math.round((RGBA.alpha*(RGBA.red/255)+(alpha*(bg.red/255)))*255);
green = Math.round((RGBA.alpha*(RGBA.green/255)+(alpha*(bg.green/255)))*255);
blue = Math.round((RGBA.alpha*(RGBA.blue/255)+(alpha*(bg.blue/255)))*255);
// brighten the flattened rgb by a percentage (100 will leave the rgb unaltered)
redBright=parseInt( Math.min(255,red*pct/100) );
greenBright=parseInt( Math.min(255,green*pct/100) );
blueBright=parseInt( Math.min(255,blue*pct/100) );
return({red:redBright,green:greenBright,blue:blueBright});
}

Categories

Resources