HTML5 Canvas + JavaScript线框球体变换问题

3
我已经编写了一些JS代码,用于在HTML5画布中绘制3D线框球体。
我从this post开始,并通过使用Qt3D vertices generation来改进它以生成一个球体网格。JS代码对顶点进行两次处理:第一次显示环,第二次显示切片。通常OpenGL会自动使用三角形连接所有顶点。
我保持了切片/环的可配置性,但是在变换代码方面存在问题,例如当我沿X轴旋转球体时。
所以,从基础开始。这是一个1次遍历,4个环,4个切片,没有变换的例子:

enter image description here

看起来一切都很好。现在是2次遍历,10个环,10个切片,没有变换:

enter image description here

仍然不错,但如果我将它在X轴上旋转30度,顶部和底部的顶点(仅Y位置)会出现问题。

enter image description here

我怀疑旋转函数或投影函数出了问题。
请问有人能帮我找出问题吗?
(注意,我不想使用Three.js,因为我的目标是将其移植到QML应用程序中)
以下是完整代码。

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>

2个回答

3
您的问题在于,当您在strokeSegment()函数中调用时,您的sphere.vertices[]数组内容被修改,因此在第二次调用每个点时,旋转会应用两次。因此,在strokeSegment()函数中,您需要将以下内容替换为:
var p = sphere.vertices[index];

使用:

var p = new Point3D();

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

然后它完美地运行,如下所示:

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>


2
简短回答:
错误在于 strokeSegment 函数。
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);
  ...

错误在于所有的rotate函数都会就地修改p,因此会修改存储在sphere.vertices中的值!因此修复它的方法很简单,只需要克隆该点即可:
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);
  ...

您可以在https://plnkr.co/edit/zs5ZxbglFxo9cbwA6MI5?p=preview找到带有固定代码的演示。

更长的补充

在发现这个问题之前,我对您的代码进行了一些尝试,并且认为已经改进了它。改进后的版本可在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();
}

主要更改如下:

  • 我明确将普通数组vertices分离成数组的数组rings,并将两个极点显式添加到其中。
  • 分离rings使我能够更频繁地清除lastX/Y,以避免引入startRenderingPortion时出现一些虚假的线条。
  • 我还引入了逻辑上类似于closePathcloseRenderingPortion。使用此方法,我能够删除您所需的点的重复。
  • 通常,我尽量避免在您的代码中采用更多面向对象的风格(请参见renderSphereclearPoint),但我更改了Point3D构造函数以支持3种模式:(x,y,z),点,空。
  • 使用更明确的标记值来表示空的lastX/Yvar EMPTY_VALUE = Number.MIN_VALUE;-1是可能的值。
请注意,您的灰色/白色颜色选择可能存在潜在的错误,我没有进行修复。我认为您的颜色应该反映“不可见”线条和Z>0Z<0的简单逻辑不能正确解决此问题。实际上,如果单个线条被场景中的其他物体遮挡,则可能仅部分可见。

非常好的答案,谢谢。我必须承认作为一个C/C++开发者,在JavaScript中指针和引用的概念并不那么清晰。这段代码只是在我的Qt/QML项目中绘制QML画布的一种捷径。 - Massimo Callegari
@MassimoCallegari,在JavaScript中,所有东西都是按值传递的,但(这是非常重要的“但”)除了原始类型之外的所有东西都是引用,你不能获得它们的“栈上”版本。因此,如果你将一个对象或数组传递给某个方法,并且它修改了参数 - 它就修改了你的值。参见https://dev59.com/jmw15IYBdhLWcg3wcbay。这些规则实际上与Qt规则相似,在Qt中,你应该将每个QWidget创建为指针。 - SergGr

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接