MNSL is a shader language designed for use within the Mana Multimedia Framework. Some design goals of MNSL are:
- Lightweight and dependency free.
- Hackable codebase.
- Easy to use and understand.
- Support for multiple shader languages.
Generally these goals have been met but there are still some areas that need improvement.
There is a very basic shadertoy like tool to try out MNSL: https://mki.sh/mnsl.html
The API is very straight forward, you simply import mnsl.MNSL
and create a context from source.
After you've created a context you can emit other shader languages with a specified config. Below you will see an example on how to load from a file and emit GLSL 300 ES:
import mnsl.MNSL;
import mnsl.MNSLContext;
import mnsl.glsl.MNSLGLSLVersion;
import mnsl.spirv.MNSLSPIRVShaderType;
import sys.io.File;
class Main {
public static function main() {
var shader: MNSLContext = MNSL.fromFile("my_shader.mns" , {});
if (shader.hasErrors()) {
trace("Shader has errors: " + shader.getErrors().join("\n"));
return;
}
if (shader.hasWarnings()) {
trace("Shader has warnings: " + shader.getWarnings().join("\n"));
}
var glsl: String = shader.emitGLSL({
version: GLSL_VER_300,
versionDirective: GLSL_CORE
});
var spirv: Bytes = shader.emitSPIRV({
shaderType: SPIRV_SHADER_TYPE_FRAGMENT
});
File.saveContent("my_shader.glsl", glsl);
File.saveBytes("my_shader.spv", spirv);
}
}
The options for glsl can be found in Source/mnsl/glsl/MNSLGLSLConfig.hx
.
Note that these options are language specific and may not be available for other languages.
Printers are used to output shader code based on the typed AST of MNSL. Currently the following prints are available:
- GLSL (
mnsl.glsl.MNSLGLSLPrinter
) - SPIR-V (
mnsl.spirv.MNSLSPIRVPrinter
, NOTE: This printer is still experimental and may not work as expected)
Every shader should contain a main function, this is the entry point of the shader. The main function is defined as follows:
func main() {
// ... shader code ...
}
Currently there is no way to define multiple main functions depending on the type of shader.
MNSL has support for defines.
@define(MAX_LIGHTS, 10)
@define(PI, 3.141592653589793)
Within code it will work like a read-only variable defined in the top-level scope. Additionally defines may be used within input, output and uniform tags.
@define(MAX_LIGHTS, 10)
@uniform(lightPos: Vec3[MAX_LIGHTS])
You may also store code in defines, for example:
@define(PI, 3.141592)
@define(SIN_PI, sin(PI))
func main() {
return SIN_PI;
}
Do note how the order of defines is not important, you may define PI
after SIN_PI
and it will still work as expected.
MNSL Contains a basic preprocessor.
#include "pbr.mns"
func main() {
#if TEST_DEFINE
return 1.0;
#else
return vec2(1.0);
#end
}
By default #include
will look in the directory of the shader. When loaded from a source string it will look in ./
.
You can set the "root" path using the rootPath
option:
var shader: MNSLContext = MNSL.fromFile("my_shader.mns" , {
rootPath: "/my/root/path"
});
If you are running on a non-sys target you may use MNSL_NO_SYS
to disable the default #include
implementation.
You can redefine the behaviour of #include
by using the preprocessorIncludeFunc
option when creating a Shader. The default implementation is:
(path: String, root: String) -> {
#if !MNSL_NO_SYS
var filePath = haxe.io.Path.join([root, path]);
if (sys.FileSystem.exists(filePath)) {
return sys.io.File.getContent(filePath);
}
return null;
#else
return null;
#end
};
You can also create defines for the preprocessor, these will be used to determine which code to include or exclude.
var shader: MNSLContext = MNSL.fromFile("my_shader.mns" , {
preprocessorDefines: [
'TEST_DEFINE'
]
});
Note that defines and preprocessor defines are not the same thing:
- Preprocessor defines can only be defined using the API and are only true/false.
- Defines can be defined in the shader code (or from the API), can hold any value (including expressions) and can be used as values in your shader code itself.
Currently the preprocessor supports the following directives:
#include <path>
: Includes the file at the given path.#if <condition>
: If the condition is true, the code between#if
and#end
will be included.#else
: If the condition is false, the code between#else
and#end
will be included.#end
: Ends the current#if
or#else
block.
MNSL is designed to output a very verbose AST, this is done to increase the amount of languages that can be generated from it.
Generally this results in a lot of redundant and inefficient code, so MNSL comes with an optimizer that will try to reduce the amount of code and make it more efficient.
The optimizer is enabled by default but can be disabled by providing an empty array of optimizations to optimizerPlugins
to the compiler options. Note that not all printers may support every optimization.
Currently MNSL provides the following optimizations:
ScalarVectorInit
- This convertsVecN(x, x, x, x)
toVecN(x)
wherex
is a scalar value.SwizzleAccess
- This convertsVecN(vec.x, vec.y, vec.z)
tovec.xyz
wherevec
is a vector andx
,y
,z
are the components of the vector.SwizzleAssign
- This convertsvec.x = x; vec.y = y;
tovec.xy = (x, y);
wherevec
is a vector andx
,y
are the components of the vector.
It is important to note that optimizations are stacked on top of each other, so the order in which they are added to the optimizerPlugins
array matters.
Inputs is what your shader will receive from the outside world, outputs is what your shader will output to the outside world and uniforms are variables that are shared between shaders. These tags must be given a type. Also note that these different tag types can share the same name, depending on the printer the actual names may be modified slightly.
@input(Position: Vec3)
@input(Colour: Vec3)
@output(Colour: Vec4)
func main() {
output.Position = input.Position;
output.Colour = input.Colour;
}
Depending on your configuration some inputs or outputs may be pre-defined for you. Below is when you may use what struct:
@input
is used for inputs to the shader, these are read-only and cannot be modified, access withinput.<name>
.@output
is used for outputs from the shader, these are write-only and cannot be read from, access withoutput.<name>
.@uniform
is used for uniforms, these are read-write and can be accessed withuniform.<name>
.
A function can be defined as follows:
func someFunction() {}
Given parameters:
func someFunction(x, y) {}
Given an explicit type for the parameters:
func someFunction(x: Float, y: Int) {}
And given an explicit return type:
func someFunction(x: Float, y: Int): Vec3 {}
Alternatively you can also define functions like:
func fsquared(x) x * x;
func fsquared(x: Float) x * x;
func fsquared(x: Float): Float x * x;
func fsquared(x) -> x * x;
func fsquared(x: Float) -> x * x;
func fsquared(x: Float): Float -> x * x;
The main restriction of functions is that you cannot recursively call functions. Also note that calling a function before it is defined is not allowed.
One may use the inline
keyword to indicate that a function should be inlined.
Note that this is only a hint to the compiler and it may choose to ignore it.
inline func someFunction(x) -> x;
Variables are pretty basic in MNSL, there are 4 valid ways to define a variable:
var x;
var x: Int;
var x = 5;
var x: Int = 5;
They can later be assigned using
x = 10;
And accessed using
x;
Some variables are only accessible at compile time, these are read-only and cannot be passed to other functions.
In some cases a variable may be a struct type. You can access the fields of a struct using the dot operator:
// considering someValue is a Vec2
myStruct.someValue.x
Vectors are internally represented as structs with some extra rules regarding their usage.
Buffers can be accessed using the square bracket operator:
// considering someBuffer is a Vec3[10]
someBuffer[0] = (1.0, 2.0, 3.0);
var index = 0;
someBuffer[index].x = 1.0;
MNSL only supports single line comments, these are defined using //
:
MNSL has a set of built-in types that are used throughout the language. Below a list of types from the source code:
public static var TUnknown(get, never): MNSLType;
public static var TString(get, never): MNSLType;
public static var TBool(get, never): MNSLType;
public static var TVoid(get, never): MNSLType;
public static var TFloat(get, never): MNSLType;
public static var TInt(get, never): MNSLType;
public static var TMat2(get, never): MNSLType;
public static var TMat23(get, never): MNSLType;
public static var TMat24(get, never): MNSLType;
public static var TMat3(get, never): MNSLType;
public static var TMat32(get, never): MNSLType;
public static var TMat34(get, never): MNSLType;
public static var TMat4(get, never): MNSLType;
public static var TMat42(get, never): MNSLType;
public static var TMat43(get, never): MNSLType;
public static var TVec2(get, never): MNSLType;
public static var TVec3(get, never): MNSLType;
public static var TVec4(get, never): MNSLType;
public static var TSampler(get, never): MNSLType;
public static var TCubeSampler(get, never): MNSLType;
public static var TCTValue(get, never): MNSLType;
public static function Template(T: String): MNSLType {
return new MNSLType('Template<$T>', true);
}
Vectors are a first-class citizen in MNSL, they are used to represent points in space, colors and other data.
You can define a vector with VecN
where N
is the number of components in the vector.
Vectors are limited to being floating point numbers!
You can initialize a vector with the following syntax:
var v: Vec3 = (1.0, 2.0, 3.0);
A scalar value can also be used to initialize a vector, this will result in a vector with all components set to the value:
var v: Vec3 = 1.0; // -> vec3 v = vec3(1.0, 1.0, 1.0);
The (...)
syntax automatically infers the type of the vector, so you can also use it without explicitly defining the type:
var v = (1.0, 2.0, 3.0); // -> vec3 v = vec3(1.0, 2.0, 3.0);
var v = (1.0, 2.0); // -> vec2 v = vec2(1.0, 2.0);
In some cases you might not want this, so you can also use the vecN
function to explicitly define the type of the vector:
var v = vec3(1.0); // -> vec3 v = vec3(1.0, 1.0, 1.0);
You can also use other vectors to initialize a vector, this will result in a vector with the same components as the original vector:
var x = (3, 4);
var y: Vec4 = (1, 2, x); // -> vec4 y = vec4(1, 2, x.x, x.y);
You can also initialize an empty vector:
var a = vec3(); // -> vec3 a = vec3(0.0, 0.0, 0.0);
var b = vec4(); // -> vec4 b = vec4(0.0, 0.0, 0.0, 1.0);
Vectors will implitly cast to other vector types using the mask (0, 0, 0, 1)
, for example:
var x: Vec2 = (1.0, 2.0);
var y: Vec4 = x; // -> vec3 y = vec3(1.0, 2.0, 0.0, 1.0);
And they may also be truncated when casting to a smaller vector type:
var x: Vec4 = (1.0, 2.0, 3.0, 4.0);
var y: Vec2 = x; // -> vec2 y = vec2(1.0, 2.0);
You can access vectors in a few wAYS
var v = (1.0, 2.0, 3.0);
var x1 = v.x;
var x2 = v[0]; // note: index must be constant int
And you may also
Matrices are also a first-class citizen in MNSL, they are used to represent transformations in space.
You can define a matrix with MatNM
or MatN
where N
is the number of rows and M
is the number of columns in the matrix.
Note that N may not equal M when using the format MatNM
, use MatN
for those.
While MNSL supports parsing strings and typing them, it should only be used for compile-time functions.
TCTValue
is a bit special as it is a type reserved for compile-time structs. Structs with this type cannot be passed as function arguments and can also not be assigned to other values or itself.
For inputs, outputs and uniforms you can define the type to be a buffer. An example of this is:
@input(x: Float[10])
Note that you must explicitly define the size of the buffer. You may additionally use defines for the size of the buffer, for example:
@define(MAX_LIGHTS, 10)
@uniform(uLightPos: Vec3[MAX_LIGHTS])
In many cases you can explicitly define the type of something, below are valid cases:
- Function Return Types (
func test(): Int {}
) - Function Parameters (
func test(x: Int) {}
) - Variable Declarations (
let x: Int = 5
) - Inputs, outputs or uniforms (
@input(x: Int)
)
Whenever you define points of interaction between your code or different shaders you are required to explicitly type the data. This currently applies to the following:
- Inputs (
@input
) - Outputs (
@output
) - Uniforms (
@uniform
)
In most cases MNSL can infer the type of a variable, parameter or return value.
func test(x) {
return x;
}
func main() {
var v: Float;
var q = test(v);
test(q);
return q;
}
The order in the above sample is as follows:
- Function
test
defined with a parameter ofTUnknown
and returnsTUnknown
. - The return type of
test
will be connected to the type of the parameterx
. - Main gets defined with no parameters and returns
TUnknown
. var v
is explicitly defined asTFloat
var q
is defined and connected to the return value oftest
, as the input totest
is aTFloat
it will also mark the parameterx
asTFloat
with the consuquence that the return value oftest
is alsoTFloat
. This means thatvar q
is also aTFloat
.test
is called withq
as the parameter, which is valid.- We return
q
, which is aTFloat
. The return type ofmain
is inferred to beTFloat
.
Generics are a way to define functions that can be used with different types.
An example is the sin(x: T): T
function, which is internally defined as:
{
name: "sin",
args: [
{
name: "value",
type: MNSLType.Template("T")
}
],
returnType: MNSLType.Template("T")
}
When calling a templated function it will create brand new types for every template, set them to TUnknown
and connect them together using constraints.
Generally speaking, generics in MNSL is very limited.
You may also limit the types that can be used with a generic parameter by using the limits array.
{
name: "sin",
args: [
{
name: "value",
type: MNSLType.Template("T", [
MNSLType.TFloat,
MNSLType.TVec2
]),
}
],
returnType: MNSLType.Template("T")
}
In the above case only Float
or Vec2
will be accepted as the type for T
. If you try to use a different type, it will result in an error.
Note that you can still pass in an Int
, Vec3
or Vec4
. If the limits can't be satisfied thru normal means, MNSL will try to cast the type to one of the limits. This means that you can pass in a Vec3
and it will be cast to a Vec2
if the function expects a Vec2
.
You can also create user-defined generics, these are defined as such:
func identity<T>(x: T): T -> x;
func add<T>(x: T, y: T): T -> x + y;
func main() {
var x = identity(10) -> add(_, 10);
}
There are no constraints on generics. Please note:
- If any parameter type is not explicitly defined, it will be inferred globally.
func multiplyBy<T>(x: T, y): T -> x * y;
func main() {
var a = multiplyBy(10, 10); // y will be inferred as Int
var b = multiplyBy(vec2(10), vec2(10)); // Error: Expected Int but got Vec2 (parameter y)
}
- If the return type is not explicitly defined, the type will be inferred from that specific call (an imaginary
Any
type).
func add<T>(x: T, y: Float) -> x * y; // Not explicitly defined.
func main() {
var a = add(10, 10.0); // T is Int, but it will return Float
var b = add(vec2(10), 10.0); // T is Vec2 and it will return Vec2
}
It is recommended to always explicitly define the parameter- and return types of generics to avoid confusion and unexpected behaviour.
Swizzling is a way to access specific components of a vector. You can use the dot operator to access specific components of a vector, for example:
var v: Vec3 = (1.0, 2.0, 3.0);
var x: Float = v.x; // x = 1.0
var y: Float = v.y; // y = 2.0
var z: Float = v.z; // z = 3.0
var allZ: Vec3 = v.zzz; // allZ = (3.0, 3.0, 3.0)
var allB: Vec4 = v.bbbb; // allB = (3.0, 3.0, 3.0, 3.0)
Depending on the type of vector you are using, the available swizzle components may differ.
x
orr
for the first component (always available)y
org
for the second component (always available)z
orb
for the third component (available forVec3
andVec4
)w
ora
for the fourth component (available forVec4
)
You are free to use and mix these swizzle components as you like, for example:
var v: Vec4 = (1.0, 2.0, 3.0, 4.0);
var q = v.rybw; // same as .rgba or .xyzw
You can also set the components of a vector using swizzling, for example:
var v: Vec4 = (1.0, 2.0, 3.0, 4.0);
v.xz = (5.0, 6.0); // v = (5.0, 2.0, 6.0, 4.0)
v.yw = 1.0; // v = (5.0, 1.0, 6.0, 1.0)
Loops in MNSL are very similar to other languages, you can use for
and while
loops.
for (init; condition; increment) {
// ... code ...
}
while (condition) {
// ... code ...
}
For example:
for (var i = 0; i < 10; i++) {
// ... code ...
}
while (i < 10) {
// ... code ...
i++;
}
Chaining is a way to call multiple functions on the same object in a single line. Consider the following code:
var F = fresnelSchlick(max(dot(V, H), 0.0), F0);
With chaining you can write this as:
var F = V -> dot(_, H) -> max(_, 0.0) -> fresnelSchlick(_, F0);
You may chain any expression on both sides of the ->
operator, this includes variables, functions and other expressions.
Here are a few examples:
var y = x -> squared(_); // squared(x);
var y = 5 + 5 -> 5 + _; // 5 + 5 + 5;
Note that _ will literally replace the _
in the expression, this means that when you use it together with binary operators, the _
will not be evaluated first.
var y = 1 + 2 -> _ * 3;
Above you may expect it to evaluate 1 + 2 = 3 first and then multiply it by 3, but it will actually evaluate to 1 + 2 * 3
which is 1 + 6 = 7
.
For this reason it is recommended to use parentheses to make sure the order of operations is correct:
var y = (1 + 2) -> _ * 3; // -> 9
Last but not least, you can use the vector initialization syntax and vector array access to chain multiple values together:
func squared(x) -> x * x;
func halfOf(x) -> x * 0.5;
func main() {
var x = 5;
var y = x -> (squared(_), halfOf(_)) -> _[0] / _[1];
}
Any type of vector may be implicitly cast to another VecN
type.
The behaviour is as follows:
- When the current value is cast to a vector of a smaller size is it truncated.
- When the current value is cast to a vector of a larger size z is set to 0.0 and w to 1.0 (if either applies)
Examples:
- vec2(2.0, 3.0) -> vec3(2.0, 3.0, 0.0)
- vec2(2.0, 3.0) -> vec4(2.0, 3.0, 0.0, 1.0)
- vec4(1.0, 2.0, 3.0, 4.0) -> vec3(1.0, 2.0, 3.0)
- vec4(1.0, 2.0, 3.0, 4.0) -> vec2(1.0, 2.0)
A numerical value (Float
or Int
) can also be cast to a vector, this will result in a vector with all components set to the value.
Example:
var a: Vec2 = 1.0; // -> vec2 a = vec2(1.0, 1.0);
var b = a * 2; // -> vec2 b = a * vec2(2.0, 2.0);
Another interesting case is with Binary Operators, for example:
Vec2 * Vec3 * Vec4
MNSL Prefers casting up (vec3->vec4) over casting down and losing information (vec4->vec3), thus the result would be:
vec4(vec3(vec2(1, 1), 0.0) * vec3(2, 2, 2), 1.0) * vec4(3, 3, 3, 3)
vec4(
vec3(
vec2(1, 1),
0.0) * vec3(2, 2, 2),
1.0) * vec4(3, 3, 3, 3)
- Language: Ternary (X ? A : B)
- Language: Pipe operator use
_
for first argument if right-hand expression does not contain_
- Analyser: Inlined functions
- Analyser: allow unused functions to have unresolved constraints
- Analyser: fix IVec2 support (and test it on
textureSize()
) - Optimiser: CommonStructBase
- Optimiser: OptimiseConstExpr
- Optimiser: Improve test case 1 (see RND folder)
- Review: Built-ins
- Review: Positional Data
- Review: SPIR-V
mod()
- Review: SPIR-V attribute names (see now fixed glsl issue)