# AI 꾸러미 — 꼬리 · 리본 (궤적) — Trail ribbon (motion trail)
> 움직이는 점의 지난 위치를 시간과 함께 모아 카메라를 보는 띠로 이어, 꼬리가 점점 가늘고 옅어지며 사라지게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i65

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

## 주문서

### 만들어 줘: 꼬리 · 리본 (궤적) — Trail ribbon (motion trail)

#### 1. 목표
굴러가는 바퀴 위 한 점 (사이클로이드)의 지나간 길을 시간 따라 사라지는 빛 리본으로 그려 줘. 분위기는 밤하늘에 빛나는 선.

#### 2. 핵심 기술 용어
- **Trail ribbon (motion trail)** — 지난 위치를 이은 띠
- **Camera-facing strip** — 진행 방향 × 시선 방향으로 띠 옆을 정해 늘 카메라를 보게
- **Age-based fade** — 나이(지금 − 찍은 시각) / 수명 으로 폭 · 투명도 줄이기

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

#### 4. 조건
- 정점 버퍼는 최대 개수로 한 번 만들고 값만 바꾼다 (매 장면 새로 만들지 않기)
- 띠 옆 방향 = 진행 방향 × (카메라 − 점) — 어느 각도에서 봐도 띠가 납작하게 사라지지 않게
- 수명(초)을 조절 가능하게 (견본 0.3~5)
- 가산 섞기 · depthWrite 끔 · 양면 · frustumCulled = false
- 한 바퀴 돌아 처음 자리로 순간 이동하면 꼬리를 지운다 (화면을 가로지르는 선 방지)

#### 5. 완성 기준 (이게 보이면 성공)
- 바퀴 위 점이 사이클로이드, 튀는 공이 포물선, 혜성이 타원을 그리며 꼬리가 끝으로 갈수록 가늘고 옅어진다
- 「꼬리 길이」를 늘리면 자취가 더 길게 남는다
- 카메라를 옆으로 돌려도 띠가 선처럼 얇아지지 않는다

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

## 원리
- 매 장면 지금 위치와 시각을 목록 맨 앞에 넣는다 (직전 점과 0.02 보다 가까우면 건너뛴다).
- 수명(견본 2.2초)보다 오래된 점과 최대 개수(220)를 넘는 점은 뒤에서 버린다.
- 점마다 진행 방향(앞뒤 점 차이)과 카메라 쪽 방향을 외적해 「옆」을 구하고, 위치 ± 옆 × 폭 으로 두 꼭짓점을 만든다.
- 폭은 나이에 따라 1 → 0.15 배로, 밝기는 (1 − 나이)^1.4 로 줄어든다. 가운데는 밝고 가장자리는 어둡게(1 − s²).

## 핵심 코드 — 점 모으기 + 카메라를 보는 띠로 펴기
(발췌: demos/demosShader.ts Ribbon 클래스의 push · build 를 정리)
```ts
const MAX = 220;
const pts: { p: THREE.Vector3; t: number }[] = [];
const pos = new Float32Array(MAX * 2 * 3);   // 점 하나 = 띠 양쪽 두 꼭짓점
const al = new Float32Array(MAX * 2);        // 밝기 (셰이더 aA)

function push(p: THREE.Vector3, t: number, life: number): void {
  const last = pts[0];
  if (!last || last.p.distanceToSquared(p) > 0.0004) pts.unshift({ p: p.clone(), t });
  while (pts.length > MAX || (pts.length > 1 && t - pts[pts.length - 1]!.t > life)) pts.pop();
}

const tan = new THREE.Vector3(), view = new THREE.Vector3(), side = new THREE.Vector3();
function build(cam: THREE.Camera, t: number, life: number, width: number): void {
  const n = pts.length;
  for (let i = 0; i < MAX; i++) {
    const k = Math.min(i, Math.max(0, n - 1));
    const q = pts[k];
    if (!q) { pos.fill(0, i * 6, i * 6 + 6); al[i * 2] = al[i * 2 + 1] = 0; continue; }
    tan.subVectors(pts[Math.max(0, k - 1)]!.p, pts[Math.min(n - 1, k + 1)]!.p);
    if (tan.lengthSq() < 1e-8) tan.set(1, 0, 0);
    view.subVectors(cam.position, q.p);
    side.crossVectors(tan, view).normalize();          // 늘 카메라를 보는 옆 방향
    const age = Math.min(1, Math.max(0, (t - q.t) / life));
    const w = width * (1 - age * 0.85);                 // 끝으로 갈수록 가늘게
    pos.set([q.p.x + side.x * w, q.p.y + side.y * w, q.p.z + side.z * w,
             q.p.x - side.x * w, q.p.y - side.y * w, q.p.z - side.z * w], i * 6);
    al[i * 2] = al[i * 2 + 1] = i < n ? Math.pow(1 - age, 1.4) : 0;
  }
  geo.attributes.position!.needsUpdate = true;
  geo.attributes.aA!.needsUpdate = true;
}
```

## 흔한 실수 · 확인 목록
- [ ] **띠를 늘 같은 방향(위쪽)으로 펴면 옆에서 볼 때 사라진다** — 진행 방향과 「점 → 카메라」를 외적해 옆을 구한다.
- [ ] **처음 자리로 순간 이동할 때 화면을 가로지르는 긴 선이 생긴다** — 한 바퀴가 끝나는 순간 점 목록을 비운다 (견본 clear()).
- [ ] **매 장면 BufferGeometry 를 새로 만들면 메모리가 샌다** — 최대 개수로 한 번 만들고 값만 바꿔 needsUpdate.
- [ ] **띠가 화면 밖으로 나가면 통째로 안 그려진다** — 경계 구가 갱신되지 않으니 frustumCulled = false.

