Search code examples
painttrigonometry

Implementing an airbrush effect in paint program (using tilt from pressure tablet)


I'm trying to implement an "airbrush" effect in a paintprogram, that takes advantage of tablet with tiltX/tiltY support. In Corel Painter, such an effect looks like enter image description here

Information from the tablet: tiltX is the angle of the pen in XY plane, tiltY is the angle of the pen in YZ plane. so I imagine the airbrush effect can be implemented as if there's a cone attached to the pen, spraying dots out the canvas within the cone radius. From the side I imagine something like this:

enter image description here

Would anyone know the math to do this, to calculate the x/y coordinates of the dots to put on the canvas in a randomized way within the cone.

A "spread" value would also be nice, like in the following picture:

enter image description here


Solution

  • Projecting points

    A spray from a spray gun with radius, tilt, center pos, and direction.

    By converting the problem into 3d you create your set of points (evenly distributed in a circle) on a plane that has a slope that is the sin of the tilt angle.

    Thus a 2D point x,y relative to the center is move to the 3d plane by the addition of z that is sloped depending on the x position. Move away from the source by distance >= to the radius of the circle

    z = radius + x * sin(tilt);
    

    You then project that 3D point back to 2D plane by dividing by the new z divided by the radius

    x = x / (z / radius);
    y = y / (z / radius);
    

    Now you just need to rotate the 2D points to the correct direction.

    First get the direction of the spray as a normalised vector.

    nx = cos(direction);
    ny = sin(direction);
    

    Then rotate the 2D point to align with that vector

    xx = x * nx - y * ny;
    yy = x * ny + y * nx;
    

    And you have the projected point add the center and draw.

    setPixel(xx + centerX, yy + centerY);
    

    Evenly distributed circular spray.

    Creating a even spray on a circular area requires a non even random function

      angle = rand(Math.PI * 2); // get a random direction
      dist = randL(rad,0);  // get a random distance.
      x = cos(angle) * dist;  // find the point.
      y = sin(angle) * dist;
    

    The function rand(num) returns a random number from 0 to num evenly distributed.

    The function randL(min,max) returns a random number that has a linear distribution from min (most probable) to max (very unlikely). see code for more info.

    Example in JS

    As the mouse can not tilt the spray is fixed at the center moving the mouse away from the center changes the tilt and the direction.

    // set up mouse
    const mouse  = {x : 0, y : 0, button : false}
    function mouseEvents(e){
    	mouse.x = e.pageX;
    	mouse.y = e.pageY;
    	mouse.button = e.type === "mousedown" ? true : e.type === "mouseup" ? false : mouse.button;
    }
    ["down","up","move"].forEach(name => document.addEventListener("mouse"+name,mouseEvents));
    
    const ctx = canvas.getContext("2d");
    var w = canvas.width;
    var h = canvas.height;
    var cw = w / 2;  // center 
    var ch = h / 2;
    
    
    // the random functions
    const rand  = (min, max = min + (min = 0)) => Math.random() * (max - min) + min;
    const randL = (min, max = min + (min = 0)) => Math.abs(Math.random() + Math.random() - 1) * (max - min) + min;
    
    // shorthand for loop
    const doFor = (count, cb) => { var i = 0; while (i < count && cb(i++) !== true); }; // the ; after while loop is important don't remove
    
    
    // draws a set of points around cx,cy and a radius of rad
    // density is the number of pixels set per pixel
    // tilt is the spray tilt in radians
    // dir is the direction 
    const rad = 40;
    function spray(rad,cx,cy,density=0.2,tilt, dir){
        const count = ((rad * rad * Math.PI) * density) | 0;
        var xA = Math.cos(dir);
        var yA = Math.sin(dir);    
        doFor(count,i=>{
          const angle = rand(Math.PI * 2);
          const dist = randL(rad,0);
          var x = Math.cos(angle) * dist;
          var y = Math.sin(angle) * dist;
          const z = rad + x * Math.sin(tilt);
          x = x / (z / rad);
          y = y / (z/ rad);
          var xx = x * xA - y * yA;
          var yy = x * yA + y * xA;
          ctx.fillRect(xx + cx, yy + cy,1,1);;
        })
    }
    
    
    function circle(rad,cx,cy,tilt, dir){
        var xA = Math.cos(dir);
        var yA = Math.sin(dir);
    
        ctx.beginPath();
        for(var i = 0; i <= 100; i ++){
            var ang = (i / 100) * Math.PI * 2;
            var x = Math.cos(ang) * rad
            var y = Math.sin(ang) * rad
            var z = rad + x * Math.sin(tilt);
    
            x = x / (z / rad);
            y = y / (z/ rad);
            var xx = x * xA - y * yA;
            var yy = x * yA + y * xA;      
            ctx.lineTo(xx + cx,yy + cy);
        }
        ctx.stroke();
        
    }
    
    
    function update(){
    
        if(w !== innerWidth || h !== innerHeight){
          cw = (w = canvas.width = innerWidth) / 2;
          ch = (h = canvas.height = innerHeight) / 2;
        }else{
          ctx.clearRect(0,0,w,h);
        }
        var dist = Math.hypot(cw-mouse.x,ch-mouse.y);
        var tilt = Math.atan2(dist,100);
        var dir = Math.atan2(ch-mouse.y,cw-mouse.x);
    
        circle(rad,cw,ch,tilt,dir);
        spray(rad,cw,ch,0.2,tilt,dir)
    
    
      
        requestAnimationFrame(update);
     
    }
    
    update();
    canvas { position : absolute; top : 0px; left: 0px; }
    <canvas id="canvas"></canvas>

    Spread

    As requested in the comments a spread value can easily be added as a factor of the tilt angle. It simply increases the y radius of the circle as the tilt increases.

    spreadRad = rad * (1 + (tilt / PI) * spread); // PI = 3.1415...
    

    Thus the point function becomes

    angle = rand(Math.PI * 2); // get a random direction
    dist = randL(rad,0);  // get a random distance.
    x = cos(angle) * dist;  // find the point.
    y = sin(angle) * dist * (1 + (tilt / PI) * spread); // PI = 3.1415...;
    

    But this is not so easy to implement as a spray as it changes the distribution of points. I have added it to the spray function. The area of the spray is increased by the spread radiusB (area of ellipse = PI * radiusA * radiusB ) so I calculate the density on the ellipse. Though I am not 100% sure if the coverage remains constant over the area. I will have to experiment to know for sure if the solution is a good one.

    The example shows a spread factor of 1.5, the red circle shows the original unspread area. I have also include mouse down to add spray so you can see how it accumulates (I have set the alpha value to 0.25). Rerun to clear.

    const rad = 40;  // radius of spray
    const spread = 1.5; // linear spread as tilt increases
    
    // set up mouse
    const mouse  = {x : 0, y : 0, button : false}
    function mouseEvents(e){
    	mouse.x = e.pageX;
    	mouse.y = e.pageY;
    	mouse.button = e.type === "mousedown" ? true : e.type === "mouseup" ? false : mouse.button;
    }
    ["down","up","move"].forEach(name => document.addEventListener("mouse"+name,mouseEvents));
    
    const ctx = canvas.getContext("2d");
    const image = document.createElement("canvas");
    var w = canvas.width;
    var h = canvas.height;
    var cw = w / 2;  // center 
    var ch = h / 2;
    
    
    // the random functions
    const rand  = (min, max = min + (min = 0)) => Math.random() * (max - min) + min;
    const randL = (min, max = min + (min = 0)) => Math.abs(Math.random() + Math.random() - 1) * (max - min) + min;
    
    // shorthand for loop
    const doFor = (count, cb) => { var i = 0; while (i < count && cb(i++) !== true); }; // the ; after while loop is important don't remove
    
    
    // draws a set of points around cx,cy and a radius of rad
    // density is the number of pixels set per pixel
    // tilt is the spray tilt in radians
    // dir is the direction 
    function spray(ctx,rad,cx,cy,density=0.2,tilt, dir){
        const spreadRad = rad * (1 + (tilt / Math.PI) * spread);
        const count = ((rad * spreadRad * Math.PI) * density) | 0;
        var xA = Math.cos(dir);
        var yA = Math.sin(dir);    
        doFor(count,i=>{
          const angle = rand(Math.PI * 2);
          const dist = randL(rad,0);
          var x = Math.cos(angle) * dist;
          var y = Math.sin(angle) * dist * (1 + (tilt / Math.PI) * spread);
          const z = rad + x * Math.sin(tilt);
          x = x / (z / rad);
          y = y / (z/ rad);
          var xx = x * xA - y * yA;
          var yy = x * yA + y * xA;
          ctx.fillRect(xx + cx, yy + cy,1,1);;
        })
    }
    
    
    function circle(rad,cx,cy,tilt, dir, spread){
        var xA = Math.cos(dir);
        var yA = Math.sin(dir);
        const spreadRad = rad * (1 + (tilt / Math.PI) * spread);
        ctx.globalAlpha = 0.5;     
        ctx.beginPath();
        for(var i = 0; i <= 100; i ++){
            var ang = (i / 100) * Math.PI * 2;
            var x = Math.cos(ang) * rad;
            var y = Math.sin(ang) * spreadRad;
            var z = rad + x * Math.sin(tilt);
    
            x = x / (z / rad);
            y = y / (z/ rad);
            var xx = x * xA - y * yA;
            var yy = x * yA + y * xA;      
            ctx.lineTo(xx + cx,yy + cy);
        }
        ctx.stroke();
        ctx.globalAlpha = 1;
        
    }
    
    
    function update(){
    
        if(w !== innerWidth || h !== innerHeight){
          cw = (w = canvas.width = innerWidth) / 2;
          ch = (h = canvas.height = innerHeight) / 2;
          image.width = w;
          image.height = h;
        }else{
          ctx.clearRect(0,0,w,h);
        }
        ctx.drawImage(image,0,0);
        var dist = Math.hypot(cw-mouse.x,ch-mouse.y);
        var tilt = Math.atan2(dist,100);
        var dir = Math.atan2(ch-mouse.y,cw-mouse.x);
        ctx.strokeStyle = "red";
        circle(rad,cw,ch,tilt,dir,0);
        ctx.strokeStyle = "black";
        circle(rad,cw,ch,tilt,dir,spread);
        if(mouse.button){
            const ct = image.getContext("2d");
            ct.globalAlpha = 0.25;
            spray(ct,rad,cw,ch,0.2,tilt,dir,spread);
            ct.globalAlpha = 1;
    
        }else{
            spray(ctx,rad,cw,ch,0.2,tilt,dir,spread);
        }
    
    
      
        requestAnimationFrame(update);
     
    }
    
    update();
    canvas { position : absolute; top : 0px; left: 0px; }
    <canvas id="canvas"></canvas>