代码仓库

https://github.com/phprao/go-graphic

变换

矩阵操作与向量操作:https://learnopengl-cn.github.io/01%20Getting%20started/07%20Transformations/

在OpenGL中,由于某些原因我们通常使用4×4的变换矩阵,而其中最重要的原因就是大部分的向量都是4分量的。

矩阵实际上就是一个数组

type Mat4 [16]float32

func Ident4() Mat4 {
	return Mat4{1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1}
}
向量的缩放
向量的位移
齐次坐标(Homogeneous Coordinates)
齐次坐标

如果一个向量的齐次坐标是0,这个坐标就是方向向量(Direction Vector),因为w坐标是0,这个向量就不能位移(译注:这也就是我们说的不能位移一个方向)。

有了位移矩阵我们就可以在3个方向x、y、z上移动物体,它是我们的变换工具箱中非常有用的一个变换矩阵。

向量的旋转

沿任意轴旋转

建议您在组合矩阵时,先进行缩放操作,然后是旋转,最后才是位移,否则它们会互相影响。

GLM是OpenGL Mathematics的缩写,GLM库从0.9.9版本起,默认会将矩阵类型初始化为一个零矩阵(所有元素均为0),而不是单位矩阵(对角元素为1,其它元素为0)。

github.com/go-gl/mathgl/mgl32
// 生成一个向量
v4 = mgl32.Vec4{1, 1, 1, 1}
v3 = mgl32.Vec3{3, 3, 3}
v4 = v3.Vec4(1)
v2 = v3.Vec2()

// 向量的Add, Sub, Mul, Dot, Cross, Len

// 矩阵之间点乘
A.Mul4(B)

// 生成4*4的单位矩阵
model := mgl32.Ident4()

// 生成一个沿向量(3,4,5)移动的变换矩阵trans3d
/*
[1, 0, 0, 3]
[0, 1, 0, 4]
[0, 0, 1, 5]
[0, 0, 0, 1]
*/
trans3d := mgl32.Translate3D(3,4,5)
// 使向量vec3(1,2,3)沿着向量(3,4,5)移动
// 结果 (4,6,8)
mgl32.TransformCoordinate(mgl32.Vec3{1, 2, 3}, trans3d)

// 生成缩放比例(2,2,2)的变换矩阵
// 如果缩放的比例是负值,会导致图像翻转
/*
[2, 0, 0, 0]
[0, 2, 0, 0]
[0, 0, 2, 0]
[0, 0, 0, 1]
*/
scale3d := mgl32.Scale3D(2, 2, 2)
// 使向量vec3(1,2,3)缩放(2,2,2)
// 结果 (2,4,6)
mgl32.TransformCoordinate(mgl32.Vec3{1, 2, 3}, scale3d)

// 沿轴(3,3,3)旋转20度的变化矩阵
/*
[5.735343 2.588426 8.066097 0.000000]
[8.066097 5.735343 2.588426 0.000000]
[2.588426 8.066097 5.735343 0.000000]
[0.000000 0.000000 0.000000 1.000000]
*/
rotate3d := mgl32.HomogRotate3D(mgl32.DegToRad(20), mgl32.Vec3{3, 3, 3})
// 使向量vec3(1,2,3)沿轴(3,3,3)旋转20度
// 结果 (35.11049, 27.302063, 35.92665)
mgl32.TransformCoordinate(mgl32.Vec3{1, 2, 3}, rotate3d)
math.Sin(angle)radiandegreemgl32.RadToDeg() 和 mgl32.DegToRad()
mgl32.Mat4
model := mgl32.Ident4()
modelUniform := gl.GetUniformLocation(program, gl.Str("model\x00"))
gl.UniformMatrix4fv(modelUniform, 1, false, &model[0])
uniform mat4 model;

示例:对现有纹理,实现先缩放,再旋转,再移动的效果

操作后效果

主要代码:

for !window.ShouldClose() {
    ......
    gl.UseProgram(program)

    rotate := mgl32.HomogRotate3D(mgl32.DegToRad(90), mgl32.Vec3{0, 0, 1})
    scale := mgl32.Scale3D(0.5, 0.5, 0.5)
    translate := mgl32.Translate3D(0.5, -0.5, 0)
    // 顺序要反着看:依次是 scale,rotate,translate
    transe := translate.Mul4(rotate).Mul4(scale)
    gl.UniformMatrix4fv(gl.GetUniformLocation(program, gl.Str("transe\x00")), 1, false, &transe[0])
    ......
}

顶点着色器

......
uniform mat4 transe;
......
void main() {
    gl_Position = transe * vec4(vPosition, 1.0);
    ......
}

我们可以让旋转的弧度随着时间变动,这样图像就旋转起来了

rotate := mgl32.HomogRotate3D(float32(glfw.GetTime()), mgl32.Vec3{0, 0, 1})
glfw.GetTime()
0.19938110100045076
0.3682488961570842
0.8834820281800326
...
1.0471016692995818
1.2141550765655154
1.380787958668221
...

表示窗口运行了多少秒。

示例2:在一个窗口中画两个箱子,一个在不停旋转,一个在不停缩小放大

for !window.ShouldClose() {
    gl.ClearColor(0.2, 0.3, 0.3, 1.0)
    gl.Clear(gl.COLOR_BUFFER_BIT)
    gl.UseProgram(program)

    gl.ActiveTexture(gl.TEXTURE0)
    gl.BindTexture(gl.TEXTURE_2D, texture1)
    gl.Uniform1i(gl.GetUniformLocation(program, gl.Str("ourTexture1"+"\x00")), 0)

    gl.ActiveTexture(gl.TEXTURE1)
    gl.BindTexture(gl.TEXTURE_2D, texture2)
    gl.Uniform1i(gl.GetUniformLocation(program, gl.Str("ourTexture2"+"\x00")), 1)

    gl.BindVertexArray(vao)

    // 第一个箱子
    rotate := mgl32.HomogRotate3D(float32(glfw.GetTime()), mgl32.Vec3{0, 0, 1}) // 旋转效果
    scale := mgl32.Scale3D(0.5, 0.5, 0.5)
    translate := mgl32.Translate3D(0.5, -0.5, 0)
    transe := translate.Mul4(rotate).Mul4(scale)
    gl.UniformMatrix4fv(gl.GetUniformLocation(program, gl.Str("transe\x00")), 1, false, &transe[0])
    gl.DrawElements(gl.TRIANGLES, pointNum, gl.UNSIGNED_INT, gl.Ptr(indices))

    // 第二个箱子
    rotate2 := mgl32.HomogRotate3D(mgl32.DegToRad(90), mgl32.Vec3{0, 0, 1})
    s := float32(math.Sin(glfw.GetTime()))
    scale2 := mgl32.Scale3D(s, s, s)
    translate2 := mgl32.Translate3D(-0.5, 0.5, 0)
    transe2 := translate2.Mul4(rotate2).Mul4(scale2)
    gl.UniformMatrix4fv(gl.GetUniformLocation(program, gl.Str("transe\x00")), 1, false, &transe2[0])
    gl.DrawElements(gl.TRIANGLES, pointNum, gl.UNSIGNED_INT, gl.Ptr(indices))

    glfw.PollEvents()
    window.SwapBuffers()
}

坐标系统

一个顶点在最终被转化为片段之前需要经历的所有不同状态。

  • 局部空间(Local Space),或者称为物体空间(Object Space)
  • 世界空间(World Space)
  • 观察空间(View Space),或者称为视觉空间(Eye Space),摄像机空间(Camera Space)
  • 裁剪空间(Clip Space)
  • 屏幕空间(Screen Space)

为了将坐标从一个坐标系变换到另一个坐标系,我们需要用到几个变换矩阵,最重要的几个分别是模型(Model)、观察(View)、投影(Projection)三个矩阵。我们的顶点坐标起始于局部空间(Local Space),在这里它称为局部坐标(Local Coordinate),它在之后会变为世界坐标(World Coordinate),观察坐标(View Coordinate),裁剪坐标(Clip Coordinate),并最后以屏幕坐标(Screen Coordinate)的形式结束。下面的这张图展示了整个流程以及各个变换过程做了什么:

矩阵变换
  • 从局部空间变换到世界空间:模型矩阵(Model Matrix)。
  • 从世界空间变换到观察空间:观察矩阵(View Matrix)。
  • 从观察空间变换到裁剪空间:投影矩阵(Projection Matrix)。
透视除法(Perspective Division)自动执行
正射投影矩阵(Orthographic Projection Matrix)透视投影矩阵(Perspective Projection Matrix)
正射投影

它由宽、高、近(Near)平面和远(Far)平面所指定。

// near平面为靠近观察者的平面
// 参数一二,表示near平面的左右坐标
// 参数三四,表示near平面的底顶坐标
// 参数五六,near和far平面距离屏幕的距离
mgl32.Ortho(0, 800, 0, 600, 0.1, 100)

正射投影对近处和远处的物体都一视同仁,也就说每个顶点的w分量都是1,但这与现实不符,实际上,同样大小的物体,距离人眼越远会看到的越小,这是由眼睛的构造决定的。

透视投影
// 第一个参数为视野角,通常为45度。
// 第二个参数为视口的宽高比。
// near和far平面距离屏幕的距离。通常设置near为0.1,far为100
mgl32.Perspective(mgl32.DegToRad(45.0), float32(windowWidth)/windowHeight, 0.1, 10.0)

距离摄像机越远,能看到的视野越大,但是屏幕大小是固定的,于是物体是会缩小的。

会修改w分量,离观察者越远的顶点坐标w分量越大。最后会让x,y,z都除以w分量,于是远处的物体就变小了。

视野角的特性:角度越小,那么看到的场景范围就越小,投影到屏幕上反而是放大的效果;反之,视野角越大,是缩小的效果。

最后的变换过程:

V_clip = M_projection * M_view * M_model * V_local
具体实践

Model Matrix

也就是说 Model 是用来调整单个的物体缩放 --> 旋转 --> 平移

View Matrix

也就是说 View 是用来调整整个场景的

Projection Matrix

将场景投影到窗口区域上,它决定了要将哪部分场景投影到屏幕上。

model := mgl32.HomogRotate3D(mgl32.DegToRad(-55), mgl32.Vec3{1, 0, 0})
view := mgl32.Translate3D(0, 0, -3)
projection := mgl32.Perspective(mgl32.DegToRad(45), float32(width)/float32(height), 0.1, 100)
transe := projection.Mul4(view).Mul4(model)
gl.UniformMatrix4fv(gl.GetUniformLocation(program, gl.Str("transe\x00")), 1, false, &transe[0])

如果有3D效果,那么就需要开启深度测试,它会对遮挡进行处理,否则效果比较奇怪。

gl.Enable(gl.DEPTH_TEST)

gl.Clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)

摄像机

前面将的坐标系统,是假设摄像机不动,而是场景在动,物体在动,其实还有一种观察方式就是让摄像机也动起来。

定义一个摄像机,就是创建一个三个单位轴相互垂直的、以摄像机的位置为原点的坐标系。

定义一个摄像机的步骤:

cameraPos := mgl32.Vec3{0, 0, 3}
cameraTarget := mgl32.Vec3{0, 0, 0}
cameraDirction := cameraPos.Sub(cameraTarget)
up := mgl32.Vec3{0, 1, 0}
cameraRight := up.Cross(cameraDirction)
cameraUp := cameraDirction.Cross(cameraRight)
cameraPos, cameraTarget, upLookAt
camera := mgl32.LookAtV(cameraPos, cameraTarget, up)
projection * camera * model

