# AI 꾸러미 — 3D 속도선 (질주감) — Speed lines (3D motion streaks)
> 빠르게 달릴 때 카메라 둘레로 가는 빛줄이 휙휙 지나가게 해서, 속도가 오를수록 선이 길어지고 진해지며 시야도 넓어진다.  
> 견본: https://ai-techstudio.web.app/#t/i216

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

## 주문서

### 만들어 줘: 3D 속도선 (질주감) — Speed lines (3D motion streaks)

#### 1. 목표
부스터를 켠 꼬마 자동차가 빨라지면 카메라 둘레로 가는 빛줄이 흐르게 해 줘 — 속도에 비례해 길어지고 진해지며 시야도 살짝 넓어지게. 분위기는 맑은 낮 하늘.

#### 2. 핵심 기술 용어
- **Speed lines (3D motion streaks)** — 3D 속도선 — 질주감
- **InstancedMesh** — 같은 선 110개를 그리기 한 번에
- **Additive blending** — 빛 더하기 섞기 — 겹칠수록 밝게
- **FOV kick** — 빨라질 때 시야각 넓히기

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

#### 4. 조건
- 선은 InstancedMesh 하나로 (선마다 Mesh 를 만들지 않는다)
- 재질은 AdditiveBlending · depthWrite: false · fog: false · toneMapped: false
- 선은 카메라 축 둘레에만 — 화면 한가운데(진행 방향)는 비워 둔다
- 속도가 느리면 투명도 0 — 늘 보이면 속도감이 없어진다
- frustumCulled = false (인스턴스가 처음 자리 밖으로 흘러가도 잘리지 않게)

#### 5. 완성 기준 (이게 보이면 성공)
- 나란히 비교에서 오른쪽만 부스터를 켤 때 카메라 둘레로 빛줄이 흐르고, 끄면 사라진다
- 빨라질수록 선이 길어지고 진해지며 시야각이 55° → 69° 로 넓어진다
- 선이 화면 가운데 차 · 길을 가리지 않는다
- 폰에서도 60fps

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

## 원리
- 가는 판(0.022 × 1) 110개를 InstancedMesh 로 한 번에 그린다. 판은 가운데가 밝고 양 끝이 사라지는 세로 그러데이션 텍스처.
- 선은 카메라 축 둘레 반지름 1.6 ~ 4.2 원통 위에 흩어 두고, 앞에서 뒤로(z 증가) 속도 × 1.35 로 흐르다 카메라 뒤(z > 6)로 가면 앞(−24 ~ −30)으로 되돌린다.
- 속도 boost(0~1)에 따라 선 길이 0.6 + 4.5·boost, 투명도 (boost − 0.08)·1.4·0.85 — 느릴 땐 아예 안 보인다.
- 빛 더하기(Additive) · 깊이 안 씀 · 안개 끔으로 그려 겹칠수록 밝고 멀리서도 흐려지지 않는다.
- 시야각을 55° → 55 + 14·boost 로 넓혀 앞으로 빨려 드는 느낌을 더한다.

## 핵심 코드 — 속도선 InstancedMesh — 카메라 둘레에서 흐르다 되돌아오기
(발췌: demos/demosJuice.ts D216 (make · update) 를 정리)
```ts
const N = 110;
const lGeo = new THREE.PlaneGeometry(0.022, 1);
lGeo.rotateX(Math.PI / 2); // 판을 z(진행) 방향으로 눕힘
const lMat = new THREE.MeshBasicMaterial({
  map: lineTex, transparent: true, opacity: 0.9, blending: THREE.AdditiveBlending,
  depthWrite: false, side: THREE.DoubleSide, toneMapped: false, fog: false,
});
const lines = new THREE.InstancedMesh(lGeo, lMat, N);
lines.frustumCulled = false;
scene.add(lines);
const lz = new Float32Array(N);
for (let i = 0; i < N; i++) lz[i] = -hash(i, 5) * 28;
const m4 = new THREE.Matrix4(), qn = new THREE.Quaternion();
const zAxis = new THREE.Vector3(0, 0, 1), vp = new THREE.Vector3(), vs = new THREE.Vector3();

function updateLines(t: number, dt: number, v: number, boost: number): void {
  const lineLen = 0.6 + boost * 4.5;
  for (let i = 0; i < N; i++) {
    lz[i] += v * dt * 1.35;
    if (lz[i] > 6) lz[i] = -24 - hash(i, Math.floor(t)) * 6; // 카메라 뒤로 가면 앞으로
    const an = hash(i, 7) * Math.PI * 2;
    const r = 1.6 + hash(i, 8) * 2.6;                       // 가운데는 비움
    vp.set(Math.cos(an) * r, 1.4 + Math.sin(an) * r * 0.75, lz[i]);
    qn.setFromAxisAngle(zAxis, an - Math.PI / 2);
    vs.set(1, 1, lineLen * (0.6 + hash(i, 9) * 0.8));
    lines.setMatrixAt(i, m4.compose(vp, qn, vs));
  }
  lines.instanceMatrix.needsUpdate = true;
  lMat.opacity = THREE.MathUtils.clamp((boost - 0.08) * 1.4, 0, 1) * 0.85;
  cam.fov = 55 + boost * 14; // 빨라지면 시야도 넓게
  cam.updateProjectionMatrix();
}
```

## 흔한 실수 · 확인 목록
- [ ] **선마다 Mesh 를 만들면 그리기 호출이 100번 넘는다** — 같은 모양이니 InstancedMesh 하나로. 이 사이트는 물체 하나씩 그리기가 느려진 원인 1순위였다.
- [ ] **안개를 켠 채 두면 먼 선이 배경색으로 묻힌다** — 선 재질만 fog: false. 장면 안개는 그대로 둔다.
- [ ] **선을 진행 방향 한가운데에도 두면 앞이 안 보인다** — 반지름 1.6 이상 원통 위에만 둔다.
- [ ] **시야각을 확 바꾸면 멀미가 난다** — boost 를 lerp(1 − e^(−3dt)) 로 부드럽게 올리고, 넓히는 폭은 +14° 안으로.

## 완성 기준 체크리스트
- [ ] 나란히 비교에서 오른쪽만 부스터를 켤 때 카메라 둘레로 빛줄이 흐르고, 끄면 사라진다
- [ ] 빨라질수록 선이 길어지고 진해지며 시야각이 55° → 69° 로 넓어진다
- [ ] 선이 화면 가운데 차 · 길을 가리지 않는다
- [ ] 폰에서도 60fps

## 이 기술 정보
- id: `i216` · 분류: 이펙트 · 연출 › 손맛 · 주스 · 3D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — InstancedMesh 하나라 그리기 1번, 매 프레임 행렬 110개만 바꾼다. 폰에서도 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i216
- 쓰면 좋을 때: 부스터 · 돌진 · 빠른 공처럼 「지금 빠르다」를 보여 줄 때 / 배경이 단순해 속도감이 안 날 때 (평평한 길 · 하늘)
- 쓰지 말 때: 느리게 생각하는 퍼즐 판 — 산만하다 / 카메라가 위에서 내려다보는 판 게임 — 선이 판을 가린다. 대신 물체 뒤 잔상 꼬리를 쓴다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### D216 — `src/demos/demosJuice.ts:3814`
```ts
const D216: Demo = {
  kind: '3d',
  caption: '부스터를 켜면 카메라 둘레로 가는 빛줄이 휙휙 — 속도에 비례해 길어지고 진해지며 시야도 살짝 넓어진다',
  make() {
    const scene = new THREE.Scene();
    scene.background = skyTex('#5fa8ff', '#d8ecff');
    scene.fog = new THREE.Fog(0xcfe6ff, 18, 48);
    const cam = new THREE.PerspectiveCamera(55, 1.6, 0.1, 80);
    cam.position.set(0, 1.55, 4.2);
    cam.lookAt(0, 0.7, -4);
    scene.add(new THREE.HemisphereLight(0xffffff, 0x6a8a5a, 1.6));
    const sun = new THREE.DirectionalLight(0xfff2dd, 2.2);
    sun.position.set(-4, 8, 2);
    scene.add(sun);
    // 길
    const roadTex = canvasTex(128, 256, (g) => {
      g.fillStyle = '#5b6070';
      g.fillRect(0, 0, 128, 256);
      for (let i = 0; i < 400; i++) {
        g.fillStyle = `rgba(255,255,255,${hash(i, 1) * 0.06})`;
        g.fillRect(hash(i, 2) * 128, hash(i, 3) * 256, 2, 2);
      }
      g.fillStyle = '#ffe066';
      g.fillRect(62, 0, 4, 128);
      g.fillStyle = '#ffffff';
      g.fillRect(4, 0, 6, 256);
      g.fillRect(118, 0, 6, 256);
    });
    roadTex.wrapT = THREE.RepeatWrapping;
    roadTex.repeat.set(1, 12);
    const road = new THREE.Mesh(new THREE.PlaneGeometry(4.4, 80), new THREE.MeshStandardMaterial({ map: roadTex, roughness: 0.85 }));
    road.rotation.x = -Math.PI / 2;
    road.position.z = -30;
    scene.add(road);
    const grassTex = canvasTex(64, 64, (g) => {
      g.fillStyle = '#6cc463';
      g.fillRect(0, 0, 64, 64);
      g.fillStyle = '#5ab352';
      g.fillRect(0, 0, 64, 32);
    });
    grassTex.wrapS = grassTex.wrapT = THREE.RepeatWrapping;
    grassTex.repeat.set(10, 40);
    const grass = new THREE.Mesh(new THREE.PlaneGeometry(60, 80), new THREE.MeshStandardMaterial({ map: grassTex, roughness: 1 }));
    grass.rotation.x = -Math.PI / 2;
    grass.position.set(0, -0.01, -30);
    scene.add(grass);
    // 길가 숫자 말뚝
    const postGeo = new THREE.CylinderGeometry(0.06, 0.06, 1.2, 8);
    const postM = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.6 });
    const signGeo = new THREE.CircleGeometry(0.34, 28);
    const posts: { g: THREE.Group; z0: number }[] = [];
    for (let i = 0; i < 10; i++) {
      const g = new THREE.Group();
      const p = new THREE.Mesh(postGeo, postM);
      p.position.y = 0.6;
      const sm = new THREE.Mesh(signGeo, new THREE.MeshStandardMaterial({ map: faceTex(String(i + 1), BLK[i % 6]!), roughness: 0.5 }));
      sm.position.y = 1.35;
      g.add(p, sm);
      g.position.x = i % 2 ? 2.7 : -2.7;
      scene.add(g);
      posts.push({ g, z0: -i * 4 });
    }
    // 나무 (멀리)
    const treeG = new THREE.SphereGeometry(0.9, 16, 12);
    const treeM = new THREE.MeshStandardMaterial({ color: 0x3f9b4f, roughness: 0.8 });
    const trees: { m: THREE.Mesh; z0: number }[] = [];
    for (let i = 0; i < 14; i++) {
      const m = new THREE.Mesh(treeG, treeM);
      m.position.set((i % 2 ? 1 : -1) * (5 + hash(i, 1) * 4), 0.8, 0);
      m.scale.set(1, 1.2, 1);
      scene.add(m);
      trees.push({ m, z0: -i * 3 });
    }
    // 차
    const car = new THREE.Group();
    const bodyM = new THREE.MeshStandardMaterial({ color: 0xff4d6d, roughness: 0.35 });
    const cb = new THREE.Mesh(roundBox(1.15, 0.42, 1.8, 0.14), bodyM);
    cb.position.y = 0.42;
    const cab = new THREE.Mesh(roundBox(0.9, 0.38, 0.9, 0.14), new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.3 }));
    cab.position.set(0, 0.78, 0.1);
    const win = new THREE.Mesh(new THREE.PlaneGeometry(0.78, 0.26), new THREE.MeshStandardMaterial({ color: 0x3b6bd8, roughness: 0.1, metalness: 0.4 }));
    win.position.set(0, 0.8, 0.56);
    car.add(cb, cab, win);
    const wheelG = new THREE.CylinderGeometry(0.22, 0.22, 0.18, 20);
    wheelG.rotateZ(Math.PI / 2);
    const wheelM = new THREE.MeshStandardMaterial({ color: 0x222233, roughness: 0.7 });
    const hubM = new THREE.MeshStandardMaterial({ color: 0xffd43b, roughness: 0.4 });
    const wheels: THREE.Group[] = [];
    for (const x of [-0.58, 0.58])
      for (const z of [-0.6, 0.6]) {
        const wg = new THREE.Group();
        const wm = new THREE.Mesh(wheelG, wheelM);
        const hb = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.08, 0.3), hubM);
        wg.add(wm, hb);
        wg.position.set(x, 0.22, z);
        car.add(wg);
        wheels.push(wg);
      }
    car.position.set(0, 0, 0);
    scene.add(car);
    // 속도선
    const N = 110;
    const lineTex = canvasTex(8, 128, (g) => {
      const gr = g.createLinearGradient(0, 0, 0, 128);
      gr.addColorStop(0, 'rgba(255,255,255,0)');
      gr.addColorStop(0.75, 'rgba(255,255,255,1)');
      gr.addColorStop(1, 'rgba(255,255,255,0)');
      g.fillStyle = gr;
      g.fillRect(0, 0, 8, 128);
    });
    const lGeo = new THREE.PlaneGeometry(0.022, 1);
    lGeo.rotateX(Math.PI / 2);
    const lMat = new THREE.MeshBasicMaterial({ map: lineTex, color: 0xffffff, transparent: true, opacity: 0.9, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide, toneMapped: false, fog: false });
    const lines = new THREE.InstancedMesh(lGeo, lMat, N);
    lines.frustumCulled = false;
    scene.add(lines);
    const lz = new Float32Array(N);
    for (let i = 0; i < N; i++) lz[i] = -hash(i, 5) * 28;
    const m4 = new THREE.Matrix4();
    const qn = new THREE.Quaternion();
    const zAxis = new THREE.Vector3(0, 0, 1);
    const vp = new THREE.Vector3();
    const vs = new THREE.Vector3();
    const hud = new Hud();
    hud.splitLabels();
    let compare = true;
    let vmax = 1;
    let scroll = 0;
    let boost = 0;
    let lineLen = 1;
    let fovK = 0;
    return {
      scene,
      camera: cam,
      update(t, dt0) {
        const dt = Math.min(dt0, 0.05);
        const ph = t % 4.2;
        const want = ph > 1.2 && ph < 3.0 ? 1 : 0;
        boost = lerp(boost, want, 1 - Math.exp(-dt * (want ? 3 : 2)));
        const v = (6 + 30 * boost) * vmax;
        scroll += v * dt;
        roadTex.offset.y = (scroll / (80 / 12)) % 1;
        grassTex.offset.y = (scroll / 2) % 1;
        for (const p of posts) p.g.position.z = -36 + ((((p.z0 + scroll) % 40) + 40) % 40);
        for (const tr of trees) tr.m.position.z = -38 + ((((tr.z0 + scroll) % 42) + 42) % 42);
        for (const w of wheels) w.rotation.x -= v * dt * 2.2;
        car.position.y = Math.abs(Math.sin(t * 22)) * 0.012 * (0.4 + boost);
        car.rotation.x = -0.05 * boost;
        car.rotation.z = Math.sin(t * 1.3) * 0.02;
        car.position.x = Math.sin(t * 0.9) * 0.35;
        // 선
        lineLen = 0.6 + boost * 4.5;
        for (let i = 0; i < N; i++) {
          lz[i] = lz[i]! + v * dt * 1.35;
          if (lz[i]! > 6) lz[i] = -24 - hash(i, Math.floor(t)) * 6;
          const an = hash(i, 7) * TAU;
          const r = 1.6 + hash(i, 8) * 2.6;
          vp.set(Math.cos(an) * r, 1.4 + Math.sin(an) * r * 0.75, lz[i]!);
          qn.setFromAxisAngle(zAxis, an - Math.PI / 2);
          vs.set(1, 1, lineLen * (0.6 + hash(i, 9) * 0.8));
          m4.compose(vp, qn, vs);
          lines.setMatrixAt(i, m4);
        }
        lines.instanceMatrix.needsUpdate = true;
        lMat.opacity = clamp01((boost - 0.08) * 1.4) * 0.85;
        fovK = boost;
      },
      render(r, w, h) {
        splitRender(
          r,
          w,
          h,
          scene,
          cam,
          compare,
          (on) => {
            lines.visible = on;
            cam.fov = on ? 55 + fovK * 14 : 55;
            cam.updateProjectionMatrix();
          },
          1,
        );
        cam.fov = 55;
        cam.updateProjectionMatrix();
        hud.render(r, w, h, compare, true);
      },
      controls: [tgl('나란히 비교 (끔 | 켬)', compare, (v) => (compare = v)), rng('최고 속도', 0.3, 2, 0.05, vmax, (v) => (vmax = v))],
      dispose() {
        disposeScene(scene);
        hud.dispose();
      },
    };
  },
};
```

## 관련 기술
- 먼저 알면 좋은 기술: [따라가는 카메라 (부드러운 추적)](https://ai-techstudio.web.app/ai/t/i426.md) `i426`
- 다음에 해 볼 기술: [카메라 킥 · 펀치 줌](https://ai-techstudio.web.app/ai/t/i199.md) `i199`
