Last month I was adding a "film" filter to a small photo journaling app I've been tinkering with on weekends, and my first instinct was the same one I have for almost everything now: open the assistant, describe the CIFilter pipeline I wanted, let it spit out something plausible. I got about two sentences into the prompt before I closed the window. Not because I suddenly developed a moral objection mid-keystroke, but because I realized I actually wanted to know how this worked, and handing it off would mean I still wouldn't, three months from now, when the filter looked wrong on some phone I don't own.

I'd just read a post by game developer Joel Auterson called "Fuck it, make it anyway," about the exhaustion of feeling like every craft skill you've built is being made irrelevant by code generation, and how he talked himself back from that ledge. In it he mentions his friend Shad, who's building an iOS camera app called Uncamera that produces genuinely film-like photos by working from raw sensor output through a 3D lookup table, rather than slapping an Instagram-style filter over an already-processed JPEG, and doing it entirely without generative AI. That distinction, raw-plus-LUT versus filter-on-JPEG, is a real technical difference, not just a purity test, and it's the one I ended up chasing down myself over the following weekend.

This post is what I actually built, the specific bug I hit along the way, and why I think the "hard way" here produces a genuinely better result, not just a more virtuous one.

The core trick isn't the LUT itself, it's making sure the image data hitting that LUT hasn't already been squashed through a tone curve meant for a completely different look. Grade first, tone-map never, and do it in that order.

Why raw sensor data beats a filter on a JPEG

Every phone camera app already does a ton of processing before you ever see a photo: demosaicing the Bayer sensor pattern into RGB pixels, applying a tone curve to compress dynamic range into something a screen can show, boosting saturation, sharpening edges. By the time you get a JPEG or even a processed HEIC, decisions have already been baked in that you can't undo. Stack an Instagram-style filter on top of that and you're grading an image that's already been graded once, by an algorithm tuned for punchy, shareable photos rather than the flatter, more forgiving tonal range that film emulation actually wants to work with.

Working from raw sensor data sidesteps that. You get the demosaiced pixel values before Apple's default tone curve and saturation boost are applied, which means your look-up table is grading something much closer to the original light hitting the sensor. It's more work, and it's slower, but the highlights don't clip the same way and the color response actually holds together the way it does on real film stock, because you're not fighting a phone's idea of "a good photo" before you even start.

Capturing raw instead of the processed photo

The first real decision point is at capture time. AVCapturePhotoOutput defaults to giving you a processed image, so you have to explicitly ask for the raw sensor data by checking which raw pixel formats the device supports and building your capture settings around one of them.

Here's the setup I ended up with on the capture side, requesting the raw format alongside a processed preview so the app still has something to show the user immediately while the raw data gets graded in the background:

let photoOutput = AVCapturePhotoOutput()

func configureRawCapture() {
    guard let rawFormat = photoOutput.availableRawPhotoPixelFormatTypes.first else {
        // Device doesn't support raw capture, fall back to processed-only
        return
    }

    let query = photoOutput.isAppleProRAWSupported
    if query { photoOutput.isAppleProRAWEnabled = true }

    let processedFormat = photoOutput
        .availablePhotoCodecTypes
        .contains(.jpeg) ? AVVideoCodecType.jpeg : nil

    let settings = AVCapturePhotoSettings(
        rawPixelFormatType: rawFormat,
        processedFormat: processedFormat.map { [AVVideoCodecKey: $0] }
    )
    settings.photoQualityPrioritization = .quality
    photoOutput.capturePhoto(with: settings, delegate: self)
}

