Skip to main content

Terrain generation

This example will showcase how terrain generation with trees can be made using multi-threading. The base script was based on this video. The TerrainGeneratorWorker is parented under the Runner script.

Runner script

Source
type TerrainData = {
x: number,
z: number,
seed: number,
resolution: number,
frequency: number,
magnitude: number,
waterHeight: number,
extraHeigt: number,
}

local TerrainDataType = require(script.TerrainDataType)
local Threader = require(game:GetService("ReplicatedStorage").Threader)

local TerrainGenerationThreader = Threader.new(10, script.TerrainGeneratorWorker)

-- define values that will be used to generate the terrain
-- NOTE: terrainSize determines how big of an area will the terrain occupy
local terrainSize = 200 -- terrainSize^2
local seed = 2
local resolution = 100
local frequency = 5
local magnitude = 15
local waterHeight = Random.new(seed):NextNumber()
local extraHeigt = 15

-- generate `workData`
local terrainPositions = {} :: { TerrainData }

for x = 1, terrainSize do
for z = 1, terrainSize do
table.insert(terrainPositions, {
x = x,
z = z,
seed = seed,
resolution = resolution,
frequency = frequency,
magnitude = magnitude,
waterHeight = waterHeight,
extraHeigt = extraHeigt,
})
end
end

task.wait(3) -- added to have a bit of an intermission until the whole pc freezes :)

TerrainGenerationThreader:Dispatch(terrainPositions):andThen(function()
local waterBlock = Instance.new("Part")
waterBlock.Name = "Water"
waterBlock.Size = Vector3.new(terrainSize, 1, terrainSize)
waterBlock.Position =
Vector3.new(terrainSize / 2 + 0.5, extraHeigt + waterHeight * magnitude, terrainSize / 2 + 0.5)
waterBlock.Color = Color3.fromRGB(41, 109, 255)
waterBlock.Transparency = 0.6
waterBlock.Anchored = true
waterBlock.Parent = workspace
end)

The runner script is responsible to dispatch all the work to the threads and prepare the most crucial data for each thread.

Before calling Threader:Dispatch() on our TerrainGenerationThreader class, we have to first make a workData table that contains every voxel's data: x, z, seed, resolution, frequency, magnitude, waterHeight, extraHeigt. Each of them is used for a different purpose: x and z is used for coordinates and in generating the perlin noise using the seed. The resolution determines how smooth will the perlin noise be. The frequency makes the perlin noise have bigger fluctuation and the magnitude increases the height.

When TerrainGenerationThreader had dispatched and all the threads had reported back with no errors then :andThen method gets ran, in which we create a part to represent water.

TerrainGeneratorWorker

Source
type TerrainData = {
x: number,
z: number,
seed: number,
resolution: number,
frequency: number,
magnitude: number,
waterHeight: number,
extraHeigt: number,
}

local Threader = require(game:GetService("ReplicatedStorage").Threader)

local TerrainGeneratorWorker = Threader.ThreadWorker.new()

function TerrainGeneratorWorker:OnDispatch(terrainSet: { TerrainData })
-- create an RNG generator with the seed
local randomNumberGenerator = Random.new(terrainSet[1].seed)

for _, terrainData in terrainSet do
task.desynchronize()

-- generate perlin noise
local noiseY = math.noise(
terrainData.x / terrainData.resolution * terrainData.frequency,
terrainData.z / terrainData.resolution * terrainData.frequency,
13 / (terrainData.seed - 0.01)
) + 0.5

local partSize = Vector3.one + Vector3.yAxis * 2
local partPosition =
Vector3.new(terrainData.x, noiseY * terrainData.magnitude - 1 + terrainData.extraHeigt, terrainData.z)
local partColor = nil

local distanceFromWaterLevel = noiseY - terrainData.waterHeight

-- above water
if distanceFromWaterLevel > 0.1 then
partColor = Color3.fromRGB(13, 134, 17)
-- below water
elseif distanceFromWaterLevel < 0 then
partColor = Color3.fromRGB(176, 86, 26)
-- at edge
else
partColor = Color3.fromRGB(208, 204, 91)
end

task.synchronize()

local part = Instance.new("Part")
part.Size = partSize
part.Position = partPosition
part.Anchored = true
part.Color = partColor
part.Parent = workspace.Terrain

-- Tree generation
if not (distanceFromWaterLevel > 0.15 and distanceFromWaterLevel < 0.5) then
continue
end

task.desynchronize()

local offsetX = randomNumberGenerator:NextNumber(-0.3, 0.3)
local offsetY = randomNumberGenerator:NextNumber(-0.4, 0.3)
local offsetZ = randomNumberGenerator:NextNumber(-0.3, 0.3)

local trunkSize = 1.5

local foliagePosition = Vector3.yAxis * trunkSize - Vector3.yAxis * 0.345
local treePosition =
CFrame.new(terrainData.x + offsetX, part.Position.Y + part.Size.Y - 1 + offsetY, terrainData.z + offsetZ)

task.synchronize()

local tree = Instance.new("Model")
tree.Name = "Tree"
tree.Parent = workspace.Terrain

local trunk = Instance.new("Part")
trunk.Color = Color3.fromRGB(176, 86, 26)
trunk.Size = Vector3.new(0.273, trunkSize, 0.273)
trunk.Anchored = true
trunk.Name = "Trunk"
trunk.Parent = tree

local foliage = Instance.new("Part")
foliage.Shape = Enum.PartType.Ball
foliage.Color = Color3.fromRGB(75, 151, 75)
foliage.Name = "Foliage"
foliage.Anchored = true
foliage.Position = foliagePosition
foliage.TopSurface = Enum.SurfaceType.Smooth
foliage.Parent = tree

tree.PrimaryPart = trunk

tree:SetPrimaryPartCFrame(treePosition)

for _, collideTree in foliage:GetTouchingParts() do
if not (collideTree.Name == "Foliage") then
continue
end

tree:Destroy()
end
end
end

return TerrainGeneratorWorker

This is the main part of our terrain generation. The worker generates a perlin noise with the given x, z, seed, frequency and magnitude values and uses it to position the voxel approprietly. After the perlin noise a.k.a. the y value of the coordinate has been calculated, the distance from the water height gets determined that results in the voxel either becoming: grass, sand, dirt. After the voxel was generated and positioned accordingly a tree will be generated at the voxel if the distance from the water falls between the range of 0.15 and 0.5. After the tree was moved to its position but collides with another tree it gets destroyed.

Result

Side view Top view