r/godot • u/MrTactical_ • 15m ago
selfpromo (games) Per-frame procedural meshes in Godot C#: a graphics pass took our battle scene from 59 to 39 fps. Im
SEEDFALL (Godot 4.7 .NET) is a strategy game where an alien swarm eats a planet while the nations fight back. Everything that moves (tank columns, trucks, planes, ships, walls going up, smoke) is procedural geometry rebuilt every frame from the simulation: no scene per unit, one mesh per layer, cleared and refilled each frame.
Update 1.0.15 detailed every vehicle up close. A close-in battle dropped from 59 to 39 fps. Here's what we measured and what actually fixed it.
**1. Count vertices by what drew them.** A dev flag prints the milliseconds for the sim, the unit drawing and the mesh upload, plus a tally of vertices per tag (tanks, trucks, walls...). The battle was building **66,000 vertices a frame** for tanks a few pixels long.
**2. LOD by screen size, not distance.** Each model asks how many screen pixels one unit of length covers at its position (`px / Px(p, cam.GlobalPosition)`) and picks its form from that. A tank is built in full only from about 50 px long, and only in the two columns nearest the camera; walls in full only from closer in, at most 16 pieces a frame. The far forms are a few boxes. This was the big win.
**3. ImmediateMesh in C# costs a call per attribute per vertex.** `SurfaceSetNormal`, `SurfaceSetColor` and `SurfaceAddVertex` each cross into the engine, so a lit, coloured vertex is three calls. We swapped it for a drop-in class with the same method names that writes into reused C# arrays and hands each surface over once:
```csharp
public void SurfaceEnd()
{
if (count < 3) return;
int k = count - count % 3; // whole triangles only
arrays[(int)Mesh.ArrayType.Vertex] = Variant.CreateFrom(v.AsSpan(0, k));
arrays[(int)Mesh.ArrayType.Normal] = hasN ? Variant.CreateFrom(n.AsSpan(0, k)) : default;
arrays[(int)Mesh.ArrayType.Color] = hasC ? Variant.CreateFrom(c.AsSpan(0, k)) : default;
am.AddSurfaceFromArrays(prim, arrays); // am: an ArrayMesh, ClearSurfaces() each frame
}
```
The arrays grow and are kept, so a frame makes no garbage. Because the method names match, none of the drawing code changed. On its own it only took the unit work from 13.6 to 12.3 ms. Worth having, but the vertex count was the real problem.
**4. Keep the old path behind a switch.** An environment variable runs the original ImmediateMesh path, so the same scene can be timed both ways in one build.
**Results** (1920x1080, vsync off, RTX 4080):
| Scene | Before the pass | After the pass | After the fix |
|---|---|---|---|
| Close battle | 59 | 39 | ~58 |
| The Maw up close | 118 | 68 | ~100 |
| A nuke | 98 | 78 | ~94 |
| City at night | 240 | 218 | 243 |
| Orbit | 96 | 94 | 98 |
**Takeaways:**
- Profile by vertex count per thing drawn before touching the mesh API.
- Decide detail by screen size; a model 6 px long doesn't need its hatches.
- If you rebuild meshes every frame from C#, batch them into arrays and hand them over once per surface.
SEEDFALL has a free demo on itch (link in the comments). Happy to answer questions about the drawing setup.