Building a Portal App in ARKit: Materials and Lighting

Learn how to add materials and lighting effects to your AR portal app with the final tutorial in this series taken from our book, ARKIt by Tutorials! By Namrata Bandekar.

Leave a rating/review
Download materials
Save for later
Share

This is an excerpt taken from Chapter 9, “Materials and Lighting”, of our book ARKit by Tutorials. This book show you how to build five immersive, great-looking AR apps in ARKit, Apple’s augmented reality framework. Enjoy!

In the first and second parts of this three-part tutorial series on ARKit, you learned how to add 3D objects to your scene with SceneKit. Now it’s time to put that knowledge to use and build the full portal. In this tutorial, you will learn how to:

  • Create walls, a ceiling and roof for your portal and adjust their position and rotation.
  • Make the inside of the portal look more realistic with different textures.
  • Add lighting to your scene.

Getting Started

Download the materials for this tutorial using the link at the top, then load up the starter project from the starter folder. Before you begin, you’ll need to know a little bit about how SceneKit works.

The SceneKit Coordinate System

As you saw in the previous part in this tutorial series, SceneKit can be used to add virtual 3D objects to your view. The SceneKit content view is comprised of a hierarchical tree structure of nodes, also known as the scene graph. A scene consists of a root node, which defines a coordinate space for the world of the scene, and other nodes that populate the world with visible content. Each node or 3D object that you render on screen is an object of type SCNNode. An SCNNode object defines the coordinate space transform (position, orientation and scale) relative to its parent node. It doesn’t have any visible content by itself.

The rootNode object in a scene defines the coordinate system of the world rendered by SceneKit. Each child node you add to this root node creates its own coordinate system, which, in turn, is inherited by its own children.

SceneKit uses a right-handed coordinate system where (by default) the direction of view is along the negative z-axis, as illustrated below.

The position of the SCNNode object is defined using an SCNVector3 which locates it within the coordinate system of its parent. The default position is the zero vector, indicating that the node is placed at the origin of the parent node’s coordinate system. In this case, SCNVector3 is a three component vector where each of the components is a Float value representing the coordinate on each axis.

The SCNNode object’s orientation, expressed as pitch, yaw, and roll angles is defined by its eulerAngles property. This is also represented by an SCNVector3 struct where each vector component is an angle in radians.

Textures

The SCNNode object by itself doesn’t have any visible content. You add 2D and 3D objects to a scene by attaching SCNGeometry objects to nodes. Geometries have attached SCNMaterial objects that determine their appearance.

An SCNMaterial has several visual properties. Each visual property is an instance of the SCNMaterialProperty class that provides a solid color, texture or other 2D content. There are a variety of visual properties for basic shading, physically based shading and special effects which can be used to make the material look more realistic.

The SceneKit asset catalog is designed specifically to help you manage your project’s assets separately from the code. In your starter project, open the Assets.scnassets folder. Notice that you already have images representing different visual properties for the ceiling, floor and walls.

With SceneKit, you can also use nodes with attached SCNLight objects to shade the geometries in a scene with light and shadow effects.

Building the Portal

Let’s jump right in to creating the floor for the portal. Open SCNNodeHelpers.swift and add the following to the top of the file just below the import SceneKit statement.

// 1
let SURFACE_LENGTH: CGFloat = 3.0
let SURFACE_HEIGHT: CGFloat = 0.2
let SURFACE_WIDTH: CGFloat = 3.0

// 2
let SCALEX: Float = 2.0
let SCALEY: Float = 2.0

// 3
let WALL_WIDTH:CGFloat = 0.2
let WALL_HEIGHT:CGFloat = 3.0
let WALL_LENGTH:CGFloat = 3.0

You’re doing a few things here:

  1. You define constants for the dimensions of the floor and ceiling of your portal. The height of the roof and ceiling corresponds to the thickness.
  2. These are constants to scale and repeat the textures over the surfaces.
  3. These define the width, height and length of the wall nodes.

Next, add the following method to SCNNodeHelpers:

