返回

SwiftUI Metal Shader: 实现可拖拽边角的图像自由变形

IOS

用 Metal Shader 在 SwiftUI 中实现自由变形图像拉伸(附带边角拖拽)

咱们平时用 Photoshop 或者类似的设计工具时,拖动图片的边角就能随意拉伸变形,宽高比也不用管,想怎么拉就怎么拉。要在 SwiftUI 里搞定这个效果,特别是要求能拖拽特定角落或者边缘、实时变形、效果平滑,还要强制使用 Metal Shader 来处理图像渲染,这事儿就有点挑战了。SwiftUI 自带的那些图片工具可应付不来这种自由度。

为啥 SwiftUI 自带的不行?

问题出在哪呢?

SwiftUI 提供的 resizable(), scaledToFit(), scaledToFill() 这几个常用的图片调整方法,都有个共同点:它们要么保持原始的宽高比,要么就是整体缩放来填充或适应容器。这意味着你没法做到只拉伸图片的右上角,而左边保持不动。它们做不到这种“局部变形”、“非均匀拉伸”。

就算你想到用 GeometryReader 来获取视图的尺寸和坐标,再结合 DragGesture 来捕捉用户拖动某个点(比如模拟的边角控制点)的手势信息,但接下来呢?你怎么告诉 SwiftUI:“嘿,只根据这个拖拽点的位置,扭曲图片对应的那一部分,而且要实时、平滑地显示出来?”

标准的 SwiftUI 渲染流程并不擅长处理这种复杂的、像素级别的、非等比的图形变换。这就是为什么我们需要引入更底层的图形处理能力——Metal。它能直接跟 GPU 打交道,执行高度定制化的渲染逻辑,搞定这种自由变形的需求。

核心思路:纹理映射与顶点变换

解决这个问题的核心想法其实不复杂:

  1. 把图片看作纹理 (Texture): 你的原始图片,在 Metal 眼里就是一个纹理。
  2. 定义一个可变形的“画板”(几何体): 我们需要一个几何形状来“贴”上这个纹理。最简单的就是用一个四边形 (Quad),它由四个顶点 (Vertices) 构成。
  3. 顶点与控制点关联: 这个四边形的四个顶点就对应着图片你想拖拽的四个角落(左上、右上、左下、右下)。
  4. 响应拖拽,移动顶点: 当用户在 SwiftUI 界面上拖动某个“控制点”(比如你画在图片角落的小圆圈)时,通过手势识别获取到新的位置。这个新位置,就用来更新对应四边形顶点的坐标。
  5. Metal Shader 出场: 这时候,Metal Shader 就派上用场了。
    • 顶点着色器 (Vertex Shader): 它的任务是接收原始四边形的顶点坐标(比如一个简单的 0 到 1 范围的正方形)和我们从 SwiftUI 传过来的、用户拖拽后的新的四个角点坐标。然后,它根据某种插值逻辑(比如双线性插值)计算出每个原始顶点最终应该被绘制到屏幕上的哪个位置。简单说,就是它把那个标准的四边形“扭曲”成了用户拖拽出来的形状。同时,它需要把原始纹理坐标(通常也是 0 到 1 范围)传递给下一步。
    • 片元/片段着色器 (Fragment Shader): 对于经过顶点着色器扭曲后的四边形覆盖的屏幕上的每一个像素点(这些点的位置是 GPU 根据顶点着色器的输出插值计算出来的),片段着色器需要知道这个屏幕像素对应原始图片纹理上的哪个坐标点(这个纹理坐标也是从顶点着色器插值传递过来的)。知道了纹理坐标,它就去原始图片纹理里把对应位置的颜色“采样”出来,作为当前屏幕像素的最终颜色。

通过这个流程,Metal 就能根据你拖拽的角点,实时地把原始图片“扭曲”并绘制到屏幕上,实现自由变形的效果。

一步步实现

下面是具体的实现步骤和代码思路。

1. SwiftUI 视图层:容器与控制点

你需要一个 SwiftUI View 来承载这一切。这个 View 至少要做几件事:

  • 显示一个基于 Metal 的视图(后面会用 UIViewRepresentable 包装 MTKView)。
  • 维护四个角点的位置状态。这些状态会传递给 Metal。
  • 在图片的四个角上绘制可交互的控制点(比如小圆圈)。
  • 为每个控制点添加拖动手势识别 (DragGesture)。
import SwiftUI
import MetalKit // 稍后会用到

