计算机图形学编程 使用OpenGL和C++

978-7-115-52128-6
作者: [美]V.斯科特·戈登(V. Scott Gordon)约翰·克莱维吉(John Clevenger)
译者: 魏广程沈瞳
编辑: 陈冀康

图书目录:

详情

本书以C++和OpenGL 作为工具,教授计算机图形学编程。全书共14 章和3 个附录。 首先从图形编程的基础和准备工作开始,依次介绍了OpenGL 图像管线、图形编程数学基础、管理3D 图形数据、纹理贴图、3D 模型、光照、阴影、天空和背景、增强表面细节、参数曲面、曲面细分、几何着色器,以及其他相关的图形编程技术。附录分别介绍了Windows、macOS 平台上的安装设置,以及Nsight 图形调试器的应用。本书每章最后配备了不同形式的习题,供读者巩固所学知识。 本书适合作为高等院校计算机科学专业的计算机图形编程课程的教材或辅导书,也适合对计算机图形编程感兴趣的读者自学。

图书摘要

版权信息

书名:计算机图形学编程(使用OpenGL和C++)

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。

著    [美]V.斯科特·戈登(V.Scott Gordon)

     [美]约翰·克莱维吉(John Clevenger)

译    魏广程  沈 瞳

责任编辑 陈冀康

人民邮电出版社出版发行  北京市丰台区成寿寺路11号

邮编 100164  电子邮件 315@ptpress.com.cn

网址 http://www.ptpress.com.cn

读者服务热线:(010)81055410

反盗版热线:(010)81055315


本书以C++和OpenGL作为工具,教授计算机图形学编程。全书共14章和3个附录。首先从图形编程的基础和准备工作开始,依次介绍了OpenGL图像管线、图形编程数学基础、管理3D图形数据、纹理贴图、3D模型、光照、阴影、天空和背景、增强表面细节、参数曲面、曲面细分、几何着色器,以及其他相关的图形编程技术。附录分别介绍了Windows、macOS平台上的安装设置,以及Nsight图形调试器的应用。本书每章最后配备了不同形式的习题,供读者巩固所学知识。

本书适合作为高等院校计算机科学专业的计算机图形编程课程的教材或辅导书,也适合对计算机图形编程感兴趣的读者自学。


本书的主要目标是用作计算机科学专业本科OpenGL 3D图形编程相关课程的教材。同时,我们也付出了很大的努力,让本书成为一本无须配合课程使用的自学教材。在以这两者为目标的前提下,我们尽力将内容解释得简单而清晰。本书中的所有代码示例都已经尽可能地简化,同时没有破坏其完整性,以便读者可以直接运行。

我们期望本书与众不同的一点是,新手(刚接触3D图形编程的人)更容易学习。关于这个主题的学习资料从来都不匮乏,恰恰相反,很多新手刚入门的时候,相关的资料就扑面而来了。我们刚接触3D图形编程的时候,就期望能遇见这样的教材—— 一步步解释基础概念,循序渐进并有序地梳理进阶概念,因此我们也尝试将本书编写成这样的教材。我们曾想将本书命名为shader programming made easy(《轻松学着色器编程》),虽然我们并不认为有什么方法能真的让着色器编程变得“轻松”,但我们希望本书能够帮助你尽可能地达成这个目标。

本书使用C++进行OpenGL编程教学。使用C++学习图形编程有以下几个好处。

值得一提的是,OpenGL也存在着与其他语言绑定。常见的有Java、C#、Python等,但本书仅关注C++。

本书与众不同的另一点是它有一个Java版,英文书名是Computer Graphics Programming in OpenGL with Java。这两本书是按同样的节奏组织的,它们使用相同类型的章节编号、主题、图表、习题和讲解方式,其代码组织方式也尽可能地相似。诚然,使用C++或Java编程肯定有着相当大的差异。尽管如此,我们相信这两本书提供了几乎相同的学习路径,甚至可以让选修同一门课的学生使用不同的语言版本作为教材。

需要说明的一点是,OpenGL有着不同的版本(稍后简述)和不同的变体。例如,在标准OpenGL(也称桌面OpenGL)之外,还有一个变体叫作OpenGL ES。它是为嵌入式系统(Embedded System)的开发而定制的(因此称为ES)。“嵌入式系统”包括手机、游戏主机、汽车和工业控制系统之类的设备。OpenGL ES的大部分内容是标准OpenGL的子集,删除了嵌入式系统通常用不到的很多操作。OpenGL ES还增加了一些功能,通常是特定目标环境下的特定功能。本书侧重于标准OpenGL。

OpenGL的另一种变体称为WebGL。WebGL基于OpenGL ES,它的设计目标是支持在浏览器中运行OpenGL。WebGL允许应用程序通过JavaScript进行OpenGL ES操作调用,从而简单地将OpenGL图形嵌入标准HTML(Web)文档中。大多数现代Web浏览器,包括Apple Safari、Google Chrome、Microsoft Internet Explorer、Mozilla Firefox和Opera,支持WebGL。由于Web编程超出了本书的讨论范围,因此本书不会涵盖WebGL。不过,由于WebGL基于OpenGL ES,而OpenGL ES又基于标准OpenGL,因此本书所涵盖的大部分内容可以直接迁移到这些OpenGL变体的学习中去。

3D图形编程这个主题通常让人想起精美而宏大的画面。事实上,许多相关热门教材中充满了令人惊叹的场景,很大程度上是为了吸引读者翻阅他们的图库。虽然我们认同这些图例的激励作用,但我们的目标是教学而非令人惊叹。本书中的图像仅仅是示例程序的输出。由于本书只是入门教程,其渲染的场景应该无法让专家侧目。然而,本书呈现的技术确实是构成当今这些炫目3D效果的基础。

我们没有尝试写一本“OpenGL参考大全”,因此,本书所涵盖的OpenGL部分只是其所有功能中的一小部分。我们的目标是以OpenGL作为基础工具,教授基于现代着色器的3D图形编程,并为读者提供足够深入的理解,以供进一步研究。

本书的主要目标读者是计算机科学专业的学生(可以是本科在读学生),其实任何想要学习计算机科学相关知识的人也适合阅读本书。因此,我们假设读者有扎实的面向对象编程基础,至少相当于计算机科学专业大二或大三学生的水平。

还有一些本书没有涵盖的内容,因为我们假设读者已经掌握了足够的背景知识,包括:

希望本书的潜在受众能够因为对其Java版的喜爱而进一步支持本书。正如前面所说的,我们期望看到这样一种情景——学生在同一门课中可以自由选择使用C++或Java版本的教材。由于这两本书按同样的节奏对教学内容进行组织编排,因此我们认为可以尝试以这种开放的方式来开展课程教学和学习。

本书从内容安排上适合从前往后阅读,即后面各章中的内容经常依赖于前面各章中所讲的内容。因此,在各章中来回跳跃地选择性阅读可能并不适合本书,读者最好逐章顺序阅读。

本书同时可以作为实用的动手指南。由于已经有许多其他偏理论的学习材料,因此读者应该将本书作为一本“练习册”,通过一边参考本书一边自己动手编程来理解基础概念。虽然我们为所有的示例提供了代码,但是想要真正理解这些概念,还是得自己动手“实现”这些代码——通过编程来搭建你自己的3D场景。

本书在第2章到第14章的最后都留给读者一些习题。有的题比较简单,仅仅需要对提供的代码进行简单改动就可以解决。那些标记为“项目”的习题,则需要读者花费更多的时间来解答,因为可能需要编写大量代码或者使用多个示例中用到的技术。少数标记为“研究”的习题,则在本书中并没有提供解题需要学习的细节知识,我们鼓励读者进行自主学习并解答。

OpenGL调用通常会有很长的参数列表。在撰写本书时,两位作者在每种情况下都会讨论是否要描述所有的参数。最终我们决定在最初的部分描述所有参数。随着主题深入,我们避免在每次OpenGL调用中陷入细枝末节的描述过程(因为调用的次数很多),以防读者失去对全局的理解。因此,在浏览示例时,读者需要在手边准备OpenGL和所使用的各种库的参考资料。

为此,我们建议结合一些优秀的在线资源使用本书。OpenGL的文档是绝对必要的。有关各种命令的详细信息,可以利用搜索引擎,或访问OpenGL的官方网站获取。

我们的示例中用到了称作GLM的数学库。在安装GLM(见附录)后,读者应该找到其在线文档并将其加入书签。

本书中经常用到的另一个库是SOIL2,用于读取和处理纹理图像文件,读者可能也需要定期查阅它的文档。SOIL2没有中心化的文档资源,但读者通过Web搜索可以找到一些例子。

还有许多关于3D图形编程的图书,我们建议与本书并行阅读(例如要解决各章后的“研究”问题)。以下是我们经常提到的5本。

本书提供随书的配套资源供读者下载,其内容有:

上述文件也可以通过访问异步社区(www.epubit.com)上的本书页面获取。

我们鼓励大学或学院的教师获取本书的教师辅助包,其中包含以下附加项:

教师辅助包可以通过联系出版商获取:contact@epubit.com.cn。

本书中的许多内容是基于我们之前出版的Computer Graphics Programming in OpenGL with Java。我们需要感谢许多帮助我们完成上一本书的人,他们继续为本书的编写提供了帮助。Java版早期的草稿被用于加州州立大学萨克拉门托分校的CSc-155(高级计算机图形编程)课程中,得到了学生的指正,他们还给出了修改建议(包括代码)。两位作者要特别感谢Mitchell Brannan、Tiffany Chiapuzio-Wong、Samson Chua、Anthony Doan、Kian Faroughi、Cody Jackson、John Johnston、Zeeshan Khaliq、Raymond Rivera、Oscar Solorzano、Darren Takemoto、Jon Tinney、James Womack 及 Victor Zepeda 的建议。

我们也从许多教师那里得到了很好的反馈,他们采用Computer Graphics Programming in OpenGL with Java作为课程教材,同时向我们分享了他们的教学经验。塔尔萨大学的Mauricio Papa博士和我们进行了几次对我们非常有帮助的邮件沟通。Sean McCrory对光照(第7章)和柏林噪声(第14章)出现的问题进行了非常详细的修正。他们的建议帮助我们对本书进行了改进。我们还收到了许多来自不同学校的学生提出的问题,这些问题帮助我们评估了我们编写方法的优缺点。

我们对本系列书的第一次试用是在2017年的秋天,当时我们的同事Pinar Muyan-Ozcelik博士在她教授的CSc-155课程上第一次使用了Computer Graphics Programming in OpenGL with Java,这让我们有机会评估我们是否实现了让本书成为“自学”资源的目标。课程进展顺利的同时,Pinar Muyan-Ozcelik博士也为每章留存了问题和更正日志。这份日志帮我们对本书进行了许多改进。

Martín Lucas Golini是SOIL2纹理图像处理库的开发者和维护者,也对本书表现出了极大的支持和热情。我们对他的帮助表示非常感谢。

Jay Turberville来自于亚利桑那州Scottsdale的Studio 522 Productions。他创建了本书英文版的封面和书中用到的海豚模型,学生们非常喜欢。Studio 522 Productions制作出极高质量的3D动画和视频,以及自定义3D建模。我们很感谢Turberville慷慨地为本书创建这个精美的模型。

我们还要感谢其他一些艺术家和研究人员。他们非常慷慨地让我们使用他们的模型和纹理。来自Planet Pixel Emporium的James Hastings-Trew提供了许多行星表面纹理。Paul Bourke允许我们使用他拥有的精彩的星域。斯坦福大学的Marc Levoy博士授权我们使用著名的“斯坦福龙”模型。Paul Baker的凹凸贴图教程是我们在许多例子中使用的“圆环”模型的基础。我们还要感谢Mercury Learning允许我们使用《DirectX 12 3D 游戏开发实战》[1]中的一些纹理。

Danny Kopec博士向我们介绍了Mercury Learning公司,并向它的出版商David Pallai引荐了我们。Kopec博士的《人工智能(第2版)》[2]的成功出版让我们考虑与Mercury合作,我们与Kopec的电话交谈也对我们非常有帮助。Kopec博士的早逝让我们深感悲痛,也对他没有机会看到本书的成书感到遗憾。

最后,我们要感谢Mercury Learning的David Pallai和Jennifer Blaney,他们一直保持着对这个项目的热情并引导我们完成了本书的整个出版流程。

如果你在阅读本书时发现任何错误,请告诉我们!尽管我们尽了最大努力,但本书肯定还有错误。当收到错误报告时,我们将会尽最大努力尽快发布。我们建立了一个用于收集并发布勘误的网页:

http://athena.ecs.csus.edu/~gordonvs/errata.html[3]

出版商Mercury Learning也保留了本书勘误表页面的链接。因此,如果我们的勘误页面的URL有变动,请查看Mercury Learning网站以获取最新链接。

[AS14] E. Angel and D. Shreiner, Interactive Computer Graphics: A Top-Down Approach with WebGL, 7th ed. (Pearson, 2014).

[KS16] J. Kessenich, G. Sellers, and D. Shreiner, OpenGL Programming Guide: The Official Guide to Learning OpenGL, Version 4.5 with SPIR-V, 9th ed. (Addison-Wesley, 2016).

[LU16] F. Luna, Introduction to 3D Game Programming with DirectX 12, 2nd ed. (Mercury Learning, 2016).

[SW15] G. Sellers, R. Wright Jr., and N. Haemel, OpenGL SuperBible: Comprehensive Tutorial and Reference, 7th ed. (Addison-Wesley, 2015).

[WO13] D. Wolff, OpenGL Shading Language Cookbook, 2nd ed. (Packt Publishing, 2013).

[1] 《DirectX 12 3D 游戏开发实战》已由人民邮电出版社出版(ISBN 978-7-115-47921-1)。——编者注

[2] 《人工智能(第2版)》已由人民邮电出版社出版(ISBN 978-7-115-48843-5)。——编者注

[3] 本书中文版已经对这些勘误进行了修改。——编者注


V.斯科特·戈登(V. Scott Gordon)博士已经在加州州立大学系统担任教授有20多年,目前在加州州立大学萨克拉门托分校教授高级图形和游戏工程课程。他撰写及合著了30多部出版物,涉及人工智能、神经网络、进化计算、软件工程、视频和策略游戏编程,以及计算机科学教育等多个领域。戈登博士在科罗拉多州立大学获得博士学位。他同时也是爵士鼓手和优秀的乒乓球运动员。

约翰·克莱维吉(John Clevenger)博士拥有超过40年的教学经验,教学内容包括高级图形、游戏架构、操作系统、VLSI芯片设计、系统仿真和其他主题。他是多个用于图形和游戏架构教学的软件框架和工具的开发人员,其中包括我们Java版第一版书中所用到的graphicslib3D库。他是国际大学生程序设计竞赛(ICPC)的技术总监,负责监督PC2的持续开发。PC2是目前世界上使用较为广泛的编程竞赛支持系统。克莱维吉博士在加州大学戴维斯分校获得博士学位。


本书由异步社区出品,社区(https://www.epubit.com/)为您提供相关资源和后续服务。

本书提供如下资源:

要获得以上配套资源,请在异步社区本书页面中单击,跳转到下载界面,按提示进行操作即可。注意:为保证购书读者的权益,该操作会给出相关提示,要求输入提取码进行验证。

如果您是教师,希望获得教学配套资源,请在社区本书页面中直接联系本书的责任编辑。

作者和编辑尽最大努力来确保书中内容的准确性,但难免会存在疏漏。欢迎您将发现的问题反馈给我们,帮助我们提升图书的质量。

当您发现错误时,请登录异步社区,按书名搜索,进入本书页面,单击“提交勘误”,输入勘误信息,单击“提交”按钮即可。本书的作者和编辑会对您提交的勘误进行审核,确认并接受后,您将获赠异步社区的100积分。积分可用于在异步社区兑换优惠券、样书或奖品。

我们的联系邮箱是contact@epubit.com.cn。

如果您对本书有任何疑问或建议,请您发邮件给我们,并请在邮件标题中注明本书书名,以便我们更高效地做出反馈。

如果您有兴趣出版图书、录制教学视频,或者参与图书翻译、技术审校等工作,可以发邮件给我们;有意出版图书的作者也可以到异步社区在线提交投稿(直接访问www.epubit.com/selfpublish/submission即可)。

如果您是学校、培训机构或企业,想批量购买本书或异步社区出版的其他图书,也可以发邮件给我们。

如果您在网上发现有针对异步社区出品图书的各种形式的盗版行为,包括对图书全部或部分内容的非授权传播,请您将怀疑有侵权行为的链接发邮件给我们。您的这一举动是对作者权益的保护,也是我们持续为您提供有价值的内容的动力之源。

“异步社区”是人民邮电出版社旗下IT专业图书社区,致力于出版精品IT技术图书和相关学习产品,为作译者提供优质出版服务。异步社区创办于2015年8月,提供大量精品IT技术图书和电子书,以及高品质技术文章和视频课程。更多详情请访问异步社区官网https://www.epubit.com。

“异步图书”是由异步社区编辑团队策划出版的精品IT专业图书的品牌,依托于人民邮电出版社近30年的计算机图书出版积累和专业编辑团队,相关图书在封面上印有异步图书的LOGO。异步图书的出版领域包括软件开发、大数据、AI、测试、前端、网络技术等。

异步社区

微信服务号


图形编程是计算机科学中最具挑战性的主题之一,并因此而闻名。当今,图形编程是基于着色器的——也就是说,有些程序是用诸如C++或Java等标准编程语言编写的,并运行在CPU上;而另一些是用专用的着色器语言编写的,并直接运行在显卡(GPU)上。着色器编程的学习曲线很陡峭,以致哪怕是绘制简单的东西,也需要一系列错综复杂的步骤,把图形数据从一个“管线”中传递下去才能完成。现代显卡能够并行处理数据,即使是绘制简单的形状,图形程序员也必须理解GPU的并行架构。

虽然这并不简单,但回报是超强的渲染能力。电子游戏中涌现出来的令人惊艳的虚拟现实和好莱坞电影中越来越逼真的特效,很大程度上是由着色器编程的进步带来的。如果阅读本书是你进入3D图形世界的第一步,那么你正在开始接受一个对自己的挑战。挑战的奖励不仅仅是漂亮的图片,还有过往不敢想象的对机器的掌控程度。欢迎来到激动人心的计算机图形编程世界!

现代图形编程使用图形库完成,也就是说,程序员编写代码时,调用一个预先定义的库(或者一系列库)中的函数,由这个库来提供对底层图形操作的支持。现在有很多图形库,但常见的平台无关图形编程库叫作OpenGL(Open Graphics Library,开放图形库)。本书将会介绍如何在C++中使用OpenGL进行3D图形编程。

在C++中使用OpenGL需要配置多个库。这里按照个人需求,可以有一系列令人眼花缭乱的选择。在本节中,我们会介绍哪几种库是必要的,各种库的一些常见选择,以及我们在本书中选择的库。

总的来说,你需要以下这些语言和库:

读者可能需要进行几个准备步骤,以保证这几种库已安装在系统中,并可以正常使用。下面几个小节将简单介绍每一种语言和库。安装和配置的更多细节请参阅附录。

C++是一种通用编程语言,最早出现在20世纪80年代中期。它的设计,以及它通常被编译成本机的机器码这一事实,使得它成为了需要高性能的系统的优秀选择,比如3D图形计算。C++的另一个优点是OpenGL调用库是基于C语言开发的。

有许多可用的C++开发环境。在阅读本书时,如果读者使用PC(Windows操作系统),我们推荐使用Microsoft Visual Studio [VS17];如果在苹果计算机上,我们推荐Xcode [XC18]。附录中也介绍了各个平台下的安装和配置。

OpenGL的1.0版本出现在1992年,是一种对供应商特定的计算机图形应用编程接口(API)的“开放性”替代。

它的规范和开发工作由当时新成立的OpenGL架构评审委员会(ARB)管理和控制。ARB是一群行业参与者组成的小组。2006年,ARB将OpenGL规范的控制权交给了Khronos Group。Khronos Group是一个非营利性联盟,不仅管理OpenGL标准,还管理很多其他的开放性行业标准。

从一开始,OpenGL就定期修订和扩展。2004年,2.0版本中引入了OpenGL着色语言(GLSL),使得“着色器程序”可以在图形管线的各个阶段被安装和直接执行。

2009年,3.1版本中移除了大量被弃用的功能,以强制使用着色器编程,而不是之前的老方法(叫作“立即模式”)。[1]在最近的功能中,4.0版本(2010年)在可编程管线中增加了一个细分阶段。

这本书假定用户的机器有一个支持至少4.3版本OpenGL的显卡。如果你不确定你的GPU支持哪个版本的OpenGL,网上有免费的应用程序可以用来找出答案。有一个这样的应用程序是GLView,由“realtech VR”公司提供[GV16]

OpenGL实际上并不是把图像直接绘制到计算机屏幕上,而是渲染到一个帧缓冲区,然后需要由这台机器来负责把帧缓冲区的内容绘制到屏幕上的一个窗口中。有不少库都可以支持这一部分工作。一个选择是使用操作系统提供的窗口管理功能,比如Microsoft Windows API。但这通常是不实用的,需要很多底层的编码工作。GLUT库曾经是一个很流行的选择,但现在已经被弃用了。它的一个现代化的演变是freeglut库。其他相关的选项还有CPW库、GLOW库和GLUI库。

GLFW是最流行的选择之一,也是我们这本书中选择使用的。它内置了对Windows、macOS、Linux和其他操作系统[GF17]的支持。它可以在其官网下载,并且必须在要使用它的机器上编译。(我们在附录中介绍了相关步骤。)

OpenGL围绕一组基本功能和扩展机制进行组织。随着技术的发展,扩展机制可以用来支持新的功能。现代版本的OpenGL,比如我们在本书中使用的4以上版本,需要识别GPU上可用的扩展。OpenGL核心中有一些内置的命令用来支持这些,但是为了使用每个现代命令,需要执行很多相当复杂的代码行。在本书中,我们会持续不断地使用这些命令。所以使用一个扩展库来处理这些细节已经成了标准做法,这样能让程序员可以直接使用现代OpenGL命令。比如Glee、GLLoader和GLEW,以及更加新的GL3W和GLAD。

列出的这些库中,常用的是GLEW,意思是OpenGL扩展牧马人(OpenGL Extension Wrangler)。它支持各种操作系统,包括Windows、Macintosh和Linux [GE17]。GLEW不是一个完美的选择。例如,它需要一个额外的DLL。最近,很多开发者选择GL3W或者GLAD。它们的优势是可以自动更新,但是要求安装Python。因为这些原因,在本书中我们选择使用GLEW。它可以在官网下载。附录中给出了安装和配置GLEW的完整说明。

3D图形编程大量使用向量和矩阵代数。因此,配合一个支持常见数学计算任务的函数库或者类包,能极大地方便OpenGL的使用。常常和OpenGL一起使用的两个这样的库是Eigen和vmath。后者在流行的OpenGL SuperBible [SW15]中被使用。

可能最流行的数学库,也是本书中使用的,是OpenGL Mathematics,一般称作GLM。它是一个只有头文件的C++库,兼容Windows、macOS和Linux [GM17]。GLM命令很方便地遵循和GLSL相同的命名惯例,使得来回阅读特定应用程序的C++和GLSL代码时更容易。GLM可以在官网下载。

GLM提供与图形概念相关的类和基本数学函数,例如矢量矩阵四元数。它还包含各种工具类,用于创建和使用常见的3D图形结构,例如透视和视角矩阵。它最早在2005年发布,由Christophe Riccio [GM17]维护。有关安装GLM的说明,请参阅附录。

从第5章开始,我们将使用图像文件来向我们图形场景中的对象添加“纹理”。这意味着我们会需要频繁加载这些图像文件到我们的C++ / OpenGL代码中。从零开始写一个纹理图像加载器是可能的。但是,考虑到各种各样的图像文件格式,使用一个纹理加载库通常是更好的。比如FreeImage、DevIL、OpenGL Image (GLI)和Glraw。简单OpenGL图像加载器(Simple OpenGL Image Loader,SOIL)可能是最常用的OpenGL图像加载库,尽管它有点过时了。

本书中使用的纹理图像加载库是SOIL2——SOIL的一个更新的分叉版本。像我们之前选择的库一样,SOIL2兼容各种平台[SO17]。附录中给出了详细的安装和配置说明。

读者可能希望利用很多其他有用的库。例如,在本书中,我们将展示如何从零开始实现一个简单的“OBJ”模型加载器。然而,正如我们将看到的,它没有处理OBJ标准中可用的很多选项。有一些更复杂的现成的OBJ加载器可供选择,比如Assimp和tinyobjloader。在我们的例子中,我们会只用在本书中介绍和实现的简单模型加载器。

在开发本书的C++版本时,我们斗争了很久,想要找到囊括用来运行示例程序的平台特定配置信息的最佳方法。配置用C++来使用OpenGL的系统,要比用Java配置复杂得多。Java版本的配置只需要几个短段落就可以描述完毕(正如在本书Java版中看到的[GC18])。最终,我们选择把安装和配置信息在各平台特定的附录中分别描述。我们希望这能为每个读者提供一个相关的地方来寻找关于他/她的系统的特定信息,而不是被和他/她无关的其他平台的信息干扰。在这个版本中,我们在附录A中提供了Microsoft Windows平台的详细配置教程,在附录B中提供了苹果Macintosh平台的详细配置教程。

[GC18] V. Gordon and J. Clevenger, Computer Graphics Programming in OpenGL with Java, 2nd ed. (Mercury Learning, 2018).

[GE17] OpenGL Extension Wrangler (GLEW), accessed October 2018.

[GF17] Graphics Library Framework (GLFW), accessed October 2018.

[GM17] OpenGL Mathematics (GLM), accessed December 2017.

[GV16] GLView, realtech-vr, accessed October 2018.

[SO17] Simple OpenGL Image Library 2 (SOIL2), SpartanJ, accessed October 2018.

[SW15] G. Sellers, R. Wright Jr., and N. Haemel, OpenGL SuperBible: Comprehensive Tutorial and Reference, 7th ed. (Addison-Wesley, 2015).

[VS17] Microsoft Visual Studio downloads, accessed October 2018.

[XC18] Apple Developer site for Xcode, accessed January 2018.

[1] 尽管如此,许多显卡厂商(比如NVIDIA)依然继续支持被弃用的功能。


OpenGL是整合软硬件的多平台2D和3D图形API。使用OpenGL需要显卡(GPU)支持足够新版的OpenGL(如第1章所述)。

在硬件方面,OpenGL提供了一个多级图形管线,可以使用一种名为GLSL的语言进行部分编程。

软件方面,OpenGL的API是用C语言编写的,因此API调用直接兼容C和C++。对于十几种其他的流行语言(Java、Perl、Python、Visual Basic、Delphi、Haskell、Lisp、Ruby等),OpenGL也有着稳定的库(或“包装器”),具有与C语言库几乎相同的性能。本书使用的C++,应该是目前流行的OpenGL语言。使用C++时,程序员编写在CPU上运行的(编译后的)代码并包含OpenGL调用。当一个C++程序包含OpenGL调用时,我们将其称为C++/OpenGL应用程序。C++/OpenGL应用程序的一个重要任务是将程序员的GLSL代码安装到GPU上。

基于C++的图形应用大致如图2.1所示,其中软件部分以底色突出显示。

图2.1 基于C++的图形应用概览

在我们后面的代码中,一部分用C++编码,进行OpenGL调用;另一部分是GLSL。C++/OpenGL应用程序、GLSL模块和硬件一起用来生成3D图形输出。当应用完成之后,最终用户直接与C++应用程序进行交互。

GLSL是一种着色器语言。着色器语言主要运行于GPU上,在图形管线上下文中。还有一些其他的着色器语言,如HLSL,用于微软的3D框架DirectX。GLSL是与OpenGL兼容的专用着色器语言,因此我们在C++/OpenGL应用代码之外,需要用GLSL写着色器代码。

本章其余内容将简单地浏览OpenGL管线的内容。读者不用期望详细理解所有细节,这里只要对各阶段如何工作有大致感觉即可。

现代3D图形编程会使用管线的概念,在管线中。将3D场景转换成2D图形的过程被分割成许多步骤。OpenGL和DirectX使用了相似的管线概念。

图2.2展示了OpenGL图形管线简化后的概览(并未展示所有阶段,仅包含我们要学习的主要阶段)。C++/OpenGL应用发送图形数据到顶点着色器,随着管线处理,最终生成在显示器上显示的像素点。

图2.2 OpenGL管线概览

用灰色阴影表示的阶段(顶点着色器、曲面细分着色器、几何着色器、片段着色器)可以用GLSL进行编程。将GLSL程序载入这些着色器阶段也是C++/OpenGL程序的责任之一,其过程如下。

(1)首先使用C++获取GLSL着色器代码,既可以从文件中读取,也可以硬编码在字符串中。

(2)接下来创建OpenGL着色器对象并将GLSL着色器代码加载进着色器对象。

(3)最后,用OpenGL命令编译并连接着色器对象,并将它们安装进GPU。

在实践中,一般至少要提供顶点着色器和片段着色器阶段的GLSL代码,而曲面细分着色器和几何着色器阶段是可选的。接下来我们将简单地过一下整个过程,并看看每步发生了什么。

我们的图形应用程序中大部分是使用C++进行编写的。根据程序目的的不同,它可能需要用标准C++库与最终用户交互,用OpenGL调用实现与3D渲染相关的任务。正如前面章节所述,我们将会使用一些扩展库:GLEW(OpenGL Extension Wrangler)、GLM(OpenGL Mathmatics)、SOIL2(Simple OpenGL Image Loader)以及GLFW(Graphics Library Framework)。

GLFW库包含了GLFWwindow类,我们可以在其上进行3D场景绘制。如前所述,OpenGL也向我们提供了用于将GLSL程序安装到可编程着色器阶段并编译的命令。最后,OpenGL使用缓冲区将3D模型和其他相关图形数据发送到管线中。

在我们尝试编写着色器之前,先写一个简单的C++/OpenGL程序,创建一个GLFWwindow实例并为其设置背景色。这个过程根本用不到着色器!其代码如程序2.1所示。程序2.1中的main()函数与本书中所有将会用到的main()函数一样。其中重要的操作有:(a)初始化GLFW库;(b)实例化GLFWwindow;(c)初始化GLEW库;(d)调用一次init()函数;(e)重复调用display()函数。

我们将每个应用程序的初始化任务都放在init()函数中,用于绘制GLFWwindow的代码都将放在display()函数中。

在本例中,glClearColor()命令指定了清除背景时用的颜色值——这里(1,0,0,1)代表RGB值中的红色(末尾的1表示不透明度)。接下来使用OpenGL调用glClear(GL_COLOR_BUFFER_BIT),实际使用红色对颜色缓冲区进行填充。

程序2.1 第一个C++/OpenGL应用程序

#include <GL\glew.h>
#include <GLFW\glfw3.h>
#include <iostream>

using namespace std;

void init(GLFWwindow* window) { }

void display(GLFWwindow* window, double currentTime) { 
   glClearColor(1.0, 0.0, 0.0, 1.0); 
   glClear(GL_COLOR_BUFFER_BIT);
}

int main(void) { 
    if (!glfwInit()) { exit(EXIT_FAILURE); } 
    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4); 
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3); 
    GLFWwindow* window = glfwCreateWindow(600, 600, "Chapter2 - program1", NULL, NULL); 
    glfwMakeContextCurrent(window); 
    if (glewInit() != GLEW_OK) { exit(EXIT_FAILURE); } 
    glfwSwapInterval(1); 

    init(window); 

    while (!glfwWindowShouldClose(window)) { 
        display(window, glfwGetTime()); 
        glfwSwapBuffers(window); 
        glfwPollEvents(); 
    } 

    glfwDestroyWindow(window); 
    glfwTerminate(); 
    exit(EXIT_SUCCESS);
}

图2.3展示了程序2.1的输出。

图2.3 程序2.1的输出

这些函数部署的机制如下:GLFW和GLEW库先分别使用glfwInit()和glewInit()初始化。glfwCreateWindow()命令负责创建GLFW窗口,同时其相关的OpenGL上下文[1]由glfwCreateWindow()命令创建,其可选项由前面的WindowHints设置。WindowHints指定了机器必须与OpenGL版本4.3兼容(“主版本号”=4,“次版本号”=3)。glfwCreateWindow命令的参数指定了窗口的宽、高(以像素为单位)以及窗口顶部的标题。(这里没有用到的另外两个参数设为NULL,分别用来允许全屏显示以及资源共享。)glfwSwapInterval()命令和glfwSwapBuffers命令用来开启垂直同步(Vsync)——GLFW窗口默认是双缓冲的。[2]这里需要注意,创建GLFW窗口并不会自动将它与当前OpenGL上下文关联起来——因此我们需要调用glfwMakeContextCurrent()。

main()函数包括了一个简单的渲染循环,用来反复调用display()方法。同时它也调用了glfwSwapBuffers()以绘制屏幕,以及glfwPollEvents()以处理窗口相关事件(如按键事件)。当GLFW探测到应该关闭窗口的事件(如用户单击了右上角的×)时,循环就会终止。这里需要注意,我们将一份GLFW窗口对象的引用传入了init()和display()调用。这些函数在特定环境下需要访问GLFW窗口对象。同时我们也将当前时间传入了display()调用,这样方便保证动画在不同计算机上以相同速度播放。在这里,我们用了glfwGetTime(),它会返回GLFW初始化之后经过的时间。

现在是时候详细看看程序2.1中的OpenGL调用了。首先关注一下这个调用:

glClear(GL_COLOR_BUFFER_BIT);

在这里,调用的OpenGL参考文档中的描述是:

void glClear(GLbitfield mask);

参数中引用了类型为GLbitfield的“GL_COLOR_BUFFER_BIT”。OpenGL有很多预定义的常量(其中很多是枚举量)。GL_COLOR_BUFFER_BIT引用了包含渲染后像素的颜色缓冲区。OpenGL有多个颜色缓冲区,这个命令会将它们全部清除——用一种被称为“清除色(clear color)”的预定义颜色填充所有缓冲区。注意,这里的“清除(clear)”表示的不是“颜色清晰”,而是用来重置缓冲区时填充的颜色(清除)。

在调用glClear()后紧接着是glClearColor()的调用。glClearColor()让我们能够指定颜色缓冲区清除后填充的值。这里我们指定了(1,0,0,1),即RGBA颜色中的红色。

最后,当用户尝试关闭GLFW窗口时,程序将退出渲染循环。这时,main()会通过分别调用glfwDestroyWindow()和glfwTerminate()通知GLFW销毁窗口以及终止运行。

在第一个OpenGL程序中,我们实际上并没有绘制任何东西——仅仅用一种颜色来填充了颜色缓冲区。要真的绘制点什么,我们需要加入顶点着色器和片段着色器。

你可能对于学习OpenGL只让你绘制少数几类非常简单的东西有点吃惊,如点、线、三角形。这些简单的东西叫作图元,多数3D模型通常是由许多三角形的图元构成。图元由顶点组成——例如三角形有3个顶点。顶点可以由很多来源产生——从文件读取并由C++/ OpenGL应用载入缓冲区、直接在C++文件中硬编码或者直接在GLSL代码中。

在加载顶点之前,C++/OpenGL应用必须编译并链接合适的GLSL顶点着色器和片段着色器程序,之后将它们载入管线。我们稍后将会看到这些命令。

C++/OpenGL应用同时也负责通知OpenGL构建三角形,通过使用如下OpenGL函数完成:

glDrawArrays(GLenum mode, Glint first, GLsizei count);

mode参数是图元的类型——对于三角形我们用GL_TRIANGLES。first参数表示从哪个顶点开始绘制(通常是顶点0,即第一个顶点),count表示总共要绘制的顶点数。

当调用glDrawArrays()时,管线中的GLSL代码开始执行。现在可以向管线加一些GLSL代码了。

不管它们从何处读入,所有的顶点都会被传入顶点着色器。顶点们会被一个一个地处理,即着色器会对每个顶点执行一次。对拥有很多顶点的大型复杂模型而言,顶点着色器会执行成百上千甚至百万次,这些执行过程通常是并行的。

现在,我们来写一个简单的程序,它仅包含一个顶点,硬编码于顶点着色器中。虽然这不足以让我们画三角形,但是足够画出一个点。为了显示这个点,我们还需要提供片段着色器。为简单起见,我们将这两个着色器程序声明为字符串数组。

程序2.2 着色器,画一个点

(#include列表与之前相同)
#define numVAOs 1     ⇽--- 新的定义

GLuint renderingProgram;      
GLuint vao[numVAOs];

GLuint createShaderProgram() { 
  const char *vshaderSource = 
    "#version 430 \n" 
    "void main(void) \n" 
    "{ gl_Position = vec4(0.0, 0.0, 0.0, 1.0); }"; 

  const char *fshaderSource = 
    "#version 430 \n" 
    "out vec4 color; \n"
    "void main(void) \n" 
    "{ color = vec4(0.0, 0.0, 1.0, 1.0); }"; 

  GLuint vShader = glCreateShader(GL_VERTEX_SHADER); 
  GLuint fShader = glCreateShader(GL_FRAGMENT_SHADER); 

  glShaderSource(vShader, 1, &vshaderSource, NULL); 
  glShaderSource(fShader, 1, &fshaderSource, NULL); 
  glCompileShader(vShader); 
  glCompileShader(fShader); 

  GLuint vfProgram = glCreateProgram(); 
  glAttachShader(vfProgram, vShader); 
  glAttachShader(vfProgram, fShader); 
  glLinkProgram(vfProgram); 

  return vfProgram;
}

void init(GLFWwindow* window) { 
  renderingProgram = createShaderProgram(); 
  glGenVertexArrays(numVAOs, vao); 
  glBindVertexArray(vao[0]);
}

void display(GLFWwindow* window, double currentTime) { 
  glUseProgram(renderingProgram); 
  glDrawArrays(GL_POINTS, 0, 1);
}

...main()函数与之前相同

程序看起来只显示了一个空的窗口(见图2.4)。但仔细观察一下,会发现窗口中央有一个蓝色的点(假设本页印刷精度足够)。OpenGL中点的默认大小为1像素。

图2.4 程序2.2的输出效果

程序2.2中有很多值得讨论的重要细节(为方便起见已用颜色标出)。首先,注意其中多次用到的“Gluint”——这是由OpenGL提供的“unsigned int”的平台无关简写(许多OpenGL结构体都是整数类型引用)。接下来,init()不再是空函数了——现在它会调用另一个叫作“createShaderProgram”的函数(我们写的)。“createShaderProgram”函数先定义了两个字符串vshaderSource和fshaderSource。之后调用了两次glCreateShader()函数,创建了类型为GL_VERTEX_SHADER和GL_FRAGMENT_SHADER的两个着色器。OpenGL创建每个着色器对象(初始值为空)的时候,会返回一个整数ID作为后面引用它的序号——我们的代码将这个ID存入了vShader和fShader变量中。之后,createShaderProgram()调用了glShaderSource(),这个函数用于将GLSL代码从字符串载入空着色器对象中。之后,用glCompileShader()编译各着色器。glShaderSource()有4个参数:(a)用来存放着色器的着色器对象,(b)着色器源代码中的字符串数量,(c)包含源代码的字符串指针,(d)最后一个没用到的参数(我们稍后会在补充章节说明中解释这个参数)。注意,这两次调用glCompileShader()时都指明了着色器的源代码字符串数量为“1”——这个参数也会在补充说明中解释。

之后应用程序创建了一个叫作vfProgram的程序对象,并储存指向它的整数ID。OpenGL“程序”对象包含一系列编译过的着色器,这里可以看到使用glCreateProgram()创建程序对象,使用glAttachShader()将着色器加入程序对象,之后使用glLinkProgram()来请求GLSL编译器确保它们的兼容性。

如前所见,在init()结束后程序调用了display()。display()函数所做的事情中包含调glUseProgram(),它将含有两个已编译着色器的程序载入OpenGL管线阶段(在GPU上!)。注意glUseProgram()并没有运行着色器,它只是将着色器加载进硬件。

我们稍后在第4章会看到,一般情况下,这里C++/OpenGL将会准备要发送给管线绘制的模型的顶点集。但是本例中,由于是第一个着色器程序,我们仅仅在顶点着色器中硬编码了一个顶点。因此,本例中的display()函数接着调用了glDrawArrays()用来启动管线处理过程。原始类型是GL_POINTS,仅用来显示一个点。

现在我们来看一下着色器,在之前用绿色展示(并在之后的解释中又重复了一遍)。正如我们所看到的,在C++/OpenGL程序中,它们声明为字符串数组。这是一种笨拙的编程方式,不过在这个超简单的例子中足够了。这个顶点着色器是:

#version 430
void main(void)
{   gl_Position = vec4(0.0, 0.0, 0.0, 1.0); }

第一行指明了OpenGL版本,这里是4.3。接下来是一个“main”函数(我们后面将会看到,GLSL句法上与C++类似)。所有顶点着色器的主要目标都是将顶点发送给管线(正如之前所说的,它会对每个顶点进行处理)。内置变量gl_Position用来设置顶点在3D空间的坐标位置,并发送至下一个管线阶段。GLSL数据类型vec4用来存储4元组,适合用来存储坐标,4元组的前3个值分别表示XYZ,第4个值在这里设为1.0(第3章将会学习第4个值的用途)。本例中,顶点坐标硬编码于原点(0,0,0)。

顶点接下来将沿着管线移动到光栅着色器,它们会在这里被转换成像素位置(更精确地说是片段——后面会解释)。最终这些像素(片段)到达片段着色器:

#version 430
out vec4 color;
void main(void)
{   color = vec4(0.0, 0.0, 1.0, 1.0); }

所有片段着色器的目的都是给将要展示的像素赋予RGB颜色。在本例中所指定的输出颜色值(0,0,1)是蓝色(第4个值1.0是不透明度)。注意这里的“out”标签表明color变量是输出变量。(在顶点着色器中并不是必须给gl_Position指定“out”标签,因为gl_Position是预定义的输出变量。)

代码中还有一处我们没有讨论的细节,即init()函数中的最后两行(以红色显示)。它们看起来可能有些神秘。我们在第4章中将会看到,当准备将数据集发送给管线时是以缓冲区形式发送的。这些缓冲区最后都会被存入顶点数组对象(Vertex Array Object,VAO)中。在本例中,我们向顶点着色器中硬编码了一个点,因此我们不需要任何缓冲区。但是,即使应用程序完全没有用到任何缓冲区,OpenGL仍然需要在使用着色器的时候至少有一个创建好的VAO,所以这两行用来创建OpenGL要求的VAO。

最后的问题就是从顶点着色器出来的顶点是如何变成片段着色器中的像素的。回忆一下图2.2中,在顶点处理和像素处理中间存在着光栅化阶段。正是在这个阶段中图元(如点或三角形)转换成了像素集合。OpenGL中默认点的大小为1像素,这就是为什么我们的单点最终渲染成了单个像素。

让我们将下面的命令加入display()函数中,就放在调用glDrawArrays()之前:

glPointSize(30.0f);

现在,当光栅化阶段从顶点着色器收到顶点时,它会为一个大小是30像素的点设置像素颜色值。输出的结果展示在图2.5中(见彩插)。

图2.5 改变glPointSize

让我们继续观察剩下的OpenGL管线。

我们在第12章中介绍曲面细分。可编程曲面细分阶段是最近加入OpenGL(在4.0版中)的功能。它提供了一个曲面细分着色器用以生成大量三角形,通常是网格形式。同时也提供一些可以以各种方式操作这些三角形的工具。例如,程序员可能需要以图2.6展示的方式操作一个曲面细分过的三角形网格。

图2.6 曲面细分着色器生成的网格

当在简单形状上需要很多顶点时,曲面细分着色器就能发挥作用了,如在方形区域或曲面上。稍后我们会看到,它在生成复杂地形时也很有用。对于这种情况,有时用GPU中的曲面细分着色器在硬件里生成三角形网格比在C++中生成要高效得多。

我们在第13章中介绍了几何着色器阶段。顶点着色器赋予程序员一次操作一个顶点的能力(“按顶点”处理),片段着色器(稍后会看到)允许一次操作一个像素(“按片段”处理),几何着色器赋予了一次操作一个图元的能力(“按图元”处理)。

回顾前文提到最通用的图元是三角形。当我们到达几何着色器阶段时,管线肯定已经完成了将顶点组合为三角形的过程(这个过程叫作图元组装)。接下来几何着色器会让程序员可以同时访问每个三角形的所有顶点。

按图元处理有很多用途,既可以让图元变形,比如拉伸或者缩小,还可以删除一些图元,从而在渲染的物体上产生“洞”——这是一种将简单模型转化为复杂模型的方法。

几何着色器也提供了生成额外图元的方法。这些方法也打开了通过转换简单模型而得到复杂模型的大门。几何着色器有一种有趣的用法,就是在物体上增加表面纹理,如凸起、鳞甚至“毛发”。考虑图2.7所示的简单环面(本书后面会介绍如何生成它)。环面的表面由上百个三角形构成。如果我们用几何着色器对每个三角形外面增加一个额外的三角形,就会得到如图2.8所示的结果。这个“鳞环面”如果是从C++/OpenGL应用程序那边从零建模生成,代价就大了。

图2.7 环面模型

图2.8 几何着色器修改后的环面

在曲面细分阶段已经给程序员同时访问模型中所有顶点的能力后,再提供一个按图元运算的着色器阶段可能看起来有点多余。它们的区别是,曲面细分只在非常少数情况下提供了这个能力——尤其在模型是由曲面细分器生成的三角形网格时。它并没有提供同时访问所有顶点,即任何从C++用缓冲区传来的顶点的能力。

最终,我们3D世界中的点、三角形、颜色等全都需要展现在一个2D显示器上。这个2D屏幕由光栅——矩形像素阵列组成。

当3D物体光栅化后,OpenGL将物体中的图元(通常是三角形)转化为片段。片段拥有关于像素的信息。光栅化过程确定了用以显示3个顶点所确定的三角形的所有像素需要绘制的位置。

光栅化过程开始时先对三角形的每对顶点进行插值。插值过程可以通过选项调节。就目前而言,使用图2.9所示的简单的线性插值就够了。原本的3个顶点标记为红色(见彩插)。

图2.9 光栅化(步骤1)

如果光栅化过程到此为止,那么呈现出的图像将会是线框模型。呈现线框模型也是OpenGL中的一个选项。通过在display()函数中glDrawArrays()调用之前添加如下命令:

glPolygonMode(GL_FRONT_AND_BACK, GL_LINE);

如果2.1.4小节中的环面使用了这行额外代码,它将会看起来如图2.10所示。

图2.10 使用线框模型渲染的环面

如果我们不加入之前的那一行代码(或者我们在其中使用GL_FILL而非GL_LINE),插值过程将会继续沿着光栅线填充三角形的内部,如图2.11所示。当应用于环面时会产生一个完全光栅化的“实心”环面,如图2.12(左)所示。请注意,在这种情况下,环面的整体形状和曲率不明显——这是因为我们没有包括任何纹理或照明技术,因此它看起来是“平”的。图2.12(右)是同样的“平”环面叠加了线框模型。前面图2.7所示的环面包括了照明效果,因此更清晰地显示了环面的形状。我们将在第7章学习照明。

图2.11 完全光栅化的三角形

图2.12 环面的完全光栅化图元渲染(左)和使用线框叠加(右)

在本章后面我们将看到,光栅化不仅可以对像素插值。任何顶点着色器输出的变量和片段着色器的输入变量都可以基于对应的像素进行插值。我们将会使用该功能生成平滑的颜色渐变,实现真实光照以及许多其他效果。

如前所述,片段着色器用于为光栅化的像素指定颜色。我们已经在程序2.2中看到了片段着色器示例。在程序2.2中,片段着色器仅将输出硬编码为特定值,从而为每个输出的像素赋予相同的颜色。不过GLSL为我们提供了其他计算颜色的方式,用以表现无穷的创造力。

一个简单的例子就是基于像素位置决定输出颜色。回忆我们在顶点着色器中,顶点的输出坐标使用了预定义变量gl_Position。在片段着色器中,同样有一个变量让程序员可以访问输入片段的坐标,叫作gl_FragCoord。我们可以通过修改程序2.2中的片段着色器,让它使用gl_FragCoord(在本例中通过GLSL属性选择语法引用它的X坐标)基于位置设置每个像素的颜色,如:

#version 430
out vec4 color;
void main(void)
{ if (gl_FragCoord.x < 200) color = vec4(1.0, 0.0, 0.0, 1.0); else color = vec4(0.0, 0.0, 1.0, 1.0);
}

如果我们像在2.1.2小节末尾那样增大GL_PointSize,渲染的点的像素颜色将会以坐标变化——X坐标小于200时是红色,否则就是蓝色,如图2.13所示(见彩插)。

图2.13 片段着色器颜色变化

当我们在display()方法中使用glDrawArrays()命令绘制场景中的物体时,我们通常期望前面的物体挡住后面的物体。这也可以推广到物体自身,我们通常期望看到物体的正面对着我们,而不是背对我们。

为了实现这个效果,我们需要隐藏面消除(Hidden Surface Removal,HSR)。基于场景需要的不同效果,OpenGL可以进行一系列不同的HSR操作。虽然这个阶段不可编程,但是理解它的工作原理是非常重要的。我们不仅需要正确地配置它,之后还需要在给场景添加阴影时对它进行进一步操作。

OpenGL通过精巧地协调两个缓冲区完成隐藏面消除:颜色缓冲区(我们之前讨论过)和深度缓冲区(也叫作Z缓冲、Z-buffer)。这两个缓冲区都和光栅的大小相同——即对于屏幕上每个像素,在两个缓冲区都各有一个对应条目。

当绘制场景中的各种对象时,片段着色器会生成像素颜色。像素颜色会存放在颜色缓冲区中——颜色缓冲区最终会被写入屏幕。当多个对象占据颜色缓冲区中的相同像素时,必须根据哪个对象最接近观察者来确定保留哪个像素颜色。

隐藏面消除按照如下步骤完成。

(1)在每个场景渲染前,深度缓冲区全部初始化为表示最大深度的值。

(2)当像素颜色由片段着色器输出时,计算它到观察者的距离。

(3)如果距离小于深度缓冲区存储的值(对当前像素),那么用当前像素颜色替换颜色缓冲区中的颜色,同时用当前距离替换深度缓冲区中的值,否则抛弃当前像素。

这个过程叫作Z-Buffer算法,如图2.14所示。

图2.14 Z-buffer算法

编译和运行GLSL代码与普通编码的过程不同,GLSL编译发生在C++运行时。另外一个复杂的地方是GLSL代码并没有运行在CPU中(它运行在GPU上),因此操作系统不总是能够捕获OpenGL运行时的错误。以上这些使得调试变得很困难,因为常常很难检测着色器是否失败,以及为什么失败。

程序2.3展示了用于捕获和显示GLSL错误的模块。其中GLSL函数glGetShaderiv()和glGetProgramiv()用于提供有关编译过的GLSL着色器和程序的信息。还有之前程序2.2中的createShaderProgram()函数,不过加入了错误检测的调用。

程序2.3包含如下3个实用程序。

checkOpenGLError()既用于检测GLSL编译错误,又用于检测OpenGL运行时的错误,因此我们强烈建议在整个C++/OpenGL应用程序开发过程中使用它。例如,在之前的程序2.2中,对于glCompileShader()和glLinkProgram()的调用很容易用程序2.3的代码进行加强,来确认所有的拼写错误和编译错误都能被捕获到,同时报告其原因。

用这些工具很重要的另一个原因是,GLSL错误并不会导致C++程序崩溃。因此,除非程序员通过步进找到错误发生的点,否则调试会非常困难。

程序2.3 用以捕获GLSL错误的模块

void printShaderLog(GLuint shader) { 
   int len = 0; 
   int chWrittn = 0; 
   char *log; 
   glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &len); 
   if (len > 0) { 
        log = (char *)malloc(len); 
        glGetShaderInfoLog(shader, len, &chWrittn, log); 
        cout << "Shader Info Log: " << log << endl; 
        free(log);
} }

