跳过正文

一个揭开3D图形神秘面纱的公式

·1518 字· loading · loading · · ·
lightmon
作者
lightmon
该博主太懒了,尚未添加简介。
目录

又是Tsoding,看得出来这哥们真的是在enjoy(coding)的;

刷到他讲三维到二维映射公式的一个视频:

感觉非常有意思,正好最近有重新学一遍javascript的打算,就拿他这个项目开个坑。

背景介绍
#

想象一下,在你的屏幕背后有一个三维的点(正对着你),它的坐标是(x, y, z),那么对他执行:

x' = x / z
y' = y / z

得到的(x', y')就是投影到你的屏幕上的二维的点(就像游戏里的三位物体渲染你的二维显示器里那样)的坐标。

此场景中人眼、三维坐标系、二维屏幕之间的关系如图:

coordinate_plot

其中,eye点位于z0点处,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的坐标系与我们假定的坐标系不同,因此需要进行坐标变换。

我们假定的坐标系:

square_grid_plot.png

当然,这里面的z轴向内无限延伸。

html-canvas中的坐标系:

screen_coordinates.png

坐标转换代码(注意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
  }
}

证明:

rotate_matrix.png
$$x' = x \cos(\beta) - y \sin(\beta)$$

$$y' = x \sin(\beta) + y \cos(\beta)$$

$$x = r \cos(\alpha)$$

$$y = r \sin(\alpha)$$

$$x' = r \cos(\alpha + \beta)$$

$$y' = r \sin(\alpha + \beta)$$

$$x' = r \left( \cos(\alpha)\cos(\beta) - \sin(\alpha)\sin(\beta) \right)$$

$$x' = \color{blue}{r \cos(\alpha)}\cos(\beta) - \color{orange}{r \sin(\alpha)}\sin(\beta)$$

$$y' = r \left( \sin(\alpha)\cos(\beta) + \cos(\alpha)\sin(\beta) \right)$$

$$y' = \color{blue}{r \cos(\alpha)}\sin(\beta) + \color{orange}{r \sin(\alpha)}\cos(\beta)$$

$$x' = x \cos(\beta) - y \sin(\beta)$$

$$y' = x \sin(\beta) + y \cos(\beta)$$

$$\begin{bmatrix} x' \\ y' \end{bmatrix} = \begin{bmatrix} \cos(\beta) & -\sin(\beta) \\ \sin(\beta) & \cos(\beta) \end{bmatrix} \begin{bmatrix} x \\ y \end{bmatrix}$$

逐帧打印
#

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)
}

vsfs数组示例:

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仓库中查看:

公式证明
#

perspective_projection

如图,我们可以很方便得看出大三角形和小三角形是相似的:

  • 小三角形:由原点\(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文档里跑
Footer Background

© 2026 lightmon

Hugo & Blowfish 强力驱动