First 3D shader: heightmap relief
Complete spatial shader: grid, heightmap, vertex displacement, normals and GPU-deformation limits.
Run the baseline result
Download the project. Requires Reckfell 1.0.0 with the C# SDK and Forward+/Vulkan. Open project.reckfell, build C# and run Main.rscene. The archive contains TerrainExhibit3D.cs, Terrain.reckfellshader and the base gallery. The controller creates the heightmap and both meshes.
Relief with the shader default height_scale=0.9:
The same surfaces at height_scale=0:
Camera, lighting, materials and antialiasing are identical. On the root, Preview=Terrain retains the shader-code default; Flat explicitly assigns 0. During play, call SetHeight(value) after _Ready; it clamps the value to 0…2.
Prepare the data
PlaneMesh uses Size=(2.8, 2.8) and 127 subdivisions per axis: 129×129 vertices. vertex() moves existing vertices only. Higher heightmap resolution does not by itself add polygons.
HeightMap is an ImageTexture from a 256×256 Rf image. Every pixel stores numeric height 0…1. This is data rather than color: sampler2D height_map has no source_color hint. C# creates mipmaps; the vertex shader reads level 0 with textureLod.
With zero mesh height, the shader raises vertices by h × height_scale. plane_size supplies the plane dimensions needed for normal scaling. Update this uniform when changing the plane Size.
Use the complete shader
Name the file Terrain.reckfellshader. vertex() samples the map at UV, raises VERTEX.y and calculates NORMAL using four neighboring samples. Sample spacing accounts for plane dimensions. Without recalculated normals, the surface can look flat despite a correct silhouette.
altitude and slope are varying inputs to the next stage. fragment() selects slope colors and PBR properties; the next lesson explains it.
Terrain.reckfellshader
shader_type spatial;
render_mode diffuse_burley, specular_schlick_ggx;
uniform sampler2D height_map : filter_linear_mipmap, repeat_disable;
uniform float height_scale : hint_range(0.0, 2.0) = 0.9;
uniform vec2 plane_size = vec2(2.8);
uniform vec4 low_color : source_color = vec4(0.16, 0.25, 0.16, 1.0);
uniform vec4 rock_color : source_color = vec4(0.55, 0.48, 0.36, 1.0);
uniform float surface_roughness : hint_range(0.0, 1.0) = 0.78;
instance uniform vec4 surface_tint : source_color = vec4(1.0);
varying float altitude;
varying float slope;
void vertex() {
vec2 texel = 1.0 / vec2(textureSize(height_map, 0));
float h = textureLod(height_map, UV, 0.0).r;
float left = textureLod(height_map, UV - vec2(texel.x, 0.0), 0.0).r;
float right = textureLod(height_map, UV + vec2(texel.x, 0.0), 0.0).r;
float back = textureLod(height_map, UV - vec2(0.0, texel.y), 0.0).r;
float front = textureLod(height_map, UV + vec2(0.0, texel.y), 0.0).r;
VERTEX.y += h * height_scale;
vec2 spacing = plane_size * texel;
NORMAL = normalize(vec3((left - right) * height_scale / (2.0 * spacing.x),
1.0, (back - front) * height_scale / (2.0 * spacing.y)));
altitude = h;
slope = 1.0 - NORMAL.y;
}
void fragment() {
float rock = smoothstep(0.08, 0.3, slope);
vec3 ground = mix(low_color.rgb, rock_color.rgb, rock);
float strata = 0.94 + 0.06 * sin(altitude * 115.0 + sin(UV.x * 25.0));
ground = mix(ground, vec3(0.74, 0.76, 0.73), smoothstep(0.7, 0.95, altitude));
ALBEDO = ground * strata * surface_tint.rgb;
ROUGHNESS = surface_roughness;
METALLIC = 0.02;
}Connect C#
TerrainExhibit3D inherits the archive’s gallery. ResourceLoader.Load<Shader> reads the .reckfellshader; MaterialOverride receives the ShaderMaterial. Both meshes share a program and material; the right mesh has an instance tint.
TerrainExhibit3D.cs
using System.Linq;
using Reckfell;
public partial class TerrainExhibit3D : CinematicGallery3D
{
public enum TerrainPreview { Terrain, Flat }
[Export] public TerrainPreview Preview { get; set; } = TerrainPreview.Terrain;
public ShaderMaterial TerrainMaterial { get; private set; } = null!;
public MeshInstance3D Left { get; private set; } = null!;
public MeshInstance3D Right { get; private set; } = null!;
public Camera3D ExhibitCamera { get; private set; } = null!;
public ImageTexture HeightMap { get; private set; } = null!;
public override void _Ready()
{
base._Ready();
HeightMap = CreateHeightMap();
TerrainMaterial = new ShaderMaterial { Shader = ResourceLoader.Load<Shader>("asset://Terrain.reckfellshader") };
TerrainMaterial.SetShaderParameter("height_map", HeightMap);
var plane = new PlaneMesh { Size = new Vector2(2.8f, 2.8f), SubdivideWidth = 127, SubdivideDepth = 127 };
Left = AddTerrain(new Vector3(-1.55f, 0.68f, -2), plane);
Right = AddTerrain(new Vector3(1.55f, 0.68f, -2), plane);
Right.SetInstanceShaderParameter("surface_tint", new Color(0.45f, 0.7f, 1));
var baseMaterial = new StandardMaterial3D { AlbedoColor = new Color(0.1f, 0.13f, 0.17f), Roughness = 0.28f, Metallic = 0.5f };
AddChild(new MeshInstance3D { Mesh = new BoxMesh { Size = new Vector3(6.3f, 0.28f, 3.2f) },
Position = new Vector3(0, 0.51f, -2), MaterialOverride = baseMaterial });
var trim = new StandardMaterial3D { AlbedoColor = new Color(0.48f, 0.32f, 0.16f), Roughness = 0.3f, Metallic = 0.75f };
foreach (float x in new[] { -3.13f, 3.13f }) AddChild(new MeshInstance3D {
Mesh = new BoxMesh { Size = new Vector3(0.035f, 0.05f, 3.2f) }, Position = new Vector3(x, 0.67f, -2), MaterialOverride = trim });
foreach (float z in new[] { -3.58f, -0.42f }) AddChild(new MeshInstance3D {
Mesh = new BoxMesh { Size = new Vector3(6.3f, 0.05f, 0.035f) }, Position = new Vector3(0, 0.67f, z), MaterialOverride = trim });
ExhibitCamera = GetChildren().OfType<Camera3D>().Single();
ExhibitCamera.Position = new Vector3(5.4f, 4.4f, 8.8f);
ExhibitCamera.LookAt(new Vector3(0, 1.5f, -5));
if (Preview == TerrainPreview.Flat) SetHeight(0);
}
private MeshInstance3D AddTerrain(Vector3 at, PlaneMesh plane)
{
var item = new MeshInstance3D { Mesh = plane, Position = at,
MaterialOverride = TerrainMaterial, ExtraCullMargin = 2 };
AddChild(item);
// This flat collider does not follow GPU vertex displacement.
var body = new StaticBody3D();
body.AddChild(new CollisionShape3D { Position = new Vector3(0, -0.02f, 0),
Shape = new BoxShape3D { Size = new Vector3(2.8f, 0.04f, 2.8f) } });
item.AddChild(body);
return item;
}
public void SetHeight(float height) => TerrainMaterial.SetShaderParameter("height_scale", Mathf.Clamp(height, 0, 2));
public ShaderMaterial MakeRightIndependent()
{
var copy = (ShaderMaterial)TerrainMaterial.Duplicate();
Right.MaterialOverride = copy;
return copy;
}
private static ImageTexture CreateHeightMap()
{
const int size = 256;
using var image = Image.CreateEmpty(size, size, false, Image.Format.Rf);
for (int y = 0; y < size; y++) for (int x = 0; x < size; x++)
{
float u = (x + 0.5f) / size * 2 - 1, v = (y + 0.5f) / size * 2 - 1;
float edge = 1 - Mathf.SmoothStep(0.64f, 0.96f, Mathf.Max(Mathf.Abs(u), Mathf.Abs(v)));
float peakA = Mathf.Exp(-((u + 0.26f) * (u + 0.26f) * 8 + (v - 0.15f) * (v - 0.15f) * 6));
float peakB = Mathf.Exp(-((u - 0.35f) * (u - 0.35f) * 12 + (v + 0.32f) * (v + 0.32f) * 9));
float ridges = 0.8f + 0.12f * Mathf.Sin(u * 18 + v * 9) + 0.06f * Mathf.Sin(u * 39 - v * 32);
float height = Mathf.Clamp((0.06f + (peakA * 0.8f + peakB * 0.6f) * ridges) * edge, 0, 1);
image.SetPixel(x, y, new Color(height, height, height));
}
image.GenerateMipmaps();
return ImageTexture.CreateFromImage(image);
}
}Separate rendering from physics
GPU displacement does not update CPU vertices or Mesh.GetAabb(). ExtraCullMargin=2 supplies culling margin for the allowed amplitude rather than generating collision. Review this margin when increasing maximum displacement.
The example places a flat BoxShape3D beneath each sample. A physics ray remains at its height after changing the shader parameter, as verified in the runtime. Walkable relief needs a separately calculated physical shape from the same height data. A convincing visual slope is not automatically a collider.
Diagnosis
| Problem | Check |
|---|---|
| Mesh is flat | Enough vertices, assigned material, height_map and nonzero height_scale |
| Height edits do nothing | Exact uniform name and the ShaderMaterial actually assigned |
| Silhouette changes but lighting stays flat | NORMAL recalculation and plane_size matching actual Size |
| Relief disappears near the frame edge | Original AABB excludes GPU displacement; ExtraCullMargin |
| Player passes through peaks | Visual deformation did not update CollisionShape3D |
| Shader did not load | .reckfellshader extension, asset:// path and compilation diagnostics |
Frames use the current local Forward+/Vulkan build: 2560×1440, internal scale 1.5, MSAA 8× and TAA. This is an illustration profile rather than mandatory game settings. The example does not implement landscape streaming, automatic physics, LOD or animated relief.