i’m actually working on something exactly like this, so here ya go just incase you still need it:
first thing is mapping a direction on the sphere to a pixel in your image. For a normal 2:1 equirectangular map (common planet map) youd have to do something like:
local function dirToUV(x, y, z)
local yc = math.clamp(y, -1, 1)
local u = 0.5 + math.atan2(x, z) / (2 * math.pi)
local v = 0.5 - math.asin(yc) / math.pi
return u, v
end
x, y, z has to be a unit vector, and you need that clamp before asin or you get NaN right at the poles (yikes). u wraps around the seam and v clamps, and v = 0 is the top row of the image, which is the north pole. just pick a convention and stick with it
then the height map is just a radius, surfaceR = R + (h - seaLevel) * heightRange, where h is the height pixel read as 0 to 1, heightRange is how many studs you want between the lowest and highest point, and seaLevel is whichever height value should sit exactly at R.
btw, roblox terrain stores a fraction per voxel, not just solid or empty, and if you write 1 or 0 you get a weird blocky look (unless you want that). If you DON’T, then what you want is:
local VOXEL = 4
local d = (voxelCentre - planetCentre).Magnitude
local occ = math.clamp(0.5 + (surfaceR - d) / VOXEL, 0, 1)
d is measured to the center of the voxel. This gives you a one voxel wide ramp across the surface and this makes the terrain look not like a bumpy mess. It’s also the same thing FillBall writes internally, so if you fill the core with FillBall and write the shell yourself they’ll meet with no seam.
For writing it, Terrain:WriteVoxels(region, 4, materials, occupancies) with a Region3 you’ve run through :ExpandToGrid(4). A few things that aren’t really documented anywhere (as far as i know), the arrays are [x][y][z] and 1 based, their dimensions are size/4 exactly and not +1, index 1 is the lowest world coordinate on that axis, and if your region corners aren’t already on the 4 stud grid it floor snaps and everything ends up offset ;(
For getting the pixels, AssetService:CreateEditableImageAsync gives you a buffer you can read directly. something that claimed hours of my life is you should read the back off EditableImage.Size instead of trusting the dimensions of the file you uploaded, because Roblox will sometimes downscale on import and if your assumed width is wrong then every single sample lands in the wrong place
i think thats all you need, it worked for me. I’m working on a plugin for this exact thing though so when im done with it i could put it out for free.
example of something similar it should look like (i used a 10000 stud earth with 8192 x 4096 map):