Faking a Haystack: A Blanket of Straws
How Haystack Together draws a barn-sized pile of pickable hay at 60 fps: an analytic surface, a low-poly shell hiding under the straws, and a lattice of integers instead of stored geometry.
The problem
Three pieces

- The surface is a formula. Ask how high the hay sits at a point, get a number back. No mesh, no voxel grid.
- A blanket of straws, one band deep. Only the top 15 cm gets drawn.
- A dark low-poly dome under the blanket. Real hay has holes in it, and without something behind those holes you see through the mound.
Part one: visuals
Where the shape comes from, how straws land on it, and why the dome never shows.
The surface is a formula
Dome(x, y):
t = distance from centre / radius # 0 at the peak, 1 at the rim
if t >= 1: return 0
return Peak * (1 - t^Sharpness)^Shoulder + LowFrequencyLumps(x, y)
CappedDome(x, y):
return max(0, min(Dome(x, y), Peak - DisplayedShiftCm)) # a ceiling, not a subtraction
SurfaceZ(x, y):
return max(CappedDome(x, y) * DigFloorFraction,
CappedDome(x, y) - DugDepthAt(x, y))DisplayedShiftCm is the height being drawn
at this instant, sliding toward the replicated step count instead of snapping to it.
Part three covers why.Shoulder exponent gets underrated. Below 1 the mound meets the floor at a
cliff and reads as a cone. At 1.55 the foot eases out and reads as hay somebody threw
into a heap.Straws are integers
SiteId(ix, iy, iz):
return (iz * LatticeY + iy) * LatticeX + ix
SiteTransform(ix, iy, iz):
rng = deterministic_hash(ix, iy, iz) # same seed everywhere, always
return transform(
lattice_position + jitter(rng),
heading in the surface tangent plane, tilt clamped to MaxTilt,
rng.pick(BladeVariants))Comb your straws and it reads as fur
Lay every blade flat along the surface tangent and the pile looks like a shaved animal. Tilting them out of the tangent plane turns it into a tangle. Give a quarter of them a random orientation and the last of the grain disappears.
Which straws get drawn
iz values land
in the band under the surface.RefreshColumn(ix, iy):
newRange = [ z sites within BandDepth below SurfaceZ(x, y), plus Fuzz above ]
oldRange = ColumnRange[ix, iy]
AddStraw for each site in newRange but not oldRange
RemoveStraw for each site in oldRange but not newRange[lo, hi] pair, so a shift diffs two intervals instead of
rebuilding the world. Drop the surface one step and most columns gain a straw at the
bottom and lose one off the top.Fuzz draws a few sites above the surface so straw ends poke out. Without it the
mound ends on a clean analytic curve and reads as a shape rather than a pile.The dome, and how it morphs

