Writing shaders with NSL

NSL is Natiny's GLSL-like shader language. A single .nsl file contains both the vertex and fragment stages.

If you have written GLSL before, you already know almost all of the language: vec3, mat4, swizzles, mix, normalize, texture, and the usual operators work the way you expect. The stages use vertex() and fragment() entry points.

This guide starts with the smallest useful shader and builds it up one idea at a time.

Your first shader

Create a file named solid.nsl:

NSL
attribute vec3 position;

uniform mat4 mtx_worldviewproj;

void vertex()
{
    POSITION = mtx_worldviewproj * vec4(position, 1.0);
}

void fragment()
{
    COLOR = vec4(0.65, 0.78, 0.22, 1.0);
}

This is a complete shader. It draws the geometry in a solid green color.

There are only four new ideas here:

  • attribute is data that arrives with each vertex.
  • uniform is one value shared by the whole draw.
  • POSITION is the final vertex position.
  • COLOR is the final pixel color.

mtx_worldviewproj is a name Natiny recognizes. The engine fills it before every draw, so the same line positions a 2D shape or a 3D model correctly.

This is the basic shape of every NSL shader: declare the data you need, write POSITION in vertex(), and write COLOR in fragment().

Passing data between stages

The vertex stage runs for vertices. The fragment stage runs for the pixels between them. A varying carries a value from one to the other, interpolating it smoothly across the surface.

Here is a shader that displays the color stored in each vertex:

NSL
attribute vec3 position;
attribute vec4 color0;

varying vec4 vertex_color;

uniform mat4 mtx_worldviewproj;

void vertex()
{
    vertex_color = color0;
    POSITION = mtx_worldviewproj * vec4(position, 1.0);
}

void fragment()
{
    COLOR = vertex_color;
}

The name and type must match in both stages: vertex() writes vertex_color, and fragment() reads it.

Drawing a texture

Textures add one more varying and one uniform:

NSL
attribute vec3 position;
attribute vec2 texcoord0;

varying vec2 uv;

uniform mat4 mtx_worldviewproj;
uniform sampler2D tex0;

void vertex()
{
    uv = texcoord0;
    POSITION = mtx_worldviewproj * vec4(position, 1.0);
}

void fragment()
{
    COLOR = texture(tex0, uv);
}

texcoord0 contains the texture coordinates supplied by the mesh or 2D draw. They travel through uv into the fragment stage, where texture() samples the image.

Declare a texture as sampler2D and sample it with texture(). tex0 receives the texture supplied by the draw unless the material assigns one explicitly. Use the material texture API to assign a texture and choose its filtering.

Making the shader adjustable

A custom uniform turns a hard-coded number into a material parameter. Add a tint to the texture shader:

NSL
uniform vec4 tint;

void fragment()
{
    COLOR = texture(tex0, uv) * tint;
}

Set tint by name through the material constant API:

TEXT
material: my_material
name:     "tint"
value:    1.0, 0.7, 0.5, 1.0

The exact call is shown in the linked API for the selected language. The important part is the contract: the name in your code and the name in the NSL file are the same.

Uniforms are also how you pass time, light colors, effect strength, or any other value that changes while the game runs. NSL does not hide these inputs; if a shader depends on something, it declares it.

A practical complete shader

The pieces above combine into the pattern used by Natiny's default material:

NSL
attribute vec3 position;
attribute vec2 texcoord0;
attribute vec4 color0;

varying vec2 uv;
varying vec4 vertex_color;

uniform mat4 mtx_worldviewproj;
uniform sampler2D tex0;
uniform vec4 tint;

void vertex()
{
    uv = texcoord0;
    vertex_color = color0;
    POSITION = mtx_worldviewproj * vec4(position, 1.0);
}

void fragment()
{
    vec4 texel = texture(tex0, uv);
    COLOR = texel * vertex_color * tint;
}

It works for sprites, text, shapes, and models. From here, effects are ordinary GLSL-style math: distort uv, move position, blend colors, sample another texture, or write a lighting function.

