Pixel Paw Productions home

Devlog · · Wyatt · 9 min read · Tuning

Moving One Hot Loop to C++ in Godot: A GDExtension Wall Mesher With a GDScript Twin

Every blow here rebuilds the struck wall sections' meshes from their damage, off the main thread, in C++. At 2x.

Philadelphia Fiefdom is written in GDScript, with one exception. When a blow lands on a wall, each section it changed has to be rebuilt: a new mesh from its voxel damage, its collision boxes, and the bricks that fell. That job moved to C++ on October 28, as a GDExtension built with godot-cpp. On the Steam Deck, in a debug build, a section's build went from 0.83 ms to 0.068 ms. The GDScript version is still in the game, and the two have to produce the same result to the last bit.

This post is about why that one job, what moving it did and didn't fix, and what it takes to ship a native library for Windows and the Deck. The walls themselves are in the post about breaking them.

GDScript until a measure says otherwise

That rule went into the destruction plan before any of it was built. The first stage was all GDScript: applying a hit to a section's voxels, then a flood fill to find what was left with nothing holding it up. On the Deck, in a release build, a sledgehammer hit cost about 15 microseconds and the heaviest test hit, a blast, about 0.12 ms with its flood fill. Across 8,000 hits the worst was about 0.4 ms, apart from one at 0.69. That stayed in GDScript.

The second stage rebuilt a hit section's mesh: the wall laid out on its brick lattice, each brick kept or dropped by the damage at its middle, its thickness around the hole. In GDScript on the Deck, in a debug build, that took about 0.4 to 1 ms a section, on the main thread, within a 1.5 ms budget a frame. Ten damaged sections took about four frames to show. That was the measure.

Two changes, not one

The fix came in two steps, and they did different things.

First, the builds moved off the main thread. The main thread takes a snapshot of the section's damage (one copy a frame, shared by every job sent that frame) and hands it to Godot's WorkerThreadPool. The build runs there, touching nothing shared, and the main thread swaps the result in later. With the builds still in GDScript, destruction's work on the main thread during the stress test (four sections hit every half second) came to a median of 0.37 ms a frame and a worst of 1.03 ms, on the Deck in a debug build. That step is what took the builds out of the frame.

Second, the build itself moved to C++. Measured on the worker, per section, median and worst:

GDScriptC++
Steam Deck (debug build)0.83 / 1.12 ms0.068 / 0.23 ms
My PC, an RTX 30900.51 / 0.70 ms0.057 / 0.11 ms

About 12 times faster on the Deck and 9 on the PC, in line with what you'd expect from tight loops leaving an interpreted language. The Deck numbers are from a debug build, where GDScript pays for extra checks. A release build might narrow the gap. I haven't re-run it in release yet.

The timing wraps the whole job on the worker, including the bit of GDScript that prepares its inputs and places the result. What it doesn't include is the main thread taking the job off the walls.

So if the GDScript builds were already off the main thread, why bother? Because a section's build time is still time. The same worker pool streams the city in, and the Deck has four cores to share. A blow that changes many sections queues many builds. And the C++ library soon took a second job that is on the main thread: the flood fill that works out what's still standing after a blow. Its worst case on the Deck (debug) went from 1.68 ms to 0.17 ms.

Here's one blow, with the mesher in each language. It's a medium blow, so its dust takes 0.07 s, and the demo runs all its builds on one worker, one after another. Four sections is the stress test's hit. Twenty-four is every wall section of my test house at once, as a worst case:

This demo needs JavaScript.

Try it: one sledgehammer blow on the Steam Deck, the first 180 ms after it lands. Switch between GDScript and C++ and watch the orange settle and the green builds shrink, while the blue swaps and the moment the wall changes stay exactly where they were. Then try the whole test house at once.

What C++ didn't fix

The hole doesn't appear any sooner. A blow's dust swells first, and the wall changes under it: 0.03 to 0.11 s after the blow depending on how hard it was, and never before three frames have been drawn. The frame that takes the blow sends no builds at all. Even in GDScript, a hit's builds were done well inside that window.

The swap doesn't get cheaper either. Putting a new mesh and its collision into the world is Godot's work on the main thread: four sections swapped in one frame cost 1.2 to 1.4 ms on the Deck (debug), the same with either mesher. Neither does the GPU: breaking walls in the stress test added 0.1 to 0.2 ms of the Deck's GPU time either way.

The worst frame of breaking walls on the Deck did come down, from 4.5 ms to 1.6 ms (debug build, my test rowhome under the stress test). The native flood fill was one part of that. The rest was scheduling: a frame that takes a blow swaps nothing, small jobs go one a frame, and every send and swap waits for a frame it fits in.

What crosses the boundary?