struct ImageStretchView: View {
    // 图片资源名称
    let imageName: String = "your_image_name" // 替换成你的图片名

    // 存储四个角点的位置状态,初始为单位矩形的四个角
    // 坐标系可以自定义,例如相对于视图中心或左上角,这里用 CGSize 方便手势处理
    @State private var topLeft: CGPoint = CGPoint(x: -100, y: -150) // 初始左上角位置
    @State private var topRight: CGPoint = CGPoint(x: 100, y: -150) // 初始右上角位置
    @State private var bottomLeft: CGPoint = CGPoint(x: -100, y: 150) // 初始左下角位置
    @State private var bottomRight: CGPoint = CGPoint(x: 100, y: 150) // 初始右下角位置

    // 控制点的大小
    let handleSize: CGFloat = 20

    var body: some View {
        GeometryReader { geometry in
            let center = CGPoint(x: geometry.size.width / 2, y: geometry.size.height / 2)

            ZStack {
                // Metal 渲染视图 (后面会创建这个 MetalViewRepresentable)
                MetalViewRepresentable(
                    imageName: imageName,
                    topLeft: normalizedPosition(topLeft, center: center, size: geometry.size),
                    topRight: normalizedPosition(topRight, center: center, size: geometry.size),
                    bottomLeft: normalizedPosition(bottomLeft, center: center, size: geometry.size),
                    bottomRight: normalizedPosition(bottomRight, center: center, size: geometry.size)
                )
                .edgesIgnoringSafeArea(.all) // 让 Metal 视图铺满

                // 控制点
                ControlHandle(position: $topLeft, geometry: geometry)
                ControlHandle(position: $topRight, geometry: geometry)
                ControlHandle(position: $bottomLeft, geometry: geometry)
                ControlHandle(position: $bottomRight, geometry: geometry)
            }
            // 设置一个参考系,让 CGPoint 状态是相对于中心点的偏移
            .frame(width: geometry.size.width, height: geometry.size.height)
            // 初始化位置要考虑 geometry.size
            .onAppear {
                // 可以根据 geometry.size 初始化更合适的默认位置
                let initialWidth: CGFloat = 200 // 图片初始显示宽度
                let initialHeight: CGFloat = 300 // 图片初始显示高度
                topLeft = CGPoint(x: -initialWidth / 2, y: -initialHeight / 2)
                topRight = CGPoint(x: initialWidth / 2, y: -initialHeight / 2)
                bottomLeft = CGPoint(x: -initialWidth / 2, y: initialHeight / 2)
                bottomRight = CGPoint(x: initialWidth / 2, y: initialHeight / 2)
            }
        }
    }

    // 将相对于中心点的 CGPoint 转换为 Metal 可能需要的标准化坐标 (-1 到 1 或 0 到 1)
    // 这里示例转换为 0 到 1,左上角为 (0,0)
    func normalizedPosition(_ point: CGPoint, center: CGPoint, size: CGSize) -> vector_float2 {
        let viewX = center.x + point.x
        let viewY = center.y + point.y
        return vector_float2(Float(viewX / size.width), Float(viewY / size.height))
    }
}

// 控制点视图及手势
struct ControlHandle: View {
    @Binding var position: CGPoint
    let geometry: GeometryProxy // 获取父视图信息
    let handleSize: CGFloat = 24

    var body: some View {
        let center = CGPoint(x: geometry.size.width / 2, y: geometry.size.height / 2)
        let absolutePosition = CGPoint(x: center.x + position.x, y: center.y + position.y)

        Circle()
            .fill(Color.blue.opacity(0.7))
            .frame(width: handleSize, height: handleSize)
            .position(absolutePosition) // 使用绝对位置定位
            .gesture(
                DragGesture()
                    .onChanged { value in
                        // 计算拖动后的新位置(相对于中心点)
                        self.position = CGPoint(
                            x: value.location.x - center.x,
                            y: value.location.y - center.y
                        )
                    }
            )
    }
}

注意: 上面的代码里,坐标系管理(比如是相对于视图中心、左上角,还是标准化坐标)需要根据你的 Metal 实现细节来精确调整 normalizedPosition 函数。示例中使用相对于中心点的 CGPoint 状态,并在传递给 Metal 前转换为标准化坐标。

2. 集成 Metal: UIViewRepresentableMTKView

要在 SwiftUI 中使用 Metal 进行渲染,最常见的做法是用 UIViewRepresentable 协议包装一个 MTKView (来自 MetalKit 框架)。

import SwiftUI
import MetalKit

