佐治亚理工-ECE4795-GPU-编程笔记-全-

佐治亚理工 ECE4795 GPU 编程笔记(全)

001:导论

概述

在本节课中,我们将介绍《GPU编程与视频游戏》这门课程。我们将探讨这门课程的起源、目标、涵盖的内容以及不涵盖的内容。同时,我们也会了解游戏行业的发展、GPU编程的重要性,以及学习本课程所需的工具和环境。


大家好,我是Aaron Laantterman,佐治亚理工学院电气与计算机工程系的教授。欢迎来到2020年夏季《GPU编程与视频游戏》课程的第一讲。

通常情况下,我会在现场观众面前进行讲座,但由于新冠疫情的影响,本学期我将录制讲座并发布在YouTube上,希望这些内容能对大家有所帮助。

大多数教授在成为某个深奥研究领域的专家后,会提议开设专题课程,通常是研究生课程。我的做法则不同:我会选择一些我想学习的内容,而唯一能让我腾出时间学习的方法,就是将其作为我工作的一部分——开设一门课程来教授它。幸运的是,我的同事们从未事先检查我是否了解我所讲的内容。因此,大约在2007年春季,我萌生了开设一门视频游戏编程课程的想法。

当时,佐治亚理工学院的计算机科学、文学、媒体和传播学系已经垄断了大部分与游戏相关的课程。因此,我需要找到一个能说服我所在EC系同事的角度。我想到的角度是高性能计算。我发邮件询问是否有同事愿意加入这次冒险,幸运的是,我的同事Sean Lee答应了。更幸运的是,他之前在英特尔工作时确实有一些图形硬件方面的经验。

当时,Xbox 360和PlayStation 3即将上市,特别是PlayStation 3配备了全新的Cell处理器。幸运的是,计算学院的David Bader和他的研究生们也加入进来,帮助我们处理这部分内容。此外,微软开发了一种方法,允许人们使用C#在零售版Xbox 360的.NET环境下以沙盒模式编写和运行代码,这在当时并不常见。

第二年,我和Sean独立开设了这门课程,由Sean负责Cell处理器的部分。我们在2009年再次开设了这门课。大约在2010年,Sean离开佐治亚理工学院回到工业界,课程就由我独自负责了。在原本讲授Cell处理器内容的那学期,我讲到一半时觉得内容有些枯燥,而且Cell处理器本身也不是一次成功的实验,所以我当场决定放弃这部分内容。

大约在2012年,迹象已经很明显了:XNA团队的许多关键成员已转向其他项目,微软最终也在那时停止了对XNA的支持。同时,Xbox 360本身也显得有些过时,而像Xbox One(我认为这个名字很荒谬,容易与初代Xbox混淆)和命名不那么有创意但更合理的PlayStation 4等新设备即将上市。因此,可以说是一个时代的更迭,我也应该开始重新思考这门课程。

由于我一直使用XNA,并开发了一系列XNA示例,所以研究MonoGame(一个本质上是.NET C# XNA框架的开源重写版)很有意义。不幸的是,当时我查看它时,它还相对较新,我无法在我的Macintosh上运行它。显然,在过去的八年里,它已经取得了巨大的发展,但当时看来并不值得投入,所以我决定暂时不再深入研究MonoGame,并在2012年秋季最后一次授课后,决定暂时搁置这门课程。

2013年过去了,没有开设任何此类课程。于是,2014年春季学期,我在法国梅斯市的佐治亚理工学院洛林校区任教。这是一张从圣母圣殿拍摄的城市照片,景色非常壮观。当时我和儿子一起玩《最终幻想6》,这款游戏有一种蒸汽朋克的氛围,我觉得这看起来非常酷。除了在佐治亚理工学院洛林校区度过时光,或者跟着我精通法语的妻子到处走,我大部分时间都待在公寓里。我接触了Unity,并开始尝试使用它,意识到这是一个可以快速运行着色器代码的好框架,我们甚至可能比原课程更深入地研究着色器内容,因为原课程花了很多时间在搭建基础框架上,以在屏幕上显示3D图形,这在XNA中需要半手动完成。

原课程很大程度上受到了PlayStation 3的Cell处理器和Xbox的三核Power PC等特别奇怪的多核架构的启发。而在Unity中,许多传统上由额外CPU核心处理的任务都由Unity在幕后处理了。但Unity也提供了对GPU的大量直接访问,你在那里编写的着色器代码与为任何这些游戏机编写专业开发工具包代码是一样的。因此,我决定放弃多核部分,专注于在这些更新、更酷的GPU上可以做的更新、更好的事情。于是,当前版本的课程——《GPU编程与视频游戏》诞生了。

如果有人想知道游戏编程是否是一项值得的智力追求,至少它在经济上是值得的。2019年全球游戏收入约为1201亿美元(取决于统计方式)。*均游戏玩家年龄为35岁,这可能与我小时候的情况不同。这个数字偏向年轻群体,现在新一代玩家是与他们小时候玩过游戏的父母一起玩游戏。

当我2007年首次开设这门课程时,《魔兽世界》刚刚推出,当时看起来规模巨大,但后来发生了《堡垒之夜》的现象。需要澄清的是,这有点偶然,我其实非常不擅长玩《堡垒之夜》。但那1201亿美元中的18亿仅仅来自《堡垒之夜》这一款游戏。因此,Epic Games仿佛有一个神奇的聚宝盆,不断喷涌出金钱。

当我首次提议开设这门课程时,我的同事Blair McIntyre指出,低层级的CPU和GPU编程技能是游戏行业所需且难以找到的。


游戏编程的特点

上一节我们介绍了课程的背景,本节中我们来看看游戏编程与通用编程有何不同。

游戏编程和实时图形模拟编程与通用编程有很大不同。在游戏编程中,假设你试图达到每秒60帧的帧率,那么你大约有16.7毫秒的时间来完成下一帧出现前需要做的所有事情。即使游戏大部分时间性能极佳,但如果在少数地方游戏变得卡顿,所有人都会在论坛上谈论你的游戏有多糟糕。这与进行天气模拟等任务非常不同,在后者中,你可以输入大量信息,然后等待一段时间获取结果。在那种情况下,你可能有一台每秒能进行数万亿次浮点计算的计算机,但如果它每运行一秒就需要停下来冷却四分之一秒,这对游戏来说就毫无用处。


本课程不涵盖的内容

在明确了本课程是什么之后,我也想明确它不是什么。

有些人可能会失望,因为我没有要求你们在这门课中从头开始设计和编写一个视频游戏。我不这样做是因为这本身很容易占用一整门课程的时间,例如我的同事Amy Bruckman在1988年首次开设的CS 4455课程,最*主要由Jeff Wilson和Mary Guandique讲授。如果你想专门学习游戏人工智能,我们有CS 4731课程。虽然我偶尔会涉及更通用的视频游戏课程中出现的许多问题,但它们不是这里的重点,所以你不应期待这些内容。

这也不是一门完整的计算机图形学课程。如果这是一门计算机科学课程,我可能会将计算机图形学设为先修课。但我身处EC系,并希望它对EC专业的学生开放,而他们中的大多数没有上过计算机图形学课程。因此,我将回顾传统计算机图形学课程中的各种主题,但仅限于本GPU编程课程所需的部分,特别是与计算机图形学光栅化模型相关的内容。在光栅化模型中,你基本上会遍历构成场景的所有三角形,并将它们绘制到屏幕上。本课程的重点将是实时图形,而不是用于电影CGI等离线计算的图形。

传统CS系图形学课程中的一些主题我们不会深入探讨。例如,大多数此类课程会花时间讨论人类如何感知光和颜色。它们还会讨论一种不同于光栅化的图像生成模型,称为光线追踪。在光线追踪中,主循环不是遍历场景中的所有三角形,而是遍历所有像素。虽然人们已经在GPU上进行光线追踪有一段时间了,但传统上GPU是以更偏向科学超级计算的GPGPU模式运行,不一定是实时的。目前,AMD和Nvidia的最新显卡确实内置了一些实时光线追踪功能。我尚未在本版本课程中包含这部分内容,因为我认为它还不够普及,不足以让许多游戏采用,使其值得纳入课程。但未来版本的课程可能会包含它。

此外,还有关于高级动画技术(如逆向运动学)的完整课程。如果你想了解更多,有一门完整的计算机动画课程。我的同事Efrenessa教授一门计算摄影学课程,还有一个扩展版本,我猜是关于添加特效的动态版本。我相信计算摄影学课程是佐治亚理工学院在线计算机科学硕士课程的一部分。


本课程涵盖的内容

我们已经花了很多时间讨论这门课程不涉及什么,现在让我们谈谈它涉及什么。

你并不一定需要最新、最强大、最昂贵的GPU显卡来完成本课程的作业。我所有的课程演示都是在2015年的15英寸MacBook Pro上完成的。回顾本课程的早期版本,我可能会说手机和*板电脑还不够强大,无法充分利用我们正在研究的所有技术,但这种情况肯定已经改变了。

计算机游戏产业在过去40年里发生了巨大变化。早期,一个人设计游戏、编程游戏、为游戏创作美术和音乐(如果当时游戏中有美术和音乐的话)。如今,这些通常是专业化的角色,团队规模可达100人,有些人的工作就是管理他人,他们不一定做任何技术性工作。也就是说,如果你既具备编程技能又具备美术技能,并能成为艺术家和程序员之间的桥梁,那么你将是一种稀有且有价值的资源。

现在,趋势又有点往回走了。目前有工具和基础设施可供个人和小团队制作游戏,即使不能与100人团队制作的游戏完全媲美,它们本身也相当出色。如果你是想进入游戏行业的学生,请意识到这可能有点残酷。据说大多数公司在工作与生活*衡方面比2004年时要好,但我不会说这种改善在所有公司都是一致的。除了工作本身需要相当长的时间外,这也是一个很难进入的行业,这也是人们工作时间如此长的部分原因。公司知道有年轻、渴望进入游戏行业的大学毕业生,他们可以进行一种“消耗与替换”的策略:如果你在三四年或五年后精疲力尽,他们总可以替换你。

你并不一定非要进入游戏行业才能从本课程中受益。如果你从事更通用的计算机工程,了解我们拥有如此强大的通用GPU处理能力用于科学超级计算的原因,是因为人们想玩更酷的《毁灭战士》和《雷神之锤》版本。计算机工程的进步确实是由游戏驱动的。理解游戏对计算系统施加的工作负载类型,将有助于你开发处理其他类型工作负载的计算系统。因此,虽然你可能不会为艺电工作,但也许你会为Nvidia或AMD设计新芯片,或者为英特尔、AMD或IBM工作(虽然它们主要被称为“CPU”供应商,但它们现在的许多CPU都集成了GPU)。当然,现在想起来,AMD和ATI在技术上是同一家公司。也许你不会编写游戏,但可能会设计新的PlayStation 5或Xbox Series X,或者任天堂为其下一代游戏机起的任何奇怪名字。

如果我们跳出传统游戏行业的框框思考,理解GPU在图形上下文中的应用,也能让你对在更通用的科学计算上下文中使用它们有所了解。即使你不专门编写游戏,时钟速度不再像以前那样快速增长的现实,也导致人们默认必须转向并行处理。话虽如此,当学生回来告诉我他们得到了游戏行业的梦想工作时,我仍然感到非常兴奋,这太棒了。

假设你不去商业游戏公司工作。现在你也有机会像70年代末80年代初那样,创建并发布自己的游戏,而在90年代你几乎没有这样的机会,那时如果你想分发游戏,必须通过磁盘和光盘,并且必须上架销售。这导致了很多问题,因为商店的货架空间有限。正如Richard Garriott(又名不列颠勋爵)在讨论将他的公司Origin Systems卖给艺电时经常谈到的那样,那些大公司不想与十几个不同的分销商打交道,他们真的只想与大约三家打交道。但如今,在电影和电视节目等领域,像亚马逊和Netflix这样的公司可以利用长尾效应,提供大量不同的产品,即使每个单独的产品销量不一定很高。当然,从环境角度来看,如果我们能用能量来传输比特,而不是移动装有这些比特的盒子,那是一件好事。

我必须向Jeff Vogel致敬,他在“独立游戏”这个术语成为标准之前,早在“共享软件”更常见的时代,就已经在开发成功的独立游戏了。他的游戏《Vernum 4》是我延迟三周提交终身教职文件的原因。幸运的是,我仍然获得了终身教职。我玩的第一款Spiderweb Software游戏是《Exile》,当时我还在读研究生。我记得我玩完演示版后,为了注册游戏,不得不打电话给Jeff,向他读出我信用卡上的数字。这让你感受到我们在在线支付游戏方面已经走了多远。尽管对于任何开发者来说,将游戏上架到三大主机*台已经变得更容易,但这仍然不是一件轻而易举的事。你仍然需要通过各家公司的审核,你创建的代码必须经过他们的程序才能上架这些游戏机。这部分是为了帮助防止盗版,但很大程度上也是因为游戏机制造商希望确保,如果有人正在PlayStation、Switch或Xbox上玩游戏,那是在一个安全的区域内运行,用户不必担心程序崩溃、机器出问题等等。你真的不想绕过这些程序,在没有他们允许的情况下将自己的游戏放上去。

以前,当游戏必须通过CD或特别是卡带等物理介质分发时,游戏机制造商确实对此控制得很严。例如,任天堂会基本上向你收取所有制造的卡带费用,无论卡带是否售出,他们都能赚钱。如今,通过在线分发,你不需要担心需要制造多少ROM卡带或压制多少光盘,所以这对很多事情来说肯定是最好的方式。微软曾有一段时间有一个有趣的计划,允许使用XNA开发游戏的人通过Xbox*台销售他们的游戏,这是独一无二的。PlayStation 3没有类似的东西,任天堂也没有。这个计划有过几个不同的名字,在Xbox菜单系统中的位置也经常变动。我对此抱有很大希望,但这些希望并未在现实中实现。许多希望是基于独立开发者开发的移动游戏销售情况良好这一事实,但我认为在手机上玩游戏和在Xbox 360上玩游戏的心理是不同的。如果你在杂货店排队,你可能会拿出手机,翻看商店,看看首页有什么酷的新游戏来打发时间。但如果你坐在Xbox 360前玩游戏,通常心里已经想好了要玩什么游戏,你很少会说“让我浏览一下Xbox Live Indie Games”,即使你知道Xbox Live Indie Games的存在。

你可能会自动认为,像微软、索尼或任天堂这样的公司设置的所有障碍,会自动意味着Windows、Macintosh和Linux的PC领域对独立开发者更友好。但你仍然有审核者,比如Steam和GOG。尽管如今你几乎可以很容易地将游戏上架到这些*台,但它们不再是以前那种审核者了。例如,Steam不再试图成为过去那种审核者。你正在与大量其他同样在这些*台上开发的游戏竞争。你可以在自己的网站上销售游戏,但除了竞争激烈的问题外,你还面临着支持各种CPU和GPU变体、操作系统和主板芯片组的困难。我知道这是id Software在发布《Rage》时遇到的问题,这是Carmack最新的伟大游戏引擎。如果你关注《见证者》游戏的开发者Jonathan Blow的Twitter,你会经常听到他大声抱怨让游戏在所有不同*台上流畅运行并支持所有不同客户的困难。

那么移动市场呢?当然,人们对审核者(特别是苹果)有意见,但我认为移动领域的主要问题只是有数百万款游戏像每微秒一样在iPhone等*台上发布。结果,你基本上需要在某个时候开始为你的游戏收取负数的费用,最终你会得到那些除了广告什么都没有的游戏,然后人们抱怨你所有的微交易。我不会在这里深入讨论。

关于为你的游戏筹资。像Kickstarter和Indiegogo这样的*台已经变得流行起来。我认为它们在早期有点饱和了。早期有像《永恒之柱》、《废土2》、《折磨:纽蒙拉之潮》这样的游戏筹集了大量资金,并且通常被视为评论界的成功。我必须特别提一下《Shadowrun Returns》,因为在所有这些项目中,我认为这个项目在明确定义范围、保持在该范围内并按时发布方面做得最好。还有一些其他游戏获得了全额资助,并且或多或少被视为成功发布,但由于某种原因仍然存在争议。我知道《Shroud of the Avatar》受到了一些批评,尽管我和儿子非常喜欢玩它。然后是典型的案例,即高度资助、或多或少已发布但极具争议的游戏,即Chris Roberts的《星际公民》。在所有获得全额资助并或多或少发布的游戏中,也有相当多的游戏获得了全额资助但从未发布,或者可悲的是,即使背后有知名的电脑游戏行业人物,甚至没有达到Kickstarter筹资的目标。

我主要谈论的是为了“娱乐”的游戏,但也有“严肃”游戏的市场。这是我的同事Ian Bogost(文学、媒体与文化学院)经常写到的,尽管Ian自己并不太喜欢这个术语。有用于培训急救人员和灾区响应者的游戏,用于培训急诊室工作人员的游戏。Ian甚至为一家乳品店创建了一个游戏,让你学习如何经营商店,实际上还包括一个冰淇淋勺模型,以学习如何正确地挖冰淇淋球。Ian还为Howard Dean的总统竞选(如果你年纪够大还记得的话)开发了一款游戏,他还创建了一款游戏,在游戏中你是金考公司的员工,基本上顾客和金考的其他员工试图让你的生活变得悲惨。需要承认的是,这实际上并未引起金考公司的注意。

Ian还写过关于《美国陆军》游戏的文章,该游戏由美国政府资助。这里展示的第一个版本使用了当时最先进的原始Unreal Tournament引擎。游戏中有两支队伍:美国陆军和坏人。我相信军方不希望人们真正扮演坏人,所以根据Ian的《Persuasive Games》一书,如果我没理解错的话,每个人都会将自己视为好人,而将另一支队伍视为坏人。我将留给你们自己对这些事情做出判断。

除了娱乐或严肃的游戏之外,实时图形还有很多应用。例如,外科医生可能受益于手术室中的实时图形,能够获取各种设备收集的数据,并更好地观察他们正在手术的人。东芝在Cell处理器还是热门事物时,曾用它进行运动跟踪。东芝还在他们的一些电视机中安装了Cell处理器。我特别感兴趣的是,PlayStation 3中的Cell处理器实际上是某种略有缺陷的版本。基本上,他们会制造一批Cell,如果所有八个协同处理单元都正常工作,它们会作为科学超级计算设备以溢价卖给像Mercury Computing这样的公司。如果其中一个SPE失效,那个失效的SPE会被屏蔽掉,然后如果你为PlayStation 3编写程序,实际上只能访问七个SPE,尽管我认为其中一个实际上被主操作系统占用了,你实际上只能编程六个左右。

实时图形也被用于好莱坞电影的前期制作中。你最终的CGI通常是由一堆计算机(无论是在机架中还是放在桌子上,形成所谓的“渲染农场”)离线渲染的,使用的技术比实时更复杂。但实时图形的能力让电影制作人能够以不同的方式试验和布置场景,本质上变成了3D动态故事板。我将在下面的链接中放一些这方面的例子。还有一些人使用游戏引擎制作有趣的电影,比如使用《光环》引擎的《红 vs 蓝》。如果你想从学术角度了解更多,我的同事Michael Nitsche在文学、媒体和传播学领域写过关于Machinima的文章。


硬件与工具的发展

上一节我们探讨了游戏编程的广泛应用,本节中我们来看看硬件和游戏开发工具的发展趋势。

回顾过去的40年,我们看到在早期,不同的计算机和不同的游戏机之间存在显著差异。雅达利2600和任天堂娱乐系统各有独特的外观。但随着时间推移,游戏机之间的差异变得越来越小。如今,PlayStation 4和Xbox One基本上都是标准的PC硬件,带有标准的PC芯片组,就像MacBook的内部结构与戴尔Windows笔记本电脑没有太大区别一样。

这主要是件好事,但我认为也失去了一些有趣的东西。在过去,像Apple II游戏和Atari 800游戏的外观会有很大不同,当时游戏设计的一个有趣部分是不同的开发者如何处理不同*台提供的创造性限制。如今,硬件本身没那么有趣了。这是一个*期的趋势:如果你看PlayStation 4和Xbox One,它们基本上是一样的。而就在上一代,你还有Xbox 360的三核Power PC和PlayStation 3的奇怪Cell处理器。但现在,进行这种大型、花哨的定制开发工作已经不值得了,使用现成的部件,或者至少是像ARM这样的公司的现成IP核更容易。

这样做的好处是,比如说,迁移到下一代Xbox,或者从PlayStation 4迁移到5,不会太困难。不像80年代,人们用汇编语言编程游戏,从6502或Z80迁移到68000是一件大事。另一个积极的方面是,硬件基本上已经趋同。并不是计算机不再变快,而是你作为程序员与硬件交互的方式确实已经稳定下来。在桌面端,你有Windows、Mac和Linux,在它们之间移植软件相当容易。在移动*台,你有iOS和Android。甚至与GPU交互的方式,Vulkan(OpenGL的后代)和微软的DirectX,开发者不再处于像80年代初那样的境地,那时你可能需要将一个流行游戏移植到十几个不同的*台,即使共享类似CPU(如6502或Z80)的*台,也常常有非常不同的支持声音硬件、图形硬件、存储机制等。

另一个*期的积极发展是现成的、价格合理的游戏引擎的兴起。Unity曾一度通过向独立开发者提供合理的定价占据了大部分市场。我认为Epic可能花了太长时间才回应Unity,但当他们回应时,他们做得相当积极。当他们首次推出Unreal 4时,你只需20美元就能获得完整的源代码。请记住,像Unreal 3这样的源代码许可证,就在大约12年前,那是六位数的许可证,我们说的是10万美元或更多,即使你能让Epic回你的电话。同样,CryEngine一度也相当昂贵。亚马逊最终收购了它,现在整个源代码都可用,这基本上是亚马逊销售亚马逊网络服务的一种方式。

另一个新出现的参与者是Godot引擎。虽然Unreal和Lumberyard的源代码可用,但Unity的源代码不可用,除非你支付大量额外费用。而且Unity的商业模式是,你真的不想去弄乱Unity的底层源代码。而对于Unreal,有些地方你不得不去弄乱底层源代码。Unreal和Lumberyard可能是“开源”的,因为你可以访问源代码,但它们不是免费的,在使用方式上存在各种许可问题。我认为Unreal在收入超过一定金额后会收取一定比例的费用。另一方面,Godot是一个完全免费和开源的引擎。它可能最接*Unity,但这对Unity或Godot都不公*,Godot确实在做自己的事情。

正如我之前提到的,本课程将主要使用Unity,但你不应该把它看作一门Unity课程。事实上,我要求你们做的一些事情,如果你们在标准的游戏开发环境中开发标准游戏,可能会违背使用Unity的最佳实践。我将使用Unity作为一种运行着色器代码的方式,但我想教授通用的GPU编程原则,这些原则也适用于Unreal、Godot或像艺电某些团队使用的Frostbite这样的专有内部引擎。同样,当我使用XNA教授早期版本的课程时,学生偶尔会称其为XNA课程,但我们同样是将XNA作为工具,试图教授一些更通用的原则。

Unity的主要API语言是C#。我不会假设你有任何C#经验,通常我会给你模板来填充内容。基本上,任何有Java、C++、C或任何其他使用花括号编译的语言的经验,都会没问题。我们实际用来与GPU对话的着色器语言将是HLSL(高级着色器语言),至少微软是这么叫的。它本质上等同于Nvidia过去称为Cg的语言。我不会假设你对此有任何了解。它的语法看起来像C,但底层语义不同,因为GPU核心本质上比标准CPU简单得多。

我还应该提到,这是一门编程课程,将完全使用现成的纹理和3D资源。如果你想发展一些自己的3D美术技能,Blender在过去几年里有了很大改进。Epic用他们从《堡垒之夜》获得的那笔巨款所做的一件事,就是为一些开源项目做出贡献,包括Blender,有趣的是,还有另一个游戏引擎Godot。另外,如果你是佐治亚理工学院的学生,或者我认为一般的大学生,Autodesk有一个非常好的教育许可系统,所以你可以免费获得Maya、3D Studio Max和其他很酷东西的教育许可来尝试。你应该利用这一点,在学生时期学习一些东西。

截至我制作这个视频的5月8日,Unity的当前版本是2019.3。我原本希望2020.1现在已经发布了,但还没有。我不能真的抱怨Unity,因为像大多数其他地方一样,他们可能因为新冠疫情而人手不足。我将开始使用2019.3开发今年的材料,并可能在课程的其余部分坚持使用这个版本。如果2020.1退出测试版并有一些我非常想使用的很酷的功能,我们可能会切换到2020.1,希望升级过程相对无痛。

我应该提到,无论你何时观看此视频,特别是在未来,我展示的一些主题可能会过时。我展示的一些截图和演示可能看起来与你的Unity版本不完全一样,要么是因为你看视频时Unity已经改变了一些东西,要么可能有些情况下我稍微作弊,使用了一两年前截取的截图。Unity在做出破坏性更改方面并不害羞,所以我展示的任何代码可能都需要一些更新才能在您使用的任何Unity版本中工作。同样,我们可能在少数地方偷懒,使用一些旧代码,这些代码可能需要修复,因为Unity的变化如此剧烈。

通常,如果你去谷歌、DuckDuckGo或必应搜索,你会发现很大一部分内容已经过时,甚至可能包括Unity自己的文档。以下两个陈述都是真的:Unity的文档相当糟糕。Unity也是迄今为止文档最完善的游戏引擎。你可以自己算算。

无论如何,如果你找到一个2013年的搜索结果,它很可能是错误的。一些底层想法可能仍然适用,但有时Unity可能已经彻底改变了他们的思维方式,试图将2013年旧博客文章或论坛帖子中的一些信息应用到当前的Unity中,常常会导致挫败感。所以当我搜索信息时,我会输入我正在寻找的内容,输入“Unity”,输入我正在寻找的更多信息,然后我会在搜索字段中加上“2020”,然后再试“2019”,再试“2018”,看看还有什么出现。我会意识到,随着我往回搜索,我可能更有可能找到不适用于当前Unity版本的信息。


总结

本节课中,我们一起学习了《GPU编程与视频游戏》课程的导论部分。我们了解了课程的起源和发展,明确了本课程专注于利用Unity环境进行GPU着色器编程,教授通用的GPU编程原理,而非完整的游戏设计或计算机图形学课程。我们还探讨了游戏行业的经济规模、技术特点以及独立开发者面临的机遇与挑战。最后,我们介绍了学习本课程所需的工具和注意事项,为后续深入学习GPU编程打下了基础。

002:3D坐标系 📐

在本节课中,我们将学习计算机图形学中3D坐标系的基础知识,包括不同的坐标系统约定、3D模型的表示方法以及图形渲染管线中涉及的坐标变换概述。

坐标系的不一致性 🤔

上一节我们介绍了光栅化模型的基本流程。本节中,我们来看看定义3D空间的基础——坐标系。

在二维坐标系中,定义通常没有太大争议。通常,X轴表示左右方向,Y轴表示上下方向。然而,进入三维图形领域后,情况开始变得非常混乱,因为业界没有形成一套统一的符号约定。

大多数数学教科书会将XY*面置于水*面,并使用Z轴表示上下方向,且采用右手坐标系。这意味着,如果你伸出右手,拇指指向X轴正方向,食指指向Y轴正方向,那么中指(在亚特兰大400号公路上行驶的手指)则指向Z轴正方向。

计算机图形学中的坐标系统 🖥️

当我们进入计算机图形学领域时,情况变得更加复杂。通常,人们可能仍使用右手坐标系,但Y轴向上。在这种情况下,拇指指向右侧(X轴),食指指向上方(Y轴),中指则指向观察者(Z轴)。

OpenGL API以及微软开发的用于Xbox 360编程的XNA框架都使用这种系统。

也存在使用左手坐标系的工具,例如微软的Direct3D以及我们将在本课程中使用的Unity软件。在左手坐标系中,伸出左手,拇指指向X轴正方向,食指指向Y轴正方向,中指则指向远离观察者的方向(Z轴正方向)。

微软创建了基于左手坐标系的Direct3D,然后又在其之上创建了使用右手坐标系的XNA,这看起来有些奇怪。Unity最初是Mac专属产品,因此它不使用Direct3D,而可能使用OpenGL。如今,Unity 3D内部会使用Direct3D、OpenGL或Vulkan等,并进行各种变换,以从用户角度呈现一致的效果。

这里的主要区别在于:在一种系统中,正Z值朝向观察者,负Z值远离观察者;而在另一种系统中,正Z值远离观察者,负Z值朝向观察者。

垂直轴的选择:Y向上 vs Z向上 ⬆️

决定使用Y轴向上而非像大多数数学书那样使用Z轴向上,并非完全凭空而来。因为这通常与屏幕坐标的定义方式相匹配。不过需要注意的是,在这些系统中,Y值增加是向上移动,但在传统的2D游戏编程中,Y值增加是向下移动。

以下是使用Z轴向上方法的工具,这更接*典型的数学教科书:

  • 虚幻引擎(至少在我上次查看时)使用Z轴向上的左手坐标系。
  • id Tech引擎(Quake使用)使用Z轴向上的右手坐标系。我在这里提到Radiant,这是与Quake引擎一起使用的编辑器。
  • Source引擎使用相同类型的坐标系,这是合理的,因为它最初基于Quake。其编辑器被称为Hammer,我的儿子经常用它来制作各种模组。
  • C4引擎(由Eric Lengyel开发)也使用这种系统。Eric写了一本优秀的书叫《3D计算机图形学的数学》。C4引擎的最新版本实际上被称为Tombstone引擎。这是一个不太知名的引擎,但具有有趣且清晰的架构,我推荐了解一下。

需要记住的一点是,尽管这些工具在涉及3D坐标时通常使用Z作为垂直轴,但当它们进行最终投影到2D屏幕时,仍然会使用Y作为垂直坐标。如果你将这里的左手坐标系旋转90度,可以像这样重绘它,这可能就是你在打开虚幻编辑器时会看到的情况。在这种情况下,我们现在认为Y是向右,而不是我们通常书写的方式中的X,而现在的X是远离你的方向。

3D模型与顶点数据 🎨

游戏中的底层3D美术资产通常是在某种建模软件中制作的,就像游戏引擎一样,这里也没有一致性。

有趣的是,Maya和3D Studio Max都是Autodesk的产品,一个使用Y向上系统,另一个使用Z向上系统,但两者都是右手坐标系。Blender是一个开源产品,自2007年我第一次开设这门课程以来,它已经有了巨大的改进,它使用右手坐标系。MilkShape曾是一个相对廉价的3D建模软件解决方案,在制作《半条命》模组等人群中相当流行。

目前,3D游戏中使用的模型由三角形构成。这些三角形上的每个点都有一个XYZ坐标来定义这些3D顶点。通常,每个顶点还有一个关联的单位法线,这源于3D艺术家从其3D建模软件中导出的过程。

以下是3D模型创建和处理的典型流程:

  1. 艺术家通常会创建某种高分辨率的光滑参数化表面。
  2. 他们会运行一个导出程序,将其分割成具有不同分辨率的多边形面。
  3. 这些可能用于不同目的,例如,一个具有大量顶点的非常高分辨率的模型可能用于生成预渲染的过场动画,而一个较低分辨率的模型则用于实时游戏玩法。

当表面被分割成具有不同顶点的三角形时,会计算一些法向量信息,试图感知由这些三角形表示的底层表面的曲率。这对于计算逼真的光照效果极其重要。没有这些信息,你会得到所谓的“*面着色”外观,可以清楚地看到构成物体的多边形。但是,如果在进行光照计算时使用这些法线信息,结果看起来会*滑得多。

一些3D模型可能还具有与不同顶点关联的颜色信息,你可以在渲染对象时在这些颜色之间进行插值。但如今,顶点颜色实际上并不常用,因为通常颜色信息(正如我们稍后将看到的)被嵌入到某种二维纹理中,用于查找该纹理。有时你会看到这个颜色槽被使用,但它可能用于渲染过程中的其他类型信息,而不是典型的颜色信息。

顶点列表与索引列表 📝

通常,你会有一个顶点列表,包含XYZ坐标,例如V1, V2, V3...,然后你会有一个三角形列表,这些三角形是顶点列表的索引。这可以节省大量内存使用。

列出这些顶点的顺序可能很重要。一个避免不必要计算的常用技术称为背面剔除,其本质是只渲染朝向观察者的三角形,而不渲染背向观察者的三角形。实现这一点的一种方法是使用多边形面的法线。

用于剔除的这些法线与之前讨论的用于光照的顶点法线不同。对于顶点法线,不同的顶点有不同的法线;而对于一个特定的三角形,只有一个法线。我们通过选择某种顺序列出顶点来嵌入这种信息。

在左手系统中,我们会按照V1, V3, V2的顺序列出。我们所做的是想象法线是你的拇指,然后你的手指沿着顶点顺序弯曲,所以我们有V1, V3, V2。

你也可以为此使用右手系统。现在的问题是,你为这些法线向量使用的是左手还是右手弯曲约定,这可能完全独立于你的引擎中实际的3D坐标系是左手还是右手。

图形管线与坐标变换 🔄

计算机图形学中有很多计算可以按不同顺序进行。图形管线每个阶段执行的确切计算可能因你选择如何排序而不同,特别是例如我们决定在哪里进行光照。但在很多情况下,你可以用不同的方式重新排列这些计算。你希望如何重新排列通常取决于你进行计算的具体硬件。

在过去,GPU功能不强,你必须在CPU上完成大部分计算,那时你安排这些计算的方式与现代情况非常不同。在现代情况下,你最终会向GPU发送大量原始数据,并期望GPU稍后进行整理。

我们已经讨论了使用不同3D坐标系表示3D对象的一般方法。实际上,在进行3D图形处理时使用了几个坐标系。

以下是3D图形中涉及的主要坐标空间:

  • 模型坐标系:你的艺术家工作时使用的坐标系,这是他们在Blender、Maya或其他3D建模软件中使用的坐标系。
  • 世界坐标系:关卡编辑器中的视图空间。这是你在关卡中放置对象(例如,在不同位置放置相同的敌方坦克模型)时所看到的空间。世界变换的任务是将你从艺术家空间带到关卡设计师空间。
  • 观察坐标系:观察变换将我们从关卡设计师工作的世界坐标空间带到相机实际使用的空间。这将是第一人称游戏中玩家的视点,或者是第三人称动作游戏中稍远的视点,或者是老式RPG或某种实时战略游戏中更远的视点。
  • 投影变换:是从相机视图到实际将物体投影到屏幕二维表面的最终过程的一部分。

我们将使用矩阵代数的语言来描述这些变换,因为这是组合各种变换的便捷方式。但你不需要上过完整的线性代数课程或记住太多线性代数知识也能学好这门课,因为我们将只使用线性代数中相当小的一个子集。这门课并不真正侧重于3D图形的理论方面,而将更侧重于实际实现问题。所以,如果你没有学过线性代数,你仍然大致可以跟上。这些变换将构成接下来几讲的基础。

总结 📚

本节课中,我们一起学习了3D图形学中坐标系的基础。我们探讨了左手与右手坐标系、Y向上与Z向上系统的区别,了解了3D模型如何由顶点和三角形构成,并初步认识了从模型空间到最终屏幕空间的一系列坐标变换。理解这些概念是后续学习3D变换、光照和渲染的基础。

003:3D顶点变换 🎮

概述

在本节课中,我们将学习3D图形渲染管线中的第一步:3D顶点变换。这是将艺术家创建的3D模型(由大量三角形构成)转换为玩家最终在屏幕上看到的图像的关键过程。我们将重点探讨如何通过矩阵运算,将模型从自身的局部坐标系转换到游戏世界坐标系,再转换到摄像机视角下的观察坐标系。

从模型到世界:世界变换

上一节我们概述了顶点变换的流程,本节中我们来看看第一步:世界变换。

艺术家在Maya或Blender等软件中创建3D模型时,使用的是模型自身的局部坐标系。世界变换的任务是将这些模型放置到游戏世界的正确位置和方向上。这个变换过程通常包含旋转、缩放和*移操作。

以下是世界变换的核心步骤:

  1. 旋转:首先将模型旋转到正确的朝向。
  2. 缩放:根据场景需要,对模型进行缩放(例如,将一个箱子模型放大或压扁)。
  3. *移:最后将模型移动到世界空间中的指定位置。

注意:操作的顺序至关重要。通常,我们按照“缩放 -> 旋转 -> *移”的顺序进行,以确保变换结果符合预期。

为了统一用矩阵乘法表示所有变换(包括*移),我们引入齐次坐标。我们将一个3D顶点 (x, y, z) 扩展为4D齐次坐标 (x, y, z, 1)。这样,*移操作也能用4x4矩阵表示。

  • *移矩阵:将点 (x, y, z) *移 (tx, ty, tz)
    [1, 0, 0, 0]
    [0, 1, 0, 0]
    [0, 0, 1, 0]
    [tx, ty, tz, 1]
    
  • 缩放矩阵:将点沿各轴缩放 (sx, sy, sz) 倍。
    [sx, 0, 0, 0]
    [0, sy, 0, 0]
    [0, 0, sz, 0]
    [0, 0, 0, 1]
    
  • 旋转矩阵(绕Z轴):将点绕Z轴旋转角度 θ
    [cosθ, sinθ, 0, 0]
    [-sinθ, cosθ, 0, 0]
    [0, 0, 1, 0]
    [0, 0, 0, 1]
    
    (绕X轴和Y轴的旋转矩阵结构类似,但正弦项的位置会变化,以保持坐标系手性一致)。

最终的世界变换矩阵 WorldMatrix 是这些基本变换矩阵按顺序相乘的结果:WorldMatrix = TranslationMatrix * RotationMatrix * ScaleMatrix(注意:在行向量左乘约定下,变换按从右到左的顺序应用)。

从世界到视图:观察变换

完成了世界变换后,所有物体都位于统一的世界坐标系中。接下来,我们需要考虑摄像机的影响,这就是观察变换。

观察变换的目的是根据摄像机的位置、观察方向和“向上”方向,将所有世界坐标转换为以摄像机为原点的观察坐标系。一种直观的理解方式是:想象摄像机本身也是世界中的一个物体。如果我们有一个矩阵能将摄像机从其局部坐标系变换到世界中的某个位置和朝向(即摄像机的“世界矩阵”),那么观察矩阵就是这个矩阵的逆矩阵。

应用这个逆矩阵到所有场景物体上,效果等同于将整个场景进行反向变换,使得摄像机位于新坐标系的原点,通常看向Z轴负方向,Y轴指向“向上”方向。

在实际开发中,我们通常不需要手动计算这个复杂的逆矩阵。图形API提供了便捷函数,只需指定摄像机位置、观察目标点和上方向量,即可生成观察矩阵。

  • Direct3D:使用 D3DXMatrixLookAtLH(左手坐标系)或 D3DXMatrixLookAtRH
  • OpenGL:通常使用右手坐标系,有类似的 gluLookAt 函数(旧版)或自行构造。
  • Unity:通过 Matrix4x4.LookAt 方法或直接使用Camera组件内置功能。

矩阵乘法的顺序与约定

在组合多个变换矩阵时,顺序不同会导致完全不同的结果。例如,先*移后缩放,缩放因子会同时作用于物体和其位移;而先缩放后*移,则只缩放物体本身。

此外,不同的图形库可能使用不同的数学约定,主要体现在向量的表示上:

  • 行向量约定:向量表示为行 [x, y, z, 1],变换矩阵右乘。变换顺序为从右到左:v' = v * M_scale * M_rotate * M_translate。Direct3D常用此约定。
  • 列向量约定:向量表示为列 [x, y, z, 1]^T,变换矩阵左乘。变换顺序为从左到右:v' = M_translate * M_rotate * M_scale * v。OpenGL、Unity常用此约定。

两种约定下的变换矩阵互为转置关系。在编写着色器代码(如HLSL)时,语言本身不关心约定,只需确保传入的矩阵和向量的乘法顺序与你选择的约定一致即可。

总结

本节课中我们一起学习了3D顶点变换的前两个核心阶段。

我们首先介绍了世界变换,它通过旋转、缩放和*移矩阵,将模型从其局部坐标系安置到游戏世界坐标系。我们使用齐次坐标将*移也纳入矩阵乘法体系,并强调了变换顺序的重要性。

接着,我们探讨了观察变换,其本质是将世界坐标系下的所有物体,转换到以摄像机为中心的观察坐标系。这可以通过计算摄像机世界变换矩阵的逆矩阵来实现,在实际中通常由图形API的辅助函数完成。

最后,我们辨析了矩阵运算中的顺序问题行/列向量约定,这是理解不同图形API代码的关键。

下一讲,我们将继续学习顶点变换的最后一步:投影变换,它将3D观察坐标最终映射到2D屏幕空间,为光栅化阶段做好准备。

004:正交投影 📐

概述

在本节课中,我们将学习顶点变换流水线中的最后一个关键步骤:将三维场景投影到二维屏幕上。我们将重点介绍正交投影,这是一种不考虑物体远*、所有投影线都*行的投影方式。理解正交投影是学习更复杂的透视投影的基础。


从世界空间到裁剪空间

上一节我们介绍了世界变换和视图变换,它们将物体从模型坐标系转换到了摄像机坐标系(视图空间)。本节中,我们来看看如何将视图空间中的三维物体“压扁”到二维屏幕上。

这个最终变换的任务是将一个三维视锥体映射到一个标准化的体积中,这个体积被称为裁剪空间。后续的硬件(如光栅化器)会期望接收裁剪空间中的顶点数据,以便将三角形转换为像素。

不同的图形API(如Direct3D和OpenGL)对裁剪空间的定义略有不同。下图展示了Direct3D使用的裁剪空间,它是一个从(x: -1, y: -1, z: 0)(x: 1, y: 1, z: 1) 的立方体。

  • X和Y坐标决定了三角形在屏幕上的最终位置。
  • Z坐标虽然不直接影响位置,但至关重要,它用于:
    1. 判断物体的前后遮挡关系(深度测试)。
    2. 为景深、雾效等后期处理效果提供深度信息。

注:本讨论的大部分内容参考自Joe Farrell的优秀文章。


坐标系惯例的差异

所有裁剪空间惯例本质上都是左手坐标系(Z轴正方向远离观察者)。然而,不同API在从视图空间转换到裁剪空间时,处理方式不同,这可能导致混淆。

以下是Direct3D和OpenGL/Unity的主要区别:

  • X和Y范围:两者都是从-11
  • Z范围:这是关键区别。
    • Direct3D:Z从0(**面)到1(远*面)。上图中立方体的前表面位于z=0
    • OpenGL:Z从-1(**面)到1(远*面)。此时,立方体的中心位于z=0
  • 视图空间的手性
    • Direct3D和Unity的视图空间是左手坐标系,转换到裁剪空间时不改变手性。
    • OpenGL的视图空间是右手坐标系,转换到裁剪空间时会切换为左手坐标系。

理解这些差异非常重要,因为书籍和教程通常只使用一种惯例,而不明确说明,这会给交叉参考带来困难。


什么是正交投影?📦

我们将要学习的正交投影是最简单的投影模式之一。它假设所有从场景射向观察者的光线都是*行的。

正交投影的过程是:在空间中选取一个*面(投影*面),然后将场景中的物体沿着*行线方向“投射”到这个*面上。物体的大小不会因为距离的远*而改变。

如果物体部分位于可视范围(视锥体)之外,硬件或软件渲染器会进行裁剪。它会检查与视锥体边界相交的三角形,并将其分割,只保留位于裁剪空间内部的部分。

下一讲我们将学习透视投影,它会模拟现实,让*处的物体看起来比远处的大。


正交投影的应用与历史

正交投影风格相当流行,尤其在2D游戏或需要保持物体比例不变的场景中(如策略游戏、建模软件的顶视图/前视图)。



在早期没有快速3D硬件支持的游戏中,开发者常常使用手绘的2D图块来“伪造”3D效果,这本质上是一种正交投影。

但这种做法可能导致视觉悖论(例如《纪念碑谷》就利用了这种悖论)。许多经典游戏如《辐射》、《博德之门》、《奥秘》都使用了这种等轴测或斜二测投影风格。


正交投影矩阵的推导 🧮

假设我们已经完成了世界变换和视图变换,所有物体都位于摄像机坐标系(视图空间)中。我们定义了一个需要渲染的立方体区域,其边界为:

  • X轴:左(L) 到 右(R)
  • Y轴:底(B) 到 顶(T)
  • Z轴:*(N) 到 远(F)

我们的目标是将这个区域映射到Direct3D风格的裁剪空间:从(L, B, N)映射到(-1, -1, 0),从(R, T, F)映射到(1, 1, 1)

X坐标的映射

我们希望将X从区间[L, R]映射到[-1, 1]。推导步骤如下:

  1. *移使左边界对齐0:x' = x - L
  2. 缩放至[0, 1]范围:x'' = (x - L) / (R - L)
  3. 缩放至[0, 2]范围:x''' = 2 * (x - L) / (R - L)
  4. *移至[-1, 1]范围:x_clip = 2 * (x - L) / (R - L) - 1

整理后得到:
x_clip = (2/(R-L)) * x - (R+L)/(R-L)

这是一个仿射变换:常数A * x + 常数B

Y坐标的映射

Y坐标的推导与X坐标完全类似,结果为:
y_clip = (2/(T-B)) * y - (T+B)/(T-B)

Z坐标的映射(Direct3D惯例)

对于Direct3D,我们需要将Z从[N, F]映射到[0, 1]

  1. *移使**面对齐0:z' = z - N
  2. 缩放至[0, 1]范围:z_clip = (z - N) / (F - N)

整理后得到:
z_clip = (1/(F-N)) * z - N/(F-N)

对于OpenGL惯例(映射到[-1, 1]),推导过程类似X坐标,结果为:z_clip = (2/(F-N)) * z - (F+N)/(F-N)


组合成变换矩阵

我们可以将上述三个变换组合成一个4x4齐次坐标变换矩阵。假设使用Direct3D的行向量惯例(向量在右,矩阵在左),矩阵形式如下:

正交投影矩阵(行向量,Direct3D惯例)

[ 2/(R-L),       0,           0,      0 ]
[    0,       2/(T-B),         0,      0 ]
[    0,           0,       1/(F-N),    0 ]
[-(R+L)/(R-L), -(T+B)/(T-B), -N/(F-N), 1 ]

如果使用OpenGL的列向量惯例(向量在右,矩阵在左),则需要使用这个矩阵的转置


在API中的使用

我们通常不需要手动计算这个矩阵,主流图形API都提供了内置函数。

Direct3D

Direct3D使用左手坐标系。它提供了D3DXMatrixOrthoOffCenterLH函数。有趣的是,这个函数生成的矩阵在数学上适用于任何Z轴方向(左手或右手),只要你正确输入NF的值。

但Direct3D API为了用户方便,做了特殊处理:即使你在右手坐标系中NF是负数(例如N=50, F=-100),API也期望你输入正数(50和100)。它内部会帮你处理符号翻转。因此,它提供了两个函数:

  • ...LH:用于左手系,分母为(F - N)
  • ...RH:用于右手系,分母为(N - F),即内部乘了一个-1。

XNA / MonoGame (C#)

作为面向对象的框架,它的Matrix.CreateOrthographicOffCenter方法直接创建并返回一个矩阵对象,无需传递指针。

OpenGL

OpenGL使用右手视图坐标系,裁剪空间Z范围为[-1, 1],且使用列向量。因此其函数glOrtho生成的矩阵更像是Direct3D矩阵的转置,并且缩放值不同。OpenGL也采用传统的C风格和独特的基于栈的矩阵操作模式。


简化版正交投影

通常,我们的视口是关于原点对称的。因此可以定义宽度(W)和高度(H),而不是左右上下边界。此时,投影矩阵可以简化。

简化后的矩阵为(Direct3D左手系):

[ 2/W,   0,       0,      0 ]
[  0,  2/H,       0,      0 ]
[  0,    0,   1/(F-N),    0 ]
[  0,    0,   -N/(F-N),   1 ]

相应的,API也提供了简化版的函数,如D3DXMatrixOrthoLH


总结

本节课我们一起学习了正交投影的核心内容:

  1. 目标:将视图空间中的三维物体映射到标准化的裁剪空间,为光栅化做准备。
  2. 原理:使用*行线进行投影,不改变物体因距离而产生的视觉大小。
  3. 数学推导:通过*移和缩放,将自定义的视景体边界映射到裁剪空间的标准立方体。
  4. 矩阵形式:推导并得到了一个4x4的齐次坐标变换矩阵。
  5. API差异:了解了Direct3D、OpenGL等不同API在坐标系惯例(左手/右手,Z范围)和矩阵生成函数上的区别。
  6. 应用:认识了正交投影在游戏历史中的广泛应用及其视觉特点。

正交投影是理解投影变换的基础。下一节课,我们将探讨更符合人眼视觉的透视投影,它会模拟“*大远小”的效果。

005:透视投影 👁️

概述

在本节课中,我们将要学习透视投影。上一节我们介绍了正交投影,本节中我们来看看透视投影,这是一种让*处的物体看起来比远处物体更大的投影方式。

透视投影与正交投影的区别

在正交投影中,物体无论距离相机多远,其显示大小都相同。透视投影则不同,距离相机较*的物体会显得比较大,而距离较远的物体则显得比较小。

视锥体与投影过程

目前我们讨论的所有投影变换都假设我们已经完成了视图变换。此时,我们本质上拥有一个沿X轴观察、所有物体居中的相机。正交投影会将这个视图空间(也称为相机空间或眼空间)中定义的三维体积,变换为一个归一化的三维裁剪空间体积。

透视投影则截然不同。我们处理的是一个类似截头金字塔的形状,称为视锥体。其顶部在**面处被截断。我们的目标是将这个视锥体投影到一个矩形体积中。

此时,我们本质上已超出了线性变换的范畴。为了实现这种变换,我们需要新的数学方法。

**面与远*面的重要性

你可以想象自己在一个黑暗的房间里,透过一扇窗户观察外面的物体。这扇窗户就是**面,所有物体都将被投影到这个*面上。

无论是正交投影还是透视投影,选择**面和远*面都有一些实际影响:

  • 任何超出远*面的物体都将不可见。
  • 如果物体位于远*面附*,当你靠*它时,它可能会突然“弹出”视野。如果你玩过任天堂64上的《007:黄金眼》,可能见过类似问题。
  • 对于**面,如果你悄悄靠*一个守卫,**面可能会“切掉”守卫的后脑勺,让你看到他的后脑内部,这看起来相当奇怪。

对于物体在远*面处“弹出”视野的情况,有时人们会添加雾效来试图掩盖,但这仍然会发生。

此外,从实际角度出发,在正交投影和透视投影中,选择合适的**面和远*面都很重要。因为Z轴信息用于确定哪些物体比其他物体更*(即哪些物体遮挡了其他物体)。如果你将**面和远*面设置得相距太远,Z轴可能没有足够的分辨率来很好地处理这个问题,从而导致一种称为Z-fighting的奇怪闪烁效果。

透视投影的数学原理

我们将把这个本质上不是三维矩形体积的视锥体,映射到一个三维矩形体积中。

左、下、*和右、上、*坐标在这个“窗口”(**面)中是有意义的,我们将把三维空间投影到这个*面上。请注意,在远*面处,我写的是z = 远*面,因为那里的右、上、左、下坐标会有所不同,具体取决于此处的各种角度。

我根据Direct3D的惯例绘制了此图。如果是OpenGL,左下角的坐标实际上会是-1。本推导中展示的矩阵是D3D风格的,但你可以稍微修改推导过程,得到OpenGL风格的矩阵(如果需要的话)。

我们将获取此空间中的物体,并将它们投影到这个**面上。一旦它们位于**面上,我们就可以像处理正交投影一样,将左到右映射为-1到1,将下到上映射为-1到1。但请记住,这现在只适用于**面上的点。在正交投影中,这种映射适用于整个三维体积,但透视投影将从根本上不同。

相似三角形与坐标变换

我们有一个三维空间中的点,假设是(x, y, z),并且我们确定它位于我们要变换的体积内。我们希望空间中的所有点都通过原点(0,0,0)(再次假设相机已移动到此点)。我们基本上将沿着通过该点的直线运行空间中的所有点,并查看它们与**面相交的位置。

此处的核心思想是,所有物体都需要构成相似三角形,所有角度都需要匹配。例如,如果我取**面上一个新x坐标(我称之为x'')与原始x坐标的比值,它需要等于**面z坐标与我正在变换的实际z坐标的比值。稍作整理,我可以用正在变换的x、**面和z坐标来表示这个新的x''。请注意,这个变换本质上是非线性的,我们需要小心处理。

我们希望尽可能将计算保持在线性矩阵代数空间中,以便可以将其与我们正在进行的其他矩阵操作结合。但在某个时刻,我们将不得不进行除法运算,而这无法仅通过直接的矩阵乘法完成。

我们构建的相似三角形必须在所有坐标中都成立。例如,y''与x''的比值应与y与x的比值匹配。因此,我可以推导出与上面类似的表达式,或者直接应用此逻辑,得到y''的表达式:y'' = n * y / z,正如我们上面得到的x'' = n * x / z

映射到裁剪空间

完成该变换后,我们现在得到一个x'',它应该位于左极限到右极限的空间内;而y''则位于从底部坐标到顶部坐标的空间内。

此时,我们可以使用上一讲中推导出的正交投影公式,将这些坐标映射到-1到+1的裁剪空间中。

因此,我们可以这样做。我们在这里得到的是X'(实际上是X''),在这里得到的是Y'(实际上是Y'')。需要说明的是,本讨论大部分基于Joe Farrell的一篇精彩文章。他使用了略有不同的符号,他在这里使用x',而我想使用x''来使某些区别更清晰。总之,我们这里得到的东西开始看起来像我们之前做过的变换,这感觉不错,但这里有一个z,这是我们无法用矩阵表示的。

然而,如果我们将所有项都乘以z,并说好吧,我们处理的将不是x'和y',而是x' * zy' * z。我们只是说所有项都会带有一个z,我们稍后会处理掉这个z。因为这样我就可以将其表示为包含x*zy*z的项,并且可以用矩阵计算来表达它。

处理Z坐标

现在我们需要考虑如何处理Z'。我们已经弄清楚了如何变换X和Y。我们确实需要对Z做一些处理,因为它将包含一些深度信息,我们需要确定遮挡关系。让我们保持一致,我们将取Z并将其映射到新的Z' * z,并尝试找出一些合理的方案。

我们将假设**面将映射到0(即Z' = 0),而远*面将映射到Z' = 1(如果我想使用OpenGL惯例,我可以让Z' = -1,但这里我们使用Direct3D惯例)。

因此,如果我想进行这种映射,我知道可以在这里代入n。如果我希望**面映射到零,我可以将其代入并得到第一个方程。现在,让我们看看另一种情况。如果我将F代入Z,我希望Z'输出1。如果我这样做,就会得到这个表达式。如果需要,我可以为Z' = -1重写这些表达式,并运行相同的逻辑,但目前我们不需要这样做。

我有两个方程和两个未知数,很容易求解出所需的P和Q(以及R)。我可以通过代入这些P、N、Q来写出完整的表达式,然后得到另一个类似于之前的公式,其中我们将所有内容映射到包含Z的项。

齐次坐标与透视除法

如果我将前面几张幻灯片中的三个表达式结合起来,就得到了这个。我稍后会解释为什么这里有W' * Z

我将把所有内容放入一个矩阵中。因此,我将把一个(x, y, z)映射到(x'*z, y'*z, z'*z),这很好,这些由这里的各项处理。现在的问题是,我到底想用这个W'坐标做什么?

惯例是,我们最终希望将所有项除以Z,即摆脱这个Z。通常的处理方式是,我们将取这个第四坐标,它应该是W' * Z,并将Z放在那里。然后,在显卡的某个部分,会有一块硬件基本上取这个向量中的所有项,并将其除以第四坐标。

因此,我们要做的是取那个第三坐标Z,并将其放入第四坐标W中。这就是这里这个1的作用:它取那个Z并将其放入这个第四W坐标。所以现在,当我将所有项除以这个W' * Z时,我实际上是在除以z,然后我就得到了我的x'、y'和z',并且在W坐标中留下一个1。

简化与常用参数

正如我们在正交投影中看到的,我很少需要拥有独立L、R、T、B、N、F的完全灵活性。通常我可以假设存在某种对称性,因此我可以只定义一个宽度和一个高度,而不是拥有独立的L、R和T、B。在这种情况下,上一张幻灯片上的矩阵会以你预期的方式简化。

但即便如此,用W和H来表达通常也不是这类矩阵的表达方式。通常有一个以弧度或度数为单位的视野(通常是度数,从关卡设计师设置相机的角度考虑)。这个视野是在这个垂直方向上定义的。然后你定义一个宽高比来确定宽度。我正在以16:9的宽高比制作此演示文稿,你可能在YouTube上也是以此观看。较旧的标准清晰度视频通常具有4:3的宽高比,这是原始电视的标准。这是一种现代显示器和笔记本电脑基本都支持的宽屏标准,用于观看电影。当然,我们还需要定义**面和远*面。

涉及一些三角学来计算视野如何映射到高度和宽度,我不打算详细讲解所有三角学,如果你愿意可以深入研究,但目前我们可以先相信这一点。

3D API中的矩阵创建

不出所料,3D API中内置了创建这些矩阵的命令。Direct3D和Unity默认都使用左手坐标系。正如我们上次提到的,由于Direct3D有一个C基础,你需要传递一个指向矩阵数据的指针,但像Unity这样的东西并不真正需要这个,你可以创建一个矩阵然后返回它,而无需显式传递指针。Unity有一个4x4矩阵库,特别包含一些像这样的矩阵创建例程。

当然,在某些API中也有此函数的右手坐标系版本。这有点奇怪,因为这里的底层数学本应适用于右手系统,你只需要为F和N输入负数,而不是正数。但即使右手系统中的远*面和**面应该用负坐标定义,所有API都假设你传入的是正数。这就是为什么你在这里看到的n - f看起来与上一张幻灯片上的f - n不同的原因,它只是为你执行了取反操作。

Direct3D XNA有它自己的版本,OpenGL也有一个版本。因为OpenGL本质上是右手坐标系,并且这里有一个C基础,但正如我上次提到的,OpenGL有一个堆栈计算模型,所以这被推送到堆栈上供以后使用,因此即使在C上下文中,你也不需要指定指针。

Unity中的自定义与默认处理

你可以在Unity中使用我们讨论过的例程创建自己的投影矩阵,或者你可以编写一些执行非常奇怪的定制矩阵的东西。你的相机有一个不可访问的投影矩阵,你可以为其赋值,但你通常只有在做一些相当特殊的事情时才需要这样做。

文档提到了斜投影,这是一种奇怪的、非标准的投影,超出了我们研究的范围,显然被水渲染系统使用。同样,你通常只有在做一些特别奇怪的事情时才需要这样做。

Unity会根据你相机在检视器中的设置,根据需要创建这些矩阵。你也可以通过脚本更改这些设置,如果你有理由这样做的话。

从裁剪空间到屏幕空间

还有一个最终的变换,那就是将-1到1映射到屏幕上实际的像素数量(整数)。在使用现代硬件进行3D图形编程时,如果你习惯于使用整数显式索引特定像素的2D图形编程,需要一段时间来适应这一点。

当然,这最终会发生。你的操作系统和你调用的API中可能还有其他东西,根据像素定义屏幕上各个窗口的位置,然后将这些-1到1映射到各个位置。正如我们稍后将看到的,这可能更加复杂,因为你可能正在进行抗锯齿处理,或者实际上渲染到一个比你实际显示的像素更多的缓冲区中。如今,很多这些都在幕后为你处理,所以本质上,我们暂时将其留给API和显卡中的“魔法”来处理。

Unity中的坐标约定

Unity定义了几种坐标约定。这些是Unity特有的,如果你使用其他引擎,可能会有不同的命名规则。它定义了一个视口系统,左下角为(0,0),右上角为(1,1)。视口系统没有任何Z坐标的概念。

还有屏幕空间坐标,从左下角(0,0)到更像整数像素的东西。因此,我们有一个像素宽度和像素高度。在这种设置中,还有一个Z坐标,这是从相机出发的“世界单位”坐标。请注意,这些具有不同的单位。

对于Unity内置的图形用户界面设施,坐标(0,0)现在位于左上角,你有了这些相机像素坐标,现在位于右下角。因此,前两种更接*我们迄今为止一直在研究的关于如何映射2D坐标的惯例,其中Y坐标增加意味着你在屏幕上向上移动。而这个GUI坐标空间,更接*传统的2D图形编程,其中Y坐标增加意味着你在屏幕上向下移动。

总结

本节课中我们一起学习了透视投影的核心原理。我们了解了它与正交投影的区别,认识了视锥体,并推导了将三维空间点通过相似三角形原理投影到**面,再映射到归一化裁剪空间的数学过程。我们还探讨了齐次坐标与透视除法的作用,以及在实际应用(如Unity引擎)中如何通过API简化矩阵创建。最后,我们简要了解了从裁剪空间到最终屏幕像素的转换过程以及Unity中的坐标系统。理解透视投影是掌握3D图形渲染中物体如何根据距离正确显示大小的关键一步。

006:背面剔除 🎮

在本节课中,我们将学习一种名为“背面剔除”的图形渲染优化技术。其核心思想是,我们观察的物体大多是封闭表面,那些背对我们的三角形面片会被正对我们的面片遮挡。因此,我们无需浪费计算资源去渲染这些看不见的背面三角形。

计算三角形法线 📐

上一节我们介绍了背面剔除的基本概念。为了判断一个三角形是否背对观察者,我们需要计算每个三角形的法线向量。

这里使用的法线,与我们之前讨论的、用于光照计算的逐顶点法线不同。那些法线由3D建模软件导出,旨在模拟比三角形网格本身更光滑的表面曲率。而背面剔除使用的,是逐三角形法线,它是一个垂直于三角形*面本身的简单向量。

我们可以通过计算两个边的向量的叉积来得到这个法线。假设三角形有三个顶点 ( P_1 )、( P_2 )、( P_3 ),我们可以计算两个向量:
[
\vec{V_1} = P_2 - P_1
]
[
\vec{V_2} = P_3 - P_1
]
然后,三角形的法线 (\vec{N}) 可以通过叉积求得(注意叉积顺序影响方向):
[
\vec{N} = \vec{V_1} \times \vec{V_2}
]

法线方向与坐标系惯例 🧭

叉积运算不满足交换律,因此向量顺序至关重要。3D模型通常遵循特定的顶点环绕顺序惯例(左手定则或右手定则),这决定了计算出的法线是指向模型外部还是内部。

例如,在左手定则下,如果你用左手握住,手指从 (\vec{V_1}) 方向弯向 (\vec{V_2}) 方向,拇指所指方向即为法线方向。如果你的游戏引擎启用了背面剔除,但导入的模型却消失了,这很可能是因为模型使用的坐标系惯例与引擎预期的不一致。

判断三角形朝向 🔍

现在我们已经有了三角形的法线,接下来如何判断它是否朝向摄像机呢?

我们可以比较两个向量:三角形的法线 (\vec{N}),以及从三角形中心指向摄像机位置的向量 (\vec{V})。通过计算这两个向量的点积,我们可以判断它们之间的夹角。

点积公式为:
[
\vec{A} \cdot \vec{B} = |\vec{A}| |\vec{B}| \cos\theta
]
其中 (\theta) 是两向量夹角。

我们只关心夹角是否大于90度:

  • 如果 (\vec{N} \cdot \vec{V} > 0),则夹角小于90度,三角形面向摄像机,需要绘制。
  • 如果 (\vec{N} \cdot \vec{V} < 0),则夹角大于90度,三角形背向摄像机,应被剔除。

注意:在光照计算中,所有方向向量通常需要归一化(长度为1)。但在这里,我们只关心点积的正负号,因此无需对法线或观察向量进行归一化处理,这简化了计算。

一个常见的误区是,在视图变换后,仅通过检查法线向量的Z分量正负来判断朝向。这在物体完全位于摄像机前方时可行,但如果一个三角形位于侧面,其法线的Z分量可能为正,但实际上它与观察向量的夹角可能已超过90度,此时仅凭Z分量判断就会出错。因此,使用点积判断更为可靠。

背面剔除的执行时机与位置 ⏱️

背面剔除可以在图形渲染管线的不同阶段进行:

  • 视图空间:这是最自然的位置。在世界-视图变换之后进行,此时空间仍保持距离和角度关系,计算直接。
  • 裁剪空间:在投影变换之后也可以进行,但由于投影会扭曲空间,需要一些额外的技巧。
  • 世界空间:在世界变换之前进行理论上可行,但需要将摄像机参数逆变换到模型原始空间(对象空间),这通常没有优势,且计算更复杂。
  • 屏幕空间:现代GPU功能强大,一种常见做法是将几何体发送到GPU,在顶点着色器阶段计算法线和点积,然后在光栅化之前决定是否剔除该三角形。甚至可以根据最终2D屏幕上顶点的环绕顺序进行快速判断。

如今,得益于GPU的强大算力,将剔除工作放在GPU端,在顶点着色器或几何着色器阶段进行,已成为高效且主流的方法。

特殊情况与注意事项 ⚠️

并非所有物体都适合进行背面剔除。以下是需要考虑的特殊情况:

以下是两种主要情况:

  1. 非封闭物体:例如飘动的旗帜、单薄的布料或透明物体。玩家有时需要看到其背面。简单地为此类物体关闭背面剔除可能导致后续光照计算出错。
  2. 解决方案:更稳健的做法是,对于需要双面可见的物体,复制其所有三角形,并将复制品的法线方向反转。这样,无论从哪一侧观察,都有一组法线正确的三角形被渲染。

总结 📝

本节课我们一起学习了背面剔除技术。我们了解到,通过计算三角形的法线向量,并与观察方向进行点积运算,可以高效地识别并剔除背对摄像机的三角形,从而节省宝贵的渲染资源。我们探讨了法线的计算、朝向的判断、不同执行阶段的优劣,以及处理双面可见物体的特殊方法。掌握背面剔除是优化实时图形应用性能的重要一步。


附:课程测验(示例)

  1. 你最喜欢的电子游戏是什么?(可以是任何*台)
  2. 你最喜欢的电影是什么?(如果难以抉择,可以列举一部你喜爱的)

007:基础光照 🎮

在本节课中,我们将学习计算机图形学中的基础光照模型。我们将从局部光照的概念开始,逐步介绍现代游戏开发中广泛使用的物理渲染(PBR)技术,并详细讲解漫反射和镜面反射的计算方法。课程内容旨在让初学者理解光照的基本原理及其在GPU编程中的应用。

局部光照与全局光照

上一节我们介绍了课程的整体框架,本节中我们来看看光照的基本分类。

我们在这门课中主要关注的光照方法是局部光照。这意味着我们只考虑光源直接照射在物体上的光线,而不考虑光线从其他表面反射后的间接光照。值得注意的是,我们暂时不处理物体与光源之间存在遮挡物(即阴影)的情况。虽然存在生成阴影的技术,但它们实现起来相当复杂,我们将在后续课程中探讨。

全局光照则包含了光线在其他物体间反弹的效果。这能创造出更加真实的场景,但计算量也显著增加。一个完整的全局光照方案会使用类似光线追踪的技术。大约十年前,人们开始利用GPU的科学计算能力进行光线追踪,但那些计算通常无法实时完成。如今,英伟达和AMD的最新显卡已内置了某种程度的实时光线追踪能力,但其应用仍有限制,且很少有游戏完全依赖它。在本课程中,我们将探讨全局光照,但主要关注的是模拟全局光照效果的巧妙方法。

物理渲染(PBR)的兴起

在了解了基础分类后,我们来看看现代游戏图形学的一个重要趋势。

2012年的一次会议上,迪士尼的一位研究员发表了他们在电影中使用的基于物理的渲染技术成果。此后,几乎每一位游戏行业的图形程序员都阅读或观看了那份报告,并开始尝试将PBR技术应用到视频游戏中。这是一项极具影响力的工作。

需要明确的是,使用物理渲染并不一定意味着要达到照片级的真实感。例如,一些风格化的角色本身在现实中并不存在,但构成其表面的各种材质对光线的反应方式,却与真实材质类似。PBR技术带来的主要好处是,当改变场景的照明方式时,无需回头修改物体上材质属性的定义。同样重要的是,物理渲染并不自动保证游戏场景看起来就“好看”。就像即使你拥有相机和灯光,但若缺乏布光和摄影的经验,作品依然不会显得专业。

以下是使用PBR技术的一个绝佳例子,即《合金装备5》中使用的Fox引擎。请花点时间观察下图,尝试分辨哪个场景是科乐美制作公司会议室的真实照片,哪个是由引擎渲染的。

有几个关键线索:一是海报的反射,它们被某种涂层覆盖,产生了更光滑的反射;二是光线作用的微妙之处,例如右侧场景中光源反射的模糊效果。实际上,这是一个陷阱问题——所有场景都是由引擎创建的。他们并没有真的把一匹马带进会议室。虽然像物体漂浮在空中这样的场景显然不真实,但其渲染效果依然令人印象深刻。

这里要感谢佐治亚理工学院的前学生John Hable,他曾任职于顽皮狗公司,从事游戏图形工作。他有两个非常出色的博客。你会发现,图形学和游戏领域最新、最棒的信息通常不在科研论文或教科书中,而是在那些致力于实现这些技术的人们的博客文章里。

物理渲染的核心概念

理解了PBR的重要性后,我们来深入探讨其包含的一系列核心概念。

当人们谈论物理渲染时,它并非单一技术,而是多种因素的结合。以下是其关键组成部分:

  • 线性空间光照:这与纹理的创作和存储方式有关,我们将在后续课程中讨论。
  • 能量守恒互易性:这两个属性对于尝试进行全局光照计算尤为重要。
  • 金属与非金属(电介质)的区分:PBR模型严格区分金属和非金属材质。在计算机图形学早期,基于启发式的技术没有这种区分。
  • 所有物体都有镜面反射:创建一个物理上合理的模型意味着所有物体都有镜面反射,即使是像纸板这样你认为不反光的东西。
  • 菲涅尔效应:所有材质都存在菲涅尔效应。例如,干燥的柏油路看起来不反光,但雨后,在太阳接*地*线的掠射角下,路面会变得像镜子一样反光。
  • 高动态范围(HDR):为了充分发挥PBR的优势,最好能在视频缓冲区中拥有足够的分辨率和动态范围,以处理真实光源的高动态范围。早期游戏图形技术受限于存储图形计算结果的缓冲区动态范围,通常只能处理非常有限的光照强度范围。而物理上合理的真实场景则可能包含非常宽广的光照强度范围。人眼通过对数尺度感知光强来处理这一问题。然后,再通过一系列色调映射技术将HDR图像映射到显示器的有限范围内,这些我们也会在后续课程中讨论。

光源类型与关键向量

在开始具体计算前,我们需要定义场景中涉及的基本元素。

现代游戏引擎可以实时处理各种复杂光源,但本课程大部分内容将使用最简单的几种模型。

  • *行光:可以想象为一束来自极远处明亮光源(如太阳)的*行光线。在场景范围内,其强度被认为没有衰减。
  • 点光源:能量向所有方向辐射,但其强度会随距离增加而衰减。太阳其实也有此效应,但由于距离极远,在地球尺度的场景中,其光线可视为*行。
  • 聚光灯:可以看作是点光源的精细化版本,它强调特定方向的照明。

接下来,我们定义光照计算中用到的一些关键向量。假设空间中有一个表面,其上有:

  • 法向量:垂直于该表面的向量,记为 n。注意,这里的法向量不同于背面剔除中为单个三角形计算的法线。这里的法向量通常由3D美术软件(如3D Studio Max, Maya)为每个顶点生成,它们试图封装底层曲面(三角形网格是对该曲面的粗糙镶嵌)的信息。
  • 光源方向向量:从表面点指向光源的向量,记为 l
  • 观察方向向量:从表面点指向摄像机(眼睛)的向量,记为 v
  • 半程向量:是 lv 的角*分线方向向量,记为 h。可以通过将两向量相加后归一化得到:h = normalize(l + v)。

一个非常重要的前提是:在进行计算前,所有这些向量都必须被归一化,即长度为1。忘记归一化是图形编程中一个极其常见的错误。

漫反射光照计算

现在,我们进入具体的计算环节,首先从较为简单的漫反射开始。

光照分为漫反射镜面反射两部分。镜面反射较为复杂,我们将在单独的课程中讲解。本节我们只讨论漫反射。

漫反射表面是指相对于照射光波长而言显得粗糙的表面。从光的视角看,它可能对人眼来说并不粗糙。由于表面粗糙,入射光线会被随机散射,能量被均匀分散。因此,对于漫反射,只要表面在摄像机可见范围内,反射光的强度与摄像机的位置无关

其工作原理基于物理定律。如果光线方向 l 与表面法线 n 完全对齐(夹角为0°),则反射光最强。随着夹角增大,反射光强度按余弦定律衰减。当光线与表面*行(夹角90°)时,几乎没有反射。

这个余弦衰减的好处是,你不需要实际计算角度的余弦三角函数值。根据三角学,两个单位向量的点积就等于它们夹角的余弦值。因此,漫反射光强系数可以计算为:

漫反射系数 = max(0, dot(n, l))

这里使用 max 函数是为了避免点积为负值(意味着光线从背面照射,不会产生反射)。

最终,该点的漫反射颜色由光源颜色和材质漫反射颜色共同决定:

最终漫反射颜色 = C_light ⊗ M_diff * max(0, dot(n, l))

其中:

  • C_light 是光源的RGB颜色值。
  • M_diff 是材质漫反射颜色的RGB值。
  • 符号表示逐分量乘法(即 R*R, G*G, B*B),结果是一个新的RGB颜色值。

双向反射分布函数(BRDF)

为了更通用地描述光照,我们引入一个核心概念。

前面给出的公式可以纳入一个更通用、更复杂的框架中,即双向反射分布函数

BRDF是一个函数,它描述了从某个方向(l)入射的光,在另一个方向(v)上反射的比例。对于漫反射,其BRDF非常简单,是一个常数(即材质的漫反射颜色 M_diff 除以 π)。这允许我们以后通过添加更复杂的镜面反射项来扩展模型。

你可能会问公式中的 π 是怎么回事。这涉及到计算机图形学背后更复杂的数学物理基础。BRDF定义中的 π 是一个归一化因子。幸运的是,对于大多数我们考虑的光源,物理公式中会出现一个 π 因子,当与漫反射BRDF中的 π 结合时,它们常常会相互抵消。因此,在着色器代码中,有时你会看到 π,有时则不会,这取决于人们采用的约定和优化。

一个“正确”的、物理上合理的BRDF需要满足一些属性,例如互易性(交换光源和观察者位置,结果应相同)和能量守恒(表面反射的光不能超过入射光)。我们之前提到的Phong镜面反射模型就不满足互易性。对于漫反射模型,能量守恒要求 M_diff 的每个分量必须小于1/π,这通常通过让美术师确保材质颜色值在0到1之间来自然满足。

光源衰减与聚光灯

最后,我们补充关于点光源和聚光灯衰减的具体计算。

对于*行光,我们假设在场景范围内强度无衰减。但对于点光源聚光灯,我们需要考虑能量随距离扩散而导致的衰减。人们有时使用物理模型(强度与距离*方成反比),有时使用类似以下的模型以获得艺术效果:

衰减因子 = 1.0 / (Kc + Kl * d + Kq * d²)

其中 d 是距离,Kc, Kl, Kq 是常数。

对于聚光灯,有多种实现方式。这里介绍一种常见的方法。设 l 为从聚光灯指向被照射点的向量(注意方向与之前相反),d 为聚光灯的照射方向向量(均已归一化)。衰减基于这两个向量之间的夹角。

首先计算 cosθ = max(0, dot(l, d))。然后,衰减因子可以定义为:

聚光灯衰减因子 = (cosθ)^f

其中 f 是控制聚光灯束宽度的指数因子。cosθ 的值在0到1之间,取幂次 f 会改变衰减曲线的形状。增大 f 会使光锥更集中,边缘更锐利;减小 f 则会使光锥更宽,边缘过渡更*滑。


本节课中我们一起学习了计算机图形学的基础光照知识。我们从局部光照与全局光照的区别讲起,了解了物理渲染(PBR)技术的核心思想及其重要性。我们详细探讨了漫反射的计算原理,引入了双向反射分布函数(BRDF)的概念,并简要介绍了点光源衰减和聚光灯效果的实现方法。这些基础概念是理解更复杂GPU着色器编程和现代游戏渲染管线的关键。在接下来的课程中,我们将深入探讨镜面反射模型、阴影技术以及全局光照的模拟方法。

008:光栅化 🎮

在本节课中,我们将学习光栅化的基本概念。光栅化是GPU确定屏幕上哪些像素需要为特定三角形着色的过程。我们将探讨颜色插值、深度缓冲以及抗锯齿等关键技术。

大家好,我是佐治亚理工学院电气与计算机工程系的教授。欢迎来到“视频游戏GPU编程”课程。

上一节我们讨论了光照,本节中我们来看看光栅化。你的GPU需要计算出屏幕上哪些像素对应需要为特定三角形绘制的像素。这里我们有一个简化的例子,三角形的顶点分别是红色、绿色和蓝色。

颜色插值(Gouraud着色)🌈

确定如何填充这些像素的最简单技术之一,是计算每个顶点的颜色,然后在它们之间进行插值。这被称为Gouraud着色。与其他技术相比,这种方法效率较高,因为我们只在顶点进行光照计算,然后在它们之间进行插值。

在实际实现中,你可以想象首先沿着这条线插值,以确定线上的颜色,然后扫描插值各个像素。如果在20世纪80年代,你可能需要学习计算机图形学课程并亲自编写实现这些功能的例程。但现在,我们只需假设显卡上的“魔法”会为我们处理这一切。

着色技术的比较 🎨

我们讨论的Gouraud着色存在一些局限性,但至少比*面着色要好。

以下是*面着色的说明:

  • 你可以计算整个三角形相同的颜色,例如基于背面剔除时使用的相同法线来计算颜色。
  • 但这会产生明显的刻面外观。
  • 如今,你通常只在刻意追求复古外观或明显的计算机生成效果时才会使用它。

Gouraud着色无疑是一种改进。在这里,虽然主题看起来*滑了,但你仍然可以看到表面被分解为三角形的方式所产生的一些效果。然而,从计算角度来看,这仍然相当高效,因为你只对每个顶点进行复杂的光照计算,然后在它们之间插值最终的光照值。

逐像素光照(Phong着色)💡

较新的图形硬件可以为场景中的每个像素执行单独的光照计算。此时,被插值的是与各个三角形相关的单位法线,即插值法线信息本身,而不是光照计算的结果。

这显然在计算上密集得多,因为你需要为每个像素计算插值后的法向量与光源的点积。但它的视觉效果要好得多,并且现代GPU可以轻松实现。我记得第一个广泛使用逐像素光照(也称为Phong着色)的游戏可能是《毁灭战士3》。如今,几乎所有游戏都使用这种技术。这是一个重大的进步,例如虚幻引擎3就使用了逐像素光照,这也是《生化奇兵》看起来如此出色的原因之一。

深度缓冲(Z缓冲)📏

除了将光照信息绘制到显存中的某个缓冲区,我们还需要填充一种叫做Z缓冲的东西。这是你写入的另一个图像,但它不包含颜色信息,而是包含深度信息。

其基本过程是:当你试图确定图像中某个像素的颜色时,首先查看深度缓冲,检查该像素是否已经被写入过。如果没有,你就写入该像素,同时将距离信息写入Z缓冲。之后,当你处理另一个物体的颜色时,会先检查深度缓冲中是否已存在一个比当前物体更*的像素颜色记录。如果是,则可以丢弃当前计算。

Z缓冲显然会占用大量内存。通常,你需要为场景中每个需要计算颜色的像素分配一个Z缓冲像素,这可能占用大量内存。例如,PlayStation 1就没有Z缓冲,它基本上会尝试对你绘制的物体进行排序,然后从后往前绘制(这称为画家算法),但这在某些情况下会产生看起来有点奇怪的结果。

另一个问题是如何表示这些距离。为了生成一个合理的Z缓冲(而不是完全混乱),你至少需要16位的分辨率,尽管实际上你需要比这更高的分辨率,而现代大显存显卡可以提供这一点。这里存储距离的方式通常不是简单的线性映射,我们将在后续课程中查看一些处理这类深度值的实际像素着色器时再讨论。

现代GPU的一个优点是,这种“检查我们正在绘制的像素是更*还是更远”的Z缓冲检查,全部由硬件自动处理。你只需设置一些标志或API,告诉硬件以某种方式处理这些Z缓冲。

抗锯齿 🔍

最后我想提到的问题是,我目前假设你会为屏幕上的每个像素进行光照计算(如果是逐像素光照或Phong着色)。当然,即使你有一个拥有海量像素的巨大屏幕,分辨率仍然是有限的。如果你只是简单地将三角形的边缘绘制到屏幕上,就会得到这些被称为锯齿的小锯齿状效果。

解决这个问题的一个相当简单的方法是,以高于实际显示的分辨率计算图像,然后以某种方式*均相邻的像素。这样你会得到一个边缘略微模糊的图像,但不会出现这种阶梯效应。有多种处理此问题的方法,它们在质量和计算复杂度上各不相同。

总结 📚

本节课中,我们一起学习了光栅化的核心概念。我们介绍了通过Gouraud着色进行颜色插值的高效方法,探讨了更逼真但计算量更大的逐像素光照(Phong着色)。我们还了解了深度缓冲(Z缓冲) 如何帮助确定像素的可见性,以及抗锯齿技术如何*滑边缘锯齿以提升视觉质量。这些技术共同构成了现代GPU渲染图像的基础。

009:纹理导论 🎮

在本节课中,我们将学习计算机图形学中一个核心概念:纹理。我们将了解纹理是什么、为什么需要它们、它们如何映射到3D物体表面,以及在处理纹理时可能遇到的技术挑战和解决方案。

概述

纹理是为了解决一个效率问题而设计的。如果试图用大量微小的三角形来构建物体,并为每个三角形的顶点定义特定颜色,这将非常浪费内存和计算资源。例如,一个广告牌只需两个三角形就能渲染其表面,但若要用三角形表现其上的所有颜色变化,则需要海量的三角形。因此,纹理应运而生。

纹理本质上是2D图像,我们将其“包裹”在3D物体表面。图像中的基本单元称为“纹素”,它们就像最终显示在屏幕上的“像素”,但存在于纹理图像中。

纹理坐标与映射

上一节我们介绍了纹理的基本概念,本节中我们来看看如何将纹理应用到物体上。

之前我们讨论过,构成物体的顶点拥有三维空间位置(物体空间坐标),以及用于光照计算的法线向量。历史上顶点也包含颜色信息,但如今颜色信息主要存储在纹理中。因此,我们为每个顶点新增一个属性:一个二维向量坐标,它代表指向2D纹理图像特定部分的索引,我们称之为纹理坐标。

与所有坐标系一样,纹理坐标也没有统一的约定:

  • Direct3D / XNA:使用左上角为(0,0),右下角为(1,1)。
  • OpenGL / Unity:使用左下角为(0,0),右上角为(1,1)。

通常,我们用(u, v)来表示纹理坐标,以区别于3D空间中的(x, y, z)坐标或屏幕空间坐标。

(图示:一个矩形表面被映射了一张人脸纹理)

定义好的顶点将包含指向纹理的坐标。通常,我们可以想象在顶点之间对纹理坐标进行线性插值,以确定每个片段(像素)应从纹理的哪个位置采样。不过,在进行透视投影时,简单的线性插值会导致问题。幸运的是,存在一个修正方法,并且现代显卡会自动处理这个细节。

纹理寻址模式

了解了基本的纹理映射后,我们来看看如何处理超出标准[0,1]范围的纹理坐标。

纹理坐标通常以模1的方式被引用。如果提供的纹理坐标大于1或是负数,可以在图形API中设置不同的寻址模式:

  • 钳位模式:将坐标限制在纹理边界,并重复边缘的纹素。这种模式使用场景不多。
  • 重复模式:让纹理*铺重复。这在游戏中很常见,例如用于墙面纹理。

然而,简单的重复可能导致玩家注意到不真实的重复图案。一个常用的技巧是叠加多个不同尺寸的纹理,让它们以不同的速率重复,从而相互交错,打破明显的重复感。


纹理过滤:放大

处理纹理时会遇到许多实际的技术问题。我们先从简单情况开始:假设我们正对着一个正方形表面观察。

常常会遇到将纹理放大的情况,即屏幕上的像素密度远高于底层纹理图像的纹素密度。处理这种放大效应有不同的方法:

以下是几种常见的纹理放大过滤方法:

  • 最*邻采样:为屏幕上的每个像素,直接抓取纹理坐标对应的、最*的单个纹素。这是90年代游戏中常见的做法,但效果通常不佳。
    • 伪代码示例color = texture[texel_floor(uv * texture_size)]
  • 双线性插值:取与目标像素重叠的四个纹素,并计算其加权*均值。这比最*邻采样效果更好,计算量也稍大,但现代GPU都能轻松处理。这也是Photoshop等软件调整图像大小时的一个选项。
    • 概念公式:在u和v两个方向上进行线性插值。


纹理过滤:缩小

比放大更棘手的问题是缩小。当你将视角拉远,物体在屏幕上占据的像素很少,但纹理图像本身非常精细。此时,屏幕上的一个像素可能对应纹理中的一大片纹素区域。

如果只是简单地抓取离像素中心最*的那个纹素,可能会因为采样问题导致严重的走样效应。想象一辆涂有垂直红白条纹的汽车:

  • 如果采样点全部落在红色条纹上,车看起来是红色的。
  • 如果汽车稍微移动,采样点全部落在白色条纹上,车看起来是白色的。
  • 如果汽车以特定速度移动,它可能会在红白之间闪烁。

这种使用最*邻采样导致的奇怪视觉效果称为“摩尔纹”,在90年代的老游戏中,尤其是在远处的纹理上经常能看到。

与放大情况类似,简单的*均(如双线性插值)可以缓解一些走样问题。但请注意,在API中通常为纹理设置统一的过滤方式,它同时适用于缩小和放大。对于包裹在物体表面的同一纹理,可能某些部分在缩小,而另一些部分在放大。

Mipmapping 技术

即使使用了双线性插值,当视角拉得非常远时,仍然可能遇到走样问题。解决这个问题的一种关键技术称为Mipmapping。

Mipmapping的核心思想是预先创建一系列尺寸递减的纹理,通常是原纹理的1/2、1/4、1/8等。每一层都是上一层的降采样版本。在渲染时,根据屏幕上像素对应纹理区域的大小,自动选择细节层次最合适的Mipmap层级进行采样。

这样做的好处是:

  1. 抗走样:使用合适尺寸的纹理能有效减少摩尔纹。
  2. 提升性能:避免为远处物体使用高分辨率纹理而浪费显存和带宽。

在Unity等现代引擎中,导入纹理时可以自动生成所有Mipmap层级。


高级过滤:三线性与各向异性

我们甚至可以在不同的Mipmap层级之间进行插值,这称为三线性过滤。其过程是:

  1. 找到与目标像素最匹配的两个Mipmap层级。
  2. 分别在两个层级上进行双线性插值,得到两个颜色值。
  3. 根据像素与两个层级的匹配程度,对这两个颜色值再进行一次线性插值。

然而,所有这些基于二维插值的方案本质上都会模糊纹理。更先进的技术,如各向异性过滤,会尝试检测边缘,并避免沿着边缘进行模糊,从而在保持锐利边缘的同时*滑纹理。这需要更强大的GPU支持。

“各向异性”意为“非各向同性”。“各向同性”的操作在所有方向上效果相同(如旋转不变的模糊),而各向异性过滤则不是。

总结

本节课中,我们一起学习了纹理的基础知识。我们了解到纹理是应用于3D模型表面的2D图像,通过纹理坐标进行映射。我们探讨了纹理的寻址模式(如重复和钳位),并深入研究了纹理过滤这一核心课题,包括处理放大时的最*邻和双线性过滤,以及处理缩小时面临的走样挑战。我们介绍了Mipmapping作为解决远处纹理走样和优化性能的关键技术,并简要提及了三线性过滤和各向异性过滤等更高级的过滤方式。现代图形API和硬件为我们处理了大部分复杂细节,但理解其背后的原理对于进行图形编程和性能优化至关重要。


(课程互动部分)

010:高级纹理技术 🎮

在本节课中,我们将学习如何超越简单的颜色贴图,利用纹理存储和计算其他类型的信息,例如表面法线、环境反射和透明度。这些技术是现代3D游戏和图形应用中的核心组成部分。

大家好,我是Er Lanchman,佐治亚理工学院电气与计算机工程系的教授。欢迎来到2020年夏季的“视频游戏GPU编程”课程。

这里的标题可能有些误导,我们今天要看的这些技术并不特别奇特。几乎所有现代3D引擎都会使用它们,任何现代GPU都能轻松处理。但这些技术展示了如何将纹理用于物体基本RGB漫反射颜色之外的其他用途。

法线贴图技术 🧭

上一节我们介绍了纹理的基本概念,本节中我们来看看如何用纹理来模拟复杂的表面细节,而无需增加模型的几何复杂度。游戏中最常用的技术之一称为法线贴图。你也会看到凹凸贴图这个术语,虽然有人认为两者有细微差别,但我倾向于互换使用它们。其核心思想是,仅由顶点定义的物体看起来可能有些单调。

我们希望能在物体表面添加额外的细节,例如,在不增加三角形数量的情况下表现出粗糙的表面。这里我们指定了一个凹凸贴图,也称为高度图。此处的灰度值表示表面上方的高度扰动。然后,我们可以使用该高度图来渲染物体,从而得到例如这个看起来很酷的橙子。这个橙子的几何形状与球体的几何形状相同,这里没有额外的顶点,这完全是利用凹凸贴图信息在光照计算中实现的一种技巧。

如今渲染算法中实际使用的并不是原始的凹凸贴图,而是会计算一种称为法线贴图的东西。我们将把这些法线存储在纹理的红色、绿色和蓝色通道中。

以下是另一个例子。我有一个漂亮的小环面,也就是甜甜圈。它上面有一个漫反射纹理,但看起来有点单调。所以,在右边这里,顶点几何形状完全没有改变。这是使用法线贴图在光照计算中实现的效果。

法线贴图表示对顶点法线插值得到的原始向量的一种扰动。如果你使用一个看起来像左边这样的法线贴图(所有法线都笔直向上),你会得到这个基础物体,看起来就像完全没有应用法线贴图一样。法线贴图中各个纹素上的箭头如果笔直向上,你可以认为它们是指向表面正外的,因此不会扰动任何法线。只有当你开始在这里制造微小的变化时,才能获得像这样的效果。

法线贴图的生成与原理 🛠️

上一节我们看到了法线贴图的效果,本节中我们来看看它们是如何生成的。法线贴图可以通过不同的方式创作。通常,艺术家会先创建一个高度图,即用灰度表示高度的图像,例如在Photoshop中。左边这里有一个二维概念示例,其中纹理的灰度值表示高度变化。然后,你可以通过一个软件来处理它,该软件将使用高度信息,并推测表面各点法线的扰动方向。

像Unity这样的游戏引擎,以及许多其他引擎,都提供了在其编辑软件中从高度图创建法线贴图的功能。你也可以编写自己的工具或使用他人的工具。让我们看一个可以在网上找到的法线贴图示例。你可以加载其中一个高度图,它会为你提供一个法线贴图,如右图所示。我稍后会解释为什么它具有这种奇怪的特殊颜色模式。

要计算法线,你基本上需要沿着各个方向获取高度场的偏导数。这里我们本质上是在使用一种微积分技巧,利用水*和垂直方向的一阶差分来计算这些偏导数的*似值。

我们需要将得到的法线归一化,使其具有单位长度。如果你推导一下数学公式,你会发现你真正需要做的就是设置Z坐标为1(即向上的坐标),然后除以它的长度即可。

因此,在法线为 (0, 0, 1) 的地方,对应没有红色、没有绿色,全是蓝色。这表示没有扰动,即围绕你物体的光滑表面。

这就是为什么你通常会在法线贴图中看到这些大的蓝色区域。你会看到红色出现在像这样的区域,因为它代表了水*方向的变化。你会看到绿色出现在像这样的区域,因为它代表了垂直方向的变化。

法线贴图的实际应用 💡

上一节我们了解了法线贴图的原理,本节中我们来看看它在实际渲染中如何提升效果。在过去,你没有太多的计算能力来进行复杂的光照处理,所以艺术家们经常在像Photoshop这样的软件中创建的纹理里包含阴影效果。你会看到90年代的很多游戏都有特定的外观,但这些阴影并不十分真实。

如今,你希望用于漫反射光照的主颜色贴图本身不包含任何光照信息。如果你使用照片作为主要来源,你确实希望获得一种场景光线均匀、物体正面朝向的情况。这样,你就可以将不同位置灯光的效果放入实时计算中,而不是已经烘焙到纹理里。

但即使你这样做了,如果你只是把它贴在墙上并移动灯光,它看起来就像有人给这面墙贴了墙纸。你真正想要的效果是,当灯光移动时,能看到所有这些角落和缝隙的变化。我们可以在进行光照计算时加入法线贴图信息。

这里你看到了类似的一般效果。所有深蓝色的地方都相对*坦。但在你会看到红色和绿色的地方,你会看到光照因为法线向量被扰动而发生变化。

然后,如果我们加入这些信息,就可以得到像右边这样的图像,如果我们移动灯光,效果会更加显著。本讲座基本上是对我们稍后将详细研究的一些技术的预览。我稍后会向你展示如何在着色器代码中实现这一点,并使用Unity进行一些物体旋转和灯光飞行的演示。这只是对我们稍后将详细研究的技术的一个小预告。

这是一个使用纹理的RGB颜色条目来表示非RGB颜色信息的例子。这里有一个小难点,虽然我们法线的Z值将是正的(在0到1之间,不能为负,因为那将指向物体内部),但即使总长度需要等于1,X和Y值本身也可能向下摆动到-1。而我们的RGB颜色必须是正的,特别是现代着色器硬件可能期望它们在0到1之间。

我们可以在着色器代码中轻松处理这个问题。我们还没有看过着色器代码,但我假设使用C++、C#、JavaScript等具有花括号和分号的语言。我们将取法线值,然后乘以0.5,这样我们就得到了从-0.5到0.5的值,然后再加上0.5,得到0到1之间的值。这就是我们在创作这些法线贴图纹理时需要做的。然后,在着色器代码中,当我们读回该值时,我们将减去0.5以将其放回-0.5到0.5的范围,然后乘以2以恢复-1到1的范围。这只是使用纹理中的颜色通道来表示渲染中实际颜色之外的某种信息的一个例子。

立方体贴图与环境反射 🌐

上一节我们探讨了用纹理存储法线信息,本节中我们来看看另一种完全不同的纹理类型。这是另一种技术,这里的纹理值确实是RGB颜色,但这是一种非常不同的纹理,称为立方体贴图。你可以想象走到户外,拿起相机,分别指向上下、左右、前后,每次指向一个方向时拍一张照片。然后把这些照片加载到Photoshop中,花大量时间试图让边缘完美对齐。这样你就构建了一个对场景整体光照环境的描述。这是基于图像的照明的基础,我们稍后会详细研究。

目前,最主要的是这使我们能够制作一些非常酷的反射效果。你可以通过拍照或以其他方式创作自己的立方体贴图,也可以在3D渲染软件中完成,不一定受限于游戏引擎的能力。Unity和其他游戏引擎可以为你创建这些立方体贴图,可以在场景开始时创建并使用,也可以在游戏过程中更新它们。现在,这将非常耗费计算资源,唯一能真正节省资源的是你可以以较小的分辨率渲染这些立方体贴图,仍然可以获得不错的效果。你也可以使用一些技巧,比如不每帧都更新,或者循环更新六个方向,每帧只更新其中一个。但如果你愿意花费额外的计算时间,那么你的立方体贴图就可以反映环境中物体移动、进入和离开场景的动态变化,并可以反映在你的反射效果中。

这里的茶壶以镜面反射的方式对光做出反应。这与我们目前看到的漫反射光照非常不同。

现代游戏中用于镜面反射的模型相当复杂,所以我们稍后会花一整节课来讨论。但现在,让我们只做一些完美的镜子,并假设你的物体完美地反射来自立方体贴图的环境光。

这与漫反射光照非常不同。这里不是指定特定的光源,光线可以说是来自所有方向,并且相机的位置与最终效果的外观非常相关。对于漫反射光来说并非如此。漫反射光照背后的整个思想是,光线无论从哪个方向入射,都会在表面反弹,并大致均匀地射向各个方向。在这里,要确定我们在特定点看到什么,我们需要取这条光线,围绕表面的法线反射它,以确定我们想在立方体贴图中查找的位置。我不打算在这里做几何推导,着色器语言中通常内置了计算反射光线的命令。如果没有,你可以用这个公式来实现。

要查找立方体贴图中的内容,着色器语言通常也有一个特定的命令来处理。

我们稍后会看到,要进行通常的纹理查找,我们会使用类似 tex2D 的东西,并给它纹理坐标(根据你的特定约定,指定为 (u, v)(s, t))。但 texCube 不同,你实际上要给出一个3D向量。这个向量不需要归一化,只要它指向正确的方向,它就会为你找出它击中了六个纹理中的哪一个,哪个面是相关的,然后从该特定图像中获取适当的纹素(如果需要,可以进行插值)。这种效果在詹姆斯·卡梅隆的电影如《深渊》和《终结者2》中得到了推广。这两部电影都有大量闪亮反射表面的CGI。在当时,这些是离线计算的,无法实时进行这类计算,因为没有足够的计算能力。

这是我们展示的第一个立方体贴图纹理示例,你可以在这里同时看到背景中的立方体贴图图像以及它在球体上的反射。如果你想更有趣,实际上可以组合这些技术。你可以在球体上放置法线贴图,给它一个粗糙的表面,那看起来会非常棒。我们稍后会做类似的事情。

Alpha通道与透明度 ✨

上一节我们介绍了立方体贴图,本节中我们来看看纹理的第四个通道——Alpha通道。我一直在使用RGB这个术语,但GPU喜欢以四为一组处理所有事情,当我们使用齐次坐标时,我们有XYZW作为第四维。因此,继续为我们的RGB添加一个分量是合理的,这第四个分量称为Alpha。

你可以将这个Alpha通道用于各种信息。例如,稍后当我们研究镜面反射模型时,我们会看到定义镜面反射如何工作的光滑度参数,你可以把它放入Alpha通道,并让它随物体表面变化。但在大多数实际应用中,Alpha用于透明度信息。

在这个特定情况下,如果我们只有这些黑白线条的RGB纹理,你只会渲染并看到黑白线条。但你可以以不同的方式使用Alpha信息,你可以使用该Alpha来决定纹理的哪些部分应该实际被渲染。你会发现人们使用这类效果不仅是为了把超级反派关进监狱,还用于像栅栏这样的东西。因此,与其为栅栏的每个柱子单独建立一个3D物体,你实际上可以有一个大的栅栏墙,并指定柱子之间的区域被“镂空”。

这样,那些部分就不会被渲染。你也看到这个技巧用于植被,比如草。如果你仔细观察草,他们可能没有为每一片草叶单独建立一个3D物体,你可能只是有一块草的贴图,使用Alpha来透过纹理中的单个草叶。Alpha不一定非要是全开或全关的东西,你也可以用它来表示透明度程度,你可以部分地看到后面被写入的对象。你需要确保透明物体在渲染过程中比不透明物体更晚绘制。

烘焙光照贴图 🍞

作为纹理创造性使用的最后一个例子,像《雷神之锤》这样的老游戏通常会有一个预先制作的光照贴图,你可以将其与一些基本纹理结合,以将静态灯光在静态表面上的效果烘焙进去。这对于漫反射反射是合理的,显然无法处理镜面反射,因为那是随着相机移动而变化的动态效果。这也是我们将在以后的讲座中研究的内容。它也无法处理可能移动的灯光或可能移动的物体。

你可以通过混合来获得不同的效果。我应该澄清,这不一定只是老游戏才使用的技术。现代游戏也会使用大量烘焙的光照贴图。当你进行这种烘焙时,可以节省实时计算,并且还可以融入一些复杂的间接光照效果,这些效果即使使用相当强大的GPU也无法实时实现。我们稍后会研究其中一些问题。


特别是,我们将研究将预烘焙光照与动态光照结合的不同方式,并使它们协同工作,产生合理的效果。

总结 📚

在本节课中,我们一起学习了多种高级纹理技术。我们从法线贴图开始,了解了如何利用纹理的RGB通道存储表面法线扰动信息,从而在不增加几何复杂度的前提下模拟丰富的表面细节。其核心是将高度图转换为法线向量并编码到纹理中,在着色器中通过公式 normal = (texColor * 2.0 - 1.0) 进行解码和归一化后用于光照计算。

接着,我们探讨了立方体贴图,它用六张纹理构成一个包围盒,用于模拟环境反射。通过计算视线在表面的反射向量,并使用 texCube 指令进行采样,可以实现逼真的镜面反射效果。

然后,我们介绍了纹理的 Alpha通道,它最常见的用途是定义像素的透明度,从而实现如栅栏、植被叶片等部分的镂空或半透明效果。渲染时需要确保透明物体在不透明物体之后绘制。

最后,我们提到了烘焙光照贴图,这是一种将静态光照效果预先计算并存储到纹理中的技术,可以显著提升视觉质量并节省运行时开销,常与动态光照结合使用。

这些技术展示了如何创造性地利用纹理存储远超颜色本身的信息,是现代实时图形渲染的基石。

011:色彩空间 🎨

概述

在本节课中,我们将学习计算机图形学中一个关键但常被误解的概念:色彩空间。我们将探讨为何图像存储和显示的方式并非线性,以及这如何影响游戏和图形应用中的光照计算与图像混合。理解并正确处理色彩空间,对于实现逼真的渲染效果至关重要。


历史背景与核心问题

上一节我们概述了色彩空间的重要性,本节中我们来看看其历史成因。早期的阴极射线管(CRT)显示器和电视,其显示亮度与输入的数值(如电压或8位值)并非呈线性关系。

你可以将其输出亮度的关系*似理解为对输入值进行幂运算,例如取输入值(范围0到1)的2.2次方。为了简化讨论,我们常使用*方(即2次方)来类比。由于输入值小于1,*方运算会使其变得更小。

这意味着,如果直接将记录原始光强度的图像显示在旧式CRT上,图像会显得过暗。因此,几乎所有的消费级相机在存储图像前,都会预先应用一个校正,这个校正*似于对输入的光强度值进行开方运算(例如*方根)。这样,相机的校正(开方)与显示器的非线性响应(*方)相互抵消,最终输出正确的亮度。

当然,这里简化了许多细节,并且没有涉及人类感知光线的复杂方式。但在大多数情况下,这不会造成问题。我们可以用一个公式来描述这个理想过程:

存储值 = sqrt(实际光强度 A)
最终输出亮度 = (存储值)^2 = A

混合图像时的问题

上一节我们介绍了在简单显示场景下色彩空间的工作原理,本节中我们来看看当需要混合或叠加图像时会出现什么问题。这个问题不仅存在于3D计算机图形学中,也出现在像Photoshop这样的图像处理软件中(常被称为sRGB色彩空间,这也是一个简化说法)。

回到计算机图形学的案例。假设你有一个3D物体,并用一盏灯照亮它,这没有问题。但如果你用两盏灯照明呢?纹理通常以经过校正(即开方后)的值存储。

如果我们在着色器代码中直接以这种“伽马空间”下的值进行光照叠加计算,例如将 sqrt(A)sqrt(B) 相加,然后显示器再对这个和进行*方运算,就会得到:

最终输出 = (sqrt(A) + sqrt(B))^2 = A + B + 2 * sqrt(A * B)

我们得到了期望的 A + B,但也引入了一个额外的交叉项 2 * sqrt(A * B),这会导致渲染结果不准确。

多年来,许多游戏使用这种“伽马空间”进行光照计算,玩家也能接受。但主要问题在于,场景中的一组灯光参数只对那个特定场景有效。美术师需要花费大量精力调整灯光,以掩盖这种不准确性。如果有人后来尝试在场景中添加一盏新灯,或者专业的电影灯光师来操作,他们会发现游戏引擎中的灯光叠加方式不符合他们的物理直觉和行业惯例。

注:为了简化数学描述,我们使用了*方和*方根。实际上,真实世界使用的幂值更接* 2.21/2.2


解决方案:线性空间光照

上一节我们看到了在伽马空间下混合光源的问题,本节中我们来看看标准的解决方案。核心思路是:在读取纹理时,立即进行“*方”运算(即从sRGB转换到线性空间),然后在线性空间下进行所有的数学计算(如光照叠加),最后在将最终像素值写入帧缓冲区(准备显示)之前,再进行一次“开方”运算(即从线性空间转换回sRGB)。

以下是实现此流程的步骤:

  1. 纹理读取时转换:从纹理中读取颜色值时,将其从伽马空间转换到线性空间。
  2. 线性空间计算:所有着色器计算(光照、混合等)均在线性空间中进行。
  3. 输出前转换:将最终计算出的线性空间颜色值转换回伽马空间,以便正确显示。

现代GPU通常内置硬件支持这种转换。你可以在图形API(如Vulkan、DirectX)中设置,让GPU在采样纹理时自动进行到线性空间的转换,并在写入帧缓冲区时自动转换回伽马空间。这节省了宝贵的着色器指令周期。


Unity引擎中的色彩空间设置

上一节我们介绍了线性空间光照的原理,本节中我们来看看如何在流行的Unity游戏引擎中配置它。

在Unity的早期版本(如Unity 4)中,正确的线性空间光照是“专业版”独有的功能。如果使用免费版,开发者必须在自己的着色器代码中手动编写转换函数,这会消耗额外的着色器性能。从Unity 5开始,Unity大幅统一了免费版和付费版的功能,线性空间光照成为所有版本的标准支持功能,这对于推行基于物理的渲染至关重要。

以下是检查与设置Unity项目色彩空间的步骤:

  1. 打开 Project Settings
  2. 选择 Player 设置面板。
  3. Other Settings 部分找到 Rendering 子项。
  4. Color Space 从默认的 Gamma 更改为 Linear

重要建议:对于任何涉及3D图形和光照的项目,都应使用 Linear 色彩空间。仅在某些纯2D精灵游戏且无动态光照的情况下,才考虑使用Gamma空间。

Unity目前提供了不同的渲染管线,它们对色彩空间的处理方式如下:

  • 内置渲染管线(旧版):如上所述,需在Player设置中手动将色彩空间从默认的Gamma改为Linear。
  • 高清渲染管线(HDRP):专为高端*台设计,默认使用Linear色彩空间。
  • 通用渲染管线(URP,原名轻量级渲染管线):面向多*台,可缩放,默认也使用Linear色彩空间。


总结

本节课中,我们一起学习了色彩空间这一核心概念。我们了解到,由于历史原因,图像的存储和显示过程存在非线性转换(伽马校正)。直接在存储值(伽马空间)下进行光照混合计算会导致错误。正确的做法是在线性空间中进行所有光照和颜色计算,这需要我们在读取纹理时进行转换,并在输出到屏幕前再次转换。现代GPU和Unity等引擎已提供硬件和软件支持来高效处理这些转换。确保你的项目设置为线性色彩空间,是实现逼真、正确渲染效果的基础步骤。

012:混合技术

在本节课中,我们将要学习GPU编程中的混合技术。混合技术允许我们将新绘制的像素颜色与帧缓冲区中已有的颜色进行组合,而不是简单地覆盖,这对于实现透明度、用户界面元素等视觉效果至关重要。


在之前的GPU编程课程中,我们通常假设,当你向帧缓冲区写入一个像素时,你会将当前像素的深度与Z缓冲区中已存储的深度值进行比较。如果该像素位置之前已被写入过,你通过比较来决定是否写入。基本上,只有当新像素比缓冲区中已有的像素更靠*摄像机时,你才会写入新的颜色值和深度值。因此,你总是在覆盖已有的颜色值。

但有时,你可能不希望简单地强制覆盖已有的颜色。你可能希望将当前要写入的像素颜色与已写入的某种颜色值进行混合,以实现诸如透明效果等功能。这可以用于渲染某种幽灵般的人物,或者渲染可以看透的着色窗户。这类技术也常用于用户界面元素。

通过使用API中的不同调用,你可以设置不同的混合模式,例如相加颜色、相减颜色。相减模式我见到的使用频率不高,它可能用于模拟通过某种滤镜镜头观察场景的效果。如果我没记错,在Jonathan Blow的游戏《见证者》中有一个类似的谜题,尽管我不清楚其具体实现方式。你也可以将像素值相乘,但据我所知,这些模式并不常用。

最常用的混合模式是Alpha混合。在这种模式下,新的颜色值是源像素和目标像素的加权组合,其权重由一个介于0到1之间的混合因子alpha决定。通常,这个alpha值就是纹理的实际Alpha通道分量。

以下是Alpha混合的核心公式:

新颜色 = (源颜色 * 源Alpha) + (目标颜色 * (1 - 源Alpha))

如果alpha值为1,那么你正在绘制的物体完全不透明,你将看不到它背后已有的任何内容。如果alpha值为0,那么你正在绘制的物体完全透明,你只能看到场景中已有的内容。

多年前,我的同事Sha Lee仅用PowerPoint就创建了这个演示。这里没有使用任何花哨的3D API。想象一下,如果你在没有Alpha混合的情况下,将黑色方块绘制在橙色三角形之上,你会得到类似这样的结果。但如果你以0.2的alpha值绘制它,那么在三角形和方块重叠的地方,你会看到一点三角形与方块混合的颜色。随着你增加alpha值,你会看到越来越多的三角形橙色。这再次说明了这个概念。

以下是另一个例子,也是Sean用PowerPoint制作的。想象你有一个黄黑相间的棋盘格图案。如果你在不使用任何透明效果的情况下绘制一些额外的物体,你可以在它上面画一个蓝色矩形,然后在上面画一个橙色矩形,最后在上面画一个紫色矩形。

如果我们进行同样的绘制,但使用透明效果呢?如果我们混合那个蓝色矩形和之前已有的内容,注意我们最终在这里得到了一些灰色,这是黄色和蓝色重叠并混合的结果。蓝色和黄色是互补色,当它们结合时,你会得到灰色。然后,如果我们绘制橙色,橙色会与黄色部分结合,给你一些带黄色调的橙色。当我们绘制紫色时,我们会得到更复杂的混合效果。你应该明白了大致的概念。


本节课中我们一起学习了GPU中的混合技术。我们了解到,混合允许我们通过加权组合源像素和目标像素的颜色来实现透明度等效果,而不是简单地覆盖像素。核心的Alpha混合公式 新颜色 = (源颜色 * 源Alpha) + (目标颜色 * (1 - 源Alpha)) 是实现这些视觉效果的基础。掌握混合技术对于创建逼真的透明物体、用户界面叠加层和各种屏幕后处理效果至关重要。

013:模板缓冲区 🎮

在本节课中,我们将学习GPU中的另一个重要缓冲区——模板缓冲区。我们将探讨它的基本概念、工作原理以及在游戏图形渲染中的实际应用,例如实现镜面效果和阴影。

概述

上一节我们简要介绍了混合技术,本节中我们来看看模板缓冲区。模板缓冲区是GPU内存中的另一个帧缓冲区,它允许你选择性地绘制屏幕的某些部分,同时屏蔽其他部分。这在实现特定视觉效果(如驾驶舱遮罩、镜面反射)时非常有用。

模板缓冲区的基本概念

我们已经讨论过主颜色缓冲区(最终显示给玩家的画面)和Z缓冲区(用于确定遮挡关系)。模板缓冲区是另一个帧缓冲区,它让你能够选择屏幕的哪些部分需要绘制,哪些部分不需要绘制。

例如,在飞行模拟器中绘制外部场景时,你可以使用模板缓冲区来遮罩掉驾驶舱部分。

这个示意图来自Zggyware网站(该网站已不存在,但文本内容可在互联网档案馆找到)。其核心思想是:如果你希望最终图像呈现特定效果,你可以预先知道某些部分(如墙壁)会遮挡其他物体(如茶壶)。与其为那些最终不可见的茶壶部分计算Z缓冲区值,不如利用模板缓冲区提前标记出遮挡区域,从而节省计算资源。

以下是其工作流程:

  1. 将已知的遮挡结构(如墙壁)信息写入模板缓冲区。
  2. 绘制茶壶时,GPU会检查模板缓冲区。
  3. 对于与标记为“不绘制”区域重叠的茶壶部分,GPU会直接跳过写入操作。

使用模板缓冲区实现镜面效果

模板缓冲区可以用来模拟镜面反射效果。虽然要实现精确的镜面反射,光线追踪是更好的解决方案,但实时光线追踪计算量依然很大。因此,模板缓冲区提供了一种高效的替代方案。

以下是实现镜面效果的基本步骤:

  1. 渲染标准物体:首先,渲染所有非镜面的常规物体。
  2. 准备模板缓冲区:清空模板缓冲区,然后渲染镜面本身。但此阶段不写入镜面的实际颜色值,而是向模板缓冲区写入一个特定值(例如1),标记出镜面所在的屏幕区域。
  3. 渲染反射场景:进行第二次渲染。这次,你使用模板缓冲区,只对标记为镜面的区域绘制颜色。关键技巧在于,在模型到世界、世界到视图的变换矩阵序列中,插入一个反射矩阵。这个矩阵能沿着镜面*面对场景进行翻转。因此,当你在模板缓冲区标记的区域内渲染物体时,你实际上渲染的是场景的反射影像。

你也可以采用相反的顺序:先完整渲染镜面,再渲染场景中不在镜面内的部分。如果镜面占据了图像的大部分区域,这样做可能出于性能考虑。

如果你想深入了解此技术,建议查阅NVIDIA的Mark Kilgard所著的优秀论文《Improving Shadows and Reflections via the Stencil Buffer》。

模板缓冲区的其他应用:阴影

模板缓冲区还可用于处理阴影,特别是通过一种称为阴影体的技术。

阴影体技术(曾在《毁灭战士3》的引擎中使用)能产生边界清晰的优质阴影。它的一种具体实现称为“深度失败算法”,也称为“Carmack反转”。(注:该算法的专利过程揭示了美国专利系统的一些问题,相关荒谬之处可在其他地方读到。)

然而,阴影体技术需要大量的CPU计算,现已不再流行。目前大多数现代主流游戏引擎(如Unity和Unreal)都使用另一种得到现代GPU硬件良好支持的技术——阴影映射

如果你想了解更多关于阴影体的知识,强烈推荐阅读NVIDIA《GPU Gems》中相关的优秀章节,该书现已免费提供在线版本。本课程后续将重点讲解阴影映射技术。

总结

本节课我们一起学习了模板缓冲区。我们了解了它是GPU中用于选择性绘制的另一个缓冲区,探讨了其实现镜面反射效果的基本原理,并简要介绍了它在阴影体技术中的应用及其被现代阴影映射技术取代的原因。掌握模板缓冲区的概念,有助于理解游戏引擎中多种视觉特效的实现基础。

014:GPU架构与汇编语言 🎮

在本节课中,我们将学习GPU架构的基础知识,特别是着色器模型、寄存器以及汇编语言层面的操作。我们将了解顶点着色器和像素着色器如何工作,以及GPU如何通过其独特的指令集(如“Swizzling”操作)高效地处理图形数据。


概述

GPU(图形处理单元)是现代视频游戏和图形应用的核心。与通用CPU不同,GPU专为大规模并行处理而设计,尤其擅长处理浮点运算和向量操作。本节课将深入探讨GPU的编程模型,包括着色器语言、寄存器架构以及底层汇编指令。我们将重点关注顶点着色器和像素着色器,这是3D图形渲染中最常用的两种着色器。


什么是着色器模型? 🤔

着色器模型定义了GPU支持的一组寄存器类型和指令。它是一份规范,规定了程序员可以通过API(如Direct3D或OpenGL)向GPU发送哪些命令。然而,GPU具体如何执行这些指令,则由GPU硬件和驱动程序决定。

随着时间推移,GPU承担了越来越多原本由CPU处理的任务。例如,早期的PC图形硬件可能只有一个绘制三角形的命令。如今,GPU几乎处理所有图形计算,从顶点变换到像素着色。


顶点着色器与像素着色器 🖥️

在GPU流水线中,顶点着色器和像素着色器扮演着关键角色。

上一节我们介绍了着色器模型的概念,本节中我们来看看这两种核心着色器的具体职责。

顶点着色器

顶点着色器负责处理3D模型的每个顶点。它的主要任务包括:

  • 坐标变换:将顶点从模型空间转换到屏幕空间。
  • 法线变换:为光照计算准备法线向量(通常使用逆转置矩阵)。
  • 传递数据:为后续的像素着色器计算并输出数据,如纹理坐标、颜色或光照信息。

顶点着色器在独立的并行计算单元上运行,每个单元只处理一个顶点,不知道其他顶点的信息。

像素着色器(片段着色器)

顶点着色器处理完后,GPU会组装三角形,并确定哪些像素属于这些三角形。接着,像素着色器开始工作。

像素着色器(也称为片段着色器)负责计算每个像素的最终颜色。它的主要任务包括:

  • 纹理采样:根据插值后的纹理坐标从纹理中获取颜色。
  • 逐像素光照:使用插值后的法线等信息进行更精确的光照计算。
  • 特效应用:实现如模糊、阴影等后期处理效果。

“片段”一词常与“像素”互换使用,但它更精确地指代可能被丢弃或合并的中间数据。


GPU数据与运算特性 🔢

从传统编程转向GPU编程时,需要适应其以浮点数运算为核心的特点。

以下是GPU编程中需要了解的核心数据与运算特性:

数据类型

  • 浮点数为主:在着色器模型4.0之前,GPU甚至没有原生的整数类型,整数需用浮点数表示。
  • 向量与矩阵:核心数据类型是浮点向量(如 float4)和矩阵(如 float4x4)。它们可被解释为行向量或列向量,具体取决于运算。
  • 寄存器:所有数据(输入、输出、常量、临时变量)都存储在四分量寄存器中。

核心概念:Swizzling 与 内建指令

GPU的强大之处在于其能高效处理四元组数据。

Swizzling 是一种在指令级别重组向量分量的强大技术。它允许你在执行操作(如加法、乘法)的同时,自由地交换、复制或选择向量的特定分量。

公式示例:假设有一个四维向量 V = (x, y, z, w)

  • V.xyzw 表示其本身。
  • V.yxzw 交换了x和y分量。
  • V.xxxx 将x分量“扩散”到所有四个分量。
  • V.rgb 在颜色语境下等同于 V.xyz

内建指令:GPU指令集包含许多针对图形运算优化的指令,例如:

  • dot:点积。
  • rsqrt:倒数*方根(用于向量归一化,normalize(v) = v * rsqrt(dot(v, v)))。
  • mad:乘加运算(a * b + c)。
  • 三角函数(sin, cos等)。

值得注意的是,没有直接的“开*方”指令,但可以通过 rsqrt 的倒数来实现。


着色器汇编代码示例 💻

虽然我们不会手动编写汇编代码,但了解其结构有助于理解GPU的高效性。以下是一些关键示例:

示例1:向量叉积

仅用两条指令即可完成三维向量的叉积计算,这得益于 mad(乘加)指令和Swizzling操作。

伪代码逻辑

// 计算 R0 和 R1 的叉积,结果存入 R2
// 使用 mad 指令同时进行乘法和加法/减法
R2 = mad(R0.zxy, R1.yzx, -R0.yzx * R1.zxy)

实际汇编指令会利用Swizzling精确控制分量的组合与运算顺序。

示例2:向量归一化

仅用三条指令即可完成向量的归一化。

代码逻辑

  1. DP3 R0.w, R1, R1 // 计算点积 dot(R1, R1),结果存入R0的w分量。
  2. RSQ R0.w, R0.w // 计算倒数*方根 1/sqrt(R0.w)
  3. MUL R0.xyz, R1, R0.w // 将R1的xyz分量乘以归一化因子,结果存入R0。

公式normalized_vector = vector * rsqrt(dot(vector, vector))

这些示例展示了GPU汇编如何通过高度集成的指令和Swizzling,用极少的步骤完成复杂运算。现代驱动程序会将我们看到的着色器模型汇编代码,进一步编译成GPU硬件真正执行的、更底层的微码。


现代着色器语言 🚀

直接编写汇编代码既繁琐又难以维护。因此,高级着色器语言应运而生。

上一节我们领略了汇编的高效与复杂,本节中我们来看看更友好的编程方式。

目前主流的高级着色器语言包括:

  • HLSL:微软DirectX系列使用的语言。
  • GLSL:OpenGL使用的语言。
  • Cg:NVIDIA早年推出的语言,现已与HLSL基本融合。

这些语言语法相似(类似C语言),它们会被编译器翻译成我们之前讨论的着色器模型汇编代码,进而被驱动编译成GPU硬件指令。使用高级语言的好处是代码更易读、易写、易维护,并且可以借助引擎(如Unity)实现跨*台编译。


总结

本节课我们一起学习了GPU架构与汇编语言的基础知识。我们了解了:

  1. 着色器模型是GPU功能的抽象规范。
  2. 顶点着色器处理每个顶点,负责坐标变换等任务。
  3. 像素着色器处理每个像素,负责计算最终颜色和特效。
  4. GPU运算以四分量浮点向量为核心,并通过 Swizzling专用指令实现极高效率。
  5. 虽然底层是汇编,但实际开发中我们使用 HLSL/GLSL 等高级着色器语言。

理解这些底层原理,有助于我们写出更高效、性能更好的着色器代码,为后续学习具体的着色器编程打下坚实基础。

015:HLSL导论 🎮

在本节课中,我们将学习高级着色语言(HLSL)。上一节我们介绍了着色器汇编代码,并指出没有人真正想写汇编代码,大家都希望使用高级着色语言来编写。本节中,我们将深入了解微软开发的一种特定高级着色语言——HLSL。

概述

HLSL由微软开发,而NVIDIA开发的CG语言最初与之不同,但两者很快趋同,因此人们常将HLSL和CG互换使用。不过,HLSL现在是更常见的术语。OpenGL有自己的着色语言GLSL,其代码很容易适配到HLSL,反之亦然。Vulkan是OpenGL的后继API,它将许多由OpenGL驱动程序隐式处理的决策交给程序员,以实现更细致的优化,但这使得Vulkan API更具挑战性。苹果则开发了自己的Metal API。Vulkan和Metal都有各自的着色语言,但这些语言在语义上与GLSL或HLSL差异不大,主要区别在于语法。这意味着像Unity这样的引擎可以让你用HLSL编写代码,然后根据需要将其翻译成其他语言。

HLSL基础

HLSL看起来像C语言,但更简单。尽管较新的着色器模型(如Shader Model 6.x)已经推出,但通常编写的代码最终会编译成相当简单的形式。着色器没有内存,只有寄存器,因此没有动态内存分配命令。较新的着色器模型支持显式跳转命令,但旧模型不支持,通常通过运行所有分支并进行条件赋值来模拟分支语义。此外,大多数函数调用实际上是内联的,编译后的代码直接插入需要的位置,而不是进行跳转。虽然最新的着色器模型可以模拟递归,但在常见的图形应用中很少需要。

变量类型:Uniform与输入

在HLSL或CG中,与C、C++、C#或Java等语言不同,存在Uniform变量和变量输入的概念。Uniform变量对所有处理的顶点和像素都是通用的,例如从模型空间到世界空间、视图空间和裁剪空间的变换矩阵。这些变量需要在着色器代码外部(如C++或C#中)设置。定义Uniform变量时,不需要显式使用uniform关键字,只要在函数外部定义即可。这些变量通常位于代码顶部。

另一种输入是来自主内存发送到GPU的大型顶点缓冲区,以及由插值器生成的像素位置和颜色等信息。这些输入使用语义(Semantics)定义,这是HLSL和CG特有的概念,与GPU上的特定硬件部件相关联,不是通用的。

Unity中的变量处理

使用Unity时,一个棘手之处在于它试图尽可能简化游戏开发,因此隐藏了许多细节。这与本课程关注GPU底层细节的目标有些冲突。Unity运行时会为我们定义许多变量,Unreal、Godot等引擎也是如此。一旦达到一定的复杂程度,你将需要通过自定义脚本创建自己的变量。

如果使用原始的DirectX、OpenGL、Vulkan或Metal编写代码(通常用C++),则需要自己处理所有这些变量。同样,XNA(或开源的MonoGame)虽然常被称为游戏引擎,但它实际上是一个专注于游戏的API,是一个位于DirectX或OpenGL之上的.NET层,并非完整的游戏引擎。在XNA中,你需要自己定义和分配所有Uniform变量。本课程前六年使用XNA时,我们必须进行所有这些显式分配。而在Unity中,许多工作由引擎处理。

存储在常量寄存器中的信息(如灯光的位置和颜色)称为Uniform,因为它们对于着色器代码处理的所有顶点和像素保持不变。

语义示例

语义通过冒号后的关键字定义,这些关键字是大写的,且不能随意创建,因为它们与GPU上的特定硬件功能相关联。例如,TEXCOORD0TEXCOORD1等与插值硬件相关联。当将大量顶点和索引数组传递到定义三角形的顶点表时,API调用会引用位置和法线等特定内容。Unity运行时内置的渲染器会为我们处理许多这些细节,但如果使用MonoGame或原始的C++与DirectX/OpenGL编写游戏,则需要自己定义这些缓冲区、加载顶点并进行所有API调用。语义连接了CPU端和GPU端,以及GPU的各种硬件元素。

数学运算与向量

着色器代码中的大多数数学运算涉及浮点值或浮点值向量和数组。着色器代码的坏消息是没有指针,因此无法创建和使用复杂的数据结构;好消息也是没有指针,因此没有指针错误。我们没有任何与指针相关的常规语法。

Shader Model 4.0引入了整数及其各种操作,但在3D图形着色器代码中很少见到使用这些功能的例子,它们主要用于更通用的GPU编程框架(如挖矿或加密)。

在深入探讨之前,我们需要谈谈HLSL中的向量,特别是如何相乘。在像MATLAB这样的语言中,有列向量和行向量的概念,但在HLSL和许多其他着色语言中,没有相同的概念,只有向量和矩阵。如何解释行向量和列向量取决于使用乘法命令的方式。如果将向量放在矩阵前面,可以将其解释为行向量乘以矩阵;如果将矩阵放在向量前面,可以将其解释为矩阵后乘以列向量。如果想将向量视为列向量但放在第一个参数,则相当于将该列向量乘以矩阵的转置;如果想将向量视为行向量但放在第二个参数,则相当于后乘以矩阵的转置。这可能导致查看不同教科书和在线着色器代码示例时产生混淆,因为有时不清楚是行向量还是列向量,答案取决于你如何对待它。

因此,在着色器代码中很少看到显式的转置命令。如果需要转置,要么在CPU端预计算并作为Uniform参数发送,要么通过特定方式使用乘法命令并解释结果。在HLSL中,你会看到许多内置命令,如点积、叉积、距离计算等。线性插值命令的一个有趣之处是,插值因子f不一定在0和1之间,因此可以进行一定程度的推断。lit命令有点奇怪,它映射到着色器模型汇编指令,用于计算旧式的Blinn-Phong光照模型,但实际实现Blinn-Phong的着色器代码通常直接编写方程而不使用该命令。normalize命令用于归一化向量,saturate命令是minmax操作的简写,用于将值限制在0和1之间。在环境映射中,我们需要使用reflect命令。sincos命令很有趣,因为它将输出赋值给参数列表中的SC组件,而不是像函数那样返回输出。确实有一个特定的着色器模型汇编指令可以同时计算一个数字的正弦和余弦。

如果你想知道在哪里可能使用sincos,请记住旋转矩阵。如果考虑某个位置和方向的对象,这些正弦和余弦值可以预计算以创建应用于所有顶点的矩阵。但你可能想象在着色器代码内部进行某种旋转动画,这时sincos命令就会派上用场。

总结

本节课我们一起学习了HLSL的基础知识,包括其与CG、GLSL等其他着色语言的关系,以及Uniform变量、语义、向量运算等核心概念。我们还探讨了在Unity等引擎中变量处理的方式,以及HLSL中常用的内置数学函数。理解这些内容对于编写高效的GPU着色器代码至关重要。

016:Unity内置着色器源代码分析 🎮

在本节课中,我们将学习如何下载并初步探索Unity引擎内置着色器的源代码。理解这些代码对于编写自定义着色器至关重要。

大家好,我是Sarah Lanchman,佐治亚理工学院电气与计算机工程系的教授。欢迎来到“GPU编程与游戏开发”课程。本节课的内容并不复杂,我们将从Unity官网下载一些着色器代码。

即便您因某些原因没有使用Unity,而是使用Unreal或Godot等其他引擎,我仍然建议您下载并浏览Unity的着色器代码。通过研究这些代码,您可以学到很多知识。

顺便一提,Godot引擎非常有趣,它完全免费且开源。在我研究过的众多引擎中,它的整体风格最接*Unity。但将两者直接比较对任何一方都不公*,因为每个引擎都在致力于实现自己的目标。

如果您正在使用Unity,那么花时间研究这些着色器代码是非常必要的。因为Unity的官方文档通常要么相当简略,要么存在错误。很多在线文档适用于Unity的早期版本,如果您通过网页论坛寻找问题答案,所看到的信息很可能是极具误导性的。

因此,如果您想做的不仅仅是使用内置的标准着色器,特别是希望您编写的着色器能与Unity内置着色器良好协作,就必须深入研究这些代码。

下载源代码

以下是下载Unity内置着色器源代码的步骤。

  1. 打开您常用的搜索引擎,输入类似“Unity archive”的关键词进行搜索。
  2. Unity提供了其所有内置着色器的源代码。虽然着色器代码被嵌入在Unity可执行文件中,但实际的源代码需要单独下载。
  3. 让我们下载2019年最新版本的源代码。如果您之前下载过2019.4.0版本的代码,可能不需要为2019.4.2版本再次下载。然而,在不同的小版本号之间,代码通常会发生变化。例如,如果您上次下载的是2019.3.15的代码,而现在使用的是2019.4,那么您需要重新下载。
  4. 当然,Windows和Mac版本的Unity可执行文件是不同的,但下载着色器代码的链接在Mac和PC版本之间是相同的。这是合理的,因为Unity希望您的游戏能够部署到各种不同的*台。
  5. 点击链接下载内置着色器压缩包。我还想获取最新测试版的着色器代码,但遗憾的是在这里找不到。因此,我们需要返回搜索引擎,搜索“2020.1”版本。
  6. 在“Additional downloads”或“Additional resources”部分,我们可以找到“Built-in shaders”的下载链接。2019年的着色器文件带有“.2019”扩展名,而2020年测试版的着色器文件则没有扩展名。看起来2020年的着色器比2019年的源代码多了大约半兆字节。
  7. 值得注意的是,2020年版本中有一个额外的“Default Resources Extra”文件夹,其中可能包含一些有趣的内容。

探索代码结构

上一节我们完成了源代码的下载,本节中我们来看看代码的组织结构。Unity有一些奇怪的文件夹命名约定。要弄清楚Unity着色器如何工作,主要需要查看两个地方:一个是“CGIncludes”文件夹,另一个是“Default Resources Extra”文件夹。

“Default Resources”文件夹本身并没有太多特别有趣的内容。理解标准着色器的工作原理并非易事,因为它被设计得非常庞大(Unity曾称之为“Uber Shader”)。它包含大量代码,并且不断延伸。使其特别难以理解的原因是,文件中并没有大量实际的代码,而主要是包含了一系列头文件。

以下是标准着色器包含的一些关键文件类型:

  • 标准核心文件:包含核心的着色器功能和计算。
  • 前向渲染相关文件:处理前向渲染路径下的光照计算。
  • 输入定义文件:定义了着色器使用的属性和变量。
  • 元数据文件:通常与预计算反弹光照(如光照贴图)的支持有关,我们将在课程后期讨论。

尽管这个文件看起来非常庞大,但它实际上采用了大量复制粘贴式的编程模式。从代码编辑器的可视化侧边栏可以快速看出,代码中存在许多重复的块状结构,这些块大部分相同,只有少数变体。

这种“Uber Shader”风格的编码方式与Unity 4版本截然不同。在Unity 4中,有大量独立的着色器文件,每个文件对应一种特定的光照效果,且每个着色器都相对较小。这也使得本课程具有挑战性,因为技术环境在不断变化,包括Unity每年的版本更新以及整个行业的发展趋势。本学期我将涵盖的一些主题,在十年前可能还属于研究范畴。

深入核心文件

除了“Default Resources Extra”文件夹,您主要需要查看的是“CGIncludes”文件夹。向下滚动,您会看到所有以“Unity”开头的文件。

“UnityStandardCore”、“UnityStandardCoreForward”、“UnityStandardInput”、“UnityStandardMeta”等是标准着色器主要包含的文件。如果我们查看“UnityStandardCoreForward”,就会发现它正在调用其他文件,例如导入“UnityStandardCoreForwardSimple”或“UnityStandardCore”。

让我们看看“UnityStandardCore”,我敢打赌这里面有很多有价值的内容。果然,它是一个26KB的源代码文件,里面包含了各种实质性的内容:关于切线的处理、金属度与光滑度的设置、粗糙度的计算等等。这看起来非常有趣,还包括全局光照相关的内容。

您可以在这里深入探索很久。猜猜怎么着?在本课程后续的相当一部分内容里,我们将一起深入这个“兔子洞”。请相信我,不要担心,不要惊慌,这将会很有趣。

本节课中,我们一起学习了如何下载Unity内置着色器的源代码,并初步探索了其核心文件结构。理解这些代码是掌握高级着色器编程和与Unity渲染管线协作的关键第一步。在接下来的课程中,我们将更深入地分析这些代码的具体实现。

017:IntroShaders Unity包预览 🎮

在本节课中,我们将预览一个专为后续课程准备的Unity示例包。这个包包含了多个着色器示例,我们将逐一探索其内容与结构。

概述

本节将介绍一个名为“IntroShaders”的Unity资源包。该包由佐治亚理工学院的Sarah Lanchman教授创建,旨在为后续的着色器编程课程提供实践示例。我们将学习如何导入此包,并预览其中包含的几个核心演示场景。

包内容与背景

这个Unity包中的演示结构深受“Catlike Coding”网站教程的影响。该网站提供了非常出色的渲染教程,强烈建议学习者前往查阅,特别是关于渲染和高级渲染的部分。此外,网站还包含一些关于可编程渲染管线的优秀教程,我们将在后续课程中稍作了解。需要注意的是,Unity对可编程渲染管线的工作方式进行了多次更改,因此查阅最新教程至关重要。

示例代码的渊源可以追溯到Unity 4时代。在Unity 5引入标准的基于物理的着色器之前,Unity 4提供了许多独立的小型着色器供选择。本演示代码中的一大部分尝试复现了这些着色器,其创作也受到了NVIDIA网站上免费提供的《CG教程》中各种建议和影响。

资源来源

本IntroShaders Unity包使用了来自Unity Asset Store的免费剑与盾模型。在此特别向这些模型的创作者致谢。他们来自俄罗斯图拉,创作了许多优秀的资源,值得大家去探索。

环境准备与导入

目前,我安装了两个Unity版本:一个是2019.4长期支持版,该版本会持续获得错误修复支持但不添加新功能;另一个是2020.1测试版,它包含最新功能但可能稳定性稍差。本IntroShaders包在两个版本中均可正常加载。

为了演示如何导入,我将创建一个新的Unity项目。我将使用2019.4版本。将项目命名为“IntroShaderLoadTest”,并选择不使用“3D with Extras”模板。同时,我们也不会使用HDRP或通用渲染管线这些新的可编程管线功能,因为本IntroShaders包不支持它们。因此,我们创建一个标准的、旧式的内置渲染管线3D项目。

创建项目后,每次建立3D场景都需要进行一项关键设置:进入项目设置,点击“Player”,将色彩空间从“Gamma”更改为“Linear”。因为使用Gamma色彩空间运行效果不佳。请定期检查此项设置,确保其未被意外改回Gamma模式,始终保持在Linear色彩空间。

现在,将Unity包文件拖拽到Assets文件夹上。Unity会显示包内包含的所有内容。确认后点击导入。此操作在2020.1版本的Unity中同样有效。导入后,在场景文件夹中会出现五个场景。

演示场景预览

导入后,场景文件夹中除了创建项目时自动生成的示例场景外,还有一组我们将要查看的简单着色器演示场景。我们将用一节课的时间来讨论它们。

以下是包中包含的核心演示场景:

  • 简单着色器演示:这是第一个演示,我们稍后会详细讨论其代码。
  • 无光照纹理演示:这将是本系列的第二课,我们将深入代码,探讨如何处理纹理。
  • 光照与着色演示:在这个场景中,除了旋转的剑和盾,我还放置了一些圆柱体,并设置了一个来回移动的光源。在第三课详细研究这些示例时,我们将讨论逐顶点光照与逐像素光照的区别,即高洛德着色与冯氏着色的区别。
  • 法线贴图演示:本系列最后查看的示例将使用法线贴图来实现你所看到的粗糙度效果。当然,法线贴图只有在进行逐像素光照时才能正常工作。

如果你之前没有使用过Unity,请注意区分场景视图和游戏视图。场景视图用于在编辑器中移动和操作物体(如光源、相机),而游戏视图才是实际运行游戏时看到的画面。需要说明的是,我提供的所有着色器代码,仅保证在场景中只有一个光源时正常工作。如果场景中有多个光源,它们可能工作正常,也可能不工作,这取决于一些具体细节。

总结

本节课我们一起预览了“IntroShaders”Unity资源包,了解了其背景、资源来源,并学习了在标准3D项目中导入该包的步骤。我们还快速浏览了包中包含的几个核心演示场景,为后续深入探讨着色器编程的具体实现做好了准备。

018:游戏对象与组件 🎮

在本节课中,我们将学习Unity游戏引擎中的核心概念:资产游戏对象组件。理解这些概念是有效使用Unity进行GPU着色器编程和游戏开发的基础。

概述

上一讲我们介绍了如何加载包含着色器代码的Unity包。本节中,我们将深入探讨Unity的基本架构,特别是资产、游戏对象和组件之间的关系。这些概念是组织和管理游戏内容的关键。

资产与游戏对象

在Unity中,你需要区分资产游戏对象的概念。

  • 资产是通用的资源文件,例如声音、脚本、3D模型等。
  • 游戏对象是这些资产在游戏场景中的具体实例。

游戏对象可以代表场景中的物理实体,例如摄像机或点光源。但有时,游戏对象也可能仅用于承载游戏逻辑代码,而不直接对应场景中的可见物体。这类对象通常将位置设置为(0, 0, 0),可以看作是Unity中的逻辑中心点。

组件系统

Unity的核心设计理念之一是组件。组件通过C#脚本定义行为。

  • 一些组件是Unity内置的,与引擎深度集成。
  • 你也可以创建自己的脚本,并将其作为自定义组件附加到游戏对象上。

以下是创建和附加组件的基本步骤:

  1. 在项目窗口中组织脚本(例如,放入“Scripts”文件夹)。
  2. 在检视窗口中,点击游戏对象的“Add Component”按钮。
  3. 从列表中选择内置组件或你编写的脚本。

例如,在演示项目中,我创建了名为“RotateTestObject”的脚本。将其附加到盾牌游戏对象上,盾牌就会开始旋转。

// 这是一个简化的旋转组件脚本示例
public class RotateTestObject : MonoBehaviour {
    public float speed = 50.0f;
    void Update() {
        transform.Rotate(Vector3.up * speed * Time.deltaTime);
    }
}

游戏对象的固有属性

Unity中的所有游戏对象都自动拥有一个变换组件。它定义了对象的位置、旋转和缩放。

// 变换组件的基本属性
transform.position = new Vector3(0, 0, 0); // 位置
transform.rotation = Quaternion.identity;  // 旋转
transform.localScale = new Vector3(1, 1, 1); // 缩放

即使一个游戏对象不代表物理实体,它仍然拥有变换组件。例如,一个仅用于管理游戏状态的“GameManager”对象,其变换通常就设置在原点。

渲染相关组件

对于需要在屏幕上显示的对象,它们通常包含以下组件:

  • 网格过滤器:持有3D模型数据。
  • 网格渲染器:负责调用Unity渲染例程,将模型绘制到屏幕上。如果禁用此组件,对象就会消失。
  • 材质:定义了物体表面的视觉属性(如颜色、纹理),我们将在后续课程中详细讨论。

组件与面向对象设计

在计算机科学的面向对象编程中,通常讨论“是一个”的继承层次结构。而Unity的组件模型更倾向于“有一个”的组合思想。

  • “是一个”:通过类继承实现(例如,Enemy类继承自Character类)。
  • “有一个”:通过为游戏对象附加多个独立的组件来实现(例如,一个游戏对象“有一个”渲染组件、“有一个”物理组件、“有一个”音效组件)。

这种组件系统提供了极大的灵活性和代码复用性。

总结

本节课我们一起学习了Unity的三个核心概念:

  1. 资产:构成游戏的原始资源。
  2. 游戏对象:场景中资产的具体实例,是组件的容器。
  3. 组件:定义游戏对象行为的脚本或内置模块,通过“有一个”的关系附加到对象上。

理解资产、游戏对象和组件如何协同工作,是有效使用Unity进行任何类型开发(包括我们重点关注的GPU着色器编程)的第一步。在接下来的课程中,我们将更专注于着色器代码本身的编写与优化。

019:材质系统详解 🎨

概述

在本节课中,我们将学习3D图形编程中的核心概念——材质。材质是决定物体外观的关键,它结合了着色器代码与一系列参数。我们将以Unity引擎为例,讲解材质的基本构成、如何应用纹理与参数,以及颜色空间处理等关键知识。这些概念在绝大多数3D引擎中都是相通的。

材质的基本构成

上一节我们概述了材质的重要性,本节中我们来看看材质的具体构成。材质是着色器与一组参数的结合体。着色器是一段运行在GPU上的代码,它决定了如何计算物体表面每个像素的颜色。参数则可以是数字或纹理,用于调整着色器的具体表现。

例如,在材质中调整“衰减因子”这类数值参数,会改变光照强度随距离减弱的效果,从而使物体表面明暗发生变化。

纹理的应用

除了数值参数,纹理是材质中最常用的元素。以下是纹理应用的几种方式:

  • 基础漫反射贴图:这是最常用的纹理,定义了物体的基本颜色和图案。
  • 非常规纹理应用:你可以尝试将其他类型的纹理(如粒子效果纹理或法线贴图)用作基础贴图,但这通常会产生扭曲或非预期的视觉效果,因为它们并非为此目的设计。
  • 纹理替换:可以将一个物体的纹理(如盾牌的纹理)应用到另一个物体(如剑)上,但这可能导致图案错乱,因为UV映射不匹配。

操作上,你可以通过点击选择或直接拖拽的方式将纹理资源赋予材质的对应参数槽。

着色器与参数的关联

现在让我们深入了解着色器与参数的协作方式。在Unity中,当你为材质更换不同的着色器时,引擎会智能地管理参数。

即使你从一个复杂的着色器(包含多个纹理参数)切换到一个简单的着色器(参数较少),当你再次切换回复杂着色器时,Unity通常会记住并恢复你之前设置的参数值。这个功能非常实用,避免了参数丢失的麻烦。

颜色空间与纹理导入设置

在处理纹理时,理解颜色空间至关重要。这关系到我们之前讨论过的伽马空间与线性空间。

显示器通常以伽马空间显示图像,这意味着纹理文件中的颜色值通常存储了*似*方根的映射。为了在着色器中正确进行光照计算,我们需要在线性空间中进行数学运算。

以下是关键设置步骤:

  1. 在Unity项目设置的Player中,可以选择使用线性颜色空间。启用后,GPU会在读取纹理时自动进行“*方”操作(转换到线性空间),并在将最终颜色写入缓冲区时进行“*方根”操作(转换回伽马空间)。
  2. 纹理导入设置中的“sRGB (Color Texture)”选项控制此行为。对于存储颜色信息的纹理(如漫反射贴图),应勾选此选项,启用自动转换。
  3. 对于存储其他数据(如高度、金属度)的纹理,或法线贴图(存储的是方向向量而非颜色),则必须取消勾选此选项,避免错误的转换。幸运的是,将纹理类型设置为“Normal map”时,Unity会自动处理这一点。

总结

本节课中我们一起学习了材质系统的核心知识。我们明确了材质是着色器参数的组合体。我们探讨了如何应用和替换纹理,了解了Unity如何在不同着色器间管理参数。最后,我们深入讲解了线性颜色空间的重要性,以及如何通过纹理导入设置中的sRGB选项来正确控制纹理数据的解读方式,确保光照计算的准确性。掌握这些是进行GPU编程和实现复杂视觉效果的基础。

020:Unity中的材质提取 🎮

在本节课中,我们将学习如何在Unity中处理导入的3D模型,特别是当模型的材质被“嵌入”在网格内部而无法直接编辑时。我们将通过一个具体的例子,演示如何将这些材质提取出来,使其成为可独立编辑的资产。


概述

Unity处理导入模型的方式在不同版本中有所变化。过去,导入模型时会自动创建独立的材质文件。但在较新的版本中,为了保持项目整洁,材质默认被嵌入在模型网格内部。这虽然减少了文件数量,但也导致我们无法直接修改这些材质。因此,我们需要一个“提取”过程,将材质从网格中分离出来。

上一节我们介绍了Unity中材质的基本概念,本节中我们来看看如何从导入的模型中提取材质。


准备项目与环境

首先,我们需要一个Unity项目。本教程使用Unity 2022.1版本,但方法同样适用于2021及更早的版本。

开始前,请务必检查并设置正确的色彩空间:

  1. 进入 Edit > Project Settings
  2. Player 设置中,找到 Graphics 部分。
  3. Color Space 从默认的 Gamma 改为 Linear

代码示例: 此设置在项目设置中完成,无直接代码。


导入示例模型

为了演示,我们从Unity Asset Store导入了一个免费的“黑暗幻想”资源包。这个包中的模型材质已经是独立的,便于我们理解标准工作流程。

然而,我们真正要处理的是另一种情况:从外部软件(如Blender、3ds Max)导出的FBX模型,其材质是嵌入式的。

以下是我们将导入的散热器模型步骤:

  1. 将FBX模型文件拖入项目的 Assets 文件夹。
  2. 同时导入模型附带的纹理图片文件夹。

将模型从项目视图拖入场景后,你可能会发现无法在检视面板中修改其材质属性,所有选项都是灰色的。这是因为材质被嵌入了。


提取嵌入材质

当材质被嵌入时,我们需要手动将其提取为独立文件。

以下是提取材质的步骤:

  1. Project 窗口中选择导入的模型文件(例如 radiator.fbx)。
  2. Inspector 窗口中找到 Materials 分页。
  3. 点击 Extract Materials 按钮。
  4. 系统会提示你选择或创建一个文件夹来存放提取出的材质。注意: Unity通常期望此文件夹名为 Materials,使用其他名称可能导致后续引用问题。

提取完成后,你会在指定文件夹中看到新的 .mat 材质文件。但此时场景中的模型可能仍在使用旧的嵌入材质。


应用提取后的材质

提取材质后,需要告诉模型使用新的外部材质文件,而不是内部的嵌入材质。

操作步骤如下:

  1. 再次选中模型文件。
  2. InspectorMaterials 分页,将 Location 选项从 Use Embedded Materials 改为 Use External Materials (Legacy)
  3. Naming 下拉菜单中,选择合适的命名规则(例如 By Base Texture Name)来帮助Unity匹配材质。
  4. 点击 Apply

现在,选择场景中的模型,你应该可以在 Mesh Renderer 组件下看到材质引用已更新为新提取的 .mat 文件,并且可以自由编辑其属性了。

核心概念公式:
可编辑材质 = 提取(嵌入材质) + 应用(外部材质引用)


组织项目结构

为了避免所有提取的材质都堆放在一个 Materials 文件夹中造成混乱,建议为每个模型创建独立的子文件夹进行管理。

以下是推荐的文件夹结构组织方法:

  1. Assets 下为模型创建一个专属文件夹(例如 Radiator)。
  2. 将模型FBX文件、提取出的 Materials 文件夹以及纹理 Textures 文件夹都移入这个专属文件夹内。
  3. 这样,所有相关资产都集中在了一起,便于管理和维护。

处理法线贴图等纹理设置

提取材质后,有时会遇到纹理导入设置不正确的问题,例如法线贴图未被正确识别。

解决方法如下:

  1. Project 窗口中选择被识别为普通图片的法线贴图。
  2. Inspector 中,将 Texture TypeDefault 改为 Normal map
  3. 如果原图是灰度高度图,请勾选 Create from Grayscale
  4. 点击 Apply

这样,材质就能正确使用法线贴图来表现表面细节了。


其他注意事项与命名规则

Use External Materials (Legacy) 设置中,Naming 选项决定了提取材质时的命名方式,这会影响材质的查找和创建。

以下是几种常见的命名规则:

  • By Base Texture Name: 根据纹理图片的名称来命名材质。
  • Model Name + Model‘s Material: 结合模型名称和模型内材质原名来命名。
  • From Model’s Material: 直接使用模型内嵌材质的原始名称。

不同的规则适用于不同的项目结构,你可能需要根据实际情况进行尝试和选择。


总结

本节课中我们一起学习了在Unity中处理嵌入材质的关键流程。我们了解到,新版本Unity默认将材质嵌入模型内部,需要通过 Extract Materials 功能将其分离。提取后,还需将模型的材质引用切换为 Use External Materials (Legacy) 并点击 Apply 才能生效。此外,合理的项目文件夹组织和正确的纹理导入设置(尤其是法线贴图)也是保证材质正常工作的关键步骤。掌握这些技巧,你就能自由地编辑任何导入模型的材质了。

021:内置与可编写脚本渲染管线

在本节课中,我们将学习Unity中两种主要的渲染管线架构:内置渲染管线和可编写脚本渲染管线。我们将了解它们的基本概念、区别以及各自的适用场景。

在深入编写着色器代码之前,我们需要先了解Unity设置其图形系统的方式。多年来,Unity内置了多种用于渲染的管线,其基础是我们目前所学的光栅化模型。延迟渲染则更为复杂,它预先计算各种光照信息并存储在缓冲区中,然后运行一个特殊的着色器来处理所有像素,以确定最终的光照效果。这是一种更高级的技术,我们将在后续课程中探讨。目前,我所展示的所有代码都基于前向渲染范式。早期版本的一些管线现已不再使用。因此,接下来许多课程中展示的代码都将使用内置渲染管线

过去,我们并不特别强调“内置管线”,因为当时只有这些管线。现在,为了与新的可编写脚本渲染管线区分,我们必须使用“内置管线”这个说法。

可编写脚本渲染管线简介

上一节我们介绍了内置渲染管线,本节中我们来看看新的可编写脚本渲染管线框架。原始的内置渲染管线提供了各种回调和设置,允许你以不同方式启用或禁用某些功能。而可编写脚本渲染管线框架则向开发者暴露了更多底层功能,让你能更精细地控制图形渲染的流程。

Unity提供了两种主要的可编写脚本渲染管线:

以下是两种主要的可编写脚本渲染管线:

  • 通用渲染管线:最初称为“轻量级渲染管线”。更名为“通用”可能是因为“轻量级”容易让人联想到性能较低的设备(如手机)。实际上,URP内置了非常强大的功能,其设计理念是能够灵活地向上或向下扩展,既适用于手机等移动设备,也适用于高端的自定义游戏设备。
  • 高清渲染管线:顾名思义,HDRP需要性能相当强大的硬件才能流畅运行。

这两种管线本身并不特殊,它们是由Unity使用可编写脚本渲染管线的相同功能编写的。这意味着,如果你愿意,也可以编写自己的自定义管线。你可以从零开始编写(但不推荐),或者基于Unity提供的管线源码进行修改,以满足特定需求。与原始的内置管线不同,可编写脚本渲染管线的源代码对用户是可用的。

我推测Unity最终会走向全面采用可编写脚本渲染管线的道路,而内置管线可能会被逐步弃用。但目前距离那个阶段还很远。就目前而言,为了教学方便,我选择继续使用内置渲染管线,这样可以更容易地沿用本课程之前版本的着色器示例代码。

本节课中,我们一起学习了Unity中内置渲染管线可编写脚本渲染管线的核心区别。我们了解到内置管线是传统架构,而可编写脚本管线(如URP和HDRP)提供了更底层、更灵活的控制能力。在接下来的课程中,我们将基于内置管线来学习着色器编程的基础知识。

022:简单无光着色器 🎨

在本节课中,我们将学习如何编写一些简单的、不响应光照的着色器代码。我们将从Unity*台上的具体HLSL代码示例开始,逐步理解顶点着色器和片段(像素)着色器的基本结构和工作原理。


概述

我们已经花了很多时间回顾计算机图形学的基础知识,并讨论了如何使用Unity作为实现图形算法的*台。我们也泛泛地谈到了着色器代码。现在,我们将最终查看一些实际可工作的着色器代码的具体示例。我们将从一些不响应光照的简单着色器代码开始。

重要提示:如果你使用传统内置渲染管线启动一个空白的3D项目,请务必进入项目设置的“Player Settings”,确保色彩空间设置为“Linear”。即使在2020.1 beta版本中,它仍然默认是“Gamma”,这并不理想。幸运的是,如果你启动通用渲染管线或高清渲染管线项目,它会默认设置为“Linear”。


第一个着色器:纯色着色器

经过本课程的多次讲座,我们终于可以看一些着色器代码了。实际的HLSL代码位于 HLSLPROGRAMENDHLSL 之间。在一些较早的Unity着色器示例中,你可能会看到 CGPROGRAM 而不是 HLSLPROGRAM。根据当前Unity文档,HLSLPROGRAMCGPROGRAM 之间的唯一区别在于自动包含的各种头文件。实际上,HLSL和CG本质上是相同的。

代码结构与元数据

HLSLPROGRAMENDHLSL 之外的所有内容并非HLSL本身的一部分,而是Unity特定的元数据。这些元数据告诉Unity如何组织你即将提供的代码。例如,它会将着色器放在名为“GPU 20”的文件夹中,并在其中创建一个名为“Solid Color”的着色器。我们目前查看的所有着色器都只有一个子着色器(SubShader)和一个通道(Pass),后续我们会看到具有多个通道的情况。

包含文件与预定义变量

#include 指令的作用与你预期的一致。几乎任何为Unity编写的着色器都需要包含 UnityCG.cginc 这组定义。

这可能会让事情看起来有点复杂,因为我们将使用许多Unity为你预定义的变量。Unity运行时会在内部运行各种脚本,来设置这些变量,例如 UNITY_MATRIX_MVP(模型-视图-投影矩阵)和 unity_ObjectToWorld(物体到世界的变换矩阵)。unity_ObjectToWorld 是一个4x4矩阵,用于将坐标从模型空间变换到世界空间。UNITY_MATRIX_MVP 是视图变换和透视变换的组合。

注意:Unity的命名约定可能有些令人困惑。它列出了变换应用于向量时的顺序,但这并不是顶点变换的实际矩阵乘法顺序。因为我们使用的是列向量约定(向量在右侧,矩阵在左侧),所以Unity实际上是在CPU端预先计算了 P * V(投影矩阵乘以视图矩阵)。变量名中的“V”在前可能会造成误解,但这只是Unity使用的约定。

如果你不使用Unity的渲染例程来填充这些变量,你就需要在C#脚本中显式地创建这些矩阵,并通过CPU端发送到GPU。Unity在幕后为我们处理了这些。

编译指令与着色器函数

我们使用 #pragma 指令来告诉编译器哪个是顶点着色器,哪个是片段(像素)着色器。这里使用“fragment”来表示像素着色器。

#pragma vertex vertSolidColor
#pragma fragment fragSolidColor

我们定义了顶点着色器函数 vertSolidColor 和片段着色器函数 fragSolidColor。人们通常将它们简称为 vertfrag,但为了展示不同的示例,这里使用了更具描述性的名称。

顶点着色器详解

顶点着色器使用一种特殊的语法来定义输入和输出。POSITION 语义表示输入的是顶点位置数据。你不能随意创建语义,必须使用预定义的语义。

顶点着色器处理单个顶点,并输出一个四维坐标,该坐标表示在最终投影裁剪空间中的屏幕位置,使用 SV_POSITION 语义标记。

在代码中,我们首先将模型空间的位置转换到世界空间。我们采用了一种优化提示:不是直接进行4x4矩阵乘法,而是将位置向量的x, y, z分量取出,手动添加一个 w=1 的分量,然后再进行矩阵乘法。这样做是希望编译器能利用最后一个分量为1的事实来简化实际的矩阵乘法代码,从而生成更高效的代码。这是一种以牺牲部分代码清晰度为代价来提示编译器优化的做法。

// 将模型空间位置转换到世界空间(带优化提示)
float4 worldPos = mul(unity_ObjectToWorld, float4(v.vertex.xyz, 1.0));
// 将世界空间位置转换到裁剪空间
o.pos = mul(UNITY_MATRIX_VP, worldPos);

注意:代码中没有显式地进行透视除法(即除以w分量)。这个操作由GPU上的其他硬件(或固件)处理。作为程序员,我们不需要在着色器中编写这一步。

片段着色器详解

顶点着色器运行后,会生成大量像素(远多于顶点数量)。然后,这些信息被用来计算颜色。

在这个最简单的示例中,我们的片段着色器不接受任何参数,只是简单地返回一个颜色值。这里我们返回绿色(RGBA: 0, 1, 0, 1)。

fixed4 fragSolidColor() : SV_Target
{
    return fixed4(0, 1, 0, 1); // 返回绿色
}

运行示例与矩阵揭秘

在示例场景中,有四个物体应用了不同的着色器。最左边是一个使用Unity标准内置着色器的盾牌,作为通用参考。旁边是应用了我们刚编写的“纯色着色器”的物体,它显示为绿色。

如果你好奇Unity内部的各种矩阵是什么样子,可以通过一些测试来获取。例如,可以创建一个脚本,使用Unity的 Matrix4x4 类库计算各种变换矩阵,并与内置例程的结果进行比较,从而推断出矩阵的实际构成。正交投影矩阵和透视投影矩阵的具体形式可以通过这种方式验证。Unity文档通常不会明确列出这些矩阵的具体值。


另一种编码风格:输出参数

上一节我们介绍了使用 return 语句返回单个值的顶点着色器写法。本节中我们来看看另一种风格,即使用 out 输出参数。

我们可以不使用 return 命令,而是将函数声明为 void 类型,并通过 out 参数来输出结果。这是一种编程语言风格的功能,而非纯数学函数。

void vertSolidColorOut (appdata_base v, out float4 pos : SV_POSITION)
{
    // 计算裁剪空间位置
    float4 worldPos = mul(unity_ObjectToWorld, float4(v.vertex.xyz, 1.0));
    pos = mul(UNITY_MATRIX_VP, worldPos);
}

在这种风格下,片段着色器可以直接输出一个颜色变量。我们需要记住为这个输出变量赋值。

void fragSolidColorOut (out fixed4 col : SV_Target)
{
    col = fixed4(1, 0, 0, 1); // 输出红色
}

将着色器改为这种风格后,物体会显示为红色。这证明了代码的有效性。


可视化着色器:位置与法线

到目前为止,我们展示的片段着色器都没有接受任何参数。现在,我们将让着色器接受一个颜色参数,并直接显示它。同时,我们也将探索如何用着色器来可视化几何数据。

位置可视化

在顶点着色器中,我们可以将位置信息(例如模型空间的X, Y, Z坐标)直接转换为RGB颜色并输出。这是一种诊断工具,用于可视化几何数据并确保其被正确解释。

我们将X坐标映射为红色,Y映射为绿色,Z映射为蓝色。这个映射本身没有特别的逻辑,只是为了可视化。坐标值大于1的部分会“冲白”(达到最大值),负值部分则会显示为黑色。

// 在顶点着色器中计算颜色
o.color = float4(v.vertex.xyz, 1.0); // 将位置直接作为颜色
// 在片段着色器中输出插值后的颜色
fixed4 fragVertexDemo (float4 color : COLOR0) : SV_Target
{
    return color;
}

重要概念:插值:片段着色器接收到的 color 参数并不是直接从顶点着色器传递过来的原始值,而是由硬件在三角形顶点之间插值后的结果。COLOR0 语义将这个输出与硬件的颜色插值单元连接起来。

在示例场景中,从右数第二个物体应用了“顶点演示着色器”,其X, Y, Z坐标被解释为颜色,产生了一种有趣的视觉效果。

法线可视化

法线信息通常用于漫反射等光照计算。但在这里,我们仅用它进行可视化。法线向量的分量范围通常在-1到1之间。

为了将其映射到0到1的颜色范围,我们可以对每个分量进行变换:(normal.xyz + 1.0) * 0.5

// 在顶点着色器中可视化法线
float3 visualNormal = (v.normal.xyz + 1.0) * 0.5;
o.color = float4(visualNormal, 1.0);

同样,我们在片段着色器中输出这个插值后的颜色。需要注意的是,这里显示的是模型空间中的法线。对法线向量进行坐标空间变换比变换顶点位置要复杂一些,我们将在后续课程中介绍。

在示例场景中,最右边的物体正在运行这个法线可视化代码。这对于调试和艺术效果都非常有用。


总结

本节课中,我们一起学习了如何编写简单的无光照着色器。我们从最基本的纯色着色器开始,理解了HLSL代码在Unity中的基本结构,包括编译指令、包含文件、预定义变量以及顶点/片段着色器的编写。我们探讨了两种不同的函数输出风格:使用 return 语句和使用 out 参数。最后,我们学习了如何利用着色器将几何数据(如位置和法线)可视化为颜色,这是一种强大的调试和艺术创作工具。这些基础知识是我们后续学习更复杂光照和渲染技术的起点。

023:在着色器代码中访问纹理 🎨

在本节课中,我们将学习如何在着色器代码中访问和使用纹理。我们将探讨如何在Unity中设置纹理属性,如何在顶点和片段着色器之间传递纹理坐标,以及如何执行纹理采样。课程内容将涵盖基本的纹理映射概念,并展示两种不同的代码结构来实现相同的功能。


概述

本节将介绍着色器代码中处理纹理的核心部分。我们将看到如何声明纹理属性,如何在顶点着色器中接收纹理坐标,以及如何在片段着色器中进行纹理查找。代码主体与第20讲的内容相似,主要新增的是属性部分。


属性声明与变量

属性代表可以在Unity检视面板中赋值的变量。HLSL代码实际位于 HLSLPROGRAMENDHLSL 行之间。其外的内容是元信息,用于告知Unity如何使用此特定着色器代码。大多数其他引擎也会有类似的设置。

以下是属性声明的示例:

Properties {
    _BaseTex ("Base RGB", 2D) = "white" {}
}

此处变量名 _BaseTex 将出现在实际的HLSL程序中。下划线是Unity的命名约定。字符串中的“Base RGB”是此属性在Unity检视面板中的标签。括号内的内容描述了属性的类型,这是一个在CPU端赋值、然后应用于所有待处理顶点或像素的uniform变量"white" {} 设置了默认值。


编译器指令与语义

我们在此看到的HLSL编译器指令与上一讲相同。处理纹理坐标语义时有一个特别容易混淆的问题。“语义”指的是定义显卡上硬件寄存器行为的这些 TEXCOORD0 等大写字母标识符。

主要问题在于,顶点着色器输入中的 TEXCOORD0 与顶点着色器输出中的 TEXCOORD0 完全不同。

输入中的 TEXCOORD0 通过API连接到CPU端。而输出中的 TEXCOORD0 则将顶点着色器的输出连接到插值硬件,该硬件为特定三角形生成的各个像素创建所有插值后的UV值。如果这两者被命名为不同的东西会更好理解。在着色器代码中,你没有义务必须将从API传入的 TEXCOORD0 与连接顶点着色器和像素着色器的 TEXCOORD0 匹配起来,它们是不同的东西。

在本例中,我们将其视为相同的东西,因为我们只是将传入的纹理坐标原封不动地输出。


顶点着色器

大部分代码与上一讲相同。顶点着色器接收顶点的位置,并需要输出一个裁剪空间中的位置,GPU用它来确定三角形的位置,并在需要时进行Z缓冲等操作。

我们在此将物体空间中的位置转换到世界空间。这个添加了 1 的奇怪写法有点特殊,你并非必须这样做,可以直接进行4x4矩阵乘法,但这样写可能暗示编译器可以进行一些优化。

然后,我们获取世界空间中的位置,并通过视图和投影变换得到裁剪空间中的坐标并输出。

对于纹理坐标,除了每个3D顶点具有位置外,它还有一个2D坐标。在Unity中,这些坐标称为UV。其他引擎可能使用S和T的约定。人们使用U、V或S、T作为坐标,因为X和Y已被3D坐标XYZ占用。这些2D纹理坐标代表对图像的查找,该图像是包裹在物体周围的纹理。我们在这里只是将传入的UV坐标原样发送出去。当然,输出的UV坐标会经过插值硬件。

因此,片段着色器将进行纹理查找,查找的不是传递三角形三个顶点纹理坐标所产生的三个坐标之一,而是光栅化过程中创建的数十或数百个像素的插值坐标。这样,你就可以显示一个漂亮的图像,而不是一个大色块。


片段着色器与纹理查找

片段着色器将执行纹理查找。至关重要的是,实际的纹理查找(由 tex2D 命令执行)发生在像素着色器中。如果你在顶点着色器中看到 tex2D 引用,可能有一些特殊原因,但大多数情况下通常不需要这样做。任何合理*台上的现代着色器模型都可以在顶点着色器中执行纹理查找(如果你出于某种原因需要的话)。早期的着色器模型、早期的GPU实际上只在像素着色器中具有纹理查找能力,因为那通常是需要它的地方。

tex2D 命令为我们执行查找。你给它一个纹理变量名和一个二维坐标,它就会进行查找。此时进行查找时,它将执行我们在之前课程中看过的图像插值(纹理插值),并根据你在API中设置的标志选择插值技术。如果你使用Unity,这些通常是在特定纹理的检视面板中设置的。

在这里,我们将读取该纹理,不进行任何光照或其他复杂计算,直接返回该RGBA值。我们必须使用 COLOR 语义来执行此操作,因为这告诉GPU这是一个RGB值,将其发送到缓冲区,并可能根据你在API中设置的行为变量以不同方式解释Alpha通道。


变量声明与结构体

在提供顶点着色器和片段着色器代码之前,我们有一个声明。在这里,我们将带下划线的 _BaseTex 统一变量声明为 sampler2D,这基本上意味着这是一个2D纹理。请注意,我必须在着色器代码本身中声明它,仅在此处的属性列表中声明是不够的。此外,如果属性列表中指示的类型与着色器代码本身中给出的特定类型不兼容,Unity可能会报错或发生其他奇怪的事情。

另一点需要注意的是,虽然我对像素着色器使用了返回结构,但对顶点着色器没有使用那种返回结构。相反,我们在参数列表中使用这些 out 关键字,并在此处分配这些参数。

在上一讲中,我承诺会展示另一种处理方式,现在我们就来看看。这段代码做完全相同的事情,只是我们引入了类似于C语言中结构体的用法。

这里我们定义了一个 appdata 结构体和一个 v2f 结构体。appdata 结构体包含每个顶点的输入位置和输入UV坐标。v2f 结构体有一个裁剪空间位置 SV_POSITION 和UV纹理坐标。这里的所有底层逻辑都是相同的。我只需要在这里稍微不同地使用点符号来访问正确的内容。例如,这里我使用 .uv 来选择此输入结构体的uv组件。

请注意,我不需要显式地有一个输入 SV_POSITION,显卡将获取这些裁剪空间位置,并根据此处存在语义的事实来构建三角形。我想再次强调,你在此处看到的 TEXCOORD0(即UV组件)与用于连接像素着色器的 TEXCOORD0 坐标不同。

我们需要类似地调整顶点结构中的代码,从输入结构体中检索位置和UV坐标,并类似地将内容分配给此处指定的输出结构体。我们需要使用点符号,但一旦我们将这些内容分配给输出结构体,就可以返回它。这只是一种编码风格,如果这段代码和那段代码编译成不同的着色器汇编代码,我会感到非常惊讶。


场景演示与功能扩展

现在我们来看看Unity包中的“Unlit Texture”场景。这并不十分有趣,因为这里没有实际的光照。它看起来有点卡通感,因为它只是获取纹理并绘制它,没有发生什么非常令人兴奋的事情。

在左侧,你可以看到原始的纹理着色器。如果我展开它,可以查看纹理。这与你在着色器代码字符串中看到的“Base RGB”相对应。

我们可以尝试其他操作,比如把剑的纹理放上去,这看起来很奇怪,让我们撤销它。然后下一个是“Textured Structure Shader”,正如我所说,它的操作完全相同,只是编码风格不同。

在Unity中,你应该能够在此处添加一些偏移来改变传入的纹理坐标,但这似乎不起作用,这似乎是个问题。你还应该能够对传入的坐标应用缩放因子,例如,如果你想为砖墙等重复此纹理,但更改它也不起作用。

为了能够处理此功能,我们必须做一些额外的事情。右侧的剑和盾牌使用了另一个我称为“Texture Tiling Correctly”的着色器。如果我们使用这个,那么我们就可以正确地调整*铺。

让我们将其更改为10和10,你会看到一堆小盾牌。你可以在每个方向上以不同方式操作,并以不同方式拉伸它,这很有趣。让我们朝另一个方向拉伸它。好的,这也很有趣。或者你也可以偏移盾牌,如果我在这里放0.5和0.5,那么它会获取纹理的另一部分。无论如何,为了实现此功能,我们必须稍微调整代码。


实现纹理缩放与偏移

修复方法如下,这非常令人烦恼,是Unity特有的。

Unity着色器编译器希望你获取所有纹理,并创建一个末尾附加了下划线和大写字母“T”的 float4 变量。这是非常非常Unity特定的。然后有一个宏,你实际上可以在Unity着色器源代码的各种包含文件中查找它。你给这个宏一个UV纹理坐标和要查找的纹理,它将替换所需的几个变换,以合并来自检视面板的用于处理纹理缩放和移动的功能。

核心代码调整如下:

// 声明一个与纹理对应的_ST变量(Unity特定)
float4 _BaseTex_ST;

// 在顶点着色器中应用缩放和偏移
o.uv = TRANSFORM_TEX(v.uv, _BaseTex);

// 片段着色器中使用宏处理后的UV进行采样
fixed4 col = tex2D(_BaseTex, i.uv);

TRANSFORM_TEX 宏会自动应用在材质检视面板中设置的*铺和偏移值。


总结

本节课中,我们一起学习了在着色器代码中访问纹理的完整流程。我们从声明纹理属性开始,理解了uniform变量的概念。接着,我们探讨了顶点着色器如何接收并传递纹理坐标,以及片段着色器如何使用 tex2D 函数进行纹理采样。我们还比较了两种不同的代码结构:一种使用输出参数,另一种使用返回结构体,两者功能等价。最后,我们了解了如何在Unity中实现纹理的缩放和偏移功能,这需要通过特定的 _ST 变量和 TRANSFORM_TEX 宏来完成。掌握这些基础知识,你就能在着色器中有效地使用纹理来增强模型的视觉效果。

024:法向量变换 🧭

在本节课中,我们将要学习如何正确变换法向量。我们已经了解了如何变换顶点位置,本节中我们来看看法向量的变换有何不同,特别是在存在非均匀缩放的情况下。

概述

顶点位置通常使用四维坐标(W分量设为1)进行变换,以方便*移。法向量通常被视为从原点(即表面点)出发、指向特定方向的向量。直观上,变换法向量似乎只需应用与顶点位置相同的旋转,而忽略*移部分。这在只有旋转或均匀缩放时是可行的,但在非均匀缩放(即在不同方向上缩放比例不同)时,直接应用变换矩阵的左上角3x3部分会导致法向量不再与变换后的表面垂直。因此,我们需要一种特殊的变换方法来保持法向量的正交性。

法向量变换的挑战

上一节我们介绍了顶点位置的变换,本节中我们来看看法向量变换的特殊之处。核心问题在于,当模型在不同方向上被不同程度地拉伸或压缩时,简单地应用相同的旋转和缩放矩阵会破坏法向量与表面的垂直关系。

以下是导致问题的关键情况:

  • 旋转与均匀缩放:直接使用变换矩阵的左上角3x3部分(记为 M)是可行的。
  • 非均匀缩放:直接使用 M 变换法向量会导致其不再垂直于表面。这在美术人员为了复用模型而对其进行非均匀拉伸时很常见。

法向量变换的推导

为了保持变换后法向量 n' 与表面切线向量 t' 的正交性(即点积为零),我们需要找到一个变换矩阵 W 来变换法向量。

假设变换前的法向量 n 和切线向量 t 正交:
n · t = 0

顶点位置(及切线向量)使用矩阵 M 变换:
t' = t M

我们希望法向量经过矩阵 W 变换后,仍与 t' 正交:
n' · t' = 0
代入得:
(n W) · (t M) = 0

在行向量表示法中,点积可写为:
(n W) (t M)^T = 0
展开转置:
n W M^T t^T = 0

由于 n · t = n t^T = 0,要使上式成立,一个充分条件是:
W M^T = I (单位矩阵)

由此可解出 W
W = (MT) = (M{-1})T

因此,变换法向量的正确矩阵是原始顶点变换矩阵左上角3x3部分(M)的逆矩阵的转置。

核心公式与特殊情况

法向量变换的核心公式为:
n' = n * (M{-1})T

以下是两种常见情况:

  1. 仅包含旋转(或均匀缩放)的矩阵:此时 M 是正交矩阵,其逆等于其转置,因此 (M^{-1})^T = M。可以直接用 M 变换法向量。
  2. 包含非均匀缩放的矩阵:必须使用 逆的转置 (M^{-1})^T 来确保法向量的正确性。

在代码中(例如在着色器中),你可能会看到这样的操作:

// 将法向量从模型空间变换到世界空间
vec3 worldNormal = normalize(mat3(transpose(inverse(modelMatrix))) * normal);

或者,更高效的做法是在CPU端计算好法线矩阵(Normal Matrix)再传入着色器。

总结

本节课中我们一起学习了法向量变换。关键点在于,为了在任意线性变换(尤其是非均匀缩放)后保持法向量与表面的垂直关系,不能直接使用变换顶点位置的矩阵,而必须使用该矩阵左上角3x3部分的逆的转置。记住这个核心公式 n' = n * (M{-1})T,就能在各种变换下获得正确的光照和渲染效果。

025:简单光照着色器教程 🎮

在本节课中,我们将学习如何编写处理光照的着色器代码。我们将重点介绍两种实现漫反射光照的方法:逐顶点光照(也称为Gouraud着色)和逐像素光照(也称为Phong着色)。课程将涵盖从基础概念到实际代码实现的全过程,确保初学者能够理解并应用这些技术。


概述

上一节我们介绍了基础的纹理着色器。本节中,我们将探讨如何为3D对象添加光照效果,使其看起来更加真实。我们将从逐顶点光照开始,然后过渡到更高质量的逐像素光照。

着色器结构

以下是实现光照效果的基本着色器结构。我们首先处理顶点着色器,将对象空间中的位置转换到世界空间,再转换到裁剪空间。纹理坐标则直接传递,但会通过一个变换宏来处理偏移和缩放。

// 顶点着色器示例结构
v2f vert (appdata v)
{
    v2f o;
    // 位置变换:对象空间 -> 世界空间 -> 裁剪空间
    o.vertex = UnityObjectToClipPos(v.vertex);
    // 纹理坐标变换
    o.uv = TRANSFORM_TEX(v.uv, _MainTex);
    return o;
}

逐顶点光照(Gouraud着色)

逐顶点光照在顶点着色器中计算光照强度,然后通过硬件插值传递给像素着色器。这种方法计算量较小,但效果可能不够*滑。

核心计算

光照计算的核心是朗伯反射模型。我们计算光线方向与表面法线的点积,并应用衰减(对于点光源)。

公式
diffuse = lightColor * max(0, dot(normal, lightDir)) * attenuation

以下是实现步骤:

  1. 变换法线:将法线从对象空间变换到世界空间。由于法线是方向向量,需要使用模型变换矩阵的逆转置矩阵。
    float3 worldNormal = mul(v.normal, (float3x3)unity_WorldToObject);
    worldNormal = normalize(worldNormal);
    
  2. 计算光线方向:根据光源类型(*行光或点光源)计算指向光源的向量。
    float3 lightDir = _WorldSpaceLightPos0.xyz - v.worldPos * _WorldSpaceLightPos0.w;
    lightDir = normalize(lightDir);
    
  3. 计算衰减:仅对点光源应用基于距离的衰减。
    float atten = 1.0;
    #ifdef USING_POINT_LIGHT
        float dist = length(_WorldSpaceLightPos0.xyz - v.worldPos);
        atten = 1.0 / (1.0 + _AttenuationFactor * dist * dist);
    #endif
    
  4. 计算漫反射光:结合光线颜色、法线点积和衰减因子。
    float diff = max(0, dot(worldNormal, lightDir));
    float3 diffuse = _LightColor0.rgb * diff * atten;
    

传递数据

计算出的光照强度(diffuse)将与纹理坐标一起,通过插值寄存器传递给像素着色器。

struct v2f
{
    float2 uv : TEXCOORD0;
    float3 diffuse : TEXCOORD1; // 用于传递光照计算结果
    float4 vertex : SV_POSITION;
};

逐像素光照(Phong着色)

逐像素光照将大部分计算转移到像素着色器中进行,通过对每个像素独立计算光照来获得更*滑、更高质量的效果。现代GPU可以轻松处理这种计算。

顶点着色器的变化

在逐像素光照中,顶点着色器的工作变简单了,主要是进行几何变换和传递必要的原始数据(如世界空间位置和法线)给像素着色器。

v2f vert (appdata v)
{
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex); // 裁剪空间位置
    o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; // 世界空间位置
    o.worldNormal = UnityObjectToWorldNormal(v.normal); // 世界空间法线
    o.uv = TRANSFORM_TEX(v.uv, _MainTex); // 纹理坐标
    return o;
}

像素着色器的计算

现在,所有复杂的光照计算都在像素着色器中完成。流程与逐顶点光照类似,但针对每个像素执行。

  1. 重新归一化法线:经过插值后的法线不再是单位长度,必须重新归一化。
    float3 normal = normalize(i.worldNormal); // 关键步骤!
    
  2. 进行完整的光照计算:使用插值得到的世界空间位置和重新归一化的法线,重复在逐顶点着色器中进行的光照计算步骤(计算光线方向、衰减、漫反射等)。
fixed4 frag (v2f i) : SV_Target
{
    // 采样纹理
    fixed4 col = tex2D(_MainTex, i.uv);
    // 归一化插值后的法线
    float3 normal = normalize(i.worldNormal);
    // 计算光线方向(处理*行光/点光源)
    float3 lightDir = _WorldSpaceLightPos0.xyz - i.worldPos * _WorldSpaceLightPos0.w;
    lightDir = normalize(lightDir);
    // 计算衰减
    float atten = 1.0;
    #ifdef USING_POINT_LIGHT
        float dist = length(_WorldSpaceLightPos0.xyz - i.worldPos);
        atten = 1.0 / (1.0 + _AttenuationFactor * dist * dist);
    #endif
    // 计算漫反射
    float diff = max(0, dot(normal, lightDir));
    float3 diffuse = _LightColor0.rgb * diff * atten;
    // 最终颜色 = 纹理颜色 * 光照
    col.rgb *= diffuse;
    return col;
}

常见错误:忘记在像素着色器中归一化法线会导致光照错误,在网格较粗糙时尤其明显。

效果对比

为了直观展示两种方法的区别,我们可以在一个场景中并排使用两种着色器。

  • 逐顶点光照:在圆柱体等曲面物体上,可以看到明显的三角形面片轮廓,光斑移动时会出现“条纹”效果。这是因为光照只在顶点计算,面内部通过插值得到。
  • 逐像素光照:在圆柱体上产生*滑、连续的光照过渡,没有明显的面片感。因为每个像素都独立计算了光照。

总结

本节课中我们一起学习了两种实现漫反射光照的着色器技术。

  • 我们首先介绍了逐顶点光照(Gouraud着色),它在顶点着色器中计算光照,效率较高但视觉效果有局限,适合性能要求严格的场景或简单模型。
  • 接着,我们探讨了逐像素光照(Phong着色),它将计算移至像素着色器,通过对每个像素进行独立计算来获得更*滑、更高质量的光照效果,是现代图形应用中的标准做法。
  • 我们详细讲解了两种方法中法线变换光线方向计算(兼容*行光和点光源)、衰减处理以及数据传递的关键步骤和常见陷阱。

理解这两种基础光照模型,是学习更复杂着色器效果(如高光反射、法线贴图等)的重要基石。

026:法线贴图 🎮

在本节课中,我们将学习如何在着色器中实现法线贴图技术。法线贴图是一种在不增加模型几何复杂度的前提下,通过改变表面法线方向来模拟凹凸细节的技术。我们将基于上一节学习的漫反射光照着色器进行扩展。


概述

法线贴图的核心思想是使用一张特殊的纹理(法线贴图)来存储每个像素点的法线向量偏移信息。在片段着色器中,我们读取这张贴图,并结合模型原始的切线和副切线信息,将贴图中的法线从切线空间转换到世界空间,最终用于光照计算。


属性与变量声明

首先,我们需要在着色器代码中声明法线贴图属性,并定义对应的变量。

Properties
{
    _MainTex ("Base Texture", 2D) = "white" {}
    _BumpMap ("Normal Map", 2D) = "bump" {}
}

在CGPROGRAM代码块中,我们需要声明与属性对应的采样器。

sampler2D _MainTex;
sampler2D _BumpMap;

顶点着色器输入与输出

顶点着色器需要接收比之前更多的数据。除了位置和法线,我们还需要模型的切线信息,以便构建切线空间。

以下是顶点着色器的输入结构体。

struct appdata
{
    float4 vertex : POSITION;
    float3 normal : NORMAL;
    float4 tangent : TANGENT;
    float2 texcoord : TEXCOORD0;
};

顶点着色器的输出结构体需要包含传递给片段着色器的所有插值数据。

struct v2f
{
    float4 pos : SV_POSITION;
    float2 uv : TEXCOORD0;
    float2 uv_bump : TEXCOORD1;
    float3 worldNormal : TEXCOORD2;
    float3 worldTangent : TEXCOORD3;
    float3 worldBitangent : TEXCOORD4;
    float3 worldPos : TEXCOORD5;
};

顶点着色器处理

在顶点着色器中,我们需要完成以下任务:

  1. 将顶点位置变换到裁剪空间。
  2. 将法线和切线变换到世界空间。
  3. 计算副切线(叉乘法线和切线)。
  4. 传递纹理坐标和世界空间位置。

以下是顶点着色器的主要代码。

v2f vert (appdata v)
{
    v2f o;
    // 变换顶点位置到裁剪空间
    o.pos = UnityObjectToClipPos(v.vertex);
    // 传递主纹理和法线贴图的UV坐标(允许独立缩放偏移)
    o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
    o.uv_bump = TRANSFORM_TEX(v.texcoord, _BumpMap);
    // 变换法线到世界空间(使用逆转置矩阵)
    o.worldNormal = normalize(mul(v.normal, (float3x3)unity_WorldToObject));
    // 变换切线到世界空间(仅使用旋转部分)
    o.worldTangent = normalize(mul((float3x3)unity_ObjectToWorld, v.tangent.xyz));
    // 计算副切线(叉乘)并考虑切线方向(W分量)
    o.worldBitangent = normalize(cross(o.worldNormal, o.worldTangent) * v.tangent.w);
    // 计算世界空间顶点位置
    o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
    return o;
}

注意:法线变换使用unity_WorldToObject矩阵的逆转置,而切线变换直接使用unity_ObjectToWorld的3x3部分。副切线计算中乘以v.tangent.w用于处理镜像UV,具体数学原理可参考额外资料。


片段着色器处理

在片段着色器中,我们的核心任务是:

  1. 从法线贴图中解码出切线空间法线。
  2. 利用插值得到的TBN矩阵(切线、副切线、法线)将其转换到世界空间。
  3. 使用转换后的法线进行光照计算。

以下是片段着色器的主要步骤。

1. 解码法线贴图

法线贴图通常将X和Y分量存储在Alpha和Green通道中,我们需要将其解码为完整的3D向量。

// 从法线贴图读取并解码切线空间法线
fixed4 packedNormal = tex2D(_BumpMap, i.uv_bump);
float3 tangentNormal;
// 从Alpha和Green通道解码X和Y,范围从[0,1]映射到[-1,1]
tangentNormal.xy = (packedNormal.wy * 2 - 1);
// 根据单位向量性质计算Z分量
tangentNormal.z = sqrt(1 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));

2. 构建TBN矩阵并转换法线

使用从顶点着色器插值并重新归一化后的TBN向量构建矩阵,将切线空间法线变换到世界空间。

// 重新归一化插值后的TBN向量
float3 worldNormal = normalize(i.worldNormal);
float3 worldTangent = normalize(i.worldTangent);
float3 worldBitangent = normalize(i.worldBitangent);
// 构建TBN矩阵
float3x3 TBN = float3x3(worldTangent, worldBitangent, worldNormal);
// 将切线空间法线变换到世界空间
float3 worldNormalPerturbed = normalize(mul(TBN, tangentNormal));

3. 进行光照计算

使用扰动后的世界空间法线进行漫反射光照计算。光照计算部分与之前课程类似,需要处理方向光和点光的差异。

// 计算指向光源的向量并归一化用于点积计算
float3 lightDir = normalize(_WorldSpaceLightPos0.xyz - i.worldPos * _WorldSpaceLightPos0.w);
// 保留未归一化的版本用于点光衰减计算
float3 lightDirUnnorm = _WorldSpaceLightPos0.xyz - i.worldPos * _WorldSpaceLightPos0.w;
float atten = 1.0;
// 如果是点光源(w分量为1),计算衰减
if (_WorldSpaceLightPos0.w != 0.0) {
    atten = 1.0 / (1.0 + length(lightDirUnnorm) * length(lightDirUnnorm));
}
// 计算漫反射系数(兰伯特光照)
float diff = max(0, dot(worldNormalPerturbed, lightDir));
// 结合光照颜色和衰减
float3 lightColor = _LightColor0.rgb;
float3 diffuse = diff * lightColor * atten;
// 采样基础颜色贴图
float4 baseColor = tex2D(_MainTex, i.uv);
// 最终输出颜色
return float4(baseColor.rgb * diffuse, 1.0);


在Unity中测试效果

将编写好的着色器应用到材质上,并为其指定基础纹理和法线贴图。在场景中观察,应用了法线贴图的物体会根据光照方向显示出丰富的凹凸细节,而仅使用基础纹理的物体则显得*坦。

提示:为了获得更明显的效果,可以使用高对比度的法线贴图。在Unity编辑器中,将法线贴图拖拽到材质的对应属性槽即可。


关于Unity渲染路径的注意事项

本示例着色器使用了Tags { "LightMode"="ForwardAdd" },这意味着它主要在附加前向渲染通道中执行,用于处理额外的方向光和所有点光源。主方向光通常在基础前向渲染通道中处理。

因此,如果你在场景中只使用一个方向光,并且它被Unity认定为主方向光,那么使用此标签的着色器可能不会被调用,导致物体“消失”。为了使着色器能同时处理基础通道和附加通道,一个更健壮的实现应包含多个子着色器(SubShader)或通道(Pass),分别对应"LightMode"="ForwardBase""LightMode"="ForwardAdd"


总结

本节课中我们一起学习了法线贴图的实现原理。我们了解了如何通过切线、副切线和法线构建TBN矩阵,将法线贴图中存储的切线空间向量转换到世界空间,并最终用于增强光照计算的视觉效果。关键在于理解从2D纹理解码3D法线、构建变换矩阵以及处理不同渲染通道的逻辑。虽然涉及一些Unity特定的实现细节,但核心的图形学概念(切线空间、法线变换、TBN矩阵)是跨引擎通用的。

027:环境映射 🌐

在本节课中,我们将学习一种特殊的纹理映射技术——环境映射。我们将探讨如何利用立方体贴图来模拟物体表面的反射和折射效果,并比较逐顶点计算与逐像素计算在实现这些效果时的差异。


概述

环境映射是一种用于模拟物体表面反射和折射周围环境的技术。它通过预计算的立方体贴图来“伪造”这些效果,无需进行实时光线追踪。本节课将介绍其核心概念、实现方法,并展示如何在Unity中编写相关着色器。


核心概念与公式

在环境映射中,我们主要使用两个关键向量:反射向量折射向量

  • 反射向量的计算公式通常由着色器语言内置函数实现:
    reflect(I, N)
    
    其中 I 是入射向量(从表面指向相机的反方向),N 是表面法线。

  • 折射向量的计算遵循斯涅尔定律,同样由内置函数实现:
    refract(I, N, eta)
    
    其中 eta 是两种介质的折射率之比(eta = n_i / n_t)。

  • 立方体贴图采样使用 texCUBE 函数,它接受一个三维方向向量,并返回该方向对应的颜色值。

实现方法对比

逐顶点环境映射

上一节我们介绍了反射和折射的基本原理。本节中,我们首先来看一种简单的实现方式:在顶点着色器中计算反射/折射向量。

在逐顶点方法中,我们在顶点着色器为每个顶点计算世界空间下的反射和折射方向向量。然后,GPU的插值硬件会在三角形内部对这些向量进行线性插值,并将结果传递给像素着色器进行立方体贴图查找。

以下是该方法的简要步骤:

  1. 在顶点着色器中将顶点位置和法线变换到世界空间。
  2. 计算从相机到顶点的入射向量。
  3. 使用 reflectrefract 函数计算反射和折射向量。
  4. 将计算出的向量传递给像素着色器。
  5. 在像素着色器中,使用插值后的向量对立方体贴图进行采样,并根据混合系数混合两种颜色。

然而,这种方法存在明显缺陷。由于向量在三角形内部只是简单线性插值,当表面曲率变化较大或折射率较高时,会产生明显的视觉瑕疵,如闪烁和扭曲的“宝石状”效果。

逐像素环境映射

鉴于逐顶点方法的不足,我们需要一种更精确的方法。本节我们将转向逐像素环境映射。

在逐像素方法中,我们将计算推迟到像素着色器中进行。顶点着色器只负责将必要的基础信息(如世界空间位置和法线)传递给像素着色器。

以下是该方法的改进步骤:

  1. 顶点着色器仅进行几何变换,将世界空间位置和法线传递给像素着色器。
  2. 在像素着色器中,为每个像素重新计算精确的入射向量。
  3. 使用该入射向量和插值后的法线,在像素级别计算反射和折射向量。
  4. 进行立方体贴图采样和颜色混合。

这种方法消除了逐顶点插值带来的瑕疵,效果更加*滑和准确。


进阶效果:菲涅尔效应

除了基本的反射和折射混合,我们还可以模拟菲涅尔效应——即反射与透射的比例随观察角度而变的现象(例如,在浅角度观察水面时反射更强烈)。

我们使用一个简化的经验公式来模拟这一效果:

float reflectionFactor = max(0.0, min(1.0, bias + scale * pow(1.0 + dot(I, N), power)));

其中 biasscalepower 是可调参数。reflectionFactor 用于在反射颜色和折射颜色之间进行插值。当视线与法线*行时,折射主导;当视线与表面趋于*行时,反射主导。

为了调试和直观理解参数影响,着色器还包含一个“蓝黄”可视化模式,用蓝色代表反射部分,黄色代表折射部分。


作业:色散效果

一个有趣的扩展是实现色散效果。这模拟了光通过棱镜时不同波长(颜色)的光折射角度不同的现象。

实现思路是:

  • 为红、绿、蓝三个颜色通道分别指定略微不同的折射率比值(eta)。
  • 在像素着色器中,使用各自的 eta 值计算三个不同的折射向量。
  • 分别用这三个向量对立方体贴图进行采样,得到的结果分别作为输出颜色的R、G、B通道。

这样就能产生彩虹般的边缘色散效果,常用于渲染水晶、钻石等材质。


总结

本节课我们一起学习了环境映射技术。我们了解了如何使用立方体贴图来模拟反射和折射这种依赖于视点的光学效果。我们比较了逐顶点计算和逐像素计算两种实现方式,认识到逐像素计算对于质量至关重要。我们还介绍了如何通过简化的公式来模拟菲涅尔效应,并提出了实现色散效果的思路。环境映射是一种高效且广泛使用的技术,能够在实时渲染中极大地增强物体的视觉真实感。

028:投影纹理 🎮

在本节课中,我们将学习如何使用投影纹理技术。这是一种将二维纹理像幻灯片投影仪一样,投射到场景中其他物体表面的方法。我们将了解其背后的数学原理,并在Unity中通过代码实现这一效果。


概述

在前面的课程中,我们学习了包裹在物体表面的二维纹理,以及用于模拟反射和折射效果的三维立方体贴图。本节课,我们将回到二维纹理,但以一种全新的方式使用它们:想象将它们放入幻灯片投影仪,然后投射到场景中的其他物体上。

投影纹理的原理

投影纹理的核心思想是模拟从光源视角进行的渲染。我们使用与将3D物体投影到相机*面相同的矩阵变换,但这里使用的是光源的位置和方向,而非相机的位置和方向,就好像光源本身就是一个相机。

以下是实现投影纹理所需的关键变换步骤:

  1. 模型坐标 -> 世界空间:将物体从自身的模型坐标系转换到全局世界坐标系。
  2. 世界空间 -> 光源视图空间:使用光源的“视角”(即视图矩阵)将世界坐标转换到以光源为原点的视图空间。
  3. 视图空间 -> 光源裁剪空间:使用光源的投影矩阵进行投影变换。
  4. 坐标范围转换:将裁剪空间坐标从 [-1, 1] 的范围转换到纹理采样所需的 [0, 1] 范围。

在代码中,我们使用 S, T, R, Q 代替通常的 X, Y, Z, W,以避免与相机坐标混淆。其中,Q 分量相当于透视除法中的 W 分量,通过除以 Q 来实现纹理的透视投影效果。

Unity 中的演示

在深入代码之前,让我们先在一个Unity演示中直观地感受投影纹理的效果。

演示场景中包含一个地面和几个旋转、移动的物体。一个“Buzz”角色的纹理被从一个虚拟的“投影仪”光源投射到所有物体上。我们可以调整光源的“聚光”参数来控制投射区域的大小和边缘衰减。

演示还展示了纹理的环绕模式(Clamp, Repeat, Mirror等)对投影效果的影响,以及如何通过脚本动态控制投影参数。

代码实现解析

上一节我们预览了投影纹理的效果,本节中我们来看看实现这一效果的着色器代码和C#脚本是如何工作的。

着色器部分

首先,我们在着色器中声明所需的变量和结构。

// 在Properties块中声明,用于在材质面板调整
_ProjectorTex ("Projected Texture", 2D) = "white" {}
_SpotPower ("Spotlight Power", Range(0.01, 1)) = 0.5

// 在CGPROGRAM中声明对应的变量
sampler2D _ProjectorTex;
float _SpotPower;
float4x4 _ProjectorMatrixVP; // 光源的视图投影矩阵
float3 _SpotlightDirection;   // 聚光方向

顶点着色器的主要任务是计算并传递各种空间下的坐标。

// 顶点着色器输出结构
struct v2f {
    float4 pos : SV_POSITION;      // 裁剪空间位置(用于光栅化)
    float2 uv : TEXCOORD0;         // 物体自身纹理坐标
    float3 worldPos : TEXCOORD1;   // 世界空间位置
    float3 worldNormal : TEXCOORD2;// 世界空间法线
    float4 posProj : TEXCOORD3;    // 投影纹理坐标(S,T,R,Q)
};

v2f vert (appdata_base v) {
    v2f o;
    // 常规变换:物体空间 -> 世界空间 -> 相机视图空间 -> 裁剪空间
    o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
    o.pos = mul(UNITY_MATRIX_VP, float4(o.worldPos, 1.0));

    // 变换法线到世界空间(使用逆转置矩阵)
    o.worldNormal = normalize(mul(v.normal, (float3x3)unity_WorldToObject));

    // 传递物体自身纹理坐标(支持缩放和偏移)
    o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);

    // **关键步骤**:计算投影纹理坐标
    // 使用光源的视图投影矩阵变换世界坐标
    o.posProj = mul(_ProjectorMatrixVP, float4(o.worldPos, 1.0));

    return o;
}

片段着色器负责最终的颜色计算,结合了基础纹理、漫反射光照、聚光效果和投影纹理。

fixed4 frag (v2f i) : SV_Target {
    // 1. 基础纹理颜色
    fixed4 baseColor = tex2D(_MainTex, i.uv);

    // 2. 漫反射光照计算
    float3 lightDir = normalize(_WorldSpaceLightPos0.xyz - i.worldPos * _WorldSpaceLightPos0.w);
    float diff = max(0, dot(normalize(i.worldNormal), lightDir));
    fixed4 litColor = _LightColor0 * diff;

    // 3. 聚光效果计算
    float3 spotDir = normalize(_SpotlightDirection);
    // 注意:lightDir指向光源,spotDir指向照射方向,因此需要取反
    float spotFactor = max(0, dot(-lightDir, spotDir));
    // 使用幂函数控制聚光边缘的衰减程度
    spotFactor = pow(spotFactor, _SpotPower * 128);

    // 4. **关键步骤**:采样投影纹理
    // 使用 tex2Dproj 函数,它自动处理 posProj 的透视除法 (除以Q分量)
    fixed4 projColor = tex2Dproj(_ProjectorTex, i.posProj);

    // 5. 合并所有效果
    fixed4 finalColor = baseColor * litColor * spotFactor * projColor;
    finalColor.a = 1.0; // 简单处理Alpha通道
    return finalColor;
}

C# 脚本部分

着色器中的 _ProjectorMatrixVP_SpotlightDirection 需要从CPU端设置。我们通过一个附加到“投影仪”游戏对象(带Camera组件)的C#脚本来完成。

using UnityEngine;

[ExecuteInEditMode] // 在编辑模式下也运行,方便预览
public class ProjectorMatrixSetter : MonoBehaviour
{
    private Camera myCamera;
    private Matrix4x4 viewMatrix;
    private Matrix4x4 projMatrix;

    void Start()
    {
        // 获取附加到同一游戏对象上的Camera组件
        myCamera = GetComponent<Camera>();
        if (myCamera == null)
        {
            Debug.LogError("This script requires a Camera component on the same GameObject.");
        }
    }

    void Update()
    {
        if (myCamera != null)
        {
            // 1. 从“投影仪”相机获取视图矩阵和投影矩阵
            viewMatrix = myCamera.worldToCameraMatrix;
            projMatrix = GL.GetGPUProjectionMatrix(myCamera.projectionMatrix, false);

            // 2. 构造从[-1,1]到[0,1]的缩放*移矩阵
            Matrix4x4 scaleOffset = Matrix4x4.identity;
            scaleOffset.m00 = scaleOffset.m11 = scaleOffset.m22 = 0.5f;
            scaleOffset.m03 = scaleOffset.m13 = scaleOffset.m23 = 0.5f;

            // 3. 组合成最终的光源视图投影矩阵
            Matrix4x4 projectorVP = scaleOffset * projMatrix * viewMatrix;

            // 4. 将矩阵和方向设置为全局着色器属性
            // (注意:此方法会影响场景中所有使用该变量名的材质,适用于单光源演示)
            Shader.SetGlobalMatrix("_ProjectorMatrixVP", projectorVP);
            Shader.SetGlobalVector("_SpotlightDirection", transform.forward);
        }
    }
}

注意事项与扩展

我们已经看到了投影纹理的基本实现,但在实际应用中还需要注意一些问题。

以下是使用投影纹理时的一些关键点和潜在问题:

  • 性能考虑:示例中使用 Shader.SetGlobalMatrix 通过字符串名称查找着色器属性,这在性能上开销较大。对于生产环境,应使用属性ID(Shader.PropertyToID)进行缓存和设置。
  • 多光源支持:示例代码是全局设置,只适用于单个投影光源。支持多个投影灯需要更复杂的架构,例如使用每个物体的材质属性块(MaterialPropertyBlock)。
  • 镜像问题:投影数学本身不关心方向,有时会导致纹理被镜像投射,需要额外的裁剪或判断逻辑。
  • 纹理边缘:用于投影的纹理最好在边缘留有空像素或使用合适的环绕模式,避免不想要的拉伸或重复。
  • Unity内置方案:Unity引擎本身内置了支持纹理投影的聚光灯(在光源组件中称为“Cookie”)。它的实现更高效、功能更完整(如支持阴影),但原理与本节课介绍的核心思想相通。理解我们手动实现的版本有助于深入理解其工作机制。

总结

本节课中,我们一起学习了投影纹理技术。我们从原理出发,理解了如何将光源视为一个相机,通过视图投影变换将纹理坐标投射到物体表面。随后,我们在Unity中逐步实现了这一效果,编写了包含关键变换的着色器代码,以及用于传递矩阵数据的C#脚本。

投影纹理不仅本身是一种创造视觉效果的强大工具(如营造氛围、播放动态海报),它更是实现更高级技术(如下一节课将介绍的阴影映射)的基础。通过手动实现这个过程,我们加深了对渲染管线中坐标变换和纹理采样机制的理解。

029:阴影映射 🎮

在本节课中,我们将学习一种在现代游戏中实现实时阴影的核心技术——阴影映射。我们将了解其基本原理、实现步骤以及在实际应用中可能遇到的问题。

概述

上一讲我们简要提到了阴影体积技术。本节我们将深入探讨目前更为主流的阴影映射技术。这是一种基于光栅化的多通道渲染技巧,用于在实时图形中计算物体投射的阴影。

阴影映射的基本原理

阴影映射是一种需要多个渲染通道的算法。其核心思想是从光源的视角渲染场景,生成一张深度图(阴影贴图),然后在从摄像机视角渲染最终场景时,利用这张深度图来判断像素是否处于阴影中。

以下是实现阴影映射的两个主要步骤:

  1. 从光源视角渲染:将光源视为摄像机,渲染整个场景。此过程不计算颜色,只将每个像素到光源的最*距离(深度值)记录到深度缓冲区中,生成一张阴影贴图

    // 伪代码:第一通道,生成阴影贴图
    RenderSceneFromLightView();
    depthMap = CaptureDepthBuffer();
    
  2. 从摄像机视角渲染:正常渲染场景。对于每个待渲染的像素,计算其到光源的距离,并与阴影贴图中对应位置存储的深度值进行比较。

    // 伪代码:第二通道,渲染并判断阴影
    for each (pixel in camera view) {
        float distanceToLight = CalculateDistanceToLight(pixel.worldPosition);
        float storedDepth = SampleShadowMap(depthMap, pixel.projectedLightCoord);
        
        if (distanceToLight > storedDepth + bias) {
            // 该像素被其他物体遮挡,处于阴影中
            pixel.color *= shadowFactor;
        } else {
            // 该像素未被遮挡,进行正常光照计算
            pixel.color = CalculateLighting(pixel);
        }
    }
    

    如果当前像素到光源的距离大于阴影贴图中记录的深度值,则说明该像素与光源之间存在其他物体,因此它处于阴影中。反之,则接受光照。

原理图解

为了更直观地理解,我们通过图解来分析两种情况。

情况一:物体在阴影中

假设场景中有一个靠*光源的蓝色物体和一个稍远的绿色物体。

  • 第一通道(光源视角):蓝色物体距离光源更*,其深度值被记录在阴影贴图中。
  • 第二通道(摄像机视角):当渲染绿色物体上的某个像素时,计算其到光源的距离。由于这个距离大于阴影贴图中对应位置记录的(蓝色物体的)深度值,系统判定该像素被蓝色物体遮挡,因此处于阴影中。

情况二:物体不在阴影中

假设只有绿色物体,没有蓝色遮挡物。

  • 第一通道:阴影贴图记录的是绿色物体到光源的深度值。
  • 第二通道:渲染绿色物体像素时,计算出的距离与阴影贴图中记录的值基本相等。因此判定没有遮挡,该像素接受光照。

阴影映射的挑战与优化

理论上,上述方法能生成阴影。但在实践中,直接应用会遇到一些问题,需要引入优化技巧。

以下是阴影映射常见的几个问题及其应对策略:

  • 阴影痤疮:由于深度缓冲区的精度限制,物体表面可能会错误地判定为自我遮挡,产生条纹状瑕疵。解决方法是为深度比较引入一个偏移量
    // 添加 bias 以避免阴影痤疮
    if (distanceToLight > storedDepth + bias) { ... }
    

  • 块状锯齿:当阴影贴图分辨率不足,或阴影被投射到离光源很远的表面时,阴影边缘会出现明显的像素块状感。可以通过提高阴影贴图分辨率或使用百分比渐*滤波等过滤技术来*滑边缘。

  • 硬阴影:基础的阴影映射只能生成边缘锐利的硬阴影。现实中的阴影通常有柔和的边缘。可以通过PCF等技术来模拟软阴影效果。

与其他技术的结合

阴影映射可以与上一讲学习的投影纹理技术结合,创造出更复杂的效果。例如,可以模拟树叶缝隙中透下的斑驳光影,或者实现类似幻灯投影机与阴影互动的场景。

总结

本节课我们一起学习了实时阴影生成的关键技术——阴影映射。我们了解了它的双通道渲染原理:首先生成光源视角的深度图,然后在主渲染中通过深度比较决定阴影区域。我们还探讨了实践中遇到的阴影痤疮、块状锯齿等问题,以及通过添加偏移量、过滤等技术进行优化的方法。虽然这是一种基于光栅化的“技巧”,但在实时光线追踪普及之前,它仍是游戏和实时图形应用中生成阴影最主流和高效的方法之一。

030:Unity中的实时阴影 🎮

在本节课中,我们将学习如何在Unity引擎中实现和配置实时阴影。我们将从创建一个基础场景开始,逐步探索Unity内置渲染管线中与阴影相关的各项设置,包括阴影类型、分辨率、偏置以及如何控制单个物体的阴影投射与接收。

上一讲我们介绍了阴影贴图(Shadow Mapping)的通用实现原理。本节中,我们来看看如何在Unity这一具体的游戏引擎中应用这些概念。

创建基础场景

首先,启动一个全新的3D项目,并使用内置渲染管线。建议使用Unity 2022.1或相*版本。

打开新项目后,应首先调整颜色空间设置。进入 Edit -> Project Settings -> Player,向下滚动找到 Rendering 部分的 Color Space,将其从 Gamma 改为 Linear。线性颜色空间能提供更准确的光照计算。

接下来,创建一个*面(Plane)作为地面,并将其位置设置为 (0, 0, 0)

然后,创建一些用于投射阴影的物体。例如,创建一个立方体(Cube),调整其缩放以形成一堵墙(例如,Y轴缩放为3,X轴缩放以延伸)。将其命名为“Wall 1”。复制这面墙以创建第二面墙。

为了专注于理解光照,可以关闭天空盒(Skybox)的环境光影响。进入 Window -> Rendering -> Lighting,在 Environment 标签页下,将 Skybox Material 设置为 None。此时场景中的唯一光源是默认的*行光(Directional Light)。

配置光源与阴影

在Unity中,光源组件是控制阴影的核心。选中场景中的*行光,可以在检查器(Inspector)中看到 Shadow 相关设置。

阴影类型:光源的阴影模式有多个选项:

  • No Shadows:不投射任何阴影,性能开销最小。
  • Hard Shadows:产生边缘锐利的阴影,能清晰看到阴影贴图像素结构。
  • Soft Shadows:产生边缘柔和的阴影,通过过滤减轻了像素感,视觉上更自然,但计算开销稍大。
  • Mixed:此模式与烘焙光照(Baked Lighting)相关,用于混合实时与预计算光照。在当前纯实时设置下,其效果与 Realtime 类似。

可以尝试旋转光源方向,观察墙上和地面上的阴影如何变化。也可以将光源类型改为点光源(Point Light),并调整其位置和范围,观察阴影的动态变化。

阴影质量参数详解

以下是光源阴影设置中影响质量和效果的核心参数:

  • Strength:控制阴影的暗度。降低此值会使阴影变淡,并非完全符合物理模型,但可用于实现非全黑的阴影艺术效果。
  • Resolution:设置阴影贴图的分辨率。选项包括 LowMediumHighVery High。分辨率越高,阴影边缘越清晰,锯齿越少,但GPU内存占用和渲染开销也越大。
  • Bias:阴影深度偏置。用于将阴影略微“推离”投射物体,是解决 自阴影瑕疵(Self-Shadowing Artifacts) 的常用技术。值通常在0到2之间。
  • Normal Bias:法线偏置。沿表面法线方向“收缩”阴影投射面,是另一种避免自阴影瑕疵的方法。值通常在0到3之间。
  • Near Plane:定义渲染阴影贴图时,视锥体(Frustum)的*裁剪面距离。距离光源过*的物体将不会被纳入阴影计算,可用于优化性能。

控制单个物体的阴影行为

除了全局光源设置,还可以控制每个网格渲染器(Mesh Renderer)的阴影行为。

选中场景中的物体(如圆柱体),在检查器的 Mesh Renderer 组件中,找到 Shadows 部分:

  • Cast Shadows:控制该物体是否投射阴影。选项包括:
    • On:正常投射阴影。
    • Off:不投射阴影。
    • Two Sided:强制双面投射阴影,即使物体材质启用了背面剔除(Backface Culling)。对于*面等单面物体,即使光源在背面也能投射阴影。
    • Shadows Only:物体本身不可见,但会投射阴影。这可以用于创造有趣的游戏机制,例如让隐形敌人仅通过阴影暴露位置。
  • Receive Shadows:控制该物体是否接收其他物体投射的阴影。可以独立于阴影投射进行开关。

性能考量与脚本控制

实时阴影计算开销很大,尤其是在移动*台或复杂场景中。为了优化性能,可以:

  1. 为不重要的动态光源(如短暂出现的爆炸光效)关闭阴影。
  2. 根据物体重要性,选择性地关闭其 Cast ShadowsReceive Shadows 属性。
  3. 在质量设置(Quality Settings)中全局调整阴影分辨率和距离。

所有这些阴影参数都可以通过C#脚本进行动态控制,为实现游戏逻辑(如昼夜循环、动态光源管理)提供了灵活性。

本节课中我们一起学习了在Unity中实现实时阴影的完整流程。我们从创建基础场景和配置光源开始,详细探讨了阴影类型、质量参数(如强度、分辨率、偏置)的具体作用,并学习了如何精细控制单个物体的阴影投射与接收行为。理解这些设置对于在游戏中*衡视觉质量与运行性能至关重要。在后续课程中,我们将探讨另一种重要的光照技术——烘焙光照(Baked Lighting)与光照贴图(Lightmapping)。

031:Cook-Torrance高光模型

在本节课中,我们将学习Cook-Torrance高光模型。这是基于物理的渲染中最流行的镜面反射模型。我们将从回顾之前课程中提到的简单镜面反射模型开始,逐步深入探讨如何构建一个更符合物理规律、能量守恒且计算可行的模型。

概述

在之前关于环境贴图的课程中,我们简要提到了完美的镜面反射。到目前为止,我们主要讨论的是漫反射光照模型。本讲我们将重点探讨Cook-Torrance模型,这是基于物理的渲染中最流行的镜面反射模型。

基于物理的渲染并非追求完美的物理模拟,而是旨在获得物理上可信、同时又能实时计算的结果。这样,我们在调整材质和光源时,效果会更加可预测。

准备工作与核心原则

在开始之前,如果你使用Unity等引擎,请确保使用线性颜色空间,而非伽马颜色空间。

基于物理的渲染有两个核心原则需要遵守:

  1. 能量守恒:除非材质是自发光光源,否则它反射的能量不能超过入射的能量。
  2. 亥姆霍兹互易性:交换观察者和光源的位置,应该得到相同的计算结果。

材质分类:金属与非金属

现代艺术工作流和基于物理的渲染对金属和非金属材质做了明确区分。

  • 金属:只有镜面反射,且镜面反射可以带有颜色。
  • 非金属:在电气工程中称为电介质。在这里,电介质泛指一切非金属材质。电介质同时具有漫反射和镜面反射,但其镜面反射是白色的。

一个常见的误解是,像粉笔、纸板这样的材质没有镜面反射。实际上,它们有,尤其是在掠射角度下。为了获得真实的效果,我们需要为它们添加镜面反射。

高动态范围渲染

如果你尝试实现这些物理特性,并使用真实的光源和材质,你会发现相机接收到的光线具有极大的动态范围。过去,艺术家们需要花费大量时间调整光源强度,以避免图像过曝。但在追求物理真实感时,我们必须处理场景中极暗和极亮的部分。

因此,你可能需要一个能处理高动态范围的渲染缓冲区,然后使用色调映射泛光等技术,将高动态范围信息压缩到显示器能够正常显示的范围内。

双向反射分布函数回顾

在课程早期,我们介绍了双向反射分布函数的概念。

BRDF是一个函数,它描述了从某个方向入射的光,在另一个方向反射的比例。对于我们常用的光源类型,公式前会有一个“魔法”因子 π。在本课程中,你可以暂时将其视为一个常数。

以下是BRDF的一般形式:

反射光颜色 = BRDF * 光源颜色 * max(0, dot(法线, 光源方向))

其中,光源颜色 * max(0, dot(法线, 光源方向)) 是常见的兰伯特漫反射项。

从经典模型到物理模型

我们一直使用的漫反射模型比较简单。

漫反射BRDF = 材质颜色 / π

当它与包含π因子的光源计算时,π会相互抵消,因此我们经常在代码中省略它。

过去,人们常在漫反射模型上添加一个Blinn-Phong高光项来模拟镜面反射。其形式如下:

Blinn-Phong高光项 = (材质高光色 / π) * pow(max(0, dot(半角向量, 法线)), 光泽度)

这里的半角向量是观察方向与光源方向的中间向量。然而,这个经典模型不满足亥姆霍兹互易性,因为它分母中的 dot(法线, 光源方向)dot(法线, 观察方向) 并不总是相等。

为了使它满足互易性,一个简单的修改是去掉分母:

修正的Blinn-Phong高光项 = (材质高光色 / π) * pow(max(0, dot(半角向量, 法线)), 光泽度)

但这样又引入了能量不守恒的问题。随着高光斑点变窄,其总亮度并没有相应增加。

为了解决能量守恒问题,需要在公式前添加一个与光泽度相关的归一化因子 (光泽度 + 8) / 8

能量守恒的Blinn-Phong = (材质高光色 / π) * ((光泽度 + 8) / 8) * pow(max(0, dot(半角向量, 法线)), 光泽度)

这样,当高光变窄时,它会同时变得更亮,从而保持总的反射能量大致不变。这对于艺术家调整参数更为友好。

菲涅尔效应

菲涅尔效应描述了反射率随观察角度变化的现象。当视线与表面法线夹角越大时,反射越强。

完整的菲涅尔方程很复杂,在着色器中常用Schlick*似

F = F0 + (1 - F0) * pow(1 - dot(观察方向, 半角向量), 5)

其中,F0是法线入射时的基础反射率。

  • 对于电介质,F0是一个很小的灰度值,反射很弱。
  • 对于金属,F0值较大,且可以带有颜色。

当观察方向与光源方向一致时,公式后半部分为0,反射率就是F0。当处于掠射角时,反射率趋*于1,所有材质都会表现出强烈的镜面反射。

菲涅尔效应适用于所有材质,而不仅仅是金属。它解释了为什么在掠射角度下,即使是纸板这样的材质也会出现高光。

微表面理论与Cook-Torrance模型

现实中的表面并非绝对光滑,而是由许多微小的凹凸构成。这些微表面的粗糙度决定了镜面反射的锐利或模糊程度。

Cook-Torrance模型是一个综合考虑了微表面分布、几何遮蔽和菲涅尔效应的镜面反射模型。其一般形式如下:

镜面反射BRDF = (F * D * G) / (4 * dot(法线, 观察方向) * dot(法线, 光源方向))

其中:

  • F:菲涅尔项,使用Schlick*似计算。
  • D:法线分布函数,描述微表面法线的分布,决定高光的形状和宽度。
  • G:几何遮蔽项,描述微表面之间互相遮挡导致的光线衰减。

法线分布函数

D项定义了微表面法线朝向半角向量H的几率。它通常由一个粗糙度参数控制。

  • 修正的Blinn-Phong:可以作为D项的一种简单选择,但已不常用。
  • GGX/Trowbridge-Reitz:这是目前最流行的D项。它能产生一个锐利的核心和长而*滑的拖尾,更符合许多真实材质的观察结果。其公式如下:

D_GGX = (α^2) / (π * ( (dot(法线, 半角向量)^2) * (α^2 - 1) + 1 )^2 )

其中,α是粗糙度参数。艺术家通常使用一个0到1的光滑度参数,然后通过 α = (1 - 光滑度)^2 之类的映射来得到粗糙度。

几何遮蔽项

G项描述了微表面在入射和出射时可能产生的阴影和遮蔽效应。原始的Cook-Torrance G项比较复杂。

一个与GGX分布配套的常用G项是Smith-Schlick-GGX,它可以分解为入射和出射两个因子的乘积:

G_Smith = G1(dot(法线, 光源方向)) * G1(dot(法线, 观察方向))
G1(n·v) = (n·v) / ( (n·v) * (1 - k) + k )

其中,k是与粗糙度相关的参数,例如 k = (粗糙度^2) / 2

为了计算优化,人们常定义一个可见性项,它合并了G项和Cook-Torrance分母的一部分:

V = G / (4 * dot(法线, 光源方向) * dot(法线, 观察方向))

这样,最终的镜面反射计算就变成了:

镜面反射 = F * D * V

完整的着色模型

最终,一个基于物理的着色模型通常结合了漫反射和镜面反射:

最终颜色 = (漫反射颜色 / π + 镜面反射BRDF) * 光源颜色 * max(0, dot(法线, 光源方向))

对于金属,其漫反射颜色应为黑色,因为所有入射光都被吸收或镜面反射。为了确保能量守恒,材质的漫反射颜色和高光颜色之和应小于1。

总结

本节课我们一起学习了Cook-Torrance高光模型。我们从经典的Blinn-Phong模型出发,指出了其在物理正确性上的不足。然后,我们逐步引入了能量守恒、亥姆霍兹互易性、菲涅尔效应等核心概念。最后,我们深入探讨了Cook-Torrance模型的三个组成部分:菲涅尔项、法线分布函数和几何遮蔽项,并介绍了目前业界主流的GGX和Smith-Schlick-GGX实现方案。

掌握这个模型,是理解现代基于物理的渲染管线的基础,它能帮助我们在游戏中创造出更加真实可信的材质和光照效果。

032:Unity中完全烘焙光照贴图 🎮

在本节课中,我们将学习如何在Unity中预计算光照效果,并将其“烘焙”到称为光照贴图的特殊纹理中。这与我们之前看到的实时计算光照效果不同。

上一节我们介绍了实时光照,本节中我们来看看如何通过烘焙来模拟更复杂的光照效果,例如光线反弹。

场景设置与线性色彩空间

首先,我们需要正确设置项目。在Unity的默认3D内置渲染管线项目中,光照可能看起来不正确。这是因为色彩空间设置。

以下是关键步骤:

  1. 进入 项目设置
  2. 选择 播放器 选项卡。
  3. 其他设置 下,将 色彩空间伽马 切换到 线性
// 此设置在编辑器中进行,无需代码。
// 路径:Project Settings -> Player -> Other Settings -> Color Space

完成此设置后,场景中的光照显示将恢复正常。

实时光照场景示例

我们从一个使用实时光照的简单场景开始。场景中包含一个聚光灯、一个球体、几个方块和墙壁。

  • 球体材质:被特意设置为不真实的材质。其反照率颜色为蓝色,但高光颜色为红色。这有助于清晰区分漫反射和高光反射。
  • 聚光灯:设置为实时模式。这意味着所有光照计算(包括阴影)都在游戏运行时每帧进行。
  • 局限性:实时光照无法计算光线在表面间反弹的效果(即全局光照)。因此,只有被灯光直接照射的区域是明亮的,背光面和阴影区域完全黑暗。

切换到烘焙光照模式

接下来,我们查看同一个场景的烘焙光照版本。将聚光灯的模式从“实时”改为“烘焙”。

烘焙光照的核心流程是:Unity在编辑器中进行一次复杂的预计算,模拟光线从光源发出后,在场景所有静态物体表面的反弹和吸收过程,并将最终的光照强度和颜色信息存储到光照贴图纹理中。

烘焙完成后,你会注意到场景发生了显著变化:

  • 出现间接光照:即使墙壁没有被灯光直接照射,现在也能被看到。这是因为光线从地板或其他墙壁反弹到了这里。
  • 球体背光面变亮:球体背离光源的一侧现在呈现出微弱的蓝色。这是蓝色墙壁反射的间接光照造成的。

静态对象与光照烘焙

烘焙光照只对标记为静态的游戏对象生效。这是一个承诺,告诉Unity这些物体在游戏运行时不会移动,因此可以安全地将它们的光照信息预先计算并烘焙到纹理中。

以下是不同对象状态的影响:

  • 标记为静态的物体:参与光照烘焙计算,可以接收和贡献间接光照,并能投射烘焙阴影。
  • 未标记为静态的物体:不参与光照烘焙计算。在烘焙光照场景中,它们只能接收直接光照(如果光源模式为“混合”或“实时”),或者完全不接收光照(如果光源为纯烘焙模式)。它们也不会贡献间接光。

重要提示:将物体标记为静态并不会在代码层面阻止其移动。如果在运行时通过脚本移动了一个静态物体,它的视觉效果(如阴影)仍会停留在烘焙时的位置,导致画面错误。

光照贴图与方向性贴图

Unity会自动为场景生成光照贴图。你可以通过 窗口 -> 渲染 -> 光照 打开光照设置窗口,并在 烘焙光照贴图 选项卡中查看它们。

Unity实际上会生成两种贴图:

  1. 颜色光照贴图:存储了烘焙后的光照颜色和强度信息。
  2. 方向性贴图:这是一种额外的贴图,用于编码光照的主要入射方向信息。着色器可以利用这些数据来模拟更精确的漫反射效果,即使光照是烘焙的。

烘焙参数:间接光乘数

在灯光组件的烘焙设置中,有一个关键参数叫间接光乘数。它控制着光线每次反弹后保留的能量强度。

  • 值 = 1:物理上准确的能量保留(基于材质反射率)。
  • 值 > 1:光线在每次反弹后能量增加,会导致场景异常明亮,通常用于艺术化效果。
  • 值 < 1:光线在每次反弹后能量衰减更快,间接光照效果更弱。
  • 值 = 0:完全禁用间接光照,场景将只有直接光照效果。

烘焙光照的优缺点

优点

  • 高质量全局光照:可以计算逼真的光线反弹和颜色渗透效果。
  • 运行时性能极高:光照计算在游戏运行前已完成,运行时只需简单的纹理采样,对GPU消耗极低。

缺点

  • 无法处理动态物体:静态物体的光照是固定的,移动后会穿帮。
  • 无法包含高光:高光反射依赖于观察者(摄像机)的位置,而烘焙时摄像机位置未知。因此,烘焙光照只能处理漫反射
  • 增加内存占用:光照贴图需要额外的纹理内存。
  • 烘焙耗时:修改场景或光照后需要重新烘焙,等待时间较长。

总结

本节课中我们一起学习了Unity中的完全烘焙光照贴图技术。我们了解了如何通过将灯光模式设置为“烘焙”来预计算全局光照,并将结果存储到光照贴图中。我们探讨了“静态”对象的概念及其对烘焙的重要性,查看了生成的光照贴图和方向性贴图,并调整了间接光乘数参数。最后,我们总结了烘焙光照在提供高质量间接光方面的优势,以及其在处理动态对象和高光反射方面的局限性。

在下一讲中,我们将探讨一种折中的“混合”光照模式,它试图结合烘焙光照和实时光照的优点。

033:Unity中混合模式灯光的光照贴图 🎮

在本节课中,我们将学习Unity中混合模式灯光的工作原理,特别是它如何结合实时灯光与烘焙灯光来生成光照贴图,并实现如镜面高光等实时效果。

上一节我们介绍了纯烘焙灯光模式,它能够计算全局光照和光线反弹,但无法处理依赖于摄像机位置的镜面高光。本节中我们来看看混合模式灯光,它如何解决这个问题。

混合模式灯光将光照计算分为两部分:

  • 直接光照:由实时灯光计算,支持镜面高光等效果。
  • 间接光照(反弹光):预先烘焙到光照贴图中。

因此,混合模式下的光照贴图仅包含间接光照信息,直接光照效果则由GPU在运行时实时计算并叠加。

以下是混合模式灯光的关键特性:

  • 支持镜面高光:因为直接光照是实时计算的。
  • 保留全局光照:间接光照和光线反弹效果通过烘焙保留。
  • 性能权衡:比纯烘焙模式需要更多实时计算,但比纯实时模式性能更好。

为了更直观地理解,我们可以通过Unity的调试菜单查看不同的光照贴图。

  • 烘焙光照贴图:在纯烘焙模式下,此贴图包含直接和间接的所有光照信息。
  • 方向性贴图:编码了间接光照射到表面点的方向信息,这对于法线贴图等效果很重要。
  • 阴影遮罩:这是混合模式(特别是“Shadowmask”模式)下特有的贴图。它存储了烘焙阴影的信息,允许Unity在运行时将烘焙阴影与实时阴影结合。

关于阴影遮罩,Unity官方文档说明:

阴影遮罩光照模式将实时直接光照与烘焙间接光照相结合。它通过使用一种称为阴影遮罩的额外光照贴图纹理,以及在光照探针中存储额外信息,使得Unity能够在运行时组合烘焙和实时阴影,并渲染远距离阴影。阴影遮罩纹理每个纹素最多可包含四盏灯光的信息。

对于本课程的学习者而言,无需过度深究阴影遮罩的实现细节,了解其核心作用即可。


我们可以通过一个多灯光的场景来观察阴影遮罩的分配机制。

在一个包含五盏混合模式聚光灯的场景中,阴影遮罩最多只能为四盏灯分配独立的通道(RGBA)。Unity的烘焙系统会自动决定哪四盏灯被纳入阴影遮罩,而超出的灯光将回退到完全的烘焙光照处理。通过开关灯光可以观察到阴影遮罩的分配会动态变化。


最后,我们探讨方向性贴图的重要性。在一个同时包含烘焙光、实时光和混合光的场景中,只有实时光和混合光能产生镜面高光。

如果地面材质使用了法线贴图,那么来自间接光照的细节表现(如凹凸感)就依赖于方向性贴图提供的入射光方向信息。如果将方向模式切换为“非方向性”,方向性贴图将消失,法线贴图在间接光照下的效果会大打折扣。


本节课中我们一起学习了Unity中混合模式灯光的光照贴图。我们了解到混合模式巧妙地结合了实时计算与预烘焙的优点:实时计算直接光照以支持镜面高光等动态效果,同时通过烘焙光照贴图来保留高质量的全局光照和光线反弹。我们还简要介绍了阴影遮罩和方向性贴图这两个关键概念及其作用。

034:环境遮蔽光

概述

在本节课中,我们将学习环境遮蔽光(Ambient Occlusion)的概念及其在游戏开发中的应用。我们将重点探讨如何在Unity中,将环境遮蔽光效果烘焙到光照贴图中,并了解其核心参数与视觉效果。


课程内容

大家好,我是Er Lanchman,佐治亚理工学院电气与计算机工程系的教授。在本次关于视频游戏GPU编程的讲座中,我将讨论环境遮蔽光。

使用环境遮蔽光时需要谨慎。这是一个很酷的效果,但很容易过度使用。具体来说,我将讨论如何将环境遮蔽光效果烘焙到Unity的光照贴图中。我不会讨论任何花哨的实时环境遮蔽光计算,也不会讨论屏幕空间环境遮蔽光这种实时后处理效果(尽管我可能在未来的讲座中简要提及),更不会讨论使用Maya等工具为3D对象创建的环境遮蔽光纹理贴图。

这是一个演示场景,你可以从Github下载它。我在上一讲中也使用了这个场景。场景顶部有一个实时光源,中间有一个烘焙光源,底部则是一个混合光源。

接下来,我将开启烘焙的环境遮蔽光效果。当你观察时,请特别注意房间的角落,以及天花板、墙壁和地板相接的地方。

现在,让我们打开“窗口 -> 渲染 -> 光照”设置面板。向下滚动,找到环境遮蔽光选项并将其开启。然后,我们来调整一下这里的各种滑块。

让我们看看官方文档对这些参数的说明。我们有一个“间接贡献”滑块,用于控制环境遮蔽光对间接光照的影响程度。滑块值越高,由间接光照亮的褶皱、孔洞和紧密表面区域就显得越暗。通常,只将环境遮蔽光应用于间接光照会更真实。

我们还有一个类似的“直接贡献”滑块。文档说明,默认情况下环境遮蔽光不影响直接光照。使用此滑块可以启用该效果,但这并不真实,可能仅用于艺术目的。

现在,让我们调整一下“间接贡献”滑块,将其值提高一点。我们需要等待Unity计算一会儿。

大家好,在剪辑后我重新开始讲解。由于某种原因,计算预估时间不断增长,所以我先关闭了环境遮蔽光,然后再重新开启并设置参数,现在它似乎正常工作了。

当“间接贡献”设置为1.89时,你可以看到角落变暗了,墙壁、天花板和地板相接的缝隙也变得更暗了。环境遮蔽光的理念是:考虑到场景中那种普遍、漫反射式的光线弹跳效果,如果你身处这样一个角落,光线能进来的方向就更少,物体可能会阻挡光线,因此这个区域会被人为地变暗。

其实现原理是,系统在场景中选取一个特定点,从该点发射出一系列光线,然后检测在一定距离内(我认为这是“最大距离”设置)是否击中了物体。如果击中了,它就假设可能有光线被阻挡了。

让我点击游戏视图,这样你就能在没有编辑器界面干扰的情况下看到效果了。

让我们再调整一下。先把光照设置调回来,然后把“直接贡献”也设为1.89。

实际上之前是1.84,不确定看起来是否有区别。要真正比较,需要截图并排对比。

让我们把“间接贡献”调低,只保留“直接贡献”的效果。等等,为什么这个数字在上升?别这样。

好吧,我感觉有些失控了。让我把这个调低,然后关掉它。现在它在没有环境遮蔽光的情况下运行。让我先让它计算完。

现在,让我重新开启环境遮蔽光,只调高“直接贡献”,但不调高“间接贡献”,看看效果如何。现在数字在下降了。我不知道为什么它似乎会卡住,然后需要我关闭再重新开启。如果有人知道原因,请在下方留言。

让我们把这个值调高很多。哦,不,它不喜欢这样,看,它又开始异常了。好吧,让我把它全部调回最低。不,它还在上升。好吧,让我关掉它。

现在我要做的是重新开启它,然后调高“直接贡献”的值,但也许不会调到最高。看,这里的数字在下降。实际上,让我做一件可能早就该做的事:截图。这是只有“直接贡献”环境遮蔽光的效果。看看这和关闭环境遮蔽光时是否真的有区别。

现在让我关闭环境遮蔽光。无论这里的直接光效果是什么,对于这个场景来说,可能不是展示它的最佳例子。是的,我确实没看出什么区别。好吧,至少对于这个场景,我认为这没什么意思。

让我们重新开启环境遮蔽光。我将把“直接贡献”调低。现在,让我把“间接贡献”调高。

看,我们现在有了这些大片的黑色区域。正如我提到的,这个效果很容易过度使用。请记住,环境遮蔽光是一种模拟全局光照某些方面的方法,但它不是真正的、完整的全局光照计算,它是一种技巧。还要记住,环境遮蔽光主要处理漫反射光照,并不真正处理高光效果。

我想强调的一点是,Unity将环境遮蔽光烘焙到光照贴图中的方式,是将其直接烘焙进光照贴图纹理本身。环境遮蔽光效果并非存储在某些独立的光照贴图纹理中。

为了验证这一点,如果我在这里查看烘焙的光照贴图,你可以看到这些变暗的边缘。实际上,由于这里网格的显示方式,在这里更容易看到,你可以看到这些暗线。如果我去掉环境遮蔽光效果,这些暗线就会消失。


总结

本节课中,我们一起学习了环境遮蔽光的基本概念。我们了解到,环境遮蔽光是一种用于模拟角落和缝隙因光线被遮挡而变暗的技术,能增加场景的深度感和真实感。在Unity中,我们可以通过光照设置面板将其效果烘焙到光照贴图中,并通过“间接贡献”和“直接贡献”参数来控制其影响的强度。需要注意的是,这是一个容易被过度使用的艺术化效果,使用时需保持克制,以追求自然真实的结果。

035:Unity中的光探针与球谐函数 🎮

在本节课中,我们将学习Unity中的光探针与球谐函数。上一节我们介绍了光照贴图,它通过预计算光照效果来节省运行时开销。本节中,我们来看看如何为动态物体提供全局光照信息。

概述

光探针是一种技术,用于为场景中的动态物体提供烘焙的全局光照信息。与光照贴图不同,光照贴图仅适用于标记为静态的物体。光探针通过在场景中放置采样点来捕获光照信息,动态物体运行时通过插值这些点的信息来获得光照。

光探针的工作原理

以下是光探针的基本工作流程:

  1. 创建光探针组:在Unity编辑器中,通过菜单 GameObject > Light > Light Probe Group 创建一个光探针组。
  2. 放置光探针:进入编辑模式,在场景的关键位置放置光探针采样点。
  3. 烘焙光照:进行光照烘焙时,Unity会计算每个光探针位置从各个方向接收到的光照信息。
  4. 运行时插值:对于动态物体,Unity会查找其周围最*的光探针,并插值它们存储的光照信息来照亮该物体。

光探针的存储方式

从技术角度看,每个光探针存储的是一个球面全景HDR图像,该图像使用球谐函数进行编码。Unity文档指出,它使用前两级球谐函数,这对应于9个系数。

由于需要为红、绿、蓝三个颜色通道分别存储,因此每个光探针总共存储 27个球谐系数

公式表示Light_Probe_Data = {SH_Coefficients_Red[9], SH_Coefficients_Green[9], SH_Coefficients_Blue[9]}

什么是球谐函数?

球谐函数类似于傅里叶级数,但它的定义域是球面而非区间。它非常适合用来表示从不同方向入射的光照的整体变化趋势。

核心概念:球谐函数是一组定义在球面上的正交基函数,任何定义在球面上的函数都可以用这些基函数的线性组合来*似表示。

以下是一个球谐函数基函数的可视化示例(L0至L3):

图中蓝色区域表示函数值为正,黄色区域表示函数值为负。Unity仅使用前两级(L0和L1),这足以*滑地表示大范围的照明变化,但无法捕捉尖锐的阴影或高光细节。

光探针的局限性

光探针提供的是漫反射光照的*似信息,主要用于环境光和间接光照。它不包含精确的镜面反射细节。对于镜面反射,需要使用反射探针。

此外,光探针可能产生一种称为“振铃”的伪影。当光探针一侧光线极亮而另一侧极暗时,球谐函数重建可能会导致暗侧出现不应有的光斑。

在Unity中,可以通过勾选光探针组件的 Remove Ringing 选项来缓解此问题,但这会降低光照对比度和准确性。

实践:查看光探针数据

在提供的演示场景中,有一个自定义着色器(Discovery Shader)可以可视化原始的光探针数据。

关键代码行

// 获取并输出光探针的漫反射信息(球谐函数数据)
c = float4(gi.indirect.diffuse, 1);

这行代码直接从Unity提供的光照数据结构中提取了由光探针插值得到的漫反射颜色。

如果将物体材质切换为标准着色器,你还会看到实时直接光照与光探针提供的间接光照相结合的效果。

光探针与混合光照模式

当光源设置为 Baked 模式时,其直接光和间接光都会烘焙进光照贴图和光探针。
当光源设置为 Mixed 模式时,只有间接光(反弹光)被烘焙进光照贴图和光探针,直接光则留待实时计算。

这解释了为何在演示中,将聚光灯从Baked切换到Mixed后,光探针可视化(Discovery Shader)中的红色直接光部分会消失。

总结

本节课中我们一起学习了:

  1. 光探针的作用:为动态物体提供烘焙的全局光照信息。
  2. 其工作原理:在场景中放置采样点,烘焙时存储光照信息,运行时为动态物体进行插值。
  3. 数据的存储:使用球谐函数编码光照信息,每个探针存储27个系数。
  4. 优点与局限:能有效提供柔和的间接光照,但不擅长处理尖锐阴影和镜面高光,且可能产生振铃伪影。
  5. 工作流程:创建光探针组、放置探针、烘焙光照,动态物体即可自动获取光照。

光探针是*衡画面质量与性能的重要工具。下一节课,我们将探讨用于处理镜面反射的反射探针

036:Unity中的反射探针 🎮

在本节课中,我们将学习Unity中的反射探针。反射探针是一种用于处理物体表面镜面反射的技术,它能自动为场景生成立方体贴图,从而让动态物体也能拥有逼真的反射效果。

上一讲我们介绍了用于处理漫反射光照的光照探针。本节中,我们来看看如何处理依赖于观察角度的镜面反射。

场景与设置准备

首先,为了确保光照计算准确,需要将项目的色彩空间设置为线性空间。

以下是设置步骤:

  1. 进入 Project Settings
  2. 选择 Player 选项卡。
  3. 确保 Color Space 设置为 Linear

本课程使用的演示场景和资源可以从GitHub页面下载。场景中包含一个建筑结构、一个天空盒、若干光源以及三个反射探针。

反射探针基础概念

反射探针是Unity自动生成的立方体贴图。与光照贴图或光照探针只能处理漫反射不同,反射探针专门用于模拟镜面反射效果。

在场景中,反射探针显示为一个带有方框的图标。这个方框定义了探针的影响区域。

反射探针的工作模式

反射探针有三种主要的工作模式,决定了立方体贴图如何生成和更新。

以下是三种模式:

  • Baked(烘焙):仅烘焙场景中的静态物体。动态物体移动时,反射内容不会更新。
  • Realtime(实时):在游戏运行时动态生成立方体贴图,可以包含动态物体,但计算开销很大。
  • Custom(自定义):允许开发者指定一个已有的立方体贴图纹理,而不由Unity自动生成。

影响区域与混合

每个反射探针都有一个边界盒。当物体在场景中移动时,它会根据位置在多个探针的立方体贴图之间进行*滑的交叉淡化混合。

例如,物体在探针A和探针B的重叠区域内移动时,其反射效果会从探针A的贴图逐渐过渡到探针B的贴图。

粗糙度与模糊处理

在真实的材质中,表面的粗糙度会影响镜面反射的清晰度。Unity通过预计算立方体贴图的多级渐远纹理 来实现这一效果。

在材质中,Smoothness(*滑度) 参数控制着反射的模糊程度。*滑度越低(表面越粗糙),Unity就会使用越模糊的Mip层级来采样立方体贴图,模拟出散射的反射光效果。这个过程涉及复杂的镜面反射卷积 计算。

性能考量

使用实时反射探针对性能影响较大。为了优化,Unity提供了时间分片 更新选项。

以下是几种更新策略:

  • All Faces At Once:一帧内更新立方体贴图的所有六个面。
  • Individual Faces:将更新分摊到多帧完成(例如每帧更新一个面)。
  • No Time Slicing:无时间分片,更新在一帧内完成。

开发者需要根据场景需求和目标*台性能来权衡选择。

实际应用演示

在标准着色器中,结合Metallic(金属度)Smoothness(*滑度) 参数,反射探针能与场景中的直接光照(如点光源、聚光灯)共同作用,形成完整的镜面高光。

例如,一个高金属度、高*滑度的表面会清晰地反射出立方体贴图的内容和光源的亮点。

本节课中我们一起学习了Unity反射探针的核心原理与应用。反射探针通过自动生成和混合立方体贴图,为动态物体提供了基于图像的镜面反射照明,是增强场景视觉真实感的重要工具。你需要理解其烘焙与实时模式的区别、影响区域的混合机制,以及如何通过*滑度参数控制反射的模糊程度,从而在效果和性能之间做出合理选择。

037:Unity标准着色器详解 🎮

在本节课中,我们将深入学习Unity的标准着色器。在前几节关于GPU编程与游戏开发的课程中,我们主要分析了我为阐述概念而编写的自定义着色器。这些着色器灵活性不足,例如,它们可能只支持单一的点光源或方向光。如果使用了错误的光源类型,物体可能会消失,这常常让我的学生感到困惑。因此,本节课我们将重点研究Unity的标准着色器。虽然我之前曾提及或偶尔使用过它,但并未深入探讨。这里,我将概述其功能。在接下来的几节课中,我们将深入分析创建这个复杂着色器的源代码。

项目设置与环境准备

首先,我创建了一个空白的3D内置渲染管线项目。和往常一样,我们需要进入项目设置。

我已经将色彩空间设置为线性。通常,项目默认使用伽马空间,你必须手动将其改为线性。我会反复强调这一点,因为人们常常忘记这个步骤。

接下来,我们前往Unity资源商店。

让我们搜索一个僵尸模型。

我找到了一个风格化的僵尸模型。但我需要免费的资源,所以将价格滑块拉到最底端。很好,这个僵尸看起来相当吓人。

这个模型可以用于你的僵尸生存类游戏。想象一下,世界已经被僵尸占领了。

我选择这个特定模型的原因是,查看其包内容中的纹理贴图时,会发现它几乎包含了所有需要的元素。

它提供了漫反射贴图、环境光遮蔽贴图(这是我们之前没有深入讨论过的)、一张自发光贴图(可能是为了让眼睛发光),以及一张金属度贴图(虽然看起来没什么效果,但稍后会详细检查)和一张不错的法线贴图。

我不太确定自发光贴图的具体用途,也许是让眼睛发光。

金属度贴图似乎没有明显效果,但这可能只是显示问题,我们稍后会详细检查。此外,还有一张很好的法线贴图。

好的,让我们将这个资源添加到我的项目中,然后导入到Unity。

在Unity中打开。

现在,进入包管理器,我们应该能找到僵尸资源。在我的资源中,正在获取资源,找到了僵尸。这里有一堆僵尸模型。这些可能是我之前下载的其他僵尸资源。我想这个是我需要的僵尸。导入。

导入后,场景文件夹里没有新内容。让我们查看僵尸文件夹。这里有一个包含多个僵尸的场景,这很好。场景中有什么呢?有摄像机、一些光照探针(这很不错)、一个反射探针,以及一个方向光(可以看到它投射了阴影)。这里还有一群僵尸,它们都一样吗?播放这个场景时,僵尸有动画吗?是的,它们有动画。

僵尸正在向你走来,它们奔跑,然后倒下。在预制体和FBX文件夹中,有三个不同的僵尸模型,它们之间可能有些差异,但我无法分辨。无论如何,让我们详细查看其中一个。

点击这里的僵尸角色。

展开它。我们看到许多不同的身体部件。向下滚动,会发现它们都使用了“僵尸”材质,而这个材质使用了标准着色器。注意,当我点击某个部件时,这里会出现一个小球,显示哪些光照探针在起作用。让我到上方,选择Gizmos,并关闭光照探针组的Gizmo显示。

现在,我可以点击僵尸的各个部分,而不会看到那个代表光照探针的球体了。由于所有部件都使用相同的材质,我可以选择任意一个部件来调整材质,更改会应用到整个僵尸。

探索材质属性:自发光与光照

让我们先来探索一下。首先关注眼睛。我的理论是眼睛会发光。所以,如果我把场景中的光源关掉。

啊,我们看到眼睛在发光了。让我也关掉天空盒,避免天空盒的光照干扰。同时关闭反射探针,以消除来自反射探针的高光。再关闭光照探针组。这会是永久性的吗?我需要重新烘焙吗?让我们看看。

如果我把主方向光切换到实时模式,这样就没有任何烘焙光照了。为什么我还能看到僵尸?自发光贴图里是不是有什么我不知道的内容?让我看看。没有,我只看到一个看起来像眼球的东西。做个简单的检查,我把自发光贴图设置为“无”。哦,这很有趣。

自发光颜色默认应该是黑色,但现在它变成了白色。这个默认值很奇怪。为什么关掉所有光源后我还能看到僵尸?让我再看看光照设置对话框,环境设置里是不是还有一个颜色需要去掉?即使移除了天空盒材质,场景中是否还有一个环境颜色?如果有,把它调到最低。哦,确实有。仅仅移除天空盒材质是不够的,还需要移除场景环境光照中的另一个颜色。我之前不知道这一点,这很好。

现在,场景中没有任何光源了。很好。回到我们的僵尸,选择僵尸的一部分,把那个诡异的眼球自发光贴图放回去。就是这个。看,诡异的眼睛,诡异的眼睛。好的,这很有教育意义。😊

现在,我打赌如果我打开反射探针,眼睛会有点偏蓝。我说对了。虽然很难看清,但你可以看到这里有一点蓝色的轮廓,因为我关掉了天空盒。Unity默认使用一种蓝色背景。让我把主摄像机的背景颜色设为黑色。等等,反射探针有它自己的摄像机来创建场景。我需要把这个调低。好了,现在一切都按我想要的方式设置了。

我花了这么多时间做这些,是为了非常明确地展示每个光源的作用,所以我想先清除所有干扰。现在,场景中唯一的光源就是眼球的自发光贴图。

逐步添加效果:法线贴图与金属度

现在,让我们开始添加有趣的效果。首先,我要重新打开主方向光。这样,物体上就有一些光照了。现在,让我们来调整纹理贴图。

首先,我想调整法线贴图。如果我把法线贴图的强度设置为0,模型看起来会*滑很多。随着我增加这个数值,贴图的效果会越来越明显。我甚至可以增加到1以上,产生非常夸张的效果。在这里,我基本上是在极大地破坏效果,但这可能用于某些特殊效果会很有趣。

为了再次说明这一点,让我把反照率贴图设置为“无”,这样你就能更清楚地看到法线贴图的效果。看,这里非常*滑,等等。

现在,如果我看僵尸的金属度/*滑度贴图,它看起来好像没什么内容。但这只是显示了RGB图像,在检查器中,我还可以查看它的Alpha通道。

标准着色器(名称后没有带“Specular”字样的)被称为高光着色器的“金属度”模式。在这种情况下,你可以通过纹理或滑块提供一个金属度参数,同时还有一个单独的*滑度参数,也可以通过纹理或滑块控制。基本上,金属度参数表示它是电介质(值为0)还是金属(值为1)。记住,电介质材料有漫反射和高光两方面,而金属材料只有高光方面。

如果你想了解如何处理相关纹理,让我查一下“Alpha”这个词。通常有一种纹理叫做金属/高光贴图。材料的金属度水*由纹理的红色通道值控制。

而材料的*滑度水*则由纹理的Alpha通道控制,绿色和蓝色通道被忽略。如果我们查看这个特定的纹理,里面什么都没有。所以这个僵尸没有金属度属性,这有点令人失望,我本来希望能演示金属效果。但僵尸模型本身很酷,所以我们继续。

那么,Alpha通道就是*滑度参数。这些较亮的区域对应什么?什么被认为是*滑的?为了理解这一点,让我们看看反照率贴图。

啊,在这里。这是头部。这些是睫毛吗?那是什么?好的,那是眼睛。所以这些可能是睫毛。睫毛是*滑的。

我猜这些是牙齿。我不确定。让我们回到模型,把反照率贴图放回去。是的,我猜那是牙齿。抱歉,也许那就是牙齿。所以牙齿被认为是闪亮的。

哦,我之前把*滑度滑块调到了0,让我把它调回来。啊,你看,当我调高*滑度滑块时,你会看到更多的高光反射效果。这个滑块会乘以我们从纹理中得到的效果。

为了强调这一点,让我再次移除反照率贴图。让我们看看,再试试*滑度滑块。哦,你知道吗,这很有趣。有反照率贴图时,效果明显得多。好了。

我们也看看如果再次移除这个纹理会怎样。这里有一个*滑度纹理,你看不到它是因为它在Alpha通道里。但我确实看到了区别,看我按Ctrl+Z,是的。好的,我要做的是使用……撤销。😔 撤销。重做,撤销。所以,你确实能看到包含*滑度贴图的效果。

我刚意识到我打开了反射探针,所以场景不仅仅被方向光照亮。抱歉,我不认为这影响了我阐述的主要观点。无论如何,让我关掉反射探针,确认方向光是场景中唯一的光源。

核心概念:环境光遮蔽

这为我们讨论本节课要总结的核心主题——环境光遮蔽贴图——做好了准备。让我们看看它是什么样子。

环境光遮蔽贴图的理念是,你希望在一些角落、缝隙和凹陷处(比如眼睛周围或耳朵里)变得更暗,这些地方相对于僵尸的其他部分,光线通常更难到达。这使我们能够调暗间接光源(如光照探针和反射探针)的贡献。它不影响直接光。

你可以让美术师手绘这样的纹理,但通常这类贴图是在3D软件(如Blender或Maya)中计算生成的。基本上,软件会在模型表面的每个点发射出一系列射线,追踪一小段距离,然后计算有多少射线在途中碰到了其他物体。这个计算是静态的。当然,这里的僵尸是动态的,它在移动、追逐你。而环境光遮蔽贴图只对制作贴图时软件中模型的姿态准确。还有一种实时环境光遮蔽技术,叫做屏幕空间环境光遮蔽,我将在后面讨论后期处理时介绍。

还要注意,这与我们在创建光照贴图时作为参数看到的环境光遮蔽概念不同。在那里,环境光遮蔽被烘焙到贴图中;而在这里,它是一个单独的纹理。

除了环境光遮蔽贴图,我还有一个滑块来控制其效果的强度。现在移动滑块似乎没有效果,因为场景中只有一个光源——一个实时的方向光(直接光),所以环境光遮蔽贴图没有起作用。

但如果我打开反射探针,场景中就会有更多光照。让我关掉再打开。现在,如果我调整那个滑块,你会看到效果。当我调高时,效果非常细微。看脖子这里,当我调高时,脖子变暗了。看眼睛周围,也变暗了。腋下这里也是类似的。

让我关掉反射探针,打开光照探针组。这是有光照探针的效果,这是没有的效果。记住,光照探针处理场景中的反弹光,我们可以用它来表示动态物体上的间接光(这些物体无法使用光照贴图)。通过空间中各点的光照探针来表示这种光,使用的是球谐函数。

有了光照探针,让我们回到滑块,调高调低。哦,效果非常细微。看这里。好了,你可以在脸颊的毛发上看到一点效果。

让我同时打开反射探针和光照探针。记住,光照探针处理漫反射类的光,反射探针处理高光类的光。让我同时打开两者,然后调整滑块。好的,所以……😔 好了。

我在想,如果移除反照率贴图,会不会更容易看到效果?好的,现在我的反射探针和光照探针都就位了,让我们调整环境光遮蔽的滑块看看。让我把环境光遮蔽贴图放回去。啊,这样更容易看到环境光遮蔽的效果了。哦,让我先不要反射探针,对吧?现在你可以在脖子这里看到效果。现在在腋下这里肯定能看到了。

总结与扩展

我已经讲了快20分钟,但只是触及了标准着色器的皮毛。它还有高度贴图、细节遮罩、次级贴图等等功能。你可以查看相关文档,也有很多关于标准着色器的YouTube教程。在接下来的几节课中,我们将深入代码。

为了说明环境光遮蔽,我之前把法线贴图效果调到了0。现在让我把法线贴图加回来。好了。

最后我想提一下,这里的标准着色器使用了Unity所谓的“金属度工作流”。还有一种替代方案是“高光设置”,它在解释各种纹理的方式上有所不同。你可以查看Unity文档了解它们的区别。

对于标准着色器,它暴露了一个金属度值,用于说明材料是否是金属。对于金属材料,反照率颜色控制高光反射的颜色,并且大部分光反射为高光反射。非金属材料的高光反射颜色与入射光相同,并且在正对表面观察时几乎不反射。

这里一个容易混淆的地方是,对于金属,反照率其实不是真正的反照率,而是正对表面的反射颜色。但无论如何。

对于标准高光设置,我们选择这个着色器是为了经典的方法。使用高光颜色来控制材料中高光反射的颜色和强度。这使得高光反射的颜色可以与漫反射颜色不同。这个着色器允许你做一些技术上可能不符合物理规律,但可能很有趣的事情。


本节课中,我们一起学习了Unity标准着色器的基本构成与核心功能。我们从项目设置开始,逐步探索了自发光、法线贴图、金属度/*滑度以及环境光遮蔽等关键属性。通过调整僵尸模型的材质,我们直观地看到了每种贴图对最终渲染效果的影响。标准着色器是一个功能强大且灵活的工具,理解其工作原理是进行高效游戏美术资源制作和高级着色器编程的基础。在接下来的课程中,我们将深入其源代码,进一步揭开其内部机制。

038:解析Unity标准着色器(第一部分)🎮

在本节课中,我们将学习如何探索和解析Unity内置渲染管线中的标准着色器(Standard Shader)的源代码。我们将从下载源代码开始,逐步创建一个可修改的自定义版本,并了解其基本结构和组成。


概述

上一讲我们介绍了Unity内置渲染管线中的标准着色器。本节中,我们将深入其源代码,尝试理解其实现方式。与以往不同,本次课程将展示一个探索代码的过程,希望能让大家了解阅读着色器代码时的思考方式。


获取源代码

首先,我们需要获取标准着色器的源代码。

  1. 访问Unity官网的存档下载页面(unity3d.com/unity/download/archive)。
  2. 选择长期支持版本(例如2022.1.6)。
  3. 根据操作系统下载对应的内置着色器包(例如“Built-in shaders for Mac”)。
  4. 解压后,在“Default Resources Extra”文件夹中可以找到源代码。

注意:该文件夹中包含许多Unity 4时代的遗留着色器,但我们现在关注的是名为“Standard”或“Standard (Specular setup)”的着色器,在Unity 5发布时它也被称为“Uber Shader”。


准备测试环境

在开始分析代码前,我们需要一个场景和模型进行测试。

  1. 创建一个新的3D项目(使用内置渲染管线)。
  2. 在项目设置(Project Settings)中,将色彩空间从伽马(Gamma)切换到线性(Linear)。这是一个重要步骤,请务必执行。
  3. 导入一个包含多种贴图的角色模型(例如Jam-o字符)用于测试。理想的测试模型应包含:
    • 反照率贴图(Albedo Map)
    • 法线贴图(Normal Map)
    • 金属光滑度贴图(Metallic Smoothness Map)
    • 环境光遮蔽贴图(Ambient Occlusion Map)

导入模型后,检查其材质使用的着色器,确认是“Standard”系列。


创建自定义着色器副本

为了能够安全地修改和分析,我们需要创建标准着色器的自定义副本。

以下是操作步骤:

  1. 在Assets目录下创建一个新文件夹,命名为“MyShaders”。
  2. 从下载的源代码中,找到并复制以下两个核心文件到“MyShaders”文件夹:
    • Standard.shader
    • StandardSpecular.shader
  3. 重命名这两个文件,以避免与Unity内置着色器冲突。例如:
    • MyStandard.shader
    • MyStandardSpecular.shader
  4. 打开这两个新文件,将着色器的名称(Shader “...”)也相应修改,例如改为 Shader “Custom/MyStandard”
  5. 最关键的一步:在文件内进行全局搜索和替换,将所有引用原始Unity内置头文件(如UnityStandard)的路径,改为指向我们本地副本的路径。例如,将 #include “UnityStandardCore.cginc” 改为 #include “Assets/MyShaders/MyUnityStandardCore.cginc”

核心概念:通过创建副本并修改引用路径,我们建立了一个独立于Unity引擎、可自由编辑的着色器代码库。


分析着色器通道结构

标准着色器包含多个渲染通道(Pass),用于处理不同的渲染情况。为了理清结构,我们可以创建一个“通道概览”文件。

  1. 复制 MyStandard.shader,创建一个名为 PassMyStandard.shader 的分析文件。
  2. 在这个文件中,删除所有具体的着色器代码,只保留每个通道(Pass)的框架信息,包括:
    • 通道名称(如 ForwardBase, ForwardAdd
    • 光照模式标签(Tags { “LightMode” = “...” }
    • 使用的顶点/片元着色器函数名
    • 包含的头文件

通过分析,我们可以总结出标准着色器在正向渲染(Forward Rendering)下的主要通道:

  • ForwardBase (基础前向通道):处理最重要的方向光、自发光纹理和光照贴图。每个物体渲染一次。
  • ForwardAdd (附加前向通道):处理额外的光源(如其他方向光、点光源、聚光灯)。每个光源渲染一次。
  • ShadowCaster (阴影投射通道):用于生成物体的阴影。
  • Deferred (延迟渲染通道):用于延迟渲染路径(本课暂不深入)。
  • Meta (元通道):不用于实时游戏渲染,专门用于全局光照(GI)计算。

注意:文件中通常还包含针对Shader Model 2.0的备用通道,以兼容旧硬件。但现代游戏开发通常以Shader Model 3.0或更高为目标,可以忽略这些备用通道。


解决路径与编辑器问题

在尝试使用自定义着色器时,可能会遇到路径错误和自定义编辑器界面失效的问题。

  1. 路径问题:确保所有 #include 指令中的文件路径都正确指向了“MyShaders”文件夹下的本地副本。可能需要使用绝对路径,如 Assets/MyShaders/...
  2. 自定义编辑器界面:标准着色器使用一个C#脚本(StandardShaderGUI)来提供材质检视面板中美观的UI。如果希望自定义着色器也拥有此界面,需要:
    • 复制 StandardShaderGUI.cs 到项目的 Editor 文件夹。
    • 修改着色器文件顶部的 CustomEditor “StandardShaderGUI” 行,指向你的副本。
    • 修改C#脚本中的类名和相关引用。注意:这个过程可能涉及复杂的C#代码修改,如果遇到编译错误,一个临时的解决方案是暂时改回使用 CustomEditor “StandardShaderGUI”,直接调用Unity内置的编辑器。

核心概念CustomEditor 指令将着色器与一个C#脚本关联,该脚本控制材质在Unity编辑器中的属性显示方式。


深入核心代码

要理解着色器如何工作,需要查看其核心计算文件。

  1. 打开 MyUnityStandardCoreForward.cginc 文件,这是正向渲染的核心。
  2. 你会发现它主要引用了其他文件,例如:
    • UnityStandardCore.cginc:包含主要的照明计算函数。
    • UnityStandardConfig.cginc:包含大量的宏定义和配置,用于根据目标*台(如Shader Model版本)启用或禁用特定功能(如GGX高光、球谐函数等)。
  3. 这是一个典型的“套娃”结构,一个文件引用另一个,层层深入。为了完全掌控代码,你需要将所有被引用的 .cginc 文件都复制到本地目录,并修改其中的文件引用路径,确保整个依赖链都是你的自定义版本。

这个过程虽然繁琐,但能让你获得一个完全独立、可任意修改的标准着色器实现,是深入学习其原理的宝贵基础。


总结

本节课中我们一起学习了如何开始解析Unity标准着色器的源代码。我们完成了以下步骤:获取官方源代码、创建测试场景、建立自定义的可修改着色器副本、分析其多通道渲染结构,并初步了解了其复杂的文件依赖关系。在下一部分,我们将利用这个准备好的自定义代码库,真正开始深入探索标准着色器内部的照明模型和具体算法实现。

039:解析Unity标准着色器(第二部分)🎮

在本节课中,我们将继续深入解析Unity内置渲染管线的标准着色器。我们将重点关注顶点着色器和像素着色器的核心逻辑,探索光照计算、全局光照以及物理渲染(PBR)的具体实现。通过剖析代码结构,我们将理解一个现代、复杂的生产级着色器是如何组织的。


顶点着色器:数据准备与变换

上一节我们介绍了标准着色器的整体结构和多个Pass。本节中,我们来看看顶点着色器具体如何处理输入数据。

顶点着色器的主要任务是将模型的顶点数据从对象空间转换到齐次裁剪空间,并计算后续像素着色器所需的各种插值数据,如纹理坐标、法线、视角向量等。

以下是vertForwardBase函数中的核心步骤:

  1. 计算裁剪空间位置
    代码通过UnityObjectToClipPos宏(本质上是mul(UNITY_MATRIX_VP, mul(unity_ObjectToWorld, float4(v.vertex.xyz, 1.0))))将顶点位置变换到裁剪空间。这种分步写法旨在为编译器提供优化提示。

  1. 处理纹理坐标
    使用TRANSFORM_TEX宏处理纹理的缩放(Tiling)和偏移(Offset)。

  2. 计算视角向量
    计算从顶点到相机世界位置的向量。对于Shader Model 3.0及以上,为了性能,仅在顶点着色器中进行*似计算,真正的归一化留到像素着色器进行。

    o.eyeVec.xyz = UnityWorldSpaceViewDir(posWorld);
    

  1. 变换法线到世界空间
    使用UnityObjectToWorldNormal函数。如果对象缩放不均匀,此函数会使用逆转置矩阵进行正确变换。

    float3 worldNormal = UnityObjectToWorldNormal(v.normal);
    
  2. 处理全局光照(GI)数据
    调用VertexGIForward函数。此函数根据情况返回两种数据之一:如果对象使用了光照贴图,则返回光照贴图UV;否则,计算并返回基于球谐函数(Spherical Harmonics)的顶点环境光颜色。

  3. 处理雾效
    调用UNITY_TRANSFER_FOG宏,将雾效因子打包到输出数据的特定分量中(例如,存入eyeVec.w)。

vertForwardAdd函数是用于附加光照Pass的顶点着色器,其逻辑是vertForwardBase的一个子集,主要移除了环境光和球谐函数的计算部分。


像素着色器:光照合成核心

顶点着色器准备好了插值数据后,像素着色器开始进行复杂的光照计算。fragForwardBaseInternal是基础Pass的核心。

1. 数据准备 (FRAGMENT_SETUPUnityGlobalIllumination)

像素着色器首先通过FRAGMENT_SETUP宏初始化一个包含表面参数(如漫反射颜色albedo、高光颜色specColor、粗糙度smoothness等)的结构体。这些值来自纹理采样和材质属性。

接着,通过UnityGlobalIllumination函数计算间接光照。这是一个非常复杂的函数,它负责:

  • 间接漫反射:从光照贴图或球谐函数中采样环境光。
  • 间接高光:从反射探针(Reflection Probes)的立方体贴图中采样镜面反射信息。
  • 环境光遮蔽:将环境光遮蔽(AO)贴图的影响应用到间接光照上。

2. 主直接光照计算 (UNITY_BRDF_PBS)

这是物理渲染的核心。Unity通过UNITY_BRDF_PBS宏调用其PBR BRDF函数。该宏在UnityStandardBRDF.cginc中定义,并提供了几种BRDF实现的选择。

最常用的是基于迪士尼原则的BRDF1(GGX/Smith模型)。让我们看看它的关键部分:

  • 迪士尼漫反射模型
    与传统的Lambert漫反射不同,迪士尼模型引入了粗糙度因子,使得粗糙表面的边缘反射更亮,而光滑表面的边缘更暗,更符合观察结果。

    // 简化版的迪士尼漫反射核心
    float FD90 = 0.5 + 2 * VoH * VoH * roughness;
    float FdV = 1 + (FD90 - 1) * Pow5(1 - NoV);
    float FdL = 1 + (FD90 - 1) * Pow5(1 - NoL);
    return FdV * FdL;
    
  • 镜面反射项(Cook-Torrance BRDF)
    包含分布函数(D)、几何遮蔽函数(G)和菲涅尔项(F)。

    • 分布函数(GGX):描述微表面法线的分布。
      float D_GGX = a2 / (PI * denom * denom);
      
    • 几何遮蔽函数(Smith):描述微表面阴影和遮蔽。
    • 菲涅尔项(Schlick*似):描述不同视角下反射与折射的比例。
      float3 F_Schlick = F0 + (1 - F0) * pow(1 - VoH, 5);
      

  • 能量守恒
    代码尝试在漫反射和镜面反射之间保持能量守恒。1 - reflectivity(1减去反射率)代表了可用于漫反射的能量比例。基础反射率F0通常由金属度贴图或高光颜色贴图决定。

3. 最终颜色合成

最终像素颜色是直接光照和间接光照的合成:

// 简化合成逻辑
float3 color = (diffuseDirect + diffuseGI) * albedo;
color += specularDirect * lightColor;
color += specularGI * surfaceReduction; // surfaceReduction 是用于IBL的因子
color += emissionColor; // 自发光
return ApplyFog(color, fogCoord); // 应用雾效

对于附加光照Pass(fragForwardAddInternal),计算逻辑类似,但间接光照部分为零,因为所有间接光照已在基础Pass中计算完毕。它只处理当前附加光源的直接光照贡献,并以加法混合模式渲染到帧缓冲区。


代码结构与评价

本节课我们一起深入探索了Unity标准着色器中前向渲染路径的核心代码。我们可以总结出该着色器代码的以下几个特点:

  1. 模块化但分散:功能被拆分成大量小函数和宏,分布在多个.cginc包含文件中。这有利于复用,但极大地增加了代码阅读和跟踪的难度。
  2. 历史包袱沉重:代码中保留了大量对旧硬件(如Shader Model 2.0)和旧渲染路径(如“Simple”版本)的支持,使得核心逻辑被条件编译指令包围。
  3. 复杂的GI系统:它紧密集成了Unity的全局光照系统,包括实时光照贴图、烘焙光照贴图、球谐函数、反射探针和光照探针,这部分逻辑非常复杂。
  4. 生产级PBR实现:它实现了一个经过生产验证的、基于迪士尼原则的PBR模型,并考虑了能量守恒、多光源处理等细节。

给初学者的建议:如果希望基于此代码创建自定义着色器,一个实用的起点是:

  1. 删除所有针对SHADER_TARGET低于3.0的代码路径。
  2. 删除或忽略“Simple”版本的Shader变体。
  3. 将注意力集中在UnityStandardBRDF.cgincUnityStandardCore.cginc这两个核心文件上。
  4. 尝试将一些关键宏展开,以更清晰地理解数据流。

虽然这套代码结构看起来像一团“乱麻”,但它也展示了如何在一个着色器中组织并实现一个功能完整、支持大量特性的现代渲染模型。理解它有助于我们驾驭复杂度和编写高性能、高质量的图形代码。

040:Unity中的表面着色器 🎨

在本节课中,我们将学习Unity中的表面着色器技术。这是一种高级着色器编写方法,它通过代码生成简化了与光照交互的复杂过程。我们将探讨其工作原理、核心概念以及如何自定义光照模型。


概述

表面着色器是Unity特有的一项技术,它旨在简化编写受光照影响的着色器的复杂性。尽管它是Unity内置渲染管线的功能,但其核心思想——分离表面材质描述与光照计算——在其他引擎中也有类似实现。本教程将详细介绍表面着色器的结构、使用方法以及如何自定义光照模型。


表面着色器的核心概念

表面着色器的核心思想是将表面材质属性光照模型分离开。你的主要工作是填充一个描述表面信息的结构体,而Unity会自动生成处理各种光照类型、阴影选项和渲染路径(如前向渲染和延迟渲染)的底层顶点和片段着色器代码。

在代码中,这通过 #pragma surface 指令实现。例如,定义一个使用标准光照模型的表面着色器:

#pragma surface surf Standard

这里,surf 是你编写的表面处理函数的名称,Standard 指定了使用的光照模型。


标准工作流程:Metallic 与 Specular

Unity的表面着色器支持两种主要的工作流程,它们定义了如何描述材质。

Metallic 工作流程

在Metallic工作流程中,你主要使用两个参数:

  • Albedo Map (反照率贴图):对于电介质(非金属),它存储漫反射颜色。对于金属,它存储的是镜面反射的正面反射率 F0
  • Metallic Map (金属度贴图):这是一个单通道贴图,0表示电介质,1表示金属。理论上应为0或1,但出于艺术原因,也可以使用中间值。

其核心公式体现在 DiffuseAndSpecularFromMetallic 函数中:

// 计算镜面反射颜色和“一减反射率”
specColor = lerp(unity_ColorSpaceDielectricSpec.rgb, albedo, metallic);
oneMinusReflectivity = (1 - unity_ColorSpaceDielectricSpec.a) - metallic * (1 - unity_ColorSpaceDielectricSpec.a);
// 计算真正的漫反射反照率
diffuseAlbedo = albedo * oneMinusReflectivity;

对于纯金属(metallic = 1),diffuseAlbedo 为0。对于纯电介质(metallic = 0),diffuseAlbedoalbedo 的96%,因为4%的能量分配给了电介质的镜面反射。

Specular 工作流程

Specular工作流程更为通用:

  • Albedo Map (反照率贴图):始终表示漫反射颜色。对于金属,应设为黑色。
  • Specular Map (高光贴图):直接指定镜面反射颜色 F0。对于电介质,这通常是一个灰度值。

编写表面着色器

以下是创建一个基本表面着色器的步骤。

创建模板

在Unity编辑器中,可以通过 Assets > Create > Shader > Standard Surface Shader 来生成一个模板代码。

代码结构解析

生成的模板代码主要包含以下部分:

  1. 属性块 (Properties):定义在材质检视器中可调整的变量。
  2. CGPROGRAM 指令
    #pragma surface surf Standard fullforwardshadows
    
    这声明了这是一个表面着色器,表面函数是 surf,使用 Standard 光照模型,并启用完整的阴影支持。
  3. 输入结构体 (struct Input):必须命名为 Input。用于从顶点着色器向表面函数传递数据。要访问纹理坐标,必须使用 uv_ 加纹理名称的变量名,例如 float2 uv_MainTex
  4. 表面函数 (surf):这是你编写的核心函数,负责填充 SurfaceOutputStandard 结构体(如果使用Standard光照模型)。你需要在这里采样纹理、计算颜色、金属度、*滑度等。

一个简单的 surf 函数示例:

void surf (Input IN, inout SurfaceOutputStandard o) {
    fixed4 c = tex2D(_MainTex, IN.uv_MainTex) * _Color;
    o.Albedo = c.rgb;
    o.Metallic = _Metallic;
    o.Smoothness = _Glossiness;
    o.Alpha = c.a;
}

自定义光照模型

表面着色器允许你定义自己的光照模型。Unity会根据 #pragma surface 中指定的名称(例如 Standard)去寻找名为 Lighting<Name>Lighting<Name>_GI 的函数。

例如,对于 Standard 模型,Unity会调用 LightingStandard 函数来计算直接光照,调用 LightingStandard_GI 函数来设置全局光照(GI)数据。

LightingStandard 函数内部会调用 Unity_BRDF_PBS 函数,该函数实现了基于物理的渲染(PBR)核心算法,结合了迪士尼漫反射模型和Cook-Torrance镜面反射模型(使用GGX分布和Smith联合可见性函数)。


内置渲染管线中的光照处理

在Unity内置渲染管线的前向渲染路径中,光照处理遵循特定规则:

  1. Base Pass (基础通道):渲染物体时,处理一个最重要的逐像素方向光(可带阴影)和所有球谐光照/顶点光照。
  2. Additional Passes (附加通道):为每个额外的、影响该物体的逐像素光渲染一个附加通道。这些通道默认无阴影。

表面着色器编译器会自动为你生成处理这些多通道情况的代码。


总结

本节课我们一起学习了Unity中的表面着色器。我们了解了它如何通过分离表面属性和光照计算来简化复杂着色器的编写。我们探讨了Metallic和Specular两种工作流程的差异,分析了表面着色器的基本代码结构,包括Input结构、surface函数以及自定义光照模型的原理。最后,我们回顾了内置渲染管线前向渲染的光照处理机制。表面着色器是理解Unity着色器编写和光照交互的重要工具。

041:表面着色器中的程序性顶点修改 🎮

在本节课中,我们将学习如何在GPU上实现酷炫的程序性动画效果,例如波浪形变。我们将深入探讨如何在Unity的表面着色器中注入顶点修改功能,从而在运行时动态改变网格的形状。

上一节我们介绍了Unity的表面着色器技术,它允许我们通过编写函数来定义材质属性以及自定义光照交互。本节中,我们将看看如何在这个流程中插入顶点修改操作。

概述与准备

如果你想亲自尝试本节代码,可以访问我的GitHub仓库 CS-EC4795,获取名为 GPU22_VertexModSurfaceShader 的Unity包。将其加载到一个使用标准内置渲染管线的3D项目中,并记得将色彩空间设置为线性。下方描述区提供了GitHub链接。

场景中显示的这个物体,在CPU侧实际上只是一个*面。CPU将一个*面网格发送给GPU,而你在这里看到的波浪效果完全是在GPU上实时计算生成的。

场景与效果演示

以下是场景设置与参数调节的演示。

我的场景包含两盏点光源:一盏红色的和一盏白色的。请注意,场景中有两个结构相同但细分密度不同的*面。左侧*面网格非常密集,右侧则粗糙得多。这两个*面是使用一个特殊脚本创建的,你可以在编辑器文件夹中找到它,该脚本最初由Michael Garforth编写,发布在Unity Wiki上。

通过点击材质上的小三角图标,可以在场景视图中实时预览动画效果,而无需进入播放模式。

可调节参数

以下是该着色器的主要可调节参数及其效果:

  • 振幅:控制波浪的起伏高度。增加振幅会使波浪更加剧烈。
  • 时间频率:控制波浪波动的速度。数值越高,波动越快。
  • 波数:控制波浪的空间频率,即波浪的密集程度。增加波数会使波浪更紧密,但可能导致右侧粗糙网格出现严重的走样现象。
  • 方向:控制*面波传播的方向。对于圆形波,此参数无效。
  • 波形切换:可以在*面波和圆形波(类似水滴落下的涟漪)之间切换。圆形波在粗糙网格上的走样问题通常更为明显。

背后的数学原理

该着色器实现的数学基础是二维波动方程的解。你无需深入理解方程细节,只需了解其大致形式。

对于*面波,其位移公式基于正弦函数:
位移Y = 振幅A * sin(波数向量K · 坐标(X, Z) - 时间频率ω * 时间t)
其中,K是波数向量,决定了波的传播方向和空间频率;ω是角频率,决定了波的时间频率。

对于圆形波,我们采用了一个简化的*似公式来模拟涟漪效果:
位移Y = 振幅A * sin(波数K * 径向距离R - 时间频率ω * 时间t) / (ε + R)
主要区别在于增加了除以径向距离R的项,以模拟能量随扩散而衰减的效果,并添加了一个小常数ε来避免除以零的错误。

代码实现解析

在Unity的CG代码中,我们可以找到名为 appdata_ 开头的结构体。通过顶点修改函数,我们可以在标准的3D图形顶点处理流程之前修改这些结构体中的数据。

以下是着色器代码的核心部分解析:

Shader "Custom/VertexWaveSurface" {
    Properties {
        // ... 定义振幅、波数等属性
    }
    SubShader {
        Tags { "RenderType"="Opaque" }
        // 关键:声明顶点修改函数
        #pragma surface surf Standard vertex:makeWave

        // 顶点修改函数
        void makeWave(inout appdata_full v) {
            // 1. 计算波数向量
            float kx, kz;
            sincos(_Direction, kz, kx); // 注意sincos参数顺序
            kx *= _WaveNumber;
            kz *= _WaveNumber;

            // 2. 根据波形选择计算正弦函数的参数
            float sinusoidArgument;
            if (_WaveType < 0.5) { // *面波
                sinusoidArgument = (v.vertex.x * kx + v.vertex.z * kz) - _Time.y * _TemporalFrequency;
            } else { // 圆形波
                float r = sqrt(v.vertex.x * v.vertex.x + v.vertex.z * v.vertex.z);
                sinusoidArgument = _WaveNumber * r - _Time.y * _TemporalFrequency;
            }

            // 3. 计算位移并应用到顶点Y坐标
            float s, c;
            sincos(sinusoidArgument, s, c);
            float displacement = _Amplitude * s;

            if (_WaveType > 0.5) { // 圆形波需要衰减
                float r = sqrt(v.vertex.x * v.vertex.x + v.vertex.z * v.vertex.z);
                displacement /= (0.5 + r); // 0.5是避免除零的epsilon
            }

            v.vertex.y += displacement;

            // 4. 修正法线(重要!)
            // 为了使光照正确,必须根据表面变形调整法线方向。
            // 这里涉及偏导数计算,代码已简化。
            float derivative = _Amplitude * _WaveNumber * c;
            if (_WaveType > 0.5) {
                float r = sqrt(v.vertex.x * v.vertex.x + v.vertex.z * v.vertex.z);
                derivative /= (0.5 + r);
                // 圆形波的法线修正更复杂,此处略去细节
            }
            // 粗略调整法线(*面波*似)
            v.normal = normalize(float3(-kx * derivative, 1.0, -kz * derivative));
        }

        // 表面着色器函数
        void surf (Input IN, inout SurfaceOutputStandard o) {
            // ... 设置金属度、光滑度等标准表面属性
            o.Albedo = float3(0.7, 0.7, 0.8);
            o.Metallic = _Metallic;
            o.Smoothness = _Smoothness;
        }
    }
    FallBack "Diffuse"
}

代码关键点:

  1. #pragma surface surf Standard vertex:makeWave 这行指令告诉Unity,表面着色器使用标准光照模型,并且顶点处理由自定义函数 makeWave 负责。
  2. makeWave 函数接收一个 appdata_full 结构体(包含顶点位置、法线等信息),并通过 inout 关键字修改它。
  3. 函数内首先根据参数计算波数向量和正弦函数的参数。
  4. 然后计算位移量,并应用到顶点的Y坐标上。
  5. 至关重要的一步:修改顶点位置后,必须相应地修正顶点法线。如果法线仍指向原始方向(如垂直向上),光照计算就会错误,物体看起来会像*坦的。修正法线需要计算表面变形的偏导数,对于*面波,可以*似计算;对于复杂变形,则需要更精确的数学。
  6. 最后的 surf 函数定义了标准的表面材质属性。

当然,同样的顶点变形逻辑也可以用在手写的自定义顶点着色器中,只需在世界变换、视图变换等操作之前应用这些修改即可。

总结

本节课中,我们一起学习了如何在Unity表面着色器中实现程序性顶点修改。我们了解了如何通过编写顶点函数来动态改变网格形状,从而创建出*面波和圆形波浪动画。关键点在于:不仅要修改顶点位置以实现形变,还必须同步更新法线信息,以确保光照渲染的正确性。这种技术为创造动态、丰富的视觉特效提供了强大的工具。


致Georgia Tech选课学生:请前往Canvas完成本讲测验。请告诉我:1. 你在Georgia Tech上过的最喜欢的课程(不包括我教的);2. 你最喜欢那门课的哪个方面;3. 你上过的最不喜欢的课程(不包括我教的);4. 你最不喜欢那门课的哪个方面。此信息仅为我个人兴趣收集,将严格保密。

042:标准着色器重写为表面着色器 🎮

在本节课中,我们将学习如何将Unity内置的标准着色器(Standard Shader)重写为表面着色器(Surface Shader)。通过这个过程,我们可以更深入地理解标准着色器的工作原理,并掌握表面着色器技术的实际应用。


上一节我们介绍了Unity标准着色器及其在游戏渲染中的重要性。本节中,我们来看看如何利用表面着色器技术来重新实现它。

我的名字是Er Lancherman,是佐治亚理工学院电气与计算机工程系的教授。在这门关于视频游戏GPU编程的课程中,我用了三节课讲解Unity内置渲染管线中的标准着色器,也用了几节课介绍表面着色器。表面着色器是Unity提供的一项技术,允许你编写一小段代码来指定材质属性,以及一个可选的、描述光线应如何响应这些材质属性的自定义光照函数。然后,表面着色器编译器会为你生成大量样板代码,这些代码会处理Unity支持的各种光源,并为你编写所需的所有顶点和片段着色器。

标准着色器本身并不是一个表面着色器,它是一段包含顶点和片段着色器的自定义HLSL代码。因此,我认为尝试将Unity标准着色器重写为表面着色器会很有教育意义,既能更好地理解标准着色器,也能提供一个表面着色器的示例。在左侧,我有一把使用Unity标准着色器的剑和盾牌。在右侧,则使用了名为“My Standard From Surface”的着色器,这是我使用表面着色器技术实现的版本。你可以从我的GitHub获取这个演示的Unity包,名为“GPU22_StandardFromSurfaceShader”。

我尚未对原始标准着色器和我的表面着色器版本进行详细比较,因此不确定所有功能是否都正常工作,也不确定是否完全匹配。但我想指出的一点是高度图(Height Map)和视差映射(Parallax Mapping)的效果。让我把盾牌上的法线贴图强度调低到零,你会看到高度图产生了一些有趣的效果。现在让我调整高度图的强度,将其从零开始逐渐调高,并放大一点以便观察。这就是一种称为视差的有趣效果,我之前没有讲过,它比我们在本课程中目前讨论的其他效果更高级,但它是标准着色器的一部分,因此也是我在这里实现的内容之一。让我们重新打开法线贴图,现在也可以加上视差效果。

当我坐下来开始这项使用表面着色器技术重现标准着色器的任务时,在整理硬盘时发现,我早在2018年就已经做过了,只是忘记了。所以,这个“My Standard From Surface”着色器从属性(Properties)开始,这些属性是我直接从原始标准着色器中复制过来的。现在,我们不再有顶点和片段着色器,而是定义了一个表面着色器。当然,我调用了自定义光照模型“MyStandard”。请注意,不要与之前某节课中的“My Standard Shader”混淆。在那节课中,我只是复制了原始着色器代码并在所有内容前加上了“My”。我们在这里的做法不同,这是在为表面着色器创建一个光照模型。

我从UnityCG和UnityPBSLighting中导入了一些内容,但对于其他我们需要的东西,我实际上会将代码复制到这个文件中,试图将所有内容集中在一个地方。着色器功能(Shader Features)是从原始标准着色器复制过来的。同样,这里的各种声明也是复制的。在这里,我定义了MySurfaceOutputStandard结构体,这是从UnityPBSLighting中的SurfaceOutputStandard结构体复制过来的。需要注意的是,对于你自己的自定义光照模型,你可以在这个结构体中放入任何你想要的内容。

让我们看看这里还有什么。我有一堆从其他文件复制过来的东西,当我这样做时,我通常在名称前加上“My”,以区分此文件中的内容和Unity主要包含文件中的内容,这样我们就可以根据需要在这里修改东西。这里有一个函数定义了“MyStandard”版本的标准光照模型的光照模型。这里调用了BRDF函数等。上面是光照函数LightingMyStandard_GI(全局光照)。往下翻,我放了一堆函数在这个文件里,以便集中管理。例如,我从UnityCG中复制了UnpackNormal函数,并在这里将其命名为MyUnpackNormal,这样我就可以在一个地方拥有所有内容,而不需要在文件之间跳转和追踪代码。在某些情况下,我将几个函数重新组合成一个函数,以使逻辑更清晰。

继续往下翻,这里有一些关于解包法线、混合法线、读取细节遮罩纹理(这是一个更高级的表面着色器功能)的内容。这里是MyNormalInTangentSpace函数,以及一堆复杂的东西。这里是纹理提取函数,包括发射贴图、Alpha提取、反照率贴图提取(这包括了许多与细节遮罩相关的内容)、遮挡纹理、金属度/光滑度提取。哦,这里还有使用高度图的视差效果函数。老实说,我并不完全理解它的工作原理,如果我花功夫是可以理解的,但暂时不想深入。这里是实际的表面函数(surf)。看看四年前自己写的代码总是很有趣。我当时写道:“在整个标准着色器的子例程中,使用XY作为主贴图UV坐标、ZW作为次级贴图UV坐标的风格是一致的。” 这是个不错的观察。

对于这个演示代码和实际的标准着色器,纹理坐标分配可能发生在顶点着色器中(在UnityStandardConfig中通过一个纹理代码例程完成)。但在表面着色器编译后,这个分配可能会发生在片段着色器中,因此效率可能稍低,但没关系。显然,标准着色器中关于使用备用UV集的内容,我不太记得具体问题是什么,但当时我遇到了问题,所以直接把它去掉了。

我们继续:在各个纹理中查找数据,处理视差、反照率、Alpha,获取法线。我在这里有一条注释:“如果你要写入法线,请写入切线空间法线。” 这是表面着色器结构体期望的一部分。这里调用了MyNormalInTangentSpace函数。然后我们获取发射纹理、遮挡、金属度/光滑度,并相应地设置所有内容。就是这样。

我还应该提到,标准着色器有一个自定义检视面板(Custom Inspector),我把它复制过来并改名为“MyStandardFromSurfaceGUI”。这个自定义编辑器必须放在Editor文件夹中。就本课程而言,你不需要担心如何制作这样的自定义检视面板的细节;如果没有它,你只会有一个常规的检视面板,这也没问题。我在这里加了一条注释:“由Er Lanchman修改以便编译”。我不太记得当时具体改了些什么,但记得当时为了让原始的GUI工作遇到了问题,四年前我肯定找到了修复方法。

表面着色器编译器会获取你的表面着色器代码,并为你编写一堆顶点和片段着色器。我们实际上可以查看它生成的代码。点击“Show generated code”,结果代码就在这里了,而且有很多。往下翻,看看有多少行?等等,这个文件竟然有 26,624 行!

我应该做一个简化版本,去掉一些功能,也许能得到一个更小的编译文件。幸运的是,看起来我四年前也做了这个。如果你去我的GitHub,我在那里放了代码“GPU22_SimplifiedStandardFromSurfaceShader”。是的,我又忘了自己做过这个,是在整理文件时发现的。我意识到当我深夜录制这些课程并且很累时,我听起来有点像William Shatner。

好的,新建一个3D项目。和往常一样,我们进入项目设置,在Player中将颜色空间设置为线性,因为伽马空间不好,我们需要线性空间。导入“GPU22_SimplifiedStandardFromSurfaceShader”。检查测试场景。哦,我在这里放了一堆东西。这里有一把剑和盾牌使用标准着色器。这里有一把剑和盾牌使用我的简化版标准着色器。这里还有一把剑和盾牌使用我的最简版标准着色器。看起来我创建了两种不同简化级别的着色器。哦,我应该提一下,我使用了一张几年前DragonCon上Buzz的照片制作了法线贴图,并把它用在盾牌上,让法线贴图效果更明显。

让我们看看我写的着色器代码。我有一个叫“mystandardshader.txt”的文件。我记得四年前做这个的时候,编译花了很长时间。现在这个不是前几节课创建的“My Standard”,而是我刚才展示的文件中称为“StandardFromSurfaceShader”的东西,我在这里加上了“Txt”后缀,这样Unity就不会尝试编译它。那么,简化版着色器是什么样的呢?

我写了什么?移除了与透明度混合模式、次级贴图相关的代码,以及关闭镜面高光和反射的复选框选项。所以现在这里的代码更少了。这里有我的光照模型、辅助函数、从纹理获取数据的函数、视差相关的东西,还有表面函数。让我们看看表面着色器编译器生成的代码。结果代码不再是超过200万行,而是只有 66,533 行。

让我们看看检视面板里有什么。我们有反照率、金属度、光滑度这些常规选项,但没有任何次级贴图相关的选项。果然,这些着色器的每个变体都需要自己单独的自定义检视面板GUI文件。对于我的最简版着色器,只有反照率、金属度/光滑度和一个法线贴图。查看代码,我有一条注释,说明我移除了与透明度混合模式、次级贴图相关的代码(这些在简化版中已经移除了)。但在最简版中,我还移除了高度图、视差效果、遮挡贴图和发射贴图。所以这段代码应该更紧凑,更容易理解,尽管仍然有很多内容需要消化。

让我们看看表面着色器编译器为最简版生成的代码。现在只有……哦,还在加载……只有 14,867 行代码。现在,我可以保存这个文件,更改它的名称,这样它就变成了一个标准的Unity着色器文件,然后你可以修改它、优化它,做任何你想做的事情。这次代码量不再那么夸张了,让我们看看代码。这里有一堆表面着色器编译器添加的注释,说明它需要什么。它在这里添加了一些包含文件,比如UnityCG.cginc等。关于内部数据、虚拟预处理器以解决HLSL编译器行处理问题的各种内容,真是疯狂。

那么,让我看看是否能找到编译后的代码在哪里,因为这里的所有内容都是我的原始代码(或者说,是源自Unity的原始代码)。在某个地方,我们应该能看到表面着色器编译器自己生成的函数。哦,这里是结构体,这是用于在插值器中传递数据的v2f_surf结构体,包含世界法线、世界位置等。这是关于着色器目标的吗?关于雾坐标和阴影坐标的内容。哦,这里有一堆这些结构体的变体,看起来我们在某个编译器指令中,它会选择不同的版本。找到了,这里是实际的顶点着色器。这里是我们期望看到的内容:将顶点从物体坐标转换到裁剪坐标,传递纹理坐标、世界位置、世界法线,变换切线,如果需要的话计算副法线。关于动态光照贴图、更多光照贴图的内容。这里是关于那四个可以逐顶点计算的点光源的内容,关于球谐函数的东西,一堆关于雾的内容。现在我们有了片段着色器。更多关于雾的内容。这里我们查看光照方向、世界视角方向。我们有一堆被初始化为0的变量。我想之后我们实际上会进行纹理查找。我的纹理查找在哪里?涉及光照结构的东西?全局光照内容。然后它调用了实际的光照函数,其职责是填充那些被初始化为0的各种变量。

以上就是表面着色器编译器创建的HLSL代码。

在所有这些课程中,我还没有展示过为这些着色器创建的实际汇编代码。让我们点击“Compile and show code”。好的,这不是一个有效的着色器文件,内容仅用于信息和调试目的。开始往下翻,看看我们能发现什么。哇,看看所有这些,太疯狂了。我们有一些看起来像 u_xlat39, u_xlat 的东西,我猜这些是寄存器。看 FMA,这可能是某种乘加指令。所以这看起来不像你通常看到的汇编代码样子,但这些都映射到汇编指令。这里有一些点积操作,一些倒数*方根操作。哦,这真的很有趣,这里有一个纹理查找操作。这绝对不是你想手动编写的东西。

在结束之前,有一点我需要澄清:高度图在概念上是一个标量图。我放了一张Buzz的RGB图片在这里。与视差映射相关的例程会提取绿色通道作为高度图使用。再次说明,这与你可能用来生成法线贴图的高度图是分开的,尽管它们通常是同一个东西,以产生一致的效果。


本节课中,我们一起学习了将复杂的Unity标准着色器重写为表面着色器的过程。我们看到了表面着色器如何通过编译器自动生成大量样板代码,简化了处理多光源和复杂光照模型的开发工作。通过创建简化版本,我们也了解到如何控制生成代码的复杂度,使其更易于理解和维护。这个过程不仅加深了我们对标准着色器内部机制的理解,也展示了表面着色器在提高开发效率方面的强大能力。

如果你是正在修读本课程学分的佐治亚理工学院学生,请前往Canvas完成与本讲相关的小测验,其中只有一个问题,希望你用几句话给出建议:我和其他教授在教授异步课程时,可以做些什么来确保大家能跟上观看讲座的进度而不落后。

043:基于内置流水线的DIY后期处理 🎮

在本节课中,我们将学习如何在Unity内置渲染管线中,通过编写自定义脚本和着色器,实现DIY的后期处理效果。我们将探讨边缘检测、深度与法线信息可视化,以及一个简化的延迟渲染示例。


概述

后期处理类似于为每一帧画面应用Photoshop滤镜。在游戏中,它可以实现诸如泛光、运动模糊等视觉效果。本节课我们将绕过Unity官方的后期处理栈,手动实现几个效果,以理解其背后的工作原理。

上一节我们介绍了后期处理的基本概念,本节中我们来看看如何通过代码具体实现。


场景与脚本设置

我们使用一个演示场景,其中包含马、骷髅模型和一个带有砖墙纹理的背景。所有物体都使用内置渲染管线的标准着色器。

摄像机附有三个脚本:

  1. 延迟渲染演示脚本:用于展示高级渲染概念。
  2. 轮廓线脚本:随时间在原始图像和边缘检测图像之间进行变形。
  3. 深度法线脚本:用于可视化场景的深度和视图空间法线信息。

以下是脚本在检视面板中的示例:

所有相关代码和着色器都位于名为 Shaders and Support 的文件夹中,便于管理。


实现轮廓线(边缘检测)效果

首先,我们来看如何实现一个简单的边缘检测效果。请注意,在实际项目中使用内置管线时,应优先使用Unity提供的后期处理栈包。这里的DIY版本是为了揭示底层原理。

着色器代码分析

所有后期处理着色器都需要包含 UnityCG.cginc 文件。其核心思想是:先正常渲染一帧,然后将该帧图像作为纹理,再渲染一个覆盖全屏的四边形(两个三角形)并应用我们的效果。

我们使用 Hidden 特性来隐藏着色器,使其不出现在材质球的着色器列表中。

Shader "Hidden/OutlineShader"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _Speed ("Speed", Float) = 1.0
    }
    SubShader
    {
        // ... 通道和顶点着色器代码(通常由UnityCG提供)...

        sampler2D _MainTex;
        float4 _MainTex_TexelSize; // Unity提供的纹理像素尺寸信息
        float _Speed;

        fixed4 frag (v2f i) : SV_Target
        {
            // 采样原始像素及其上下左右相邻像素
            fixed4 original = tex2D(_MainTex, i.uv);
            fixed4 originalLeft = tex2D(_MainTex, i.uv - float2(_MainTex_TexelSize.x, 0));
            fixed4 originalRight = tex2D(_MainTex, i.uv + float2(_MainTex_TexelSize.x, 0));
            fixed4 originalUp = tex2D(_MainTex, i.uv - float2(0, _MainTex_TexelSize.y));
            fixed4 originalDown = tex2D(_MainTex, i.uv + float2(0, _MainTex_TexelSize.y));

            // 计算水*和垂直方向的一阶差分(*似梯度)
            float3 horizontalDiff = abs(original.rgb - originalLeft.rgb) + abs(original.rgb - originalRight.rgb);
            float3 verticalDiff = abs(original.rgb - originalUp.rgb) + abs(original.rgb - originalDown.rgb);

            // 合并梯度得到边缘强度
            float edgeStrength = length(horizontalDiff + verticalDiff);

            // 根据时间在原始图像和边缘图像之间插值
            float t = 0.5 * (1.0 + sin(_Time.y * _Speed));
            fixed4 edgeColor = fixed4(edgeStrength.xxx, original.a);
            fixed4 finalColor = lerp(original, edgeColor, t);

            return finalColor;
        }
        // ...
    }
}

关键点

  • _MainTex_TexelSize 是一个 float4,其 xy 分量分别表示纹理像素在UV空间中的宽度和高度倒数,用于精确访问相邻像素。
  • 通过计算相邻像素的颜色差异来*似图像梯度,从而检测边缘。
  • 使用 _Time.y_Speed 变量来实现随时间变化的动态效果。

C# 脚本驱动

C#脚本负责将着色器材质应用到摄像机的渲染图像回调中。

[RequireComponent(typeof(Camera))]
public class Outliner : MonoBehaviour
{
    public Shader outlineShader;
    private Material outlineMaterial;
    public float speed = 1.0f;

    void Start()
    {
        // 根据着色器名称查找并创建材质
        outlineShader = Shader.Find("Hidden/OutlineShader");
        outlineMaterial = new Material(outlineShader);
    }

    void Update()
    {
        // 将C#端的speed变量传递给着色器中的_Speed属性
        outlineMaterial.SetFloat("_Speed", speed);
    }

    // OnRenderImage 在常规渲染完成后、显示到屏幕前被调用
    void OnRenderImage(RenderTexture source, RenderTexture destination)
    {
        // 将源图像通过我们的轮廓线材质渲染到目标图像
        Graphics.Blit(source, destination, outlineMaterial);
    }
}

关键点

  • [RequireComponent(typeof(Camera))] 确保脚本只能附加到摄像机对象。
  • Shader.Find 通过名称(必须与着色器中定义的完全一致)来查找着色器。
  • OnRenderImage 是内置管线提供的回调函数,是注入后期处理效果的关键。
  • Graphics.Blit 函数执行实际的图像复制和处理操作。

可视化深度与法线信息

接下来,我们看看如何访问和显示场景的深度缓冲与视图空间法线信息。这些信息可用于实现景深、屏幕空间反射等高级效果。

着色器代码分析

这个着色器使用Unity内置的 _CameraDepthNormalsTexture 来获取数据。

Shader "Hidden/DepthNormalShader"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
    }
    SubShader
    {
        sampler2D _MainTex;
        sampler2D _CameraDepthNormalsTexture; // Unity提供的深度法线纹理

        fixed4 frag (v2f i) : SV_Target
        {
            // 解码深度和法线信息
            float3 viewNormal;
            float depth;
            DecodeDepthNormal(tex2D(_CameraDepthNormalsTexture, i.uv), depth, viewNormal);

            // 将法线分量从[-1, 1]映射到[0, 1]以便显示为颜色
            fixed3 normalColor = viewNormal * 0.5 + 0.5;

            // 深度值本身在0到1之间,可以直接显示为灰度
            fixed depthColor = depth;

            // 随时间在法线彩色图和深度灰度图之间交叉淡化
            float t = 0.5 * (1.0 + sin(_Time.y));
            fixed3 finalColor = lerp(depthColor, normalColor, t);

            return fixed4(finalColor, 1.0);
        }
    }
}

关键点

  • _CameraDepthNormalsTexture 是Unity在特定设置下自动生成的纹理,编码了深度和法线信息。
  • DecodeDepthNormalUnityCG.cginc 中的辅助函数,用于从纹理中提取深度值和视图空间法线向量。
  • 深度值通常经过非线性变换以更好地利用精度,Linear01Depth 函数可将其转换为线性的0-1范围。

C# 脚本设置

为了让Unity生成 _CameraDepthNormalsTexture,需要在脚本中设置摄像机的深度纹理模式。

[RequireComponent(typeof(Camera))]
public class DepthNormalViewer : MonoBehaviour
{
    void Start()
    {
        // 指示摄像机渲染深度和法线信息到纹理
        GetComponent<Camera>().depthTextureMode = DepthTextureMode.DepthNormals;
    }
    // ... OnRenderImage 方法与之前类似 ...
}

简化的延迟渲染演示

最后,我们通过一个示例来体验延迟渲染的基本思想。与之前所有的正向渲染不同,延迟渲染先将几何信息(位置、法线、材质属性等)存储到多个缓冲区(G-Buffer),然后在第二个光照处理阶段统一计算光照。

着色器与脚本逻辑

在这个简化示例中,我们的G-Buffer只包含深度和法线信息,其他材质属性假设为全局统一值。

着色器核心任务(在片段着色器中)

  1. 从深度法线纹理重建每个像素在视图空间中的位置。
  2. 使用自定义的光照计算(例如简化的Blinn-Phong模型)为每个像素着色。

C# 脚本核心任务

  1. OnPreRender 回调中,设置摄像机参数(用于位置重建)。
  2. Update 中更新光源位置。
  3. OnRenderImage 中,将光源位置等数据传递给着色器,并执行渲染。
void OnPreRender()
{
    // 设置摄像机投影参数,用于在着色器中从屏幕空间反推视图空间位置
    Camera cam = GetComponent<Camera>();
    float height = 2 * cam.nearClipPlane * Mathf.Tan(cam.fieldOfView * 0.5f * Mathf.Deg2Rad);
    float width = height * cam.aspect;
    Vector3 cameraData = new Vector3(width, height, cam.nearClipPlane);
    deferredMaterial.SetVector("_CameraData", cameraData);
}

关键公式(视图空间位置重建)
在着色器中,从屏幕UV和深度值 z 重建视图空间位置 posVS 的简化过程如下:

  1. 将UV从 [0,1] 映射到NDC的 [-1,1] 范围:clipPos.xy = uv * 2.0 - 1.0
  2. 利用相似三角形原理,用深度值 z 和摄像机**面大小(_CameraData.xy)以及**面距离(_CameraData.z)计算:
    posVS.xy = clipPos.xy * _CameraData.xy * (z / _CameraData.z);
    posVS.z = z; // 深度值直接作为Z坐标
    

效果叠加与项目实践

一个有趣的现象是,这些后期处理效果可以叠加。例如,可以在深度法线可视化效果之上再运行轮廓线效果。

如果你想亲自尝试:

  1. 前往 Lanterntronics 的 GitHub 页面,找到 CSEC 4795 代码仓库。
  2. 下载名为 GPU_21_PostProc_Deferred_BuiltIn_DIY 的Unity包(名称很长,旨在与官方后期处理栈项目区分)。
  3. 新建一个空白3D Unity项目,将该包导入Assets文件夹。
  4. 打开演示场景并运行。

总结

本节课我们一起学习了:

  1. DIY后期处理原理:通过 OnRenderImage 回调和 Graphics.Blit,在渲染完成后对全屏图像进行处理。
  2. 边缘检测实现:通过采样相邻像素计算颜色梯度,实现动态轮廓线效果。
  3. 深度与法线访问:如何通过 _CameraDepthNormalsTextureDecodeDepthNormal 函数获取并可视化这些底层图形信息。
  4. 延迟渲染初探:了解了延迟渲染的基本概念,并实现了一个简化的、基于深度法线重建位置的自定义光照示例。

通过手动实现这些效果,我们深入理解了Unity内置渲染管线中后期处理的注入点、数据传递方式和基本图像处理技巧,为学习和使用更高级、更高效的官方工具奠定了坚实基础。

044:Unity内置流水线的后处理栈 🎮

在本节课中,我们将学习如何在Unity内置渲染管线中使用官方的后处理栈(Post-processing Stack)来创建和管理后处理效果。我们将了解如何安装后处理栈包,如何创建自定义后处理效果,以及如何将它们与Unity内置的效果结合使用。


项目设置与包安装

上一节我们介绍了使用自定义脚本实现后处理效果的方法。本节中,我们来看看如何使用Unity官方提供的、更强大的后处理栈框架。

首先,需要创建一个新的Unity项目(例如“PostProcess Stack Test”)。为了使用后处理栈,必须从Package Manager中安装“Post Processing”包。请注意,此包仅适用于内置渲染管线(Built-in Render Pipeline)。对于通用渲染管线(URP)或高清渲染管线(HDRP),它们各自拥有独立的后处理系统,与本教程介绍的系统不兼容。

安装完成后,可以从提供的GitHub链接下载并导入演示资源包。如果导入后出现着色器错误,通常可以通过在Assets文件夹上右键选择“Reimport All”来解决。


后处理组件与效果应用

在场景中,主摄像机上需要附加两个关键组件:Post-process LayerPost-process Volume

Post-process Layer 组件必须附加在摄像机上,它负责与场景中的各个后处理体积进行通信。而 Post-process Volume 组件则定义了后处理效果的生效范围及其参数。虽然为了方便演示,可以将其附加在摄像机上,但最佳实践是将其作为独立的游戏对象。

以下是核心组件的作用:

  • Post-process Layer:摄像机的“接收器”,用于启用后处理并管理效果堆栈的顺序。
  • Post-process Volume:效果的“容器”,可以定义效果的作用空间(全局或局部)和具体参数。

Post-process Volume 中,可以添加各种效果,例如内置的“Chromatic Aberration”(色差)或“Bloom”(泛光)。每个效果的具体参数(如强度、阈值)可以通过勾选参数旁边的“Override”复选框来激活和调整。取消勾选则恢复为默认值。


后处理体积与混合

Post-process Volume 的核心概念在于“体积”。可以创建一个3D对象类型的后处理体积,并通过调整其缩放(Scale)来定义其影响范围。

当摄像机位于该体积内时,体积中定义的效果和参数覆盖才会生效。这允许开发者设计这样的场景:当玩家进入特定区域(如迷雾森林或水下)时,后处理效果(如雾浓度、色调)会*滑地过渡变化。

为了实现这种基于体积的混合,需要在摄像机的 Post-process Layer 组件中,将 Trigger 属性设置为代表玩家或摄像机的碰撞体。如果体积设置为“Is Global”,则其效果会影响整个场景,无需触发器。


自定义后处理着色器

现在,我们深入探讨如何编写自定义的后处理着色器,并将其集成到后处理栈中。

后处理着色器的代码结构与之前课程中使用的略有不同。它采用了Unity较新的HLSL风格。顶点着色器部分通常很简单,主要负责传递纹理坐标。

片段着色器是效果实现的核心。以下是一个边缘检测效果的示例代码片段:

// 采样当前像素颜色
float4 col = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.texcoord);
// 采样相邻像素颜色以计算梯度
float4 colR = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.texcoord + float2(_MainTex_TexelSize.x, 0));
float4 colU = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.texcoord + float2(0, _MainTex_TexelSize.y));
// 计算边缘强度
float edge = abs(colR.r - col.r) + abs(colU.r - col.r);
// 在原始图像和边缘图像之间进行交叉淡化
return lerp(col, float4(edge.xxx, 1), _Blend);

主要的区别在于纹理采样方式。新的 TEXTURE2D/SAMPLER 宏将纹理对象与采样器状态分离,这符合现代图形API(如DX11)的最佳实践,允许更高效的资源管理。采样时使用 SAMPLE_TEXTURE2D(textureName, samplerName, uv) 宏。


自定义后处理效果的C#脚本

为了让自定义着色器在后处理栈中工作,还需要一个对应的C#脚本。这个脚本继承自 PostProcessEffectSettings,并负责将参数传递给着色器。

以下是脚本结构的关键部分:

// 1. 定义效果设置类,包含可在Inspector中调整的参数
[Serializable]
[PostProcess(typeof(MyCustomEffectRenderer), PostProcessEvent.AfterStack, "Custom/MyEffect")]
public sealed class MyCustomEffect : PostProcessEffectSettings
{
    [Range(0f, 1f), Tooltip("Effect intensity.")]
    public FloatParameter intensity = new FloatParameter { value = 0.5f };
}

// 2. 定义效果渲染器类,负责执行渲染命令
public sealed class MyCustomEffectRenderer : PostProcessEffectRenderer<MyCustomEffect>
{
    public override void Render(PostProcessRenderContext context)
    {
        // 获取属性表并设置着色器参数
        var sheet = context.propertySheets.Get(Shader.Find("Custom/MyEffect"));
        sheet.properties.SetFloat("_Intensity", settings.intensity);
        // 使用自定义着色器将源图像渲染到目标
        context.command.BlitFullscreenTriangle(context.source, context.destination, sheet, 0);
    }
}

脚本中的 PostProcessEvent.AfterStack 属性决定了该效果在渲染流水线中的执行顺序(例如,在Unity所有内置效果之后执行)。


深度与法线纹理的使用

许多高级后处理效果(如景深、边缘检测)需要访问场景的深度和法线信息。在后处理栈中,可以通过声明 _CameraDepthNormalsTexture 来获取这些编码后的数据。

以下是如何在着色器中解码并使用深度和法线信息的示例:

// 声明深度法线纹理
TEXTURE2D(_CameraDepthNormalsTexture);
SAMPLER(sampler_CameraDepthNormalsTexture);

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/ggtech-ece4795-gpuprog/img/9a6bfcd9ed6c501a9ca7627f2e52a5c6_14.png)

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/ggtech-ece4795-gpuprog/img/9a6bfcd9ed6c501a9ca7627f2e52a5c6_16.png)

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/ggtech-ece4795-gpuprog/img/9a6bfcd9ed6c501a9ca7627f2e52a5c6_18.png)

// 在片段着色器中
float4 depthnormal = SAMPLE_TEXTURE2D(_CameraDepthNormalsTexture, sampler_CameraDepthNormalsTexture, i.texcoord);
// 解码深度值 (0到1范围,1为远*面)
float depth = DecodeFloatRG(depthnormal.zw);
// 解码法线向量
float3 normal = DecodeViewNormalStereo(depthnormal);
// 将法线从(-1,1)映射到(0,1)以便显示
normal = normal * 0.5 + 0.5;

请注意,解码深度和法线需要使用Unity提供的特定函数(如 DecodeFloatRGDecodeViewNormalStereo)。如果只需要深度信息,也可以使用 _CameraDepthTexture,但解码方式会有所不同,通常使用 Linear01Depth 函数来获取线性的、在0到1范围内的深度值。

为了让Unity生成这些纹理,必须在对应的效果渲染器C#脚本中重写 GetCameraFlags 方法,并返回 DepthTextureDepthNormals 标志。


总结

本节课中我们一起学习了Unity内置渲染管线中后处理栈的完整工作流程。我们从安装和设置开始,了解了 Post-process LayerPost-process Volume 的核心作用。我们探索了如何利用后处理体积实现空间化的效果混合。接着,我们深入研究了如何编写符合后处理栈规范的自定义着色器,包括新的纹理采样语法。我们还学习了如何通过C#脚本将自定义效果集成到后处理框架中,并控制其执行顺序。最后,我们探讨了如何访问和使用深度与法线纹理来实现更复杂的效果。掌握这套流程,你将能够高效地创建和管理专业级的游戏后处理效果。

045:URP中的自定义后处理 😤

在本节课中,我们将探讨在Unity的通用渲染管线中实现自定义后处理效果所面临的挑战与现状。我们将回顾历史兼容性问题,分析当前可行的解决方案,并理解Unity版本迭代对开发工作流的影响。

兼容性断裂:从LWRP到URP

上一节我们介绍了课程背景,本节中我们来看看版本兼容性带来的具体问题。

在2020年夏季学期,我以纯远程学习形式教授了这门课程。由于这是首次完全远程教学,时间紧张,一些通常在课程末期讲解的主题(如后处理)最终未能涵盖。2021年,当我回顾2019年的教学资料时,发现了一个为内置渲染管线编写的自定义后处理效果演示包,以及另一个声称适用于轻量级渲染管线的后处理效果包。

为内置渲染管线编写的自定义后处理演示代码在Unity 2021.1版本中运行良好。然而,当我尝试在Unity 2021.1中运行为轻量级渲染管线编写的版本时(此时URP本质上是LWRP的重命名版本),它完全无法工作。

对于任何有Unity开发经验的人来说,花费数天时间调试代码以适应新版本并非陌生体验。因此,我着手进行必要的修改工作。

经过一番看似无尽的折腾后,我发现了官方文档中的这段说明:

URP目前不支持自定义后处理效果。如果你的项目使用了自定义后处理效果,这些效果目前无法在URP中重现。自定义后处理效果将在URP的未来版本中得到支持。

我最初以为,在2019年,我可能尝试过将内置管线的演示移植到LWRP,但因无法实现或过于复杂而从未完成。但如果是这样,我为何会将其导出为一个Unity包?作为验证,我查看了佐治亚理工学院使用的Canvas学习管理系统上2019年夏季的课程资料,发现确实存在一个“后处理-轻量级渲染管线”包。这说明它当时至少能运行到足以让我认为可以上传供学生下载的程度。

版本差异:问题的根源

上一节我们看到了兼容性问题,本节中我们来看看导致问题的具体版本差异。

当我发现Ditzel Games(不确定发音)的优秀教程时,我感到更加困惑。在该教程中,我看到了一些代码,它们看起来非常像我为内置渲染管线编写的自定义后处理着色器代码。教程中明确指出,虽然URP包含自己的集成后处理解决方案,但该版本的URP也支持后处理V2包——这正是我在内置管线中使用的。文档甚至提到了“为了与现有项目向后兼容”。

那么问题出在哪里?

事实证明,关键在于你查看的是哪个版本。以下是关键发现:

  • 在URP 7.x版本(如7.3, 7.6, 7.7)中,文档均提及对后处理V2包的支持。
  • 但当版本号跳到8.0时,情况突变。文档明确指出:URP与后处理V2包不兼容

这意味着兼容性在7.x和8.0版本之间被破坏了。这类情况令人沮丧,因为它意味着在搜索Unity相关信息时,你必须将当前年份(如果幸运的话,前一年份)与搜索词一起使用,并努力关注*期创建的信息,因为任何超过两年的信息都可能已经过时。

这对于YouTube搜索尤其棘手,因为旧视频往往拥有更多观看量,从而可能在搜索结果中排名更高,而这些视频更可能已经失效。

代码与注释的陷阱

上一节我们分析了版本差异,本节中我们来看看过时的代码和注释如何加剧问题。

那个后处理V2包中甚至有一个名为StdLib的文件,其中包含这样一条注释:

// 因为这个框架需要同时支持旧版渲染管线和可编程渲染管线,我们不能使用Unity着色器库。

但文件中没有任何注释来说明这个情况在包的后继版本中已不再成立。

在Unity(乃至一般编程中)你会经常看到这种情况。注释本质上是不会执行的代码。如果维护不善,注释很容易变得陈旧,从而产生误导。

至少,如果一个教程名为“为LWRP创建自定义后处理效果”,除了标明创建时使用的Unity版本外,如果能指出它在哪个URP包版本之后就不再适用,将会非常有帮助,这样你就知道该寻找其他资料了。

当前解决方案与未来展望

上一节我们讨论了信息过时的问题,本节中我们来看看目前在URP中实现自定义后处理的可能性。

现在,在通用渲染管线中实现自定义后处理效果并非不可能,只是目前非常棘手。这需要深入钻研可编程渲染管线架构的内部机制,正如Code Monkey的优秀教程所描述的那样。我也推荐查看Gabrie Co.的教程。

当然,一些聪明人已经创建了一些框架来尝试简化这个过程。然而,无论他们的努力多么巧妙,这些框架都很可能在Unity未来的某个版本中失效,或者当Unity最终完成他们本应一开始就做好的功能时,变得不再必要。

如果你查看通用渲染管线的开发路线图,可以看到他们正在进行中的事项,其中就包括后处理自定义效果。这让我有些困惑,因为他们之前已经让它运行起来了。正如前面提到的,在2019.3 LTS版本中,使用URP包版本7.2时,你可以使用后处理V2及其自定义效果,但他们却在URP版本8中破坏了它。请注意,现在是2021年,而URP可能已经到了版本12。

文档确实提到你可以使用渲染通道来实现一些自定义效果,但这同样需要相当深入地了解URP和整个可编程渲染管线结构。

路线图上最让我兴奋的功能实际上是关于表面着色器的部分。这是内置渲染管线拥有的功能,非常棒。它允许你在文本代码中编写描述表面材质属性的函数,以及另一个描述光照模型的函数,然后它会将这些配对起来,并生成处理Unity提供的所有光源所需的不同组合的代码。它帮助你避免处理大量样板代码,也无需进行大量复制粘贴。但不知何故,当他们转向可编程渲染管线时,这个功能消失了。

现在,他们希望每个人都使用Shader Graph,这是一种新的图形化编写着色器的方式。但正如我将在另一节课中吐槽的,图形化编程语言可能存在很多问题。例如,可能只需三四行代码、在屏幕上只占很小空间的内容,突然可能需要占据整个屏幕的版面。不过,这将是另一个话题的讨论了。


本节课中我们一起学习了在Unity URP中实现自定义后处理效果所面临的挑战。我们回顾了从LWRP到URP过渡中的兼容性断裂问题,分析了不同版本(特别是7.x与8.0+)的差异,指出了依赖过时教程和代码注释的风险。最后,我们探讨了当前实现自定义效果需要深入URP底层的现状,并展望了官方路线图中表面着色器等功能的回归。理解这些版本和架构的变迁,对于在Unity中进行可持续的图形编程至关重要。

046:Unity脚本化渲染流程入门 🎮

在本节课中,我们将学习Unity的脚本化渲染流程。我们将从零开始创建一个渲染管线,而不是使用Unity提供的通用渲染管线或高清渲染管线。通过学习自定义管线的构建,你将能更好地理解URP或HDRP的内部机制,并对渲染管线的工作原理有更深入的认识。

概述

上一节我们介绍了GPU编程的基础概念,本节中我们来看看如何构建一个自定义的Unity脚本化渲染管线。我们将探讨其必要性、适用场景以及学习资源。

何时使用自定义渲染管线

如果你正在开发游戏,通常不建议从零开始编写脚本化渲染管线,除非你有非常特殊或独特的渲染需求。建议选择通用渲染管线或高清渲染管线。

由于URP和HDRP在不同版本更新中可能不稳定,你可能会考虑坚持使用课程中目前介绍的内置渲染管线,因为它相对稳定。

即使你在游戏中不使用脚本化渲染管线,学习SRP仍然有用。它能让你了解渲染器在底层需要完成的工作,从而深入理解内置管线或其他引擎(如Unreal或Godot)的渲染器工作原理。

学习资源推荐

以下是推荐的自定义脚本化渲染管线学习资源:

  • 视频教程:Game Dev Guide的《解锁Unity脚本化渲染管线的力量》。虽然其中内容大多也能通过修改内置管线实现,但仍具参考价值。
  • 技术演讲:Alex Williams的《构建自定义渲染管线》。这个演讲值得更多关注。
  • 高级案例:Unite Copenhagen 2019上的《Battleplanet: Judgment Day》演讲。其中展示的一些效果很难在内置管线中实现。
  • 实战分享:Mark Mayers向波士顿Unity小组分享的游戏《Deis》相关内容。其中涉及的技术在内置管线中极难实现。

总结

本节课中我们一起学习了Unity脚本化渲染流程的基本概念。我们了解了自定义管线的适用场景,并获得了相关的学习资源指引。下一节我们将开始动手实践,构建我们自己的渲染管线基础框架。

047:Unity中的自定义前向脚本渲染流水线 🎮

在本节课中,我们将学习如何在Unity中创建一个自定义的前向渲染脚本化渲染流水线。我们将从设置项目开始,逐步讲解如何编写着色器代码和C#脚本,以构建一个能够处理不透明和透明对象、支持多光源渲染的简单渲染管线。


概述

脚本化渲染流水线允许开发者完全控制Unity的渲染流程。本节我们将创建一个名为“My Pipeline”的自定义前向渲染管线。我们将使用一个自定义的着色器,它能在单次渲染通道中处理多个光源,并支持不透明与透明材质的混合渲染。

项目设置

首先,我们需要创建一个新的Unity项目并导入必要的资源包。

  1. 创建一个新的3D项目,命名为“SRP 4 demo”。
  2. 打开 Window > Package Manager
  3. 在左上角的下拉菜单中选择 Unity Registry
  4. 搜索并安装 Core RP Library 包。这是脚本化渲染流水线的基础包。
  5. 从提供的资源中,将 GPU 23 SRP forward Unity包导入项目。

导入完成后,打开 Scenes 文件夹中的 SRP intro 场景。你可能会看到一些球体显示为黑色,这是因为场景目前仍在使用Unity内置的渲染管线。

配置渲染管线

为了让我们的自定义着色器正常工作,需要将项目切换到自定义渲染管线。

  1. 进入 Edit > Project Settings > Player
  2. Other Settings 下,将 Color Space 设置为 Linear
  3. 进入 Edit > Project Settings > Graphics
  4. Scriptable Render Pipeline Settings 字段中,点击圆圈图标,选择我们导入的 My Pipeline 资产。

完成切换后,场景中的球体应正确显示颜色和光照效果。一个灯光被脚本驱动来回移动,以展示动态光照。

理解自定义着色器

我们的自定义着色器名为 MyLit。它比URP或HDRP中的标准着色器更简单,但包含了处理光照和透明度的核心逻辑。

着色器结构

以下是着色器代码的核心部分概览:

Shader "Custom/MyLit"
{
    Properties
    {
        _Color ("Color", Color) = (1,1,1,1)
    }
    SubShader
    {
        // 定义渲染标签和通道
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            // HLSL代码块开始
            HLSLPROGRAM
            // 顶点和片段着色器函数声明
            #pragma vertex vert
            #pragma fragment frag
            // 包含必要的HLSL库
            #include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl"
            // ... 更多代码
            ENDHLSL
        }
    }
}

处理光源与透明度

与内置管线不同,我们的着色器在单次通道中处理所有(最多4个)光源。光源数据(颜色、方向/位置)通过C#脚本传递到着色器的全局变量数组中。

透明度通过混合模式控制。在材质检视面板中,可以设置 SrcBlend(源混合)和 DstBlend(目标混合)模式:

  • 不透明物体SrcBlend = One, DstBlend = Zero, ZWrite = On
  • 透明物体SrcBlend = SrcAlpha, DstBlend = OneMinusSrcAlpha, ZWrite = Off

驱动渲染的C#脚本

自定义渲染管线需要两个核心C#脚本:一个用于创建管线资产,另一个用于定义渲染逻辑。

1. 管线资产脚本 (MyPipelineAsset)

这个脚本继承自 RenderPipelineAsset,主要作用是创建我们的自定义管线实例。

using UnityEngine;
using UnityEngine.Rendering;

[CreateAssetMenu(menuName = "Rendering/My Pipeline")]
public class MyPipelineAsset : RenderPipelineAsset
{
    public bool dynamicBatching;
    public bool instancing;

    protected override RenderPipeline CreatePipeline()
    {
        // 创建并返回我们的自定义管线实例,传入配置参数
        return new MyPipeline(dynamicBatching, instancing);
    }
}

[CreateAssetMenu] 属性使得我们可以在Project窗口中通过右键菜单创建此资产。

2. 管线渲染脚本 (MyPipeline)

这是最复杂的部分,继承自 RenderPipeline,并重写了 Render 方法以定义完整的渲染流程。

以下是渲染一帧的主要步骤:

  1. 设置与剔除:获取相机参数,对场景进行剔除,找出可见的渲染器和光源。
  2. 配置光源:从剔除结果中提取最多4个最重要的光源信息(颜色、方向/位置),并通过命令缓冲区传递给着色器。
    // 将C#端的光源方向/位置数据传递到着色器
    buffer.SetGlobalVectorArray(visibleLightDirectionsOrPositionsId, visibleLightDirectionsOrPositions);
    buffer.SetGlobalVectorArray(visibleLightColorsId, visibleLightColors);
    
  3. 绘制不透明物体:设置排序方式为 SortingCriteria.CommonOpaque(大致从前向后绘制),使用 RenderQueueRange.opaque 过滤设置,然后绘制所有不透明物体。
  4. 绘制天空盒:在绘制完不透明物体后绘制天空盒,避免不必要的覆盖。
  5. 绘制透明物体:更改排序方式为 SortingCriteria.CommonTransparent(从后向前绘制),使用 RenderQueueRange.transparent 过滤设置,然后绘制透明物体。

整个过程中,我们使用 CommandBuffer 来批量提交渲染命令以提高效率。对于场景视图的支持,需要通过检查 Camera.cameraType 并调用 EmitWorldGeometryForSceneView 方法来实现。

总结

本节课中,我们一起学习了如何在Unity中构建一个自定义的前向脚本化渲染流水线。我们涵盖了从项目设置、导入核心包,到编写处理多光源和透明度的自定义HLSL着色器。更重要的是,我们深入分析了驱动该管线的C#脚本,了解了如何通过 MyPipelineAsset 创建配置资产,以及如何在 MyPipeline 中实现完整的渲染循环——包括剔除、光源数据传递、命令缓冲区使用以及分别绘制不透明物体、天空盒和透明物体的关键步骤。

这个自定义管线虽然基础,但它清晰地展示了脚本化渲染流水线的核心控制逻辑,为你进一步探索更复杂的URP或HDRP管线,或打造专属渲染效果奠定了坚实的基础。

048:Unity中的自定义延迟渲染脚本化渲染管线

在本节课中,我们将学习如何在Unity中创建一个自定义的脚本化渲染管线,该管线实现了延迟渲染技术。我们将探讨延迟渲染的基本概念,并逐步解析实现该管线所需的着色器代码和C#脚本。

概述

延迟渲染是一种先收集场景几何信息(如位置、法线、颜色),再统一进行光照计算的渲染技术。与正向渲染相比,它特别适合处理大量动态光源的场景。本节课将基于一个自定义的脚本化渲染管线示例,详细讲解其实现过程。

准备工作

在开始之前,需要确保已经安装了Unity的核心渲染管线库。可以从GitHub获取演示包 GPp 23 SRPde unity package 并导入到项目中。

首先,在Unity编辑器中打开包管理器,确保选中Unity注册表,并安装核心RP库。对于自定义脚本化渲染管线,只需要核心RP库。

导入演示包后,打开延迟渲染场景。此时场景可能无法正常显示,因为项目仍在使用内置渲染管线。需要切换到我们自定义的管线。

进入 Edit -> Project Settings,进行以下设置:

  1. Player 设置中,确保颜色空间设置为 Linear
  2. Graphics 设置中,选择我们的自定义管线资产 myde pipeline

切换管线后,场景应能正常显示。演示场景中包含四个光源(其中一个通过脚本来回移动)以及多个不透明和透明物体。

着色器概览

在正向渲染示例中,我们使用一个名为 mylit 的着色器处理所有物体。但在延迟渲染中,我们需要两个独立的着色器:

  • mylit opaque:用于处理不透明物体,负责向G缓冲区填充反照率和法线信息。
  • my deferred:用于执行延迟渲染光照计算,基于G缓冲区中的信息计算最终颜色。

此外,还有两个特殊的着色器用于管线内部流程:

  • copy depth:用于深度信息复制。
  • myde:即延迟光照着色器。

这些着色器被组织在不同的菜单中,mylit 系列在 GPU23 deferred 菜单下,而工具类着色器在 GP23 deferred utility 菜单下。

管线资产配置

自定义管线资产 My Pipeline Asset 可以在 Assets -> Create -> Rendering 菜单中创建。在资产检查器中,可以启用或禁用动态批处理和GPU实例化等优化选项。最关键的是需要为 Copy Depth MaterialDeferred Material 槽位分配对应的材质。

如果创建了新的管线资产但未正确分配材质,切换到该管线时渲染会出错。需要手动将材质拖拽到对应槽位。

不透明物体着色器 (mylit opaque)

该着色器的核心任务不是计算最终颜色,而是向G缓冲区输出数据。

以下是其关键部分代码结构:

// 定义渲染类型和光照模式标签
Tags { "RenderType"="Opaque" "LightMode"="GBuffer" }

// 定义输出到多个渲染目标的结构体
struct GBufferOutput
{
    float4 RT0 : SV_Target0; // 反照率 (RGB) 和占位Alpha (A)
    float4 RT1 : SV_Target1; // 编码后的法线信息 (XYZ 映射到 0-1)
};

// 顶点着色器(与正向渲染相同)
Varyings vert (Attributes input)
{
    // ... 计算裁剪空间位置等
}

// 片段着色器
GBufferOutput frag (Varyings input)
{
    // 归一化插值后的法线
    float3 normalWS = normalize(input.normal);

    // 输出到G缓冲区
    GBufferOutput output;
    output.RT0 = float4(_Color.rgb, 1.0); // 输出反照率颜色
    output.RT1 = float4(normalWS * 0.5 + 0.5, 1.0); // 将法线从(-1,1)映射到(0,1)
    return output;
}

延迟光照着色器 (my deferred)

这个着色器类似于后处理效果,它读取G缓冲区和深度纹理,计算光照并输出最终颜色。

以下是其核心逻辑:

// 声明G缓冲区纹理和深度纹理
TEXTURE2D(_GBuffer0); SAMPLER(sampler_GBuffer0); // 反照率
TEXTURE2D(_GBuffer1); SAMPLER(sampler_GBuffer1); // 法线
TEXTURE2D_FLOAT(_CameraDepthTexture); SAMPLER(sampler_CameraDepthTexture); // 深度

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/ggtech-ece4795-gpuprog/img/f9d0e178c4cdc3794399473e1d60d236_14.png)

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/ggtech-ece4795-gpuprog/img/f9d0e178c4cdc3794399473e1d60d236_15.png)

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/ggtech-ece4795-gpuprog/img/f9d0e178c4cdc3794399473e1d60d236_16.png)

// 顶点着色器(绘制全屏四边形)
float4 vert (float3 vertex : POSITION) : SV_POSITION
{
    return float4(vertex, 1.0);
}

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/ggtech-ece4795-gpuprog/img/f9d0e178c4cdc3794399473e1d60d236_18.png)

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/ggtech-ece4795-gpuprog/img/f9d0e178c4cdc3794399473e1d60d236_19.png)

// 片段着色器
float4 frag (float4 positionCS : SV_POSITION) : SV_Target
{
    // 1. 计算屏幕UV
    float2 uv = positionCS.xy * _ScreenParams.zw; // _ScreenParams.zw 为 (1/width, 1/height)

    // 2. 从G缓冲区采样数据
    float4 albedoData = SAMPLE_TEXTURE2D(_GBuffer0, sampler_GBuffer0, uv);
    float3 albedo = albedoData.rgb;

    float4 normalData = SAMPLE_TEXTURE2D(_GBuffer1, sampler_GBuffer1, uv);
    float3 normalWS = normalData.rgb * 2.0 - 1.0; // 将法线从(0,1)映射回(-1,1)

    // 3. 从深度纹理重建世界空间位置(关键步骤)
    float depth = SAMPLE_TEXTURE2D(_CameraDepthTexture, sampler_CameraDepthTexture, uv).r;
    float3 positionWS = SampleDepthAsWorldPosition(uv, depth);

    // 4. 基于G缓冲区数据和重建的位置进行光照计算
    float3 diffuse = 0;
    for (int i = 0; i < _LightCount; i++)
    {
        // 计算每个光源的贡献(漫反射)
        // ... 光照计算代码
        diffuse += CalculateDiffuseLight(_Lights[i], positionWS, normalWS);
    }

    // 5. 输出最终颜色
    return float4(diffuse * albedo, 1.0);
}

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/ggtech-ece4795-gpuprog/img/f9d0e178c4cdc3794399473e1d60d236_21.png)

// 从深度值重建世界空间位置的函数
float3 SampleDepthAsWorldPosition(float2 uv, float depth)
{
    // 一系列坐标空间变换(NDC -> 裁剪空间 -> 视图空间 -> 世界空间)
    // 涉及逆投影矩阵和逆视图矩阵
    // ... 具体变换代码
    return worldPos;
}

深度重建说明SampleDepthAsWorldPosition 函数执行了一系列坐标变换,将屏幕UV和深度值转换回世界空间坐标,这是延迟光照计算所必需的。虽然变换细节复杂,但可以将其视为一个可靠的工具函数。

可视化G缓冲区

为了理解G缓冲区的内容,我们可以临时修改延迟光照着色器,直接输出其中的数据以便观察。

例如,要可视化反照率缓冲区,可以将最终输出改为:
return float4(albedo, 1.0);

要可视化法线缓冲区(世界空间法线),可以输出:
return float4(normalWS, 1.0);
注意,因为法线分量可能为负,直接显示时负值会被截断为黑色。

要可视化深度缓冲区,可以采样深度纹理并输出灰度值:
float depth = SAMPLE_TEXTURE2D(_CameraDepthTexture, sampler_CameraDepthTexture, uv).r; return float4(depth.xxx, 1.0);

透明物体与正向渲染路径

延迟渲染无法直接处理透明混合。因此,透明物体仍需使用传统的正向渲染路径。

透明物体的着色器代码与上一讲的正向渲染着色器基本相同,关键区别在于需要显式设置混合模式和深度写入:

Tags { "LightMode"="Forward" "RenderType"="Transparent" "Queue"="Transparent"}
Blend SrcAlpha OneMinusSrcAlpha
ZWrite Off

光照模式标签设为 "Forward",并关闭深度写入(ZWrite Off)以允许混合,但深度测试(ZTest)默认开启,确保透明物体在不透明物体之后时不会被绘制。

深度复制着色器 (copy depth)

这是一个简单的工具着色器,用于将深度纹理中的数据复制回摄像机的深度缓冲区。其片段着色器核心代码如下:

float frag (Varyings input) : SV_Depth
{
    float depth = SAMPLE_TEXTURE2D(_CameraDepthTexture, sampler_CameraDepthTexture, input.uv).r;
    return depth;
}

使用 SV_Depth 语义表示输出到深度缓冲区。

C# 渲染管线脚本

C#脚本负责组织整个渲染流程。以下是 MyPipeline 渲染器类的主要步骤:

  1. 初始化与资源获取:构造函数从管线资产中获取材质和设置。

    public MyPipeline(MyPipelineAsset asset)
    {
        _copyDepthMaterial = asset.copyDepthMaterial;
        _deferredMaterial = asset.deferredMaterial;
        // ... 获取其他设置
    }
    
  2. 设置渲染目标:为G缓冲区(反照率、法线)和深度缓冲区创建临时渲染纹理。

    // 创建G缓冲区0(反照率,RGB8格式)
    cmd.GetTemporaryRT(_gbuffer0Id, camera.pixelWidth, camera.pixelHeight, 0, FilterMode.Point, RenderTextureFormat.ARGB32);
    // 创建G缓冲区1(法线,RGB10A2格式以获得更高精度)
    cmd.GetTemporaryRT(_gbuffer1Id, camera.pixelWidth, camera.pixelHeight, 0, FilterMode.Point, RenderTextureFormat.ARGB2101010);
    // 创建深度渲染纹理
    cmd.GetTemporaryRT(_depthRTId, camera.pixelWidth, camera.pixelHeight, 24, FilterMode.Point, RenderTextureFormat.Depth);
    
  3. G缓冲区填充通道:将摄像机的渲染目标设置为G缓冲区,然后绘制所有不透明物体。使用的着色器通道标签是 "LightMode"="GBuffer"

    var drawSettings = new DrawingSettings(new ShaderTagId("GBuffer"), ...);
    context.DrawRenderers(cullingResults, ref drawSettings, ref filterSettings);
    
  4. 延迟光照计算:使用 Blit 命令和 _deferredMaterial 材质,读取G缓冲区和深度纹理,计算所有光源的贡献,并将结果绘制到一个中间的颜色渲染纹理(_colorRTId)中。

    cmd.Blit(_gbuffer0Id, _colorRTId, _deferredMaterial); // _gbuffer0Id 仅是占位符,实际数据由材质内部分配
    
  5. 绘制天空盒与透明物体:将渲染目标切换回颜色渲染纹理(_colorRTId),然后绘制天空盒。接着,使用标签 "LightMode"="Forward" 绘制所有透明物体(使用正向渲染)。

    cmd.SetRenderTarget(_colorRTId);
    context.DrawSkybox(camera);
    // ... 绘制透明物体
    
  6. 复制到最终缓冲区:将计算好的颜色和深度信息复制到Unity摄像机最终使用的缓冲区(BuiltinRenderTextureType.CameraTarget)。

    • 首先,使用 _copyDepthMaterial 将深度信息复制到摄像机的深度缓冲区。
    cmd.Blit(_depthRTId, BuiltinRenderTextureType.CameraTarget, _copyDepthMaterial);
    
    • 然后,将颜色信息直接复制到摄像机的颜色缓冲区。
    cmd.Blit(_colorRTId, BuiltinRenderTextureType.CameraTarget);
    
  7. 清理:释放所有临时申请的渲染纹理资源。

    cmd.ReleaseTemporaryRT(_gbuffer0Id);
    cmd.ReleaseTemporaryRT(_gbuffer1Id);
    cmd.ReleaseTemporaryRT(_depthRTId);
    cmd.ReleaseTemporaryRT(_colorRTId);
    

流程疑问:在实现中,颜色和深度信息需要先渲染到中间纹理,再复制到最终目标,而不是直接渲染到最终缓冲区。这可能是由于Unity脚本化渲染管线API的限制或内部机制决定的。

总结

本节课我们一起学习了在Unity中实现自定义延迟渲染脚本化渲染管线的完整过程。

我们首先了解了延迟渲染的基本思想:将几何信息(反照率、法线)先存储到G缓冲区,再统一进行光照计算。接着,我们分析了四个关键着色器的作用:

  • mylit opaque 负责填充G缓冲区。
  • my deferred 负责进行延迟光照计算。
  • 透明物体使用修改后的正向渲染着色器。
  • copy depth 用于深度缓冲区管理。

最后,我们剖析了C#渲染管线脚本,它像指挥家一样协调整个流程:设置目标、填充G缓冲区、执行延迟光照、处理透明物体,并将最终结果呈现到屏幕。虽然其中一些步骤(如深度复制)的具体原因可能涉及引擎底层细节,但整个架构清晰地展示了延迟渲染在Unity中的实现路径。

049:自定义可编程渲染管线中的阴影

概述

在本节课中,我们将学习如何在自定义的可编程渲染管线中实现阴影映射技术。我们将基于之前课程中构建的前向渲染管线进行扩展,添加对方向光源阴影的支持。核心内容包括理解阴影映射的基本原理、编写支持阴影的着色器,以及在C#脚本中管理阴影贴图的渲染流程。

阴影映射原理简介

上一节我们介绍了前向渲染管线的基本结构,本节中我们来看看如何为其添加阴影。

阴影映射是现代游戏引擎中处理阴影的主流技术。其基本工作原理分为两步:

  1. 从光源的视角渲染整个场景,但只记录深度信息(即每个像素距离光源的远*),生成一张深度贴图(Shadow Map)。
  2. 从摄像机视角正常渲染场景时,对于每个需要着色的像素点,计算其到光源的距离,并与深度贴图中存储的对应深度值进行比较。如果该点的距离大于深度贴图中的值,则说明该点被其他物体遮挡,处于阴影中。

对于方向光,我们使用正交投影来模拟从无限远处*行照射的光源视角。

着色器代码解析

以下是实现阴影的核心着色器代码部分,包含两个渲染通道(Pass)。

主渲染通道(计算最终颜色)

这个通道负责计算最终呈现在屏幕上的像素颜色,并加入阴影判断。

// 定义从世界空间转换到光源裁剪空间的矩阵
float4x4 _WorldToShadow;

// 声明阴影贴图及专用的深度比较采样器
TEXTURE2D_SHADOW(_ShadowMap);
SAMPLER_CMP(sampler_ShadowMap);

// 在片段着色器中计算阴影衰减因子
float3 shadowCoord = mul(_WorldToShadow, float4(i.worldPos, 1.0)).xyz;
// 对透视投影(如点光源/聚光灯)需要进行透视除法,方向光可省略
// shadowCoord.xyz /= shadowCoord.w;

// 使用深度比较采样器查询阴影贴图
float shadowAttenuation = SAMPLE_TEXTURE2D_SHADOW(_ShadowMap, sampler_ShadowMap, shadowCoord.xyz);

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/ggtech-ece4795-gpuprog/img/8b26138cc9a09f3c8b547806a43322e0_17.png)

// 将阴影衰减因子应用于最终颜色计算
float3 finalColor = (diffuseLight * _Color.rgb) * shadowAttenuation;

代码解释

  • _WorldToShadow 矩阵将顶点从世界空间变换到光源的裁剪空间。
  • SAMPLE_TEXTURE2D_SHADOW 是特殊的宏,它使用 shadowCoord.xy 查询纹理,并将查询到的深度值与 shadowCoord.z 进行比较。如果纹理中的深度值更小(即物体更*),则返回1(受光),否则返回0(阴影)。
  • 最终颜色乘以 shadowAttenuation,阴影区域颜色被衰减为黑色。

阴影投射通道(生成深度贴图)

这个通道专门用于从光源视角渲染场景,生成深度贴图。

// 使用特定的LightMode标签,标识此通道用于渲染阴影
#pragma vertex vert
#pragma fragment frag
#pragma multi_compile _ _SHADOWS_DEPTH

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/ggtech-ece4795-gpuprog/img/8b26138cc9a09f3c8b547806a43322e0_23.png)

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/ggtech-ece4795-gpuprog/img/8b26138cc9a09f3c8b547806a43322e0_24.png)

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/ggtech-ece4795-gpuprog/img/8b26138cc9a09f3c8b547806a43322e0_26.png)

// 顶点着色器:将顶点位置转换到光源裁剪空间,并应用阴影偏移(Bias)
Varyings vert(Attributes input)
{
    Varyings output;
    VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS.xyz);
    output.positionCS = mul(_WorldToShadow, float4(vertexInput.positionWS, 1.0));
    // 应用Z偏移,防止阴影痤疮(Shadow Acne)
    output.positionCS.z += _ShadowBias;
    return output;
}

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/ggtech-ece4795-gpuprog/img/8b26138cc9a09f3c8b547806a43322e0_28.png)

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/ggtech-ece4795-gpuprog/img/8b26138cc9a09f3c8b547806a43322e0_30.png)

// 片段着色器:无需输出颜色,只需保证深度值写入深度缓冲区
half4 frag(Varyings input) : SV_Target
{
    return 0;
}

代码解释

  • 此通道的 LightMode 被设置为 ShadowCaster,管线会调用它来渲染深度。
  • 顶点着色器主要进行坐标变换。添加 _ShadowBias 是一个小技巧,用于解决由于深度精度问题导致的“阴影痤疮”(表面自阴影产生的条纹)。
  • 片段着色器返回0即可,因为此通道只关心深度缓冲区的写入。

C#渲染管线脚本解析

接下来,我们分析在C#脚本中如何组织和管理阴影渲染的流程。

初始化与资源管理

脚本开始部分定义了渲染阴影所需的资源。

// 声明阴影贴图渲染纹理
private RenderTexture shadowMapTexture;
// 定义着色器属性标识符,用于高效传递数据
private static int shadowMapTextureId = Shader.PropertyToID("_ShadowMap");
private static int worldToShadowMatrixId = Shader.PropertyToID("_WorldToShadow");
private static int shadowBiasId = Shader.PropertyToID("_ShadowBias");

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/ggtech-ece4795-gpuprog/img/8b26138cc9a09f3c8b547806a43322e0_38.png)

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/ggtech-ece4795-gpuprog/img/8b26138cc9a09f3c8b547806a43322e0_39.png)

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/ggtech-ece4795-gpuprog/img/8b26138cc9a09f3c8b547806a43322e0_40.png)

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/ggtech-ece4795-gpuprog/img/8b26138cc9a09f3c8b547806a43322e0_41.png)

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/ggtech-ece4795-gpuprog/img/8b26138cc9a09f3c8b547806a43322e0_43.png)

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/ggtech-ece4795-gpuprog/img/8b26138cc9a09f3c8b547806a43322e0_45.png)

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/ggtech-ece4795-gpuprog/img/8b26138cc9a09f3c8b547806a43322e0_46.png)

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/ggtech-ece4795-gpuprog/img/8b26138cc9a09f3c8b547806a43322e0_47.png)

// 在渲染开始时创建阴影贴图
shadowMapTexture = new RenderTexture(1024, 1024, 16, RenderTextureFormat.Shadowmap);
shadowMapTexture.filterMode = FilterMode.Bilinear;
shadowMapTexture.wrapMode = TextureWrapMode.Clamp;
CommandBuffer.SetGlobalTexture(shadowMapTextureId, shadowMapTexture);

代码解释

  • 创建一张1024x1024、16位深度的 RenderTexture,格式设为 Shadowmap,专门用于存储深度信息。
  • 使用 Shader.PropertyToID 获取属性名称对应的整数ID,提升设置着色器属性的效率。
  • 通过 CommandBuffer.SetGlobalTexture 将创建好的阴影贴图传递给所有着色器。

渲染流程控制

主要的渲染函数控制着阴影贴图生成和主场景绘制的顺序。

以下是渲染一帧的关键步骤列表:

  1. 渲染阴影贴图:调用 RenderShadows 方法,将命令加入 shadowBuffer
  2. 设置主摄像机:配置主摄像机的渲染目标和清空状态。
  3. 绘制不透明物体:使用主着色器通道(包含阴影计算)绘制场景。
  4. 清理资源:在管线销毁时,手动释放 RenderTexture 占用的GPU内存。

阴影贴图渲染函数

RenderShadows 函数负责具体配置并从光源视角渲染深度。

private void RenderShadows(ScriptableRenderContext context, ref CullingResults cullingResults)
{
    // 1. 获取光源的视图和投影矩阵(此处硬编码处理第0个方向光)
    Matrix4x4 viewMatrix, projMatrix;
    ShadowSplitData splitData;
    bool success = cullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitives(
        0, 0, 1, new Vector3(0, 0, 0), 1024, 
        light.shadowNearPlane, out projMatrix, out viewMatrix, out splitData);

    // 2. 设置渲染状态,以阴影贴图为目标,仅清空深度
    CommandBuffer shadowBuffer = new CommandBuffer { name = "Shadow Buffer" };
    CoreUtils.SetRenderTarget(shadowBuffer, shadowMapTexture, 
        RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store, 
        ClearFlag.Depth, Color.clear);

    // 3. 执行命令,设置视图投影矩阵和阴影偏移量
    shadowBuffer.SetViewProjectionMatrices(viewMatrix, projMatrix);
    shadowBuffer.SetGlobalFloat(shadowBiasId, light.shadowBias);
    context.ExecuteCommandBuffer(shadowBuffer);
    shadowBuffer.Clear();

    // 4. 绘制阴影投射物体
    var shadowSettings = new DrawingSettings(shaderTagId, new SortingSettings(camera));
    var shadowFilterSettings = new FilteringSettings(RenderQueueRange.opaque);
    context.DrawRenderers(cullingResults, ref shadowSettings, ref shadowFilterSettings);

    // 5. 计算并传递世界到阴影矩阵到着色器
    Matrix4x4 scaleOffsetMatrix = Matrix4x4.identity;
    scaleOffsetMatrix.m00 = scaleOffsetMatrix.m11 = scaleOffsetMatrix.m22 = 0.5f;
    scaleOffsetMatrix.m03 = scaleOffsetMatrix.m13 = scaleOffsetMatrix.m23 = 0.5f;
    // 处理*台相关的Z反转
    if (SystemInfo.usesReversedZBuffer) 
    {
        scaleOffsetMatrix.m22 = -0.5f;
        scaleOffsetMatrix.m23 = 0.5f;
    }
    Matrix4x4 worldToShadowMatrix = scaleOffsetMatrix * (projMatrix * viewMatrix);
    CommandBuffer.SetGlobalMatrix(worldToShadowMatrixId, worldToShadowMatrix);
}

代码解释

  • ComputeDirectionalShadowMatricesAndCullingPrimitives 是Unity SRP API,用于计算从指定方向光视角渲染所需的矩阵和裁剪信息。
  • SetRenderTargetshadowMapTexture 设置为当前渲染目标,并指定加载/存储操作。DontCare 表示不加载现有内容,可提升Tile-Based GPU的性能。
  • 绘制设置 (DrawingSettings) 使用了特定的 ShaderTagId(如 ShadowCaster),确保只有标记了对应LightMode的着色器通道(即我们写的第二个Pass)会被调用。
  • 最后计算的 worldToShadowMatrix 结合了视图投影矩阵和一个缩放偏移矩阵。因为裁剪空间坐标范围是[-1, 1],而纹理采样坐标需要[0, 1],这个矩阵就是用来进行这个转换。同时,它还处理了不同图形API(如DirectX和OpenGL)在深度缓冲区Z方向上的差异。

总结

本节课中我们一起学习了在自定义可编程渲染管线中实现阴影映射的完整流程。

我们首先回顾了阴影映射的基本原理:从光源视角生成深度贴图,然后在主渲染中通过比较深度值来决定阴影。接着,我们深入分析了实现该技术所需的两个着色器通道:一个用于生成深度贴图,另一个则在计算最终颜色时采样深度贴图并应用阴影衰减。最后,我们解读了C#端的管线脚本,了解了如何管理阴影贴图资源、控制渲染顺序、计算必要的变换矩阵,并将所有数据正确地传递给GPU。

通过本课的学习,你掌握了在Unity SRP框架下为方向光添加动态阴影的核心技能,这是构建高质量实时渲染效果的重要一步。

050:将内置渲染管线着色器升级至通用渲染管线 (URP) 🚀

在本节课中,我们将学习如何将最初为Unity内置渲染管线编写的自定义HLSL着色器代码,迁移到通用渲染管线中。我们将通过一个实际的、逐步的转换过程来演示,重点关注如何解决编译错误、适配新的光照数据结构和处理不同的光照类型。


概述

我们将从一个标准的3D Unity项目开始,该项目包含一系列为内置渲染管线编写的自定义着色器。我们的目标是安装通用渲染管线包,配置项目使用URP,然后逐步修改着色器代码,使其能够在新的渲染管线中正常工作。我们将重点关注一个简单的顶点光照着色器,并解决从内置管线切换到URP时遇到的关键问题。

创建项目与安装URP

首先,我们创建一个标准的3D项目,而不是直接创建URP项目。这样做是为了模拟将现有项目升级到URP的真实场景。

接下来,我们需要通过包管理器安装通用渲染管线包。安装URP时,其依赖的核心渲染管线库也会被自动安装。

安装完成后,我们需要创建URP所需的资源。通过菜单 Rendering > Universal Render Pipeline,我们可以创建 Pipeline AssetRenderer Asset。我们将它们分别命名为 My URP AssetMy URP Asset_Renderer

然后,我们需要配置项目使用新的URP。进入 Edit > Project Settings > Graphics,在 Scriptable Render Pipeline Settings 字段中,选择我们刚刚创建的 My URP Asset。同时,为了获得正确的光照效果,我们还需要在 Player 设置中将 Color SpaceGamma 改为 Linear

完成这些步骤后,项目理论上应该切换到了URP。然而,我们发现一些简单的着色器场景仍然可以运行,而使用了复杂光照的示例场景则出现了问题。这表明我们的自定义着色器需要针对URP进行适配。

修改顶点光照着色器

我们的第一个目标是修复一个名为 VertexLit 的简单着色器。这个着色器最初引用了内置管线的核心头文件 UnityCG.cginc

在URP中,我们需要使用不同的头文件。根据Unity文档,我们应该引用 Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl

因此,我们在着色器代码中将:

#include "UnityCG.cginc"

替换为:

#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

同时,我们还需要更新着色器的标签,以符合URP的规范。我们将 Tags 块修改为:

Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalRenderPipeline" }

进行这些修改后,着色器编译出现了错误,提示找不到 _WorldSpaceLightPos0 变量。这是一个内置管线中的光照位置变量。在URP中,我们需要使用新的变量来获取光照信息。

适配URP的光照系统

通过搜索和查阅资料,我们发现URP通过一个名为 _MainLightPosition 的变量来提供主光源(通常是一个方向光)的位置/方向信息。其颜色信息则存储在 _MainLightColor 中。

因此,我们在着色器中声明并使用这些变量:

float4 _MainLightPosition;
float4 _MainLightColor;

我们将代码中所有使用 _WorldSpaceLightPos0 的地方替换为 _MainLightPosition,将 _LightColor0 替换为 _MainLightColor

修改后,着色器成功编译,但物体显示为黑色。我们发现,只有当场景中的主光源是方向光时,光照才起作用。这是因为 _MainLightPosition 在URP中默认关联的是方向光。

为了同时支持方向光和点光源,我们需要引入一个编译开关。我们使用 shader_feature 来定义一个关键字 _LIGHT_TYPE_POINT,用于在材质面板上选择光源类型。

以下是处理不同光源类型的核心逻辑代码片段:

#ifdef _LIGHT_TYPE_POINT
    // 处理点光源:从URP的附加光源数据中获取信息
    int perObjectLightIndex = 0; // 假设场景中只有一个点光源
    // ... 从 _AdditionalLightsPosition 和 _AdditionalLightsColor 数组中获取数据 ...
    float4 lightPositionWS = float4(_AdditionalLightsPosition[perObjectLightIndex].xyz, 1.0);
    float3 lightColor = _AdditionalLightsColor[perObjectLightIndex].rgb;
#else
    // 处理方向光:使用URP的主光源数据
    float4 lightPositionWS = _MainLightPosition;
    float3 lightColor = _MainLightColor.rgb;
#endif

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/ggtech-ece4795-gpuprog/img/02305ff23130c177d89eb6490e6341d1_35.png)

// 统一计算光照方向
float3 lightDir;
if (lightPositionWS.w == 0.0) {
    // 方向光:lightPositionWS.xyz 本身就是方向
    lightDir = normalize(lightPositionWS.xyz);
} else {
    // 点光源:需要从顶点位置指向光源位置
    lightDir = normalize(lightPositionWS.xyz - input.worldPos);
}

通过这种方式,我们可以在同一个着色器中,根据材质的选择来编译处理不同光源类型的代码分支。

调试与问题解决

在迁移过程中,一个常见的错误是变量名引用不一致。例如,我们可能在一个地方使用了新变量 lightPositionWS,但在另一个地方错误地保留了旧变量名 _MainLightPosition。这会导致方向光工作正常,但点光源失效。仔细检查并统一所有相关变量名是解决问题的关键。

另一个需要注意的是,URP在一次绘制调用中处理多个光源的方式与内置管线不同。内置管线使用多个Pass(前向基础Pass和多个前向附加Pass),而URP通常在单Pass中通过循环处理所有光源。对于我们的入门示例,为了保持代码简洁,我们暂时只处理一个光源(主方向光或第一个点光源)。在实际项目中,应使用URP提供的 GetAdditionalLight 等函数来正确遍历所有光源。

更新其他着色器与总结

在成功升级顶点光照着色器后,我们可以将相同的策略应用到其他着色器上,例如像素光照着色器和法线贴图着色器。核心步骤包括:

  1. 替换头文件引用。
  2. 更新着色器标签。
  3. 将内置光照变量(如 _WorldSpaceLightPos0, _LightColor0)替换为URP的对应变量(_MainLightPosition, _MainLightColor, _AdditionalLightsPosition, _AdditionalLightsColor)。
  4. 使用 shader_featuremulti_compile 来处理不同的渲染路径或光照类型。
  5. 仔细调试,确保所有变量名和逻辑路径正确无误。

本节课中我们一起学习了如何将Unity内置渲染管线的着色器迁移到通用渲染管线。我们了解了URP的基本配置,学会了替换核心头文件和光照变量,并掌握了通过编译开关让一个着色器适配不同光源类型的方法。虽然这个过程需要耐心调试,但它揭示了现代可编程渲染管线的工作方式,为编写高性能、跨*台的着色器打下了基础。在后续课程中,我们可以将此基础扩展到更复杂的着色器和高清渲染管线。

051:将内置渲染管线着色器迁移至通用渲染管线(URP)🎮

在本节课中,我们将学习如何将最初为Unity内置渲染管线编写的着色器代码,迁移到新的通用渲染管线(URP)中。我们将通过一个具体的像素光照着色器示例,逐步演示代码的修改过程,包括更新标签、包含文件、光照数据获取方式以及命名规范,最终使着色器与URP兼容。


概述

本教程基于佐治亚理工学院“GPU编程与游戏”课程中的示例代码。我们将把一个使用内置渲染管线的像素光照着色器,转换为适用于通用渲染管线的版本。转换过程涉及多个关键步骤,包括更新着色器标签、替换核心包含文件、重构光照变量以及调整纹理采样方式。

上一节我们介绍了课程背景和迁移目标,本节中我们来看看具体的代码转换步骤。

步骤一:更新基础信息与标签

首先,需要更新着色器的基本信息和渲染标签,以适配URP的单通道渲染模式。

以下是需要修改的初始部分:

// 旧标签(内置管线,根据光源类型选择不同通道)
Tags { "LightMode"="ForwardBase" } // 或 "LightMode"="ForwardAdd"
// 新标签(URP管线,统一使用一个通道)
Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" }

同时,将着色器文件重命名为包含“URP”字样,以作区分。

步骤二:替换核心包含文件

URP使用不同的HLSL包含文件来提供内置函数和变量。

以下是关键修改:

// 旧包含文件(内置管线)
#include "UnityCG.cginc"
// 新包含文件(URP)
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

这个新的头文件提供了URP着色器所需的核心功能和数据结构。

步骤三:重构光照数据获取

内置管线与URP获取光照信息的方式不同。我们需要从顶点光照着色器的URP版本中复制相关代码。

以下是需要添加的光照数据准备代码:

// 获取主光源数据(URP方式)
Light light = GetMainLight();
float3 lightColor = light.color;
float3 lightPositionWS = light.direction; // 对于方向光
// 注意:点光源的位置获取方式不同,需使用light.position

在像素着色器中,使用新的变量名(如 lightPositionWSlightColor)替换旧的 _LightColor0 等内置变量。

步骤四:修复错误与测试

完成代码粘贴和变量名替换后,着色器可能无法立即工作,控制台会显示编译错误。例如,常见的错误是未定义的标识符。

需要根据错误信息(如“line 84a”)定位并修正问题,例如确保所有 lightPositionWS 变量名拼写一致。修正后,着色器应能正确编译并显示光照效果。

上一节我们完成了基础迁移并修复了错误,本节中我们来看看如何为更复杂的着色器(如法线贴图着色器)应用相同的转换。

步骤五:升级法线贴图着色器

对于包含法线贴图的着色器,迁移流程是相同的。

  1. 更新标签和包含文件:与基础像素光照着色器步骤一致。
  2. 复制光照数据代码:将相同的URP光照获取逻辑粘贴到新着色器的相应位置。
  3. 替换变量名:将所有旧版光照变量替换为URP版本(lightPositionWS, lightColor)。
  4. 测试功能:转换后,法线贴图效果应能正常显示。可以应用更明显的法线贴图(如自定义Logo)来验证效果。

步骤六:统一命名规范

为了使教学代码与Unity官方URP着色器代码风格保持一致,建议修改一些结构体和变量的命名。

以下是命名变更示例:

// 旧命名(自定义)
struct A2V { ... }; // Application to Vertex
struct V2F { ... }; // Vertex to Fragment
sampler2D _MainTex;
// 新命名(与Unity URP Lit Shader对齐)
struct Attributes { ... };
struct Varyings { ... };
sampler2D _BaseMap;

这种一致性有助于学生未来阅读官方文档和源码。修改后,需在材质球上重新指定纹理。

步骤七:添加SRP批处理器兼容性

为了让着色器与URP的SRP批处理器兼容以提升渲染效率,需要将材质属性包装在特定的CBuffer块中。

以下是添加SRP批处理器兼容性的代码:

CBUFFER_START(UnityPerMaterial)
    float4 _BaseMap_ST; // 纹理缩放偏移属性必须放在里面
    float _Factor;
CBUFFER_END
// 纹理声明现在放在CBuffer外部
TEXTURE2D(_BaseMap);
SAMPLER(sampler_BaseMap);

在片元着色器中,使用新的宏来采样纹理:

// 旧采样方式
fixed4 col = tex2D(_MainTex, i.uv);
// 新采样方式(URP)
half4 col = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, i.uv);

完成这些更改后,着色器信息中应显示“SRP Batcher compatible”。

步骤八:最终整理与导出

  1. 场景设置:关闭天空盒和环境光,以便更清晰地观察着色器自身的光照效果。在Window -> Rendering -> Lighting的设置中,将环境光源的Source设置为Color,并将颜色调为黑色。
  2. 项目导出:使用Unity的Assets -> Export Package...功能,导出整个更新后的着色器演示项目,排除不必要的默认场景,并分享给学习者。


总结

本节课中我们一起学习了将Unity内置渲染管线着色器迁移到通用渲染管线(URP)的完整流程。我们涵盖了从更新标签、包含文件到重构光照逻辑的核心步骤,并介绍了统一命名规范以及添加SRP批处理器兼容性等高级优化技巧。关键点在于理解URP以单通道方式处理光照,并熟练使用其提供的GetMainLight()等新API来替换旧变量。通过本教程的实践,你应能掌握将传统着色器升级至现代渲染管线的基本方法。

052:GPU附加内容-C

概述

在本节课中,我们将探讨Unity内置渲染管线与可编程渲染管线(SRP)之间的差异,特别是通用渲染管线(URP)和高清渲染管线(HDRP)在着色器代码和命名约定上的不一致性。

大家好,我是Er Laantterman,佐治亚理工学院电气与计算机工程教授。这段内容录制于2022年夏季,属于本年度“视频游戏GPU编程”课程的一部分。

我坚持使用Unity的内置渲染管线,尽管Unity希望所有人都转向这些新的可编程渲染管线,即通用渲染管线(URP)或高清渲染管线(HDRP)。多年来,我一直告诉学生,今年我们将继续使用内置管线,但明年可能会转向可编程渲染管线。这些说法的前提是,Unity最终会为可编程渲染管线架构提供像样的文档,并且会提供类似于内置管线中Surface Shader技术的等效功能。Surface Shader是内置管线中一个非常有用的特性,但在任何可编程渲染管线中都不存在。

此外,他们本应恢复通用渲染管线中的自定义后处理效果。这些功能过去在URP中是可用的,但在某个时间点,Unity破坏了这一功能,并且多年来一直没有修复。

尽管Unity在向可编程渲染管线的过渡中处理不当,但我认为SRP在理论上看起来相当不错,并且我仍然希望最终值得将课程转向SRP。因此,我确实想开始深入研究SRP代码。

探索URP与HDRP的Lit着色器代码

上一节我们提到了转向SRP的期望,本节中我们来看看URP和HDRP中Lit着色器的具体代码实现。

如果你创建一个空白的URP项目,可以进入Packages并搜索Lit着色器。向下滚动,找到后双击即可查看Lit着色器代码。对于HDRP项目,你也可以执行相同的操作来获取HDRP的Lit着色器代码。

在我的屏幕上,左侧是通用渲染管线(URP)的Lit着色器代码,右侧是高清晰渲染管线(HDRP)的Lit着色器代码。这些Lit着色器旨在等效于内置管线中的标准着色器。

查看这里的代码,我预计会有一些差异。HDRP应该拥有一整套URP所没有的功能,这没问题,因为它面向更高端的硬件。但在具有共同行为的地方,你可能会期望有一些共同的约定。例如,我们有一个叫做“base color”的东西,这里也有一个叫做“base color”的东西。在URP中,对应的纹理叫做“base map”。

然而,在HDRP中,它被称为“base color map”。为什么这些名称不同?它们的基本功能是相同的。我可以想象他们在这里使用“base map”这个术语的原因,因为如果我没记错的话,这是原始内置管线中标准着色器使用的术语。但他们已经声明,这些新的可编程渲染管线无论如何都不会与原始内置管线兼容。所以,如果你借此机会重新开始,你应该彻底重新开始,采用共同的命名约定。那么,为什么不为了两者之间的一致性,也把这个叫做“base color map”呢?

是的,你确实无法将相同的代码用于两个管线,但你可能有可以共享的代码片段。为什么不通过使用共同的命名约定来让我们共享这些片段呢?

让我们再向下滚动一点。看这里,在URP中,法线贴图被称为“bump map”。

但在HDRP中,法线贴图被称为“_NormalMap”。再次强调,为什么不使用相同的名称?为什么我们要有不同的名称?

继续向下看,我们有高度贴图。同样的情况,在左侧它被称为“parallax map”,在右侧它被称为“height map”。你为什么要叫它们不同的名字?

那么,自发光纹理呢?哦,情况相同。在URP中我们有“emission color”,但在右侧HDRP中我们有“emissive color”。在左侧的URP中,我们有“emission map”。在右侧,我们有“emissive color map”。为什么我们使用两个相似但略有不同的词?

这真的感觉像是Unity创建了一个URP团队,又创建了一个HDRP团队(实际上,URP早年被称为LWRP,即轻量级渲染管线)。总之,它创建了两个团队来开发这两个不同的管线,而且看起来他们不允许团队之间相互交流。Unity应该有人为此被解雇。管理如此混乱令人震惊。

总结

本节课中我们一起学习了Unity内置渲染管线与可编程渲染管线(SRP)的现状,并深入比较了通用渲染管线(URP)和高清渲染管线(HDRP)中Lit着色器代码的差异。我们发现,尽管这两个管线服务于相似的目的,但它们在关键术语和纹理的命名上存在显著的不一致,例如base mapbase color mapbump map_NormalMapparallax mapheight map,以及emissionemissive。这种不一致性反映了开发过程中可能缺乏协调,为开发者在代码复用和理解上造成了不必要的障碍。

posted @ 2026-03-26 13:20  布客飞龙V  阅读(8)  评论(0)    收藏  举报