Skip to main content
Version: 4.x.x

Simulcast (Adaptive Bitrate) - iOS

Simulcast is a technique used in real-time video communication where a single camera publishes several versions of the same video at once. Each version is called a layer, and has its own resolution and bitrate.

VideoSDK then delivers the appropriate layer to each participant, based on their requirements and network conditions.

Why Simulcast Matters​

Without simulcast, one camera publishes a single version of its video, and everyone receives it. That version has to suit every participant at once, so it is set either high enough for the participant with the best connection, which leaves the participant on a weak connection with frozen video, or low enough for the weakest connection, which leaves everyone else with a soft picture they did not need to settle for.

Simulcast removes that compromise. Because several layers are already being published, VideoSDK can give each participant a different one, and can move a participant to a lighter layer the moment their connection tightens and back up when it recovers.

Configuring Simulcast​

Simulcast is configured when creating a video track using createCameraVideoTrack(). The two parameters that control simulcast are multiStream and maxLayer. If you never pass a custom camera track, the camera takes multiStream from createRoom() instead, and opens at the size set by its maxResolution parameter: 1280x720 with .hd, the default, or 960x540 with .sd.

multiStream​

multiStream determines whether a camera track publishes multiple video layers or a single layer. It is true by default, so simulcast is enabled unless you explicitly disable it.

One participant publishes 720p, 360p, and 180p layers, and VideoSDK sends each viewer the layer that suits them

Setting multiStream to false publishes a single video layer. Every participant then receives the same layer regardless of their screen size or network conditions.

One participant publishes a single 720p stream, and every viewer receives 720p

The number of layers depends on the resolution of the track's encoderConfig.

encoderConfig ResolutionLayers
Less than 480 pixels on the longer edge1
480–959 pixels2
960 pixels or higher3

For example, a 1280x720 track can publish:

320x180 → Low
640x360 → Medium
1280x720 → High

VideoSDK scales the camera video to the encoderConfig resolution before sending it, so the layers do not depend on the resolution the camera captures.

note

These layer sets apply to iOS. The other client SDKs use their own, so the same encoderConfig can publish slightly different layers elsewhere.

maxLayer​

maxLayer limits the maximum number of layers published by a track. It accepts EncodingLayer.MAX_LAYER_2 or EncodingLayer.MAX_LAYER_3, defaults to MAX_LAYER_3, and applies only when multiStream is true.

For example, a 1280x720 track publishes:

MAX_LAYER_3 → 320x180, 640x360, 1280x720
MAX_LAYER_2 → 320x180, 1280x720

With MAX_LAYER_2, the middle layer is omitted, so the track publishes only the lowest and highest layers, which preserves both a high-quality option and a low-bandwidth fallback while encoding one stream fewer. This is worth reaching for on devices where encoding three streams costs more CPU or battery than the middle layer is worth.

info

maxLayer never adds layers beyond what the encoderConfig allows, and there is no middle layer to drop on a track that publishes two. On h360p_w640p or h480p_w640p, MAX_LAYER_2 and MAX_LAYER_3 produce the same result.

Refer to the Optimize Video Track guide for the full set of createCameraVideoTrack() parameters and for examples of passing a custom track to a room.

Selecting the Video Quality Layer​

Once multiple video layers are published, your application can control which layer each participant receives.

danger

setQuality() chooses between published layers, so it has nothing to act on when a track was created with multiStream set to false. That track publishes one layer, and every participant receives it.

setQuality() sets the layer for a remote participant, accepting a VideoQuality value of .high, .medium, or .low. Reach for it when your layout already tells you how much detail a view needs, so you can set the level from your own rules. A speaker in the main stage takes .high, and the same participant in a sidebar takes .low.

setQuality() is async throws, so call it with try await inside a Task. It throws a VideoSDKError if the call fails, for example ERROR_ACTION_PERFORMED_BEFORE_ROOM_JOINED when you call it before joining the room.

Refer to the Layout and Grid Management guide for recommended quality levels for different layouts.

Example​

let participant = self.room?.participants.values.first

Task {
do {
try await participant?.setQuality(.low)
} catch {
// For example, this participant's webcam is off
print("setQuality failed: \(error)")
}
}
note

