# AI 꾸러미 — 충격파 화면 왜곡 — Shockwave distortion (screen-space)
> 장면을 텍스처로 먼저 그리고 퍼지는 고리 띠 안의 픽셀만 바깥쪽으로 밀어 읽어, 터지는 순간 화면이 출렁이게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i60

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

## 주문서

### 만들어 줘: 충격파 화면 왜곡 — Shockwave distortion (screen-space)

#### 1. 목표
별이 터지는 순간 순간에 고리가 퍼지며 화면을 밀어 일그러뜨리는 충격파 후처리를 넣어 줘. 분위기는 밝고 신나는 사탕 장난감 세상.

#### 2. 핵심 기술 용어
- **Shockwave distortion (screen-space)** — 화면 좌표에서 고리 모양으로 밀어 읽기
- **Render target post-process** — 장면을 텍스처에 그린 뒤 화면 전체 판으로 다시 그리기
- **Chromatic aberration** — 빨강 · 초록 · 파랑을 조금씩 다르게 밀어 색이 갈라짐

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

#### 4. 조건
- 터진 곳의 3D 위치를 화면 좌표(0~1)로 바꿔 uniform 으로 (y 는 위아래 뒤집기)
- 거리 계산에 화면 비율을 곱해 고리가 원으로 보이게
- 고리 나이에 따라 반지름은 커지고 세기는 줄어 1.3초 안에 사라지게
- 세기 슬라이더(0~3)와 「한 번씩 끄고 비교」
- 렌더 타깃은 화면 크기가 바뀌면 다시 만든다

#### 5. 완성 기준 (이게 보이면 성공)
- 별이 터질 때 고리가 퍼지며 고리 위 장난감 · 바닥 무늬가 바깥으로 휘어 보인다
- 고리 가장자리에 빨강 · 파랑 색 갈라짐이 살짝 보인다
- 「한 번씩 끄고 비교」로 켬/끔이 번갈아 나와 차이가 분명하다
- 창 크기를 바꿔도 고리가 원 모양 그대로

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

## 원리
- 1) 장면을 화면 크기 렌더 타깃에 그린다. 2) 화면을 덮는 판에서 그 텍스처를 읽어 다시 그린다.
- 터진 곳(화면 좌표 uC)에서의 거리 r 과 고리 반지름 R = 나이 × 1.15 를 비교해, 두께 0.13 띠 안에서만 band = 1 − x² 로 세기를 준다.
- 띠 안 픽셀은 바깥 방향으로 0.06 × 세기 만큼 밀린 곳을 읽는다 — 나이가 1.3 초가 되면 사라진다(fade).
- 빨강 1.25배 · 초록 1배 · 파랑 0.75배로 밀면 가장자리에 무지갯빛 갈라짐이 생긴다.
- 화면 비율(asp)을 곱해 거리를 재야 고리가 타원이 아닌 원이 된다.

## 핵심 코드 — 충격파 후처리 (화면 판의 조각 셰이더)
(발췌: demos/demosShader.ts shockDemo() 의 후처리 셰이더를 정리)
```glsl
// TS 쪽: renderer.setRenderTarget(rt); renderer.render(scene, cam); renderer.setRenderTarget(null);
//        uC = 터진 곳 화면 좌표(y 뒤집기), uAge = 터진 뒤 초, 그다음 화면 판을 그린다
uniform sampler2D tScene; uniform vec2 uRes; uniform float uAge; uniform vec2 uC; uniform float uAmt;
varying vec2 vUv;
void main(){
  vec2 asp = vec2(uRes.x / uRes.y, 1.0);
  vec2 d = (vUv - uC) * asp; float r = length(d);           // 비율을 곱해야 고리가 원
  float R = uAge * 1.15; float th = 0.13;                     // 고리 반지름 · 두께
  float x = (r - R) / th;
  float band = abs(x) < 1.0 ? 1.0 - x * x : 0.0;              // 띠 안에서만 세기
  float prof = abs(x) < 1.0 ? (1.0 - x * x) * sign(-x + 0.0001) * -1.0 : 0.0;
  float fade = clamp(1.0 - uAge / 1.3, 0.0, 1.0);
  vec2 dir = normalize(d + 1e-5) / asp;
  vec2 off = dir * band * fade * 0.06 * uAmt * (0.6 + 0.4 * prof);
  vec3 col;
  col.r = texture2D(tScene, vUv - off * 1.25).r;              // 색마다 조금씩 다르게 → 갈라짐
  col.g = texture2D(tScene, vUv - off).g;
  col.b = texture2D(tScene, vUv - off * 0.75).b;
  col += vec3(1.0, 0.95, 0.8) * band * fade * 0.18;           // 고리 위 옅은 빛
  gl_FragColor = vec4(col, 1.0);
}
```

## 흔한 실수 · 확인 목록
- [ ] **화면 비율을 안 곱하면 고리가 옆으로 늘어난 타원이 된다** — UV 차이에 (가로/세로, 1) 을 곱해 거리를 재고, 미는 방향은 다시 나눈다.
- [ ] **3D 위치를 화면 좌표로 바꿀 때 y 가 뒤집힌다** — project() 결과를 0~1 로 바꾼 뒤 1 − y. 견본은 toScreen 결과로 uC.set(x, 1 − y).
- [ ] **렌더 타깃을 한 번만 만들면 창을 키울 때 흐려진다** — 그릴 때마다 (w, h) 가 바뀌었는지 보고 setSize. 픽셀 비율도 곱한다.
- [ ] **후처리 패스에서 톤 매핑 · 색 공간을 빠뜨리면 색이 달라진다** — 직접 짠 화면 판 셰이더 끝에 tonemapping · colorspace 처리를 넣거나, 장면 쪽과 같은 설정을 쓴다.

## 완성 기준 체크리스트
- [ ] 별이 터질 때 고리가 퍼지며 고리 위 장난감 · 바닥 무늬가 바깥으로 휘어 보인다
- [ ] 고리 가장자리에 빨강 · 파랑 색 갈라짐이 살짝 보인다
- [ ] 「한 번씩 끄고 비교」로 켬/끔이 번갈아 나와 차이가 분명하다
- [ ] 창 크기를 바꿔도 고리가 원 모양 그대로