struct MetalViewRepresentable: UIViewRepresentable {
    let imageName: String
    // 接收来自 SwiftUI 的四个角点标准化坐标
    var topLeft: vector_float2
    var topRight: vector_float2
    var bottomLeft: vector_float2
    var bottomRight: vector_float2

    // 创建 UIKit 视图 (MTKView)
    func makeUIView(context: Context) -> MTKView {
        let mtkView = MTKView()
        mtkView.device = MTLCreateSystemDefaultDevice() // 获取默认 Metal 设备 (GPU)
        guard let device = mtkView.device else {
            fatalError("Metal is not supported on this device")
        }

        // 创建 Renderer 类实例(负责 Metal 渲染逻辑)
        let renderer = Renderer(mtkView: mtkView, imageName: imageName)
        context.coordinator.renderer = renderer
        mtkView.delegate = context.coordinator // 设置 MTKView 的代理
        mtkView.enableSetNeedsDisplay = true // 按需渲染
        mtkView.isPaused = true // 初始暂停,由 SwiftUI 状态变化触发更新
        mtkView.clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 0) // 透明背景

        return mtkView
    }

    // 更新 UIKit 视图(当 SwiftUI 状态变化时调用)
    func updateUIView(_ uiView: MTKView, context: Context) {
        // 把最新的角点位置传递给 Renderer
        context.coordinator.renderer?.updateCornerPoints(
            topLeft: topLeft,
            topRight: topRight,
            bottomLeft: bottomLeft,
            bottomRight: bottomRight
        )
        // 通知 MTKView 需要重绘
        uiView.setNeedsDisplay()
    }

    // 创建协调器 (Coordinator),通常用作 MTKView 的代理
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    // Coordinator 类,遵循 MTKViewDelegate
    class Coordinator: NSObject, MTKViewDelegate {
        var parent: MetalViewRepresentable
        var renderer: Renderer?

        init(_ parent: MetalViewRepresentable) {
            self.parent = parent
            super.init()
        }

        // 当 MTKView 尺寸变化时调用
        func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
            renderer?.mtkView(view, drawableSizeWillChange: size)
        }

        // 核心渲染方法,每次需要绘制时调用
        func draw(in view: MTKView) {
            renderer?.draw(in: view)
        }
    }
}

// Metal 渲染逻辑封装类 (下面会详细定义)
class Renderer {
    // ... Metal 渲染所需的属性和方法 ...
    init(mtkView: MTKView, imageName: String) {
        // ... 初始化 Metal 设备、命令队列、渲染管线状态、纹理、缓冲区等 ...
    }
    func updateCornerPoints(topLeft: vector_float2, topRight: vector_float2, bottomLeft: vector_float2, bottomRight: vector_float2) {
        // ... 更新存储角点位置的缓冲区 ...
    }
    func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
        // ... 处理视图尺寸变化 ...
    }
    func draw(in view: MTKView) {
        // ... 执行渲染命令 ...
    }
}

3. Renderer 类:核心 Metal 逻辑

这个 Renderer 类是 Metal 代码的家。它需要处理:

  • 设备和队列: 获取 MTLDevice (GPU) 和 MTLCommandQueue (命令队列)。
  • 渲染管线: 创建 MTLRenderPipelineState,这需要链接顶点着色器和片段着色器。
  • 顶点数据: 创建一个 MTLBuffer 存储构成四边形的顶点数据。这个四边形通常是固定的,比如顶点坐标为 (0,0), (1,0), (0,1), (1,1),纹理坐标也对应。
  • 纹理: 加载你的图片 (UIImageCGImage),并用 MTLTextureLoader 把它转换成 MTLTexture
  • Uniform 数据: 创建一个 MTLBuffer 来存储需要传递给着色器的动态数据,也就是四个角点的位置 (topLeft, topRight 等)。
  • draw(in:) 方法实现: 这是渲染循环的核心。
    1. 获取当前的 MTLCommandBufferMTLRenderDrawable
    2. 创建一个 MTLRenderCommandEncoder
    3. 设置渲染管线状态。
    4. 设置顶点缓冲区。
    5. 设置(更新后的)角点位置缓冲区,让顶点着色器能访问到。
    6. 设置图片纹理,让片段着色器能访问到。
    7. 发出绘制命令 (drawPrimitives)。
    8. 结束编码 (endEncoding)。
    9. 呈现绘制结果 (present)。
    10. 提交命令缓冲区 (commit)。
import MetalKit

