Soil and grass
A 20 × 20 m plot of shader soil with mounds, moss and damp patches, under 200,000 wind-blown grass blades in one draw, for a three.js game.
It's Soil Studio by mohamedachrefelouafi (Chiro Visuals), MIT, made into one piece you add to a scene: Patch added a painted field that decides where moss and grass grow, a slab of earth under the plot, and a CPU copy of the height so players and props stand on it. In the live demo, drag to turn around the plot and scroll to zoom; pick a look, and try Regrow.
Use it in a game
Copy soil.js, height.js, soil-studio/ and public/textures into your game, with three.js. The grass defaults to 1.5 m, which hides a person and most of a campfire, so set it for your scene: land.setGrass('uHeight', 0.18).
import { createSoilAndGrass, loadSoilTextures } from './soil.js';
const textures = await loadSoilTextures(renderer, './textures/');
const land = createSoilAndGrass({ textures, sun }); // sun: your THREE.DirectionalLight
scene.add(land.group);
function tick(dt) {
land.update(dt); // moves the wind on
player.position.y = land.heightAt(player.position.x, player.position.z);
composer.render();
}
One height function shapes everything. groundHeightAt (GLSL, soil-studio/soil-shaders.js) is broad
mounds times finer lumps (centred on ground level by uMoundMid), plus the moss carpet's thickness. The ground's vertex shader raises the plot by it, and every blade of grass plants its base on it in its own vertex shader, so grass always sits on the
ground. land.heightAt(x, z) (height.js) is the same function on the CPU, line by line, reading the same
uniforms, so it agrees with the picture whatever you change:
- Walk on it: put the player's feet at
heightAt(x, z)every frame. - Put props on it: a rock, a tree, a fence post:
y = heightAt(x, z). Sink them a few centimetres so they don't float on the moss's lumps. - The ground's slope (to tilt a prop or slow a player): sample
heightAta few centimetres either side. - Change the shape with uniforms, never by moving the mesh:
land.setSeed(n)for new ground,land.soil.uMoundDepth. The mounds and moss taper flat over the plot's last 2 m, so plots can sit side by side.
Paint where things grow. land.field is a texture over the plot, R = wetness,
G = growth, that the shaders read: moss and grass grow only where growth is high (and grass grows shorter where
it's low), and wet soil goes dark and glossy. Write land.field.wet and land.field.growth (Float32Arrays,
field.size squared, 0 to 1; field.cellOf(x, z) gives the texel), then call land.field.upload(). All growth
and no wetness is the plain look. It's how the demo's Regrow works (startRegrow in demo.js), and the same
trick makes watering, trampling, burning or footpaths in a game.
The look is uniforms, shared by reference, so changing a value changes the picture at once and never
recompiles a shader: land.soil (mounds, tone, damp patches, the optional dry cracks), land.moss (coverage,
depth, tint), land.wind (strength, speed, gust), and land.grass.uniforms (land.setGrass(name, value) sets
one: uCoverage, uHeight, uWidth, uCurl, uColorBase, uColorTip, uPatchColor,
uTranslucency...). land.setSoil(textures.soil.dry) swaps the soil's textures. looks.js has four whole looks
as plain values, and applyLook in demo.js shows where each value goes.
The grass is one draw of instanced blades scattered over the plot: 200,000 at full density, about 500 a square
metre. It's made for a patch you look at or play on, not an open world. Fewer blades:
land.grass.setDensity(0..1); createSoilAndGrass({ blades }) sets the count, half the plot's size, and depth
the slab of earth under it (0 for none, if your own ground carries on from the plot's flat rim).
Mist is Soil Studio's volumetric clouds (soil-studio/clouds.js, in its post stack postfx.js): a box of 3D
noise raymarched at a fraction of the resolution, lit by the sun. Lay it low over the plot for mist in
the hollows (placeMist in demo.js), or high for clouds.
Warm it up before you show it. Three compiles a shader the first time it draws one, and the soil's and the
grass's are big. The demo, before it says it's ready: loads every texture, compiles every material for the post
stack's render target (renderer.compileAsync with that target set), draws each look once with the mist on, and
times a few frames of the heaviest look to pick a quality tier (below 22 ms a frame: high; above: low, with 45%
of the blades, lighter mist, no 4x MSAA and a smaller shadow map). Do the same behind your loading
screen.
Dry cracks (Worley cells: cracked, sun-baked plates in bare soil) are in the shader but off in every look,
and not among the controls people see: land.soil.uCrackEnabled.value = 1 turns them on (the demo's cracks
control, for agents), with uCrackAmount, uCrackScale, uCrackWidth, uCrackWarp and uCrackDepth. They
only show where there's no moss or grass, and cost nothing while off.
Everything is three.js 0.185 with custom GLSL patched into MeshStandardMaterial, so the ground and grass keep
three's lights, shadows, fog and environment light. It's WebGL, not WebGPU.
Controls
The block page talks to the demo over the patch-block message channel (lib/patch-block.js). In the frame,
drag to turn around the plot and scroll to zoom.
For people (the block page shows these):
| id | Control | Type | Range | What it does |
|---|---|---|---|---|
look | Look | choice | 4 options, each with a picture; default Mossy meadow | A whole look at once: the light, the soil, the grass and the moss. |
grass | Grass | number | 0 to 100 %, default 49 | How much of the plot the grass covers: from bare soil and moss to a thick carpet. |
grass.height | Grass height | number | 0.2 to 1.5 m, default 0.7 | How tall the blades grow. |
moss | Moss | number | 0 to 100 %, default 70 | How much of the soil the moss carpet covers. |
mounds | Mounds | number | 0 to 4, default 2 | How far the ground heaves into mounds and hollows: flat at nothing. |
The looks (looks.js), each with a picture (thumbs/looks/<id>.webp, a still of this demo made by
tools/make-pictures.mjs):
- Mossy meadow: tufts of long grass over moss and damp soil in a warm afternoon light.
- Misty morning: dew, mist lying in the hollows and a pale early sun behind it.
- Dry field: straw-coloured grass on dusty soil under a high summer sun.
- Lush grass: tall, thick green grass after the rain, with moss in the hollows.
A look sets the other controls too, and the demo sends them back in its state.
Triggers:
- New ground (
new-ground): shapes new ground: new mounds, moss and patches of grass. - Regrow (
regrow): the ground goes bare, then greens again from the middle out (the painted field at work).
For agents (10 more, in recipe.json with every range and default):
| id | Control | Type | Range | What it does |
|---|---|---|---|---|
seed | Ground | number | 1 to 9999, default 1 | Which ground: every number shapes its own mounds, moss and grass patches. |
wind | Wind | number | 0 to 12 m/s | How hard the wind blows through the grass. |
grass.tip | Grass colour | color | The colour of the sunlit tips; the bases stay darker. | |
damp | Damp | number | 0 to 1 | How much of the soil is damp: darker and glossier. |
mist | Mist | toggle | A layer of mist lying on the plot, lit by the sun. | |
cracks | Dry cracks | toggle | default off | Cracked, sun-baked plates in the bare soil. |
sun.height | Sun height | number | 2 to 70° | How high the sun stands. |
sun.turn | Sun direction | number | -180 to 180° | Where the sun is, from straight ahead of the opening view. |
quality | Quality | choice | Auto, High, Low | Auto measures the computer while the demo loads. |
drift | Camera drift | toggle | default on | The plot turns slowly until someone drags it. |
Build it
npm install
npm run build # writes recipe.json, then dist/ (~4.6 MB), with thumbs/ copied in
node tools/test.mjs # the block protocol, headless (patch-lab's toolbox/block/test-block.mjs)
node tools/make-pictures.mjs # the looks' pictures and cover.jpg
dist/ is the live demo exactly as released, with index.html at the top and nothing loaded from other hosts.
| Path | What |
|---|---|
soil.js | The block: createSoilAndGrass, loadSoilTextures (Patch, MIT) |
height.js | The ground's height on the CPU, the GLSL ported line by line (Patch, MIT) |
soil-studio/ | Soil Studio's shaders, grass, mist and post stack, with Patch's changes marked Patch: in each file (MIT, ©2026 mohamedachrefelouafi) |
demo.js, index.html, looks.js | The demo page and its looks (Patch, MIT) |
recipe.json, make-recipe.mjs | The release recipe (mode browser with its block section) |
thumbs/looks/ | The Look choice's pictures, stills of this demo |
public/textures/ | ambientCG's Ground048, Ground103 and Moss002 at 1K, saved again as smaller JPEGs |
tools/ | Pictures, cover, screenshots and the protocol test |
lib/patch-block.js | Patch's block demo helper, as served at patch.gg/patch-block.js |
fonts/ | Red Hat Mono 500, latin, for the tip |
Left out of upstream: its page and GUI (main.js beyond the shaders), its model moss (model.js), and its two
sample car models, whose source and licence aren't known.
Credits and licences
- Soil Studio by mohamedachrefelouafi (Chiro Visuals), MIT, © 2026 mohamedachrefelouafi
(
soil-studio/LICENSE): the soil, moss, crack and mound shaders, the instanced wind-blown grass, the volumetric mist and the post stack. github.com/achrefelouafi/GrassSystemThreeJS, commitb236b2a. Credit line: "Soil Studio by mohamedachrefelouafi (MIT)". - Textures: Ground048, Ground103 and Moss002, ambientCG, CC0.
- three.js: MIT.
- Red Hat Mono: The Red Hat Project Authors, SIL Open Font License 1.1 (
fonts/OFL-red-hat-mono.txt). - The block (
soil.js,height.js), the changes insoil-studio/, the demo and its looks: Patch, MIT. The pictures inthumbs/: stills of this demo, Patch, CC0.
Code: MIT (LICENSE). Art: CC0 (the textures and the pictures); the font is OFL 1.1.