From 42f0a4263dd0d8b2656dbde5da720b84e2142d3a Mon Sep 17 00:00:00 2001 From: SpectralDragon Date: Thu, 12 Nov 2026 23:30:38 +0300 Subject: [PATCH 1/5] Expose reusable browser GPU host and presentation capture --- Package.swift | 4 +- Sources/SAGAWebDiagnostic/Diagnostic.swift | 5 +- Sources/SAGAWebDiagnostic/WebReactor.swift | 103 ++++++------ .../WebGPUHost.swift | 86 ++++++---- Sources/SAGAWebHost/WebHostParameters.swift | 155 ++++++++++++++++++ scripts/test-web-host-presentation.mjs | 62 +++++++ web/golden.mjs | 74 +++++++++ web/webgpu-host.test.mjs | 38 +++++ web/webgpu.mjs | 111 ++++++++++--- 9 files changed, 529 insertions(+), 109 deletions(-) rename Sources/{SAGAWebDiagnostic => SAGAWebHost}/WebGPUHost.swift (92%) create mode 100644 Sources/SAGAWebHost/WebHostParameters.swift create mode 100644 scripts/test-web-host-presentation.mjs create mode 100644 web/golden.mjs create mode 100644 web/webgpu-host.test.mjs diff --git a/Package.swift b/Package.swift index 632204e..e60f955 100644 --- a/Package.swift +++ b/Package.swift @@ -13,13 +13,14 @@ let package = Package( .library(name: "SAGACompiler", targets: ["SAGACompiler"]), .library(name: "SAGAMetal", targets: ["SAGAMetal"]), .library(name: "SAGAWebGPU", targets: ["SAGAWebGPU"]), + .library(name: "SAGAWebHost", targets: ["SAGAWebHost"]), .library(name: "SAGAVulkan", targets: ["SAGAVulkan"]), .library(name: "SAGAD3D12", targets: ["SAGAD3D12"]), ], targets: [ .executableTarget(name: "SAGAMetalDiagnostic", dependencies: ["SAGATypes", "SAGACore", "SAGAMetal"]), .executableTarget(name: "SAGAMetalWindowDiagnostic", dependencies: ["SAGATypes", "SAGACore", "SAGACompiler", "SAGAShaderIR", "SAGAMetal"]), - .executableTarget(name: "SAGAWebDiagnostic", dependencies: ["SAGATypes", "SAGABackend", "SAGACore", "SAGAShaderIR", "SAGACompiler", "SAGAWebGPU"]), + .executableTarget(name: "SAGAWebDiagnostic", dependencies: ["SAGATypes", "SAGABackend", "SAGACore", "SAGAShaderIR", "SAGACompiler", "SAGAWebGPU", "SAGAWebHost"]), .executableTarget(name: "SAGABenchmark", dependencies: ["SAGATypes", "SAGACore", "SAGACompiler", "SAGAVulkan"], linkerSettings: [.linkedLibrary("winmm", .when(platforms: [.windows]))]), .target(name: "SAGATypes"), @@ -34,6 +35,7 @@ let package = Package( .target(name: "SAGACompiler", dependencies: ["SAGAShaderIR", "SAGAValidation", "SAGAFrontends", "SAGABackends", "SAGATypes"]), .target(name: "SAGAMetal", dependencies: ["SAGATypes", "SAGABackend", "SAGAShaderIR"]), .target(name: "SAGAWebGPU", dependencies: ["SAGATypes", "SAGABackend", "SAGAShaderIR"]), + .target(name: "SAGAWebHost", dependencies: ["SAGATypes", "SAGABackend", "SAGAShaderIR", "SAGAWebGPU"]), .target(name: "SAGAVulkan", dependencies: ["SAGATypes", "SAGABackend", "SAGAShaderIR", "SAGACompiler"]), .target(name: "SAGAD3D12", dependencies: ["SAGATypes", "SAGABackend", "SAGAShaderIR", "SAGACompiler"]), .testTarget(name: "SAGAFoundationTests", dependencies: ["SAGA", "SAGATypes", "SAGACore", "SAGABackend", "SAGAShaderIR", "SAGAValidation", "SAGAFrontends", "SAGABackends", "SAGACompiler", "SAGAMetal", "SAGAWebGPU", "SAGAVulkan", "SAGAD3D12"]), diff --git a/Sources/SAGAWebDiagnostic/Diagnostic.swift b/Sources/SAGAWebDiagnostic/Diagnostic.swift index 1385688..00d8afb 100644 --- a/Sources/SAGAWebDiagnostic/Diagnostic.swift +++ b/Sources/SAGAWebDiagnostic/Diagnostic.swift @@ -13,6 +13,7 @@ import SAGACore import SAGAShaderIR import SAGACompiler import SAGAWebGPU +import SAGAWebHost func runWebDiagnostic( capabilities: DeviceCapabilities, supportsPresentation: Bool, videoFrames: (UInt64, UInt64, UInt64) @@ -51,7 +52,9 @@ func runWebDiagnostic( output[i] = original + base + progress + 1u; } """ - let host = WebGPUHost(capabilities: capabilities, supportsPresentation: supportsPresentation) + let host = makeWebGPUHost( + capabilities: capabilities, supportsPresentation: supportsPresentation, transport: BrowserRPC.shared + ) let device = try await Device.make(backend: BrowserComputeBackend(host: host)) do { try await validateNativeEarlyDepthFeature(device) diff --git a/Sources/SAGAWebDiagnostic/WebReactor.swift b/Sources/SAGAWebDiagnostic/WebReactor.swift index 6d6f643..7f31ea0 100644 --- a/Sources/SAGAWebDiagnostic/WebReactor.swift +++ b/Sources/SAGAWebDiagnostic/WebReactor.swift @@ -1,6 +1,7 @@ #if os(WASI) && compiler(>=6.3) @_spi(ExperimentalCustomExecutors) import _Concurrency import SAGATypes +import SAGAWebHost @_extern(wasm, module: "saga_host", name: "schedule") private func schedule(_ pointer: UnsafeMutableRawPointer) @@ -37,7 +38,7 @@ private enum ReactorFactory: ExecutorFactory { static let defaultExecutor: any TaskExecutor = ReactorExecutor(main: false) } -actor BrowserRPC { +actor BrowserRPC: BrowserTransport { static let shared = BrowserRPC() static let packetLimit = 16 * 1024 * 1024 private var nextID: UInt32 = 0 @@ -112,57 +113,55 @@ func start(_ maxBufferSize: UInt64, _ maxTextureDimension2D: UInt32, _ presentat let status: UInt32 let message: String do { - var features: Set = readableStorage == 0 ? [] : [.storageTextureRead, .storageTextureReadWrite] - if shaderF16 != 0 { features.insert(.shaderF16) } - if uniformBufferStandardLayout != 0 { features.insert(.uniformBufferStandardLayout) } - if indirectFirstInstance != 0 { features.insert(.indirectFirstInstance) } - if clipDistances != 0 { features.insert(.clipDistances) } - if primitiveIndex != 0 { features.insert(.primitiveIndex) } - if depthClipControl != 0 { features.insert(.depthClipControl) } - if timestampQuery != 0 { features.insert(.timestampQuery) } - if subgroups != 0 { features.insert(.subgroups) } - if subgroupSizeControl != 0 { features.insert(.subgroupSizeControl) } - if subgroupID != 0 { features.insert(.subgroupID) } - if subgroupUniformity != 0 { features.insert(.subgroupUniformity) } - if linearIndexing != 0 { features.insert(.linearIndexing) } - if vertexFormatSnorm1010102 != 0 { features.insert(.vertexFormatSnorm1010102) } - if dualSourceBlending != 0 { features.insert(.dualSourceBlending) } - if textureCompressionBC != 0 { features.insert(.textureCompressionBC) } - if textureCompressionETC2 != 0 { features.insert(.textureCompressionETC2) } - if textureCompressionASTC != 0 { features.insert(.textureCompressionASTC) } - if textureCompressionBCSliced3D != 0 { features.insert(.textureCompressionBCSliced3D) } - if textureCompressionASTCSliced3D != 0 { features.insert(.textureCompressionASTCSliced3D) } - if float32Filterable != 0 { features.insert(.float32Filterable) } - if float32Blendable != 0 { features.insert(.float32Blendable) } - if textureFormatsTier1 != 0 { - features.formUnion([.halfFloatStorageTextures, .narrowInteger16StorageTextures, .normalized16Textures, - .packedColorStorageTextures, .narrow8BitStorageTextures, .snorm8Renderable]) - } - if rg11b10UfloatRenderable != 0 { features.insert(.rg11b10UfloatRenderable) } - if depth32FloatStencil8 != 0 { features.insert(.depth32FloatStencil8) } - if bgra8UnormStorage != 0 { features.insert(.bgra8UnormStorage) } - if textureFormatsTier2 != 0 { features.formUnion([.r16FloatStorageReadWrite, .integer16StorageReadWrite, .storage8BitReadWrite, .wideRGBAStorageReadWrite]) } - let float32Formats: [TextureFormat] = [.r32Float, .rg32Float, .rgba32Float] - let multisampledFloat32Formats = Set(float32Formats.enumerated().compactMap { index, format in - multisampledFloat32Mask & (1 << index) == 0 ? nil : format - }) - let capabilities = try DeviceCapabilities(maxBufferSize: maxBufferSize, features: features, - maxTextureDimension2D: maxTextureDimension2D, - multisampledFloat32Formats: multisampledFloat32Formats, - maxImmediateSize: maxImmediateSize, - subgroupSizeRange: subgroupMinSize > 0 && subgroupMaxSize >= subgroupMinSize - ? subgroupMinSize...subgroupMaxSize : nil, - maxComputeWorkgroupStorageSize: maxComputeWorkgroupStorageSize, - maxComputeInvocationsPerWorkgroup: maxComputeInvocationsPerWorkgroup, - maxComputeWorkgroupSize: .init(x: maxComputeWorkgroupSizeX, - y: maxComputeWorkgroupSizeY, z: maxComputeWorkgroupSizeZ), - maxComputeWorkgroupsPerDimension: maxComputeWorkgroupsPerDimension, - maxVertexAttributes: maxVertexAttributes, maxVertexBuffers: maxVertexBuffers, - maxBindGroupsPlusVertexBuffers: maxBindGroupsPlusVertexBuffers, - maxVertexBufferArrayStride: maxVertexBufferArrayStride, - maxInterStageShaderVariables: maxInterStageShaderVariables, - maxColorAttachments: maxColorAttachments, - maxColorAttachmentBytesPerSample: maxColorAttachmentBytesPerSample) + var parameters = WebHostParameters() + parameters.maxBufferSize = maxBufferSize + parameters.maxTextureDimension2D = maxTextureDimension2D + parameters.presentation = presentation + parameters.readableStorage = readableStorage + parameters.shaderF16 = shaderF16 + parameters.uniformBufferStandardLayout = uniformBufferStandardLayout + parameters.indirectFirstInstance = indirectFirstInstance + parameters.clipDistances = clipDistances + parameters.primitiveIndex = primitiveIndex + parameters.depthClipControl = depthClipControl + parameters.timestampQuery = timestampQuery + parameters.subgroups = subgroups + parameters.subgroupID = subgroupID + parameters.dualSourceBlending = dualSourceBlending + parameters.textureCompressionBC = textureCompressionBC + parameters.textureCompressionETC2 = textureCompressionETC2 + parameters.textureCompressionASTC = textureCompressionASTC + parameters.textureCompressionBCSliced3D = textureCompressionBCSliced3D + parameters.textureCompressionASTCSliced3D = textureCompressionASTCSliced3D + parameters.float32Filterable = float32Filterable + parameters.float32Blendable = float32Blendable + parameters.multisampledFloat32Mask = multisampledFloat32Mask + parameters.textureFormatsTier1 = textureFormatsTier1 + parameters.textureFormatsTier2 = textureFormatsTier2 + parameters.rg11b10UfloatRenderable = rg11b10UfloatRenderable + parameters.depth32FloatStencil8 = depth32FloatStencil8 + parameters.bgra8UnormStorage = bgra8UnormStorage + parameters.maxImmediateSize = maxImmediateSize + parameters.linearIndexing = linearIndexing + parameters.subgroupSizeControl = subgroupSizeControl + parameters.subgroupMinSize = subgroupMinSize + parameters.subgroupMaxSize = subgroupMaxSize + parameters.subgroupUniformity = subgroupUniformity + parameters.maxComputeWorkgroupStorageSize = maxComputeWorkgroupStorageSize + parameters.maxComputeInvocationsPerWorkgroup = maxComputeInvocationsPerWorkgroup + parameters.maxComputeWorkgroupSizeX = maxComputeWorkgroupSizeX + parameters.maxComputeWorkgroupSizeY = maxComputeWorkgroupSizeY + parameters.maxComputeWorkgroupSizeZ = maxComputeWorkgroupSizeZ + parameters.maxComputeWorkgroupsPerDimension = maxComputeWorkgroupsPerDimension + parameters.maxVertexAttributes = maxVertexAttributes + parameters.maxVertexBuffers = maxVertexBuffers + parameters.maxVertexBufferArrayStride = maxVertexBufferArrayStride + parameters.vertexFormatSnorm1010102 = vertexFormatSnorm1010102 + parameters.maxInterStageShaderVariables = maxInterStageShaderVariables + parameters.maxColorAttachments = maxColorAttachments + parameters.maxColorAttachmentBytesPerSample = maxColorAttachmentBytesPerSample + parameters.maxBindGroupsPlusVertexBuffers = maxBindGroupsPlusVertexBuffers + let capabilities = try makeWebDeviceCapabilities(parameters) message = try await runWebDiagnostic(capabilities: capabilities, supportsPresentation: presentation != 0, videoFrames: (firstVideoFrame, secondVideoFrame, closedVideoFrame)) status = 0 diff --git a/Sources/SAGAWebDiagnostic/WebGPUHost.swift b/Sources/SAGAWebHost/WebGPUHost.swift similarity index 92% rename from Sources/SAGAWebDiagnostic/WebGPUHost.swift rename to Sources/SAGAWebHost/WebGPUHost.swift index f0fa673..6f09311 100644 --- a/Sources/SAGAWebDiagnostic/WebGPUHost.swift +++ b/Sources/SAGAWebHost/WebGPUHost.swift @@ -1,9 +1,22 @@ -#if os(WASI) && compiler(>=6.3) import SAGATypes import SAGABackend import SAGAShaderIR import SAGAWebGPU +/// A transport sends a single SAGA command packet to a browser GPU host. +public protocol BrowserTransport: Sendable { + func send(_ bytes: [UInt8]) async throws -> [UInt8] +} + +/// Creates a host that sends SAGA commands through a caller-owned transport. +public func makeWebGPUHost( + capabilities: DeviceCapabilities, supportsPresentation: Bool, transport: some BrowserTransport +) -> some BrowserGPUPresentationHost { + WebGPUHost(capabilities: capabilities, supportsPresentation: supportsPresentation, transport: transport) +} + +private let browserPacketLimit = 16 * 1024 * 1024 + private struct Packet { var bytes: [UInt8] = [] init(_ opcode: UInt32) { add(opcode) } @@ -106,49 +119,55 @@ private struct Packet { if let size = binding.size { add(size) } } } - func send() async throws -> [UInt8] { try await BrowserRPC.shared.send(bytes) } + func send(using transport: any BrowserTransport) async throws -> [UInt8] { + guard bytes.count <= browserPacketLimit else { + throw GPUError.validation("Browser bridge packet exceeds its limit") + } + return try await transport.send(bytes) + } } -struct WebGPUHost: BrowserGPUPresentationHost, SamplerBackendDevice, TextureViewBackendDevice, QuerySetBackendDevice, +private struct WebGPUHost: BrowserGPUPresentationHost, SamplerBackendDevice, TextureViewBackendDevice, QuerySetBackendDevice, BufferMappingBackendDevice, ExternalTextureBackendDevice { let capabilities: DeviceCapabilities let supportsPresentation: Bool + let transport: any BrowserTransport func allocateBuffer(id: ResourceID, descriptor: BufferDescriptor) async throws { var packet = Packet(1) packet.add(id); packet.add(descriptor.size); packet.add(descriptor.usage.rawValue) packet.add(UInt32(descriptor.mappedAtCreation ? 1 : 0)) - _ = try await packet.send() + _ = try await packet.send(using: transport) } func destroyBuffer(id: ResourceID) async { var packet = Packet(2) packet.add(id) - _ = try? await packet.send() + _ = try? await packet.send(using: transport) } func allocateExternalTexture(id: ResourceID, descriptor: ExternalTextureDescriptor) async throws { var packet = Packet(26) packet.add(id); packet.add(descriptor.videoFrame); packet.add(descriptor.colorSpace.rawValue) - _ = try await packet.send() + _ = try await packet.send(using: transport) } func destroyExternalTexture(id: ResourceID) async { var packet = Packet(27) packet.add(id) - _ = try? await packet.send() + _ = try? await packet.send(using: transport) } func beginBufferMapping(id: ResourceID, mode: BufferMapMode, offset: UInt64, size: UInt64) async throws { var packet = Packet(19) packet.add(id); packet.add(mode.rawValue); packet.add(offset); packet.add(size) - _ = try await packet.send() + _ = try await packet.send(using: transport) } func readMappedBytes(id: ResourceID, offset: UInt64, size: UInt64) async throws -> [UInt8] { guard let count = Int(exactly: size) else { throw GPUError.validation("Mapped readback is too large") } var result: [UInt8] = [] result.reserveCapacity(count) while result.count < count { - let length = min(count - result.count, BrowserRPC.packetLimit - 32) + let length = min(count - result.count, browserPacketLimit - 32) var packet = Packet(20) packet.add(id); packet.add(offset + UInt64(result.count)); packet.add(UInt32(length)) - let reply = try await packet.send() + let reply = try await packet.send(using: transport) guard reply.count == length else { throw GPUError.backendFailure("Truncated browser mapped readback") } result.append(contentsOf: reply) } @@ -157,29 +176,29 @@ struct WebGPUHost: BrowserGPUPresentationHost, SamplerBackendDevice, TextureView func writeMappedBytes(id: ResourceID, offset: UInt64, bytes: [UInt8]) async throws { var start = 0 while start < bytes.count { - let length = min(bytes.count - start, BrowserRPC.packetLimit - 32) + let length = min(bytes.count - start, browserPacketLimit - 32) var packet = Packet(21) packet.add(id); packet.add(offset + UInt64(start)); packet.add(UInt32(length)) packet.bytes.append(contentsOf: bytes[start..<(start + length)]) - _ = try await packet.send() + _ = try await packet.send(using: transport) start += length } } func endBufferMapping(id: ResourceID) async throws { var packet = Packet(22) packet.add(id) - _ = try await packet.send() + _ = try await packet.send(using: transport) } func allocateQuerySet(id: ResourceID, descriptor: QuerySetDescriptor) async throws { try descriptor.validate(against: capabilities) var packet = Packet(17) packet.add(id); packet.add(descriptor.type.rawValue); packet.add(descriptor.count); packet.add(descriptor.label) - _ = try await packet.send() + _ = try await packet.send(using: transport) } func destroyQuerySet(id: ResourceID) async { var packet = Packet(18) packet.add(id) - _ = try? await packet.send() + _ = try? await packet.send(using: transport) } func allocateComputePipeline(_ pipeline: ComputePipeline, shader: ComputeShaderArtifact) async throws { guard case .text(.wgsl, let source) = shader.artifact else { throw GPUError.validation("Expected WGSL") } @@ -215,12 +234,12 @@ struct WebGPUHost: BrowserGPUPresentationHost, SamplerBackendDevice, TextureView packet.add(slot, remapped: pipeline.webBindingIndices) packet.add(UInt64(0)); packet.add(UInt32(6)) } - _ = try await packet.send() + _ = try await packet.send(using: transport) } func destroyComputePipeline(id: ResourceID) async { var packet = Packet(4) packet.add(id) - _ = try? await packet.send() + _ = try? await packet.send(using: transport) } func allocateSampler(id: ResourceID, descriptor: SamplerDescriptor) async throws { var packet = Packet(13) @@ -232,12 +251,12 @@ struct WebGPUHost: BrowserGPUPresentationHost, SamplerBackendDevice, TextureView packet.add(Double(descriptor.lodMinClamp).bitPattern); packet.add(Double(descriptor.lodMaxClamp).bitPattern) packet.add(descriptor.compare.map { $0.rawValue + 1 } ?? 0) packet.add(UInt32(descriptor.maxAnisotropy)) - _ = try await packet.send() + _ = try await packet.send(using: transport) } func destroySampler(id: ResourceID) async { var packet = Packet(14) packet.add(id) - _ = try? await packet.send() + _ = try? await packet.send(using: transport) } func allocateTexture(id: ResourceID, descriptor: TextureDescriptor) async throws { var packet = Packet(8) @@ -246,12 +265,12 @@ struct WebGPUHost: BrowserGPUPresentationHost, SamplerBackendDevice, TextureView packet.add(descriptor.sampleCount) packet.add(descriptor.dimension == .d3 ? descriptor.depth : descriptor.arrayLayerCount) packet.add(descriptor.dimension.rawValue) - _ = try await packet.send() + _ = try await packet.send(using: transport) } func destroyTexture(id: ResourceID) async { var packet = Packet(9) packet.add(id) - _ = try? await packet.send() + _ = try? await packet.send(using: transport) } func allocateTextureView(id: ResourceID, texture: ResourceID, mipLevels: Range) async throws { try await allocateTextureView(id: id, texture: texture, mipLevels: mipLevels, aspect: .all) @@ -271,12 +290,12 @@ struct WebGPUHost: BrowserGPUPresentationHost, SamplerBackendDevice, TextureView packet.add(descriptor.baseArrayLayer) packet.add(UInt32(descriptor.arrayLayerCount == nil ? 0 : 1)) if let count = descriptor.arrayLayerCount { packet.add(count) } - _ = try await packet.send() + _ = try await packet.send(using: transport) } func destroyTextureView(id: ResourceID) async { var packet = Packet(16) packet.add(id) - _ = try? await packet.send() + _ = try? await packet.send(using: transport) } func allocateRenderPipeline( _ pipeline: RenderPipeline, vertex: RenderStageArtifact, fragment: RenderStageArtifact @@ -376,17 +395,17 @@ struct WebGPUHost: BrowserGPUPresentationHost, SamplerBackendDevice, TextureView } } } - _ = try await packet.send() + _ = try await packet.send(using: transport) } func destroyRenderPipeline(id: ResourceID) async { var packet = Packet(11) packet.add(id) - _ = try? await packet.send() + _ = try? await packet.send(using: transport) } func close(owner: UInt64) async { var packet = Packet(5) packet.add(owner) - _ = try? await packet.send() + _ = try? await packet.send(using: transport) } func present(_ submission: Submission, source: ResourceID) async throws { guard supportsPresentation, submission.resources.contains(source) else { @@ -394,10 +413,10 @@ struct WebGPUHost: BrowserGPUPresentationHost, SamplerBackendDevice, TextureView } var packet = Packet(12) packet.add(source) - _ = try await packet.send() + _ = try await packet.send(using: transport) } func submit(_ submission: Submission) async throws { - _ = try await Packet(7).send() + _ = try await Packet(7).send(using: transport) } func submitCommands(_ submission: Submission, commands: [BufferCommand]) async throws -> [[UInt32]] { var packet = Packet(6) @@ -550,12 +569,12 @@ struct WebGPUHost: BrowserGPUPresentationHost, SamplerBackendDevice, TextureView } private func sendSubmission(_ packet: Packet, submission: Submission) async throws -> [UInt8] { - if packet.bytes.count <= BrowserRPC.packetLimit { return try await packet.send() } + if packet.bytes.count <= browserPacketLimit { return try await packet.send(using: transport) } guard let owner = submission.resources.first?.device else { throw GPUError.validation("A staged browser submission requires a resource owner") } let key = ResourceID(device: owner, serial: submission.sequence) - let chunkCapacity = BrowserRPC.packetLimit - 40 + let chunkCapacity = browserPacketLimit - 40 do { var start = 0 while start < packet.bytes.count { @@ -563,16 +582,16 @@ struct WebGPUHost: BrowserGPUPresentationHost, SamplerBackendDevice, TextureView var chunk = Packet(23) chunk.add(key); chunk.add(UInt64(packet.bytes.count)); chunk.add(UInt64(start)); chunk.add(UInt32(length)) chunk.bytes.append(contentsOf: packet.bytes[start..<(start + length)]) - _ = try await chunk.send() + _ = try await chunk.send(using: transport) start += length } var execute = Packet(24) execute.add(key) - return try await execute.send() + return try await execute.send(using: transport) } catch { var discard = Packet(25) discard.add(key) - _ = try? await discard.send() + _ = try? await discard.send(using: transport) throw error } } @@ -584,4 +603,3 @@ private extension SamplerRequirement { return requiresNonFiltering ? 2 : 0 } } -#endif diff --git a/Sources/SAGAWebHost/WebHostParameters.swift b/Sources/SAGAWebHost/WebHostParameters.swift new file mode 100644 index 0000000..f1e0ecd --- /dev/null +++ b/Sources/SAGAWebHost/WebHostParameters.swift @@ -0,0 +1,155 @@ +import SAGATypes + +/// Feature flags and limits reported by the JavaScript WebGPU host. +public struct WebHostParameters: Sendable { + public var maxBufferSize: UInt64 = 0 + public var maxTextureDimension2D: UInt32 = 0 + public var presentation: UInt32 = 0 + public var readableStorage: UInt32 = 0 + public var shaderF16: UInt32 = 0 + public var uniformBufferStandardLayout: UInt32 = 0 + public var indirectFirstInstance: UInt32 = 0 + public var clipDistances: UInt32 = 0 + public var primitiveIndex: UInt32 = 0 + public var depthClipControl: UInt32 = 0 + public var timestampQuery: UInt32 = 0 + public var subgroups: UInt32 = 0 + public var subgroupID: UInt32 = 0 + public var dualSourceBlending: UInt32 = 0 + public var textureCompressionBC: UInt32 = 0 + public var textureCompressionETC2: UInt32 = 0 + public var textureCompressionASTC: UInt32 = 0 + public var textureCompressionBCSliced3D: UInt32 = 0 + public var textureCompressionASTCSliced3D: UInt32 = 0 + public var float32Filterable: UInt32 = 0 + public var float32Blendable: UInt32 = 0 + public var multisampledFloat32Mask: UInt32 = 0 + public var textureFormatsTier1: UInt32 = 0 + public var textureFormatsTier2: UInt32 = 0 + public var rg11b10UfloatRenderable: UInt32 = 0 + public var depth32FloatStencil8: UInt32 = 0 + public var bgra8UnormStorage: UInt32 = 0 + public var maxImmediateSize: UInt32 = 0 + public var linearIndexing: UInt32 = 0 + public var subgroupSizeControl: UInt32 = 0 + public var subgroupMinSize: UInt32 = 0 + public var subgroupMaxSize: UInt32 = 0 + public var subgroupUniformity: UInt32 = 0 + public var maxComputeWorkgroupStorageSize: UInt32 = 0 + public var maxComputeInvocationsPerWorkgroup: UInt32 = 0 + public var maxComputeWorkgroupSizeX: UInt32 = 0 + public var maxComputeWorkgroupSizeY: UInt32 = 0 + public var maxComputeWorkgroupSizeZ: UInt32 = 0 + public var maxComputeWorkgroupsPerDimension: UInt32 = 0 + public var maxVertexAttributes: UInt32 = 0 + public var maxVertexBuffers: UInt32 = 0 + public var maxVertexBufferArrayStride: UInt32 = 0 + public var vertexFormatSnorm1010102: UInt32 = 0 + public var maxInterStageShaderVariables: UInt32 = 0 + public var maxColorAttachments: UInt32 = 0 + public var maxColorAttachmentBytesPerSample: UInt32 = 0 + public var maxBindGroupsPlusVertexBuffers: UInt32 = 0 + + public init() {} +} + +/// Validates the limits and maps the host feature flags to SAGA capabilities. +public func makeWebDeviceCapabilities(_ parameters: WebHostParameters) throws -> DeviceCapabilities { + let maxBufferSize = parameters.maxBufferSize + let maxTextureDimension2D = parameters.maxTextureDimension2D + let readableStorage = parameters.readableStorage + let shaderF16 = parameters.shaderF16 + let uniformBufferStandardLayout = parameters.uniformBufferStandardLayout + let indirectFirstInstance = parameters.indirectFirstInstance + let clipDistances = parameters.clipDistances + let primitiveIndex = parameters.primitiveIndex + let depthClipControl = parameters.depthClipControl + let timestampQuery = parameters.timestampQuery + let subgroups = parameters.subgroups + let subgroupID = parameters.subgroupID + let dualSourceBlending = parameters.dualSourceBlending + let textureCompressionBC = parameters.textureCompressionBC + let textureCompressionETC2 = parameters.textureCompressionETC2 + let textureCompressionASTC = parameters.textureCompressionASTC + let textureCompressionBCSliced3D = parameters.textureCompressionBCSliced3D + let textureCompressionASTCSliced3D = parameters.textureCompressionASTCSliced3D + let float32Filterable = parameters.float32Filterable + let float32Blendable = parameters.float32Blendable + let multisampledFloat32Mask = parameters.multisampledFloat32Mask + let textureFormatsTier1 = parameters.textureFormatsTier1 + let textureFormatsTier2 = parameters.textureFormatsTier2 + let rg11b10UfloatRenderable = parameters.rg11b10UfloatRenderable + let depth32FloatStencil8 = parameters.depth32FloatStencil8 + let bgra8UnormStorage = parameters.bgra8UnormStorage + let maxImmediateSize = parameters.maxImmediateSize + let linearIndexing = parameters.linearIndexing + let subgroupSizeControl = parameters.subgroupSizeControl + let subgroupMinSize = parameters.subgroupMinSize + let subgroupMaxSize = parameters.subgroupMaxSize + let subgroupUniformity = parameters.subgroupUniformity + let maxComputeWorkgroupStorageSize = parameters.maxComputeWorkgroupStorageSize + let maxComputeInvocationsPerWorkgroup = parameters.maxComputeInvocationsPerWorkgroup + let maxComputeWorkgroupSizeX = parameters.maxComputeWorkgroupSizeX + let maxComputeWorkgroupSizeY = parameters.maxComputeWorkgroupSizeY + let maxComputeWorkgroupSizeZ = parameters.maxComputeWorkgroupSizeZ + let maxComputeWorkgroupsPerDimension = parameters.maxComputeWorkgroupsPerDimension + let maxVertexAttributes = parameters.maxVertexAttributes + let maxVertexBuffers = parameters.maxVertexBuffers + let maxVertexBufferArrayStride = parameters.maxVertexBufferArrayStride + let vertexFormatSnorm1010102 = parameters.vertexFormatSnorm1010102 + let maxInterStageShaderVariables = parameters.maxInterStageShaderVariables + let maxColorAttachments = parameters.maxColorAttachments + let maxColorAttachmentBytesPerSample = parameters.maxColorAttachmentBytesPerSample + let maxBindGroupsPlusVertexBuffers = parameters.maxBindGroupsPlusVertexBuffers + var features: Set = readableStorage == 0 ? [] : [.storageTextureRead, .storageTextureReadWrite] + if shaderF16 != 0 { features.insert(.shaderF16) } + if uniformBufferStandardLayout != 0 { features.insert(.uniformBufferStandardLayout) } + if indirectFirstInstance != 0 { features.insert(.indirectFirstInstance) } + if clipDistances != 0 { features.insert(.clipDistances) } + if primitiveIndex != 0 { features.insert(.primitiveIndex) } + if depthClipControl != 0 { features.insert(.depthClipControl) } + if timestampQuery != 0 { features.insert(.timestampQuery) } + if subgroups != 0 { features.insert(.subgroups) } + if subgroupSizeControl != 0 { features.insert(.subgroupSizeControl) } + if subgroupID != 0 { features.insert(.subgroupID) } + if subgroupUniformity != 0 { features.insert(.subgroupUniformity) } + if linearIndexing != 0 { features.insert(.linearIndexing) } + if vertexFormatSnorm1010102 != 0 { features.insert(.vertexFormatSnorm1010102) } + if dualSourceBlending != 0 { features.insert(.dualSourceBlending) } + if textureCompressionBC != 0 { features.insert(.textureCompressionBC) } + if textureCompressionETC2 != 0 { features.insert(.textureCompressionETC2) } + if textureCompressionASTC != 0 { features.insert(.textureCompressionASTC) } + if textureCompressionBCSliced3D != 0 { features.insert(.textureCompressionBCSliced3D) } + if textureCompressionASTCSliced3D != 0 { features.insert(.textureCompressionASTCSliced3D) } + if float32Filterable != 0 { features.insert(.float32Filterable) } + if float32Blendable != 0 { features.insert(.float32Blendable) } + if textureFormatsTier1 != 0 { + features.formUnion([.halfFloatStorageTextures, .narrowInteger16StorageTextures, .normalized16Textures, + .packedColorStorageTextures, .narrow8BitStorageTextures, .snorm8Renderable]) + } + if rg11b10UfloatRenderable != 0 { features.insert(.rg11b10UfloatRenderable) } + if depth32FloatStencil8 != 0 { features.insert(.depth32FloatStencil8) } + if bgra8UnormStorage != 0 { features.insert(.bgra8UnormStorage) } + if textureFormatsTier2 != 0 { features.formUnion([.r16FloatStorageReadWrite, .integer16StorageReadWrite, .storage8BitReadWrite, .wideRGBAStorageReadWrite]) } + let float32Formats: [TextureFormat] = [.r32Float, .rg32Float, .rgba32Float] + let multisampledFloat32Formats = Set(float32Formats.enumerated().compactMap { index, format in + multisampledFloat32Mask & (1 << index) == 0 ? nil : format + }) + return try DeviceCapabilities(maxBufferSize: maxBufferSize, features: features, + maxTextureDimension2D: maxTextureDimension2D, + multisampledFloat32Formats: multisampledFloat32Formats, + maxImmediateSize: maxImmediateSize, + subgroupSizeRange: subgroupMinSize > 0 && subgroupMaxSize >= subgroupMinSize + ? subgroupMinSize...subgroupMaxSize : nil, + maxComputeWorkgroupStorageSize: maxComputeWorkgroupStorageSize, + maxComputeInvocationsPerWorkgroup: maxComputeInvocationsPerWorkgroup, + maxComputeWorkgroupSize: .init(x: maxComputeWorkgroupSizeX, + y: maxComputeWorkgroupSizeY, z: maxComputeWorkgroupSizeZ), + maxComputeWorkgroupsPerDimension: maxComputeWorkgroupsPerDimension, + maxVertexAttributes: maxVertexAttributes, maxVertexBuffers: maxVertexBuffers, + maxBindGroupsPlusVertexBuffers: maxBindGroupsPlusVertexBuffers, + maxVertexBufferArrayStride: maxVertexBufferArrayStride, + maxInterStageShaderVariables: maxInterStageShaderVariables, + maxColorAttachments: maxColorAttachments, + maxColorAttachmentBytesPerSample: maxColorAttachmentBytesPerSample) +} diff --git a/scripts/test-web-host-presentation.mjs b/scripts/test-web-host-presentation.mjs new file mode 100644 index 0000000..ab3612f --- /dev/null +++ b/scripts/test-web-host-presentation.mjs @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { basename } from "node:path"; +const require = createRequire(import.meta.url); +const { chromium } = require(process.env.SAGA_PLAYWRIGHT ?? "playwright-core"); +const browser = await chromium.launch({ + executablePath: process.env.SAGA_CHROMIUM, + args: ["--enable-unsafe-webgpu", "--use-angle=metal"], +}); +try { + const page = await browser.newPage(); + const root = "http://localhost:8799/"; + const modules = new Map(await Promise.all(["webgpu.mjs", "golden.mjs"].map(async file => + [file, await readFile(new URL(`../web/${file}`, import.meta.url))]))); + await page.route(root + "**", route => { + const module = modules.get(basename(new URL(route.request().url()).pathname)); + return module ? route.fulfill({ contentType: "text/javascript", body: module }) + : route.fulfill({ contentType: "text/html", body: "" }); + }); + await page.goto(root); + const result = await page.evaluate(async () => { + const { openWebGPU } = await import("./webgpu.mjs"); + const host = await openWebGPU(null, { deferCanvas: true }); + try { + host.attachCanvas(document.querySelector("canvas")); + function packet(operation, ...words) { + const out = new Uint8Array(20 + 4 * words.length); + const view = new DataView(out.buffer); + view.setUint32(0, operation, true); + view.setBigUint64(4, 1n, true); + view.setBigUint64(12, 2n, true); + words.forEach((word, i) => view.setUint32(20 + 4 * i, word, true)); + return out; + } + await host.handle(packet(8, 2, 2, 2, GPUTextureUsage.COPY_SRC | GPUTextureUsage.COPY_DST, 1, 1, 1, 0)); + // Submit one writeTexture command. The texture and wire data are BGRA. + const writeHeader = [6, 1, 17, 1, 0, 2, 0, 0, 0, 0, 0, 0, 2, 2, 1, 1, 8, 0, 16]; + const upload = new Uint8Array(writeHeader.length * 4 + 16); + const uploadView = new DataView(upload.buffer); + writeHeader.forEach((word, i) => uploadView.setUint32(i * 4, word, true)); + for (let i = writeHeader.length * 4; i < upload.length; i += 4) upload.set([7, 13, 211, 255], i); + await host.handle(upload); + const capture = host.captureNextFrame({ signal: AbortSignal.timeout(5000) }); + await host.handle(packet(12)); + const frame = await capture; + const { compareFrames, frameFromPng, frameToPng } = await import("./golden.mjs"); + const roundtrip = await frameFromPng(await frameToPng(frame)); + const dark = { ...roundtrip, pixels: roundtrip.pixels.map((byte, i) => i % 4 === 3 ? byte : byte / 2) }; + return { width: frame.width, height: frame.height, pixels: [...frame.pixels], + match: compareFrames(frame, roundtrip).ok, rejectsDarkened: !compareFrames(frame, dark).ok, ...host.metrics() }; + } finally { host.close(); } + }); + assert.equal(result.presentedFrames, 1); + assert.deepEqual(result.surfaceSize, [2, 2]); + assert.equal(result.width, 2); + assert.equal(result.height, 2); + assert.deepEqual(result.pixels, Array(4).fill([211, 13, 7, 255]).flat()); + assert.equal(result.match, true); + assert.equal(result.rejectsDarkened, true); + console.log("Deferred canvas presented one frame; WebGPU readback returned all 16 expected bytes."); +} finally { await browser.close(); } diff --git a/web/golden.mjs b/web/golden.mjs new file mode 100644 index 0000000..030c13e --- /dev/null +++ b/web/golden.mjs @@ -0,0 +1,74 @@ +// Compare presentation readbacks without reading the WebGPU canvas itself. + +function canvasFor(frame) { + const canvas = new OffscreenCanvas(frame.width, frame.height); + canvas.getContext("2d").putImageData(new ImageData(frame.pixels, frame.width, frame.height), 0, 0); + return canvas; +} + +function resize(frame, width, height) { + if (frame.width === width && frame.height === height) return frame; + const canvas = new OffscreenCanvas(width, height); + const context = canvas.getContext("2d", { willReadFrequently: true }); + context.drawImage(canvasFor(frame), 0, 0, width, height); + return { width, height, pixels: context.getImageData(0, 0, width, height).data }; +} + +export function compareFrames(actual, expected, { channelTolerance = 12, maxBadFraction = 0.02, maxMeanError = 4 } = {}) { + if (actual.width !== expected.width || actual.height !== expected.height) throw new Error("Golden frame size differs"); + const count = actual.width * actual.height; + if (!count || actual.pixels.length !== count * 4 || expected.pixels.length !== count * 4) { + throw new Error("Golden frame has invalid pixel data"); + } + let sum = 0; + let bad = 0; + for (let i = 0; i < count * 4; i += 4) { + let worst = 0; + for (let channel = 0; channel < 4; channel++) { + const delta = Math.abs(actual.pixels[i + channel] - expected.pixels[i + channel]); + sum += delta; + worst = Math.max(worst, delta); + } + if (worst > channelTolerance) bad++; + } + const meanError = sum / (count * 4); + const badFraction = bad / count; + return { ok: meanError <= maxMeanError && badFraction <= maxBadFraction, meanError, badFraction }; +} + +export async function captureStable(host, { width = 160, height = 96 } = {}) { + let previous; + for (let attempt = 0; attempt < 8; attempt++) { + const frame = resize(await host.captureNextFrame({ signal: AbortSignal.timeout(10_000) }), width, height); + if (previous && compareFrames(frame, previous, { channelTolerance: 2, maxBadFraction: 0, maxMeanError: 0.5 }).ok) { + return frame; + } + previous = frame; + } + throw new Error("Browser frames did not settle"); +} + +export function frameToPng(frame) { + return canvasFor(frame).convertToBlob({ type: "image/png" }); +} + +export async function frameFromPng(blob) { + const bitmap = await createImageBitmap(blob); + try { + const { width, height } = bitmap; + const canvas = new OffscreenCanvas(width, height); + const context = canvas.getContext("2d", { willReadFrequently: true }); + context.drawImage(bitmap, 0, 0); + return { width, height, pixels: context.getImageData(0, 0, width, height).data }; + } finally { + bitmap.close(); + } +} + +export async function checkGolden(host, url) { + const response = await fetch(url); + if (!response.ok) throw new Error(`Failed to load golden frame (${response.status})`); + const expected = await frameFromPng(await response.blob()); + const actual = await captureStable(host, { width: expected.width, height: expected.height }); + return { ...compareFrames(actual, expected), actual }; +} diff --git a/web/webgpu-host.test.mjs b/web/webgpu-host.test.mjs new file mode 100644 index 0000000..a87ac1a --- /dev/null +++ b/web/webgpu-host.test.mjs @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { openWebGPU } from "./webgpu.mjs"; + +test("deferred presentation can attach the engine canvas after opening the host", async () => { + globalThis.GPUTextureUsage = { RENDER_ATTACHMENT: 16, TEXTURE_BINDING: 4 }; + const device = { + createTexture() { return { destroy() {} }; }, + features: new Set(), limits: {}, lost: new Promise(() => {}), + pushErrorScope() {}, async popErrorScope() { return null; }, + createShaderModule() {}, async createRenderPipelineAsync() {}, destroy() {}, + }; + Object.defineProperty(globalThis, "navigator", { configurable: true, value: { + gpu: { requestAdapter: async () => ({ + info: { isFallbackAdapter: false }, features: new Set(), limits: {}, + requestDevice: async () => device, + }) }, + } }); + const headless = await openWebGPU(null); + const deferred = await openWebGPU(null, { deferCanvas: true }); + let calls = 0; + const context = {}; + const canvas = { getContext: name => { assert.equal(name, "webgpu"); calls++; return context; } }; + try { + assert.equal(headless.supportsPresentation, false); + assert.equal(deferred.supportsPresentation, true); + assert.throws(() => deferred.attachCanvas({ getContext: () => null }), /cannot supply/); + deferred.attachCanvas(canvas); + deferred.attachCanvas(canvas); + assert.equal(calls, 1); + assert.equal(deferred.metrics().surfaceSize, null); + } finally { + headless.close(); + deferred.close(); + delete globalThis.navigator; + delete globalThis.GPUTextureUsage; + } +}); diff --git a/web/webgpu.mjs b/web/webgpu.mjs index dde7a43..a0c16d6 100644 --- a/web/webgpu.mjs +++ b/web/webgpu.mjs @@ -241,8 +241,8 @@ async function probeSnorm1010102VertexFormat(device) { } } -export async function openWebGPU(canvas) { - const context = canvas?.getContext("webgpu"); +export async function openWebGPU(canvas, { deferCanvas = false } = {}) { + let context = canvas?.getContext("webgpu"); if (canvas && !context) throw new Error("Canvas cannot supply a WebGPU surface"); const adapter = await navigator.gpu?.requestAdapter(); if (!adapter || adapter.info.isFallbackAdapter) throw new Error("No hardware WebGPU adapter available"); @@ -308,6 +308,7 @@ export async function openWebGPU(canvas) { let timestampMarkerPipeline; let submissions = 0; let presentedFrames = 0; + let nextCapture; let configuredFormat; let ownsConfiguration = false; let closed = false; @@ -1534,28 +1535,92 @@ export async function openWebGPU(canvas) { return new Uint8Array(); } + function attachCanvas(target) { + if (closed) throw new Error("Browser host is closed"); + if (target === canvas && context) return; + if (context) throw new Error("Browser host already has a presentation surface"); + if (!deferCanvas) throw new Error("Browser host did not request deferred presentation"); + const targetContext = target?.getContext("webgpu"); + if (!targetContext) throw new Error("Canvas cannot supply a WebGPU surface"); + canvas = target; + context = targetContext; + } + + function captureNextFrame({ signal } = {}) { + if (closed || !context) return Promise.reject(new Error("No open browser presentation surface")); + if (nextCapture) return Promise.reject(new Error("A browser frame capture is already pending")); + if (signal?.aborted) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + function finish(callback, value) { + signal?.removeEventListener("abort", abort); + callback(value); + } + function abort() { + if (nextCapture === capture) nextCapture = undefined; + finish(reject, signal.reason); + } + const capture = { resolve: frame => finish(resolve, frame), reject: error => finish(reject, error) }; + signal?.addEventListener("abort", abort, { once: true }); + nextCapture = capture; + }); + } + async function present(id, source) { if (!context) throw new Error("Browser host does not have a presentation surface"); const { width, height, format } = source; - await withErrors(async () => { - if (textures.get(id) !== source) { - throw Object.assign(new Error("Browser texture released before presentation"), { status: 1 }); - } - if (canvas.width !== width) canvas.width = width; - if (canvas.height !== height) canvas.height = height; - if (configuredFormat !== format) { - context.configure({ device, format, alphaMode: "opaque", usage: GPUTextureUsage.COPY_DST }); - ownsConfiguration = true; + const capture = nextCapture; + nextCapture = undefined; + let staging; + const stride = Math.ceil(width * 4 / 256) * 256; + const canCapture = /^(rgba|bgra)8unorm(-srgb)?$/.test(format); + try { + await withErrors(async () => { + if (textures.get(id) !== source) { + throw Object.assign(new Error("Browser texture released before presentation"), { status: 1 }); + } + if (canvas.width !== width) canvas.width = width; + if (canvas.height !== height) canvas.height = height; + if (configuredFormat !== format) { + context.configure({ device, format, alphaMode: "opaque", usage: GPUTextureUsage.COPY_DST }); + ownsConfiguration = true; + } + // Acquire and submit in the same turn before the canvas texture can expire. + const destination = context.getCurrentTexture(); + const encoder = device.createCommandEncoder(); + encoder.copyTextureToTexture({ texture: source }, { texture: destination }, [width, height]); + if (capture && canCapture) { + staging = device.createBuffer({ size: stride * height, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ }); + encoder.copyTextureToBuffer({ texture: source }, { buffer: staging, bytesPerRow: stride }, [width, height]); + } + device.queue.submit([encoder.finish()]); + await device.queue.onSubmittedWorkDone(); + }); + configuredFormat = format; + presentedFrames += 1; + } catch (error) { + staging?.destroy(); + capture?.reject(error); + throw error; + } + if (!capture) return; + if (!staging) { + capture.reject(new Error(`Browser frame capture does not support ${format}`)); + return; + } + try { + await withErrors(() => staging.mapAsync(GPUMapMode.READ)); + const padded = new Uint8Array(staging.getMappedRange()); + const pixels = new Uint8ClampedArray(width * height * 4); + for (let row = 0; row < height; row++) pixels.set(padded.subarray(row * stride, row * stride + width * 4), row * width * 4); + if (format.startsWith("bgra")) { + for (let i = 0; i < pixels.length; i += 4) [pixels[i], pixels[i + 2]] = [pixels[i + 2], pixels[i]]; } - // Acquire and submit in the same turn before the canvas texture can expire. - const destination = context.getCurrentTexture(); - const encoder = device.createCommandEncoder(); - encoder.copyTextureToTexture({ texture: source }, { texture: destination }, [width, height]); - device.queue.submit([encoder.finish()]); - await device.queue.onSubmittedWorkDone(); - }); - configuredFormat = format; - presentedFrames += 1; + capture.resolve({ width, height, pixels }); + } catch (error) { + capture.reject(error); + } finally { + staging.destroy(); + } } return { @@ -1580,7 +1645,7 @@ export async function openWebGPU(canvas) { ? device.limits.maxImmediateSize ?? 0 : 0, maxTextureDimension2D: device.limits.maxTextureDimension2D, supportsReadableStorage: navigator.gpu.wgslLanguageFeatures?.has("readonly_and_readwrite_storage_textures") ?? false, - supportsPresentation: context != null, + supportsPresentation: context != null || deferCanvas, supportsShaderF16: device.features.has("shader-f16"), supportsIndirectFirstInstance: indirectFirstInstance, supportsClipDistances: device.features.has("clip-distances"), @@ -1613,6 +1678,8 @@ export async function openWebGPU(canvas) { registerVideoFrame, unregisterVideoFrame: token => videoFrames.delete(token), handle, + attachCanvas, + captureNextFrame, metrics: () => ({ submissions, remainingResources: buffers.size + textures.size + textureViews.size + samplers.size + pipelines.size + renderPipelines.size + querySets.size + mappings.size + stagedSubmissions.size + @@ -1622,6 +1689,8 @@ export async function openWebGPU(canvas) { close() { if (closed) return; closed = true; + nextCapture?.reject(new Error("Browser host is closed")); + nextCapture = undefined; for (const resources of [buffers, mappings, pendingMappings, textures, textureViews, samplers, pipelines, renderPipelines, querySets, stagedSubmissions, externalTextures, videoFrames]) resources.clear(); if (ownsConfiguration) context.unconfigure(); From cea6698a15e2e12360b2bf983d35a29d4bc220d3 Mon Sep 17 00:00:00 2001 From: SpectralDragon Date: Fri, 13 Nov 2026 00:04:19 +0300 Subject: [PATCH 2/5] Route presentation to a requested surface without changing legacy backends --- Sources/SAGABackend/PresentationBackendDevice.swift | 10 ++++++++++ Sources/SAGACore/Device.swift | 7 ++++++- .../SAGAFoundationTests/RenderSubmissionTests.swift | 12 ++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/Sources/SAGABackend/PresentationBackendDevice.swift b/Sources/SAGABackend/PresentationBackendDevice.swift index 1b87349..8f9ce50 100644 --- a/Sources/SAGABackend/PresentationBackendDevice.swift +++ b/Sources/SAGABackend/PresentationBackendDevice.swift @@ -7,4 +7,14 @@ public protocol PresentationBackendDevice: RenderBackendDevice { /// Submit the surface copy in queue order and hold the source through GPU completion. func present(_ submission: Submission, source: ResourceID) async throws + + /// Surface zero is the default surface. Backends with dynamic surfaces assign nonzero IDs. + func present(_ submission: Submission, source: ResourceID, surface: UInt32) async throws +} + +extension PresentationBackendDevice { + public func present(_ submission: Submission, source: ResourceID, surface: UInt32) async throws { + guard surface == 0 else { throw GPUError.unsupported("Backend does not have the requested surface") } + try await present(submission, source: source) + } } diff --git a/Sources/SAGACore/Device.swift b/Sources/SAGACore/Device.swift index 593bba6..deb2cc1 100644 --- a/Sources/SAGACore/Device.swift +++ b/Sources/SAGACore/Device.swift @@ -755,6 +755,11 @@ public actor Device { /// Copy an offscreen color texture to the attached surface in submission order. public func present(_ source: ResourceID) throws -> SubmissionTicket { + try present(source, surface: 0) + } + + /// Copy an offscreen color texture to the selected surface in submission order. + public func present(_ source: ResourceID, surface: UInt32) throws -> SubmissionTicket { let presentation: any PresentationBackendDevice do { try Task.checkCancellation() @@ -775,7 +780,7 @@ public actor Device { let error = normalize(error); record(error); throw error } let (sequence, completion) = try enqueue([source]) { submission in - try await presentation.present(submission, source: source) + try await presentation.present(submission, source: source, surface: surface) } return SubmissionTicket(sequence: sequence, completion: completion) } diff --git a/Tests/SAGAFoundationTests/RenderSubmissionTests.swift b/Tests/SAGAFoundationTests/RenderSubmissionTests.swift index 08bde49..8d3288e 100644 --- a/Tests/SAGAFoundationTests/RenderSubmissionTests.swift +++ b/Tests/SAGAFoundationTests/RenderSubmissionTests.swift @@ -60,6 +60,18 @@ private func makePipeline(_ device: Device, format: TextureFormat = .rgba8Unorm) return try await device.createRenderPipeline(vertex: vertex, fragment: fragment, colorFormat: format) } +@Test func singleSurfacePresentationRejectsAnUnknownSurfaceID() async throws { + let backend = RenderTestBackend(presentationGate: Gate()) + let device = try await Device.make(backend: backend) + let frame = try await device.createTexture(.init(width: 8, height: 8, format: .rgba8Unorm, usage: .copySource)) + let ticket = try await device.present(frame, surface: 1) + await #expect(throws: GPUError.unsupported("Backend does not have the requested surface")) { + try await ticket.wait() + } + #expect(await backend.presented.isEmpty) + await device.close() +} + @Test func cancelledTextureAllocationRetiresUnpublishedResource() async throws { let gate = Gate() let backend = RenderTestBackend(allocationGate: gate) From ac36c4d0267423179fafafe428e2ceaf72cc5c5d Mon Sep 17 00:00:00 2001 From: SpectralDragon Date: Thu, 12 Nov 2026 23:44:12 +0300 Subject: [PATCH 3/5] Attach MetalKit layers after creating the SAGA device --- Sources/SAGAMetal/MetalBackend.swift | 58 ++++++++++++++++--- .../SAGAMetal/MetalPresentationSurface.swift | 8 ++- 2 files changed, 56 insertions(+), 10 deletions(-) diff --git a/Sources/SAGAMetal/MetalBackend.swift b/Sources/SAGAMetal/MetalBackend.swift index 8fbd56d..a500201 100644 --- a/Sources/SAGAMetal/MetalBackend.swift +++ b/Sources/SAGAMetal/MetalBackend.swift @@ -39,6 +39,14 @@ public enum MetalBackend { } #if canImport(Metal) && canImport(QuartzCore) + /// Creates a device that can attach Metal layers after GPU resources have been created. + public static func makePresentableDevice( + adapter: MetalAdapter? = requestAdapter(), timeout: Duration = .seconds(10) + ) throws -> MetalDevice { + guard let adapter else { throw GPUError.adapterUnavailable } + return try MetalDevice(adapter: adapter, timeout: timeout, surface: nil, enablePresentation: true, shaderLogHandler: nil) + } + @MainActor public static func makeDevice( surface: MetalPresentationSurface, adapter: MetalAdapter? = requestAdapter(), @@ -63,7 +71,8 @@ public final class MetalDevice: ComputeAccelerationStructureBackendDevice, Prese #if canImport(Metal) private let native: any MTLDevice private let queue: any MTLCommandQueue - private let surface: MetalPresentationSurface? + private var surfaces: [UInt32: MetalPresentationSurface] + private var nextSurfaceID: UInt32 = 0 private let timestampCounters: (any MTLCounterSet)? private struct Buffer { let descriptor: BufferDescriptor @@ -194,7 +203,8 @@ public final class MetalDevice: ComputeAccelerationStructureBackendDevice, Prese } private var meshPipelines: [ResourceID: MeshState] = [:] private var surfacePipeline: (any MTLRenderPipelineState)? - private var presenting = false + private var presentingSurface: UInt32? + private var surfaceRetirement: CheckedContinuation? private var indexValidator: MetalIndexValidator? private var dispatchValidator: MetalDispatchValidator? private var meshDispatchValidator: MetalDispatchValidator? @@ -210,15 +220,16 @@ public final class MetalDevice: ComputeAccelerationStructureBackendDevice, Prese nonisolated fileprivate init( adapter: MetalAdapter, timeout: Duration, surface: MetalPresentationSurface?, + enablePresentation: Bool = false, shaderLogHandler: (@Sendable (String) -> Void)? ) throws { guard timeout > .zero else { throw GPUError.validation("Timeout must be positive") } self.timeout = timeout adapterName = adapter.name - supportsPresentation = surface != nil + supportsPresentation = enablePresentation || surface != nil #if canImport(Metal) native = adapter.native - self.surface = surface + self.surfaces = surface.map { [0: $0] } ?? [:] let supportsShaderLogging = if #available(macOS 15, iOS 18, tvOS 18, visionOS 2, *) { native.supportsFamily(.apple6) || native.supportsFamily(.mac2) } else { false } @@ -1448,10 +1459,37 @@ public final class MetalDevice: ComputeAccelerationStructureBackendDevice, Prese } public func present(_ submission: Submission, source: ResourceID) async throws { + try await present(submission, source: source, surface: 0) + } + + #if canImport(Metal) && canImport(QuartzCore) + /// Native device for configuring a MetalKit view before attaching its layer. + public var metalDevice: any MTLDevice { native } + + public func addSurface(_ surface: MetalPresentationSurface) throws -> UInt32 { + try requireActive() + guard supportsPresentation else { throw GPUError.unsupported("Metal device was created without presentation") } + guard nextSurfaceID < UInt32.max else { throw GPUError.validation("Metal surface IDs exhausted") } + try surface.attach(to: native) + nextSurfaceID += 1 + surfaces[nextSurfaceID] = surface + return nextSurfaceID + } + + public func removeSurface(_ id: UInt32) async { + guard let surface = surfaces.removeValue(forKey: id) else { return } + if presentingSurface == id { + await withCheckedContinuation { surfaceRetirement = $0 } + } + surface.detach() + } + #endif + + public func present(_ submission: Submission, source: ResourceID, surface id: UInt32) async throws { try requireActive() #if canImport(Metal) && canImport(QuartzCore) - guard let surface else { throw GPUError.unsupported("Metal backend does not have a presentation surface") } - guard !presenting else { throw GPUError.validation("Metal surface already has a pending frame") } + guard let surface = surfaces[id] else { throw GPUError.unsupported("Metal backend does not have the requested surface") } + guard presentingSurface == nil else { throw GPUError.validation("Metal device already has a pending frame") } guard submission.resources.contains(source), let texture = textures[source], texture.descriptor.format.isNormalizedColor, texture.descriptor.sampleCount == 1, texture.descriptor.dimension == .d2, texture.descriptor.arrayLayerCount == 1, @@ -1459,8 +1497,12 @@ public final class MetalDevice: ComputeAccelerationStructureBackendDevice, Prese throw GPUError.validation("Unpinned or invalid Metal presentation source") } let descriptor = texture.descriptor - presenting = true - defer { presenting = false } + presentingSurface = id + defer { + presentingSurface = nil + surfaceRetirement?.resume() + surfaceRetirement = nil + } let pipeline: any MTLRenderPipelineState if let surfacePipeline { pipeline = surfacePipeline diff --git a/Sources/SAGAMetal/MetalPresentationSurface.swift b/Sources/SAGAMetal/MetalPresentationSurface.swift index 4afbb09..124b4f9 100644 --- a/Sources/SAGAMetal/MetalPresentationSurface.swift +++ b/Sources/SAGAMetal/MetalPresentationSurface.swift @@ -10,7 +10,7 @@ public struct MetalPresentedFrame: Sendable, Equatable { public let presentedTime: Double } -/// Owns configuration of an AppKit-hosted Metal layer for one backend. +/// Owns configuration of an Apple Metal layer for one backend. @MainActor public final class MetalPresentationSurface { #if canImport(Metal) && canImport(QuartzCore) @@ -29,10 +29,14 @@ public final class MetalPresentationSurface { layer.device = device layer.pixelFormat = .bgra8Unorm layer.framebufferOnly = true - layer.isOpaque = true layer.allowsNextDrawableTimeout = true } + func detach() { + attached = false + layer.device = nil + } + func acquire(width: UInt32, height: UInt32) throws -> any CAMetalDrawable { let size = CGSize(width: Int(width), height: Int(height)) if layer.drawableSize != size { layer.drawableSize = size } From dc0b6a5406dd97304eb63c045112e7ac9c9fae67 Mon Sep 17 00:00:00 2001 From: SpectralDragon Date: Thu, 12 Nov 2026 23:54:21 +0300 Subject: [PATCH 4/5] Emit nested shader expressions without growing the render worker stack --- Sources/SAGABackends/ShaderBodyWriter.swift | 177 +++++++++++++------- 1 file changed, 117 insertions(+), 60 deletions(-) diff --git a/Sources/SAGABackends/ShaderBodyWriter.swift b/Sources/SAGABackends/ShaderBodyWriter.swift index d144ac2..4f518e5 100644 --- a/Sources/SAGABackends/ShaderBodyWriter.swift +++ b/Sources/SAGABackends/ShaderBodyWriter.swift @@ -4146,17 +4146,119 @@ struct ShaderBodyWriter { throw unsupported("access outside a supported vector or render structure") } + private enum ValueStep { + case visit(IRHandle, parenthesized: Bool) + case leave + case construct(IRHandle, count: Int) + case splat(IRHandle, width: Int) + case suffix(String) + case cast(IRHandle, IRScalar, IRHandle) + case member(IRHandle, IRHandle, String) + case binary(IRBinaryOperator, IRHandle, IRHandle, IRHandle, Bool) + } + + // Large shader expression trees can overflow a render worker's Swift stack + // even within the shader depth budget. Walk common expression branches on + // the heap, keeping depth and source accounting in the original order. private mutating func value( _ handle: IRHandle, parenthesized: Bool = true ) throws -> String { - try budget.consume(.work) - try budget.enterDepth() - defer { try? budget.leaveDepth() } - if continuingCaptures[handle] != nil { - continuingCaptures[handle] = true - return "saga_loop_value_\(handle.index)" + var steps: [ValueStep] = [.visit(handle, parenthesized: parenthesized)] + var values: [String] = [] + var depth = 0 + defer { + for _ in 0..= count else { throw ShaderFailure.invalidHandle } + let components = Array(values.suffix(count)) + values.removeLast(count) + values.append(try construct(type, components: components)) + case .splat(let type, let width): + guard let lane = values.popLast() else { throw ShaderFailure.invalidHandle } + if outputFormat == .hlsl { + values.append("(\(lane)).\(String(repeating: "x", count: width))") + } else { + values.append(try construct(type, components: [lane])) + } + case .suffix(let suffix): + guard let base = values.popLast() else { throw ShaderFailure.invalidHandle } + values.append(base + suffix) + case .member(let node, let type, let member): + guard let base = values.popLast() else { throw ShaderFailure.invalidHandle } + let reference = base + member + if try packedVectorMember(node) { + values.append("\(try typeName(type))(\(reference))") + } else { + values.append(reference) + } + case .cast(let operand, let target, let resultType): + guard let source = values.popLast() else { throw ShaderFailure.invalidHandle } + values.append(try formatCast(operand, source: source, target: target, resultType: resultType)) + case .binary(let op, let left, let right, let resultType, let wrap): + guard let rhs = values.popLast(), let lhs = values.popLast() else { throw ShaderFailure.invalidHandle } + values.append(try formatBinaryValue(op, left: left, right: right, lhs: lhs, rhs: rhs, + resultType: resultType, parenthesized: wrap)) + } } - let expression = try function.expressions.value(for: handle) + + guard values.count == 1, let result = values.first else { throw ShaderFailure.invalidHandle } + return result + } + + private mutating func valueLeaf( + _ handle: IRHandle, expression: IRExpression, parenthesized: Bool + ) throws -> String { switch expression.kind { case .cooperativeLoad where temporaries.contains(handle), .cooperativeMultiplyAdd where temporaries.contains(handle): @@ -4208,49 +4310,20 @@ struct ShaderBodyWriter { if !arguments[index].isEmpty { return arguments[index] } guard let fields = argumentFields[index] else { throw ShaderFailure.invalidHandle } return try construct(expression.type, components: fields) - case .compose(let type, let components): - // Charge before expansion so an expression shared by several constructors stays bounded. - try budget.consume(.sourceBytes, amount: components.count + 1) - let values = try components.map { try value($0) } - return try construct(type, components: values) - case .splat(let size, let operand): - let lane = try value(operand) - if outputFormat == .hlsl { - return "(\(lane)).\(String(repeating: "x", count: Int(size.rawValue)))" - } - return try construct(expression.type, components: [lane]) - case .swizzle(let size, let vector, let pattern): - let suffix = [pattern.x, pattern.y, pattern.z, pattern.w] - .prefix(Int(size.rawValue)).map(\.rawValue).joined() - return try value(vector) + "." + suffix - case .accessIndex(let base, let index): - let source = try function.expressions.value(for: base) - if case .functionArgument(let argument) = source.kind, let slot = Int(exactly: argument), - let fields = argumentFields[slot], let field = Int(exactly: index), fields.indices.contains(field) { - return fields[field] - } - let field = try member(index, of: source.type) - let reference = try value(base) + field - if try packedVectorMember(handle) { return "\(try typeName(expression.type))(\(reference))" } - return reference case .relational(let reduction, let operand) where reduction.isBooleanReduction: let operandType = try function.expressions.value(for: operand).type let argument = try value(operand) if case .scalar(.bool) = try module.types.value(for: operandType).kind { return argument } return "\(reduction.rawValue)(\(argument))" case .bitcast(let operand, let target): return try bitcast(operand, to: target) - case .cast(let operand, let target): - return try castValue(operand, target: target, resultType: expression.type) case .unary(let op, let argument): return try unaryValue(op, argument: argument, resultType: expression.type, parenthesized: parenthesized) - case .binary(let op, let left, let right): - return try binaryValue(op, left: left, right: right, resultType: expression.type, parenthesized: parenthesized) default: throw unsupported("expression") } } - private mutating func castValue( - _ operand: IRHandle, target: IRScalar, resultType: IRHandle + private func formatCast( + _ operand: IRHandle, source: String, target: IRScalar, resultType: IRHandle ) throws -> String { let operandType = try function.expressions.value(for: operand).type let input: IRScalar @@ -4263,20 +4336,18 @@ struct ShaderBodyWriter { input = scalar width = nil case .matrix(_, _, let scalar) where [.f16, .f32, .f64].contains(scalar) && [.f16, .f32, .f64].contains(target): - return try numericCast(typeName(resultType), value(operand)) + return try numericCast(typeName(resultType), source) default: throw unsupported("cast operand type") } if input == .bool || target == .bool { - let source = try value(operand) let type = try typeName(resultType) return booleanCast(type, source) } if input == .f64 || target == .f64 { guard target.kind == .float else { throw unsupported("f64 to integer conversion") } - return try numericCast(typeName(resultType), value(operand)) + return try numericCast(typeName(resultType), source) } if input == .i16 || input == .u16 || target == .i16 || target == .u16 { - let source = try value(operand) let type = try typeName(resultType) if outputFormat == .wgsl { return numericCast(type, source) } if target == .i16 || target == .u16 { @@ -4288,7 +4359,6 @@ struct ShaderBodyWriter { return numericCast(type, source) } if input.width == 8 || target.width == 8 { - let source = try value(operand) let type = try typeName(resultType) if outputFormat == .wgsl { return numericCast(type, source) } if input.kind == .float { @@ -4301,20 +4371,19 @@ struct ShaderBodyWriter { } if [.f32, .f16].contains(input), [.i32, .u32].contains(target) { // WGSL conversions already clamp to the destination range and round toward zero. - if outputFormat == .wgsl { return try numericCast(typeName(resultType), value(operand)) } - return try floatToIntegerCast(value(operand), input: input, target: target, + if outputFormat == .wgsl { return try numericCast(typeName(resultType), source) } + return try floatToIntegerCast(source, input: input, target: target, width: width, resultType: typeName(resultType)) } if target == .f16, [.f32, .i32, .u32].contains(input) { - return try numericCast(typeName(resultType), value(operand)) + return try numericCast(typeName(resultType), source) } if input == .f16, target == .f32 { - return try numericCast(typeName(resultType), value(operand)) + return try numericCast(typeName(resultType), source) } guard [.i32, .u32].contains(input), [.i32, .u32, .f32].contains(target) else { throw unsupported("cast outside the supported numeric profile") } - let source = try value(operand) if target == .f32 { return "\(try typeName(resultType))(\(source))" } return input == target ? source : integerBits(source, signed: target == .i32, width: width, in: outputFormat) } @@ -4377,18 +4446,6 @@ struct ShaderBodyWriter { return parenthesized ? "(\(unary))" : unary } - private mutating func binaryValue( - _ op: IRBinaryOperator, left: IRHandle, right: IRHandle, - resultType: IRHandle, parenthesized: Bool - ) throws -> String { - // Validate and charge before expanding either child, as in the original traversal. - try prepareBinaryValue(op, left: left, right: right) - let lhs = try value(left) - let rhs = try value(right) - return try formatBinaryValue(op, left: left, right: right, lhs: lhs, rhs: rhs, - resultType: resultType, parenthesized: parenthesized) - } - private mutating func prepareBinaryValue( _ op: IRBinaryOperator, left: IRHandle, right: IRHandle ) throws { From 8be633b5be0f747e0aa4e030fcad03c4ca17908f Mon Sep 17 00:00:00 2001 From: SpectralDragon Date: Fri, 13 Nov 2026 00:03:20 +0300 Subject: [PATCH 5/5] Support SAGAMetal builds for iOS and tvOS simulators --- Package.swift | 2 +- Sources/SAGAMetal/MetalBackend.swift | 2 ++ Sources/SAGAMetal/MetalPresentationSurface.swift | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index e60f955..7e43a66 100644 --- a/Package.swift +++ b/Package.swift @@ -3,7 +3,7 @@ import PackageDescription let package = Package( name: "SAGA", - platforms: [.macOS(.v14)], + platforms: [.macOS(.v14), .iOS(.v18), .tvOS(.v18)], products: [ .executable(name: "SAGAMetalDiagnostic", targets: ["SAGAMetalDiagnostic"]), .executable(name: "SAGAMetalWindowDiagnostic", targets: ["SAGAMetalWindowDiagnostic"]), diff --git a/Sources/SAGAMetal/MetalBackend.swift b/Sources/SAGAMetal/MetalBackend.swift index a500201..cc95704 100644 --- a/Sources/SAGAMetal/MetalBackend.swift +++ b/Sources/SAGAMetal/MetalBackend.swift @@ -385,9 +385,11 @@ public final class MetalDevice: ComputeAccelerationStructureBackendDevice, Prese var meshWorkgroupCount: UInt32 = 0 if features.contains(.meshShaders) { var metal4 = false + #if !targetEnvironment(simulator) if #available(macOS 26, iOS 26, tvOS 26, visionOS 26, *) { metal4 = native.supportsFamily(.metal4) } + #endif taskWorkgroupCount = metal4 || native.supportsFamily(.apple7) ? UInt32.max : 1024 if #available(macOS 26, iOS 26, tvOS 26, visionOS 26, *), native.supportsFamily(.apple10) { meshWorkgroupCount = 1 << 22 diff --git a/Sources/SAGAMetal/MetalPresentationSurface.swift b/Sources/SAGAMetal/MetalPresentationSurface.swift index 124b4f9..62b76e6 100644 --- a/Sources/SAGAMetal/MetalPresentationSurface.swift +++ b/Sources/SAGAMetal/MetalPresentationSurface.swift @@ -43,12 +43,14 @@ public final class MetalPresentationSurface { guard let drawable = layer.nextDrawable() else { throw GPUError.backendFailure("Metal surface did not supply a drawable") } + #if !targetEnvironment(simulator) if let onPresented { drawable.addPresentedHandler { drawable in let frame = MetalPresentedFrame(width: width, height: height, presentedTime: drawable.presentedTime) Task { @MainActor in onPresented(frame) } } } + #endif return drawable } #else