Metal 练习:第四篇-Lighting


Metal 练习:第四篇-Lighting

此篇练习是基于前一篇 的拓展

此篇练习完成后,将会学到如何给立方体添加Lighting,过程中还会学到:

  • 一些基本光照概念
  • “冯式”光照模型组成
  • 使用着色器如何在场景中为每个点计算光照效果

第一步

首先我们要理解光照如何工作。“光照”是指将光源产生的光应用到渲染对象上。光源(如太阳或灯)产生光,这些光的光线与环境发生碰撞并照亮环境。我们的眼睛可以看到环境,然后有一个图像渲染在我们眼睛的视网膜上。在真实环境中,有各种各样的光源。光源工作像下图:

光线从光源的各个方向发出。在这篇练习中,我们将使用一个平行光线的光源,就像太阳一样,被称为定向光,通常在3D游戏中使用。

冯式光照模型

根据光源有多种算法用来着色对象,但最流行的一种被称为冯式照明模型。这种模型之所以流行是有原因的,它不仅很容易实现和理解,而且性能也很好,效果看起来也很棒。此模型由三部分组成:

  1. 环境光照:表示光从各个方向射到物体上,你可以把它想像成光线在房间里反弹
  2. 漫射光照:表示根据物体与光源的角度而变亮或变暗的光,在这一个部分中,我认为这是视觉效果中最重要的部分
  3. 反射光照:表示在直接面对光源的小区域引起一个明亮的光点,你可以把它想成一块闪亮的金属上的一个亮点

项目搭建

打开工程,运行程序,一个3D的立方体,看起来非常棒,除了立方体区域都是均匀的,所以看起来有点平,我们将通过照明的力量来改善图像。

环境光照概述

首先要记住,环境光照会以相同的数量突出场景中的所有表面,无论表面位于何处,表面面对的方向,或者光的方向是什么。计算环境光照,需要两个参数:

  1. 光的颜色:光可以有不同的颜色,例如:一个红色的光,会将物体染成红色,但通常会选择白光,因为不会给物体染色
  2. 环境密度:这个值表示光的强度,值超高,场景的照明越亮
// 有上面两个值后,就可以像下面的公式计算环境光照
Ambient color = Light color * Ambient intensity

添加环境光照

创建一个Light
struct Light {

    var color: (Float, Float, Float)  // 1. 存储光的颜色(r,g,b)
    var ambientIntensity: Float       // 2. 存储环境效果的强度
    
    static func size() -> Int {       // 3. 获取当前结构体的大小
        return MemoryLayout.size * 4
    }
    
    func raw() -> [Float] {
        let raw = [color.0, color.1, color.2, ambientIntensity] // 4. 将当前结构体转换成[Float]
        return raw
    }
}

Node.swift中的属性中加上下面代码

// 创建一个白色光,强度为 0.2
let light = Light(color: (1.0, 1.0, 1.0), ambientIntensity: 0.2)
传递光数据给GPU

Node.swiftinit()方法中

// 找到下面这句
self.bufferProvider = BufferProvider(device: device, inflightBuffersCount: 3, sizeOfUniformsBuffer: MemoryLayout.size * Matrix4.numberOfElements() * 2)
// 替换为下面代码
let sizeOfUniformsBuffer = MemoryLayout.size * Matrix4.numberOfElements() * 2 + Light.size()
self.bufferProvider = BufferProvider(device: device, inflightBuffersCount: 3, sizeOfUniformsBuffer: sizeOfUniformsBuffer)

上面增加了空间给光数据,找到BufferProvider.swift

