# AI 꾸러미 — 부드러운 입자 (soft particles) — Soft particles (depth fade)
> 장면 깊이를 먼저 그려 두고 입자 깊이와 비교해서, 연기 · 안개 입자가 바닥 · 기둥과 만나는 곳의 딱 잘린 선을 부드럽게 지운다.  
> 견본: https://ai-techstudio.web.app/#t/i175

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

## 주문서

### 만들어 줘: 부드러운 입자 (soft particles) — Soft particles (depth fade)

#### 1. 목표
바닥에 깔린 안개 · 연기가 바닥 · 벽과 만나는 곳을 깊이 비교로 부드럽게 해 줘 — 왼쪽 딱딱한 입자 · 오른쪽 부드러운 입자를 나눠 비교할 수 있게. 분위기는 보랏빛 신비로운 안개.

#### 2. 핵심 기술 용어
- **Soft particles (depth fade)** — 부드러운 입자 — 깊이 차이로 가장자리 옅게
- **WebGLRenderTarget + DepthTexture** — 장면 깊이를 텍스처로 받기
- **perspectiveDepthToViewZ** — 깊이 값을 카메라 거리로 바꾸는 three.js 함수
- **Depth pre-pass** — 입자 빼고 장면을 먼저 한 번 그리기

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

#### 4. 조건
- 깊이 패스는 입자를 숨기고(visible = false) 그린 뒤 다시 보인다
- 렌더 타깃 크기 = 그리는 화면 크기, 바뀌면 setSize — uRes 도 같은 값
- 셰이더에서 #include <packing> 후 perspectiveDepthToViewZ(깊이, near, far) 로 카메라 거리로 바꿔 비교
- 부드러운 거리(0.1 ~ 2.5) 조절 값과 켬/끔(uSoftOn) 비교
- 반투명 · 스프라이트는 깊이 패스에서 숨긴다

#### 5. 완성 기준 (이게 보이면 성공)
- 화면을 반으로 나눠, 왼쪽은 안개가 바닥 · 기둥과 만나는 곳에 잘린 선이 보이고 오른쪽은 스르르 사라진다
- 「부드러운 거리」를 키우면 물체 가까이 안개가 더 넓게 옅어진다
- 나눠 비교를 끄면 화면 전체가 부드러운 입자로 그려진다
- 화면 크기를 바꿔도 경계가 어긋나지 않는다

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

## 원리
- 큰 연기 판이 바닥이나 기둥을 뚫고 들어가면 만나는 곳에 칼로 자른 듯한 선이 생긴다.
- 입자를 빼고 장면을 먼저 렌더 타깃(깊이 텍스처 포함)에 그려, 픽셀마다 「뒤에 있는 물체까지 거리」를 얻는다.
- 입자 셰이더에서 그 거리와 입자 자신의 거리 차이를 재 투명도에 곱한다: a × clamp((입자 z − 장면 z) / 부드러운 거리, 0, 1).
- 물체에 가까울수록 입자가 옅어져 경계가 사라진다. 부드러운 거리 0.7 이 견본 기본값.

## 핵심 코드 — 깊이 패스 + 입자 셰이더의 깊이 비교
(발췌: demos/demosSkillA.ts i175 render() · POOL_FS(SOFT) 를 정리)
```ts
// 1) 장면 깊이를 받을 렌더 타깃
const rt = new THREE.WebGLRenderTarget(1, 1);
rt.depthTexture = new THREE.DepthTexture(1, 1);
const U = particles.material.uniforms; // uDepth · uRes · uNear · uFar · uSoft
U.uDepth.value = rt.depthTexture;
U.uSoft.value = 0.7;

function render(r: THREE.WebGLRenderer, w: number, h: number): void {
  if (rt.width !== w || rt.height !== h) rt.setSize(w, h);
  U.uRes.value.set(w, h);
  U.uNear.value = cam.near;
  U.uFar.value = cam.far;
  particles.visible = false;       // 입자 빼고 장면만
  r.setRenderTarget(rt);
  r.render(scene, cam);
  r.setRenderTarget(null);
  particles.visible = true;
  r.render(scene, cam);            // 이제 진짜 화면
}

// 2) 입자 조각 셰이더 (요지) — 정점 셰이더에서 vViewZ = (modelViewMatrix * pos).z
const softFS =
  '#include <packing>\n' +
  'uniform sampler2D uDepth; uniform vec2 uRes; uniform float uNear, uFar, uSoft;\n' +
  'varying float vViewZ;\n' +
  'float softFade(){\n' +
  '  float sd = texture2D(uDepth, gl_FragCoord.xy / uRes).x;\n' +
  '  float sz = perspectiveDepthToViewZ(sd, uNear, uFar);\n' +   // 뒤 물체까지 (음수 z)
  '  return clamp((vViewZ - sz) / uSoft, 0.0, 1.0);\n' +          // 가까울수록 0
  '}\n';
// 알파에 곱한다: a *= softFade();
```

