mcpField
T
threejs-weather

threejs-weather

github.com/mengto创意与媒体7.1k1.2k 次安装3 天前更新
代码仓库

关于 threejs-weather

用于在 Three.js 场景中实现雨、风暴、降雪及相关效果的技能,介绍贴合相机的粒子体积和共享天气状态。还涵盖闪电、积雪、湿地面与循环环境音。

主要功能

  1. 01贴合相机的天气粒子体积
  2. 02通过绘制范围控制密度
  3. 03雨与风暴状态缩放
  4. 04闪电闪光与延迟雷声
  5. 05降雪及积雪累积
  6. 06湿地面与环境音处理

适用场景

  1. 01为 Three.js 场景添加雨或风暴
  2. 02构建强度变化的暴雪效果
  3. 03呈现积雪与融雪过程
  4. 04制作湿地面效果和天气环境音

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/mengto/skills --skill threejs-weather

安装后在 Claude Code 中输入/threejs-weather确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

agents/
openai.yaml
demo/
index.html
PROMPT.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)

如果改为让片段淡入淡出,每一轮都会降至静音,所有人都听得出来,只是没人提起。

为天气单独设置增益总线,与音乐分开;暴风雪应作为在基础环境音上逐渐增强的第二层播放,而不是交叉淡化切换到另一个片段——在风暴中途硬切换非常明显。

完成前的检查事项

  • 观察一滴雨从生成到落地的全过程。如果找不到雨滴,说明你的体积超出了视锥体。
  • 让场景持续下雪两分钟。结束时地面应该明显更白,而且你应该至少看到一场暴风雪来袭并消退。
  • 从晴朗天气进入雪天五次。每次都必须从平静状态开始。
  • 在风暴中站一分钟。闪电应来自不同方向,雷声绝不能与闪光同时出现。
  • 在风暴运行时进行性能分析。粒子开销很低;你忘记加门控的重新光照才不是。

评论

支持 Markdown