Chapters

Hide chapters

Metal by Tutorials

Second Edition · iOS 13 · Swift 5.1 · Xcode 11

Before You Begin

Section 0: 3 chapters
Show chapters Hide chapters

Section I: The Player

Section 1: 8 chapters
Show chapters Hide chapters

Section III: The Effects

Section 3: 10 chapters
Show chapters Hide chapters

5. Lighting Fundamentals
Written by Caroline Begbie

In this chapter, you’ll learn basic lighting. However, more importantly, you’ll learn how to manipulate data in shaders and be on the path to mastering shader artistry. Lighting, shadows, non-photorealistic rendering — these are all techniques that start with the methods you’ll learn in this chapter.

One of the simplest methods of lighting is the Phong reflection model. It’s named after Bui Tong Phong who published a paper in 1975 extending older lighting models. The idea is not to attempt duplication of light and reflection physics but to generate pictures that look realistic.

This model has been popular for over 40 years and is a great place to start learning how to fake lighting using a few lines of code. All computer images are fake, but there are more modern real-time rendering methods that model the physics of light.

In Chapter 7, “Maps & Materials,” you’ll briefly look at Physically Based Rendering (PBR), the lighting technique that your renderer will eventually use.

The starter project

Open the starter project for this chapter. There’s substantial refactoring, but no new Metal code. The resulting render is the same as from the end of the previous chapter, but the refactored code makes it easier to render more than one model.

  • Node.swift: This defines the base class for everything that needs a transform matrix. Models, camera and lights will all need position information, so they will all eventually be subclasses of Node. The transform information in Node abstracts away the matrices. So you just set the position, rotation and scale of a Node, and Node will automatically update its model matrix.

  • Camera.swift: All the code dealing with the view matrix and the projection matrix is now abstracted into a Camera class. By having Camera as a subclass of Node, you can give the camera a position which will automatically update the view matrix. This will make it easier, later on, to move about the scene or have a First or Third Person camera. In Camera, you can also update field of view and aspect ratio properties which will affect the projection matrix. A new Camera subclass, ArcballCamera, with some fancy matrix calculation, allows you to rotate the scene and zoom into it, so that you’ll be able to fully appreciate your new lighting.

  • Model.swift: Most of the code from Renderer’s init(metalView:) that set up the train model is now in the Model class. You can simply set up a Model instance with a name and Model will load up the model file into a mesh with submeshes. You’re not restricted to .obj files now either. Model I/O will also read in .usdz files. Try importing the wheelbarrow from Apple’s AR samples at https://developer.apple.com/augmented-reality/quick-look/. You’ll need to change the model’s scale to about 0.01. Not all USDZ files will work - you’ll be able to import animated models after Chapter 8, “Character Animation”.

  • Mesh.swift: So far, you have been taking the first MDLMesh and converting it to the MTKMesh that goes into the Metal vertex buffer. Some models will have more than one MDLMesh, so Mesh uses zip() to combine all the MDLMeshes and MTKMeshes to create a Mesh array held by Model.

  • Submesh.swift: Submeshes are in a class of their own. Submesh will later hold surface material and texture information.

  • VertexDescriptor.swift: Vertex descriptor creation is now an extension on MDLVertexDescriptor.

Take some time to review the above changes, as these will persist throughout the book.

Renderer now has a models property which is an array of Models. You’re no longer limited to just one train. To render a second model, you create a new instance of Model, specifying the filename. You can then append the model to Renderer’s models array and change the new model’s position, rotation and scale at any time.

You now can rotate the camera using your mouse or trackpad. ViewControllerExtension.swift is two files: one for the macOS target and one for iOS. It adds the appropriate gestures to the view. ViewControllerExtension.swift contains the handler functions to do the zooming and rotating. These update the camera’s position and rotation, which in turn updates the scene’s view matrix. On macOS, you can scroll to zoom, and click and drag to rotate the scene. On iOS, you can pinch to zoom and pan to rotate.

The project also contains an extra model: a tree. You’ll add this to your scene during the chapter.

DebugLights.swift contains some code that you’ll use later for debugging where lights are located. Point lights will draw as dots and the direction of the sun will draw as a line.

Familiarize yourself with the code and build and run the project and rotate and zoom the train with your mouse or trackpad.

Note: Experiment with projection too. When you run the app and rotate the train, you’ll see that the distant pair of wheels is much smaller than the front ones. In Renderer, in mtkView(_:drawableSizeWillChange:), change the projection field of view from 70º to 40º and rerun the app. You’ll see that the size difference is a lot less due to the narrower field of view. Remember to change the field of view back to 70º.

Representing color

The physics of light is a vast, fascinating topic with many books and a large part of the internet dedicated to it. However, in this book, you’ll learn the necessary basics to get you rendering light, color and simple shading. You can find further reading in references.markdown in the resources directory for this chapter.

In the real world, the reflection of different wavelengths of light is what gives an object its color. A surface that absorbs all light is black. Inside the computer world, pixels display color. The more pixels, the better the resolution and this makes the resulting image clearer. Each pixel is made up of subpixels. These are a predetermined single color, either red, green or blue. By turning on and off these subpixels, depending on the color depth, the screen can display most of the colors visible to the human eye.

In Swift, you can represent a color using the RGB values for that pixel. For example, float3(1, 0, 0) is a red pixel, float3(0, 0, 0) is black and float3(1, 1, 1) is white.

From a shading point of view, you can combine a red surface with a gray light by multiplying the two values together:

let result = float3(1.0, 0.0, 0.0) * float3(0.5, 0.5, 0.5) 

The result is (0.5, 0, 0), which is a darker shade of red.

For simple Phong lighting, you can use the slope of the surface. The more the surface slopes away from a light source, the darker the surface becomes.

Normals

The slope of a surface can determine how much a surface reflects light.

In the following diagram, point A is facing straight toward the sun and will receive the most amount of light; point B is facing slightly away but will still receive some light; point C is facing entirely away from the sun and shouldn’t receive any of the light.

Note: In the real world, light bounces from surface to surface; if there’s any light in the room, there will be some reflection from objects that gently lights the back surfaces of all the other objects. This is global illumination. The Phong lighting model lights each object individually and is called local illumination.

The dotted lines in the diagram are tangent to the surface. A tangent line is a straight line that best describes the slope of the curve at a point.

The lines coming out of the circle are at right angles to the tangent lines. These are called surface normals, or usually just normals. In Chapter 2, “3D Models.” you took a look at an .obj file. This file generally contains surface normal values which you can use for finding out the slope of a surface at any given point.

Note: If the .obj file does not contain surface normals, Model I/O can generate them on import using MDLMesh’s addNormals(withAttributeNamed:creaseThreshold:).

Add normals to vertex descriptor

To be able to assess the slope of the surface in the fragment function, you’ll need to send the vertex normal to the fragment function via the vertex function. You’ll add the normals to the vertex descriptor so that the vertex function can process them.

In VertexDescriptor.swift, when initializing defaultVertexDescriptor, you currently create the vertex descriptor with one attribute: the position.

The normal will be the second attribute.

In defaultVertexDescriptor, locate the comment // add the normal attribute here. After the comment, add this code:

vertexDescriptor.attributes[1] =
      MDLVertexAttribute(name: MDLVertexAttributeNormal,
                         format: .float3,
                         offset: offset,
                         bufferIndex: 0)
offset += MemoryLayout<float3>.stride

This tells the vertex descriptor to add the normal attribute as a float3 at an offset of 12 (the stride of a float3). That offset is the length of the position attribute.

The layout’s stride changes by the second float3. The vertex buffer now has its data laid out as:

[0:position, 0:normal, 1:position, 1:normal, ...]

Update the shader functions

Remember that the pipeline state uses this vertex descriptor so that the vertex function can process the attributes. You added another attribute to the vertex descriptor, so in Shaders.metal, add this to the struct VertexIn:

float3 normal [[attribute(1)]];

You’ve now matched the struct attribute 1 with the vertex descriptor attribute 1, and you will be able to access the normal attribute in the vertex function.

Currently, you’re only returning the position from the vertex function, but now you’ll need to send the repositioned normal to the fragment function. Instead of returning a float4 from the vertex function, you’ll return a struct.

Still in Shaders.metal, before the vertex function, add this struct:

struct VertexOut {
  float4 position [[position]];
  float3 normal;
};

The GPU still needs to know which of these properties is the position, so you mark position with the position attribute.

In Shaders.metal, change the vertex function to this:

vertex VertexOut 
       vertex_main(const VertexIn vertexIn [[stage_in]],
                   constant Uniforms &uniforms [[buffer(1)]])
{
  VertexOut out {
    .position = uniforms.projectionMatrix * uniforms.viewMatrix
                  * uniforms.modelMatrix * vertexIn.position,
    .normal = vertexIn.normal
  };
  return out;
}

You’re now returning both the position and the normal from the vertex function inside a struct, instead of just the position.

Now for the fragment function. Change the fragment function to:

fragment float4 fragment_main(VertexOut in [[stage_in]]) {
  return float4(in.normal, 1);
}

Here, you use the attribute [[stage_in]] to receive the VertexOut information from the vertex function. Just for fun, so that you can visualize the normal values, you return the normal value as a color, converting it to a float4.

Build and run.

That looks messed up!