The requested quality is a preference, not a guarantee. If the participant's network cannot support the requested layer, VideoSDK may deliver a lower layer and move back to the requested quality when the connection improves.

note

setQuality() has no effect when called on the local participant, because a participant does not receive their own video from the server.

setScreenShareQuality() does the same for a remote participant's screen share, and takes the same values. It has an effect only when the screen share is published with several layers, for example from a track created with createScreenShareVideoTrack(multiStream: true).

Example​

Task {
do {
try await participant?.setScreenShareQuality(.high)
} catch {
print("setScreenShareQuality failed: \(error)")
}
}

Instead of choosing a level yourself, you can report the size of the view that shows a participant's video with setViewPort(width:height:). VideoSDK then picks the smallest layer that still covers that size. Pass the size in pixels, and call it again whenever the view is resized. setQuality() and setViewPort(width:height:) both set the webcam layer you receive, so use one of them for a given participant.

Example​

// videoView shows this participant's video. The size is in pixels, not points.
let scale = videoView.traitCollection.displayScale
let width = Int(videoView.bounds.width * scale)
let height = Int(videoView.bounds.height * scale)

Task {
do {
try await participant?.setViewPort(width: width, height: height)
} catch {
print("setViewPort failed: \(error)")
}
}

Capping the Quality You Send​

You can also limit what your own webcam sends, which saves upload bandwidth.

  • setWebcamQuality() of the Room class caps the quality of the webcam video you send. .high sends every layer, .medium stops the largest one, and .low sends only the smallest, so other participants receive at most that quality. It is async throws, and throws ERROR_WEBCAM_NOT_ENABLED when your webcam is off, or ERROR_SET_WEBCAM_QUALITY_FAILED when the cap can't be applied. The cap stays in force when you turn the webcam off and on again.
  • dynaCast, a parameter of createRoom(), lets VideoSDK lower the quality of the webcam video you send while no participant is watching its larger layers, and raise it again when someone is. It defaults to false, and never goes above the cap set with setWebcamQuality().

Neither has any effect on a webcam that publishes a single layer, such as one with multiStream set to false or a codec other than VP8.

Example​

do {
room = try VideoSDK.createRoom(
roomId: roomId,
token: token,
participantName: "John Doe",
dynaCast: true
)
} catch {
print("Failed to create the room: \(error)")
}

// Later, while your webcam is on
Task {
do {
try await room?.setWebcamQuality(.medium)
} catch {
print("Couldn't cap the webcam quality: \(error)")
}
}

Checking the Current Video Layer​

Use getVideoStats() to check which video quality layer you are receiving from a remote participant.

The returned stats include:

  • currentSpatialLayer — the layer currently being received.
  • preferredSpatialLayer — the layer that was requested.
  • currentTemporalLayer — the temporal layer currently being received.
  • preferredTemporalLayer — the temporal layer that was requested.

Layers are numbered from 0, and a higher number is a higher-quality layer. On a track publishing three layers, 2 is the sharpest and 0 is the lightest. When currentSpatialLayer is below preferredSpatialLayer, a lighter layer than the one requested is being delivered, usually because the connection does not support the requested one yet.

A field has no number until its layer is known. For example, preferredSpatialLayer has none until you request a layer for that participant, so read these fields with as? Int.

The stats also include metrics such as bitrate, codec, size, rtt, jitter, and packetsLost, plus limitation for your own video, which can help diagnose the current video quality.

Refer to the Understanding Call Quality guide for more information about these metrics.

Example​

let participant = self.room?.participants.values.first

if let stats = participant?.getVideoStats() as? [[String: Any]], let first = stats.first {
if let current = first["currentSpatialLayer"] as? Int {
print("Layer in use: \(current)")
}
if let preferred = first["preferredSpatialLayer"] as? Int {
print("Layer requested: \(preferred)")
}
}
note

These statistics are a snapshot taken when the method is called.

Simulcast and Codecs​

The codec a track is encoded with determines whether it can publish layers at all.

  • VP8, the default, supports multiStream, so a track using it publishes layers as described above.
  • Refer to the Video Codecs guide for which codecs support multiStream on iOS, and for how to handle the cases where a codec is not supported.

API Reference​

The API references for all the methods and events utilized in this guide are provided below.

Got a Question? Ask us on discord