Под новый год задали вопрос — как создать террейн? Вопрос конечно интересный, в особенности что я несколько лет назад на него потратил не один день изучения. И потому решил таки сделать урок на эту тему. И хотя я не буду рассматривать полноценно как создать натуральные биомы, но сам принцип попробую объяснить.
Начну с того, что уже появились новые специальные инструменты для этого типа операций с 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
Наверное на этом пока что всё. Хотя конечно можно ещё про генерацию озёр, пещер, ресурсов и много чего ещё написать… Возможно позже и допишу.