├── .gitignore ├── README.md ├── RelativisticRenderer.swiftpm ├── .gitignore ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ └── Screenshot 2024-02-26 at 5.45.02 pm.png │ ├── Contents.json │ └── starmap_2020_4k.imageset │ │ ├── Contents.json │ │ └── starmap_2020_4k.jpg ├── Await.swift ├── Binding+extensions.swift ├── BlackholePlaygroundApp.swift ├── BlitShader.swift ├── ConfigOverlay.swift ├── Diagram.swift ├── DiagramView.swift ├── GestureCatcher.swift ├── IntroEffectShader.swift ├── MathExtensions.swift ├── Metal.swift ├── MetalView.swift ├── OnboardAndThen.swift ├── Package.swift ├── RayTracingShader.swift ├── RelativisticRenderer.swift ├── RenderCoordinator.swift ├── RenderView.swift ├── RootView.swift └── SimpleError.swift ├── blackbody_lookup_generator.py └── screenshot.png /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Blackhole Playground 2 | 3 | For the 2024 Swift Student Challenge I created a raytracer which incorporates general relativity 4 | to be able to render blackholes (in realtime). The playground also includes a 2d version of the 5 | raytracer which produces in interactive diagram showing the paths of light rays emitted from a 6 | light source in the presence of a black hole. 7 | 8 | The aim is to help students who are learning about General Relativity to visualize problems and 9 | gain an intuition for what's going on behind the maths. It also just looks pretty cool. 10 | 11 | The raytracing is done by numerically integrating a second order differential equation derived 12 | from Schwarzschild's solution to the Einstein Field Equations for the specific case of a single 13 | non-rotating spherical mass in an otherwise empty universe. In reality blackholes almost always 14 | have significant angular momentum, and I'd be interested in investigating how much a blackhole's 15 | rotation affects how it looks. 16 | 17 | The playground features an onboarding flow that runs everytime the you reopen the app, since it 18 | is a playground. But if turned into an actual app I'd of course clean up the onboarding flow and 19 | make it only run once. 20 | 21 | Please note that much of the app was crammed pretty close to the deadline so there's definitely 22 | more that I could clean up. 23 | 24 | ![A screenshot of the blackhole renderer's 3d view](screenshot.png) 25 | 26 | ## Notable inaccuracies 27 | 28 | - Doesn't consider the velocity of the observer (which may or may not affect the produced images, 29 | I'd need to investigate how significant the difference would be) 30 | - Doesn't simulate phenoma that affect the frequency of light, such as the relativistic doppler shift 31 | effect. 32 | - The accretion disk is only vaguely physically based (the gradient colouring is based on 33 | blackbody radiation). To make it more realistic without introducing a janky looking static 34 | texture, a fluid simulation of some sort would likely be required (which I may try my hand 35 | at some day...) 36 | - And probably more! 37 | 38 | ## Starmap credits 39 | 40 | > NASA/Goddard Space Flight Center Scientific Visualization Studio. Gaia DR2: ESA/Gaia/DPAC. 41 | > Constellation figures based on those developed for the IAU by Alan MacRobert of Sky and 42 | > Telescope magazine (Roger Sinnott and Rick Fienberg). 43 | -------------------------------------------------------------------------------- /RelativisticRenderer.swiftpm/.gitignore: -------------------------------------------------------------------------------- 1 | /.swiftpm 2 | -------------------------------------------------------------------------------- /RelativisticRenderer.swiftpm/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Screenshot 2024-02-26 at 5.45.02 pm.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | } 9 | ], 10 | "info" : { 11 | "author" : "xcode", 12 | "version" : 1 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /RelativisticRenderer.swiftpm/Assets.xcassets/AppIcon.appiconset/Screenshot 2024-02-26 at 5.45.02 pm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stackotter/relativistic-renderer/571a2f68f0b64b7180e8c720d4e21c4c2da110b0/RelativisticRenderer.swiftpm/Assets.xcassets/AppIcon.appiconset/Screenshot 2024-02-26 at 5.45.02 pm.png -------------------------------------------------------------------------------- /RelativisticRenderer.swiftpm/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /RelativisticRenderer.swiftpm/Assets.xcassets/starmap_2020_4k.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "starmap_2020_4k.jpg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /RelativisticRenderer.swiftpm/Assets.xcassets/starmap_2020_4k.imageset/starmap_2020_4k.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stackotter/relativistic-renderer/571a2f68f0b64b7180e8c720d4e21c4c2da110b0/RelativisticRenderer.swiftpm/Assets.xcassets/starmap_2020_4k.imageset/starmap_2020_4k.jpg -------------------------------------------------------------------------------- /RelativisticRenderer.swiftpm/Await.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct Await: View { 4 | var task: () async throws -> R 5 | 6 | @ViewBuilder var placeholder: () -> Placeholder 7 | @ViewBuilder var success: (R) -> Success 8 | @ViewBuilder var failure: (any Error) -> Failure 9 | 10 | @State var state = AwaitState.awaiting 11 | 12 | init( 13 | _ task: @escaping () async throws -> R, 14 | @ViewBuilder placeholder: @escaping () -> Placeholder, 15 | @ViewBuilder success: @escaping (R) -> Success, 16 | @ViewBuilder failure: @escaping (any Error) -> Failure 17 | ) { 18 | self.task = task 19 | self.placeholder = placeholder 20 | self.success = success 21 | self.failure = failure 22 | } 23 | 24 | enum AwaitState { 25 | case awaiting 26 | case success(R) 27 | case failure(any Error) 28 | } 29 | 30 | var body: some View { 31 | VStack { 32 | switch state { 33 | case .awaiting: 34 | placeholder() 35 | case let .success(result): 36 | success(result) 37 | case let .failure(error): 38 | failure(error) 39 | } 40 | } 41 | .onAppear { 42 | Task { 43 | do { 44 | let result = try await task() 45 | Task { @MainActor in 46 | state = .success(result) 47 | } 48 | } catch { 49 | state = .failure(error) 50 | } 51 | } 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /RelativisticRenderer.swiftpm/Binding+extensions.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public extension Binding where Value: BinaryInteger { 4 | func into() -> Binding { 5 | Binding( 6 | get: { F(wrappedValue) }, 7 | set: { wrappedValue = Value($0) } 8 | ) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /RelativisticRenderer.swiftpm/BlackholePlaygroundApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @main 4 | struct BlackholePlaygroundApp: App { 5 | var body: some Scene { 6 | WindowGroup { 7 | RootView() 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /RelativisticRenderer.swiftpm/BlitShader.swift: -------------------------------------------------------------------------------- 1 | let blitShaderSource = 2 | """ 3 | #include 4 | 5 | using namespace metal; 6 | 7 | constexpr sampler textureSampler (mag_filter::linear, min_filter::linear, address::repeat); 8 | 9 | typedef struct { 10 | float4 position [[position]]; 11 | float2 uv; 12 | } BlitVertex; 13 | 14 | constant BlitVertex blitVertices[] = { 15 | { .position = float4(-1.0, 1.0, 0, 1), .uv = float2(0, 0) }, 16 | { .position = float4(1.0, 1.0, 0, 1), .uv = float2(1, 0) }, 17 | { .position = float4(1.0, -1.0, 0, 1), .uv = float2(1, 1) }, 18 | { .position = float4(1.0, -1.0, 0, 1), .uv = float2(1, 1) }, 19 | { .position = float4(-1.0, -1.0, 0, 1), .uv = float2(0, 1) }, 20 | { .position = float4(-1.0, 1.0, 0, 1), .uv = float2(0, 0) } 21 | }; 22 | 23 | vertex BlitVertex blitVertexFunction(uint vertexId [[vertex_id]]) { 24 | return blitVertices[vertexId]; 25 | } 26 | 27 | fragment float4 blitFragmentFunction(BlitVertex in [[stage_in]], 28 | texture2d inTexture [[texture(0)]]) { 29 | return inTexture.sample(textureSampler, in.uv); 30 | } 31 | """ 32 | -------------------------------------------------------------------------------- /RelativisticRenderer.swiftpm/ConfigOverlay.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ConfigOverlay: View { 4 | @ViewBuilder 5 | var child: () -> Child 6 | 7 | var body: some View { 8 | HStack(alignment: .top) { 9 | VStack { 10 | VStack(alignment: .leading) { 11 | child() 12 | } 13 | .padding(16) 14 | .background(.black.opacity(0.6)) 15 | .frame(width: 400) 16 | 17 | Spacer() 18 | } 19 | Spacer() 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /RelativisticRenderer.swiftpm/Diagram.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct DiagramBuilder { 4 | var path: Path 5 | var scale: CGFloat 6 | var offset: CGPoint 7 | 8 | mutating func addLine(_ points: [CGPoint]) { 9 | guard let firstPoint = points.first else { 10 | return 11 | } 12 | let transformedPoint = firstPoint * scale + offset 13 | path.move(to: transformedPoint) 14 | for point in points[1...] { 15 | let transformedPoint = point * scale + offset 16 | // `Path` starts acting weird if points get too far out of frame (perhaps some sort of culling algorithm 17 | // gets confused. 18 | if transformedPoint.magnitude > 1000000 { 19 | break 20 | } 21 | path.addLine(to: transformedPoint) 22 | } 23 | } 24 | 25 | mutating func addCircle(center: CGPoint, radius: CGFloat) { 26 | path.move(to: (center + CGPoint(x: radius, y: 0)) * scale + offset) 27 | path.addArc( 28 | center: center * scale + offset, 29 | radius: radius * scale, 30 | startAngle: .zero, 31 | endAngle: .radians(2 * .pi), 32 | clockwise: true 33 | ) 34 | } 35 | } 36 | 37 | struct Diagram: View { 38 | var scale: CGFloat 39 | var offset: CGPoint 40 | var buildDiagram: (inout DiagramBuilder) -> Void 41 | var color = Color.white 42 | var strokeStyle = StrokeStyle() 43 | 44 | var body: some View { 45 | Path { path in 46 | var builder = DiagramBuilder(path: path, scale: scale, offset: offset) 47 | buildDiagram(&builder) 48 | path = builder.path 49 | } 50 | .stroke(color, style: strokeStyle) 51 | } 52 | 53 | func stroke(_ color: Color, style: StrokeStyle) -> Self { 54 | var diagram = self 55 | diagram.color = color 56 | diagram.strokeStyle = style 57 | return diagram 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /RelativisticRenderer.swiftpm/DiagramView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A 2d diagram of light bending around a blackhole. Allows the light source to be moved around and 4 | /// the simulation to be configured (e.g. by changing granularity). 5 | struct DiagramView: View { 6 | static var lineWidth: CGFloat { 2 } 7 | static var g: CGFloat { 1 } 8 | static var c: CGFloat { 1 } 9 | static var blackHolePosition: CGPoint { CGPoint(x: 26, y: 15) } 10 | 11 | @State var scale: CGFloat = 30 12 | @State var stepsMagnitude: CGFloat = 3 13 | @State var maxRevolutions = 3 14 | @State var precisePositioning = false 15 | @State var offset: CGPoint 16 | 17 | @State var currentObserverPosition = CGPoint(x: 10, y: 15) 18 | @GestureState var observerDragDistance = CGSize.zero 19 | 20 | var steps: Int { 21 | Int(pow(10, stepsMagnitude)) 22 | } 23 | 24 | var tab: Binding? 25 | 26 | init(tab: Binding?, offset: CGPoint? = nil) { 27 | self.tab = tab 28 | self.offset = offset ?? .zero 29 | } 30 | 31 | var observerPosition: CGPoint { 32 | return currentObserverPosition + observerDragDistance 33 | } 34 | 35 | /// The radius within which nothing can escape, not even light. The chosen coordinate system 36 | /// means that this is simply 1 for our blackhole. 37 | static var schwarzschildRadius: CGFloat { 1 } 38 | 39 | var body: some View { 40 | NavigationSplitView { 41 | configPanel 42 | } detail: { 43 | ZStack { 44 | GeometryReader { geometry in 45 | ZStack { 46 | Diagram(scale: scale, offset: offset) { builder in 47 | for i in 0...10 { 48 | let radians = -CGFloat.pi / 4 * CGFloat(i) / 10 + .pi / 8 49 | let velocity = CGPoint(x: Self.c * cos(radians), y: Self.c * sin(radians)) 50 | builder.addLine(Self.trajectory( 51 | initialPosition: observerPosition, 52 | initialVelocity: velocity, 53 | massPosition: Self.blackHolePosition, 54 | maxRevolutions: maxRevolutions, 55 | steps: steps 56 | )) 57 | } 58 | } 59 | .stroke(Color.yellow, style: StrokeStyle(lineWidth: Self.lineWidth)) 60 | 61 | Diagram(scale: scale, offset: offset) { builder in 62 | builder.addCircle(center: Self.blackHolePosition, radius: Self.schwarzschildRadius) 63 | } 64 | .stroke(Color.white, style: StrokeStyle(lineWidth: Self.lineWidth)) 65 | } 66 | } 67 | .contentShape(Rectangle()) 68 | .background(Color.black) 69 | .clipped() 70 | .modifier(GestureModifier(offset: $offset, scale: $scale)) 71 | 72 | flashlight 73 | } 74 | .toolbar { 75 | ToolbarItem(placement: .topBarTrailing) { 76 | Picker("Tab", selection: tab ?? Binding { RootView.Tab._2d } set: { _ in }) { 77 | Text("2d").tag(RootView.Tab._2d) 78 | Text("3d").tag(RootView.Tab._3d) 79 | } 80 | .pickerStyle(SegmentedPickerStyle()) 81 | .disabled(tab == nil) 82 | } 83 | } 84 | } 85 | } 86 | 87 | var configPanel: some View { 88 | List { 89 | Text("Max revolutions: \(maxRevolutions)") 90 | Slider(value: $maxRevolutions.into(), in: 1...10) 91 | 92 | Text("Steps: \(steps)") 93 | Slider(value: $stepsMagnitude, in: 0...4) 94 | .onChange(of: stepsMagnitude) { _ in 95 | // Click the steps to logarithmic increments 96 | stepsMagnitude = log10(CGFloat(steps)) 97 | } 98 | 99 | Toggle("Precise positioning", isOn: $precisePositioning) 100 | } 101 | } 102 | 103 | var flashlight: some View { 104 | VStack(alignment: .leading) { 105 | HStack { 106 | Image(systemName: "flashlight.on.fill") 107 | .contentShape(Rectangle()) 108 | .gesture( 109 | DragGesture(coordinateSpace: CoordinateSpace.global) 110 | .map { value in 111 | let sensitivity = precisePositioning ? 0.2 : 1 112 | return value.translation / scale * sensitivity 113 | } 114 | .updating($observerDragDistance) { value, state, _ in 115 | state = value 116 | } 117 | .onEnded { value in 118 | currentObserverPosition += value 119 | } 120 | ) 121 | .rotationEffect(.radians(.pi / 2)) 122 | .scaleEffect(3) 123 | .offset(x: -32, y: -10) 124 | .offset(x: (observerPosition.x) * scale + offset.x, y: (observerPosition.y) * scale + offset.y) 125 | .foregroundColor(.white) 126 | Spacer() 127 | } 128 | Spacer() 129 | } 130 | } 131 | 132 | static func trajectory( 133 | initialPosition: CGPoint, 134 | initialVelocity: CGPoint, 135 | massPosition: CGPoint, 136 | maxRevolutions: Int, 137 | steps: Int 138 | ) -> [CGPoint] { 139 | var points: [CGPoint] = [initialPosition] 140 | let polarPosition = (initialPosition - massPosition).polar 141 | 142 | var u = 1 / polarPosition.radius 143 | var xBasis = polarPosition.cartesian 144 | xBasis /= xBasis.magnitude 145 | var yBasis = CGPoint(x: -xBasis.y, y: xBasis.x) 146 | yBasis /= yBasis.magnitude 147 | let ray = initialVelocity / initialVelocity.magnitude 148 | if atan2(dot(ray, yBasis), dot(ray, xBasis)) < 0 { 149 | yBasis *= -1 150 | } 151 | var du = -dot(ray, xBasis) / dot(ray, yBasis) * u 152 | var phi: CGFloat = 0 153 | 154 | // Don't start ray tracing if we're inside the event horizon already 155 | if u > 1 / schwarzschildRadius { 156 | return points 157 | } 158 | 159 | var position: CGPoint = polarPosition.cartesian 160 | var previousPosition = position 161 | for i in 0.. 0 { 166 | let maxStep = (1 / schwarzschildRadius - u) / du 167 | step = min(step, maxStep) 168 | } 169 | 170 | u += du * step 171 | let ddu = -u * (1 - 1.5 * u * u) 172 | du += ddu * step 173 | phi += step 174 | 175 | if u < 0 { 176 | // The ray has shot off to infinity, extend it an arbitrarily large amount and stop 177 | let step: CGFloat = 10000 178 | 179 | // If this condition is met on the first step, then it means the ray is basically 180 | // travelling directly away from the black hole's center of mass. That's useful 181 | // because on the first step we don't have a previous step to extrapolate from. 182 | let velocity = if i == 0 { 183 | position 184 | } else { 185 | position - previousPosition 186 | } 187 | 188 | points.append(position + velocity / velocity.magnitude * step + massPosition) 189 | break 190 | } 191 | 192 | previousPosition = position 193 | position = (xBasis * cos(phi) + yBasis * sin(phi)) / u 194 | points.append(position + massPosition) 195 | 196 | // Stop once we cross the event horizon 197 | if u > 1 / schwarzschildRadius { 198 | break 199 | } 200 | } 201 | 202 | return points 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /RelativisticRenderer.swiftpm/GestureCatcher.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | class GestureCatcherView: UIView { 4 | var offset: CGPoint 5 | var offsetDifference: CGPoint = .zero 6 | let offsetChanged: (CGPoint) -> Void 7 | var scale: CGFloat 8 | var currentPinchScaleFactor: CGFloat = 1 9 | let scaleChanged: (CGFloat) -> Void 10 | 11 | init( 12 | currentOffset: CGPoint, 13 | currentScale: CGFloat, 14 | offsetChanged: @escaping (CGPoint) -> Void, 15 | scaleChanged: @escaping (CGFloat) -> Void 16 | ) { 17 | self.offset = currentOffset 18 | self.scale = currentScale 19 | self.offsetChanged = offsetChanged 20 | self.scaleChanged = scaleChanged 21 | super.init(frame: .zero) 22 | let panGesture = UIPanGestureRecognizer(target: self, action: #selector(pan(gesture:))) 23 | panGesture.cancelsTouchesInView = false 24 | addGestureRecognizer(panGesture) 25 | let pinchGesture = UIPinchGestureRecognizer(target: self, action: #selector(pinch(gesture:))) 26 | pinchGesture.cancelsTouchesInView = false 27 | addGestureRecognizer(pinchGesture) 28 | } 29 | 30 | required init?(coder: NSCoder) { 31 | fatalError() 32 | } 33 | 34 | @objc private func pan(gesture: UIPanGestureRecognizer) { 35 | switch gesture.state { 36 | case .changed, .ended: 37 | let translation = gesture.translation(in: self) 38 | offsetDifference = translation 39 | if gesture.state == .ended { 40 | offset += offsetDifference 41 | offsetDifference = .zero 42 | } 43 | offsetChanged(offset + offsetDifference) 44 | default: 45 | break 46 | } 47 | } 48 | 49 | @objc private func pinch(gesture: UIPinchGestureRecognizer) { 50 | switch gesture.state { 51 | case .changed, .ended: 52 | let previousScale = scale * currentPinchScaleFactor 53 | currentPinchScaleFactor = gesture.scale 54 | if gesture.state == .ended { 55 | scale *= currentPinchScaleFactor 56 | currentPinchScaleFactor = 1 57 | } 58 | let newScale = scale * currentPinchScaleFactor 59 | scaleChanged(newScale) 60 | 61 | let pinchLocation = gesture.location(in: self) 62 | let scaleCenter = pinchLocation - offset 63 | 64 | offset -= scaleCenter * (newScale / previousScale - 1) 65 | offsetChanged(offset) 66 | default: 67 | break 68 | } 69 | } 70 | } 71 | 72 | struct GestureCatcher: UIViewRepresentable { 73 | @Binding var offset: CGPoint 74 | @Binding var scale: CGFloat 75 | 76 | func makeUIView(context: Context) -> GestureCatcherView { 77 | return GestureCatcherView( 78 | currentOffset: offset, 79 | currentScale: scale, 80 | offsetChanged: { value in 81 | offset = value 82 | }, 83 | scaleChanged: { value in 84 | scale = value 85 | } 86 | ) 87 | } 88 | 89 | func updateUIView(_ view: GestureCatcherView, context: Context) { } 90 | } 91 | 92 | struct GestureModifier: ViewModifier { 93 | @Binding var offset: CGPoint 94 | @Binding var scale: CGFloat 95 | 96 | func body(content: Content) -> some View { 97 | content 98 | .overlay(GestureCatcher(offset: $offset, scale: $scale)) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /RelativisticRenderer.swiftpm/IntroEffectShader.swift: -------------------------------------------------------------------------------- 1 | let introEffectShaderSource = 2 | """ 3 | #include 4 | 5 | using namespace metal; 6 | 7 | constexpr sampler textureSampler (mag_filter::linear, min_filter::linear, address::repeat); 8 | 9 | typedef enum { 10 | STAR_MAP = 0, 11 | CHECKER_BOARD = 1 12 | } Background; 13 | 14 | typedef struct { 15 | float cameraX; 16 | float cameraY; 17 | float cameraZ; 18 | float cameraPitch; 19 | float cameraYaw; 20 | Background background; 21 | int stepCount; 22 | int maxRevolutions; 23 | float accretionDiskStart; 24 | float accretionDiskEnd; 25 | uint8_t renderAccretionDisk; 26 | } Configuration; 27 | 28 | kernel void computeFunction(uint2 gid [[thread_position_in_grid]], 29 | texture2d outTexture [[texture(0)]], 30 | texture2d skyTexture [[texture(1)]], 31 | constant float &time [[buffer(0)]], 32 | constant Configuration &config [[buffer(1)]]) { 33 | float textureWidth = (float)outTexture.get_width(); 34 | float textureHeight = (float)outTexture.get_height(); 35 | 36 | // A ray from the camera representing the direction that light must come from to 37 | // contribute to the current pixel (we trace this ray backwards). 38 | float3 cartesianRay = float3( 39 | (float(gid.x) - textureWidth/2) / textureWidth * 2, 40 | 1, 41 | -(float(gid.y) - textureHeight/2) / textureWidth * 2 42 | ); 43 | float rayTheta = atan2(cartesianRay.z, cartesianRay.x); 44 | float rayPhi = atan2(cartesianRay.y, length(cartesianRay.xz)); 45 | 46 | float zoom = log(time + 3) * 2; 47 | cartesianRay = float3( 48 | cos(rayPhi + zoom) * sin(rayTheta), 49 | sin(rayPhi + zoom), 50 | cos(rayPhi + zoom) * cos(rayTheta) 51 | ); 52 | 53 | float3 ray = normalize(cartesianRay); 54 | float2 uv = float2( 55 | atan2(ray.z, ray.x) / (2 * M_PI_F) + 0.5 + time * 0.02, 56 | atan2(ray.y, length(ray.xz)) / M_PI_F + 0.5 57 | ); 58 | 59 | float4 color = skyTexture.sample(textureSampler, uv); 60 | outTexture.write(color, gid); 61 | } 62 | """ 63 | -------------------------------------------------------------------------------- /RelativisticRenderer.swiftpm/MathExtensions.swift: -------------------------------------------------------------------------------- 1 | import CoreGraphics 2 | 3 | func /(_ lhs: CGPoint, _ rhs: CGFloat) -> CGPoint { 4 | return lhs * (1 / rhs) 5 | } 6 | 7 | func /=(_ lhs: inout CGPoint, _ rhs: CGFloat) { 8 | lhs = lhs / rhs 9 | } 10 | 11 | func /(_ lhs: CGSize, _ rhs: CGFloat) -> CGSize { 12 | return lhs * (1 / rhs) 13 | } 14 | 15 | func +(_ lhs: CGPoint, _ rhs: CGPoint) -> CGPoint { 16 | var lhs = lhs 17 | lhs.x += rhs.x 18 | lhs.y += rhs.y 19 | return lhs 20 | } 21 | 22 | func +=(_ lhs: inout CGPoint, _ rhs: CGPoint) { 23 | lhs.x += rhs.x 24 | lhs.y += rhs.y 25 | } 26 | 27 | func +(_ lhs: CGPoint, _ rhs: CGSize) -> CGPoint { 28 | var lhs = lhs 29 | lhs.x += rhs.width 30 | lhs.y += rhs.height 31 | return lhs 32 | } 33 | 34 | func +=(_ lhs: inout CGPoint, _ rhs: CGSize) { 35 | lhs.x += rhs.width 36 | lhs.y += rhs.height 37 | } 38 | 39 | func -(_ lhs: CGPoint, _ rhs: CGPoint) -> CGPoint { 40 | return lhs + (-rhs) 41 | } 42 | 43 | func -=(_ lhs: inout CGPoint, _ rhs: CGPoint) { 44 | lhs += -rhs 45 | } 46 | 47 | prefix func -(_ point: CGPoint) -> CGPoint { 48 | return point * -1 49 | } 50 | 51 | func *(_ lhs: CGPoint, _ rhs: CGFloat) -> CGPoint { 52 | var lhs = lhs 53 | lhs.x *= rhs 54 | lhs.y *= rhs 55 | return lhs 56 | } 57 | 58 | func *=(_ lhs: inout CGPoint, _ rhs: CGFloat) { 59 | lhs.x *= rhs 60 | lhs.y *= rhs 61 | } 62 | 63 | func *(_ lhs: CGSize, _ rhs: CGFloat) -> CGSize { 64 | var lhs = lhs 65 | lhs.width *= rhs 66 | lhs.height *= rhs 67 | return lhs 68 | } 69 | 70 | protocol CGVector { 71 | var components: [CGFloat] { get } 72 | } 73 | 74 | extension CGVector { 75 | var magnitude: CGFloat { 76 | sqrt( 77 | components 78 | .map { $0 * $0 } 79 | .reduce(0, +) 80 | ) 81 | } 82 | } 83 | 84 | extension CGPoint: CGVector { 85 | var components: [CGFloat] { 86 | [x, y] 87 | } 88 | } 89 | 90 | extension CGSize: CGVector { 91 | var components: [CGFloat] { 92 | [width, height] 93 | } 94 | } 95 | 96 | extension CGPoint { 97 | var polar: PolarPoint { 98 | PolarPoint(radius: magnitude, theta: atan2(y, x)) 99 | } 100 | } 101 | 102 | func dot(_ lhs: CGPoint, _ rhs: CGPoint) -> CGFloat { 103 | lhs.x * rhs.x + lhs.y * rhs.y 104 | } 105 | 106 | struct PolarPoint { 107 | var radius: CGFloat 108 | var theta: CGFloat 109 | 110 | var cartesian: CGPoint { 111 | CGPoint(x: radius * cos(theta), y: radius * sin(theta)) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /RelativisticRenderer.swiftpm/Metal.swift: -------------------------------------------------------------------------------- 1 | import Metal 2 | 3 | struct MetalDevice { 4 | var wrappedDevice: any MTLDevice 5 | 6 | init(wrapping device: any MTLDevice) { 7 | self.wrappedDevice = device 8 | } 9 | 10 | static func systemDefault() throws -> MetalDevice { 11 | guard let device = MTLCreateSystemDefaultDevice() else { 12 | throw SimpleError("Failed to create system default device") 13 | } 14 | return Self(wrapping: device) 15 | } 16 | 17 | func compileMetalLibrary(source: String) throws -> MetalLibrary { 18 | let options = MTLCompileOptions() 19 | do { 20 | return MetalLibrary(wrapping: try wrappedDevice.makeLibrary(source: source, options: options)) 21 | } catch { 22 | throw SimpleError("Failed to compile shaders: \(error)") 23 | } 24 | } 25 | 26 | func makeComputePipelineState(function: any MTLFunction) throws -> any MTLComputePipelineState { 27 | do { 28 | return try wrappedDevice.makeComputePipelineState(function: function) 29 | } catch { 30 | throw SimpleError("Failed to make compute pipeline state: \(error)") 31 | } 32 | } 33 | 34 | func makeRenderPipelineState( 35 | vertexFunction: any MTLFunction, 36 | fragmentFunction: any MTLFunction 37 | ) throws -> any MTLRenderPipelineState { 38 | let pipelineDescriptor = MTLRenderPipelineDescriptor() 39 | pipelineDescriptor.vertexFunction = vertexFunction 40 | pipelineDescriptor.fragmentFunction = fragmentFunction 41 | pipelineDescriptor.colorAttachments[0].pixelFormat = .bgra8Unorm 42 | 43 | do { 44 | return try wrappedDevice.makeRenderPipelineState(descriptor: pipelineDescriptor) 45 | } catch { 46 | throw SimpleError("Failed to make pipeline state: \(error)") 47 | } 48 | } 49 | 50 | func makeScalarBuffer() throws -> MetalScalarBuffer { 51 | guard let buffer = wrappedDevice.makeBuffer(length: MemoryLayout.stride) else { 52 | throw SimpleError("Failed to create buffer") 53 | } 54 | return MetalScalarBuffer(wrapping: buffer) 55 | } 56 | 57 | func makeScalarBuffer(initialValue: T) throws -> MetalScalarBuffer { 58 | guard let buffer = wrappedDevice.makeBuffer(length: MemoryLayout.stride) else { 59 | throw SimpleError("Failed to create buffer") 60 | } 61 | let scalarBuffer = MetalScalarBuffer(wrapping: buffer) 62 | scalarBuffer.copyMemory(from: initialValue) 63 | return scalarBuffer 64 | } 65 | } 66 | 67 | struct MetalLibrary { 68 | var wrappedLibrary: any MTLLibrary 69 | 70 | init(wrapping library: any MTLLibrary) { 71 | self.wrappedLibrary = library 72 | } 73 | 74 | func getFunction(name: String) throws -> any MTLFunction { 75 | guard let function = wrappedLibrary.makeFunction(name: name) else { 76 | throw SimpleError("Failed to get function '\(name)'") 77 | } 78 | return function 79 | } 80 | } 81 | 82 | struct MetalScalarBuffer { 83 | var wrappedBuffer: any MTLBuffer 84 | 85 | init(wrapping buffer: any MTLBuffer) { 86 | wrappedBuffer = buffer 87 | } 88 | 89 | func copyMemory(from value: T) { 90 | withUnsafePointer(to: value) { pointer in 91 | wrappedBuffer.contents().copyMemory(from: pointer, byteCount: MemoryLayout.stride) 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /RelativisticRenderer.swiftpm/MetalView.swift: -------------------------------------------------------------------------------- 1 | import MetalKit 2 | import SwiftUI 3 | 4 | struct MetalView: UIViewRepresentable { 5 | typealias UIViewType = MTKView 6 | 7 | @Binding var error: String? 8 | var configuration: Coordinator.Configuration 9 | let createCoordinator: () throws -> Coordinator 10 | 11 | func makeCoordinator() -> Coordinator? { 12 | do { 13 | return try createCoordinator() 14 | } catch { 15 | DispatchQueue.main.async { 16 | self.error = "Failed to create render coordinator: \(error)" 17 | } 18 | return nil 19 | } 20 | } 21 | 22 | func makeUIView(context: Context) -> MTKView { 23 | let mtkView = MTKView() 24 | mtkView.delegate = context.coordinator 25 | context.coordinator?.setup(mtkView) 26 | return mtkView 27 | } 28 | 29 | func updateUIView(_ uiView: MTKView, context: Context) { 30 | context.coordinator?.configuration = configuration 31 | } 32 | } 33 | 34 | protocol MetalViewCoordinator: MTKViewDelegate { 35 | associatedtype Configuration 36 | var configuration: Configuration { get set } 37 | func setup(_ view: MTKView) 38 | } 39 | -------------------------------------------------------------------------------- /RelativisticRenderer.swiftpm/OnboardAndThen.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct OnboardAndThen: View { 4 | var resources: RelativisticRenderer.Resources 5 | @ViewBuilder var child: () -> Child 6 | 7 | @State var onboardingState = OnboardingState.onboarding(.title) 8 | @State var titleOpacity: Double = 0 9 | 10 | enum OnboardingState { 11 | case onboarding(OnboardingStep) 12 | case done 13 | } 14 | 15 | enum OnboardingStep: CaseIterable { 16 | case title 17 | case intro 18 | case _2d 19 | case _3d 20 | } 21 | 22 | let introduction = Introduction( 23 | sections: [ 24 | Introduction.Section( 25 | title: "General relativity", 26 | paragraphs: [ 27 | "Blackholes and the behaviour of other objects in their presence is best described by Einstein's Theory of General Relativity.", 28 | "Einstein's radical new way of describing gravity proposed that gravity is the result of curvature in spacetime. The more massive an object, the more it curves spacetime.", 29 | "Einstein's equations are extremely difficult to solve in the general case, but the brilliant physicist Karl Schwarzschild found a solution that applies when you have a single spherical non-rotating mass. It's a simplification, but Schwarzschild's solution is accurate enough in many situations." 30 | ] 31 | ), 32 | Introduction.Section( 33 | title: "Light", 34 | paragraphs: [ 35 | "Light is famously both a particle and wave, but under General Relativity, we just treat it as a particle - the photon.", 36 | "Photons have no mass, but they're still affected by the curvature of spacetime due to their momentum. This gives rise to the strange appearance of blackholes." 37 | ] 38 | ), 39 | Introduction.Section( 40 | title: "Blackholes", 41 | paragraphs: ["A blackhole is an object that has collapsed under its own gravity into a single point in space. Not even light can escape beyond the point of no return - the event horizon."] 42 | ), 43 | Introduction.Section( 44 | title: "Accretion disks", 45 | paragraphs: ["Blackholes often form extremely hot disks of gas that reach extreme temperatures and speeds. Contrary to how they may look, accretion disks truly are flat disks, but their light gets bent before it reaches your eyes allowing you to see the top and bottom of the disk at the same time."] 46 | ), 47 | Introduction.Section( 48 | title: "Raytracing", 49 | paragraphs: [ 50 | "Raytracing is one of the two main ways that computers render scenes (the other being rasterization). Raytracing involves tracing the path of light (backwards) to figure out which colour of light could have reached a particular pixel of your virtual camera.", 51 | "Raytracers generally assume that light travels in straight lines, but as you now know, it doesn't always! The raytracer you'll see in the 3d view has been specifically designed to incorporate the effects of general relativity while tracing the path of light through the scene.", 52 | "You may notice that as you change the raytracing settings the camera seems to zoom in or out. This is due to the error increasing/decreasing as you change the granularity of the raytracer." 53 | ] 54 | ) 55 | ] 56 | ) 57 | 58 | let instructionPanel2d = InstructionPanel( 59 | paragraphs: [ 60 | "In 2d view you can move a light source around a blackhole and observe how the rays of light are affected by the blackhole's extreme gravity.", 61 | "The rays are traced by solving Schwarzschild's solution with a numerical integration method." 62 | ], 63 | legend: [ 64 | ("slider.horizontal.3", "Use the sidebar to configure the number of raytracing steps, the number of times that light will get followed around the blackhole (for performance reasons), or access precision positioning mode."), 65 | ("arrow.up.and.down.and.arrow.left.and.right", "Drag the torch to move it"), 66 | ("arrow.up.and.down.and.arrow.left.and.right", "Drag anywhere else to move the camera"), 67 | ("arrow.down.left.and.arrow.up.right", "Pinch to zoom") 68 | ] 69 | ) 70 | 71 | let instructionPanel3d = InstructionPanel( 72 | paragraphs: [ 73 | "In 3d view you can move around a blackhole and observe how the blackhole's extreme gravity magnifies, and warps objects behind the blackhole, even allowing you to see parts of the accretion disk that would be hidden by the blackhole if not for light bending. If you look closely you can see multiple copies of stars as they pass behind the blackhole." 74 | ], 75 | legend: [ 76 | ("slider.horizontal.3", "Configure the environment and raytracer in the sidebar"), 77 | ("arrow.up.and.down.and.arrow.left.and.right", "Drag to move the camera"), 78 | ] 79 | ) 80 | 81 | var body: some View { 82 | switch onboardingState { 83 | case .onboarding(let onboardingStep): 84 | switch onboardingStep { 85 | case .title, .intro: 86 | ZStack { 87 | MetalView(error: Binding { nil } set: { _ in }, configuration: .default.with(\.introEffect, true)) { 88 | try RenderCoordinator.create(with: resources) 89 | } 90 | .ignoresSafeArea() 91 | 92 | if onboardingStep == .title { 93 | VStack { 94 | Text("Blackhole Playground") 95 | .font(.title) 96 | Spacer().frame(height: 16) 97 | Text("Explore the physics of light under extreme gravity") 98 | Spacer().frame(height: 16) 99 | HStack { 100 | Image(systemName: "rectangle.landscape.rotate") 101 | Text("Landscape mode is recommended") 102 | } 103 | Spacer().frame(height: 16) 104 | Button("Begin") { 105 | onboardingState = .onboarding(.intro) 106 | } 107 | .buttonStyle(.bordered) 108 | } 109 | .padding(16) 110 | .background(Color(UIColor.systemBackground)) 111 | .clipShape(RoundedRectangle(cornerSize: CGSize(width: 8, height: 8))) 112 | .opacity(titleOpacity) 113 | .onAppear { 114 | withAnimation(.easeInOut(duration: 1).delay(0.5)) { 115 | titleOpacity = 1 116 | } 117 | } 118 | } else { 119 | VStack(alignment: .leading) { 120 | ScrollView { 121 | VStack(alignment: .leading) { 122 | introduction 123 | } 124 | } 125 | .padding(.bottom, 16) 126 | Button("Next") { 127 | onboardingState = .onboarding(._2d) 128 | } 129 | .buttonStyle(.bordered) 130 | } 131 | .padding() 132 | .frame(maxWidth: 600) 133 | .background(Color(UIColor.systemBackground)) 134 | .clipShape(RoundedRectangle(cornerRadius: 8)) 135 | .padding() 136 | } 137 | } 138 | case ._2d: 139 | ZStack { 140 | DiagramView(tab: nil, offset: CGPoint(x: -200, y: -250)) 141 | 142 | InstructionPanelOverlay(panel: instructionPanel2d) { 143 | onboardingState = .onboarding(._3d) 144 | } 145 | } 146 | case ._3d: 147 | ZStack { 148 | RenderView(tab: nil, resources: resources) 149 | 150 | InstructionPanelOverlay(panel: instructionPanel3d) { 151 | onboardingState = .done 152 | } 153 | } 154 | } 155 | case .done: 156 | child() 157 | } 158 | } 159 | } 160 | 161 | extension OnboardAndThen { 162 | struct Introduction: View { 163 | var sections: [Section] 164 | 165 | struct Section { 166 | var title: String 167 | var paragraphs: [String] 168 | } 169 | 170 | var body: some View { 171 | ForEach(sections, id: \.title) { section in 172 | Text(section.title) 173 | .font(.title) 174 | .padding(.bottom, 16) 175 | ForEach(section.paragraphs, id: \.self) { paragraph in 176 | Text(paragraph) 177 | .padding(.bottom, 16) 178 | } 179 | } 180 | } 181 | } 182 | } 183 | 184 | extension OnboardAndThen { 185 | struct InstructionPanel: View { 186 | var paragraphs: [String] 187 | var legend: [(icon: String, description: String)] 188 | 189 | var body: some View { 190 | Group { 191 | ForEach(paragraphs, id: \.self) { paragraph in 192 | Text(paragraph) 193 | } 194 | ForEach(legend, id: \.description) { (icon, description) in 195 | HStack { 196 | Image(systemName: icon) 197 | Text(description) 198 | } 199 | } 200 | } 201 | .padding(.bottom, 16) 202 | } 203 | } 204 | } 205 | 206 | extension OnboardAndThen { 207 | struct InstructionPanelOverlay: View { 208 | var panel: InstructionPanel 209 | var next: () -> Void 210 | 211 | var body: some View { 212 | HStack { 213 | Spacer() 214 | VStack { 215 | Spacer() 216 | VStack(alignment: .leading) { 217 | panel 218 | 219 | Button("Next") { 220 | next() 221 | } 222 | .buttonStyle(.bordered) 223 | } 224 | .padding() 225 | .background(Color(UIColor.systemBackground)) 226 | .clipShape(RoundedRectangle(cornerSize: CGSize(width: 8, height: 8))) 227 | .frame(width: 600) 228 | .offset(x: -16, y: -16) 229 | } 230 | } 231 | } 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /RelativisticRenderer.swiftpm/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.8 2 | 3 | // WARNING: 4 | // This file is automatically generated. 5 | // Do not edit it by hand because the contents will be replaced. 6 | 7 | import PackageDescription 8 | import AppleProductTypes 9 | 10 | let package = Package( 11 | name: "BlackholePlayground", 12 | platforms: [ 13 | .iOS("16.0") 14 | ], 15 | products: [ 16 | .iOSApplication( 17 | name: "BlackholePlayground", 18 | targets: ["AppModule"], 19 | bundleIdentifier: "dev.stackotter.BlackholeRenderer", 20 | teamIdentifier: "2W73HS7DLT", 21 | displayVersion: "1.0", 22 | bundleVersion: "1", 23 | appIcon: .asset("AppIcon"), 24 | accentColor: .presetColor(.blue), 25 | supportedDeviceFamilies: [ 26 | .pad, 27 | .phone 28 | ], 29 | supportedInterfaceOrientations: [ 30 | .portrait, 31 | .landscapeRight, 32 | .landscapeLeft, 33 | .portraitUpsideDown(.when(deviceFamilies: [.pad])) 34 | ] 35 | ) 36 | ], 37 | targets: [ 38 | .executableTarget( 39 | name: "AppModule", 40 | path: "." 41 | ) 42 | ] 43 | ) -------------------------------------------------------------------------------- /RelativisticRenderer.swiftpm/RayTracingShader.swift: -------------------------------------------------------------------------------- 1 | let rayTracingShaderSource = 2 | """ 3 | #include 4 | 5 | using namespace metal; 6 | 7 | constexpr sampler textureSampler (mag_filter::linear, min_filter::linear, address::repeat); 8 | 9 | float4 sampleCheckerBoard(float2 uv, float scaleFactor) { 10 | float2 scaledUV = uv * scaleFactor; 11 | if (length(scaledUV) < 0.25) { 12 | return float4(1, 0.75, 0, 1); 13 | } 14 | if (((int)floor(scaledUV.x) % 2 == 0) == ((int)floor(scaledUV.y) % 2 == 0)) { 15 | return float4(1, 1, 1, 1); 16 | } else { 17 | return float4(0, 0, 0, 1); 18 | } 19 | } 20 | 21 | // Converted from the hex values at http://www.vendian.org/mncharity/dir3/blackbody/ 22 | // The first color is for 1000K (Kelvin), the second for 1200K and so on (in increments of 200K). 23 | constant float3 blackBodyRadiationLookup[] = { 24 | float3(255.0 / 255.0, 56.0 / 255.0, 0.0 / 255.0), 25 | float3(255.0 / 255.0, 83.0 / 255.0, 0.0 / 255.0), 26 | float3(255.0 / 255.0, 101.0 / 255.0, 0.0 / 255.0), 27 | float3(255.0 / 255.0, 115.0 / 255.0, 0.0 / 255.0), 28 | float3(255.0 / 255.0, 126.0 / 255.0, 0.0 / 255.0), 29 | float3(255.0 / 255.0, 137.0 / 255.0, 18.0 / 255.0), 30 | float3(255.0 / 255.0, 147.0 / 255.0, 44.0 / 255.0), 31 | float3(255.0 / 255.0, 157.0 / 255.0, 63.0 / 255.0), 32 | float3(255.0 / 255.0, 165.0 / 255.0, 79.0 / 255.0), 33 | float3(255.0 / 255.0, 173.0 / 255.0, 94.0 / 255.0), 34 | float3(255.0 / 255.0, 180.0 / 255.0, 107.0 / 255.0), 35 | float3(255.0 / 255.0, 187.0 / 255.0, 120.0 / 255.0), 36 | float3(255.0 / 255.0, 193.0 / 255.0, 132.0 / 255.0), 37 | float3(255.0 / 255.0, 199.0 / 255.0, 143.0 / 255.0), 38 | float3(255.0 / 255.0, 204.0 / 255.0, 153.0 / 255.0), 39 | float3(255.0 / 255.0, 209.0 / 255.0, 163.0 / 255.0), 40 | float3(255.0 / 255.0, 213.0 / 255.0, 173.0 / 255.0), 41 | float3(255.0 / 255.0, 217.0 / 255.0, 182.0 / 255.0), 42 | float3(255.0 / 255.0, 221.0 / 255.0, 190.0 / 255.0), 43 | float3(255.0 / 255.0, 225.0 / 255.0, 198.0 / 255.0), 44 | float3(255.0 / 255.0, 228.0 / 255.0, 206.0 / 255.0), 45 | float3(255.0 / 255.0, 232.0 / 255.0, 213.0 / 255.0), 46 | float3(255.0 / 255.0, 235.0 / 255.0, 220.0 / 255.0), 47 | float3(255.0 / 255.0, 238.0 / 255.0, 227.0 / 255.0), 48 | float3(255.0 / 255.0, 240.0 / 255.0, 233.0 / 255.0), 49 | float3(255.0 / 255.0, 243.0 / 255.0, 239.0 / 255.0), 50 | float3(255.0 / 255.0, 245.0 / 255.0, 245.0 / 255.0), 51 | float3(255.0 / 255.0, 248.0 / 255.0, 251.0 / 255.0), 52 | float3(254.0 / 255.0, 249.0 / 255.0, 255.0 / 255.0) 53 | }; 54 | 55 | // Approximation adapted from: https://github.com/zubetto/BlackBodyRadiation/blob/main/BlackBodyRadiation.hlsl 56 | // To did this precisely we'd need to perform integration, which would significantly 57 | // hurt performance. 58 | float3 blackBodyRadiation(float temperature) { 59 | float step = 200.0; 60 | float k = clamp(temperature, 1000.0, 6599.0) - 1000.0; 61 | int index = floor(k / step); 62 | return mix( 63 | blackBodyRadiationLookup[index], 64 | blackBodyRadiationLookup[index + 1], 65 | k / step - float(index) 66 | ); 67 | } 68 | 69 | typedef enum { 70 | STAR_MAP = 0, 71 | CHECKER_BOARD = 1 72 | } Background; 73 | 74 | typedef struct { 75 | float cameraX; 76 | float cameraY; 77 | float cameraZ; 78 | float cameraPitch; 79 | float cameraYaw; 80 | Background background; 81 | int stepCount; 82 | int maxRevolutions; 83 | float accretionDiskStart; 84 | float accretionDiskEnd; 85 | uint8_t renderAccretionDisk; 86 | uint8_t introEffect; // Ignored by shader 87 | } Configuration; 88 | 89 | kernel void computeFunction(uint2 gid [[thread_position_in_grid]], 90 | texture2d outTexture [[texture(0)]], 91 | texture2d skyTexture [[texture(1)]], 92 | constant float &time [[buffer(0)]], 93 | constant Configuration &config [[buffer(1)]]) { 94 | float textureWidth = (float)outTexture.get_width(); 95 | float textureHeight = (float)outTexture.get_height(); 96 | 97 | // A ray from the camera representing the direction that light must come from to 98 | // contribute to the current pixel (we trace this ray backwards). 99 | float3 cartesianRay = float3( 100 | (float(gid.x) - textureWidth/2) / textureWidth * 2, 101 | -(float(gid.y) - textureHeight/2) / textureWidth * 2, 102 | 1 103 | ); 104 | 105 | float pitch = config.cameraPitch; 106 | float3x3 rotX = float3x3( 107 | { 1.0, 0.0, 0.0 }, 108 | { 0.0, cos(pitch), sin(pitch) }, 109 | { 0.0, -sin(pitch), cos(pitch) } 110 | ); 111 | float yaw = config.cameraYaw; 112 | float3x3 rotY = float3x3( 113 | { cos(yaw), 0.0, sin(yaw) }, 114 | { 0.0, 1.0, 0.0 }, 115 | { -sin(yaw), 0.0, cos(yaw) } 116 | ); 117 | cartesianRay = rotY * rotX * cartesianRay; 118 | 119 | // We rotate our coordinate system based on the initial velocity and the position of the mass 120 | // so that the ray travels in the xz-plane. 121 | float3 position = float3(config.cameraX, config.cameraY, config.cameraZ); 122 | float3 xBasis = normalize(position); 123 | float3 unitRay = normalize(cartesianRay); 124 | // A vector perpendicular to xBasis and in the same plane as xBasis and unitRay 125 | float3 yBasis = normalize(cross(cross(xBasis, unitRay), xBasis)); 126 | 127 | float r = length(position); 128 | float u = 1.0 / r; 129 | float du = -dot(unitRay, xBasis) / dot(unitRay, yBasis) * u; 130 | 131 | float phi = 0.0; 132 | 133 | float3 previousPosition = position; 134 | float previousU = u; 135 | 136 | int steps = config.stepCount; 137 | float maxRevolutions = float(config.maxRevolutions); 138 | float4 color = float4(0, 0, 0, 0); 139 | for (int i = 0; i < steps; i++) { 140 | float step = maxRevolutions * 2.0 * M_PI_F / float(steps); 141 | previousU = u; 142 | u += du * step; 143 | float ddu = -u * (1.0 - 1.5 * u * u); 144 | du += ddu * step; 145 | if (u <= 0.0) { 146 | // Non-positive u means that the radius has exploded off to infinity. Just 147 | // set it to something really small (yet positive) and finish tracing. 148 | u = 0.000001; 149 | break; 150 | } 151 | phi += step; 152 | previousPosition = position; 153 | position = (cos(phi) * xBasis + sin(phi) * yBasis) / u; 154 | if (config.renderAccretionDisk && sign(previousPosition.y) != sign(position.y)) { 155 | // We assume that the photon is travelling in a straight line between the previous 156 | // and current positions so that we can easily perform an intersection. 157 | float lerpFactor = abs(previousPosition.y) / abs(previousPosition.y - position.y); 158 | float r = length(mix(previousPosition, position, lerpFactor)); 159 | if (r > config.accretionDiskStart && r < config.accretionDiskEnd) { 160 | float factor = (r - config.accretionDiskStart) / (config.accretionDiskEnd - config.accretionDiskStart); 161 | float3 emittedColor = blackBodyRadiation(mix(4000, 1000, factor)); 162 | outTexture.write(float4(emittedColor, 1), gid); 163 | return; 164 | } 165 | } 166 | 167 | if (u > 1.0) { 168 | break; 169 | } 170 | } 171 | 172 | float schwarzschildRadius = 1.0; 173 | if (1.0 / u < schwarzschildRadius ) { 174 | color = float4(0, 0, 0, 1) * (1 - color.a) + color * color.a; 175 | } else { 176 | float3 ray; 177 | // If the stepCount is 0 then gravity has been disabled. 178 | if (config.stepCount == 0) { 179 | ray = unitRay; 180 | if (config.renderAccretionDisk && sign(position.y) != sign(ray.y)) { 181 | // We assume that the photon is travelling in a straight line between the previous 182 | // and current positions so that we can easily perform an intersection. 183 | float k = abs(position.y / ray.y); 184 | float r = length(position + ray * k); 185 | if (r > config.accretionDiskStart && r < config.accretionDiskEnd) { 186 | float factor = (r - config.accretionDiskStart) / (config.accretionDiskEnd - config.accretionDiskStart); 187 | float3 emittedColor = blackBodyRadiation(mix(4000, 1000, factor)); 188 | outTexture.write(float4(emittedColor, 1), gid); 189 | return; 190 | } 191 | } 192 | } else { 193 | ray = position - previousPosition; 194 | } 195 | 196 | float2 uv = float2( 197 | atan2(ray.z, ray.x) / (2 * M_PI_F) + 0.5 + time * 0.01, 198 | atan2(ray.y, length(ray.xz)) / M_PI_F + 0.5 199 | ); 200 | 201 | float4 sampledColor; 202 | if (config.background == STAR_MAP) { 203 | sampledColor = skyTexture.sample(textureSampler, uv); 204 | } else if (config.background == CHECKER_BOARD) { 205 | sampledColor = sampleCheckerBoard(uv, 40); 206 | } else { 207 | // Make it obvious that something has gone wrong 208 | sampledColor = float4(1, 0, 1, 1); 209 | } 210 | 211 | color = sampledColor * (1 - color.a) + color * color.a; 212 | } 213 | outTexture.write(color, gid); 214 | } 215 | """ 216 | -------------------------------------------------------------------------------- /RelativisticRenderer.swiftpm/RelativisticRenderer.swift: -------------------------------------------------------------------------------- 1 | import MetalKit 2 | 3 | enum Background: Int32 { 4 | case starMap = 0 5 | case checkerBoard = 1 6 | } 7 | 8 | struct RelativisticRenderer: Renderer { 9 | struct Configuration: Default { 10 | var cameraX: Float 11 | var cameraY: Float 12 | var cameraZ: Float 13 | var cameraPitch: Float 14 | var cameraYaw: Float 15 | var background: Int32 16 | var stepCount: Int32 17 | var maxRevolutions: Int32 18 | var accretionDiskStart: Float 19 | var accretionDiskEnd: Float 20 | var renderAccretionDisk: Bool 21 | var introEffect: Bool 22 | 23 | var cameraPosition: SIMD3 { 24 | get { 25 | SIMD3(cameraX, cameraY, cameraZ) 26 | } 27 | set { 28 | cameraX = newValue.x 29 | cameraY = newValue.y 30 | cameraZ = newValue.z 31 | } 32 | } 33 | 34 | static let `default` = Self( 35 | cameraX: 0, 36 | cameraY: 0, 37 | cameraZ: -5, 38 | cameraPitch: 0, 39 | cameraYaw: 0, 40 | background: Background.starMap.rawValue, 41 | stepCount: 30, 42 | maxRevolutions: 1, 43 | accretionDiskStart: 1.5, 44 | accretionDiskEnd: 3.0, 45 | renderAccretionDisk: true, 46 | introEffect: false 47 | ) 48 | 49 | func with(_ keyPath: WritableKeyPath, _ value: T) -> Self { 50 | var config = self 51 | config[keyPath: keyPath] = value 52 | return config 53 | } 54 | } 55 | 56 | struct Resources { 57 | var computeLibrary: MetalLibrary 58 | var introEffectLibrary: MetalLibrary 59 | var blitLibrary: MetalLibrary 60 | } 61 | 62 | let computePipelineState: any MTLComputePipelineState 63 | let introEffectPipelineState: any MTLComputePipelineState 64 | let blitPipelineState: any MTLRenderPipelineState 65 | var computeOutputTexture: (any MTLTexture)? 66 | let skyTexture: any MTLTexture 67 | let timeBuffer: MetalScalarBuffer 68 | let initialTime: CFAbsoluteTime 69 | let configBuffer: MetalScalarBuffer 70 | 71 | static func loadResources() async throws -> Resources { 72 | let device = try MetalDevice.systemDefault() 73 | let computeLibrary = try device.compileMetalLibrary(source: rayTracingShaderSource) 74 | let introEffectLibrary = try device.compileMetalLibrary(source: introEffectShaderSource) 75 | let blitLibrary = try device.compileMetalLibrary(source: blitShaderSource) 76 | return Resources(computeLibrary: computeLibrary, introEffectLibrary: introEffectLibrary, blitLibrary: blitLibrary) 77 | } 78 | 79 | init(device: MetalDevice, commandQueue: any MTLCommandQueue, resources: Resources) throws { 80 | let computeFunction = try resources.computeLibrary.getFunction(name: "computeFunction") 81 | computePipelineState = try device.makeComputePipelineState(function: computeFunction) 82 | 83 | let introEffectFunction = try resources.introEffectLibrary.getFunction(name: "computeFunction") 84 | introEffectPipelineState = try device.makeComputePipelineState(function: introEffectFunction) 85 | 86 | let blitVertexFunction = try resources.blitLibrary.getFunction(name: "blitVertexFunction") 87 | let blitFragmentFunction = try resources.blitLibrary.getFunction(name: "blitFragmentFunction") 88 | 89 | blitPipelineState = try device.makeRenderPipelineState( 90 | vertexFunction: blitVertexFunction, 91 | fragmentFunction: blitFragmentFunction 92 | ) 93 | 94 | do { 95 | skyTexture = try MTKTextureLoader(device: device.wrappedDevice).newTexture(name: "starmap_2020_4k", scaleFactor: 1, bundle: Bundle.main) 96 | } catch { 97 | throw SimpleError("Failed to load sky texture: \(error)") 98 | } 99 | 100 | timeBuffer = try device.makeScalarBuffer() 101 | initialTime = CFAbsoluteTimeGetCurrent() 102 | 103 | configBuffer = try device.makeScalarBuffer() 104 | } 105 | 106 | mutating func render( 107 | view: MTKView, 108 | configuration: Configuration, 109 | device: MetalDevice, 110 | renderPassDescriptor: MTLRenderPassDescriptor, 111 | drawable: MTLDrawable, 112 | commandBuffer: MTLCommandBuffer 113 | ) throws { 114 | let computeOutputTexture = try updateOutputTexture(device.wrappedDevice, view) 115 | 116 | guard let computeEncoder = commandBuffer.makeComputeCommandEncoder() else { 117 | throw SimpleError("Failed to make compute command encoder") 118 | } 119 | 120 | let time = Float(CFAbsoluteTimeGetCurrent() - initialTime) 121 | timeBuffer.copyMemory(from: time) 122 | configBuffer.copyMemory(from: configuration) 123 | 124 | if configuration.introEffect { 125 | computeEncoder.setComputePipelineState(introEffectPipelineState) 126 | } else { 127 | computeEncoder.setComputePipelineState(computePipelineState) 128 | } 129 | computeEncoder.setTexture(computeOutputTexture, index: 0) 130 | computeEncoder.setTexture(skyTexture, index: 1) 131 | computeEncoder.setBuffer(timeBuffer.wrappedBuffer, offset: 0, index: 0) 132 | computeEncoder.setBuffer(configBuffer.wrappedBuffer, offset: 0, index: 1) 133 | computeEncoder.dispatchThreadgroups( 134 | MTLSize(width: Int(view.drawableSize.width + 15) / 16, height: Int(view.drawableSize.height + 15) / 16, depth: 1), 135 | threadsPerThreadgroup: MTLSize(width: 16, height: 16, depth: 1) 136 | ) 137 | computeEncoder.endEncoding() 138 | 139 | guard let blitRenderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor) else { 140 | throw SimpleError("Failed to make render command encoder for blitting output to drawable") 141 | } 142 | 143 | blitRenderEncoder.setRenderPipelineState(blitPipelineState) 144 | blitRenderEncoder.setFragmentTexture(computeOutputTexture, index: 0) 145 | blitRenderEncoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6) 146 | blitRenderEncoder.endEncoding() 147 | 148 | commandBuffer.present(drawable) 149 | commandBuffer.commit() 150 | } 151 | 152 | mutating func updateOutputTexture(_ device: MTLDevice, _ view: MTKView) throws -> MTLTexture { 153 | guard 154 | let computeOutputTexture = self.computeOutputTexture, 155 | computeOutputTexture.width == Int(view.drawableSize.width), 156 | computeOutputTexture.height == Int(view.drawableSize.height) 157 | else { 158 | let textureDescriptor = MTLTextureDescriptor() 159 | textureDescriptor.width = Int(view.drawableSize.width) 160 | textureDescriptor.height = Int(view.drawableSize.height) 161 | textureDescriptor.pixelFormat = .bgra8Unorm 162 | textureDescriptor.usage = [.shaderRead, .shaderWrite] 163 | guard let computeOutputTexture = device.makeTexture(descriptor: textureDescriptor) else { 164 | throw SimpleError("Failed to make compute shader output texture") 165 | } 166 | self.computeOutputTexture = computeOutputTexture 167 | 168 | return computeOutputTexture 169 | } 170 | 171 | return computeOutputTexture 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /RelativisticRenderer.swiftpm/RenderCoordinator.swift: -------------------------------------------------------------------------------- 1 | import MetalKit 2 | 3 | final class RenderCoordinator: NSObject, MetalViewCoordinator { 4 | typealias Configuration = ConcreteRenderer.Configuration 5 | 6 | let device: MetalDevice 7 | let commandQueue: any MTLCommandQueue 8 | var renderer: ConcreteRenderer 9 | var configuration: ConcreteRenderer.Configuration 10 | 11 | init( 12 | device: MetalDevice, 13 | commandQueue: any MTLCommandQueue, 14 | renderer: ConcreteRenderer, 15 | configuration: Configuration 16 | ) { 17 | self.device = device 18 | self.commandQueue = commandQueue 19 | self.renderer = renderer 20 | self.configuration = configuration 21 | } 22 | 23 | static func create(with resources: ConcreteRenderer.Resources) throws -> RenderCoordinator { 24 | let device = try MetalDevice.systemDefault() 25 | 26 | guard let commandQueue = device.wrappedDevice.makeCommandQueue() else { 27 | throw SimpleError("Failed to make Metal command queue") 28 | } 29 | 30 | let renderer: ConcreteRenderer 31 | do { 32 | renderer = try ConcreteRenderer( 33 | device: device, 34 | commandQueue: commandQueue, 35 | resources: resources 36 | ) 37 | } catch { 38 | throw SimpleError("Failed to create renderer: \(error)") 39 | } 40 | 41 | let configuration = Configuration.default 42 | 43 | return RenderCoordinator( 44 | device: device, 45 | commandQueue: commandQueue, 46 | renderer: renderer, 47 | configuration: configuration 48 | ) 49 | } 50 | 51 | func setup(_ view: MTKView) { 52 | view.colorPixelFormat = .bgra8Unorm 53 | view.clearColor = MTLClearColorMake(0, 1, 0, 1) 54 | view.device = device.wrappedDevice 55 | view.drawableSize = view.frame.size 56 | view.framebufferOnly = false 57 | } 58 | 59 | func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {} 60 | 61 | func draw(in view: MTKView) { 62 | do { 63 | guard let renderPassDescriptor = view.currentRenderPassDescriptor else { 64 | throw SimpleError("Failed to get current render pass descriptor") 65 | } 66 | 67 | guard let drawable = view.currentDrawable else { 68 | throw SimpleError("Failed to get current drawable") 69 | } 70 | 71 | guard let commandBuffer = commandQueue.makeCommandBuffer() else { 72 | throw SimpleError("Failed to make command buffer") 73 | } 74 | 75 | try renderer.render( 76 | view: view, 77 | configuration: configuration, 78 | device: device, 79 | renderPassDescriptor: renderPassDescriptor, 80 | drawable: drawable, 81 | commandBuffer: commandBuffer 82 | ) 83 | } catch { 84 | print("Failed to render frame: \(error)") 85 | } 86 | } 87 | } 88 | 89 | protocol Default { 90 | static var `default`: Self { get } 91 | } 92 | 93 | protocol Renderer { 94 | associatedtype Configuration: Default 95 | associatedtype Resources 96 | init(device: MetalDevice, commandQueue: MTLCommandQueue, resources: Resources) throws 97 | mutating func render( 98 | view: MTKView, 99 | configuration: Configuration, 100 | device: MetalDevice, 101 | renderPassDescriptor: MTLRenderPassDescriptor, 102 | drawable: any MTLDrawable, 103 | commandBuffer: any MTLCommandBuffer 104 | ) throws 105 | } 106 | -------------------------------------------------------------------------------- /RelativisticRenderer.swiftpm/RenderView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A 3d view of a blackhole with a configurable shader and environment. Allows the user to 4 | /// move around via drag gestures. 5 | struct RenderView: View { 6 | @State var error: String? 7 | @State var rendererConfig = RelativisticRenderer.Configuration.default 8 | @State var offset = CGPoint(x: 0, y: CGFloat.pi / 64 * 400) 9 | @State var scale: CGFloat = 1 10 | @State var distance: CGFloat = 8 11 | @State var minPhi: Float = -.pi / 2 12 | @State var steps: Int = 30 13 | @State var renderWithGravity = true 14 | 15 | var tab: Binding? 16 | 17 | var resources: RelativisticRenderer.Resources 18 | 19 | func updateCamera() { 20 | let radius = Float(distance) 21 | let phi = Float(-offset.y / 400) 22 | // `minPhi` is used to act as a 'tare' to limit the pitch to the range -90 degrees to 90 degrees. 23 | // This could be avoided by creating a new gesture recognizer that knows about this range limit, 24 | // but this is much simpler and allows us to reuse the same ugly UIKit code. 25 | minPhi = min(minPhi, phi) 26 | minPhi = max(minPhi, phi - .pi) 27 | let cameraPitch = phi - minPhi - .pi / 2 28 | let cameraYaw = Float(offset.x / 400) 29 | rendererConfig.cameraPosition.x = -radius * cos(cameraPitch) * sin(cameraYaw) 30 | rendererConfig.cameraPosition.y = -radius * sin(cameraPitch) 31 | rendererConfig.cameraPosition.z = -radius * cos(cameraPitch) * cos(cameraYaw) 32 | rendererConfig.cameraYaw = -cameraYaw 33 | rendererConfig.cameraPitch = -cameraPitch 34 | } 35 | 36 | var body: some View { 37 | if let error { 38 | Text(error) 39 | .font(.system(size: 12).monospaced()) 40 | } else { 41 | NavigationSplitView { 42 | configPanel 43 | } detail: { 44 | MetalView(error: $error, configuration: rendererConfig) { 45 | try RenderCoordinator.create(with: resources) 46 | } 47 | .overlay(GestureCatcher(offset: $offset, scale: $scale)) 48 | .onChange(of: offset) { _ in 49 | updateCamera() 50 | } 51 | .onChange(of: distance) { _ in 52 | updateCamera() 53 | } 54 | .onAppear { 55 | updateCamera() 56 | } 57 | .toolbar { 58 | ToolbarItem(placement: .topBarTrailing) { 59 | Picker("Tab", selection: tab ?? Binding { RootView.Tab._3d } set: { _ in }) { 60 | Text("2d").tag(RootView.Tab._2d) 61 | Text("3d").tag(RootView.Tab._3d) 62 | } 63 | .pickerStyle(SegmentedPickerStyle()) 64 | .disabled(tab == nil) 65 | } 66 | } 67 | } 68 | } 69 | } 70 | 71 | var configPanel: some View { 72 | List { 73 | Section("Environment") { 74 | Picker(selection: $rendererConfig.background) { 75 | Text("Star map") 76 | .tag(Background.starMap.rawValue) 77 | Text("Checker board") 78 | .tag(Background.checkerBoard.rawValue) 79 | } label: { 80 | Text("Background") 81 | } 82 | } 83 | 84 | Section("Accretion disk") { 85 | Toggle(isOn: $rendererConfig.renderAccretionDisk) { 86 | Text("Render accretion disk") 87 | } 88 | 89 | ConfigSlider("Accretion disk start", value: $rendererConfig.accretionDiskStart, in: 1...10) 90 | .onChange(of: rendererConfig.accretionDiskStart) { _ in 91 | if rendererConfig.accretionDiskStart > rendererConfig.accretionDiskEnd { 92 | rendererConfig.accretionDiskStart = rendererConfig.accretionDiskEnd 93 | } 94 | } 95 | .disabled(!rendererConfig.renderAccretionDisk) 96 | 97 | ConfigSlider("Accretion disk end", value: $rendererConfig.accretionDiskEnd, in: 1...10) 98 | .onChange(of: rendererConfig.accretionDiskEnd) { _ in 99 | if rendererConfig.accretionDiskEnd < rendererConfig.accretionDiskStart { 100 | rendererConfig.accretionDiskEnd = rendererConfig.accretionDiskStart 101 | } 102 | } 103 | .disabled(!rendererConfig.renderAccretionDisk) 104 | } 105 | 106 | Section("Observer") { 107 | ConfigSlider("Distance from blackhole", value: $distance, in: 1...100) 108 | } 109 | 110 | Section("Raytracing") { 111 | Toggle("Gravity", isOn: $renderWithGravity) 112 | .onChange(of: renderWithGravity) { _ in 113 | if renderWithGravity { 114 | rendererConfig.stepCount = Int32(steps) 115 | } else { 116 | rendererConfig.stepCount = 0 117 | } 118 | } 119 | Text("Steps: \(steps)") 120 | Slider( 121 | value: Binding { 122 | log10(CGFloat(rendererConfig.stepCount)) 123 | } set: { newValue in 124 | rendererConfig.stepCount = Int32(pow(10, newValue)) 125 | steps = Int(rendererConfig.stepCount) 126 | }, 127 | in: 0...3 128 | ) 129 | .disabled(!renderWithGravity) 130 | ConfigSlider("Max revolutions", value: $rendererConfig.maxRevolutions.into(), in: 1...10) 131 | .disabled(!renderWithGravity) 132 | } 133 | } 134 | } 135 | } 136 | 137 | struct ConfigSlider: View where Value.Stride: BinaryFloatingPoint { 138 | var label: String 139 | @Binding var value: Value 140 | var range: ClosedRange 141 | 142 | var formattedValue: String { 143 | let formatter = NumberFormatter() 144 | formatter.usesSignificantDigits = true 145 | formatter.minimumSignificantDigits = 1 146 | formatter.maximumSignificantDigits = 2 147 | return formatter.string(from: NSNumber(value: Double(value))) ?? "\(value)" 148 | } 149 | 150 | init(_ label: String, value: Binding, in range: ClosedRange) { 151 | self.label = label 152 | self._value = value 153 | self.range = range 154 | } 155 | 156 | var body: some View { 157 | Text("\(label): \(formattedValue)") 158 | Slider(value: $value, in: range) 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /RelativisticRenderer.swiftpm/RootView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct RootView: View { 4 | @State var tab = Tab._2d 5 | 6 | enum Tab: Hashable { 7 | case _2d 8 | case _3d 9 | } 10 | 11 | var body: some View { 12 | // Shaders are loaded upfront since they are compiled at runtime instead of compile time in 13 | // this playground 14 | Await(RelativisticRenderer.loadResources) { 15 | Text("Loading resources") 16 | } success: { resources in 17 | OnboardAndThen(resources: resources) { 18 | TabView(selection: $tab) { 19 | DiagramView(tab: $tab) 20 | .tabItem { 21 | Text("2d") 22 | } 23 | .tag(Tab._2d) 24 | 25 | RenderView(tab: $tab, resources: resources) 26 | .tabItem { 27 | Text("3d") 28 | } 29 | .tag(Tab._3d) 30 | } 31 | .tabViewStyle(.page(indexDisplayMode: .never)) 32 | } 33 | } failure: { error in 34 | Text("Failed to load resources: \(error.localizedDescription)") 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /RelativisticRenderer.swiftpm/SimpleError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct SimpleError: LocalizedError, CustomStringConvertible { 4 | var message: String 5 | 6 | var errorDescription: String? { 7 | message 8 | } 9 | 10 | var description: String { 11 | message 12 | } 13 | 14 | init(_ message: String) { 15 | self.message = message 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /blackbody_lookup_generator.py: -------------------------------------------------------------------------------- 1 | s = """ 2 | 1000 K #ff3800 3 | 1200 K #ff5300 4 | 1400 K #ff6500 5 | 1600 K #ff7300 6 | 1800 K #ff7e00 7 | 2000 K #ff8912 8 | 2200 K #ff932c 9 | 2400 K #ff9d3f 10 | 2600 K #ffa54f 11 | 2800 K #ffad5e 12 | 3000 K #ffb46b 13 | 3200 K #ffbb78 14 | 3400 K #ffc184 15 | 3600 K #ffc78f 16 | 3800 K #ffcc99 17 | 4000 K #ffd1a3 18 | 4200 K #ffd5ad 19 | 4400 K #ffd9b6 20 | 4600 K #ffddbe 21 | 4800 K #ffe1c6 22 | 5000 K #ffe4ce 23 | 5200 K #ffe8d5 24 | 5400 K #ffebdc 25 | 5600 K #ffeee3 26 | 5800 K #fff0e9 27 | 6000 K #fff3ef 28 | 6200 K #fff5f5 29 | 6400 K #fff8fb 30 | 6600 K #fef9ff 31 | """.strip() 32 | 33 | out = [] 34 | for line in s.splitlines(): 35 | line = line.strip() 36 | hex = line.split(" ")[3][1:] 37 | r = hex[:2] 38 | g = hex[2:4] 39 | b = hex[4:] 40 | r = int(r, base=16) 41 | g = int(g, base=16) 42 | b = int(b, base=16) 43 | out.append(f"float3({r}.0 / 255.0, {g}.0 / 255.0, {b}.0 / 255.0)") 44 | print(",\n".join(out)) 45 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stackotter/relativistic-renderer/571a2f68f0b64b7180e8c720d4e21c4c2da110b0/screenshot.png --------------------------------------------------------------------------------