Core Image: Real-Time Camera Filters and Image Processing
Your photo app’s “vintage film” filter takes 400ms per frame. Users see a stuttering preview that makes the camera feel
broken. The problem is not Core Image itself — it is how you set up the rendering pipeline. A properly configured
CIContext with GPU-backed rendering processes filters in under 8ms per frame, even when chaining five effects
together.
This guide covers CIFilter fundamentals, filter chaining, custom CIKernel authoring, CIContext configuration, and
real-time camera processing through AVCaptureVideoDataOutput. We won’t cover Vision-based image analysis or SwiftUI’s
built-in visual effect modifiers (see SwiftUI Visual Effects for those).
Contents
- The Problem
- Core Image Architecture
- Filter Chaining for Production Effects
- Real-Time Camera Filters
- Writing Custom CIKernels
- Performance Considerations
- When to Use (and When Not To)
- Summary
The Problem
Consider Pixar’s internal dailies app, where animators review rendered frames with color correction applied in
real-time. A naive implementation creates a new CIContext every frame and renders to a UIImage:
func applyFilter(to image: UIImage) -> UIImage? {
// Problem 1: New CIContext every call — expensive allocation
let context = CIContext()
guard let ciImage = CIImage(image: image),
let filter = CIFilter(name: "CISepiaTone") else {
return nil
}
filter.setValue(ciImage, forKey: kCIInputImageKey)
filter.setValue(0.8, forKey: kCIInputIntensityKey)
guard let output = filter.outputImage,
// Problem 2: Forces immediate CPU-side rendering
let cgImage = context.createCGImage(
output, from: output.extent
) else {
return nil
}
// Problem 3: UIImage allocation adds another copy
return UIImage(cgImage: cgImage)
}
This code has three compounding issues: recreating CIContext on every call (each allocation negotiates with the GPU
driver), forcing CPU-side rendering with createCGImage, and creating an unnecessary UIImage copy. On a live camera
feed at 30 fps, this approach will drop frames immediately.
Core Image Architecture
Core Image’s power comes from its lazy evaluation model. When you set a filter’s input and read its outputImage, no
pixel processing happens. Core Image builds a recipe — a directed acyclic graph of operations. Rendering only occurs
when you explicitly request output through a CIContext.
CIContext: Create Once, Reuse Forever
CIContext is the most expensive object in the Core
Image stack. It manages GPU resources, shader compilation caches, and intermediate texture allocations. Create one at
initialization time and reuse it for the lifetime of your view or service:
final class PixarFilterEngine {
// Single context for the entire engine's lifetime — exposed for direct rendering
let ciContext: CIContext
init() {
// Use Metal for GPU-accelerated rendering
guard let device = MTLCreateSystemDefaultDevice() else {
fatalError("Metal is required for Core Image")
}
self.ciContext = CIContext(
mtlDevice: device,
options: [
.cacheIntermediates: false,
.priorityRequestLow: false,
.workingColorSpace: CGColorSpace(
name: CGColorSpace.sRGB
)!
]
)
}
}
Setting cacheIntermediates to false is important for camera pipelines. The default behavior caches intermediate
textures for reuse, which helps when applying the same filter chain to the same image repeatedly (like adjusting a
slider). For a camera feed where every frame is different, those caches waste memory.
CIFilter: The Type-Safe Way
Since iOS 13, Apple provides generated Swift types for built-in filters via CIFilterBuiltins, giving you compile-time
safety instead of stringly-typed setValue(_:forKey:) calls:
import CoreImage.CIFilterBuiltins
func applySepiaTone(
to input: CIImage,
intensity: Float = 0.8
) -> CIImage {
let filter = CIFilter.sepiaTone()
filter.inputImage = input
filter.intensity = intensity
return filter.outputImage ?? input
}
Apple Docs:
CIFilter.sepiaTone()— Core Image
This approach catches typos at compile time, provides autocomplete for parameters, and is the recommended pattern going
forward. The older CIFilter(name:) API still works, but reserve it for dynamically selected filters where you need
runtime flexibility.
Filter Chaining for Production Effects
A single filter rarely produces a polished look. Pixar’s dailies app might chain color adjustments, vignette, and grain to simulate a specific film stock. Because Core Image evaluates lazily, chaining is free until rendering:
extension PixarFilterEngine {
/// Applies a "Pixar Vintage Dailies" look:
/// warm color shift + vignette + subtle grain
func pixarVintageLook(input: CIImage) -> CIImage {
// Step 1: Warm color temperature
let temperatureFilter = CIFilter.temperatureAndTint()
temperatureFilter.inputImage = input
temperatureFilter.neutral = CIVector(x: 5500, y: 0)
temperatureFilter.targetNeutral = CIVector(
x: 6500, y: 0
)
let warmed = temperatureFilter.outputImage ?? input
// Step 2: Vignette for cinematic framing
let vignette = CIFilter.vignette()
vignette.inputImage = warmed
vignette.intensity = 1.2
vignette.radius = 2.0
let vignetted = vignette.outputImage ?? warmed
// Step 3: Film grain via noise generation
let noiseGenerator = CIFilter.randomGenerator()
guard let noise = noiseGenerator.outputImage else {
return vignetted
}
// Crop noise to match input extent and reduce opacity
let croppedNoise = noise.cropped(to: input.extent)
let grainFilter = CIFilter.multiplyCompositing()
grainFilter.inputImage = croppedNoise
.applyingFilter("CIColorMatrix", parameters: [
"inputRVector": CIVector(
x: 0, y: 0, z: 0, w: 0.03
),
"inputGVector": CIVector(
x: 0, y: 0, z: 0, w: 0.03
),
"inputBVector": CIVector(
x: 0, y: 0, z: 0, w: 0.03
),
"inputAVector": CIVector(
x: 0, y: 0, z: 0, w: 1.0
),
"inputBiasVector": CIVector(
x: 0.97, y: 0.97, z: 0.97, w: 0
)
])
grainFilter.backgroundImage = vignetted
return grainFilter.outputImage ?? vignetted
}
}
At this point, no pixels have been processed. Core Image has built a graph: temperature -> vignette -> noise composite. All three filters execute in a single GPU pass when you render the output.
Rendering the Output
For displaying in a UIImageView or saving to disk, render through the reusable context:
extension PixarFilterEngine {
func renderToCGImage(_ ciImage: CIImage) -> CGImage? {
ciContext.createCGImage(ciImage, from: ciImage.extent)
}
func renderToPixelBuffer(
_ ciImage: CIImage,
to buffer: CVPixelBuffer
) {
ciContext.render(ciImage, to: buffer)
}
}
The render(_:to:) variant that writes directly to a CVPixelBuffer avoids a GPU-to-CPU round trip entirely — which is
exactly what you need for camera preview.
Real-Time Camera Filters with AVCaptureVideoDataOutput
This is where Core Image earns its reputation. By combining
AVCaptureVideoDataOutput with a
Metal-backed CIContext, you can process every camera frame through your filter chain and display the result in real
time.
The Camera Pipeline
import AVFoundation
import CoreImage
import MetalKit
final class FilteredCameraController:
NSObject, ObservableObject {
private let captureSession = AVCaptureSession()
private let videoOutput = AVCaptureVideoDataOutput()
private let processingQueue = DispatchQueue(
label: "com.pixar.dailies.camera",
qos: .userInteractive
)
private let filterEngine = PixarFilterEngine()
let metalView: MTKView
private let metalDevice: MTLDevice
private let commandQueue: MTLCommandQueue
private var currentCIImage: CIImage?
override init() {
guard let device = MTLCreateSystemDefaultDevice(),
let queue = device.makeCommandQueue() else {
fatalError("Metal is required")
}
self.metalDevice = device
self.commandQueue = queue
self.metalView = MTKView(frame: .zero, device: device)
super.init()
metalView.delegate = self
metalView.framebufferOnly = false
metalView.enableSetNeedsDisplay = false
metalView.isPaused = false
configureCaptureSession()
}
private func configureCaptureSession() {
captureSession.sessionPreset = .hd1920x1080
guard let camera = AVCaptureDevice.default(
.builtInWideAngleCamera,
for: .video,
position: .back
),
let input = try? AVCaptureDeviceInput(device: camera)
else {
return
}
videoOutput.setSampleBufferDelegate(
self, queue: processingQueue
)
videoOutput.alwaysDiscardsLateVideoFrames = true
videoOutput.videoSettings = [
kCVPixelBufferPixelFormatTypeKey as String:
kCVPixelFormatType_32BGRA
]
captureSession.beginConfiguration()
if captureSession.canAddInput(input) {
captureSession.addInput(input)
}
if captureSession.canAddOutput(videoOutput) {
captureSession.addOutput(videoOutput)
}
captureSession.commitConfiguration()
}
func start() {
processingQueue.async { [weak self] in
self?.captureSession.startRunning()
}
}
func stop() {
captureSession.stopRunning()
}
}
Two critical settings deserve attention. alwaysDiscardsLateVideoFrames = true tells AVFoundation to drop frames rather
than queue them if your filter takes too long — without this, you get increasing latency as frames pile up. The
qos: .userInteractive priority on the processing queue ensures the scheduler gives your filter work top priority.
Processing Each Frame
extension FilteredCameraController:
AVCaptureVideoDataOutputSampleBufferDelegate {
func captureOutput(
_ output: AVCaptureOutput,
didOutput sampleBuffer: CMSampleBuffer,
from connection: AVCaptureConnection
) {
guard let pixelBuffer =
CMSampleBufferGetImageBuffer(sampleBuffer) else {
return
}
// CIImage from pixel buffer — zero-copy
let ciImage = CIImage(cvPixelBuffer: pixelBuffer)
.oriented(.right) // Correct camera orientation
// Apply the filter chain
let filtered = filterEngine.pixarVintageLook(
input: ciImage
)
// Store for MTKView rendering on next display refresh
currentCIImage = filtered
}
}
CIImage(cvPixelBuffer:) wraps the camera’s pixel buffer without copying data. The orientation correction with
.oriented(.right) handles the physical camera sensor orientation.
Rendering to MTKView
extension FilteredCameraController: MTKViewDelegate {
func mtkView(
_ view: MTKView,
drawableSizeWillChange size: CGSize
) {}
func draw(in view: MTKView) {
guard let currentCIImage,
let drawable = view.currentDrawable,
let commandBuffer = commandQueue
.makeCommandBuffer() else {
return
}
// Reuse filterEngine's CIContext — never create one per frame
let ciContext = filterEngine.ciContext
// Scale image to fill the view
let drawableSize = view.drawableSize
let scaleX = drawableSize.width
/ currentCIImage.extent.width
let scaleY = drawableSize.height
/ currentCIImage.extent.height
let scale = max(scaleX, scaleY)
let scaledImage = currentCIImage
.transformed(by: CGAffineTransform(
scaleX: scale, y: scale
))
// Center the image
let xOffset = (drawableSize.width
- scaledImage.extent.width) / 2
let yOffset = (drawableSize.height
- scaledImage.extent.height) / 2
let centeredImage = scaledImage
.transformed(by: CGAffineTransform(
translationX: xOffset, y: yOffset
))
let destination = CIRenderDestination(
width: Int(drawableSize.width),
height: Int(drawableSize.height),
pixelFormat: view.colorPixelFormat,
commandBuffer: commandBuffer,
mtlTextureProvider: { drawable.texture }
)
do {
try ciContext.startTask(
toRender: centeredImage,
to: destination
)
} catch {
print("Core Image render failed: \(error)")
}
commandBuffer.present(drawable)
commandBuffer.commit()
}
}
Tip: Use
CIRenderDestination(iOS 11+) instead ofcontext.render(_:to:)for Metal-backed rendering. It lets Core Image batch filter evaluation and Metal rendering into a single command buffer, reducing GPU synchronization overhead.
Writing Custom CIKernels
Built-in filters cover most needs, but sometimes you need a custom effect. Since iOS 15, CIKernels are written in Metal Shading Language and compiled ahead of time.
Creating a Custom “Pixar Glow” Kernel
First, create a .ci.metal file in your project. The .ci. infix tells the build system to compile this as a Core
Image kernel rather than a standard Metal shader:
// PixarGlow.ci.metal
#include <CoreImage/CoreImage.h>
extern "C" float4 pixarGlow(
coreimage::sample_t pixel,
float glowIntensity,
coreimage::destination dest
) {
// Boost highlights and add warm tint
float luminance = dot(
pixel.rgb, float3(0.2126, 0.7152, 0.0722)
);
float glowAmount = smoothstep(
0.5, 1.0, luminance
) * glowIntensity;
float3 warmGlow = float3(1.0, 0.95, 0.8);
float3 result = mix(pixel.rgb, warmGlow, glowAmount);
return float4(result, pixel.a);
}
Then wrap it in a CIFilter subclass:
final class PixarGlowFilter: CIFilter {
@objc dynamic var inputImage: CIImage?
@objc dynamic var glowIntensity: Float = 0.6
private static let kernel: CIColorKernel? = {
guard let url = Bundle.main.url(
forResource: "default",
withExtension: "metallib"
),
let data = try? Data(contentsOf: url) else {
return nil
}
return try? CIColorKernel(
functionName: "pixarGlow",
fromMetalLibraryData: data
)
}()
override var outputImage: CIImage? {
guard let input = inputImage,
let kernel = Self.kernel else {
return inputImage
}
return kernel.apply(
extent: input.extent,
roiCallback: { _, rect in rect },
arguments: [input, glowIntensity]
)
}
}
Warning: The
roiCallback(region of interest) must accurately describe which input region is needed to compute each output pixel. For color kernels that operate per-pixel, returning the same rect is correct. For convolution kernels (blurs, edge detection), you must expand the rect by the kernel radius or you will get black edges.
Performance Considerations
Core Image is already GPU-accelerated, but the difference between a naive setup and an optimized one is the difference between 8 fps and 60 fps.
CIContext creation: Measured at 15-30ms on iPhone 15 Pro. Creating one per frame at 30 fps burns your entire frame budget before any filtering happens. Create once and reuse.
Filter graph optimization: Core Image automatically concatenates compatible filters into a single GPU shader pass. A
chain of sepiaTone -> vignette -> colorControls compiles into one shader, not three sequential render passes. This
only works when you chain CIImage outputs — do not render to an intermediate CGImage between filters.
Pixel format matters: Use kCVPixelFormatType_32BGRA for camera output. Core Image’s Metal pipeline expects BGRA
natively. Using 420YpCbCr8BiPlanarFullRange (the camera’s default) adds a color space conversion step on every frame.
Memory pressure: Each CIImage in a chain holds a reference to its input. Long chains can keep many textures alive.
For real-time camera work, set .cacheIntermediates: false on your context and let each frame’s textures deallocate
naturally.
| Configuration | Frame Time (iPhone 15 Pro) |
|---|---|
| New CIContext per frame | ~35ms (28 fps) |
| Reused CIContext, CGImage output | ~12ms (60 fps, CPU-bound) |
| Reused CIContext, Metal destination | ~5ms (60 fps, GPU only) |
| 5-filter chain, Metal destination | ~8ms (60 fps) |
Apple Docs:
CIContext— Core Image
When to Use (and When Not To)
| Scenario | Recommendation |
|---|---|
| Real-time camera filters | Core Image with Metal. Purpose-built. |
| Static image adjustments | Core Image. 10 filters cost as one. |
| Custom per-pixel shaders | Custom CIKernel in Metal. |
| Simple compositing/blending | Core Image compositing filters. |
| ML-based style transfer | Core ML with Vision instead. |
| Batch processing photos | Core Image + CIContext reuse. |
| SwiftUI view blur/opacity | SwiftUI’s .visualEffect modifier. |
Summary
- Create
CIContextonce with a Metal device and reuse it for the lifetime of your filter engine. This single change eliminates the most common Core Image performance pitfall. - Use the type-safe
CIFilter.filterName()API fromCIFilterBuiltins(iOS 13+) instead of stringly-typedCIFilter(name:)for compile-time safety. - Chain filters by passing
outputImagefrom one filter as input to the next. Core Image merges compatible operations into a single GPU pass automatically. - For real-time camera, combine
AVCaptureVideoDataOutputwithCIRenderDestinationandMTKViewto keep the entire pipeline on the GPU with zero CPU copies. - Write custom kernels in Metal Shading Language with the
.ci.metalfile extension for effects that built-in filters cannot achieve.
Ready to combine Core Image filters with SwiftUI’s declarative effects system? Explore SwiftUI Visual Effects for the view-layer side of image processing.