struct Vertex {
    var position: vector_float2 // 顶点坐标 (例如 0,0 到 1,1)
    var texCoord: vector_float2 // 纹理坐标 (也是 0,0 到 1,1)
}

struct CornerPoints {
    var topLeft: vector_float2
    var topRight: vector_float2
    var bottomLeft: vector_float2
    var bottomRight: vector_float2
}

class Renderer: NSObject {
    let device: MTLDevice
    let commandQueue: MTLCommandQueue
    var pipelineState: MTLRenderPipelineState!
    var vertexBuffer: MTLBuffer!
    var texture: MTLTexture!
    var cornerPointsBuffer: MTLBuffer!
    var samplerState: MTLSamplerState!

    // 存储最新的角点位置
    var currentCornerPoints: CornerPoints

    init(mtkView: MTKView, imageName: String) {
        self.device = mtkView.device!
        self.commandQueue = device.makeCommandQueue()!

        // 初始化角点(可以设默认值,之后会被 updateUIView 更新)
        currentCornerPoints = CornerPoints(topLeft: [0,0], topRight: [1,0], bottomLeft: [0,1], bottomRight: [1,1])

        super.init()

        loadTexture(imageName: imageName)
        buildBuffers()
        buildPipelineState(pixelFormat: mtkView.colorPixelFormat)
        buildSamplerState()
    }

    func loadTexture(imageName: String) {
        guard let image = UIImage(named: imageName) else {
            fatalError("Failed to load image: \(imageName)")
        }
        guard let cgImage = image.cgImage else {
             fatalError("Failed to get CGImage from UIImage")
        }

        let textureLoader = MTKTextureLoader(device: device)
        let options: [MTKTextureLoader.Option : Any] = [
            .origin: MTKTextureLoader.Origin.topLeft, // 根据你的图片格式和纹理坐标系调整
            .SRGB: false // 通常 UI 图片不需要 sRGB 转换
        ]

        do {
            texture = try textureLoader.newTexture(cgImage: cgImage, options: options)
        } catch {
            fatalError("Failed to create texture: \(error)")
        }
    }

    func buildBuffers() {
        // 标准四边形顶点和纹理坐标
        let vertices: [Vertex] = [
            Vertex(position: [0, 0], texCoord: [0, 0]), // 左上
            Vertex(position: [1, 0], texCoord: [1, 0]), // 右上
            Vertex(position: [0, 1], texCoord: [0, 1]), // 左下
            Vertex(position: [1, 1], texCoord: [1, 1])  // 右下
        ]
        vertexBuffer = device.makeBuffer(bytes: vertices, length: vertices.count * MemoryLayout<Vertex>.stride, options: [])

        // 角点位置缓冲区,先创建,内容在 updateCornerPoints 更新
        cornerPointsBuffer = device.makeBuffer(length: MemoryLayout<CornerPoints>.stride, options: [])
    }

    func buildPipelineState(pixelFormat: MTLPixelFormat) {
        let library = device.makeDefaultLibrary()!
        let vertexFunction = library.makeFunction(name: "vertexShader")
        let fragmentFunction = library.makeFunction(name: "fragmentShader")

        let pipelineDescriptor = MTLRenderPipelineDescriptor()
        pipelineDescriptor.vertexFunction = vertexFunction
        pipelineDescriptor.fragmentFunction = fragmentFunction
        pipelineDescriptor.colorAttachments[0].pixelFormat = pixelFormat

        // 设置顶点符,告诉 Metal 如何解析顶点数据
        let vertexDescriptor = MTLVertexDescriptor()
        vertexDescriptor.attributes[0].format = .float2 // position
        vertexDescriptor.attributes[0].offset = 0
        vertexDescriptor.attributes[0].bufferIndex = 0
        vertexDescriptor.attributes[1].format = .float2 // texCoord
        vertexDescriptor.attributes[1].offset = MemoryLayout<vector_float2>.stride
        vertexDescriptor.attributes[1].bufferIndex = 0
        vertexDescriptor.layouts[0].stride = MemoryLayout<Vertex>.stride
        pipelineDescriptor.vertexDescriptor = vertexDescriptor

        do {
            pipelineState = try device.makeRenderPipelineState(descriptor: pipelineDescriptor)
        } catch {
            fatalError("Failed to create pipeline state: \(error)")
        }
    }

     func buildSamplerState() {
        let samplerDescriptor = MTLSamplerDescriptor()
        samplerDescriptor.minFilter = .linear // 线性滤波,拉伸时效果更平滑
        samplerDescriptor.magFilter = .linear
        samplerDescriptor.sAddressMode = .clampToEdge // 超出边界时取边缘像素
        samplerDescriptor.tAddressMode = .clampToEdge
        samplerState = device.makeSamplerState(descriptor: samplerDescriptor)
    }

