最爱午后红茶

平行光作用下的山峰河谷

日期图标
2023-06-20

定义光源和材质

直接从主类 LightingApp 的构造函数开始:

LightingApp::LightingApp(HINSTANCE hInstance)
    : D3DApp(hInstance), mLandVB(0), mLandIB(0), mWavesVB(0), mWavesIB(0),
      mFX(0), mTech(0), mfxWorld(0), mfxWorldInvTranspose(0), mfxEyePosW(0),
      mfxDirLight(0), mfxPointLight(0), mfxSpotLight(0), mfxMaterial(0),
      mfxWorldViewProj(0), mInputLayout(0), mEyePosW(0.0f, 0.0f, 0.0f),
      mTheta(1.5f * MathHelper::Pi), mPhi(0.1f * MathHelper::Pi),
      mRadius(80.0f) {
  mMainWndCaption = L"Lighting Demo";

  mLastMousePos.x = 0;
  mLastMousePos.y = 0;

  XMMATRIX I = XMMatrixIdentity();
  XMStoreFloat4x4(&mLandWorld, I);
  XMStoreFloat4x4(&mWavesWorld, I);
  XMStoreFloat4x4(&mView, I);
  XMStoreFloat4x4(&mProj, I);

  XMMATRIX wavesOffset = XMMatrixTranslation(0.0f, -3.0f, 0.0f);
  XMStoreFloat4x4(&mWavesWorld, wavesOffset);

  // Directional light.
  mDirLight.Ambient = XMFLOAT4(0.2f, 0.2f, 0.2f, 1.0f);
  mDirLight.Diffuse = XMFLOAT4(0.5f, 0.5f, 0.5f, 1.0f);
  mDirLight.Specular = XMFLOAT4(0.5f, 0.5f, 0.5f, 1.0f);
  mDirLight.Direction = XMFLOAT3(0.57735f, -0.57735f, 0.57735f);

  // Point light--position is changed every frame to animate in UpdateScene
  // function.
  mPointLight.Ambient = XMFLOAT4(0.3f, 0.3f, 0.3f, 1.0f);
  mPointLight.Diffuse = XMFLOAT4(0.7f, 0.7f, 0.7f, 1.0f);
  mPointLight.Specular = XMFLOAT4(0.7f, 0.7f, 0.7f, 1.0f);
  mPointLight.Att = XMFLOAT3(0.0f, 0.1f, 0.0f);
  mPointLight.Range = 25.0f;

  // Spot light--position and direction changed every frame to animate in
  // UpdateScene function.
  mSpotLight.Ambient = XMFLOAT4(0.0f, 0.0f, 0.0f, 1.0f);
  mSpotLight.Diffuse = XMFLOAT4(1.0f, 1.0f, 0.0f, 1.0f);
  mSpotLight.Specular = XMFLOAT4(1.0f, 1.0f, 1.0f, 1.0f);
  mSpotLight.Att = XMFLOAT3(1.0f, 0.0f, 0.0f);
  mSpotLight.Spot = 96.0f;
  mSpotLight.Range = 10000.0f;

  mLandMat.Ambient = XMFLOAT4(0.48f, 0.77f, 0.46f, 1.0f);
  mLandMat.Diffuse = XMFLOAT4(0.48f, 0.77f, 0.46f, 1.0f);
  mLandMat.Specular = XMFLOAT4(0.2f, 0.2f, 0.2f, 16.0f);

  mWavesMat.Ambient = XMFLOAT4(0.137f, 0.42f, 0.556f, 1.0f);
  mWavesMat.Diffuse = XMFLOAT4(0.137f, 0.42f, 0.556f, 1.0f);
  mWavesMat.Specular = XMFLOAT4(0.8f, 0.8f, 0.8f, 96.0f);
}

可以看到构造函数里面包括 3 个光源(平行光、点光、聚光)信息和 2 个材质(山峰、河谷)信息。这里先简单说下什么是材质(具体后续在 Games-101-Materials and Appearances 中会详细介绍):

在计算机图形学中,材质(Material)是指描述物体外观和表面特性的属性集合。它定义了物体如何反射、吸收和传播光线,并决定了物体在渲染过程中的视觉效果。

简单来说我们看到的物体的外观表现出来的的效果(比如我们能区分金属与布料),就是材质与光线相互作用的结果。

一个材质通常包含几个关键属性:

  • 颜色(Color):描述物体的基本颜色,可以是纯色或纹理图像。颜色属性决定了物体的可见外观。
  • 反射率(Reflectance):描述物体对入射光线的反射行为。不同材质具有不同的反射率,从完全反射到完全吸收的范围内变化。
  • 高光(Specular):描述物体表面的镜面反射特性,即物体的高亮部分。高光属性定义了物体上反射光线的大小、形状和颜色。
  • 透明度(Transparency):描述物体是否透明或半透明。这决定了光线是否能够穿过物体并影响背后的对象。
  • 折射率(Refraction):描述当光线通过透明物体时发生的折射行为。折射率决定了光线通过物体时的弯曲和变形。
  • 纹理(Texture):描述物体表面上的细节图案或图像。纹理可以提供更加真实和细腻的外观,使物体看起来具有纹理、颗粒感或图案。

从材质可以包含的属性可以看出,材质跟纹理是有相关关系的。材质在定义物体的外观时可以使用纹理来增强或改变表面的视觉效果。纹理可作为材质的一部分,用于提供具体的图像或图案。通过将纹理与其他材质属性结合起来,可以实现更加真实和细腻的渲染结果。

在计算机图形学中,材质属性通常通过使用着色器进行处理。着色器根据物体的材质属性计算出对应的光照效果,并将其应用于每个像素或顶点上,以最终呈现出物体的表面外观。不同的渲染技术和算法可以进一步模拟和优化材质的视觉效果,以实现更加逼真和令人信服的渲染结果。

简单了解完材质后,我们先从简单的平行光(mDirLight)和山峰材质(mLandMat)开始。

平行光的结构:

// Note: Make sure structure alignment agrees with HLSL structure padding rules.
//   Elements are packed into 4D vectors with the restriction that an element
//   cannot straddle a 4D vector boundary.

struct DirectionalLight {
  DirectionalLight() { ZeroMemory(this, sizeof(this)); }

  XMFLOAT4 Ambient; // 入射光(环境光)颜色
  XMFLOAT4 Diffuse; // 入射光(漫反射光)颜色
  XMFLOAT4 Specular; // 入射光(高光)颜色
  XMFLOAT3 Direction; // 光照方向(单位向量)
  float
      Pad; // Pad the last float so we can set an array of lights if we wanted.
};

其中变量 Pad 只是用作 C++HLSL 之间的结构打包,并无具体含义。

山峰材质的结构:

struct Material {
  Material() { ZeroMemory(this, sizeof(this)); }

  XMFLOAT4 Ambient; // 环境光反射率
  XMFLOAT4 Diffuse; // 漫反射率
  XMFLOAT4 Specular; // 高光反射率
  XMFLOAT4 Reflect; // 本程序未使用
};

接着我们再回看下两者的初始化:

...
// Directional light.
mDirLight.Ambient = XMFLOAT4(0.2f, 0.2f, 0.2f, 1.0f);
mDirLight.Diffuse = XMFLOAT4(0.5f, 0.5f, 0.5f, 1.0f);
mDirLight.Specular = XMFLOAT4(0.5f, 0.5f, 0.5f, 1.0f);
mDirLight.Direction = XMFLOAT3(0.57735f, -0.57735f, 0.57735f);
...
mLandMat.Ambient = XMFLOAT4(0.48f, 0.77f, 0.46f, 1.0f);
mLandMat.Diffuse = XMFLOAT4(0.48f, 0.77f, 0.46f, 1.0f);
mLandMat.Specular = XMFLOAT4(0.2f, 0.2f, 0.2f, 16.0f);
...

本程序的着色频率是逐像素的(即 Games-101-Frequencies 中的 Phong shading)。因此,顶点信息需要包含法线信息,顶点结构定义如下:

struct Vertex {
  XMFLOAT3 Pos;
  XMFLOAT3 Normal;
};

我们可以看到,原本顶点包含的颜色信息被去掉了。那么,物体表面的颜色是如何确定的呢?前面说了我们看到物体的外观是物体材质和光线作用的结果,这结果里面就包含物体的颜色。如果我们只看平行光源的环境光与山峰材质相互作用结果(具体相互作用的代码在后面详细介绍),它长这样:

平行光源的环境光与山峰材质相互作用结果

图一:平行光源的环境光与山峰材质相互作用结果

我们为什么能看出山峰是绿色的呢?因为这是环境光与材质作用的结果,这个过程描述为向量的分量相乘

  • mDirLight.Ambient 表示环境光入射光的颜色(20% 红光 + 20% 绿光 + 20% 蓝光),最后一个参数未使用。
  • mLandMat.Ambient 表示山峰材质对环境光的反射率(反射 48% 红光 + 反射 77% 绿光 + 反射 46% 蓝光),最后一个参数未使用。

当环境光照射在山峰上时,其反射出去的光的颜色是这两个向量分量颜色相乘(对应位置相乘)的结果:

  • (0.2, 0.2, 0.2)⨂(0.48, 0.77, 0.46) = (0.096, 0.154, 0.092)

可以看出,其中反射出去的光中绿光是最强的。每一个像素的颜色在环境光跟材质作用的结果都这样去计算,我们就能看到整个山峰看起来就是绿色的(不过因为环境光本来就弱,所以整体结果都偏暗)。再加上漫反射和高光的作用的话,物体就能呈现出更高的亮度和更好的质感,不过后两者可不是简单地进行颜色分量的乘法(稍后详细分析)。

我们注意到平行光的光照方向如下所示:

mDirLight.Direction = XMFLOAT3(0.57735f, -0.57735f, 0.57735f);

因为三个分量的绝对值相等,根据其在三维空间中的方向,我们知道本程序平行光的方向应该是跟网格地形(正方形)成 45° 沿着某一对角照射:

平行光方向
图二:平行光方向

即平行光方向向量需要满足 (a, -a, a) 的形式,而方向向量又要是单位向量,则有:

  • a2+(a)2+a2=1a^2 + (-a)^2 + a^2 = 1

a2=13a^2 = \frac{1}{3},求解出 a0.57735a \approx 0.57735

着色

接着我们分析下顶点着色器和像素着色器,看看它是怎么把整个平行光源跟每个点(像素)作用并确认该点(像素)的最终颜色的。

先看顶点着色器:

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;

  // Transform to world space space.
  vout.PosW = mul(float4(vin.PosL, 1.0f), gWorld).xyz;
  vout.NormalW = mul(vin.NormalL, (float3x3)gWorldInvTranspose);

  // Transform to homogeneous clip space.
  vout.PosH = mul(float4(vin.PosL, 1.0f), gWorldViewProj);

  return vout;
}

除了必要的把顶点从局部空间(PosL)转换成齐次裁剪空间(PosH)以外,还额外做了两个事情:

  1. 保存了顶点在世界空间中的坐标(用于在像素着色器中计算顶点到相机的方向:观测方向)
  2. 对局部空间中的法线变换为其在世界空间中的方向

其中法线不能像顶点那样直接做世界变换是因为如果模型应用了一个不成比例的缩放变换,那么在世界变换后的法线将不再垂直于 Shading point 表面,具体分析见:法线向量。而初始法线的计算由山峰和河谷各自的算法完成,这里不展开说。

再看像素着色器:

float4 PS(VertexOut pin) : SV_Target {
  // Interpolating normal can unnormalize it, so normalize it.
  pin.NormalW = normalize(pin.NormalW);

  // 观测方向
  float3 toEyeW = normalize(gEyePosW - pin.PosW);

  // Start with a sum of zero.
  float4 ambient = float4(0.0f, 0.0f, 0.0f, 0.0f);
  float4 diffuse = float4(0.0f, 0.0f, 0.0f, 0.0f);
  float4 spec = float4(0.0f, 0.0f, 0.0f, 0.0f);

  // Sum the light contribution from each light source.
  float4 A, D, S;

  ComputeDirectionalLight(gMaterial, gDirLight, pin.NormalW, toEyeW, A, D, S);
  ambient += A; // 进入相机(人眼)的环境光
  diffuse += D; // 进入相机(人眼)的漫反射光
  spec += S; // 进入相机(人眼)的高光

  // 先注释掉点光和聚光的效果
  //
  //ComputePointLight(gMaterial, gPointLight, pin.PosW, pin.NormalW, toEyeW, A, D,
  //                  S);
  //ambient += A;
  //diffuse += D;
  //spec += S;

  //ComputeSpotLight(gMaterial, gSpotLight, pin.PosW, pin.NormalW, toEyeW, A, D,
  //                 S);
  //ambient += A;
  //diffuse += D;
  //spec += S;

  float4 litColor = ambient + diffuse + spec;

  // Common to take alpha from diffuse material.
  litColor.a = gMaterial.Diffuse.a;

  return litColor;
}

从像素着色器的代码可以看到,确认颜色的关键在于 ComputeDirectionalLight() 函数的处理:

//---------------------------------------------------------------------------------------
// Computes the ambient, diffuse, and specular terms in the lighting equation
// from a directional light.  We need to output the terms separately because
// later we will modify the individual terms.
//---------------------------------------------------------------------------------------
void ComputeDirectionalLight(Material mat, DirectionalLight L, float3 normal,
                             float3 toEye, out float4 ambient,
                             out float4 diffuse, out float4 spec) {
  // Initialize outputs.
  ambient = float4(0.0f, 0.0f, 0.0f, 0.0f);
  diffuse = float4(0.0f, 0.0f, 0.0f, 0.0f);
  spec = float4(0.0f, 0.0f, 0.0f, 0.0f);

  // 光照向量方向跟光的传播方向相反
  // The light vector aims opposite the direction the light rays travel.
  float3 lightVec = -L.Direction;

  // 这里就是前面说的环境光与材质分量颜色相乘,以此确认反射的环境光
  // Add ambient term.
  ambient = mat.Ambient * L.Ambient;

  // Add diffuse and specular term, provided the surface is in
  // the line of site of the light.

  float diffuseFactor = dot(lightVec, normal);

  // Flatten to avoid dynamic branching.
  [flatten] if (diffuseFactor > 0.0f) {
    float3 v = reflect(-lightVec, normal);
    float specFactor = pow(max(dot(v, toEye), 0.0f), mat.Specular.w);

    diffuse = diffuseFactor * mat.Diffuse * L.Diffuse;
    spec = specFactor * mat.Specular * L.Specular;
  }
}

我们重点看下漫反射和高光。有了 Games-101-Illumination 的基础,我们知道漫反射的计算:

diffuse = diffuseFactor * mat.Diffuse * L.Diffuse;

对应的是:

  • Ld=kd(I/r2)max(0,nl)L_d = k_{d}(I/r^2)max(0, \overrightarrow{n}\cdot\overrightarrow{l})

其中 kd(I/r2)k_{d}(I/r^2) 相当于 mat.Diffuse * L.Diffuse,而 nl\overrightarrow{n}\cdot\overrightarrow{l} 相当于 diffuseFactor = dot(lightVec, normal)

类似的,高光的计算也是一一对应的:

spec = specFactor * mat.Specular * L.Specular;

对应的是:

  • Ls=ks(I/r2)max(0,nh)pL_s = k_{s}(I/r^2)max(0, \overrightarrow{n}\cdot\overrightarrow{h})^p

其中 ks(I/r2)k_{s}(I/r^2) 相当于 mat.Specular * L.Specular,而 (nh)p(\overrightarrow{n}\cdot\overrightarrow{h})^p 相当于 specFactor = pow(max(dot(v, toEye), 0.0f), mat.Specular.w)。这里也体现了材质结构中 Specular 的第四个分量的作用就是指数 pp:控制高光的大小。

整个平行光跟山峰和河谷作用的结果如下:

平行光下的山峰河谷
图三:平行光下的山峰河谷
* 未经同意不得转载。