Equirectangular sketches

Hi,

I’m trying to draw things on top of a 360 equirectangular image. The problem is, of course elements I draw won’t keep their proportions when viewed with a 360 viewer.

Is there any library or technique that would help me achieve this?

I suppose this would be quite challenging, as it would require p5 to understand sizes and positions in degrees instead of pixels and drawing functions would have to convert those degrees back to the canvas pixels with a projection formula, or something like that.

Something that I think would work is drawing elements to a hidden graphics object, exporting that to a PNG, using some third party graphics library to warp that into its equirectangular-correct version and drawing it back to the p5 canvas, but I suspect performance will be terrible, making live interactions difficult.

Ideas?

You’re correct that is not a trivial task to make an undistorted equirectangular cube map or sphere map. I did some experimentation with mapping from 3d polar coordinates to 2d coordinates projected on to the face of the faces of a cube in order to draw a cube-map that results in an undistorted cylinder with some distortion around the half cylinder caps.

Drawing your equirectangular map in real time is going to be a challenge performance wise no matter what you do. One of the advantages of the approach is the ability to pre-render and then just use the texture statically (it could be a video texture).

I am not an expert, so I am eager to see what others have to say.

2 Likes

Thanks. That got my gears turning. I think I could automatically pre-process the background images from equi to cubemap, allow the user to add elements to the cubemap (easier both to the machine and the person doing it, I think), then in the final saving step convert everything back to equi (or just the elements and overlay them on the original equi)

I found this on StackOverflow:

Demo:

https://www.clicktorelease.com/tools/CubemapToEquirectangular/index-managed.html

This is for a different graphics library (Three.js), but the concepts will be the same so it could probably be ported for use in p5.js

Interesting. That would probably be the ideal approach. The problem is I’m starting with an already very complex 2D canvas, and I don’t think I can migrate to WEBGL without breaking many things. The 2D work is still the main purpose of the program and it does not look as good with WEBGL, some drawing functions don’t do the same… (at least the last time I tried).

I think what makes my use case uncommon is I need to process a 360 space on a flat canvas while keeping drawn elements proportional in the final 360 output. I just tried laying out the 6 faces of the cubemap and placing elements on them and it is a decent solution. Once in VR the elements create kind of an imaginary box around the camera, but it’s not entirely ugly, as long as nothing is drawn on the intersections.

Okay, it took some noodling, but I came up with a theoretical solution that might sort-of-kind-of work for your situation. Here are the basic principles:

  1. Around the center of an equirectangular projection the amount of distortion is minimal (the horizontal degrees per pixel remains similar).
  2. Mapping equirectangular pixel coordinates to polar coordinates is trivial.
  3. If we convert polar coordinates into a 3d vector we can use axis angle rotation to translate a set of vectors from one area of the sphere to another.
  4. We can convert from 3d vectors to polar coordinates, and thus back into 2d equirectangular texture coordinates.

This approach would require each drawing instruction to be constrained to a limited size in degrees, and positions would need to be on polar coordinates, but in theory you could take any set of 2d drawing instructions and make the paint onto a sphere with limited distortion. The big downside of this approach is that it is really slow, especially for high resolution textures.

// How many degrees are covered by the drawing area (if you draw something bigger then this won't work)
const drawingDegrees = 30;
const drawingSize = 256;
const drawingDegreesPerPixel = drawingDegrees / drawingSize;
const texGraphicsSize = 512;
const texGraphicsDegreesPerPixel = 180 / texGraphicsSize;

let glContext;

let texGraphics;
let drawing;

let latSlider;
let longSlider;
let radiusSlider;
let strokeSlider;

let bg;

let latAxis;
let longAxis;
let center;

function setup() {
  let c = createCanvas(windowWidth, windowHeight, WEBGL);
  glContext = c.GL;
  angleMode(DEGREES);
  noStroke();

  texGraphics = createGraphics(texGraphicsSize * 2, texGraphicsSize);
  drawing = createGraphics(drawingSize, drawingSize);
  drawing.ellipseMode(RADIUS);
  drawing.fill('red');
  drawing.stroke('blue');

  latSlider = createSlider(0, 180, 90);
  longSlider = createSlider(0, 360, 180);
  // circle radius in degrees
  radiusSlider = createSlider(1, 15, 5);
  // circle stroke in pixels
  strokeSlider = createSlider(1, 20, 2);
  
  latSlider.position(10, 10);
  longSlider.position(10, 30);
  radiusSlider.position(10, 50);
  strokeSlider.position(10, 70);
  
  latSlider.changed(updateGraphics);
  longSlider.changed(updateGraphics);
  radiusSlider.changed(updateGraphics);
  strokeSlider.changed(updateGraphics);

  bg = color(200);

  center = createVector(0, 0, 1);
  latAxis = createVector(1, 0, 0);
  longAxis = createVector(0, 1, 0);
  
  updateGraphics();
}

