reckfellENGINE

Первый 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
C#
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 или анимацию рельефа.

Документация Reckfell EngineНаверх ↑
БЫСТРЫЙ ПЕРЕХОДНачало работыEntity и иерархияC# & .NET 10Сцены и префабы
Руководства и полный API Reckfell K
reckfellENGINE
RECKFELL ACCOUNT

Вход в аккаунт

Войдите в аккаунт Reckfell.

Один аккаунт для всей платформы Reckfell.