Custom CPU terrain in GDScript¶
Compute terrain in pure GDScript, on the CPU. Use this when your terrain
needs CPU-side data a shader can't easily reach — a heightmap Image, tiles you
downloaded, world state — or when you'd rather write GDScript than GLSL. This is
a CesBuilder on the CPU device (see the builders overview).
You define the same functions as the GPU path — height, color, and
optionally normal — but in GDScript, and batched: each is called once per
chunk with a PackedVector3Array of texel directions, returning one value per
direction.
Performance: baking runs on the main thread, one call per chunk (not per texel) — the planet hands you a
PackedVector3Arrayof all the chunk's texel directions and you return a packed array, so there's no per-texel call overhead. The loop still runs in GDScript, though, so for heavy math keep the planet'stile_resmoderate, or use the GPU path (thousands of GPU threads, far faster — also authored without Rust).If the bake is slow — or has to wait on a file or the network — don't do it here: use Advanced: async bake, which moves the work off the frame.
The CPU path is the slow path — even async
In rough order of speed:
- GPU builder (
.glsl) — thousands of GPU threads, no CPU cost. Prefer it for anything procedural. - A CPU builder written in Rust, as your own GDExtension — real threads, no interpreter, no per-chunk marshalling between GDScript and the engine. If you need CPU-side terrain and you care about frame times, this is the route that actually holds up.
- This page — a CPU builder in GDScript. The most convenient, and by a wide margin the slowest.
And async is not a free pass. It keeps a heavy bake off the main thread, but it
does not make the CPU route free: _bake_requested itself runs on the main
thread, the results still have to be handed back and uploaded, and a burst of new
chunks — a fast dive at the surface, a teleport — can still cost you frames. It
turns a hard stall into a softer one, not into nothing.
The faster Rust builder API will get proper documentation of its own in a future release.
1. Write the builder script¶
Create a script that extends CesBuilder, selects the CPU device in _init,
and defines the functions. The @tool + class_name are what make it show up in
the inspector's New list:
@tool
class_name MyCpuTerrain
extends CesBuilder
func _init() -> void:
device = 1 # BuilderDevice.CPU (0 = GPU, 1 = CPU) — bake height/color on the CPU
# REQUIRED — displacement as a FRACTION of the planet radius (0 = sea level),
# one entry per direction. Each `dirs[i]` is a unit world-space direction.
func height(dirs: PackedVector3Array) -> PackedFloat32Array:
var out := PackedFloat32Array()
out.resize(dirs.size())
for i in dirs.size():
var d := dirs[i]
out[i] = maxf(0.5 * sin(d.x * 5.0) * sin(d.z * 5.0) + 0.25 * d.y, 0.0) * 0.05
return out
# REQUIRED — surface albedo, one entry per direction. `heights[i]` is height()[i].
func color(dirs: PackedVector3Array, heights: PackedFloat32Array) -> PackedColorArray:
var out := PackedColorArray()
out.resize(dirs.size())
for i in dirs.size():
var h := heights[i]
if h <= 0.0001:
out[i] = Color(0.03, 0.15, 0.45) # sea
else:
var t := clampf(h / 0.05, 0.0, 1.0)
out[i] = Color(0.2, 0.45, 0.15).lerp(Color(0.9, 0.9, 0.92), t)
return out
# OPTIONAL — world normals. Omit this and the library finite-differences the
# height grid for you (relief shading for free).
#func normal(dirs: PackedVector3Array, heights: PackedFloat32Array) -> PackedVector3Array:
# var out := PackedVector3Array()
# out.resize(dirs.size())
# for i in dirs.size():
# out[i] = dirs[i] # radial = smooth shading
# return out
deviceis an int in GDScript:0= GPU,1= CPU. TheBuilderDeviceenum is Rust-side (it renders as a dropdown in the inspector) and its names aren't reachable from GDScript, so write the number and keep the comment.
With the device set to CPU in _init the planet calls your batched
height/color/normal once per chunk on the main-thread baker. To expose
tunables as inspector sliders, see
Advanced: custom parameters below.
2. Assign it to your planet¶
On the Celestial node, find the Builder property (it holds the
default noise builder). Click Revert value to clear it, then choose New →
MyCpuTerrain (the script you created). Set the planet's tile_res to a
moderate value (e.g. 128) — lower it if the per-chunk GDScript loop is too slow.
If
MyCpuTerraindoesn't appear in the New list, the editor hasn't picked up the new global class yet — restart the editor (or Project → Reload Current Project), which also reloads the latest build of the extension.
Advanced: custom parameters¶
Expose tunables as inspector sliders by adding @export variables to your
builder script — then just read them directly in height/color/normal.
Unlike the GPU path there's no shader and no #define: it's the same GDScript, so
you reference each parameter by its normal (lowercase) name.
@tool
class_name MyCpuTerrain
extends CesBuilder
@export_range(0.5, 8.0, 0.01) var ridge_sharpness: float = 2.0
@export_range(0.0, 1.0, 0.001) var snow_line: float = 0.8
func _init() -> void:
device = 1 # BuilderDevice.CPU
func height(dirs: PackedVector3Array) -> PackedFloat32Array:
var out := PackedFloat32Array()
out.resize(dirs.size())
for i in dirs.size():
var d := dirs[i]
var r := 1.0 - absf(sin(d.x * 5.0) * sin(d.z * 5.0)) # 0..1 ridge
out[i] = pow(r, ridge_sharpness) * 0.06 # <- your param
return out
func color(dirs: PackedVector3Array, heights: PackedFloat32Array) -> PackedColorArray:
var out := PackedColorArray()
out.resize(dirs.size())
for i in dirs.size():
var t := clampf(heights[i] / 0.06, 0.0, 1.0)
if heights[i] <= 0.0001:
out[i] = Color(0.03, 0.15, 0.45) # sea
elif t >= snow_line: # <- your param
out[i] = Color(0.92, 0.94, 0.97) # snow
else:
out[i] = Color(0.2, 0.45, 0.15)
return out
Dragging a slider updates the terrain live — the planet polls the builder's
@export float values each frame and re-bakes when one changes, so no
emit_changed() is needed. (Editing the function code isn't polled; re-run the
scene, or call emit_changed() yourself, to pick that up.)
Advanced: async bake (threads, files, network)¶
height/color run on the main thread, so anything slow inside them — a
heavy loop, a file read, an HTTP request — stalls the frame. Define
_bake_requested instead and the planet stops waiting for you: it hands you
the chunks it wants and moves on. You bake them however you like, and call
submit_chunk when each one is ready.
While a chunk has no surface it simply isn't drawn at full detail — a coarser ancestor covers that ground — so a slow bake mostly costs detail rather than framerate. "Mostly": the hand-off is still real work on the main thread, so a large burst of new chunks can cost you a frame or two even here.
Only three names below belong to this library: the planet calls
_bake_requested (and, optionally, _base_ready) on your builder, and you
call submit_chunk (and chunk_dirs) on it. Everything else — bake_one,
member variables, helper functions — is ordinary code of yours, named however
you like.
@tool
class_name MyAsyncTerrain
extends CesBuilder
func _init() -> void:
device = 1 # BuilderDevice.CPU
# CALLED BY THE PLANET, main thread, once per frame. Take the work and return —
# never block here.
func _bake_requested(requests: Array) -> void:
for r in requests:
WorkerThreadPool.add_task(bake_one.bind(r))
# Just a function of yours. Here it runs on a worker thread; it could equally be
# the callback of an HTTP request, or anything else.
func bake_one(r: Dictionary) -> void:
var tile_res: int = r["tile_res"]
var n := tile_res * tile_res
# Per-texel directions are not in the request (they're big); ask for them.
var dirs: PackedVector3Array = chunk_dirs(r["corners"], tile_res)
var heights := PackedFloat32Array()
heights.resize(n)
var colors := PackedColorArray()
colors.resize(n)
for i in n:
var d := dirs[i]
var h := maxf(0.5 * sin(d.x * 5.0) * sin(d.z * 5.0) + 0.25 * d.y, 0.0) * 0.05
heights[i] = h
colors[i] = Color(0.2, 0.45, 0.15) if h > 0.0001 else Color(0.03, 0.15, 0.45)
# Safe to call from any thread. Pass a 4th PackedVector3Array of normals to
# override the automatic finite-difference.
submit_chunk(r["handle"], heights, colors)
The request¶
Each entry of requests is a Dictionary:
| key | type | meaning |
|---|---|---|
handle |
int |
opaque key — pass it back to submit_chunk |
corners |
PackedVector3Array |
the chunk's 3 unit-sphere corners |
tile_res |
int |
bake a tile_res × tile_res grid |
depth |
int |
the chunk's LOD depth |
A chunk is requested once and not asked for again while you owe it. If the
camera flies past before you answer, the planet forgets it — your late
submit_chunk is dropped, and the chunk is re-requested if the player returns.
There is no cancel callback to handle.
Where you may call submit_chunk¶
Anywhere, at any time, on any thread, as many times as you want. There is no
designated place for it — it takes a lock, hands the surface over and returns.
Call it straight from _bake_requested if the chunk is already cached; call it
from a worker task; call it from an HTTPRequest's request_completed signal
minutes later. The planet picks up whatever has arrived on its next frame.
The only thing that matters is the handle: whatever you pass must be the one
from the request you're answering. Keep it alongside your in-flight work.
Submitting again refines a chunk¶
submit_chunk is idempotent: call it again for a handle you already
submitted and the planet re-draws that chunk with the new surface. That's the
whole streaming story — return a coarse tile now, and when the real one
downloads, submit the same handle again:
var _http := HTTPRequest.new() # a Node; add it to the tree from your scene
func _bake_requested(requests: Array) -> void:
for r in requests:
submit_chunk(r["handle"], coarse_heights(r), coarse_colors(r)) # something now
start_download(r) # better later
# Your own signal handler — nothing to do with CelestialSim.
func _on_download_finished(handle: int, body: PackedByteArray) -> void:
var heights := decode_heights(body)
var colors := decode_colors(body)
submit_chunk(handle, heights, colors) # replaces the coarse version
Hiding the planet until your data arrives¶
Add _base_ready and the planet shows its plain fallback until you say your
base data has landed — no half-loaded terrain during the first downloads:
Notes¶
heightsandcolorsmust each hold exactlytile_res²entries, row-major. A wrong length is reported once and the submission dropped; non-finite heights are treated as0.chunk_dirs(corners, tile_res)is pure — safe from a worker thread. Call it only if you need per-texel directions; a tile-streaming builder usually wants a lat/lon box derived fromcornersinstead.- Heights are in your unit; a
height_scalemultiplies them into the displacement. It is not inherited — declare it yourself (@export var height_scale: float) and the library reads it by name. For real-world metres on an Earth-sized planet, setheight_scale = exaggeration / 6_371_000.0. - Don't do slow work inside
_bake_requesteditself — it runs on the main thread. Hand it to a thread, a download, or a queue, and return. - Defining
_bake_requestedreplacesheight/color; don't write both.
Notes¶
- Direction mapping is handled for you: you receive a
PackedVector3Arrayof unit world directions — one per texel — and return one value per entry, in the same order. (Internally the corners are interpolated and renormalized, matching the shaders closely over each small chunk.) - A missing
height= flat, missingcolor= white, missingnormal= finite-differenced — so partial builders still work. Returning a shorter array thandirs.size()leaves the remaining texels at the default. - The returned
heightis a fraction; aheight_scaleyou declare on your builder multiplies the geometry displacement (see above).
Which path should I use?¶
GPU (.glsl) |
CPU (GDScript) | CPU async (GDScript) | |
|---|---|---|---|
| You define | terrain_height / terrain_color |
height / color |
_bake_requested |
| Language | GLSL | GDScript | GDScript |
| Runs on | thousands of GPU threads | main thread, one batched call per chunk | wherever you put it |
| Best for | procedural terrain | image/data-driven, CPU logic | slow bakes, streaming, network tiles |
| Blocks the frame | no | yes, while it bakes | not while baking, but bursts can still cost frames |
| tile_res | full (256+) | moderate | full |
See custom_terrain_gpu.md for the GPU path, or the builders overview.