Первый 3D shader: рельеф из карты высот
Законченный spatial shader: сетка, heightmap, смещение вершин, нормали и границы GPU-деформации.
Запустить исходный результат
Скачать проект. Нужны Reckfell 1.0.0 с C# SDK и Forward+/Vulkan. Откройте project.reckfell, соберите C# и запустите Main.rscene. В архиве находятся TerrainExhibit3D.cs, Terrain.reckfellshader и базовая галерея. Управляющий класс создаёт карту высот и оба меша.
Рельеф с shader-default height_scale=0.9:
Те же поверхности при height_scale=0:
Камера, свет, материалы и сглаживание одинаковы. У корня Preview=Terrain сохраняет default из shader-кода, Flat явно задаёт 0. Во время игры вызывайте SetHeight(value) после _Ready; метод ограничивает значение диапазоном 0…2.
Подготовить данные
PlaneMesh имеет Size=(2.8, 2.8) и по 127 подразделений: 129×129 вершин. vertex() перемещает только существующие вершины. Увеличение разрешения heightmap само по себе не добавляет полигоны.
HeightMap — ImageTexture из изображения 256×256 в формате Rf. В каждом пикселе хранится числовая высота 0…1. Это данные, не цвет: у sampler2D height_map нет source_color. Mipmaps создаются в C#, а vertex shader читает уровень 0 через textureLod.
У исходной плоскости VERTEX.y=0; shader поднимает вершины на h × height_scale. Размеры плоскости передаются в plane_size: они нужны для масштаба нормалей. Если меняете Size плоскости, меняйте и этот uniform.
Использовать законченный shader
Файл должен называться Terrain.reckfellshader. vertex() читает карту в UV, поднимает VERTEX.y и вычисляет NORMAL по четырём соседним отсчётам. Расстояние между отсчётами учитывает размер плоскости. Без пересчёта нормалей поверхность может выглядеть плоской даже при правильном силуэте.
altitude и slope — varying для следующей стадии. fragment() выбирает цвет склонов и PBR-параметры; его разбор — в следующем уроке.
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;
}Подключить из C#
TerrainExhibit3D наследует галерею из архива. ResourceLoader.Load<Shader> читает .reckfellshader, ShaderMaterial назначается MaterialOverride. Оба меша делят одну программу и материал; правый имеет 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);
}
}Отделить изображение от физики
GPU-деформация не обновляет CPU-вершины и Mesh.GetAabb(). ExtraCullMargin=2 даёт запас culling для разрешённой амплитуды, но не генерирует коллизию. Если увеличиваете максимальное смещение, пересмотрите этот запас.
В примере под каждым образцом находится плоская BoxShape3D. Физический ray остаётся на её высоте после изменения shader-параметра — это проверено в runtime. Для проходимого рельефа нужен отдельный расчёт физической формы из тех же данных высоты. Нельзя считать красивый визуальный склон готовым collider.
Диагностика
| Проблема | Проверка |
|---|---|
| Меш плоский | Достаточно ли вершин, назначен ли материал, height_map и ненулевой height_scale |
| Изменение высоты не видно | Точное имя uniform; изменён ли именно назначенный ShaderMaterial |
| Силуэт изменился, свет плоский | Пересчёт NORMAL и соответствие plane_size реальному Size |
| Части рельефа пропадают у края кадра | Исходный AABB не учитывает GPU-смещение; запас ExtraCullMargin |
| Игрок проходит сквозь вершины | Визуальная деформация не обновила CollisionShape3D |
| Shader не загрузился | Расширение .reckfellshader, путь asset:// и диагностика компиляции |
Кадры сняты текущей локальной сборкой в Forward+/Vulkan: 2560×1440, внутренний масштаб 1.5, MSAA 8× и TAA. Это профиль иллюстраций, не обязательные настройки игры. Пример не реализует стриминг ландшафта, автоматическую физику, LOD или анимацию рельефа.