Terrain WriteVoxels

I want to make it so that first the grass is generated and then the soil from fractal noise, but in the end I get that everything consists of soil.

local TerrainGeneratorService = {}
TerrainGeneratorService.__index = TerrainGeneratorService

local MAP_SIZE = Vector3.new(6144, 0, 6144)
local RESOLUTION = 4
local CHUNK_SIZE = 256

local TERRAIN_LAYERS = {
    Grass = 1,
    Ground = 2
}

local MATERIAL_LAYERS = {
    {
        name = "Ground",
        noiseScale = 90,
        threshold = {-0.5, -0.1},
        material = Enum.Material.Ground,
        occupancy = 1.0
    }
}

function TerrainGeneratorService.new()
	local self = setmetatable({}, TerrainGeneratorService)
	
    local seed = math.random(0, os.time())
    math.randomseed(seed)

	self.services = {
		Workspace = game:GetService("Workspace"),
		UserInputService = game:GetService("UserInputService")
	}
	
	self.terrain = self.services.Workspace.Terrain
	self:ConnectEvents()

    self:Generate()
	return self
end

function TerrainGeneratorService:ConnectEvents()
	self.services.UserInputService.InputBegan:Connect(function(input, gameProcessed)
		if input.UserInputType == Enum.UserInputType.Keyboard and not gameProcessed then
			if input.KeyCode == Enum.KeyCode.N then
				self:Generate()
			end
		end
	end)
end

function TerrainGeneratorService:FractalNoise(x, z, octaves, persistence)
	local total = 0
	local frequency = 1
	local amplitude = 1
	local maxValue = 0

	for _ = 1, octaves do
		total = total + math.noise(x * frequency, z * frequency) * amplitude
		maxValue += amplitude
		amplitude *= persistence
	end

	return total / maxValue
end

function TerrainGeneratorService:GetMaterial(x, z)
	local selectedMaterial = MATERIAL_LAYERS[1].material
    local selectedOccupancy = MATERIAL_LAYERS[1].occupancy

    local offset = math.random(0, 255)

    for _, layer in ipairs(MATERIAL_LAYERS) do
        local nx = (x + offset) / layer.noiseScale
        local nz = (z + offset) / layer.noiseScale

        local noiseValue = self:FractalNoise(nx, nz, 1, 0.5)

        if noiseValue > layer.threshold[1] and noiseValue < layer.threshold[2] then
            selectedMaterial = layer.material
            selectedOccupancy = layer.occupancy
        end
    end

    return selectedMaterial, selectedOccupancy
end

function TerrainGeneratorService:Generate()
    local voxelsPerChunk = CHUNK_SIZE / RESOLUTION

    for layer, _ in pairs(TERRAIN_LAYERS) do
        for x = -MAP_SIZE.X, MAP_SIZE.X, CHUNK_SIZE do
            for z = -MAP_SIZE.Z, MAP_SIZE.Z, CHUNK_SIZE do
                local materials = {}
                local occupancies = {}
                for i = 1, voxelsPerChunk do
                    materials[i] = {}
                    occupancies[i] = {}
                    materials[i][1] = {}
                    occupancies[i][1] = {}
                    for j = 1, voxelsPerChunk do
                        if layer == TERRAIN_LAYERS.Grass then
                            materials[i][1][j] = Enum.Material.Grass
                            occupancies[i][1][j] = 1.0
                        else
                            local material, occupancy = self:GetMaterial(x, z)
                            materials[i][1][j] = material
                            occupancies[i][1][j] = occupancy
                        end
                    end
                end

                local minPos = Vector3.new(x, 0, z)
                local maxPos = Vector3.new(x + CHUNK_SIZE, 1, z + CHUNK_SIZE)
                local region = Region3.new(minPos, maxPos):ExpandToGrid(RESOLUTION)
                self.terrain:WriteVoxels(region, RESOLUTION, materials, occupancies)
            end
        end
    end
end

return TerrainGeneratorService

1 Like