void printProgramLog(int prog) { 
   int len = 0; 
   int chWrittn = 0; 
   char *log; 
   glGetProgramiv(prog, GL_INFO_LOG_LENGTH, &len); 
   if (len > 0) { 
        log = (char *)malloc(len); 
        glGetProgramInfoLog(prog, len, &chWrittn, log); 
        cout << "Program Info Log: " << log << endl; 
        free(log);
} }

bool checkOpenGLError() { 
   bool foundError = false; 
   int glErr = glGetError(); 
   while (glErr != GL_NO_ERROR) { 
      cout << "glError: " << glErr << endl; 
      foundError = true; 
      glErr = glGetError(); 
   } 
   return foundError;
}

检测OpengGL错误的示例如下:

GLuint createShaderProgram() { 
  GLint vertCompiled; 
  GLint fragCompiled; 
  GLint linked; 
  . . . 
  // 捕获编译着色器时的错误

  glCompileShader(vShader); 
  checkOpenGLError(); 
  glGetShaderiv(vShader, GL_COMPILE_STATUS, &vertCompiled); 
  if (vertCompiled != 1) { 
       cout << "vertex compilation failed" << endl; 
       printShaderLog(vShader); 
  }

  glCompileShader(fShader); 
  checkOpenGLError(); 
  glGetShaderiv(fShader, GL_COMPILE_STATUS, &fragCompiled); 
  if (fragCompiled != 1) { 
       cout << "fragment compilation failed" << endl; 
       printShaderLog(fShader); 
  }

  // 捕获链接着色器时的错误

  glAttachShader(vfProgram, vShader); 
  glAttachShader(vfProgram, fShader); 
  glLinkProgram(vfProgram); 
  checkOpenGLError(); 
  glGetProgramiv(vfProgram, GL_LINK_STATUS, &linked); 
  if (linked != 1) { 
       cout << "linking failed" << endl; 
       printProgramLog(vfProgram); 
  } 
  return vfProgram;
}

还有一些其他用于推测着色器代码运行时错误成因的技巧。着色器运行时错误的常见结果是输出屏幕上完全空白,根本没有输出。即使是着色器中的一个小拼写错误也可能导致这种结果,这样就很难断定是哪个管线阶段发生了错误。没有任何输出的情况下,找到错误的成因就像大海捞针。

其中一种有用的技巧就是暂时将片段着色器换成程序2.2中的片段着色器。回忆程序2.2中,片段着色器仅输出一个特定颜色——例如蓝色。如果后来的输出中的几何形状正确(但是全是蓝色),那么顶点着色器应该是正确的,错误应该发生在片段着色器。如果输出的仍然是空白屏幕,那错误很可能发生在管线的更早期,譬如顶点着色器。

在附录C中,我们展示了另一种有用的调试工具,叫作Nsight,适用于特定型号Nvidia显卡的机器。

到此为止,GLSL着色器代码已经内联存储在字符串中了。当程序变得更复杂时,这么做就不实际了。我们应当将我们的着色器代码存在文件中并读入它们。

读入文本文件是基础C++技能,我们在此就不赘述了。但是,为实用起见,用于读取着色器的代码readShaderSource()在程序2.4中提供。它读取着色器文本文件并返回一个字符串数组,其中每个字符串是文件中的一行文本。然后根据读入的行数确定该数组的大小。注意,createShaderProgram()在这里替换了程序2.2中的版本。在本例中,顶点着色器和片段着色器代码现在分别放在文本文件“vertShader.glsl”和“fragShader.glsl”中。

程序2.4 从文件读取GLSL源文件

