3

I have written some JS code to plot a 3D wireframe sphere into a HTML5 canvas.

I started from this post and improved it by using Qt3D vertices generation for a sphere mesh. The JS code does 2 passes on the vertices: the first to display rings, and the second to display slices. Normally OpenGL would connect all the vertices automatically with triangles.

I kept the slices/rings configurable but I have issues with the transformation code, for example when I rotate the sphere along the X axis.

So, starting from the basics. Here's a 1-pass, 4 rings, 4 slices, no transformation:

enter image description here

Seems all good. Now 2-passes, 10 rings, 10 slices, no transformation:

enter image description here

Still good, but if I rotate it 30° on the X Axis, the top and bottom vertices (Y position only apparently) get messed up.

enter image description here

I suspect there is something wrong in the rotation functions, or in the projection function.

Can someone please help me to figure out what's going on here ?

(Note that I don't want to use Three.js cause my goal is to port this in a QML application)

Here's the full code.

var sphere = new Sphere3D();
var rotation = new Point3D();
var distance = 1000;
var lastX = -1;
var lastY = -1;

function Point3D() {
  this.x = 0;
  this.y = 0;
  this.z = 0;
}

function Sphere3D(radius) {
  this.vertices = new Array();
  this.radius = (typeof(radius) == "undefined" || typeof(radius) != "number") ? 20.0 : radius;
  this.rings = 10;
  this.slices = 10;
  this.numberOfVertices = 0;

  var M_PI_2 = Math.PI / 2;
  var dTheta = (Math.PI * 2) / this.slices;
  var dPhi = Math.PI / this.rings;

  // Iterate over latitudes (rings)
  for (var lat = 0; lat < this.rings + 1; ++lat) {
    var phi = M_PI_2 - lat * dPhi;
    var cosPhi = Math.cos(phi);
    var sinPhi = Math.sin(phi);

    // Iterate over longitudes (slices)
    for (var lon = 0; lon < this.slices + 1; ++lon) {
      var theta = lon * dTheta;
      var cosTheta = Math.cos(theta);
      var sinTheta = Math.sin(theta);
      p = this.vertices[this.numberOfVertices] = new Point3D();

      p.x = this.radius * cosTheta * cosPhi;
      p.y = this.radius * sinPhi;
      p.z = this.radius * sinTheta * cosPhi;
      this.numberOfVertices++;
    }
  }
}

function rotateX(point, radians) {
  var y = point.y;
  point.y = (y * Math.cos(radians)) + (point.z * Math.sin(radians) * -1.0);
  point.z = (y * Math.sin(radians)) + (point.z * Math.cos(radians));
}

function rotateY(point, radians) {
  var x = point.x;
  point.x = (x * Math.cos(radians)) + (point.z * Math.sin(radians) * -1.0);
  point.z = (x * Math.sin(radians)) + (point.z * Math.cos(radians));
}

function rotateZ(point, radians) {
  var x = point.x;
  point.x = (x * Math.cos(radians)) + (point.y * Math.sin(radians) * -1.0);
  point.y = (x * Math.sin(radians)) + (point.y * Math.cos(radians));
}

function projection(xy, z, xyOffset, zOffset, distance) {
  return ((distance * xy) / (z - zOffset)) + xyOffset;
}

function strokeSegment(index, ctx, width, height) {
  var x, y;
  var p = sphere.vertices[index];

  rotateX(p, rotation.x);
  rotateY(p, rotation.y);
  rotateZ(p, rotation.z);

  x = projection(p.x, p.z, width / 2.0, 100.0, distance);
  y = projection(p.y, p.z, height / 2.0, 100.0, distance);

  if (lastX == -1 && lastY == -1) {
    lastX = x;
    lastY = y;
    return;
  }

  if (x >= 0 && x < width && y >= 0 && y < height) {
    if (p.z < 0) {
      ctx.strokeStyle = "gray";
    } else {
      ctx.strokeStyle = "white";
    }
    ctx.beginPath();
    ctx.moveTo(lastX, lastY);
    ctx.lineTo(x, y);
    ctx.stroke();
    ctx.closePath();
    lastX = x;
    lastY = y;
  }
}

function render() {
  var canvas = document.getElementById("sphere3d");
  var width = canvas.getAttribute("width");
  var height = canvas.getAttribute("height");
  var ctx = canvas.getContext('2d');

  var p = new Point3D();
  ctx.fillStyle = "black";

  ctx.clearRect(0, 0, width, height);
  ctx.fillRect(0, 0, width, height);

  // draw each vertex to get the first sphere skeleton
  for (i = 0; i < sphere.numberOfVertices; i++) {
    strokeSegment(i, ctx, width, height);
  }

  // now walk through rings to draw the slices
  for (i = 0; i < sphere.slices + 1; i++) {
    for (var j = 0; j < sphere.rings + 1; j++) {
      strokeSegment(i + (j * (sphere.slices + 1)), ctx, width, height);
    }
  }
}

function init() {
  rotation.x = Math.PI / 6;
  render();
}
canvas {
  background: black;
  display: block;
}
<body onLoad="init();">
  <canvas id="sphere3d" width="500" height="500">
    Your browser does not support HTML5 canvas.
  </canvas>
</body>
Massimo Callegari
  • 2,099
  • 1
  • 26
  • 39

2 Answers2

3

Your problem is that the contents of your sphere.vertices[] array is being modified inside your strokeSegment() call so the rotation gets applied twice when you invoke it the second time on each point. So, in strokeSegment() you need to replace:

var p = sphere.vertices[index];

with:

var p = new Point3D();

p.x = sphere.vertices[index].x;
p.y = sphere.vertices[index].y;
p.z = sphere.vertices[index].z;

Then it works perfectly as shown below:

var sphere = new Sphere3D();
var rotation = new Point3D();
var distance = 1000;
var lastX = -1;
var lastY = -1;

function Point3D() {
  this.x = 0;
  this.y = 0;
  this.z = 0;
}

function Sphere3D(radius) {
  this.vertices = new Array();
  this.radius = (typeof(radius) == "undefined" || typeof(radius) != "number") ? 20.0 : radius;
  this.rings = 10;
  this.slices = 10;
  this.numberOfVertices = 0;

  var M_PI_2 = Math.PI / 2;
  var dTheta = (Math.PI * 2) / this.slices;
  var dPhi = Math.PI / this.rings;

  // Iterate over latitudes (rings)
  for (var lat = 0; lat < this.rings + 1; ++lat) {
    var phi = M_PI_2 - lat * dPhi;
    var cosPhi = Math.cos(phi);
    var sinPhi = Math.sin(phi);

    // Iterate over longitudes (slices)
    for (var lon = 0; lon < this.slices + 1; ++lon) {
      var theta = lon * dTheta;
      var cosTheta = Math.cos(theta);
      var sinTheta = Math.sin(theta);
      p = this.vertices[this.numberOfVertices] = new Point3D();

      p.x = this.radius * cosTheta * cosPhi;
      p.y = this.radius * sinPhi;
      p.z = this.radius * sinTheta * cosPhi;
      this.numberOfVertices++;
    }
  }
}

function rotateX(point, radians) {
  var y = point.y;
  point.y = (y * Math.cos(radians)) + (point.z * Math.sin(radians) * -1.0);
  point.z = (y * Math.sin(radians)) + (point.z * Math.cos(radians));
}

function rotateY(point, radians) {
  var x = point.x;
  point.x = (x * Math.cos(radians)) + (point.z * Math.sin(radians) * -1.0);
  point.z = (x * Math.sin(radians)) + (point.z * Math.cos(radians));
}

function rotateZ(point, radians) {
  var x = point.x;
  point.x = (x * Math.cos(radians)) + (point.y * Math.sin(radians) * -1.0);
  point.y = (x * Math.sin(radians)) + (point.y * Math.cos(radians));
}

function projection(xy, z, xyOffset, zOffset, distance) {
  return ((distance * xy) / (z - zOffset)) + xyOffset;
}

function strokeSegment(index, ctx, width, height) {
  var x, y;
  var p = new Point3D();

  p.x = sphere.vertices[index].x;
  p.y = sphere.vertices[index].y;
  p.z = sphere.vertices[index].z;

  rotateX(p, rotation.x);
  rotateY(p, rotation.y);
  rotateZ(p, rotation.z);

  x = projection(p.x, p.z, width / 2.0, 100.0, distance);
  y = projection(p.y, p.z, height / 2.0, 100.0, distance);

  if (lastX == -1 && lastY == -1) {
    lastX = x;
    lastY = y;
    return;
  }

  if (x >= 0 && x < width && y >= 0 && y < height) {
    if (p.z < 0) {
      ctx.strokeStyle = "gray";
    } else {
      ctx.strokeStyle = "white";
    }
    ctx.beginPath();
    ctx.moveTo(lastX, lastY);
    ctx.lineTo(x, y);
    ctx.stroke();
    ctx.closePath();
    lastX = x;
    lastY = y;
  }
}

function render() {
  var canvas = document.getElementById("sphere3d");
  var width = canvas.getAttribute("width");
  var height = canvas.getAttribute("height");
  var ctx = canvas.getContext('2d');

  var p = new Point3D();
  ctx.fillStyle = "black";

  ctx.clearRect(0, 0, width, height);
  ctx.fillRect(0, 0, width, height);

  // draw each vertex to get the first sphere skeleton
  for (i = 0; i < sphere.numberOfVertices; i++) {
    strokeSegment(i, ctx, width, height);
  }

  // now walk through rings to draw the slices
  for (i = 0; i < sphere.slices + 1; i++) {
    for (var j = 0; j < sphere.rings + 1; j++) {
      strokeSegment(i + (j * (sphere.slices + 1)), ctx, width, height);
    }
  }
}

function init() {
  rotation.x = Math.PI / 3;
  render();
}
canvas {
  background: black;
  display: block;
}
<body onLoad="init();">
  <canvas id="sphere3d" width="500" height="500">
    Your browser does not support HTML5 canvas.
  </canvas>
</body>
fmacdee
  • 2,353
  • 10
  • 15
2

Short answer

The bug is in strokeSegment function

function strokeSegment(index, ctx, width, height) {
  var x, y;
  var p = sphere.vertices[index];

  rotateX(p, rotation.x);
  rotateY(p, rotation.y);
  rotateZ(p, rotation.z);
  ...

The bug is that all the rotate functions modify p inplace and thus the modify the value stored in sphere.vertices! So the way to fix it is simply to clone the point:

function strokeSegment(index, ctx, width, height) {
  var x, y;
  var p0 = sphere.vertices[index];
  var p = new Point3D();
  p.x = p0.x;
  p.y = p0.y;
  p.z = p0.z;

  rotateX(p, rotation.x);
  rotateY(p, rotation.y);
  rotateZ(p, rotation.z);
  ...

You may find demo with fixed code at https://plnkr.co/edit/zs5ZxbglFxo9cbwA6MI5?p=preview

Longer addition

Before I found this issue I played with your code a bit and I think improved it. Improved version is available at https://plnkr.co/edit/tpTZ8GH9eByVARUIYZBi?p=preview

var sphere = new Sphere3D();
var rotation = new Point3D(0, 0, 0);
var distance = 1000;

var EMPTY_VALUE = Number.MIN_VALUE;

function Point3D(x, y, z) {
    if (arguments.length == 3) {
        this.x = x;
        this.y = y;
        this.z = z;
    }
    else if (arguments.length == 1) {
        fillPointFromPoint(this, x); // 1 argument means point
    }
    else {
        clearPoint(this); // no arguments mean creat empty
    }
}

function fillPointFromPoint(target, src) {
    target.x = src.x;
    target.y = src.y;
    target.z = src.z;
}

function clearPoint(p) {
    p.x = EMPTY_VALUE;
    p.y = EMPTY_VALUE;
    p.z = EMPTY_VALUE;
}

function Sphere3D(radius) {
    this.radius = (typeof(radius) == "undefined" || typeof(radius) != "number") ? 20.0 : radius;
    this.innerRingsCount = 9; // better be odd so we have explicit Equator
    this.slicesCount = 8;


    var M_PI_2 = Math.PI / 2;
    var dTheta = (Math.PI * 2) / this.slicesCount;
    var dPhi = Math.PI / this.innerRingsCount;


    this.rings = [];
    // always add both poles
    this.rings.push([new Point3D(0, this.radius, 0)]);

    // Iterate over latitudes (rings)
    for (var lat = 0; lat < this.innerRingsCount; ++lat) {
        var phi = M_PI_2 - lat * dPhi - dPhi / 2;
        var cosPhi = Math.cos(phi);
        var sinPhi = Math.sin(phi);
        console.log("lat = " + lat + " phi = " + (phi / Math.PI) + " sinPhi = " + sinPhi);

        var vertices = [];
        // Iterate over longitudes (slices)
        for (var lon = 0; lon < this.slicesCount; ++lon) {
            var theta = lon * dTheta;
            var cosTheta = Math.cos(theta);
            var sinTheta = Math.sin(theta);
            var p = new Point3D();
            p.x = this.radius * cosTheta * cosPhi;
            p.y = this.radius * sinPhi;
            p.z = this.radius * sinTheta * cosPhi;
            vertices.push(p);
        }
        this.rings.push(vertices);
    }

    // always add both poles
    this.rings.push([new Point3D(0, -this.radius, 0)]);
}

function rotateX(point, radians) {
    var y = point.y;
    point.y = (y * Math.cos(radians)) + (point.z * Math.sin(radians) * -1.0);
    point.z = (y * Math.sin(radians)) + (point.z * Math.cos(radians));
}

function rotateY(point, radians) {
    var x = point.x;
    point.x = (x * Math.cos(radians)) + (point.z * Math.sin(radians) * -1.0);
    point.z = (x * Math.sin(radians)) + (point.z * Math.cos(radians));
}

function rotateZ(point, radians) {
    var x = point.x;
    point.x = (x * Math.cos(radians)) + (point.y * Math.sin(radians) * -1.0);
    point.y = (x * Math.sin(radians)) + (point.y * Math.cos(radians));
}

function projection(xy, z, xyOffset, zOffset, distance) {
    return ((distance * xy) / (z - zOffset)) + xyOffset;
}


var lastP = new Point3D();
var firstP = new Point3D();

function startRenderingPortion() {
    clearPoint(lastP);
    clearPoint(firstP);
}

function closeRenderingPortion(ctx, width, height) {
    strokeSegmentImpl(ctx, firstP.x, firstP.y, firstP.z, width, height);
    clearPoint(lastP);
    clearPoint(firstP);
}

function strokeSegmentImpl(ctx, x, y, z, width, height) {
    if (x >= 0 && x < width && y >= 0 && y < height) {
        // as we work with floating point numbers, there might near zero that != 0
        // choose gray if one of two points is definitely (z < 0) and other has (z <= 0)
        // Note also that in term of visibility this is a wrong logic! Line is invisible
        // only if it is shadowed by another polygon and this depends on relative "Z" not
        // absolute values
        var eps = 0.01;
        if (((z < -eps) && (lastP.z < eps))
            || ((z < eps) && (lastP.z < -eps))) {
            ctx.strokeStyle = "gray";
        } else {
            ctx.strokeStyle = "white";
        }

        if ((x === lastP.x) && (y == lastP.y)) {
            ctx.beginPath();
            // draw single point
            ctx.moveTo(x, y);
            ctx.lineTo(x + 1, y + 1);
            ctx.stroke();
            ctx.closePath();
        } else {
            ctx.beginPath();
            ctx.moveTo(lastP.x, lastP.y);
            ctx.lineTo(x, y);
            ctx.stroke();
            ctx.closePath();
        }
        lastP.x = x;
        lastP.y = y;
        lastP.z = z;
    }
}

function strokeSegment(p0, ctx, width, height) {
    var p = new Point3D(p0); // clone original point to not mess it up with rotation!
    rotateX(p, rotation.x);
    rotateY(p, rotation.y);
    rotateZ(p, rotation.z);

    var x, y;
    x = projection(p.x, p.z, width / 2.0, 100.0, distance);
    y = projection(p.y, p.z, height / 2.0, 100.0, distance);

    if (lastP.x === EMPTY_VALUE && lastP.y === EMPTY_VALUE) {
        lastP = new Point3D(x, y, p.z);
        fillPointFromPoint(firstP, lastP);
        return;
    }
    strokeSegmentImpl(ctx, x, y, p.z, width, height);
}


function renderSphere(ctx, width, height, sphere) {
    var i, j;
    var vertices;
    // draw each vertex to get the first sphere skeleton
    for (i = 0; i < sphere.rings.length; i++) {
        startRenderingPortion();
        vertices = sphere.rings[i];
        for (j = 0; j < vertices.length; j++) {
            strokeSegment(vertices[j], ctx, width, height);
        }
        closeRenderingPortion(ctx, width, height);
    }

    // now walk through rings to draw the slices

    for (i = 0; i < sphere.slicesCount; i++) {
        startRenderingPortion();
        for (j = 0; j < sphere.rings.length; j++) {
            vertices = sphere.rings[j];
            var p = vertices[i % vertices.length];// for top and bottom vertices.length = 1
            strokeSegment(p, ctx, width, height);
        }
        //closeRenderingPortion(ctx, width, height); // don't close back!
    }
}

function render() {
    var canvas = document.getElementById("sphere3d");
    var width = canvas.getAttribute("width");
    var height = canvas.getAttribute("height");
    var ctx = canvas.getContext('2d');

    ctx.fillStyle = "black";

    ctx.clearRect(0, 0, width, height);
    ctx.fillRect(0, 0, width, height);

    renderSphere(ctx, width, height, sphere);
}

function init() {
    rotation.x = Math.PI / 6;
    //rotation.y = Math.PI / 6;
    rotation.z = Math.PI / 6;
    render();
}

Main changes are:

  • I explicitly separated plain array vertices in array of arrays rings and also explicitly add both poles to it.
  • Separation of rings allowed me to clear lastX/Y more often to avoid some spurious lines by introducing startRenderingPortion.
  • I also introduced closeRenderingPortion that is logically similar to closePath. Using this method I was able to remove duplication of points that you needed.
  • Generally I tried to avoid more-OOP-ish style as you do in your code (see renderSphere or clearPoint) but I changed Point3D constructor to support 3 modes: (x,y,z), point, empty.
  • Use more explicit marker value for empty lastX/Y var EMPTY_VALUE = Number.MIN_VALUE;. -1 is a possible value

Note also that there is a potential bug with your gray/white color selection that I didn't fix. I assume your color should reflect "invisible" lines and simple logic of Z > 0 vs Z < 0 doesn't solve this issue properly. Actually single line might be visible only partially if it is obscured by other things in the scene.

SergGr
  • 23,570
  • 2
  • 30
  • 51
  • Excellent answer, thanks. I have to admit that being a C/C++ developer, the concept of pointers and references is not so clear in JavaScript. This code is just a shortcut to draw on a QML Canvas in my Qt/QML project. – Massimo Callegari Mar 15 '17 at 08:18
  • @MassimoCallegari, in JavaScript everything is passed by value but (and this is a very important "but") everything except primitive types is a reference and you *can't* get "on stack" version of them. So if you pass an object or an array into some method and it modifies the argument - it modifies your value. See also http://stackoverflow.com/questions/6605640/javascript-by-reference-vs-by-value. These rules are actually similar to the Qt rules where you should create every QWidget as a pointer – SergGr Mar 15 '17 at 08:26