From b50da2cb27dbfc910bd8e54ce81c795dcf2083a3 Mon Sep 17 00:00:00 2001 From: Signal Date: Mon, 22 Jun 2026 16:21:43 -0400 Subject: [PATCH] Initial Commit --- .gitignore | 8 + Package.swift | 43 + .../ArtifactGraphics/ArtifactGraphics.swift | 119 +++ Sources/ArtifactGraphics/RenderPass.swift | 217 +++++ Sources/ArtifactGraphics/WebGPU.swift | 899 ++++++++++++++++++ .../ArtifactGraphicsTests.swift | 12 + 6 files changed, 1298 insertions(+) create mode 100644 .gitignore create mode 100644 Package.swift create mode 100644 Sources/ArtifactGraphics/ArtifactGraphics.swift create mode 100644 Sources/ArtifactGraphics/RenderPass.swift create mode 100644 Sources/ArtifactGraphics/WebGPU.swift create mode 100644 Tests/ArtifactGraphicsTests/ArtifactGraphicsTests.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0023a53 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..a535f7e --- /dev/null +++ b/Package.swift @@ -0,0 +1,43 @@ +// swift-tools-version: 6.1 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "ArtifactGraphics", + platforms: [.macOS(.v14)], + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "ArtifactGraphics", + targets: ["ArtifactGraphics"] + ), + ], + traits: [ + .default(enabledTraits: ["WebGPU"]), + .trait(name: "WebGPU", description: "Enables the builtin WebGPU backend.") + ], + dependencies: [ + .package(path: "../ArtifactPlatform"), + .package(path: "../ArtifactColor"), + .package(path: "../ArtifactMath"), + .package(path: "../WGPUNative"), + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "ArtifactGraphics", + dependencies: [ + "ArtifactColor", + "ArtifactPlatform", + "ArtifactMath", + .product(name: "WGPUNative", package: "WGPUNative", condition: .when(traits: ["WebGPU"])), + ], + ), + .testTarget( + name: "ArtifactGraphicsTests", + dependencies: ["ArtifactGraphics"] + ), + ] +) diff --git a/Sources/ArtifactGraphics/ArtifactGraphics.swift b/Sources/ArtifactGraphics/ArtifactGraphics.swift new file mode 100644 index 0000000..6fc3d02 --- /dev/null +++ b/Sources/ArtifactGraphics/ArtifactGraphics.swift @@ -0,0 +1,119 @@ + +import ArtifactMath +import ArtifactPlatform + +public protocol Graphics { + @MainActor + init(_ window: any Window) + + var scene: [Drawable] { get set } + + func createBuffer(label: String, usage: [BufferUsage], size: Int) -> any Buffer + func createTexture(label: String, usage: [TextureUsage], size: [UInt32]) -> any Texture + func createSampler(label: String) -> any Sampler + + func resizeViewport(width: Int, height: Int) + func draw(alpha: Double) + func reload() +} + +public class Drawable { + public enum Operation { + case draw(count: Int) + case pushScissor(rect: Rect) + case popScissor + case pushTransform(_ transformation: Mat4f) + case popTransform + } + + var passes: Set? + public var vertices: any Buffer + public var vertexCount: Int + public var indices: (any Buffer)? + public var indexCount: Int? + public var uniforms: [UInt8]? + public var operations: [Operation]? + + public init(passes: Set? = nil, vertices: any Buffer, vertexCount: Int, indices: (any Buffer)? = nil, indexCount: Int? = nil, uniforms: [UInt8]? = nil, operations: [Operation]? = nil) { + self.passes = passes + self.vertices = vertices + self.vertexCount = vertexCount + self.indices = indices + self.indexCount = indexCount + self.uniforms = uniforms + self.operations = operations + } +} + +public protocol Buffer { + var size: UInt64 { get } + + func writeData(_ data: [UInt8]) +} + +public enum BufferUsage: UInt32, Sendable, CaseIterable { + case vertex + case index + case uniform + case storage + case indirect + + case copySrc + case copyDest + case mapRead + case mapWrite +} + +public protocol Texture { + var width: UInt32 { get } + var height: UInt32 { get } + var depth: UInt32 { get } + var dimensions: Int { get } + + func writeData(_ data: [UInt8]) +} + +public enum TextureUsage { + case storage + case texture + case attachment + + case copySrc + case copyDest +} + +public protocol Sampler { + +} + +public enum AddressMode { + case `repeat` + case mirrorRepeat + case clamp +} + +public enum ShaderStage { + case vertex + case fragment + case compute +} + +public enum BufferBindingType { + case storage + case readOnlyStorage + case uniform +} + +public enum SamplerBindingType { + case filtering + case comparison + case nonfiltering +} + +public enum TextureSampleType { + case sint + case uint + case float + case depth +} + diff --git a/Sources/ArtifactGraphics/RenderPass.swift b/Sources/ArtifactGraphics/RenderPass.swift new file mode 100644 index 0000000..b7e4cf8 --- /dev/null +++ b/Sources/ArtifactGraphics/RenderPass.swift @@ -0,0 +1,217 @@ + +import ArtifactColor + +public enum RenderPassType { + case graphics + case compute +} + +public enum RenderPassDataType { + case i32 + case f32 + case vec3f + case vec3i + case vec2f + case vec21 + case mat4f + case mat4i + case sampler + case buffer +} + +public enum RenderPassBinding { + case buffer(stage: ShaderStage, type: BufferBindingType, buffer: any Buffer) + case sampler(stage: ShaderStage, type: SamplerBindingType, sampler: any Sampler) + case texture(stage: ShaderStage, sampleType: TextureSampleType, texture: any Texture) +} + +public class Shader { + let entryPoint: StaticString + var source: String + var constants: [String: Double] + + public init(entryPoint: StaticString = "main", source: String, constants: [String: Double] = [:]) { + self.entryPoint = entryPoint + self.source = source + self.constants = constants + } +} + +public enum PrimitiveTopology { + case points + case lines + case lineStrip + case triangles + case triangleStrip +} + +public enum WindingOrder { + case clockwise + case counterclockwise +} + +public enum VertexStepMode { + case vertex + case instance +} + +public enum VertexFormat { + case uint8 + case uint16 + case uint32 +} + +public enum VertexAttributeType: UInt32 { + case uint8 + + case vec2f + case vec3f + case vec4f + + case vec2d + case vec3d + case vec4d + + case vec2u + case vec3u + case vec4u + + case vec2n + case vec3n + case vec4n + + var size: Int { + switch self { + case .uint8: MemoryLayout.size + + case .vec2f: MemoryLayout.size * 2 + case .vec3f: MemoryLayout.size * 3 + case .vec4f: MemoryLayout.size * 4 + + case .vec2d: MemoryLayout.size * 2 + case .vec3d: MemoryLayout.size * 3 + case .vec4d: MemoryLayout.size * 4 + + case .vec2u, .vec2n: MemoryLayout.size * 2 + case .vec3u, .vec3n: MemoryLayout.size * 3 + case .vec4u, .vec4n: MemoryLayout.size * 4 + } + } +} + +public struct VertexBufferLayout { + let stepMode: VertexStepMode + let attributes: [VertexAttributeType] + + public init(stepMode: VertexStepMode = .vertex, attributes: [VertexAttributeType]) { + self.stepMode = stepMode + self.attributes = attributes + } +} + +public enum BlendFactor: UInt32, Sendable, CaseIterable { + case zero + case one + case src + case oneMinusSrc + case srcAlpha + case oneMinusSrcAlpha + case dest + case oneMinusDest + case dstAlpha + case oneMinusDstAlpha + case srcAlphaSaturated + case constant + case oneMinusConstant + case src1 + case oneMinusSrc1 + case src1Alpha + case oneMinusSrc1Alpha +} + +public enum BlendOperation: UInt32, Sendable, CaseIterable { + case add + case subtract + case reverseSubtract + case max + case min +} + +public struct ColorTarget { + let colorBlendOperation: BlendOperation + let colorBlendSrcFactor: BlendFactor + let colorBlendDestFactor: BlendFactor + let alphaBlendOperation: BlendOperation + let alphaBlendSrcFactor: BlendFactor + let alphaBlendDestFactor: BlendFactor + let channels: Color.Channel + + public init( + channels: Color.Channel, + colorBlendOperation: BlendOperation = .add, + colorBlendSrcFactor: BlendFactor = .one, + colorBlendDestFactor: BlendFactor = .zero, + alphaBlendOperation: BlendOperation = .add, + alphaBlendSrcFactor: BlendFactor = .one, + alphaBlendDestFactor: BlendFactor = .zero + ) { + self.channels = channels + self.colorBlendOperation = colorBlendOperation + self.colorBlendSrcFactor = colorBlendSrcFactor + self.colorBlendDestFactor = colorBlendDestFactor + self.alphaBlendOperation = alphaBlendOperation + self.alphaBlendSrcFactor = alphaBlendSrcFactor + self.alphaBlendDestFactor = alphaBlendDestFactor + } +} + +public class RenderPass { + var name: StaticString + var label: StaticString + var type: RenderPassType + var depends: [StaticString] + var bindings: [RenderPassBinding] + var vertexShader: Shader + var vertexLayout: [VertexBufferLayout] + var fragmentShader: Shader + var colorTargets: [ColorTarget] + var topology: PrimitiveTopology + var windingOrder: WindingOrder + var hasDepthStencil: Bool + var multisampling: Int + var clearColor: Color + + public init( + name: StaticString, + label: StaticString? = nil, + type: RenderPassType = .graphics, + depends: [StaticString] = [], + bindings: [RenderPassBinding] = [], + vertexLayout: [VertexBufferLayout], + vertexShader: Shader, + colorTargets: [ColorTarget], + fragmentShader: Shader, + depthStencil: Bool? = nil, + multisampling: Int = 1, + topology: PrimitiveTopology = .triangles, + windingOrder: WindingOrder = .counterclockwise, + clearColor: Color + ) { + self.name = name + self.label = label ?? name + self.type = type + self.depends = depends + self.bindings = bindings + self.vertexShader = vertexShader + self.vertexLayout = vertexLayout + self.fragmentShader = fragmentShader + self.colorTargets = colorTargets + self.hasDepthStencil = depthStencil != nil + self.multisampling = multisampling + self.topology = topology + self.windingOrder = windingOrder + self.clearColor = clearColor + } +} + + diff --git a/Sources/ArtifactGraphics/WebGPU.swift b/Sources/ArtifactGraphics/WebGPU.swift new file mode 100644 index 0000000..0cf2aef --- /dev/null +++ b/Sources/ArtifactGraphics/WebGPU.swift @@ -0,0 +1,899 @@ + +#if WebGPU +import ArtifactMath +import ArtifactPlatform +import WGPUNative + +public protocol WGPUCompatibleWindow: Window { + func getWGPUSurface(instance: WGPUInstance) -> WGPUSurface +} + +//#if canImport(GLFW) +//import glfw3webgpu +// +//extension GLFWWindow: WGPUCompatibleWindow { +// public func getWGPUSurface(instance: WGPUInstance) -> WGPUSurface { +// return glfwCreateWindowWGPUSurface(instance, window) +// } +//} +//#endif + +private func stringView(_ string: StaticString) -> WGPUStringView { + string.withUTF8Buffer { ptr in + ptr.withMemoryRebound(to: CChar.self) { ptr in + WGPUStringView(data: ptr.baseAddress, length: string.utf8CodeUnitCount) + } + } +} + +//private func stringView(retained string: String) -> WGPUStringView { +// return string.withCString { ptr in +// WGPUStringView(data: ptr, length: string.count) +// } +//} + +private actor StringViewStorage { + static var views: [[UInt8]] = [] +} + + +private func stringView(retaining string: String) -> WGPUStringView { + var bytes = Array(string.utf8) + StringViewStorage.views.append(bytes) + return bytes.withUnsafeBytes { ptr in + WGPUStringView(data: ptr.assumingMemoryBound(to: CChar.self).baseAddress, length: string.count) + } +} + +extension BufferUsage { + var wgpuValue: WGPUFlags { + switch self { + case .vertex: WGPUBufferUsage_Vertex + case .index: WGPUBufferUsage_Index + case .uniform: WGPUBufferUsage_Uniform + case .storage: WGPUBufferUsage_Storage + case .indirect: WGPUBufferUsage_Indirect + + case .copySrc: WGPUBufferUsage_CopySrc + case .copyDest: WGPUBufferUsage_CopyDst + case .mapRead: WGPUBufferUsage_MapRead + case .mapWrite: WGPUBufferUsage_MapWrite + } + } +} + +public class WebGPUBuffer: Buffer { + private let context: WebGPUGraphics + let handle: WGPUBuffer + public let size: UInt64 + + public init(context: WebGPUGraphics, label: String, usage: [BufferUsage], size: UInt64, mapped: Bool = false) { + self.context = context + self.size = size + var desc = WGPUBufferDescriptor( + nextInChain: nil, + label: stringView(retaining: label), + usage: usage.reduce(0) { $0 | $1.wgpuValue }, + size: size, + mappedAtCreation: mapped ? 1 : 0 + ) + handle = wgpuDeviceCreateBuffer(context.device, &desc) + } + + public func writeData(_ data: [UInt8]) { + data.withUnsafeBytes { ptr in + wgpuQueueWriteBuffer(context.queue, handle, 0, ptr.baseAddress, data.count) + } + } +} + + +extension TextureUsage { + var wgpuValue: WGPUFlags { + switch self { + case .texture: WGPUTextureUsage_TextureBinding + case .storage: WGPUTextureUsage_StorageBinding + case .attachment: WGPUTextureUsage_RenderAttachment + + case .copySrc: WGPUTextureUsage_CopySrc + case .copyDest: WGPUTextureUsage_CopyDst + } + } +} + +public class WebGPUTexture: Texture { + private let context: WebGPUGraphics + let handle: WGPUTexture + let view: WGPUTextureView + public let width: UInt32 + public let height: UInt32 + public let depth: UInt32 + public let dimensions: Int + + init(context: WebGPUGraphics, label: String, usage: [TextureUsage], format: WGPUTextureFormat = WGPUTextureFormat_BGRA8Unorm, dimensions: Int = 2, size: [UInt32], mipLevels: UInt32 = 1, samples: UInt32 = 1) { + self.context = context + width = max(1, size[0]) + height = max(1, size.count > 1 ? size[1] : 1) + depth = max(1, size.count > 2 ? size[2] : 1) + self.dimensions = dimensions + + let usage = usage.reduce(0) { $0 | $1.wgpuValue } + var desc = WGPUTextureDescriptor( + nextInChain: nil, + label: stringView(retaining: label), + usage: usage, + dimension: dimensions == 3 ? WGPUTextureDimension_3D : dimensions == 1 ? WGPUTextureDimension_1D : WGPUTextureDimension_2D, + size: WGPUExtent3D(width: width, height: height, depthOrArrayLayers: depth), + format: format, + mipLevelCount: mipLevels, + sampleCount: samples, + viewFormatCount: 0, + viewFormats: nil + ) + handle = wgpuDeviceCreateTexture(context.device, &desc) + + var viewDesc = WGPUTextureViewDescriptor( + nextInChain: nil, + label: stringView(retaining: label), + format: format, + dimension: dimensions == 3 ? WGPUTextureViewDimension_3D : dimensions == 1 ? WGPUTextureViewDimension_1D : WGPUTextureViewDimension_2D, + baseMipLevel: 0, + mipLevelCount: mipLevels, + baseArrayLayer: 0, + arrayLayerCount: 1, + aspect: WGPUTextureAspect_All, + usage: usage + ) + view = wgpuTextureCreateView(handle, &viewDesc) + } + + deinit { + wgpuTextureViewRelease(view) + wgpuTextureDestroy(handle) + wgpuTextureRelease(handle) + } + + public func writeData(_ data: [UInt8]) { + var size = WGPUExtent3D( + width: width, + height: height, + depthOrArrayLayers: depth + ) + + var layout = WGPUTexelCopyBufferLayout( + offset: 0, + bytesPerRow: width * 4, + rowsPerImage: height + ) + + var dest = WGPUTexelCopyTextureInfo( + texture: handle, + mipLevel: 0, + origin: WGPUOrigin3D(x: 0, y: 0, z: 0), + aspect: WGPUTextureAspect_All + ) + + data.withUnsafeBytes { ptr in + wgpuQueueWriteTexture(context.queue, &dest, ptr.baseAddress, data.count, &layout, &size) + } + } +} + +public class WebGPUSampler: Sampler { + private let context: WebGPUGraphics + let handle: WGPUSampler + + public init(context: WebGPUGraphics, label: String, addressMode: AddressMode = .clamp) { + self.context = context + + var desc = WGPUSamplerDescriptor( + nextInChain: nil, + label: stringView(retaining: label), + addressModeU: addressMode.wgpuValue, + addressModeV: addressMode.wgpuValue, // TODO: Allow configuring these separately. + addressModeW: addressMode.wgpuValue, + magFilter: WGPUFilterMode_Nearest, // TODO: Allow configuration. + minFilter: WGPUFilterMode_Nearest, // TODO: Allow configuration. + mipmapFilter: WGPUMipmapFilterMode_Linear, // TODO: Allow configuration. + lodMinClamp: 0, // TODO: Allow configuration. + lodMaxClamp: 0, // TODO: Allow configuration. + compare: WGPUCompareFunction_Undefined, // TODO: Allow configuration. + maxAnisotropy: 1 // TODO: Allow configuration. + ) + handle = wgpuDeviceCreateSampler(context.device, &desc) + } + + deinit { + wgpuSamplerRelease(handle) + } +} + +extension AddressMode { + var wgpuValue: WGPUAddressMode { + switch self { + case .repeat: WGPUAddressMode_Repeat + case .mirrorRepeat: WGPUAddressMode_MirrorRepeat + case .clamp: WGPUAddressMode_ClampToEdge + } + } +} + +extension ShaderStage { + var wgpuValue: WGPUShaderStage { + switch self { + case .vertex: WGPUShaderStage_Vertex + case .fragment: WGPUShaderStage_Fragment + case .compute: WGPUShaderStage_Compute + } + } +} + +extension BufferBindingType { + var wgpuValue: WGPUBufferBindingType { + switch self { + case .storage: WGPUBufferBindingType_Storage + case .readOnlyStorage: WGPUBufferBindingType_ReadOnlyStorage + case .uniform: WGPUBufferBindingType_Uniform + } + } +} + +extension SamplerBindingType { + var wgpuValue: WGPUSamplerBindingType { + switch self { + case .comparison: WGPUSamplerBindingType_Comparison + case .filtering: WGPUSamplerBindingType_Filtering + case .nonfiltering: WGPUSamplerBindingType_NonFiltering + } + } +} + +extension TextureSampleType { + var wgpuValue: WGPUTextureSampleType { + switch self { + case .float: WGPUTextureSampleType_Float + case .sint: WGPUTextureSampleType_Sint + case .uint: WGPUTextureSampleType_Uint + case .depth: WGPUTextureSampleType_Depth + } + } +} + +extension PrimitiveTopology { + var wgpuValue: WGPUPrimitiveTopology { + switch self { + case .points: WGPUPrimitiveTopology_PointList + case .lines: WGPUPrimitiveTopology_LineList + case .lineStrip: WGPUPrimitiveTopology_LineStrip + case .triangles: WGPUPrimitiveTopology_TriangleList + case .triangleStrip: WGPUPrimitiveTopology_TriangleStrip + } + } +} + +extension WindingOrder { + var wgpuValue: WGPUFrontFace { + switch self { + case .clockwise: WGPUFrontFace_CW + case .counterclockwise: WGPUFrontFace_CCW + } + } +} + +extension VertexStepMode { + var wgpuValue: WGPUVertexStepMode { + switch self { + case .vertex: WGPUVertexStepMode_Vertex + case .instance: WGPUVertexStepMode_Instance + } + } +} + +extension VertexAttributeType { + var wgpuValue: WGPUVertexFormat { + switch self { + case .uint8: WGPUVertexFormat_Uint8 + + case .vec2f: WGPUVertexFormat_Float32x2 + case .vec3f: WGPUVertexFormat_Float32x3 + case .vec4f: WGPUVertexFormat_Float32x4 + + case .vec2n: WGPUVertexFormat_Unorm8x2 + case .vec4n: WGPUVertexFormat_Unorm8x4 + + default: + fatalError("Unsupported vertex format: \(self)") + } + } +} + +extension BlendFactor { + var wgpuValue: WGPUBlendFactor { + switch self { + case .zero: WGPUBlendFactor_Zero + case .one: WGPUBlendFactor_One + case .src: WGPUBlendFactor_Src + case .oneMinusSrc: WGPUBlendFactor_OneMinusSrc + case .srcAlpha: WGPUBlendFactor_SrcAlpha + case .oneMinusSrcAlpha: WGPUBlendFactor_OneMinusSrcAlpha + case .dest: WGPUBlendFactor_Dst + case .oneMinusDest: WGPUBlendFactor_OneMinusDst + case .dstAlpha: WGPUBlendFactor_DstAlpha + case .oneMinusDstAlpha: WGPUBlendFactor_OneMinusDstAlpha + case .srcAlphaSaturated: WGPUBlendFactor_SrcAlphaSaturated + case .constant: WGPUBlendFactor_Constant + case .oneMinusConstant: WGPUBlendFactor_OneMinusConstant + case .src1: WGPUBlendFactor_Src1 + case .oneMinusSrc1: WGPUBlendFactor_OneMinusSrc1 + case .src1Alpha: WGPUBlendFactor_Src1Alpha + case .oneMinusSrc1Alpha: WGPUBlendFactor_OneMinusSrc1Alpha + } + } +} + +extension BlendOperation { + var wgpuValue: WGPUBlendOperation { + switch self { + case .add: WGPUBlendOperation_Add + case .subtract: WGPUBlendOperation_Subtract + case .reverseSubtract: WGPUBlendOperation_ReverseSubtract + case .min: WGPUBlendOperation_Min + case .max: WGPUBlendOperation_Max + } + } +} + +public class WebGPUPipeline { + let context: WebGPUGraphics + var handle: WGPURenderPipeline! + var bindGroup: WGPUBindGroup! + private var vertexOutputs: [WGPUVertexBufferLayout] = [] + private var blendStates: UnsafeMutablePointer? + private var targets: UnsafeMutablePointer? + private var fragmentState: WGPUFragmentState! + + init(context: WebGPUGraphics, pass: RenderPass) { + self.context = context + generate(from: pass) + } + + func generateLayout(from pass: RenderPass) -> WGPUBindGroupLayout { + var bindingNum: UInt32 = 0 + var layouts: [WGPUBindGroupLayoutEntry] = [] + var entries: [WGPUBindGroupEntry] = [] + for item in pass.bindings { + switch item { + case .buffer(let stage, let type, let buffer): + layouts.append(WGPUBindGroupLayoutEntry( + nextInChain: nil, + binding: bindingNum, + visibility: stage.wgpuValue, + buffer: WGPUBufferBindingLayout(nextInChain: nil, type: type.wgpuValue, hasDynamicOffset: 0, minBindingSize: 0), + sampler: WGPUSamplerBindingLayout(), + texture: WGPUTextureBindingLayout(), + storageTexture: WGPUStorageTextureBindingLayout() + )) + entries.append(WGPUBindGroupEntry( + nextInChain: nil, + binding: bindingNum, + buffer: (buffer as! WebGPUBuffer).handle, + offset: 0, // TODO: Allow configuration. (This is the byte offset of the binding range for the buffer.) + size: buffer.size, + sampler: nil, + textureView: nil + )) + case .sampler(let stage, let type, let sampler): + layouts.append(WGPUBindGroupLayoutEntry( + nextInChain: nil, + binding: bindingNum, + visibility: stage.wgpuValue, + buffer: WGPUBufferBindingLayout(), + sampler: WGPUSamplerBindingLayout(nextInChain: nil, type: type.wgpuValue), + texture: WGPUTextureBindingLayout(), + storageTexture: WGPUStorageTextureBindingLayout() + )) + entries.append(WGPUBindGroupEntry( + nextInChain: nil, + binding: bindingNum, + buffer: nil, + offset: 0, + size: 0, + sampler: (sampler as! WebGPUSampler).handle, + textureView: nil + )) + case .texture(let stage, let sampleType, let texture): + layouts.append(WGPUBindGroupLayoutEntry( + nextInChain: nil, + binding: bindingNum, + visibility: stage.wgpuValue, + buffer: WGPUBufferBindingLayout(), + sampler: WGPUSamplerBindingLayout(), + texture: WGPUTextureBindingLayout(nextInChain: nil, sampleType: sampleType.wgpuValue, viewDimension: texture.dimensions == 3 ? WGPUTextureViewDimension_3D : texture.dimensions == 1 ? WGPUTextureViewDimension_1D : WGPUTextureViewDimension_2D, multisampled: 0), // TODO: Multisample? + storageTexture: WGPUStorageTextureBindingLayout() + )) + entries.append(WGPUBindGroupEntry( + nextInChain: nil, + binding: bindingNum, + buffer: nil, + offset: 0, + size: 0, + sampler: nil, + textureView: (texture as! WebGPUTexture).view + )) + } + bindingNum += 1 + } + + var bindGroupLayout = layouts.withUnsafeBufferPointer { layoutsPtr in + var bindGroupLayoutDesc = WGPUBindGroupLayoutDescriptor( + nextInChain: nil, + label: stringView("."), + entryCount: layouts.count, + entries: layoutsPtr.baseAddress + ) + return wgpuDeviceCreateBindGroupLayout(context.device, &bindGroupLayoutDesc) + } + + bindGroup = entries.withUnsafeBufferPointer { entries in + var bindGroupDesc = WGPUBindGroupDescriptor( + nextInChain: nil, + label: stringView(retaining: "\(pass.name) - bind group"), + layout: bindGroupLayout, + entryCount: entries.count, + entries: entries.baseAddress + ) + return wgpuDeviceCreateBindGroup(context.device, &bindGroupDesc) + } + + return withUnsafePointer(to: bindGroupLayout) { bindGroupLayout in + var layoutDesc = WGPUPipelineLayoutDescriptor( + nextInChain: nil, + label: stringView(retaining: "\(pass.name) - layout"), + bindGroupLayoutCount: 1, // TODO: Multiple layouts? + bindGroupLayouts: bindGroupLayout + ) + return wgpuDeviceCreatePipelineLayout(context.device, &layoutDesc) + } + } + + func generateShaderModule(code: String) -> WGPUShaderModule { + withExtendedLifetime(WGPUChainedStruct(next: nil, sType: WGPUSType_ShaderSourceWGSL)) { chain in // TODO: Support multiple languages. + var source = WGPUShaderSourceWGSL(chain: chain, code: stringView(retaining: code)) + return withUnsafePointer(to: chain) { chain in + var desc = WGPUShaderModuleDescriptor(nextInChain: chain, label: stringView("Shader")) + return wgpuDeviceCreateShaderModule(context.device, &desc) + } + } + } + + func generateShaders(from pass: RenderPass) -> (vertexState: WGPUVertexState, buffers: [WGPUVertexBufferLayout], vertexAttributes: [[WGPUVertexAttribute]]) { + var vertexShader = generateShaderModule(code: pass.vertexShader.source) + + var constants = pass.vertexShader.constants.map { WGPUConstantEntry(nextInChain: nil, key: stringView(retaining: $0), value: $1) } + + // This is needed to extend the lifetime of the attribute lists. + var attributeStore: [[WGPUVertexAttribute]] = [] + var outputs = pass.vertexLayout.map { layout in + var num: UInt32 = 0 + var offset: UInt64 = 0 + var attributes = layout.attributes.map { item in + defer { + num += 1 + offset += UInt64(item.size) + } + return WGPUVertexAttribute( + format: item.wgpuValue, + offset: offset, + shaderLocation: num + ) + } + attributeStore.append(attributes) + return attributes.withUnsafeBufferPointer { attributes in + return WGPUVertexBufferLayout(stepMode: layout.stepMode.wgpuValue, arrayStride: offset, attributeCount: layout.attributes.count, attributes: attributes.baseAddress) + } + } + + var vertexState = constants.withUnsafeBufferPointer { constants in + outputs.withUnsafeBufferPointer { outputs in + WGPUVertexState( + nextInChain: nil, + module: vertexShader, + entryPoint: stringView(pass.vertexShader.entryPoint), + constantCount: constants.count, + constants: constants.baseAddress, + bufferCount: outputs.count, + buffers: outputs.baseAddress + ) + } + } + + let fragmentShader = generateShaderModule(code: pass.fragmentShader.source) + + let fragmentConstants = pass.fragmentShader.constants.map { WGPUConstantEntry(nextInChain: nil, key: stringView(retaining: $0), value: $1) } + + if pass.colorTargets.count > 0 { + // We manually allocate memory here to ensure that the pointer remains stable until we need to use it. + blendStates = UnsafeMutablePointer.allocate(capacity: pass.colorTargets.count) + targets = UnsafeMutablePointer.allocate(capacity: pass.colorTargets.count) + + var i = 0 + for item in pass.colorTargets { + blendStates![i] = WGPUBlendState( + color: WGPUBlendComponent( + operation: item.colorBlendOperation.wgpuValue, + srcFactor: item.colorBlendSrcFactor.wgpuValue, + dstFactor: item.colorBlendDestFactor.wgpuValue + ), + alpha: WGPUBlendComponent( + operation: item.alphaBlendOperation.wgpuValue, + srcFactor: item.alphaBlendSrcFactor.wgpuValue, + dstFactor: item.alphaBlendDestFactor.wgpuValue + ) + ) + let mask = switch item.channels { + case .all: WGPUColorWriteMask_All + case .none: WGPUColorWriteMask_None + case .r: WGPUColorWriteMask_Red + case .g: WGPUColorWriteMask_Green + case .b: WGPUColorWriteMask_Blue + case .a: WGPUColorWriteMask_Alpha + } + + targets![i] = WGPUColorTargetState( + nextInChain: nil, + format: WGPUTextureFormat_BGRA8Unorm, // TODO: Allow configuration. + blend: blendStates!.advanced(by: i), + writeMask: mask + ) + + i += 1 + } + } + + fragmentState = fragmentConstants.withUnsafeBufferPointer { constants in + WGPUFragmentState( + nextInChain: nil, + module: fragmentShader, + entryPoint: stringView(pass.fragmentShader.entryPoint), + constantCount: constants.count, + constants: constants.baseAddress, + targetCount: pass.colorTargets.count, + targets: targets + ) + } + + return (vertexState, outputs, attributeStore) + } + + func generate(from pass: RenderPass) { + let layout = generateLayout(from: pass) + let shaders = generateShaders(from: pass) + + let primitiveState = WGPUPrimitiveState( + nextInChain: nil, + topology: pass.topology.wgpuValue, + stripIndexFormat: pass.topology == .lineStrip || pass.topology == .triangleStrip ? WGPUIndexFormat_Uint32 : WGPUIndexFormat_Undefined, // TODO: Allow configuration. + frontFace: pass.windingOrder.wgpuValue, + cullMode: WGPUCullMode_Back, // TODO: Allow configuration. + unclippedDepth: 0 // TODO: Allow configuration. + ) + + let depthStencil: WGPUDepthStencilState? = if pass.hasDepthStencil { + WGPUDepthStencilState( // TODO: Allow configuration. + nextInChain: nil, + format: WGPUTextureFormat_Depth32Float, + depthWriteEnabled: WGPUOptionalBool_True, + depthCompare: WGPUCompareFunction_Less, + stencilFront: WGPUStencilFaceState(), + stencilBack: WGPUStencilFaceState(), + stencilReadMask: 0, + stencilWriteMask: 0, + depthBias: 0, + depthBiasSlopeScale: 0, + depthBiasClamp: 0 + ) + } else { nil } + + let multisampleState = WGPUMultisampleState( // TODO: Allow configuration. + nextInChain: nil, + count: UInt32(pass.multisampling), + mask: 0, + alphaToCoverageEnabled: 0 + ) + + withUnsafePointer(to: fragmentState!) { fragmentState in + let cb: (UnsafePointer?) -> Void = { [self] depthStencil in + var desc = WGPURenderPipelineDescriptor( + nextInChain: nil, + label: stringView(pass.name), + layout: layout, + vertex: shaders.vertexState, + primitive: primitiveState, + depthStencil: depthStencil, + multisample: multisampleState, + fragment: fragmentState + ) + + handle = wgpuDeviceCreateRenderPipeline(context.device, &desc) + } + if let depthStencil = depthStencil { + withUnsafePointer(to: depthStencil) { ptr in + cb(ptr) + } + } else { + cb(nil) + } + } + blendStates?.deallocate() + targets?.deallocate() + } +} + +public class WebGPUGraphics: Graphics { + var passes: [RenderPass] = [] + var viewportWidth: Int + var viewportHeight: Int + public var scene: [Drawable] = [] + + let instance: WGPUInstance + var adapter: WGPUAdapter! + var device: WGPUDevice! + var surface: WGPUSurface! + var queue: WGPUQueue! + var pipelines: [String: WebGPUPipeline] = [:] + var antialiased: WebGPUTexture! + + private var _depthTexture = false + lazy var depthTexture: WebGPUTexture = { + _depthTexture = true + return .init(context: self, label: "Depth texture", usage: [.attachment], format: WGPUTextureFormat_Depth32Float, size: [UInt32(viewportWidth), UInt32(viewportHeight)]) // TODO: Adapt to view size. + }() + + @MainActor + public required convenience init(_ window: any Window) { + assert(window is any WGPUCompatibleWindow, "The window is not capable of providing a WebGPU surface.") + self.init(window as! any WGPUCompatibleWindow) + } + + @MainActor + init(_ window: any WGPUCompatibleWindow) { + viewportWidth = window.width + viewportHeight = window.height + + instance = wgpuCreateInstance(nil) + var adapterOptions = WGPURequestAdapterOptions() + wgpuInstanceRequestAdapter(instance, &adapterOptions, WGPURequestAdapterCallbackInfo(nextInChain: nil, mode: WGPUCallbackMode_AllowProcessEvents, callback: { status, adapter, err, userdata, _ in + if status == WGPURequestAdapterStatus_Success { + let me = Unmanaged.fromOpaque(userdata!).takeUnretainedValue() + me.adapter = adapter + } else { + fatalError("Failed to initialize WebGPU: Adapter request error (\(status)): \(String(cString: err.data))") + } + }, userdata1: Unmanaged.passUnretained(self).toOpaque(), userdata2: nil)) + while adapter == nil { + wgpuInstanceProcessEvents(instance) + } + var deviceOptions = WGPUDeviceDescriptor() + wgpuAdapterRequestDevice(adapter, &deviceOptions, WGPURequestDeviceCallbackInfo(nextInChain: nil, mode: WGPUCallbackMode_AllowProcessEvents, callback: { status, device, err, userdata, _ in + if status == WGPURequestDeviceStatus_Success { + let me = Unmanaged.fromOpaque(userdata!).takeUnretainedValue() + me.device = device + } else { + fatalError("Failed to initialize WebGPU: Device request error (\(status)): \(String(cString: err.data))") + } + }, userdata1: Unmanaged.passUnretained(self).toOpaque(), userdata2: nil)) + while device == nil { + wgpuInstanceProcessEvents(instance) + } + surface = window.getWGPUSurface(instance: instance) + + resizeViewport(width: window.width, height: window.height) + + window.onResize(resizeViewport) + + window.onDraw(draw) + + queue = wgpuDeviceGetQueue(device) + } + + deinit { + wgpuQueueRelease(queue) + wgpuSurfaceRelease(surface) + wgpuDeviceDestroy(device) + wgpuDeviceRelease(device) + wgpuAdapterRelease(adapter) + wgpuInstanceRelease(instance) + } + + public func resizeViewport(width: Int, height: Int) { + viewportWidth = width + viewportHeight = height + + wgpuSurfaceUnconfigure(surface) + + var config = WGPUSurfaceConfiguration( + nextInChain: nil, + device: device, + format: WGPUTextureFormat_BGRA8Unorm, // TODO: Allow configuration. + usage: WGPUTextureUsage_RenderAttachment, + width: UInt32(viewportWidth), + height: UInt32(viewportHeight), + viewFormatCount: 0, + viewFormats: nil, + alphaMode: WGPUCompositeAlphaMode_Auto, + presentMode: WGPUPresentMode_Fifo + ) + wgpuSurfaceConfigure(surface, &config) + + if antialiased != nil { + antialiased = WebGPUTexture(context: self, label: "MSAA intermediate texture", usage: [.copyDest, .attachment], size: [UInt32(viewportWidth), UInt32(viewportHeight)]) + } + + if _depthTexture { + depthTexture = .init(context: self, label: "Depth texture", usage: [.attachment], format: WGPUTextureFormat_Depth32Float, size: [UInt32(viewportWidth), UInt32(viewportHeight)]) // TODO: Adapt to view size. + } + + reload() + } + + public func createBuffer(label: String, usage: [BufferUsage], size: Int) -> any Buffer { + WebGPUBuffer(context: self, label: label, usage: usage, size: UInt64(size)) + } + + public func createTexture(label: String, usage: [TextureUsage], size: [UInt32]) -> any Texture { + WebGPUTexture(context: self, label: label, usage: usage, size: size) + } + + public func createSampler(label: String) -> any Sampler { + WebGPUSampler(context: self, label: label, addressMode: .clamp) + } + + public func reload() { + for pass in passes { + pipelines["\(pass.name)"]?.generate(from: pass) + } + } + + public func draw(alpha: Double) { + var surfaceTexture = WGPUSurfaceTexture() + wgpuSurfaceGetCurrentTexture(surface, &surfaceTexture) + if (surfaceTexture.status != WGPUSurfaceGetCurrentTextureStatus_SuccessOptimal && surfaceTexture.status != WGPUSurfaceGetCurrentTextureStatus_SuccessSuboptimal || surfaceTexture.texture == nil) { + print("Failed to get surface texture") + return + } + let nextTexture = wgpuTextureCreateView(surfaceTexture.texture, nil) + if nextTexture == nil { + print("Failed to create texture view") + return + } + + let encoder = wgpuDeviceCreateCommandEncoder(device, nil) + + for pass in passes { + var colorAttachment = WGPURenderPassColorAttachment( + nextInChain: nil, + view: nextTexture, + depthSlice: WGPU_DEPTH_SLICE_UNDEFINED, + resolveTarget: nil, + loadOp: WGPULoadOp_Clear, + storeOp: WGPUStoreOp_Store, + clearValue: WGPUColor(r: Double(pass.clearColor.r) / 255, g: Double(pass.clearColor.g) / 255, b: Double(pass.clearColor.b) / 255, a: Double(pass.clearColor.a) / 255) + ) + + if pass.multisampling > 1 { + if antialiased == nil { + antialiased = WebGPUTexture(context: self, label: "MSAA intermediate texture", usage: [.copyDest, .attachment], size: [UInt32(viewportWidth), UInt32(viewportHeight)], samples: UInt32(pass.multisampling)) + } + colorAttachment.view = antialiased.view + colorAttachment.resolveTarget = nextTexture + colorAttachment.storeOp = WGPUStoreOp_Discard + } + + let renderPass = withUnsafePointer(to: colorAttachment) { colorAttachment in + let cb: (UnsafePointer?) -> WGPURenderPassEncoder = { depthAttachment in + var renderPassDesc = WGPURenderPassDescriptor( + nextInChain: nil, + label: stringView(pass.label), + colorAttachmentCount: 1, + colorAttachments: colorAttachment, + depthStencilAttachment: depthAttachment, + occlusionQuerySet: nil, + timestampWrites: nil + ) + return wgpuCommandEncoderBeginRenderPass(encoder, &renderPassDesc) + } + + if pass.hasDepthStencil { + let depthAttachment = WGPURenderPassDepthStencilAttachment( // TODO: Allow configuration. + view: depthTexture.view, + depthLoadOp: WGPULoadOp_Clear, + depthStoreOp: WGPUStoreOp_Store, + depthClearValue: 1, + depthReadOnly: 0, + stencilLoadOp: WGPULoadOp_Undefined, + stencilStoreOp: WGPUStoreOp_Undefined, + stencilClearValue: 0, + stencilReadOnly: 1 + ) + + return withUnsafePointer(to: depthAttachment) { depthAttachment in + cb(depthAttachment) + } + } else { + return cb(nil) + } + } + + let pipeline = pipelines["\(pass.name)"]! + + wgpuRenderPassEncoderSetPipeline(renderPass, pipeline.handle) + wgpuRenderPassEncoderSetViewport(renderPass, 0, 0, Float(viewportWidth), Float(viewportHeight), 0, 1) + wgpuRenderPassEncoderSetBindGroup(renderPass, 0, pipeline.bindGroup, 0, nil) + + for item in scene { + if item.passes != nil && !item.passes!.contains("\(pass.name)") { continue } + wgpuRenderPassEncoderSetVertexBuffer(renderPass, 0, (item.vertices as! WebGPUBuffer).handle, 0, item.vertices.size) + + for binding in pass.bindings { + if case .buffer(_, .uniform, let uniformBuffer) = binding { + if let data = item.uniforms { + uniformBuffer.writeData(data) + break + } + } + } + + if item.indices != nil { + wgpuRenderPassEncoderSetIndexBuffer(renderPass, (item.indices! as! WebGPUBuffer).handle, WGPUIndexFormat_Uint32, 0, item.indices!.size) + + var offset: Int = 0 + var scissor: [Rect] = [] + var transform: [Mat4f] = [] + for op in item.operations ?? [.draw(count: item.indexCount ?? item.vertexCount)] { + switch op { + case .draw(let count): + wgpuRenderPassEncoderDrawIndexed(renderPass, UInt32(count), 1, UInt32(offset), 0, 0) + offset += count + case .pushScissor(let rect): + wgpuRenderPassEncoderSetScissorRect(renderPass, UInt32(rect.left), UInt32(rect.top), UInt32(rect.width), UInt32(rect.height)) + scissor.append(rect) + case .popScissor: + scissor.removeLast() + let rect = scissor.last ?? Rect(origin: Vec2f(0, 0), size: Vec2f(viewportWidth, viewportHeight)) + wgpuRenderPassEncoderSetScissorRect(renderPass, UInt32(rect.left), UInt32(rect.top), UInt32(rect.width), UInt32(rect.height)) + case .pushTransform(let transformation): + transform.append(transformation) + case .popTransform: + transform.removeLast() + } + } + } else { + wgpuRenderPassEncoderDraw(renderPass, UInt32(item.vertexCount), 1, 0, 0) + } + } + + wgpuRenderPassEncoderEnd(renderPass) + wgpuRenderPassEncoderRelease(renderPass) + } + + var commandBufferDesc = WGPUCommandBufferDescriptor(nextInChain: nil, label: stringView("Command buffer")) + var cmdBuffer = wgpuCommandEncoderFinish(encoder, &commandBufferDesc) + wgpuQueueSubmit(queue, 1, &cmdBuffer) + wgpuSurfacePresent(surface) // TODO: Remove for wasm builds? + wgpuTextureViewRelease(nextTexture) + wgpuTextureRelease(surfaceTexture.texture) + wgpuCommandBufferRelease(cmdBuffer) + wgpuCommandEncoderRelease(encoder) + } + + public func registerPass(_ def: RenderPass) { + passes.append(def) + let pipeline = WebGPUPipeline(context: self, pass: def) + pipelines["\(def.name)"] = pipeline + passes.sort { a, b in + b.depends.contains { "\($0)" == "\(a.name)" } + } + } +} + +#endif diff --git a/Tests/ArtifactGraphicsTests/ArtifactGraphicsTests.swift b/Tests/ArtifactGraphicsTests/ArtifactGraphicsTests.swift new file mode 100644 index 0000000..2bc9d7f --- /dev/null +++ b/Tests/ArtifactGraphicsTests/ArtifactGraphicsTests.swift @@ -0,0 +1,12 @@ +import XCTest +@testable import ArtifactGraphics + +final class ArtifactGraphicsTests: XCTestCase { + func testExample() throws { + // XCTest Documentation + // https://developer.apple.com/documentation/xctest + + // Defining Test Cases and Test Methods + // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods + } +}