learn opengl技术笔记
此部分是计算机图形学的一些理论知识
现代计算机图形学基础一:线性代数与空间变换
参考的是知乎上博主写的:现代计算机图形学基础一:线性代数与空间变换 - 知乎 (zhihu.com)。
我还是觉得还是要自己看图形学的书比较好。看别人写的东西不一定能全部看懂。
如果没有技术笔记面试起来会很麻烦。
点积
数学中又叫数量积。是指接受在实数R上的两个向量并返回一个实数值标量的二元运算。指两向量相乘返回实数。
叉积
叉积的方向与两个初始向量正交,这个方向我们可以由右手螺旋定则确定。我们可以伸出右手作a向量到b向量的叉积我们可以发现叉出的方向是正朝上的,而用右手螺旋定则b向量到a向量的叉积叉出的方向是正朝下的。所以a x b=-b x a。
叉积在图形学中的应用:
可以确定两个向量的左右关系,其次还可以确定点是不是在三角形内部。
如下图,从a向量叉到b向量叉出来的方向是+z的,也就是是说a向量叉b向量大于0(也可以说b在a的左侧);同理b向量叉到a向量叉出来的方向是- z的,也就是是说b向量叉a向量小于0(也可以说a在b的右侧)。
如下图,三角形的方向是逆时针的,从向量AB叉到向量AP叉出来的方向是+ z,说明P点在AB的左侧;从向量BC叉到向量BP叉出来的方向是+ z,说明P点在BC的左侧;从向量CA叉到向量CP叉出来的方向是+ z,说明P点在AC的左侧,这就说明P点在三角形的内部。因为如果不在的话那么至少存在一条边使得P点在右侧(三角形是顺时针也没有问题,P点都在三角形的右边,我们只要保证P点一直在三条边的左边或者右边就可以说它在三角形的内部)。
线性变换和非线性变换:
旋转和缩放可用线性变换来表示。
缩放:
旋转:
平移:
平移则是非线性变换。
我们可以看到这是先缩放旋转后平移。先平移再乘缩放旋转矩阵的话数值会变大。所以不行。
我们不想把这种平移变换作为一个特殊的列子去表示,于是大佬们开始思考是否有统一的方式来表示所有转换?大佬们找到了这种统一的方式,那就是引入齐次坐标(Homogenous Coordinates)。齐次坐标原本是将一个原本是n维的向量用一个n+1维向量来表示,我觉得这是一个非常优美的地方,因为增加一个维度以后,就可以在高纬度通过线性变换来完成低维度的平移。
因此我们可以在齐次坐标下,把目前所有的平移、旋转、缩放都可以写成线性变换(也就是一个矩阵matrix)
为什么要把向量2D vector和2D point区别对待,为什么在2D vector后面加一个0而不是一个其他的数?这是因为向量平移的不变性(一个向量无论你做怎样的平移,它的大小和方向都是不变的),因此我们把向量的最后一个维度增加一个0就是为了保护它,让它平移之后保持不变(这个怎么理解呢?就是说有一个向量(x, y)T 我们希望它平移之后还是(x, y)T而不是(x+tx, y+ty)T,为了保护它让它不变我们就把向量的最后一个维度增加一个0 )。
变换
变换解决的是怎么把相机在三维空间中看到的东西转投射到2D屏幕上来。即把坐标转化为标准化坐标,最后转化为屏幕坐标。
而给坐标变换的过程我们可以理解为拍照的过程。1.给物体找个位置(模型变换Model transformation,在世界坐标中找个好位置)。2.给相机找个合适位置。(视角变换View transformation,这时是相机怎么去看物体,也就是物体相对于相机的位置。物体已经转化为观察坐标。)3.怎么去看,用正交投影去看,还是透视投影去看(会把物体坐标转化到单位立方体来)。
模型变换
我们知道模型变换的平移、旋转、缩放和视角变换还是3D模型的变换。模型变换的功能是把物体摆到我们想要的位置。一个引入齐次坐标的矩阵搞定。我们还在对模型进行数据层面的改变。并未进入到投射到2D屏幕上的环节。
视角变换
视角变换解决的是摄像机和物体的相对位置的问题。想象一下摄像机本身就有一个自己的坐标系。摄像机坐标系的定义,三个参数:摄像机的位置position、拍摄的方向direction、视点的正向方向Up direction。
第一步需要摄像机的位置移到原点,然后再把摄像机坐标系(e,t,g)旋转到世界坐标系(x,y,z),那么此时物体相对摄像机的坐标就是世界场景坐标。这两步由这两个矩阵完成。Mview = RviewTview,Mview 指摄像机下的坐标,Tview是指摄像机移到原点的矩阵T, Rview是指把摄像机坐标系(e,t,g)旋转到世界坐标系(x,y,z)的矩阵R。
投影变换
这时我们需要把三维的东西投射到屏幕二维上来。分为正交投影和透视投影。
正交投影
正交投影的目的就是把正交视点范围长方体里面的空间的物体转换到单位立方体里面来。主要分为两步:第一步把长方体的中心点平移到原点坐标,第二步进行缩放。
我们知道正交投影的视点范围是一个长方体[l,r],[t,b],[n,f],因此可以求出其中心坐标为[(l+r)/2,(b+t)/2,(f+n)/2],因此我们求得平移矩阵和缩放矩阵。最后就可以实现正交视点范围长方体里面的空间的物体转换到单位立方体里面来。
注意:在进行正交投影时,是先乘移动矩阵,再缩放。如果先缩放掉,求中心坐标就是用缩放后的大小去求了。所以我们要先用原来的大小求出它的中心坐标,然后再移动。移动是把长方体中心移动到单位立方体的原点。
透视投影
博主的一些内容看不懂。先跳过。
视口变换
把单位立方体映射到屏幕空间。屏幕空间(screen space),我们可以从下面的图中看到:屏幕覆盖的范围是从(0, 0) 到 (width, height),而像素可以用一个个的方格表示它们索引的范围是从(0, 0)到 (width - 1, height - 1),比如这个蓝色的像素就可以用(2,1)表示,并且像素 (x, y)的中心在(x + 0.5, y + 0.5),其中x和y都是整型的。
单位立方体的x和y在平面上的范围是 [-1, 1]2,而像素平面的范围是[0, width] x [0, height]。原来我们单位立方体的x和y都是2,现在x要变成width那就需要乘width /2,而y要变成height那就需要乘height/2,而z保持不变则为1(这个z值后面会详细介绍用来做深度测试),然后我们不要忘了此时立方体的中心点还在(0,0)而屏幕的中心点在(width /2,height/2)因此还需要平移(width /2,height/2)个单位(概括为先缩放再平移)。这样我们就可以算出视口变化矩阵。
此时得到的不过是屏幕空间中的一些三角形。我们需要把这些三角形打碎打成像素并且告诉每个像素的值是多少然后显示在屏幕上,这一个完整的过程我们称之为光栅化(Rasterization)。我们要把这些三角形打碎成一个个的像素并且给每一个像素赋值,也就是光栅化(Rasterization)。
所以总结下来:世界场景中的物体映射到电脑二维屏幕空间,经历了四个变换依次是模型变换(Model transformation)、视角变换(View transformation)、投影变换(Perspective Projection)、视口变换(Screentransformation)。
现代计算机图形学基础二:光栅化(Rasterization)
光栅化就是把东西画在屏幕上的一个过程。实时渲染的核心组件——图形渲染管线(The Graphics Rendering Pipeline),它的主要功能是在给定一个虚拟相机、 三维物体、光源等等的情况下生成或渲染二维图像。
在左图中,一个虚拟相机位于锥体的顶端(四条线汇合处)。只有视景体内的图元会被渲染。右图显示了相机所“看到”的内容。请注意,左图中的红色甜甜圈形状物体不在右图中,因为它位于视锥体外部。左图中扭曲的蓝色棱柱也被裁剪于视锥体的顶面上。
实时渲染管线的各阶段是并行执行的。大致分为四个主要阶段:应用程序阶段,几何处理阶段,光栅化阶段和像素处理阶段。
为什么经过一系列的空间变换和视口变换得到的是一系列屏幕空间的三角形 ?三角形在图形学中有很多很好的性质:(1)三角形是最基本的多边形,并且任何其他的多边形都可以拆分为三角形。(2)三个点可以保证他在一个平面如果是四边形四个点就不能保证。(3)它可以很好地用叉积判断一个点是不是在三角形内部(三角形的内外定义特别清晰)。
采样
采样就是给定一个连续的函数,在不同的点求它的值,也可以认为,采样是把一个连续的函数离散化的过程。
根据这个定义我们定义一个判断函数inside(tri, x, y)函数判断这个像素的中心是否在三角形内,这个函数的定义这就涉及到 上一章讲的叉积。如果这个点一直在三角形三条边的左边或右边(看三角形边向量的方向)那么就可以认为这个点在这个三角形的内部。这个点在三角形的内部那么我们就定义它为1,不在内部就定义为0(这里的像素只考虑是显示还是不显示的问题,还不考虑像素内部颜色的变化。1或0应该是这个函数的返回值。
遍历整个屏幕的像素太慢,只判断这个三角形包围盒里面的像素会快一点。
像素是一个小方块并且它内部的颜色是均匀的。在把这些像素点进行填充的时候,在像素层面边缘就不光滑。产生锯齿。
也就是在判断某个像素点的中心是否在一个三角形面片里的时候会产生锯齿。
产生锯齿的2个原因:1.像素本身有一定的大小。2.是采样的速度更不上信号变化的速度(高频信号采样不足)。
采样在图形学中广泛存在。光栅化的过程其实就是在屏幕空间离散的像素中心点上进行采样来判断像素是否在三角形内的采样。
一张照片其实就是所有达到感光元件的光学信息,通过把它离散成图像的过程,其实这也是采样。采样不仅可以发生在不同的位置,也可以发生在不同的时间,视频就是在时间中进行采样的。
出现锯齿也可以说是走样。
反走样
反走样的方法有先模糊后采样来优化。(模糊也可以认为是低通滤波器,把三角形边界的这种高频信号给过滤掉)。
采样速度更不上信号变化的速度(高频信号采样不足)就会产生走样问题。这里的速度跟不上指的是什么?
信号原理中的傅里叶变换:傅里叶变换可以将信号分解为频率。即傅里叶变换可以把函数从实域变到频域。对高频信号采样不足指的是高频信号和低频信号有相同采样点时,采样错误地显示为来自低频信号,在给定采样点的时候无法区分的两个频率速率称为“别名“。
为什么模糊(预过滤)后进行采样行抗锯齿的原理是什么?
模糊就是剔除某些特定频率。高通滤波器(High-pass filter)就是只保留高频信号(可以理解为图形内容中的细节,或者说边界-变化大的地方);反过来如果把高频信号全部去除只保留低频信号,我们就会得到一张相对模糊的图。光栅化抗锯齿做的先模糊就是采用了低通滤波器(Low-pass filter),使其高频信号边界去除然后图形就会变得模糊。
滤波=卷积=平均
模糊就是采用了低通滤波器,可以认为是一种平均操作。
卷积是什么?后面真的说不清了。
现代计算机图形学基础三:着色(Blinn Phong(冯氏 )反射模型与纹理映射)
实时渲染管线的前半部分即光栅化的过程:空间中的场景模型经过各种空间变换(Model, View, Projection transforms),把空间的物体转换到单位立方体里面来(cuboid to “canonical” cube [-1, 1]3。然后通过视口变化矩阵(Viewport transform matrix)把单位立方体映射到屏幕空间(Canonical Cube to Screen)。此时我们需要把屏幕空间的三角形离散化成一个个像素。到这里我们就可以将一个三角形画在屏幕上了。
实时渲染管线流:
实时渲染管线流从应用程序开始,输入3D空间中的顶点,经顶点处理后得到在屏幕空间上的顶点流。
再是三角形处理。得到屏幕上的三角形,即三角流。这是几何处理阶段。
然后是光栅化阶段,把三角形离散成未着色的像素区。即片段,片段流。
然后是片段处理,得到着色的片段。
再是帧缓冲操作得到输出像素。该阶段是像素处理阶段。再结合learnOpengl这个图。也就是说我们要做的一个是应用程序阶段,编写.obj文件的导入代码。然后是在顶点着色器中写矩阵运算。
很显然的,几何处理阶段我们是管不了的。正如learnOpengl这张图,图元装配是无法被定义的。光栅化为三角形分配片段也是无法被定义的,可能是因为这是由模型数据决定的。至于片段处理着色,我们可以自定义,可以使用纹理着色,让纹理混合着色,使用RGB向量着色都可以。
深度测试
解决可见性或遮挡问题。即屏幕上的遮挡关系正确。
光照与基本着色模型
Blinn-Phong着色模型(Blinn-Phong Reflectance Model)有三个部分:镜面反射高光(Specular highlights)、漫反射部分(Diffuse reflection)、间接光照(Ambient lighting)。
漫反射Diffuse reflection
表示物体明暗程度。主要考察一个着色点接受的能量的多少。
漫反射中的公式:Shading-Ld = kd (I/r^2) max(0, n · l)。
第一个l是点光源的能量。到达球的表面的每个点的能量就是l/r^2。每个点接收的能量和法线n及指向光源的光源向量的夹角有关。照与法线n平行那就是全部接受cos θ=1,如果是垂直那么就没有接收的cos θ=0。kd是材质。
镜面反射(Specular)
解决如何看到高光。入射光线l与View的角平分线(也成为半程向量)是否与法线n接近。再按照能量接收思路得到公式Ls =ks(I/r2) max(0, n · h)p。
(严格来说向量v+l不是角平分线)。p是高光指数。
再加上一个全局光照项就可以得到着色模型-(Blinn-Phong Reflection Model)。
着色频率
着色模型考察的是单个着色点怎么着色。对所有着色点做着色操作就是着色频率。着色频率可分为对三角形着色(flat shading),对每个顶点着色(Gouraud shading),对每个像素着色(Phong shading)。
每种着色主要是法线的计算方式的不同。
三角形着色比较好算因为只要算它的法线n就可以了。顶点着色的法线计算在虚幻引擎或其他建模软件中一般顶点中是已经记录好了顶点的法线。一个简单实用的计算顶点法线的方法:平均加权。
加权平均求和,每个顶点都与若干个三角形相交那么我们就可以根据三角形的法线加权算出定点的法线。
求出了每个顶点的法线后怎么对像素着色。用到重心坐标做插值求每个像素的法向量。在纹理映射部分介绍重心坐标。
什么时候用三角形着色或像素着色。取决于模型精度。模型的三角面特别多多于像素的时候显然是三角面着色好。
Learn Opengl
opengl里的一些概念
更改OpenGL状态:设置选项,操作缓冲。最后,我们使用当前OpenGL上下文来渲染。
OpenGL常见的工作流:首先创建一个对象,然后用一个id保存它的引用。然后我们将对象绑定至上下文的目标位置,然后用这个绑定了的位置设置各种选项。最后不用的时候解绑。
所以opengl是一个巨大的状态机,从工作流可知里面包含了大量state,event,action,transition等概念。
GLFW
渲染需要一个Opengl的上下文和一个窗口。
GLFW库提供了渲染物体所需的最低限度的接口,允许用户创建OpenGL上下文、定义窗口参数以及处理用户输入。最终显示出一个简单的窗口来让我们使用。所以它是专门针对Opengl的c语言库。
GLAD
GLAD是用来管理OpenGL的函数指针的。在调用任何OpenGL的函数之前我们需要初始化GLAD。
在绘制一个简单的三角形之前我们要做哪些工作?
要有窗口来承担渲染工作,以及处理用户输入。
工作流:
引入头文件:
绘制之前要有窗口来承担显示工作。自然要调用glfw库的一些函数来设置窗口的一些参数。所以最开始引入glfw头文件。我们是用opengl来绘制的。glfw是针对opengl的库,是依赖于opengl头文件的。glad是包含了正确的opengl头文件的。所以最开始要引入glad头文件。
先引入包含正确opengl头文件的glad头文件。然后再引入设置窗口参数的glfw头文件。
初始化glfw:
初始化glfw。告诉glfw opengl的主版本和次版本号、使用什么模式。
创建窗口对象,设置上下文:
glfw的事情弄完之后就创建窗口对象。把窗口对象设置为当前线程上下文。
初始化glad:
调用opengl函数之前需要初始化管理opengl函数指针的glad。
注册回调函数:
回调函数里用户改变窗口大小时设置视口大小。
渲染循环:
渲染循环基本流:
检查某个按键是否被按下。
设置清空屏幕的颜色,清空屏幕颜色缓冲。
交换颜色缓冲。
检查触发事件。
通过后面的学习之后,我们发现有很多东西会在渲染循环里去更改。并且我们后面在设计Mesh类时,甚至把绘制Draw这一行为单独抽象出来。这时我们要重新整理渲染循环流:
新的渲染循环流:
检查某个按键是否被按下。
设置清空屏幕的颜色,清空屏幕颜色缓冲。
着色器启用。
按顺序先构造投影矩阵、再构造view矩阵,构造model矩阵。矩阵写入顶点着色器。
调用Draw函数绘制。
交换颜色缓冲。
检查触发事件。
Opengl的图形渲染管线
实时渲染管线(Real-Time Rendering Pipeline)的各阶段是并行执行的,每个阶段都取决于上一阶段的结果。大致分为四个主要阶段:应用程序阶段(Application),几何处理阶段(Geometry Processing),光栅化阶段(Rasterization)和像素处理阶段(Pixel Processing)。
不同阶段交给不同的小程序来完成不同阶段的小程序叫做着色器。
我们用库api把窗口设置好后。然后就是编程实现光栅化的过程。
光栅化第一阶段是MVSP变换,然后是把三角形打碎告诉每个三角形像素值是多少,即采样。
现代Opengl中至少定义顶点着色器和片段着色器。顶点着色器对应的是S变换,投影变换这一过程。片段着色器对应就是采样和着色过程。
图形渲染管线是要用到GPU的。着色器是运行在GPU上的程序。所以不可避免地要对GPU进行编程。
依据计算机工作原理,顶点数据从CPU到显卡内存较慢,GPU要使用顶点数据最好是直接从GPU内存里取用数据。顶点着色器会在GPU创建内存存储顶点数据。
所以在初始化glfw创建好窗口对象之后,渲染简单三角面片的工作流是:
着色器流程:
写GLSL代码
(初次学习时,GLSL代码将会以一个字符串形式放在同一源文件中)
创建着色器对象
把着色器源码附加到顶点着色器对象上
编译着色器
(检查着色器是否编译成功的日志)
片段着色器的创建和编译是非常雷同的。
着色器程序流程:
创建着色器程序
把两个着色器附加到着色器程序
链接着色器程序对象
链接检查日志流程:
检查对象是否编译成功
返回对象信息日志
两个着色器对象可以删除了
我们会在清空屏幕颜色缓冲之后渲染简单三角面片之前使用着色器程序
创建顶点缓冲对象来管理GPU中存储顶点数据的内存。在此之前准备好顶点数据。刚学习的时候我们把它以标准化设备坐标的形式直接写在main函数里。创建顶点缓冲之前。
VBO流程:
创建VBO对象
VBO绑定为数组缓冲类型
把顶点数据写入到缓冲内存里
链接顶点属性:
配置顶点属性指针告诉不同顶点属性怎么解析缓冲中的顶点数据,启用顶点属性数组中的哪一个属性。
每渲染一次物体就要绑定一次VBO进行顶点属性设置,如果下一次渲染的是另一个物体就要设置另一个物体的顶点属性。循环解决不了。这些操作下来代码还是比较多的。代码复用性差,所以就抽象出一层VAO,一开始将所有VBO绑定对应的VAO,在渲染循环中具体绘制之前,绑定对应VAO就行了,而无需重复写那些属性设置代码。
VAO流程:
创建VAO对象。
绑定VAO。
启用顶点着色器中某个位置的顶点属性数组对象
设置这个位置的顶点属性指针
在实际编程的时候,VAO流程里掺杂了VBO的流程,我们应该从VAO流程的功能来看,VAO是怎样一个流程。
后面我们可以知道使用多个VAO和VBO时。可以一次性创建多个VAO和VBO。整体来看VAO和VBO一起的流程是:
绑定VAO
给VBO绑定缓冲目标类型
把顶点数据写入顶点属性缓冲VBO中,注意:glBufferData()函数的第三个参数是被复制数据的指针。因为开始学习是是一个vertices数组,所以直接写了数组名。
启用某个位置的顶点属性数组VAO
设置顶点属性指针(第几个属性,属性是几分量的向量,属性数据类型,是否标准化,读下一个此属性的数据在顶点数组中需跨的步长,此属性的数据在顶点数组中一开始的偏移量)
(在后面我们会知道,顶点数组中有多个顶点属性,比如位置,法向量,纹理。我们可以启用一个属性,设置一个属性指针)。
即将进入循环渲染之前为了避免对顶点缓冲对象和顶点数组对象的误改,可以把这两个东西解绑。
然后就可以在清空屏幕颜色缓冲之后,交换颜色缓冲之前使用着色器程序,绑定VAO。绘制三角面片。
EBO相当于1把一个索引数据写到显卡内存里去。EBO流程与VBO类似
EBO流程:
创建EBO对象
给EBO绑定为元素数组缓冲类型
把索引数据写入元素数组缓冲内存里
工作流的最后:
所有缓冲对象和程序对象不再使用后应当删除
glGenBuffers(2, VBOs);//第一个参数不是ID而是数量。
从练习中我们知道,似乎一个着色器程序只能绑定一个顶点着色器和片段着色器。在渲染过程中中途我们没办法解绑原有的片段着色器,再绑定新的片段着色器。然后再次启用着色器程序。而且这也不符合逻辑,渲染是一个循环,是不断在渲染的。所以如果要使用多个片段着色器。就要使用多个着色器程序。也就是说要对两个不同位置的三角形渲染不同的颜色,就要使用不同顶点数据和不同顶点缓冲对象和顶点数组对象。顶点着色器可以一样,片段着色器是不同的。
着色器:
顶点着色器和片段着色器之间的通信:
顶点着色器中以out关键字,片段着色器中以in为关键字,声明GLSL同向量类型同名变量。
CPU和GPU的通信:
在着色器中用关键字uniform来声明向量类型变量
main函数中获得这个uniform类型变量的位置值
修改这个着色器里的uniform变量值前必须先启用着色器程序
用获得的位置值设置这个uniform变量
因为是把顶点数据写到数组缓冲里的。设置顶点属性指针也是告诉某个顶点着色器怎么解析顶点数组里的数据把这些数据分配给第几个顶点属性。所以从顶点数据里添加更多数据开始
更多顶点属性流程:
顶点数组中添加更多数据
顶点着色器中用关键字layout(location = X)和in关键字设置第X个属性位置值和变量类型
设置顶点属性指针告诉顶点着色器怎么解析数据才能把顶点数组里的数据给哪个顶点属性
启用顶点属性X
配置好后绘制就行了,渲染循环里无需改动了
作为一个工程,把各个功能模块化,一些模块有着自己的工作流。那么它们应该封装成类。参照之前着色器的工作流。在这个工作流里的输入我们只需提供GLSL的代码,而这个着色器流的最终就是我们可以使用一行代码启用着色器程序。那么把它抽象成一个着色器类。
着色器类头文件的实现流程:
着色器流对外接收的输入:
顶点着色器和片段着色器一般都需要定义的源码。从接收到源码输入便开始构造。
此项作为构造函数:
构造函数的输入参数是两着色器源码的文件名(在VS工程里便是如此)。
对外提供的服务:
公有成员函数:
着色器启用
提供uniform的名字以修改该uniform类型变量的值
在C++里使用文件流来对文件进行操作,这部分放到shader构造函数。
文件读流程:
声明文件流对象
设置掩码使文件操作异常时抛出异常
文件流对象打开文件
声明字符串流
文件流缓冲到字符串流
文件流对象关闭文件
字符串流转换为字符串
文件打开可能失败,从那里开始try。到转字符串之后为catch,异常输出日志。
字符串流转化成c字符数组类型
后面就是顶点着色器、片段着色器、着色器程序创建流程(着色器程序ID应为类的成员变量)
后面是对声明的函数进行实现
额外的:
着色器检查是否编译成功有重复代码我们把它封装成函数
它的执行由需要着色器的ID或着色器程序的ID。他的不同输出也由输入决定。由于我们在不同位置调用的检查函数。非常特殊,只需在不同位置输入要检查的着色器类型字符串即可。
纹理:
理论:
纹理有纹理管线
纹理映射流程:
投影映射(Projector And Mapping)、变换函数(Corresponder Function)、纹理采样(Texture Sampling)。
管线开始于空间坐标,如(−2.3,7.1,88.2)表示空间位置。对象空间坐标经过 project function (投影函数) 投影来到 parameter space (参数空间) ,在这个例子中该投影函数称为正交投影,得到坐标 (0.32,0.29),即参数空间坐标,一般被称为 texture coordinates (纹理坐标)。
该过程称为 texture mapping (纹理映射),注意区分 texture map (纹理贴图)。
纹理坐标经过 corresponder functions (响应函数) 来到 texture space (纹理空间), 在这个例子中贴图分辨率为 256×256,那么直接将当前坐标乘上分辨率即可得到坐标 ( 81.92,74.24),去掉小数部分得到坐标 (81,74),即 texel texture (纹素坐标)。
纹素坐标对应了纹理坐标在纹理空间中的位置,真正访问纹理贴图的纹素值 (纹理贴图上每个坐标对应的值,注意纹素值是离散的,去掉小数部分的原因后文会详细介绍)。
纹素值就是 sRGB color space 中的 color coordinates(0.9, 0.8, 0.7)。
1) 投影映射:将三维物体坐标转化为二维参数空间 uv 坐标,实时渲染中,uv 坐标通常是保存在顶点信息中
2) 变换函数:将 uv 坐标经过处理变换后,根据实际的纹理尺寸,转化为纹理空间坐标,此时也可能有小数
3) 纹理采样:依据纹理空间坐标,对纹理进行采样,要处理放大和缩小两个情况,其中缩小的情况更为复杂,牵涉到各向异性过滤的算法
4) 纹理转换:通过采样得到纹理值后,往往不能直接使用,还需要进行相应转换才能使用
纹理环绕方式:
Opengl提供的函数解决的是把3维坐标变为uv坐标过程中超出[0,1]的值进行处理。
纹理过滤:
解决的是实际场景中小纹理贴大模型和大纹理贴小模型的问题。解决方案是不同场景采用不同插值方式,Opengl提供了函数来设置不同插值方式。
多级渐远纹理:
解决的是同一个纹理要显示远近效果时,2D屏幕上底部表示近的像素覆盖纹理面积变化较小,顶部表示远的像素覆盖的纹理面积大,纹理信号变化通常会很大,解决方案是建立一系列不同尺寸的多级纹理,Opengl叫做多级渐远纹理。Opengl也提供了相应函数来完成这一工作。
纹理流程:
头文件:
Opengl工程里需先下载stb_image.h头文件。自己添加一个新的C++源文件,名字可命名为stb_image.cpp。
源文件中预处理指令处理stb_image.h
顶点数组、着色器:
在学习过程中,我们自己在顶点数组中添加了纹理坐标。
顶点着色器中使用layout(location=2) in等关键字添加第三个属性
使用out关键字添加vec2类型的接收纹理坐标的向量
片段着色器中使用in关键字声明和顶点着色器同类型同名的用于接受纹理坐标的向量。
使用uniform关键字声明一个samplar2D类型的变量来接收CPU传递过来的纹理数据
片段着色器最终使用texture()函数,以纹理和纹理坐标为参数采样得到的颜色作为片段着色器的输出
纹理对象:
创建纹理对象
对象绑定为2D纹理类型
纹理S轴T环绕方式设置
纹理缩小放大插值(过滤)方式设置
加载纹理图片。(加载纹理图片函数会获得纹理图片的长宽,颜色通道并把它们存储起来)。会存储在一个unsigned char*上。
生成纹理(生成在哪个目标上,纹理级别,存储格式,长宽,X,原图格式、类型数据,加载进来的图片数据)
创建多级渐远纹理
此时可以释放掉存储纹理数据的内存。尤其在加载下一张纹理之前
额外的:
我们用一个Int变量来创建一个纹理对象。创建时会给该Int变量赋值。那么意味着当我们使用glBindTexture()为某个纹理对象绑定纹理类型时其实它认的就是一个int值。这很奇妙,当然仅对于纹理对象来说,一个int就表示一个纹理对象。
顶点数组指针对数据解析的调节:
顶点数组中在顶点数据(vec3类型的向量),颜色数据(vec3类型的向量),又增添了纹理坐标(vec2类型向量)。
所以顶点数据即属性0的解析,步长需改为8*sizeof(float)。偏移为0。
颜色数据即属性1的解析,步长也需改为8*sizeof(float)。偏移为(void*)(3*sizeof(float))。
新增纹理坐标数据即属性2的解析,纹理数据每次为2个float,数组中下一个纹理坐标为8*sizeof(float),偏移为(void*)(6*sizeof(float))。
循环渲染中绑定VAO之前先绑定纹理。即可把纹理贴到三角面片中。
多个使用多个纹理:
片段着色器中,一个采样器采样一个纹理。在同一个面片上贴第二个纹理,在片段着色器中使用uniform关键字,再声明一个sampler类型的采样器。
片段着色器表现两个纹理混合的效果是使用mix()函数。
CPU中再次创建纹理对象
绑定为2D纹理缓冲类型
每个纹理都要单独为其:
设置S、T轴环绕方式
纹理放大、缩小采样方式
加载纹理图片
生成纹理
生成多级渐远纹理
为采样器分配纹理单元:
在CPU和片段着色器通信之前需先启用着色器
使用CPU和片段着色器通信的方式,glUniform1i()给片段着色器中的采样器分配纹理单元。(CPU这边一共有16个纹理单元可供使用)
激活和绑定纹理单元:
绑定VAO之前,也就是绘制之前分别激活纹理单元和绑定纹理单元
额外的:
stb_image库中,在加载所有纹理之前有设置纹理翻转的函数,stbi_set_flip_vertically_on_load(true)函数。true表示垂直翻转。
错误日志:
生成纹理时记得一些图片的颜色通道是GL_RGBA:
在使用生成纹理函数生成纹理时,一些图片有alpha通道。所以我们使用这个函数生成纹理时指定纹理的颜色存储格式为GL_RGBA,以及指定加载纹理时的颜色格式也要为GL_RGBA。否则会出现纹理贴上去花掉的情况。
我们知道纹理坐标的两个轴的值在[0.0, 1.0]之间。那么贴纹理的时候要实现水平翻转效果,在片段着色器中就不要把纹理坐标的x弄成负值。想要实现水平翻转,x轴用1-TexCoord.x就行。
GL_REPEAT对超出纹理坐标范围之外的纹理坐标如何设置纹理方式呢?
比如顶点坐标的右上角对应的纹理坐标为(2.0, 2.0)。那么原来贴上去的效果就是,原来的纹理坐标缩小0.5,在面片上会出现两个纹理图片。(2.0, 2.0)对应的是第二个纹理图的右上角。
GL_CLAMP_TO_EDGE对超出范围之外的纹理坐标如何设置纹理贴图方式呢?
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);// glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);//
这里我们分别对纹理的S和T轴都设置了超出坐标后的纹理环绕方式。得到如下效果:
需要注意的是,我们对笑脸纹理的S和T轴设置的超出范围的纹理坐标的处理方式是重复。所以笑脸是重复。而箱子由于左上和右上还有右下都超出了1(2),所以纹理被缩小了0.5(0.3)且分成了4(6)份贴上去。那么除了左下角那个纹理以外的纹理都是超出的坐标范围。看这个边缘拉伸效果,应该是只有S(水平轴)会有往S轴拉伸的效果,T(竖直轴)会有往T轴拉升的效果。
上下键控制箱子和笑脸的显示程度:
显然glfw的这个窗口捕捉按键函数会捕捉按键按下的时间,按得越久,捕捉函数运行得越多。这是为什么里面每次uniform的值的改变是0.001f了。
额外的:
纹理坐标的原点在左下角,y轴正方向朝上。x轴正方向朝右。
//屏幕坐标的原点在左上角,y轴正方向朝下,x轴正方向朝右。(这句话有待验证)
图片的y轴的z原点在左上角。y轴朝下,x轴向右。
变换
代数知识:
向量与标量运算:
向量可加、减、乘、除标量。向量每个分量分别对该标量进行运算即可。
标量加减乘除向量无意义。
向量乘法:
两向量点乘,两向量对应分量相乘,再全部相加。
两向量叉乘,向量a的两个分量与向量b的两个分量交叉相乘做差,为新的向量的一个分量。n维向量有n组交叉相乘做差结果。a向量最后一个分量与b向量第一个分量相乘,b向量最后一个分量与a向量第一个分量相乘形成交叉。
矩阵和图像索引:
矩阵索引行在前,列在后:(行,列)。
图像索引列在前,行在后:(列,行)。
矩阵:
矩阵和标量的运算:
矩阵可以加减乘除一个标量。矩阵的每个元素对该标量进行运算即可。
标量加减乘除矩阵无定义。
矩阵之间加减:
两矩阵对应位置元素相加减即可。
矩阵之间相乘:
矩阵A第n行和矩阵B第m列相对应,对应位置元素相乘,最后全部相加为新矩阵(n,m)位置的元素。这也是为什么矩阵A的列数必须要等于矩阵B的行数。
矩阵和向量相乘:
一个M×N的矩阵乘一个N×1的向量。那么就可以对这个向量进行变换。
注意:我们说的是矩阵乘向量而不是向量乘矩阵。矩阵不满足交换律。
新矩阵的行数取决于参与运算左矩阵A的行数,新矩阵列数取决于参与运算右矩阵的列数。
通过矩阵乘向量让向量缩放:
从单位矩阵出发,我们可以定义一个缩放矩阵来对一个向量进行缩放。
单位矩阵是一个左斜对角线全是1的行列相同的N×N矩阵。
在opegl里我们用到的矩阵一般是4×4或3×3矩阵就行了。其中多出来的一维是构建了齐次坐标。因为我们的坐标一般是3维或2维的。把它们看成3×1和2×1的矩阵。我们的左矩阵就是3×3或2×2的,加上一维就是4×4或3×3的。
我们构建缩放矩阵可以像下面这样去构建:
可以发现想要改某个轴的缩放程度只需要改变单位矩阵相应行数上的1即可。为了方便起见我们可以把三个方向的缩放量用向量表示为。
通过矩阵让向量位移:
通过单位矩阵和矩阵乘3×1的向量运算,我们很容易构造出可以使一个3维向量发生位移的4×4矩阵。
为了方便助记,同样可以把3个方向的位移量用向量表示为。
通过矩阵让向量旋转:
怎么推导矩阵的构造呢?先从向量在二维平面的旋转出发。
参考链接:二维(三维)坐标系中旋转矩阵详细推导_二维旋转矩阵-CSDN博客
点P逆时针旋转到P'。已知量是r,θ,α。容易得到如下式子:
由两角和公式:
对上式进行展开得:
我们要构造旋转矩阵,显然要消掉r。把最开始的式子代进来。得:
容易构造2×2的逆时针旋转矩阵为:
。
在二维平面上的逆时针旋转,即为绕z轴旋转,把矩阵扩展到3维,绕z轴旋转z轴不变,所以表示z轴的第三行那里仍为1。且加上齐次坐标可得:
绕z轴旋转:
同理地,绕哪根轴旋转,哪根轴的分量不变,矩阵里该行为1。把其它轴当作正交的x轴和y轴,可以套用同样的旋转量。来构造旋转矩阵。
绕x轴旋转:
绕y轴旋转:
稍微有点不一样是绕y轴的旋转,显然表示x轴的行和表示z轴的行乘了-1。我觉得这是绕的那根轴的朝向导致的。这里我们额外记一下,绕z轴和绕x轴的矩阵是按推导来的,
绕y轴旋转的矩阵要乘以-1。
矩阵组合:
正如之前所学的,我们要把所有变化写到一个矩阵中。表示成线性变换。根据上面的学习,我们知道缩放量和位移量在一个变换矩阵中的位置了。
那么如何把这些写在同一个矩阵里呢,两个矩阵相乘就可以解决问题。以下是一个例子:
在构造一个综合的变换矩阵时,我们把位移矩阵写左边,缩放矩阵写右边。但当我们用不同功能的矩阵乘一个向量来实现缩放和位移时。实际运算要先用缩放矩阵乘向量,再用位移矩阵乘。如果反过来,位移也会被缩放掉。
非要把多个矩阵乘起来最后再乘以向量,在应用时应该是最右边的矩阵与向量相乘,所以你应该从右向左读这个乘法。
所以实际的运算应该是先进行缩放操作,然后是旋转,最后才是位移。旋转也是同理,旋转是乘以小于等于1三角函数,是变小了再进行缩放这个会与预期不符。
矩阵和矩阵相乘、矩阵和向量相乘在代码里的应用:
变换矩阵创建:
正如之前所学的,平移矩阵和缩放矩阵都始于单位矩阵。
声明创建mat4类型的矩阵变量,(默认为0矩阵)用mat4(1.0f)初始化为单位矩阵。
将其变为平移矩阵:
使用translate()函数,正如所学的那样,提供一个vec3平移向量即可,这里vec3的每一个分量表示该方向上的位移。当然还要提供一个单位矩阵。即可把这个单位矩阵变成平移矩阵。
将其变为旋转矩阵:
用rotate()函数,正如所学的,提供一个弧度,和轴。这里的轴也是用向量来表示,只不过哪个分量有值表示绕哪个根轴旋转。
将其变为缩放矩阵:
使用scale函数,参数和平移矩阵同理。
CPU与GPU顶点着色器通信:
把矩阵送入GPU中的顶点着色器:
顶点着色器中使用uniform关键字声明mat4类型的变量
CPU中按变量名得到顶点着色器中该uniform类型变量的位置
使用glUniformMatrix4fv()函数实际把矩阵传入,传入时可以设置矩阵是否转置,并且还要改变矩阵数据存储方式给Opengl。GLM的矩阵存储方式和Opengl并不一样,就是把这个矩阵弄成指针形式。
额外的:
如果我们在循环渲染中希望每一帧提供给构造旋转矩阵的函数的弧度值都是不同的,根据一个变换矩阵的创建流程,我们必须在每次循环时都从一个单位矩阵开始。如果这个矩阵要被改变,它就不能被复用。
当我们要把箱子做多种变换时,一定要考虑实际的矩阵和向量的计算顺序,代码中的顺序正好与这反过来。由实际练习结果可知,如果计算时是先把位移矩阵和向量相乘,再把旋转矩阵和向量相乘。那么位移的量也要参与旋转。其实由旋转矩阵的推导过程也可以知道。
你用平移过的坐标再去做旋转。相当于向量的模r发生了变化。那么其实还是在绕着屏幕中心转。
先旋转再平移,至少模r没变,再移动,相当于把中心绕轴也移动了。那么就可以在右下角旋转。
变换和坐标系统
变换过程:
在我们之前学过变换之后,我们知道模型从3D到二维屏幕上会经过模型变换,视角变换,投影变换,视口变换。最终到2D屏幕。而这些变换都是对坐标进行变换。这些坐标的每一次变换之后要在不同坐标系下才有它的意义。
还是以三维空间中摄像机看到的物体投射到2D平面,如何拍好一张照片为例。
一开始始于模型坐标。模型有个中心位置。模型身上的各个坐标都是相对于它的中心的。
1.给物体找个好位置。在世界中找个好位置。那么这是在世界坐标系里去找的。用模型矩阵转换到世界坐标。这一步是模型变换。
2.给相机找个好位置。也就是物体相对于相机的位置。这时物体是在相机的视角里的。用观察矩阵转换到观察空间,这一步是视角变换。
3.然后是投影变换,就是转换到单位立方体里来,即标准化设备坐标。此时的空间是裁剪空间。用投影矩阵转换到裁剪空间。这一步是投影变换。
这一过程我们可以这样来表示:
当然,矩阵乘向量是从右往左算的。
4.Opengl对裁剪坐标执行透视除法,变为标准化设备坐标。再是Opengl使用glViewPort内部参数将标准化设备坐标映射到屏幕坐标。这一步是视口变换。
坐标系:
左手坐标系和右手坐标系主要是z轴的朝向不同。
世界空间、观察空间、裁剪空间都是右手坐标系。
标准坐标空间使用的是左手坐标系。
模型空间不一定。
右手坐标系:右手大拇指朝右x轴,食指朝上y轴,中指指向自己z轴。
左手坐标系:左手大拇指朝右x轴,食指朝上y轴,中指远离自己,z轴。
Opengl中的变换
学了这些理论之后,应用这些理论我们使用这3个变换可以把一个二维面片的东西呈现三维的效果。当然后面我们渲染3Dmo模型时没这么麻烦。依然按照拍照的思路来。
坐标空间的变换都是由矩阵来完成。
1.模型变换矩阵把模型放到世界空间
模型变换依然包含缩放,旋转、位移。所以我们声明mat4矩阵,这个矩阵专门用来调整模型。用scale(),rotare(),translate()函数所做的一系列操作后的变换矩阵都是模型变换矩阵。首先让那个二维面片在右手坐标系中绕x轴旋转,让它放平一点。
2.视角变换调整相机位置
摄像机一开始在世界坐标中心。我们想让相机离模型远一点,也就是朝屏幕外我们的方向(右手坐标系z轴正方向)近一点。由于相机不存在等同于让模型坐标远离相机,朝右手坐标系z轴负方向移动。这里声明一个mat4矩阵专门用来调整模型做视角变换。这里可以对它使用translate()函数,定义z分量朝负方向移动3,使物体往z轴负方向(屏幕里)移动3,即表示相机朝屏幕外移动3。来作为视角变换矩阵。
3.采用哪种投影来观察做投影变换
投影变换也是用一个矩阵来完成。但是我们在定义这个投影矩阵时更像是在定义一个平截头体。通过提供视角的角度,视口的宽高比,近平面和原平面的距离等参数来定义一个平截头体。近平面和远平面的距离按数值来看应该是在世界坐标系中离中心也就是相机的距离。透视投影平截头体使用perspective()函数来定义透视投影矩阵。虽然我们更像是定义一个平截头体。
显然所有的变换矩阵都是在顶点着色器中和顶点向量做运算的。在顶点着色器中定义声明这3个矩阵。
CPU中把这3个矩阵送入顶点着色器中。
把2D图片呈现3D效果流程:
定义模型变换矩阵。
定义视角变换矩阵。
定义投影矩阵,提供的参数通常像是在定义一个平截头体。
顶点着色器声明这3个矩阵。
CPU把这3个矩阵送入。
Opengl使用了36个顶点来画出一个立方体。
z缓冲和深度测试
关于z缓冲和深度测试在高级Opengl里应该会提及更多。
z缓冲里存储了每个片段的z值。在输出一个片段的颜色时。该片段会和z缓冲里的进行比较。当前片段z值大于z缓冲里的z值,那么就可以覆盖在上面。这一过程叫深度测试。
我们唯一可以决定的是这一过程是否开启。这个开启状态是一个全局状态,在glfw对窗口设置好之后即可用glEnable(GL_DEPETH_TEST)进行设置。
深度缓冲
在渲染过程中上一帧模型的z可能会发生变化。那么在渲染下一帧之前需要把这一帧的帧缓冲清除掉。使用glClear()在颜色缓冲位后或上深度缓冲GL_DEPETH_BUFFER_BIT。
渲染循环中用同一个顶点数组绘制多个立方体
渲染循环中多次调用glDrawArrays()就可以绘制多个立方体(此处以绘制立方体为例)。
本质上用的是同一套顶点数据。由于要在不同位置处绘制它们。所以需要定义一个vec3类型的数组。里面存储不同的位移向量。
在渲染循环里写个for循环,在这个循环里每次循环用数组中不同的位移向量来构造位移矩阵即模型变换矩阵。然后写入着色器。
然后调用glDrawArrays()绘制,即可在每次渲染循环绘制出在不同位置的立方体。
如果想加入旋转效果,再对模型变换矩阵使用rotate()函数构造旋转矩阵即可。需要注意的是实际计算顺序和构造矩阵顺序是相反的。
摄像机和观察空间
在观察空间里描述一个物体时,这个物体的顶点的坐标显然是相对于摄像机位置与方向而言的。但是在世界空间里它的坐标是一个数值,在观察空间里它又得是另一个数值。那么我们该如何定义我们的观察空间呢?我们将需要3根正交的单位向量来表示相机空间。
显然我们是在世界空间里找我们的相机,一开始我们的相机是有位置的和朝向的。这里我们定义它们就相当于找到它们好了。
摄像机位置:
我们可以定义一个vec3类型的向量camerPos表示摄像机的位置,比如vec3(0.0f, 0.0f, 3.0f)。注意:这里和我们之前让一个二维面片绕x轴旋转定义view矩阵时不一样。这里单纯的只是在世界坐标中定义摄像机位置。
摄像机朝向方向:
我们需要定义一个位置表示摄像机看向的位置。我们定义为(0.0, 0.0, 0.0)。为了让摄像机空间也符合右手坐标系。我们把摄像机位置减去看向位置定义为摄像机朝向方向,即实际看向的反方向。这里是和z轴方向相同的。对它标准化(单位化)可得到朝向的单位向量(编程中可使用glm的normalize()函数对向量单位化)。
相机的右轴:
我们先定义朝向y正方向的单位向量(0.0, 1.0, 0.0)为世界上向量。上向量和相机朝向向量做叉乘得单位的右向量(编程中可使用glm的corss()函数做叉乘)。
相机上轴:
相机上周由相机方向向量和右向量做叉乘运算同理可得上向量。
有了这3个轴向量。我们便可以定义摄像机空间。同时我们来正真地构造一个视角变换的矩阵。是的世界空间的坐标转移到观察空间来。
Look At矩阵
我们会按照这种方式构造Look At矩阵。
显然Look At矩阵是由一个以右向量,上向量,相机方向向量为分量的有齐次坐标的矩阵和一个以相机位置坐标为位移量的矩阵相乘得到的。
我们又知道,由相机的方向向量构造出右向量和上向量在理想情况下它们都是和x,y,z轴重合的方向向量。左边那个矩阵是一个单位矩阵。在最简单的情况下Look At矩阵就是一个位移矩阵。
我们现在知道了Look At矩阵是如何构造的,以及它实际长什么样。
现在我们要看在代码中怎么实现它。
相机绕模型转(让模型自己绕y轴转起来):
正如之前提到的,从最开始的相机位置(vec3向量),相机朝向位置(vec3向量),上向量。就可以构造出一个Look At矩阵。在代码里是Look At()函数,并按顺序依次提供这些3分量向量。最后赋值给一个未定义的view矩阵。
所以我们在构造Look At矩阵为view矩阵时,使用三角函数改变相机位置向量cameraPos的x和z分量,可以使得模型在空间中绕y轴旋转。等价于相机绕模型旋转。之后我们我们用WASD来改cameraPos向量时,好像真的在改一个存在的相机的位置一样。
通过WASD键来移动相机位置,设定相机始终朝自己位置的前方看:
可以先设置z负方向的单位向量vec3 camerFront(0.0, 0.0, -1.0)表示向前,camerUp向量是已经设置好了,cameraFront叉乘cameraUp可以得到相机右向量。
在processInput函数里,通过glfwGetKey函数检测到相应按键之后。W向前和S向后,camerPos加等于或减等于相机向前单位向量。A向左和D向右,camerPos加等于或减等于相机右向量。此时camerPos是一个全局的变量。通过设置一个speed来乘以这些方向移动的单位向量,可以设置移动的速度。
让相机始终朝前看。移动过程中相机位置会发生变化,然后再往前看一点,那么相机看向的位置就是cameraPos+camerFront。
移动速度平衡 :
有的电脑每秒绘制帧数更多,循环渲染调用快,processInput调用多,位置响应就多,移动就会更快。如果帧数低,调用渲染调用慢,processInput调用少。移动速度就会慢。我们主要平衡调用慢,导致移动速度慢的问题。
在每次渲染循环中,在当前帧获取时间。用这个时间减去上一帧获取的时间。得到一个deltaTime。当前帧已成为过去式,把当前帧获取的时间给存储上一帧获取的时间。我们把这个delatTime乘个2.5。给cameraSpeed,这样来让deltaTime过大时得到一个较大得速度。
视角移动
在右手坐标系中,我们定义向上,向下看为俯仰角(Pitch),向左向右看为偏航角(yaw),视角盯住前方翻滚镜头为翻滚角(roll)。改变视角就是改变相机看的位置。即改变cameraFront。
我们只考虑一个FPS式的视角移动。所以我们只考虑俯仰角和偏航角。
当camerFront偏航角为0时,俯仰角Pitch在x轴或z轴上以及y轴上的投影为(注意:因为摄像机看的方向是单位向量,即长度是1,所以以下所有的投影计算前面都有个1。只是省略了):
dirction.x = cos(pitch),dirction.z = cos(pitch),dirction.y = sin(yaw)。
当俯仰角为0时,偏航角yaw在x轴或z上的投影为:
directin.x = cos(yaw),direction.z = sin(yaw)。
而如果我们看这个图的话,其实最终:
direction.x = cos(pitch) * cos(yaw)
direction.y = sin(pitch)
direction.z = cos(pitch)*sin(yaw)。
换句话来说,我们知道pitch和yaw的值,我们就可以用这3个三角函数式去构造direction向量的3个分量从而构造direction向量。
在二维平面移动鼠标时我们会改变pitch和yaw。我们如何把鼠标的移动转化成pitch和yaw呢?其实我们直接把鼠标上一帧和这一帧水平的偏移量定义为yaw的改变量,上一帧和这一帧竖直的偏移量定义为pitch的改变量。
编程监听鼠标和隐藏光标:
我们用glfwSetInputMode()函数捕捉并隐藏光标。
监听鼠标移动事件这个函数需要我们自己来写,因为我们在监听鼠标移动时还有其它要做的事。这个回调函数每次应该传入当前帧捕捉到的鼠标的位置,还要告诉函数是哪个窗口。即void mouse_callback(GLFWwindow* window, double xpos, double ypos)。
然后我们用glfwGetCursorPosCallback()来注册它。glfwGetCursorPosCallbcak()的第二个参数只需填写我们自己写的鼠标回调函数的名字就可以了。它的两个位置会由GLFW获取并传进去。
这个回调(监听)函数要做什么:
无非是这一帧的水平坐标减去上一帧的水平座标为水平方向的偏移量。
这一帧的竖直坐标减去上一帧的竖直坐标为竖直方向的偏移量。(竖直偏移量要用上一帧减去这一帧的y坐标,这里想不太清)。
初始的上一帧的坐标设定为屏幕的中间。
当前水平和竖直的坐标称为过去式,给上一帧的水平和竖直坐标。
偏移量乘以一个设定的灵敏度(此处灵敏度为一个缩小的值)。
然后就是yaw加等于水平方向的偏移量。
pitch加等于竖直方向的偏移量。
对yaw和pitch进行约束,超出89.0f约束到89.0f。
有了pitch和yaw之后,我们就可以构造一个front向量,最后把它标准化后给cameraFront。
缩放
缩放其实是更改定义平截头体的视角,我们设定的是45.0f。
缩放的大体流程和监听鼠标流程是一样的。用glfwSetScrollCallback()函数来注册鼠标滚轮回调函数。
自己实现鼠标滚轮回调函数void scroll_callbcak()。scroll_callback()函数的后两个参数是xoffset和yoffset。在滚动鼠标滚轮时,glfwSetScrollCallback()函数会自动填充进去。
然后在这个函数里我们用fov减等于yoffset就可以了。因为yoffset一开始时就是最大的45。
然后约束的最小是1,最大是45。
实现一个相机类Camera:
根据之前所学内容,定义一个LookAt矩阵是从相机位置、上向量开始的,其实还有相机的pitch和yaw角。这些是相机的主要属性。所以它们可以作为构造函数的参数。而像相机的移动速度,鼠标灵敏性,缩放程度这些附加的属性可以在调用构造函数时用参数列表初始化。相机的很多属性都需要在外面修改,这些属性应该属于public类型的。
public的成员应该包括:
位置向量Position
上向量Up
前向量Front
右向量Right
世界上向量WorldUp
俯仰角pitch
偏航角yaw
移动速度Speed
灵敏度Sensitivity
缩放程度Zoom
Camer类构造函数的设计:
依据之前所讲的,相机位置向量,上向量,初始的pitch和yaw为主要属性作为构造函数的参数。其他属性用初始化列表初始化。同时相机位置向量,上向量,pitch和yaw一般都有个固定值,位置向量和上向量可以在构造函数里写默认参数。pitch和yaw可以在构造时的默认值可以是头文件里写好的常量值。
需要注意的是这个构造函数的上向量,在调用构造函数的参数里应该是一个默认参数。这里提供的其实是世界上向量,相机上向量要最后算。
更为严格构造函数:
还需要一个可以自定义相机位置、上向量、pitch、yaw的相机构造函数,这里不再使用默认参数。此时自定义的相机位置和上向量在构造时最好提供它们的坐标。严格来说这个提供的上向量不是相机的上向量,而是世界上向量。相机上向量应该是由右向量叉前向量得到。
相机类里的相机位置向量和上向量在理想情况下用带有默认参数的构造函数可以初始化。世界向量是没有默认参数的构造函数里初始化。像Front前向量,右向量、上向量,需要多次计算得到的就不适合放到构造函数里。Front向量会随着pitch和yaw的变化更改。Front向量的变化会影响到右向量和上向量。函数具有专门化,ProcessMouseMovement函数是专门用来改变pitch和yaw的。Front、右向量、上向量的更新我们可以专门做一个updataVector()函数来做。
updataVector()函数:
不像Position向量在ProcessKeyboard函数中通过加减Front和Right向量直接更新。Front、右向量、上向量在调用构造函数时要初始化,鼠标移动Front向量要跟新,会导致上向量,右向量更新。这些都可以交个updataVector函数来做。这个函数是专门Camera类自己调用的,它应该是private成员。
由于按WASD改变的还是和相机有关的量,即相机里的成员的量。所以我们为相机类做一个成员函数ProcessKeyboard()。以此来修改成员变量。
ProcessKeyboard函数设计:
在main函数里检测键盘是通过processInput()来检测的,它有办法知道是按哪个键。那么怎么告诉ProcessKeyboard()函数按的是哪个键呢?由于main源文件是包含了Camera类头文件的。我们可以把ProcessKeyboard()函数的第一个参数设计为传递一个枚举量,这个枚举量就是Camera头文件中写好的4个方向的枚举量。比如按倒W键,调用ProcessKeyboard函数是,就把W键的枚举量填进去就行了。
还有就是平衡移速度的deltaTime,在main函数里算好传过去。
同样的还有鼠标移动改变的是相机的pitch和yaw,也同样为其设计一个ProcessMouseMovement()函数。
ProcessMouseMovement函数设计:
偏移量不属于相机的属性,完全在main里算好就行,提供给ProcessMouseMoveMent()去更改相机yaw和pitch。
我们再为这个函数提供一个是否限制视角的bool值,以在一些特殊场合下我们可以不限制视角。
ProcessMouseScroll函数设计:
同样的还应该为相机类设计一个更改它缩放程度的函数,我们在注册scroll_callback函数时,glfw会自动填充yoffset,我们实际使用的就是这个yoffset。所以ProcessMouseScroll也只需一个参数即可。
相机类还应该可以返回View矩阵,写道着色器去,应该设计一个GetViewMatrix()函数。
额外的:
含有默认参数的函数,调用该函数时,可以不填写默认参数位置的参数,调用时,该参数会被默认值初始化,如果填写了,用填写值初始化。
使用Assimp库加载模型
在走进3d视角并尝试对一个3d的物体进行控制之后,我们应该尝试真正地加载一个由艺术家创作的模型。
一路走来我们接触了顶点数据,纹理坐标。纹理等一系列渲染出一个漂亮的图形所学的数据。那么一个复杂的3D模型是否包含这些数据呢?答案是肯定。不仅如此,一些模型可能还会包括像光照、多种材质、动画数据、摄像机、完整的场景等其它信息。
作为开发人员,我们当然不像使用一个应用程序一样,点点按钮就以加载一个模型。我们不仅要从开发的层面把模型加载进来,还要拿到这些模型的基本数据,并可以更改它们。
所以在开发时我们使用外部库来加载模型。我们使用Assimp库来加载模型,并用它提供的接口来把模型的基本数据,像Vertices(包含顶点坐标,法线向量,纹理坐标)。Textrue(包含纹理id和类型)。
我们并非通过Assimp库直接把模型的这些数据提取出来。而是Assimp会用一个盒子把这些数据分门别类地自己先装起来。然后我们再去拿。或者说,Assimp定义了一个模型的原始数据的结构或者组织层级。
此外还需知道,一个模型通常由几个子模型组成。比如一个持枪的人物模型,那么人和枪就是分开的建模。但是它们整体作为一个模型。Assimp把一个整体的模型定义为scene(场景),子模型定义为mesh(网)。到了mesh(网)就有它们自己的顶点位置,法向量,纹理坐标和材质了。尤其是不同模型,他们会有自己的材质。mesh网下面就是面(面片),面片里包含了一个面片顶点的索引。
我们用Assimp把模型加载进来之后,虽然Assimp把这些数据分门别类地装了起来,当我们面对这些数据时,看起来更像是一个数据包一样的东西。对于这对数据,我们至少需要一个入口。我们大概可以看到,这里面有一定的层级关系,这些数据可能类似于一个树的结构,一个scene里面有很多的mesh。如果直接以scene作为这个数据结构的入口根结点,那么显然是不合适的。
所以Assimp还是提供了一个含有子节点和父节点结点的多叉树结构以及表结构。
aiNode{
int mNumMeshes;
int mNumChildren;
int mMeshes[X];//Mesh索引
aiNode mChildren[X];
}* node;
总的来说场景scene中有3大类数据,节点型数据。实际的mesh类数据,实际的材质类数据。
以scene下的mRootNode为根结点aiNode类型数据。包括根节点,每个节点里都有一个数组存储scene下的mesh数组的索引。由这些索引来访问scene里mesh数组中的mesh。节点里还有节点数组。
而mesh里则有场景scene中材质material数组的索引。通过该索引来访问场景中材质material的数组。
mesh(子模型/网)对象的设计:
根据之前所学,一个模型有许多顶点坐标,法线坐标,纹理坐标。现在把这些统称为顶点属性,还有许多纹理属性,我们要声明绑定VAO,VBO。把这些属性写入顶点着色器。然后是绘制这个子模型mesh。既然Assimp把一个子模型定义为mesh(网),我们也应该写一个mesh类,来拿取这些数据。
作为一个子模型,它包含多个顶点,一个顶点包含顶点坐标,法线向量,纹理坐标等属性。这些可以放在结构体中。struct Vertex{ glm::vec3 Postion; glm:: vec3 Normal; glm::vec2 TexCoords; }。
纹理属性包含纹理id和纹理类型这些数据可以放在struct Texture{ unsigned int id; string type; }
此外为了避免重复绘制顶点,每个mesh(网)里面还存有索引数组。Assimp是把索引数组,放到mesh的成员面(face)对象里的,即索引数组是face的成员。但是对于我们来说,我们无需抽象出face层,我们只需拿到这个mesh的所有索引即可。这里可以直接定义mesh的索引成员vector<unsigned int> indices。
同理的,一个mesh包含多个顶点属性,纹理属性。用vector来定义这两种属性的容器。vector<Vertex> vertices,vector<texture> textures。
有了数据成员后便可以构造mesh对象。mesh的构造函数的参数就是以上我们定义的三种数据类型的容器。我们此处在设计接收Assimp数据的盒子,至于这三个参数是怎么从Assimp中挖出来的,我们会循序渐进地稍后提及。构造时直接把参数的3个容器分别给mesh对象里的成员就可以了。
接下来就是把这个mesh的这些属性写入GPU,正如我们之前所做的那样创建各种缓冲对象,绑定它们,把顶点写入缓冲。启用着色器里的哪个顶点属性。设置顶点属性指针,。这些工作我们把它叫做设置mesh即void setupMesh()。这个是由专门由mesh自己调用的,所以这个函数应当是私有类型函数。
此外每个mesh应该有自己的顶点数组对象和顶点缓冲对象还有索引缓冲对象。所以可以在mesh的私有成员中设置VAO,VBO,EBO。
设置(初始化)mesh函数setupMesh()的设计:
还是按照我们之前提到过的流程,创建顶点数组对象VAO,创建顶点缓冲对象VBO,创建索引对象EBO。绑定VAO,绑定VBO。
把顶点数组写入顶点缓冲中。由于现在每个mesh的顶点中不再是仅有位置坐标,而且是保存在vector容器里。在计算发送数据的大小时,就得计算容器最小单位大小乘以容器的容量。即vertices.size() * sizeof(Vertex)。然后glBufferData()函数的第三个参数是被发送给数据的指针,此处应为&vertices[0]。
先用glEnableVertexArray()函数启用哪个顶点属性。
然后就是设置顶点属性指针,设置哪个顶点属性。glVertexAttribPointer()函数第一个参数是指定第X个顶点属性,第二个参数是该顶点属性是多大,第三个参数是这个属性的数据类型,第四个参数是是否标准化,第五个参数是解析到下一个属性时的步长,第五个是该属性在在顶点数据中的偏移量,现在我们使用offsetof()来获取偏移量。
记得去顶点着色器中按顺序去添加这3个属性。
然后就是为mesh类写一个Draw函数。
在设计Draw函数之前,我们可以想以下,我们可不可以单独做一个Draw的函数,在main的渲染循环里调用这个Draw函数,然后去循环便Model类中的每个mesh。然后再把Draw的流程走一遍。又或者不写Draw函数,直接在渲染循环里把Draw的流程走一遍。但是这样有一个问题,如果有两个模型Model对象呢,一个渲染循环里就需要写两个Draw的流程。代码复用性查,所以我们还是希望每个Mesh有自己的Draw函数,再给Model类也设置一个Draw函数。Model类调用它的Draw函数时会遍历它的所有mesh,然后每个mesh再调用自己的draw函数。等价于渲染循环中多次调用glDrawElements()来绘制出多个子模型。
Draw函数的设计:
在设计Draw函数之前,我们需要提前知道从Assimp定义的材质对象中取纹理的过程,以及把取出来的纹理保存到mesh的texture类型的容器的过程。我们在使用mMaterials()函数来加载纹理时函数的第二个参数都是Assimp的数据类型aiTexturesType_DIFFUSE和aiTexturesType_SPECULAR。说明Assimp也希望我们从Scene下的material数组拿纹理时也按类别来拿。但是我们给每个mesh只设计一个存储texture对象的容器。即我们把不同种纹理对象存储在同一个容器里。正如LearnOpengl作者所提到的,我们并不知道我们拿过来的这个mesh里有多少种纹理,以及每种纹理有几个。这就导致片段着色器中会声明同种类型后缀不同的采样器。比如:
uniform sampler2D texture_diffuse1;
uniform sampler2D texture_diffuse2;
……
uniform sampler2D texture_specular1;
……
由于在给采样器分配纹理位置时是要提供采样器名称的,所以我们在遍历一个mesh的纹理数组时需要知道它是什么纹理类型,即拿到它的string成员name。我们还要知道这种纹理是第几次出现,所以对于不同的纹理我们需要不同的计数变量。最后在用glGetUniformLocation()函数找采样器位置时,把name和计数器拼接起来就是后缀不同的采样的名称。当然int的计数器得转化为string型的。
有Mesh时Draw函数流:
遍历mesh纹理数组为每个纹理分配纹理位置,绑定纹理类型
绑定VAO
绘制
解绑VAO
所以总结下来就是Mesh类里公有数据成员有:
vector<Vertex> vertices
vector<unsigned int> indices
vector<Texture> textures
unsigned int VAO
公有成员函数有:
构造函数
void Draw()
私有数据成员有:
unsigned int VBO, EBO
私有函数成员:
void setupMesh()
我们设计完我们认为的模型的基本单位mesh后,我们应该抽象出一个Model类,mesh类型的容器作为它的成员,表示一个模型是由一个个mesh组成的。我们对Model这个类大体的定位为,它能加载模型,并且把Assimp里的数据写到它自己的mesh容器里。要绘制这些mesh的时候,调用它自己的一个Draw函数。也就是说它对外表现的行为为能加载一个模型,并且它提供一个绘制函数,可以让这些mesh全部绘制起来。至于怎么从Assimp中拿到各种数据,则是一个自动化的过程,并不是直接提供给外界的服务,没有直接和外界联系。
经过以上分析我们大体可以知道一个Model类对外提供的服务为公有成员,为两个函数分别为:
loadModel(char* )用来根据文件路径加载模型。这个函数应该为私有类型,见下面解释
Draw()函数。用来绘制mesh
Model类的构造函数
私有成员有:
数据成员:
vector<Mesh> meshes
理论上来说我们拿到mesh数据后对外界来说是不需要去更改的,所以vector<Mesh>的容器应当被设计为私有类型。
函数成员:
依据Assimp数据结构而设计的:
processNode()函数,用于遍历Scene下的所有Node。函数参数在详细设计时再提及。
processMesh()函数,在某一Node里真正拿取mesh数据并返回一个mesh的函数
loadMaterialTextures()函数,在某一mesh里真正拿取到一个texture数组,并返回一个texture数组。这个数组可以是vector类型的。
loadModel函数需设计为私有成员的原因:
我们需要一个什么参数来构造一个Model对象呢?显然,一个模型文件路径。我们可以直接在构造函数里调用loadModel函数,loadModel函数的参数是string型,用来接收从构造函数参数来的文件路径。对于同一个模型,我们不希望它随意地在程序的任何位置调用它的loadModel函数。从功能上来讲,整个程序在运行过程中loadModel只调用一次。所以最好把它设计为私有类型的。
补充一条Model类的公有数据成员:
vector<Texture> textures_loaded。用来存储已经加载过的模型对象。专门在生成模型之前,和访问到的纹理源的路径作比较。
Draw函数比较简单,我们先把它设计掉。
Draw()函数设计:
无需返回任何东西,返回类型void。
拿到自己mesh容器的size,遍历容器每个元素,调用每个元素的Draw函数即可。
然后按照Model类处理模型数据的顺序来设计函数。
loadModel函数的设计:
loadModel作为接收模型文件的一个接口,它不需要返回任何东西,模型载入错误的报告我们可以在函数里设计。
声明Assimp域下的Importer类型变量
声明aiScene指针型数据scene,即模型所有数据的入口。
import的成原函数ReadFile()函数将会返回一个aiScene,给scene赋值。显然真正读文件的函数是import的这个ReadFile函数,第一个参数是文件路径,第二个参数可以用“|”来并列Assimp提供的枚举值来对模型进行一些设置,我们此处填aiProcess_Triangulate | aiProcess_FilpUVs表示把模型所有图元转变为三角形,以及翻转所有纹理的y轴坐标。
然后是模型加载是否失败的检查:
通过!scene或!scene->mRootNode或scene->mFlag & AI_SCENE_FLAGS_INCOMPLETE来检查是否出错,出错可输出错误信息。
我们通过string类型的文件路径调用string的成员函数substr()来截取模型文件所在目录,这个目录会在后面加载纹理时和纹理名拼接得到纹理完整的路径来加载纹理。
有了scene之后,把scene下的mRootNode作为参数开始遍历Node,调用processNode()。还要把scene传进去,因为很多时候Node里都是索引,要真正访问还是要靠scene来访问。
processNode的设计:
先看看当下Node里有多少mesh,再写个循环,逐个遍历当下Node里的mesh索引数组,用索引数组里的索引通过scene访问正真的mesh。
当然访问到的mesh还是aiMesh*型的,声明一个aiMesh* mesh来接住这个mesh。到目前为止已经把Assimp里的aiMesh挖出来了。但是还要把它变成我们定义的mesh。这个时候就调用processMesh来处理aiMesh。
processMesh的设计:
Model类里是一个mesh的vector容器,所以processMesh的返回结果应当是一个mesh。
它的参数是aiMesh*类型的mesh和aiScene的scene。因为scene下真正的纹理数组还是得scene访问。
先在processMesh函数空间内声明三个构造Mesh的容器。因为我们返回的是一个mesh对象,返回时调用Mesh的构造函数以processMesh函数空间里的这3个容器为参数,返回一个mesh对象。在调用processMesh出用Model的mesh vector容器把这个mesh压栈。
mesh里有mNumVertices,可以看到这个mesh有多少个顶点对象,写个for循环在mNumVertices范围内遍历。在for循环空间里声明一个Vertex对象,用来存储这个实际mesh里的三个顶点属性,mVertices位置数据,mNormals法线向量,mTextureCoords纹理坐标。在拿数据的时候我们也可以声明临时一个glm::vec3 vector或vec2 vec来一个个拿分量。然后给Vertex.Position或Vertex.Normal或Vertex.TexCoords。
mesh纹理坐标分量比较特殊,它是一个二维数组,行总是0,比如mesh->mTextureCoords[0][i].x。
然后去mesh里的Face数组去拿索引。先看看mesh里有多少个Face,mesh->mNumFaces。遍历每个Face,Face依然是aiFace类型。再看Face里有多少索引。直接把face.mIndices[j]压入processMesh函数空间indices。
然后是拿材质。先看看mesh下的材质索引是否大于0。即if( mesh->mMaterialndex > 0)。然后根据mesh下的索引用scene访问scene下的材质数组。这里的材质仍是aiMaterial*型的。从这里我们可以知道每个mesh它的材质索引只有一个。然后就是分纹理类型地去拿纹理。
这里我们使用loadModel函数去拿纹理。
loadMaterial函数的设计:
lodMaterialTextures函数当然要以之前取得的aiMaterial*的material为参数,第二个参数填Assimp定义的纹理类型ai_TextureType_DIFFUSE或ai_TextureType_SPECULAR。第三个参数填写一个纹理类型名的字符串"texture_diffuse"或“texture_specular”。
我们应该还记得,加载和创建纹理时,纹理的数据源只是一张图片,它几乎没有任何数据特征可言。仅有的特征可能就是它的绝对文件路径。文件路径这个特征可以用来判断该纹理是否被加载过,这个稍后会介绍。
所以我们调用loadModel函数时就要填充一些参数来区别目前加载的纹理将用作漫反射纹理还是镜面反射纹理。第三个参数填写名称也是为了给纹理对象的type成员赋值。
loadMaterial函数的返回类型应当为一个Texture对象型的容器。
如果loadMaterial函数返回的是一个Texture对象的容器,那么在函数的开始我们就可以声明一个vector<Texture> textures,期间我们用创建的纹理对象压栈,然后再返回它。
流程:
用aiMaterial*的mat成员函数GetTextureCount(type)来查看某种纹理个数有多少。
然后调用aiMaterial* mat的成员函数GetTexture(type, i, &str)。找到第i个这种纹理图的位置写入到str。用Assimp自己的函数来遍历纹理元数据。
这是合理的,因为纹理的源数据是一张图片,我们是用纹理的文件位置来载入纹理并创建纹理对象的。这里我们得到的文件名,后面会和在loadModel函数里得到的模型文件目录组合成模型位置。
这里把纹理图的位置和它所在目录位置拼接不是会重合目录之前的内容吗?注意我们默认纹理文件的路径是相对于模型文件的本地路径。
现在,在生成纹理对象之前我们就比较这个纹理的路径和已经加载的纹理的对象路径是否相同,如果相同,我们依然要把已经加载过的纹理对象压栈到loadMaterialTextures空间中的textures里面去,因为纹理对象一个都不能少。然后再跳过生成纹理对象的过程,访问下一个纹理源。
如果不同我们就可以声明一个临时的纹理对象,用得到的纹理源的路径以及模型文件的目录来生成纹理了。
生成纹理流程我们可以把它抽象成一个函数,即TextureFromFile()。
TextureFromFile函数的设计:
TextureFromFile函数的参数应为char*的path和string类型directory。这两个字符串路径在函数一开始就会拼接成一个完整的纹理文件的路径。
基于纹理生成流程:用int变量创建纹理对象,载入纹理文件,给纹理对象绑定纹理类型,生成纹理,创建多级渐远纹理,设置纹理S、T轴环绕模式,设置纹理放大缩小采样方式,释放纹理数据字符指针。TextureFromFile函数返回就是这个表示纹理对象的int型变量即可。可以看到纹理生成过程大部分是状态设置函数。
额外的:
载入纹理函数会返回一个char*的data。可以if判断这个data,再走完纹理生成流程。
如果data出现了假值,输出一个纹理加载错误的日志。
最后返回函数空间内的纹理ID。
在载入纹理时,的stbi_load函数的第4个参数,nrComponents。会被写入纹理的颜色通道个数,其实它值就是1或3或4。我们要根据纹理图的颜色通道值在glTexImage2D()函数的第3、7个参数位置填写正确的颜色通道格式。这个可以临时创建一个GLenum format,通道值为1是把GL_RED赋给format,3把GL_RGB赋给format,4就把GL_RGBA赋给format。
---------------------------------
到现在,一个纹理对象的id得到了赋值。变量type,则用loadMaterialTextures()的第三个参数string typeName来填充。path用mat->GetTexture()的到的纹理路径str来填充。
至此,一个纹理对象texture才完整地生成。此函数空间内textures压栈。加载过的纹理数组textures_loaded也压栈。返回此函数空间的textures容器。
到目前为止,在processMesh函数空间内,构建一个mesh所需的数据都已拿到,只需在processMesh函数的结尾调用Mesh()的构造函数,把3个容器填进去。返回一个mesh。在processNode内meshers压meshes容器的栈。
柱面坐标系:
根据右图可知,柱面坐标上与平面坐标中的关系如下:

【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了