如何在2D CANVAS 中渲染3D图像

原文地址

在探索如何实践的最佳方法时,我们想出了一个十分有趣的原型:从一个充满颗粒的旋转球开始。

See the Pen render-3d-in-2d-canvas by yuchenyao (@yuchenyao) on CodePen.

从技术角度出发,第一步是最有趣的。因为接下来所有的动画步骤都是基于平面2D,不能使用3D渲染器,例如Three.js。因此,我必须搞清楚如何只用 Canvas 2D API 来渲染3D图像。

在这篇文章,我将向你们展示我是如何做到的。首先,我要解释一下如何使用JavaScript Canvas 2D API来渲染一个3D场景中的基本图像。接着,在文章的第二部分中,我将向你们展示如何用使这些变得更有趣。

1. 创建Canvas画布

准备开始前,我们需要在HTML中新增一个canvas元素。我们还要为元素创建一个ID以便于选择。这些就是在HTML中要做的所有工作!

1
<canvas id="scene"></canvas>

对于CSS,我们需要去除body的默认margin,并且用overflow: hidden;来阻止滚动条的显示。因为我们想让canvas铺满屏幕,因此我们把它的宽高定义为可视窗口的100%。

1
2
3
4
5
6
7
8
body {
margin: 0;
overflow: hidden;
}
canvas {
width:100vw;
height:100vh;
}

为了在JavaScript中创建一个满屏的canvas,我们需要从DOM中选中canvas,然后获取canvas 2d 上下文。

1
2
3
4
5
6
const canvas = document.getElementById('scene');
let width = canvas.offsetWidth; // Width of the scene
let height = canvas.offsetHeight; // Height of the scene
const ctx = canvas.getContext('2d');

2. 创建颗粒

我们的目标是用大量的点创造一个球体,因此我们需要在球体表面计算坐标。球体表面坐标的计算不再使用经典的笛卡尔坐标系(x,y,z),而是采用极坐标系中的三个值:

  • Radius 半径
  • Theta 球心与球面一点的连线与z轴的角度 [0, Pi]
  • Phi 球心与球面一点的连线在xy平面的投影与x轴的角度 [0, 2Pi]

在我们的例子中,我们希望每个原点都是随机的,因此我们在创建原点时,要将Theta和Phi定义为随机值。每个原点的半径都一样,我们可以把它存为全局变量。

关于球坐标与三维直角坐标的转换看这里

1
2
3
4
5
6
7
// 定义一些常量
const DOTS_AMOUNT = 1000; // 点的数量
const DOT_RADIUS = 4; // 点的半径
let GLOBE_RADIUS = width * 0.4; //球体半径
let PROJECTION_CENTER_X = width / 2; //画布x轴中心
let PROJECTION_CENTER_Y = height / 2; // 画布y轴中心
let FIELD_OF_VIEW = width * 0.8; // 视野区域
1
2
3
4
5
6
7
8
9
10
11
12
function createDots() {
dots.length = 0;
for (let i = 0; i < DOTS_AMOUNT; i++) {
const theta = Math.random() * 2 * Math.PI; // [0, 2PI]的随机值
const phi = Math.acos((Math.random() * 2) - 1); // [0, PI]的随机值
// 球坐标转换为三维直角坐标
const x = GLOBE_RADIUS * Math.sin(phi) * Math.cos(theta);
const y = GLOBE_RADIUS * Math.sin(phi) * Math.sin(theta);
const z = GLOBE_RADIUS * Math.cos(phi);
dots.push(new Dot(x, y, z));
}
}

为什么使用Math.acos()创建 PHI 的随机值?

1
2
const phi = Math.random() * Math.PI;
const phi = Math.acos((Math.random() * 2) - 1);

因为点在球体表面的分布会不均匀,在球的两极会分布更多的点。

创建 Class Dot

为了很好的管理大量的颗粒,最简单的方法就是使用Class。Class允许我们为每个颗粒定义随机的属性,并且让它们之间共享通用方法。

创建Class的第一步就是constructor方法,我们用它来存储每个颗粒的自定义属性。接着为小圆点创建两个方法:project()draw()project()就是奇迹发生的地方,在这里我们把颗粒的3D坐标转换到了2D世界。最后,我们计算得出projected valued之后, draw()帮我们在canvas上画出颗粒

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Dot {
constructor(x, y, z) {
this.x = x;
this.y = y;
this.z = z;
this.xProject = 0;
this.yProject = 0;
this.sizeProjection = 0;
}
// 将3D坐标投射到2D Canvas
project(sin, cos) {
const rotX = cos * this.x + sin * this.z;
const rotZ = -sin * this.x + cos * this.z;
this.sizeProjection = FIELD_OF_VIEW / (FIELD_OF_VIEW - rotZ);
this.xProject = (rotX * this.sizeProjection) + PROJECTION_CENTER_X;
this.yProject = (this.y * this.sizeProjection) + PROJECTION_CENTER_Y;
}
draw(sin, cos) {
this.project(sin, cos);
ctx.beginPath();
ctx.arc(this.xProject, this.yProject, DOT_RADIUS * this.sizeProjection, 0, Math.PI * 2);
ctx.closePath();
ctx.fill();
}
}

3. 场景渲染

现在所有的颗粒都做好了渲染到屏幕的准备。接下来我们需要创建一个简单的方法,遍历所有的小圆点,并将他们渲染到canvas上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function render(a) {
ctx.clearRect(0, 0, width, height);
// 增大旋转角度
rotation = a * 0.0004;
const sineRotation = Math.sin(rotation);
const cosineRotation = Math.cos(rotation);
for (var i = 0; i < dots.length; i++) {
dots[i].draw(sineRotation, cosineRotation);
}
window.requestAnimationFrame(render);
}

4. 3D到2D的转换总结

  • 首先要随机生成大量的圆点。固定球体半径,随机生成Theta和Phi。然后把球坐标(r,theta,phi)转换为三维直角坐标(x,y,z)。
1
2
3
4
5
6
7
8
9
for (let i = 0; i < DOTS_AMOUNT; i++) {
const theta = Math.random() * 2 * Math.PI; // [0, 2PI]的随机值
const phi = Math.acos((Math.random() * 2) - 1); // [0, PI]的随机值
// 球坐标转换为三维直角坐标
const x = GLOBE_RADIUS * Math.sin(phi) * Math.cos(theta);
const y = GLOBE_RADIUS * Math.sin(phi) * Math.sin(theta);
const z = GLOBE_RADIUS * Math.cos(phi);
dots.push(new Dot(x, y, z));
}
  • 因为球体是横向转动,因此球体上的点在y轴上的值并未改变,我们只需要考虑x轴和z轴,此时俯视整个坐标轴。我们需要计算出点的运动轨迹,并在canvas上画出,这样所有的点一起转动便是一个旋转的球了。
    坐标旋转变换
1
2
  • 原点在z轴上越大,视觉上距离我们越近,在z轴上越小,视觉上距离我们越远。