引子

说在前面,本人并非wmc,同样也是音游苦手。本次只是为了探索Steamdeck的潜力,因此对于一些关于Maimai的概念解释和术语可能不准确,请见谅。

目前的效果是,能在Steamdeck的桌面模式下进行竖屏的游玩,只显示P1,解锁全曲目和难度,支持Freeplay但只能以Guest模式进入游戏,同时MelonLoader无法加载,因此没办法加载Mod如AquaMai。

另外请注意,这只是一次探索,并非最优雅的解决方式。

在开始前请速览下一节列出的资料。

资料速览

建议首先遵循SDEZ配置指南1完成相关资源的下载和配置,出现问题后再从EmulineSDEZ配置指南2的评论区中寻找解决方案。

P.S. Emuline目前已经关闭了注册渠道。

部署指南

Steamdeck的初步配置

  1. 进入Desktop Mode,打开应用商店,安装ProtonUp-Qt和ProtonTricks。

  2. 打开ProtonUp-Qt安装GE-Proton,这是Proton的一个开源的增强版本,添加了若干的Patch。另外补充一下,Proton是在Wine的基础上改动的,因此如果要在Linux跑游戏的话还是首选Proton,不行了可以再尝试Wine。

    P.S. 在GE-Proton的官方Repo中看到说在Proton是会跑在容器中的,如果要在Steam之外运行程序的话只能借助某个特定Launcher才能做到。不过本人并没有使用该Launcher,我猜是该容器只是为Proton配置好了对应的环境变量和文件系统,当然可以借助其他的方式完成。

下载和配置SDEZ

  1. 在电脑上下载1.55或1.56版本的SDEZ,如果是在evilleaker下载的话,请按照SDEZ配置指南1解压和配置Segatools,在Emuline提供的Pixeldrain下载的则是已经配置好的版本。
  2. 配置好后可以先在PC上试试,如果OK的话说明配置大体没问题,这里不用虚拟Aime卡并注册AquaDX和MuNet也行,因为目前来看Deck上也用不了。
  3. 将配置好的传输到Deck,由于文件不小,建议的传输方式为使用Wrapinator,如果网络的好的话用SMB或SFTP也行。

SDEZ在Steamdeck上的配置

注意,下面的所有操作都在桌面模式中进行!

  1. 将Package/Sinmai.exe右键添加到Non Steam Game,打开Steam,首先将游戏执行文件的路径改为Package文件夹中的Start.bat或Launch.bat,注意路径中不能有中文;然后设置兼容性,改为GE-Proton。

  2. 此时试着启动游戏,Sinmai会闪退,只有bat和inject的命令行窗口出现,在Steam中停止游戏即可。

  3. 打开刚才安装好的ProtonTricks,进入到SDEZ默认的Wine容器,选择第一项即安装Windows DLL或组件,安装下面的功能:

    • dotnet 3.5
    • dotnet 4.0
    • dotnet 4.8
    • dxvk
    • ucrtbase2019
    • vcrun2012
    • vcrun2019
  4. 装好后还是使用ProtonTrick对SDEZ的容器运行Wine配置程序,选择驱动器一栏,删除掉根目录到Z盘的映射,添加容器根目录到D盘的映射。

  5. 修改Package/segatools.ini,dns一栏中的default填aquadx.hydev.org或play.mumur.net都行,keychip请留空,否则游戏会卡死。

  6. 修改config_client.json,lan_install/server和net_delivery/enable都修改为true,否则会卡配信サーバーcheck过不去。

  7. 修改config_common.json,找到credit项修改为如下所示的内容,开启freeplay:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    "credit":
    {
    "enable": true,
    "max_credit": 24,
    "config":
    {
    "coin_chute_type_common": true,
    "service_type_common": true,
    "freeplay": true,
    "coin_chute_multiplier": [ 1, 1 ],
    "coin_to_credit": 1,
    "bonus_adder": 0,
    "game_cost": [ 1,2,2 ]
    }
    }
  8. 修改Start.bat(或Launch.bat),将Sinmai的启动参数改为:

    -screen-fullscreen 1 -screen-width 1600 -screen-height 1280

  9. 因为没有找到让窗口变成portrait的方法,最抽象的一步要来了:打开系统设置,找到显示和监视器一栏,将屏幕的方向调整为第一个也就是800x1280。由于这一步的存在,之后进行游玩时也只能在桌面模式下玩。如果有人能找到旋转屏幕的方法就好了,实测config_common.json中video一栏的配置不会生效。

  10. 打开Steam,完成最后的按键映射,新建一个控制器布局,将ABXYD-Pad一一映射到键盘的QWEADZXC八键上,分别对应洗衣机(bushi)的每个按钮,同时建议把背面握持键也做一下映射,比如R5映射到Esc,这样可以方便退出游戏。

大功告成

可以开玩了!

注意事项

  • 游玩前请进入到桌面模式,并调整为竖屏。
  • 长按右侧手柄的三道杠按钮可以切换按键绑定,在游戏中需要使用我们之前配置好的布局。

简介

暴雪在2017年的GDC上做了关于守望先锋中ECS系统设计的分享,那个时候的暴雪啊,反观现在。B站有大佬的译制版本,链接在此

ECS系统

ECS的核心目的是将行为与状态严格解耦。为了做到这一点,ECS系统将架构划分为了实体 Entity、组件 Component还有系统 System,见下图:

  • Component只包含状态以及一些用于读取状态的辅助函数,辅助函数无行为且无副作用,Component的生命周期管理会使用多态来实现,即重写构造和析构函数。

  • Entity基本就是一个Components的集合,另外有唯一的Entity ID作为标识符。

  • System负责纯粹的行为,可以读取并更新Components,System的更新逻辑大致如下,EntityAdmin发起System的更新,每个System会重写自己的Update。System在Update时会遍历行为所涉及到的所有Component,并利用Component中保存的状态完成行为,必要时还可以使用访问同一个Entity上作为Sibling出现的其他类型Component。

行为与状态的解耦

上面的设计初看已经能解决行为与状态的耦合了,但是必须还要考虑System中的一些细节。比如系统A与系统B存在交互,系统B需要访问系统A并且保持一些状态,同时B的行为还会在A中产生副作用。暴雪最初的方法是创建一个全局变量用于维护这些一次性状态,这样做有若干的问题:

  • 增加编译开销,改全局变量会使所涉及到的任何System都重新编译
  • System间产生了耦合,无法确定Side Effect是该在A还是在B中

解决方法就是将这些一次性状态和逻辑移动到每个EntityAdmin中只有一个的Singleton Component中。说它时Component也不尽然,它既有行为又有状态,同时也不归属到Entity而是由Admin直接管理,不过考虑到有状态这一点叫Component也不是不行。

使用Singleton Component后,B就可以执行其中所实现的辅助行为函数了,这样就防止了A和B间的行为互相渗透。暴雪也对这些Utility Function提出了具体的要求,如下图所示:

副作用

对于Singleton Component的辅助行为函数,我们当然是希望其中的副作用越少越好,并且副作用所影响的东西也是越少越好。对于这种共享的行为来说,当然副作用是难以避免的,同时与共享该行为的System数量有关。一旦Side Effect的数量增多,实际上也就产生了很严重的耦合,这里我猜可能的例子就是Side Effect有先后顺序的情况。

暴雪因此提议将所有的Side Effect延迟执行,一个系统会将要发生的Minor Effect提交到一个对应类型的Pending列表中,并在Entity Admin的Update步骤最后添加对应类型Side Effect的ResolveSystem来将所有待执行的Minor Effect合成为每帧一个的Major Effect。

Tim举了个形象的例子,比如猎空(应该说闪光?)和法鸡(还是讲法老之鹰?)都在设计同一个位置,猎空的双枪会带来大量的弹痕贴花,法鸡的火箭炮则会带来一个爆炸贴花,理论上如果猎空的子弹先命中那么法鸡的爆炸贴花应该是要覆盖弹痕贴花的。将每个贴花视作一个Minor Effect,完全可以延迟到ResolveSystem中再应用LOD和覆盖等等规则,从而大幅降低开销。

初步总结

通过ECS系统的约束,可以大幅提高工程的可维护性、解耦度以及可读性。

分享的后半部分是关于Netcode与ESC的结合的,这个先挖个坑。

这篇文章是对于GAMES 104游戏引擎物理系统一节中GJK算法的学习总结。

Minkowski Sum 闵可夫斯基和

首先明确,一个闭合形状可以通过边界点集来定义。

对于两个形状 $A$ 和 $B$ 来说,他们的闵可夫斯基和即为两个集合中的点两两相加形成的点集,记作 $A+B$ 。一个直观的理解就是将其中的一个形状 $A$ 所在坐标系的原点平移到另一个形状 $B$ 的边界上,并沿着 $B$ 的边界扫过一周,这样得到的新边界即为Minkowski Sum,如下图所示:

intuition

闵可夫斯基和有一个重要的性质,即闵可夫斯基和的凸包等于两个形状各自凸包的闵可夫斯基和。这样的话,如果两个形状本就是凸的话,Minkowski Sum的凸包就会恰好包含两个形状内无穷多个点两两相加的结果。

Minkowski Difference 闵可夫斯基差

如果两个形状相交的话,那么一定会存在至少一对 $A$ 和 $B$ 中的点 $p_A$ 和 $p_B$ ,使得 $p_A-p_B=0$ 。尝试将这个观察与上面所述的Minkowski Sum联系起来,定义闵可夫斯基差为 $A-B=A+(-B)$ , $(-B)$ 即 $B$ 中的点绕原点翻转后得到的点集。

P.S. 按照Wiki上的说法,这其实不是Minkowski Difference的正统定义,而是Hermann Minkowski,不过在碰撞检测中只会用到上面的定义,这里就不再深究区别了。

根据Minkowski Sum的性质, $A-B$ 的中含中包含了所有点对的差值。也就是说如果这个凸包能够包围原点的话,这两个形状就是相交的。这样我们就把一个本来要做无穷次判断才能得到答案的问题成功转化为了一个有界的问题。乍一看,单纯按照这种方法来测试的话复杂度仍是平方级别的。可以证明得到的凸包至多只有 $|A|+|B|$ 个顶点,[3]中给出了在 $O(n\log n)$ 内直接构造Minkowski Sum凸包的算法,大致的思路就是首先按照逆时针的顺序对于两个点集中的顶点排序,再使用双指针比较Polar Angle来保证不重不漏,具体可以参考原文章,这里我们不会用到这种算法。

GJK算法

当两个形状的点数很多时,由于我们的目标只是判断包不包含原点,相应闵可夫斯基差的凸包可能会有很多冗余。比如在2D的情况下,我们实际上只需要判断闵可夫斯基差中最接近原点的三个点与原点的关系即可。这就是GJK算法的Motivation。

单纯形定义为 n 维空间中最简单的几何体,比如在2D空间中就是三角形,3D空间中就是四面体。GJK 是一个迭代算法。它的目标是构建一个位于 $A-B$ 内部的单纯形,并检查这个单纯形是否包含原点。

GJK算法引入了支撑函数的概念,用于感知形状的边界。支撑函数的定义如下:给定一个方向向量 $d$,形状 $C$ 的支撑函数返回在 $C$ 中沿着方向 $d$ 最远的点。支撑函数的一个关键特性是:闵可夫斯基差的支撑点可以通过原形状 $A$ 和 $B$ 的支撑点来计算,即support(A - B, d) = support(A, d) - support(B, -d),这点也很直观。

GJK的伪代码如下。NearestSimplex(s)会接收一个若干维单纯形 $s$ ,返回 $s$ 上离原点最近的子部分(比如对于三角形可能返回的是一个三角形或是低一维即一维单纯形的线段)、从简化后的单纯形指向原点最近的方向向量(比如简化后的单纯形是线段时返回线段的垂足到原点的方向向量)以及判断原点是否在单纯形内的判断结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
function GJK_intersection(shape p, shape q, vector initial_axis):
vector A = Support(p, initial_axis) − Support(q, −initial_axis)
simplex s = {A}
vector D = −A

loop:
A = Support(p, D) − Support(q, −D)
if dot(A, D) < 0:
reject
s = s ∪ {A}
s, D, contains_origin := NearestSimplex(s)
if contains_origin:
accept

