Skip to content

[p5.js 2.0+ Bug Report]: Vector.slerp() does not work correctly for vectors of more than three dimensions #8928

Open

Description

@sidwellr

Most appropriate sub-area of p5.js?

  • Accessibility
  • Color
  • Core/Environment/Rendering
  • Data
  • DOM
  • Events
  • Image
  • IO
  • Math
  • Typography
  • Utilities
  • WebGL
  • WebGPU
  • p5.strands
  • Build process
  • Unit testing
  • Internationalization
  • Friendly errors
  • Other (specify if possible)

p5.js version

2.3.0

Web browser and version

Brave 1.90.122

Operating system

Linux Mint 22.1

Steps to reproduce this

Steps:

  1. Create two 4D vectors
  2. Use slerp() to interpolate between the two
  3. Note the result is incorrect

Snippet:

function setup() {
  let c = 0.5;
  let v1 = createVector(0, 1, 0, 1);
  let v2 = createVector(1, 0, 1, 0);
  v1.slerp(v2, 0.5);
  
  // v1 should be vector[0.707, 0.707, 0.707, 0.707] but is actually vector[0.5, 0.707, 0.5, 1]
  print(v1.toString());
}

Discussion

slerp() currently uses cross() for its computation, which is only defined for 3D vectors. It needs to use a more general formula, which is readily available on the web; search for "slerp formula".

Feel free to adapt the following (to both the instance and static methods). In my brief testing, it gives the same result as the current slerp() for 2D and 3D vectors, and also seems to work correctly for 4D vectors. It can certainly be optimized.

function slerp(v0, v1, t) {
  let m = v0.mag() + (v1.mag() - v0.mag()) * t;
  let r0 = p5.Vector.normalize(v0);
  let r1 = p5.Vector.normalize(v1);
  let a = acos(r0.dot(r1));
  if (a === 0) {
    return p5.Vector.add(v0, p5.Vector.sub(v1, v0).mult(t));
  } else {
    r0.mult(sin((1 - t) * a) / sin(a));
    r1.mult(sin(t * a) / sin(a));
    return r0.add(r1).mult(m);
  }
}

