Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

implementation of p5.Vector slerp function #6222

Merged
merged 11 commits into from
Jun 20, 2023
180 changes: 180 additions & 0 deletions src/math/p5.Vector.js
Original file line number Diff line number Diff line change
Expand Up @@ -1742,6 +1742,156 @@ p5.Vector = class {
return this;
}

/**
* Performs spherical linear interpolation with the other vector
* and returns the resulting vector.
* The result of slerping between 2D vectors is always a 2D vector.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By only mentioning 2D vectors in the description, I worry people might incorrectly assume it's only for 2D. Maybe we can say "This works in both 3D and 2D" before this sentence?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spherical linear interpolation is usually used in 3D, so I didn't mention it, but I would like to describe it if necessary (I wanted to emphasize that the result is 2D even if each 2D vectors are pointered directions oppositely).

*
* @method slerp
* @param {p5.Vector} v the p5.Vector to slerp to
* @param {Number} amt The amount of interpolation. some value between 0.0
* (old vector) and 1.0 (new vector). 0.9 is very near
* the new vector. 0.5 is halfway in between.
* @return {p5.Vector}
*
* @example
* <div class="norender">
* <code>
*
* const v1 = createVector(1, 0, 0);
* const v2 = createVector(0, 1, 0);
*
* const v = v1.slerp(v2, 1/3);
* print(v.toString());
* // v's components are almost [cos(30°), sin(30°), 0]
* </code>
* </div>
*
* <div>
* <code>
* let needle;
* function setup() {
* createCanvas(100, 100);
* stroke(0);
* strokeWeight(4);
*
* needle = createVector(50, 0);
* }
*
* function draw(){
* background(255);
* translate(50, 50);
*
* const theta = Math.atan2(mouseY-50, mouseX-50);
* const v = createVector(50 * Math.cos(theta), 50 * Math.sin(theta));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be a little clearer here if we do const v = createVector(mouseX-50, mouseY-50).setMag(50) without the extra trig?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's true that shorter is better, so I'll fix it!

* // slerp between v and needle
* // needle vector is changed by slerp function.
* needle.slerp(v, 0.05);
*
* line(0, 0, needle.x, needle.y);
* }
* </code>
* </div>
*
* <div>
* <code>
* let v1, v2, v3;
* function setup(){
* createCanvas(100, 100, WEBGL);
* noStroke();
* v1 = createVector(30, 0, 0);
* v2 = createVector(0, 30, 0);
* v3 = createVector(0, 0, 30);
* }
*
* function draw(){
* background(0);
* lights();
* fill(255);
* ambientMaterial(255);
*
* const t = (frameCount % 60) / 60;
* // v1, v2, v3 is not changed by slerp function.
* // because this function is static version.
* const v4 = p5.Vector.slerp(v1, v2, t);
* const v5 = p5.Vector.slerp(v2, v3, t);
* const v6 = p5.Vector.slerp(v3, v1, t);
* translate(v4.x, v4.y, v4.z);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this example, I wonder if we can make what's happening a bit clearer:

  • Since the spheres end in a configuration that looks identical to the start, it might accidentally look like the vectors keep moving, maybe coloring them differently and/or making them oscillate back and forth instead of jumping back to the start would make it easier to see what's happening?
  • Although it's less lines of code, subtracting the previous point from the next might add a bit of confusion. Maybe we could push/pop even though it's longer? or use something like line() where we can use coordiantes without translate()?

As a quick test, do you think something like this is clearer? https://editor.p5js.org/davepagurek/sketches/NBexafWPU

function draw(){
  background(255);

  const t = map(sin(frameCount/30), -1, 1, 0, 1);
  // v1, v2, v3 is not changed by slerp function.
  // because this function is static version.
  const v4 = p5.Vector.slerp(v1, v2, t);
  const v5 = p5.Vector.slerp(v2, v3, t);
  const v6 = p5.Vector.slerp(v3, v1, t);
  strokeWeight(10)
  strokeCap(SQUARE)
  stroke('red')
  line(0, 0, 0, v4.x, v4.y, v4.z);
  stroke('green')
  line(0, 0, 0, v5.x, v5.y, v5.z);
  stroke('blue')
  line(0, 0, 0, v6.x, v6.y, v6.z);
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought that example might be a little confusing for me too. It's true that "cylinders" or "lines" are easier to understand, so I'm going to rewrite them. I would like to adopt the "lines" because it is easier for the code to understand.

Copy link
Contributor Author

@inaridarkfox4231 inaridarkfox4231 Jun 20, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about this...?
slerp sample for 3D

function setup(){
  createCanvas(100, 100, WEBGL);
}

function draw(){
  background(255);
  
  const vx = createVector(30, 0, 0);
  const vy = createVector(0, 30, 0);
  const vz = createVector(0, 0, 30);

  const t = map(sin(frameCount * TAU / 120), -1, 1, 0, 1);
  // v1, v2, v3 is not changed by slerp function.
  // because this function is static version.
  const vSlerpXY = p5.Vector.slerp(vx, vy, t);
  const vSlerpYZ = p5.Vector.slerp(vy, vz, t);
  const vSlerpZX = p5.Vector.slerp(vz, vx, t);
  strokeWeight(6);
  strokeCap(SQUARE);
  stroke("red");
  line(0, 0, 0, vSlerpXY.x, vSlerpXY.y, vSlerpXY.z);
  stroke("green");
  line(0, 0, 0, vSlerpYZ.x, vSlerpYZ.y, vSlerpYZ.z);
  stroke("blue");
  line(0, 0, 0, vSlerpZX.x, vSlerpZX.y, vSlerpZX.z);
}

Renamed variables to make it clearer which direction the axis is pointing.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks great!

* sphere(5);
* translate(v5.x - v4.x, v5.y - v4.y, v5.z - v4.z);
* sphere(5);
* translate(v6.x - v5.x, v6.y - v5.y, v6.z - v5.z);
* sphere(5);
* }
* </code>
* </div>
*/
slerp(v, amt) {
// edge cases.
if (amt === 0) { return this; }
if (amt === 1) { return this.set(v); }

// calculate magnitudes
const selfMag = this.mag();
const vMag = v.mag();
const magmag = selfMag * vMag;
// if either is a zero vector, linearly interpolate by these vectors
if (magmag === 0) {
this.mult(1 - amt).add(v.x * amt, v.y * amt, v.z * amt);
return this;
}
// the cross product of 'this' and 'v' is the axis of rotation
const axis = this.cross(v);
const axisMag = axis.mag();
// Calculates the angle between 'this' and 'v'
const theta = Math.atan2(axisMag, this.dot(v));

// However, if the norm of axis is 0, normalization cannot be performed,
// so we will divide the cases
if (axisMag > 0) {
axis.x /= axisMag;
axis.y /= axisMag;
axis.z /= axisMag;
} else if (theta < Math.PI * 0.5) {
// if the norm is 0 and the angle is less than PI/2,
// the angle is very close to 0, so do linear interpolation.
this.mult(1 - amt).add(v.x * amt, v.y * amt, v.z * amt);
return this;
} else {
// If the norm is 0 and the angle is more than PI/2, the angle is
// very close to PI.
// In this case v can be regarded as '-this', so take any vector
// that is orthogonal to 'this' and use that as the axis.
if (this.z === 0 && v.z === 0) {
// if both this and v are 2D vectors, use (0,0,1)
// this makes the result also a 2D vector.
axis.set(0, 0, 1);
} else if (this.x !== 0) {
// if the x components is not 0, use (y, -x, 0)
axis.set(this.y, -this.x, 0).normalize();
} else {
// if the x components is 0, use (1,0,0)
axis.set(1, 0, 0);
}
}

// Since 'axis' is a unit vector, ey is a vector of the same length as 'result'.
const ey = axis.cross(this);
// interpolate the length with 'this' and 'v'.
const lerpedMagFactor = (1 - amt) + amt * vMag / selfMag;
// imagine an orthonormal basis where "axis", "result" and "ey" are
// the unit vectors of the z, x and y axes respectively.
// rotates "result" around "axis" by t*angle towards "ey".
const cosMultiplier = lerpedMagFactor * Math.cos(amt * theta);
const sinMultiplier = lerpedMagFactor * Math.sin(amt * theta);
// then, calculate 'result'.
this.x = this.x * cosMultiplier + ey.x * sinMultiplier;
this.y = this.y * cosMultiplier + ey.y * sinMultiplier;
this.z = this.z * cosMultiplier + ey.z * sinMultiplier;

return this;
}