func repeatTextures(geometry: SCNGeometry, scaleX: Float, scaleY: Float) {
  // 1
  geometry.firstMaterial?.diffuse.wrapS = SCNWrapMode.repeat
  geometry.firstMaterial?.selfIllumination.wrapS = SCNWrapMode.repeat
  geometry.firstMaterial?.normal.wrapS = SCNWrapMode.repeat
  geometry.firstMaterial?.specular.wrapS = SCNWrapMode.repeat
  geometry.firstMaterial?.emission.wrapS = SCNWrapMode.repeat
  geometry.firstMaterial?.roughness.wrapS = SCNWrapMode.repeat

  // 2
  geometry.firstMaterial?.diffuse.wrapT = SCNWrapMode.repeat
  geometry.firstMaterial?.selfIllumination.wrapT = SCNWrapMode.repeat
  geometry.firstMaterial?.normal.wrapT = SCNWrapMode.repeat
  geometry.firstMaterial?.specular.wrapT = SCNWrapMode.repeat
  geometry.firstMaterial?.emission.wrapT = SCNWrapMode.repeat
  geometry.firstMaterial?.roughness.wrapT = SCNWrapMode.repeat

  // 3
  geometry.firstMaterial?.diffuse.contentsTransform =
    SCNMatrix4MakeScale(scaleX, scaleY, 0)
  geometry.firstMaterial?.selfIllumination.contentsTransform =
    SCNMatrix4MakeScale(scaleX, scaleY, 0)
  geometry.firstMaterial?.normal.contentsTransform =
    SCNMatrix4MakeScale(scaleX, scaleY, 0)
  geometry.firstMaterial?.specular.contentsTransform =
    SCNMatrix4MakeScale(scaleX, scaleY, 0)
  geometry.firstMaterial?.emission.contentsTransform =
    SCNMatrix4MakeScale(scaleX, scaleY, 0)
  geometry.firstMaterial?.roughness.contentsTransform =
    SCNMatrix4MakeScale(scaleX, scaleY, 0)
}

This defines a method to repeat the texture images over the surface in the X and Y dimensions.

Here’s the breakdown:

  1. The method takes an SCNGeometry object and the X and Y scaling factors as the input. Texture mapping uses the S and T coordinate system which is just another naming convention: S corresponds to X and T corresponds to Y. Here you define the wrapping mode for the S dimension as SCNWrapMode.repeat for all the visual properties of your material.
  2. You define the wrapping mode for the T dimension as SCNWrapMode.repeat as well for all visual properties. With the repeat mode, texture sampling uses only the fractional part of texture coordinates.
  3. Here, each of the visual properties contentsTransform is set to a scale transform described by anSCNMatrix4 struct. You set the X and Y scaling factors to scaleX and scaleY respectively.

You only want to show the floor and ceiling nodes when the user is inside the portal; any other time, you need to hide them. To implement this, add the following method to SCNNodeHelpers:

func makeOuterSurfaceNode(width: CGFloat,
                          height: CGFloat,
                          length: CGFloat) -> SCNNode {
  // 1
  let outerSurface = SCNBox(width: SURFACE_WIDTH,
                            height: SURFACE_HEIGHT,
                            length: SURFACE_LENGTH,
                            chamferRadius: 0)
  
  // 2
  outerSurface.firstMaterial?.diffuse.contents = UIColor.white
  outerSurface.firstMaterial?.transparency = 0.000001
  
  // 3
  let outerSurfaceNode = SCNNode(geometry: outerSurface)
  outerSurfaceNode.renderingOrder = 10
  return outerSurfaceNode
}

Taking a look at each numbered comment:

  1. Create an outerSurface scene box geometry object with the dimensions of the floor and ceiling.
  2. Add visible content to the box object’s diffuse property so it is rendered. You set the transparency to a very low value so the object is hidden from view.
  1. Create an SCNNode object from the outerSurface geometry. Set renderingOrder for the node to 10. Nodes with a larger rendering order are rendered last. To make the ceiling and floor invisible from outside the portal, you will make the rendering order of the inner ceiling and floor nodes much larger than 10.

Now add the following code to SCNNodeHelpers to create the portal floor:

func makeFloorNode() -> SCNNode {
  // 1
  let outerFloorNode = makeOuterSurfaceNode(
                       width: SURFACE_WIDTH,
                       height: SURFACE_HEIGHT,
                       length: SURFACE_LENGTH)
  
  // 2
  outerFloorNode.position = SCNVector3(SURFACE_HEIGHT * 0.5,
                                       -SURFACE_HEIGHT, 0)
  let floorNode = SCNNode()
  floorNode.addChildNode(outerFloorNode)

  // 3
  let innerFloor = SCNBox(width: SURFACE_WIDTH,
                          height: SURFACE_HEIGHT,
                          length: SURFACE_LENGTH,
                          chamferRadius: 0)
  
  // 4
  innerFloor.firstMaterial?.lightingModel = .physicallyBased
  innerFloor.firstMaterial?.diffuse.contents =
    UIImage(named: 
    "Assets.scnassets/floor/textures/Floor_Diffuse.png")
  innerFloor.firstMaterial?.normal.contents =
    UIImage(named: 
    "Assets.scnassets/floor/textures/Floor_Normal.png")
  innerFloor.firstMaterial?.roughness.contents =
    UIImage(named: 
    "Assets.scnassets/floor/textures/Floor_Roughness.png")
  innerFloor.firstMaterial?.specular.contents =
    UIImage(named: 
    "Assets.scnassets/floor/textures/Floor_Specular.png")
  innerFloor.firstMaterial?.selfIllumination.contents =
    UIImage(named: 
    "Assets.scnassets/floor/textures/Floor_Gloss.png")
  
  // 5  
  repeatTextures(geometry: innerFloor, 
                 scaleX: SCALEX, scaleY: SCALEY)
  
  // 6
  let innerFloorNode = SCNNode(geometry: innerFloor)
  innerFloorNode.renderingOrder = 100
  
  // 7
  innerFloorNode.position = SCNVector3(SURFACE_HEIGHT * 0.5, 
                                       0, 0)
  floorNode.addChildNode(innerFloorNode)
  return floorNode
}

Breaking this down:

  1. Create the lower side of the floor node using the floor’s dimensions.
  2. Position outerFloorNode such that it’s laid out on the bottom side of the floor node. Add the node to the floorNode which holds both the inner and outer surfaces of the floor.
  3. You make the geometry of the floor using the SCNBox object initialized with the constants declared previously for each dimension.
  4. The lightingModel of the material for the floor is set to physicallyBased. This type of shading incorporates a realistic abstraction of physical lights and materials. The contents for various visual properties for the material are set using texture images from the scnassets catalog.
  5. The texture for the material is repeated over the X and Y dimensions using repeatTextures(), which you defined before.
  6. You create a node for the floor using the innerFloor geometry object and set the rendering order to higher than that of the outerFloorNode. This ensures that when the user is outside the portal, the floor node will be invisible.
  7. Finally, set the position of innerFloorNode to sit above the outerFloorNode and add it as a child to floorNode. Return the floor node object to the caller.

Open PortalViewController.swift and add the following constants:

let POSITION_Y: CGFloat = -WALL_HEIGHT*0.5
let POSITION_Z: CGFloat = -SURFACE_LENGTH*0.5

These constants represent the position offsets for nodes in the Y and Z dimensions.

Add the floor node to your portal by replacing makePortal().

func makePortal() -> SCNNode {
  // 1
  let portal = SCNNode()
  
  // 2
  let floorNode = makeFloorNode()
  floorNode.position = SCNVector3(0, POSITION_Y, POSITION_Z)
  
  // 3
  portal.addChildNode(floorNode)
  return portal
}

Fairly straightforward code:

  1. You create a SCNNode object to hold the portal.
  2. You create the floor node using makeFloorNode() defined in SCNNodeHelpers. You set the position of floorNode using the constant offsets. The center of the SCNGeometry is set to this location in the node’s parent’s coordinate system.
  3. Add the floorNode to the portal node and return the portal node. Note that the portal node is added to the node created at the anchor’s position when the user taps the view in renderer(_ :, didAdd:, for:).

Build and run the app. You’ll notice the floor node is dark. That’s because you haven’t added a light source yet!

Now add the ceiling node. Open SCNNodeHelpers.swift and add the following method:

func makeCeilingNode() -> SCNNode {
  // 1
  let outerCeilingNode = makeOuterSurfaceNode(
                          width: SURFACE_WIDTH,
                          height: SURFACE_HEIGHT,
                          length: SURFACE_LENGTH)
  
  // 2                                            
  outerCeilingNode.position = SCNVector3(SURFACE_HEIGHT * 0.5,
                                         SURFACE_HEIGHT, 0)
  let ceilingNode = SCNNode()
  ceilingNode.addChildNode(outerCeilingNode)

  // 3
  let innerCeiling = SCNBox(width: SURFACE_WIDTH,
                            height: SURFACE_HEIGHT,
                            length: SURFACE_LENGTH,
                            chamferRadius: 0)
  
  // 4                            
  innerCeiling.firstMaterial?.lightingModel = .physicallyBased
  innerCeiling.firstMaterial?.diffuse.contents =
    UIImage(named: 
    "Assets.scnassets/ceiling/textures/Ceiling_Diffuse.png")
  innerCeiling.firstMaterial?.emission.contents =
    UIImage(named: 
    "Assets.scnassets/ceiling/textures/Ceiling_Emis.png")
  innerCeiling.firstMaterial?.normal.contents =
    UIImage(named: 
    "Assets.scnassets/ceiling/textures/Ceiling_Normal.png")
  innerCeiling.firstMaterial?.specular.contents =
    UIImage(named: 
    "Assets.scnassets/ceiling/textures/Ceiling_Specular.png")
  innerCeiling.firstMaterial?.selfIllumination.contents =
    UIImage(named: 
    "Assets.scnassets/ceiling/textures/Ceiling_Gloss.png")
  
  // 5
  repeatTextures(geometry: innerCeiling, scaleX: 
                 SCALEX, scaleY: SCALEY)
  
  // 6
  let innerCeilingNode = SCNNode(geometry: innerCeiling)
  innerCeilingNode.renderingOrder = 100
  
  // 7
  innerCeilingNode.position = SCNVector3(SURFACE_HEIGHT * 0.5, 
                                         0, 0)
  ceilingNode.addChildNode(innerCeilingNode)  
  return ceilingNode
}