对于GJK算法的说明如下:

  • dot(A, D) < 0说明支撑点在接近原点的方向向量的投影比原点要近,由于支撑点已经是最接近原点的点了,因此这个代表闵可夫斯基差的单纯形就绝不可能包含原点了,可以直接返回reject。
  • NearestSimplex(s)内对于单纯形的处理如下,以输入为三角形 $ABC$ 为例,步骤如下:
    • 检查原点 $O$ 是否在 $AB$ 的内侧,可通过计算 (B - A) × (O - A) 的符号来判断。如果 $ABC$ 逆时针排列则叉积的结果小于$0$则在外侧,否则为内侧。
    • 同样地,检查边 $BC$ 和 $CA$。
    • 若是都在内侧,直接返回。
    • 否则原点一旦在任意一条边的外侧,则将单纯形简化为这条边对应的线段(即更靠近原点的子部分),同时返回从该线段指向原点的垂直方向。

总的来说,GJK通过迭代维护了闵可夫斯基差的一个最接近原点的子集,利用这个子集作为闵可夫斯基差的代理来判断是否包含原点,从而大大提高了算法的效率。

具体的实现先挖个坑 ;)

EPA算法 (Expanding Polytope Algorithm)

EPA算法算是GJK的扩展,当检测到碰撞时,EPA可以算出来穿透方向和穿透深度,当我们需要把两个物体分离时会很有用。这部分也暂时留个坑,可以先参考[4]。

参考

  1. GJK算法的Wiki
  2. GJK算法的可视化展示
  3. Minkowski sum of convex polygons
  4. EPA (Expanding Polytope Algorithm)

前言

近期去了海南旅游,有幸观赏到了美丽的日落,配上洁净的沙滩和硕果累累的椰树可以称得上是种享受。现在想想,这种美丽的晚霞也非首次目睹,然而即便是欣赏了上千次,每当通过那绚烂的大气层看向那浩瀚无垠的银河时仍会感到头晕目眩。

此外,在本人玩过的游戏中,欧卡2中的大气和云也是令人印象深刻,比如下面这张(忘了是在哪里截的了,好像是在荷兰?)。不过这个云感觉不是用噪声做的,太逼真了,层次也分明。之前特意观察过,云似乎是有多张实拍的贴图,随着时间做简单的线性插值的。

Euro Truck Simulator2

当然巫师3的大气也很美,下面这张图好像是在陶森特截的。一个题外话,GAMES104也拿类似的场景举过例子,最终的画面表现中Bloom功不可没。不过感觉这张截图中的Fog似乎用力过猛了。

The Witcher 3

既然无论是湛蓝的晴天还是翠绿兼血红的晚霞都能让我们浮想联翩,不如手动去创造一个出来。

参与介质和体渲染

显而易见地是,大气是一种参与介质,光会与参与介质发生以下的几种交互:

自发光

粒子吸收能量后释放光能就有了发光现象,对于大气可以先不考虑这种交互的影响。

散射

散射分为两种,首先是外散射,表现为光线被散射后脱离视线方向。然后是内散射,对于某个粒子来说,周围粒子发生外散射后一部分光线偏向了该粒子,为它带来了额外的能量。也就是说,其实只有一种散射即外散射,但是对于它发生一次或是多次的情况需要分别考虑。这里就把单次散射和多次散射建立了联系,后面会看到具体是怎么回事。

定义散射系数为$\sigma_s$,基于大气的密度在高度上的分布是不均匀的,以及粒子对不同波长的光散射率不一样这两个观察,可以知道$\sigma_s$是关于波长$\lambda$和高度$h$的函数。为了方便后续的讨论,我们希望能够将散射系数拆分为为两个函数乘积的形式,这两个函数分别以$\lambda$和$h$作为参数。选择某一高度作为基准平面(海平面),计算此时的散射系数为$\sigma(\lambda,0)$,并使用$\rho(h)$来表示介质密度随高度的分布,散射系数可表示为

$$\sigma_s(\lambda, h)=\sigma(\lambda,0)\rho(h)$$

吸收

介质的吸收系数用$\sigma_a$来表示,[2]中提到了它是一个概率密度函数,因此取值范围不是0到1。在一段极短距离$dt$上(其实应该用导数符号的,偷个懒),辐照度的吸收量即

$$dL_o=-\sigma_a L_i dt$$

解微分方程即得到某段距离$d$上的能量保留率,在计算时我们会使用分段求和的方式来逼近这个积分

$$e^{-\int_0^d\sigma_a(p+\omega t, \omega) dt}$$

同样的,吸收系数也是关于波长$\lambda$和高度$h$的函数,我们也会使用它的分解形式来计算。

透射率

外散射也可以理解为一种衰减,不妨考虑一条光线沿直线传播,将散射系数$\sigma_s$加上吸收系数$\sigma_a$,再定义湮灭系数$\sigma_t=\sigma_a+\sigma_s$。仿照计算能量保留率的思路,可以算出来光线透过某个介质后的能量还剩下多少,称之为透射率Transmittance:

$$T=e^{-\int_0^d\sigma_t(p+\omega t, \omega) dt}$$

由于指数函数,很容易可以看出某一段路径的Transmittance等价于多段子路径Transmittance的乘积。同时把指数上的积分单独拎出来看的话,它也等价于所有子路径上积分值的和。不妨为这个积分(不考虑符号)起个名字,叫光学深度$\tau$,数学上即为对应的积分:

$$\tau=\int_0^d\sigma_t(p+\omega t, \omega) d$$

只要能算出$\tau$,透射率自然就能拿到了。不过在实现大气散射的过程中,只需要知道Transmittance的计算方法就行了,我们不会去单独计算和存储光学深度。

相位函数

我们已经定义了散射系数,它的意义是在某一点上能量被散射的概率,那被散射的能量在不同的方向上是如何分布的呢?这就要请出相位函数了,它是一个在球面上积分为1的函数,描述了某个方向上逃逸的散射光能量的百分比。为了简化问题,假设相位函数是各向同性的,定义为$phase(\theta)$,其中$\theta$即为外散射方向与入射方向的夹角。

散射函数

将散射系数与相位函数相乘就得到了我们所需要的散射函数

$$S=\sigma_s \cdot phase(\theta)$$

常见的散射方式

大气中常见的两种散射为瑞利散射和米氏散射,他们对应了不同的基准散射系数$\sigma(\lambda,0)$、相位函数$phase(\theta)$和密度分布函数$\rho(h)$。

瑞利(Rayleigh)散射

瑞利散射发生于粒子远小于波长的情况下,它的方向性较弱,但是散射率随波长的变化较大。瑞利散射对于蓝光的散射率很大,因此有了蓝天;它对红光的散射较弱,在傍晚由于蓝光由于传播距离增大和多次散射而几乎衰减殆尽,而红光则能够顺利地到达地面,因此傍晚的天空呈玫瑰红色。瑞利散射和吸收不会同时发生。瑞利散射的各种属性参考本节的表格。

米氏(Mie)散射

当大气中粒子的直径与辐射的波长相当时则发生米氏散射。它有很强的方向性,不过对于不同波长的光基本一视同仁。同时,发生米氏散射的粒子同时也会发生吸收,且散射和吸收共用一个密度随高度的分布函数。米氏散射的各种属性也请参考本节的表格。

光与介质的其他交互方式

为了实现更真实的大气,我们还需要考虑臭氧层,它的特点是不发生散射只发生吸收。

参数速查表

通过上面的描述,可以总结出我们需要的所有参数分别是:基准平面处的散射系数$\sigma_s(\lambda,0)$、密度随高度的分布$\rho(h)$、
基准平面处的吸收系数$\sigma_a(\lambda,0)$以及相位函数$phase(\theta)$。两种散射的高度函数都是以指数衰减的形式定义的,使用标高$H$来进行归一化,而臭氧层的密度由于不是随高度单调变化的,所以它的$\rho(h)$会有所不同。之后会使用上标来区分这几种交互方式。

对于米氏散射而言,它的相位函数带有额外的各向异性参数$g$,用于调节散射波瓣的形状。

散射系数和吸收系数在不同的参考资料中有细微的差别,下面的具体值与参考资料[1]相同。

Rayleigh Mie Ozone
$\sigma_s(\lambda,0)$ $(5.802,13.558,33.1) \times 1e^{-6}$ $(3.996,3.996,3.996) \times 1e^{-6}$
$\sigma_a(\lambda,0)$ $(4.40,4.40,4.40) \times 1e^{-6}$ $(0.650,1.881,0.085) \times 1e^{-6}$
$phase(\theta)$ $\frac{3}{16\pi}(1+\cos^2\theta)$ $\frac{3}{8\pi}\frac{1-g^2}{2+g^2}\frac{1+\cos^2\theta}{(1+g^2-2g\cos \theta)^{3/2}}$
$\rho(h)$ $e^{-h/H}, H=8500m$ $e^{-h/H}, H=1200m$ $max(0,1-\frac{|h-c|}{w}),c(enter)=25km,w(idth)=15km$

通用的相位函数

查阅资料发现,其实还有一些更通用的相位函数,比如HG(Henyey-Greenstein)和Draine相位函数。米氏散射的相位函数就是Draine(见下式)相位函数在$\alpha=1$时的特例:

Draine Phase Function

单次散射模型

有了上面对于某种粒子的散射与吸收的模型,我们来着手考虑光从太阳到相机的这段路途上发生了什么。在不考虑自发光的假设下,只需要累加视线上所有粒子的内散射乘上该粒子到相机中间的透射率即可得到来自大气的辐照度,表达为数学式如下:

$$L_i(c,v)=\int_{t=0}^{||p-c||}T(c,c+vt)L_{in-scattering}(c+vt,-v)\sigma_s dt$$

上式中的$L_{in-scattering}(c+vt,-v)$包含了某个粒子的所有内散射,即周围粒子传递给该粒子的能量乘以相位函数的总和。它实际上是一个球面上的积分,显然很不好算,我们不妨来考虑一个简化的模型:对于积分路径上的粒子,我们只考虑有多少阳光直直地穿过大气并到达了这个粒子,它散射到视线方向上的光又有多少成功透过大气到达了相机。也就是说模型统计了如下路径的贡献:太阳$\rarr$透射$\rarr$粒子$\rarr$散射$\rarr$透射$\rarr$相机,称之为单次散射模型。同样的,我们只需要在视线上做如下的积分即可,下面的示意图来自[1]:

Single Scattering

也就是说,我们需要计算的量为从太阳到某点$p$的透射率$T1$,点$p$的散射函数$S$以及点$p$到相机的透射率$T2$。由于要使用分段求和去逼近积分,所以肯定需要遍历路径上的每一个点,中间可以顺手把$T2$和$S$算出来,但是$T1$的话会额外增加一层循环,对于实时渲染来说难以接受平方的复杂度。所以考虑能否将太阳到某点$p$的透射率预计算出来。

预计算Transmittance

首先一个最简单的观察,在相同的高度上,透射率是各向同性的,也就是说天顶角相同时的透射率也相等。根据这个观察就只有两个参数要在意了,即离地心的距离$r$(减去半径就是高度)和天顶角的余弦$\mu=\cos \theta$,可以制成一张二维的LUT供查询。这样就需要将$r$和$\mu$映射到UV,根据[1],使用归一化地平线的切线长度作为$u$,并用归一化后的视线上下扫射能够所取到的距离作为$v$,能够提高查找表的利用效率并避免在极点处产生瑕疵。

实现UV的映射后,只需要一个全屏的Pass就行了,计算出来的结果如图:

Precomputed Transmittance

然后我们就可以利用这张LUT,在自定义天空盒材质中采样了,最终的渲染结果如下。

single_scattering_result_1

single_scattering_result_2

在实验中发现使用距离来归一化天顶角还是会在头顶上产生畸变,如下图所示,之后试着用一下[5]中提到的映射方式:

distortion

多次散射模型

只考虑单次散射大多数情况下是足够的,不过当太阳高度角很小甚至为负时就会显得有些不足了,特别是头顶上会显得死黑,我们来尝试处理一下多次散射。将光线在大气中发生散射的次数称为大气散射的阶数。

定义$L_n(p,\omega)$为在经历了$n$阶散射后从$p$沿着$\omega$方向看向天空的光照,也就是我们最终要求的量,$G_n(p,v)$为考虑$n$阶散射的情况下$p$点散射到$v$方向上的能量和,注意在下面的定义中$G$项的积分内部并没有乘上散射系数,公式截图来自[1]:

n_order_scattering_formula

将$G$的定义带入到$L$中可以看出这是一个三重积分,计算复杂度很高。不过球面上的采样数并不需要太高,这个性质可以利用一下。传统方法中,$n$阶散射由迭代更新得出,输入$L_{n-1}$,根据四个参数$r,\mu,\mu_{s},\nu$,计算一次二次散射,即可得到$L_n$并制成一张4D的LUT,如下图所示:

old_multi_scattering

Epic基于对于Path Tracing Reference的观察,提出了一种简化的计算方式,基于下面的两个假设,分别是:

  1. 大于等于2阶的散射中的相位函数趋于各向同性
  2. 计算大于2阶的散射时,认为某点周围任意一点的$G_n$与其相同

第一点意味着,在相位函数变为各项同性时,可以把4个参数中的$\mu$和$\mu_{s}$这两个角度干掉,将LUT降低到2维。

新方法的主要亮点在于第二点,[1]和[4]中都给出了简要的推导,这里复制了[4]中的推导过程,首先再次给出$G$和$L$的定义:

multi_scattering_derivation_1

根据假设2,在计算大于2阶的散射时,$G$项对于路径上的点来说都是常数,可以从积分里提出来

multi_scattering_derivation_2

定义$L_f$和$f_{ms}$为,$f_{ms}$为传输函数

multi_scattering_derivation_7

可以注意到刚才的式子中的积分部分其实就是$L_f$,将定义带入到式子中可以得到相邻阶$G_n$之间的一个比例关系

multi_scattering_derivation_4

这样,如果能算出$f_{ms}$和$G_2$的话,就可以直接利用等比数列求和算出来无穷阶的$G$了

multi_scattering_derivation_5

$f_{ms}$和$G_2$都是三重积分,原论文[5]中在球面上均匀取了64个采样方向,每个方向步进20步,最终的结果保存为一张32*32的二维LUT,UV代表的分别是归一化高度和太阳天顶角

new_multi_scattering

具体实现中,使用Fibonacci采样预先生成64个方向,做Ray Marching时计算Single Scattering并积累到$G_2$上并利用之前的Transmittance LUT顺便计算$f_{ms}$,渲染Sky View LUT时累加多重散射项即可。加上多重散射后的对比如下,最明显的不同就是天空会更蓝一点

new_multi_scattering_result

Atmospheric Fog

远景的物体也会收到大气散射的影响,我们实际看到的颜色应该为(远处物体的反射光×远处物体到相机的透射率+远处物体到相机对应视线路径上的大气散射光)。这样的话需要对于每一个着色点都需要做Ray Marching来计算透射率和散射光,考虑如何做成LUT提高计算效率。

方法就是将视锥按照深度分层,每层的分辨率都很小比如32x32,每层都单独计算Scattering和Transmittance,后处理时对这个LUT做三线性插值并与Color Buffer混合即可。这种方法的不足就是没有体积效果。

在项目实现中,会从最近的分层开始逐渐向远处计算,这样就能复用之前累加的光学深度了。将视锥按照线性深度均匀地划分为16层,每层最多步进4步,相当于32*32条光线在带有距离限制的情况下步进了64步。

[1]中提到可以利用光学深度的性质采样两次Transmittance LUT来查询一段路径上的Transmittance,窃以为有两点不足,一是多了两次采样,二是Transmittance LUT本身对应的光线覆盖距离区间很大且精度不高,而View Frustum一般不会有那么大,所以不如在步进中重新算一遍来的实在。

下面是加上Atmospheric Fog的效果,远处的山多了朦胧的感觉

showcase

总结

最后附上项目地址:Github

参考资料

  1. 实时大气散射渲染实战
  2. Physically Based Rendering:体积散射
  3. Supplemental: An Approximate Mie Scattering Function for Fog and Cloud Rendering
  4. UE4新版大气实时渲染-论文导读
  5. A Scalable and Production Ready Sky and Atmosphere Rendering Technique
  6. Physically Based Sky, Atmosphere & Cloud Rendering

本文为GAMES 202 PBR Material的笔记

PBR和PBR材质

所谓PBR,指的是渲染过程中的一切都是基于物理的,包括材质、光源、相机和光照传输,不过在实践中一般单指材质。

实时渲染中的PBR Materials

表面的材质主要有两种,Microfacet模型和Disney Principled BRDFs。不过按照课件所说,这两种其实并非是基于物理的,它们仍然遵循着RTR领域的优良传统:hack和近似。

体积渲染的话,关注点更多是在如何快速近似单次或多次散射。

Microfacet(微平面)模型

Microfacet模型将表面近似成若干微小的镜面,并通过控制微平面的法线朝向来模拟不同的材质。回顾一下Microfacet BRDF的公式:

$$ f(\mathbf{i}, \mathbf{o}) = \frac{\mathbf{F}(\mathbf{i}, \mathbf{h})\mathbf{G}(\mathbf{i}, \mathbf{o}, \mathbf{h})\mathbf{D}(\mathbf{h})}{4(\mathbf{n}, \mathbf{i})(\mathbf{n}, \mathbf{o})} $$

其中$\mathbf{F}$项是菲涅尔项,可以让材质展现菲涅尔效应,$\mathbf{G}$项是阴影-遮挡项(阴影和遮挡其实是两码事,下文会解释),$\mathbf{G}$是法线分布。

F

大多数材质在观察视角接近掠射时反射会增强,这就是菲涅尔效应。对于绝缘体来说,这种效应尤其明显,而导体(金属)的菲涅尔效应则没有那么明显,因为无论观察角度如何反射率都很接近$1$。

菲涅尔项的计算非常复杂,不仅要考虑光的极化(偏振),对于金属还会涉及复数域上的计算,因此RTR领域中必须找到快速近似的方法。常用的近似是Schilick’s approximation,它不考虑极化。Schlick’s appoximatio的公式如下,其中$n_1$和$n_2$分别是入射介质和出射介质的折射率:

$$ R(\theta)=R_0+(1-R_0)(1-\cos \theta)^5, R_0=(\frac{n_1-n_2}{n_1+n_2})^2 $$

D

描述法线的分布,分布越集中则高光更明显,分布越分散则越接近diffuse材质。常用的模型有Beckmann和GGX。

