The fairways were buried in meadow grass, and the fix was to stop working on the grass
The golf course in this game had been routed, carved, meshed and given a drive. It was walkable. It did not look like a golf course. It looked like a field with some flat patches in it, and the reason was the grass: the landscape's meadow grass grows 20 to 35 cm and it was growing straight up through every green, tee, fairway and bunker on the site.
This entry is about the day spent fixing that, and about the fact that the fix was eventually thrown away in favour of something much cheaper. Both halves are worth writing down, because the throwing away is the part that produced the good-looking course.
Grass is growing through my meshes and there is no paint layer to mask it with
The landscape grass here is driven by a noise function and nothing else — there is no spatial mask, and this project has never used landscape paint layers. So grass emits at full density across the whole map, and the golf surfaces, which float a few centimetres above the landscape, get 25 cm blades pushing up between them.
The obvious engine answer is landscape visibility holes: punch the terrain out under the course. That answer is wrong here for a reason specific to this project — the golf turf meshes deliberately have no collision, because the ball physics is analytic and reads polygons rather than tracing meshes. Punching the landscape would drop the player straight through the course.
So instead: bake a world-space mask offline from the same 133 polygons the turf meshes were built from, import it as a greyscale texture, and multiply the three grass weights by it in the shared landscape material. 21.8 hectares mown out of the envelope, about 23%.
Three details in that are load-bearing and none of them are obvious:
- **The mask is built from the surface polygons, not from the "what lie is the ball in"
function** — even though the two describe the same course. The lie function has water beating everything in its priority order, so a fairway edge that overhangs a river margin reads as water, falls through to not mown, and sprouts grass through the mesh.
- **The border ring of the texture is forced to fully unmasked, and the address mode is
clamped.** Everywhere outside the course samples that border, so the rest of the 8 km map is provably untouched with no branching in the shader at all.
- The finest grass layer gets masked too. It exists only so footprints have blades to
land in. Nobody wants footprints on a mown fairway.
The texture import reported success and applied one setting out of three
Here is the trap that nearly tiled a golf course across an entire county.
The import call was asked for greyscale compression, clamped tiling, and sRGB off. It returned success. Reading the asset back afterwards: compression was the default colour format, tiling was wrap, and only the sRGB flag had actually taken.
Wrap is the dangerous one. The mask is a picture of a golf course; tiled, it would have applied that pattern to all 8 km of landscape, and the failure would have looked like a bizarre grass bug rather than an import setting. The compression miss is subtler, and this project has been bitten by its cousin before — a sampler type that disagrees with a texture's compression makes the material fall back to the default checker, silently, with one line in the log. That exact mistake once put the entire landscape on the default material.
Read back every texture import setting you care about — not the return value of the import, the properties on the asset afterwards.
My instance count returns zero for grass that is visibly rendering
The natural way to prove a grass mask works is to count grass instances inside the course and compare with outside. The count came back zero. Everywhere.
It reads exactly like a spectacular success — no grass at all on the course! — and it is completely meaningless. The instance-count query is blind to landscape grass. The components exist, they reference the right meshes, grass demonstrably renders, and the count is zero.
The only thing that caught it was running the same query somewhere with known-good grass, in the middle of town, and getting zero there too. A verification tool that has never been run against a known-good case is not a verification tool. That control run cost about a minute and stopped a false success being written down as a fact.
Getting a visual check was worse, and three separate things confound it — all worth knowing before you spend an afternoon on them:
- The editor does not render viewport frames while it is in the background, so a queued
high-resolution screenshot sits there for minutes and what is on screen is a stale frame.
- Landscape grass generates around the live viewport camera. After flushing the grass cache
it will not rebuild unless the editor is actually ticking — so an unfocused editor shows no blades anywhere, and that is not evidence about anything.
- An aerial before-and-after is worthless. Above about 30 m all grass is culled, so the
masked and unmasked frames are pixel-identical. The check has to be at eye height, standing on a boundary between mown ground and rough.
The fairways render as bare orange-brown earth
With the grass gone from the course, the course went brown.
The reflex diagnoses were all about the material: a hundred-times error in the texture coordinates, stale terrain data underneath, a seasonal dryness parameter turned up. All three were investigated and all three died. The material was fine.
The mesh was too coarse. Each hole's fairway ribbon was built from 26 segments over roughly 350 m — vertices 13.5 m apart — with only three chains across a ribbon 28 to 44 m wide. The landscape underneath is not flat, so between those vertices the ground simply came through the mesh. Measured properly: bare ground visible at 36.5% of fairway midpoints and 41.2% of semi-rough midpoints. And because the mask had already removed the grass there, those spots lost their grass and gained nothing. Brown earth.
Two things about how that was measured are the transferable part:
Sampling a mesh at its own vertices cannot detect a mesh that is too coarse. The first audit did exactly that and reported 0.9% buried, with zero problems at the worst-looking spot on the course. Of course it did — the vertices were traced onto the landscape when the mesh was baked, so every vertex sits exactly on the ground by construction. The error lives between them. Sample midpoints.
An audit has to respect the topology it is auditing. These surfaces come in two kinds: open parallel chains for the strip shapes, closed rings for the fan shapes. An audit that assumes every ring is closed invents a phantom 400 m edge across each fairway and inflates its own numbers.
Deriving the tessellation from a target edge length instead of a fixed segment count took the maximum edge from 29 m down to 2.42 m, and the ground-through figures to 2.8% and 4.2%. A live shot confirmed a green mown fairway.
And then the whole grass layer was deleted
The developer looked at it and made the call: drop the separate golf turf entirely, use the same grass as the rest of the level, and let the course get its identity from what stands on it — trees, foliage, animals.
He was right, and twelve hours of work went in the bin. The turf never looked playable; it looked like a differently-coloured field. The reverting itself is worth a note, because the cheap revert and the correct revert were not the same:
- The 18 surface actors were destroyed, recoverable from their manifest.
- The mask texture was overwritten with a plain white 64x64 image, so every grass weight
multiplies by one and the landscape grass simply grows everywhere again.
- The landscape material was left alone. The mask multiply is still spliced into it,
doing nothing. Editing a shared landscape material forces a full shader recompile, and on an 8 GB card that is a real risk of a long stall or a crash. Blanking a texture is the zero-risk revert of a texture-driven feature.
The tessellation fix stayed in the code, and it is still correct if the turf is ever revived.
402 trees hard-crashed the renderer, so the course had seven
The reason the course had almost nothing standing on it was a bone budget. Every tree in this project was a skeletal mesh, the level was already at about 390,000 bones — roughly 47% of the engine's assert point — and an earlier attempt at 402 oaks crashed the renderer outright. Parkland needs hundreds of trees. Seven were what fitted.
That blocker turned out not to exist. The procedural vegetation asset these trees come from can export a tree as a static mesh rather than a skeletal one, straight from its graph editor, which moves the whole tree population off the bone budget and onto hardware instancing.
Two things nearly went badly wrong on the way.
The export settings default to overwriting the asset you are already using. The mesh name field is pre-filled with the name of the existing skeletal mesh, and the replace policy defaults to replace. Exporting with the defaults would have overwritten, in place, the asset that 370 placed oaks elsewhere in the level were referencing. Change the name field before you touch anything else in that panel.
And the triangle count makes a good export look gutted. The export dialog says 126,486 triangles. The resulting asset reads back with 1,150 triangles at its top detail level. That is a 110x discrepancy and it looks exactly like a known failure mode where an exporter returns a bare winter twig. The mesh was perfect. 1,150 is the fallback proxy — the real geometry lives in the micropolygon representation, and no triangle count anywhere exposes it.
So: the readback cannot distinguish a good export from a ruined one, and only a render can. Spawn it, light it, photograph it. Which produced its own trap — a capture came back pure black, and the cause was another editor session's window covering the viewport so it had stopped drawing. A black capture means the viewport is not being drawn, not that the asset is broken.
The entire flock spawned eight centimetres underground
With trees possible, the course got dressed properly in one long push: 274 trees across six species and twelve variants, 2,180 shrubs, understory trees, ferns and wildflowers, 212 gorse bushes, 79 molehills, about 2,440 m of post-and-rail boundary fence as 1,219 instances, 44 rooks and 43 rabbits.
Almost every rule in that placement is about not scattering things evenly, because an even sprinkle is the single strongest tell that a computer placed it. Molehills run in wandering chains 1.6 to 4 m apart, because a mole drives a tunnel and pushes spoil up through it at intervals. Gorse grows in thickets. Rabbits are colonial, and every one of them has a tree within 35 m, because a rabbit will not graze further than that from cover. Rooks feed in flocks on short grass and never on a green — a flock on a putting surface would be moved off it within the hour, so leaving one there reads as neglect rather than as wildlife. Trees go in three deliberately different patterns: singles framing the sides of a fairway, short belts between adjacent holes so you cannot see straight down the next one, and specimens in the arc the clubhouse verandah looks at.
The bugs were all of one family — things that are true of an actor but not of the thing you were picturing.
The whole rook flock was buried up to its neck. They were placed at the traced ground height. A Character's actor origin is the centre of its collision capsule, not its feet, so every bird sank by half a capsule. Add the capsule half-height, read from the class defaults per bird, and assert it.
Every foraging rabbit was frozen in the identical pose. The placer varied which animation clip each rabbit played, which felt like enough, and it is not: they all started at time zero and ran at the same rate, so a live check found all nineteen foragers at clip position 0.591 seconds exactly. Varying the starting phase and the play rate per instance fixed it, and because the rates differ slightly they keep drifting apart instead of re-synchronising.
A material assignment reported success and changed nothing. Reading a mesh's material array, setting the material on each entry, and writing the array back is a no-op — the readback still showed the old material. Build fresh material structs, carrying the slot names across, and set that list instead. Then read it back.
148 fence segments elsewhere in the level had been rendering as default checker for who knows how long. The timber fence material had never compiled: its roughness sampler was set to linear colour against a masks-compressed texture, so the whole material silently fell back to the default. One log line, no error, and nobody had walked past those fences.
And the one that is pure level-design comedy: 190 fence modules were placed down the middle of a 70 mph dual carriageway. The estate boundary polygon — which is also the out-of-bounds line the golf game reads — genuinely runs along the middle of a main road for 422 m. The planner believed it. The fix was to gap the fence against the live road centrelines exported from the level, rather than against an offline road audit that was stale and did not even contain the golf club's own access drive. Nearest fence instance to that road's centreline afterwards: 16.3 m, against a carriageway half-width of 10.4 m.
What is honestly not done
- The course has never been walked at eye height. Every figure above is measured, or rendered
from a fixed camera. Nobody has stood on a fairway.
- The rooks and rabbits are placed, not directed. They each play one animation in place. They
do not flee, graze or move. That is a deliberate floor for an art pass, and calling it an ecology would be a lie.
- The trees are still the wrong species in spirit. The oaks are woodland presets — drawn-up and
narrow-crowned — where open-grown parkland oaks are short-boled and broad. They are compensated by scaling crowns wider than tall, which genuinely fixes the silhouette at distance and is an honest half-measure up close.
- **137 base materials in the level are missing the flag that lets them be used on instanced
meshes.** The editor quietly sets it at load time and warns, which is why the message log keeps popping up — but a cooked build cannot do that, so they would ship on the default material. Known, deferred, written down.
What to take from it
- A verification query that has never been run against a known-good case proves nothing. The
grass count returned zero for a healthy town centre too.
- Sample midpoints, not vertices, when asking whether a surface is too coarse. Vertices sit on
the ground by construction; the error is always between them.
- Read texture import settings back off the asset. An import can report success having applied
one of the three things you asked for, and the wrong tiling mode is a map-wide bug.
- A triangle count cannot tell you a mesh is intact when the real geometry lives in a
micropolygon representation. Render it.
- Check the default target of any export dialog before changing anything else in it. A name
field pre-filled with the name of your live asset, sitting next to a replace policy, is a loaded gun.
- An actor's origin is not its feet. Anything placed by ground trace needs the capsule
half-height added, and needs that asserted rather than eyeballed.
- Varying which clip plays is not varying the animation. Vary the phase and the rate too, or
you get a chorus line.
- Twelve hours of work is not a reason to keep a feature. The grass layer was fixed, correct
and measured — and deleting it made the course look better, because the thing that reads as a golf course is what stands on the grass, not the grass.