As you rotate the train, parts of it seem almost transparent. The rasterizer is jumbling up the depth order of the vertices. When you look at a train from the front, you expect the back of the train to be hidden behind the front of the train so you can’t see it. However, the rasterizer does not process depth order by default, so you need to give the rasterizer the information it needs with a depth stencil state.

Depth

You may remember from Chapter 3, “The Rendering Pipeline,” that during the rendering pipeline, the Stencil Test unit checks whether fragments are visible after the fragment function. If a fragment is determined to be behind another fragment, then it’s discarded. You’ll give the render encoder an MTLDepthStencilState property that will describe how this testing should be done.

Back to Renderer.swift. First tell the view what sort of depth information to hold:

Toward the top of init(metalView:), but after setting metalView.device, add:

metalView.depthStencilPixelFormat = .depth32Float

The pipeline state that’s used by the render encoder has to have the same depth pixel format. The pipeline state is now held by Model.

So in Model.swift, in buildPipelineState(), before creating the pipeline state, add the depth information to the descriptor:

pipelineDescriptor.depthAttachmentPixelFormat = .depth32Float

In Renderer.swift, add a property for the depth stencil state:

let depthStencilState: MTLDepthStencilState

Your code won’t compile until you have completed the initialization.

Create this method to instantiate the depth stencil state:

static func buildDepthStencilState() -> MTLDepthStencilState? {
// 1
  let descriptor = MTLDepthStencilDescriptor()
// 2
  descriptor.depthCompareFunction = .less
// 3
  descriptor.isDepthWriteEnabled = true
  return
      Renderer.device.makeDepthStencilState(
          descriptor: descriptor)
}

Going through this code:

  1. You create a descriptor that you’ll use to initialize the depth stencil state, just as you did the pipeline state.
  2. You specify how to compare between the current fragment and fragments already processed. In this case, if the current fragment depth is less than the depth of the previous fragment in the framebuffer, the current fragment replaces that previous fragment.
  3. You state whether to write depth values or not. If you have multiple passes (see Chapter 14, “Multipass Rendering”), sometimes you will want to read the already drawn fragments, in which case set it to false. However, this is always true if you are drawing objects and you need them to have depth.

Call the method from init(metalView:) before super.init():

depthStencilState = Renderer.buildDepthStencilState()!

Your code should now compile.

In draw(in:), add this to the top of the method after guard:

renderEncoder.setDepthStencilState(depthStencilState)

Build and run to see your train in glorious 3D. As you rotate the train, it will appear in shades of red, green, blue and black.

Consider what you see in this render. The train is no longer a single color blue because you’re returning the normal value as a color. The normals are currently in object space, so even though the train is rotated 45º in world space, and you are updating the camera/view space as you rotate the train, the colors/normals don’t change.

When a normal points along the model’s x-axis (to the right) the value is [1, 0, 0]. That’s the same as red in RGB values. Thus the fragment is colored red for those normals pointing to the right. The normals pointing upwards are 1 on the y-axis, so the color is green. The normals pointing towards the camera are negative. When a color is [0, 0, 0] or less, it’s black. If you rotate the train, you’ll see that the normals pointing in the z direction are blue [0, 0, 1].

Although this is not a final render, returning values as colors from the fragment function is an excellent way of debugging and determining what a value is.

Now that you have normals in the fragment function, you can start manipulating colors depending on the direction they’re facing.

Hemispheric lighting

Hemispheric lighting is where half of a scene is lit in one color, and the other half in another. In the following image, the sky lights the top of the sphere and the ground lights the bottom of the sphere.

You’ll change the fragment function to light the train with this hemispheric lighting. If the normals face up, they’ll be blue; if they face down, they’ll be green; for the interim values, they’ll blend.

In Shaders.metal, replace fragment_main() with:

fragment float4 fragment_main(VertexOut in [[stage_in]]) {
  float4 sky = float4(0.34, 0.9, 1.0, 1.0);
  float4 earth = float4(0.29, 0.58, 0.2, 1.0);
  float intensity = in.normal.y * 0.5 + 0.5;
  return mix(earth, sky, intensity);
}

The function mix interpolates between the first two values depending on the third value which needs to be between 0 and 1. Your normal values are between -1 and 1, so you convert the intensity to be between 0 and 1.

Build and run to see your lit train. The top of the train is blue, and its underside is green.

You should now be starting to understand the power of the fragment shader. You can color any object precisely the way you want. In Chapter 11, “Tessellation and Terrains,” you’ll be doing a similar effect: placing snow on a terrain depending on the slope.

Hemispheric lighting isn’t very realistic, so now you’ll move on to better lighting effects.

Light types

There are several standard light options in computer graphics, each of which has their origin in the real world.

  • Directional Light: Sends light rays in a single direction. The sun is a directional light.
  • Point Light: Sends light rays in all directions like a light bulb.
  • Spotlight: Sends light rays in limited directions defined by a cone. A flashlight or a desk lamp would be a spotlight.

