Под новый год задали вопрос — как создать террейн? Вопрос конечно интересный, в особенности что я несколько лет назад на него потратил не один день изучения. И потому решил таки сделать урок на эту тему. И хотя я не буду рассматривать полноценно как создать натуральные биомы, но сам принцип попробую объяснить.
Начну с того, что уже появились новые специальные инструменты для этого типа операций с terrain. Я их конечно посмотрел и.. для меня они показались через чур сложные для восприятия.
Но сперва чуть-чуть теории — террейн это без шовная поверхность по которой перемещается персонаж. То самое, что создаётся через генерацию в Terrain editor:

Но нам нужно создать это посредством кода. Т.е. нам нужна.. карта высот, что будет задавать изменение нашей поверхности. Нам подошли бы функции типа sin и cos, что имеют пределы отрисовки и периоды. Но как раз из-за наличия периодов они нам и не подходят. Лезем в интернет и поисковик нам услужливо говорит, что для этого есть шумы Перлинга. В Роблокс за это отвечает функция math.noise() что генерирует значения от -1 до 1. Т.е. мы вновь имеем пределы, но избавляемся от периодов. На плоскости это будет выглядеть так:

Т.е. чем больше значение, тем светлее будет область. Основная же прелесть этой функции состоит в относительной плавности переходов.
Ну что же, давайте начнём писать код для создания нашего ландшафта. Первое что приходит в голову — ландшафт должен создавать сервер, чтобы он был одинаков у всех игроков. Как следствие это будет script расположенный в ServerScriptService (не обязательно).
Итак. Наша функция выдаёт значения от -1 до +1, в операндах у неё два других значения. Что практически идеально для нас, ведь у нас на плоскости две координаты X и Z, а Y это и будет высота получаемая из этой функции. То есть, примитивно, мы перебираем координаты X, Z и получаем координату Y. Ну что же давай-те сделаем наш ландшафт из.. кубиков.
function WorldGenerator(x, z)
local y = math.noise(x, z)
return y
end
print("Start")
for x = -10, 10 do
for z = -10, 10 do
local part = Instance.new("Part")
part.Size = Vector3.new(1,1,1) -- задаём размер
local y = WorldGenerator(x, z) -- получаем координату
part.Anchored = true -- отключаем физику
part.Position = Vector3.new(x,y,z) -- размещаем в мире
part.Parent = workspace
end
end
print("End")

Видим какую-то фигню… Потому что у math.noise() по факту три параметра. Третий можно считать как зерно генерации. Т.е. то, что задаст саму последовательность получаемых значений. Так же, зададим переменной и размер генерируемой области:
local SIZE = 10 -- стартовый размер local SEED = math.random() -- стартовое зерно function WorldGenerator(x, z) local y = math.noise(x, z, SEED) return y end
И получим уже более похожее на реальность

На этом вспоминаем что значения math.noise() меняются в малых пределах и нам нужна ещё одна константа — увеличивающая шаг по вертикали! И, как следствие, нужна и переменная чтобы поднять наш мирок над baseplate.
local SIZE = 10 -- стартовый размер
local HEIGHT = 2 -- максимум по вертикали
local BASE = 3 -- смещение по вертикали
local SEED = math.random() -- стартовое зерно
function WorldGenerator(x, z)
local y = math.noise(x, z, SEED)
return y
end
print("Start")
for x = -SIZE, SIZE do
for z = -SIZE, SIZE do
local part = Instance.new("Part")
part.Size = Vector3.new(1,1,1) -- задаём размер
local y = HEIGHT * WorldGenerator(x, z) + BASE -- получаем координату
part.Anchored = true -- отключаем физику
part.Position = Vector3.new(x,y,z) -- размещаем в мире
part.Parent = workspace
end
task.wait()
end
print("End")

Что же, уже имеем некоторое подобие мира. Хотя… Если приглядеться, то у нас слишком резкие перепады высот. А значит.. нам нужно чтобы наши задействованные координаты не менялись слишком резко, т.е. нужна ещё одна константа — она будет уменьшать значение наших координат для math.noise().
local SCALE = 10 -- сколько пунктов в единице function WorldGenerator(x, z) local y = math.noise(x/SCALE, z/SCALE, SEED) return y end

Ну вот! Уже почти красота!
Что нам остаётся? Превратить это в terrain. Для этого у нас есть древний метод FillBlock у Terrain, представляющий собой заполнение пространства: workspace.Terrain:FillBlock(cframe, size, material)
local SIZE = 10 -- стартовый размер
local HEIGHT = 2 -- максимум по вертикали
local BASE = 3 -- смещение по вертикали
local SEED = math.random() -- стартовое зерно
local SCALE = 10 -- сколько пунктов в единице
function WorldGenerator(x, z)
local y = math.noise(x/SCALE, z/SCALE, SEED)
return y
end
print("Start")
for x = -SIZE, SIZE do
for z = -SIZE, SIZE do
local part = Instance.new("Part")
part.Size = Vector3.new(1,1,1) -- задаём размер
local y = HEIGHT * WorldGenerator(x, z) + BASE -- получаем координату
part.Anchored = true -- отключаем физику
part.Position = Vector3.new(x,y,z) -- размещаем в мире
part.Parent = workspace
-- создание ландшафта
workspace.Terrain:FillBlock(part.CFrame, part.Size, Enum.Material.Grass)
end
task.wait() -- анти зависание
end
print("End")

Ну вроде как получили что хотели. Надо только убрать генератор партов.. и сменить материал (чтобы травка не мешалась).
local SIZE = 10 -- стартовый размер
local HEIGHT = 2 -- максимум по вертикали
local BASE = 3 -- смещение по вертикали
local SEED = math.random() -- стартовое зерно
local SCALE = 10 -- сколько пунктов в единице
function WorldGenerator(x, z)
local y = math.noise(x/SCALE, z/SCALE, SEED)
return y
end
print("Start")
for x = -SIZE, SIZE do
for z = -SIZE, SIZE do
-- local part = Instance.new("Part")
-- part.Size = Vector3.new(1,1,1) -- задаём размер
local y = HEIGHT * WorldGenerator(x, z) + BASE -- получаем координату
-- part.Anchored = true -- отключаем физику
-- part.Position = Vector3.new(x,y,z) -- размещаем в мире
-- part.Parent = workspace
-- создание ландшафта
-- workspace.Terrain:FillBlock(part.CFrame, part.Size, Enum.Material.Ground)
workspace.Terrain:FillBlock(
CFrame.new(x, y, z), -- где распологаем
Vector3.new(1,1,1), -- какого размера
Enum.Material.Ground -- что именно
)
end
task.wait() -- анти зависание
end
print("End")

Получилось. Но как-то криво… (с) Смешарики
Начинаем читать про Terrain и нам многократно говорят — размер ячейки равен 4х4х4. Ну что-же, давайте добавим это в код (и окончательно избавимся от старого кода и функции из одной строки). И пора бы уже baseplate удалить из мира.
local SIZE = 100 -- стартовый размер
local HEIGHT = 50 -- максимум по вертикали
local BASE = -HEIGHT -- смещение по вертикали
local SEED = math.random() -- стартовое зерно
local SCALE = 40 -- сколько пунктов в единице
local GRID = 4 -- точность сетки (4х4х4 по умолчанию)
print("Start")
for x = -SIZE, SIZE do
for z = -SIZE, SIZE do
local y = BASE + HEIGHT * math.noise(x/SCALE, z/SCALE, SEED) -- получаем координату
-- создание ландшафта
workspace.Terrain:FillBlock(
CFrame.new(x*GRID, y, z*GRID), -- где распологаем
Vector3.new(1,1,1), -- какого размера
Enum.Material.Ground -- что именно
)
end
task.wait() -- анти зависание
end
print("End")

Ну вроде как теперь это больше похоже на ландшафт. Хотя и какой-то.. угловатый. Это всё исправляется.. подбором значений констант. Хотя, из-за особенностей движка мы не сможем избавится от гребёнки не используя FillRegion.
Но сейчас более интересный вопрос — как ускорить генерацию? Первая мысль — убрать task.wait(), но она не верная, т.к. тогда, при долгой генерации (большой размер), у нас вылезет ошибка зависания скрипта. А значит надо чтобы этот task.wait() срабатывал с некоторой периодичностью. А потому добавим timer, который будет сверять время и выполнять task.wait(), если оно прошло. Что-то типа такого:
local SIZE = 100 -- стартовый размер
local HEIGHT = 50 -- максимум по вертикали
local BASE = -HEIGHT -- смещение по вертикали
local SEED = math.random() -- стартовое зерно
local SCALE = 15 -- сколько пунктов в единице (x/z)
local GRID = 4 -- точность сетки (4х4х4 по умолчанию)
local timer = workspace:GetServerTimeNow()
print("Start")
for x = -SIZE, SIZE do
for z = -SIZE, SIZE do
local y = BASE + HEIGHT * math.noise(SEED, x/SCALE, z/SCALE) -- получаем координату
-- создание ландшафта
workspace.Terrain:FillBlock(
CFrame.new(x*GRID, y, z*GRID), -- где располагаем
Vector3.new(GRID,GRID*2,GRID), -- какого размера
Enum.Material.Ground -- что именно
)
-- анти фриз (60 кадров в секунду)
if workspace:GetServerTimeNow() - timer > 30/60 then
timer = workspace:GetServerTimeNow()
task.wait()
end
end
end
print("End")