In your for layer, _ in pairs(TERRAIN_LAYERS) do loop, you’re doing:

                        if layer == TERRAIN_LAYERS.Grass then
                            materials[i][1][j] = Enum.Material.Grass
                            occupancies[i][1][j] = 1.0
                        else
                            local material, occupancy = self:GetMaterial(x, z)
                            materials[i][1][j] = material
                            occupancies[i][1][j] = occupancy
                        end

layer is your key, not the id you set to that key. So you can either change it to check for if layer == "Grass" or still check for the id by replacing the _ with id and checking for that in your for loop

I don’t see anything else wrong so hope that helps out

1 Like

I used your advice but it didn’t help

Place.rbxl (53.9 KB)

1 Like

Thanks for sending the place, I believe I’ve got it working

For the ground you were calling self:GetMaterial(x, z) with the same chunk corner coordinates. Every voxel in that chunk got the exact same noise value, most of the time that value fell outside your threshold, so GetMaterial returned the default Ground/1 occupancy and overwrote whatever was there

Here’s the full updated fix:

local TerrainGeneratorService = {}
TerrainGeneratorService.__index = TerrainGeneratorService

local MAP_SIZE = Vector3.new(6144, 0, 6144)
local RESOLUTION = 4
local CHUNK_SIZE = 256

local TERRAIN_LAYERS = {
	Grass = 1,
	Ground = 2
}

local MATERIAL_LAYERS = {
	{
		name = "Ground",
		noiseScale = 90,
		threshold = { -0.5, -0.1 },
		material = Enum.Material.Ground,
		occupancy = 1
	}
}

function TerrainGeneratorService.new()
	local self = setmetatable({}, TerrainGeneratorService)

	math.randomseed(os.time())

	self.services = {
		Workspace = game:GetService("Workspace"),
		UserInputService = game:GetService("UserInputService")
	}

	self.terrain = self.services.Workspace.Terrain
	self:ConnectEvents()
	self:Generate()

	return self
end

function TerrainGeneratorService:ConnectEvents()
	self.services.UserInputService.InputBegan:Connect(function(input, processed)
		if processed then return end
		if input.UserInputType == Enum.UserInputType.Keyboard and input.KeyCode == Enum.KeyCode.N then
			self:Generate()
		end
	end)
end

function TerrainGeneratorService:FractalNoise(x, z, octaves, persistence)
	local total = 0
	local freq = 1
	local amp = 1
	local maxVal = 0

	for _ = 1, octaves do
		total += math.noise(x * freq, z * freq) * amp
		maxVal += amp
		amp *= persistence
		freq *= 2
	end

	return total / maxVal
end

function TerrainGeneratorService:GetMaterial(x, z)
	local layer = MATERIAL_LAYERS[1]
	local nx = x / layer.noiseScale
	local nz = z / layer.noiseScale
	local n = self:FractalNoise(nx, nz, 1, 0.5)

	if n > layer.threshold[1] and n < layer.threshold[2] then
		return layer.material, layer.occupancy
	end

	return Enum.Material.Grass, 1
end

function TerrainGeneratorService:Generate()
	local voxelsPerChunk = CHUNK_SIZE / RESOLUTION

	for layerName, _ in pairs(TERRAIN_LAYERS) do
		for x = -MAP_SIZE.X, MAP_SIZE.X, CHUNK_SIZE do
			for z = -MAP_SIZE.Z, MAP_SIZE.Z, CHUNK_SIZE do
				local materials, occupancies = {}, {}

				for i = 1, voxelsPerChunk do
					materials[i], occupancies[i] = {}, {}
					materials[i][1], occupancies[i][1] = {}, {}

					for j = 1, voxelsPerChunk do
						local px = x + (i - 1) * RESOLUTION
						local pz = z + (j - 1) * RESOLUTION

						if TERRAIN_LAYERS[layerName] == TERRAIN_LAYERS.Grass then
							materials[i][1][j] = Enum.Material.Grass
							occupancies[i][1][j] = 1
						else
							local mat, occ = self:GetMaterial(px, pz)
							materials[i][1][j] = mat
							occupancies[i][1][j] = occ
						end
					end
				end

				local region = Region3.new(
					Vector3.new(x, 0, z),
					Vector3.new(x + CHUNK_SIZE, 1, z + CHUNK_SIZE)
				):ExpandToGrid(RESOLUTION)

				self.terrain:WriteVoxels(region, RESOLUTION, materials, occupancies)
			end
		end
	end