RebuildInnerShell():
for each (ring, sector):
x, y = polar(ring / Rings * Radius, 2*PI * sector / Sectors)
z = CappedDome(x, y) - DeepestDigWithin(x, y, ErodeCm)
vertex = (x, y, z) - SurfaceNormal(x, y) * InsetCm # push it UNDER
uv = (arc around, arc down the flank) / TileCm
if vertexCount == lastVertexCount: UpdateMeshSectionInPlace(...) # the morph
else: RecreateMeshSection(...)Dome - Shift while the straws stood at min(Dome, Cap). It hung a
full shift below the blanket on the flanks, and dropped to the floor outright wherever
the dome was shorter than the shift. That's daylight down the sides of the pile, and
it took one shared function to close it.Part two: behaviour
Digging, slumping, and answering "is this point inside the hay?" without a single collision primitive.
Digging and the hole that refills
SlumpPass(columns):
for each neighbour pair:
overhang = column.dug - neighbour.dug
if overhang > MaxStep + Epsilon:
move = (overhang - MaxStep) * SlumpRate
column.dug -= move; neighbour.dug += move # MOVED, never createdEpsilon
deadband earns its place: each pass moves a fraction of the excess, so the overhang
decays toward the threshold and never arrives. Without it the pile keeps nudging
itself forever.Collision
IsPointInHay(point, tolerance, minSurface):
surface = SurfaceZ(point.x, point.y)
if surface < minSurface: return false # reject the feathered rim
return point.z <= surface + tolerancepow() instead of a scene query, and it beats a trace on correctness. The
dome sits 5 cm under the blanket and rebuilds on a cooldown, so tracing it runs late
and comes up small: it eats straws that missed and misses straws that landed.minSurface guard matters more than it looks. Near the rim the dome eases into
the floor, so without it a straw lying on the barn boards reads as below the surface
and gets swallowed.Throwing a straw back
OnThrownStrawLands(straw):
if in a wheelbarrow: add to barrow load, stay picked
else if IsPointInHay(straw): ReturnStrawToSite(straw.site)
else: lie on the floor, despawn later
ReturnStrawToSite(site):
PickedSites.remove(site); PicksTaken -= 1
ApplyUndig(sitePosition, DepthPerStraw) # same falloff, subtractedPart three: replication
The entire shared authority of a haystack is one int32. Everything else stays local.
One integer on the wire
void AHayMound::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& Out) const
{
Super::GetLifetimeReplicatedProps(Out);
DOREPLIFETIME(AHayMound, ShiftSteps); // the whole thing
}ShiftSteps is a step count read off how much hay the
players have taken between them, and the server owns it.SiteTransform on the same integer land the same blade in
the same spot, so there's nothing to agree about.What each client keeps to itself
| State | Where it lives |
|---|---|
ShiftSteps, the surface height | replicated |
| Picked sites, dug depth per column | local to each client |
PicksTaken, the HUD tally | local to each client |
| A wheelbarrow's load | server authoritative |
Letting the client fall behind on purpose
OnRep_ShiftSteps has no body. A client learns the new height, then walks down to it
at its own pace:TickShiftQueue():
if AppliedShiftSteps >= ShiftSteps: return # already there
if DisplayedShiftCm hasn't reached AppliedShiftSteps: return # last release still travelling
AppliedShiftSteps = ShiftSteps # the whole backlog, in one releaseShiftSteps is the authority and can jump as far ahead as it likes.
AppliedShiftSteps is what the client has drawn, and it only moves once the drawn
surface has finished travelling to where the last release put it.SettleSeconds, so a hundred-step backlog
leaves a pile standing there for ten minutes with no straw in it. Take the whole
backlog and DisplayedShiftCm gets one target to slide to, which the settle covers in
SettleSeconds however far it has to go.Part four: performance
Five things that decided the frame budget. The expensive ones never showed up in a draw call count.
Split the instances spatially
| Layout | Game thread, per picking frame |
|---|---|
| 1 component, 137k instances | 26 ms |
| 3 components, 46k each | 12 ms |
| Spatial tiles, ~1k each | negligible |
Budget straw operations, not columns
Tick():
budget = StrawOpsPerTick
while budget > 0 and columns remain:
if TilesSettling >= MaxSettlingTiles and next column starts a new tile:
break # hold at the tile boundary
budget -= RefreshColumn(next column)Take the straws out of ray tracing, and put their shadows on a switch
Get the depth darkening back for free
Losing per-straw shadows costs most of the mound's depth, and the blanket flattens into fuzz. You already know how deep each straw sits when you place it, so write that depth into per-instance custom data and let the material multiply it in. Fake occlusion that costs nothing and can't flicker.
Cull the straws, keep the dome
Highlight with one overlaid mesh
What it adds up to
| Drawn straws | ~250,000, pickable one at a time |
| Stored per-straw data | none, every straw is a function of its index |
| Replicated state | one int32 |
| Collision primitives | one, the dome, so the pile is climbable |
| Straws in the ray tracing scene | zero |
| Stand-in for shadows, GI and distance | one ~1,300 triangle dome |
- Make the shape a function rather than data, and replicate the function's inputs.
- Derive the instances from integer indices, so they cost nothing to store or send.
- Draw a shell of instances, and put something cheap behind it so the gaps read as shadow.
- Hand that cheap thing the expensive work: shadows, GI, distance.
- Budget the writes and batch them spatially. The upload is the cost.
AHayMound;
picking and throwing live in UHayMoundPickerComponent.