    // 由 Representable 的 updateUIView 调用
    func updateCornerPoints(topLeft: vector_float2, topRight: vector_float2, bottomLeft: vector_float2, bottomRight: vector_float2) {
        currentCornerPoints = CornerPoints(topLeft: topLeft, topRight: topRight, bottomLeft: bottomLeft, bottomRight: bottomRight)
        // 将新的角点数据写入缓冲区
        let bufferPointer = cornerPointsBuffer.contents()
        memcpy(bufferPointer, &currentCornerPoints, MemoryLayout<CornerPoints>.stride)
    }

    func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
        // 如果你的渲染依赖于视图尺寸(例如用于坐标转换),在这里处理
    }

    func draw(in view: MTKView) {
        guard let drawable = view.currentDrawable,
              let renderPassDescriptor = view.currentRenderPassDescriptor,
              let commandBuffer = commandQueue.makeCommandBuffer(),
              let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor)
        else { return }

        // 设置渲染管线
        renderEncoder.setRenderPipelineState(pipelineState)

        // 设置顶点缓冲区
        renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)

        // 设置包含角点位置的缓冲区,让顶点着色器能读到
        renderEncoder.setVertexBuffer(cornerPointsBuffer, offset: 0, index: 1)

        // 设置纹理和采样器,让片段着色器能读到
        renderEncoder.setFragmentTexture(texture, index: 0)
        renderEncoder.setFragmentSamplerState(samplerState, index: 0)

        // 绘制构成四边形的两个三角形 (通常用 Triangle Strip)
        // 顶点顺序: 左上(0), 右上(1), 左下(2), 右下(3)
        // Triangle Strip: 0 -> 1 -> 2 -> 3 会构成两个三角形: (0,1,2) 和 (1,3,2)
        renderEncoder.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4)

        // 完成渲染命令编码
        renderEncoder.endEncoding()

        // 安排 drawable 的呈现
        commandBuffer.present(drawable)

        // 提交命令到 GPU 执行
        commandBuffer.commit()
        // commandBuffer.waitUntilCompleted() // 可以加上用于调试,但会阻塞线程
    }
}

4. Metal Shader 代码 (.metal 文件)

你需要一个 .metal 文件,比如 Shaders.metal,包含顶点和片段着色器的代码。

#include <metal_stdlib>
using namespace metal;

// 顶点着色器的输入结构,与 Swift 中的 Vertex 对应
struct VertexIn {
    float2 position [[attribute(0)]]; // 从 buffer 0 读取
    float2 texCoord [[attribute(1)]]; // 从 buffer 0 读取
};

// 从 Swift 传来的角点位置
struct CornerPoints {
    float2 topLeft;
    float2 topRight;
    float2 bottomLeft;
    float2 bottomRight;
};

// 顶点着色器的输出结构(传递给片段着色器)
struct VertexOut {
    float4 position [[position]]; // 变换后的顶点屏幕坐标
    float2 texCoord;            // 原始纹理坐标,透传
};

// 顶点着色器
vertex VertexOut vertexShader(VertexIn vertexIn [[stage_in]],
                               constant CornerPoints &corners [[buffer(1)]]) // 从 buffer 1 读取角点
{
    VertexOut out;
    float2 uv = vertexIn.position; // 使用原始 position (0->1) 作为插值因子

    // 使用双线性插值根据原始顶点位置(uv)和目标角点计算最终屏幕位置
    // top = lerp(topLeft, topRight, u)
    // bottom = lerp(bottomLeft, bottomRight, u)
    // final = lerp(top, bottom, v)
    float2 top = mix(corners.topLeft, corners.topRight, uv.x);
    float2 bottom = mix(corners.bottomLeft, corners.bottomRight, uv.x);
    float2 finalPos = mix(top, bottom, uv.y);

    // Metal 需要的屏幕坐标是 NDC (-11)
    // 如果传入的 corners 是 01 范围,需要转换: (pos * 2.0 - 1.0)
    // 同时 Y 轴可能需要反转,Metal NDC Y向上为正
    out.position = float4(finalPos.x * 2.0 - 1.0, (1.0 - finalPos.y) * 2.0 - 1.0, 0.0, 1.0);

    // 直接传递原始纹理坐标给片段着色器
    out.texCoord = vertexIn.texCoord;

    return out;
}