Activity

  1. modified the milestones: 2.4, 2.x Anytime on Jun 17, 2026
  2. ksen0 commented on Jun 17, 2026

    @ksen0
    Member

    @davepagurek what do you think about slerp outside of 3D? I do not know how commonly needed that is

  3. LalitNarayanYadav commented on Jul 2, 2026

    @LalitNarayanYadav
    Contributor

    This addresses the question raised above regarding whether slerp() is meaningful outside of three dimensions: it is. Spherical linear interpolation is well-defined in any number of dimensions, as it is fundamentally a rotation along the great circle connecting two unit vectors on an n-sphere, scaled by an interpolated magnitude. The current implementation is limited to 3D only because it relies on cross() to reconstruct an explicit interpolation axis, and cross() is itself a 3D-only operation (see #8925). Once the axis-based approach is replaced with one based on dot(), which is dimension-agnostic, the formula generalizes without modification.

    Formula

    The closed-form slerp used above is the standard one:

    theta = acos(dot(normalize(v0), normalize(v1)))
    result = normalize(v0) * sin((1-t) * theta) / sin(theta)
           + normalize(v1) * sin(t * theta) / sin(theta)
    result *= lerp(mag(v0), mag(v1), t)
    

    This depends only on dot(), normalize(), mag(), and scalar arithmetic, none of which are specific to three dimensions.

    I verified this against the repro case in the issue description. With v1 = [0, 1, 0, 1], v2 = [1, 0, 1, 0], and t = 0.5, the formula produces [0.5, 0.5, 0.5, 0.5] prior to magnitude scaling, which normalizes to magnitude √2, i.e. [0.707, 0.707, 0.707, 0.707]. This matches the expected output stated in the issue.

    Edge cases requiring explicit handling

    Two conditions in the formula above are not self-evident from the snippet and should be addressed in any implementation:

    1. theta === 0. When the two input vectors point in the same direction, sin(theta) is zero and appears in the denominator of both coefficients. This should be handled either by retaining the existing t = 0 / t = 1 early-return behavior already present in slerp(), or by falling through to a plain lerp() when theta is zero.
    2. Floating-point drift in dot(). Because dot(normalize(v0), normalize(v1)) is a cosine value, it should be mathematically bounded to [-1, 1], but floating-point error can push it fractionally outside that range for near-parallel vectors. Feeding an out-of-range value directly to acos() returns NaN. The dot product should be clamped (e.g. constrain(dot, -1, 1)) before being passed to acos().

    Proposed next step

    I would be glad to submit a pull request implementing this. The change would update both the instance method and the static p5.Vector.slerp(), and would include a 4D test case added to the existing slerp unit tests to guard against regression.

    Before doing so, I would appreciate input on the preferred handling of the theta === 0 case - specifically, whether it should fall back to lerp(), or preserve the current early-return structure used elsewhere in slerp() - so the implementation matches project conventions.

  4. inaridarkfox4231 commented on Jul 10, 2026

    @inaridarkfox4231
    Contributor

    What should the result of SLERP be?

    slerp ?

    function setup() {
      createCanvas(windowWidth, windowHeight);
      background(100);
      const v0 = createVector(1, 0, 0);
      const v1 = createVector(-1, 1e-8, 0);
      console.log(slerp(v0, v1, 0.1).array());
      console.log(p5.Vector.slerp(v0, v1, 0.1).array());
    }
    
    function slerp(v0, v1, t) {
      let m = v0.mag() + (v1.mag() - v0.mag()) * t;
      let r0 = p5.Vector.normalize(v0);
      let r1 = p5.Vector.normalize(v1);
      let a = acos(r0.dot(r1));
      if (a === 0) {
        return p5.Vector.add(v0, p5.Vector.sub(v1, v0).mult(t));
      } else {
        r0.mult(sin((1 - t) * a) / sin(a));
        r1.mult(sin(t * a) / sin(a));
        return r0.add(r1).mult(m);
      }
    }
    [Array](https://openprocessing.org/@u161812/2978533#)
      0: 1
      1: 25233152.496711466
      2: 0
    
    [Array](https://openprocessing.org/@u161812/2978533#)
      0: 0.9510565166041706
      1: 0.30901699342389094
      2: 0
  5. inaridarkfox4231 commented on Jul 10, 2026

    @inaridarkfox4231
    Contributor

    That said, I have absolutely no interest in 4D vectors, so I am not interested in this issue either. However, as the person responsible for implementing p5.Vector.slerp, I felt compelled to point out a few concerns.

    If you wish to modify this function, please feel free to do so as you please.

  6. inaridarkfox4231 commented on Jul 11, 2026

    @inaridarkfox4231
    Contributor

    Improvement proposal.

    $$\begin{align*} & (a^2+b^2+c^2+d^2)(e^2+f^2+g^2+h^2) \\\ &= (ae+bf+cg+dh)^2 + (af-be)^2+(ag-ce)^2+(bg-cf)^2+(ah-de)^2+(bh-df)^2+(ch-dg)^2. \end{align*}$$

    slerp inspect

    function slerp2(v0, v1, t) {
      const m = v0.mag() + (v1.mag() - v0.mag()) * t;
      const r0 = p5.Vector.normalize(v0);
      const r1 = p5.Vector.normalize(v1);
    
      const a = acos(r0.dot(r1));
      const b = Math.atan2(
    	  Math.sqrt(
    		  Math.pow(r0.x*r1.y-r0.y*r1.x,2) + Math.pow(r0.x*r1.z-r0.z*r1.x,2) + Math.pow(r0.y*r1.z-r0.z*r1.y,2) +
    		  Math.pow(r0.x*r1.w-r0.w*r1.x,2) + Math.pow(r0.y*r1.w-r0.w*r1.y,2) + Math.pow(r0.z*r1.w-r0.w*r1.z,2)
    	  ),
    	  r0.dot(r1)
      );
      console.log(Math.PI-a);
      console.log(Math.PI-b);
    
      // use b instead of a.
    
      if (b === 0) {
        return p5.Vector.add(v0, p5.Vector.sub(v1, v0).mult(t));
      } else if (b === Math.PI) {
    	  console.log('two vector opposite error.');
    	  return null;
      } else {
        r0.mult(sin((1 - t) * b) / sin(b));
        r1.mult(sin(t * b) / sin(b));
        return r0.add(r1).mult(m);
      }
    }

    acos() exhibits unstable behavior near 0 and Math.PI. Clamping the dot product between -1 and 1 does not improve accuracy.

      console.log(Math.PI-a); // NaN
      console.log(Math.PI-b); // 5.773502564920818e-9

    This method calculates both cosine and sine and then derives the angle from them, resulting in high angular precision.

    console.log('---four dimension case---');
    
    const v2 = createVector(0,1,0,1);
    const v3 = createVector(1,0,1,0);
    
    const s20 = slerp(v2,v3,0.5);
    const s21 = slerp2(v2,v3,0.5);
    const s3 = p5.Vector.slerp(v2,v3,0.5);
    
    console.log(s20); // 0.7071067811865476, 0.7071067811865476, 0.7071067811865476, 0.7071067811865476
    console.log(s21); // 0.7071067811865476, 0.7071067811865476, 0.7071067811865476, 0.7071067811865476
    console.log(s3); // 0.5, 0.7071067811865476, 0.5, 1
    
    console.log('---four dimension corner case---');
    
    const v4 = createVector(1,0,1,1);
    const v5 = createVector(-1,1e-8,-1,-1);
    
    const s4 = slerp(v4,v5,0.1);
    const s5 = slerp2(v4,v5,0.1);
    
    console.log(s4); // 0, 1e-8, 0, 0
    console.log(s5); // 0.9510565234362703, 0.5352331341267489 ,0.9510565234362703 ,0.9510565234362703

    ※Returning null when two vectors point in exactly opposite directions is a provisional measure.

  7. inaridarkfox4231 commented on Jul 11, 2026

    @inaridarkfox4231
    Contributor

    Also, I'm not sure if this has already been noted, but it seems array() does not support four dimensions.

    function setup() {
    	createCanvas(windowWidth, windowHeight);
    	background(100);
    	v=createVector(1,0,1,1);
    	v.normalize();
    	w=createVector(-1,1e-8,-1,-1);
    	w.normalize();
    	console.log(v.array());
    	console.log(w.array());
    	console.log(v.dot(w));
    }
    Image
  8. Aman-goel-04 commented on Jul 25, 2026

    @Aman-goel-04

    Hey, @ksen0, I'd like to work on this, could you assign it to me?

    Looking at the current slerp(), it's actually already using an atan2-based angle (Math.atan2(axisMag, this.dot(v))), so the numerical-stability concern raised above is largely already addressed for 3D. The part that's actually 3D-only is narrower than it first appears: cross() is used for two things, (1) getting axisMag (the sine-of-angle term) and (2) building ey = axis.cross(this), a second basis vector orthogonal to this, pointing toward v, used to do a 2D rotation within the plane spanned by this and v.

    Both uses can be replaced with a Gram-Schmidt projection instead of a cross product, which is dimension-agnostic:

    const r0 = p5.Vector.normalize(this);
    const r1 = p5.Vector.normalize(v);
    const dotVal = r0.dot(r1);                                   // cos(theta)
    const proj = p5.Vector.sub(r1, p5.Vector.mult(r0, dotVal));   // component of r1 orthogonal to r0
    const sinTheta = proj.mag();
    const theta = Math.atan2(sinTheta, dotVal);

    proj normalized is the generalized ey. Everything after that in the current implementation -> lerpedMagFactor, cosMultiplier/sinMultiplier, and the final this.x = this.x * cosMultiplier + ey.x * sinMultiplier combination, isn't 3D-specific and carries over unchanged, just iterated over this.values/v.values instead of x/y/z.

    I verified this against the 4D repro case in the issue ([0,1,0,1], [1,0,1,0], t=0.5) and it produces the expected [0.707, 0.707, 0.707, 0.707].

    Degenerate case (theta close to PI): the existing code's fallback for "axis norm is 0 and angle is near PI" picks an arbitrary vector orthogonal to this (using specific 3D constructions like (y, -x, 0)). In N dimensions there's no single obvious choice, antiparallel vectors leave an entire (N-1)-dimensional space of valid rotation planes, so any choice here is arbitrary, not a "true" generalization. I'll implement something reasonable (e.g., zero the largest-magnitude component and swap a pair of others to build an orthogonal vector), just wanted to flag this.

    Dimension mismatch: dot()/mult() already truncate to the smaller vector's dimension via prioritizeSmallerDimension. I'll have slerp() follow the same convention for consistency, unless there's a preference otherwise.

    I'll update both the instance and static (p5.Vector.slerp()) versions, and add 4D test cases to the existing slerp unit tests, including the near-antiparallel corner case from the thread above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions