【DirectX12龙书机翻整理】第8章 光照

        本文章使用机器翻译并略加修改,不保证完全正确。并且只用于学习用途,如有侵权请联系本人删除。

        如果你对DirectX、OpenGL、Vulkan感兴趣,欢迎加群:C++图形学 818038139

第8章 光照

        考虑图 8.1, 在左边我们有一个未照亮的球体,在右边我们有一个照亮的球体。 正如你所看到的,左边的球体看起来相当平坦,它甚至可能不是一个球体,只是一个二维圆!而右侧的球体看起来确实是3D的,照明和阴影有助于我们感知实体的形式和体积。事实上,我们对世界的视觉感知取决于光及其与材质,因此,生成逼真场景的大部分问题与物理上精确的照明模型有关。

 图 8.1 (a) 一个没有光照的球体看起来是二维的。 (b) 发光的球体看起来是 3D 的。

         一般来说,模型越精确计算量越大越昂贵; 因此必须在真实和效率之间取得平衡。 例如,电影的3D特殊场景可能要复杂得多,并且使用非常逼真灯光模型相比于游戏,因为电影的帧是预渲染的,所以它们可以可以花费数小时或数天来处理一帧。而游戏是实时的应用程序,需要以至少 30 帧的速率运行。

        请注意,本书中解释和实现的照明模型主要基于 [Möller08] 中描述的那个。

        目标

        1. 对灯光和材质之间的相互作用有一个基本的了解

        2.了解局部光照和全局光照的区别

        3. 我们在数学上描述表面上一个点的方向“朝向”,这样我们就可以确定入射光照射的角度表面

        4. 学习如何正确变换法线向量

        5. 能够区分环境光、漫反射光和镜面光

        6. 学习如何实现平行光源、点光源和聚光灯

        7. 了解如何通过控制来改变光强度作为深度的函数衰减参数

        8.1 灯照与材质的相互作用

         使用光照时,我们不再直接指定顶点颜色;相反,我们指定材质和灯光,然后使用照明公式来计算顶点颜色,基于光与材质的相互作用。这导致更逼真的着色对象(再次比较图 8.1a 和 8.1b)。

        材质可以被认为是决定光如何与物体相互作用的属性。此类属性例如:表面反射或吸收光的颜色,材料的折射率、光滑程度表面、表面的透明度。通过指定材质属性,我们可以模拟不同种类的真实世界物体表面,如木材、石头、玻璃、金属和水。

        在我们的模型中,光源可以发出各种强度的红光、绿光和蓝光;这样我们就可以模拟出各种灯光颜色。当光从光源向外传播时并与物体碰撞,一些光可能会被吸收,一些可能会被反射(对于透明物体,例如玻璃,一些光会穿过介质,但我们在这里不考虑透明度)。反射光现在沿着新路径传播并且可能会撞击到其他物体,在这些物体上,光会再次被吸收和反射。一道光线在完全吸收之前可能会撞击许多物体。大概是一些光线最终进入眼睛(见图 8.2)并撞击光感受器细胞(命名为视锥细胞和视杆细胞)在视网膜上。

图 8.2 (a) 入射白光的通量。 (b) 光线照射到圆柱体上,一些光线被吸收,而另一些光线被散射到眼睛和球体上。 (c) 从圆柱体向球体反射的光被吸收或再次反射并进入眼睛。 (d) 眼睛接收的入射光决定了呈像。 

        根据三原色理论(参见 [Santrock03]),视网膜包含三个各种光感受器,每一种都对红光、绿光和蓝光敏感(有些重叠)。传入的RGB光会刺激其相应的光感受器,刺激强度取决于光的强度。当光感受器被刺激(或不被刺激)时,神经冲动沿着视神经向大脑发送,大脑根据光感受器的刺激在你的脑海中形成一个图像。 (当然,如果你闭上/遮住眼睛,感受器细胞不会受到刺激,大脑会将其记录为黑色的。)

        例如,再次考虑图 8.2。假设圆柱体的材料反射75%的红光,75%的绿光,吸收其余部分,球体反射 25%红光并吸收其余部分,假设光源是纯白光。当光线照射到圆柱体上时,所有的蓝光都被吸收了,只有75%的红光和绿光被反射(即中等强度的黄色)。通过漫反射,其中一些进入眼睛,一些进入球体。进入眼睛的部分主要刺激红色和绿色视锥细胞程度中等;因此,观察者将圆柱体视为半明亮的黄色。现在,其他光线向球体传播并撞击它。球体反射25%红光并吸收其余部分;因此,稀释的入射红光(中高强度红色)被进一步稀释和反射,所有入射的绿光都被吸收。这剩余的红光然后进入眼睛并主要刺激红视锥细胞程度低。因此,观察者将球体视为暗红色。

        我们(以及大多数实时应用程序)在本书中采用的光照模型称为局部光照模型。 使用局部模型,每个对象都独立于另一个对象被照亮,在照明过程中只考虑光源直接发出的光(即从其他场景对象反射到当前被照亮的对象的光被忽略)。 图 8.3 显示了该模型的结果。

 图 8.3 在物理上,墙壁阻挡了灯泡发出的光线,球体位于墙壁的阴影中。 然而,在局部照明模型中,球体被照亮,就好像墙壁不存在一样。

        另一方面,全局照明模型不仅考虑从光源直接发射的光,还考虑从场景中其他对象反射回来的间接光,从而对光照对象进行建模。 这些被称为全局照明模型,因为它们在照亮物体时会考虑全局场景中的所有内容。 对于实时游戏来说,全局光照模型通常非常昂贵(但非常接近于生成逼真的场景)。 寻找近似全局光照的实时方法是一个正在进行的研究领域。 例如,参见体素全局照明 [http://ondemand.gputechconf.com/gtc/2014/presentations/S4552-rt-voxel-based-globalillumination-gpus.pdf]。 其他流行的方法是预先计算静态对象(例如墙壁、雕像)的间接照明,然后使用该结果来近似间接照明对于动态对象(例如,移动的游戏角色)。 

        8.2 法向量

        面法线是描述多边形所面对方向的单位向量(即,它与多边形上的所有点正交); 见图 8.4a,曲面法线是与曲面上一点的切平面正交的单位向量; 见图 8.4b,观察表面法线确定表面上的点“朝向”的方向。

 图 8.4 (a) 面法线与面上的所有点正交。 (b) 表面法线是与表面上一点的切平面正交的向量。

        

 图 8.5 顶点法线 n0 和 n1 定义在线段顶点 p0 和 p1 处。 通过顶点法线之间的线性插值(加权平均)找到线段内部的点p的法线向量n; 也就是说,n = n0 + t(n1 – n0) 其中 t 使得 p = p0 + t(p1 – p0)为简单起见,在线段上进行法线插值,这个想法直接概括为在 3D 三角形上进行插值。

        对于照明计算,我们需要三角形网格表面上每个点的表面法线,以便我们可以确定光线照射网格表面上的点的角度。 为了获得表面法线,我们仅在顶点处指定表面法线(所谓的顶点法线)。 然后,为了在三角形网格表面上的每个点处获得表面法线近似值,这些顶点法线将在光栅化期间跨三角形进行插值(回忆 §5.10.3 并参见图 8.5)。

        对法线进行插值并对每个像素进行光照计算称为像素光照或 phong 光照。 一种成本较低但精度较低的方法是对每个顶点进行光照计算。 然后从顶点着色器输出每个顶点光照计算的结果,并在三角形的像素上进行插值。 出于性能考虑,将计算从像素着色器转移到顶点着色器是一种常见的性能优化,有时视觉差异非常细微,因此这种优化非常有吸引力。

        8.2.1 法线向量计算

        为了找到三角形 p0, p1, p2 的面法线,我们首先计算位于三角形边缘的两个向量:

\\u=p_1-p_0\\v=p_2-p_0

        然后面法向量为:

n=\frac{u \times v}{||u \times v||}

        下面是一个函数,它根据三角形的三个顶点计算三角形正面(第 5.10.2 节)的面法线。

XMVECTOR ComputeNormal(
    FXMVECTOR p0,
    FXMVECTOR p1,
    FXMVECTOR p2)
{
    XMVECTOR u = p1 - p0;
    XMVECTOR v = p2 - p0;
    return XMVector3Normalize(XMVector3Cross(u,v));
}

 图 8.6 中间顶点由相邻的四个多边形共享,因此我们通过平均四个多边形面法线来近似中间顶点法线。

        对于可微曲面,我们可以使用微积分来求曲面上点的法线。 不幸的是,三角形网格是不可微的。 通常应用于三角形网格的技术称为顶点法线平均。 网格中的顶点法线 n 或任意顶点 v 是通过平均网格中共享顶点 v 的每个多边形的面法线来找到的。例如,在图 8.6 中,网格中的四个多边形共享顶点 v,因此,v 的顶点法线由下式给出:

 n_{avg}=\frac{n_0+n_1+n_2+n_3}{||n_0+n_1+n_2+n_3||}

        在上面的示例中,我们不需要像在典型平均值中那样除以 4,因为我们对结果进行了归一化。 还要注意,可以构建更复杂的平均方案; 例如,在权重由多边形的面积确定的情况下,可以使用加权平均值(例如,面积较大的多边形比面积较小的多边形具有更大的权重)。

        以下伪代码显示了如何在给定三角形网格的顶点和索引列表的情况下实现这种平均:

// Input:
// 1. An array of vertices (mVertices). Each vertex has a
// position component (pos) and a normal component (normal).
// 2. An array of indices (mIndices).
// For each triangle in the mesh:
for(UINT i = 0; i < mNumTriangles; ++i)
{
	// indices of the ith triangle
	UINT i0 = mIndices[i*3+0];
	UINT i1 = mIndices[i*3+1];
	UINT i2 = mIndices[i*3+2];
	// vertices of ith triangle
	Vertex v0 = mVertices[i0];
	Vertex v1 = mVertices[i1];
	Vertex v2 = mVertices[i2];
	// compute face normal
	Vector3 e0 = v1.pos - v0.pos;
	Vector3 e1 = v2.pos - v0.pos;
	Vector3 faceNormal = Cross(e0, e1);
	// This triangle shares the following three vertices,
	// so add this face normal into the average of these
	// vertex normals.
	mVertices[i0].normal += faceNormal;
	mVertices[i1].normal += faceNormal;
	mVertices[i2].normal += faceNormal;
}
// For each vertex v, we have summed the face normals of all
// the triangles that share v, so now we just need to normalize.
for(UINT i = 0; i < mNumVertices; ++i)
	mVertices[i].normal = Normalize(&mVertices[i].normal));

        8.2.2 转换法线向量

        考虑图 8.7a,其中我们有一个与法线向量n正交的切向量u=v_1-v_0。 如果我们应用非均匀缩放变换A,我们从图 8.7b 中看到,变换后的切线向量 u_A=v_1A-v_0A 不会与变换后的法线向量nA保持正交。

 图 8.7 (a) 变换前的表面法线。 (b) 在 x 轴上按 2 个单位缩放后,法线不再与表面正交。 (c) 通过缩放变换的反转置正确变换的表面法线。

        所以我们的问题是这样的:给定一个变换点和向量(非法线)的变换矩阵 A,我们想要找到一个变换法向量的变换矩阵 B,使得变换的切向量与变换的法向量正交(即,uA·nB = 0)。 为此,让我们首先从我们知道的事情开始:我们知道法向量n与切向量u正交:

        因此B=(A^{-1})^{T}(A 的逆转置)在变换法向量方面发挥作用,使它们垂直于其相关的变换切向量uA。

        注意,如果矩阵是正交的(A^{T}=A^{-1}),那么B=(A^{-1})^{T}=(A^{T})^T=A ;也就是说,我们不需要计算逆转置,因为 A 在这种情况下完成了这项工作。 总之,当通过非均匀或剪切变换变换法线向量时,请使用反转置。

        我们在MathHelper.h中实现了一个辅助函数来计算逆转置:

static XMMATRIX InverseTranspose(CXMMATRIX M)
{
    XMMATRIX A = M;
    A.r[3] = XMVectorSet(0.0f, 0.0f, 0.0f, 1.0f);
    XMVECTOR det = XMMatrixDeterminant(A);
    return XMMatrixTranspose(XMMatrixInverse(&det, A));
}

        我们从矩阵中清除任何平移,因为我们使用逆转置来变换向量,并且平移仅适用于点。 但是,从 §3.2.1 中我们知道,为向量设置 w = 0(使用齐次坐标)可以防止向量被平移修改。 因此,我们不需要将矩阵中的平移归零。 问题是如果我们想连接反转置和另一个不包含非均匀缩放的矩阵,比如视图矩阵(A^{-1})^{T}V ,转置平移在(A^{-1})^{T}的第四列“ 泄漏”到产品矩阵中,导致错误。 因此,我们将平移归零作为预防措施以避免此错误。 正确的方法是通过((AV)^{-1})^{T}转换法线。 下面是一个缩放和平移矩阵的示例,以及第四列不是 [0, 0, 0, 1]T 的逆转置:

        即使使用反转置变换,法线向量也可能失去其单位长度; 因此,它们可能需要在转换后重新归一化。

        8.3 光照中的重要向量

        在本节中,我们总结了一些与照明有关的重要向量。参考图 8.8,E是眼睛位置,我们正在考虑眼睛看到的点p沿着由单位矢量v定义的位置线。在点p处,表面具有法线n,并且该点被a以入射方向I传播的光线。光矢量L是指向与入射到表面点的光线相反方向的单位矢量。尽管使用光的传播方向I可能更直观,但对于照明计算,我们使用光矢量L;特别是,为了计算兰伯特余弦定律,向量 L 用于计算L\cdot n=cos\theta _{i},其中\theta _i是L和n之间的角度。反射矢量r是入射光矢量关于表面法线n的反射。视图向量(或到眼向量)v = normalize(E - p) 是从表面点 p 到眼点 E 的单位向量,它定义了从眼睛到所看到的表面上的点的站点线。有时我们需要使用向量 -v,它是从眼睛到我们正在评估其光照的表面上的点的单位向量。 

 图 8.8 照明计算中涉及的重要向量。

        反射向量由下式给出:r=I-2(n\cdot I)n ,见图 8.9(假设 n 是单位向量)。但是,我们实际上可以使用HLSL内在反射函数在着色器程序中为我们计算 r。

图 8.9 反射几何。 

        8.4 兰伯特余弦定律 

        我们可以将光视为沿特定方向穿过空间的光子的集合。 每个光子携带一些(光)能量。 每秒发射的(光)能量称为辐射通量。 每个区域的辐射通量密度(称为辐照度)很重要,因为这将决定一个表面上的一个区域接收到多少光(从而决定它在眼睛看来的亮度)。 简单地说,我们可以将辐照度视为照射到表面上的区域的光量,或通过空间中虚构区域的光量。

        正面撞击表面的光(即光矢量L等于法线矢量n)以一定角度扫视表面的光强,我们可以如下计算。 考虑一个横截面积为A_1的小光束,辐射通量P穿过它。 如果我们将光束正面对准一个表面(图 8.10a),那么光束会照射到表面上的A_1区域,A_1处的辐照度为E_1=\frac{P}{A_1}。 现在假设我们旋转光束,使其以一定角度照射表面(图 8.10b),然后光束覆盖更大的区域A_2,照射该区域的辐照度为E_2=\frac{P}{A_2}。 通过三角函数,A_1A_2通过以下方式相关:

cos\theta =\frac{A_1}{A_2}\Rightarrow \frac{1}{A_2}=\frac{cos\theta }{A_1}

        所以,

E_2=\frac{P}{A_2}=\frac{P}{A_1}cos\theta =E_1cos\theta =E_1(n\cdot L)

 图 8.10 (a) 截面积为 A1 的光束正射在表面上。(b) 截面积为 A1 的光束以一定角度照射到表面上以覆盖表面上更大的面积 A2,从而将光能传播到整个表面 更大的区域,从而使光线看起来“更暗”。

        换言之,照度照射面积A_2等于垂直于光方向的面积A_1处的照度比例为n\cdot L=cos\theta。 这称为兰伯特余弦定律。 为了处理光线照射到表面背面的情况(导致点积为负),我们使用max函数钳制结果:

        f(\theta )=max(cos\theta,\, 0)=max(L\cdot n, \, 0)

        图 8.11 显示了 f(θ) 的曲线图,以了解从 0.0 到 1.0(即 0% 到 100%)范围内的强度如何随 θ 变化。

 图 8.11 对于 -2 ≤ θ ≤ 2,函数 f (θ) = max(cosθ, 0) = max(L·n, 0) 的绘图。注意 π/2 ≈ 1.57。

        8.5 漫反射光

        考虑一个不透明物体的表面,如图 8.12 所示。当光线照射到表面上的一点时,一些光线会进入物体内部并与表面附近的物质相互作用。光线会在内部反弹,其中一部分会被吸收,其余部分会从表面向各个方向散射;这称为漫反射。为简化起见,我们假设光线在光线进入的同一点被散射出去。吸收和散射的量取决于材料;例如,木头、泥土、砖块、瓷砖和灰泥会以不同的方式吸收/散射光(这就是材料看起来不同的原因)。在我们为这种光/材料相互作用建模的近似值中,我们规定光在表面上方的所有方向上均等地散射出去;因此,无论观察点(眼睛位置)如何,反射光都会到达眼睛。因此,我们不需要考虑视点(即漫反射光照计算是独立于视点的),并且无论视点如何,表面上一个点的颜色看起来总是相同的。

图 8.12 当入射到漫射表面时,入射光在各个方向上均等地散射。 这个想法是光进入介质内部并在表面下散射。 一些光会被吸收,其余的会从表面散射回来。 因为很难模拟这种次表面散射,我们假设重新发射的光在表面上方的所有方向上以光进入的点为中心均匀地散射出去。

        我们将漫反射照明的计算分为两部分。 对于第一部分,我们指定灯的颜色和漫反射反照率颜色。 漫反射反射率指定表面由于漫反射而反射的入射光量(通过能量守恒,未反射的量被材料吸收)。 这是通过分量颜色乘法来处理的(因为光可以被着色)。 例如,假设表面上的某个点反射 50% 的入射红光、100% 的绿光和 75% 的蓝光,并且入射光的颜色是 80% 强度的白光。 也就是说,入射光量由B_L = (0.8, 0.8, 0.8)给出,漫反射率由m_d =(0.5, 1.0, 0.75)给出; 那么从该点反射的光量由下式给出: 

