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:
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:
attributeis data that arrives with each vertex.uniformis one value shared by the whole draw.POSITIONis the final vertex position.COLORis 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:
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:
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:
uniform vec4 tint;
void fragment()
{
COLOR = texture(tex0, uv) * tint;
}
Set tint by name through the material constant API:
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:
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:
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:
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
- Both stages live in one file. Use
vertex()andfragment()instead of maintaining two shader files. - Do not add shader setup declarations. NSL does not use
#version,layout, register numbers, binding numbers or uniform blocks. - Declare textures as
sampler2D. Sample them withtexture(texture_name, uv)and configure filtering on the material. - Number conversions are explicit. If
countis anint, writevalue * float(count). An unsuffixed literal such as2can adapt to its context;2uand2.0have 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
-1to1, with+1at the top; - clip-space depth runs from
0at the near plane to1at the far plane; - texture coordinate
V = 0is the top row; FRAGCOORDstarts 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()writesPOSITION; - check that
fragment()writesCOLOR; - 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.