Raw Dev Log #1: GPU Instancing, or How 2,583 Plants Became 3 Draw Calls












Walking on my treadmill while writing this. I have ADHD so if exercise isn't the first thing I do in the morning, it's just not likely to happen, lol. I bought a cheap treadmill off Amazon and set up the hydraulic table I already use for my mouse pad when I'm sitting, raised it up to about belly button height in front of my PC with my mouse and keyboard on it. Monitors tilt up a bit so I can see them while standing. Something in the house is better for me than the gym or yoga which I tend to stop going to in a couple months after signing up, as is generally the case. I was walking 2-3 hours per day for about a week before flying to Boston to show the game at PAX East, but now I'm back at it after a couple days recovery.
I showed a little bit of the new farm terrain in the last post. But here's an interesting problem and solutions to the new terrain I've never mentioned before.
GPU Instancing: How I Got 2,583 Plants Down to 3 Draw Calls
So my 3D modeler first started sending me terrain model tests back in November 2025 or earlier. He was actually a fan of the game originally. Redesigned the Cornucopia logo on his own almost two years ago just because he wanted to, after reaching out to me. Over time he's become pretty much the only active person working alongside me on the game. He has been working as a contractor for about the past 2 years on the game, and is now the most important person helping on the game. (And as of right now, the only other person other than me.) A player who loved what I was building and ended up helping build it. That kind of thing doesn't happen often.
In March 2026 just prior to PAX East he sent over a complete farm terrain redevelopment after we planned and brainstormed it for many months. I wanted to implement it prior to PAX, but it just wasn't possible. The terrain is WAY WAY more detailed and interesting than before and includes a new connected oceanic zone. And every day he keeps sending more fixes and improvements as we work together. Flowers, bushes, ground cover, coral for the oceanic zones, little plants growing between rocks. Today he sent background parallax layers with pine trees and oak at different depths behind the farm. The game has temperate farmland, oceanic zones, cave environments, rocky areas, the town. All of these areas are getting filled in with environmental details that give them actual personality. Stuff that was missing before.
He kept asking me in the past, "How much can I add?", and really kept trying to push the scope larger and more detailed than I thought was possible for performance.
And I kept looking at the designs thinking about the GPU, FPS, and performance on Switch/consoles.
The Problem
Every separate object in a game is a request to the graphics card. Every flower, every bush, every little ground cover plant. "Draw this." "Ok now draw this." "Now this one."
2,583++ of them.
On a decent gaming PC, fine. But we're also developing for console and we want it running well on lower end PCs, laptops, and maybe Mac in the future. Those systems care a lot about how many separate draw requests you throw at them. It's not about how many triangles are on screen, it's about how many separate things you're asking the GPU to handle at once.
And I'm looking at this beautiful scene that finally has the environmental detail the game was always missing, and I'm thinking... do I have to tell him to cut it back? Because the game can't handle it?
I really didn't want to.
The Breakthrough
My first attempt was standard GPU instancing, where you tell the GPU "here's one mesh, draw it 500 times at different positions." Efficient. But it requires identical geometry and these plants are all unique shapes from Blender. Different flowers, different bushes, different sizes. Didn't compress enough.
Then I realized something.
These plants are all stuff the player can't interact with. You can't pick them up, walk into them, nothing. They're purely visual. And they share the same texture atlas.
This is actually the first time we've ever added environmental greenery that's un-interactable. Pretty much everything in the game, you can interact with. So that's the reason we can instance these with the GPU. But anything that's collidable or you can interact with, like the regular props or regular weeds or trees, those need their own separate game objects with their own scripts and information on them. And I don't really think I can safely instance those because of the amount of unique information and interactability stored on each one.
If nobody interacts with them and they share the same texture... why are they separate objects? What if I just take all the nearby ones and literally merge their meshes into one big mesh? Unique shapes don't matter once you bake all the vertices into world space. The GPU just sees one object. (Individually animating each of them with wind was another concern, but I get into that later in this post.)
That was the moment everything changed.
The Process
The first problem was trees. Your character walks around tree trunks and bumps into them, so trunks need collision. If I merged the trunks into one big mesh you'd just clip right through everything. But the leafy canopy on top? Nobody needs to walk up there. So canopies can be combined, trunks can't. Same thing for cosmetic vegetation and bushes, don't need collisions for them.
I needed to separate every tree in the scene into its two parts before doing anything else. Wrote a tool in Unity that does it in one click. Canopy meshes get grouped for baking, trunk meshes stay individual but get marked static so Unity batches them behind the scenes.
Then I made the actual vegetation baker. This is the tool that does the combining. You select a parent object with all the plants underneath it, click one button, and it handles everything. It splits the world into a grid where each cell is 20 units across. I chose that size specifically because it's roughly one screen width for the isometric camera. That way the GPU can skip entire cells that are offscreen instead of trying to process one giant mesh that covers the whole map. Within each cell, it merges plant meshes together up to 60,000 vertices. 16-bit index format where possible because it's faster on less powerful hardware.
I also wrote a one-click optimizer on top of that. Turns off shadow casting on all vegetation (shadows are expensive on weaker hardware and honestly you don't notice them on small plants), marks everything for static batching, and gives me a report of the estimated draw calls so I can see exactly where we're at.
We ran actual density tests too. I imported a test file literally called GrassDensityCapacityTest to see how much we could push before the frame rate died. Turns out the system handles way more than we expected. That was a really good moment. The 3D modeler has also been sending me all kinds of tests throughout the months of this farm terrain remake. Like how far the player can jump, how high they jump, platforming elements, sand wetness tests, all kinds of stuff. It's actually hard to remember it all, but it's been a lot. And that's really helped him with the process of how to model all this stuff in Blender. It's been a lot. It's hard to remember it all.
The Wind
This is the part I'm most happy about and honestly surprised it works properly with the GPU instancing thanks to a custom shader and script.
When every plant was its own object, each one swayed in the wind on its own. Easy. But once you combine thousands of them into a few big meshes, they're all the same object now. How do you make individual plants inside one combined mesh still move independently?
Before combining, I go through each plant and "paint" its vertices with a sway weight. The bottom of the plant, the part in the ground, gets painted with 0. That means don't move, you're anchored. The top gets painted with 1. Full sway. Everything in between is a smooth gradient. So the stems barely move, the middle moves a bit, and the tips of the leaves and petals move the most. Just like a real plant in the wind.
Then I wrote a shader that reads those painted values and pushes the vertices around. I use two overlapping sine waves at slightly different frequencies. That layering is what makes it feel gusty and organic instead of everything going perfectly back and forth in sync. Some plants lean left while the one right next to it leans right. Some are mid-sway while others are catching up.
The shader I wrote ended up handling all of these details automatically once it's all baked and the wind settings are on. And you can actually set the wind values for each batch, so the behavior of the tree foliage animates differently than the separately batched random vegetation like flowers and weeds and decorative stuff.
And I thought carefully about what should and shouldn't sway. Coral sitting on rocks? Stays still. Ground cover flat against terrain? Static. I made separate NoWind material variants for those. Small detail but when everything sways including stuff that shouldn't, the whole scene looks wrong.
The tree canopies have a different feel from ground plants too. More of a slow, subtle breathing kind of movement. Softer than the obvious swaying of flowers and bushes. Different vegetation, different personality.
For any devs reading: the shader handles all three rendering modes (baked combined mesh, standalone tree with wind component, plain mesh) without any if/else branching. GPUs are slow at branching, so I use step() and lerp() to blend between modes with pure math. Same code path for everything.
The Result
I ran the baker and watched the draw call counter go from 2,583 to 3.
2,583 draw calls became 3. A 99.88% reduction.
This was pretty surprising, and I was very happy seeing this work properly.
99% reduction. The farm used to be just the interactive props sitting on kind of a bare surface. Now there are flowers growing between every rock, bushes along every path, ground cover everywhere. And when you're walking through it all and everything is swaying around you in the wind, each plant moving a little differently... that's a handful of draw calls doing the work of thousands. You'd never know. All of the existing stuff that you can interact with works the same. It's just all of this decorative environmental stuff that really brings the world to life is what's GPU instanced.
Haven't tested on console yet specifically. Numbers look really promising though.
And the most important thing: my modeler can add as much environmental vegetation as he wants now. I don't have to be the person saying "cut it back" when it looks this good. A fan of the game who ended up being the person making it beautiful, and now there's not much holding him back in terms of creating this terrain. That's a good feeling.
I should note that there's a lot of things specific to the game that are constraints due to the non-rotating nature of the camera view. It's sort of a Paper Mario style where you can zoom in and out but it's fixed to one direction. We don't want any of the design to have higher elements in the foreground that block the camera view when you're in the lower angle perspective. So that was also a key design decision when remaking the terrain, and it took a little while to totally convey it all to the modeler over the months. Trial and error of tests.
Technical Deep Dive (For Devs Who Want To Do This)
If you want to implement something similar, here's how the key pieces actually work. Skip this if you're not into code.
The Baking Pipeline
The Vegetation Baker is a Unity EditorWindow. Takes a parent GameObject, collects every child that has a MeshFilter, and runs through three stages.
First, wind color baking. For each unique shared mesh, it clones the mesh and writes vertex colors. Finds the local Y bounds of the plant (min to max), normalizes every vertex Y into a 0-to-1 range. That goes into the red channel. Green channel gets set to 0 as a "this has been baked" marker. Unbaked meshes have G=1 (the default white), so the shader can tell the difference. Caches these baked meshes so identical plants reuse the same version instead of cloning every time.
Second, spatial chunking. Groups all the CombineInstance data by grid cell. Each cell is CELL_SIZE units (I use 20, matching the isometric camera viewport width). Within each cell, builds sub-chunks that respect a max vertex count of 60,000. Grid coordinates are just floor(position / cellSize). Simple but effective.
Third, combining. For each chunk, creates a new Mesh, picks index format based on vertex count (16-bit when under 65K, 32-bit otherwise), calls Unity's CombineMeshes() with mergeSubMeshes and useMatrices both true so the world-space transforms get baked in. Recalculates bounds. Saves everything as a VegetationData ScriptableObject with the combined meshes stored as sub-assets.
The Wind Shader
Surface shader with a custom vertex function. The branchless mode detection looks like this:
float isBaked = step(v.color.g, 0.49); // 1 if baked vertex colors
float hasMeshWind = step(0.001, windData.x); // 1 if MeshWind component active
These two values drive everything through nested lerps. For baked meshes, the sway comes from vertex color R (squared for a nice ease-in curve at the base). For MeshWind meshes, from world Y within the sway range. For plain meshes, from local vertex Y.
The displacement uses world position for phase so nearby plants share similar timing but aren't identical:
float phase = (worldPos.x + worldPos.z + _Time.y * speed) * freq;
float sinVal = sin(phase) + sin(phase * 1.3 + _Time.y * 0.7) * 0.3;
Second sine at 1.3x frequency with a time offset creates the gusty feel. Displacement goes into X and a smaller amount into Z for diagonal sway. Transforms back to object space before applying.
At Runtime
The VegetationRenderer just calls Graphics.DrawMesh() for each combined mesh every frame. No GameObjects, no transforms, just raw draw calls. The instancing fallback path is still there using DrawMeshInstanced() with manual frustum culling, but the combined path is what we actually use.
What I'd Do Differently
If I started fresh, I'd probably look into compute shader indirect instancing. More flexibility for LOD and dynamic spawning. But for a project where the vegetation is placed by a modeler and doesn't change at runtime, the baking approach is simpler and it works really well.
There's a reason I needed all of this working before anything else. Can the new area connect to the farm without a loading screen? I didn't think it was possible before this. The modeler was really insistent that we have the lower new area seamlessly connected to the farm, and I was thinking the whole time that it's probably not gonna be a good idea because it's gonna lower the FPS too much and we should just have a loading screen in between and have it as a separate area. But I haven't tested incredibly thoroughly on low end hardware, so I can't say definitively if we're still gonna run into any issues. But right now it looks amazing. And his dream of having so many details did appear to come true due to how I've optimized all this stuff, and really becoming aware of GPU instancing and writing these custom scripts and shaders. So the future of these terrain remakes is looking really exciting!
-david
Comments