Metal 练习:第四篇-Lighting
此篇练习是基于前一篇 Metal 练习:第三篇-添加Texture 的拓展
此篇练习完成后,将会学到如何给立方体添加Lighting,过程中还会学到:
- 一些基本光照概念
- “冯式”光照模型组成
- 使用着色器如何在场景中为每个点计算光照效果
第一步
首先我们要理解光照如何工作。“光照”是指将光源产生的光应用到渲染对象上。光源(如太阳或灯)产生光,这些光的光线与环境发生碰撞并照亮环境。我们的眼睛可以看到环境,然后有一个图像渲染在我们眼睛的视网膜上。在真实环境中,有各种各样的光源。光源工作像下图:
光线从光源的各个方向发出。在这篇练习中,我们将使用一个平行光线的光源,就像太阳一样,被称为定向光,通常在3D游戏中使用。
冯式光照模型
根据光源有多种算法用来着色对象,但最流行的一种被称为冯式照明模型。这种模型之所以流行是有原因的,它不仅很容易实现和理解,而且性能也很好,效果看起来也很棒。此模型由三部分组成:
- 环境光照:表示光从各个方向射到物体上,你可以把它想像成光线在房间里反弹
- 漫射光照:表示根据物体与光源的角度而变亮或变暗的光,在这一个部分中,我认为这是视觉效果中最重要的部分
- 反射光照:表示在直接面对光源的小区域引起一个明亮的光点,你可以把它想成一块闪亮的金属上的一个亮点
项目搭建
打开Metal 练习:第三篇-添加Texture工程,运行程序,一个3D的立方体,看起来非常棒,除了立方体区域都是均匀的,所以看起来有点平,我们将通过照明的力量来改善图像。
环境光照概述
首先要记住,环境光照会以相同的数量突出场景中的所有表面,无论表面位于何处,表面面对的方向,或者光的方向是什么。计算环境光照,需要两个参数:
- 光的颜色:光可以有不同的颜色,例如:一个红色的光,会将物体染成红色,但通常会选择白光,因为不会给物体染色
- 环境密度:这个值表示光的强度,值超高,场景的照明越亮
// 有上面两个值后,就可以像下面的公式计算环境光照
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<Float>.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.swift
的init()
方法中
// 找到下面这句
self.bufferProvider = BufferProvider(device: device, inflightBuffersCount: 3, sizeOfUniformsBuffer: MemoryLayout<Float>.size * Matrix4.numberOfElements() * 2)
// 替换为下面代码
let sizeOfUniformsBuffer = MemoryLayout<Float>.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<Float>.size * Matrix4.numberOfElements())
memcpy(bufferPointer + MemoryLayout<Float>.size * Matrix4.numberOfElements(), projectionMatrix.raw(), MemoryLayout<Float>.size*Matrix4.numberOfElements())
// 紧接上面代码添加下面代码,将光数据拷贝到uniform buffer中
memcpy(bufferPointer + 2 * MemoryLayout<Float>.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<float> 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
漫射光照:如果法线向量面对光源,漫射光越亮,法线向外倾斜,漫射光越弱。计算漫射光,需要三个参数
- 光颜色:需要光的颜色,像环境光照一样,此处也用白色
- 漫射强度:值越大,漫射的效果越强
- 漫射因子:光方向向量与顶点法线的点积,两个向量角度越小,值越高,漫射效果超强
计算漫射光的公式: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<Float>.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)。现在的问题是反射向量与指向相机的向量有多近?
- 越多的反射向量朝向相机,这个点就有越多的光泽
- 反射向量离相机越远,片段就会变得越暗。
反射因子计算: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<Float>.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