Data Natiny can provide

Declare only the vertex attributes you use. Natiny recognizes these names:

Attribute Type Contains
position vec3 Vertex position
texcoord0 vec2 Texture coordinate
color0 vec4 Vertex color, including the current draw color
normal vec3 Model normal; (0, 0, 1) for 2D geometry

Attribute names are part of the contract with the engine. A different name, such as vertex_position, cannot be filled and material creation will report an error.

Natiny also fills several uniforms when you declare them with their recognized names:

Uniform Type Contains
mtx_world mat4 Object-to-world transform
mtx_view mat4 Camera view transform
mtx_proj mat4 Projection transform
mtx_worldviewproj mat4 Complete object-to-clip transform
mtx_normal mat4 Inverse-transpose worldview transform
camera_pos vec4 Camera position in world space; w is 1
target_size vec4 Width, height, 1 / width, 1 / height

Most shaders only need mtx_worldviewproj. The others are there when you begin writing lighting, screen-space effects, or custom geometry passes.

Built-in stage values

NSL provides these stage values:

Name Where What to do with it
POSITION Vertex Write the final clip-space position
COLOR Fragment Write the first render target
TARGET0 Fragment The same output as COLOR, under a numbered name
TARGET1 ... TARGET3 Fragment Write additional render targets
FRAGCOORD Fragment Read the pixel position from the target's top-left
FRONT_FACING Fragment Read whether the triangle faces forward
VERTEX_INDEX Vertex Read the current vertex index
INSTANCE_INDEX Vertex Read the current instance index
DEPTH Fragment Optionally write a custom depth

For an ordinary material, you only need POSITION and COLOR. A pass that writes several targets at once can spell the first one TARGET0 instead, so every output it writes is numbered the same way - see multiple render targets.

The GLSL you already know

NSL supports the familiar scalar, vector, and matrix types:

NSL
bool  int  uint  float
vec2  vec3  vec4
ivec2 ivec3 ivec4
uvec2 uvec3 uvec4
bvec2 bvec3 bvec4
mat2  mat3  mat4

Swizzles such as .xyz, .rgba, and .st work normally. So do control flow, local variables, arrays, operators, and functions such as:

NSL
abs clamp cross dot floor fract length max min mix normalize pow
reflect refract sin smoothstep sqrt step texture textureLod

You can write your own functions too. Define a function before the first place that calls it; NSL does not use forward declarations.

Rules worth remembering

  1. Both stages live in one file. Use vertex() and fragment() instead of maintaining two shader files.
  2. Do not add shader setup declarations. NSL does not use #version, layout, register numbers, binding numbers or uniform blocks.
  3. Declare textures as sampler2D. Sample them with texture(texture_name, uv) and configure filtering on the material.
  4. Number conversions are explicit. If count is an int, write value * float(count). An unsuffixed literal such as 2 can adapt to its context; 2u and 2.0 have fixed types.

NSL does not currently have #include, #define, struct, or compute shaders.

Coordinates are consistent

NSL uses these coordinate conventions:

  • clip-space X and Y run from -1 to 1, with +1 at the top;
  • clip-space depth runs from 0 at the near plane to 1 at the far plane;
  • texture coordinate V = 0 is the top row;
  • FRAGCOORD starts at the top-left of the render target.

The matrices supplied by Natiny already follow these rules. You only need to think about them when constructing POSITION yourself or implementing a screen-space effect.

When a shader does not compile

NSL diagnostics include the file, line, column, source line, and a pointer to the failing expression. Start with the first error; later errors are often a consequence of it.

The most common fixes are simple:

  • check that vertex() writes POSITION;
  • check that fragment() writes COLOR;
  • check that a varying has the same name and type in both stages;
  • check attribute names against the supported table above;
  • convert mixed number types explicitly;
  • define helper functions before calling them.

Shaders are compiled when you load them through the shader loader, so mistakes are reported before the material is drawn.