c_d=B_L\bigotimes m_d=(0.8,\, 0.8,\, 0.8)\bigotimes (0.5,\, 1.0,\, 0.75)=(0.4,\, 0.8,\, 0.6)

        请注意,漫反射反射率分量必须在 0.0 到 1.0 的范围内,以便它们描述反射光的比例。

        然而,上面的公式并不完全正确。 我们仍然需要包括兰伯特余弦定律(它根据表面法线和光矢量之间的角度控制表面接收的原始光量)。 令B_L表示入射光量,m_d为漫反射率,L 为光矢量,n 为表面法线。 然后一个点的漫反射光量由下式给出:

c_d=max(L\cdot n,\,0)\cdot B_L\bigotimes m_d(eq. 8.1)

        8.6 环境光

        如前所述,我们的照明模型没有考虑从场景中的其他对象反弹的间接光。 然而,我们在现实世界中看到的很多光都是间接的。 例如,与房间相连的走廊可能不在房间内有光源的直接位置,但光线会从房间的墙壁反射回来,其中一些可能会进入走廊,从而照亮它上升一点。 作为第二个例子,假设我们坐在一个房间里,桌子上有一个茶壶,房间里只有一个光源。 只有茶壶的一侧被光源直接照射; 然而,茶壶的背面不会是全黑的。 这是因为一些光线从房间的墙壁或其他物体上反射出来,最终照射到茶壶的背面。

        为了破解这种间接光,我们在光照方程中引入了一个环境项:

c_a=A_L\bigotimes m_d(eq. 8.2)

        颜色A_L指定表面接收的间接(环境)光的总量,由于光从其他表面反弹时发生的吸收,这可能与从光源发出的光不同。 漫反射率m_d指定表面由于漫反射而反射的光量比例。 我们使用相同的值来指定表面反射的入射环境光量; 也就是说,对于环境照明,我们正在模拟间接(环境)光的漫反射。 所有环境光所做的只是均匀地照亮物体一点——根本没有真正的物理计算。 这个想法是间接光已经在场景周围散射和反弹了很多次,以至于它在各个方向上都均匀地照射到物体上。

        8.7 镜面光

        我们使用漫反射光来模拟漫反射,其中光线进入介质,在介质内部反射,一些光线被吸收,剩余的光线从介质中向各个方向散射。 第二种反射是由于菲涅耳效应而发生的,这是一种物理现象。 当光到达具有不同折射率的两种介质之间的界面时,一些光会被反射,而剩余的光会被折射(见图 8.13)。 折射率是一种介质的物理性质,它是真空中的光速与给定介质中的光速之比。 我们将这种光反射过程称为镜面反射,将反射光称为镜面光。 镜面光如图 8.14a 所示。

图 8.13 (a) 具有法线 n 的完全平面镜的菲涅耳效应。 入射光 I 被分裂,其中一部分沿反射方向 r 反射,其余光沿折射方向 t 折射进入介质。 所有这些向量都在同一个平面上。 反射矢量与法线之间的夹角始终为 θi,与光矢量 L = -I 与法线 n 之间的夹角相同。 折射矢量和 -n 之间的角度 θt 取决于两种介质之间的折射率,并由 Snell 定律指定。 (b) 大多数物体不是完全平坦的镜子,而是具有微观粗糙度。 这导致反射光和折射光围绕反射和折射矢量传播。 

        如果折射矢量离开介质(从另一侧)进入眼睛,则物体看起来是透明的。 也就是说,光穿过透明物体。 实时图形通常使用 alpha 混合或后期处理效果来近似透明对象的折射,我们将在本书后面解释。 目前,我们只考虑不透明的物体。

        对于不透明物体,折射光进入介质并经历漫反射。 因此我们可以从图 8.14b 中看到,对于不透明物体,从表面反射并进入眼睛的光量是体反射(漫反射)光和镜面反射的组合。 与漫射光相比,镜面光可能不会进入眼睛,因为它会沿特定方向反射; 也就是说,镜面光照计算依赖于视点。 这意味着当眼睛在场景中移动时,它接收到的镜面光量会发生变化。

图 8.14 (a) 粗糙表面的镜面光围绕反射矢量 r 传播。 (b) 进入眼睛的反射光是镜面反射和漫反射的组合。

        8.7.1 菲涅耳效应

        让我们考虑一个具有法线 n 的平面,它将两种具有不同折射率的介质分开。 由于表面的折射率不连续,当入射光照射到表面时,一些反射离开表面,一些折射进入表面(见图 8.13)。 菲涅耳方程在数学上描述了被反射的入射光的百分比,0 ≤ R_F ≤ 1。通过能量守恒,如果R_F是反射光的量,则1-R_F是折射光的量。 值R_F是一个RGB矢量,因为反射量取决于光的颜色。

        反射多少光取决于介质(某些材料比其他材料更具反射性)以及法线向量 n 和光向量 L 之间的角度\theta_i。由于它们的复杂性,完整的菲涅耳方程通常不会用于实时渲染;而是使用 Schlick 近似:

R_F(\theta_i)=R_F(0^{\circ})+(1-R_F(0^{\circ}))(1-cos\theta_i )^5

        RF(0°) 是介质的属性; 以下是一些常见材料的值[Möller08]:

        图 8.15 显示了几个不同R_F(0^{\circ})的 Schlick 近似图。主要观察结果是反射量随着\theta _i→ 90° 而增加。让我们看一个真实世界的例子。考虑图 8.16。假设我们站在几英尺深的平静池塘上方,池塘里的水相对清澈。如果我们向下看,我们主要看到的是池塘的底部沙子和岩石。这是因为从环境中射下来的光线反射到我们的眼睛里,形成了一个接近 0.0° 的小角度\theta _i;因此,反射量低,并且通过能量守恒,折射量高。另一方面,如果我们看向地平线,我们会看到池塘水中的强烈反射。这是因为从环境中射入我们眼睛的光线形成了一个接近 90.0° 的角度\theta _i,从而增加了反射量。这种行为通常被称为菲涅耳效应。简单总结一下菲涅耳效应:反射光的量取决于材料(R_F(0^{\circ}))以及法线和光矢量之间的角度。

 图 8.15 为不同材料绘制的石里克近似值:水、红宝石和铁。

        金属吸收透射光 [Möller08],这意味着它们不会具有body reflectance(不会发生漫反射)。 然而,金属不会出现黑色,因为它们具有高R_F(0^{\circ})值,这意味着即使在接近 0° 的小入射角下,它们也会反射相当多的镜面光。

图 8.16 (a):在池塘中向下看,反射低,折射高,因为 L 和 n 之间的夹角很小。 (b) 看向地平线,反射高,折射低,因为 L 和 n 之间的角度更接近 90°。

        8.7.2 粗糙度

        现实世界中的反光物体往往不是完美的镜子。 即使一个物体的表面看起来很平坦,在微观层面上我们也可以认为它具有粗糙度。 参考图 8.17,我们可以把完美的镜子想象成没有粗糙度,它的微观法线都指向与宏观法线相同的方向。 随着粗糙度的增加,微观法线的方向偏离宏观法线,导致反射光扩散到镜面波瓣中。

图 8.17 (a) 黑色水平条表示小表面元素的放大倍数。 在微观层面,由于微观层面的粗糙度,该区域有许多指向不同方向的微观法线。 表面越光滑,微观法线与宏观法线越对齐; 表面越粗糙,微观法线就越偏离宏观法线。 (b) 这种粗糙度导致镜面反射光散开。 镜面反射的形状称为镜面波瓣。 一般来说,镜面反射瓣的形状可以根据被建模的表面材料的类型而变化。

        为了对粗糙度进行数学建模,我们采用了微面模型,我们将微观表面建模为称为微面的微小平面元素的集合; 微法线是微面的法线。 对于给定的视图 v 和光矢量 L,我们想知道将 L 反射到 v 中的微面的比例; 换句话说,具有正常 h = normalize(L + v) 的微面的比例; 见图 8.18。 这将告诉我们有多少光从镜面反射反射到眼睛中——将 L 反射到 v 中的微面越多,眼睛看到的镜面光越亮。

图 8.18 具有正常 h 的微面将 L 反射到 v 中。

        向量 h 位于 L 和 v 的中间,因此称为中间向量。此外,我们还要介绍中间向量 h 与宏观法线 n 之间的夹角\theta_h

        我们定义归一化分布函数\rho (\theta _h)\in [0,\,1] 来表示具有法线 h 的微面与宏观法线 n 成角度\theta_h的分数。 直观地说,我们期望\rho (\theta _h)\theta _h=0^{\circ}时达到最大值。 也就是说,我们预计微面法线会偏向宏观法线,并且随着\theta _h的增加(当 h 偏离微观法线 n 时),我们预计具有正常 h 的微面的比例会减少。 具有刚刚讨论的期望的模型\rho (\theta _h)的流行可控函数是:

\rho (\theta _h)=cos^m(\theta _h)=(n\cdot h)^m(这里原书是cos^m(n\cdot h)

        注意cos\theta _h=n\cdot h提供了向量和单位长度。 图 8.19 显示了各种 m 的\rho (\theta _h)=cos_h(\theta _h) 。 这里 m 控制粗糙度,它指定了具有法线 h 的微面与宏观法线 n 成角度\theta _h的分数。 随着 m 的减小,表面变得更粗糙,并且微面法线越来越偏离宏观法线。 随着 m 的增加,表面变得更光滑,并且微平面法线逐渐收敛到宏观法线。

 图 8.19 模拟粗糙度的函数。

        我们可以将\rho (\theta _h)与归一化因子相结合,以获得一个新函数,该函数根据粗糙度对光的镜面反射量进行建模:

S(\theta _h)=\frac{m+8}{8}cos^m(\theta _h)=\frac{m+8}{8}(n\cdot h)^m

        图 8.20 显示了不同 m 的这个函数。 和以前一样,m 控制粗糙度,但我们添加了归一化因子,以便保存光能; 它本质上是控制图 8.20 中曲线的高度,以便在镜面反射波瓣随着 m 变宽或变窄时,整体光能保持不变。 对于较小的 m,表面更粗糙,并且随着光能更加分散,镜面叶变宽; 因此,我们预计镜面高光会更暗,因为能量已经分散。 另一方面,m越大,表面越光滑,镜面叶越窄; 因此,由于能量集中,我们预计镜面高光会更亮。 在几何上,m 控制镜面反射瓣的扩展。 要模拟光滑表面(如抛光金属),您将使用大 m,而对于较粗糙的表面,您将使用小 m。

 图 8.20 模拟由于粗糙度引起的光镜面反射的函数。

        为了结束本节,让我们将菲涅耳反射和表面粗糙度结合起来。 我们试图计算有多少光反射到视图方向 v(见图 8.18)。 回想一下,具有法线 h 的微面将光反射到 v 中。设\alpha _h是光矢量和半矢量 h 之间的角度,然后R_F(\alpha _h) 告诉我们由于菲涅耳效应而从 h 反射到 v 中的光量。 将菲涅耳效应引起的反射光量R_F(\alpha _h)与粗糙度引起的反射光量S(\theta _h)相乘,得到镜面反射光量:让 (max(L·n, 0)·BL) 表示照射到我们正在照明的表面点的入射光量,那么 (max(L·n, 0)·BL) 由于粗糙度和菲涅耳效应而镜面反射到眼睛中的分量由下式给出: 

c_s=max(L\cdot n,\,0)\cdot B_L\bigotimes R_F(\alpha _h)\frac{m+8}{8}(n\cdot h)^m(eq. 8.3)

        观察到如果 L·n ≤ 0,光线会照射到我们正在计算的表面的背面;因此正面表面不会接收到光线。 

        8.8 灯光模型回顾

        将所有东西放在一起,从表面反射的总光量是环境光反射率、漫反射光反射率和镜面光反射率的光量的总和:

        1. 环境光c_a:模拟由于间接光而从表面反射的光量。

        2. 漫射光c_d:模拟进入介质内部的光,在表面下方散射,其中一些光被吸收,剩余的光从表面散射回来。 因为很难模拟这种次表面散射,我们假设重新发射的光在表面上方的所有方向上以光进入的点为中心均匀地散射出去。

        3. 镜面光c_s:模拟由于菲涅耳效应和表面粗糙度而从表面反射的光。

        这导致了我们的着色器在本书中实现的光照方程:

        \\LitColor=c_a+c_d+c_s\\=A_L\bigotimes m_d+max(L\cdot n,\,0)\cdot B_L\bigotimes \left [ m_d+R_F(\alpha _h)\frac{m+8}{8}(n\cdot h)^m \right ](eq. 8.4)

        该等式中的所有向量都假定为单位长度。

        L:光矢量指向光源。
        n:表面法线。
        h:中间向量位于光向量和视图向量(从被照亮的表面点到眼点的向量)的中间。
        A_L:表示进入的环境光量。
        B_L:表示入射直射光的数量。
        m_d:指定表面由于漫反射而反射的入射光量。
        L\cdot n:兰伯特余弦定律。
        \alpha _h:半向量h与光向量L的夹角。
        R_F(\alpha _h):指定由于菲涅耳效应而反射到眼睛中的大约 h 的光量。
        m:控制表面粗糙度。
        (n\cdot h)_h:指定法线 h 与宏观法线 n 成角度\theta _h的微面的比例。
        \frac{m+8}{8}在镜面反射中模拟能量守恒的归一化因子。

        图 8.21 显示了这三个组件如何协同工作。

 图 8.21 (a) 仅用环境光着色的球体,使其均匀变亮。 (b) 环境光和漫射光相结合。 由于兰伯特余弦定律,现在可以从亮到暗平滑过渡。 (c) 环境光、漫射光和镜面光。 镜面光照产生镜面高光。

        方程 4 是一个常见且流行的光照方程,但它只是一个模型。其他光照模型也被提出。

        8.9 材质实现

        我们的材质结构如下所示,并在 d3dUtil.h 中定义:

// Simple struct to represent a material for our demos.
struct Material
{
    // Unique material name for lookup.
    std::string Name;
    // Index into constant buffer corresponding to this material.
    int MatCBIndex = -1;
    // Index into SRV heap for diffuse texture. Used in the texturing chapter.
    int DiffuseSrvHeapIndex = -1;
    // Dirty flag indicating the material has changed and we need to update the constant buffer. Because we have a material constant
    // buffer for each FrameResource, we have to apply the update to each
    // FrameResource. Thus, when we modify a material we should set
    // NumFramesDirty = gNumFrameResources so that each frame resource
    // gets the update.
    int NumFramesDirty = gNumFrameResources;
    // Material constant buffer data used for shading.
    DirectX::XMFLOAT4 DiffuseAlbedo = { 1.0f, 1.0f,
        1.0f, 1.0f };
    DirectX::XMFLOAT3 FresnelR0 = { 0.01f, 0.01f, 0.01f };
    float Roughness = 0.25f;
    DirectX::XMFLOAT4X4 MatTransform = MathHelper::Identity4x4();
};

        对真实世界的材质进行建模需要结合为DiffuseAlbedo(漫反射率)和FresnelR0(R_F(0^{\circ})) 设置值,以及一些艺术性的调整。 例如,金属导体会吸收进入金属内部的折射光 [Möller08],这意味着金属不会发生漫反射(即 DiffuseAlbedo 为零)。 但是,为了补偿我们没有对光照进行 100% 的物理模拟,将 DiffuseAlbedo 值设为低而不是零可能会产生更好的艺术效果。 重点是:我们将尝试使用物理上逼真的材质值,但如果最终结果从艺术角度看起来更好,则可以随意调整值。

        在我们的材料结构中,粗糙度(Roughness)在 [0, 1] 范围内的归一化浮点值中指定。 粗糙度为 0 表示表面非常光滑,粗糙度为 1 表示物理上可能的最粗糙表面。 标准化范围使创作粗糙度和比较不同材料之间的粗糙度变得更加容易。 例如,粗糙度为 0.6 的材料的粗糙度是粗糙度为 0.3 的材料的两倍。 在着色器代码中,我们将使用粗糙度来推导公式 8.4 中使用的指数 m。 请注意,根据我们对粗糙度的定义,光泽度 = 1 – 粗糙度 ∈ [0, 1]。

        现在的一个问题是我们应该在什么粒度上指定材质值? 材料值可能在表面上有所不同; 也就是说,表面上的不同点可能具有不同的材质值。 例如,考虑如图 8.22 所示的汽车模型,其中车架、车窗、灯和轮胎以不同的方式反射和吸收光,因此材料值需要在汽车表面上变化。

图 8.22。 一个汽车网格分为五个材质属性组。 

        为了实现这种变化,一种解决方案可能是在每个顶点的基础上指定材质值。 然后这些每个顶点的材质将在光栅化期间跨三角形进行插值,为我们提供三角形网格表面上每个点的材质值。 然而,正如我们在第 7 章的“Hills”演示中看到的那样,每个顶点的颜色仍然过于粗糙,无法真实地模拟精细细节。 此外,每个顶点的颜色为我们的顶点结构添加了额外的数据,我们需要有工具来绘制每个顶点的颜色。 相反,流行的解决方案是使用纹理映射,这将不得不等到下一章。 在本章中,我们允许在绘制调用频率下进行材质更改。 为此,我们定义了每种独特材料的属性并将它们放在一个表中:

std::unordered_map<std::string, std::unique_ptr<Material>> mMaterials;
void LitWavesApp::BuildMaterials()
{
    auto grass = std::make_unique<Material>();
    grass->Name = "grass";
    grass->MatCBIndex = 0;
    grass->DiffuseAlbedo = XMFLOAT4(0.2f, 0.6f, 0.6f, 1.0f);
    grass->FresnelR0 = XMFLOAT3(0.01f, 0.01f, 0.01f);
    grass->Roughness = 0.125f;
    // This is not a good water material definition,
    // but we do not have all the rendering tools we need (transparency, environment
    // reflection), so we fake it for now.
    auto water = std::make_unique<Material>();
    water->Name = "water";
    water->MatCBIndex = 1;
    water->DiffuseAlbedo = XMFLOAT4(0.0f, 0.2f, 0.6f, 1.0f);
    water->FresnelR0 = XMFLOAT3(0.1f, 0.1f, 0.1f);
    water->Roughness = 0.0f;
    mMaterials["grass"] = std::move(grass);
    mMaterials["water"] = std::move(water);
}

        上表将材料数据存储在系统内存中。 为了让 GPU 访问着色器中的材质数据,我们需要将相关数据镜像到一个常量缓冲区中。 就像我们对每个对象的常量缓冲区所做的一样,我们向每个 FrameResource 添加一个常量缓冲区,它将存储每种材质的常量:

struct MaterialConstants
{
    DirectX::XMFLOAT4 DiffuseAlbedo = { 1.0f, 1.0f,
        1.0f, 1.0f };
    DirectX::XMFLOAT3 FresnelR0 = { 0.01f, 0.01f, 0.01f };
    float Roughness = 0.25f;
    // Used in the chapter on texture mapping.
    DirectX::XMFLOAT4X4 MatTransform = MathHelper::Identity4x4();
};
struct FrameResource
{ 
public:
…
    std::unique_ptr<UploadBuffer<MaterialConstants>>
    MaterialCB = nullptr;
…
};

        请注意,MaterialConstants 结构包含 Material 数据的子集; 具体来说,它只包含着色器渲染所需的数据。

        在更新函数中,材质数据会在其更改(“脏”)时复制到常量缓冲区的子区域,以便 GPU 材质常量缓冲区数据与系统内存材质数据保持同步:

void LitWavesApp::UpdateMaterialCBs(const GameTimer& gt)
{
    auto currMaterialCB = mCurrFrameResource->MaterialCB.get();
    for(auto& e : mMaterials)
    {
        // Only update the cbuffer data if the constants have changed. If
        // the cbuffer data changes, it needs to be updated for each
        // FrameResource.
        Material* mat = e.second.get();
        if(mat->NumFramesDirty > 0)
        {
            XMMATRIX matTransform = XMLoadFloat4x4(&mat->MatTransform);
            MaterialConstants matConstants;
            matConstants.DiffuseAlbedo = mat->DiffuseAlbedo;
            matConstants.FresnelR0 = mat->FresnelR0;
            matConstants.Roughness = mat->Roughness;
            currMaterialCB->CopyData(mat->MatCBIndex, matConstants);
            // Next FrameResource need to be updated too.
            mat->NumFramesDirty—;
        }
    }
}

        现在每个渲染项都包含一个指向材质的指针。 请注意,多个渲染项可以引用同一个 Material 对象; 例如,多个渲染项可能使用相同的“砖”材质。 反过来,每个 Material 对象都有一个索引,指定它的常量数据是否在材质常量缓冲区中。 由此,我们可以偏移到我们正在绘制的渲染项所需的常量数据的虚拟地址,并将其设置为期望材质常量数据的根描述符。 (或者,我们可以偏移到堆中的 CBV 描述符并设置描述符表,但我们在此演示中定义了根签名以获取材料常量缓冲区的根描述符而不是表。)以下代码显示了我们如何 用不同的材质绘制渲染项目:

void LitWavesApp::DrawRenderItems(
ID3D12GraphicsCommandList* cmdList,
const std::vector<RenderItem*>& ritems)
{
    UINT objCBByteSize = d3dUtil::CalcConstantBufferByteSize(sizeof(ObjectConstants));
    UINT matCBByteSize = d3dUtil::CalcConstantBufferByteSize(sizeof(MaterialConstants));
    auto objectCB = mCurrFrameResource->ObjectCB->Resource();
    auto matCB = mCurrFrameResource->MaterialCB->Resource();
    // For each render item…
    for(size_t i = 0; i < ritems.size(); ++i)
    {
        auto ri = ritems[i];
        cmdList->IASetVertexBuffers(0, 1, &ri->Geo->VertexBufferView());
        cmdList->IASetIndexBuffer(&ri->Geo->IndexBufferView());
        cmdList->IASetPrimitiveTopology(ri->PrimitiveType);
        D3D12_GPU_VIRTUAL_ADDRESS objCBAddress = objectCB->GetGPUVirtualAddress() +
            ri->ObjCBIndex*objCBByteSize;
        D3D12_GPU_VIRTUAL_ADDRESS matCBAddress = matCB->GetGPUVirtualAddress() +
            ri->Mat->MatCBIndex*matCBByteSize;
        cmdList->SetGraphicsRootConstantBufferView(0, objCBAddress);
        cmdList->SetGraphicsRootConstantBufferView(1, matCBAddress);
        cmdList->DrawIndexedInstanced(ri->IndexCount, 1, 
        ri->StartIndexLocation, ri->BaseVertexLocation, 0);
    }
}

        我们提醒读者,我们需要三角形网格表面上每个点的法线向量,以便我们可以确定光照射网格表面上的点的角度(对于兰伯特余弦定律)。 为了获得三角形网格表面上每个点的法线向量近似值,我们在顶点级别指定法线。 这些顶点法线将在光栅化过程中跨三角形插值。 

        到目前为止,我们已经讨论了光的组成部分,但我们还没有讨论特定种类的光源。 接下来的三个部分描述了如何实现平行光、点光源和聚光灯。

        8.10 平行光源

        平行光(或定向光)近似于距离很远的光源。因此,我们可以将所有入射光线近似为彼此平行(图 8.23)。 此外,由于光源距离很远,我们可以忽略距离的影响,只指定光线照射到场景中的光强。

 图 8.23 撞击表面的平行光线。

        平行光源由一个向量定义,该向量指定光线行进的方向。 因为光线是平行的,所以它们都使用相同的方向矢量。 光矢量指向与光线传播的相反方向。 可以准确地建模为定向光的光源的一个常见示例是太阳(图 8.24)。 

图 8.24 该图未按比例绘制,但如果您选择地球上的一个小表面区域,则照射该区域的光线大致平行。 

        8.11 点光源

        点光源的一个很好的物理示例是灯泡。 它向所有方向呈球形辐射(图 8.25)。 具体地,对于任意点P,存在一条源自点光位置Q的光线向该点行进。 像往常一样,我们将光矢量定义为相反的方向; 即点P到点光源Q的方向:

 L=\frac{Q-P}{||Q-P||}

        本质上,点光源和平行光之间的唯一区别是光矢量的计算方式——对于点光源来说,它会因点而异,但对于平行光来说保持不变。

 图 8.25 点光源向各个方向辐射; 特别地,对于任意点 P,存在一条从点源 Q 指向 P 的光线。

        8.11.1 衰减 

        在物理上,根据平方反比定律,光强度会随着距离的变化而减弱。 也就是说,距离光源距离为 d 的点的光强由下式给出:

I(d)=\frac{I_0}{d^2}

        其中I_0是距离光源 d = 1 处的光强度。 如果您设置基于物理的光照值并使用 HDR(高动态范围)光照和色调映射,则此方法效果很好。 然而,一个更容易上手的公式,也是我们将在我们的演示中使用的公式,是一个线性衰减函数:

att(d)=saturate(\frac{falloffEnd-d}{falloffEnd-falloffStart})

        图 8.26 描绘了这个函数的图表。 saturate(饱和)函数将参数限制在 [0, 1] 范围内:

saturate=\left\{\begin{matrix} x,0\leq x\leq <1\\ 0,x< 0\\ 1,x> 1 \end{matrix}\right.

图 8.26 缩放光照值的衰减因子保持在最大强度 (1.0) 直到距离 d 达到 falloffStart,然后随着距离达到 falloffEnd 线性衰减到 0.0。

        评估点光源的公式与公式 8.4 相同,但我们必须通过衰减因子 att(d) 缩放光源值B_L。 请注意,衰减不会影响环境项,因为环境项用于对反射的间接光进行建模。

        使用我们的衰减函数,与光源的距离大于或等于 falloffEnd 的点不会接收到光。 这提供了一个有用的光照优化:在我们的着色器程序中,如果一个点超出范围,那么我们可以提前返回并通过动态分支跳过光照计算。

        8.12 聚光灯

        聚光灯的一个很好的物理示例是手电筒。 本质上,聚光灯的位置为 Q,指向方向 d,并通过锥形辐射光(见图 8.27)。

图 8.27 聚光灯的位置为 Q,对准方向 d,并通过角度为\phi _{max}的锥体辐射光。 

         为了实现聚光灯,我们从点光源开始:光矢量由下式给出:

 L=\frac{Q-P}{||Q-P||}

        其中 P 是被点亮的点的位置,Q 是聚光灯的位置。 从图 8.27 中观察到,当且仅当 -L 和 d 之间的角度φ小于锥角\phi _{max}时,P 位于聚光灯的锥形内部(因此接收光)。 此外,聚光灯锥体中的所有光线不应具有相同的强度; 锥体中心的光应该是最强烈的,并且随着φ从 0 增加到\phi _{max},光强度应该衰减到零。

        那么我们如何控制强度衰减作为φ的函数,以及我们如何控制聚光灯锥体的大小? 我们可以使用具有与图 8.19 相同的图形的函数,但将\theta _h替换为φ并将 m 替换为 s:

k_{spot}(\phi )=max(cos\phi ,\,0)^5=max(-L\cdot d,\,0)^5

        这给了我们想要的东西:强度随着 φ 的增加而平滑衰减; 此外,通过改变指数 s,我们可以间接控制 φmax(强度下降到 0 的角度); 也就是说,我们可以通过改变s来缩小或扩大聚光灯锥。 例如,如果我们设置 s = 8,则圆锥的半角约为 45°。

        聚光灯方程与公式 8.4 类似,只是我们将光源值B_L乘以衰减因子 att(d) 和聚光灯因子 kspot 以根据点相对于聚光灯锥的位置来缩放光强度。

        我们看到聚光灯比点光源更昂贵,因为我们需要计算额外的 kspot 因子并乘以它。 类似地,我们看到点光比平行光更昂贵,因为需要计算距离 d(这实际上非常昂贵,因为距离涉及平方根运算),我们需要计算并乘以衰减因子。 总而言之,平行光是最便宜的光源,其次是点光源,其次是聚光灯是最昂贵的光源。

        8.13 光源实现

        本节讨论实现平行光、点光源和聚光灯的详细信息。

        8.13.1 光源结构

        在 d3dUtil.h 中,我们定义了以下结构来支持灯光。 这种结构可以表示平行光、点光或聚光灯。 但是,根据灯光类型,某些值不会被使用; 例如,点光源不使用方向数据成员。

struct Light
{
    DirectX::XMFLOAT3 Strength; // Light color
    float FalloffStart; // point/spot light only
    DirectX::XMFLOAT3 Direction;// directional/spot light only
    float FalloffEnd; // point/spot light only
    DirectX::XMFLOAT3 Position; // point/spot light only
    float SpotPower; // spot light only
};

        LightingUtils.hlsl 文件定义了反映这些的结构:

struct Light
{
    float3 Strength;
    float FalloffStart; // point/spot light only
    float3 Direction; // directional/spot light only
    float FalloffEnd; // point/spot light only
    float3 Position; // point light only
    float SpotPower; // spot light only
};

        Light 结构(以及 MaterialConstants 结构)中列出的数据成员的顺序不是任意的。 他们了解 HLSL 结构包装规则。 有关详细信息,请参阅附录 B(“结构打包”),但主要思想是在 HLSL 中,结构填充发生,以便将元素打包到 4D 向量中,限制单个元素不能拆分为两个 4D 向量。 这意味着上述结构可以很好地打包成三个 4D 向量,如下所示:

vector 1: (Strength.x, Strength.y, Strength.z, FalloffStart)
vector 2: (Direction.x, Direction.y, Direction.z, FalloffEnd)
vector 3: (Position.x, Position.y, Position.z, SpotPower)

        另一方面,如果我们这样编写 Light 结构:

struct Light
{
    DirectX::XMFLOAT3 Strength; // Light color
    DirectX::XMFLOAT3 Direction;// directional/spot light only
    DirectX::XMFLOAT3 Position; // point/spot light only
    float FalloffStart; // point/spot light only
    float FalloffEnd; // point/spot light only
    float SpotPower; // spot light only
};
struct Light
{
    float3 Strength;
    float3 Direction; // directional/spot light only
    float3 Position; // point light only
    float FalloffStart; // point/spot light only
    float FalloffEnd; // point/spot light only
    float SpotPower; // spot light only
};

        然后它将被打包成四个 4D 向量,如下所示:

vector 1: (Strength.x, Strength.y, Strength.z, empty)
vector 2: (Direction.x, Direction.y, Direction.z, empty)
vector 3: (Position.x, Position.y, Position.z, empty)
vector 4: (FalloffStart, FalloffEnd, SpotPower, empty)

        第二种方法占用更多数据,但这不是主要问题。 更严重的问题是,我们有一个镜像 HLSL 结构的 C++ 应用程序端结构,但是 C++ 结构没有遵循相同的 HLSL 打包规则; 因此,C++ 和 HLSL 结构布局可能不会匹配,除非您仔细使用 HLSL 打包规则并编写它们以便它们匹配。 如果 C++ 和 HLSL 结构布局不匹配,那么当我们使用 memcpy 将数据从 CPU 上传到 GPU 常量缓冲区时,就会出现渲染错误。

        8.13.2 常用辅助函数

        LightingUtils.hlsl 中定义的以下三个函数包含一种以上灯光类型通用的代码,因此我们在辅助函数中定义。

        1. CalcAttenuation:实现一个线性衰减因子,适用于点光源和聚光灯。
        2. SchlickFresnel:菲涅耳方程的 Schlick 近似; 由于菲涅耳效应,它基于光矢量 L 和表面法线 n 之间的角度,近似于法线 n 的表面反射的光的百分比。
        3. BlinnPhong:计算反射到眼睛的光量; 它是漫反射和镜面反射之和。

float CalcAttenuation(float d, float falloffStart, float falloffEnd)
{
    // Linear falloff.
    return saturate((falloffEnd-d) / (falloffEnd - falloffStart));
}
// Schlick gives an approximation to Fresnel reflectance
// (see pg. 233 “Real-Time Rendering 3rd Ed.”).
// R0 = ( (n-1)/(n+1) )^2, where n is the index of refraction.
float3 SchlickFresnel(float3 R0, float3 normal, float3 lightVec)
{
    float cosIncidentAngle = saturate(dot(normal, lightVec));
    float f0 = 1.0f - cosIncidentAngle;
    float3 reflectPercent = R0 + (1.0f - R0) * (f0*f0*f0*f0*f0);
    return reflectPercent;
}
struct Material
{
    float4 DiffuseAlbedo;
    float3 FresnelR0;
    // Shininess is inverse of roughness: Shininess = 1-roughness.
    float Shininess;
};
float3 BlinnPhong(float3 lightStrength, float3 lightVec,
    float3 normal, float3 toEye, Material mat)
{
    // Derive m from the shininess, which is derived from the roughness.
    const float m = mat.Shininess * 256.0f;
    float3 halfVec = normalize(toEye + lightVec);
    float roughnessFactor = (m + 8.0f)*pow(max(dot(halfVec, normal), 0.0f), m) / 8.0f;
    float3 fresnelFactor = SchlickFresnel(mat.FresnelR0, halfVec, lightVec);
    // Our spec formula goes outside [0,1] range, but we are doing
    // LDR rendering. So scale it down a bit.
    specAlbedo = specAlbedo / (specAlbedo + 1.0f);
    return (mat.DiffuseAlbedo.rgb + specAlbedo) * lightStrength;
}

        使用了以下固有 HLSL 函数:dot、pow 和 max,它们分别是向量点积函数、幂函数和最大值函数。 大多数 HLSL 内在函数的描述可以在附录 B 中找到,以及其他 HLSL 语法的快速入门。 然而,需要注意的一点是,当两个向量与 operator* 相乘时,乘法是按分量进行的。

        我们计算高光反照率的公式允许高光值大于 1,这表示高光非常亮。 然而,我们的渲染目标期望颜色值在 [0, 1] 的低动态范围 (LDR) 内。 由于我们的渲染目标要求颜色值在 [0, 1] 范围内,因此超出此范围的值将被限制为 1.0。 因此,要在没有锐利钳位的情况下获得更柔和的镜面高光,我们需要缩小镜面反射率:
        specAlbedo = specAlbedo / (specAlbedo + 1.0f);
        高动态范围 (HDR) 光照使用浮点渲染目标,允许光照值超出范围 [0, 1],然后执行色调映射步骤将高动态范围重新映射回 [0, 1]用于显示,同时保留重要的细节。 HDR 渲染和色调映射本身就是一门学科——参见 [Reinhard10] 的教科书。 然而,[Pettineo12] 提供了一个很好的介绍和演示来进行实验。

        在 PC 上,HLSL 函数始终是内联的; 因此,函数或参数传递没有性能开销。 

        8.13.3 平行光实现

        给定眼睛位置 E 和眼睛可见表面上的点 p,表面法线 n 和材料属性,以下 HLSL 函数输出来自定向光源的反射到眼睛方向的光量 v = normalize(E - p)。 在我们的示例中,将在像素着色器中调用此函数,以根据光照确定像素的颜色。

float3 ComputeDirectionalLight(Light L, Material mat,
    float3 normal, float3 toEye)
{
    // The light vector aims opposite the direction the light rays travel.
    float3 lightVec = -L.Direction;
    // Scale light down by Lambert’s cosine law.
    float ndotl = max(dot(lightVec, normal), 0.0f);
    float3 lightStrength = L.Strength * ndotl;
    return BlinnPhong(lightStrength, lightVec, normal, toEye, mat);
}

        8.13.4 点光源实现

        给定眼睛位置 E 和给定表面法线 n 的眼睛可见表面上的点 p 和材料属性,以下 HLSL 函数输出来自点光源的反射到眼睛方向的光量 v = normalize (E - p) 。 在我们的示例中,将在像素着色器中调用此函数,以根据光照确定像素的颜色。(没错,这一段和前面一样)

float3 ComputePointLight(Light L, Material mat, float3 pos,
    float3 normal, float3 toEye)
{
    // The vector from the surface to the light.
    float3 lightVec = L.Position - pos;
    // The distance from surface to light.
    float d = length(lightVec);
    // Range test.
    if(d > L.FalloffEnd)
        return 0.0f;
    // Normalize the light vector.
    lightVec /= d;
    // Scale light down by Lambert’s cosine law.
    float ndotl = max(dot(lightVec, normal), 0.0f);
    float3 lightStrength = L.Strength * ndotl;
    // Attenuate light by distance.
    float att = CalcAttenuation(d, L.FalloffStart, L.FalloffEnd);
    lightStrength *= att;
    return BlinnPhong(lightStrength, lightVec, normal, toEye, mat);
}

        8.13.5 聚光灯实现

        给定眼睛位置 E 和表面法线 n 的表面上的点 p 和材料属性,以下 HLSL 函数输出来自点光源的反射到眼睛方向的光量 v = normalize (E - p)。 在我们的示例中,将在像素着色器中调用此函数,以根据光照确定像素的颜色。

float3 ComputeSpotLight(Light L, Material mat, float3 pos,
    float3 normal, float3 toEye)
{
    // The vector from the surface to the light.
    float3 lightVec = L.Position - pos;
    // The distance from surface to light.
    float d = length(lightVec);
    // Range test.
    if(d > L.FalloffEnd)
        return 0.0f;
    // Normalize the light vector.
    lightVec /= d;
    // Scale light down by Lambert’s cosine law.
    float ndotl = max(dot(lightVec, normal), 0.0f);
    float3 lightStrength = L.Strength * ndotl;
    // Attenuate light by distance.
    float att = CalcAttenuation(d, L.FalloffStart, L.FalloffEnd);
    lightStrength *= att;
    // Scale by spotlight
    float spotFactor = pow(max(dot(-lightVec, L.Direction), 0.0f), L.SpotPower);
    lightStrength *= spotFactor;
    return BlinnPhong(lightStrength, lightVec, normal, toEye, mat);
}

        8.13.6 多个光源

        光照是相加的,因此在一个场景中支持多个光照仅仅意味着我们需要遍历每个光源并将其对我们正在评估光照的点/像素的贡献相加。 我们的示例框架最多支持 16 个灯。 我们可以使用平行光、点光源或聚光灯的任意组合,但总数不得超过 16 个。 此外,我们的代码使用的约定是平行光必须在光阵列中排在第一位,点光源排在第二位,聚光灯排在最后。 下面的代码计算一个点的光照方程:

#define MaxLights 16
// Constant data that varies per material.
cbuffer cbPass : register(b2)
{
    …
    // Indices [0, NUM_DIR_LIGHTS) are directional lights;
    // indices [NUM_DIR_LIGHTS, NUM_DIR_LIGHTS+NUM_POINT_LIGHTS) are
    // point lights;
    // indices [NUM_DIR_LIGHTS+NUM_POINT_LIGHTS,
    // NUM_DIR_LIGHTS+NUM_POINT_LIGHT+NUM_SPOT_LIGHTS)
    // are spot lights for a maximum of MaxLights per object.
    Light gLights[MaxLights];
};
float4 ComputeLighting(Light gLights[MaxLights],
    Material mat,
    float3 pos, float3 normal, float3 toEye,
    float3 shadowFactor)
{
    float3 result = 0.0f;
    int i = 0;
#if (NUM_DIR_LIGHTS > 0)
    for(i = 0; i < NUM_DIR_LIGHTS; ++i)
    {
        result += shadowFactor[i] *
        ComputeDirectionalLight(gLights[i], mat, normal, toEye);
    }
#endif
#if (NUM_POINT_LIGHTS > 0)
    for(i = NUM_DIR_LIGHTS; i < NUM_DIR_LIGHTS+NUM_POINT_LIGHTS; ++i)
    {
        result += ComputePointLight(gLights[i], mat, pos, normal, toEye);
    }
#endif
#if (NUM_SPOT_LIGHTS > 0)
    for(i = NUM_DIR_LIGHTS + NUM_POINT_LIGHTS; 
        i < NUM_DIR_LIGHTS + NUM_POINT_LIGHTS + NUM_SPOT_LIGHTS;
        ++i)
    {
        result += ComputeSpotLight(gLights[i], mat, pos, normal, toEye);
    }
#endif
    return float4(result, 0.0f);
}

       观察每种类型的灯的数量是用#defines 控制的。 这个想法是让着色器只对实际需要的灯光数量进行光照方程。 所以如果一个应用程序只需要三个灯,我们只计算三个灯。 如果您的应用程序需要在不同时间支持不同数量的灯光,那么您只需使用不同的#defines 生成不同的着色器。

        shadowFactor 参数将在有关阴影的章节中使用。 所以现在,我们只是将它设置为向量 (1, 1, 1),这使得阴影因子在方程中没有影响。

        8.13.7 主要HLSL文件

        下面的代码包含用于本章演示的顶点和像素着色器,并利用了我们目前讨论的LightingUtil.hlsl 中的 HLSL 代码。 

//*********************************************************************
// Default.hlsl by Frank Luna (C) 2015 All Rights Reserved.
//
// Default shader, currently supports lighting.
//*********************************************************************
// Defaults for number of lights.
#ifndef NUM_DIR_LIGHTS
#define NUM_DIR_LIGHTS 1
#endif
#ifndef NUM_POINT_LIGHTS
#define NUM_POINT_LIGHTS 0
#endif
#ifndef NUM_SPOT_LIGHTS
#define NUM_SPOT_LIGHTS 0
#endif
// Include structures and functions for lighting.
#include "LightingUtil.hlsl"
// Constant data that varies per frame.
cbuffer cbPerObject : register(b0)
{
	float4x4 gWorld;
};
cbuffer cbMaterial : register(b1)
{
	float4 gDiffuseAlbedo;
	float3 gFresnelR0;
	float gRoughness;
	float4x4 gMatTransform;
};
// Constant data that varies per material.
cbuffer cbPass : register(b2)
{
	float4x4 gView;
	float4x4 gInvView;
	float4x4 gProj;
	float4x4 gInvProj;
	float4x4 gViewProj;
	float4x4 gInvViewProj;
	float3 gEyePosW;
	float cbPerObjectPad1;
	float2 gRenderTargetSize;
	float2 gInvRenderTargetSize;
	float gNearZ;
	float gFarZ;
	float gTotalTime;
	float gDeltaTime;
	float4 gAmbientLight;
	// Indices [0, NUM_DIR_LIGHTS) are directional lights;
	// indices [NUM_DIR_LIGHTS, NUM_DIR_LIGHTS+NUM_POINT_LIGHTS) are
	// point lights;
	// indices [NUM_DIR_LIGHTS+NUM_POINT_LIGHTS,
	// NUM_DIR_LIGHTS+NUM_POINT_LIGHT+NUM_SPOT_LIGHTS)
	// are spot lights for a maximum of MaxLights per object.
	Light gLights[MaxLights];
};
struct VertexIn
{
	float3 PosL : POSITION;
	float3 NormalL : NORMAL;
};
struct VertexOut
{
	float4 PosH : SV_POSITION;
	float3 PosW : POSITION;
	float3 NormalW : NORMAL;
};
VertexOut VS(VertexIn vin)
{
	VertexOut vout = (VertexOut)0.0f;
	// Transform to world space.
	float4 posW = mul(float4(vin.PosL, 1.0f), gWorld);
	vout.PosW = posW.xyz;
	// Assumes nonuniform scaling; otherwise, need to use
	// inverse-transpose of world matrix.
	vout.NormalW = mul(vin.NormalL, (float3x3)gWorld);
	// Transform to homogeneous clip space.
	vout.PosH = mul(posW, gViewProj);
	return vout;
}
float4 PS(VertexOut pin) : SV_Target
{
	// Interpolating normal can unnormalize it, so renormalize it.
	pin.NormalW = normalize(pin.NormalW);
	// Vector from point being lit to eye.
	float3 toEyeW = normalize(gEyePosW - pin.PosW);
	// Indirect lighting.
	float4 ambient = gAmbientLight*gDiffuseAlbedo;
	// Direct lighting.
	const float shininess = 1.0f - gRoughness;
	Material mat = { gDiffuseAlbedo, gFresnelR0, shininess };
	float3 shadowFactor = 1.0f;
	float4 directLight = ComputeLighting(gLights, mat, pin.PosW, pin.NormalW, toEyeW, shadowFactor);
	float4 litColor = ambient + directLight;
	// Common convention to take alpha from diffuse
	material.litColor.a = gDiffuseAlbedo.a;
	return litColor;
}

        8.14 灯光演示程序

        灯光演示建立在前一章的“Waves”演示的基础上。 它使用一种定向光来代表太阳。 用户可以使用向左、向右、向上和向下箭头键旋转太阳位置。 虽然我们已经讨论了如何实现材质和灯光,但以下小节将介绍尚未讨论的实现细节。 图 8.28 显示了照明演示的屏幕截图。

图 8.28。 灯光演示的屏幕截图。 

        8.14.1 顶点格式

        照明计算需要表面法线。 我们在顶点级别定义法线; 然后将这些法线插值到三角形的像素上,以便我们可以对每个像素进行光照计算。 此外,我们不再指定顶点颜色。 相反,像素颜色是通过对每个像素应用照明方程来生成的。 为了支持顶点法线,我们修改我们的顶点结构,如下所示:

// C++ Vertex structure
struct Vertex
{
    DirectX::XMFLOAT3 Pos;
    DirectX::XMFLOAT3 Normal;
};
// Corresponding HLSL vertex structure
struct VertexIn
{
    float3 PosL : POSITION;
    float3 NormalL : NORMAL;
};

        当我们添加一个新的顶点格式时,我们需要用一个新的输入布局描述来描述它:

mInputLayout =
{
    { "POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0,
        D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 },
    { "NORMAL", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 12,
        D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 }
};

        8.14.2 法线计算

        GeometryGenerator 中的形状函数已经使用顶点法线创建数据,所以我们都设置在那里。 但是,因为我们在这个演示中修改了网格的高度以使其看起来像地形,所以我们需要自己为地形生成法线向量。

        因为我们的地形表面由函数 y = f(x, z) 给出,所以我们可以直接使用微积分计算法线向量,而不是 §8.2.1 中描述的法线平均技术。 为此,对于曲面上的每个点,我们通过取偏导数在 +x- 和 +z- 方向上形成两个切向量:

T_x=\left ( 1,\frac{\partial f}{\partial x},0 \right )

 T_z=\left ( 0,\frac{\partial f}{\partial z},1 \right )

         这两个向量位于曲面点的切平面内。 取叉积然后给出法向量:

\\n=T_z\times T_x\\=\begin{vmatrix} i & j & k\\ 0 & \frac{\partial f}{\partial z} & 1\\ 1 & \frac{\partial f}{\partial x} & 0 \end{vmatrix}\\=\left ( \begin{vmatrix} \frac{\partial f}{\partial z} & 1\\ \frac{\partial f}{\partial x} & 0 \end{vmatrix},-\begin{vmatrix} 0 & 1\\ 1 & 0 \end{vmatrix},\begin{vmatrix} 0 & \frac{\partial f}{\partial z}\\ 1 & \frac{\partial f}{\partial x} \end{vmatrix}\right )\\=\begin{pmatrix} -\frac{\partial f}{\partial x},& 1,& -\frac{\partial f}{\partial x} \end{pmatrix}

        我们用来生成陆地网格的函数是:

f(x,z)=0.3z\cdot sin(0.1x)+0.3x\cdot cos(0.1z)

        偏导数是:

         因此,表面点 (x, f (x, z), z) 处的表面法线由下式给出:

        我们注意到这个表面法线不是单位长度的,所以在光照计算之前需要对其进行归一化。 

         具体来说,我们对每个顶点进行上述法线计算,得到顶点法线:

XMFLOAT3 LitWavesApp::GetHillsNormal(float x, float z)const
{
    // n = (-df/dx, 1, -df/dz)
    XMFLOAT3 n(-0.03f*z*cosf(0.1f*x) - 0.3f*cosf(0.1f*z),
        1.0f,
        -0.3f*sinf(0.1f*x) + 0.03f*x*sinf(0.1f*z));
    XMVECTOR unitNormal = XMVector3Normalize(XMLoadFloat3(&n));
    XMStoreFloat3(&n, unitNormal);
    return n;
}

        水面的法线向量以类似的方式完成,只是我们没有水的公式。 但是,可以使用有限差分方案来近似每个顶点处的切向量(参见 [Lengyel02] 或任何数值分析书籍)。

        如果你的微积分生锈了(还给老师了),不要担心,因为它不会在本书中发挥重要作用。 现在它很有用,因为我们正在使用数学表面来生成我们的几何图形,以便我们可以绘制一些有趣的对象。 最终,我们将从 3D 建模程序导出的文件中加载 3D 网格。

         8.14.3 更新平行光位置

        如第 8.13.7 节所示,我们的 Lights 数组被放置在 per-pass 常量缓冲区中。 该演示使用一个定向光来表示太阳,并允许用户使用左、右、上和下箭头键旋转太阳位置。 所以每一帧,我们都需要计算来自太阳的新光线方向,并将其设置为 per-pass 常量缓冲区。

        我们在球坐标 (ρ, θ, φ) 中跟踪太阳位置,但半径 ρ 无关紧要,因为我们假设太阳无限远。 特别是,我们只使用 ρ = 1 使其位于单位球面上,并将 (1, θ, φ) 解释为朝向太阳的方向。 光的方向只是朝向太阳的方向的负方向。 下面是更新太阳的相关代码。

float mSunTheta = 1.25f*XM_PI;
float mSunPhi = XM_PIDIV4;
void LitWavesApp::OnKeyboardInput(const GameTimer& gt)
{
    const float dt = gt.DeltaTime();
    if(GetAsyncKeyState(VK_LEFT) & 0x8000)
        mSunTheta -= 1.0f*dt;
    if(GetAsyncKeyState(VK_RIGHT) & 0x8000)
        mSunTheta += 1.0f*dt;
    if(GetAsyncKeyState(VK_UP) & 0x8000)
        mSunPhi -= 1.0f*dt;
    if(GetAsyncKeyState(VK_DOWN) & 0x8000)
        mSunPhi += 1.0f*dt;
    mSunPhi = MathHelper::Clamp(mSunPhi, 0.1f, XM_PIDIV2);
}
void LitWavesApp::UpdateMainPassCB(const GameTimer& gt)
{
    …
    XMVECTOR lightDir = -MathHelper::SphericalToCartesian(1.0f, mSunTheta, mSunPhi);
    XMStoreFloat3(&mMainPassCB.Lights[0].Direction, lightDir);
    mMainPassCB.Lights[0].Strength = { 0.8f, 0.8f, 0.7f };
    auto currPassCB = mCurrFrameResource->PassCB.get();
    currPassCB->CopyData(0, mMainPassCB);
}

        将 Light 数组放入 per-pass 常量缓冲区意味着每个渲染通道中的灯光不能超过 16 个(我们支持的最大灯光数量)。 这对于小型演示来说绰绰有余。 但是,对于大型游戏世界,这还不够,因为您可以想象游戏关卡中散布着数百个灯光。 一种解决方案是将 Light 数组移动到每个对象的常量缓冲区。 然后,对于每个对象 O,您搜索场景并找到影响对象 O 的灯光,并将这些灯光绑定到常量缓冲区。 会影响 O 的灯光是与它相交的灯光(点光源的球体和聚光灯的圆锥体)。 另一种流行的策略是使用延迟渲染或 Forward+ 渲染。

        8.14.4 更新根签名 

        光照为我们的着色器程序引入了一个新的材质常量缓冲区。 为了支持这一点,我们需要更新我们的根签名以支持额外的常量缓冲区。 与每个对象的常量缓冲区一样,我们使用材质常量缓冲区的根描述符来支持直接绑定常量缓冲区,而不是通过描述符堆。

        8.15 小结

        1. 对于光照,我们不再指定逐顶点颜色,而是定义场景灯光和逐顶点材质。材料可以被认为是决定光如何与物体表面相互作用的属性。每个顶点材质在三角形的整个面上进行插值,以获得三角形网格的每个表面点的材质值。然后,照明方程根据光和表面材料之间的相互作用计算眼睛看到的表面颜色;还涉及其他参数,例如表面法线和眼睛位置。
        2. 曲面法线是与曲面上一点的切平面正交的单位向量。表面法线确定表面上的点“朝向”的方向。对于照明计算,我们需要三角形网格表面上每个点的表面法线,以便我们可以确定光线照射到网格表面上的点的角度。为了获得表面法线,我们仅在顶点处指定表面法线(所谓的顶点法线)。然后,为了获得三角形网格表面上每个点的表面法线近似值,这些顶点法线将在光栅化期间跨三角形进行插值。对于任意三角形网格,顶点法线通常通过称为法线平均的技术来近似。如果矩阵 A 用于变换点和向量(非法线向量),那么(A^{-1})^T应该用于变换表面法线。
        3. 平行(定向)光近似于非常远的光源。因此,我们可以将所有入射光线近似为彼此平行。定向光的一个物理示例是相对于地球的太阳。点光源向各个方向发射光。点光源的一个物理示例是灯泡。聚光灯通过锥形发光。聚光灯的一个物理示例是手电筒。
        4. 由于菲涅耳效应,当光线到达两种不同折射率的介质之间的界面时,一部分光线会被反射,而剩余的光线会折射到介质中。反射多少光取决于介质(某些材料比其他材料更具反射性)以及法线向量 n 和光向量 L 之间的角度\theta _i。由于它们的复杂性,完整的菲涅耳方程通常不会在实际中使用- 时间渲染;相反,使用 Schlick 近似。
        5. 现实世界中的反光物体往往不是完美的镜子。即使一个物体的表面看起来很平坦,在微观层面上我们也可以认为它具有粗糙度。我们可以认为完美的镜子没有粗糙度,其微观法线都与宏观法线指向同一方向。随着粗糙度的增加,微法线的方向与宏观法线发散,导致反射光扩散到镜面波瓣中。
        6. 环境光模拟在场景周围多次散射和反射的间接光,它在各个方向上均等地照射到对象,从而均匀地照亮它。漫射光模拟进入介质内部并在表面下方散射的光,其中一些光被吸收,剩余的光从表面散射回来。因为很难模拟这种次表面散射,我们假设重新发射的光在表面上方的所有方向上以光进入的点为中心均匀地散射出去。镜面光模拟由于菲涅耳效应和表面粗糙度而从表面反射的光。

        8.16 练习

        1.修改本章的灯光演示,使定向灯只发出大部分红光。此外,使用正弦函数使光的强度随时间振荡,使光看起来像脉冲。使用彩色和脉冲灯可用于不同的游戏情绪;例如,脉冲红灯可用于表示紧急情况。
        2.通过改变材质的粗糙度来修改本章的光照演示。
        3. 修改上一章的“形状”演示,添加材质和三点照明系统。三点照明系统通常用于电影和摄影中,以获得比仅一个光源提供的更好的照明;它由一个称为主光的主光源、一个通常从主光朝向侧面方向的辅助补光和一个背光灯组成。我们使用三点照明作为一种伪造间接照明的方法,它提供了比仅使用环境组件进行间接照明更好的对象定义。三点照明系统使用三个定向灯。

图 8.29 练习 3 解决方案的屏幕截图。 

        4. 修改练习 3 的解决方案,移除三点照明,并在柱上方添加一个以每个球体为中心的点。
        5. 修改练习 3 的解决方案,移除三点照明,并添加以柱上方每个球体为中心并向下瞄准的聚光灯。
        6. 卡通风格照明的一个特点是从一种颜色阴影到另一种颜色的突然过渡(与平滑过渡相反),如图 8.30 所示。 这可以通过以通常的方式计算 kd 和 ks 来实现,然后在像素着色器中使用它们之前通过如下离散函数对其进行转换: 

        修改本章的光照演示以使用这种卡通着色。 (注意:上面的函数 f 和 g 只是开始的示例函数,可以调整直到得到你想要的结果。) 

图 8.30 卡通灯光截图。 

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值