Beckmann NDF

Beckmann NDF在形式上类似高斯分布,都是指数族的。各项同性Beckmann分布的公式如下:

其中$\alpha$代表粗糙度,$\theta_h$代表半程向量与法线的夹角。老师也解释了为何其中会有$\tan \theta$项,因为Beckmann是定义在Slope Space(就是垂直于半径的切平面)上的,所以原来的$x$值就被替换为了$\tan \theta$(见下图)。

GGX(or Trowbridge-Reitz)

与Beckmann相比峰度更小,拥有更长的尾部,因而材质会展现出更平缓的高光过渡,公式如下($m$就是半程向量):

GGX还有一个通用的形式GTR(Generalized Trowbridge-Reitz),它可以通过额外的参数$\gamma$进一步控制long-tail的程度,下面是GTR的可视化:

Unity 2022对于GGX的实现如下:

1
2
3
4
5
6
7
inline float GGXTerm (float NdotH, float roughness)
{
float a2 = roughness * roughness;
float d = (NdotH * a2 - NdotH) * NdotH + 1.0f; // 2 mad
return UNITY_INV_PI * a2 / (d * d + 1e-7f); // This function is not intended to be running on Mobile,
// therefore epsilon is smaller than what can be represented by half
}

G

G项为所谓的Shadowing-masking term,用于计算微平面间的自遮挡现象。Shadowing是指入射光被微平面所遮挡,而masking是指出射光被微平面所遮挡,分别对应下面左右两张图:

同时也可以直接从BRDF公式的角度来说明G项存在的必要性,由于归一化常数$(\mathbf{n}, \mathbf{i})$的存在,分母在观察角度近乎垂直时会接近$0$。此时不考虑自遮挡的话,边缘会异常明亮。

Smith Shadowing-masking term

Smith是常用的阴影遮挡项,它假设shadowing和masking是独立的两项,公式如下:

Unity 2022中对应的实现如下:

1
2
3
4
5
6
7
inline half SmithVisibilityTerm (half NdotL, half NdotV, half k)
{
half gL = NdotL * (1-k) + k;
half gV = NdotV * (1-k) + k;
return 1.0 / (gL * gV + 1e-5f); // This function is not intended to be running on Mobile,
// therefore epsilon is smaller than can be represented by half
}

Kulla-Conty Approximation

上文中的Microfacet BRDF存在一些问题,考虑下面白炉测试(white furnace test)的结果,菲涅尔项固定为$1$:

上图是渲染的效果,下图是cosine-weighted BRDF在半球上的积分,从下图中可以很明显地看到有能量损失。消失的能量去哪里了呢,由于菲涅尔项为$1$,而不同法线的分布又不会造成能量的损耗,说明问题只有可能出在$\mathbf{G}$项上。此前我们假设了那些被遮挡的光线被吸收了,然而这与微平面是镜面的假设是相悖的,被遮挡的光线应该在多次弹射后重新射出。只有额外考虑经历Multiple Bounces的光线后,BRDF才是守恒的。

Kulla-Conty即采用了这个思路去实现能量守恒,首先计算反射率:

Kulla-Conty近似希望使用一个额外的BRDF来补全多次弹射的能量,这个额外的BRDF的cosine-weighted积分为$1-E(\mu_o)$。又因为BRDF需要满足reciprocity,Kulla-Conty干脆就假设额外的BRDF满足形式$c(1-E(\mu_i))(1-E(\mu_o))$,这样需要计算归一化常数$c$就可以了。经过一番计算后,可以得到BRDF的公式:

不过$E(\mu)$和$E_{avg}$都是积分,需要预计算才能用在实时渲染中。$E(\mu)$可以制成一张关于roughness和$\mu$的2D纹理,而$E_{avg}$由于对于$\mu$进行了积分,只需要一张1D纹理即可存储。

Kulla-Conty Approximation with Color

如果BRDF带有颜色信息呢?这时我们就不能简单地将菲涅尔项固定为$1$了,因为颜色信息就在$\mathbf{F}$中。颜色就是对光线的吸收,也就是能量损失,所以思路就是再去计算一个衰减系数乘到$f_{ms}$前面。首先定义平均菲涅尔项为:

这时候经历了$k$次弹射的能量为:

$$ F_{avg}^k(1-E_{avg})^k\cdot F_{avg}E_{avg} $$

将各次弹射的能量累加起来进行无穷级数求和后得到:

$$ \frac{F_{avg}E_{avg}}{1-F_{avg}(1-E_{avg})} $$

将这个系数乘到额外的BRDF即可。

结果

使用Kulla-Conty近似后,明显明亮了许多:


GAMES202 HW3的完成记录~

总览

在延迟渲染管线下,为一个光源为方向光,材质为漫反射 (Diffuse) 的场景实现屏幕空间下的全局光照效果(两次反射)。

作业3共分为三个部分:

  1. 实现对场景直接光照的着色 (考虑阴影)。
  2. 实现屏幕空间下光线的求交 (SSR)。
  3. 实现对场景间接光照的着色。

作业文档里使用的术语是BSDF,不过既然本次作业只会涉及到漫反射材质,下文可能会出现BSDF和BRDF的互换。

Part 1:直接光照

这部分的两个子任务是计算漫反射材质BSDF的值以及光照的强度(包含可见性),非常简单。

首先是EvalDiffuse函数的实现。虽然EvalDiffuse接收了三个参数$w_i, w_o$和$uv$,但是对于漫反射材质来说,前两个代表方向的参数都是不需要的。作业说明中提示要用到保存在G-Buffer中的法线信息,意味着计算出来的BSDF是cosine weighted的。知道了这些之后,实现EvalDiffuse就是随手的事啦:

1
2
3
4
5
6
7
vec3 EvalDiffuse(vec3 wi, vec3 wo, vec2 uv) {
vec3 albedo = GetGBufferDiffuse(uv);
vec3 n = GetGBufferNormalWorld(uv);
float cos_theta = dot(n, wi);
vec3 bsdf = albedo * max(0.0, cos_theta) * INV_PI;
return bsdf;
}

漫反射BSDF

