Terrain system has wierdly offset parts after greedy meshing

You can write your topic however you want, but you need to answer these questions:

  1. What do you want to achieve? Keep it simple and clear!
    I want to make procedural voxel terrain.

  2. What is the issue? Include screenshots / videos if possible!
    Holes appear in generated terrain

Upon selecting the chunk it seems some of the parts were offset weirdly

I think it might be a problem with the meshing algorithm but I have yet to fix it.

  1. What solutions have you tried so far? Did you look for solutions on the Developer Hub?
    I tried commenting out the part i though was causing it but alas to no avail

Server side code

----------------------------
-- Services
----------------------------

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerStorage = game:GetService("ServerStorage")
local ServerScriptService = game:GetService("ServerScriptService")

----------------------------
-- Helpers
----------------------------


-- Folders
local Services = ServerScriptService.Services

----------------------------
-- Variables
----------------------------

-- Framework
local Terra = require(ReplicatedStorage.Terra)

-- Constants
local CurrentBiome = {
	Name = 'Plains' ,
	Frequency = 100, 
	Amplitude = 50,
	Palette = {
		{"Grass",1},
		{"Dirt",5},
		{"Stone",6}
	}
}

local Normals = {
	vector.create(0, 1, 0),
	vector.create(0, -1, 0),
	vector.create(0, 0, -1),
	vector.create(0, 0, 1),
	vector.create(1, 0, 0),
	vector.create(-1, 0, 0)
}

-- Events
local Events = ReplicatedStorage.Events
local RequestChunk = Events.Terrain.RequestChunk
local UpdateChunk = Events.Terrain.UpdateChunk

----------------------------
-- Type Definition
----------------------------

local Classes = ReplicatedStorage.Classes

type Voxel = {
	["Block"] : string,
	["Exposed"] : boolean
}

type VoxelGroup = {
	["Block"] : string,
	["Size"] : vector,
}

type ChunkData = {[vector] : Voxel}
type ChunkMesh = {[vector] : VoxelGroup}

----------------------------
-- Private Methods
----------------------------

----------------------------
-- Service
----------------------------

local Terrain = {}

----------------------------
-- Service Properties
----------------------------

-- Generation Settings
Terrain.Seed = tick()
Terrain.ChunkSize = 16
Terrain.BlockSize = 4

Terrain.BaseLevel = -64 -- Lowest possible Y coord a block can spawn in
Terrain.SurfaceLevel = 64 -- the Y coord that the surface heightmap will be placed upon.
Terrain.HeightLevel = 128 -- Highest possible Y coord a block can spawn in

-- Containers
Terrain.Chunks = {} :: {[Vector2] : ChunkData}
Terrain.ChunkMeshes = {} :: {[Vector2] : ChunkMesh}

----------------------------
-- Framework Methods
----------------------------

--[[
	Runs after all modules have been required.
]]
function Terrain.Init()
	-- Generate our chunks based on seed
	for X = -3, 3, 1 do
		for Z = -3, 3, 1 do
			-- Get Chunk position
			local ChunkPosition = Vector2.new(X, Z)

			-- Generate Chunk Data
			local HighestY = Terrain.HeightLevel
			Terrain.Chunks[tostring(ChunkPosition)], HighestY = Terrain.GenerateChunkData(ChunkPosition)
			Terrain.ChunkMeshes[tostring(ChunkPosition)] = Terrain.GreedyMeshData(Terrain.Chunks[tostring(ChunkPosition)], HighestY)
		end
	end
	
	RequestChunk.OnServerEvent:Connect(function(Player : Player, ChunkPosition : Vector2)
		-- Get or generate chunk data
		local Mesh = Terrain.GetChunkMesh(ChunkPosition)
		
		-- Send it back
		RequestChunk:FireClient(Player, Mesh, ChunkPosition)
	end)
end

--[[
	Runs after all modules have been initialized.
]]
function Terrain.Start()
	
end

----------------------------
-- Service Methods
----------------------------

--[[
	Generates a chunk and returns it as a dictionary of voxels, where the index 
	is the voxels position and the value is the voxel type.
]]
function Terrain.GenerateChunkData(ChunkPosition : Vector2): ({[vector] : Voxel}, number)
	local Frequency = CurrentBiome["Frequency"]
	local Amplitude = CurrentBiome["Amplitude"]

	-- Generate Voxel Chunk Height map whilst recording the highest height
	local HeightMap : {{number}} = {}
	local HighestY = 0 -- In blocks not studs (that is important)
	
	for X = 1, Terrain.ChunkSize do
		HeightMap[X] = {}
		for Z = 1, Terrain.ChunkSize do
			local ActualX = X + (ChunkPosition.X * Terrain.ChunkSize)
			local ActualZ = Z + (ChunkPosition.Y * Terrain.ChunkSize)
			
			local Noise = Terrain.GetNoiseValue(ActualX / Frequency, ActualZ / Frequency) * Amplitude
			
			local Y = math.floor(Noise/Terrain.BlockSize)
			
			if Y > HighestY then
				HighestY = Y
			end

			HeightMap[X][Z] = Y
		end
	end
	
	-- Generate voxel data with that height map
	local ChunkData = {}
	
	for X = 1, Terrain.ChunkSize do
		for Z = 1, Terrain.ChunkSize do
			for Y = HeightMap[X][Z], Terrain.BaseLevel, -1 do
				local Position = vector.create(X, Y, Z)
				
				ChunkData[Position] = {
					["Block"] = if Y == HeightMap[X][Z] then "Grass" else "Dirt",
				}
			end
		end
	end
	
	-- Not Used Yet
	-- Generate caves with 3d perlin noise ( TODO )
	--for Position : vector, Voxel : Voxel in ChunkData do
	--	local ActX = Position.x + (ChunkPosition.X * Terrain.ChunkSize)
	--	local ActZ = Position.z + (ChunkPosition.Y * Terrain.ChunkSize)
		
	--	local IsAir = Terrain.Get3DNoiseValue(ActX / Frequency, ActZ / Frequency, Position.z / Frequency) > 0
		
	--	if IsAir then
	--		ChunkData[Position] = nil
	--	end
	--end
		
	
	-- Loop through the data and mark voxels as exposed if they have an air block near them
	for Position : vector, Voxel : Voxel in ChunkData do
		for Index : number, Normal : vector in Normals do
			if ChunkData[Position + Normal] then
				continue
			end
			
			ChunkData[Position].Exposed = true
			break
		end
	end
	
	return ChunkData, HighestY
end

--[[
	Greedy meshes the passed chunk data.
]]
function Terrain.GreedyMeshData(Data : ChunkData, HighestY : number): {[vector] : VoxelGroup}
	local Mesh : VoxelMesh = {}

	-- Deepclone model data
	for Position : vector, Voxel : Voxel in Data do
		if not Voxel.Exposed then
			continue
		end
		
		-- Temp to only render surface
		if Data[Position + vector.create(0, 1, 0)] then
			continue
		end
		
		Mesh[Position] = {
			["Block"] = Voxel.Block,
			["Size"] = vector.create(1, 1, 1),
		}
	end

	-- Container Variables
	local StartPosition : vector = nil

	-- Greedymesh on the Z axis
	for X = 1, Terrain.ChunkSize, 1 do
		for Y = Terrain.BaseLevel, Terrain.HeightLevel, 1 do
			for Z = 1, Terrain.ChunkSize, 1 do
				-- Get Current Position
				local CurrentPosition = vector.create(X, Y, Z)

				-- Assert Adjacent Part
				local AdjacentPart = Mesh[CurrentPosition]

				-- If no adjacent part then reset progress to greedy mesh
				if not AdjacentPart then
					StartPosition = nil
					continue
				end

				-- If there is a part then record the starting position
				if not StartPosition then
					StartPosition = CurrentPosition
					continue
				end

				-- If not the same type of part then continue
				if not (AdjacentPart.Block == Mesh[StartPosition].Block) then
					StartPosition = nil
					continue
				end

				-- Greedy Mesh
				Mesh[StartPosition].Size += vector.create(0, 0, 1)

				-- Remove old adjacent part
				Mesh[CurrentPosition] = nil
			end
		end
	end
	
	StartPosition = nil

	-- Greedymesh on the X axis
	for Y = Terrain.BaseLevel, Terrain.HeightLevel, 1 do
		for Z = 1, Terrain.ChunkSize, 1 do
			for X = 1, Terrain.ChunkSize, 1 do
				-- Get Current Position
				local CurrentPosition = vector.create(X, Y, Z)
				
				-- Assert Adjacent Part
				local AdjacentPart = Mesh[CurrentPosition]

				-- If no adjacent part then reset progress to greedy mesh
				if not AdjacentPart then
					StartPosition = nil
					continue
				end

				-- If there is a part then record the starting position
				if not StartPosition then
					StartPosition = CurrentPosition
					continue
				end

				-- If not the same type of part then continue
				if not (AdjacentPart.Block == Mesh[StartPosition].Block) then
					StartPosition = nil
					continue
				end

				if not (AdjacentPart.Size.z == Mesh[StartPosition].Size.z) then
					StartPosition = nil
					continue
				end

				-- Greedy Mesh
				Mesh[StartPosition].Size += vector.create(1, 0, 0)

				-- Remove old adjacent part
				Mesh[CurrentPosition] = nil
			end
		end
	end
	
	StartPosition = nil

	-- Greedymesh on the Y axis
	for Z = 1, Terrain.ChunkSize, 1 do
		for X = 1, Terrain.ChunkSize, 1 do
			for Y = Terrain.BaseLevel, Terrain.HeightLevel, 1 do
				-- Get Current Position
				local CurrentPosition = vector.create(X, Y, Z)

				-- Assert Adjacent Part
				local AdjacentPart = Mesh[CurrentPosition]

				-- If no adjacent part then reset progress to greedy mesh
				if not AdjacentPart then
					StartPosition = nil
					continue
				end

				-- If there is a part then record the starting position
				if not StartPosition then
					StartPosition = CurrentPosition
					continue
				end

				-- If not the same type of part then continue
				if not (AdjacentPart.Block == Mesh[StartPosition].Block) then
					StartPosition = nil
					continue
				end

				if not (AdjacentPart.Size.z == Mesh[StartPosition].Size.z) then
					StartPosition = nil
					continue
				end

				if not (AdjacentPart.Size.x == Mesh[StartPosition].Size.x) then
					StartPosition = nil
					continue
				end

				-- Greedy Mesh
				Mesh[StartPosition].Size += vector.create(0, 1, 0)

				-- Remove old adjacent part
				Mesh[CurrentPosition] = nil
			end
		end
	end
	
	return Mesh
end

----------------------------
-- Assert Methods
----------------------------

--[[
	Asserts whether the passed chunk has data for it
]]
function Terrain.AssertChunk(Chunk : Vector2): boolean
	return not (Terrain.Chunks[tostring(Chunk)] == nil)
end

----------------------------
-- Getter Methods
----------------------------

--[[
	Returns the block type for the passed position
]]
function Terrain.GetBlock(BlockPosition : vector): string -- Not Used Yet
	return "Grass"
end

--[[
	Returns the noise value for the provided position
]]
function Terrain.GetNoiseValue(X : number, Z : number): number
	return math.noise(X, Z, Terrain.Seed)
end

--[[
	Returns the 3d noise value for the provided position
]]
function Terrain.Get3DNoiseValue(X : number, Y : number, Z : number): number
	return math.noise(Terrain.Seed + X, Y, Z)
end

--[[
	Gets and returns chunk data for the passed position or generates it if not there
]]
function Terrain.GetChunkData(ChunkPosition : Vector2)
	if not Terrain.Chunks[tostring(ChunkPosition)] then
		Terrain.Chunks[tostring(ChunkPosition)] = Terrain.GenerateChunkData(ChunkPosition)
	end
	
	return Terrain.Chunks[tostring(ChunkPosition)]
end

--[[
	Gets and returns chunk data for the passed position or generates it if not there
]]
function Terrain.GetChunkMesh(ChunkPosition : Vector2)
	if not Terrain.ChunkMeshes[tostring(ChunkPosition)] then
		local ChunkData = Terrain.GetChunkData(ChunkPosition)
		Terrain.ChunkMeshes[tostring(ChunkPosition)] = Terrain.GreedyMeshData(ChunkData, Terrain.HeightLevel)
	end

	return Terrain.ChunkMeshes[tostring(ChunkPosition)]
end

----------------------------
-- Return
----------------------------

return Terrain

Client side code

----------------------------
-- Services
----------------------------

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

----------------------------
-- Helpers
----------------------------


----------------------------
-- Variables
----------------------------

-- Player
local Player = Players.LocalPlayer

-- Framework
local Terra = require(ReplicatedStorage.Terra)

-- Events
local Events = ReplicatedStorage.Events
local RequestChunk = Events.Terrain.RequestChunk
local UpdateChunk = Events.Terrain.UpdateChunk

-- Containers
local TerrainFolder = workspace.Terrain

----------------------------
-- Type Definition
----------------------------

local Classes = ReplicatedStorage.Classes

type Voxel = {
	["Block"] : string,
	["Size"] : vector,
}

type ChunkData = {[vector] : Voxel}

----------------------------
-- Private Methods
----------------------------

--[[
	Converts a string to a vector2 value
]]
local function StringToVector(String : string): Vector2
	local X, Y = table.unpack(String:split(","))
	
	return Vector2.new(tonumber(X), tonumber(Y))
end

----------------------------
-- Controller
----------------------------

local Terrain = {}

----------------------------
-- Controller Properties
----------------------------

-- Containers
Terrain.Rendered = {} :: {[string] : Model}
Terrain.Requested = {} :: {[string] : boolean}

-- Settings
Terrain.ChunkSize = 16
Terrain.BlockSize = 4

Terrain.MinRenderDistance = 5 -- Chunks inside this radius will be requested First
Terrain.TargetRenderDistance = 16 -- Chunks outside this radius will be culled

----------------------------
-- Framework Methods
----------------------------

--[[
	Runs after all modules have been required.
]]
function Terrain.Init()
	-- Listen to events
	RequestChunk.OnClientEvent:Connect(function(ChunkData : ChunkData, Chunk : Vector2) -- Render chunk on chunk data send
		Terrain.RenderChunk(ChunkData, Chunk)
	end)

	UpdateChunk.OnClientEvent:Connect(function(Chunk : Vector2, Changes : {}) -- 
		-- TODO
	end)
end

--[[
	Runs after all modules have been initialized.
]]
function Terrain.Start()
	
end

--[[
	Connects to renderstepped after all modules have been started.
]]
function Terrain.Loop(DeltaTime : number)
	if not Player.Character then
		return
	end
	
	if not Player.Character.PrimaryPart then
		return
	end
	
	local CurrentChunk = Terrain.GetCurrentChunk()

	for X = CurrentChunk.X - Terrain.MinRenderDistance, CurrentChunk.X + Terrain.MinRenderDistance, 1 do
		for Z = CurrentChunk.Y - Terrain.MinRenderDistance, CurrentChunk.Y + Terrain.MinRenderDistance, 1 do
			local ChunkPosition = Vector2.new(X, Z)
			local Index = tostring(ChunkPosition)

			if Terrain.Requested[Index] then
				continue
			end
			Terrain.Requested[Index] = true

			-- Request Chunk
			RequestChunk:FireServer(ChunkPosition)
		end
	end

	for ChunkIndex : string, Chunk : Model in Terrain.Rendered do
		local ChunkPosition : Vector2 = StringToVector(ChunkIndex)
		
		-- Distance Check
		local Dist = (CurrentChunk - ChunkPosition).Magnitude

		if Dist <= Terrain.TargetRenderDistance then
			continue
		end
		
		-- Derender Chunk
		Terrain.DerenderChunk(ChunkPosition)
	end
end

----------------------------
-- Controller Methods
----------------------------

--[[
	Renders the passed chunk
]]
function Terrain.RenderChunk(ChunkData : ChunkData, ChunkPosition : Vector2)
	local Chunk = Instance.new("Model", TerrainFolder)
	Chunk.Name = tostring(ChunkPosition)

	local ChunkOffset = Vector3.new(ChunkPosition.X, 0, ChunkPosition.Y) * Terrain.ChunkSize * Terrain.BlockSize

	-- Generate parts
	for Pos : string, Voxel : Voxel in ChunkData do
		local X, Y, Z = table.unpack(Pos:gsub("<Vector3>", ""):split(","))
		X = X:sub(3, -1)
		Z = Z:sub(1, -2)
		
		X = tonumber(X)
		Y = tonumber(Y)
		Z = tonumber(Z)

		local Part = script:FindFirstChild(Voxel.Block):Clone()

		-- Calculate size and stuff
		local ActSize = Vector3.new(Voxel.Size.x, Voxel.Size.y, Voxel.Size.z) * Terrain.BlockSize
		local ActPos = Vector3.new(X, Y, Z) * Terrain.BlockSize -- Actual position in the chunk

		-- Update Part
		Part.Size = ActSize
		Part.Position = ChunkOffset + ActPos + (ActSize/2)
		
		Part.Parent = Chunk
	end

	-- Store reference to chunk
	local Index = tostring(ChunkPosition)
	Terrain.Rendered[Index] = Chunk
end

--[[
	Removes the passed chunk
]]
function Terrain.DerenderChunk(ChunkPosition : Vector2)
	local Index = tostring(ChunkPosition)
	
	Terrain.Rendered[Index]:Destroy()
	Terrain.Rendered[Index] = nil
	Terrain.Requested[Index] = nil
end

----------------------------
-- Assert Methods
----------------------------

--[[
	Asserts whether or not the client is fully rendering all neccessary chunks
]]
function Terrain.FullyRendered(): boolean

end

----------------------------
-- Getter Methods
----------------------------

--[[
	Returns the coordinates of the chunk the player is currently in
	
	Used for rendering stuff.
]]
function Terrain.GetCurrentChunk(): Vector2
	local CurrentPosition = Player.Character.PrimaryPart.Position

	-- Divide by chunk size and floor
	local X = math.floor(CurrentPosition.X / Terrain.ChunkSize / Terrain.BlockSize)
	local Z = math.floor(CurrentPosition.Z / Terrain.ChunkSize / Terrain.BlockSize)

	return Vector2.new(X, Z)
end

----------------------------
-- Return
----------------------------

return Terrain

1 Like

I fixed it:

