# AI 꾸러미 — 날씨 (비 · 눈 · 안개 바꾸기) — Weather system (blended presets)
> 맑음 · 비 · 눈 · 안개 네 묶음 값(해 세기 · 하늘색 · 안개 짙기)을 부드럽게 섞고 입자 · 젖은 바닥 · 웅덩이 · 눈 쌓임을 함께 바꿔 날씨를 바꾼다.  
> 견본: https://ai-techstudio.web.app/#t/i434

이 문서를 코드 도우미(Claude Code · Cursor · ChatGPT 등)에 그대로 주면 돼요. 「## 주문서」 가 할 일, 나머지는 참고 자료예요.

## 주문서

### 만들어 줘: 날씨 (비 · 눈 · 안개 바꾸기) — Weather system (blended presets)

#### 1. 목표
작은 마을에 날씨 바꾸기를 넣어 줘 — 맑음 · 비 · 눈 · 안개가 4초마다 자동으로 부드럽게 넘어가고 바닥이 젖거나 눈이 쌓이게.

#### 2. 핵심 기술 용어
- **Weather system (blended presets)** — 날씨 묶음 값을 서서히 섞어 바꾸기
- **GPU particles (rain · snow in vertex shader)** — 정점 셰이더에서 떨어뜨리는 비 · 눈 점
- **FogExp2** — 거리에 따라 지수로 짙어지는 안개
- **Wet surface (roughness down) + puddle ripples** — 젖으면 거칠기를 낮추고 웅덩이에 물결 고리

#### 3. 환경
- 플랫폼: three.js r186 (ES 모듈 · TypeScript, `import * as THREE from "three"`), WebGL2, 외부 라이브러리 추가 없이
- 화면: 3D · 브라우저 — PC 와 폰(가로 844×390 · 세로 390×844) 모두, 60fps 목표

#### 4. 조건
- 날씨 값은 표 하나(WEATHER)로, 섞기는 가중치 평균 — 날씨를 갑자기 바꾸지 않는다
- 비 · 눈 위치는 정점 셰이더에서 mod(y - 시간 × 빠르기, 12) 로 (비 14 · 눈 1.3), 눈은 sin/cos 로 흔들기
- 입자 재질은 transparent · depthWrite false, 세기 0 이면 visible false
- 젖음: 풀 거칠기 0.92 → 0.45 · 돌 0.8 → 0.2, 색은 0.35 만큼 어둡게
- 번개는 비 가중치가 0.7 넘을 때만, 2.5 ~ 5.5초 간격으로 하늘 · 반구 빛을 잠깐 올리기

#### 5. 완성 기준 (이게 보이면 성공)
- 맑음 → 비 → 눈 → 안개가 4초마다 부드럽게 넘어간다 (해 세기 · 하늘색 · 안개가 함께)
- 비가 오면 바닥이 어둡고 반들반들해지며 웅덩이에 동그란 물결이 퍼지고, 가끔 번개가 친다
- 눈이 오면 지붕 · 풀 · 나뭇잎이 서서히 하얗게 덮이고, 맑아지면 천천히 녹는다
- 「세기」 슬라이더로 입자 양 · 안개 짙기가 함께 바뀌고, 폰에서도 부드럽다

#### 6. 진행 방식
- 핵심 코드 위주로, 설명은 짧게. 내 프로젝트에 끼워 넣기 쉬운 함수 · 클래스로 나눠 줘.
- 처음 화면에 바로 결과가 보이게, 그리고 켬/끔(또는 전/후) 비교를 할 수 있게 만들어 줘.
- 그림 · 소리 · 모델 파일이 필요하면 코드로 만든 임시 대체물로 먼저 돌아가게 하고, 진짜 파일로 바꿀 자리를 표시해 줘.
- 마지막에 「확인 방법」(무엇을 보면 성공인지)과 「조절할 값」 목록을 짧게 정리해 줘.
- 답변과 코드 주석은 한국어로 해 줘.

## 원리
- 날씨마다 값 묶음 하나: 맑음(해 2.7 · 안개 0.004), 비(0.45 · 0.035), 눈(0.9 · 0.03), 안개(0.7 · 0.085) + 하늘 위 · 지평선 색.
- 고른 날씨 가중치를 1 로, 나머지를 0 으로 천천히 끌어간다 (1 - exp(-dt × 2.2)). 모든 값은 가중치로 더한 평균이라 넘어가는 동안도 자연스럽다.
- 비 · 눈은 점 수천 개를 정점 셰이더에서 시간으로 떨어뜨리고 높이 12 로 되감는다 (mod). CPU 는 시간만 넘긴다.
- 비 가중치만큼 풀 · 돌의 거칠기를 낮추고 색을 어둡게, 웅덩이를 보이게. 눈은 쌓임 값을 따로 천천히 올려 지붕 · 풀 · 잎 색을 흰색 쪽으로.

## 핵심 코드 — 날씨 묶음 섞기 + 정점 셰이더 비 · 눈
(발췌: demos/demosCamLight.ts WEATHER · particleMat() · demoWeather() update 를 정리)
```ts
import * as THREE from 'three';

const C = (h: number): THREE.Color => new THREE.Color(h);
const WEATHER = [
  { name: '맑음', sun: 2.7, hemi: 1.1, top: C(0x3b8ee6), hor: C(0xcbe7ff), fog: 0.004 },
  { name: '비', sun: 0.45, hemi: 0.75, top: C(0x4a5568), hor: C(0x8a94a6), fog: 0.035 },
  { name: '눈', sun: 0.9, hemi: 1.0, top: C(0x8e9fb8), hor: C(0xdfe6f0), fog: 0.03 },
  { name: '안개', sun: 0.7, hemi: 0.95, top: C(0xb8c2cc), hor: C(0xd6dce2), fog: 0.085 },
];
const fog = new THREE.FogExp2(0xcbe7ff, 0.004);
scene.fog = fog;
const wts = [1, 0, 0, 0];

function update(dt: number, cur: number): void {
  for (let i = 0; i < 4; i++) wts[i]! += ((i === cur ? 1 : 0) - wts[i]!) * (1 - Math.exp(-dt * 2.2));
  let sunI = 0, hemiI = 0, fd = 0;
  const hor = new THREE.Color(0, 0, 0);
  WEATHER.forEach((W, i) => {
    const k = wts[i]!;
    sunI += W.sun * k; hemiI += W.hemi * k; fd += W.fog * k;
    hor.r += W.hor.r * k; hor.g += W.hor.g * k; hor.b += W.hor.b * k;
  });
  sun.intensity = sunI;
  hemi.intensity = hemiI;
  fog.color.copy(hor);
  fog.density = fd;
  rainU.uA.value = wts[1]!; // 비 입자 진하기
  snowU.uA.value = wts[2]!;
  grassMat.roughness = 0.92 + (0.45 - 0.92) * wts[1]!; // 젖으면 반들반들
}

// 비 정점 셰이더 (눈은 빠르기 1.3 + sin/cos 흔들기)
const RAIN_VS = 'uniform float uTime, uPx, uA; varying float vA;' +
  'void main(){ vec3 p = position; float H = 12.0;' +
  ' p.y = mod(p.y - uTime * 14.0, H); p.x += p.y * 0.08;' +           // 떨어지며 살짝 비스듬히
  ' vec4 mv = modelViewMatrix * vec4(p, 1.0);' +
  ' vA = uA * smoothstep(0.0, 1.0, p.y) * smoothstep(H, H - 2.0, p.y);' + // 위아래 끝에서 사라짐
  ' gl_PointSize = uPx * 0.22 / -mv.z; gl_Position = projectionMatrix * mv; }';
```

