Lighting, Ambient, Diffuse and Specular (Metal Part 16)
Defining light objects with properties like position, color, intensity, range. Collecting and passing down to the GPU all light information. Defining standard surface values in the material, like color and glossiness. Accessing the material and lights information from the corresponding buffers in the lit fragment shader. Defining the structure of the mathematical model of Phong shading. Calculating ambient illumination using the color, intensity and attenuation. Calculating the diffuse using the dot product between the normals and the light direction. Calculating the specular by refracting the light direction along the normals, and doing the dot product by the direction to the camera.
Source Code
References
- Metal Render Pipeline tutorial series by Rick Twohy
- MSL: const, constant and device
- C arrays behave as pointers
- C Pointers
Table of Content
Light Component
Will be in charge of keeping the position of the light, by reusing the transform object. Also generates the Light Data whenever requested.
1enum LightType { 2 case Point 3 case Directional 4} 5 6class Light : Component { 7 8 public var type: LightType = LightType.Point 9 10 public var intensity: Float = 1 11 public var ambient: Float = 0.3 12 public var color: float4 = Colors.White 13 14 public var range: Float = 0.6 15 16 private var _data: LightData = LightData() 17 public var data: LightData! { 18 19 _data.position = gameObject.position 20 _data.color = color 21 _data.intensity = intensity 22 _data.ambient = ambient 23 _data.range = range 24 25 return _data 26 } 27}
Light Data
Is just a struct that can be passed down to the GPU with the relevant Light information.
1struct LightData: sizeable { 2 var position: float3 = float3(repeating: 0) 3 var color: float4 = Colors.White 4 var intensity: Float = 1 5 var ambient: Float = 0.3 6 var range: Float = 0.6 7}
The GPU will also define a struct to access the data from the buffer.
1struct LightData { 2 float3 position; 3 float4 color; 4 float intensity; 5 float ambient; 6 float range; 7};
Light Manager
Each time a light component is added to a game object, the light manager keeps track of it.
1class GameObject : Transform { 2 private var _components: [Component]! = [] 3 4 public func addComponent(_ component: Component){ 5 _components.append(component) 6 component.setGameObject(self) 7 8 // set the camera as the main camera 9 if let camera = component as? Camera { 10 CameraManager.mainCamera = camera 11 } 12 13 // keep track of the lights 14 if let light = component as? Light { 15 LightManager.addLight(light) 16 } 17 } 18}
The Light Manager is in charge of generating the buffer with all the Light Data.
It's very inefficient to do this on each cycle, but for siplicity we will keep it like this for now.
1class LightManager { 2 public static var lightsBuffer: MTLBuffer? { 3 if(_lights.count == 0) { return nil } 4 5 _lightDatas = [] 6 for light in _lights { 7 _lightDatas.append(light.data) 8 } 9 10 // ineficcient, but will do for now 11 // we need an updated position of the lights 12 return Engine.device.makeBuffer(bytes: _lightDatas, length: LightData.stride * _lightDatas.count , options: []) 13 } 14 15 public static var lightsCount: Int! { 16 return _lights.count 17 } 18 19 private static var _lightDatas: [LightData]! = [] 20 private static var _lights: [Light]! = [] 21 private static var _lightsBuffer: MTLBuffer! 22 23 static func addLight(_ light: Light) { 24 _lights.append(light) 25 } 26}
Scene
Now also sets the Light Data buffer whenever there are lights on the scene.
1override func render() { 2 3 updateSceneConstants() 4 5 // set the view matrix and projection matrix 6 Graphics.renderCommandEncoder.setVertexBytes(&_sceneConstants, length: SceneConstants.stride, index: 2) 7 8 // set light data 9 if LightManager.lightsCount > 0 { 10 var lightsCount = LightManager.lightsCount 11 Graphics.renderCommandEncoder.setFragmentBuffer(LightManager.lightsBuffer, offset: 0, index: 1) 12 Graphics.renderCommandEncoder.setFragmentBytes(&lightsCount, length: Int32.stride, index: 2) 13 } 14 15 super.render() 16}
Material
Materials now also have some common surface properties, like color and glossiness. In the future they can also have standard surface properties like emission, metallic, albedo, normal maps, etc.
By default, Materials set their values to the GPU in the fragment buffer 0.
1class Material { 2 public var materialData: MaterialData = MaterialData() 3 4 public var vertexFunctionName: String = VertexFunctionNames.Basic 5 public var fragmentFunctionName: String = FragmentFunctionNames.VertexColor 6 7 public var renderPipelineStateId: String { 8 return "\(vertexFunctionName)/\(fragmentFunctionName)" 9 } 10 11 func setColor(_ color: float4){ 12 materialData.color = color 13 } 14 15 func setGlossiness(_ glossiness: Float){ 16 materialData.glossiness = glossiness 17 } 18 19 func setGpuValues() { 20 Graphics.renderCommandEncoder.setFragmentBytes(&materialData, length: MaterialData.stride, index: 0) 21 } 22}
Material Data
Is just a struct that can be passed down to the GPU with the relevant Material values.
1struct MaterialData: sizeable { 2 public var color: float4 = Colors.White 3 public var glossiness: Float = 2 4}
The GPU will also define a struct to access the data from the buffer.
1struct MaterialData { 2 float4 color; 3 float glossiness; 4};
Lit Shader
The general structure of the lit shader that samples textures will be like below. It takes in the material properties to access the glossiness for the specular calculation. The lights will come in an array, each with their poisition and other relevant data.
The fragment shader will also have access to the camera position, and the normals of the fragments.
1fragment half4 lit_texture_sample_fragment_shader( 2 const FragmentData IN [[ stage_in ]], 3 4 constant MaterialData & materialData [[ buffer(0) ]], 5 6 constant LightData * lights [[ buffer(1) ]], 7 constant int & lightsCount [[ buffer(2) ]], 8 9 // sampler and texture2d coming in their corresponding memory blocks 10 sampler sampler2d [[ sampler(0) ]], 11 texture2d<float> texture [[ texture(0) ]] 12){ 13 // sample texture 14 float4 color = texture.sample(sampler2d, IN.uv); 15 16 for(int i = 0; i < lightsCount; i++){ 17 LightData light = lights[i]; 18 19 // TODO: CALCULATE PHONG LIGHTING 20 } 21 22 float4 phong = totalAmbient + totalDiffuse + totalSpecular; 23 color = color * phong; 24 25 return half4(color.r, color.g, color.b, color.a); 26}
Attenuation
We calculate a linear attenuation to make the light intensity fade away the farther away we are from the light, given a range.
1LightData light = lights[i]; 2 3// light direction 4float4 lightDir = float4(light.position.xyz, 1) - IN.worldPosition; 5 6// attenuation 7float distanceToLight = length(lightDir); 8float attenuation = 1 - clamp(distanceToLight/light.range, 0.0, 1.0);
Ambient
The ambient is calculated using the ambient intensity of the light, its color and the attenuation.
1// ambient 2float4 ambient = light.color * light.ambient * attenuation;
Diffuse
The diffuse is calculated by doing the DOT product between the surface normal and the light direction vector. The more the surface is to a 90 degree to the light direction, or past it facing away from the light, the darker it's going to be.
1// diffuse 2float nDotL = max(dot(normalize(IN.worldNormal), normalize(lightDir)), 0.0); 3float4 diffuse = light.color * nDotL * light.intensity * attenuation;
Specular
The specular is calculated by reflecting the light direction by the surface normal vector. Then doing the DOT product between the reflected light direction and the view direction.
The more the rays are pointing to the camera, the brighter.
Finally we do a power by the glossiness defined in the material, the more glossiness, the sharper the reflections will be.
1// specular 2float3 viewDir = IN.cameraPosition - IN.worldPosition.xyz; 3float3 reflectedLightDir = reflect(-normalize(lightDir.xyz), normalize(IN.worldNormal.xyz)); 4float vDotL = max(0.0, dot(reflectedLightDir, normalize(viewDir))); // avoid negative values 5vDotL = pow(vDotL, materialData.glossiness); 6float4 specular = light.color * vDotL * light.intensity * attenuation;
Result
The 3D model of Samus is now correclty illuminated with ambient, diffuse and specular components.