Skip to content

jazzychad/MeshWarpEffect

Repository files navigation

MeshWarpEffect

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
Checkerboard with a 3x3 grid of control points in their resting positions The same checkerboard smoothly distorted after dragging the center control point

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.

Requirements

The Metal shader ships inside the package and is loaded from the package's resource bundle — your app doesn't need to provide anything.

Installation

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"]
    )
]

Quick start

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.

Core concepts

Vertices: MeshPointPair

Every mesh vertex is a pair of positions:

  • from — where the vertex sits on the undeformed view
  • to — 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).

Faces: MeshFace

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.

maxSampleOffset

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).

Defining faces: ordering and winding

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.

How mesh warping works

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:

  1. Normalizes the pixel position to UV space (divides by the view size).
  2. 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.)
  3. 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.
  4. Maps forward through the original quad. The same (u, v) is bilinearly interpolated across the four original (from) corners — producing the source position.
  5. 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 demo app

The package contains an interactive demo — a checkerboard with a 3×3 grid of draggable control points (shown at the top of this README).

  1. Clone the repo and open the package folder in Xcode.
  2. Previews: select the MeshWarpEffectDemoUI scheme and open MeshWarpDemoView.swift. (Xcode can't render previews in SwiftPM executable targets, which is why the demo view lives in its own library target.)
  3. Run it: select the MeshWarpEffectDemoApp scheme 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.

Edge behavior and tips

  • 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 == to along 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.
  • 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.

License

MIT — see LICENSE.

About

SwiftUI distortionEffect for implementing a mesh warp transform on a view

Resources

License

Stars

29 stars

Watchers

1 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors