Depth & Light II · Part 1 of 8
Starting Again from a Binary
Rake shipped free on 15 September 2026, the app the first series was written around. Eight days later, on 23 September, its Swift sources were found to be lost. What was left was whatever Xcode had left lying about. This article is about how much of an app can be got back from that, and how the rest was rebuilt and held to it.
What survived
Xcode keeps the products of every build in DerivedData, and a debug build of 13 September was still there. A debug build keeps symbol names, and a .swiftdoc file holding every doc comment in the source. There was an Xcode archive from the same day as well, but its files could not be read.
Out of the debug build came the names of RakeCore’s types and functions, every doc comment, the app’s Info.plist and privacy manifest, and the compiled Depth Anything V2 Small model with its Apache 2.0 licence. Running strings over the binary gave back the interface’s text. The icon was downloaded from the store at 1024 pixels. All of it went into a salvage/ folder, the first commit of the new repository, and the plan’s first step was a repository with a remote: the lost code is why the rebuild exists, and nothing of it is to be only on one Mac again.
What did not survive was the Swift itself and the whole of the interface. And the shipped build came two days after the debug one, so the rebuild was checked against what the Rake page promised.
A shader kept as a string
One piece came back whole. Rake 1.0 did its lighting on the GPU, and the Metal source was not in a .metal file. RakeCore was a Swift package, and swift build does not compile a package’s .metal files, so 1.0 kept the shader as a Swift string and compiled it at first use. Its own doc comment, recovered with the rest, said so. A string literal goes into the binary as text, and so the full 195-line shader was lifted out of the debug build intact, with its four kernels: lightMap, guide, composite and relight.
It sits in the new Shader.swift unchanged, still as a string and still for the same reason, under a comment that says where it came from. This is its shadow march, the cone walked toward the light that Shadows That Something Blocks described:
// Same march as Relighter.visibility: one cone toward the light's centre.
float visibility(float3 p0, float3 n, GPULight L, texture2d<float, access::read> depthTex, constant Uniforms& u) {
…
const int steps = 48;
float occlusion = 0.0f;
for (int i = 1; i <= steps; i++) {
float t = float(i) / float(steps) * dist;
float3 s = p + dir * t;
…
float bias = 0.015f + 0.03f * t;
float intrusion = s.z - bias - z;
float cone = L.tint.w * t / dist;
float f = cone > 0.0f ? clamp((intrusion + cone) / (2.0f * cone), 0.0f, 1.0f) : (intrusion > 0.0f ? 1.0f : 0.0f);
occlusion = max(occlusion, f);
if (occlusion >= 1.0f) break;
}
return 1.0f - occlusion;
}The comment’s first line names a CPU function, Relighter.visibility, which no longer existed. 1.0 held its GPU to a plain CPU renderer, and the shader is the surviving half of that pair. So it became the specification: the CPU reference was written again to give what the shader gives, and the two were made to agree before anything was built on them.
Notes without code
The doc comments are the other half of the inheritance, and they turned out to be the better half, because they record reasons. The comment on the subject’s depth, which decides where the lights are placed, says it is the median over the central 40 per cent of the frame, and says why neither alternative was used: the whole-frame median puts the light behind the person in a deep room, and the nearest pixels put it in their lap when a shirt reaches the bottom edge. The code could be written straight from that.
The interface strings came back less tidily. strings reads runs of plain characters, and the guide’s line about light power arrives in two pieces where its minus sign stood: “+1 doubles the light,” on one line and “1 halves it.” on the next. The guide in the rebuilt app uses 1.0’s words, recovered this way and put back together.
Rebuilding the cleanup
The shader lights a depth map; it does not prepare one. Everything between the file and the shader had to come from the notes: reading the map, and cleaning it up so that normals can be taken from it. A Portrait photo stores its disparity in 256 levels, and normals taken straight from those levels come out as terraces. The notes gave the recipe: a 3×3 median, then blur and clamp each pixel back into its own level, over and over, leaving alone any pixel that sits on a jump of more than three levels, then a luma-weighted median along the edges, then a final 2-pixel box blur. In the rebuilt Condition.swift those settings read:
public struct ConditioningSettings: Sendable {
/// Blur radii, coarse to fine, each run for `dequantIterations` blur/clamp rounds.
public var dequantRadii = [48, 32, 24, 16, 12, 8, 6, 4, 3, 2, 1]
public var dequantIterations = 30
/// How far, in quantisation bins, a pixel may move from its stored value. One, not a half:
/// the phone may round down, and ±½ bin forced wavy terraces onto flat walls.
public var clampBins: Float = 1
/// A step of more than this many bins to a neighbour is a silhouette; …
public var jumpBins: Float = 3
…
public var finalBlur = 2
…
}It is judged on a synthetic scene, a sphere and a box in front of a wall, stored the way the phone stores it, with disparity floored onto 256 levels. The sphere’s exact normal is known at every pixel, so the error can be measured in degrees. The measure skips sphere pixels within 4 working pixels of the edge, and counts a pixel left with no normal as 90 degrees wrong.
The new scene is harder than 1.0’s. The wall stands at 5 metres, so the 256 levels are spread from the sphere all the way back to it and the sphere gets only a few of them. 1.0’s notes measured their own easier scene and are not comparable figure for figure. On this one, the mean error from the raw levels and after the cleanup, at the native size and at twice it:
Two changes from 1.0’s notes
The recipe was followed except in two places, both on edges. The final box blur, as written, drew a black ring around every silhouette. Blurring across the jump from a sphere to the wall leaves a ramp, every step of which is too steep to be a surface, and the relighter lit those steps as surfaces turned away from everything. 1.0’s notes warned against masking that blur away near edges, which had measured worse, and that is not what was done. The blur still runs everywhere; it averages only neighbours within 3 per cent of the centre’s disparity, the idea From Depth to Surface put as smoothing within surfaces and not across them:
let limit = tolerance * v
var sum: Float = 0, n: Float = 0
for yy in max(0, y - r)...min(h - 1, y + r) {
for xx in max(0, x - r)...min(w - 1, x + r) {
let u = src[yy * w + xx]
if u > 0, abs(u - v) <= limit { sum += u; n += 1 }
}
}
out[y * w + x] = sum / nThe second change is a sliver of pixels on each silhouette that have depth but no normal, because the surface is too steep on both sides of them. Left dark, they outline every edge; instead each takes the normal of the neighbour nearest to it in depth, growing outward a ring at a time for three rings.
Holding the GPU to the CPU
With the cleanup and the CPU renderer back, the salvaged relight kernel and the rebuilt Relighter.render can be run on the same scene and compared pixel by pixel in 8-bit steps:
public func difference(from lights: [Light], settings: RenderSettings = .init()) -> (mean: Double, max: Int) {
let a = render(lights: lights, settings: settings)
let b = relighter.render(lights: lights, settings: settings)
…
}Before you read on
Does a match within 1/255 prove the cleanup and the normals were rebuilt correctly?
No. The Metal renderer is handed the CPU’s depth and the CPU’s normals as textures, so the comparison checks the lighting and the shadow march, ported in one direction and held in the other. The cleanup and the normals are checked only by the sphere: the measure in degrees above is what says they were rebuilt well.
The engine is a Swift package again, with a small Mac command, rake, that conditions a photo or the synthetic scene, lights it and writes images. RakeCore is compiled with optimisation even in Debug builds, because the cleanup is tight loops over every pixel, and without it the app sat on “Analyzing” for well over 20 seconds.
Back to 1.0’s feature list
Relight was then rebuilt to the list the store and the Rake page promised: Portrait and any-photo pickers, the four looks and the three lights, drag and pinch to move a light, power, depth, size and warmth for each, the share of the original kept, hold to compare, and Share and Save at full resolution. In the simulator on an iPhone 17 Pro, a look was applied, a light dragged, and Save wrote a relit 3024×4032 JPEG. Pinch, and anything on a real device, were not checked. The app is Rake Plus, version 2.0, built to go out in Rake’s App Store listing as an update.
Of four MIT-licensed sample photos, the vase and the laptop relit correctly. Three things are still open in the plan. On the sample called SoftCream the subject-depth guess lands on the background and puts the key light behind the cone, as 1.0’s heuristic did. Faint dimples show on smooth objects, as they did in 1.0. And the cleanup takes about 1.2 seconds a map on the CPU, which is worth moving to Metal.
What the tests hold
- The synthetic scene is quantised like the phone: at most 256 distinct values, each on a grid of one step.
- On the sphere, the raw levels give a mean normal error above 10°; conditioned, the mean is under 2° and the 95th percentile under 5°. At twice the scale the conditioned mean is still under 2°.
- The
relightkernel matches the CPU renderer in all four modes with a mean difference under 0.05 of a step and no pixel more than one step out of 255. - The light map composited over the photo agrees with the one-pass render to a mean under half a step and no more than three.
- A light beside the sphere darkens more than 2,000 pixels of the wall behind it to under a fifth.
- Daylight at 5600 K adds no colour cast, and tungsten at 3200 K is warm.
- On the real vase photo, the map has 256 levels and, after conditioning, more than 99.9 per cent of the pixels with depth have a normal.
- Estimated depth is placed between 0.5 and 4 metres, and a photo with no depth map is refused rather than guessed at.
That is the engine Rake had, rebuilt and measured. The next article is the first thing it was asked to do that Rake never did: lay fog through a photo by how far away each part of it is.