EditableMesh restrictions

While using EditableMesh I created a sphere of size 10,000, you may notice this already that this surpasses the size limit of 2048. I did this by creating many (100) meshparts with an EditableMesh attached to each to then line up to form its surface.

As a serverscript, when ran (F8) it works perfect, however, as a server script when you actually test/play it, it has a strange issue where all meshparts are created with their EditableMesh’s with no errors printed but you can’t see any of the mesh.

As a local script it just reaches the memory budget limit of 8 EditableMesh’s.

local AssetService = game:GetService("AssetService")

local POSITION = Vector3.new(0, 0, 0)
local RADIUS = 5250
local SUBDIVISIONS = 7.5
local HEIGHT_INTENSITY = 500 
local TEX_SIZE = 1024

local NUM_REGIONS = 250 

math.randomseed(os.time() ^ 2)
local NOISE_SEED = Vector3.new(math.random(-1e5, 1e5), math.random(-1e5, 1e5), math.random(-1e5, 1e5))

local PI = math.pi
local PI_2 = math.pi * 2

local function getFibonacciSpherePoints(samples)
	local points = {}
	local phi = math.pi * (3 - math.sqrt(5))
	for i = 0, samples - 1 do
		local y = 1 - (i / (samples - 1)) * 2
		local radius = math.sqrt(1 - y * y) 
		local theta = phi * i 
		table.insert(points, Vector3.new(math.cos(theta) * radius, y, math.sin(theta) * radius))
	end
	return points
end

local function generateBadlandsPalette()
	return {
		Color3.fromRGB(89, 52, 39),
		Color3.fromRGB(161, 85, 59),
		Color3.fromRGB(209, 178, 129),
		Color3.fromRGB(124, 126, 110),
		Color3.fromRGB(143, 89, 102),
		Color3.fromRGB(230, 214, 186),
		Color3.fromRGB(74, 65, 62),
		Color3.fromRGB(189, 135, 85),
	}
end

local function fbm(p, scale, octaves)
	local total, freq, amp, maxVal = 0, scale, 1, 0
	for i = 1, octaves do
		total += (math.noise(p.X * freq + NOISE_SEED.X, p.Y * freq + NOISE_SEED.Y, p.Z * freq + NOISE_SEED.Z) + 1) * 0.5 * amp
		maxVal += amp
		amp *= 0.5
		freq *= 2
	end
	return total / maxVal
end

local function ridgedNoise(p, scale, octaves)
	local total, freq, amp, maxVal = 0, scale, 1, 0
	for i = 1, octaves do
		local n = math.noise(p.X * freq + NOISE_SEED.X, p.Y * freq + NOISE_SEED.Y, p.Z * freq + NOISE_SEED.Z)
		n = 1 - math.abs(n) 
		total += n * n * amp
		maxVal += amp
		amp *= 0.5
		freq *= 2
	end
	return total / maxVal
end

local function terraced(v, bands)
	return (math.floor(v * bands) + math.pow(math.fmod(v * bands, 1), 3)) / bands
end

local function getCombinedHeight(u)
	local base = fbm(u, 2.0, 8)
	local ridges = ridgedNoise(u, 3, 8)
	local mesaBaseNoise = fbm(u, 1.5, 7)
	local mesas = terraced(mesaBaseNoise, 12)
	local grit = fbm(u, 25, 4) * 0.03

	local finalHeight = math.max(mesas * 0.6, ridges * base)

	return finalHeight + grit
end