## 이 기술 정보
- id: `i60` · 분류: 이펙트 · 연출 › 화려한 효과 (VFX) · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의) — 장면을 한 번 텍스처로 그리고 화면 전체 판을 한 번 더 그린다(텍스처 읽기 3번). 장면 비용은 같고 화면 크기만큼 더해진다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i60
- 쓰면 좋을 때: 정답 · 득점 · 폭발처럼 한순간을 크게 강조할 때 / 진동 · 히트스톱과 함께 손맛을 줄 때
- 쓰지 말 때: 계속 켜 두는 효과 — 눈이 피곤하다. 0.5~1.3초 한 번 / 후처리를 아예 안 쓰는 가벼운 장면 — 고리 판 메시(i178)로 흉내

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### shockDemo — `src/demos/demosShader.ts:1429`
```ts
function shockDemo(): Scene3D {
  const scene = new THREE.Scene();
  const bg = gradTex([
    [0, '#4aa0f0'],
    [1, '#cfeaff'],
  ]);
  scene.background = bg;
  lights(scene, 0xffffff, 0x8899aa, 1.0, 1.6);
  const world = new THREE.Group();
  const ck = checkerTex('#ffe9b0', '#ffd27a');
  ck.repeat.set(5, 5);
  const floor = new THREE.Mesh(new THREE.PlaneGeometry(14, 14), new THREE.MeshStandardMaterial({ map: ck, roughness: 0.9 }));
  floor.rotation.x = -Math.PI / 2;
  world.add(floor);
  const COLS = [0xff5a7a, 0x4ac8ff, 0x7ad65a, 0xffb22a, 0xa07aff, 0xff7ad8];
  const toys: THREE.Mesh[] = [];
  const geos = [new THREE.SphereGeometry(0.32, 24, 16), new THREE.BoxGeometry(0.5, 0.5, 0.5), new THREE.ConeGeometry(0.3, 0.6, 20), new THREE.TorusGeometry(0.24, 0.1, 12, 24)];
  let k = 0;
  for (let z = -3; z <= 1; z++)
    for (let x = -3; x <= 3; x++) {
      if (Math.abs(x) < 1 && Math.abs(z + 1) < 1) continue;
      const m = new THREE.Mesh(geos[k % 4]!, new THREE.MeshStandardMaterial({ color: COLS[k % 6]!, roughness: 0.4 }));
      m.position.set(x * 1.1, 0.35, z * 1.1);
      toys.push(m);
      world.add(m);
      k++;
    }
  const star = new THREE.Mesh(new THREE.IcosahedronGeometry(0.5, 0), new THREE.MeshStandardMaterial({ color: 0xffe14a, emissive: 0xffa000, emissiveIntensity: 0.6, metalness: 0.3, roughness: 0.3, flatShading: true }));
  star.position.set(0, 0.8, -1.1);
  world.add(star);
  scene.add(world);
  const cam = new THREE.PerspectiveCamera(45, 1, 0.1, 60);
  cam.position.set(0, 4.2, 5.2);
  cam.lookAt(0, 0, -1);
  const rt = makeRT();
  const pu = { tScene: { value: null as THREE.Texture | null }, uRes: { value: new THREE.Vector2(1, 1) }, uTime: { value: 0 }, uAge: { value: 0 }, uC: { value: new THREE.Vector2(0.5, 0.5) }, uAmt: { value: 1 }, uOn: { value: 1 } };
  const post = fsq(
    /* glsl */ `${POST_HEAD} uniform float uAge; uniform vec2 uC; uniform float uAmt; uniform float uOn;
    void main(){
      vec2 asp = vec2(uRes.x / uRes.y, 1.0);
      vec2 d = (vUv - uC) * asp; float r = length(d);
      float R = uAge * 1.15; float th = 0.13;
      float x = (r - R) / th;
      float prof = abs(x) < 1.0 ? (1.0 - x * x) * sign(-x + 0.0001) * -1.0 : 0.0;
      float band = abs(x) < 1.0 ? 1.0 - x * x : 0.0;
      float fade = clamp(1.0 - uAge / 1.3, 0.0, 1.0);
      vec2 dir = normalize(d + 1e-5) / asp;
      vec2 off = dir * band * fade * 0.06 * uAmt * uOn * (0.6 + 0.4 * prof);
      vec3 col;
      col.r = texture2D(tScene, vUv - off * 1.25).r;
      col.g = texture2D(tScene, vUv - off).g;
      col.b = texture2D(tScene, vUv - off * 0.75).b;
      col += vec3(1.0, 0.95, 0.8) * band * fade * 0.18 * uOn;
      gl_FragColor = vec4(col, 1.0);
      ${POST_TAIL}
    }`,
    pu,
  );
  const tags = new Tags(['충격파 왜곡']);
  let mode: 'alt' | 'on' = 'alt';
  return {
    scene,
    camera: cam,
    update(t) {
      const P = 2.0;
      const age = t % P;
      const wave = Math.floor(t / P);
      pu.uAge.value = age;
      pu.uTime.value = t;
      pu.uOn.value = mode === 'on' || wave % 2 === 0 ? 1 : 0;
      tags.set(0, pu.uOn.value ? '충격파 왜곡 켬' : '왜곡 없음');
      const pop = Math.exp(-age * 6);
      star.scale.setScalar(1 + pop * 0.6);
      star.rotation.set(t * 0.7, t, 0);
      toys.forEach((m, i) => {
        m.rotation.y = t * 0.8 + i;
        m.position.y = 0.35 + Math.abs(Math.sin(t * 2 + i * 0.7)) * 0.12;
      });
    },
    render(r, w, h) {
      const target = rt.get(w, h);
      const prev = r.getRenderTarget();
      r.setRenderTarget(target);
      r.clear();
      r.render(scene, cam);
      r.setRenderTarget(prev);
      const c = toScreen(star.position, cam);
      pu.uC.value.set(c[0], 1 - c[1]);
      pu.tScene.value = target.texture;
      pu.uRes.value.set(w, h);
      r.render(post.scene, post.camera);
      tags.draw(r, w, h, [[0.5, 0.08]]);
    },
    controls: [
      { type: 'range', label: '밀어내는 세기', min: 0, max: 3, step: 0.05, value: 1, on: (v) => (pu.uAmt.value = v) },
      { type: 'toggle', label: '한 번씩 끄고 비교', value: true, on: (v) => (mode = v ? 'alt' : 'on') },
    ],
    dispose() {
      freeAll(world);
      for (const g of geos) g.dispose();
      bg.dispose();
      ck.dispose();
      rt.dispose();
      post.dispose();
      tags.dispose();
    },
  };
}
```

## 관련 기술
- 먼저 알면 좋은 기술: [후처리 사슬 (EffectComposer → OutputPass)](https://ai-techstudio.web.app/ai/t/u21.md) `u21`
- 다음에 해 볼 기술: [아지랑이 (heat haze)](https://ai-techstudio.web.app/ai/t/i61.md) `i61` · [충격 고리 · 땅 파동](https://ai-techstudio.web.app/ai/t/i178.md) `i178`
- 참고 문서: [three.js 문서 — WebGLRenderTarget](https://threejs.org/docs/#api/en/renderers/WebGLRenderTarget)
