技术文档

作者: 乔红喵喵 | 来源:发表于2018-07-07 22:58 被阅读16次

    视频和视频压缩

    • 什么是视频

      • 视频(Video)泛指将一系列静态影像以电信号的方式加以捕捉、纪录、处理、储存、传送与重现的各种技术。连续的图像变化每秒超过24帧(frame)画面以上时,根据视觉暂留原理,人眼无法辨别单幅的静态画面;看上去是平滑连续的视觉效果,这样连续的画面叫做视频。视频技术最早是为了电视系统而发展,但现在已经发展为各种不同的格式以利消费者将视频记录下来。网络技术的发达也促使视频的纪录片段以串流媒体的形式存在于因特网之上并可被电脑接收与播放。视频与电影属于不同的技术,后者是利用照相术将动态的影像捕捉为一系列的静态照片。
    • 视频数据格式

      • YUV数据格式。YUV是被欧洲电视系统所采用的一种颜色编码方法(属于PAL),是PAL和SECAM模拟彩色电视制式采用的颜色空间。在现代彩色电视系统中,通常采用三管彩色摄影机或彩色CCD摄影机进行取像,然后把取得的彩色图像信号经分色、分别放大校正后得到RGB,再经过矩阵变换电路得到亮度信号Y和两个色差信号B-Y(即U)、R-Y(即V),最后发送端将亮度和色差三个信号分别进行编码,用同一信道发送出去。这种色彩的表示方法就是所谓的YUV色彩空间表示。采用YUV色彩空间的重要性是它的亮度信号Y和色度信号U、V是分离的。

      • YUV主要用于优化彩色视频信号的传输,使其向后相容老式黑白电视。与RGB视频信号传输相比,它最大的优点在于只需占用极少的频宽(RGB要求三个独立的视频信号同时传输)。其中“Y”表示明亮度(Luminance或Luma),也就是灰阶值;而“U”和“V” 表示的则是色度(Chrominance或Chroma),作用是描述影像色彩及饱和度,用于指定像素的颜色。“亮度”是透过RGB输入信号来建立的,方法是将RGB信号的特定部分叠加到一起。“色度”则定义了颜色的两个方面─色调与饱和度,分别用Cr和Cb来表示。其中,Cr反映了RGB输入信号红色部分与RGB信号亮度值之间的差异。而Cb反映的是RGB输入信号蓝色部分与RGB信号亮度值之间的差异。

      • 采用YUV色彩空间的重要性是它的亮度信号Y和色度信号U、V是分离的。如果只有Y信号分量而没有U、V分量,那么这样表示的图像就是黑白灰度图像。彩色电视采用YUV空间正是为了用亮度信号Y解决彩色电视机与黑白电视机的兼容问题,使黑白电视机也能接收彩色电视信号。

      • YUV格式有两大类:planar和packed。对于planar的YUV格式,先连续存储所有像素点的Y,紧接着存储所有像素点的U,随后是所有像素点的V。对于packed的YUV格式,每个像素点的Y,U,V是连续交*存储的。

      • YUV,分为三个分量,“Y”表示明亮度(Luminance或Luma),也就是灰度值;而“U”和“V” 表示的则是色度(Chrominance或Chroma),作用是描述影像色彩及饱和度,用于指定像素的颜色。
        与我们熟知的RGB类似,YUV也是一种颜色编码方法,主要用于电视系统以及模拟视频领域,它将亮度信息(Y)与色彩信息(UV)分离,没有UV信息一样可以显示完整的图像,只不过是黑白的,这样的设计很好地解决了彩色电视机与黑白电视的兼容问题。并且,YUV不像RGB那样要求三个独立的视频信号同时传输,所以用YUV方式传送占用极少的频宽。

      • YUV码流的存储格式其实与其采样的方式密切相关,主流的采样方式有三种,YUV4:4:4,YUV4:2:2,YUV4:2:0,可以通过网上其它文章了解,这里我想强调的是如何根据其采样格式来从码流中还原每个像素点的YUV值,因为只有正确地还原了每个像素点的YUV值,才能通过YUV与RGB的转换公式提取出每个像素点的RGB值,然后显示出来。

      • 1.YUV4:4:4采样,每一个Y对应一组UV分量。

      • 2.YUV4:2:2采样,每两个Y共用一组UV分量。

      • 3.YUV4:2:0采样,每四个Y共用一组UV分量。

      image
      • 下面我用图的形式给出常见的YUV码流的存储方式,并在存储方式后面附有取样每个像素点的YUV数据的方法,其中,Cb、Cr的含义等同于U、V。

      • 1) YUVY 格式 (属于YUV422)YUYV为YUV422采样的存储格式中的一种,相邻的两个Y共用其相邻的两个Cb、Cr,分析,对于像素点Y'00、Y'01 而言,其Cb、Cr的值均为 Cb00、Cr00,其他的像素点的YUV取值依次类推。

      • 2) UYVY 格式 (属于YUV422)UYVY格式也是YUV422采样的存储格式中的一种,只不过与YUYV不同的是UV的排列顺序不一样而已,还原其每个像素点的YUV值的方法与上面一样。

      • 3) YUV422P(属于YUV422)YUV422P也属于YUV422的一种,它是一种Plane模式,即平面模式,并不是将YUV数据交错存储,而是先存放所有的Y分量,然后存储所有的U(Cb)分量,最后存储所有的V(Cr)分量,如上图所示。其每一个像素点的YUV值提取方法也是遵循YUV422格式的最基本提取方法,即两个Y共用一个UV。比如,对于像素点Y'00、Y'01 而言,其Cb、Cr的值均为 Cb00、Cr00。

      • 4)YV12,YU12格式(属于YUV420)YU12和YV12属于YUV420格式,也是一种Plane模式,将Y、U、V分量分别打包,依次存储。其每一个像素点的YUV数据提取遵循YUV420格式的提取方式,即4个Y分量共用一组UV。注意,上图中,Y'00、Y'01、Y'10、Y'11共用Cr00、Cb00,其他依次类推。

      • 5)NV12、NV21(属于YUV420)NV12和NV21属于YUV420格式,是一种two-plane模式,即Y和UV分为两个Plane,但是UV(CbCr)为交错存储,而不是分为三个plane。其提取方式与上一种类似,即Y'00、Y'01、Y'10、Y'11共用Cr00、Cb00。YUV420 planar数据, 以720×488大小图象YUV420planar为例,其存储格式是: 共大小为(720×480×3>>1)字节,分为三个部分:Y,U和V。Y分量:(720×480)个字节。U(Cb)分量:(720×480>>2)个字节。V(Cr)分量:(720×480>>2)个字节。三个部分内部均是行优先存储,三个部分之间是Y,U,V 顺序存储。即YUV数据的0--720×480字节是Y分量值,720×480--720×480×5/4字节是U分量,720×480×5/4 --720×480×3/2字节是V分量。

    • 视频压缩
      • 为什么要进行视频压缩,视频的大小其实是非常惊人的,举个例子来说,若视频的像素采用RGBA8888(红,绿,蓝,Alpha每个各占8位)来储存的,每个像素就是32位,4个字节,那么一帧720p(1280 × 720)的图像大小就是 1280 × 720 × 4 = 3686400 字节,就是 3.5 MB 的大小。那么一秒钟就是 3.5 MB × 25 = 87MB,一个小时就是 87MB × 3600 = 305GB,由此可见,如果不对视频进行压缩,连存储都是问题,更别说通过网络传输了。

      • 视频压缩算法,目前来说,常用的视频压缩算法时 H.264 算法。H.264,同时也是MPEG-4第十部分,是由ITU-T视频编码专家组(VCEG)和ISO/IEC动态图像专家组(MPEG)联合组成的联合视频组(JVT,Joint Video Team)提出的高度压缩数字视频编解码器标准。这个标准通常被称之为H.264/AVC(或者AVC/H.264或者H.264/MPEG-4 AVC或MPEG-4/H.264 AVC)而明确的说明它两方面的开发者。国际上制定视频编解码技术的组织有两个,一个是“国际电联(ITU-T)”,它制定的标准有H.261、H.263、H.263+等,另一个是“国际标准化组织(ISO)”它制定的标准有MPEG-1、MPEG-2、MPEG-4等。而H.264则是由两个组织联合组建的联合视频组(JVT)共同制定的新数字视频编码标准,所以它既是ITU-T的H.264,又是ISO/IEC的MPEG-4高级视频编码(Advanced Video Coding,AVC)的第10 部分。因此,不论是MPEG-4 AVC、MPEG-4 Part 10,还是ISO/IEC 14496-10,都是指H.264。

      • H264压缩算法的原理。H264压缩技术主要采用了以下几种方法对视频数据进行压缩。包括:

        • 帧内预测压缩,解决的是空域数据冗余问题。
        • 帧间预测压缩(运动估计与补偿),解决的是时域数据冗徐问题。
        • 整数离散余弦变换(DCT),将空间上的相关性变为频域上无关的数据然后进行量化。
        • CABAC压缩。
      • H.264技术细节,H.264将视频帧分为三种,分别是I帧,P帧,B帧,H264采用的核心算法是帧内压缩和帧间压缩,帧内压缩是生成I帧的算法,帧间压缩是生成B帧和P帧的算法。

        • I帧特点:

          • 1.它是一个全帧压缩编码帧。它将全帧图像信息进行JPEG压缩编码及传输;
          • 2.解码时仅用I帧的数据就可重构完整图像;
          • 3.I帧描述了图像背景和运动主体的详情;
          • 4.I帧不需要参考其他画面而生成;
          • 5.I帧是P帧和B帧的参考帧(其质量直接影响到同组中以后各帧的质量);
          • 6.I帧是帧组GOP的基础帧(第一帧),在一组中只有一个I帧;
          • 7.I帧不需要考虑运动矢量;
          • 8.I帧所占数据的信息量比较大。
        • P帧:前向预测编码帧。P帧表示的是这一帧跟之前的一个关键帧(或P帧)的差别,解码时需要用之前缓存的画面叠加上本帧定义的差别,生成最终画面。(也就是差别帧,P帧没有完整画面数据,只有与前一帧的画面差别的数据)P帧是以I帧为参考帧,在I帧中找出P帧“某点”的预测值和运动矢量,取预测差值和运动矢量一起传送。在接收端根据运动矢量从I帧中找出P帧“某点”的预测值并与差值相加以得到P帧“某点”样值,从而可得到完整的P帧。

        • P帧特点:

          • 1.P帧是I帧后面相隔1~2帧的编码帧;
          • 2.P帧采用运动补偿的方法传送它与前面的I或P帧的差值及运动矢量(预测误差);
          • 3.解码时必须将I帧中的预测值与预测误差求和后才能重构完整的P帧图像;
          • 4.P帧属于前向预测的帧间编码。它只参考前面最靠近它的I帧或P帧;
          • 5.P帧可以是其后面P帧的参考帧,也可以是其前后的B帧的参考帧;
          • 6.由于P帧是参考帧,它可能造成解码错误的扩散;
          • 7.由于是差值传送,P帧的压缩比较高。
        • B帧:双向预测内插编码帧。B帧是双向差别帧,也就是B帧记录的是本帧与前后帧的差别(具体比较复杂,有4种情况,但我这样说简单些),换言之,要解码B 帧,不仅要取得之前的缓存画面,还要解码之后的画面,通过前后画面的与本帧数据的叠加取得最终的画面。B帧压缩率高,但是解码时CPU会比较累。

        • B帧的预测与重构。B帧以前面的I或P帧和后面的P帧为参考帧,“找出”B帧“某点”的预测值和两个运动矢量,并取预测差值和运动矢量传送。接收端根据运动矢量在两个参考帧中“找出(算出)”预测值并与差值求和,得到B帧“某点”样值,从而可得到完整的B帧。

        • B帧特点

          • 1.B帧是由前面的I或P帧和后面的P帧来进行预测的;
          • 2.B帧传送的是它与前面的I或P帧和后面的P帧之间的预测误差及运动矢量;
          • 3.B帧是双向预测编码帧;
          • 4.B帧压缩比最高,因为它只反映丙参考帧间运动主体的变化情况,预测比较准确;
          • 5.B帧不是参考帧,不会造成解码错误的扩散。
        • I、B、P各帧是根据压缩算法的需要,是人为定义的,它们都是实实在在的物理帧。一般来说,I帧的压缩率是7(跟JPG差不多),P帧是20,B帧可 以达到50。可见使用B帧能节省大量空间,节省出来的空间可以用来保存多一些I帧,这样在相同码率下,可以提供更好的画质。

        • H.264 技术的选择,由于我们的应用场景要求低延迟,而 H.264 中的B帧是双向预测的,要解码一个B帧,必须要等待后面的帧才可以解码,所以我们的应用场景之中,只使用I帧和P帧

      • H264 的具体实现。在我们的项目中,我们使用了x264来对视频进行编码,用FFmpeg来对视频进行解码。

        • x264简介。H.264从1999年开始,到2003年形成草案,最后在2007年定稿有待核实。在ITU的标准里称为H.264,在MPEG的标准里是MPEG-4的一个组成部分--MPEG-4 Part 10,又叫Advanced Video Codec,因此常常称为MPEG-4 AVC或直接叫AVC。H.264编码能实现非常好的压缩比,有广泛的适用码率(适于从超低码率低延迟的电话会议到高码率的BluRay光盘和HDTV码流),良好的硬件支持(以PSP、iPod和显卡DXVA为代表)和众多强大的厂商作后盾。x264始于2003年,从当开源社区的MPEG4-ASP编码器Xvid小有所成时开始的,经过几年的开发,特别是Dark Shikari加入开发后,x264逐渐成为了最好的视频编码器。
        image
        • FFmpeg简介。FFmpeg是一套可以用来记录、转换数字音频、视频,并能将其转化为流的开源计算机程序。采用LGPL或GPL许可证。它提供了录制、转换以及流化音视频的完整解决方案。它包含了非常先进的音频/视频编解码库libavcodec,为了保证高可移植性和编解码质量,libavcodec里很多code都是从头开发的。FFmpeg在Linux平台下开发,但它同样也可以在其它操作系统环境中编译运行,包括Windows、Mac OS X等。

        [图片上传失败...(image-7478d2-1530975479292)]

    视频传输

    • 基础网络知识。
      • TCP(Transmission Control Protocol 传输控制协议)是一种面向连接的、可靠的、基于字节流的传输层通信协议,由IETF的RFC 793定义。在简化的计算机网络OSI模型中,它完成第四层传输层所指定的功能,用户数据报协议(UDP)是同一层内个重要的传输协议。在因特网协议族(Internet protocol suite)中,TCP层是位于IP层之上,应用层之下的中间层。不同主机的应用层之间经常需要可靠的、像管道一样的连接,但是IP层不提供这样的流机制,而是提供不可靠的包交换。应用层向TCP层发送用于网间传输的、用8位字节表示的数据流,然后TCP把数据流分区成适当长度的报文段(通常受该计算机连接的网络的数据链路层的最大传输单元(MTU)的限制)。之后TCP把结果包传给IP层,由它来通过网络将包传送给接收端实体的TCP层。TCP为了保证不发生丢包,就给每个包一个序号,同时序号也保证了传送到接收端实体的包的按序接收。然后接收端实体对已成功收到的包发回一个相应的确认(ACK);如果发送端实体在合理的往返时延(RTT)内未收到确认,那么对应的数据包就被假设为已丢失将会被进行重传。TCP用一个校验和函数来检验数据是否有错误;在发送和接收时都要计算校验和。

      • UDP 是User Datagram Protocol的简称, 中文名是用户数据报协议,是OSI(Open System Interconnection,开放式系统互联)参考模型中一种无连接的传输层协议,提供面向事务的简单不可靠信息传送服务,IETF RFC 768是UDP的正式规范。UDP在IP报文的协议号是17。UDP协议全称是用户数据报协议 [1] ,在网络中它与TCP协议一样用于处理数据包,是一种无连接的协议。在OSI模型中,在第四层——传输层,处于IP协议的上一层。UDP有不提供数据包分组、组装和不能对数据包进行排序的缺点,也就是说,当报文发送之后,是无法得知其是否安全完整到达的。UDP用来支持那些需要在计算机之间传输数据的网络应用。包括网络视频会议系统在内的众多的客户/服务器模式的网络应用都需要使用UDP协议。UDP协议从问世至今已经被使用了很多年,虽然其最初的光彩已经被一些类似协议所掩盖,但是即使是在今天UDP仍然不失为一项非常实用和可行的网络传输层协议。与所熟知的TCP(传输控制协议)协议一样,UDP协议直接位于IP(网际协议)协议的顶层。根据OSI(开放系统互连)参考模型,UDP和TCP都属于传输层协议。UDP协议的主要作用是将网络数据流量压缩成数据包的形式。一个典型的数据包就是一个二进制数据的传输单位。每一个数据包的前8个字节用来包含报头信息,剩余字节则用来包含具体的传输数据。

    • POSIX标准。POSIX表示可移植操作系统接口(Portable Operating System Interface of UNIX,缩写为 POSIX ),POSIX标准定义了操作系统应该为应用程序提供的接口标准,是IEEE为要在各种UNIX操作系统上运行的软件而定义的一系列API标准的总称,其正式称呼为IEEE 1003,而国际标准名称为ISO/IEC 9945。POSIX标准意在期望获得源代码级别的软件可移植性。换句话说,为一个POSIX兼容的操作系统编写的程序,应该可以在任何其它的POSIX操作系统(即使是来自另一个厂商)上编译执行。POSIX 并不局限于 UNIX。许多其它的操作系统,例如 DEC OpenVMS 支持 POSIX 标准,尤其是 IEEE Std. 1003.1-1990(1995 年修订)或 POSIX.1,POSIX.1 提供了源代码级别的 C 语言应用编程接口(API)给操作系统的服务程序,例如读写文件。POSIX.1 已经被国际标准化组织(International Standards Organization,ISO)所接受,被命名为 ISO/IEC 9945-1:1990 标准。

    • 使用POSIX标准可以方便得在多个平台上使用统一的接口,未来在跨平台上,将会有很高的可移植性。

    • TCP Serve的实现

      • 数据结构
    struct sockaddr
    {
        //地址族,2字节
        unsigned short sa_family;
        //存放地址和端口,14字节
        char sa_data[14];
    }
    
    struct sockaddr_in
    {
        //地址族
        short int sin_family;
        //端口号(使用网络字节序)
        unsigned short int sin_port;
        //地址
        struct in_addr sin_addr;
        //8字节数组,全为0,该字节数组的作用只是为了让两种数据结构大小相同而保留的空字节
        unsigned char sin_zero[8]
    }
    
    对于sockaddr,大部分的情况下只是用于bind,connect,recvfrom,sendto等函数的参数,指明地址信息,在一般编程中,并不对此结构体直接操作。而用sockaddr_in来替。
    
    • 函数

      • socket,用于创建一个socket。
      • bind,将socket于本机上的ip和端口绑定,随后就可以使用该端口进行通讯。
      • connect,客户端专用,可以端可以使用这个函数与远程主机建立一条tcp连接。
      • listen,将socket处于被动监听的状态。并为socket建立一个输入队列,将到达服务器的请求信息存放到这个队列中,直到程序处理他们。
      • accept,让服务器接受客户端的连接请求。
      • close,关闭连接。
      • send,发送数据。
      • recv,接受数据。
    • 例子程序
      服务器端

    /*socket tcp服务器端*/
    #include <sys/stat.h>
    #include <fcntl.h>
    #include <errno.h>
    #include <netdb.h>
    #include <sys/types.h>
    #include <sys/socket.h>
    #include <netinet/in.h>
    #include <arpa/inet.h>
    #include <stdio.h>
    #include <string.h>
    #include <stdlib.h>
    #include <unistd.h>
    #define SERVER_PORT 6666
    /*
    监听后,一直处于accept阻塞状态,
    直到有客户端连接,
    当客户端如数quit后,断开与客户端的连接
    */
    int main()
    {
        //调用socket函数返回的文件描述符
        int serverSocket;
        //声明两个套接字sockaddr_in结构体变量,分别表示客户端和服务器
        struct sockaddr_in server_addr;
        struct sockaddr_in clientAddr;
        int addr_len = sizeof(clientAddr);
        int client;
        char buffer[200];
        int iDataNum;
        //socket函数,失败返回-1
        //int socket(int domain, int type, int protocol);
        //第一个参数表示使用的地址类型,一般都是ipv4,AF_INET
        //第二个参数表示套接字类型:tcp:面向连接的稳定数据传输SOCK_STREAM
        //第三个参数设置为0
        if((serverSocket = socket(AF_INET, SOCK_STREAM, 0)) < 0)
        {
            perror("socket");
            return 1;
        }
        bzero(&server_addr, sizeof(server_addr));
        //初始化服务器端的套接字,并用htons和htonl将端口和地址转成网络字节序
        server_addr.sin_family = AF_INET;
        server_addr.sin_port = htons(SERVER_PORT);
        //ip可是是本服务器的ip,也可以用宏INADDR_ANY代替,代表0.0.0.0,表明所有地址
        server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
        //对于bind,accept之类的函数,里面套接字参数都是需要强制转换成(struct sockaddr *)
        //bind三个参数:服务器端的套接字的文件描述符,
        if(bind(serverSocket, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0)
        {
            perror("connect");
            return 1;
        }
        //设置服务器上的socket为监听状态
        if(listen(serverSocket, 5) < 0)
        {
            perror("listen");
            return 1;
        }
        while(1)
        {
            printf("监听端口: %d\n", SERVER_PORT);
            s
            //调用accept函数后,会进入阻塞状态
            //accept返回一个套接字的文件描述符,这样服务器端便有两个套接字的文件描述符,
            //serverSocket和client。
            //serverSocket仍然继续在监听状态,client则负责接收和发送数据
            //clientAddr是一个传出参数,accept返回时,传出客户端的地址和端口号
            //addr_len是一个传入-传出参数,传入的是调用者提供的缓冲区的clientAddr的长度,以避免缓冲区溢出。
            //传出的是客户端地址结构体的实际长度。
            //出错返回-1
            
            client = accept(serverSocket, (struct sockaddr*)&clientAddr, (socklen_t*)&addr_len);
            if(client < 0)
            {
                perror("accept");
                continue;
            }
            printf("等待消息...\n");
            
            //inet_ntoa ip地址转换函数,将网络字节序IP转换为点分十进制IP
            //表达式:char *inet_ntoa (struct in_addr);
            
            printf("IP is %s\n", inet_ntoa(clientAddr.sin_addr));
            printf("Port is %d\n", htons(clientAddr.sin_port));
            while(1)
            {
                printf("读取消息:");
                buffer[0] = '\0';
                iDataNum = recv(client, buffer, 1024, 0);
                if(iDataNum < 0)
                {
                    perror("recv null");
                    continue;
                }
                buffer[iDataNum] = '\0';
                if(strcmp(buffer, "quit") == 0)
                    break;
                printf("%s\n", buffer);
                
                printf("发送消息:");
                scanf("%s", buffer);
                printf("\n");
                send(client, buffer, strlen(buffer), 0);
                if(strcmp(buffer, "quit") == 0)
                    break;
            }
        }
        close(serverSocket);
        return 0;
    }
    

    客户端

    /*socket tcp客户端*/
    #include <sys/stat.h>
    #include <fcntl.h>
    #include <errno.h>
    #include <netdb.h>
    #include <sys/types.h>
    #include <sys/socket.h>
    #include <netinet/in.h>
    #include <arpa/inet.h>
    #include <stdio.h>
    #include <string.h>
    #include <stdlib.h>
    #include <unistd.h>
    #define SERVER_PORT 6666
    /*
    连接到服务器后,会不停循环,等待输入,
    输入quit后,断开与服务器的连接
    */
    int main()
    {
        //客户端只需要一个套接字文件描述符,用于和服务器通信
        int clientSocket;
        //描述服务器的socket
        struct sockaddr_in serverAddr;
        char sendbuf[200];
        char recvbuf[200];
        int iDataNum;
        if((clientSocket = socket(AF_INET, SOCK_STREAM, 0)) < 0)
        {
            perror("socket");
            return 1;
        }
        serverAddr.sin_family = AF_INET;
        serverAddr.sin_port = htons(SERVER_PORT);
        //指定服务器端的ip,本地测试:127.0.0.1
        //inet_addr()函数,将点分十进制IP转换成网络字节序IP
        serverAddr.sin_addr.s_addr = inet_addr("127.0.0.1");
        if(connect(clientSocket, (struct sockaddr *)&serverAddr, sizeof(serverAddr)) < 0)
        {
            perror("connect");
            return 1;
        }
        printf("连接到主机...\n");
        while(1)
        {
            printf("发送消息:");
            scanf("%s", sendbuf);
            printf("\n");
            send(clientSocket, sendbuf, strlen(sendbuf), 0);
    
            if(strcmp(sendbuf, "quit") == 0)
                break;
            printf("读取消息:");
            recvbuf[0] = '\0';
            iDataNum = recv(clientSocket, recvbuf, 200, 0);
            recvbuf[iDataNum] = '\0';
            printf("%s\n", recvbuf);
        }
        close(clientSocket);
        return 0;
    }
    

    OpenGL

    image
    • OpenGL 简介。OpenGL(全写Open Graphics Library)是指定义了一个跨编程语言、跨平台的编程接口规格的专业的图形程序接口。它用于三维图像(二维的亦可),是一个功能强大,调用方便的底层图形库。
      OpenGL是行业领域中最为广泛接纳的 2D/3D 图形 API,其自诞生至今已催生了各种计算机平台及设备上的数千优秀应用程序。OpenGL是独立于视窗操作系统或其它操作系统的,亦是网络透明的。在包含CAD、内容创作、能源、娱乐、游戏开发、制造业、制药业及虚拟现实等行业领域中,OpenGL 的高视觉表现力图形处理软件的开发。

    在OpenGL中,任何事物都在3D空间中,而屏幕和窗口却是2D像素数组,这导致OpenGL的大部分工作都是关于把3D坐标转变为适应你屏幕的2D像素。3D坐标转为2D坐标的处理过程是由OpenGL的<def>图形渲染管线</def>(Graphics Pipeline,大多译为管线,实际上指的是一堆原始图形数据途经一个输送管道,期间经过各种变化处理最终出现在屏幕的过程)管理的。图形渲染管线可以被划分为两个主要部分:第一部分把你的3D坐标转换为2D坐标,第二部分是把2D坐标转变为实际的有颜色的像素。这个教程里,我们会简单地讨论一下图形渲染管线,以及如何利用它创建一些漂亮的像素。

    图形渲染管线接受一组3D坐标,然后把它们转变为你屏幕上的有色2D像素输出。图形渲染管线可以被划分为几个阶段,每个阶段将会把前一个阶段的输出作为输入。所有这些阶段都是高度专门化的(它们都有一个特定的函数),并且很容易并行执行。正是由于它们具有并行执行的特性,当今大多数显卡都有成千上万的小处理核心,它们在GPU上为每一个(渲染管线)阶段运行各自的小程序,从而在图形渲染管线中快速处理你的数据。这些小程序叫做<def>着色器</def>(Shader)。

    有些着色器允许开发者自己配置,这就允许我们用自己写的着色器来替换默认的。这样我们就可以更细致地控制图形渲染管线中的特定部分了,而且因为它们运行在GPU上,所以它们可以给我们节约宝贵的CPU时间。OpenGL着色器是用<def>OpenGL着色器语言</def>(OpenGL Shading Language, <def>GLSL</def>)写成的,在下一节中我们再花更多时间研究它。

    下面,你会看到一个图形渲染管线的每个阶段的抽象展示。要注意蓝色部分代表的是我们可以注入自定义的着色器的部分。

    image

    如你所见,图形渲染管线包含很多部分,每个部分都将在转换顶点数据到最终像素这一过程中处理各自特定的阶段。我们会概括性地解释一下渲染管线的每个部分,让你对图形渲染管线的工作方式有个大概了解。

    首先,我们以数组的形式传递3个3D坐标作为图形渲染管线的输入,用来表示一个三角形,这个数组叫做顶点数据(Vertex Data);顶点数据是一系列顶点的集合。一个<def>顶点</def>(Vertex)是一个3D坐标的数据的集合。而顶点数据是用<def>顶点属性</def>(Vertex Attribute)表示的,它可以包含任何我们想用的数据,但是简单起见,我们还是假定每个顶点只由一个3D位置(译注1)和一些颜色值组成的吧。

    图形渲染管线的第一个部分是<def>顶点着色器</def>(Vertex Shader),它把一个单独的顶点作为输入。顶点着色器主要的目的是把3D坐标转为另一种3D坐标(后面会解释),同时顶点着色器允许我们对顶点属性进行一些基本处理。

    <def>图元装配</def>(Primitive Assembly)阶段将顶点着色器输出的所有顶点作为输入(如果是<var>GL_POINTS</var>,那么就是一个顶点),并所有的点装配成指定图元的形状;本节例子中是一个三角形。

    图元装配阶段的输出会传递给<def>几何着色器</def>(Geometry Shader)。几何着色器把图元形式的一系列顶点的集合作为输入,它可以通过产生新顶点构造出新的(或是其它的)图元来生成其他形状。例子中,它生成了另一个三角形。

    几何着色器的输出会被传入<def>光栅化阶段</def>(Rasterization Stage),这里它会把图元映射为最终屏幕上相应的像素,生成供片段着色器(Fragment Shader)使用的片段(Fragment)。在片段着色器运行之前会执行<def>裁切</def>(Clipping)。裁切会丢弃超出你的视图以外的所有像素,用来提升执行效率。

    <def>片段着色器</def>的主要目的是计算一个像素的最终颜色,这也是所有OpenGL高级效果产生的地方。通常,片段着色器包含3D场景的数据(比如光照、阴影、光的颜色等等),这些数据可以被用来计算最终像素的颜色。

    在所有对应颜色值确定以后,最终的对象将会被传到最后一个阶段,我们叫做<def>Alpha测试</def>和<def>混合</def>(Blending)阶段。这个阶段检测片段的对应的深度(和模板(Stencil))值(后面会讲),用它们来判断这个像素是其它物体的前面还是后面,决定是否应该丢弃。这个阶段也会检查<def>alpha</def>值(alpha值定义了一个物体的透明度)并对物体进行<def>混合</def>(Blend)。所以,即使在片段着色器中计算出来了一个像素输出的颜色,在渲染多个三角形的时候最后的像素颜色也可能完全不同。

    可以看到,图形渲染管线非常复杂,它包含很多可配置的部分。然而,对于大多数场合,我们只需要配置顶点和片段着色器就行了。几何着色器是可选的,通常使用它默认的着色器就行了。

    在现代OpenGL中,我们必须定义至少一个顶点着色器和一个片段着色器(因为GPU中没有默认的顶点/片段着色器)。出于这个原因,刚开始学习现代OpenGL的时候可能会非常困难,因为在你能够渲染自己的第一个三角形之前已经需要了解一大堆知识了。在本节结束你最终渲染出你的三角形的时候,你也会了解到非常多的图形编程知识。

    顶点输入

    开始绘制图形之前,我们必须先给OpenGL输入一些顶点数据。OpenGL是一个3D图形库,所以我们在OpenGL中指定的所有坐标都是3D坐标(x、y和z)。OpenGL不是简单地把所有的3D坐标变换为屏幕上的2D像素;OpenGL仅当3D坐标在3个轴(x、y和z)上都为-1.0到1.0的范围内时才处理它。所有在所谓的<def>标准化设备坐标</def>(Normalized Device Coordinates)范围内的坐标才会最终呈现在屏幕上(在这个范围以外的坐标都不会显示)。

    由于我们希望渲染一个三角形,我们一共要指定三个顶点,每个顶点都有一个3D位置。我们会将它们以标准化设备坐标的形式(OpenGL的可见区域)定义为一个float数组。

    float vertices[] = {
        -0.5f, -0.5f, 0.0f,
         0.5f, -0.5f, 0.0f,
         0.0f,  0.5f, 0.0f
    };
    

    由于OpenGL是在3D空间中工作的,而我们渲染的是一个2D三角形,我们将它顶点的z坐标设置为0.0。这样子的话三角形每一点的深度(Depth,译注2)都是一样的,从而使它看上去像是2D的。

    定义这样的顶点数据以后,我们会把它作为输入发送给图形渲染管线的第一个处理阶段:顶点着色器。它会在GPU上创建内存用于储存我们的顶点数据,还要配置OpenGL如何解释这些内存,并且指定其如何发送给显卡。顶点着色器接着会处理我们在内存中指定数量的顶点。

    我们通过<def>顶点缓冲对象</def>(Vertex Buffer Objects, VBO)管理这个内存,它会在GPU内存(通常被称为显存)中储存大量顶点。使用这些缓冲对象的好处是我们可以一次性的发送一大批数据到显卡上,而不是每个顶点发送一次。从CPU把数据发送到显卡相对较慢,所以只要可能我们都要尝试尽量一次性发送尽可能多的数据。当数据发送至显卡的内存中后,顶点着色器几乎能立即访问顶点,这是个非常快的过程。

    顶点缓冲对象是我们在[OpenGL](01 OpenGL.md)教程中第一个出现的OpenGL对象。就像OpenGL中的其它对象一样,这个缓冲有一个独一无二的ID,所以我们可以使用<fun>glGenBuffers</fun>函数和一个缓冲ID生成一个VBO对象:

    unsigned int VBO;
    glGenBuffers(1, &VBO);
    

    OpenGL有很多缓冲对象类型,顶点缓冲对象的缓冲类型是<var>GL_ARRAY_BUFFER</var>。OpenGL允许我们同时绑定多个缓冲,只要它们是不同的缓冲类型。我们可以使用<fun>glBindBuffer</fun>函数把新创建的缓冲绑定到<var>GL_ARRAY_BUFFER</var>目标上:

    glBindBuffer(GL_ARRAY_BUFFER, VBO);  
    

    从这一刻起,我们使用的任何(在<var>GL_ARRAY_BUFFER</var>目标上的)缓冲调用都会用来配置当前绑定的缓冲(<var>VBO</var>)。然后我们可以调用<fun>glBufferData</fun>函数,它会把之前定义的顶点数据复制到缓冲的内存中:

    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
    

    <fun>glBufferData</fun>是一个专门用来把用户定义的数据复制到当前绑定缓冲的函数。它的第一个参数是目标缓冲的类型:顶点缓冲对象当前绑定到<var>GL_ARRAY_BUFFER</var>目标上。第二个参数指定传输数据的大小(以字节为单位);用一个简单的sizeof计算出顶点数据大小就行。第三个参数是我们希望发送的实际数据。

    第四个参数指定了我们希望显卡如何管理给定的数据。它有三种形式:

    • <var>GL_STATIC_DRAW</var> :数据不会或几乎不会改变。
    • <var>GL_DYNAMIC_DRAW</var>:数据会被改变很多。
    • <var>GL_STREAM_DRAW</var> :数据每次绘制时都会改变。

    三角形的位置数据不会改变,每次渲染调用时都保持原样,所以它的使用类型最好是<var>GL_STATIC_DRAW</var>。如果,比如说一个缓冲中的数据将频繁被改变,那么使用的类型就是<var>GL_DYNAMIC_DRAW</var>或<var>GL_STREAM_DRAW</var>,这样就能确保显卡把数据放在能够高速写入的内存部分。

    现在我们已经把顶点数据储存在显卡的内存中,用<var>VBO</var>这个顶点缓冲对象管理。下面我们会创建一个顶点和片段着色器来真正处理这些数据。现在我们开始着手创建它们吧。

    顶点着色器

    顶点着色器(Vertex Shader)是几个可编程着色器中的一个。如果我们打算做渲染的话,现代OpenGL需要我们至少设置一个顶点和一个片段着色器。我们会简要介绍一下着色器以及配置两个非常简单的着色器来绘制我们第一个三角形。下一节中我们会更详细的讨论着色器。

    我们需要做的第一件事是用着色器语言GLSL(OpenGL Shading Language)编写顶点着色器,然后编译这个着色器,这样我们就可以在程序中使用它了。下面你会看到一个非常基础的GLSL顶点着色器的源代码:

    #version 330 core
    layout (location = 0) in vec3 aPos;
    
    void main()
    {
        gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
    }
    

    可以看到,GLSL看起来很像C语言。每个着色器都起始于一个版本声明。OpenGL 3.3以及和更高版本中,GLSL版本号和OpenGL的版本是匹配的(比如说GLSL 420版本对应于OpenGL 4.2)。我们同样明确表示我们会使用核心模式。

    下一步,使用in关键字,在顶点着色器中声明所有的输入顶点属性(Input Vertex Attribute)。现在我们只关心位置(Position)数据,所以我们只需要一个顶点属性。GLSL有一个向量数据类型,它包含1到4个float分量,包含的数量可以从它的后缀数字看出来。由于每个顶点都有一个3D坐标,我们就创建一个vec3输入变量<var>aPos</var>。我们同样也通过layout (location = 0)设定了输入变量的位置值(Location)你后面会看到为什么我们会需要这个位置值。

    为了设置顶点着色器的输出,我们必须把位置数据赋值给预定义的<var>gl_Position</var>变量,它在幕后是vec4类型的。在<fun>main</fun>函数的最后,我们将<var>gl_Position</var>设置的值会成为该顶点着色器的输出。由于我们的输入是一个3分量的向量,我们必须把它转换为4分量的。我们可以把vec3的数据作为vec4构造器的参数,同时把w分量设置为1.0f(我们会在后面解释为什么)来完成这一任务。

    当前这个顶点着色器可能是我们能想到的最简单的顶点着色器了,因为我们对输入数据什么都没有处理就把它传到着色器的输出了。在真实的程序里输入数据通常都不是标准化设备坐标,所以我们首先必须先把它们转换至OpenGL的可视区域内。

    编译着色器

    我们已经写了一个顶点着色器源码(储存在一个C的字符串中),但是为了能够让OpenGL使用它,我们必须在运行时动态编译它的源码。

    我们首先要做的是创建一个着色器对象,注意还是用ID来引用的。所以我们储存这个顶点着色器为unsigned int,然后用<fun>glCreateShader</fun>创建这个着色器:

    unsigned int vertexShader;
    vertexShader = glCreateShader(GL_VERTEX_SHADER);
    

    我们把需要创建的着色器类型以参数形式提供给<fun>glCreateShader</fun>。由于我们正在创建一个顶点着色器,传递的参数是<var>GL_VERTEX_SHADER</var>。

    下一步我们把这个着色器源码附加到着色器对象上,然后编译它:

    glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
    glCompileShader(vertexShader);
    

    <fun>glShaderSource</fun>函数把要编译的着色器对象作为第一个参数。第二参数指定了传递的源码字符串数量,这里只有一个。第三个参数是顶点着色器真正的源码,第四个参数我们先设置为NULL

    你可能会希望检测在调用<fun>glCompileShader</fun>后编译是否成功了,如果没成功的话,你还会希望知道错误是什么,这样你才能修复它们。检测编译时错误可以通过以下代码来实现:

    int success;
    char infoLog[512];
    glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &success);

    首先我们定义一个整型变量来表示是否成功编译,还定义了一个储存错误消息(如果有的话)的容器。然后我们用<fun>glGetShaderiv</fun>检查是否编译成功。如果编译失败,我们会用<fun>glGetShaderInfoLog</fun>获取错误消息,然后打印它。

    if(!success)
    {
    glGetShaderInfoLog(vertexShader, 512, NULL, infoLog);
    std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" << infoLog << std::endl;
    }

    如果编译的时候没有检测到任何错误,顶点着色器就被编译成功了。

    片段着色器

    片段着色器(Fragment Shader)是第二个也是最后一个我们打算创建的用于渲染三角形的着色器。片段着色器所做的是计算像素最后的颜色输出。为了让事情更简单,我们的片段着色器将会一直输出橘黄色。

    在计算机图形中颜色被表示为有4个元素的数组:红色、绿色、蓝色和alpha(透明度)分量,通常缩写为RGBA。当在OpenGL或GLSL中定义一个颜色的时候,我们把颜色每个分量的强度设置在0.0到1.0之间。比如说我们设置红为1.0f,绿为1.0f,我们会得到两个颜色的混合色,即黄色。这三种颜色分量的不同调配可以生成超过1600万种不同的颜色!

    #version 330 core
    out vec4 FragColor;
    
    void main()
    {
        FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);
    } 
    

    片段着色器只需要一个输出变量,这个变量是一个4分量向量,它表示的是最终的输出颜色,我们应该自己将其计算出来。我们可以用out关键字声明输出变量,这里我们命名为<var>FragColor</var>。下面,我们将一个alpha值为1.0(1.0代表完全不透明)的橘黄色的vec4赋值给颜色输出。

    编译片段着色器的过程与顶点着色器类似,只不过我们使用<var>GL_FRAGMENT_SHADER</var>常量作为着色器类型:

    unsigned int fragmentShader;
    fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
    glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
    glCompileShader(fragmentShader);
    

    两个着色器现在都编译了,剩下的事情是把两个着色器对象链接到一个用来渲染的<def>着色器程序</def>(Shader Program)中。

    着色器程序对象(Shader Program Object)是多个着色器合并之后并最终链接完成的版本。如果要使用刚才编译的着色器我们必须把它们<def>链接</def>(Link)为一个着色器程序对象,然后在渲染对象的时候激活这个着色器程序。已激活着色器程序的着色器将在我们发送渲染调用的时候被使用。

    当链接着色器至一个程序的时候,它会把每个着色器的输出链接到下个着色器的输入。当输出和输入不匹配的时候,你会得到一个连接错误。

    创建一个程序对象很简单:

    unsigned int shaderProgram;
    shaderProgram = glCreateProgram();
    

    <fun>glCreateProgram</fun>函数创建一个程序,并返回新创建程序对象的ID引用。现在我们需要把之前编译的着色器附加到程序对象上,然后用<fun>glLinkProgram</fun>链接它们:

    glAttachShader(shaderProgram, vertexShader);
    glAttachShader(shaderProgram, fragmentShader);
    glLinkProgram(shaderProgram);
    

    代码应该很清楚,我们把着色器附加到了程序上,然后用<fun>glLinkProgram</fun>链接。

    得到的结果就是一个程序对象,我们可以调用<fun>glUseProgram</fun>函数,用刚创建的程序对象作为它的参数,以激活这个程序对象:

    glUseProgram(shaderProgram);
    

    在<fun>glUseProgram</fun>函数调用之后,每个着色器调用和渲染调用都会使用这个程序对象(也就是之前写的着色器)了。

    对了,在把着色器对象链接到程序对象以后,记得删除着色器对象,我们不再需要它们了:

    glDeleteShader(vertexShader);
    glDeleteShader(fragmentShader);
    

    现在,我们已经把输入顶点数据发送给了GPU,并指示了GPU如何在顶点和片段着色器中处理它。就快要完成了,但还没结束,OpenGL还不知道它该如何解释内存中的顶点数据,以及它该如何将顶点数据链接到顶点着色器的属性上。我们需要告诉OpenGL怎么做。

    链接顶点属性

    顶点着色器允许我们指定任何以顶点属性为形式的输入。这使其具有很强的灵活性的同时,它还的确意味着我们必须手动指定输入数据的哪一个部分对应顶点着色器的哪一个顶点属性。所以,我们必须在渲染前指定OpenGL该如何解释顶点数据。

    • 位置数据被储存为32位(4字节)浮点值。
    • 每个位置包含3个这样的值。
    • 在这3个值之间没有空隙(或其他值)。这几个值在数组中<def>紧密排列</def>(Tightly Packed)。
    • 数据中第一个值在缓冲开始的位置。

    有了这些信息我们就可以使用<fun>glVertexAttribPointer</fun>函数告诉OpenGL该如何解析顶点数据(应用到逐个顶点属性上)了:

    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
    glEnableVertexAttribArray(0);
    

    <var>glVertexAttribPointer</var>函数的参数非常多,所以我会逐一介绍它们:

    • 第一个参数指定我们要配置的顶点属性。还记得我们在顶点着色器中使用layout(location = 0)定义了<var>position</var>顶点属性的位置值(Location)吗?它可以把顶点属性的位置值设置为0。因为我们希望把数据传递到这一个顶点属性中,所以这里我们传入0
    • 第二个参数指定顶点属性的大小。顶点属性是一个vec3,它由3个值组成,所以大小是3。
    • 第三个参数指定数据的类型,这里是<var>GL_FLOAT</var>(GLSL中vec*都是由浮点数值组成的)。
    • 下个参数定义我们是否希望数据被标准化(Normalize)。如果我们设置为<var>GL_TRUE</var>,所有数据都会被映射到0(对于有符号型signed数据是-1)到1之间。我们把它设置为<var>GL_FALSE</var>。
    • 第五个参数叫做<def>步长</def>(Stride),它告诉我们在连续的顶点属性组之间的间隔。由于下个组位置数据在3个float之后,我们把步长设置为3 * sizeof(float)。要注意的是由于我们知道这个数组是紧密排列的(在两个顶点属性之间没有空隙)我们也可以设置为0来让OpenGL决定具体步长是多少(只有当数值是紧密排列时才可用)。一旦我们有更多的顶点属性,我们就必须更小心地定义每个顶点属性之间的间隔,我们在后面会看到更多的例子(译注: 这个参数的意思简单说就是从这个属性第二次出现的地方到整个数组0位置之间有多少字节)。
    • 最后一个参数的类型是void*,所以需要我们进行这个奇怪的强制类型转换。它表示位置数据在缓冲中起始位置的<def>偏移量</def>(Offset)。由于位置数据在数组的开头,所以这里是0。我们会在后面详细解释这个参数。

    现在我们已经定义了OpenGL该如何解释顶点数据,我们现在应该使用<fun>glEnableVertexAttribArray</fun>,以顶点属性位置值作为参数,启用顶点属性;顶点属性默认是禁用的。自此,所有东西都已经设置好了:我们使用一个顶点缓冲对象将顶点数据初始化至缓冲中,建立了一个顶点和一个片段着色器,并告诉了OpenGL如何把顶点数据链接到顶点着色器的顶点属性上。在OpenGL中绘制一个物体,代码会像是这样:

    // 0. 复制顶点数组到缓冲中供OpenGL使用
    glBindBuffer(GL_ARRAY_BUFFER, VBO);
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
    // 1. 设置顶点属性指针
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
    glEnableVertexAttribArray(0);
    // 2. 当我们渲染一个物体时要使用着色器程序
    glUseProgram(shaderProgram);
    // 3. 绘制物体
    someOpenGLFunctionThatDrawsOurTriangle();
    

    每当我们绘制一个物体的时候都必须重复这一过程。这看起来可能不多,但是如果有超过5个顶点属性,上百个不同物体呢(这其实并不罕见)。绑定正确的缓冲对象,为每个物体配置所有顶点属性很快就变成一件麻烦事。有没有一些方法可以使我们把所有这些状态配置储存在一个对象中,并且可以通过绑定这个对象来恢复状态呢?

    顶点数组对象

    <def>顶点数组对象</def>(Vertex Array Object, <def>VAO</def>)可以像顶点缓冲对象那样被绑定,任何随后的顶点属性调用都会储存在这个VAO中。这样的好处就是,当配置顶点属性指针时,你只需要将那些调用执行一次,之后再绘制物体的时候只需要绑定相应的VAO就行了。这使在不同顶点数据和属性配置之间切换变得非常简单,只需要绑定不同的VAO就行了。刚刚设置的所有状态都将存储在VAO中

    一个顶点数组对象会储存以下这些内容:

    • <fun>glEnableVertexAttribArray</fun>和<fun>glDisableVertexAttribArray</fun>的调用。
    • 通过<fun>glVertexAttribPointer</fun>设置的顶点属性配置。
    • 通过<fun>glVertexAttribPointer</fun>调用与顶点属性关联的顶点缓冲对象。

    创建一个VAO和创建一个VBO很类似:

    unsigned int VAO;
    glGenVertexArrays(1, &VAO);
    

    要想使用VAO,要做的只是使用<fun>glBindVertexArray</fun>绑定VAO。从绑定之后起,我们应该绑定和配置对应的VBO和属性指针,之后解绑VAO供之后使用。当我们打算绘制一个物体的时候,我们只要在绘制物体前简单地把VAO绑定到希望使用的设定上就行了。这段代码应该看起来像这样:

    // ..:: 初始化代码(只运行一次 (除非你的物体频繁改变)) :: ..
    // 1. 绑定VAO
    glBindVertexArray(VAO);
    // 2. 把顶点数组复制到缓冲中供OpenGL使用
    glBindBuffer(GL_ARRAY_BUFFER, VBO);
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
    // 3. 设置顶点属性指针
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
    glEnableVertexAttribArray(0);
     
    [...]
     
    // ..:: 绘制代码(渲染循环中) :: ..
    // 4. 绘制物体
    glUseProgram(shaderProgram);
    glBindVertexArray(VAO);
    someOpenGLFunctionThatDrawsOurTriangle();
    

    就这么多了!前面做的一切都是等待这一刻,一个储存了我们顶点属性配置和应使用的VBO的顶点数组对象。一般当你打算绘制多个物体时,你首先要生成/配置所有的VAO(和必须的VBO及属性指针),然后储存它们供后面使用。当我们打算绘制物体的时候就拿出相应的VAO,绑定它,绘制完物体后,再解绑VAO。

    要想绘制我们想要的物体,OpenGL给我们提供了<fun>glDrawArrays</fun>函数,它使用当前激活的着色器,之前定义的顶点属性配置,和VBO的顶点数据(通过VAO间接绑定)来绘制图元。

    glUseProgram(shaderProgram);
    glBindVertexArray(VAO);
    glDrawArrays(GL_TRIANGLES, 0, 3);
    

    <fun>glDrawArrays</fun>函数第一个参数是我们打算绘制的OpenGL图元的类型。由于我们在一开始时说过,我们希望绘制的是一个三角形,这里传递<var>GL_TRIANGLES</var>给它。第二个参数指定了顶点数组的起始索引,我们这里填0。最后一个参数指定我们打算绘制多少个顶点,这里是3(我们只从我们的数据中渲染一个三角形,它只有3个顶点长)。

    如果你编译通过了,你应该看到下面的结果:

    image

    相关文章

      网友评论

        本文标题:技术文档

        本文链接:https://www.haomeiwen.com/subject/audnuftx.html