/**
* Reflect a vector about a normal to a line in 2D, or about a normal to a
* plane in 3D.
Expand Down Expand Up @@ -2358,6 +2508,36 @@ p5.Vector = class {
return target;
}

/**
* Performs spherical linear interpolation with the other vector
* and returns the resulting vector.
* The result of slerping between 2D vectors is always a 2D vector.
*/
/**
* @method slerp
* @static
* @param {p5.Vector} v1 old vector
* @param {p5.Vector} v2 new vectpr
* @param {Number} amt
* @param {p5.Vector} [target] The vector to receive the result
* @return {p5.Vector} slerped vector between v1 and v2
*/
static slerp(v1, v2, amt, target) {
if (!target) {
target = v1.copy();
if (arguments.length === 4) {
p5._friendlyError(
'The target parameter is undefined, it should be of type p5.Vector',
'p5.Vector.slerp'
);
}
} else {
target.set(v1);
}
target.slerp(v2, amt);
return target;
}

/**
* Calculates the magnitude (length) of the vector and returns the result as
* a float (this is simply the equation `sqrt(x*x + y*y + z*z)`.)
Expand Down
94 changes: 94 additions & 0 deletions test/unit/math/p5.Vector.js
Original file line number Diff line number Diff line change
Expand Up @@ -1422,6 +1422,100 @@ suite('p5.Vector', function() {
});
});

suite('v.slerp(w, amt)', function() {
var w;
setup(function() {
v.set(1, 2, 3);
w = new p5.Vector(4, 6, 8);
});

test('if amt is 0, returns original vector', function() {
v.slerp(w, 0);
expect(v.x).to.eql(1);
expect(v.y).to.eql(2);
expect(v.z).to.eql(3);
});

test('if amt is 1, returns argument vector', function() {
v.slerp(w, 1);
expect(v.x).to.eql(4);
expect(v.y).to.eql(6);
expect(v.z).to.eql(8);
});

test('if both v and w are 2D, then result will also be 2D.', function() {
v.set(2, 3, 0);
w.set(3, -2, 0);
v.slerp(w, 0.3);
expect(v.z).to.eql(0);

v.set(1, 4, 0);
w.set(-1, -4, 0);
v.slerp(w, 0.8);
expect(v.z).to.eql(0);
});

test('if one side is a zero vector, linearly interpolate.', function() {
v.set(0, 0, 0);
w.set(2, 4, 6);
v.slerp(w, 0.5);
expect(v.x).to.eql(1);
expect(v.y).to.eql(2);
expect(v.z).to.eql(3);
});

test('If they are pointing in the same direction, linearly interpolate.', function() {
v.set(5, 11, 16);
w.set(15, 33, 48);
v.slerp(w, 0.5);
expect(v.x).to.eql(10);
expect(v.y).to.eql(22);
expect(v.z).to.eql(32);
});
});

suite('p5.Vector.slerp(v1, v2, amt)', function() {
var res, v1, v2;
setup(function() {
v1 = new p5.Vector(1, 0, 0);
v2 = new p5.Vector(0, 0, 1);
res = p5.Vector.slerp(v1, v2, 1/3);
});

test('should not be undefined', function() {
expect(res).to.not.eql(undefined);
});

test('should be a p5.Vector', function() {
expect(res).to.be.an.instanceof(p5.Vector);
});

test('should return neither v1 nor v2', function() {
expect(res).to.not.eql(v1);
expect(res).to.not.eql(v2);
});

test('Make sure the interpolation in 1/3 is correct', function() {
expect(res.x).to.be.closeTo(Math.cos(Math.PI/6), 0.00001);
expect(res.y).to.be.closeTo(0, 0.00001);
expect(res.z).to.be.closeTo(Math.sin(Math.PI/6), 0.00001);
});

test('Make sure the interpolation in -1/3 is correct', function() {
p5.Vector.slerp(v1, v2, -1/3, res);
expect(res.x).to.be.closeTo(Math.cos(-Math.PI/6), 0.00001);
expect(res.y).to.be.closeTo(0, 0.00001);
expect(res.z).to.be.closeTo(Math.sin(-Math.PI/6), 0.00001);
});

test('Make sure the interpolation in 5/3 is correct', function() {
p5.Vector.slerp(v1, v2, 5/3, res);
expect(res.x).to.be.closeTo(Math.cos(5*Math.PI/6), 0.00001);
expect(res.y).to.be.closeTo(0, 0.00001);
expect(res.z).to.be.closeTo(Math.sin(5*Math.PI/6), 0.00001);
});
});

suite('p5.Vector.fromAngle(angle)', function() {
var res, angle;
setup(function() {
Expand Down