## 완성 기준 체크리스트
- [ ] 바퀴 위 점이 사이클로이드, 튀는 공이 포물선, 혜성이 타원을 그리며 꼬리가 끝으로 갈수록 가늘고 옅어진다
- [ ] 「꼬리 길이」를 늘리면 자취가 더 길게 남는다
- [ ] 카메라를 옆으로 돌려도 띠가 선처럼 얇아지지 않는다

## 이 기술 정보
- id: `i65` · 분류: 이펙트 · 연출 › 화려한 효과 (VFX) · 3D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 띠 하나 = 정점 440개를 매 장면 다시 씀. 띠 몇 개는 아무 부담 없음, 수백 개면 CPU 가 바빠진다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i65
- 쓰면 좋을 때: 포물선 · 사이클로이드 같은 「자취」를 보여 주는 수학 설명 / 빠르게 움직이는 공 · 칼끝 · 미사일의 손맛
- 쓰지 말 때: 아주 많은 물체에 각각 — 띠마다 정점 갱신이 CPU 비용. 입자 늘이기(i173)로 / 정지된 곡선 — 굵은 선(Line2) 하나면 된다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### Ribbon — `src/demos/demosShader.ts:2182`
```ts
class Ribbon {
  pts: { p: THREE.Vector3; t: number }[] = [];
  geo = new THREE.BufferGeometry();
  mesh: THREE.Mesh;
  private pos: Float32Array;
  private al: Float32Array;
  constructor(
    color: number,
    private width: number,
    private max = 220,
  ) {
    this.pos = new Float32Array(max * 2 * 3);
    this.al = new Float32Array(max * 2);
    this.geo.setAttribute('position', new THREE.BufferAttribute(this.pos, 3));
    this.geo.setAttribute('aA', new THREE.BufferAttribute(this.al, 1));
    const sd = new Float32Array(max * 2);
    for (let i = 0; i < max * 2; i++) sd[i] = i % 2 ? -1 : 1;
    this.geo.setAttribute('aS', new THREE.BufferAttribute(sd, 1));
    const idx: number[] = [];
    for (let i = 0; i < max - 1; i++) {
      const a = i * 2;
      idx.push(a, a + 1, a + 2, a + 1, a + 3, a + 2);
    }
    this.geo.setIndex(idx);
    this.mesh = new THREE.Mesh(
      this.geo,
      new THREE.ShaderMaterial({
        uniforms: { uC: { value: new THREE.Color(color) } },
        transparent: true,
        depthWrite: false,
        side: THREE.DoubleSide,
        blending: THREE.AdditiveBlending,
        vertexShader: /* glsl */ `attribute float aA; attribute float aS; varying float vA; varying float vS;
          void main(){ vA = aA; vS = aS; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
        fragmentShader: /* glsl */ `uniform vec3 uC; varying float vA; varying float vS;
          void main(){ float e = 1.0 - vS * vS; gl_FragColor = vec4((uC + vec3(0.5) * e * vA) * vA * (0.35 + 0.65 * e), 1.0); }`,
      }),
    );
    this.mesh.frustumCulled = false;
  }
  push(p: THREE.Vector3, t: number, life: number): void {
    const last = this.pts[0];
    if (!last || last.p.distanceToSquared(p) > 0.0004) this.pts.unshift({ p: p.clone(), t });
    while (this.pts.length > this.max || (this.pts.length > 1 && t - this.pts[this.pts.length - 1]!.t > life)) this.pts.pop();
  }
  clear(): void {
    this.pts.length = 0;
  }
  build(cam: THREE.Camera, t: number, life: number): void {
    const n = this.pts.length;
    const tan = new THREE.Vector3();
    const view = new THREE.Vector3();
    const side = new THREE.Vector3();
    for (let i = 0; i < this.max; i++) {
      const k = Math.min(i, Math.max(0, n - 1));
      const q = this.pts[k];
      if (!q) {
        this.pos.fill(0, i * 6, i * 6 + 6);
        this.al[i * 2] = this.al[i * 2 + 1] = 0;
        continue;
      }
      const a = this.pts[Math.max(0, k - 1)]!.p;
      const b = this.pts[Math.min(n - 1, k + 1)]!.p;
      tan.subVectors(a, b);
      if (tan.lengthSq() < 1e-8) tan.set(1, 0, 0);
      view.subVectors(cam.position, q.p);
      side.crossVectors(tan, view).normalize();
      const age = clamp((t - q.t) / life, 0, 1);
      const w = this.width * (1 - age * 0.85);
      this.pos.set([q.p.x + side.x * w, q.p.y + side.y * w, q.p.z + side.z * w, q.p.x - side.x * w, q.p.y - side.y * w, q.p.z - side.z * w], i * 6);
      const al = i < n ? Math.pow(1 - age, 1.4) : 0;
      this.al[i * 2] = this.al[i * 2 + 1] = al;
    }
    this.geo.attributes['position']!.needsUpdate = true;
    this.geo.attributes['aA']!.needsUpdate = true;
  }
  dispose(): void {
    this.geo.dispose();
    (this.mesh.material as THREE.Material).dispose();
  }
}
```

## 관련 기술
- 같은 방법으로 만든 효과 예시: [손가락 칼날 궤적 (슬래시)](https://ai-techstudio.web.app/ai/t/i213.md) `i213`
- 먼저 알면 좋은 기술: [필살기 연출 (빛꼬리 · 잔상)](https://ai-techstudio.web.app/ai/t/u37.md) `u37`
- 다음에 해 볼 기술: [속도 방향으로 늘어나는 입자](https://ai-techstudio.web.app/ai/t/i173.md) `i173` · [입자 방출기 (수명 곡선)](https://ai-techstudio.web.app/ai/t/i170.md) `i170`
