# AI 꾸러미 — 카메라 흔들림 (잡음 기반 · 감쇠) — Trauma-based camera shake
> 충격마다 trauma 값을 올리고 빠르게 줄이며 trauma² 만큼 부드러운 잡음으로 카메라를 옆 · 위 · 기울기로 흔들어서, 덜컹거리지 않고 묵직하게 흔들렸다 잦아든다.  
> 견본: https://ai-techstudio.web.app/#t/i429

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

## 주문서

### 만들어 줘: 카메라 흔들림 (잡음 기반 · 감쇠) — Trauma-based camera shake

#### 1. 목표
떨어지는 상자 · 쾅 착지에 잡음 기반 카메라 흔들림을 넣어 줘 — 충격마다 세기가 쌓였다 빠르게 잦아들고, 무작위 대신 부드러운 잡음으로 · 세기 · 주파수 조절. 분위기는 맑은 마을.

#### 2. 핵심 기술 용어
- **Trauma-based camera shake** — 충격 값(trauma) 기반 카메라 흔들림
- **Value noise (1D, smooth)** — 부드러운 1차원 잡음
- **Shake decay** — 흔들림이 빠르게 잦아들기
- **Camera local offset (right · up · roll)** — 카메라 자기 축으로 밀기 · 기울이기

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

#### 4. 조건
- 흔들림 = trauma² — 그대로 쓰지 않는다
- 방향은 부드러운 잡음(시간 × 주파수)으로 — 매 프레임 Math.random() 금지 (비교용으로만)
- 매 프레임 기본 자리에서 다시 시작해 흔들림을 더한다 (흔들림이 쌓여 카메라가 떠내려가지 않게)
- 카메라 자기 축(오른쪽 · 위 · 기울기)으로 흔든다
- 세기 · 주파수(3 ~ 30) · 잡음/무작위 조절

#### 5. 완성 기준 (이게 보이면 성공)
- 상자가 떨어져 닿는 순간 화면이 묵직하게 출렁였다 1초 안에 잦아든다
- 위 막대의 세기가 확 올랐다가 빠르게 줄어든다
- 「부드러운 잡음」을 끄면 매 장면 덜컹거리는 무작위 흔들림과 비교된다
- 흔들림이 끝나면 카메라가 정확히 원래 자리에 있다

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

## 원리
- trauma(0 ~ 1)를 둔다: 충격이 오면 trauma += 세기(최대 1), 매 프레임 trauma −= dt × 1.5 로 빠르게 준다.
- 실제 흔들림 = trauma². 제곱이라 큰 충격은 크게, 작은 것은 거의 안 흔들리고, 끝이 부드럽게 사라진다.
- 흔들 방향은 Math.random() 이 아니라 시간 × 주파수(14)로 읽는 부드러운 잡음 두 겹(0.8 + 2.1배 빠른 0.2). 그래서 덜컹 대신 출렁.
- 매 프레임 카메라를 기본 자리로 되돌린 뒤, 카메라 자기 오른쪽 · 위 축으로 n × s × 0.55 만큼 밀고 rotateZ(n × s × 0.07) 로 살짝 기울인다.

## 핵심 코드 — trauma + 부드러운 잡음 흔들림
(발췌: demos/demosCamLight.ts vnoise() · demoShake() update() 를 정리)
```ts
const hash1 = (n: number) => { const s = Math.sin(n * 127.1 + 311.7) * 43758.5453; return s - Math.floor(s); };
function vnoise(x: number, seed: number): number { // 부드러운 1차원 잡음 (−1 ~ 1)
  const i = Math.floor(x), f = x - i;
  const a = hash1(i + seed * 157.3), b = hash1(i + 1 + seed * 157.3);
  const u = f * f * (3 - 2 * f);
  return (a + (b - a) * u) * 2 - 1;
}

const st = { power: 1, freq: 14, decay: 1.5 };
let trauma = 0;
const right = new THREE.Vector3(), up = new THREE.Vector3();

function hit(): void { trauma = Math.min(1, trauma + st.power); } // 충격마다 쌓기

function shake(cam: THREE.PerspectiveCamera, base: THREE.Vector3, lookAt: THREE.Vector3, t: number, dt: number): void {
  trauma = Math.max(0, trauma - dt * st.decay);
  cam.position.copy(base);        // 매 프레임 기본 자리부터
  cam.lookAt(lookAt);
  const s = trauma * trauma;      // 제곱 — 큰 충격만 크게
  const f = t * st.freq;
  const nx = vnoise(f, 1) * 0.8 + vnoise(f * 2.1, 4) * 0.2;
  const ny = vnoise(f, 2) * 0.8 + vnoise(f * 2.1, 5) * 0.2;
  const nr = vnoise(f, 3);
  cam.updateMatrixWorld();
  right.setFromMatrixColumn(cam.matrixWorld, 0);
  up.setFromMatrixColumn(cam.matrixWorld, 1);
  cam.position.addScaledVector(right, nx * s * 0.55).addScaledVector(up, ny * s * 0.55);
  cam.rotateZ(nr * s * 0.07);     // 살짝 기울기
}
```

