代码工程地址:
https://github.com/jiabaodan/Direct12BookReadingNotes
学习目标
- 理解基本的材质和光照之间交互方式;
- 熟悉局部光照和全局光照之间的不同;
- 学习如何用数学的方式描述平面上一个点的方向,以便于计算入射光和平面之间的夹角;
- 学习如何准确的变换法向量;
- 区分环境光,漫反射和高光;
- 学习如何实现点光源、方向光和聚光灯;
- 学习如何通过改变一个函数中衰变参数的方式来改变光照强度。
1 灯光和材质的交互
当使用光照后,我们不再直接给顶点设置颜色,而是通过材质和灯光属性,根据光照公式计算顶点的颜色。它可以让物体的颜色更加真实。
本书中的光照模型都是使用局部光照模型。物体之间是不可照亮的,只计算直接从光源发射的灯光。
全局光照不仅计算光源发射的灯光,也计算物体之间反射的间接光照,关于全局光照的介绍可以参考下面的文章:
http://on-demand.gputechconf.com/gtc/2014/presentations/S4552-rt-voxel-based-global-illumination-gpus.pdf
2 法向量
面法线是一个用来描述多边形朝向的单位向量。
为了光照计算,我们知道平面中每个三角形每个点的法线,以便于计算入射光和平面的夹角。为了包含法线,我们为每个顶点定义法线。那么为了近似得到每个顶点的法线,在光栅化阶段会为每个点进行线性差值计算法线。
线性差值求法线并且逐顶点计算光照的算法叫像素光照或者冯氏光照(phong lighting)。另一种性能更高但是准确度更低的光照算法是逐顶点计算。所以将光照从逐像素移动到逐顶点是一个通用的优化方式,这是一种很诱人的优化方式,因为它对画质和视觉效果大部分情况下影响很小。
2.1 法向量的计算
为了计算三角形Δp0, p1, p2的面法线,我们先计算三角形两条边的向量:
u = p1 – p0
v = p2 – p0
面法线就可以计算如下:
n=∣∣u×v∣∣u×v
下面是计算一个三角面前向向量的代码:
XMVECTOR ComputeNormal(FXMVECTOR p0,
FXMVECTOR p1,
FXMVECTOR p2)
{
XMVECTOR u = p1 - p0;
XMVECTOR v = p2 - p0;
return XMVector3Normalize(XMVector3Cross(u,v));
}
对于一个可微分的平面,我们可以使用微积分算出平面上顶点的法线。但是三角面是不可微分的。所以我们通过共用顶点的每个面法线的平均值来求顶点的法向量。如上图,法向量就等于:
navg=∣∣n0+n1+n2+n3∣∣n0+n1+n2+n3
下面的伪代码展示了如何在三角列表的顶点和索引中实现法向量的计算:
// 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));
2.2 法向量的坐标系变换
假设我们有一个切线向量u = v1 − v0垂直于一个法向量n。如果应用一个非均匀的缩放变换A,得到的向量uA = v1A − v0A不再垂直于nA,如下图:
所以现在的问题是:给定一个变换矩阵A,我们要求出变换矩阵B来变换法线,保证法线在变换后还是垂直于变换后的向量。为了计算B,我们从已知的条件开始推导,n和u是垂直的:
所以B=(A−1)T(A的逆转置矩阵)。如果A是正交矩阵(A−1=AT)那么B=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来防止向量被位移,我们不需要把位移清零。这样做的目的是如果我们要串联其他不包含不均匀缩放的矩阵的时候,会导致矩阵相乘的错误。所以这样做是为了避免这种错误。下面就是一个包含位移和缩放的矩阵的例子,可以看到不清楚位移的话,求出的你转职矩阵是什么奇怪样子:
即使只是做了逆转置操作,向量还是可能会改变长度,所以需要再次标准化一下。
3 光照计算中的重要向量
这节中概括一下在光照计算中重要的向量(如下图)。
E是眼睛(相机)的位置,我们主要考虑从点P到眼睛E的向量v;在P点有平面的法向量n;点P被方向为I的光照打中;向量L是P指向光源的单位向量;从直觉上我们应该更多使用I,但是在光照计算中我们一般使用向量L;根据余弦定律L·n = cos θi;向量r是入射光的反射单位向量
反射向量计算:r = I – 2(n·I)n,如下图,我们可以直接使用HLSL内置的reflect函数来计算
4 LAMBERT&RSQUO’S 余弦法则
光线正面照射到平面反射的光线要比一某一个角度照射到平面反射的光线要强。考虑一小束辐射通量为P的光通过A1,正面照射到表面,那么辐射度就是E1 = P/A1。现在旋转这束光以一个角度照射到A2平面,那么辐射度就是E2 = P/A2,根据三角函数,A1和A2的关系如下:
所以:
也就是照射到A2的辐射量等于垂直照射到A1的辐射量乘以n·L = cos θ。这个就是Lambert’s余弦定理。为了考虑到照射到背面的情况,我们把结果限制在大于0:f(θ) = max(cosθ, 0) = max(L·n, 0)。
下图展示了光照强度如何随着角度变化:
5 漫反射光
考虑到一个不透明的物体表面,如下图。当一个灯光照射到它表面,部分光照射到物体内部并和表面附近进行交互。光线会在内部进行反射,其中一些会被吸收,另外的部分会反射到各个方向,这个就是漫反射光。在我们模拟的光线/材质交互模型中,我们确保在各个方向反射的光是相等的,另外反射到相机的光线与相机的位置无关。所以我们不需要考虑相机的位置。
我们将计算划分为2个部分。第一部分定义光线的颜色和计算漫反射反射率颜色;比如入射光线BL和反射率md,那么漫反射光颜色计算如下:
cd = BL ⊗ md = (0.8, 0.8, 0.8) ⊗ (0.5, 1.0, 0.75) = (0.4, 0.8, 0.6)
漫反射发射率范围必须是0到1.
上述公式并不完全正确,我们还需要引进兰伯特余弦定律。在上述基础上增加L光线向量,n是平面法向量,那么漫反射光照计算公式如下:
6 环境光
为了简单模拟非直接光照,我们引入环境光计算公式:
所有环境光一致的照亮每一个物体,没有任何实际物理计算。这个思路在于环境光已经在场景中反射N多次,以至于每个方向的光照强度都是一样的。
7 高光
另外一种反射发生在菲涅尔效果中,当光线穿过2个不同反射率的媒介时,部分光线被反射,部分被折射。我们将这种反射现象叫做高光反射,如下图:
如果当前介质存在折射向量并且可以照到摄像机,这个物体就是透明的。实时的图形学主要是使用alpha混合和后期特效处理来模拟透明,我们将在后续的章节中讲解。目前我们只考虑不透明物体。
对于不透明物体,反射光是高光和漫反射光的组合:
7.1 菲涅尔效果
假设有一个平面将2个不同折射率的介质区分开来,当光线照射到这个平面时,部分被反射,部分被折射。菲涅尔公式从数学上描述了光线反射的百分比,0 ≤ RF ≤ 1。根据能量守恒定律,如果RF是反射的光线,那么1-RF就是折射的光线。RF是一个基于RGB的向量,因为反射的量是基于颜色的。
具体多少会被反射主要基于媒介以及法向量n和光线向量L的夹角θi。因为太复杂了,菲涅尔公式不使用在实时图形学中,而是用Schlick approximation:
RF(0°)是媒介的一个属性,下面是一些材质的通用值:
根据下图,菲涅尔效果可以简单的总结为:反射的光强度是基于材质(RF(0°))和法线于光向量之间的夹角。
7.2 粗糙度
为了用数学模拟粗糙度,我们引入微面元理论:我们使用很多微小的理想平面来模拟微观下的平面;这些微法线就是微面元的法线。给定一个视角向量v和光向量L,我们希望知道微面元从L反射到V的百分比;也就是具有法线h = normalize(L +v)的微面元。根据下图,如果有更多的微面元可以从L反射到v,那么高光反射光线就越强:
其中向量h称之为半角向量(halfway vector),它是v和L之间的向量;θh是半角向量h和法向量n之间的夹角。
我们定义标准分布函数ρ(θh) ∈ [0, 1]来表示这些具有法线为h的微面元(具有和n夹角θh);我们希望θh = 0°时ρ(θh)达到最大,然后随着θh增大,ρ(θh)开始减少。一个和流行的方程可以达到我们的预期:
其中向量都是单位向量,m控制粗糙度,不同m值的变化如下图:
我们可以用一个标准化因子来合并ρ(θh),组成一个新的方程:
新增加的这个因子主要用以控制曲线的高度:当m值较小的时候,材质更接近于裂开的木头,表面更加粗糙,反射的光强也更小。
最后我们将菲涅尔反射和粗糙度公式合并起来,我们希望计算具有光向量L的光线反射到视角向量v的强度;具有法线为h的微面元满足要求。令αh为半角向量h与光向量之间的夹角,那么RF(αh)就是反射的光的量。继续乘以具有粗糙度m的高光反射的值S(θh);令(max(L·n, 0)·BL)为照亮表面的亮度,那么最终高光反射的公式就如下:
8 光照模型回顾
我们将所有的内容集中到一起,光线反射的总和是环境光反射的量加漫反射的量加高光反射的量:
- 环境光ca:模拟表面反射的非直接光照;
- 漫反射光cd:模拟光线照射如介质后,分散到介质内部,其中部分被吸收,另一部分被散射会表面。我们假设散射会表面的光线在每个方向上的值相等;
- 高光cs:模拟光线照射到表面后,根据涅菲尔定路和粗糙度反射会的光线。
根据上述,推导出本书着色器中使用的光照计算公式:
其中每一个向量都是单位向量:
- L:光向量,由照射到平面的点指向光源;
- n:平面的法向量;
- h:半角向量,光向量和视角向量中间的向量(能够反射到眼睛的微面元的法向量);
- AL:环境光入射光强;
- BL:光源入射光强;
- md:表面的漫反射百分比;
- L·n:兰伯特余弦定理;
- αh:半角向量h和光向量L之间的夹角;
- RF(αh):根据涅菲尔定律,具有法向量h的微面元反射到眼睛的光强;
- m:控制表面的粗糙度;
- (n·h)h:定义具有法向量h的微面元的百分比;
- (m+8)/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的值,和一些美术上的加工。
我们的结构中,粗糙度是标准化到[0, 1]的一个值,在着色器程序中光滑度将有该值派生出来shininess = 1 – roughness ∈ [0, 1]。
其中一个问题是我们设置材质的间隔是什么,同一个表面上材质可能会发生变化。比如如下图,一辆汽车上需要多个不同材质:
为了解决这个问题,其中一个方案是基于每个顶点设置材质值,这些材质在光栅化时会被插值计算。但是从之前的Demo中可以看出,逐顶点的计算颜色还是看起来很粗糙,另外逐顶点计算会增加我们顶点结构的大小。一个很普遍的方法是使用贴图映射,这个需要到下一章中讲解。这章中我们允许材质在绘制调用中频繁变化。为了达到上述效果,我们定义多个唯一的材质放置到一张表中:
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访问到它们,我们需要镜像相关的数据到一个常量缓冲中:
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包含的是材质数据的子集,着色器渲染时需要的数据。
在更新函数中,如果材质发生变化,就把它复制到常量缓冲的子区间中:
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
mat->NumFramesDirty--;
}
}
}
现在每个渲染单元都拥有一个材质指针,有时候多个物体可能需要使用同一个材质。所以每一个材质包含一个索引用来偏移到要使用的在常量缓冲中的材质。(之前我们是使用描述表,但是这次使用根描述)下面代码展示了如何实现:
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);
}
}
10 平行光
平行光模拟的是非常遥远的光,所以我们可以认为它的所有入射光线都是平行的,并且可以不考虑距离,只考虑强度。
11 点光源
对于任意点P,从点光源位置Q照向点P,则光向量(与灯光照射方向相反)为:
11.1 衰减
从物理上来讲,灯光强度基于距离遵循平方反比定律:
I0是d = 1时的光照强度,如果你是建立基于物理的光照,并且使用HDR(高动态光照)和色调映射(tonemapping)效果会非常好。但是我们在Demo中使用一个更简单的公式,一个线性衰减公式:
该公式的函数图:
12 聚光灯
聚光灯具有一个位置Q,指向一个方向d,向一个圆锥体内发射光线。
光向量和点光源一样:
根据上图,如果一个位置P要被照到的话,向量-L和d的夹角需要小于ϕmax,并且光照强度不一样,在角度为0时最强,随着角度增加到最大,光照强度减少到0。
为了达到这个我们可以使用之前的公式,并替换其中的参数:
可以看出聚光灯是最消耗性能的,因为需要计算kspot因子并乘以它;其次是点光源,因为计算距离的时候有开平方运算;最快的是方向光。
13 光照的实现
这一节讨论光照实现的细节。
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
};
这些结构中定义的属性顺序要固定(MaterialConstants结构也一样)。它们通过HLSL结构填充规则来审核(See Appendix B (“Structure Packing”))。在HLSL中的主要思路是,结构会填充对齐,也就是说元素会被塞进4D向量,单个元素被限制不能分开到2个4D向量中。根据这个规则,上述的元素会被完美放到3个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
};
那么它修需要装进4个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)
第二种方法导致使用了更多的数据,但是这个不是主要问题。更重要的问题在于C++不遵循HLSL装配原则;所以C++和HLSL结构布局可能不匹配(除非严格遵循HLSL装配原则);如果C++和HLSL布局不匹配的话,在内存拷贝(memcpy)的时候可能导致渲染错误。
13.2 通用协助函数
下面三个函数定义在LightingUtils.hlsl文件中:
- CalcAttenuation:定义了线性衰减因子,使用在点光源和聚光灯中;
- SchlickFresnel:用Schlick近似的菲涅尔方程;
- 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;
}
dot, pow, 和max函数是HLSL内置的函数,可以在附录B中查看更多内置函数。
我们的方程中计算高光反射率允许高光高于1来表示很亮的光线,但是我们的渲染目标希望颜色值是[0, 1]的LDR(low dynamic-range)。超出范围的值会简单的截取到[0, 1]。所以为了得到自然的高光效果,我们需要对该值进行缩放:
specAlbedo = specAlbedo / (specAlbedo + 1.0f);
HDR(High-Dynamic-Range)光照使用浮点渲染目标,可以允许值超过[0, 1],然后一个色调映射的(tonemapping)步骤将它重新映射到[0,1]来显示,保留细节是最重要的。HDR渲染和tonemapping是它的一个课题—[Reinhard10]的书籍,[Pettineo12]提供了很好的介绍和Demo展示。
在PC上,HLSL函数总是内敛的,所以不会有函数调用和参数传递带来的性能消耗。
13.3 实现方向光
给出眼睛位置E和表面上可见的拥有法向量n的点P,以及材质属性,下面的HLSL可以通过方向光属性输出光线的强度:
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);
}
13.4 实现点光源
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);
}
13.5 实现聚光灯
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);
}
13.6 多个灯光的实现
我们的框架支持自多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);
}
13.7 主要的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;
}
14 光照Demo
该Demo是基于之前的“Waves”Demo修改的,它使用了一个方向光来模拟太阳。用户可以用过键盘方向键来旋转太阳位置:
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
}
};
14.2 法向量的计算
GeometryGenerator函数已经计算好了法向量,但是在本Demo中,我们修改了顶点的高度来模拟山脉,所以需要重新计算法向量。
因为山脉的方程为y = f(x, z),所以我们可以直接使用微积分来计算。在平面上的每个点,我们在x和z方向使用偏导数形成2个切向量:
这两个切向量都是在该顶点的切平面上,所以使用叉积运算求出法向量。
我们使用的创建陆地的函数是:
那么偏导数:
所以最终法向量公式:
因为这个求出来的法向量不是单位向量,所以在用以计算光照的时候需要单位化一下。
我们对每个顶点进行法向量求解:
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;
}
对于水流也是类型的思路求解法向量,但是本Demo中我们没有使用该方程,而是使用有限差分格式(finite difference scheme)(see [Lengyel02] or any numerical analysis book)。
14.3 更新灯光的方向
我们将太阳的位置放置在球体坐标系(ρ, θ, ϕ)中,因为半径并不重要,因为我们假设太阳是无限远,所以使用的是(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数组放置在PassCB表示我们不能有多于16个灯光的限制,在小Demo中这个比较高效,但是在大型游戏中就无法满足需求,因为场景中可能有上百个灯光。其中一个解决方案是,将Light数组放置到per-objectCB中,这样对于每个物体,可以计算能够影响它的灯光,然后绑定到对于的CB中;另外的流行的方案是使用延时渲染或者正向渲染。
14.4 更新根签名
增加一个绑定材质的根描述。
15 总结
- 增加光照以后,我们不再直接定义逐顶点的颜色,而是逐顶点的材质。材质的属性可以描述物体平面和场景灯光的交互。逐顶点的材质在三角形表面通过差值的方式应用到每个片元点,然后光照计算公式计算出每个片元的颜色(本demo是基于像素的光照计算)。
- 表面法线是一个垂直于该顶点所在的切面的一个单位向量。顶点法线也会在光栅化的时候被插值计算。对于固定的顶点列表,顶点法线是通过法线平均值的算法来计算的。如果矩阵A是顶点和向量的变换矩阵,那么(A−1)T就是法线的变换矩阵。
- 在菲涅尔效果中,当光线照射到2个不同折射率介质中间时,部分光线被折射,剩余部分被反射;光线如何被反射主要基于介质和法向量n和光向量L的夹角θi。因为完整的菲涅尔公式非常复杂,所以不使用在实时图形学计算中,我们使用简化的Schlick approximation。
- 可以反射的物体在现实世界中不可能是一个完美的镜子,在微观情况下,它是粗糙的。我们可以想象完美的镜子是不粗糙了,它的每个点的法向量都指向同一个方向;随着粗糙度的增加,法向量的方向开始分散开,导致产生分散的高光效果;
- 环境光模拟非直接光照,因为在场景中反射和分散了太多次,所以它从任意方向照亮物体具有相同的颜色;漫反射光模拟光线照射入物体,在物体内部反射,部分光被吸收,剩余部分被朝各个方向反射出表面;高光反射基于涅菲尔效果和表面粗糙度。
16 练习
1. 将本章中的Demo中的方向光颜色修改为红色,并根据时间修改光照强度
代码在https://github.com/jiabaodan/Direct12BookReadingNotes中的Chapter8_Exercises_1_Pulsing工程
修改代码UpdateMainPassCB函数中的光照颜色即可:
// 红色变化的灯光
float paulsValue = sin(gt.TotalTime() * 2);
paulsValue < 0 ? -paulsValue : paulsValue;
mMainPassCB.Lights[0].Strength = { paulsValue, 0.0f, 0.0f };
2. 修改本章中的Demo中的材质粗糙度
修改BuildMaterials方法中的Material->Roughness值即可
3. 在前一章shape Demo中增加材质和3个点光源系统;包含一个主光源叫key light,第二重要的光源fill light一般瞄准主光源的侧面和背面。我们使用3点光源系统来模拟非直接光照,这个效果要比直接使用环境光效果好。为3个点光源系统使用3个方向光:
代码在https://github.com/jiabaodan/Direct12BookReadingNotes中的Chapter8_Exercises_3_LitShapes工程
根据本章中的介绍增加材质和灯光即可
4. 将上面Demo3个方向光修改为一个点光源(5.修改为一个聚光灯)
修改对应灯光和shader中灯光数量设置即可
C++:
// 还需要修改Shader中灯光的数量
// EXE.3 3个方向光
//mMainPassCB.Lights[0].Direction = { 0.57735f, -0.57735f, 0.57735f };
//mMainPassCB.Lights[0].Strength = { 0.6f, 0.6f, 0.6f };
//mMainPassCB.Lights[1].Direction = { -0.57735f, -0.57735f, 0.57735f };
//mMainPassCB.Lights[1].Strength = { 0.3f, 0.3f, 0.3f };
//mMainPassCB.Lights[2].Direction = { 0.0f, -0.707f, -0.707f };
//mMainPassCB.Lights[2].Strength = { 0.15f, 0.15f, 0.15f };
// EXE.4 1个点光源
//mMainPassCB.Lights[0].Direction = { 0.57735f, -0.57735f, 0.57735f };
//mMainPassCB.Lights[0].Strength = { 0.6f, 0.6f, 0.6f };
//mMainPassCB.Lights[0].Position = { 0.0f, 7.0f, 0.0f };
// EXE.5 1个聚光灯
mMainPassCB.Lights[0].Direction = { 0.0f, -1.0f, 0.0f };
mMainPassCB.Lights[0].Strength = { 1.f, 1.f, 1.f };
mMainPassCB.Lights[0].Position = { 0.0f, 8.0f, 0.0f };
Shader:
// Defaults for number of lights.
#ifndef NUM_DIR_LIGHTS
#define NUM_DIR_LIGHTS 0
#endif
#ifndef NUM_POINT_LIGHTS
#define NUM_POINT_LIGHTS 0
#endif
#ifndef NUM_SPOT_LIGHTS
#define NUM_SPOT_LIGHTS 1
#endif
6. 一种卡通效果的实现是使眼色产生突变,它可以通过正常计算kd和ks,但是通过类似下面的方程产生颜色突变:
修改本章的Demo,产生卡通效果:
代码在https://github.com/jiabaodan/Direct12BookReadingNotes中的Chapter8_Exercises_6_CartoonStyle工程
根据给出的公式修改Shader代码即可:
// 添加卡通化效果
for(int i = 0; i < 3; ++i)
{
if (mat.DiffuseAlbedo[i] < 0)
{
mat.DiffuseAlbedo[i] = 0;
}
else if (mat.DiffuseAlbedo[i] < 0.5f)
{
mat.DiffuseAlbedo[i] = 0.6f;
}
else
{
mat.DiffuseAlbedo[i] = 1.0f;
}
}
for(int i = 0; i < 3; ++i)
{
if (specAlbedo[i] < 0.1)
{
specAlbedo[i] = 0;
}
else if (specAlbedo[i] < 0.8f)
{
specAlbedo[i] = 0.5f;
}
else
{
specAlbedo[i] = 0.8f;
}
}