local function bakeECHOTexture(palette)
	local buf = buffer.create(TEX_SIZE * TEX_SIZE * 4)
	local numColors = #palette

	for y = 0, TEX_SIZE - 1 do
		local lat = (0.5 - (y / (TEX_SIZE - 1))) * PI
		local sinLat, cosLat = math.sin(lat), math.cos(lat)

		for x = 0, TEX_SIZE - 1 do
			local lon = ((x / (TEX_SIZE - 1)) - 0.5) * PI_2
			local u = Vector3.new(math.cos(lon) * cosLat, sinLat, math.sin(lon) * cosLat)

			local h = getCombinedHeight(u)
			local stripeNoise = math.noise(u.X * 5, u.Y * 2, u.Z * 5) * 0.05
			local stripeValue = (h + stripeNoise) * 25 

			local index = math.floor(stripeValue % numColors) + 1
			local nextIndex = (index % numColors) + 1
			local fraction = stripeValue % 1

			fraction = math.pow(fraction, 0.5) 
			local color = palette[index]:Lerp(palette[nextIndex], fraction)

			local grain = (math.noise(u.X*200, u.Y*200, u.Z*200) * 0.1) + 0.9
			if h < 0.1 then color = color:Lerp(Color3.new(0,0,0), 0.2) end

			local offset = (y * TEX_SIZE + x) * 4
			buffer.writeu8(buf, offset, math.clamp(color.R * 255 * grain, 0, 255))
			buffer.writeu8(buf, offset + 1, math.clamp(color.G * 255 * grain, 0, 255))
			buffer.writeu8(buf, offset + 2, math.clamp(color.B * 255 * grain, 0, 255))
			buffer.writeu8(buf, offset + 3, 255)
		end
		if y % 64 == 0 then task.wait() end
	end

	local editImage = AssetService:CreateEditableImage({Size = Vector2.new(TEX_SIZE, TEX_SIZE)})
	editImage:WritePixelsBuffer(Vector2.zero, Vector2.new(TEX_SIZE, TEX_SIZE), buf)
	return Content.fromObject(editImage)
end

local function generateMeshes(textureContent)
	local TAU = (1 + math.sqrt(5)) / 2
	local rawVerts = {
		Vector3.new(-1,TAU,0).Unit, Vector3.new(1,TAU,0).Unit, Vector3.new(-1,-TAU,0).Unit, Vector3.new(1,-TAU,0).Unit,
		Vector3.new(0,-1,TAU).Unit, Vector3.new(0,1,TAU).Unit, Vector3.new(0,-1,-TAU).Unit, Vector3.new(0,1,-TAU).Unit,
		Vector3.new(TAU,0,-1).Unit, Vector3.new(TAU,0,1).Unit, Vector3.new(-TAU,0,-1).Unit, Vector3.new(-TAU,0,1).Unit
	}
	local faces = {
		{1,12,6},{1,6,2},{1,2,8},{1,8,11},{1,11,12},{2,6,10},{6,12,5},{12,11,3},
		{11,8,7},{8,2,9},{4,10,5},{4,5,3},{4,3,7},{4,7,9},{4,9,10},{5,10,6},
		{3,5,12},{7,3,11},{9,7,8},{10,9,2}
	}

	local midpoints = {}
	local function getMid(v1, v2)
		local minI, maxI = math.min(v1, v2), math.max(v1, v2)
		local key = bit32.bor(bit32.lshift(minI, 16), maxI)
		if midpoints[key] then return midpoints[key] end
		local idx = #rawVerts + 1
		rawVerts[idx] = (rawVerts[minI] + rawVerts[maxI]).Unit
		midpoints[key] = idx
		return idx
	end

	for _ = 1, SUBDIVISIONS do
		local newFaces = {}
		for _, f in ipairs(faces) do
			local ab, bc, ca = getMid(f[1], f[2]), getMid(f[2], f[3]), getMid(f[3], f[1])
			table.insert(newFaces, {f[1], ab, ca})
			table.insert(newFaces, {f[2], bc, ab})
			table.insert(newFaces, {f[3], ca, bc})
			table.insert(newFaces, {ab, bc, ca})
		end
		faces = newFaces
	end

	local regionCenters = getFibonacciSpherePoints(NUM_REGIONS)
	local regionFaces = table.create(NUM_REGIONS)
	for i = 1, NUM_REGIONS do regionFaces[i] = {} end

	for _, f in ipairs(faces) do
		local centroidUnit = ((rawVerts[f[1]] + rawVerts[f[2]] + rawVerts[f[3]]) / 3).Unit
		local bestDist, bestRegion = math.huge, 1
		for i, center in ipairs(regionCenters) do
			local dist = (centroidUnit - center).Magnitude
			if dist < bestDist then bestDist, bestRegion = dist, i end
		end
		table.insert(regionFaces[bestRegion], f)
	end

	local planetModel = Instance.new("Model", workspace)
	planetModel.Name = "Badlands_Planet"

	for regionId = 1, NUM_REGIONS do
		local em = AssetService:CreateEditableMesh()
		local vertexMap = {}

		for i, f in ipairs(regionFaces[regionId]) do
			local localFaceIds, uvs = {}, {}
			for j = 1, 3 do
				local vIdx = f[j]
				local rawPos = rawVerts[vIdx]
				local uv = Vector2.new(0.5 + math.atan2(rawPos.Z, rawPos.X) / PI_2, 0.5 - math.asin(rawPos.Y) / PI)
				uvs[j] = uv
				if not vertexMap[vIdx] then
					local h = getCombinedHeight(rawPos)
					local displacement = (h - 0.2) * HEIGHT_INTENSITY
					local r = RADIUS + displacement
					vertexMap[vIdx] = em:AddVertex(rawPos * r)
				end
				localFaceIds[j] = vertexMap[vIdx]
			end

			if math.abs(uvs[1].X - uvs[2].X) > 0.5 or math.abs(uvs[1].X - uvs[3].X) > 0.5 then
				for j = 1, 3 do if uvs[j].X < 0.5 then uvs[j] += Vector2.new(1, 0) end end
			end

			local faceId = em:AddTriangle(localFaceIds[1], localFaceIds[2], localFaceIds[3])
			em:SetFaceUVs(faceId, {em:AddUV(uvs[1]), em:AddUV(uvs[2]), em:AddUV(uvs[3])})
			if i % 1000 == 0 then task.wait() end
		end

		local success, mp = pcall(function() return AssetService:CreateMeshPartAsync(Content.fromObject(em)) end)
		if success and mp then
			mp.Name = "Region_" .. regionId
			mp.Position = POSITION
			mp.Anchored = true
			mp.Material = Enum.Material.Basalt
			mp.TextureContent = textureContent
			mp.Parent = planetModel
		end
		task.wait()
	end
end

task.spawn(function()
	local palette = generateBadlandsPalette() 
	local tex = bakeECHOTexture(palette)
	generateMeshes(tex)
end)

Hey! The issue isn’t really your generation code it’s hitting EditableMesh / mesh creation limits.

You’re doing:

very high subdivisions
250 regions
thousands of triangles per region

This easily goes past what Roblox allows for runtime mesh creation, so CreateMeshPartAsync will start silently failing or erroring.

Main problems in your script

SUBDIVISIONS = 7.5
This is WAY too high subdivision grows exponentially (like 20 → thousands → millions of triangles)


NUM_REGIONS = 250
You’re creating 250 separate meshes, each with lots of verts


Combined result
→ too many vertices / triangles
→ hits engine limits


What could possibly help is

Lower subdivisions A LOT

local SUBDIVISIONS = 4 -- or even 3

Reduce regions

local NUM_REGIONS = 50 -- start lower

Add debug for mesh creation

local success, mp = pcall(function()
	return AssetService:CreateMeshPartAsync(Content.fromObject(em))
end)

if not success then
	warn("Mesh failed to create for region", regionId)
end

Important limitation

Roblox has limits on:

max vertices per mesh
total memory
runtime mesh creation

So even if your code is correct:

it will still fail if the mesh is too complex


Docs

https://create.roblox.com/docs/reference/engine/classes/AssetService#CreateMeshPartAsync
https://create.roblox.com/docs/reference/engine/classes/EditableMesh

1 Like

Thanks, but I’ve checked and that doesn’t seem to be the problem. I’m aware of the high count of subdivisions and the number of regions and have narrowed this down to just be a problem with how Roblox handles EditableMesh on the client.

There’s two parts of this.

  1. EditableMesh does not currently support replication. Instead of the replicated object, you will get a checkerboard box.

  2. Memory limits

Editable assets are currently expensive for memory usage. To minimize its impact on client performance, EditableMesh has strict client-side memory budgets, although the server, Studio, and plugins operate with unlimited memory.

One thing that might be useful is looking at the CreateDataModelContentAsync studio beta, which allows you to convert EditableMeshes and EditableImages into DataModelContent. DataModelContent will replicate and has much higher memory budgets, although it is still in studio beta.

1 Like