ShaderView is a SwiftUI package designed for integrating and displaying Metal shaders. It simplifies the process of using custom shaders written in .metal
in your SwiftUI applications.
Add any .metal file to the project, can be empty. This will ensure that a metal library can be created and default shaders can be compiled.
ShaderView()
allows you to display any shader defined in a .metal
file. To use your custom shader, simply specify the name of the shader when initializing ShaderView
ShaderView()
fragmentShaderName
: Optional String. Name of the fragment shader. Defaults to a standard shader if not provided.vertexShaderName
: Optional String. Name of the vertex shader. Defaults to a standard shader if not provided.fallbackView
: Optional View. A view displayed in case of an error. Defaults toFallbackView()
if not provided.placeholderView
: Optional View. A view displayed while shaders are loading. Defaults toPlaceholderView()
if not provided.shaderInput
: Optional ShaderInputable. The input for the shader. Defaults to a new instance ofShaderInput()
if not provided.
When using only one of the default shaders (defaultFragmentShader or defaultVertexShader) it is crucial to match output of the vertex shader to input of the fragment shader. Here are some default layouts which will ensure that your shaders will work with the package especially when you choose to only use one of the default shaders.
These structs are used by my package to pass information to shaders. They must be included in .metal files even when using custom shaders since the packages buffers expect them.
VertexOutput
: Default vertex shaders output and default fragment shaders expected input.
struct VertexOutput {
float4 position [[position]];
float2 screenCoord;
};
ViewPort:
Default viewport structure for size, required input by both default shaders at buffer position 0.
struct Viewport {
float2 size;
};
Here is a template that ensures compatibility with the package. When customizing shaders, keep the parameters the same except for shaderInput. ShaderInput can be replaced with a custom one, more about it in Customizing shader input section below.
fragment float4 customFragmentShader(VertexOutput in [[stage_in]],
constant Viewport& viewport [[buffer(0)]],
constant ShaderInput& shaderInput [[buffer(1)]]){
// Shader logic...
return float4(red, green, blue, alpha); //Standard return format
}
Here is a template that ensures compatibility with the package. When customizing, make sure to have same input and output types.
vertex VertexOutput customVertexShader(uint vertexID [[vertex_id]],
constant Viewport& viewport [[buffer(0)]]) {
// Shader logic...
return VertexOutput; //Standard return format compatable with the default fragment shader
}
Any shader input given to ShaderView
has to conform to this protocol. It requires methods used by the package for converting data to metal friendly form, and managing its changes.
public protocol ShaderInputable: AnyObject, ObservableObject{
init(time: Float)
var time: Float {get set}
var onChange: (() -> Void)? { get set }
func updateProperties(from input: any ShaderInputable)
func metalData() -> Data
}
init(time: Float)
: A constructor that initializes the shader input with a given time value.var time: Float
: A property representing the time, which is managed by the package to count time every frame.var onChange: (() -> Void)?
: An optional closure that can be called whenever the shader's properties change to change shader inputs during runtime.func updateProperties(from input: any ShaderInputable)
: A method to update the properties of the shader input from another instance conforming toShaderInputable
. Should be done to same class members without creating a new instance for best performance of the package.func metalData() -> Data
: A method to convert shader input properties into a Metal-compatibleData
format. Which will be used by package to make correct size of buffer.
class ShaderInputableExample: ShaderInputable {
var time: Float = 0.0
var onChange: (() -> Void)?
//New variable example. Changing this value triggers the 'onChange' closure, allowing the shader to respond to changes in its active state.
var isActive: Bool = true{
didSet{
onChange?()
}
}
required init(time: Float){
self.time = time
self.isActive = true
}
func updateProperties(from input: any ShaderInputable){
guard let input = input as? ShaderInputableExample else {
return
}
self.isActive = input.isActive;
}
func metalData() -> Data {
var metalInput = CustomMetalShaderInput(time: self.time, isActive: self.isActive)
return Data(bytes: &metalInput, count: MemoryLayout<CustomMetalShaderInput>.size)
}
}
class SubclassedShaderInput: ShaderInput {
//New variable example. Changing this value triggers the 'onChange' closure, allowing the shader to respond to changes in its active state.
var isActive: Bool {
didSet {
onChange?()
}
}
required init(time: Float) {
self.isActive = true
super.init(time: time)
}
override func updateProperties(from input: any ShaderInputable){
guard let input = input as? SubclassedShaderInput else {
return
}
self.isActive = input.isActive;
}
override func metalData() -> Data {
var metalInput = CustomMetalShaderInput(time: self.time, isActive: self.isActive)
return Data(bytes: &metalInput, count: MemoryLayout<CustomMetalShaderInput>.size)
}
}
For metal to be able to read shader input data correctly, the buffer sizes and expectations need to match. It's recommended to define custom shader input struct that will also be defined in metal in the same way.
- Note: Sometimes same type of variables have different sizes in swift and metal, and buffers don't match. Padding can be used to fix this problem of different size of buffers on passing data to shaders.
This example is compatible with SubclassedShaderInput and ShaderInputableExample from above.
struct CustomMetalShaderInput {
var time: Float
var isActive: Bool //new variable example
var padding: [UInt8] = Array(repeating: 0, count: 3) //needed for difference in size of boolean in swift vs metal
}
To change log level use ShaderViewLogger.setLogLevel(level: newLevel)
where newLevel is any level from
public enum ShaderViewLogLevel: Int {
case none = 0
case error = 1
case debug = 2
}