Here’s what’s happening:

  1. Similar to the floor, you create an outerCeilingNode with the dimensions for the ceiling.
  2. Set the position of the outer ceiling node so that it goes on top of the ceiling. Create a node to hold the inner and outer sides of the ceiling. Add outerCeilingNode as a child of the ceilingNode.
  3. Make innerCeiling an SCNBox object with the respective dimensions.
  4. Set the lightingModel to physicallyBased. Also set the contents of the visual properties that are defined by various texture images found in the assets catalog.
  5. repeatTextures() wraps the texture images in both the X and Y dimensions to create a repeated pattern for the ceiling.
  6. Create innerCeilingNode using the innerCeiling geometry and set its renderingOrder property to a high value so that it gets rendered after the outerCeilingNode.
  7. Position innerCeilingNode within its parent node and add it as a child of ceilingNode. Return ceilingNode to the caller.

Now to call this from somewhere. Open PortalViewController.swift and add the following block of code to makePortal() just before the return statement.

// 1
let ceilingNode = makeCeilingNode()
ceilingNode.position = SCNVector3(0,
                                  POSITION_Y+WALL_HEIGHT,
                                  POSITION_Z)
// 2
portal.addChildNode(ceilingNode)

You also subtract SURFACE_HEIGHT to account for the thickness of the ceiling. The Z coordinate is set to the POSITION_Z offset similar to the floor. This is how far away the center of the ceiling is from the camera along the Z axis.

  1. Create the ceiling node using makeCeilingNode() which you just defined. Set the position of the center of ceilingNode to the SCNVector3 struct. The Y coordinate of the center is offset by the Y position of the floor added to the height of the wall.

    You also subtract SURFACE_HEIGHT to account for the thickness of the ceiling. The Z coordinate is set to the POSITION_Z offset similar to the floor. This is how far away the center of the ceiling is from the camera along the Z axis.

  2. Add ceilingNode as a child of the portal.

Build and run the app. Here’s what you’ll see:

Time to add the walls!

Open SCNNodeHelpers.swift and add the following method.

func makeWallNode(length: CGFloat = WALL_LENGTH,
                  height: CGFloat = WALL_HEIGHT,
                  maskLowerSide:Bool = false) -> SCNNode {
    
  // 1                      
  let outerWall = SCNBox(width: WALL_WIDTH,
                         height: height,
                         length: length,
                         chamferRadius: 0)
  // 2                        
  outerWall.firstMaterial?.diffuse.contents = UIColor.white
  outerWall.firstMaterial?.transparency = 0.000001

  // 3
  let outerWallNode = SCNNode(geometry: outerWall)
  let multiplier: CGFloat = maskLowerSide ? -1 : 1
  outerWallNode.position = SCNVector3(WALL_WIDTH*multiplier,0,0)
  outerWallNode.renderingOrder = 10
  
  // 4
  let wallNode = SCNNode()
  wallNode.addChildNode(outerWallNode)

  // 5
  let innerWall = SCNBox(width: WALL_WIDTH,
                         height: height,
                         length: length,
                         chamferRadius: 0)
  
  // 6                       
  innerWall.firstMaterial?.lightingModel = .physicallyBased
  innerWall.firstMaterial?.diffuse.contents =
    UIImage(named: 
    "Assets.scnassets/wall/textures/Walls_Diffuse.png")
  innerWall.firstMaterial?.metalness.contents =
    UIImage(named: 
    "Assets.scnassets/wall/textures/Walls_Metalness.png")
  innerWall.firstMaterial?.roughness.contents =
    UIImage(named: 
    "Assets.scnassets/wall/textures/Walls_Roughness.png")
  innerWall.firstMaterial?.normal.contents =
    UIImage(named: 
    "Assets.scnassets/wall/textures/Walls_Normal.png")
  innerWall.firstMaterial?.specular.contents =
    UIImage(named: 
    "Assets.scnassets/wall/textures/Walls_Spec.png")
  innerWall.firstMaterial?.selfIllumination.contents =
    UIImage(named: 
    "Assets.scnassets/wall/textures/Walls_Gloss.png")

  // 7
  let innerWallNode = SCNNode(geometry: innerWall)
  wallNode.addChildNode(innerWallNode)  
  return wallNode
}

Going over the code step-by-step:

You set the position of the node such that the outer wall is offset by the wall width in the X dimension. Set the rendering order for the outer wall to a low number so that it’s rendered first. This makes the walls invisible from the outside.

  1. You create an outerWall node which will sit on the outside of the wall to make it appear transparent from the outside. You create an SCNBox object matching the wall’s dimensions.
  2. You set the diffuse contents of the material to a monochrome white color and the transparency to a low number. This helps achieve the see-through effect if you look at the wall from outside the room.
  3. You create a node with the outerWall geometry. The multiplier is set based on which side of the wall the outer wall needs to be rendered. If maskLowerSide is set to true, the outer wall is placed below the inner wall in the wall node’s coordinate system; otherwise, it’s placed above.

    You set the position of the node such that the outer wall is offset by the wall width in the X dimension. Set the rendering order for the outer wall to a low number so that it’s rendered first. This makes the walls invisible from the outside.

  4. You also create a node to hold the wall and add the outerWallNode as its child node.
  5. You make innerWall an SCNBox object with the respective wall dimensions.
  6. You set the lightingModel to physicallyBased. Similar to the ceiling and floor nodes, you set the contents of the visual properties that are defined by various texture images for the walls.
  1. Finally, you create an innerWallNode object using the innerWall geometry. Add this node to the parent wallNode object. By default, innerWallNode is placed at the origin of wallNode. Return the node to the caller.

Now add the far wall for the portal. Open PortalViewController.swift and add the following to the end of makePortal() just before the return statement:

// 1
let farWallNode = makeWallNode()

// 2
farWallNode.eulerAngles = SCNVector3(0, 
                                     90.0.degreesToRadians, 0)

// 3
farWallNode.position = SCNVector3(0,
                                  POSITION_Y+WALL_HEIGHT*0.5,
                                  POSITION_Z-SURFACE_LENGTH*0.5)
portal.addChildNode(farWallNode)

This is fairly straightforward:

  1. Create a node for the far wall. farWallNode needs the mask on the lower side. So the default value of false for maskLowerSide will do.
  2. Add eulerAngles to the node. Since the wall is rotated along the Y axis and perpendicular to the camera, it has a rotation of 90 degrees for the second component. The wall does not have a rotation angle for the X and Z axes.
  3. Set the position of the center of farWallNode such that its height is offset by POSITION_Y. Its depth is calculated by adding the depth of the center of the ceiling to the distance from the center of the ceiling to its far end.

Build and run the app, and you will see the far wall attached to the ceiling on top and attached to the floor on the bottom.

Next up you will add the right and left walls. In makePortal(), add the following code just before the return portal statement to create the right and left side walls:

// 1
let rightSideWallNode = makeWallNode(maskLowerSide: true)

// 2
rightSideWallNode.eulerAngles = SCNVector3(0, 180.0.degreesToRadians, 0)

// 3
rightSideWallNode.position = SCNVector3(WALL_LENGTH*0.5,
                              POSITION_Y+WALL_HEIGHT*0.5,
                              POSITION_Z)
portal.addChildNode(rightSideWallNode)

// 4
let leftSideWallNode = makeWallNode(maskLowerSide: true)

// 5
leftSideWallNode.position = SCNVector3(-WALL_LENGTH*0.5,
                            POSITION_Y+WALL_HEIGHT*0.5,
                            POSITION_Z)
portal.addChildNode(leftSideWallNode)

Going through this step-by-step:

  1. Create a node for the right wall. You want to put the outer wall on the lower side of the node so you set maskLowerSide to true.
  2. You set the rotation of the wall along the Y axis to 180 degrees. This ensures the wall has its inner side facing the right way.
  3. Set the location of the wall so that it’s flush with the right edge of the far wall, ceiling and floor. Add rightSideWallNode as a child node of portal.
  4. Similar to the right wall node, create a node to represent the left wall with maskLowerSide set to true.
  5. The left wall does not have any rotation applied to it, but you adjust its location so that it’s flush with the left edge of the far wall, floor and ceiling. You add the left wall node as a child node of the portal node.

Build and run the app, and your portal now has three walls. If you move out of the portal, none of the walls are visible.