2

The problem

I'm trying to create a brush tool with opacity jitter (like in Photoshop). The specific problem is:

Draw a stroke on an HTML canvas with different levels of opacity. Pixels with higher opacity should replace pixels with lower opacity; otherwise, pixels are left unchanged.

Transparency should not be lost in the process. The stroke is drawn on a separate canvas and merged with a background canvas afterwards.

The result should look like this. All code and the corresponding output can be found here (JSFiddle).

Because you can't stroke a single path with different levels of opacity (please correct me if I'm wrong) my code creates a path for each segment with different opacity.

Non-solution 1, Using the 'darken' blend mode

The darken blend mode yields the desired result when using opaque pixels but doesn't seem to work with transparency. Loosing transparency is a dealbreaker.

With opaque pixels:

enter image description here

With transparent pixels:

enter image description here

Non-solution 2, Using the 'destination-out' compositing operator

Before drawing a new stroke segment, subtract its opacity from subjacent pixels by using the 'destination-out' compositing operator. Then add the new stroke segment with 'source-over'. This works almost but it's a little bit off.

enter image description here

Looking for a solution

I want to avoid manipulating each pixel by hand (which I have done in the past). Am I missing something obvious? Is there a simple solution to this problem?

"Links to jsfiddle.net must be accompanied by code."
Community
  • 1
  • 1

3 Answers3

4

Because you can't stroke a single path with different levels of opacity (please correct me if I'm wrong)

You're wrong =)

When you use globalCompositeOperation = 'destination-out' (which you are in lineDestinationOut) you need to set the strokeStyle opacity to 1 to remove everything.

However, simply changing that in your fiddle doesn't have the required effect due to the order of your path build. Build the 10% transparent one first, the whole length, then delete and draw the two 40% transparent bits.

Here's a jsfiddle of the code below

var canvas = document.getElementById('canvas');
var cx = canvas.getContext('2d');
cx.lineCap = 'round';
cx.lineJoin = 'round';
cx.lineWidth = 40;

// Create the first line, 10% transparency, the whole length of the shape.
cx.strokeStyle = 'rgba(0,0,255,0.1)';
cx.beginPath();
cx.moveTo(20,20);
cx.lineTo(260,20);
cx.lineTo(220,60);
cx.stroke();
cx.closePath();

// Create the first part of the second line, first by clearing the first
// line, then 40% transparency.
cx.strokeStyle = 'black';
cx.globalCompositeOperation = 'destination-out';
cx.beginPath();
cx.moveTo(20,20);
cx.lineTo(100,20);
cx.stroke();
cx.strokeStyle = 'rgba(0,0,255,0.4)';
cx.globalCompositeOperation = 'source-over';
cx.stroke();
cx.closePath();

// Create the second part of the second line, same as above.
cx.strokeStyle = 'black';
cx.globalCompositeOperation = 'destination-out';
cx.beginPath();
cx.moveTo(180,20);
cx.lineTo(260,20);
cx.stroke();
cx.strokeStyle = 'rgba(0,0,255,0.4)';
cx.globalCompositeOperation = 'source-over';
cx.stroke();
cx.closePath();
Henry Blyth
  • 1,700
  • 1
  • 15
  • 23
  • +1 for creating the desired effect. It would be interesting to create a more broadly useful tool that wouldn't require custom coding for each set of lines+alphas. – markE Jan 04 '14 at 22:15
  • Thanks =) Do you simply mean a method to simplify the above, or something to query the pixels of the canvas, determine if they are more/less transparent, and draw either above or below, respectively? I thought about the latter and found [this answer](http://stackoverflow.com/a/667074/3150057), but it'll get mighty complex when multiple paths/shapes have already been drawn. – Henry Blyth Jan 04 '14 at 22:55
  • I was thinking something like this so all higher alpha pixels will replace lower alpha pixels rather than blending with them: (1) Put each line+alpha in an array. (2) Sort the array in ascending alpha order. (3) apply your draw/erase/draw method to each successive element in the array to build up a final result. – markE Jan 04 '14 at 23:18
  • That would work! It would get really complex if something was already drawn before your method, and you needed to take that into account - the kind of problem that makes my brain shutdown because the complexity increases so quickly! – Henry Blyth Jan 04 '14 at 23:22
  • @markE Thanks, Henry. Thanks, markE. There is a problem using destination-out with full opacity though (which I was already aware of): It leaves a small shadow. The cause of this is probably the anti-aliased edge of the stroke. These pixels are completely erased by destination-out leaving a gap behind. Look at [this](http://jsfiddle.net/UbBMK/) JSFiddle. You can try to compensate by adding/subtracting a correction value but the result is never perfect. But it's not off the table yet... This comment reaches its character limit so I will continue in another comment. – Another Guy Jan 05 '14 at 13:27
  • @markE Just repeating what has been suggested. My implementation could look like this: A two-dimensional array. First dimension: 100 elements for each opacity level (1%-100%). Second dimension: Strokes done with the corresponding opacity level. Drawing is done by iterating over the array beginning at 1% and working my way up, erasing pixels of lower opacity levels with destination-out before drawing the strokes of the current level with source-over. – Another Guy Jan 05 '14 at 14:07
1

Use two layers to draw to:

  • First calculate the top layer opacity 40% - 10% and set this as alpha on top layer
  • Set bottom layer to 10%
  • Set top layer with dashed lines (lineDash) (calculate the dash-pattern size based on size requirements)
  • Draw lines to both layers and the bottom layer will be a single long line, the top layer will draw a dashed line on top when stroked.
  • Copy both layers to main canvas when done.
  • Thanks. There's a caveat: My example is rather artificial. I should have pointed out that a real stroke has more levels of opacity and segments of varying length. My bad. – Another Guy Jan 05 '14 at 14:57
0

@HenryBlyth's answer is probably the best you're going to get; there's no native API to do what you're being asked to do (which, in my opinion, is kinda weird anyways... opacity isn't really supposed to replace pixels).

To spell out the solution in one paragraph: Split up your "stroke" into individual paths with different opacities. Draw the lowest opacity paths as normal. Then, draw the higher opacities with "desitination-out" to remove the low-opacity paths that overlap. Then, draw the high opacity paths as usual, with "source-over", to create the effect desired.

As suggested in the comments to that answer, @markE's comment about making each path an object that is pre-sorted before drawing is a great suggestion. Since you want to perform manual drawing logic that the native API can't do, turning each path into an object and dealing with them that way will be far easier than manually manipulating each pixel (though that solution would work, it could also drive you mad.)

You mention that each stroke is being done on another canvas, which is great, because you can record the mouseevents that fire as that line is being drawn, create an object to represent that path, and then use that object and others in your "merged" canvas without having to worry about pixel manipulation or anything else. I highly recommend switching to an object-oriented approach like @markE suggested, if possible.

Hylianpuffball
  • 1,553
  • 10
  • 13
  • Thanks for weighing in with a thorough response. Using destination-out has a problem attached to it which I've explained in a comment above but I'll pursue the suggested solution by Henry Blyth and markE nonetheless. More tests will have to show if it's an tolerable annoyance or a dealbreaker. Manipulating each pixel isn't that much of a deal coding-wise but in terms of performance it's a nightmare. So I want to avoid it at all costs. And yes, no worries, I'll stick with an object-oriented approach whenever possible. ;) – Another Guy Jan 05 '14 at 14:40