## 흔한 실수 · 확인 목록
- [ ] **날씨를 한 번에 바꾸면 화면이 툭 끊긴다** — 모든 값을 가중치 평균으로 두고 가중치를 지수로 천천히 끌어간다.
- [ ] **입자 위치를 CPU 에서 매 프레임 고치면 폰에서 느리다** — 처음 자리만 넣고 정점 셰이더에서 시간으로 떨어뜨린다 (mod 로 되감기).
- [ ] **느린 폰에서 프레임 시간을 0.05 로 자르면 날씨가 느리게 흐른다** — 이 사이트는 실제 시간으로 흐르게 고쳤다 — 새 게임은 실제 dt (0.25초까지) 를 쓴다.

## 완성 기준 체크리스트
- [ ] 맑음 → 비 → 눈 → 안개가 4초마다 부드럽게 넘어간다 (해 세기 · 하늘색 · 안개가 함께)
- [ ] 비가 오면 바닥이 어둡고 반들반들해지며 웅덩이에 동그란 물결이 퍼지고, 가끔 번개가 친다
- [ ] 눈이 오면 지붕 · 풀 · 나뭇잎이 서서히 하얗게 덮이고, 맑아지면 천천히 녹는다
- [ ] 「세기」 슬라이더로 입자 양 · 안개 짙기가 함께 바뀌고, 폰에서도 부드럽다

## 이 기술 정보
- id: `i434` · 분류: 그래픽 · 셰이더 › 빛 · 환경 · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의) — 비 2,600 · 눈 900 점(정점 셰이더로 움직여 CPU 0) + 웅덩이 6장 + 안개. 그림자 지도 1024² 하나. 폰은 입자 수를 반으로.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i434
- 쓰면 좋을 때: 배경 분위기를 바꾸는 날씨 · 계절 / 비 오는 날 젖은 바닥 · 웅덩이 연출 / 같은 장면을 맑음 · 안개 두 분위기로
- 쓰지 말 때: 비가 물체에 부딪혀 튀어야 할 때 — 대신 충돌하는 입자 / 실내 장면 — 창밖에만 입자를 둔다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### demoWeather — `src/demos/demosCamLight.ts:2302`
```ts
function demoWeather(): Scene3D {
  const { scene, cam, sky, vil, sun, hemi } = villageWorld();
  const fog = new THREE.FogExp2(0xcbe7ff, 0.004);
  scene.fog = fog;
  const mk = (n: number, seed: number): THREE.BufferGeometry => {
    const g = new THREE.BufferGeometry();
    const p = new Float32Array(n * 3);
    const r = rng(seed);
    for (let i = 0; i < n; i++) p.set([(r() - 0.5) * 18, r() * 12, (r() - 0.5) * 18], i * 3);
    g.setAttribute('position', new THREE.BufferAttribute(p, 3));
    return g;
  };
  const ru = { uTime: { value: 0 }, uPx: { value: 300 }, uA: { value: 0 } };
  const nu = { uTime: { value: 0 }, uPx: { value: 300 }, uA: { value: 0 } };
  const rain = new THREE.Points(mk(2600, 4), particleMat('rain', ru));
  const snow = new THREE.Points(mk(900, 8), particleMat('snow', nu));
  rain.frustumCulled = snow.frustumCulled = false;
  scene.add(rain, snow);
  /* 웅덩이 */
  const pu = { uWet: { value: 0 }, uTime: { value: 0 }, uSky: { value: new THREE.Color() }, uDeep: { value: new THREE.Color(0x2a3140) } };
  const puddleMat = new THREE.ShaderMaterial({
    uniforms: pu,
    transparent: true,
    depthWrite: false,
    polygonOffset: true,
    polygonOffsetFactor: -2,
    vertexShader: /* glsl */ `
      varying vec2 vUv; varying vec3 vW;
      void main(){ vUv = uv; vec4 w = modelMatrix * vec4(position,1.0); vW = w.xyz; gl_Position = projectionMatrix * viewMatrix * w; }`,
    fragmentShader: /* glsl */ `
      uniform float uWet, uTime; uniform vec3 uSky, uDeep;
      varying vec2 vUv; varying vec3 vW;
      void main(){
        vec2 p = vUv * 2.0 - 1.0;
        float edge = smoothstep(1.0, 0.75, length(p));
        vec3 V = normalize(cameraPosition - vW);
        float fres = 0.25 + 0.75 * pow(1.0 - clamp(V.y, 0.0, 1.0), 2.0);
        vec3 col = mix(uDeep, uSky * 1.45, fres);
        vec2 g = vW.xz * 2.2;
        vec2 cell = floor(g);
        vec2 f = fract(g) - 0.5;
        float h = fract(sin(dot(cell, vec2(127.1, 311.7))) * 43758.5453);
        float ph = fract(uTime * 1.3 + h);
        vec2 o = (vec2(h, fract(h * 7.31)) - 0.5) * 0.3;
        float d = length(f - o);
        float ring = smoothstep(0.035, 0.0, abs(d - ph * 0.42)) * (1.0 - ph);
        col += ring * 0.6;
        gl_FragColor = vec4(col, edge * uWet * 0.92);
        #include <tonemapping_fragment>
        #include <colorspace_fragment>
      }`,
  });
  const pg = new THREE.CircleGeometry(1, 40).rotateX(-Math.PI / 2);
  const puddles: [number, number, number, number][] = [
    [0.6, 0.5, 0.75, 0.55],
    [-1.0, -0.7, 0.5, 0.4],
    [-1.9, 3.7, 0.6, 0.45],
    [4.4, -0.2, 0.55, 0.4],
    [1.6, -2.4, 0.45, 0.35],
    [-4.8, -0.4, 0.6, 0.45],
  ];
  for (const [x, z, sx, sz] of puddles) {
    const m = new THREE.Mesh(pg, puddleMat);
    m.position.set(x, 0.085, z);
    m.scale.set(sx, 1, sz);
    m.renderOrder = 1;
    scene.add(m);
  }
  /* 원래 색 기억 (눈 쌓임) */
  const grass0 = vil.grassMat.color.clone();
  const roof0 = vil.roofMats.map((m) => m.color.clone());
  const leaf0 = vil.leafMats.map((m) => m.color.clone());
  const stone0 = vil.stoneMat.color.clone();
  const snowC = C(0xf4f8ff);
  const wetGrass = C(0x4f8f3e);
  const hud = new Hud();
  const st = { auto: true, w: 1, amount: 1 };
  const wts = [1, 0, 0, 0];
  let acc = 0;
  let flash = 0;
  let nextFlash = 2;
  let cur = 0;
  const tc = new THREE.Color();
  const tc2 = new THREE.Color();
  return {
    scene,
    camera: cam,
    update(t, dt) {
      dt = Math.min(dt, 0.05);
      cur = st.auto ? Math.floor(t / 4) % 4 : st.w;
      for (let i = 0; i < 4; i++) wts[i]! += ((i === cur ? 1 : 0) - wts[i]!) * (1 - Math.exp(-dt * 2.2));
      let sunI = 0;
      let hemiI = 0;
      let fd = 0;
      tc.setRGB(0, 0, 0);
      tc2.setRGB(0, 0, 0);
      for (let i = 0; i < 4; i++) {
        const W = WEATHER[i]!;
        const k = wts[i]!;
        sunI += W.sun * k;
        hemiI += W.hemi * k;
        fd += W.fog * k;
        tc.r += W.top.r * k;
        tc.g += W.top.g * k;
        tc.b += W.top.b * k;
        tc2.r += W.hor.r * k;
        tc2.g += W.hor.g * k;
        tc2.b += W.hor.b * k;
      }
      /* 번개 */
      if (wts[1]! > 0.7 && t > nextFlash) {
        flash = 1;
        nextFlash = t + 2.5 + hash1(Math.floor(t)) * 3;
      }
      flash = Math.max(0, flash - dt * 6);
      const fl = flash > 0.5 || (flash > 0.15 && flash < 0.3) ? 1 : 0;
      sky.u.uTop.value.copy(tc).lerp(C(0xc8d4ff), fl * 0.5);
      sky.u.uHor.value.copy(tc2).lerp(C(0xffffff), fl * 0.4);
      sky.u.uBot.value.copy(tc2).multiplyScalar(0.8);
      sky.u.uSunK.value = wts[0]! * 0.7;
      sun.intensity = sunI;
      hemi.intensity = hemiI + fl * 2.2;
      fog.color.copy(tc2);
      fog.density = fd * (0.5 + 0.5 * st.amount);
      ru.uA.value = wts[1]! * st.amount;
      nu.uA.value = wts[2]! * st.amount;
      ru.uTime.value = t;
      nu.uTime.value = t;
      rain.visible = ru.uA.value > 0.01;
      snow.visible = nu.uA.value > 0.01;
      /* 젖은 바닥 · 눈 쌓임 */
      const wet = wts[1]!;
      acc += ((cur === 2 ? 1 : 0) * st.amount - acc) * (1 - Math.exp(-dt * (cur === 2 ? 1.8 : 1.6)));
      pu.uWet.value = wet;
      pu.uTime.value = t;
      pu.uSky.value.copy(tc2);
      vil.grassMat.color.copy(grass0).lerp(wetGrass, wet * 0.6).lerp(snowC, acc * 0.95);
      vil.grassMat.roughness = lerp(0.92, 0.45, wet);
      vil.stoneMat.color.copy(stone0).multiplyScalar(1 - wet * 0.35).lerp(snowC, acc * 0.8);
      vil.stoneMat.roughness = lerp(0.8, 0.2, wet);
      vil.roofMats.forEach((m, i) => m.color.copy(roof0[i]!).lerp(snowC, acc * 0.75));
      vil.leafMats.forEach((m, i) => m.color.copy(leaf0[i]!).lerp(snowC, acc * 0.45));
      vil.winMat.emissiveIntensity = (1 - wts[0]!) * 1.1;
      vil.lampMat.emissiveIntensity = 0.4 + (1 - wts[0]!) * 2;
      const yaw = 0.4 + t * 0.08;
      cam.position.set(Math.sin(yaw) * 13.5, 7.5, Math.cos(yaw) * 13.5);
      cam.lookAt(0, 0.5, 0);
    },
    render(r, w, h) {
      ru.uPx.value = h;
      nu.uPx.value = h;
      withState(r, () => {
        r.render(scene, cam);
        const u = Math.min(1.9, w / 280, h / 175);
        hud.label('a', WEATHER[cur]!.name, 10 * u, 10 * u, 20 * u, { bg: 'rgba(14,20,44,0.75)' });
        hud.draw(r, w, h);
      });
    },
    controls: [
      { type: 'toggle', label: '날씨 자동으로 바꾸기', value: true, on: (v) => (st.auto = v) },
      { type: 'range', label: '날씨 (0 맑음 · 1 비 · 2 눈 · 3 안개)', min: 0, max: 3, step: 1, value: 1, on: (v) => ((st.w = v), (st.auto = false)) },
      { type: 'range', label: '세기 (입자 · 안개)', min: 0.2, max: 1.5, step: 0.05, value: 1, on: (v) => (st.amount = v) },
    ] as Control[],
    dispose() {
      disposeTree(scene);
      pg.dispose();
      hud.dispose();
    },
  };
}
```

### i434 견본 항목 — `src/demos/demosCamLight.ts:2484`
```ts
  i434: { kind: '3d', caption: '맑음 → 비 → 눈 → 안개: 입자 · 안개 · 빛 세기를 함께, 비 오면 젖은 바닥 · 웅덩이 물결', make: () => demoWeather() }
```

## 관련 기술
- 먼저 알면 좋은 기술: [하루 시간 흐름 (해 · 하늘색)](https://ai-techstudio.web.app/ai/t/i431.md) `i431` · [인스턴싱 (InstancedMesh)](https://ai-techstudio.web.app/ai/t/u36.md) `u36`
- 다음에 해 볼 기술: [평면 반사 (Reflector)](https://ai-techstudio.web.app/ai/t/i14.md) `i14` · [입자 방출기 (수명 곡선)](https://ai-techstudio.web.app/ai/t/i170.md) `i170`