The delegate then gets handed a rawFileURL (or raw pixel buffer, depending on how you've configured things) alongside the processed JPEG. That raw payload is what I feed into the demosaicing and grading step next, and it's noticeably larger and slower to work with than a JPEG, which is the first real cost of doing this properly instead of just filtering a thumbnail.

The gotcha: CIRAWFilter fights your LUT by default

This is where I actually got stuck for an evening. Apple gives you CIRAWFilter to turn that raw sensor data into a usable CIImage, and it's genuinely good at what it's designed for, which is producing a nice-looking photo with sensible defaults. The problem is that "sensible defaults" means it applies its own tone curve and contrast boost before handing you the image, and my LUT was designed assuming a flatter, more linear input. Applying a film grade on top of an image that's already been boosted meant blown highlights and crushed shadows in exactly the places the LUT was supposed to be gentle.

My first attempt looked like this, and produced photos that looked more like a badly overexposed Polaroid than film stock:

func gradedImage(fromRawData data: Data) -> CIImage? {
    guard let rawFilter = CIRAWFilter(imageData: data, identifierHint: nil) else {
        return nil
    }
    guard let baseImage = rawFilter.outputImage else { return nil }

    let cubeFilter = CIFilter(name: "CIColorCube")!
    cubeFilter.setValue(baseImage, forKey: kCIInputImageKey)
    cubeFilter.setValue(filmLUTData, forKey: "inputCubeData")
    cubeFilter.setValue(64, forKey: "inputCubeDimension")
    return cubeFilter.outputValue(forKey: kCIOutputImageKey) as? CIImage
}

The fix was to turn off the boost CIRAWFilter applies by default before it ever gets to the color cube, giving the LUT a much flatter starting point to work from:

func gradedImage(fromRawData data: Data) -> CIImage? {
    guard let rawFilter = CIRAWFilter(imageData: data, identifierHint: nil) else {
        return nil
    }
    rawFilter.boostAmount = 0.0
    rawFilter.enableChromaticNoiseReduction = false
    guard let baseImage = rawFilter.outputImage else { return nil }

    let cubeFilter = CIFilter(name: "CIColorCube")!
    cubeFilter.setValue(baseImage, forKey: kCIInputImageKey)
    cubeFilter.setValue(filmLUTData, forKey: "inputCubeData")
    cubeFilter.setValue(64, forKey: "inputCubeDimension")
    return cubeFilter.outputValue(forKey: kCIOutputImageKey) as? CIImage
}

Setting boostAmount to zero was the single line that made the biggest difference in the whole project. Everything else, the actual LUT math, the raw capture plumbing, was more code but less consequential than that one property doing something I hadn't expected by default.

Turning a .cube file into something CIColorCube understands

Film emulation LUTs are usually distributed as .cube files, a plain text format that started life in the color grading world and lists RGB output triplets for every point on a 3D grid of input values. CIColorCube doesn't read .cube files directly, it wants a flat Data blob of floating point RGBA values in a specific order, so you need a small parser in between.

I wrote a fairly minimal one that just handles the common case, a cubic LUT with a single LUT_3D_SIZE header and no 1D shaper curve:

func parseCubeLUT(from text: String) -> (dimension: Int, data: Data)? {
    var dimension = 0
    var values: [Float] = []

    for line in text.split(separator: "\n") {
        let trimmed = line.trimmingCharacters(in: .whitespaces)
        if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
        if trimmed.hasPrefix("LUT_3D_SIZE") {
            dimension = Int(trimmed.split(separator: " ").last ?? "") ?? 0
            continue
        }
        if trimmed.hasPrefix("TITLE") || trimmed.hasPrefix("DOMAIN") { continue }

        let components = trimmed.split(separator: " ").compactMap { Float($0) }
        guard components.count == 3 else { continue }
        values.append(contentsOf: [components[0], components[1], components[2], 1.0])
    }

    guard dimension > 0, values.count == dimension * dimension * dimension * 4 else {
        return nil
    }
    return (dimension, Data(bytes: values, count: values.count * MemoryLayout.size))
}

That gets you a dimension and a data blob you can hand straight to CIColorCube's inputCubeDimension and inputCubeData keys. It's not a general-purpose .cube parser, there's no support for 1D shaper LUTs or non-cubic dimensions, but it covers every film emulation LUT I've actually used, and writing it myself meant I understood exactly what shape of data the filter expected, instead of trusting a black box to get the byte layout right.

Color space mismatches will quietly wash out your grade

Once the LUT was applying correctly on my test device, I handed a build to a friend with an older iPhone and the film look was noticeably weaker, more washed out, almost like the LUT wasn't being applied at all in the shadows. It wasn't a crash, nothing threw, the app just quietly produced a slightly wrong photo every single time on that particular device.

The cause was a color space mismatch: CIColorCube assumes its input is already in a specific working color space, and if the image coming out of your raw pipeline is tagged with a wider gamut like Display P3, the cube ends up sampling from the wrong region of its grid for a chunk of your tonal range.

If your film look seems inconsistent across devices or looks flatter than expected in shadows, check whether you're feeding CIColorCube a wide-gamut image without accounting for it. Apple's own docs point you toward CIColorCubeWithColorSpace for exactly this reason, and skipping that step is an easy way to lose an evening.

Switching to CIColorCubeWithColorSpace and explicitly passing the working color space of the image fixed it immediately. It's a one-line change in the filter name and an extra key in the parameters, but it's the kind of thing that's genuinely hard to catch just from reading the API surface, because both filters run without complaint, they just produce quietly wrong output on certain devices.

Why I'd still do this the slow way

None of this took me particularly long once I'd actually sat down with it, maybe a weekend and change, most of it spent staring at washed-out test photos trying to figure out where the color was leaking. That's roughly the point Auterson was making in his post: the friction of doing something by hand is also where the actual learning lives, and the alternative isn't really faster once you account for the time you'll spend later not understanding your own code.

What stuck with me from his post wasn't really about tools at all, it was about motivation once the head-pats stop being guaranteed. He talks about building little shell scripts and tools that colleagues used to compliment, and how that stopped mattering once anyone could generate the same thing from a prompt. That's a real loss, and I don't think pretending it isn't helps anyone.

"I simply do not enjoy programming with a code assistant. It isn't fun for me, the output doesn't feel like mine, and I take no pride in what it produces."Joel Auterson, "Fuck it, make it anyway"

I don't think every line of every app needs to be handcrafted, and I'm not going to pretend generated boilerplate for a settings screen carries some deep creative weight. But a camera pipeline that's meant to be the actual point of the app, the thing that makes it feel different from the built-in Camera app, felt like exactly the wrong place to outsource the thinking. Shad's approach with Uncamera, going all the way to raw sensor data instead of stopping at a filter, is a stronger technical decision than mine in some ways, and it's the reason I bothered chasing this down instead of settling for a CIFilter chain on top of a JPEG.

Where this leaves the app

The filter's shipped now, sitting behind a flag while I test it on a wider spread of devices, because raw capture support and available pixel formats vary enough between phones that I still don't fully trust my fallback path. If you're building something similar, the raw-plus-LUT approach is worth the extra week only if the look is actually central to what you're making; if it's one filter among twelve in a general photo app, a well-tuned CIFilter chain on the processed image is probably the more sensible tradeoff, and I wouldn't pretend otherwise just because I enjoyed doing it the hard way this time.

Sources: Joel Auterson — "Fuck it, make it anyway"