Your first 3D shader

You have decided to start writing your own custom Spatial shader. Maybe you saw a cool trick online that was done with shaders, or you have found that the StandardMaterial3D isn't quite meeting your needs. Either way, you have decided to write your own and now you need to figure out where to start.

This tutorial will explain how to write a Spatial shader and will cover more topics than the CanvasItem tutorial.

Spatial shaders have more built-in functionality than CanvasItem shaders. The expectation with spatial shaders is that Godot has already provided the functionality for common use cases and all the user needs to do in the shader is set the proper parameters. This is especially true for a PBR (physically based rendering) workflow.

This is a two-part tutorial. In this first part we will create terrain using vertex displacement from a heightmap in the vertex function. In the second part we will take the concepts from this tutorial and set up custom materials in a fragment shader by writing an ocean water shader.

Note

This tutorial assumes some basic shader knowledge such as types (vec2, float, sampler2D), and functions. If you are uncomfortable with these concepts it is best to get a gentle introduction from The Book of Shaders before completing this tutorial.

Where to assign my material

In 3D, objects are drawn using Meshes. Meshes are a resource type that store geometry (the shape of your object) and materials (the color and how the object reacts to light) in units called "surfaces". A Mesh can have multiple surfaces, or just one. Typically, you would import a mesh from another program (e.g. Blender). But Godot also has a few PrimitiveMeshes that allow you to add basic geometry to a scene without importing Meshes.

There are multiple node types that you can use to draw a mesh. The main one is MeshInstance3D, but you can also use GPUParticles3D, MultiMeshes (with a MultiMeshInstance3D), or others.

Typically, a material is associated with a given surface in a mesh, but some nodes, like MeshInstance3D, allow you to override the material for a specific surface, or for all surfaces.

If you set a material on the surface or mesh itself, then all MeshInstance3Ds that share that mesh will share that material. However, if you want to reuse the same mesh across multiple mesh instances, but have different materials for each instance then you should set the material on the MeshInstance3D.

For this tutorial we will set our material on the mesh itself rather than taking advantage of the MeshInstance3D's ability to override materials.

Setting up

Add a new MeshInstance3D node to your scene.

In the inspector tab beside "Mesh" click "[empty]" and select "New PlaneMesh". Then click on the image of a plane that appears.

This adds a PlaneMesh to our scene.

Then, in the viewport, click in the upper left corner on the button that says "Perspective". A menu will appear. In the middle of the menu are options for how to display the scene. Select 'Display Wireframe'.

This will allow you to see the triangles making up the plane.

../../../_images/plane.png

Now set Subdivide Width and Subdivide Depth of the PlaneMesh to 32.

../../../_images/plane-sub-set.webp

You can see that there are now many more triangles in the MeshInstance3D. This will give us more vertices to work with and thus allow us to add more detail.

../../../_images/plane-sub.png

PrimitiveMeshes, like PlaneMesh, only have one surface, so instead of an array of materials there is only one. Click beside "Material" where it says "[empty]" and select "New ShaderMaterial". Then click the sphere that appears.

Now click beside "Shader" where it says "[empty]" and select "New Shader".

The shader editor should now pop up and you are ready to begin writing your first Spatial shader!

Shader magic

../../../_images/shader-editor.webp

The new shader is already generated with a shader_type variable and the fragment() function. The first thing Godot shaders need is a declaration of what type of shader they are. In this case the shader_type is set to spatial because this is a spatial shader.

shader_type spatial;

For now ignore the fragment() function and define the vertex() function. The vertex() function determines where the vertices of your MeshInstance3D appear in the final scene. We will be using it to offset the height of each vertex and make our flat plane appear like a little terrain.

We define the vertex shader like so:

void vertex() {

}

With nothing in the vertex() function, Godot will use its default vertex shader. We can easily start to make changes by adding a single line:

void vertex() {
  VERTEX.y += cos(VERTEX.x) * sin(VERTEX.z);
}

Adding this line, you should get an image like the one below.

../../../_images/cos.png

Okay, let's unpack this. The y value of the VERTEX is being increased. And we are passing the x and z components of the VERTEX as arguments to cos and sin; that gives us a wave-like appearance across the x and z axes.

What we want to achieve is the look of little hills; after all. cos and sin already look kind of like hills. We do so by scaling the inputs to the cos and sin functions.

void vertex() {
  VERTEX.y += cos(VERTEX.x * 4.0) * sin(VERTEX.z * 4.0);
}
../../../_images/cos4.png

This looks better, but it is still too spiky and repetitive, let's make it a little more interesting.

Noise heightmap

Noise is a very popular tool for faking the look of terrain. Think of it as similar to the cosine function where you have repeating hills except, with noise, each hill has a different height.

Godot provides the NoiseTexture2D resource for generating a noise texture that can be accessed from a shader.

To access a texture in a shader add the following code near the top of your shader, outside the vertex() function.

uniform sampler2D noise;

This will allow you to send a noise texture to the shader. Now look in the inspector under your material. You should see a section called "Shader Params". If you open it up, you'll see a section called "noise".

Click beside it where it says "[empty]" and select "New NoiseTexture2D". Then in your NoiseTexture2D click beside where it says "Noise" and select "New FastNoiseLite".

Note

FastNoiseLite is used by the NoiseTexture2D to generate a heightmap.

Once you set it up and should look like this.

../../../_images/noise-set.webp

Now, access the noise texture using the texture() function. texture() takes a texture as the first argument and a vec2 for the position on the texture as the second argument. We use the x and z channels of VERTEX to determine where on the texture to look up. Note that the PlaneMesh coordinates are within the [-1,1] range (for a size of 2), while the texture coordinates are within [0,1], so to normalize we divide by the size of the PlaneMesh by 2.0 and add 0.5.