注意,这也是过渡篇。本文会告诉你绘制 3D 图形的简单原理,并利用上节学习的 svg 的 polygon 指令绘制一个长方体。

1. 介绍

最终的效果如下图:


027-使用 go 绘制长方体_go


图1 绘制长方体(

2×4×1 2 × 4 × 1 )

可能有人觉得这很简单,只要计算出这个长方体 8 个顶点的坐标不就 ok 了,没错。在 3 维坐标系中,这个长方体的坐标如下(方格的单位是 1):


027-使用 go 绘制长方体_svg_02


图2 长方体


知道了 8 个点的坐标,绘制起来就相当简单了。wait, wait, wait! 这个图给的是三维坐标???!!!可是我们如何在二维画布上画出它?

在此之前,我们需要有一种能将三维坐标投影到二维坐标系中的办法。这是一种有损的转换。这也就是为什么有些不同形状的物体在二维空间里我们区分不开的原因。

2. 等角投影(isometric projection)

等角投影是众多投影中的一种方法,这各投影方法,使得二维平面中的图看起来仍然像是立体的。图 1 实际上就是利用等解投影计算出 8 个顶点的二维坐标后绘制出来的。

废话不多说,看看如何得出等角投影的公式。总之我们希望是下面的一个输出输出:


xx,yy=project(x,y,z) x x , y y = p r o j e c t ( x , y , z )

经过函数 project p r o j e c t 处理后,三维坐标(x,y,z) ( x , y , z ) 被投影到二维空间的坐标为 (xx,yy) ( x x , y y ) .


027-使用 go 绘制长方体_svg_03


图3 将点 P 投影到二维空间


在等角投影中,坐标系 Oxyz O x y z 的三个轴在二维空间中的夹角互为 60 度,且 z 轴和二维空间的 y 轴方向相反(见下面红字部分)。对于图 3 中点 P(x,y,z) P ( x , y , z ) ,如果要将其投影到二维空间,则在二维空间的坐标为多少呢?

这里需要注意一点:尽管图 3 里的二维坐标系的 y 轴方向是向上的,但是在 svg 绘图系统里,我们习惯是 y 轴方向下为正。因此,后面在计算坐标的时候,就以 y 向下为正来计算,这样会很方便!

为了简单起见,不妨先计算点 P′ P ′


P′x=xcosπ6−ycosπ6=(x−y)cosπ6P′y=xsinπ6+ysinπ6=(x+y)sinπ6 P ′ x = x c o s π 6 − y c o s π 6 = ( x − y ) c o s π 6 P ′ y = x s i n π 6 + y s i n π 6 = ( x + y ) s i n π 6

上面的推导过程非常简单。接下来,你已经知道了 P′ P ′ 的坐标了,而 P P 实际上只是将 P′P′ 向上平移得到,则 P P


Px=P′xPy=P′y−zPx=P′xPy=P′y−z

那么最终的公式为:


xx=(x−y)cosπ6yy=(x+y)sinπ6−z x x = ( x − y ) c o s π 6 y y = ( x + y ) s i n π 6 − z

再来个例子,点 P(4,2,1) P ( 4 , 2 , 1 ) ,等角投影到二维空间后的坐标是:


xx=(4−2)×3‾√2=1.732yy=(4+2)×12−1=2 x x = ( 4 − 2 ) × 3 2 = 1.732 y y = ( 4 + 2 ) × 1 2 − 1 = 2

没错,从结果来看,点 P P <script type="math/tex" id="MathJax-Element-18">P</script> 的位置差不多就在二维坐标系中的这个位置。

3. go 输出长方体

3.1 代码

package main

import (
"fmt"
"io"
"math"
"net/http"
)

const (
angle = math.Pi / 6
width = 500
height = 500
)

var sin30, cos30 = math.Sin(angle), math.Cos(angle)

func draw(w io.Writer) {
// 初始化 8 个顶点坐标,分别对应图 2 中的 (a, b, c, d, e, f, g, h) 8 个顶点
x1, y1 := 0.0, 0.0
x2, y2 := 0.0, 2.0
x3, y3 := 4.0, 2.0
x4, y4 := 4.0, 0.0
x5, y5 := 0.0, 0.0
x6, y6 := 0.0, 2.0
x7, y7 := 4.0, 2.0
x8, y8 := 4.0, 0.0
z1, z2 := 0.0, 1.0

// 计算投影后的坐标
xx1, yy1 := project(x1, y1, z1)
xx2, yy2 := project(x2, y2, z1)
xx3, yy3 := project(x3, y3, z1)
xx4, yy4 := project(x4, y4, z1)
xx5, yy5 := project(x5, y5, z2)
xx6, yy6 := project(x6, y6, z2)
xx7, yy7 := project(x7, y7, z2)
xx8, yy8 := project(x8, y8, z2)

// 绘制 svg 多边形
fmt.Fprintf(w, "<svg xmlns='http://www.w3.org/2000/svg' style='stroke:red; fill:#FFFF99; stroke-width:2.7' width='%g' height='%g'>\n", width, height)
io.WriteString(w, polygon(xx1, yy1, xx2, yy2, xx3, yy3, xx4, yy4)) // 底面
io.WriteString(w, polygon(xx1, yy1, xx2, yy2, xx6, yy6, xx5, yy5)) // 背面
io.WriteString(w, polygon(xx1, yy1, xx4, yy4, xx8, yy8, xx5, yy5)) // 右侧
io.WriteString(w, polygon(xx2, yy2, xx3, yy3, xx7, yy7, xx6, yy6)) // 左侧
io.WriteString(w, polygon(xx3, yy3, xx4, yy4, xx8, yy8, xx7, yy7)) // 正面
io.WriteString(w, polygon(xx5, yy5, xx6, yy6, xx7, yy7, xx8, yy8)) // 顶面
fmt.Fprintf(w, "</svg>\n")
}

// 绘制多边形
func polygon(x1, y1, x2, y2, x3, y3, x4, y4 float64) string {
var svg string
scale := 100.0
// 为了保证长方体能在画布正中间,因此需要给横坐标加上 width/2, 纵坐标加上 height/2
// 另一方面,这个坐标值太小了,因此乘以 100 将其放大了 100 倍
svg = fmt.Sprintf("<polygon points='%g,%g %g,%g %g,%g %g,%g' />\n",
width/2+x1*scale, height/2+y1*scale,
width/2+x2*scale, height/2+y2*scale,
width/2+x3*scale, height/2+y3*scale,
width/2+x4*scale, height/2+y4*scale)
return svg
}

// 投影函数,第二节推导的公式
func project(x, y, z float64) (float64, float64) {
xx := x*cos30 - y*cos30
yy := x*sin30 + y*sin30 - z
return xx, yy
}

func handle(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/svg+xml")
draw(w)
}

func main() {
http.HandleFunc("/", handle)
err := http.ListenAndServe(":8080", nil)
if err != nil {
fmt.Printf("%v", err)
}
}

3.2 关于绘制多边形

也许你看到了 3.1 里我使用多边形 polygon 指令绘制了长方体的 6 个面。为什么不用线段来画呢?你要知道,长方体背面的实际上我们是看不见的,如果使用线段来画,这样这个长方体看起来就像个透明的了。

使用 polygon 的好处是,后面绘制的图形如果和前面的图形有重叠,会产生遮挡。这有点像图层,上面的图层遮挡下面的图层。所以在 3.1 里,我是先绘制长方体的底面,背面,然后右侧面,最后是左侧面正面和顶面。

4. 总结

  • 掌握等角投影公式推导
  • 掌握 polygon 指令

下一篇,我们继续绘制更加复杂的图形。