// 片段着色器
fragment float4 fragmentShader(VertexOut fragmentIn [[stage_in]],
                                texture2d<float> imageTexture [[texture(0)]], // 纹理对象
                                sampler imageSampler [[sampler(0)]])          // 采样器
{
    // 使用从顶点着色器传来的(经过插值的)纹理坐标对纹理进行采样
    float4 color = imageTexture.sample(imageSampler, fragmentIn.texCoord);
    return color;
}

关键点解释:

  • 顶点着色器 (vertexShader):
    • 接收每个原始顶点的信息 (VertexIn) 和所有四个目标角点的位置 (CornerPoints from buffer(1)).
    • 利用原始顶点的 position(通常是 0 到 1 范围内,可以看作是其在原始正方形中的相对位置 uv)进行双线性插值 (mix 函数)。这可以计算出该顶点在被拖拽后的新屏幕位置 finalPos
    • 将计算出的 finalPos(这里假设它是 0 到 1 范围)转换为 Metal 需要的规范化设备坐标 (NDC),范围是 -1 到 +1,并且 Y 轴向上。float4(finalPos.x * 2.0 - 1.0, (1.0 - finalPos.y) * 2.0 - 1.0, 0.0, 1.0) 完成了这个转换。
    • 把原始的纹理坐标 vertexIn.texCoord 原封不动地传给输出结构 VertexOuttexCoord 成员。GPU 的光栅化阶段会自动对这个 texCoord 进行插值。
  • 片段着色器 (fragmentShader):
    • 接收插值后的 VertexOut(我们关心的是其中的 texCoord)。
    • 接收图片纹理 (imageTexture) 和纹理采样器 (imageSampler)。
    • 使用插值得到的 fragmentIn.texCoordimageTexture 进行采样,获取该像素对应的原始图片颜色。
    • 返回这个颜色值。

5. 整合与运行

  1. Shaders.metal 文件添加到你的 Xcode 项目中,确保它被正确编译(通常 Xcode 会自动处理)。
  2. 确保你的 Renderer 类正确初始化了所有 Metal 对象(Device, Queue, PipelineState, Buffers, Texture, Sampler)。
  3. ImageStretchView 放入你的 SwiftUI 视图层级中。
  4. 确保你的 Assets.xcassets 中包含了名为 "your_image_name" 的图片。
  5. 运行 App,你应该能看到图片,并且可以通过拖动蓝色圆圈来实时、自由地拉伸变形图片。

进阶与优化

  • 性能: 对于简单的四边形变形,性能通常不是问题。但如果需要更复杂的几何体或效果,关注:
    • 避免在 draw(in:) 中进行昂贵的操作,尽量提前准备好数据。
    • 使用合适的纹理采样器设置(MTLSamplerMinMagFilterLinear 通常能提供更平滑的拉伸效果)。
    • 按需渲染 (enableSetNeedsDisplay = true, isPaused = true 并通过 setNeedsDisplay() 触发)可以节省电量。
  • 边界处理: 当前的拖拽没有限制,角点可能会交叉导致图像翻转或出现奇怪的重叠。你可以添加逻辑:
    • 在 SwiftUI 的 DragGesture.onChanged 中检查新位置是否会导致非法形状(例如,左边的点跑到了右边的点右侧),如果是则阻止更新。
    • 或者在 Metal 顶点着色器中处理,但可能更复杂。
  • 抗锯齿: MTKView 默认可能没有开启多重采样抗锯齿(MSAA)。如果边缘有锯齿感,可以在创建 MTKView 时设置 sampleCount(例如 4),并在 Renderer 中相应地配置渲染管线的 rasterSampleCount 以及处理 MSAA 纹理的解析。
  • 坐标系精度: 确保 SwiftUI 的坐标、UIViewRepresentable 中传递的坐标、以及 Metal 着色器中使用的坐标系之间转换是准确无误的。尤其是 Y 轴方向(UIKit/SwiftUI 左上角为 (0,0) Y 向下,Metal NDC Y 向上)。示例代码中的转换需要仔细核对。
  • 边缘拉伸(而非角点): 如果你想实现拖动边缘来拉伸,思路类似。你需要识别拖拽的是哪条边,然后更新这条边对应的两个顶点的位置。例如,拖动右边缘,就同时更新右上角和右下角的 X 坐标。

通过结合 SwiftUI 的手势处理和状态管理,以及 Metal 强大的自定义渲染能力,就能实现这种高度交互、自由变形的图像效果。