示例1

一个立方体绕着Y轴旋转,正常情况下我们只能看到Y轴这个面,加入了相机之后我们就能看到一个立体效果。

model := mgl32.HomogRotate3D(float32(glfw.GetTime()), mgl32.Vec3{0, 1, 0})
camera := mgl32.LookAtV(mgl32.Vec3{2, 2, 2}, mgl32.Vec3{0, 0, 0}, mgl32.Vec3{0, 1, 0})
projection := mgl32.Perspective(mgl32.DegToRad(45), float32(width)/height, 0.1, 100)
transe := projection.Mul4(camera).Mul4(model)
gl.UniformMatrix4fv(gl.GetUniformLocation(program, gl.Str("transe\x00")), 1, false, &transe[0])

示例2

场景不动,摄像机的位置围绕着一个圆旋转,圆半径为3

radius := 3.0
cx := float32(math.Sin(glfw.GetTime()) * radius)
cz := float32(math.Cos(glfw.GetTime()) * radius)
camera := mgl32.LookAtV(mgl32.Vec3{cx, 2, cz}, mgl32.Vec3{0, 0, 0}, mgl32.Vec3{0, 1, 0})
projection := mgl32.Perspective(mgl32.DegToRad(45), float32(width)/height, 0.1, 100)
transe := projection.Mul4(camera)
gl.UniformMatrix4fv(gl.GetUniformLocation(program, gl.Str("transe\x00")), 1, false, &transe[0])

示例3

使用按键WSAD来控制相机左右前后移动,

cameraPos := mgl32.Vec3{0, 0, 3}
cameraFront := mgl32.Vec3{0, 0, -1}
cameraUp := mgl32.Vec3{0, 1, 0}

func KeyPressAction(window *glfw.Window) {
	keyCallback := func(w *glfw.Window, key glfw.Key, scancode int, action glfw.Action, mods glfw.ModifierKey) {
		cameraSpeed := float32(0.05)
		if key == glfw.KeyW && action == glfw.Press {
			cameraPos = cameraPos.Sub(cameraFront.Mul(cameraSpeed))
		}
		if key == glfw.KeyS && action == glfw.Press {
			cameraPos = cameraPos.Add(cameraFront.Mul(cameraSpeed))
		}
		if key == glfw.KeyA && action == glfw.Press {
             // Normalize 标准化坐标使其落在 [-1,1]
			cameraPos = cameraPos.Add(cameraFront.Cross(cameraUp).Normalize().Mul(cameraSpeed))
		}
		if key == glfw.KeyD && action == glfw.Press {
			cameraPos = cameraPos.Sub(cameraFront.Cross(cameraUp).Normalize().Mul(cameraSpeed))
		}
         // log.Println(cameraPos, cameraPos.Add(cameraFront))
	}
	window.SetKeyCallback(keyCallback)
}

func Run10() {
	......
	KeyPressAction(window)

	for !window.ShouldClose() {
		......
         // 这样能保证无论我们怎么移动,摄像机都会注视着目标方向
		camera := mgl32.LookAtV(cameraPos, cameraPos.Add(cameraFront), cameraUp)
		projection := mgl32.Perspective(mgl32.DegToRad(45), float32(width)/height, 0.1, 100)
		transe := projection.Mul4(camera)
		gl.UniformMatrix4fv(gl.GetUniformLocation(program, gl.Str("transe\x00")), 1, false, &transe[0])

		......

		glfw.PollEvents()
		window.SwapBuffers()
	}
}
叉乘
这样能保证无论我们怎么移动,摄像机都会注视着目标方向keyCallbackcameraPoscameraFrontcameraTarget = cameraPos + cameraFront

视角移动

欧拉角

欧拉角(Euler Angle)是可以表示3D空间中任何旋转的3个值,由莱昂哈德·欧拉(Leonhard Euler)在18世纪提出。一共有3种欧拉角:俯仰角(Pitch)、偏航角(Yaw)和滚转角(Roll),下面的图片展示了它们的含义:

俯仰角是描述我们如何往上或往下看的角,可以在第一张图中看到。第二张图展示了偏航角,偏航角表示我们往左和往右看的程度。滚转角代表我们如何翻滚摄像机,通常在太空飞船的摄像机中使用。每个欧拉角都有一个值来表示,把三个角结合起来我们就能够计算3D空间中任何的旋转向量了。

最终的方向向量计算公式

// direction代表摄像机的前轴(Front),这个前轴是和本文第一幅图片的第二个摄像机的方向向量是相反的
direction.x = cos(glm::radians(pitch)) * cos(glm::radians(yaw)); 
direction.y = sin(glm::radians(pitch));
direction.z = cos(glm::radians(pitch)) * sin(glm::radians(yaw));
鼠标控制

鼠标坐标系的原点为屏幕左上角,向右为X正,向下为Y正,因此Y轴的增量应该反过来。

刚进来的时候会出现抖动,那是因为默认的cursorX,cursorY在屏幕中心,而鼠标刚开始并不在屏幕中心,因此要初始化起始点。

俯仰角,要让用户不能看向高于89度的地方(在90度时视角会发生翻转),同样也不允许小于-89度。这样能够保证用户只能看到天空或脚下,但是不能超越这个限制。类比于人的眼睛仰视和俯视,超过了90度看到的东西会反过来。

偏航角却可以是360度旋转。

yawpitchcameraFrontcameraFront(0,0,-1)yawpitchcameraFront = (0,0,-1)pitch = 0, yaw = -90
var firstMouse bool
var cursorX float64 = 400
var cursorY float64 = 300
var yaw float64 = -90
var pitch float64
sensitivity := 0.05 // 鼠标移动的灵敏度
cursorPosCallback := func(w *glfw.Window, xpos float64, ypos float64) {
    if firstMouse {
        cursorX = xpos
        cursorY = ypos
        firstMouse = false
    }

    xoffset := sensitivity * (xpos - cursorX)
    yoffset := sensitivity * (cursorY - ypos)
    cursorX = xpos
    cursorY = ypos
    yaw += xoffset
    pitch += yoffset
    if pitch > 89 {
        pitch = 89
    }
    if pitch < -89 {
        pitch = -89
    }

    cameraFront = mgl32.Vec3{
        float32(math.Cos(float64(mgl32.DegToRad(float32(pitch)))) * math.Cos(float64(mgl32.DegToRad(float32(yaw))))),
        float32(math.Sin(float64(mgl32.DegToRad(float32(pitch))))),
        float32(math.Cos(float64(mgl32.DegToRad(float32(pitch)))) * math.Sin(float64(mgl32.DegToRad(float32(yaw))))),
    }.Normalize()
}
window.SetCursorPosCallback(cursorPosCallback)

天空盒
前面我们讲过,纹理贴图默认是双面贴的,也就是说立方体内部也贴好了,但是细细分析就会发现,外面和里面的图片是左右翻转的,如果此时我们把相机位置放到立方体中心会怎么样呢,其实这就是一个天空盒的效果,通过控制鼠标,我们可以在一个内部空间遨游。当然,更精细的天空盒需要六个面有不同的贴图。

当然,天空盒的正确操作应该是使用立方体贴图,也就是纹理坐标是三维的,参考实现 https://learnopengl-cn.github.io/04%20Advanced%20OpenGL/06%20Cubemaps

首先是创建立方体纹理

