SPB Git

spb/metrika Public

Stata-class statistics, GPU-accelerated by Apple Silicon. Native Swift — no Electron, no Python runtime, no compromises.

Swift 92.4% HTML 3.3% R 3% Shell 1.3%
8.7 KB · 233 lines swift
Raw Blame History
1//2//  MetalScatterView.swift3//  Metrika4//5//  Author:  Simon-Pierre Boucher6//  Contact: contact@spboucher.ai7//  Copyright © 2026 Simon-Pierre Boucher. All rights reserved.8//910import MetalKit11import SwiftUI12import ZQGraphics1314/// Metal point-sprite renderer for scatter plots past the Swift Charts15/// threshold (CLAUDE.md §7 pane 4). Unified memory: the packed vertex16/// buffer is written once; pan (scroll) and zoom (pinch or scroll+⌥)17/// only touch a 24-byte uniform.18struct MetalScatterPane: View {19    let plot: ZQPlotSpec2021    var body: some View {22        VStack(spacing: 0) {23            MetalScatterView(plot: plot)24            Divider()25            HStack {26                Text("\(plot.series.reduce(0) { $0 + $1.x.count }) points — Metal renderer")27                Spacer()28                Text("scroll to pan · pinch or ⌥-scroll to zoom · double-click to reset")29            }30            .font(.caption)31            .foregroundStyle(.secondary)32            .padding(6)33        }34    }35}3637struct MetalScatterView: NSViewRepresentable {38    let plot: ZQPlotSpec3940    func makeCoordinator() -> Renderer { Renderer() }4142    func makeNSView(context: Context) -> ScatterMTKView {43        let view = ScatterMTKView()44        view.device = MTLCreateSystemDefaultDevice()45        view.delegate = context.coordinator46        view.renderer = context.coordinator47        view.enableSetNeedsDisplay = true48        view.isPaused = true49        view.clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 0)50        view.layer?.isOpaque = false51        context.coordinator.attach(view: view)52        context.coordinator.load(plot: plot)53        return view54    }5556    func updateNSView(_ view: ScatterMTKView, context: Context) {57        context.coordinator.load(plot: plot)58        view.needsDisplay = true59    }6061    /// MTKView subclass owning the pan/zoom gestures.62    final class ScatterMTKView: MTKView {63        weak var renderer: Renderer?6465        override func scrollWheel(with event: NSEvent) {66            if event.modifierFlags.contains(.option) {67                renderer?.zoom(by: 1 + event.scrollingDeltaY * 0.01)68            } else {69                renderer?.pan(70                    deltaX: event.scrollingDeltaX,71                    deltaY: event.scrollingDeltaY,72                    viewSize: bounds.size73                )74            }75            needsDisplay = true76        }7778        override func magnify(with event: NSEvent) {79            renderer?.zoom(by: 1 + event.magnification)80            needsDisplay = true81        }8283        override func mouseDown(with event: NSEvent) {84            if event.clickCount == 2 {85                renderer?.resetViewport()86                needsDisplay = true87            }88        }89    }9091    /// Pipeline + vertex buffer + viewport state.92    @MainActor93    final class Renderer: NSObject, MTKViewDelegate {94        private struct Uniforms {95            var center: SIMD2<Float>96            var scale: SIMD2<Float>97            var pointSize: Float98        }99100        private var pipeline: MTLRenderPipelineState?101        private var commandQueue: MTLCommandQueue?102        private var vertexBuffer: MTLBuffer?103        private var pointCount = 0104        private var loadedPlotSignature = 0105106        // Data bounds and viewport state.107        private var dataCenter = SIMD2<Float>(0, 0)108        private var dataHalfSpan = SIMD2<Float>(1, 1)109        private var zoomLevel: Float = 1110        private var panOffset = SIMD2<Float>(0, 0)111        private var aspect: Float = 1112113        func attach(view: MTKView) {114            guard let device = view.device else { return }115            commandQueue = device.makeCommandQueue()116            guard let library = device.makeDefaultLibrary(),117                  let vertexFunction = library.makeFunction(name: "scatterVertex"),118                  let fragmentFunction = library.makeFunction(name: "scatterFragment")119            else { return }120121            let descriptor = MTLRenderPipelineDescriptor()122            descriptor.vertexFunction = vertexFunction123            descriptor.fragmentFunction = fragmentFunction124            descriptor.colorAttachments[0].pixelFormat = view.colorPixelFormat125            descriptor.colorAttachments[0].isBlendingEnabled = true126            descriptor.colorAttachments[0].sourceRGBBlendFactor = .sourceAlpha127            descriptor.colorAttachments[0].destinationRGBBlendFactor = .oneMinusSourceAlpha128            descriptor.colorAttachments[0].sourceAlphaBlendFactor = .one129            descriptor.colorAttachments[0].destinationAlphaBlendFactor = .oneMinusSourceAlpha130            pipeline = try? device.makeRenderPipelineState(descriptor: descriptor)131        }132133        /// Rebuilds the vertex buffer when the plot actually changes.134        func load(plot: ZQPlotSpec) {135            let signature = plot.series.reduce(plot.series.count) {136                $0 &* 31 &+ $1.x.count137            }138            guard signature != loadedPlotSignature,139                  let device = commandQueue?.device else { return }140            loadedPlotSignature = signature141142            let palette: [SIMD4<UInt8>] = [143                .init(79, 140, 255, 220), .init(255, 149, 0, 220),144                .init(52, 199, 89, 220), .init(175, 82, 222, 220),145                .init(255, 59, 48, 220), .init(90, 200, 250, 220),146            ]147148            var minX = Float.greatestFiniteMagnitude, maxX = -Float.greatestFiniteMagnitude149            var minY = Float.greatestFiniteMagnitude, maxY = -Float.greatestFiniteMagnitude150            var packed = [UInt8]()151            var count = 0152            for (seriesIndex, series) in plot.series.enumerated() {153                let color = palette[seriesIndex % palette.count]154                for i in 0..<series.x.count {155                    let x = Float(series.x[i])156                    let y = Float(series.y[i])157                    guard x.isFinite, y.isFinite else { continue }158                    minX = min(minX, x); maxX = max(maxX, x)159                    minY = min(minY, y); maxY = max(maxY, y)160                    withUnsafeBytes(of: x) { packed.append(contentsOf: $0) }161                    withUnsafeBytes(of: y) { packed.append(contentsOf: $0) }162                    packed.append(contentsOf: [color.x, color.y, color.z, color.w])163                    count += 1164                }165            }166            guard count > 0 else { return }167            pointCount = count168            vertexBuffer = packed.withUnsafeBytes {169                device.makeBuffer(bytes: $0.baseAddress!, length: $0.count)170            }171172            dataCenter = SIMD2((minX + maxX) / 2, (minY + maxY) / 2)173            dataHalfSpan = SIMD2(174                max((maxX - minX) / 2, 1e-9) * 1.05,175                max((maxY - minY) / 2, 1e-9) * 1.05176            )177            resetViewport()178        }179180        func resetViewport() {181            zoomLevel = 1182            panOffset = .zero183        }184185        func zoom(by factor: Double) {186            zoomLevel = min(max(zoomLevel * Float(factor), 0.1), 10_000)187        }188189        func pan(deltaX: Double, deltaY: Double, viewSize: CGSize) {190            guard viewSize.width > 0, viewSize.height > 0 else { return }191            panOffset.x -= Float(deltaX) * 2 * dataHalfSpan.x192                / (zoomLevel * Float(viewSize.width))193            panOffset.y += Float(deltaY) * 2 * dataHalfSpan.y194                / (zoomLevel * Float(viewSize.height))195        }196197        nonisolated func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {}198199        nonisolated func draw(in view: MTKView) {200            MainActor.assumeIsolated {201                render(in: view)202            }203        }204205        private func render(in view: MTKView) {206            guard let pipeline,207                  let vertexBuffer,208                  pointCount > 0,209                  let descriptor = view.currentRenderPassDescriptor,210                  let drawable = view.currentDrawable,211                  let commandBuffer = commandQueue?.makeCommandBuffer(),212                  let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor)213            else { return }214215            var uniforms = Uniforms(216                center: dataCenter + panOffset,217                scale: SIMD2(218                    zoomLevel / dataHalfSpan.x,219                    zoomLevel / dataHalfSpan.y220                ),221                pointSize: Float(max(2, 6 - log10(Double(max(pointCount, 10)))))222            )223            encoder.setRenderPipelineState(pipeline)224            encoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)225            encoder.setVertexBytes(&uniforms, length: MemoryLayout<Uniforms>.size, index: 1)226            encoder.drawPrimitives(type: .point, vertexStart: 0, vertexCount: pointCount)227            encoder.endEncoding()228            commandBuffer.present(drawable)229            commandBuffer.commit()230        }231    }232}233