Back to Blog Home
← all posts

How to use Metal shaders on iOS with NativeScript

January 14, 2024 — by Nathan Walker

You can touch the GPU directly by adding Metal shaders to your NativeScript project, let's look at how to do so.

Metal powers hardware-accelerated graphics on Apple platforms by providing a low-overhead API, rich shading language, tight integration between graphics and compute, and an unparalleled suite of GPU profiling and debugging tools.

With Metal, apps can leverage a GPU to quickly render complex scenes and run computational tasks in parallel. For example, apps in these categories use Metal to maximize their performance:

  • Games that render sophisticated 2D or 3D environments
  • Video processing apps, like Final Cut Pro
  • Scientific research apps that analyze and process large datasets
  • Fully immersive visionOS apps

Add a Metal shader

A shader is written in MSL (Metal Shading Language), which is a variant of C++ designed for GPU programming. There are a lot of neat samples out there you can play with and can work with C++ programmers to create them. Let's just try a sample to understand how to use them in our NativeScript project.

These files are written into a .metal file which is just another iOS app resource like anything else.

  • Create App_Resources/iOS/src/sample.metal with the following:
#include <metal_stdlib>

using namespace metal;

// Lines
float hash( float n ) {
    return fract(sin(n)*753.5453123);
}

// Slight modification of iq's noise function.
float noise(vector_float2 x )
{
    vector_float2 p = floor(x);
    vector_float2 f = fract(x);
    f = f*f*(3.0-2.0*f);
    
    float n = p.x + p.y*157.0;
    return mix(
               mix( hash(n+  0.0), hash(n+  1.0),f.x),
               mix( hash(n+157.0), hash(n+158.0),f.x),
               f.y);
}


float fbm(vector_float2 p, vector_float3 a)
{
    float v = 0.0;
    v += noise(p*a.x)*0.50 ;
    v += noise(p*a.y)*1.50 ;
    v += noise(p*a.z)*0.125 * 0.1; // variable
    return v;
}


vector_float3 drawLines(
  vector_float2 uv,
  vector_float3 fbmOffset,
  vector_float3 color1,
  vector_float3 colorSet[4],
  float secs
)
  {
    float timeVal = secs * 0.1;
    vector_float3 finalColor = vector_float3( 0.0 );
    vector_float3 colorSets[4] = {
        vector_float3(0.7, 0.05, 1.0),
        vector_float3(1.0, 0.19, 0.0),
        vector_float3(0.0, 1.0, 0.3),
        vector_float3(0.0, 0.38, 1.0)
    };
    
    for( int i=0; i < 4; ++i )
    {

        float indexAsFloat = float(i);
        float amp = 80.0 + (indexAsFloat*0.0);
        float period = 2.0 + (indexAsFloat+2.0);
        float thickness = mix( 0.4, 0.2, noise(uv*2.0) );
        
        float t = abs( 1. /(sin(uv.y + fbm( uv + timeVal * period, fbmOffset )) * amp) * thickness );
        
        finalColor +=  t * colorSets[i];
    }

    
    for( int i=0; i < 4; ++i )
    {
  
        float indexAsFloat = float(i);
        float amp = 40.0 + (indexAsFloat*5.0);
        float period = 9.0 + (indexAsFloat+2.0);
        float thickness = mix( 0.1, 0.1, noise(uv * 12.0) );
        
        float t = abs( 1. /(sin(uv.y + fbm( uv + timeVal * period, fbmOffset )) * amp) * thickness );
        
        finalColor +=  t * colorSets[i] * color1;
    }
    
    return finalColor;
}


[[ stitchable ]] half4 timeLines(
  float2 position,
  half4 color,
  float4 bounds,
  float secs,
  float tapValue
) {
    
    vector_float2 uv = ( position / bounds.w ) * 1.0 - 1.0;
    uv *= 1.0 + 0.5;

    
    vector_float3 lineColor1 = vector_float3( 1.0, 0.0, 0.5 );
    vector_float3 lineColor2 = vector_float3( 0.3, 0.5, 1.5 );
    
    float spread = abs(tapValue);
    vector_float3 finalColor = vector_float3(0);
    vector_float3 colorSet[4] = {
        vector_float3(0.7, 0.05, 1.0),
        vector_float3(1.0, 0.19, 0.0),
        vector_float3(0.0, 1.0, 0.3),
        vector_float3(0.0, 0.38, 1.0)
    };
    
    float t = sin( secs ) * 0.5 + 0.5;
    float pulse = mix( 0.05, 0.20, t);

    
    finalColor = drawLines(uv, vector_float3( 65.2, 40.0, 4.0), lineColor1, colorSet, secs * 0.1) * pulse;
    finalColor += drawLines( uv, vector_float3( 5.0 * spread/2, 2.1 * spread, 1.0), lineColor2, colorSet, secs );
    
    return half4(half3(finalColor), 1.0);
    
}

By including files into App_Resources/iOS/src we are allowing the NativeScript CLI to auto include them into our Xcode project which makes them available for use anywhere in our project.

We can enable usage of that shader via a View extension modifier which can be wired a number of ways. We'll just drop in a SwiftUI view to play around with it in this example.

  • Create a App_Resources/iOS/src/Sample.swift with the following:
import SwiftUI

struct MetalSample: View {
    @State var start = Date()
    @State var tapCount: CGFloat = 0

    var body: some View {
        ZStack {
            TimelineView(.animation) { context in
                Rectangle()
                    .foregroundStyle(.white)
                    .timeLines(
                        seconds: context.date.timeIntervalSince1970 - self.start.timeIntervalSince1970,
                        tapValue: tapCount
                    )
            }
            Button(action: {
               self.tapCount += 1
            }) {
                Text("Metal is Dope")
            }
        }
    }
}

@objc
class MetalSampleProvider: UIViewController, SwiftUIProvider {
    private var swiftUI: MetalSample?

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }

    required public init() {
        super.init(nibName: nil, bundle: nil)
    }

    public override func viewDidLoad() {
        super.viewDidLoad()
        swiftUI = MetalSample()
        setupSwiftUIView(content: swiftUI)
    }

    /// Receive data from NativeScript
    func updateData(data: NSDictionary) {}
    /// Allow sending of data to NativeScript
    var onEvent: ((NSDictionary) -> ())?
}


extension View {
    
    func timeLines(seconds: Double,  tapValue: CGFloat ) -> some View {
        self
            .colorEffect(
                ShaderLibrary.default.timeLines(
                    .boundingRect,
                    .float(seconds),
                    .float(tapValue))
            )
    }
}

Use Metal in our NativeScript layout

We can now initialize our provider to use it within any layout by using the SwiftUI plugin

npm install @nativescript/swift-ui

Inside our main.ts (or app.ts) bootstrap file (or root component) we can do the following:

import { registerElement } from '@nativescript/angular';
import { registerSwiftUI, SwiftUI, UIDataDriver } from '@nativescript/swift-ui';

// we could also run `ns typings ios` to include our custom types if desired
declare var MetalSampleProvider: any;
registerSwiftUI('metalSample', view => new UIDataDriver(MetalSampleProvider.alloc().init(), view));
registerElement('SwiftUI', () => SwiftUI);

It's now available to use anywhere, learn more here:

<GridLayout class="bg-black">
  <SwiftUI swiftId="metalSample" class="w-full h-full"></SwiftUI>
</GridLayout>

Note: Depending on the features you plan to use, you may want to increase your target iOS by including this line in your App_Resources/iOS/build.xcconfig file:

IPHONEOS_DEPLOYMENT_TARGET = 17.0;

Exploring more?

There's a lot you can do here by even including the Metal TypeScript declarations here into your references.d.ts, you can learn more here. This would allow you to dive deeper into using it's Library references as discussed here:

https://developer.apple.com/documentation/metal/performing_calculations_on_a_gpu