Directional light

A scene can have many lights. In fact, in studio photography, it would be highly unusual to have just a single light. By putting lights into a scene, you control where shadows fall and the level of darkness. You’ll add several lights to your scene through the chapter.

The first light you’ll create is the sun. The sun is a point light that puts out light in all directions, but for computer modeling, you can consider it a directional light. It’s a powerful light source a long way away. By the time the light rays reach the earth, the rays appear to be parallel. Check this outside on a sunny day — everything you can see has its shadow going in the same direction.

To define the light types, you’ll create a Light struct that both the GPU and the CPU can read, and you’ll hold an array of Lights in Renderer.

At the end of Common.h, before #endif, create an enum of the light types you’ll be using:

typedef enum {
  unused = 0,
  Sunlight = 1,
  Spotlight = 2,
  Pointlight = 3,
  Ambientlight = 4
} LightType;

Under this, add the struct that defines a light:

typedef struct {
  vector_float3 position;  
  vector_float3 color;
  vector_float3 specularColor;
  float intensity;
  vector_float3 attenuation;
  LightType type;
} Light;

You’ll learn about these properties later in the chapter.

Because you’ll have several lights, in Renderer.swift, create a method for a default light:

func buildDefaultLight() -> Light {
  var light = Light()
  light.position = [0, 0, 0]
  light.color = [1, 1, 1]
  light.specularColor = [0.6, 0.6, 0.6]
  light.intensity = 1
  light.attenuation = float3(1, 0, 0)
  light.type = Sunlight
  return light
}

Still in Renderer.swift, create a property for a sun directional light:

lazy var sunlight: Light = {
  var light = buildDefaultLight()
  light.position = [1, 2, -2]
  return light
}()

position is in world space. This will place a light to the right of the scene, and forward of the train. The train is currently placed at the world’s origin.

Under that property, create an array to hold the various lights you’ll be creating shortly:

var lights: [Light] = []

At the end of init(metalView:), add the sun to the array of lights:

lights.append(sunlight)

You’ll do all the light shading in the fragment function so you’ll need to pass the array of lights to that function. There is no way to find out the number of items in an array in Metal Shading Language, so you’ll pass this in a uniform struct.

In Common.h, before #endif, add this:

typedef struct {
  uint lightCount;
  vector_float3 cameraPosition;
} FragmentUniforms;

You’ll need the camera position property later.

Set up a new property in Renderer:

var fragmentUniforms = FragmentUniforms()

At the end of init(metalView:), add this:

fragmentUniforms.lightCount = UInt32(lights.count)

In Renderer in draw(in:), just before // render all the models in the array, add this:

renderEncoder.setFragmentBytes(&lights,
         length: MemoryLayout<Light>.stride * lights.count,
         index: 2)
renderEncoder.setFragmentBytes(&fragmentUniforms, 
         length: MemoryLayout<FragmentUniforms>.stride, 
         index: 3)

Here, you send the array of lights to the fragment function in buffer index 2 and the total count of all the lights in index 3. With all the buffer indexes floating around, the hard-coded index numbers are starting to be difficult to remember; you’ll organize this in the challenge at the end of this chapter.

You’ve now set up a sun light on the Swift side. You’ll do all the actual light calculations in the fragment function, and go into more depth about light properties.

The Phong reflection model

In the Phong reflection model, there are three types of light reflection. You’ll calculate each of these, and then add them up to produce a final color.

  • Diffuse: In theory, light coming at a surface bounces off at an angle reflected about the surface normal at that point. However, surfaces are microscopically rough, so light bounces off in all directions as the picture above indicates. This produces a diffuse color where the light intensity is proportional to the angle between the incoming light and the surface normal. In computer graphics, this model is called Lambertian reflectance named after Johann Heinrich Lambert who died in 1777. In the real-world, this diffuse reflection is generally true of dull, rough surfaces, but the surface with the most Lambertian property is human-made: Spectralon (https://en.wikipedia.org/wiki/Spectralon), which is used for optical components.

  • Specular: The smoother the surface, the shinier it is, and the light bounces off the surface in fewer directions. A mirror completely reflects off the surface normal without deflection. Shiny objects produce a visible specular highlight, and rendering specular lighting can give your viewers hints about what sort of surface an object is — whether a car is an old wreck or fresh off the sales lot.

  • Ambient: In the real-world, light bounces around all over the place, so a shadowed object is rarely entirely black. This is the ambient reflection.

A surface color is made up of an emissive surface color plus contributions from ambient, diffuse and specular. For diffuse and specular, to find out how much light the surface should receive at a particular point, all you have to do is find out the angle between the incoming light direction and the surface normal.

The dot product

Fortunately, there’s a straightforward mathematical operation to discover the angle between two vectors called the dot product.

And:

Where ||A|| means the length (or magnitude) of vector A.

Even more fortunately, both simd and Metal Shading Language have a function dot() to get the dot product, so you don’t have to remember the formulas.

As well as finding out the angle between two vectors, you can use the dot product for checking whether two vectors are pointing in the same direction.

Resize the two vectors into unit vectors — that’s vectors with a length of 1. You can do this using the normalize() function. If the unit vectors are parallel with the same direction, the dot product result will be 1. If they are parallel but opposite directions, the result will be -1. If they are at right angles (orthogonal), the result will be 0.

Looking at the previous diagram, if the yellow (sun) vector is pointing straight down, and the blue (normal) vector is pointing straight up, the dot product will be -1. This value is the cosine angle between the two vectors. The great thing about cosines is that they are always values between -1 and 1 so you can use this range to determine how bright the light should be at a certain point.

Take the following example:

The sun is pouring down from the sky with a direction vector of [2, -2, 0]. Vector A is a normal vector of [-2, 2, 0]. The two vectors are pointing in opposite directions, so when you turn the vectors into unit vectors (normalize them), the dot product of them will be -1.

Vector B is a normal vector of [0.3, 2, 0]. Sunlight is a directional light, so uses the same direction vector. Sunlight and B when normalized have a dot product of -0.59.

This playground code demonstrates the calculations.

Note: The result after line 8 shows that you should always be careful when using floating points, as results are never exact. Never use an expression such as if (x == 1.0) - always check <= or >=.

In the fragment shader, you’ll be able to take these values and multiply the fragment color by the dot product to get the brightness of the fragment.

Diffuse reflection

In this app, shading from the sun does not depend on where the camera is. When you rotate the scene, you’re rotating the world, including the sun. The sun’s position will be in world space, and you’ll put the model’s normals into the same world space to be able to calculate the dot product against the sunlight direction. You can choose any space, as long as you are consistent and are sure to calculate with vectors and positions in the same space.

To be able to assess the slope of the surface in the fragment function, you’ll reposition the normals in the vertex function in much the same way as you repositioned the vertex position earlier. You’ll add the normals to the vertex descriptor so that the vertex function can process them.

In Shaders.metal, change VertexOut to:

struct VertexOut {
  float4 position [[position]];
  float3 worldPosition;
  float3 worldNormal;
};

The last two properties, worldPosition and worldNormal, will hold the vertex position and normal in world space.

In vertex_main() remove:

.normal = vertexIn.normal

You’ll replace the fragment function in a moment, so don’t worry about the compiler error there.

Calculating the new position of normals is a bit different from the vertex position calculation. MathLibrary.swift contains a matrix method to create a normal matrix from another matrix. This normal matrix is a 3×3 matrix, because firstly, you’ll do lighting in world space which doesn’t need projection, and secondly, translating an object does not affect the slope of the normals. Therefore, you don’t need the fourth W dimension. However, if you scale an object in one direction (non-linearly), then the normals of the object are no longer orthogonal and this approach won’t work. As long as you decide that your engine does not allow non-linear scaling, then you can use the upper-left 3×3 portion of the model matrix, and that’s what you’ll do here.

In Common.h, add this matrix property to Uniforms:

matrix_float3x3 normalMatrix;

This will hold the normal matrix in world space.

In Renderer.swift, in draw(in:), near the top of the for model in models loop, after you’ve set uniforms.modelMatrix, add this:

uniforms.normalMatrix = uniforms.modelMatrix.upperLeft

This creates the normal matrix from the model matrix.

In Shaders.metal, in vertex_main, when defining out, populate the VertexOut properties:

.worldPosition = (uniforms.modelMatrix * vertexIn.position).xyz,
.worldNormal = uniforms.normalMatrix * vertexIn.normal

Here, you convert the vertex position and normal to world space.

Earlier in the chapter, you sent Renderer’s lights array to the fragment function in index 2, but you haven’t changed the fragment function to receive the array. Change the fragment function to the following:

fragment float4 fragment_main(VertexOut in [[stage_in]],
// 1
    constant Light *lights [[buffer(2)]],
    constant FragmentUniforms &fragmentUniforms [[buffer(3)]]) {
  float3 baseColor = float3(0, 0, 1);
  float3 diffuseColor = 0;
  // 2
  float3 normalDirection = normalize(in.worldNormal);
  for (uint i = 0; i < fragmentUniforms.lightCount; i++) {
    Light light = lights[i];
    if (light.type == Sunlight) {
      float3 lightDirection = normalize(-light.position);
      // 3
      float diffuseIntensity = 
              saturate(-dot(lightDirection, normalDirection));
      // 4
      diffuseColor += light.color 
                        * baseColor * diffuseIntensity;
    }
  }
  // 5
  float3 color = diffuseColor;
  return float4(color, 1);
}

Going through this code:

  1. You accept the lights into constant space. You also make the base color of the train blue again.
  2. You get the light’s direction vector from the light’s position and turn the direction vectors into unit vectors so that both the normal and light vectors have a length of 1.
  3. You get the dot product of the two vectors. When the fragment fully points toward the light, the dot product will be -1. It’s easier for further calculation to make this value positive, so you negate the dot product. saturate() makes sure the value is between 0 and 1 by clamping the negative numbers. This gives you the slope of the surface, and therefore the intensity of the diffuse factor.
  4. Multiply the blue color by the diffuse intensity to get the diffuse shading.
  5. Set the final color to the diffuse color. Shortly this value will include ambient, specular and other lights too.

DebugLights.swift has some debugging methods. You’ll find DebugLights.swift in the Utility group. Remove /* and */ around debugLights(renderEncoder:lightType:), but leave the other comment marks there so as not to get a compile error.

To visualize the direction of the sun light using this debugging method, in Renderer.swift, toward the end of draw(in:), before renderEncoder.endEncoding(), add this:

debugLights(renderEncoder: renderEncoder, lightType: Sunlight)

Build and run.

The red lines show the parallel sun light direction vector. As you rotate the train, you can see that the brightest parts are the ones facing towards the sun.

Note: the debug method uses .line as the rendering type. Unfortunately line width is not configurable, so the lines may disappear at certain angles when they are too thin to render.

This shading is pleasing, but not accurate. Take a look at the back of the train. The back of the cabin is black; however, you can see that the top of the chassis is bright blue because it’s facing up. In the real-world, the chassis would be blocked by the cabin and so be in the shade. However, you’re currently not taking occlusion into account, and you won’t be until you master shadows in Chapter 14, “Multipass and Deferred Rendering”.

Ambient reflection

In the real-world, colors are rarely pure black. There’s light bouncing about all over the place. To simulate this, you can use ambient lighting. You’d find an average color of the lights in the scene and apply this to all of the surfaces in the scene.

In Renderer.swift, add an ambient light property:

lazy var ambientLight: Light = {
  var light = buildDefaultLight()
  light.color = [0.5, 1, 0]
  light.intensity = 0.1
  light.type = Ambientlight
  return light
}()

This light is a bright green color, but it’s toned down so it will be only 10% of the intensity.

Add this bright green light to lights. In init(metalView:), after lights.append(sunlight), add this:

lights.append(ambientLight)

In Shaders.metal, in fragment_main(), add a variable to hold ambience from all the ambient lights at the top of the function:

float3 ambientColor = 0;

Inside the for loop after the end of the if (light.type == Sunlight) conditional, add this:

else if (light.type == Ambientlight) {
  ambientColor += light.color * light.intensity;
}

Change:

float3 color = diffuseColor;

To:

float3 color = diffuseColor + ambientColor;

Build and run. If you look closely, the black shadows are now tinged green as if there is a green light being bounced around the scene. Change light.intensity if you want more pronounced ambient light.

This image has an ambient light intensity of 0.2:

Specular reflection

Last, but not least, is the specular reflection. Your train is starting to look great, but now you have a chance to put a coat of shiny varnish on it and make it spec(-tac-)ular. The specular highlight depends upon the position of the observer. If you pass a shiny car, you’ll only see the highlight at certain angles.

The light comes in (L) and is reflected (R) about the normal (N). If the viewer (V) is within a particular cone around the reflection (R), then the viewer will see the specular highlight. That cone is an exponential shininess parameter. The shinier the surface is, the smaller and more intense the specular highlight.

In your case, the viewer is your camera so you’ll need to pass the camera coordinates, again in world position, to the fragment function. Earlier, you set up a cameraPosition property in fragmentUniforms, and this is what you’ll use to pass the camera position.

In Renderer.swift, in draw(in:), just after uniforms.viewMatrix = camera.viewMatrix, add this:

fragmentUniforms.cameraPosition = camera.position

camera.position is already in world space, and you’re already passing fragmentUniforms to the fragment function, so you don’t need to take further action here.

In Shaders.metal, in fragment_main(), add the following variables:

float3 specularColor = 0;
float materialShininess = 32;
float3 materialSpecularColor = float3(1, 1, 1);

These hold the specular value for all the lights, the surface material properties of a shininess factor and the specular color. These variables are temporary as you’ll later take the values from the model’s material properties in a subsequent chapter.

Inside the for loop and inside the if (light.type == Sunlight) conditional, after calculating diffuseColor, add this:

if (diffuseIntensity > 0) {
  // 1 (R)
  float3 reflection = 
      reflect(lightDirection, normalDirection);
  // 2 (V)
  float3 cameraDirection = 
      normalize(in.worldPosition 
        - fragmentUniforms.cameraPosition); 
  // 3
  float specularIntensity = 
      pow(saturate(-dot(reflection, cameraDirection)), 
          materialShininess);
  specularColor += 
      light.specularColor * materialSpecularColor 
        * specularIntensity;
}

Going through this code:

  1. Looking at the image above, for the calculation, you’ll need (L)ight, (R)eflection, (N)ormal and (V)iew. You already have (L) and (N), so here you use the Metal Shading Language function reflect() to get (R).

  2. You need the view vector between the fragment and the camera for (V).

  3. Now you calculate the specular intensity. You find the angle between the reflection and the view using the dot product, clamp the result between 0 and 1 using saturate(), and raise the result to a shininess power using pow(). You then use this intensity to work out the specular color for the fragment.

At the end of the function, change:

float3 color = diffuseColor + ambientColor;

To:

float3 color = diffuseColor + ambientColor + specularColor;

You can build and run now, but to get a more exciting render, add a second model to the scene.

In Renderer.swift, in init(metalView:), after models.append(train), add this code:

let fir = Model(name: "treefir.obj")
fir.position = [1.4, 0, 0]
models.append(fir)

With the new Model class, that’s how easy it is to add models to your scene!

At the top of Renderer, change the camera properties to show the scene better:

lazy var camera: Camera = {
  let camera = ArcballCamera()
  camera.distance = 2.5
  camera.target = [0.5, 0.5, 0]
  camera.rotation.x = Float(-10).degreesToRadians
  return camera
}()

Build and run to see your completed lighting.

Your tree is a bit too blue and shiny. In Chapter 7, “Maps and Materials” you’ll find out how to read in material and texture properties from the model to change its color and lighting.

You’ve created a realistic enough lighting situation for a sun. You can add more variety and realism to your scene with point and spot lights.

Point lights

As opposed to the sun light, where we converted the position into parallel direction vectors, point lights shoot out light rays in all directions.

A light bulb will only light an area of a certain radius, beyond which everything is dark. So you’ll also specify attenuation where a ray of light doesn’t travel infinitely far.

Light attenuation can occur abruptly or gradually. The formula for attenuation is:

This formula gives the curved fall-off. You’ll represent xyz with a float3. No attenuation at all will be float3(1, 0, 0) — substituting x, y and z into the formula results in a value of 1.

In Renderer.swift, add a point light property to Renderer:

lazy var redLight: Light = {
  var light = buildDefaultLight()
  light.position = [-0, 0.5, -0.5]
  light.color = [1, 0, 0]
  light.attenuation = float3(1, 3, 4)
  light.type = Pointlight
  return light
}()

Here, you created a red point light with a position and attenuation. You can experiment with the attenuation values to change radius and fall-off.

Toward the end of init(metalView), add the light to the lights array:

lights.append(redLight)

Toward the end of draw(in:), to debug the point light instead of the sun, change:

debugLights(renderEncoder: renderEncoder, lightType: Sunlight)

To:

debugLights(renderEncoder: renderEncoder, lightType: Pointlight)

Build and run. You’ll see a small red dot next to the train. This is the position of the point light.

Note: The shader for the point light debug dot is worth looking at. In DebugLights.metal, in fragment_light(), the square point is turned into a circle by discarding fragments greater than a certain radius from the center of the point.

The debug lights function shows you where the point light is, but it does not produce any light yet. You’ll do this in the fragment shader.

In Shaders.metal, in the fragment function, add a third part to the conditional testing of the light type:

else if (light.type == Pointlight) {
  // 1
  float d = distance(light.position, in.worldPosition);
  // 2
  float3 lightDirection = normalize(in.worldPosition 
                                    - light.position);
  // 3
  float attenuation = 1.0 / (light.attenuation.x + 
      light.attenuation.y * d + light.attenuation.z * d * d);

  float diffuseIntensity = 
      saturate(-dot(lightDirection, normalDirection));
  float3 color = light.color * baseColor * diffuseIntensity;
  // 4
  color *= attenuation;
  diffuseColor += color;
}

Going through this code:

  1. You find out the distance between the light and the fragment position.
  2. With the directional sun light, you used the position as a direction. Here, you calculate the direction from the fragment position to the light position.
  3. Calculate the attenuation using the attenuation formula and the distance to see how bright the fragment will be.
  4. After calculating the diffuse color as you did for the sun light, multiply this color by the attenuation.

Build and run, and — no change!

This is because the base color is (0, 0, 1) and the light color is (1, 0, 0). When multiplying the light color by the base color, the result will be (0, 0, 0), and so the light will have no contribution towards the fragment color.

In Shaders.metal, at the top of the fragment function, change the base color to:

float3 baseColor = float3(1, 1, 1);

Build and run, and you see the full effect of the red point light.

As well as on the side of the train, the red light shines on the back of the wheel and on the top of the chassis.

The models are shaded slightly green because of the ambient light.

Spotlights

The last type of light you’ll create in this chapter is the spotlight. This sends light rays in limited directions. Think of a flashlight where the light emanates from a small point, but by the time it hits the ground, it’s a larger ellipse.

You define a cone angle to contain the light rays with a cone direction. You also define a cone power to control the attenuation at the edge of the ellipse.

In Common.h, add the cone properties to the Light struct:

float coneAngle;
vector_float3 coneDirection;
float coneAttenuation;

In Renderer.swift, add a new light to Renderer:

lazy var spotlight: Light = {
  var light = buildDefaultLight()
  light.position = [0.4, 0.8, 1]
  light.color = [1, 0, 1]
  light.attenuation = float3(1, 0.5, 0)
  light.type = Spotlight
  light.coneAngle = Float(40).degreesToRadians
  light.coneDirection = [-2, 0, -1.5]
  light.coneAttenuation = 12
  return light
}()

This light is similar to the point light with the added cone angle, direction and cone attenuation. Add the light to the array of lights in init(metalView):

lights.append(spotlight)

At the end of draw(in:), change the debug light render to Spotlight:

debugLights(renderEncoder: renderEncoder, lightType: Spotlight)

Additionally, in DebugLights.swift, un-comment out the two lines of code from debugLights(renderEncoder:lightType:). This will render the spotlight position and direction for debugging.

In Shaders.metal, in the fragment function, add another part to the conditional testing of the light type:

else if (light.type == Spotlight) {
  // 1
  float d = distance(light.position, in.worldPosition);
  float3 lightDirection = normalize(in.worldPosition 
                                    - light.position);
  // 2
  float3 coneDirection = normalize(light.coneDirection);
  float spotResult = dot(lightDirection, coneDirection);
  // 3
  if (spotResult > cos(light.coneAngle)) {
    float attenuation = 1.0 / (light.attenuation.x +
        light.attenuation.y * d + light.attenuation.z * d * d);
    // 4
    attenuation *= pow(spotResult, light.coneAttenuation);
    float diffuseIntensity = 
             saturate(dot(-lightDirection, normalDirection));
    float3 color = light.color * baseColor * diffuseIntensity;
    color *= attenuation;
    diffuseColor += color;
  }
}

This is very similar to the point light code. Going through the comments:

  1. Calculate the distance and direction as you did for the point light. This ray of light may be outside of the spot cone.
  2. Calculate the cosine angle (that’s the dot product) between that ray direction and the direction the spot light is pointing.
  3. If that result is outside of the cone angle, then ignore the ray. Otherwise, calculate the attenuation as for the point light. Vectors pointing in the same direction have a dot product of 1.0.
  4. Calculate the attenuation at the edge of the spot light using coneAttenuation as the power.

Build and run, and rotate the scene. The spotlight is behind the train and tree.

Experiment with changing the various attenuations. A cone angle of 5º with attenuation of (1, 0, 0) and a cone attenuation of 1000 will produce a very small targeted soft light; whereas a cone angle of 20º with a cone attenuation of 1 will produce a sharp-edged round light.

Challenge

You’re currently using hard-coded magic numbers for all the buffer indices and attributes. As your app grows, these indices and attributes will be much harder to keep track of. Your challenge for this chapter is to hunt down all of the magic numbers and give them names. Just as you did for LightType, you’ll create an enum in Common.h.

This code should get you started:

typedef enum {
  BufferIndexVertices = 0,
  BufferIndexUniforms = 1
} BufferIndices;

You can now use these constants in both Swift and C++ shader functions:

//Swift
renderEncoder.setVertexBytes(&uniforms,
                  length: MemoryLayout<Uniforms>.stride,
                  index: Int(BufferIndexUniforms.rawValue))

// Shader Function
vertex VertexOut 
    vertex_main(const VertexIn vertexIn [[stage_in]],
                constant Uniforms &uniforms 
                        [[buffer(BufferIndexUniforms)]])

The completed code is in the challenge folder for this chapter.

Where to go from here?

You’ve covered a lot of lighting information in this chapter. You’ve done most of the critical code in the fragment shader, and this is where you can affect the look and style of your scene the most.

You’ve done some weird and wonderful calculations by passing values through the vertex function to the fragment function and working out dot products between surface normals and various light directions. The formulas you used in this chapter are a small cross-section of computer graphics research that various brilliant mathematicians have come up with over the years. If you want to read more about lighting, you’ll find some interesting internet sites listed in references.markdown in the Resources folder for this chapter.

In the next chapter, you’ll learn another important method of changing how a surface looks: Texturing.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.