要注意的一点是,对于最终的漫反射值我们要乘上$\pi$才能保证BSDF的能量守恒。这个结论之前是知道的,下面给出一个简单的推导。首先根据反射率$\rho$的定义有($E_i$和$E_o$分别代表irradiance和radiant exitance):

$$E_o=\int_\Omega L(\omega_o)\cos \theta_o \mathrm{d}\omega=\rho E_i$$

由BRDF的定义可得:

$$\mathrm{d}L(\omega_o) = f_r \mathrm{d}E(\omega_i)$$

我们知道漫反射材质的$f_r$是常数,两边同时积分可得(注意$E(\omega_i)$和$E_i$不是一个东西):

$$L(\omega_o) = f_r E_i$$

带入到反射率的定义后:

$$\rho E_i = \int_\Omega f_r E_i \cos\theta_o \mathrm{d}\omega = f_r E_i \int_\Omega \cos\theta_o \mathrm{d}\omega = \pi f_r E_i$$

从上式可知:

$$f_r = \frac{\rho}{\pi}$$

然后是EvalDirectionalLight的实现,需要考虑可见性项,也就是要从GBuffer中提取阴影信息:

1
2
3
4
5
vec3 EvalDirectionalLight(vec2 uv) {
vec3 Le = vec3(0.0); // 自发光项
vec3 Ld = uLightRadiance * GetGBufferuShadow(uv);
return Le + Ld;
}

至此第一部分就结束了。

Part 2:Ray Marching

第二部分需要实现一个RayMarching算法来完成屏幕空间的求交,基本思路就是从某个像素对应的世界坐标出发,沿着给定的方向按照一定的步长行进若干步,直到当前坐标在屏幕空间中被遮挡,说明找到了交点。

由于我们不知道光线会行进多远,所以必须设置一个最大的行进步数。同时我们也不知道光线每步要走多远,所以这个值也是个超参数。当然,这种所谓线性搜索地方法是很慢的,老师在课上提到了使用HiZ方法来自适应地调整步长进而提高求交效率,不过在作业框架中实现HiZ是很困难的,这里就先留个坑,有意实现可以参考[2]。

回到算法的实现上来,其实就是一个步进光线然后与深度图比较的过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
bool RayMarch(vec3 ori, vec3 dir, out vec3 hitPos) {
const float threshold = MARCH_STRIDE * 2.0;
for (int i = 0; i < MARCH_MAX_STEPS; i++)
{
ori += dir * MARCH_STRIDE;
float ray_depth = GetDepth(ori);
vec2 uv = GetScreenCoordinate(ori);
float geo_depth = GetGBufferDepth(uv);
if (ray_depth > geo_depth) { // intersection found
if (ray_depth - geo_depth > threshold)
return false;
if (dot(dir, GetGBufferNormalWorld(uv)) >= 0.0)
return false;
hitPos = ori;
return true;
}
}
return false;
}

其中关于threshold会放在第三部分之后解释。其实在其他同学的实现中,只需要判断ray_depth > geo_depth就足够了,不过我这边如果这么写的话阴影部分会出现严重的噪点。分析了一下发现遮挡物如果是背朝光线的话一定不会对最终的结果有贡献,就额外增加了一个判断光线与法线夹角的判断。按照文档的提示验证镜面反射效果:

由于步长并不是非常小,所以在图像中间会有明显的瑕疵(跳变),不过整体效果是对的,说明实现基本正确。

Part 3:间接光照

Part 3是最有意思的一部分,需要实现支持one-bounce的间接光照。当然,如SSRT的名字所暗示的,采样是不可避免的。对于每个像素点,我们采样一条光线,然后使用Part 2实现的方法来完成屏幕空间内的求交,计算该交点的直接光照后加权并累加到最终的间接光照中去,按照给出的伪代码实现即可。框架提供了均匀采样和按照余弦分布采样两种半球采样方式,后者算是一种重要性采样了,直接无脑选择。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
vec3 EvalIndirectLight(vec3 wo, vec2 uv, vec3 worldPos, vec3 lightDir, inout float s) {
vec3 n = GetGBufferNormalWorld(uv);
vec3 t, b;
LocalBasis(n, t, b);
float pdf;
vec3 hitPos;
vec2 uv1;
vec3 L = vec3(0.0); // one bounce
for (int i = 0; i < SAMPLE_NUM; i++)
{
vec3 dir = normalize(mat3(t, b, n) * SampleHemisphereCos(s, pdf));
if (RayMarch(worldPos, dir, hitPos))
{
uv1 = GetScreenCoordinate(hitPos);
L += EvalDiffuse(dir, wo, uv) * EvalDiffuse(lightDir, -dir, uv1) *
EvalDirectionalLight(uv1) / pdf;
}
}
L /= float(SAMPLE_NUM);
return L;
}

由于采样出来的方向向量是在局部空间的,所以需要构造局部坐标系的基向量并将方向向量转换到世界坐标系中。LocalBasis所用的方法似乎是叫Frisvad方法(GPT说的),很容易验证它是对的,但是不知道原理是什么。

改进

终于可以看到结果了,却大失所望,画面中有很多的噪点,而且不同角度下的渲染结果非常不一致,尤其是在圈出的部分有漏光现象:

转动视角后更加明显:

来探究一下原因,其实与老师上课讲的是一样的:屏幕空间会丢失信息。具体来讲,由于GBuffer只会记录位于最前面的表面的信息,当前视角中被遮挡的表面的任何信息都是未知的。在RayMarching的过程中,由于判断交点存在的条件只是深度更大,所以会出现对于遮挡关系的误判。算法错误地认为某个可见的表面会对结果产生贡献,其实真正的交点在更远处的表面上或根本不存在。为了减少误判,[1]中使用了加threshold并动态调整步长的方式在优化。在试验中发现只需要threshold就能达到较好的效果,所需的改动就是如果某一次判断时光线的深度与GBuffer中的深度相差较大就认为交点不可见并返回false,改进后的算法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
bool RayMarch(vec3 ori, vec3 dir, out vec3 hitPos) {
vec3 last_ori;
const float threshold = MARCH_STRIDE * 2.0;
for (int i = 0; i < MARCH_MAX_STEPS; i++)
{
last_ori = ori;
ori += dir * MARCH_STRIDE;
float ray_depth = GetDepth(ori);
vec2 uv = GetScreenCoordinate(ori);
float geo_depth = GetGBufferDepth(uv);
if (ray_depth > geo_depth) { // intersection found
if (dot(dir, GetGBufferNormalWorld(uv)) >= 0.0 || ray_depth - geo_depth > threshold)
return false;
hitPos = ori;
return true;
}
}
return false;
}