func MakeTextureCube(filepathArray []string) uint32 {
	var texture uint32
	gl.GenTextures(1, &texture)
	gl.BindTexture(gl.TEXTURE_CUBE_MAP, texture)

	for i := 0; i < len(filepathArray); i++ {
		imgFile2, _ := os.Open(filepathArray[i])
		defer imgFile2.Close()
		img2, _, _ := image.Decode(imgFile2)
		rgba2 := image.NewRGBA(img2.Bounds())
		draw.Draw(rgba2, rgba2.Bounds(), img2, image.Point{0, 0}, draw.Src)

		// right, left, top, bottom, back, front
		//
		// TEXTURE_CUBE_MAP_POSITIVE_X   = 0x8515
		// TEXTURE_CUBE_MAP_NEGATIVE_X   = 0x8516
		// TEXTURE_CUBE_MAP_POSITIVE_Y   = 0x8517
		// TEXTURE_CUBE_MAP_NEGATIVE_Y   = 0x8518
		// TEXTURE_CUBE_MAP_POSITIVE_Z   = 0x8519
		// TEXTURE_CUBE_MAP_NEGATIVE_Z   = 0x851A
		gl.TexImage2D(gl.TEXTURE_CUBE_MAP_POSITIVE_X+uint32(i), 0, gl.RGBA, int32(rgba2.Rect.Size().X), int32(rgba2.Rect.Size().Y), 0, gl.RGBA, gl.UNSIGNED_BYTE, gl.Ptr(rgba2.Pix))
	}

	gl.TexParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
	gl.TexParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
	gl.TexParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_WRAP_R, gl.CLAMP_TO_EDGE)
	gl.TexParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
	gl.TexParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_MAG_FILTER, gl.LINEAR)

	return texture
}

然后是顶点数据,我们不需要设置纹理坐标,而是直接取的顶点坐标作为纹理坐标。

#version 410

in vec3 vPosition;
out vec3 textureDir;

uniform mat4 transe;

void main() {
	gl_Position = transe * vec4(vPosition, 1.0);
	textureDir = vPosition;
}
samplerCube
#version 410

in vec3 textureDir;

out vec4 frag_colour;

uniform samplerCube cubemap;

void main() {
	frag_colour = texture(cubemap, textureDir);
}
right, left, top, bottom, back, front
滚轮控制缩放
45.0f1.0f45.0f
var fov float64 = 45
scrollCallback := func(w *glfw.Window, xoff float64, yoff float64) {
    if fov >= 1.0 && fov <= 45.0 {
        fov -= yoff
    }
    if fov <= 1.0 {
        fov = 1.0
    }
    if fov >= 45.0 {
        fov = 45.0
    }
}
window.SetScrollCallback(scrollCallback)
......
projection := mgl32.Perspective(mgl32.DegToRad(float32(fov)), float32(width)/height, 0.1, 100)

保存图片

ctrl+sgl.ReadPixels()func ReadPixels(x int32, y int32, width int32, height int32, format uint32, xtype uint32, pixels unsafe.Pointer)
gl.TexImage2D()

但是我们用的图形库基本都是将左上角作为(0,0)点的,因此保存的图片是Y轴上下颠倒的,因此需要自行翻转Y轴。

func (c *Camera) SavePng(filepath string) {
	img := image.NewRGBA(image.Rect(0, 0, c.WindowWidth, c.WindowHeight))

	gl.ReadPixels(0, 0, int32(c.WindowWidth), int32(c.WindowHeight), gl.RGBA, gl.UNSIGNED_BYTE, gl.Ptr(img.Pix))

	// 翻转Y坐标
	for x := 0; x < c.WindowWidth; x++ {
		for y := 0; y < c.WindowHeight/2; y++ {
			s := img.RGBAAt(x, y)
			t := img.RGBAAt(x, c.WindowHeight-1-y)
			img.SetRGBA(x, y, t)
			img.SetRGBA(x, c.WindowHeight-1-y, s)
		}
	}

	if filepath == "" {
		filepath = strconv.Itoa(int(time.Now().Unix())) + ".png"
	}
	f, _ := os.Create(filepath)
	b := bufio.NewWriter(f)
	png.Encode(b, img)
	b.Flush()
	f.Close()
}