function updateGraphics() {
  texGraphics.background(bg);
  texGraphics.stroke(0);
  texGraphics.strokeWeight(4);
  texGraphics.line(0, texGraphicsSize / 2, texGraphicsSize * 2, texGraphicsSize / 2);
  texGraphics.line(texGraphicsSize, 0, texGraphicsSize, texGraphicsSize);
  texGraphics.loadPixels();

  drawing.clear();
  drawing.push();
  drawing.translate(drawingSize / 2, drawingSize / 2);
  drawing.strokeWeight(strokeSlider.value());
  drawing.circle(0, 0, radiusSlider.value() / drawingDegreesPerPixel);
  drawing.pop();
  drawing.loadPixels();

  let drawingCenter = rotateAround(
    rotateAround(center, latAxis, latSlider.value() - 90),
    longAxis,
    longSlider.value() - 180
  );
  // In order to map each pixel of our texture to a pixel in the drawing we will
  // need to find the 3d vector for the point on a sphere and transform it by
  // this axis-angle rotation
  let transformAxis = p5.Vector.cross(drawingCenter, center);
  let transformAngle = drawingCenter.angleBetween(center);

  // For each pixel in the texture
  for (let y = 0; y < texGraphicsSize; y++) {
    for (let x = 0; x < texGraphicsSize * 2; x++) {
      // Calculate the polar coordinates for this pixel in the texture
      let texLat = (y - (texGraphicsSize / 2)) * texGraphicsDegreesPerPixel;
      let texLong = (x - texGraphicsSize) * texGraphicsDegreesPerPixel;

      let texVector = rotateAround(
        rotateAround(center, latAxis, texLat),
        longAxis,
        texLong
      );
      
      let drawingVector = rotateAround(
        texVector,
        transformAxis,
        transformAngle
      );

      // Find the polar cordinates of the transformed vector.
      // Project the vector onto the plane throught the equator.
      let planarVector =
          p5.Vector.sub(
            drawingVector,
            p5.Vector.mult(longAxis, drawingVector.dot(longAxis))
          );
      // The angle between the planarVector and the drawingVector is the latitude
      // (-90 to 90)
      let drawingLat =
          planarVector.angleBetween(drawingVector) *
          Math.sign(drawingVector.dot(longAxis));
      // The angle between the planarVector and the reference direction is the
      // longitude (-180 to 180)
      let drawingLong =
          planarVector.angleBetween(center) *
          Math.sign(planarVector.dot(latAxis));
      
      // Convert the lat long into a pixel position
      let drawingX = drawingLong / drawingDegreesPerPixel + drawingSize / 2;
      let drawingY = drawingLat / drawingDegreesPerPixel + drawingSize / 2;
      
      // It would be nice if there were a more efficient way to bound the outer
      // loop to avoid iterations where this check is false.
      if (drawingX >= 0 && drawingX < drawingSize &&
          drawingY >= 0 && drawingY < drawingSize) {
        
        // Sample the pixel value
        let c = drawing.get(drawingX, drawingY);
        let cNoAlpha = color(red(c), green(c), blue(c));
        texGraphics.set(x, y, lerpColor(color(texGraphics.get(x, y)), cNoAlpha, alpha(c) / 255));
      }
      // Otherwise this texture position is outside of the drawing area
    }
  }

  texGraphics.updatePixels();
}

function draw() {
  background(255);
  texture(texGraphics);
  
  push();
  camera(0, 0, (height/2.0) / tan(30.0), 0, 0, 0, 0, 1, 0);
  ortho();
  translate(-width / 2, -height / 2);
  rect(0, 0, 400, 200);
  pop();
  glContext.clear(glContext.DEPTH_BUFFER_BIT);
  
  orbitControl(2, 2, 0.1);
  ambientLight(100, 100, 100);
  directionalLight(155, 155, 155, 0.5, 0.5, -1);
  rotateY(180);
  sphere(200, 40, 20);
}

// Rotate one vector (vect) around another (axis) by the specified angle.
function rotateAround(vect, axis, angle) {
  // Make sure our axis is a unit vector
  axis = p5.Vector.normalize(axis);

  return p5.Vector.add(
    p5.Vector.mult(vect, cos(angle)),
    p5.Vector.add(
      p5.Vector.mult(
        p5.Vector.cross(axis, vect),
        sin(angle)
      ),
      p5.Vector.mult(
        p5.Vector.mult(
          axis,
          p5.Vector.dot(axis, vect)
        ),
        (1 - cos(angle))
      )
    )
  );
}

Runnable example: Sphere Mapped 2d Drawing - OpenProcessing

1 Like

Thanks! That’s fantastic. Makes a lot of sense. I’ll see if I can port it to my existing project. Really appreciated.