You can write your topic however you want, but you need to answer these questions:
-
What do you want to achieve? Keep it simple and clear!
I want to make procedural voxel terrain. -
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.
- 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