end

return TerrainGeneratorService

The fix is the variables px and pz:

local px = x + (i-1)*RESOLUTION
local pz = z + (j-1)*RESOLUTION
---
local mat, occ = self:GetMaterial(px, pz)
1 Like

You’re generating both grass and soil in the same loop, so the soil layer is overwriting the grass. You need to generate the grass first, then only replace specific areas with soil based on the noise, rather than doing everything at once.

You’re calculating the px and pz positions based on the chunk grid, but then you’re treating them as if they represent individual voxel positions within that chunk. This can cause wrong positioning when generating the terrain because you’re using the wrong scale or reference for your noise calculations. The positions need to be calculated relative to the world space, not just the chunk grid.

I believe px and pz are world-space relative

x and z in the outer loop are the chunk’s world-origin
(i-1)*RESOLUTION / (j-1)*RESOLUTION add the voxel’s offset inside that chunk

So

px = x + (i-1)*RESOLUTION
pz = z + (j-1)*RESOLUTION

should land exactly on the global 4-stud voxel grid, why would mis-positioning occur?

Sorry if I’m getting this incorrect

You need to make sure that both the chunk position and voxel position are combined correctly to get the right spot in the world. Right now, you’re adding the voxel offset directly to the chunk’s position.

x / z = world-space origin of the current chunk (e.g. 256 , 256).
(i-1)*RESOLUTION / (j-1)*RESOLUTION = local voxel offset inside the chunk (0 → 252 in 4-stud steps).

So px, pz are already full world coordinates, not local ones

Is this not correctly calculating the exact right spot in the world?

I’m only using them to sample noise; WriteVoxels still writes with the Region3 we build for that chunk so everything should line up no?

You need to convert them to local chunk coordinates before sampling.

If I converted every voxel to local chunk space before sampling, each chunk would start again at (0, 0) because local coordinates reset to the chunks origin–so every chunk re-uses the exact same (0, 0) noise sample and the pattern would tile right?

math.noise is deterministic so it creates same input → same output

  • Chunk A starts sampling at math.noise(0, 0)
  • Chunk B (256 studs to the right) also starts at math.noise(0, 0) if you fed it local coords, because its own origin is treated as 0.

Result: the noise field “tiles” every 256 studs, forming visible seams where chunks meet

In my loop I do:

local px = chunkX + (i-1)*RESOLUTION
local pz = chunkZ + (j-1)*RESOLUTION
local noise = self:FractalNoise(px / scale, pz / scale, ...)

Which should give the exact world-space coordinate for that voxel and keeps the noise continuous across chunk boundaries, switching to local chunk coordinates fixes mis-alignment that doesn’t exist as far as I can tell and introduces tiling / seams?

I might just be crazy here so I won’t bother you anymore if this is still incorrect lmao

Sorry about that, I got a bit carried away :sweat_smile:. I guess I just needed to double-check things; this is right. Using local chunk coordinates would cause the seams because each chunk would start from (0, 0) and repeat the same noise pattern. Thank you, that’s on me.

1 Like

It has helped me, but I have to make a ground generation something like on image, but it didn’t work

image

I got this

Try out these values and let me know if they look closer to what you want:

local MATERIAL_LAYERS = {
	{
		name = "Ground",
		noiseScale = 256,
		threshold = { -0.15, 0.15 },
		material = Enum.Material.Ground,
		occupancy = 1
	}
}

In case you wanna do some tweaking on your own:


noiseScale = Scales the X / Z fed into math.noise

  • Large value - lower frequency - large, smooth blobs
  • Small value - higher frequency - tight, speckly pattern

32-64 = patchy / camo, 128-512 = big continents


threshold = {low, high] = Keeps only noise values between low and high

  • Band width (high-low) sets how much of the map becomes Ground

E.g.

-- Big smooth islands with crisp edges
noiseScale = 256
threshold  = { -0.15, 0.15 }

-- Small noisy patches
noiseScale = 48
threshold  = { -0.35, 0.35 }