又是Tsoding,看得出来这哥们真的是在enjoy(coding)的;
刷到他讲三维到二维映射公式的一个视频:
感觉非常有意思,正好最近有重新学一遍javascript的打算,就拿他这个项目开个坑。
背景介绍#
想象一下,在你的屏幕背后有一个三维的点(正对着你),它的坐标是(x, y, z),那么对他执行:
x' = x / z
y' = y / z得到的(x', y')就是投影到你的屏幕上的二维的点(就像游戏里的三位物体渲染你的二维显示器里那样)的坐标。
此场景中人眼、三维坐标系、二维屏幕之间的关系如图:

其中,eye点位于z轴0点处,0点处的竖线为xy坐标面(此为侧视图),screen为在z=1处与xy坐标面平行的平面。
代码实现#
我们使用html-canvas结合javascript代码来实现坐标点的投影和展示。
图形API#
这里用到主要使用Canvas API的CanvasRenderingContext2D接口来绘制图形,详情请参考文档。
比如,使用Canvas API绘制线段:
function line(p1, p2) {
ctx.lineWidth = 3
ctx.strokeStyle = FOREGROUND
ctx.beginPath()
ctx.moveTo(p1.x, p1.y)
ctx.lineTo(p2.x, p2.y)
ctx.stroke()
}坐标系转换#
由于html-canvas的坐标系与我们假定的坐标系不同,因此需要进行坐标变换。
我们假定的坐标系:

当然,这里面的z轴向内无限延伸。
html-canvas中的坐标系:

坐标转换代码(注意y轴要反转一下):
function screen(p) {
// x: -1..1 => 0..2 => 0..1 => 0..w
// y: -1..1 => 0..2 => 1..0 => h..0
return {
x: (p.x + 1) / 2 * game.width,
y: (1 - (p.y + 1) / 2) * game.height
}
}投影#
function project({x, y, z}) {
return {
x: x / z,
y: y / z
}
}旋转#
为了有更直观的视觉效果,我们可以写一个让点绕y轴旋转(即在xz坐标轴内旋转)的函数:
function rotate_xz({x, y, z}, angle) {
// To rotate a vector with components (x, y) counter-clockwise by an angle β around the origin, use the following formulas:\(x' = x \cos(\beta) - y \sin(\beta)\)\(y' = x \sin(\beta) + y \cos(\beta)\)
const c = Math.cos(angle)
const s = Math.sin(angle)
return {
x: x * c - z * s,
y,
z: x * s + z * c
}
}证明:

逐帧打印#
function frame() {
const dt = 1 / FPS
angle += Math.PI * dt
clear()
// 遍历绘制 3D 模型的连线
for (const f of fs) {
for (let i = 0; i < f.length; ++i) {
const a = vs[f[i]]
const b = vs[f[(i + 1) % f.length]]
// 预防性保护:确保顶点存在再绘制
if (a && b) {
line(
screen(project(translate_z(rotate_xz(a, angle), dz))),
screen(project(translate_z(rotate_xz(b, angle), dz)))
)
}
}
}
setTimeout(frame, 1000 / FPS)
}vs和fs数组示例:
const vs = [
{x: 0.25, y: 0.25, z: 0.25},
{x: -0.25, y: 0.25, z: 0.25},
{x: -0.25, y: -0.25, z: 0.25},
{x: 0.25, y: -0.25, z: 0.25},
{x: 0.25, y: 0.25, z: -0.25},
{x: -0.25, y: 0.25, z: -0.25},
{x: -0.25, y: -0.25, z: -0.25},
{x: 0.25, y: -0.25, z: -0.25}
]
const fs = [
[0, 1, 2, 3],
[4, 5, 6, 7],
[0, 4],
[1, 5],
[2, 6],
[3, 7]
]这里translate_z函数简单地接受三维坐标点,并将他的z轴坐标加上dz的值,这里我们把dz的值设置为1,因为我们在点集vs中给出的点假定了z轴零点位于屏幕中心点处,而实际此处应为z=1处,所以需要加上偏置dz=1。
完整的代码实现可以在我的GitHub仓库中查看:
公式证明#

如图,我们可以很方便得看出大三角形和小三角形是相似的:
小三角形:由原点\(O\)、屏幕上的点\((0, d)\)以及投影点\(P'\)构成。它的底边长为\(d\)(即图中的\(1\)),对边长(高度)为\(x'\)。
大三角形:由原点\(O\)、点\(P\)在\(z\)轴上的投影点\((0, z)\)以及空间点\(P\)构成。它的底边长为\(z\),对边长(高度)为\(x\)。
因此有:
$$\frac{x'}{x} = \frac{d}{z}$$即:
$$x' = \frac{d \cdot x}{z} = \frac{x}{z}$$同理,
$$y' = \frac{y}{z}$$可以证明,对应空间中任取的点P和线段OP交面screen的点P',均可构成上述侧视图中的相似三角形,因此结论依然成立。
叹为观止,放个Miku玩玩:
- 研究下怎么把canvas放hugo的markdown文档里跑


