# AI 꾸러미 — 3D 입체 글자 · 로고 등장
> 두께 있는 글자가 돌며 착지 · 반짝  
> 견본: https://ai-techstudio.web.app/#t/i115

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

## 주문서

### 만들어 줘: 3D 입체 글자 · 로고 등장

#### 1. 목표
게임 시작 로고에 「3D 입체 글자 · 로고 등장」 기술을 깔끔한 기본 느낌으로 넣어 줘. — 두께 있는 글자가 돌며 착지 · 반짝

#### 2. 핵심 기술 용어
- 3D 입체 글자 · 로고 등장 — 두께 있는 글자가 돌며 착지 · 반짝

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

#### 4. 조건
- 한 프레임 16ms 안 (폰 포함) — 무거운 계산은 처음에 한 번 또는 여러 프레임에 나눠서
- 새 재질 · 셰이더는 첫 화면 전에 미리 컴파일 (renderer.compileAsync)
- 화면 가장자리 흐림 · 비네트로 얼버무리지 않기
- 외부 라이브러리 추가 없이 three.js 와 three/examples 만

#### 5. 완성 기준 (이게 보이면 성공)
- 견본처럼 「두께 있는 글자가 돌며 착지 · 반짝」 이(가) 첫 화면에서 바로 보인다
- 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

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

## 원리
- 두께 있는 글자가 돌며 착지 · 반짝


## 흔한 실수 · 확인 목록
- (아직 정리된 것이 없어요)

## 완성 기준 체크리스트
- [ ] 견본처럼 「두께 있는 글자가 돌며 착지 · 반짝」 이(가) 첫 화면에서 바로 보인다
- [ ] 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- [ ] 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- [ ] 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

## 이 기술 정보
- id: `i115` · 분류: 이펙트 · 연출 › 모션 그래픽 · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i115
- 쓰면 좋을 때: 쓰면 좋을 곳 — 게임 시작 로고 · 결과 화면

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i115 견본 항목 — `src/demos/demosMotionB.ts:929`
```ts
  i115: {
    kind: '3d',
    caption: '글꼴 모양을 따서 두께를 준 「수학!」 — 돌며 떨어져 통통 착지하고, 빛이 쓱 지나가며 반짝',
    make(T) {
      const scene = new T.Scene();
      const bgc = document.createElement('canvas');
      bgc.width = 4;
      bgc.height = 128;
      const bx = bgc.getContext('2d')!;
      const bgr = bx.createLinearGradient(0, 0, 0, 128);
      bgr.addColorStop(0, '#1b1f5e');
      bgr.addColorStop(0.65, '#3a1f6e');
      bgr.addColorStop(1, '#5a2a6a');
      bx.fillStyle = bgr;
      bx.fillRect(0, 0, 4, 128);
      const bgTex = new T.CanvasTexture(bgc);
      bgTex.colorSpace = T.SRGBColorSpace;
      scene.background = bgTex;
      scene.add(new T.HemisphereLight(0xffffff, 0x50306a, 0.7));
      const key = new T.DirectionalLight(0xffffff, 1.6);
      key.position.set(-2, 4, 5);
      scene.add(key);
      const glint = new T.PointLight(0xffffff, 0, 6, 1.2);
      scene.add(glint);
      const CH: [string, number][] = [
        ['수', 0xff6fae],
        ['학', 0x4fc3ff],
        ['!', 0xffd23f],
      ];
      const letters: { g: THREE.Group; mesh: THREE.Mesh; mats: THREE.MeshStandardMaterial[] }[] = [];
      const geos: THREE.BufferGeometry[] = [];
      const build = (): void => {
        CH.forEach(([ch, col], i) => {
          const shapes = traceGlyph(ch, TF);
          const geo = new T.ExtrudeGeometry(shapes, { depth: 0.2, bevelEnabled: true, bevelThickness: 0.045, bevelSize: 0.022, bevelSegments: 3, curveSegments: 2 });
          geo.computeBoundingBox();
          const bb = geo.boundingBox!;
          geo.translate(-(bb.min.x + bb.max.x) / 2, -bb.min.y, -0.1);
          geos.push(geo);
          let L = letters[i];
          if (!L) {
            const c = new T.Color(col);
            const front = new T.MeshStandardMaterial({ color: c, roughness: 0.28, metalness: 0.25 });
            const side = new T.MeshStandardMaterial({ color: c.clone().multiplyScalar(0.55), roughness: 0.4, metalness: 0.35 });
            const mesh = new T.Mesh(geo, [front, side]);
            const g = new T.Group();
            g.add(mesh);
            mesh.scale.setScalar(1.45);
            scene.add(g);
            L = { g, mesh, mats: [front, side] };
            letters.push(L);
          } else {
            L.mesh.geometry.dispose();
            L.mesh.geometry = geo;
          }
        });
        const widths = letters.map((l) => {
          l.mesh.geometry.computeBoundingBox();
          const b = l.mesh.geometry.boundingBox!;
          return (b.max.x - b.min.x) * 1.45;
        });
        const gap = 0.12;
        const total = widths.reduce((a, b) => a + b, 0) + gap * (widths.length - 1);
        let x = -total / 2;
        letters.forEach((l, i) => {
          l.g.userData['x'] = x + widths[i]! / 2;
          x += widths[i]! + gap;
        });
      };
      build();
      let alive = true;
      if (!document.fonts.check('100px "Black Han Sans"'))
        document.fonts
          .load('100px "Black Han Sans"')
          .then(() => {
            if (alive) build();
          })
          .catch(() => undefined);
      // 받침
      const stage = new T.Mesh(new T.CylinderGeometry(2.6, 2.6, 0.12, 64), new T.MeshStandardMaterial({ color: 0x2e2f7a, roughness: 0.6 }));
      stage.position.y = -0.62;
      stage.scale.z = 0.38;
      scene.add(stage);
      // 반짝이
      const sc = document.createElement('canvas');
      sc.width = sc.height = 64;
      const sx = sc.getContext('2d')!;
      const sg = sx.createRadialGradient(32, 32, 0, 32, 32, 32);
      sg.addColorStop(0, 'rgba(255,255,255,1)');
      sg.addColorStop(0.2, 'rgba(255,240,180,.7)');
      sg.addColorStop(1, 'rgba(255,200,100,0)');
      sx.fillStyle = sg;
      sx.fillRect(0, 0, 64, 64);
      sx.fillStyle = '#fff';
      sx.beginPath();
      sx.moveTo(32, 0);
      sx.quadraticCurveTo(34, 30, 64, 32);
      sx.quadraticCurveTo(34, 34, 32, 64);
      sx.quadraticCurveTo(30, 34, 0, 32);
      sx.quadraticCurveTo(30, 30, 32, 0);
      sx.fill();
      const sTex = new T.CanvasTexture(sc);
      const sMat = new T.SpriteMaterial({ map: sTex, blending: T.AdditiveBlending, depthWrite: false, transparent: true });
      const R = rng(3);
      const sparks = Array.from({ length: 9 }, () => {
        const s = new T.Sprite(sMat.clone());
        s.position.set((R() - 0.5) * 4.2, -0.3 + R() * 1.9, 0.35 + R() * 0.3);
        s.userData['d'] = R();
        scene.add(s);
        return s;
      });
      const cam = new T.PerspectiveCamera(32, 1, 0.1, 50);
      cam.position.set(0, 0.9, 6.6);
      cam.lookAt(0, 0.35, 0);
      let env: { r: THREE.WebGLRenderer; tex: THREE.Texture } | null = null;
      let speed = 1;
      let tt = 0;
      return {
        scene,
        camera: cam,
        update(_t, dt) {
          tt += dt * speed;
          const P = 6.4;
          const p = fract(tt / P) * P;
          const out = ease((p - 5.6) / 0.6);
          letters.forEach((l, i) => {
            const s = 0.25 + i * 0.32;
            const k = clamp01((p - s) / 0.62);
            const land = p - s - 0.62;
            let sq = 0;
            if (land > 0) sq = Math.sin(land * 17) * Math.exp(-land * 5.5) * 0.24;
            const y = k < 1 ? 3.4 * (1 - k * k) : 0;
            l.g.visible = p >= s && out < 0.999;
            l.g.position.set(l.g.userData['x'] as number, y + out * 1.6 - 0.55, 0);
            l.g.rotation.y = (1 - easeIO(k)) * TAU + Math.sin(tt * 1.2 + i) * 0.12 * (k >= 1 ? 1 : 0) + out * Math.PI;
            const sc2 = 1 - out;
            l.g.scale.set(sc2 * (1 + sq * 0.6), sc2 * (1 - sq), sc2 * (1 + sq * 0.6));
          });
          // 반짝 빛 쓸기
          const gk = clamp01((p - 2.0) / 1.3);
          glint.intensity = gk > 0 && gk < 1 ? 26 * Math.sin(gk * Math.PI) : 0;
          glint.position.set(lerp(-3.2, 3.2, gk), 0.9, 1.4);
          for (const s of sparks) {
            const d = s.userData['d'] as number;
            const k = clamp01((p - 1.6 - d * 1.8) / 0.7);
            const a = Math.sin(k * Math.PI);
            (s.material as THREE.SpriteMaterial).opacity = a;
            s.scale.setScalar(0.05 + a * 0.38);
            (s.material as THREE.SpriteMaterial).rotation = tt * 2 + d * 6;
          }
        },
        render(r) {
          if (!env || env.r !== r) {
            env?.tex.dispose();
            const pm = new T.PMREMGenerator(r);
            const room = new RoomEnvironment();
            env = { r, tex: pm.fromScene(room, 0.04).texture };
            room.dispose();
            pm.dispose();
            scene.environment = env.tex;
            scene.environmentIntensity = 0.7;
          }
          r.render(scene, cam);
        },
        controls: [{ type: 'range', label: '빠르기', min: 0, max: 2.5, step: 0.1, value: 1, on: (v) => (speed = v) }] as Control[],
        dispose() {
          alive = false;
          env?.tex.dispose();
          for (const g of geos) g.dispose();
          for (const l of letters) for (const m of l.mats) m.dispose();
          stage.geometry.dispose();
          (stage.material as THREE.Material).dispose();
          bgTex.dispose();
          sTex.dispose();
          sMat.dispose();
          for (const s of sparks) s.material.dispose();
        },
      };
    },
  }
```

## 관련 기술
- 이 효과를 만든 기술: [3D 입체 글씨 (글꼴 윤곽 돌출 · 모서리 깎기)](https://ai-techstudio.web.app/ai/t/i17.md) `i17`
