diff --git a/Sources/Voxelotl/CMakeLists.txt b/Sources/Voxelotl/CMakeLists.txt index d0f5631..0bcf9c0 100644 --- a/Sources/Voxelotl/CMakeLists.txt +++ b/Sources/Voxelotl/CMakeLists.txt @@ -1,12 +1,16 @@ add_executable(Voxelotl MACOSX_BUNDLE Assets.xcassets + + test.png + + shadertypes.h + shader.metal + + NSImageLoader.swift Renderer.swift FPSCalculator.swift Application.swift - main.swift - shader.metal - shadertypes.h - module.modulemap) + main.swift) set_source_files_properties( shader.metal PROPERTIES @@ -35,8 +39,10 @@ set_target_properties(Voxelotl PROPERTIES XCODE_SCHEME_ENABLE_GPU_FRAME_CAPTURE_MODE "Metal" XCODE_ATTRIBUTE_CODE_SIGN_ENTITLEMENTS "${CMAKE_CURRENT_SOURCE_DIR}/Voxelotl.entitlements" MACOSX_BUNDLE_COPYRIGHT "© 2024 Gay Pizza Specifications") -set_source_files_properties(Assets.xcassets PROPERTIES MACOSX_PACKAGE_LOCATION "Resources") -set_source_files_properties(module.modulemap PROPERTIES MACOSX_PACKAGE_LOCATION Modules) -source_group("Resources" FILES Assets.xcassets) +set_source_files_properties(Assets.xcassets PROPERTIES MACOSX_PACKAGE_LOCATION Resources) +set_source_files_properties(module.modulemap PROPERTIES MACOSX_PACKAGE_LOCATION Modules) +set_source_files_properties(test.png PROPERTIES MACOSX_PACKAGE_LOCATION Resources) + +source_group("Resources" FILES Assets.xcassets test.png) source_group("Source Files" REGULAR_EXPRESSION "\\.(swift|metal)$") diff --git a/Sources/Voxelotl/NSImageLoader.swift b/Sources/Voxelotl/NSImageLoader.swift new file mode 100644 index 0000000..e074f74 --- /dev/null +++ b/Sources/Voxelotl/NSImageLoader.swift @@ -0,0 +1,104 @@ +import AppKit + +struct NSImageLoader { + private static let flipVertically = true + + static func open(url: URL) throws -> Image2D { + try autoreleasepool { + // Open as a CoreGraphics image + guard let nsImage = NSImage(contentsOf: url), + let cgImage = nsImage.cgImage(forProposedRect: nil, context: nil, hints: nil) + else { + throw ImageLoaderError.openFailed("Failed to open image \"\(url.absoluteString)\"") + } + + // Convert 8-bit ARGB (sRGB) w/ pre-multiplied alpha + let alphaInfo = cgImage.alphaInfo == .none + ? CGImageAlphaInfo.noneSkipLast + : CGImageAlphaInfo.premultipliedLast + guard let colorSpace = CGColorSpace(name: CGColorSpace.sRGB), + let context = CGContext( + data: nil, + width: cgImage.width, + height: cgImage.height, + bitsPerComponent: 8, + bytesPerRow: cgImage.width * 4, + space: colorSpace, + bitmapInfo: alphaInfo.rawValue | CGBitmapInfo.byteOrder32Big.rawValue) + else { + throw ImageLoaderError.openFailed("Couldn't create graphics context") + } + + let dstRect = CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height) + if flipVertically { + // Flip the image vertically + let flipVertical = CGAffineTransform(a: 1, b: 0, c: 0, d: -1, tx: 0, ty: CGFloat(cgImage.height)) + context.concatenate(flipVertical) + context.draw(cgImage, in: dstRect) + } else { + context.draw(cgImage, in: dstRect) + } + + // Convert the context to a raw Data block and return as an Image2D + guard let data = context.data else { + throw ImageLoaderError.openFailed("Context data is null") + } + return Image2D( + Data(bytes: data, count: context.bytesPerRow * context.height), + format: .argb8888, + width: context.width, + height: context.height, + stride: context.bytesPerRow) + } + } +} + +struct Image2D { + let data: Data + let format: Format + let width: Int, height: Int, stride: Int + + public enum Format { + case argb8888, abgr8888 + case rgb888, bgr888 + //case l8, l16, a8, al88 + case s3tc_bc1, s3tc_bc2_premul + case s3tc_bc2, s3tc_bc3_premul + case s3tc_bc3, rgtc_bc4, rgtc_bc5_3dc + } +} + +extension Image2D { + init(_ data: Data, format: Format, width: Int, height: Int, stride: Int) { + self.data = data + self.format = format + self.width = width + self.height = height + self.stride = stride + } +} + +enum ImageLoaderError: Error { + case openFailed(_ message: String) +} + + +extension Bundle { + func getResource(_ path: String) throws -> URL { + guard let extIndex = path.lastIndex(of: ".") else { + throw ContentError.resourceNotFound("Malformed resource path \"\(path)\"") + } + let name = String(path[..(-0.5, -0.5, 0.0, 1.0), color: SIMD4(1.0, 0.0, 0.0, 1.0)), - ShaderVertex(position: SIMD4( 0.0, 0.5, 0.0, 1.0), color: SIMD4(0.0, 1.0, 0.0, 1.0)), - ShaderVertex(position: SIMD4( 0.5, -0.5, 0.0, 1.0), color: SIMD4(0.0, 0.0, 1.0, 1.0)) - ] +fileprivate let cubeVertices: [ShaderVertex] = [ + .init(position: .init(-1, -1, 1, 1), normal: .init( 0, 0, 1, 0), texCoord: .init(0, 0)), + .init(position: .init( 1, -1, 1, 1), normal: .init( 0, 0, 1, 0), texCoord: .init(1, 0)), + .init(position: .init(-1, 1, 1, 1), normal: .init( 0, 0, 1, 0), texCoord: .init(0, 1)), + .init(position: .init( 1, 1, 1, 1), normal: .init( 0, 0, 1, 0), texCoord: .init(1, 1)), + .init(position: .init( 1, -1, 1, 1), normal: .init( 1, 0, 0, 0), texCoord: .init(0, 0)), + .init(position: .init( 1, -1, -1, 1), normal: .init( 1, 0, 0, 0), texCoord: .init(1, 0)), + .init(position: .init( 1, 1, 1, 1), normal: .init( 1, 0, 0, 0), texCoord: .init(0, 1)), + .init(position: .init( 1, 1, -1, 1), normal: .init( 1, 0, 0, 0), texCoord: .init(1, 1)), + .init(position: .init( 1, -1, -1, 1), normal: .init( 0, 0, -1, 0), texCoord: .init(0, 0)), + .init(position: .init(-1, -1, -1, 1), normal: .init( 0, 0, -1, 0), texCoord: .init(1, 0)), + .init(position: .init( 1, 1, -1, 1), normal: .init( 0, 0, -1, 0), texCoord: .init(0, 1)), + .init(position: .init(-1, 1, -1, 1), normal: .init( 0, 0, -1, 0), texCoord: .init(1, 1)), + .init(position: .init(-1, -1, -1, 1), normal: .init(-1, 0, 0, 0), texCoord: .init(0, 0)), + .init(position: .init(-1, -1, 1, 1), normal: .init(-1, 0, 0, 0), texCoord: .init(1, 0)), + .init(position: .init(-1, 1, -1, 1), normal: .init(-1, 0, 0, 0), texCoord: .init(0, 1)), + .init(position: .init(-1, 1, 1, 1), normal: .init(-1, 0, 0, 0), texCoord: .init(1, 1)), + .init(position: .init(-1, -1, -1, 1), normal: .init( 0, -1, 0, 0), texCoord: .init(0, 0)), + .init(position: .init( 1, -1, -1, 1), normal: .init( 0, -1, 0, 0), texCoord: .init(1, 0)), + .init(position: .init(-1, -1, 1, 1), normal: .init( 0, -1, 0, 0), texCoord: .init(0, 1)), + .init(position: .init( 1, -1, 1, 1), normal: .init( 0, -1, 0, 0), texCoord: .init(1, 1)), + .init(position: .init(-1, 1, 1, 1), normal: .init( 0, 1, 0, 0), texCoord: .init(0, 0)), + .init(position: .init( 1, 1, 1, 1), normal: .init( 0, 1, 0, 0), texCoord: .init(1, 0)), + .init(position: .init(-1, 1, -1, 1), normal: .init( 0, 1, 0, 0), texCoord: .init(0, 1)), + .init(position: .init( 1, 1, -1, 1), normal: .init( 0, 1, 0, 0), texCoord: .init(1, 1)), +] +fileprivate let cubeIndices: [UInt16] = [ + 0, 1, 2, 2, 1, 3, + 4, 5, 6, 6, 5, 7, + 8, 9, 10, 10, 9, 11, + 12, 13, 14, 14, 13, 15, + 16, 17, 18, 18, 17, 19, + 20, 21, 22, 22, 21, 23 +] + +class Renderer { private var device: MTLDevice private var layer: CAMetalLayer private var viewport = MTLViewport() @@ -19,7 +49,9 @@ class Renderer { private let passDescription = MTLRenderPassDescriptor() private var pso: MTLRenderPipelineState - private var vtxBuffer: MTLBuffer! = nil + private var vtxBuffer: MTLBuffer, idxBuffer: MTLBuffer + private var defaultTexture: MTLTexture + private var cubeTexture: MTLTexture? = nil fileprivate static func createMetalDevice() -> MTLDevice? { MTLCopyAllDevices().reduce(nil, { best, dev in @@ -71,21 +103,85 @@ class Renderer { throw RendererError.initFailure("Failed to create pipeline state: \(error.localizedDescription)") } - // Create vertex buffers + // Create cube mesh buffers guard let vtxBuffer = device.makeBuffer( - bytes: Self.vertices, - length: Self.vertices.count * MemoryLayout.stride, + bytes: cubeVertices, + length: cubeVertices.count * MemoryLayout.stride, options: .storageModeManaged) else { throw RendererError.initFailure("Failed to create vertex buffer") } self.vtxBuffer = vtxBuffer + guard let idxBuffer = device.makeBuffer( + bytes: cubeIndices, + length: cubeIndices.count * MemoryLayout.stride, + options: .storageModeManaged) + else { + throw RendererError.initFailure("Failed to create index buffer") + } + self.idxBuffer = idxBuffer + + do { + self.defaultTexture = try Self.loadTexture(device, image2D: Image2D(Data([ + 0xFF, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0xFF, + 0x00, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0xFF, 0xFF + ]), format: .abgr8888, width: 2, height: 2, stride: 2 * 4)) + } catch { + throw RendererError.initFailure("Failed to create default texture") + } + + do { + self.cubeTexture = try Self.loadTexture(device, resourcePath: "test.png") + } catch RendererError.loadFailure(let message) { + print("Failed to load texture image: \(message)") + } catch { + print("Failed to load texture image: unknown error") + } } deinit { } + static func loadTexture(_ device: MTLDevice, resourcePath path: String) throws -> MTLTexture { + do { + return try loadTexture(device, url: Bundle.main.getResource(path)) + } catch ContentError.resourceNotFound(let message) { + throw RendererError.loadFailure(message) + } + } + + static func loadTexture(_ device: MTLDevice, url imageUrl: URL) throws -> MTLTexture { + do { + return try loadTexture(device, image2D: try NSImageLoader.open(url: imageUrl)) + } catch ImageLoaderError.openFailed(let message) { + throw RendererError.loadFailure(message) + } + } + + static func loadTexture(_ device: MTLDevice, image2D image: Image2D) throws -> MTLTexture { + let texDesc = MTLTextureDescriptor() + texDesc.width = image.width + texDesc.height = image.height + texDesc.pixelFormat = .rgba8Unorm_srgb + texDesc.textureType = .type2D + texDesc.storageMode = .managed + texDesc.usage = .shaderRead + guard let newTexture = device.makeTexture(descriptor: texDesc) else { + throw RendererError.loadFailure("Failed to create texture descriptor") + } + image.data.withUnsafeBytes { bytes in + newTexture.replace( + region: .init( + origin: .init(x: 0, y: 0, z: 0), + size: .init(width: image.width, height: image.height, depth: 1)), + mipmapLevel: 0, + withBytes: bytes.baseAddress!, + bytesPerRow: image.stride) + } + return newTexture + } + func resize(size: SIMD2) { self.viewport = MTLViewport( originX: 0.0, @@ -114,8 +210,16 @@ class Renderer { encoder.setCullMode(MTLCullMode.none) encoder.setRenderPipelineState(pso) - encoder.setVertexBuffer(vtxBuffer, offset: 0, index: ShaderInputIdx.vertices.rawValue) - encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3) + encoder.setFragmentTexture(cubeTexture ?? defaultTexture, index: 0) + encoder.setVertexBuffer(vtxBuffer, + offset: 0, + index: ShaderInputIdx.vertices.rawValue) + encoder.drawIndexedPrimitives( + type: .triangle, + indexCount: cubeIndices.count, + indexType: .uint16, + indexBuffer: idxBuffer, + indexBufferOffset: 0) encoder.endEncoding() commandBuf.present(rt) @@ -125,5 +229,6 @@ class Renderer { enum RendererError: Error { case initFailure(_ message: String) + case loadFailure(_ message: String) case drawFailure(_ message: String) } diff --git a/Sources/Voxelotl/shader.metal b/Sources/Voxelotl/shader.metal index 17b79ae..266d0aa 100644 --- a/Sources/Voxelotl/shader.metal +++ b/Sources/Voxelotl/shader.metal @@ -6,7 +6,8 @@ using namespace metal; struct FragmentInput { float4 position [[position]]; - float4 color; + float4 normal; + float2 texCoord; }; vertex FragmentInput vertexMain( @@ -14,11 +15,18 @@ vertex FragmentInput vertexMain( device const ShaderVertex* vtx [[buffer(ShaderInputIdxVertices)]] ) { FragmentInput out; - out.position = vtx[vertexID].position; - out.color = vtx[vertexID].color; + out.position = vtx[vertexID].position * float4(0.5, 0.5, 0.5, 1.0); + out.normal = vtx[vertexID].normal; + out.texCoord = vtx[vertexID].texCoord; return out; } -fragment float4 fragmentMain(FragmentInput in [[stage_in]]) { - return in.color; +fragment half4 fragmentMain( + FragmentInput in [[stage_in]], + texture2d tex [[texture(0)]] +) { + constexpr sampler s(address::repeat, filter::nearest); + half4 albedo = tex.sample(s, in.texCoord); + + return half4(albedo.rgb, 1.0); } diff --git a/Sources/Voxelotl/shadertypes.h b/Sources/Voxelotl/shadertypes.h index 86b087a..1299f95 100644 --- a/Sources/Voxelotl/shadertypes.h +++ b/Sources/Voxelotl/shadertypes.h @@ -16,7 +16,8 @@ typedef NS_ENUM(NSInteger, ShaderInputIdx) { typedef struct { vector_float4 position; - vector_float4 color; + vector_float4 normal; + vector_float2 texCoord; } ShaderVertex; #endif//SHADERTYPES_H diff --git a/Sources/Voxelotl/test.png b/Sources/Voxelotl/test.png new file mode 100644 index 0000000..f1b5e7f Binary files /dev/null and b/Sources/Voxelotl/test.png differ