Hello, Developer Community!
I’m currently creating a space game where you can visit life-size planets, land, build, etc. However, I’ve come across a key issue in my attempt to create a “the world moves around the player” system: lag!
No matter what I do, I cannot seem to find a balance between seamlessness, performance, and visual appeal. I know it’s possible because I’ve seen many others do it. I spent the last 2 days playing around with code. Giving up, I decided to default back to baseline functionality.
(Uses PartCache)
Terrain.lua
local Chunk = require(game.ReplicatedStorage.Chunk)
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")
local RENDER_DISTANCE = 10
local CHUNKS_LOADED_PER_TICK = 4
local CAMERA = workspace.CurrentCamera
local MinFPS = 30
local performanceMeter = require(ReplicatedStorage:WaitForChild("ClientPerformanceMeter")).new({ fpsThreshold = MinFPS })
local centerPosX, centerPosZ
local chunks = {}
local chunkLoadCounter = 0
local fastLoad = true
local localPlayer = Players.LocalPlayer
local function getChunkKey(x, z)
return x .. "," .. z
end
local function chunkWait()
chunkLoadCounter = (chunkLoadCounter + 1) % CHUNKS_LOADED_PER_TICK
if chunkLoadCounter == 0 and not fastLoad then
task.wait()
end
end
local function updateChunks()
local playerPos = localPlayer.Character.PrimaryPart.Position
centerPosX = math.floor(playerPos.x / Chunk.WIDTH_SIZE_X)
centerPosZ = math.floor(playerPos.z / Chunk.WIDTH_SIZE_Z)
local requiredChunks = {}
for x = centerPosX - RENDER_DISTANCE, centerPosX + RENDER_DISTANCE do
for z = centerPosZ - RENDER_DISTANCE, centerPosZ + RENDER_DISTANCE do
local key = getChunkKey(x, z)
requiredChunks[key] = true
end
end
for key, chunk in pairs(chunks) do
if not requiredChunks[key] then
chunk:Destroy(fastLoad)
chunks[key] = nil
chunkWait()
end
end
for key, _ in pairs(requiredChunks) do
if not chunks[key] then
local parts = key:split(",")
local x = tonumber(parts[1])
local z = tonumber(parts[2])
chunks[key] = Chunk.new(x, z, fastLoad)
chunkWait()
end
end
end
local lastAdjustmentTime = 0
local DEBOUNCE_TIME = 5
performanceMeter:OnLowFPS(function(currentFPS)
local now = tick()
if now - lastAdjustmentTime >= DEBOUNCE_TIME then
if RENDER_DISTANCE > 1 then
RENDER_DISTANCE = math.max(1, RENDER_DISTANCE - 1)
warn("Lag detected, reduced render distance to " .. tostring(RENDER_DISTANCE))
end
lastAdjustmentTime = now
end
end)
while true do
local character = localPlayer.Character
if character and character.PrimaryPart then
updateChunks()
if fastLoad then
print("Initial chunk load complete. Switching to optimized loading.")
fastLoad = false
end
end
task.wait(0.5)
end
Chunk.lua
local RS = game:GetService("ReplicatedStorage")
local Biomes = require(RS.Biomes)
local pc = require(RS.PartCache)
local X, Z = 5, 5
local WIDTH_SCALE = 25
local WATER_FLOOR_Y = -200
local wedge = RS:WaitForChild("Wedge")
wedge.Anchored = true
wedge.TopSurface = Enum.SurfaceType.Smooth
wedge.BottomSurface = Enum.SurfaceType.Smooth
wedge.CanTouch = false
wedge.CanQuery = false
wedge.CollisionGroup = "WedgeGroup"
local returnBuffer = RS.ReturnBuffer
local cache = RS.Cache
local PartCache = pc.new(wedge, 200000, cache)
local function lerp(a, b, t)
return a + (b - a) * t
end
local function getColorFromPalette(wedgeHeight, TERRAIN_HEIGHT_COLORS)
local lowerColorHeight, higherColorHeight
for height, heightColor in pairs(TERRAIN_HEIGHT_COLORS) do
if wedgeHeight == height then return heightColor end
if (wedgeHeight < height) and (not higherColorHeight or height < higherColorHeight) then
higherColorHeight = height
end
if (wedgeHeight > height) and (not lowerColorHeight or height > lowerColorHeight) then
lowerColorHeight = height
end
end
if not lowerColorHeight then return TERRAIN_HEIGHT_COLORS[higherColorHeight] end
if not higherColorHeight then return TERRAIN_HEIGHT_COLORS[lowerColorHeight] end
local alpha = (wedgeHeight - lowerColorHeight) / (higherColorHeight - lowerColorHeight)
return TERRAIN_HEIGHT_COLORS[lowerColorHeight]:lerp(TERRAIN_HEIGHT_COLORS[higherColorHeight], alpha)
end
local function draw3dTriangle(a, b, c)
local ab, ac, bc = b - a, c - a, c - b;
local abd, acd, bcd = ab:Dot(ab), ac:Dot(ac), bc:Dot(bc);
if (abd > acd and abd > bcd) then c, a = a, c; elseif (acd > bcd and acd > abd) then a, b = b, a; end
ab, ac, bc = b - a, c - a, c - b;
local right = ac:Cross(ab).unit;
local up = bc:Cross(right).unit;
local back = bc.unit;
local height = math.abs(ab:Dot(up));
local w1 = PartCache:GetPart()
w1.Size = Vector3.new(0, height, math.abs(ab:Dot(back)))
w1.CFrame = CFrame.fromMatrix((a + b)/2, right, up, back)
local w2 = PartCache:GetPart()
w2.Size = Vector3.new(0, height, math.abs(ac:Dot(back)))
w2.CFrame = CFrame.fromMatrix((a + c)/2, -right, up, -back)
return w1, w2
end
local function getHeight(chunkPosX, chunkPosZ, x, z, biomeA, biomeB, alpha)
local smoothness = lerp(biomeA.TerrainSmoothness, biomeB.TerrainSmoothness, alpha)
local heightScale = lerp(biomeA.HeightScale, biomeB.HeightScale, alpha)
local noiseValue = math.noise(
(X / smoothness * chunkPosX) + x / smoothness,
(Z / smoothness * chunkPosZ) + z / smoothness
)
local heightA = biomeA.ApplyHeightAmplification(noiseValue * biomeA.HeightScale)
local heightB = biomeB.ApplyHeightAmplification(noiseValue * biomeB.HeightScale)
return lerp(heightA, heightB, alpha)
end
local function getPosition(chunkPosX, chunkPosZ, x, z)
local worldX = chunkPosX*X*WIDTH_SCALE + x*WIDTH_SCALE
local worldZ = chunkPosZ*Z*WIDTH_SCALE + z*WIDTH_SCALE
local biomeA, biomeB, alpha = Biomes.getBiomeData(worldX, worldZ)
local worldY = getHeight(chunkPosX, chunkPosZ, x, z, biomeA, biomeB, alpha)
return Vector3.new(
worldX,
worldY,
worldZ
)
end
local function paintWedge(wedge)
local wedgePos = wedge.Position
local biomeA, biomeB, alpha = Biomes.getBiomeData(wedgePos.X, wedgePos.Z)
local colorA = getColorFromPalette(wedgePos.Y, biomeA.HeightColors)
local colorB = getColorFromPalette(wedgePos.Y, biomeB.HeightColors)
wedge.Color = colorA:lerp(colorB, alpha)
wedge.Material = Enum.Material.Grass
end
local function addWater(chunk)
local chunkCenterX = (chunk.x + 0.5) * chunk.WIDTH_SIZE_X
local chunkCenterZ = (chunk.z + 0.5) * chunk.WIDTH_SIZE_Z
local biomeA, biomeB, alpha = Biomes.getBiomeData(chunkCenterX, chunkCenterZ)
local dominantBiome = if alpha > 0.5 then biomeB else biomeA
if not dominantBiome.HasWater then return end
local waterTopY = dominantBiome.WaterLevel
local waterHeight = waterTopY - WATER_FLOOR_Y
if waterHeight <= 0 then return end
local waterPart = Instance.new("Part")
waterPart.Anchored = true
waterPart.CanCollide = false
waterPart.CanTouch = false
waterPart.CanQuery = false
waterPart.Size = Vector3.new(chunk.WIDTH_SIZE_X, waterHeight, chunk.WIDTH_SIZE_Z)
waterPart.CFrame = CFrame.new(chunkCenterX, (waterTopY + WATER_FLOOR_Y) / 2, chunkCenterZ)
waterPart.Color = Color3.fromRGB(0, 175, 255)
waterPart.Transparency = 0.5
waterPart.Material = Enum.Material.Glass
waterPart.Name = "Water"
chunk.waterPart = waterPart
end
local Chunk = {}
Chunk.__index = Chunk
Chunk.WIDTH_SIZE_X = X * WIDTH_SCALE
Chunk.WIDTH_SIZE_Z = Z * WIDTH_SCALE
function Chunk.new(chunkPosX, chunkPosZ, fastload)
local chunk = { x = chunkPosX, z = chunkPosZ }
setmetatable(chunk, Chunk)
local chunkModel = Instance.new("Model")
chunkModel.Name = "Chunk_"..chunkPosX.."_"..chunkPosZ
local positionGrid = {}
for x = 0, X do
positionGrid[x] = {}
for z = 0, Z do
positionGrid[x][z] = getPosition(chunkPosX, chunkPosZ, x, z)
end
end
local operationsBudget = 50
local operationsCounter = 0
for x = 0, X - 1 do
for z = 0, Z - 1 do
local a, b, c, d = positionGrid[x][z], positionGrid[x+1][z], positionGrid[x][z+1], positionGrid[x+1][z+1]
local w1, w2 = draw3dTriangle(a, b, c)
local w3, w4 = draw3dTriangle(b, c, d)
paintWedge(w1); paintWedge(w2); paintWedge(w3); paintWedge(w4)
w1.Parent = chunkModel
w2.Parent = chunkModel
w3.Parent = chunkModel
w4.Parent = chunkModel
if not fastload then
operationsCounter = operationsCounter + 4
if operationsCounter >= operationsBudget then
task.wait()
operationsCounter = 0
end
end
end
end
addWater(chunk)
if chunk.waterPart then
chunk.waterPart.Parent = chunkModel
end
chunkModel.Parent = workspace
chunk.model = chunkModel
return chunk
end
function Chunk:Destroy(fastload)
if self.model then
self.model.Parent = returnBuffer.Model
for _, child in pairs(self.model:GetChildren()) do
child.Parent = returnBuffer.Part
if child.Name ~= "Water" then
PartCache:ReturnPart(child)
else
child:Destroy()
end
end
end
end
function Chunk:IsReady()
return self.isReady
end
return Chunk
Biomes.lua
local Biomes = {}
local BIOME_NOISE_SCALE = 15000
local BIOME_SEED = 12345
-- ===================================================================
-- COMPLETE BIOME DEFINITIONS
-- ===================================================================
Biomes.Definitions = {
Plains = {
Name = "Plains",
HeightScale = 100,
TerrainSmoothness = 20,
HasWater = true,
WaterLevel = -50,
ApplyHeightAmplification = function(height) if height > 20 then height += (height - 20) * 1.2 end; return height end,
HeightColors = {[-50] = Color3.fromRGB(216, 204, 157), [-10] = Color3.fromRGB(72, 113, 58), [0] = Color3.fromRGB(82, 123, 68), [75] = Color3.fromRGB(76, 80, 86), [150] = Color3.fromRGB(220, 220, 220)}
},
Desert = {
Name = "Desert",
HeightScale = 40,
TerrainSmoothness = 15,
HasWater = false,
WaterLevel = -100,
ApplyHeightAmplification = function(height) return height end,
HeightColors = {[-50] = Color3.fromRGB(240, 225, 179), [20] = Color3.fromRGB(216, 204, 157), [50] = Color3.fromRGB(181, 154, 119)}
},
Mountains = {
Name = "Mountains",
HeightScale = 400,
TerrainSmoothness = 30,
HasWater = true,
WaterLevel = -50,
ApplyHeightAmplification = function(height) if height > 10 then height += (height - 10) * 2.5 end; if height < -20 then height += (height + 20) * 1.5 end; return height end,
HeightColors = {[-50] = Color3.fromRGB(72, 113, 58), [50] = Color3.fromRGB(76, 80, 86), [200] = Color3.fromRGB(139, 143, 149), [350] = Color3.fromRGB(220, 220, 220)}
},
Canyonlands = {
Name = "Canyonlands",
HeightScale = 200,
TerrainSmoothness = 18,
HasWater = true,
WaterLevel = -120,
ApplyHeightAmplification = function(height) if height > 60 then return 150 + (height-60) * 0.1 end; return -110 end,
HeightColors = {[-115] = Color3.fromRGB(181, 154, 119),[-100] = Color3.fromRGB(168, 95, 52), [150] = Color3.fromRGB(205, 113, 63)}
},
Marshlands = {
Name = "Marshlands",
HeightScale = 8,
TerrainSmoothness = 25,
HasWater = true,
WaterLevel = -6,
ApplyHeightAmplification = function(height) return height * 0.5 end,
HeightColors = {[-10] = Color3.fromRGB(86, 76, 56), [-7] = Color3.fromRGB(60, 94, 72), [-4] = Color3.fromRGB(106, 118, 73)}
},
VolcanicWastes = {
Name = "Volcanic Wastes",
HeightScale = 150,
TerrainSmoothness = 12,
HasWater = false,
WaterLevel = -100,
ApplyHeightAmplification = function(height) return height end,
HeightColors = {[-50] = Color3.fromRGB(40, 40, 45), [20] = Color3.fromRGB(20, 20, 25), [80] = Color3.fromRGB(80, 40, 40), [120] = Color3.fromRGB(255, 60, 0)}
},
Tundra = {
Name = "Tundra",
HeightScale = 30,
TerrainSmoothness = 40,
HasWater = true,
WaterLevel = -40,
ApplyHeightAmplification = function(height) return height end,
HeightColors = {[-30] = Color3.fromRGB(111, 111, 91), [0] = Color3.fromRGB(140, 148, 108), [20] = Color3.fromRGB(200, 200, 205)}
},
RedwoodForest = {
Name = "Redwood Forest",
HeightScale = 250,
TerrainSmoothness = 28,
HasWater = true,
WaterLevel = -60,
ApplyHeightAmplification = function(height) return height end,
HeightColors = {[-50] = Color3.fromRGB(94, 78, 59), [0] = Color3.fromRGB(54, 73, 48), [150] = Color3.fromRGB(83, 85, 80)}
},
Savanna = {
Name = "Savanna",
HeightScale = 25,
TerrainSmoothness = 50,
HasWater = true,
WaterLevel = -45,
ApplyHeightAmplification = function(height) return height end,
HeightColors = {[-40] = Color3.fromRGB(210, 195, 147), [0] = Color3.fromRGB(189, 172, 117), [20] = Color3.fromRGB(160, 147, 104)}
},
IceSpikes = {
Name = "Ice Spikes",
HeightScale = 300,
TerrainSmoothness = 8,
HasWater = false, WaterLevel = -100,
ApplyHeightAmplification = function(height) return height * 1.5 end,
HeightColors = {[-50] = Color3.fromRGB(118, 155, 185), [50] = Color3.fromRGB(168, 205, 235), [250] = Color3.fromRGB(240, 245, 255)}
},
Highlands = {
Name = "Highlands",
HeightScale = 200,
TerrainSmoothness = 45,
HasWater = true, WaterLevel = -70,
ApplyHeightAmplification = function(height) return height end,
HeightColors = {[-60] = Color3.fromRGB(105, 111, 74), [0] = Color3.fromRGB(82, 98, 61), [150] = Color3.fromRGB(111, 108, 99)}
},
Badlands = {
Name = "Badlands",
HeightScale = 80,
TerrainSmoothness = 10,
HasWater = false,
WaterLevel = -100,
ApplyHeightAmplification = function(height) return height end,
HeightColors = {[-50] = Color3.fromRGB(171, 131, 101), [0] = Color3.fromRGB(205, 113, 63), [30] = Color3.fromRGB(168, 95, 52), [60] = Color3.fromRGB(111, 89, 73)}
},
Rainforest = {
Name = "Rainforest",
HeightScale = 180,
TerrainSmoothness = 15,
HasWater = true,
WaterLevel = -30,
ApplyHeightAmplification = function(height) return height end,
HeightColors = {[-20] = Color3.fromRGB(48, 68, 43), [50] = Color3.fromRGB(57, 113, 65), [120] = Color3.fromRGB(80, 100, 75)}
},
Dunes = {
Name = "Dunes",
HeightScale = 80,
TerrainSmoothness = 35,
HasWater = false,
WaterLevel = -100,
ApplyHeightAmplification = function(height) return height end,
HeightColors = {[-60] = Color3.fromRGB(245, 230, 184), [50] = Color3.fromRGB(215, 194, 147)}
},
Taiga = {
Name = "Taiga",
HeightScale = 90,
TerrainSmoothness = 22,
HasWater = true,
WaterLevel = -50,
ApplyHeightAmplification = function(height) return height end,
HeightColors = {[-40] = Color3.fromRGB(94, 84, 69), [0] = Color3.fromRGB(64, 83, 68), [60] = Color3.fromRGB(200, 205, 210)}
},
CrystallineChasm = {
Name = "Crystalline Chasm",
HeightScale = 250,
TerrainSmoothness = 10,
HasWater = false,
WaterLevel = -200,
ApplyHeightAmplification = function(height) return -math.abs(height) - 50 end,
HeightColors = {[-300] = Color3.fromRGB(80, 40, 120), [-150] = Color3.fromRGB(150, 90, 200),[-60] = Color3.fromRGB(210, 160, 230)}
},
Deadlands = {
Name = "Deadlands",
HeightScale = 40,
TerrainSmoothness = 18,
HasWater = false,
WaterLevel = -80,
ApplyHeightAmplification = function(height) return height end,
HeightColors = {[-30] = Color3.fromRGB(80, 78, 70), [10] = Color3.fromRGB(120, 115, 100), [30] = Color3.fromRGB(90, 88, 80)}
},
Oasis = {
Name = "Oasis",
HeightScale = 60,
TerrainSmoothness = 30,
HasWater = true,
WaterLevel = -10,
ApplyHeightAmplification = function(height) return height - (50 / (1 + (height/50)^2)) end,
HeightColors = {[-40] = Color3.fromRGB(216, 204, 157), [-15] = Color3.fromRGB(72, 113, 58), [20] = Color3.fromRGB(240, 225, 179)}
},
CherryBlossomGrove = {
Name = "Cherry Blossom Grove",
HeightScale = 50,
TerrainSmoothness = 35,
HasWater = true,
WaterLevel = -20,
ApplyHeightAmplification = function(height) return height end,
HeightColors = {[-15] = Color3.fromRGB(82, 123, 68), [10] = Color3.fromRGB(255, 183, 197), [30] = Color3.fromRGB(255, 135, 157)}
},
FungalCaves = {
Name = "Fungal Caves",
HeightScale = 100,
TerrainSmoothness = 12,
HasWater = true,
WaterLevel = -90,
ApplyHeightAmplification = function(height) return height end,
HeightColors = {[-80] = Color3.fromRGB(85, 71, 97), [-20] = Color3.fromRGB(143, 63, 133), [50] = Color3.fromRGB(61, 53, 69)}
},
MangroveForest = {
Name = "Mangrove Forest",
HeightScale = 5,
TerrainSmoothness = 20,
HasWater = true,
WaterLevel = -2,
ApplyHeightAmplification = function(height) return height end,
HeightColors = {[-4] = Color3.fromRGB(71, 64, 50), [0] = Color3.fromRGB(56, 82, 64)}
},
Prairie = {
Name = "Prairie",
HeightScale = 15,
TerrainSmoothness = 60,
HasWater = false,
WaterLevel = -50,
ApplyHeightAmplification = function(height) return height end,
HeightColors = {[-10] = Color3.fromRGB(117, 131, 81), [10] = Color3.fromRGB(147, 151, 101)}
},
SkyIslands = {
Name = "Sky Islands",
HeightScale = 600,
TerrainSmoothness = 40,
HasWater = false,
WaterLevel = -500,
ApplyHeightAmplification = function(height) if height < 150 then return -400 end; return 200 + (height - 150) end,
HeightColors = {[-400] = Color3.fromRGB(20, 20, 20), [-350] = Color3.fromRGB(80, 70, 60), [200] = Color3.fromRGB(72, 113, 58), [400] = Color3.fromRGB(120, 120, 120)}
},
}
-- ===================================================================
-- BIOME SELECTION LOGIC
-- ===================================================================
Biomes.SortedList = {
{Value = -1.00, Biome = Biomes.Definitions.SkyIslands},
{Value = -0.90, Biome = Biomes.Definitions.CrystallineChasm},
{Value = -0.80, Biome = Biomes.Definitions.IceSpikes},
{Value = -0.70, Biome = Biomes.Definitions.VolcanicWastes},
{Value = -0.60, Biome = Biomes.Definitions.Mountains},
{Value = -0.50, Biome = Biomes.Definitions.Canyonlands},
{Value = -0.40, Biome = Biomes.Definitions.Highlands},
{Value = -0.30, Biome = Biomes.Definitions.Badlands},
{Value = -0.20, Biome = Biomes.Definitions.Tundra},
{Value = -0.10, Biome = Biomes.Definitions.Taiga},
{Value = -0.05, Biome = Biomes.Definitions.RedwoodForest},
{Value = 0.00, Biome = Biomes.Definitions.Plains},
{Value = 0.10, Biome = Biomes.Definitions.Prairie},
{Value = 0.20, Biome = Biomes.Definitions.CherryBlossomGrove},
{Value = 0.30, Biome = Biomes.Definitions.Rainforest},
{Value = 0.40, Biome = Biomes.Definitions.Savanna},
{Value = 0.50, Biome = Biomes.Definitions.FungalCaves},
{Value = 0.60, Biome = Biomes.Definitions.Marshlands},
{Value = 0.70, Biome = Biomes.Definitions.MangroveForest},
{Value = 0.80, Biome = Biomes.Definitions.Oasis},
{Value = 0.90, Biome = Biomes.Definitions.Dunes},
{Value = 0.95, Biome = Biomes.Definitions.Deadlands},
{Value = 1.00, Biome = Biomes.Definitions.Desert},
}
function Biomes.getBiomeData(worldX, worldZ)
if not worldX or not worldZ then return Biomes.SortedList[12].Biome, Biomes.SortedList[12].Biome, 0.5 end
local biomeNoise = math.noise(
BIOME_SEED,
worldX / BIOME_NOISE_SCALE,
worldZ / BIOME_NOISE_SCALE
)
local biomeA, biomeB
for i = 1, #Biomes.SortedList - 1 do
if biomeNoise >= Biomes.SortedList[i].Value and biomeNoise < Biomes.SortedList[i+1].Value then
biomeA = Biomes.SortedList[i]
biomeB = Biomes.SortedList[i+1]
break
end
end
if not biomeA then
if biomeNoise < Biomes.SortedList[1].Value then
return Biomes.SortedList[1].Biome, Biomes.SortedList[1].Biome, 0
else
return Biomes.SortedList[#Biomes.SortedList].Biome, Biomes.SortedList[#Biomes.SortedList].Biome, 1
end
end
local alpha = (biomeNoise - biomeA.Value) / (biomeB.Value - biomeA.Value)
return biomeA.Biome, biomeB.Biome, alpha
end
return Biomes
I know there are some witchcraft developers out here who know how to make anything work. So, would you recommend I go about creating a system where the world moves around the player? Especially one that can handle thousands of parts at once.
As a side, if you see any issues with my scripts or improvements that can be made, please let me know (I’m still learning). Thanks!