--[[
	Greedy meshes the passed chunk data.
]]
function Terrain.GreedyMeshData(Data : ChunkData, HighestY : number): {[vector] : VoxelGroup}
	local Mesh : VoxelMesh = {}

	-- Deepclone model data
	for Position : vector, Voxel : Voxel in Data do
		if not Voxel.Exposed then
			continue
		end
		
		-- Uncomment to only render surface
		--if Data[Position + vector.create(0, 1, 0)] then
		--	continue
		--end
		
		Mesh[Position] = {
			["Block"] = Voxel.Block,
			["Size"] = vector.create(1, 1, 1),
		}
	end

	-- Container Variables
	local StartPosition : vector = nil

	-- Greedymesh on the Z axis
	for X = 1, Terrain.ChunkSize, 1 do
		for Y = Terrain.BaseLevel, Terrain.HeightLevel, 1 do
			for Z = 1, Terrain.ChunkSize, 1 do
				-- Get Current Position
				local CurrentPosition = vector.create(X, Y, Z)
				
				-- If no part in this position then continue
				if not Mesh[CurrentPosition] then 
					StartPosition = nil
					continue 
				end

				-- If there is a part then record the starting position
				if not StartPosition then
					StartPosition = CurrentPosition
					continue
				end

				-- Assert Adjacent Part
				local AdjacentPart = Mesh[CurrentPosition]

				-- If no adjacent part then reset progress to greedy mesh
				if not AdjacentPart then
					StartPosition = nil
					continue
				end

				-- If not the same type of part then continue
				if not (AdjacentPart.Block == Mesh[StartPosition].Block) then
					StartPosition = nil
					continue
				end

				-- Greedy Mesh
				Mesh[StartPosition].Size += vector.create(0, 0, 1)

				-- Remove old adjacent part
				Mesh[CurrentPosition] = nil
			end
			
			StartPosition = nil
		end
	end
	
	

	-- Greedymesh on the X axis
	for Y = Terrain.BaseLevel, Terrain.HeightLevel, 1 do
		for Z = 1, Terrain.ChunkSize, 1 do
			for X = 1, Terrain.ChunkSize, 1 do
				-- Get Current Position
				local CurrentPosition = vector.create(X, Y, Z)
				
				-- If no part in this position then continue
				if not Mesh[CurrentPosition] then 
					StartPosition = nil
					continue 
				end

				-- If there is a part then record the starting position
				if not StartPosition then
					StartPosition = CurrentPosition
					continue
				end

				-- Assert Adjacent Part
				local AdjacentPart = Mesh[CurrentPosition]

				-- If no adjacent part then reset progress to greedy mesh
				if not AdjacentPart then
					StartPosition = nil
					continue
				end

				-- If not the same type of part then continue
				if not (AdjacentPart.Block == Mesh[StartPosition].Block) then
					StartPosition = nil
					continue
				end
				
				-- Assert that the part was the same size
				if not (AdjacentPart.Size.z == Mesh[StartPosition].Size.z) then
					StartPosition = nil
					continue
				end

				-- Greedy Mesh
				Mesh[StartPosition].Size += vector.create(1, 0, 0)

				-- Remove old adjacent part
				Mesh[CurrentPosition] = nil
			end
			
			StartPosition = nil
		end
	end

	-- Greedymesh on the Y axis
	for Z = 1, Terrain.ChunkSize, 1 do
		for X = 1, Terrain.ChunkSize, 1 do
			for Y = Terrain.BaseLevel, Terrain.HeightLevel, 1 do
				-- Get Current Position
				local CurrentPosition = vector.create(X, Y, Z)

				-- If no part in this position then continue
				if not Mesh[CurrentPosition] then 
					StartPosition = nil
					continue 
				end

				-- If there is a part then record the starting position
				if not StartPosition then
					StartPosition = CurrentPosition
					continue
				end

				-- Assert Adjacent Part
				local AdjacentPart = Mesh[CurrentPosition]

				-- If no adjacent part then reset progress to greedy mesh
				if not AdjacentPart then
					StartPosition = nil
					continue
				end

				-- If not the same type of part then continue
				if not (AdjacentPart.Block == Mesh[StartPosition].Block) then
					StartPosition = nil
					continue
				end
				
				-- Assert that the part was the same size
				if not (AdjacentPart.Size.z == Mesh[StartPosition].Size.z) then
					StartPosition = nil
					continue
				end
				
				-- Assert that the part was the same size
				if not (AdjacentPart.Size.x == Mesh[StartPosition].Size.x) then
					StartPosition = nil
					continue
				end

				-- Greedy Mesh
				Mesh[StartPosition].Size += vector.create(0, 1, 0)

				-- Remove old adjacent part
				Mesh[CurrentPosition] = nil
			end
			
			StartPosition = nil
		end
	end
	
	return Mesh
end

It was in fact the greedy meshing algorithm, I forgot to reset the starting position after every loop, and only reset once out of the loop.

Leaving this for anyone that wants a basic terrain system.

This topic was automatically closed 14 days after the last reply. New replies are no longer allowed.