// 找到函数
func nextUniformsBuffer(projectionMatrix: Matrix4, modelViewMatrix: Matrix4) -> MTLBuffer {
// 替换为下面代码
func nextUniformsBuffer(projectionMatrix: Matrix4, modelViewMatrix: Matrix4, light: Light) -> MTLBuffer {
// 给函数增加一个光参数,接着在这个方法内部,找到下面几行
memcpy(bufferPointer, modelViewMatrix.raw(), MemoryLayout.size * Matrix4.numberOfElements())
memcpy(bufferPointer + MemoryLayout.size * Matrix4.numberOfElements(), projectionMatrix.raw(), MemoryLayout.size*Matrix4.numberOfElements())
// 紧接上面代码添加下面代码,将光数据拷贝到uniform buffer中
memcpy(bufferPointer + 2 * MemoryLayout.size * Matrix4.numberOfElements(), light.raw(), Light.size())
修改着色器来接收光数据

打开Shaders.metal并在VertexOut结构体下添加一个Light结构体

struct Light {
    packed_float3 color;
    float ambientIntensity;
};

修改Uniforms包含Light

struct Uniforms{
    float4x4 modelMatrix;
    float4x4 projectionMatrix;
    Light light;
};

到这里,顶点着色器可以访问光数据,然后,片段着色器也需要这些数据,因此修改片段着色器的声明

fragment float4 basic_fragment(VertexOut interpolated [[stage_in]],
                               const device Uniforms& uniforms  [[buffer(1)]],
                               texture2d  tex2D     [[ texture(0) ]],
                               sampler           sampler2D [[ sampler(0) ]]) {

打开Node.swift,在func render(commandQueue: MTLCommandQueue, pipelineState: MTLRenderPipelineState, drawable: CAMetalDrawable, parentModelViewMatrix: Matrix4, projectionMatrix: Matrix4, clearColor: MTLClearColor?) {方法中

// 找到下面这行
renderEncoder.setVertexBuffer(uniformBuffer, offset: 0, at: 1)
// 紧接下面添加代码,不仅将uniform buffer作为参数传递给顶点着色器,也传递给片段着色器
renderEncoder.setFragmentBuffer(uniformBuffer, offset: 0, index: 1)
// 这里看到上面创建uniformBuffer缺少一个light参数,补上
let uniformBuffer = bufferProvider.nextUniformsBuffer(projectionMatrix: projectionMatrix, modelViewMatrix: nodeModelMatrix, light: light)
添加环境光照计算

Shaders.metal中的片段着色器的最顶部加上下面代码

// 从uniforms中获取光照数据,并使用值计算 ambientColor
Light light = uniforms.light;
float4 ambientColor = float4(light.color * light.ambientIntensity, 1);
// 然后将片段着色器的返回替换为下面代码
return color * ambientColor;

Run一下看看效果吧!!!

运行成功后,你发现物体是黑暗的,但就是这样的。接下就是添加一些环境光,让物体稍微突出点。为什么背景的绿颜色没有变呢?答案是:顶点着色器运行在所有几何场景下,但背景不是。事实上,它不是背景,它只是GPU在没有绘制任何东西的地方使用的一个恒定颜色。

// 修改Node.swift的render方法中设置背景色的代码,将背景置为黑色,将下面的代码
renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColor(red: 0.0, green: 104.0/255.0, blue: 5.0/255.0, alpha: 1.0)
// 替换为
renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColor(red: 0.0, green: 0.0, blue: 0.0, alpha: 1.0)

漫射光照概述

要计算漫射光照,你需要知道每个顶点面对的方向,要做到这点需要通过将一条法线与每个顶点联系起来。

法线:它是一个垂直于点所在平面的向量。这里我们会将每个顶点的法线存储在Vertex结构体中

点积:它是两个向量之间的数学函数,例如:

两个向量平行:点积为 1
两个向量方向相反: 点积为 -1
两个向量垂直: 点积为 0

漫射光照:如果法线向量面对光源,漫射光越亮,法线向外倾斜,漫射光越弱。计算漫射光,需要三个参数

  1. 光颜色:需要光的颜色,像环境光照一样,此处也用白色
  2. 漫射强度:值越大,漫射的效果越强
  3. 漫射因子:光方向向量与顶点法线的点积,两个向量角度越小,值越高,漫射效果超强
    计算漫射光的公式:Diffuse Color = Light Color * Diffuse Intensity * Diffuse factor
添加法线数据

Vertex.swift中做如下修改

// 增加以下属性
var nX, nY, nZ: Float 
// 修改floatBuffer方法
func floatBuffer() -> [Float] {
    return [x, y, z, r, g, b, a, s, t, nX, nY, nZ]
}
// 将Cubic中创建ABCEEFGHIJKLMNOP点的代码用下面的替换
//Front
let A = Vertex(x: -1.0, y:   1.0, z:   1.0, r:  1.0, g:  0.0, b:  0.0, a:  1.0, s: 0.25, t: 0.25, nX: 0.0, nY: 0.0, nZ: 1.0)
let B = Vertex(x: -1.0, y:  -1.0, z:   1.0, r:  0.0, g:  1.0, b:  0.0, a:  1.0, s: 0.25, t: 0.50, nX: 0.0, nY: 0.0, nZ: 1.0)
let C = Vertex(x:  1.0, y:  -1.0, z:   1.0, r:  0.0, g:  0.0, b:  1.0, a:  1.0, s: 0.50, t: 0.50, nX: 0.0, nY: 0.0, nZ: 1.0)
let D = Vertex(x:  1.0, y:   1.0, z:   1.0, r:  0.1, g:  0.6, b:  0.4, a:  1.0, s: 0.50, t: 0.25, nX: 0.0, nY: 0.0, nZ: 1.0)

//Left
let E = Vertex(x: -1.0, y:   1.0, z:  -1.0, r:  1.0, g:  0.0, b:  0.0, a:  1.0, s: 0.00, t: 0.25, nX: -1.0, nY: 0.0, nZ: 0.0)
let F = Vertex(x: -1.0, y:  -1.0, z:  -1.0, r:  0.0, g:  1.0, b:  0.0, a:  1.0, s: 0.00, t: 0.50, nX: -1.0, nY: 0.0, nZ: 0.0)
let G = Vertex(x: -1.0, y:  -1.0, z:   1.0, r:  0.0, g:  0.0, b:  1.0, a:  1.0, s: 0.25, t: 0.50, nX: -1.0, nY: 0.0, nZ: 0.0)
let H = Vertex(x: -1.0, y:   1.0, z:   1.0, r:  0.1, g:  0.6, b:  0.4, a:  1.0, s: 0.25, t: 0.25, nX: -1.0, nY: 0.0, nZ: 0.0)

//Right
let I = Vertex(x:  1.0, y:   1.0, z:   1.0, r:  1.0, g:  0.0, b:  0.0, a:  1.0, s: 0.50, t: 0.25, nX: 1.0, nY: 0.0, nZ: 0.0)
let J = Vertex(x:  1.0, y:  -1.0, z:   1.0, r:  0.0, g:  1.0, b:  0.0, a:  1.0, s: 0.50, t: 0.50, nX: 1.0, nY: 0.0, nZ: 0.0)
let K = Vertex(x:  1.0, y:  -1.0, z:  -1.0, r:  0.0, g:  0.0, b:  1.0, a:  1.0, s: 0.75, t: 0.50, nX: 1.0, nY: 0.0, nZ: 0.0)
let L = Vertex(x:  1.0, y:   1.0, z:  -1.0, r:  0.1, g:  0.6, b:  0.4, a:  1.0, s: 0.75, t: 0.25, nX: 1.0, nY: 0.0, nZ: 0.0)

//Top
let M = Vertex(x: -1.0, y:   1.0, z:  -1.0, r:  1.0, g:  0.0, b:  0.0, a:  1.0, s: 0.25, t: 0.00, nX: 0.0, nY: 1.0, nZ: 0.0)
let N = Vertex(x: -1.0, y:   1.0, z:   1.0, r:  0.0, g:  1.0, b:  0.0, a:  1.0, s: 0.25, t: 0.25, nX: 0.0, nY: 1.0, nZ: 0.0)
let O = Vertex(x:  1.0, y:   1.0, z:   1.0, r:  0.0, g:  0.0, b:  1.0, a:  1.0, s: 0.50, t: 0.25, nX: 0.0, nY: 1.0, nZ: 0.0)
let P = Vertex(x:  1.0, y:   1.0, z:  -1.0, r:  0.1, g:  0.6, b:  0.4, a:  1.0, s: 0.50, t: 0.00, nX: 0.0, nY: 1.0, nZ: 0.0)

//Bot
let Q = Vertex(x: -1.0, y:  -1.0, z:   1.0, r:  1.0, g:  0.0, b:  0.0, a:  1.0, s: 0.25, t: 0.50, nX: 0.0, nY: -1.0, nZ: 0.0)
let R = Vertex(x: -1.0, y:  -1.0, z:  -1.0, r:  0.0, g:  1.0, b:  0.0, a:  1.0, s: 0.25, t: 0.75, nX: 0.0, nY: -1.0, nZ: 0.0)
let S = Vertex(x:  1.0, y:  -1.0, z:  -1.0, r:  0.0, g:  0.0, b:  1.0, a:  1.0, s: 0.50, t: 0.75, nX: 0.0, nY: -1.0, nZ: 0.0)
let T = Vertex(x:  1.0, y:  -1.0, z:   1.0, r:  0.1, g:  0.6, b:  0.4, a:  1.0, s: 0.50, t: 0.50, nX: 0.0, nY: -1.0, nZ: 0.0)

//Back
let U = Vertex(x:  1.0, y:   1.0, z:  -1.0, r:  1.0, g:  0.0, b:  0.0, a:  1.0, s: 0.75, t: 0.25, nX: 0.0, nY: 0.0, nZ: -1.0)
let V = Vertex(x:  1.0, y:  -1.0, z:  -1.0, r:  0.0, g:  1.0, b:  0.0, a:  1.0, s: 0.75, t: 0.50, nX: 0.0, nY: 0.0, nZ: -1.0)
let W = Vertex(x: -1.0, y:  -1.0, z:  -1.0, r:  0.0, g:  0.0, b:  1.0, a:  1.0, s: 1.00, t: 0.50, nX: 0.0, nY: 0.0, nZ: -1.0)
let X = Vertex(x: -1.0, y:   1.0, z:  -1.0, r:  0.1, g:  0.6, b:  0.4, a:  1.0, s: 1.00, t: 0.25, nX: 0.0, nY: 0.0, nZ: -1.0)

Run一下看看效果吧!!!是不是乱七八糟!!!!!!!

此时应该是Shader.metal文件解析数据出现了问题,传递法线数据给GPU时,顶点结构体包含了法线数据,但着色器并不期望这些数据。因此,在着色器读取下一个顶点的位置数据时,读到的是前一个顶点的法线数据,所以出现了奇怪的现象。

修复上面的情况,要在Shaders.metal中的VertexIn中添加下面代码

packed_float3 normal;
添加漫射光照数据
// 在Shaders.metal的Light结构体中最底下添加
packed_float3 direction;
float diffuseIntensity;
// 在Light.swift文件中,添加两个属性
var direction: (Float, Float, Float)
var diffuseIntensity: Float
// 及对size和raw方法做出相应的修改
static func size() -> Int {
  return MemoryLayout.size * 8
} 
func raw() -> [Float] {
  let raw = [color.0, color.1, color.2, ambientIntensity, direction.0, direction.1, direction.2, diffuseIntensity]
  return raw
}
// 在Node.swift中创建Light对象的地方补充参数
let light = Light(color: (1.0,1.0,1.0), ambientIntensity: 0.2, direction: (0.0, 0.0, 1.0), diffuseIntensity: 0.8)
// (0.0, 0.0, 1.0)方向向量是垂直与屏幕的, 0.8表示一个强光
添加漫射光照计算

顶点着色器中有法线数据,但需要为每个片段着色器插入法线数据,因此要将法线数据传入VertexOut

// 在VertexOut中最下面添加
float3 normal;
// 在顶点着色器中找到下面这句
VertexOut.texCoord = VertexIn.texxCoord;
// 紧跟着添加
VertexOut.normal = (mv_Matrix * float4(VertexIn.normal, 0.0)).xyz;
// 在片段着色器中,环境光照颜色后面添加
float diffuseFactor = max(0.0, dot(interpolated.normal, light.direction)); // 法线与光源方向的点积,与0相比取大值
float4 diffuseColor = float4(light.color * light.diffuseIntensity * diffuseFactor, 1.0); // 获取漫射颜色 
// 替换 return color * ambientColor 
return color * (ambientColor + diffuseColor);

Run一下看看效果吧!!!

反射光照概述

你可以把这个想象成暴露物体光泽的组件。想象一个闪亮的金属物体在明亮的灯光下,可以看到一个小而闪亮的点。计算方法像前面一样

SpecularColor = LightColor * SpecularIntensity * SpecularFactor
当然可以通过修改强度更加完美的效果,环境光照和漫射光照也可以。

上图展示了一束光线照射到一个顶点上。顶点有一个法线(n),光经过顶点反射后的方向(r)。现在的问题是反射向量与指向相机的向量有多近?

  1. 越多的反射向量朝向相机,这个点就有越多的光泽
  2. 反射向量离相机越远,片段就会变得越暗。
    反射因子计算: SpecularFactor = - (r * eye)shininess
    在得到反射向量和eye的点积后,与一个新值(shininess)相乘。shininess是一个材质参数。例如:木头物体的shininess比金属物体的要少。
添加反射光照

打开Light.swift

// 添加两个属性
var shininess: Float
var specularIntensity: Float
// 同步修改size和raw函数
static func size() -> Int { 
// 特别说明: 当前类有10个Float的属性,应该是乘10,但GPU操作内存块的大小以16字节为单位
    return MemoryLayout.size * 12
}

func raw() -> [Float] {
    let raw = [color.0, color.1, color.2, ambientIntensity, direction.0, direction.1, direction.2, diffuseIntensity, shininess, specularIntensity]
    return raw
}

打开Node.swift

// 同步更新创建light的方法
let light = Light(color: (1.0,1.0,1.0), ambientIntensity: 0.1, direction: (0.0, 0.0, 1.0), diffuseIntensity: 0.8, shininess: 10, specularIntensity: 2)

打开Shaders.metal

// 在Light结构体中添加两个属性
float shininess;
float specularIntensity;
添加反射光照计算

打开Shaders.metal

// 在VertexOut结构体的position下面添加下面代码
float3 fragmentPosition;
// 在顶点着色器方法中 `VertexOut.position = ...`下面加上
VertexOut.fragmentPosition = (mv_Matrix * float4(VertexIn.position, 1)).xyz;
// 在片段着色器中的漫射计算下面添加
float3 eye = normalize(interpolated.fragmentPosition); // 获取`eye`向量
float3 reflection = reflect(light.direction, interpolated.normal); // 计算光穿过当前片段的反射向量
float specularFactor = pow(max(0.0, dot(reflection, eye)), light.shininess); // 计算反向因子
float4 specularColor = float4(light.color * light.specularIntensity * specularFactor, 1.0); // 结合上面的值计算出颜色
// 将 color * (ambientColor + diffuseColor) 替换为
color * (ambientColor + diffuseColor + specularColor)

Well Done!!!

参考及更多资料

  • 原文:Metal Tutorial with Swift 3 Part 4: Lighting
  • Apple’s Metal For Developers Page
  • Apple’s Metal Programming Guide
  • Apple’s Metal Shading Language Guide
  • WWDC2014 For Metal
  • WWDC2015 For Metal