Two calls a section, from GDScript into the library through ClassDB: one gathers the section's voxel damage, one builds. The inputs go over as Godot's packed arrays, ints and floats in a fixed layout both sides agree on, and the result comes back as a dictionary of packed arrays: vertices, normals, the extra data the shader reads, collision boxes, which bricks stand, and which fell. One fat call per section, never one per voxel, so the crossing should be a small part of the job. I haven't timed it on its own. The whole job, crossing included, is the 0.068 ms.

GDScript finds the library by asking whether the class exists, not by naming it. If the library isn't there, the GDScript build runs instead: a missing library costs speed, not the game.

Keeping two versions identical

The GDScript build stayed as the baseline. Two switches use it: --meshgd forces the GDScript build, and --meshparity runs both on every section and prints where they differ. On the PC and on the Deck, they don't.

Getting there meant matching GDScript's arithmetic. GDScript's floats are 64-bit, and the packed arrays the mesh comes back in are 32-bit. So the C++ computes in double and rounds at the same points GDScript does. Matching to the bit matters for more than testing. The build also produces the wall's collision, and in co-op a host and a guest may not both have the library. They should still agree on where the walls are.

Two versions can drift. On October 28, a new setting went into the GDScript build and the compiled libraries didn't have it yet. Until they were rebuilt about an hour later, the C++ build used its old value. A gap like that is exactly what the parity check shows. Every change to the mesher now lands in both, step for step, and the libraries are rebuilt with it.

Building it for Windows and the Deck

The libraries are built by hand. The Windows one with MSVC, the Linux one for the Deck with g++ under WSL, both against godot-cpp with Godot 4.7.2's API, and both committed to the repository next to the GDScript. There's no build server and no macOS build.

That's fine for now, and not fine for shipping. A Linux library needs a C library at least as new as the one it was built against. SteamOS is recent, so the Deck loads it. An older Linux desktop might not. A shipping build of the library should come from Valve's Steam Runtime SDK, or another older system, and the parity check should run on both platforms every time the libraries change, not only by hand.

Fewer triangles, same pixels

On October 30 both builds learned to merge a wall's faces. They used to lay each course of bricks on its own: a quad for each run's front and back, a piece for each side and top. Now a run carries on up through the courses above it with the same run, so a whole wall is one quad a face, and an intact window's wall is four quads around it. The toon shader draws the bricks either way. Screenshots from 11 views, compared against two baseline runs, differ only in what moves: cars, people and dither.

From commit 910b4e0, all baked buildings:

  • a 6-bay, 7-story brick block's 131 windowed wall sections: 26.0k triangles to 2.9k;
  • that whole block: 68.3k to 45.2k;
  • a 3-bay granite block: 19.9k to 9.8k;
  • the chunks within 60 m of one test spot: 412k to 256k.

Building all 131 of that block's sections in C++ got faster too, from 2.46 to 1.83 ms on my PC (best of 15 runs). And --meshparity agreed on all 892 intact sections and 647 under the stress test.

Yes, 45k is still a lot of triangles for one block in a pixel art game. The walls aren't the problem anymore. They're 2.9k of it. What's left is mostly window detail: the frames, and arched window heads built up a texel at a time. I haven't measured it piece by piece yet, so I won't guess the split. Those details are finer than a screen pixel in places, and the plan is no more geometry than the pixels need.

But I don't expect this to buy much frame rate. The Deck's frame isn't bound by triangles. Its main view takes 5 to 8 ms of GPU time, and that goes to shading pixels. What fewer triangles save is everything else: less to bake, store and stream, and less vertex work in every pass the GPU draws.

Why not the whole game?

Because nothing else measured slow enough. The simulation ticks in about 1.2 ms on my PC. The Deck's frame is GPU-bound, and C++ doesn't shade pixels faster. GDScript reloads while the game runs, which matters more than speed for code that's still changing.

The next candidate is already visible. When a house is set up to break, its windows and doors are made again in GDScript on the worker pool: about 21 ms of worker time for one house's 34 windows on my PC. That runs while the sledgehammer is still in the air, and the house is ready about 8 frames into the half-second swing on the Deck. As long as that holds, it stays in GDScript.

Quick answers

Why C++ and not C#? The project was GDScript only. A C++ GDExtension is one library per platform and adds no runtime next to the game.

Does the game still run without the library? Yes. It falls back to the GDScript build, which gives the same walls, just slower.

Are the Deck numbers from a release build? The mesher's and the flood fill's aren't: they're from a debug build, where both languages run slower than they'd ship. The first stage's hit costs are from a release build.

What's still rough is everything around the code: a hand-built library, no build server, and a Linux build that only has to work on one machine so far.

I design, direct and test Philadelphia Fiefdom, and an AI coding assistant writes most of the code with me. How I make it.

Get updates by email

One email when there's something worth seeing: new footage, playtest invites, the day the Steam page goes up and launch day.

No spam. Unsubscribe any time. Privacy

Want to play early? Join the playtest list. Rather chat? Hang out on Discord (opens in a new tab).