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.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Building a Portal App in ARKit: Materials and Lighting
30 mins
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:
- 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.
- These are constants to scale and repeat the textures over the surfaces.
- 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:
-
The method takes an
SCNGeometryobject and the X and Y scaling factors as the input. Texture mapping uses theSandTcoordinate system which is just another naming convention:Scorresponds toXandTcorresponds toY. Here you define the wrapping mode for theSdimension asSCNWrapMode.repeatfor all the visual properties of your material. -
You define the wrapping mode for the
Tdimension asSCNWrapMode.repeatas well for all visual properties. With therepeatmode, texture sampling uses only the fractional part of texture coordinates. -
Here, each of the visual properties
contentsTransformis set to a scale transform described by anSCNMatrix4struct. You set the X and Y scaling factors toscaleXandscaleYrespectively.
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:
-
Create an
outerSurfacescene box geometry object with the dimensions of the floor and ceiling. -
Add visible content to the box object’s diffuse property so it is rendered. You set the
transparencyto a very low value so the object is hidden from view.
-
Create an
SCNNodeobject from theouterSurfacegeometry. SetrenderingOrderfor 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:
- Create the lower side of the floor node using the floor’s dimensions.
-
Position
outerFloorNodesuch that it’s laid out on the bottom side of the floor node. Add the node to thefloorNodewhich holds both the inner and outer surfaces of the floor. -
You make the geometry of the floor using the
SCNBoxobject initialized with the constants declared previously for each dimension. -
The
lightingModelof the material for the floor is set tophysicallyBased. 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 thescnassetscatalog. -
The texture for the material is repeated over the X and Y dimensions using
repeatTextures(), which you defined before. -
You create a node for the floor using the
innerFloorgeometry object and set the rendering order to higher than that of theouterFloorNode. This ensures that when the user is outside the portal, the floor node will be invisible. -
Finally, set the position of
innerFloorNodeto sit above theouterFloorNodeand add it as a child tofloorNode. 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:
-
You create a
SCNNodeobject to hold the portal. -
You create the floor node using
makeFloorNode()defined inSCNNodeHelpers. You set the position offloorNodeusing the constant offsets. The center of theSCNGeometryis set to this location in the node’s parent’s coordinate system. -
Add the
floorNodeto 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 inrenderer(_ :, 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:
-
Similar to the floor, you create an
outerCeilingNodewith the dimensions for the ceiling. -
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
outerCeilingNodeas a child of theceilingNode. -
Make
innerCeilinganSCNBoxobject with the respective dimensions. -
Set the
lightingModeltophysicallyBased. Also set the contents of the visual properties that are defined by various texture images found in the assets catalog. -
repeatTextures()wraps the texture images in both the X and Y dimensions to create a repeated pattern for the ceiling. -
Create
innerCeilingNodeusing theinnerCeilinggeometry and set itsrenderingOrderproperty to a high value so that it gets rendered after theouterCeilingNode. -
Position
innerCeilingNodewithin its parent node and add it as a child ofceilingNode. ReturnceilingNodeto 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.
-
Create the ceiling node using
makeCeilingNode()which you just defined. Set the position of the center ofceilingNodeto theSCNVector3struct. 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_HEIGHTto account for the thickness of the ceiling. The Z coordinate is set to thePOSITION_Zoffset similar to the floor. This is how far away the center of the ceiling is from the camera along the Z axis. -
Add
ceilingNodeas 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.
-
You create an
outerWallnode which will sit on the outside of the wall to make it appear transparent from the outside. You create anSCNBoxobject matching the wall’s dimensions. -
You set the
diffusecontents 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. -
You create a node with the
outerWallgeometry. Themultiplieris set based on which side of the wall the outer wall needs to be rendered. IfmaskLowerSideis set totrue, 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.
-
You also create a node to hold the wall and add the
outerWallNodeas its child node. -
You make
innerWallanSCNBoxobject with the respective wall dimensions. -
You set the
lightingModeltophysicallyBased. 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.
-
Finally, you create an
innerWallNodeobject using theinnerWallgeometry. Add this node to the parentwallNodeobject. By default,innerWallNodeis placed at the origin ofwallNode. 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:
-
Create a node for the far wall.
farWallNodeneeds the mask on the lower side. So the default value offalseformaskLowerSidewill do. -
Add
eulerAnglesto the node. Since the wall is rotated along the Y axis and perpendicular to the camera, it has a rotation of90degrees for the second component. The wall does not have a rotation angle for the X and Z axes. -
Set the position of the center of
farWallNodesuch that its height is offset byPOSITION_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:
-
Create a node for the right wall. You want to put the outer wall on the lower side of the node so you set
maskLowerSidetotrue. - 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.
-
Set the location of the wall so that it’s flush with the right edge of the far wall, ceiling and floor. Add
rightSideWallNodeas a child node ofportal. -
Similar to the right wall node, create a node to represent the left wall with
maskLowerSideset totrue. - 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.