Уже быстрее и лучше, но всё так же не идеально. Но вроде как теперь ясно как создавать примитивный террейн. Давайте-ка добавим другие материалы с зависимостью от высоты.
local SIZE = 100 -- стартовый размер
local HEIGHT = 50 -- максимум по вертикали
local BASE = -HEIGHT -- смещение по вертикали
local SEED = math.random() -- стартовое зерно
local SCALE = 15 -- сколько пунктов в единице (x/z)
local GRID = 4 -- точность сетки (4х4х4 по умолчанию)
local timer = workspace:GetServerTimeNow()
print("Start")
for x = -SIZE, SIZE do
for z = -SIZE, SIZE do
local y = math.noise(SEED, x/SCALE, z/SCALE) -- получаем координату
local material = Enum.Material.Ground
if y > 0.5 then
material = Enum.Material.Glacier
elseif y > 0.4 then
material = Enum.Material.Snow
elseif y > 0.2 then
material = Enum.Material.LeafyGrass
elseif y > 0 then
material = Enum.Material.Grass
elseif y < -0.2 then
material = Enum.Material.Mud
end
y = BASE + HEIGHT * y -- получаем координату
-- создание ландшафта
workspace.Terrain:FillBlock(
CFrame.new(x*GRID, y, z*GRID), -- где распологаем
Vector3.new(GRID,GRID*2,GRID), -- какого размера
material -- что именно
)
-- анти фриз (60 кадров в секунду)
if workspace:GetServerTimeNow() - timer > 30/60 then
timer = workspace:GetServerTimeNow()
task.wait()
end
end
end
print("End")

Ну вот, уже почти совсем хорошо. Ну и последнее что сделаем, это чтобы ландшафт постоянно генерировался вокруг игрока. Т.е. будет создавать «бесконечный» мир.
Кажется что это просто? Постоянно брать координаты персонажа и рисовать мир вокруг него. Ничего сложного. Но! Нам же не нужно тратить ресурсы на генерацию мира, если он уже есть, когда игрок ходит туда-сюда. Поэтому нужно завести массив сгенерированных координат. Общий для всех игроков.
local Players = game:GetService("Players") -- служба игроков
local SIZE = 100 -- стартовый размер
local HEIGHT = 50 -- максимум по вертикали
local BASE = -HEIGHT -- смещение по вертикали
local SEED = math.random() -- стартовое зерно
local SCALE = 15 -- сколько пунктов в единице (x/z)
local GRID = 4 -- точность сетки (4х4х4 по умолчанию)
local timer = workspace:GetServerTimeNow()
local chunk = {} -- координаты сгенерированного мира
local position = {} -- позиция игрока
function CreateChunk(x, z)
local y = math.noise(SEED, x/SCALE, z/SCALE) -- получаем координату
local material = Enum.Material.Ground
if y > 0.5 then
material = Enum.Material.Glacier
elseif y > 0.4 then
material = Enum.Material.Snow
elseif y > 0.2 then
material = Enum.Material.LeafyGrass
elseif y > 0 then
material = Enum.Material.Grass
elseif y < -0.2 then
material = Enum.Material.Mud
end
y = BASE + HEIGHT * y -- получаем координату
-- создание ландшафта
workspace.Terrain:FillBlock(
CFrame.new(x*GRID, y, z*GRID), -- где располагаем
Vector3.new(GRID,GRID*2,GRID), -- какого размера
material -- что именно
)
-- анти фриз (60 кадров в секунду)
if workspace:GetServerTimeNow() - timer > 5/60 then
timer = workspace:GetServerTimeNow()
task.wait()
end
end
function CheckChunk(newPos)
-- берём только целые координаты
local posX = math.floor( newPos.X /GRID)
local posZ = math.floor( newPos.Z /GRID)
print("Start")
for x = posX - SIZE, posX + SIZE do
for z = posZ - SIZE, posZ + SIZE do
if chunk[x] == nil then
chunk[x] = {}
end
if chunk[x][z] == nil then
chunk[x][z] = true
CreateChunk(x, z)
end
end
end
print("End")
end
while true do -- бесконечный цикл отрисовки мира
for _, player in pairs(Players:GetPlayers()) do
local character = player.Character
if character and character.Parent then
local pos = character:GetPivot()
if position[character] ~= pos then -- только для новой позиции
position[character] = pos
CheckChunk( pos.Position )
end
end
end
task.wait(1)
end

Наверное на этом пока что всё. Хотя конечно можно ещё про генерацию озёр, пещер, ресурсов и много чего ещё написать… Возможно позже и допишу.
Очень доступно для понимания, спасибо!