Smoothing out Perlin noise dynamic terrain

Hello all,

I am struggling with some Perlin noise terrain I am generating. The terrain generates well, but there are noticeable ridges in the terrain.

local Terrain = workspace:WaitForChild("Terrain")


local AREA_X = 512
local AREA_Y = 256
local AREA_Z = 512
local SCALE = 128
local NUM_OCTAVES = 3
local PERSISTANCE = 0.50
local RESOLUTION = 4
local LACUNARITY = 2.00
local SEED = Rnd:NextInteger(1, 32767)


local function CalculateNoiseAtPosition(x, z)
	local LoopAmplitude = 1
	local LoopFrequency = 1
	local LoopNoise = 0
	for i = 1, NUM_OCTAVES do
		local scx = x / SCALE * LoopFrequency
		local scz = z / SCALE * LoopFrequency
		local pnv = math.noise(scx, scz, SEED)
		LoopNoise += pnv * LoopAmplitude
		LoopAmplitude *= PERSISTANCE
		LoopFrequency *= LACUNARITY
	end
	return (1 + LoopNoise)/2
end


local function GenerateTerrainTables(x_grid, z_grid)
	x_grid = x_grid or 0
	z_grid = z_grid or 0
	local MATS, OCCS = {}, {}
	for z = 1, AREA_Z/RESOLUTION do
		MATS[z] = {}
		OCCS[z] = {}
		for y = 1, AREA_Y/RESOLUTION do
			MATS[z][y] = {}
			OCCS[z][y] = {} 
			for x = 1, AREA_X/RESOLUTION do
				MATS[z][y][x] = Enum.Material.Air
				OCCS[z][y][x] = 0
			end
		end
	end
	for x = 1, AREA_X/4 do
		for z = 1, AREA_Z/4 do
			local fx = x + AREA_X/RESOLUTION * x_grid
			local fz = z + AREA_Z/RESOLUTION * z_grid
			local Noise = CalculateNoiseAtPosition(fx, fz)
			local ScaledNoise = Noise * AREA_Y/RESOLUTION
			for y = 1, ScaledNoise do
				MATS[z][y][x] = Enum.Material.Grass
				OCCS[z][y][x] = 1 -- this is most likely the issue
			end
		end
	end
	return MATS, OCCS
end


local function GenerateTerrainChunk(x_grid, z_grid)
	x_grid = x_grid or 0
	z_grid = z_grid or 0
	local MATS, OCCS = GenerateTerrainTables(x_grid, z_grid)
	local s = Vector3.new(AREA_X, 0, AREA_Z) * TERRAIN_CHUNKS_SIZE/2
	local Start = Vector3.new(AREA_Z * z_grid, 0, AREA_X * x_grid) - s
	local End = Vector3.new(AREA_Z * (z_grid + 1), AREA_Y, AREA_X * (x_grid + 1)) - s
	local FinalRegion = Region3.new(Start, End)
	Terrain:WriteVoxels(FinalRegion, RESOLUTION, MATS, OCCS)
end

GenerateTerrainChunk(0, 0)

I have tried flubbing the Occupancy values by using noise, inverse lerp, etc… nothing is working very well. I am certain the issue is that it is impossible to be more precise than a voxel with WriteVoxels() which is causing the banding.

Is there any way to smoothen this terrain out without having to resort to using something like FillRegion?

Your main issue is this part of your code

		local ScaledNoise = Noise * AREA_Y/RESOLUTION
			for y = 1, ScaledNoise do -- This line implicitly truncates ScaledNoise
				MATS[z][y][x] = Enum.Material.Grass
				OCCS[z][y][x] = 1 
			end

When ScaledNoise is, for example, 10.7, the loop for y = 1, 10.7 effectively runs for y from 1 to 10. The 0.7 fractional part is lost, leading to stepped terrain.

For the most part, this script should get rid of the noticeable ridges in the terrain.

I’ve normalised CalculateNoiseAtPosition properly and modified GenerateTerrainTables to use fractional occupancy

local Terrain = workspace:WaitForChild("Terrain")

local AREA_X = 512
local AREA_Y = 256
local AREA_Z = 512
local SCALE = 128
local NUM_OCTAVES = 3
local PERSISTANCE = 0.50
local RESOLUTION = 4
local LACUNARITY = 2.00
local TERRAIN_CHUNKS_SIZE = 1
local Rnd = Random.new()
local SEED = Rnd:NextInteger(1, 32767)

local function CalculateNoiseAtPosition(x, z)
	local currentAmplitude = 1
	local currentFrequency = 1
	local noiseSum = 0
	local totalMaxAmplitude = 0 -- Used to normalise the sum

	for i = 1, NUM_OCTAVES do
		local scx = x / SCALE * currentFrequency
		local scz = z / SCALE * currentFrequency
		local pnv = math.noise(scx, scz, SEED) -- Roblox math.noise is [-1, 1]

		noiseSum = noiseSum + pnv * currentAmplitude
		totalMaxAmplitude = totalMaxAmplitude + currentAmplitude -- Sum of all positive amplitudes

		currentAmplitude = currentAmplitude * PERSISTANCE
		currentFrequency = currentFrequency * LACUNARITY
	end

	if totalMaxAmplitude == 0 then return 0.5 end -- Avoid division by zero for safety

	-- Normalise noiseSum to be in [-1, 1] by dividing by the maximum possible sum of amplitudes
	local normalizedNoise = noiseSum / totalMaxAmplitude 

	-- Scale to [0, 1] range
	return (normalizedNoise + 1) / 2