## 흔한 실수 · 확인 목록
- [ ] **깊이 패스에 입자를 같이 그리면 입자가 자기 자신에 가려 사라진다** — 깊이 패스 전에 입자를 숨기고 끝나면 다시 보인다.
- [ ] **반투명 · 스프라이트를 숨기지 않으면 검은 네모가 생긴다** — 깊이를 다시 그리는 패스에서는 반투명 · 스프라이트도 숨긴다 — 이 사이트 후처리에서 겪은 일.
- [ ] **렌더 타깃 크기와 uRes 가 다르면 경계가 밀린다** — render 마다 크기를 비교해 setSize, uRes 도 같은 w · h 로.
- [ ] **깊이 값을 그대로 빼면 멀수록 효과가 달라진다** — 깊이 텍스처 값은 0~1 비선형이다. perspectiveDepthToViewZ 로 카메라 거리로 바꾼 뒤 뺀다.

## 완성 기준 체크리스트
- [ ] 화면을 반으로 나눠, 왼쪽은 안개가 바닥 · 기둥과 만나는 곳에 잘린 선이 보이고 오른쪽은 스르르 사라진다
- [ ] 「부드러운 거리」를 키우면 물체 가까이 안개가 더 넓게 옅어진다
- [ ] 나눠 비교를 끄면 화면 전체가 부드러운 입자로 그려진다
- [ ] 화면 크기를 바꿔도 경계가 어긋나지 않는다

## 이 기술 정보
- id: `i175` · 분류: 이펙트 · 연출 › 스킬 VFX (마법 · 미사일) · 3D · 난이도 어려움 · 폰 부담 보통 (폰 주의) — 입자 빼고 장면을 한 번 더 그린다(깊이 패스). 장면 그리기 비용이 거의 2배. 화면 크기 렌더 타깃 하나 메모리.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i175
- 쓰면 좋을 때: 바닥에 깔리는 안개 · 연기 · 오라처럼 큰 입자가 물체와 겹칠 때 / 입자가 몇 장 안 되지만 크게 보일 때 — 잘린 선이 가장 눈에 띈다
- 쓰지 말 때: 작은 불티 · 빛 점 — 잘린 선이 안 보이니 깊이 패스가 아깝다 / 폰에서 장면이 무거울 때 — 장면을 두 번 그리게 된다. 그땐 입자를 물체에서 띄워 두는 것으로 피한다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i175 견본 항목 — `src/demos/demosSkillA.ts:1920`
```ts
  i175: {
    kind: '3d',
    caption: '안개 입자가 바닥 · 기둥과 만나는 곳: 왼쪽은 딱 잘린 선, 오른쪽은 깊이로 부드럽게',
    make(): Scene3D {
      const S = makeStage({ cam: [0, 4.2, 7.6], look: [0, 0.6, 0] });
      const P = new Pool(220, S.puff, true);
      P.mesh.renderOrder = 3;
      S.scene.add(P.mesh);
      const p1 = pillar(3);
      p1.position.set(-0.9, 0, -0.4);
      const p2 = pillar(2.4);
      p2.position.set(1.6, 0, -1.2);
      const r1 = rock(0.7, 3);
      r1.position.set(0.7, 0.2, 0.9);
      S.scene.add(p1, p2, r1);
      const pl = new THREE.PointLight(0x9a80ff, 10, 9, 1.4);
      pl.position.set(0.2, 1.2, 0.6);
      S.scene.add(pl);
      const labL = S.hud.label('bl');
      const labR = S.hud.label('br');
      labL.set('딱딱한 입자 (잘린 선)', '#888');
      labR.set('부드러운 입자', '#b8a0ff');
      const lab = S.hud.label('tl');
      const divC = document.createElement('canvas');
      divC.width = 4;
      divC.height = 64;
      const dg = divC.getContext('2d')!;
      dg.fillStyle = 'rgba(255,255,255,0.75)';
      dg.fillRect(1, 0, 2, 64);
      const divider = S.hud.picture(texFrom(divC), 0.004, 'mid', 1);
      const MIST = ramp([[0, 0x9a7cff, 1.0, 0, 0.4], [0.2, 0x8f8cff, 1.0, 0.6, 0.4], [0.8, 0x6fb0ff, 0.9, 0.55, 0.4], [1, 0x6fb0ff, 0.9, 0, 0.4]]);
      const spawnMist = (age0: number): void => {
        const i = P.spawn({ x: R(-3, 3), y: R(0.1, 0.9), z: R(-2.2, 1.6), vx: R(-0.15, 0.15), vy: R(-0.02, 0.05), vz: R(-0.1, 0.1), life: R(6, 9), s0: R(1.6, 2.4), s1: R(2.4, 3.2), ramp: MIST, shape: 1, rot: R(0, TAU), rv: R(-0.15, 0.15) });
        if (i >= 0) P.age[i] = age0 * P.life[i]!;
      };
      for (let i = 0; i < 34; i++) spawnMist(Math.random());
      const motes = (): void => {
        P.spawn({ x: R(-2.5, 2.5), y: R(0.1, 1.6), z: R(-2, 1.5), vy: R(0.05, 0.2), life: R(2, 4), s0: 0.06, s1: 0.1, ramp: RAMP.magic, shape: 4, curl: 0.3 });
      };
      const rt = new THREE.WebGLRenderTarget(1, 1);
      rt.depthTexture = new THREE.DepthTexture(1, 1);
      const U = P.mat.uniforms;
      U.uDepth!.value = rt.depthTexture;
      let split = true;
      const controls: Control[] = [
        ctrlTog('나눠 비교 (끄면 전부 부드럽게)', true, (v) => (split = v)),
        ctrlRange('부드러운 거리', 0.1, 2.5, 0.05, 0.7, (v) => (U.uSoft!.value = v)),
      ];
      U.uSoft!.value = 0.7;
      let acc = 0;
      return {
        scene: S.scene,
        camera: S.cam,
        controls,
        update(t, dt0) {
          const dt = Math.min(dt0, 0.05);
          let mist = 0;
          for (let i = 0; i < P.n; i++) if (P.sh[i] === 1) mist++;
          while (mist++ < 34) spawnMist(0);
          acc += dt * 12;
          while (acc >= 1) {
            acc--;
            motes();
          }
          labL.spr.visible = labR.spr.visible = divider.visible = split;
          lab.spr.visible = !split;
          lab.set('부드러운 입자 (깊이 비교)', '#b8a0ff');
          pl.intensity = 10 + Math.sin(t * 2) * 1.5;
          P.step(dt);
          P.upload();
          S.tick(dt);
        },
        render(r, w, h) {
          if (rt.width !== w || rt.height !== h) rt.setSize(w, h);
          U.uRes!.value.set(w, h);
          U.uNear!.value = S.cam.near;
          U.uFar!.value = S.cam.far;
          P.mesh.visible = false;
          r.setRenderTarget(rt);
          r.render(S.scene, S.cam);
          r.setRenderTarget(null);
          P.mesh.visible = true;
          if (!split) {
            U.uSoftOn!.value = 1;
            r.render(S.scene, S.cam);
            return;
          }
          r.setScissorTest(true);
          const half = Math.floor(w / 2);
          U.uSoftOn!.value = 0;
          r.setScissor(0, 0, half, h);
          r.render(S.scene, S.cam);
          U.uSoftOn!.value = 1;
          r.setScissor(half, 0, w - half, h);
          r.render(S.scene, S.cam);
          r.setScissorTest(false);
        },
        resize: (w, h) => S.resize(w, h),
        dispose: () => {
          rt.dispose();
          S.dispose();
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [입자 방출기 (수명 곡선)](https://ai-techstudio.web.app/ai/t/i170.md) `i170`
- 다음에 해 볼 기술: [시간 정지 · 왜곡 구](https://ai-techstudio.web.app/ai/t/i195.md) `i195`