## 흔한 실수 · 확인 목록
- [ ] **매 프레임 Math.random() 으로 흔들면 덜컹거리고 싸 보인다** — 시간 × 주파수로 읽는 부드러운 잡음을 쓴다. 주파수로 빠르기를 조절.
- [ ] **흔들림을 카메라 자리에 계속 더하면 카메라가 떠내려간다** — 매 프레임 기본 자리 · 방향에서 다시 시작해 흔들림을 더한다.
- [ ] **trauma 를 그대로 쓰면 작은 충격도 크게 흔들린다** — 제곱(trauma²)을 쓰면 작은 건 거의 안 흔들리고 끝이 부드럽다.

## 완성 기준 체크리스트
- [ ] 상자가 떨어져 닿는 순간 화면이 묵직하게 출렁였다 1초 안에 잦아든다
- [ ] 위 막대의 세기가 확 올랐다가 빠르게 줄어든다
- [ ] 「부드러운 잡음」을 끄면 매 장면 덜컹거리는 무작위 흔들림과 비교된다
- [ ] 흔들림이 끝나면 카메라가 정확히 원래 자리에 있다

## 이 기술 정보
- id: `i429` · 분류: 이펙트 · 연출 › 카메라 · 3D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 잡음 계산 몇 번. 비용 없음.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i429
- 쓰면 좋을 때: 착지 · 충돌 · 폭발처럼 무게가 있는 순간 / 여러 충격이 이어질 때 — trauma 가 쌓여 자연스럽게 커진다
- 쓰지 말 때: 글씨를 읽어야 하는 순간 · 판을 정확히 눌러야 할 때 — 흔들림이 방해된다 / 어지러움에 민감한 사용자 — 세기를 0 으로 둘 수 있게

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### demoShake — `src/demos/demosCamLight.ts:1415`
```ts
function demoShake(): Scene3D {
  const { scene, cam, vil } = villageWorld();
  const crate = new THREE.Mesh(new RoundedBoxGeometry(1, 1, 1, 2, 0.06), new THREE.MeshStandardMaterial({ map: crateTex(), roughness: 0.75 }));
  crate.castShadow = true;
  scene.add(crate);
  const shadowDisc = new THREE.Mesh(
    new THREE.CircleGeometry(0.7, 32).rotateX(-Math.PI / 2),
    new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.3, depthWrite: false }),
  );
  shadowDisc.position.y = 0.1;
  scene.add(shadowDisc);
  /* 먼지 */
  const N = 70;
  const dg = new THREE.BufferGeometry();
  const dp = new Float32Array(N * 3);
  const dv: THREE.Vector3[] = [];
  const r0 = rng(11);
  for (let i = 0; i < N; i++) {
    const a = r0() * TAU;
    const s = 1.5 + r0() * 2.5;
    dv.push(new THREE.Vector3(Math.cos(a) * s, 0.4 + r0() * 1.4, Math.sin(a) * s));
  }
  dg.setAttribute('position', new THREE.BufferAttribute(dp, 3));
  const glow = glowTex();
  const dust = new THREE.Points(dg, new THREE.PointsMaterial({ size: 0.9, map: glow, color: 0xe6d6bc, transparent: true, depthWrite: false }));
  dust.frustumCulled = false;
  scene.add(dust);
  const mm = mapFor(vil);
  const impact = mm.dot(0xff6b4a, 0.4);
  mm.camera();
  const hud = new Hud();
  const bg = hud.rectMesh(0x0e142c, 0.7);
  const fill = hud.rectMesh(0xff6b4a, 1);
  const st = { power: 1, freq: 14, smooth: true, decay: 1.5 };
  let trauma = 0;
  let lastC = 0;
  let y = 9;
  let vy = 0;
  let hitAt = -10;
  let bounced = false;
  let manual = -1;
  const base = new THREE.Vector3();
  const right = new THREE.Vector3();
  const up = new THREE.Vector3();
  return {
    scene,
    camera: cam,
    update(t, dt) {
      dt = Math.min(dt, 0.05);
      const c = t % 3.4;
      if (c < lastC || manual >= 0) {
        y = 9;
        vy = 0;
        bounced = false;
        manual = -1;
      }
      lastC = c;
      vy -= 26 * dt;
      y += vy * dt;
      if (y < 0.5) {
        y = 0.5;
        if (!bounced) {
          trauma = Math.min(1, trauma + st.power);
          hitAt = t;
          bounced = true;
          vy = 3.2;
        } else vy = 0;
      }
      const since = t - hitAt;
      const sq = since < 0.25 ? 1 - Math.sin((since / 0.25) * Math.PI) * 0.22 : 1;
      const out = c > 2.9 ? ease((c - 2.9) / 0.4) : 0;
      crate.position.set(0, y * sq + (1 - sq) * 0, 0);
      crate.scale.set(2 - sq, sq, 2 - sq).multiplyScalar(1 - out);
      crate.rotation.y = 0.4;
      shadowDisc.scale.setScalar(clamp(1.2 - y / 10, 0.3, 1) * (1 - out));
      dust.visible = since < 1.4;
      if (dust.visible) {
        for (let i = 0; i < N; i++) {
          const v = dv[i]!;
          const k = 1 - Math.exp(-since * 3);
          dp[i * 3] = v.x * k * 0.7;
          dp[i * 3 + 1] = 0.15 + v.y * k * 0.5;
          dp[i * 3 + 2] = v.z * k * 0.7;
        }
        dg.attributes.position!.needsUpdate = true;
        (dust.material as THREE.PointsMaterial).opacity = 0.85 * (1 - since / 1.4);
      }
      trauma = Math.max(0, trauma - dt * st.decay);
      /* 기본 자리 */
      const yaw = 0.35;
      base.set(Math.sin(yaw) * 11, 8, Math.cos(yaw) * 11);
      cam.position.copy(base);
      cam.lookAt(0, 0.4, 0);
      const s = trauma * trauma;
      let nx: number;
      let ny: number;
      let nr: number;
      if (st.smooth) {
        const f = t * st.freq;
        nx = vnoise(f, 1) * 0.8 + vnoise(f * 2.1, 4) * 0.2;
        ny = vnoise(f, 2) * 0.8 + vnoise(f * 2.1, 5) * 0.2;
        nr = vnoise(f, 3);
      } else {
        nx = Math.random() * 2 - 1;
        ny = Math.random() * 2 - 1;
        nr = Math.random() * 2 - 1;
      }
      cam.updateMatrixWorld();
      right.setFromMatrixColumn(cam.matrixWorld, 0);
      up.setFromMatrixColumn(cam.matrixWorld, 1);
      cam.position.addScaledVector(right, nx * s * 0.55).addScaledVector(up, ny * s * 0.55);
      cam.rotateZ(nr * s * 0.07);
      impact.visible = since < 1.2;
      mm.setCamera(cam);
    },
    render(r, w, h) {
      withState(r, () => {
        r.render(scene, cam);
        mm.draw(r, w, h);
        const u = Math.min(1.9, w / 280, h / 175);
        hud.label('a', st.smooth ? '잡음 흔들림' : '무작위 흔들림 (비교)', 10 * u, 10 * u, 16 * u, { bg: st.smooth ? 'rgba(14,20,44,0.78)' : 'rgba(200,60,80,0.85)' });
        const bw = 90 * u;
        Hud.place(bg, 10 * u, 34 * u, bw, 9 * u);
        Hud.place(fill, 10 * u + 1.5 * u, 35.5 * u, (bw - 3 * u) * trauma, 6 * u);
        hud.label('b', `세기 ${(trauma * trauma).toFixed(2)}`, 10 * u + bw + 5 * u, 31 * u, 14 * u, { bg: 'rgba(14,20,44,0.6)' });
        hud.draw(r, w, h);
      });
    },
    controls: [
      { type: 'button', label: '다시 떨어뜨리기', on: () => (manual = 1) },
      { type: 'range', label: '흔들림 세기', min: 0.2, max: 1, step: 0.05, value: 1, on: (v) => (st.power = v) },
      { type: 'range', label: '주파수 (빠르기)', min: 3, max: 30, step: 1, value: 14, on: (v) => (st.freq = v) },
      { type: 'toggle', label: '부드러운 잡음 (끄면 매 장면 무작위 — 덜컹거림)', value: true, on: (v) => (st.smooth = v) },
    ] as Control[],
    dispose() {
      disposeTree(scene);
      mm.dispose();
      hud.dispose();
    },
  };
}
```

### i429 견본 항목 — `src/demos/demosCamLight.ts:2479`
```ts
  i429: { kind: '3d', caption: '상자가 떨어지면 부드러운 잡음으로 흔들고 빠르게 잦아듦 — 위 막대 = 흔들림 세기', make: () => demoShake() }
```

## 관련 기술
- 다음에 해 볼 기술: [카메라 킥 · 펀치 줌](https://ai-techstudio.web.app/ai/t/i199.md) `i199` · [극적인 확대 (결정적 순간)](https://ai-techstudio.web.app/ai/t/i428.md) `i428`
