【免费下载链接】Skills
Agent skills for designers and builders using Codex, Claude, Cursor, and other AI coding agents
导读
本文围绕开源仓库 Skills 中的threejs-landscapeskill(SKILL.md)展开,完整讲解如何用 Three.js 构建一块"能站住主体"的程序化风景背景:极坐标网格地形让分辨率跟随镜头、域扭曲噪声生成侵蚀感山体、顶点着色器塑形的实例化草地在零 CPU 成本下随风摆动,以及用"完整状态插值"实现的晨/午/黄昏/夜四时段无缝过渡。读完本文,你可以将这套方案直接用于 hero 背景、产品展示台、滚动叙事世界等任何需要"让建筑、物体或角色坐在一个真实场景里,而不是漂浮在一张渐变图上"的页面,并配套掌握可运行示例(demo/index.html)中的全部实现细节与验收清单。
场景示例:参考实现 demo/index.html 的运行截图,展示近景 15 万根实例化草叶、极坐标网格地形与六站天空共同构成的程序化风景(图片来自 demo/preview.jpg)。
背景的使命:让主体"身在某处",然后退场
skill 的开篇立意非常明确:背景只有一份工作——让主体看起来真的待在某处(somewhere),一眼读懂场景后马上让出注意力(见 SKILL.md 引言)。因此整个方案的设计取舍都围绕"前景主体"展开,而不是让背景自身成为焦点。
在仓库的目录组织上,threejs-landscape与两个兄弟 skill 构成组合拳:
- threejs-weather:在风景之上叠加雨、风暴或雪;
- threejs-towers:当站在风景中的主体是建筑时使用。
对应的 Agent 配置(agents/openai.yaml)给出的默认提示词即"Use $threejs-landscape to put this subject in a real place: noise terrain, instanced grass, a gradient sky and a time-of-day system",直接点明了该 skill 交付的四件套:噪声地形、实例化草、渐变天空、昼夜系统。
配套的演示提示词文档(demo/PROMPT.md)进一步定义了体验契约:一块延伸到地平线柔和地形的草地平原;拖拽环绕、滚轮/双指缩放;四个时段共用一个控件、以交叉淡化而非硬切切换、夜晚出现星星;还有一个 GRID 开关可以打开网格线框,让极坐标地形结构一目了然。
用极坐标网格"环摄"相机:分辨率跟随镜头
为什么不能用正方形高度场
文档给出的第一个硬性结论是:不要构建正方形高度场(SKILL.md 第 2 节)。长焦镜头只能看到狭窄的楔形视野,正方形网格的大部分三角形都浪费在相机背后,而真正被拍到的地平线方向却又很快耗尽分辨率。
正确的做法是以相机脚下为中心采样极坐标网格,径向环随距离越来越疏:
const 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); } }pow(t, 2.4)是整个技巧的核心:近处环密、远处环疏,使得每一环大致覆盖相同数量的屏幕像素,几何体预算被花在真正可见的地方。文档给出的经验对比是"主体附近两千个三角形胜过均匀铺开的两万个"——从源码结构看,参考 demo(demo/index.html)的具体参数略有收紧:AN=620, RN=48, R0=1.6, R1=760,也就是说约 620×48 个采样点,并按同样的 2.4 次幂曲线分布。
小心绕线方向(winding)
极坐标网格上最容易犯的错误是把所有四边形都绕反,最终从地面看到天空。文档给出的索引顺序是(a0, b1, b0, a0, a1, b1),并在 demo 中落地为(见 demo/index.html 索引构建段):
const a1 = (a + 1) % AN; const a0 = r * AN + a, b0 = r * AN + a1, a2 = (r + 1) * AN + a, b2 = (r + 1) * AN + a1; idx.push(a0, b2, b0, a0, a2, b2);注意 demo 中环形(角度方向)索引做了% AN环绕闭合,这是极坐标网格独有的细节。验收标准是:在搭建任何其他内容之前,先故意把相机绕到地平线以下看一眼,如果透过地面能看到天空,说明绕线反了。
在叠加八度之前先扭曲采样域
域扭曲(domain warp)是"侵蚀感"的最大来源
文档给出的判断很直白:朴素的 fBm 读起来像"揉皱的纸",而在求值地形之前先用另一个噪声场扭曲采样位置,是通往"看起来被侵蚀过"的地形的最大一步:
function 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; }要点有三:
- 先把采样位置扭曲(两个不同种子偏移的 fBm 各贡献 26 个单位的位移),再在扭曲后的位置叠加大尺度地形的 fBm 与山脊的 ridged 噪声;
- 保持解析函数独立于网格。草、石头、雾以及任何散落物都必须采样同一个
landH,否则它们会悬浮或下沉。demo 中这一原则贯彻得很彻底:地形网格顶点、石头放置、法线计算(landN用有限差分landH(x+e,z)-landH(x-e,z)求法线)全部走同一个函数(见 demo/index.html 的landH/landN); - 山脊层必须随距离缩放:在 40 个单位处读起来刚好的山脊振幅,在 600 个单位处就不可见了,远处的环需要把振幅乘上去,否则地平线会"变平"。
demo 的landH还额外演示了一个实用技巧:将近场压平。相机在 80 个单位高度环绕,绝对不能被包进山体里,所以代码把d<110的整片近场用地形高度乘以t*t*(3-2*t)(t=(d-110)/170的平滑过渡)压成平地,让地形成形都发生在远处(见 demo/index.html 的landH实现)。这解释了 demo 中"相机脚下永远是平地、草场永远平坦"的观感——草 patch 平移也因此只需要做平移而无需重新采样地形。
按"地形在做什么"着色,而不是贴图
斜率与湿度派生的顶点色
平铺地面贴图总会暴露自己。文档给出的方案是用地形自身的属性计算顶点色(SKILL.md 第 4 节):
const 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);这带来三个"免费"收益:悬崖变石色、洼地变绿色、山脊变浅色——不需要 UV,也没有接缝。为避免颜色出现条带,还要在顶点色上叠加一层极低频噪声。demo 的实现与之对应(demo/index.html 地形着色段):
const slope = 1 - n.y; const moist = Math.max(0, Math.min(1, (3.0 - y) / 12)); const tint = NZ.fbm(x * 0.02, z * 0.02, 2) * 0.5 + 0.5; c.copy(ROCK) .lerp(GRASS, Math.max(0, Math.min(1, 1 - slope * 3.4)) * (0.34 + moist * 0.66)) .lerp(SAND, Math.max(0, 0.42 - moist) * 0.55) .lerp(DARK, tint * 0.22);两个细节值得注意:
- demo 中
slope由有限差分法线(landN)的n.y计算,而不是网格自身的顶点法线——这样石头、草丛与地形对"坡度"的判断完全一致; - demo 额外把远环向雾色渐变:
c.lerp(new THREE.Color(0xd8c9a4), Math.pow(t, 2.6) * 0.55),让地平线没有硬边,与雾色自然衔接。
配色上 demo 使用MeshLambertMaterial({vertexColors:true}),色板为 ROCK/GRASS/SAND/DARK 四色;线框模式的 wire 网格(MeshBasicMaterial({wireframe:true, opacity:0.16}))则用于 GRID 开关,直观展示极坐标网格结构(demo/index.html)。
草:一条 ribbon,全部在顶点着色器里塑形
十万根草 = 一个 InstancedMesh
文档的核心主张是:10 万根草只是单条五段 ribbon 的InstancedMesh,每一次弯曲、倾斜、收窄和阵风都发生在顶点着色器里,因此风的成本在 CPU 上为零(SKILL.md 第 5 节):
const 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两个工程要点:
customProgramCacheKey必须有。onBeforeCompile会改变着色器源码,若不提供自定义缓存键,每个实例都会触发一次重编译;- 草场 patch 要跟着相机走。保持一片固定网格的草围绕观察者,把"网格"按格子尺寸做整数对齐平移,而不是向外扩张草场。玩家永远到不了边缘,你也永远不为身后的草付费。
demo 把这一整套落实得很完整(demo/index.html 草场段):
- 几何体:5 段
BSEG=5条带(每段 2 顶点)+ 1 个尖端顶点,共 11 个顶点; - 实例数:
GRASS_N=150000(skill 示例为 104000,参考 demo 为 15 万); - 每实例
aParams四分量:高度0.30 + Math.random()*0.36、相位0~2π、色调随机、静息倾角0.10~0.36; - patch 半径
GRASS_R=64,按面积均匀采样(rad=Math.sqrt(Math.random())*GRASS_R); - 草场沿镜头前方
GRASS_AHEAD=74个单位、按CELL=2的格子取整平移(placeCamera中的Math.round(.../CELL)*CELL),同时把uOrigin传给着色器; - 顶点着色器中风相位基于世界坐标而非局部坐标(
gWorld=gRoot.xz+uOrigin后取 sin),否则阵风纹理会随着 patch 一起"漂移"; - patch 边缘淡出:
gEdge = 1.0 - smoothstep(0.74, 1.0, length(gRoot.xz)/uRadius),避免 patch 边缘在草地上留下一道"标尺线"; - 整体
im.position.y = -0.5下移,与近场被压平的地形吻合。
一个能坑掉你一小时的颜色陷阱
文档专门用一段强调了:不要在片元着色器里硬编码草色,然后指望材质颜色能改它。diffuseColor.rgb *= mix(base, tip, t)是"乘"运算——白色材质乘上绿色常量仍然是绿色,每一个 debug 输出都会告诉你材质是白的,但画面就是不变。凡是需要给草重新上色的场景(积雪、季节、夜间调色板),那个 mix 必须使用自己的 uniform。
demo 用uSnow演示了正确姿势(demo/index.html 草片元着色器段):
vec3 gBase = vec3(0.150, 0.180, 0.092) * (0.74 + vTint * 0.54); vec3 gTip = vec3(0.560, 0.630, 0.330) * (0.76 + vTint * 0.56); gBase = mix(gBase, vec3(0.60, 0.65, 0.72), uSnow * 0.86); gTip = mix(gTip , vec3(0.90, 0.94, 1.00), uSnow); diffuseColor.rgb *= mix(gBase, gTip, pow(vT, 0.78)) * 1.30;草色深浅在着色器内完成,材质(MeshLambertMaterial({color:0xffffff}))保持白色,任何外部 recolor 都走uSnow之类的 uniform。
石头:来自同一个高度函数
石头与草不同,它们要给眼睛提供"度量尺"。文档给出的做法是:按坡度做拒绝采样(rejection sampling)散点,然后setMatrixAt写入InstancedMesh;把 icosphere 压扁并焊接,让石头看起来是"嵌入"地面而非"搁"在地上(SKILL.md 第 6 节):
geo.scale(1, 0.62, 1); geo.translate(0, 0.3, 0); // sunk, not resting三四种尺寸的几千块石头就足够了——它们最要紧的是出现在主体附近,给眼睛一个度量尺度的参照。demo 的实现细节(demo/index.html 石头段):
IcosahedronGeometry(0.5, 1)压扁(scale(1, 0.62, 1))并上移(translate(0, 0.3, 0))实现"下沉";- 1600 块,径向
2.0 + Math.pow(Math.random(), 0.55) * 95分布; - 坡度拒绝:
if (landN(x, z).y < 0.80) continue;——只有法线足够竖直(坡度够缓)的位置才放石头,石头不会出现在悬崖上; - 放置高度取自同一
landH(x,z),再减去sc*0.12让石头微陷入土; - 每块石头在 x/y/z 三个轴向独立随机缩放(约 0.10~0.52),打破重复感。
天空:8×512 画布上的六个渐变压点
把垂直渐变画进一张小 canvas,再映射到背面球体(MeshBasicMaterial({side: THREE.BackSide})),重绘成本低到可以"在过渡的每一帧都重画"(SKILL.md 第 7 节):
const 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;六个压点是定数:三个压点会变成"CSS 渐变",十个压点又无法调校。而且地平线压点要略微放在几何地平线之下,让雾色与天空在没有可见线条的情况下相接。
demo 的实现(demo/index.html 天空段):SKY_STOPS=[0, 0.30, 0.52, 0.68, 0.84, 1],天空球SphereGeometry(900, 32, 24),renderOrder=-20保证最先绘制、永不遮挡场景,fog:false, depthWrite:false让天空不受雾影响也不参与深度写入;paintSky在每帧时间过渡中通过skyMat.map.needsUpdate=true重画贴图,实现天空颜色的连续插值。
星星:只放在相机够得到的纬度带里
最常见的错误是把星星撒满整个球面。文档的估算很直观:约 10° 镜头的画面只看到球面的约 0.5%,把 1200 颗星撒满球面,屏幕上大概只有 6 颗,看起来像 bug(SKILL.md 第 8 节)。正确做法是:
- 把星星限制在相机仰角夹子实际允许到达的纬度带内;
- 用三个尺寸等级、不同点尺寸,让亮度有变化;
- 用
devicePixelRatio缩放PointsMaterial.size,否则视网膜屏上星星会消失; - 开加色混合(
AdditiveBlending)、sizeAttenuation:false、fog:false、depthWrite:false。
文档给出的量级是三个等级约 3.9 万个点,每层一个 draw call。demo 的实现(demo/index.html 星星段)略作收敛:[22000, 1.7*DPR], [6000, 2.7*DPR], [1400, 4.6*DPR]共 29400 点,纬度带u限制在[-0.06, 0.85](与相机仰角夹子EL_MIN=-0.004, EL_MAX=0.20对应),并且:
- 星色做了三种温度采样:
w>0.88暖色([1, 0.87, 0.70])、w<0.13冷蓝([0.74, 0.84, 1])、其余近白([0.97, 0.97, 0.93]),再乘0.46 + Math.pow(rnd(), 1.7) * 0.54的随机亮度; - 点纹理来自 64×64 canvas 的径向渐变软点;
- 星星初始
opacity:0,由昼夜系统的stars值统一驱动显隐(m.opacity = st; m.visible = st > 0.005),夜晚才出现。
时间系统:插值,而不是切换
每个时段是一整套"完整状态"
文档的关键设计是:把每个时段存成完整状态——太阳位置与颜色、半球光、环境光、补光、轮廓光、雾色与远近、地面色、草色、六个天空压点、阴影强度、星星不透明度——然后在两个状态之间 lerp(SKILL.md 第 9 节):
function 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); }demo 完整实现了这一架构(demo/index.html 时间系统段):THEMES数组定义 MORNING / NOON / SUNSET / NIGHT 四套状态,每套包含css(页面主题色,用于同步 UI 的浅/深色)、sky六色、fog及远近、ground/grass、sun(方位角、高度角、颜色、强度)、hemiC/ambC/fillC三组灯光、stars不透明度。applyState逐项插值:
- 太阳按方位角/高度角换算成
DirectionalLight位置,并让阴影相机随太阳高度角动态收放(ext = 22 + 34 * Math.cos(el)); - 半球光、环境光、补光、雾、地面色、草色、六个天空压点、星星不透明度全部同步插值;
- 过渡时长 1.25 秒,使用 easeInOut 曲线(
themeMix<0.5 ? 2*t*t : 1 - Math.pow(-2*t+2,2)/2),逐帧调用applyState并重绘天空 canvas。
中途切换:冻结当前插值态,而不是回跳
文档点名的第二个坑是:用户在过渡中途切换时,要把"当前已插值出的状态"冻结为新的起点 A,而不是回到上一个预设,否则每次不耐烦的点击都会跳变(SKILL.md 第 9 节)。demo 的applyTheme正是这样实现的:切换时把themeFrom里每个字段按当前进度t重新插值一遍(A.sunC = A.sunC.clone().lerp(B.sunC, t)等),再以themeMix = 0重新开始——连续点按 TIME 按钮只会连续平滑渐变,绝不跳变。
天气是"乘数",不是"更多预设"
最后一个架构原则:天气作为叠加在上面的乘数,永远不要做成新的预设。四个时段 × 四种天气应该是"四个状态 + 四个修饰符",而不是十六个状态(SKILL.md 第 9 节)。demo 中uSnow就是这种"乘数式"天气的雏形——它不改任何时段定义,只作为 uniform 叠加在草色 mix 上;更完整的雨/雪/风暴方案可进一步参考 threejs-weather。
收尾前的验收清单
文档在第 10 节给出了五条"做完之前必须检查"的项目,这也是参考 demo 的可验证验收标准(SKILL.md 第 10 节):
- 把相机绕到地平线以下一次。如果透过地面能看到天空,绕线反了;
- 看最远的一环。如果远处地形是平的,说明你的山脊振幅没有随距离缩放;
- 连续两次在过渡中途切换时段。画面任何时候都不该跳变;
- 在视网膜屏上检查。星星和细几何体是 DPR bug 最先暴露的地方;
- 盯 draw call 数量,而不是三角形数量。文档给出的对比主张是"18 个 draw call 内的 130 万三角形可以跑 120fps,而 900 个 draw call 的 20 万三角形跑不到"——批量提交比顶点总量更关键。
demo 为此还内置了无头验证钩子(demo/index.html 末尾):window.__theme可瞬间切换时段并渲染,window.__stats返回{fps, tri, calls, grass, stones, theme}的运行时统计数据,右上角常驻显示实时的 fps / 三角形数 / draw call 数——你可以用这些数字直接核对上述第 5 条,而不必肉眼估算。
把这个 skill 用起来:Agent 提示词三板斧
如果你通过 Codex、Claude、Cursor 等 AI 编码 Agent 使用该 skill,demo/PROMPT.md 提供了三档提示词模板:
- 最小提示:
Use $threejs-landscape to put this scene in a real place: a noise heightfield, instanced grass, a gradient sky, and morning/noon/sunset/night that cross-fade.——一句话锁定全部核心交付; - 复刻演示:要求以 demo/index.html 为视觉、动效与性能参考,实现"A Place To Stand Something In"——一份独立 HTML,包含可拖拽环绕/缩放、四时段交叉淡化、GRID 线框开关,并满足实现契约中的全部条目(极坐标网格、域扭曲、无贴图顶点色、顶点着色器塑形草、纬度带星星、插值时段);
- 混搭改造:
Use $threejs-landscape but make it a coastal version: wet sand, tide line, and marram grass in clumps rather than an even field.,或加上跟随地形的慢速镜头推拉。
在具体集成时,请保持"解析地形函数与网格分离"这条总纲:所有散落物(草、石头、星星、雾)采样同一个landH,所有时段只做状态插值,所有天气只做乘数——这样你就得到了一个分辨率跟随镜头、成本可控、可被任意主体"站在上面"的程序化风景。
小结
从 SKILL.md 的方法论到 demo/index.html 的落地实现,这套方案的技术主线可以归纳为四条:
- 几何预算跟着镜头走——极坐标网格 +
pow(t, 2.4)让每一环覆盖等量像素; - 一切程序化、拒绝贴图——域扭曲噪声出地形、斜率/湿度出顶点色、六压点 canvas 出天空;
- CPU 零成本的风——单条 ribbon 的
InstancedMesh把全部草形变放进顶点着色器,patch 平移而非扩张; - 时间是插值不是开关——完整状态 + 中途冻结,昼夜无缝,天气只做乘数。
配合 demo/PROMPT.md 的提示词与 agents/openai.yaml 的默认配置,即可在任意 AI 编码工作流中复现这一套"安静退场的风景"。
【免费下载链接】Skills
Agent skills for designers and builders using Codex, Claude, Cursor, and other AI coding agents
相关推荐
Understand-Anything:3步生成代码知识图谱
Understand Anything:3步生成代码知识图谱 刚接手一个二十万行的陌生项目,不知道从哪下手?Understand Anything 用一条命令扫
Ant引擎昼夜系统:时间变化与光照过渡
Ant引擎昼夜系统:时间变化与光照过渡 引言 在游戏开发和实时渲染领域,实现逼真的昼夜循环是创造沉浸式体验的关键技术。Ant引擎通过其先进的昼夜系统,为开发者提
游戏开发图形学3D渲染告别卡顿!Godot地形草渲染优化:实例化与LOD实战指南
告别卡顿!Godot地形草渲染优化:实例化与LOD实战指南 你还在为游戏中大量草丛导致帧率暴跌发愁?本文将带你用Godot Engine的实例化(Instanc
游戏开发图形学跨平台
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考