end


local function GenerateTerrainTables(chunk_x_grid, chunk_z_grid) -- Renamed params for clarity
	chunk_x_grid = chunk_x_grid or 0
	chunk_z_grid = chunk_z_grid or 0

	local voxels_per_chunk_x = AREA_X / RESOLUTION
	local voxels_per_chunk_z = AREA_Z / RESOLUTION
	local max_voxel_height_y = AREA_Y / RESOLUTION -- Max height in terms of Y-voxels

	local MATS, OCCS = {}, {}
	-- Initialise tables
	for z_idx = 1, voxels_per_chunk_z do
		MATS[z_idx] = {}
		OCCS[z_idx] = {}
		for y_idx = 1, max_voxel_height_y do
			MATS[z_idx][y_idx] = {}
			OCCS[z_idx][y_idx] = {} 
			for x_idx = 1, voxels_per_chunk_x do
				MATS[z_idx][y_idx][x_idx] = Enum.Material.Air
				OCCS[z_idx][y_idx][x_idx] = 0
			end
		end
	end

	-- Populate terrain with smoothed surfaces
	for x_chunk_voxel = 1, voxels_per_chunk_x do         -- Loop through X voxels in this chunk
		for z_chunk_voxel = 1, voxels_per_chunk_z do     -- Loop through Z voxels in this chunk

			-- Calculate global voxel coordinates for consistent noise sampling across chunks
			local global_voxel_x = (chunk_x_grid * voxels_per_chunk_x) + x_chunk_voxel
			local global_voxel_z = (chunk_z_grid * voxels_per_chunk_z) + z_chunk_voxel

			local noise_0_to_1 = CalculateNoiseAtPosition(global_voxel_x, global_voxel_z)

			-- Calculate the target height in terms of Y-voxel grid cells
			local target_height_in_voxels = noise_0_to_1 * max_voxel_height_y

			local integer_part_height = math.floor(target_height_in_voxels)
			local fractional_part_height = target_height_in_voxels - integer_part_height

			-- Fill solid voxels up to the integer_part_height
			for y_voxel_column_idx = 1, integer_part_height do
				if y_voxel_column_idx > 0 and y_voxel_column_idx <= max_voxel_height_y then -- Boundary check
					-- Table indices are [z_chunk_voxel][y_voxel_column_idx][x_chunk_voxel]
					MATS[z_chunk_voxel][y_voxel_column_idx][x_chunk_voxel] = Enum.Material.Grass
					OCCS[z_chunk_voxel][y_voxel_column_idx][x_chunk_voxel] = 1 -- Fully solid
				end
			end

			-- Fill the surface voxel with fractional occupancy for smoothness
			local surface_y_idx = integer_part_height + 1
			if surface_y_idx > 0 and surface_y_idx <= max_voxel_height_y then -- Boundary check
				if fractional_part_height > 0.01 then -- Optional: avoid tiny slivers of terrain
					MATS[z_chunk_voxel][surface_y_idx][x_chunk_voxel] = Enum.Material.Grass
					OCCS[z_chunk_voxel][surface_y_idx][x_chunk_voxel] = fractional_part_height -- Apply fractional part
				end
			end
		end
	end
	return MATS, OCCS
end


local function GenerateTerrainChunk(x_grid, z_grid)
	x_grid = x_grid or 0
	z_grid = z_grid or 0
	local MATS, OCCS = GenerateTerrainTables(x_grid, z_grid)
	local s = Vector3.new(AREA_X, 0, AREA_Z) * TERRAIN_CHUNKS_SIZE/2
	local Start = Vector3.new(AREA_Z * z_grid, 0, AREA_X * x_grid) - s
	local End = Vector3.new(AREA_Z * (z_grid + 1), AREA_Y, AREA_X * (x_grid + 1)) - s
	local FinalRegion = Region3.new(Start, End)
	Terrain:WriteVoxels(FinalRegion, RESOLUTION, MATS, OCCS)
end

GenerateTerrainChunk(0, 0)

This isn’t perfect, and you could improve I,t and its memory efficiency, etc.

1 Like

Thanks for the response.

When ScaledNoise is, for example, 10.7 , the loop for y = 1, 10.7 effectively runs for y from 1 to 10. The 0.7 fractional part is lost, leading to stepped terrain.

This fixes it as well:

...
for x = 1, AREA_X/4 do
		for z = 1, AREA_Z/4 do
			local fx = x + AREA_X/RESOLUTION * x_grid
			local fz = z + AREA_Z/RESOLUTION * z_grid
			local Noise = CalculateNoiseAtPosition(fx, fz)
			local ScaledNoise, ScaledNoiseFrac = math.modf(math.clamp(Noise * AMPLITUDE/RESOLUTION, 0, AREA_Y/RESOLUTION))
			for y = 1, ScaledNoise - 1 do
				MATS[z][y][x] = Enum.Material.Grass
				OCCS[z][y][x] = 1
			end
			MATS[z][ScaledNoise][x] = Enum.Material.Grass
			OCCS[z][ScaledNoise][x] = ScaledNoiseFrac
		end
	end
	return MATS, OCCS
...

Because of the implicit truncation, simply iterating from 0 to height - 1 with full occupancy and the remaining height with fractal occupancy seems to work well enough.

1 Like

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