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
SCNGeometry
object and the X and Y scaling factors as the input. Texture mapping uses theS
andT
coordinate system which is just another naming convention:S
corresponds toX
andT
corresponds toY
. Here you define the wrapping mode for theS
dimension asSCNWrapMode.repeat
for all the visual properties of your material. -
You define the wrapping mode for the
T
dimension asSCNWrapMode.repeat
as well for all visual properties. With therepeat
mode, texture sampling uses only the fractional part of texture coordinates. -
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 toscaleX
andscaleY
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:
-
Create an
outerSurface
scene 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
transparency
to a very low value so the object is hidden from view.
-
Create an
SCNNode
object from theouterSurface
geometry. SetrenderingOrder
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:
- Create the lower side of the floor node using the floor’s dimensions.
-
Position
outerFloorNode
such that it’s laid out on the bottom side of the floor node. Add the node to thefloorNode
which holds both the inner and outer surfaces of the floor. -
You make the geometry of the floor using the
SCNBox
object initialized with the constants declared previously for each dimension. -
The
lightingModel
of 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 thescnassets
catalog. -
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
innerFloor
geometry 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
innerFloorNode
to sit above theouterFloorNode
and 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
SCNNode
object to hold the portal. -
You create the floor node using
makeFloorNode()
defined inSCNNodeHelpers
. You set the position offloorNode
using the constant offsets. The center of theSCNGeometry
is set to this location in the node’s parent’s coordinate system. -
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 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
outerCeilingNode
with 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
outerCeilingNode
as a child of theceilingNode
. -
Make
innerCeiling
anSCNBox
object with the respective dimensions. -
Set the
lightingModel
tophysicallyBased
. 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
innerCeilingNode
using theinnerCeiling
geometry and set itsrenderingOrder
property to a high value so that it gets rendered after theouterCeilingNode
. -
Position
innerCeilingNode
within its parent node and add it as a child ofceilingNode
. ReturnceilingNode
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.
-
Create the ceiling node using
makeCeilingNode()
which you just defined. Set the position of the center ofceilingNode
to theSCNVector3
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 thePOSITION_Z
offset similar to the floor. This is how far away the center of the ceiling is from the camera along the Z axis. -
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.
-
You create an
outerWall
node which will sit on the outside of the wall to make it appear transparent from the outside. You create anSCNBox
object matching the wall’s dimensions. -
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. -
You create a node with the
outerWall
geometry. Themultiplier
is set based on which side of the wall the outer wall needs to be rendered. IfmaskLowerSide
is 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
outerWallNode
as its child node. -
You make
innerWall
anSCNBox
object with the respective wall dimensions. -
You set the
lightingModel
tophysicallyBased
. 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
innerWallNode
object using theinnerWall
geometry. Add this node to the parentwallNode
object. By default,innerWallNode
is 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.
farWallNode
needs the mask on the lower side. So the default value offalse
formaskLowerSide
will do. -
Add
eulerAngles
to the node. Since the wall is rotated along the Y axis and perpendicular to the camera, it has a rotation of90
degrees 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
farWallNode
such 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
maskLowerSide
totrue
. - 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
rightSideWallNode
as a child node ofportal
. -
Similar to the right wall node, create a node to represent the left wall with
maskLowerSide
set 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.