A SwiftUI view modifier that warps any view through a 2D mesh of control points, rendered entirely on the GPU with a Metal distortion shader. The view stays fully live — no snapshotting — so you can warp text, images, gradients, even interactive controls, and animate the mesh in real time.
This package is released as a part of my Deep Dish Swift 2026 talk "Reverse Engineering the macOS Genie Animation" - YouTube video here.
| Identity mesh | Warped mesh |
|---|---|
![]() |
![]() |
You define a mesh as a set of vertices (each with an original position and a warped position) and quad faces connecting them. The shader then redraws the view so that the content that originally sat under each vertex appears under that vertex's warped position, smoothly interpolating everything in between.
- iOS 18.0+ / macOS 14.0+ (built on SwiftUI's
visualEffect+distortionEffectAPIs) - Swift 6 toolchain
The Metal shader ships inside the package and is loaded from the package's resource bundle — your app doesn't need to provide anything.
In Xcode: File → Add Package Dependencies… and enter:
https://github.com/jazzychad/MeshWarpEffect
Or in Package.swift:
dependencies: [
.package(url: "https://github.com/jazzychad/MeshWarpEffect.git", from: "0.0.1")
],
targets: [
.target(
name: "YourTarget",
dependencies: ["MeshWarpEffect"]
)
]A single quad covering the whole view, with the bottom-right corner pulled toward the center:
import SwiftUI
import MeshWarpEffect
struct WarpedCardView: View {
// One quad: corner indices 0-3, in perimeter order (clockwise on screen).
let corners = [
CGPoint(x: 0, y: 0), // 0: top left
CGPoint(x: 1, y: 0), // 1: top right
CGPoint(x: 1, y: 1), // 2: bottom right
CGPoint(x: 0, y: 1), // 3: bottom left
]
// The same vertices with the bottom-right corner pulled inward.
let warped = [
CGPoint(x: 0, y: 0),
CGPoint(x: 1, y: 0),
CGPoint(x: 0.8, y: 0.75), // 2: pulled toward the center
CGPoint(x: 0, y: 1),
]
var body: some View {
LinearGradient(
colors: [.blue, .purple, .orange],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.frame(width: 300, height: 300)
.padding(1) // transparent edge pixel — see "Edge behavior" below
.meshWarpEffect(
pointPairs: zip(corners, warped).map { MeshPointPair(from: $0, to: $1) },
faces: [MeshFace(i0: 0, i1: 1, i2: 2, i3: 3)],
maxSampleOffset: .zero
)
}
}To animate, make the warped points @State (or derive them from a gesture) and update them — the mesh is rebuilt on every view update. The included demo app does exactly this with draggable control points.
Every mesh vertex is a pair of positions:
from— where the vertex sits on the undeformed viewto— where that vertex should appear after warping
Both are in normalized UV coordinates: (0, 0) is the view's top-left corner, (1, 1) is its bottom-right, and y increases downward (SwiftUI convention). They are resolution-independent — the same mesh works for any view size.
to points may move outside the unit square; the content will visibly travel outside the view's original bounds (see maxSampleOffset).
A face is a quadrilateral defined by four indices into your pointPairs array, e.g. MeshFace(i0: 0, i1: 1, i2: 4, i3: 3). Faces tell the shader which regions of the mesh deform together. Index ordering matters — see the next section.
How far (in points, per axis) any pixel of content is allowed to travel beyond the view's original bounds. SwiftUI uses it to pad the region it renders. If your warped mesh stays inside the unit square, .zero is fine; if vertices can be dragged outside the view, set it to the farthest distance content can move (the demo just uses the view's own size).
This is the one place you can silently go wrong, so here are the rules.
Vertices for a 3×3 grid, indexed row-major:
0 ──── 1 ──── 2
│ │ │
3 ──── 4 ──── 5
│ │ │
6 ──── 7 ──── 8
Its four faces:
let faces = [
MeshFace(i0: 0, i1: 1, i2: 4, i3: 3), // top left cell
MeshFace(i0: 1, i1: 2, i2: 5, i3: 4), // top right cell
MeshFace(i0: 3, i1: 4, i2: 7, i3: 6), // bottom left cell
MeshFace(i0: 4, i1: 5, i2: 8, i3: 7), // bottom right cell
]Rule 1 — walk the perimeter. i0 → i1 → i2 → i3 must trace the quad's outline, each index adjacent to the next. Never "hop across" the quad:
0 ────── 1 0 ────── 1
│ │ ╲ ╱
│ ✓ │ ╳ ✗ bowtie — edges cross
│ │ ╱ ╲
3 ────── 2 2 ────── 3
(0, 1, 2, 3) (0, 2, 1, 3)
A bowtie/Z ordering produces degenerate geometry and garbage rendering.
Rule 2 — i0 and i2 are opposite corners. Internally each quad is split into two triangles, (i0, i1, i2) and (i0, i2, i3), so the quad's diagonal always runs i0 – i2:
i0 ────── i1
│ ╲ │
│ ╲ │ diagonal: i0 ─ i2
│ ╲ │
i3 ────── i2
This falls out automatically when you follow Rule 1 — it's why perimeter order is required.
Rule 3 — winding direction is your choice, but be consistent. Both clockwise and counterclockwise perimeter orders work (point-in-triangle testing and the inverse bilinear mapping are winding-independent), and the starting corner doesn't matter: (0, 1, 4, 3), (4, 3, 0, 1), and (3, 4, 1, 0) all describe the same valid quad. Pick one convention — this package and its demo use start at top-left, wind clockwise on screen (TL, TR, BR, BL). One heads-up if you're used to 3D graphics: SwiftUI's coordinate space is y-down, so what looks clockwise on screen is mathematically counterclockwise in a y-up system. Since winding doesn't affect rendering here, consistency is purely for your own sanity when generating face indices in loops.
Rule 4 — keep warped quads convex. The math is well-defined while each to-quad stays convex (or nearly so). If a quad folds over itself — one corner dragged past the opposite edge — the inverse mapping becomes ambiguous and you'll see artifacts in that cell. Original (from) quads are typically a regular grid, but they don't have to be — they follow the same rules.
SwiftUI distortion shaders run backwards. For every output pixel, the shader must answer: "which pixel of the original view should appear here?" Mapping destination → source (instead of pushing source pixels forward) guarantees every output pixel gets exactly one answer.
For each destination pixel, the meshTransformTrisQuads shader:
- Normalizes the pixel position to UV space (divides by the view size).
- Finds the containing face. Each quad was pre-split on the CPU into the two triangles from Rule 2. The shader walks the triangle list and uses a barycentric containment test to find the triangle — and therefore the quad — whose warped (
to) geometry contains this pixel. (Triangle tests are cheap and unambiguous; quads are not.) - Inverts the bilinear map. Within that quad, it solves a small quadratic to find the parametric coordinates
(u, v)such that bilinearly interpolating the four warped corners at(u, v)lands exactly on this pixel. - Maps forward through the original quad. The same
(u, v)is bilinearly interpolated across the four original (from) corners — producing the source position. - Samples the view's content at that source position (scaled back to view coordinates).
Pixels not inside any warped face sample the source at (0, 0) — see below.
Why quads and bilinear interpolation, rather than just triangles? A pure triangle mesh would map each triangle affinely, which is cheaper but creates a visible crease along every quad's diagonal when the mesh deforms. Inverse-bilinear interpolation treats the quad as a single smooth patch — the classic "mesh warp" look. The triangles only exist to make the "which face am I in?" lookup fast and robust.
The package contains an interactive demo — a checkerboard with a 3×3 grid of draggable control points (shown at the top of this README).
- Clone the repo and open the package folder in Xcode.
- Previews: select the
MeshWarpEffectDemoUIscheme and openMeshWarpDemoView.swift. (Xcode can't render previews in SwiftPM executable targets, which is why the demo view lives in its own library target.) - Run it: select the
MeshWarpEffectDemoAppscheme with the My Mac destination. Bare SwiftPM executables don't launch on iOS simulators — use the previews to interact with it on iOS.
Development note: swift build / swift test work from the command line, but the SwiftPM CLI doesn't compile Metal shaders into package bundles, so CLI-built binaries render no distortion. Use Xcode for anything visual.
- Pixels outside the warped mesh sample the source at
(0, 0)— whatever color your view's top-left pixel has will smear across uncovered regions. Two easy strategies:- Keep your border vertices pinned (
from == toalong the edges) so the mesh always covers the full view, and only warp interior vertices. - Give the content a transparent origin pixel — e.g.
.padding(1)before.meshWarpEffect(...)— so uncovered regions render as transparent. The demo does this, which is why dragging an edge point inward reveals clean transparency rather than streaks.
- Keep your border vertices pinned (
- Performance: the shader linearly scans the triangle list per pixel, so cost scales with face count. Meshes up to dozens of quads are effectively free; think twice before feeding it thousands.
- Faces are independent — they don't have to form a grid or even touch. Any collection of valid quads works.
MIT — see LICENSE.