其中threshold的大小与STRIDE呈正相关,降低在某些STRIDE较大的场景中错误地舍弃交点的几率。

最终的结果如下

场景1,采样数=8, stride=0.1, max_steps=50:

场景1,采样数=8, stride=0.1, max_steps=50:

场景3,采样数=8, stride=0.6, max_steps=30(记得在engine.js中切换灯光,要么场景会很暗):

暗处的噪点说实话并不明显,场景三还是很震撼的,除了硬阴影的锯齿有点扎眼外。

参考

  1. Games202 作业三 SSR实现
  2. GAMES202-作业3

该文章内容主要来自于虚幻5的官方文档[4]。

介绍

一开始只觉得Lumen听起来很高大上,以为它除了渲染之外还有一些内容,最后发现是和Nanite搞混了(难绷)。按照文档的介绍,Lumen其实就是UE5新推出的全局光照(GI)和反射系统。最离谱的是,Lumen可以近似反射了无限次的漫反射光照和高光光照,甚至能在复杂环境中达成实时渲染。Lumen是此前各种屏幕空间技术(SSGI)和距离场环境光遮蔽(DFAO)的替代品。P.S. DFAO是虚幻4中所用的环境光遮蔽方法,文档里描述其与SSAO最大的区别是遮蔽在场景空间遮挡物中进行计算,因此出屏丢失数据不会导致瑕疵,至于原理吗,我猜与GAMES202中所讲的距离场阴影类似。

要启用Lumen,需要在Project Settings->Rnedering中启用Dynamic Global Illumination和Reflections这两项(新项目应该是默认启用的)。

特性

全局光照

  • 不限制弹射次数的间接漫反射光照
  • 真实阴影(在光线追踪器中,阴影就不需要用传统方法去做了)
  • 要求实时性的情况下会以较低的分辨率来计算间接光照

天光(Sky Lighting)

天光在UE中负责模拟来自天空或远景的间接光照[1],Skylight计算的光照会填充场景中的阴影部分,增强环境光照。Lumen也会为半透明材质和体积雾计算一个低质量的全局光照。

自发光材质

自发光材质的贡献在Lumen的Final Gather Process中被计算。目前Final Gather还不太理解,留个坑。

反射

Lumen支持各种粗糙度材质反射,包括清漆(clear coat)和不透明材质的glossy反射,打开对应选项后也能渲染出半透明材质在最前面一层surface上的反射。Lumen还支持渲染单层水面的镜面反射。

Clear Coat材质

即基础层上覆盖了薄薄的一层透明涂料,比如车漆[2]。Clear coat材质也可以使用Cook-Torrance microfacet BRDF来表示[3]。

双面树叶

允许光线在树叶中的次表面散射,效果出奇的好。下图右边是开启Two-Sided Foliage之后的效果

设置(略)

补充说明

Lumen的更新速度

因为lumen似乎使用的是类似光照探针的技术并将计算压力分摊到多帧上来保证实时性的,所以局部光照条件的变化会快速传播,而全局的光照变化(如禁用阳光)需要一定的时间才能收敛。实测如果在项目中将主要的平行光设置为不可见的话,场景会缓慢变暗。而且如果仔细观察的话,直接光照会立即消失,间接光照则是缓慢黯淡。

Lumen反射

Lumen Reflections没有与Lumen GI耦合,可以单独拿出来配合静态烘焙光照(Lightmap)来使用。

材质环境光遮蔽(AO贴图)

由于使用了光线追踪,屏幕空间的AO和AO贴图也就没有必要了,不过还是可以在项目设置中启用的。
P.S.1 UE编辑器界面中Buffer Visualization->Ambient Occulusion似乎指的是SSAO,此时由于未启用SSAO所以应该只能观察到纯白色。
P.S.2 [5]中提到,Buffer Visualization其实就是绘制了G-Buffer中的内容,但是材质里面的AO贴图并不会被写入到G-buffer中,而是会被叠加到Lightmap,Stationary sky light和Reflection capture specular中去,因此预览Material Ambient Occlusion无法得到正确的内容。

参考资料

  1. What exactly is a Skylight in UE4?
  2. 车漆渲染做法Clear-Coat
  3. Physically Based Rendering in Filament#Clear coat model
  4. Lumen Global Illumination and Reflections
  5. Unreal | AO那些事

Welcome to Hexo! This is your very first post. Check documentation for more info. If you get any problems when using Hexo, you can find the answer in troubleshooting or you can ask me on GitHub.

Quick Start

Create a new post

1
$ hexo new "My New Post"

More info: Writing

Run server

1
$ hexo server

More info: Server

Generate static files

1
$ hexo generate

More info: Generating

Deploy to remote sites

1
$ hexo deploy

More info: Deployment

背景

PreZ技术的全称是Pre-depth Pass,从这个名字中大概就能得知该技术的一些要点如2-pass和depth pass。PreZ是为了防止开启Alpha Test时可能出现的渲染顺序错误。假如现在我们开启了Alpha Test并且启用了Early-Z来提高渲染效率,由于在片元着色器运行之前我们并不知道哪些片元会被Alpha Test所剔除,提前进行深度测试很可能会导致错误。

方法

PreZ的思路也很简单,既然将深度测试提前的终极目标就是尽可能地减少渲染的开销,那能不能在保持Alpha Test开启的情况下只渲染最低限度的信息?既然Alpha Test会导致深度信息错误,那不如直接把depth buffer取出来单独渲染,得到正确的深度信息后再进行更复杂的光照计算。这样我们就构想出了一个两趟的算法:

  1. Pass 1:保持Alpha Test开启,Z-Write开启,只渲染深度图

  2. Pass 2:关闭Z-Write,将深度测试的条件设置为EQUAL,运行片元着色

这样因为Pass 1中已经渲染了考虑discard后正确的深度图,在Pass 2中关闭Z-Write后,由于根本不会改变深度,GPU便可以对所有物体开启Early-Z了,大大减少了Overdraw。

0%