From 64db513f62f44bf69f56205486ff1edbeaa50ce7 Mon Sep 17 00:00:00 2001 From: a dinosaur Date: Fri, 16 Aug 2024 22:18:44 +1000 Subject: [PATCH] sRGB aware framebuffer & colour handling --- Sources/Voxelotl/CMakeLists.txt | 1 + Sources/Voxelotl/Color.swift | 183 +++++++++++++++++++++++++ Sources/Voxelotl/FloatExtensions.swift | 2 +- Sources/Voxelotl/Game.swift | 29 ++-- Sources/Voxelotl/Renderer.swift | 18 ++- 5 files changed, 214 insertions(+), 19 deletions(-) create mode 100644 Sources/Voxelotl/Color.swift diff --git a/Sources/Voxelotl/CMakeLists.txt b/Sources/Voxelotl/CMakeLists.txt index b963708..5e2bfcc 100644 --- a/Sources/Voxelotl/CMakeLists.txt +++ b/Sources/Voxelotl/CMakeLists.txt @@ -10,6 +10,7 @@ add_executable(Voxelotl MACOSX_BUNDLE Matrix4x4.swift Rectangle.swift AABB.swift + Color.swift NSImageLoader.swift Renderer.swift diff --git a/Sources/Voxelotl/Color.swift b/Sources/Voxelotl/Color.swift new file mode 100644 index 0000000..99a16be --- /dev/null +++ b/Sources/Voxelotl/Color.swift @@ -0,0 +1,183 @@ +import Foundation + +public struct Color: Equatable { + private var _values: SIMD4 + + internal var values: SIMD4 { self._values } + + public init(r newR: T, g newG: T, b newB: T, a newA: T) { + self._values = .init(newR, newG, newB, newA) + } + + @inline(__always) public var r: T { get { self._values.x } set(newR) { self._values.x = newR } } + @inline(__always) public var g: T { get { self._values.y } set(newG) { self._values.y = newG } } + @inline(__always) public var b: T { get { self._values.z } set(newB) { self._values.z = newB } } + @inline(__always) public var a: T { get { self._values.w } set(newA) { self._values.w = newA } } +} + +// Sadly doesn't seem to be a better way to do this generically at the moment +public extension Color where T: AdditiveArithmetic { + @inline(__always) static var zero: T { T.zero } +} +public extension Color where T: FixedWidthInteger { + static var black: Self { .init(r: zero, g: zero, b: zero, a: one) } + static var white: Self { .init(r: one, g: one, b: one, a: one) } + static var red: Self { .init(r: one, g: zero, b: zero, a: one) } + static var green: Self { .init(r: zero, g: one, b: zero, a: one) } + static var blue: Self { .init(r: zero, g: zero, b: one, a: one) } + static var yellow: Self { .init(r: one, g: one, b: zero, a: one) } + static var magenta: Self { .init(r: one, g: zero, b: one, a: one) } + static var cyan: Self { .init(r: zero, g: one, b: one, a: one) } +} +public extension Color where T: BinaryFloatingPoint { + static var black: Self { .init(r: zero, g: zero, b: zero, a: one) } + static var white: Self { .init(r: one, g: one, b: one, a: one) } + static var red: Self { .init(r: one, g: zero, b: zero, a: one) } + static var green: Self { .init(r: zero, g: one, b: zero, a: one) } + static var blue: Self { .init(r: zero, g: zero, b: one, a: one) } + static var yellow: Self { .init(r: one, g: one, b: zero, a: one) } + static var magenta: Self { .init(r: one, g: zero, b: one, a: one) } + static var cyan: Self { .init(r: zero, g: one, b: one, a: one) } +} + +public extension Color where T: FixedWidthInteger { + @inline(__always) static var one: T { T.max } + + init(r newR: T, g newG: T, b newB: T) { + self.init(r: newR, g: newG, b: newB, a: Self.one) + } + + init(_ other: Color) { + self.init( + r: T((other.r * U(0xFF)).clamp(0, 0xFF)), + g: T((other.g * U(0xFF)).clamp(0, 0xFF)), + b: T((other.b * U(0xFF)).clamp(0, 0xFF)), + a: T((other.a * U(0xFF)).clamp(0, 0xFF))) + } + + func mix(_ other: Self, _ n: Float) -> Self { + Self(Color(self).mix(Color(other), n.saturated)) + } + + var linear: Self { Self(Color(self).linear) } + var sRGB: Self { Self(Color(self).sRGB) } +} + +public extension Color where T == UInt8 { + init(rgba8888 c: UInt32) { + self.init( + r: UInt8((c & 0xFF000000) >> 24), + g: UInt8((c & 0x00FF0000) >> 16), + b: UInt8((c & 0x0000FF00) >> 8), + a: UInt8((c & 0x000000FF) >> 0)) + } + + init(rgb888 c: UInt32) { + self.init( + r: UInt8((c & 0xFF0000) >> 16), + g: UInt8((c & 0x00FF00) >> 8), + b: UInt8((c & 0x0000FF) >> 0)) + } + + var sRGB: Self { + return Self( + r: sRGB8FromLinear8Table[Int(r)], + g: sRGB8FromLinear8Table[Int(g)], + b: sRGB8FromLinear8Table[Int(b)], + a: a) + } +} + +fileprivate var sRGB8FromLinear8Table: [UInt8] = { + (0..<0x100).map { i in + var x = Double(i) / 0xFF + if x < 0.0031308 { + x *= 12.92 + } else { + x = 1.055 * pow(abs(x), 1 / 2.4) - 0.055 + } + x = floor((x * 0xFF) + 0.5) + return UInt8(truncating: NSNumber(value: x)) + } +}() + +public extension Color where T: BinaryFloatingPoint { + @inline(__always) static var one: T { T(1) } + + init(r newR: T, g newG: T, b newB: T) { + self.init(r: newR, g: newG, b: newB, a: Self.one) + } + + init(_ other: Color) { + let mul = 1 / T(0xFF) + self.init( + r: T(other.r) * mul, + g: T(other.g) * mul, + b: T(other.b) * mul, + a: T(other.a) * mul) + } + + init(rgba8888 c: UInt32) { + self.init(Color(rgba8888: c)) + } + + init(rgb888 c: UInt32) { + self.init(Color(rgb888: c)) + } + + func mix(_ other: Self, _ n: T) -> Self{ + let x = n.saturated + return .init( + r: x.lerp(r, other.r), + g: x.lerp(g, other.g), + b: x.lerp(b, other.b), + a: x.lerp(a, other.a)) + } + + var linear: Self { + Self( + r: linearFromSRGB(r), + g: linearFromSRGB(g), + b: linearFromSRGB(b), + a: a) + } + + var sRGB: Self { + Self( + r: sRGBFromLinear(r), + g: sRGBFromLinear(g), + b: sRGBFromLinear(b), + a: a) + } + + @inline(__always) fileprivate func linearFromSRGB(_ x: T) -> T { + if x < 0.04045 { + x / 12.92 + } else { + T(pow((Double(x) + 0.055) / 1.055, 2.4)) + } + } + + @inline(__always) fileprivate func sRGBFromLinear(_ x: T) -> T { + if x.isNaN || x <= 0 { + 0 + } else if x >= 1 { + 1 + } else if x < 0.0031308 { + x * 12.92 + } else { +#if false + // Approximation + 1.13005 * sqrt(abs(x - 0.00228)) - 0.13448 * x + 0.005719 +#else + T(1.055 * pow(Double(abs(x)), 1 / 2.4) - 0.055) +#endif + } + } +} + +public extension SIMD4 { + init(_ other: Color) { + self = other.values + } +} diff --git a/Sources/Voxelotl/FloatExtensions.swift b/Sources/Voxelotl/FloatExtensions.swift index 628fe77..25d9e82 100644 --- a/Sources/Voxelotl/FloatExtensions.swift +++ b/Sources/Voxelotl/FloatExtensions.swift @@ -6,5 +6,5 @@ public extension FloatingPoint { @inline(__always) func mlerp(_ a: Self, _ b: Self) -> Self { a + (b - a) * self } @inline(__always) func clamp(_ a: Self, _ b: Self) -> Self { min(max(self, a), b) } - @inline(__always) func saturate() -> Self { self.clamp(0, 1) } + @inline(__always) var saturated: Self { self.clamp(0, 1) } } diff --git a/Sources/Voxelotl/Game.swift b/Sources/Voxelotl/Game.swift index 3c8296c..a0976f9 100644 --- a/Sources/Voxelotl/Game.swift +++ b/Sources/Voxelotl/Game.swift @@ -2,20 +2,20 @@ import simd struct Box { var geometry: AABB - var color: SIMD4 = .one + var color: Color = .white } struct Instance { let position: SIMD3 let scale: SIMD3 let rotation: simd_quatf - let color: SIMD4 + let color: Color init( position: SIMD3 = .zero, scale: SIMD3 = .one, rotation: simd_quatf = .identity, - color: SIMD4 = .one + color: Color = .white ) { self.position = position self.scale = scale @@ -25,13 +25,20 @@ struct Instance { } let boxes: [Box] = [ - Box(geometry: .fromUnitCube(position: .init(0, -1, 0) * 2, scale: .init(10, 0.1, 10) * 2)), - Box(geometry: .fromUnitCube(position: .init(-2.5, 0, -3) * 2, scale: .init(repeating: 2)), color: .init(1, 0.5, 0.75, 1)), - Box(geometry: .fromUnitCube(position: .init(-2.5, -0.5, -5) * 2, scale: .init(repeating: 2)), color: .init(0.75, 1, 1, 1)) + Box(geometry: .fromUnitCube( + position: .init( 0, -1, 0) * 2, + scale: .init(10, 0.1, 10) * 2)), + Box(geometry: .fromUnitCube( + position: .init(-2.5, 0, -3) * 2, + scale: .init(repeating: 2)), + color: .init(rgb888: 0xFF80BF).linear), + Box(geometry: .fromUnitCube( + position: .init(-2.5, -0.5, -5) * 2, + scale: .init(repeating: 2)), + color: .init(rgb888: 0xBFFFFF).linear) ] class Game: GameDelegate { - private var fpsCalculator = FPSCalculator() var camera = Camera(fov: 60, size: .one, range: 0.06...50) var player = Player() @@ -61,15 +68,15 @@ class Game: GameDelegate { var instances: [Instance] = boxes.map { Instance( position: $0.geometry.center, - scale: $0.geometry.size * 0.5, - color: $0.color) + scale: $0.geometry.size * 0.5, + color: $0.color) } instances.append( Instance( position: .init(0, sin(totalTime * 1.5) * 0.5, -2) * 2, - scale: .init(repeating: 0.5), + scale: .init(repeating: 0.5), rotation: .init(angle: totalTime * 3.0, axis: .init(0, 1, 0)), - color: .init(0.5, 0.5, 1, 1))) + color: .init(r: 0.5, g: 0.5, b: 1).linear)) renderer.batch(instances: instances, camera: self.camera) } diff --git a/Sources/Voxelotl/Renderer.swift b/Sources/Voxelotl/Renderer.swift index feed28c..798542f 100644 --- a/Sources/Voxelotl/Renderer.swift +++ b/Sources/Voxelotl/Renderer.swift @@ -41,7 +41,9 @@ fileprivate let cubeIndices: [UInt16] = [ ] fileprivate let numFramesInFlight: Int = 3 +fileprivate let colorFormat: MTLPixelFormat = .bgra8Unorm_srgb fileprivate let depthFormat: MTLPixelFormat = .depth16Unorm +fileprivate let clearColor: Color = .black.mix(.white, 0.1).linear public class Renderer { private var device: MTLDevice @@ -86,7 +88,7 @@ public class Renderer { self.device = device layer.device = device - layer.pixelFormat = MTLPixelFormat.bgra8Unorm + layer.pixelFormat = colorFormat // Setup command queue guard let queue = device.makeCommandQueue() else { @@ -99,7 +101,7 @@ public class Renderer { passDescription.colorAttachments[0].loadAction = .clear passDescription.colorAttachments[0].storeAction = .store - passDescription.colorAttachments[0].clearColor = MTLClearColorMake(0.1, 0.1, 0.1, 1.0) + passDescription.colorAttachments[0].clearColor = MTLClearColor(clearColor) passDescription.depthAttachment.loadAction = .clear passDescription.depthAttachment.storeAction = .dontCare passDescription.depthAttachment.clearDepth = 1.0 @@ -349,11 +351,7 @@ public class Renderer { .translate(instance.position) * matrix_float4x4(instance.rotation) * .scale(instance.scale), - color: .init( - UInt8(truncating: NSNumber(value: instance.color.x * 0xFF)), - UInt8(truncating: NSNumber(value: instance.color.y * 0xFF)), - UInt8(truncating: NSNumber(value: instance.color.z * 0xFF)), - UInt8(truncating: NSNumber(value: instance.color.w * 0xFF)))) + color: SIMD4(Color(instance.color))) } // Ideal as long as our uniforms total 4 KB or less @@ -374,6 +372,12 @@ public class Renderer { } } +extension MTLClearColor { + init(_ color: Color) { + self.init(red: color.r, green: color.g, blue: color.b, alpha: color.a) + } +} + enum RendererError: Error { case initFailure(_ message: String) case loadFailure(_ message: String)