ROBLOX — Генератор ландшафта — Terrain generation

Автор: | 3 января, 2025
Поделиться...

Под новый год задали вопрос — как создать террейн? Вопрос конечно интересный, в особенности что я несколько лет назад на него потратил не один день изучения. И потому решил таки сделать урок на эту тему. И хотя я не буду рассматривать полноценно как создать натуральные биомы, но сам принцип попробую объяснить.

Начну с того, что уже появились новые специальные инструменты для этого типа операций с 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

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


Поделиться...

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *