关于 threejs-weather
用于在 Three.js 场景中实现雨、风暴、降雪及相关效果的技能,介绍贴合相机的粒子体积和共享天气状态。还涵盖闪电、积雪、湿地面与循环环境音。
主要功能
- 01贴合相机的天气粒子体积
- 02通过绘制范围控制密度
- 03雨与风暴状态缩放
- 04闪电闪光与延迟雷声
- 05降雪及积雪累积
- 06湿地面与环境音处理
适用场景
- 01为 Three.js 场景添加雨或风暴
- 02构建强度变化的暴雪效果
- 03呈现积雪与融雪过程
- 04制作湿地面效果和天气环境音
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ mengto/ skills --skill threejs-weather安装后在 Claude Code 中输入/threejs-weather确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
agents/openai.yamldemo/index.htmlPROMPT.md详细介绍
Three.js Weather
天气效果常见的两个问题:粒子完全不在摄像机视野内,或者每种天气状态都是一个彼此毫无关联的独立系统。这两种情况都可以避免。
与 threejs-landscape 搭配,为降下的雨雪提供地面;与 threejs-towers 搭配,让雨雪有东西可以落在上面。
将体积锚定在视锥体中
任何降雨系统的第一个版本,通常都会在原点周围放置一个世界尺度的粒子盒,结果却什么也看不到。使用长焦镜头时,摄像机看到的是一个狭窄的锥体,因此几乎所有雨滴都不在摄像机视野内。
构建一个小体积,并将其放在摄像机前方,使其朝向与摄像机一致:
const WX_W = 17, WX_D = 50, WX_TOP = 40; // narrow, deep, tall
function anchor(o) {
const fx = -Math.sin(camAz), fz = -Math.cos(camAz);
o.position.set(cam.position.x + fx * 46, 0, cam.position.z + fz * 46);
o.rotation.y = camAz;
}
将它的 frustumCulled = false,因为你每帧都会移动它,包围球会碍事。
Float32BufferAttribute 会复制你的数组
这个问题会让你白白花上一下午。该属性会复制你传入的内容,因此你保留引用的数组并不是 GPU 读取的数组:
const attr = new THREE.Float32BufferAttribute(pos, 3);
attr.setUsage(THREE.DynamicDrawUsage);
geo.setAttribute('position', attr);
return { pts, pos: attr.array, n }; // keep the attribute's array
症状:一切看起来都正确,但没有任何东西移动,而且没有任何报错。
密度由绘制范围控制,而不是创建新缓冲区
一次性分配最大容量——一场完整的暴风雨或一场完整的暴风雪——然后通过减少绘制数量来降低密度:
RAIN.geo.setDrawRange(0, Math.round(RAIN.n * density));
这样一来,暴风雨占用的内存不会比毛毛雨多;它只是停止显示大部分雨滴。天气变化时重新分配缓冲区,会在观众正好注视屏幕时造成卡顿。
暴风雨就是倾斜的雨
不要再编写第二套粒子系统。使用雨的状态,并调高所有参数:
| 雨 | 暴风雨 | |
|---|---|---|
| 绘制的雨滴 | 粒子池的 60% | 100% |
| 下落速度 | ×1.0 | ×1.42 |
| 倾斜度 | 2.4 | 随速度²变化 |
| 日照 | 30% | 14% |
| 雾的远端距离 | ×0.52 | ×0.38 |
倾斜度应该比速度增长得更快——slant = base * v * v——因为这才能体现出风,而不是“雨,只是下得更快”。
闪电需要独立的光源
不要修改昼夜状态来制造闪光。添加一个不受其他系统影响的光源,这样无论天空当前是什么状态,闪电都能照亮它:
const bolt = new THREE.DirectionalLight(0xe8eeff, 0);
一次闪电包含多次闪烁——一次先导放电和两三次回击——每次都按指数衰减:
function strike() {
const near = Math.random(); // 0 distant … 1 close
const s = 0.42 + near * 0.58;
pulses = [{ t: 0, a: s }];
let tt = 0;
for (let i = 0, n = 1 + (Math.random() * 2.6 | 0); i < n; i++) {
tt += 0.05 + Math.random() * 0.14;
pulses.push({ t: tt, a: s * (0.30 + Math.random() * 0.65) });
}
setTimeout(() => sfx(near > 0.5 ? 'thunder_near' : 'thunder_far',
{ gain: 0.30 + near * 0.60 }),
(0.32 + (1 - near) * 2.7) * 1000); // sound arrives late
}
function step(dt) {
let v = 0;
for (const p of pulses) if (age >= p.t) v += p.a * Math.exp(-(age - p.t) / 0.085);
bolt.intensity = v * 3.2;
skyMat.color.setScalar(1 + v * 0.80); // multiplies the sky map
flashEl.style.opacity = (v * 0.15).toFixed(3);
}
有两个细节最为关键。雷声会因距离而延迟,而且延迟越久,声音越小——这种相关性正是让闪电显得遥远的关键。还有,DOM 闪光应置于文字下方,而不是上方:覆盖全屏、置于文字上方的白色叠层会让页面褪色,因此应将它直接放在画布上方,并将不透明度保持在约 0.18 以下。
每次闪电发生时都重新定位光源。闪光如果总从同一侧出现,第二次之后就会失去效果。
暴风雪是缓慢变化的包络,而不是一种状态
雪不会永远以同一速度飘落。运行一个循环——平静、增强、狂吹、减弱——并用它调节降雪状态:
const BLIZ = [12, 6, 15, 8]; // seconds
function blizzardAt(t) {
let u = t % TOTAL; if (u < 0) u += TOTAL;
if (u < BLIZ[0]) return 0;
... // smoothstep in, hold, out
}
狂吹阶段要增大雪花池、加厚雪花、推动它们横向飘动,并让雾靠近。离开下雪状态时,将阶段时钟重置为零,否则进入下雪状态时就会直接跳到白茫茫一片的阶段——负的起始偏移会回绕到错误的阶段,看起来像一个怎么也找不到的 bug。
飘落的雪和积雪是两个变量
这一点最容易被忘记。单独用一个缓慢计时器跟踪已经落地的雪:降雪时积雪增加,天气转暖时则融化:
snowPack += (target - snowPack) * (target > snowPack ? dt / 24 : dt / 13);
然后只要雪还在下,就让它持续将地面、石头和草地染白。刚开始下雪时和站在雪中一分钟后,画面不应该一样。
如果表面颜色是在着色器内部混合的——实例化草地通常就是这样——材质颜色就无法影响它。为这种混合单独添加一个 uniform,并让雪先积在草尖,再逐渐向下铺开:
gBase = mix(gBase, vec3(0.60, 0.65, 0.72), uSnow * 0.86);
gTip = mix(gTip, vec3(0.90, 0.94, 1.00), uSnow);
仅在缓慢变化的数值发生变化时重新打光
重新打光场景并非没有成本,而暴风雪强度和积雪深度都在持续缓慢变化。为它们设置触发条件:
const look = blizK + snowPack;
if (Math.abs(look - lastLook) > 0.006) { lastLook = look; refreshLook(); }
这样每秒只会触发几次,而不是六十次,屏幕上看不出任何区别。
湿地
按投入产出比排序,有三件成本低的事:降低粗糙度并增加少量金属度,让地面映出天空;在凹处添加扁平的半透明水坑贴花;然后实例化一个生命周期较短的水花环精灵,并根据降雨强度按比例生成。水花才是让雨看起来像是正在落下,而不只是雨水纹理的关键。
无缝循环的环境音
生成的音频永远无法干净地循环。用等功率交叉淡化将尾部叠加到开头,并丢弃重叠部分——这样片段就能从起点结束:
for i in range(f):
t = i / f
out[i] = a[i] * sin(t * pi / 2) + a[n - f + i] * cos(t * pi / 2)
如果改为让片段淡入淡出,每一轮都会降至静音,所有人都听得出来,只是没人提起。
为天气单独设置增益总线,与音乐分开;暴风雪应作为在基础环境音上逐渐增强的第二层播放,而不是交叉淡化切换到另一个片段——在风暴中途硬切换非常明显。
完成前的检查事项
- 观察一滴雨从生成到落地的全过程。如果找不到雨滴,说明你的体积超出了视锥体。
- 让场景持续下雪两分钟。结束时地面应该明显更白,而且你应该至少看到一场暴风雪来袭并消退。
- 从晴朗天气进入雪天五次。每次都必须从平静状态开始。
- 在风暴中站一分钟。闪电应来自不同方向,雷声绝不能与闪光同时出现。
- 在风暴运行时进行性能分析。粒子开销很低;你忘记加门控的重新光照才不是。