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
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:
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:
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);
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.
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>
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>