Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Build a live Three.js landscape that stays quiet behind a subject — a noise heightfield on a polar grid so resolution follows the lens, ground coloured by slope and moisture rather than by texture, instanced GPU grass whose wind costs nothing on the CPU, scattered stones, a gradient sky dome, a star field you can actually see, and a time-of-day system that cross-fades instead of cutting. Use for hero backdrops, product stages, scroll worlds, or any scene where a building, object, or figure has t
.claude/skills/mengto-threejs-landscape/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-04 | ✗→✓ | ▲ Improved | 2% | 0% |
| case-05 | ✗→✓ | ▲ Improved | -10% | 0% |
| case-12 | ✗→✓ | ▲ Improved | 16% | 0% |
| case-15 | ✗→✓ | ▲ Improved | 48% | 0% |
| case-06 | ✓→✓ | = Same ✓ | 25% | 0% |
A backdrop has one job: make the subject look like it is somewhere. Everything here is chosen so the landscape reads at a glance and then gets out of the way.
Reach for threejs-weather to put rain, storm or snow on top of it, and threejs-towers when the subject standing in it is architecture.
Do not build a square heightfield. A long lens sees a narrow wedge, so a square grid spends most of its triangles behind the camera and still runs out of resolution at the horizon.
Sample on a polar grid centred under the camera, with radial rings that get further apart as they recede:
jsconst AN = 900, RN = 52, R0 = 2.0, R1 = 700; // angular, radial, near, far for (let r = 0; r <= RN; r++) { const t = r / RN; const rad = R0 + (R1 - R0) * Math.pow(t, 2.4); // dense near, sparse far for (let a = 0; a < AN; a++) { const th = a / AN * Math.PI * 2; push(Math.cos(th) * rad, landH(x, z), Math.sin(th) * rad); } }
Two thousand triangles near the subject beat two hundred thousand spread evenly. pow(t, 2.4) is the whole trick: every ring covers roughly the same number of screen pixels.
Watch the winding. On a polar grid it is easy to wind every quad the wrong way and end up looking at the sky through the ground. Index as (a0, b1, b0, a0, a1, b1) and check by orbiting under the horizon once, deliberately, before you build anything else on top.
Plain fBm reads as crumpled paper. Warping the sample position with another noise field before you evaluate it is the single biggest step toward terrain that looks eroded:
jsfunction landH(x, z) { const wx = x + fbm(x * 0.012, z * 0.012, 3) * 26; // domain warp const wz = z + fbm(x * 0.012 + 41, z * 0.012 - 17, 3) * 26; let h = fbm(wx * 0.0075, wz * 0.0075, 5) * 34; // broad landforms h += ridged(wx * 0.021, wz * 0.021, 3) * 9; // ridge lines return h; }
Keep the analytic function separate from the mesh. Grass, stones, fog and anything else you scatter must sample the same landH, or they will float and sink.
Ridge layers have to scale with distance. A ridge amplitude that reads well at 40 units is invisible at 600, so the far rings need theirs multiplied up or the horizon goes flat.
A tiled ground texture always announces itself. Compute a vertex colour from the terrain's own properties instead:
jsconst slope = 1 - normal.y; // steep = rock const moist = smoothstep(-4, 6, -height); // low = wet, green const c = rock.clone() .lerp(grass, (1 - slope * 3.2) * (0.35 + moist * 0.65)) .lerp(sand, Math.max(0, 0.5 - moist) * 0.6);
You get cliffs that go stony, hollows that go green and ridges that go pale, for free, with no UVs and no seams. Keep a very low-frequency noise on top so the colour does not band.
100k blades is a single InstancedMesh of a five-segment ribbon. Every bend, lean, taper and gust happens in the vertex shader, so the wind costs nothing on the CPU:
jsconst geo = new THREE.BufferGeometry(); // 11 verts: a strip + a tip const mesh = new THREE.InstancedMesh(geo, mat, 104000); mat.onBeforeCompile = sh => { Object.assign(sh.uniforms, grassUni); sh.vertexShader = ` uniform float uTime, uWindAmp; uniform vec2 uWind; attribute vec4 aParams; // height, phase, tint, lean varying float vT; varying float vTint; ` + sh.vertexShader.replace('#include <begin_vertex>', ` float gT = position.y; // 0 at root, 1 at tip float gBend = uRestBend + sin(uTime * 1.7 + aParams.y) * uWindAmp; vec2 gRib = ... ; // sweep the blade along an arc vec3 transformed = vec3(gRib.x, gT * aParams.x, gRib.y); `); }; mat.customProgramCacheKey = () => 'grass'; // or every instance recompiles
Anchor the field to the camera. Keep a fixed grid of blades around the viewer and move the grid, snapping to cell size, rather than growing the field outward. The player never reaches the edge and you never pay for grass behind them.
Do not hard-code the blade colour in the fragment shader and then expect the material colour to change it. diffuseColor.rgb *= mix(base, tip, t) multiplies whatever the material gave you, so a white material times a green constant is still green. If anything — settled snow, a season, a night palette — has to recolour the grass, that mix needs its own uniform. This costs an hour to find because every debug print says the material is white.
Scatter with rejection sampling against slope, then setMatrixAt on an InstancedMesh. Weld the icosphere and scale it flat so they read as embedded rather than dropped:
jsgeo.scale(1, 0.62, 1); geo.translate(0, 0.3, 0); // sunk, not resting
A few thousand at three or four sizes is enough. They matter most near the subject, where they give the eye something to measure scale against.
Paint a vertical gradient into a tiny canvas and map it to a back-side sphere. Repainting it is so cheap you can do it every frame of a transition:
jsconst grd = ctx.createLinearGradient(0, 0, 0, 512); [0, 0.30, 0.52, 0.68, 0.84, 1].forEach((s, i) => grd.addColorStop(s, cols[i])); ctx.fillStyle = grd; ctx.fillRect(0, 0, 8, 512); skyMat.map.needsUpdate = true;
Six stops is the number. Three gives you a CSS gradient; ten and you cannot tune it. Put the horizon stop slightly below the geometric horizon so the fog colour and the sky meet without a visible line.
The mistake is scattering over the whole sphere. With a ~10° lens the frame sees about 0.5% of it, so 1,200 stars puts roughly six on screen and reads as a bug.
PointsMaterial.size by devicePixelRatio or they vanish on retina.sizeAttenuation: false, fog: false, depthWrite: false.Around 39,000 points across three classes gives a sky that reads as stars rather than as noise. It costs one draw call each.
Store each time of day as a full state — sun position and colour, hemisphere, ambient, fill, rim, fog colour and range, ground, grass, six sky stops, shadow strength, star opacity — and lerp between two of them:
jsfunction applyState(A, B, t) { key.position.setFromSpherical(lerpAngle(A.sun, B.sun, t)); key.color.copy(A.sunC).clone().lerp(B.sunC, t); scene.fog.color.copy(A.fog).lerp(B.fog, t); for (let i = 0; i < 6; i++) CUR.sky[i].copy(A.sky[i]).lerp(B.sky[i], t); paintSky(CUR.sky); }
When the user switches mid-transition, freeze the current interpolated state as the new A rather than snapping to the last preset. Otherwise every impatient click jumps.
Keep weather as a multiplier layered on top of this, never as more presets. Four times of day × four weathers is four states and four modifiers, not sixteen.
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→fail | 44,101 | 45,834 | +4% | 1 | 1 | 0% | 8,278 | 10,590 | +28% | 0 | 0 | — |
case-02 | fail→fail | 48,555 | 49,669 | +2% | 1 | 1 | 0% | 8,276 | 10,588 | +28% | 0 | 0 | — |
case-03 | fail→fail | 45,729 | 46,522 | +2% | 1 | 1 | 0% | 8,273 | 10,585 | +28% | 0 | 0 | — |
case-04 | fail→pass | 48,096 | 10,380 | -78% | 1 | 1 | 0% | 3,237 | 3,294 | +2% | 0 | 0 | — |
case-05 | fail→pass | 32,603 | 17,434 | -47% | 1 | 1 | 0% | 5,295 | 4,769 | -10% | 0 | 0 | — |
case-06 | pass→pass | 22,434 | 12,721 | -43% | 1 | 1 | 0% | 2,942 | 3,663 | +25% | 0 | 0 | — |
case-07 | pass→pass | 17,352 | 12,833 | -26% | 1 | 1 | 0% | 2,564 | 3,617 | +41% | 0 | 0 | — |
case-08 | pass→pass | 28,077 | 29,698 | +6% | 1 | 1 | 0% | 4,443 | 6,855 | +54% | 0 | 0 | — |
case-09 | pass→pass | 26,460 | 23,710 | -10% | 1 | 1 | 0% | 3,692 | 5,788 | +57% | 0 | 0 | — |
case-10 | pass→pass | 12,490 | 9,315 | -25% | 1 | 1 | 0% | 1,259 | 2,975 | +136% | 0 | 0 | — |
case-11 | fail→fail | 24,317 | 19,942 | -18% | 1 | 1 | 0% | 3,268 | 4,942 | +51% | 0 | 0 | — |
case-12 | fail→pass | 22,785 | 12,419 | -45% | 1 | 1 | 0% | 3,021 | 3,508 | +16% | 0 | 0 | — |
case-13 | pass→pass | 21,095 | 12,204 | -42% | 1 | 1 | 0% | 2,516 | 3,427 | +36% | 0 | 0 | — |
case-14 | pass→pass | 25,528 | 19,784 | -23% | 1 | 1 | 0% | 3,725 | 4,890 | +31% | 0 | 0 | — |
case-15 | fail→pass | 20,780 | 14,161 | -32% | 1 | 1 | 0% | 2,527 | 3,746 | +48% | 0 | 0 | — |
case-16 | pass→pass | 25,830 | 23,990 | -7% | 1 | 1 | 0% | 3,784 | 5,738 | +52% | 0 | 0 | — |
case-17 | pass→pass | 24,941 | 21,791 | -13% | 1 | 1 | 0% | 3,231 | 5,409 | +67% | 0 | 0 | — |
case-18 | pass→pass | 13,035 | 10,356 | -21% | 1 | 1 | 0% | 1,370 | 3,179 | +132% | 0 | 0 | — |
case-19 | pass→pass | 22,658 | 16,207 | -28% | 1 | 1 | 0% | 2,629 | 4,050 | +54% | 0 | 0 | — |
case-20 | fail→fail | 30,244 | 36,779 | +22% | 1 | 1 | 0% | 4,138 | 7,987 | +93% | 0 | 0 | — |
case-21 | fail→fail | 27,173 | 29,739 | +9% | 1 | 1 | 0% | 3,585 | 6,905 | +93% | 0 | 0 | — |
case-22 | pass→pass | 21,431 | 24,845 | +16% | 1 | 1 | 0% | 2,478 | 5,670 | +129% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted, and 21 counted toward the lift figure. The other 1 produced results that are not comparable between the two arms, so they are excluded from the headline rather than averaged into it. The headline lift of +18 percentage points is the difference between those two pass rates over the 21 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.