(....#includes与之前相同, main(), display(), init()也与之前相同,同时加入如下代码...)
#include <string>
#include <iostream>
#include <fstream>
. . .
string readShaderSource(const char *filePath) { 
    string content; 
    ifstream fileStream(filePath, ios::in); 
    string line = ""; 
    while (!fileStream.eof()) { 
        getline(fileStream, line); 
        content.append(line + "\n"); 
    } 
    fileStream.close(); 
    return content;
  }

  GLuint createShaderProgram() { 
    (...与之前相同,同时加入如下代码...)
    string vertShaderStr = readShaderSource("vertShader.glsl"); 
    string fragShaderStr = readShaderSource("fragShader.glsl"); 

    const char *vertShaderSrc = vertShaderStr.c_str(); 
    const char *fragShaderSrc = fragShaderStr.c_str(); 

    glShaderSource(vShader, 1, &vertShaderSrc, NULL); 
    glShaderSource(fShader, 1, &fragShaderSrc, NULL); 

    (…构建如前的渲染程序)
}

最终我们想要绘制的不止是一个单独的点,而是想要绘制由很多顶点组成的对象。本书的大部分章节将会致力于这一主题。现在我们从一个简单的例子开始——我们将会定义3个顶点,并用它们绘制一个三角形,如图2.15所示。

图2.15 绘制简单三角形

我们可以通过对程序2.2(事实上是从文件读入着色器的程序2.4)进行两个小改动来实现绘制三角形:(a)修改顶点着色器,以便将3个不同的点输出到后续的管线阶段;(b)修改glDrawArrays()调用,指定3个顶点。

在C++/OpenGL应用程序中[特别是在glDrawArrays()调用中]我们指定了GL_TRIANGLES(而非GL_POINTS),同时也指定了管线中有3个顶点。这样顶点着色器会在每个迭代运行3遍,内置变量gl_VertexID会自增(初始值为0)。通过检测gl_VertexID的值,着色器设计为可以在每次运行时输出不同的点。前面说到这3个点之后会经过光栅化阶段,生成一个填充过的三角形。程序的改动显示在程序2.5中(余下的代码与之前在程序2.4中的相同)。

程序2.5 绘制三角形

顶点着色器

#version 430
void main(void)
{ if (gl_VertexID == 0) gl_Position = vec4( 0.25, -0.25, 0.0, 1.0); 
  else if (gl_VertexID == 1) gl_Position = vec4(-0.25, -0.25, 0.0, 1.0);   else gl_Position = vec4( 0.25, 0.25, 0.0, 1.0); }

C++/OpenGL 应用程序——在display()函数中

. . .
glDrawArrays(GL_TRIANGLES, 0, 3);

本书中的很多技术可以用于动画。当场景中的物体移动或改变时,场景会被重复渲染以实时反映这些改动。

回顾2.1.1小节中,我们构建的main()函数只调用了init()一次,之后就重复调用display()。因此虽然前面所有的例子看起来都是静态绘制的场景,但实际上main()函数中的循环会让它们一次又一次地绘制。

因此,main()函数的结构已经可以支持动画了。我们只需要设计display()函数来随时间改变绘制的东西。场景的每一次绘制都叫作一帧,调用display()的频率叫作帧率。在程序逻辑中移动的速率可以通过自前一帧到目前经过的时间来控制(这就是为什么我们会将“currentTime”作为display()函数的参数)。

程序2.6中展示了动画示例。我们使用了程序2.5中的三角形,并给它加入了先向右,再向左,往复移动的动画。在本例中,我们不考虑经过的时间,因此三角形的移动或快或慢,基于运行计算机的处理速度。在未来的示例中,我们将会使用经过的时间来确保无论在什么配置的计算机上运行,动画都保持以同样的速度播放。

在程序2.6中,程序的display()方法维持一个变量“x”用于偏移三角形的X轴位置。每当display()调用时,它的值都会改变(因此每帧都不同)。同时每当它到达1.0或者−1.0时,都会改变方向。在x中的值会被复制到顶点着色器的“offset”变量中。执行这个复制的机制叫作Uniform变量(统一变量),稍后我们会在第4章中学习它。目前不必了解统一变量的细节。现在,只需要注意C++/OpenGL应用程序先调用glGetUniformLocation()获取指向“offset”变量的指针,之后调用glProgramUniform1f()将x的值复制给offset。之后顶点着色器会将offset加给所绘制三角形的X坐标。注意,每次调用display()时背景都会被清除,以避免三角形移动时留下一串轨迹。图2.16展示了3个时间点显示的图像(当然,书中的静态图是无法展示移动的)。

程序2.6 简单动画示例

注意,除了添加三角形动画代码之外,我们还在display()函数的开头添加了这行代码:

glClear(GL_DEPTH_BUFFER_BIT);

图2.16 移动的三角形动画

虽然在本例中并不是必需的,我们仍然把它加在这里,同时它会在之后的大多数应用程序中存在。回忆2.1.7小节中讨论的,隐藏面消除需要同时用到颜色缓冲区和深度缓冲区。当我们后面渐渐地开始绘制更复杂的3D场景时,每帧初始化(清除)深度缓冲区就是必要的,尤其是对于动画场景,要确保深度对比不会受旧的深度数据影响。从前面的例子中可以明显看出,清除深度缓冲区的命令与清除颜色缓冲区的命令基本相同。

目前为止,我们的所有C++/OpenGL应用程序代码都放在同一个叫作“main.cpp”的文件中,GLSL着色器代码放在“vertShader.glsl”和“fragShader.glsl”文件中。我们承认在main.cpp中塞进很多应用代码不是最佳实践,但我们在本书中采用这个约定,以便于在每个例子中,哪个文件包含这个例子中主要的C++/OpenGL代码这件事都很清楚。在本教材中,主要的C++/OpenGL文件总是叫作“main.cpp”。在实践中,应用程序当然应该模块化,以适当对应应用的各功能。

但是,当我们继续学习时,我们会遇到一些情况。在这些情况下,我们会创建一些实用的模块,并在不同的应用程序中使用。当时机适当,我们会将这些模块分离到单独的文件中以便重用。例如,稍后我们会定义一个Sphere类。这个类会在很多例子中用到,因此它会分到它自己的文件(Sphere.cpp和Sphere.h)中。

相似地,当我们遇到需要重用函数的时候,我们会把它们放进“Utils.cpp”(与“Utils.h”关联)。我们已经看到好几个适合放进“Utils.cpp”的函数了:2.2节中描述的错误检测模块和2.3节中描述的用来读入GLSL着色器的函数。后者非常适合重载,如“createShaderProgram()”可以对应用中所有可能的管线着色器组合进行定义:

•  GLuint Utils::createShaderProgram(const char *vp, const char *fp)
•  GLuint Utils::createShaderProgram(const char *vp, const char *gp, const char *fp)
•  GLuint Utils::createShaderProgram(const char *vp, const char *tCS, const char* tES, const char *fp)
•  GLuint Utils::createShaderProgram(const char *vp, const char *tCS, const char* tES, const char *gp,  
 const char *fp)

以上列出的第一个条目支持仅使用了顶点着色器和片段着色器的程序。第二个支持使用了顶点着色器、几何着色器和片段着色器的情况。第三个支持用了顶点着色器、曲面细分着色器和片段着色器的情况。第四个支持用了顶点着色器、曲面细分着色器、几何着色器和片段着色器的情况。每个条目中,接受的参数都包含着色器代码的GLSL文件路径。例如,如下调用使用了其中一个重载函数,以编译并链接包含顶点着色器和片段着色器的管线。编译链接后的程序被放在变量“renderingProgram”中:

renderingProgram = Utils::createShaderProgram("vertShader.glsl", "fragShader.glsl");

这些createShaderProgram()实现都可以在随书附赠的配套资源中找到(在“Utils.cpp”文件中),同时它们都包含了2.2节中的错误处理。它们并没有什么新内容,只是用这种方式组织以便使用。随着我们继续向前推进本书,会有更多相似的函数加入Utils.cpp。我们强烈鼓励读者阅读配套资源中的Utils.cpp文件,甚至有需要时可在其中加入函数。配套资源中的程序是根据学习本书的方法构建的,因此了解它们的结构应当有助加强自己对书中内容的理解。

我们对于在“Utils.cpp”文件中的函数,都以静态函数进行实现,因此不需要实例化Utils类。基于正在开发的系统架构,读者可能会倾向于使用实例方法甚至独立函数实现它们。

我们所有的着色器文件都使用“.glsl”后缀。

在本章中,还有很多我们没有讨论到的OpenGL管线细节。我们略过了许多内部阶段,同时完全省略了纹理的处理。我们在本章的目标是,对后面要用来编码的框架有尽可能简单的整体印象。当我们继续学习时,会学到更多的细节。同时我们也推迟了展示曲面细分着色器和几何着色器的代码。在之后的章节中,我们会构建一套完整的系统,来展现如何为每个阶段编写实际的着色器。

对于如何组织场景动画代码,尤其是线程管理,有着更复杂的方法。有的语言中的库,如JOGL和LWJGL(对于Java)会提供一些支持动画的类。我们鼓励对于设计特定应用渲染循环(或者“游戏循环”)感兴趣的读者去读一些在游戏引擎设计上更加专业的图书[NY14],同时跟踪在gamedev.net [GD17]上的讨论。

我们在glShaderSource()命令上注释了一个细节。它的第四个参数指定了一个“长度数组”,其中包括给定着色器程序中每行代码的字符串的整数长度。如果这个参数被设为null,像我们之前那样,OpenGL将会自动从以null结尾的字符串中构建这个数组。因此我们特地确保所有我们传给glShaderSource()的字符串都是以null结尾的[通过在createShaderProgram()中调用c_str()函数]。实际中通常也会遇到手动构建这些数组而非传入null的应用程序。

在本书中,读者可能多次想要了解OpenGL某些方面的数值限制。例如,程序员可能需要知道几何着色器可以生成的最大输出数,或者可以为渲染点指定的最大尺寸。这些值中很多都依赖于实现,即在不同的机器上是不同的。OpenGL提供了通过使用glGet()指令来获取这些值的机制。基于查询的参数的不同类型,glGet()指令也有着不同的形式。例如,查询点的尺寸的最大值时,如下调用会将最小值和最大值(基于运行机器上的OpenGL实现)放入名为“size”的数组中的前两个元素。

glGetFloatv(GL_POINT_SIZE_RANGE, size)

这类查询有很多。更多示例参见OpenGL参考文档[OP16]

在本章中,我们尝试在每次OpenGL调用时,描述其各个参数。当我们向后推进时,这么做就会显得冗余,因此当我们觉得描述参数只会妨碍理解时,就不会描述该参数。这是因为很多OpenGL函数有大量与我们示例无关的参数。必要时读者应当使用OpenGL文档来获取参数详情。

2.1 修改程序2.2,增加动画,让绘制的点周而复始地放大和缩小。提示:使用glPointSize()方法,用一个变量作为参数。

2.2 修改程序2.5,使之绘制等腰三角形(而非图2.15所示的直角三角形)。

2.3 (项目)修改程序2.5,使之包含程序2.3中所示的错误检查模块。之后,尝试在着色器中加入各种错误,同时观察渲染行为以及生成的错误信息。

[GD17] Game Development Network, accessed October 2018.

[NY14] R. Nystrom, “Game Loop,” in Game Programming Patterns (Genever Benning, 2014), and accessed October 2018.

[OP16] OpenGL 4.5 Reference Pages, accessed July 2016.

[SW15] G. Sellers, R. Wright Jr., and N. Haemel, OpenGL SuperBible: Comprehensive Tutorial and Reference, 7th ed. (Addison-Wesley, 2015).

《OpenGL超级宝典(第5版)》(ISBN 978-7-115-27456-4,定价108元),俗称OpenGL蓝宝书。

[1] “上下文”是指OpenGL实例及其状态信息,其中包括诸如颜色缓冲区之类的项。

[2] “双缓冲”意味着有两个颜色缓冲区—— 一个显示,一个渲染。渲染整个帧后,将交换缓冲区。缓冲用于减少不良的视觉伪影。


相关图书

计算机图形学编程(使用OpenGL和C++)(第2版)
计算机图形学编程(使用OpenGL和C++)(第2版)
计算机图形学入门:3D渲染指南
计算机图形学入门:3D渲染指南
从零开始:数字图像处理的编程基础与应用
从零开始:数字图像处理的编程基础与应用
OpenCV图像处理入门与实践
OpenCV图像处理入门与实践
趣味掌控板编程
趣味掌控板编程
OpenGL超级宝典(第7版)
OpenGL超级宝典(第7版)

相关文章

相关课程