# AI 꾸러미 — 선택 강조 맥박 테 — Selection highlight (pulsing outline)
> 고른 물체만 조금 큰 뒷면 껍데기 테와 바깥 빛무리를 숨 쉬듯 키웠다 줄여, 「지금 누를 것」이 판 위에서 바로 보이게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i226

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

## 주문서

### 만들어 줘: 선택 강조 맥박 테 — Selection highlight (pulsing outline)

#### 1. 목표
판 위 말 · 블록 (지금 누를 것) 가운데 고른 것에 숨 쉬듯 맥박치는 빛 테와 빛무리를 둘러 줘 — 고르면 부드럽게 켜지고, 바닥에 물결 고리. 느낌은 따뜻한 금빛.

#### 2. 핵심 기술 용어
- **Selection highlight (pulsing outline)** — 선택 강조 — 맥박치는 테
- **Inverted hull + normal extrusion** — 법선 쪽으로 민 뒷면 껍데기
- **Fresnel glow shell** — 바깥으로 옅어지는 빛무리 껍데기

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

#### 4. 조건
- 테 · 빛무리 껍데기는 같은 geometry · BackSide · 정점을 normal 쪽으로 밀기 (크기 배율 아님 — 모서리가 벌어지지 않게)
- 켜짐 값 uOn 은 dt 기반으로 부드럽게, 0.01 아래면 visible = false
- 빛무리는 AdditiveBlending · depthWrite false
- 바닥 물결 고리 · 고른 것 살짝 떠오름을 함께
- 「다음 것 고르기」 · 「저절로 바꾸기」 · 「테 두께」 · 「맥박 빠르기」

#### 5. 완성 기준 (이게 보이면 성공)
- 물체 다섯 개 중 하나만 금빛 테 + 빛무리가 숨 쉬듯 커졌다 작아지고, 바닥에 고리가 퍼진다
- 다음 것으로 바꾸면 앞의 테는 줄어들고 새 테가 자라난다 (뚝 끊기지 않음)
- 상자 · 공 · 고리 · 캡슐 모두 테 굵기가 고르다

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

## 원리
- 같은 모양을 뒷면만 그리면서 정점을 법선 쪽으로 uW(0.045)만큼 민다 — 물체를 두른 테가 된다.
- 두께에 맥박 0.7 + 0.6 × (0.5 + 0.5 sin(t × 4.2)) 을 곱해 숨 쉬듯 굵어졌다 가늘어진다.
- 바깥에 3.4배 더 민 두 번째 껍데기를 더하기 섞기로 그리고, 가장자리로 갈수록 옅게(1 − 프레넬) — 빛무리.
- 켜짐 값 uOn 을 0 → 1 로 부드럽게(dt × 9) 옮겨, 고를 때 테가 쑥 자라고 고르지 않으면 줄어든다.
- 바닥에는 고리 두 개가 커지며 옅어져 「여기!」를 알린다. 고른 것은 살짝 떠오르며 더 빨리 돈다.

## 핵심 코드 — 맥박치는 껍데기 테 + 빛무리
(발췌: demos/demosLook2.ts makeSelect() 를 정리)
```ts
const shared = { uTime: { value: 0 }, uW: { value: 0.045 }, uSpeed: { value: 1 } };
// scale 만큼 법선 쪽으로 밀기 (테 1.0 · 빛무리 3.4)
const hullVert = (scale: string) => [
  'uniform float uW, uTime, uSpeed, uOn; varying vec3 vN; varying vec3 vW;',
  'void main(){',
  '  float pulse = 0.5 + 0.5 * sin(uTime * 4.2 * uSpeed);',
  '  vec3 p = position + normal * uW * (' + scale + ') * (0.7 + 0.6 * pulse) * uOn;',
  '  vec4 w = modelMatrix * vec4(p, 1.0); vW = w.xyz; vN = normalize(mat3(modelMatrix) * normal);',
  '  gl_Position = projectionMatrix * viewMatrix * w;',
  '}'].join(' ');

function addHighlight(g: THREE.Group, geo: THREE.BufferGeometry) {
  const u = { ...shared, uOn: { value: 0 } };   // 켜짐은 물체마다
  const ring = new THREE.ShaderMaterial({
    uniforms: u, vertexShader: hullVert('1.0'), side: THREE.BackSide,
    fragmentShader: 'uniform float uTime, uSpeed, uOn; void main(){ float pulse = 0.5 + 0.5 * sin(uTime * 4.2 * uSpeed);' +
      ' gl_FragColor = vec4(mix(vec3(1.0, 0.85, 0.25), vec3(1.0, 1.0, 0.9), pulse) * 1.2, 1.0); }',
  });
  const glow = new THREE.ShaderMaterial({
    uniforms: u, vertexShader: hullVert('3.4'), side: THREE.BackSide,
    transparent: true, blending: THREE.AdditiveBlending, depthWrite: false,
    fragmentShader: 'uniform float uTime, uSpeed, uOn; varying vec3 vN; varying vec3 vW; void main(){' +
      ' float pulse = 0.5 + 0.5 * sin(uTime * 4.2 * uSpeed); vec3 V = normalize(cameraPosition - vW);' +
      ' float f = pow(1.0 - abs(dot(normalize(vN), V)), 1.5);' +
      ' gl_FragColor = vec4(vec3(1.0, 0.8, 0.3) * (1.0 - f) * (0.25 + 0.35 * pulse) * uOn, 1.0); }',
  });
  const hull = new THREE.Mesh(geo, ring), halo = new THREE.Mesh(geo, glow);
  g.add(halo, hull);
  return { u, hull, halo };
}

// 매 프레임: 고른 것은 1 로, 나머지는 0 으로 부드럽게
function tick(h: ReturnType<typeof addHighlight>, selected: boolean, dt: number) {
  h.u.uOn.value += ((selected ? 1 : 0) - h.u.uOn.value) * Math.min(1, dt * 9);
  h.hull.visible = h.halo.visible = h.u.uOn.value > 0.01;
}
```

## 흔한 실수 · 확인 목록
- [ ] **크기 배율로 껍데기를 키우면 상자 모서리 · 고리 안쪽에서 테가 벌어지거나 사라진다** — 정점을 법선 쪽으로 미는 방식으로. 견본은 상자 · 공 · 캡슐 · 고리 · 다면체 모두 같은 셰이더를 쓴다.
- [ ] **켜고 끌 때 뚝 나타나면 싸 보인다** — uOn 을 dt × 9 로 쫓아가게 해 쑥 자라고 줄어들게.
- [ ] **누를 곳이 옆 칸 글씨로만 안내되면 아이들이 모른다** — 이 사이트 원칙 — 누를 곳은 판 위에서 빛나게. 맥박 테가 그 방법이다.

## 완성 기준 체크리스트
- [ ] 물체 다섯 개 중 하나만 금빛 테 + 빛무리가 숨 쉬듯 커졌다 작아지고, 바닥에 고리가 퍼진다
- [ ] 다음 것으로 바꾸면 앞의 테는 줄어들고 새 테가 자라난다 (뚝 끊기지 않음)
- [ ] 상자 · 공 · 고리 · 캡슐 모두 테 굵기가 고르다

## 이 기술 정보
- id: `i226` · 분류: 그래픽 · 셰이더 › 재질 · 그림체 · 3D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 고른 물체 하나에 껍데기 두 장 더 그리기. 고르지 않은 것은 visible = false 라 비용 0.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i226
- 쓰면 좋을 때: 판 게임에서 지금 누를 수 있는 것 · 고른 것을 크게 알려야 할 때 / 아이들이 「어디 눌러?」를 묻지 않게
- 쓰지 말 때: 얇은 판 · 카드 — 껍데기가 판 두께만큼만 보인다. 대신 화면 외곽선(i03)이나 테두리 빛(i10) / 수십 개를 동시에 강조 — 맥박이 시끄럽다. 하나 · 둘만

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### makeSelect — `src/demos/demosLook2.ts:1228`
```ts
function makeSelect(): Scene3D {
  const scene = new THREE.Scene();
  scene.background = gradBg([
    [0, '#151a33'],
    [1, '#2a2148'],
  ]);
  const cam = new THREE.PerspectiveCamera(36, 1.6, 0.1, 50);
  cam.position.set(0, 2.5, 6.4);
  cam.lookAt(0, 0.55, 0);
  scene.add(new THREE.HemisphereLight(0xdfe6ff, 0x302040, 1.4));
  const dl = new THREE.DirectionalLight(0xffffff, 2.4);
  dl.position.set(3, 5, 4);
  scene.add(dl);
  const root = new THREE.Group();
  scene.add(root);
  const table = new THREE.Mesh(new THREE.CylinderGeometry(3.4, 3.5, 0.2, 64), new THREE.MeshStandardMaterial({ color: 0x3a3060, roughness: 0.7 }));
  table.position.y = -0.1;
  root.add(table);
  const geos: THREE.BufferGeometry[] = [
    new RoundedBoxGeometry(0.8, 0.8, 0.8, 5, 0.16),
    new THREE.SphereGeometry(0.46, 40, 28),
    new THREE.CapsuleGeometry(0.28, 0.5, 8, 24),
    new THREE.TorusGeometry(0.34, 0.15, 20, 48),
    new THREE.IcosahedronGeometry(0.46, 4),
  ];
  const colors = [0xff5a6e, 0xffcb3d, 0x47a8ff, 0x4fd28a, 0xb88cff];
  const outlineUni = { uTime: { value: 0 }, uW: { value: 0.045 }, uSpeed: { value: 1 } };
  const items: { g: THREE.Group; hull: THREE.Mesh; glow: THREE.Mesh; mat: THREE.ShaderMaterial; gmat: THREE.ShaderMaterial; baseY: number; on: number }[] = [];
  const hullVert = (scale: string) => /* glsl */ `
    uniform float uW, uTime, uSpeed, uOn;
    varying vec3 vN; varying vec3 vW;
    void main(){
      float pulse = 0.5 + 0.5 * sin(uTime * 4.2 * uSpeed);
      vec3 p = position + normal * uW * (${scale}) * (0.7 + 0.6 * pulse) * uOn;
      vec4 w = modelMatrix * vec4(p, 1.0); vW = w.xyz; vN = normalize(mat3(modelMatrix) * normal);
      gl_Position = projectionMatrix * viewMatrix * w;
    }`;
  geos.forEach((geo, i) => {
    const g = new THREE.Group();
    const x = (i - 2) * 1.25;
    const z = i % 2 ? -0.5 : 0.4;
    g.position.set(x, 0.5, z);
    const m = new THREE.Mesh(geo, new THREE.MeshPhysicalMaterial({ color: colors[i]!, roughness: 0.35, clearcoat: 0.6, clearcoatRoughness: 0.25 }));
    const u = { ...outlineUni, uOn: { value: 0 } };
    const mat = new THREE.ShaderMaterial({
      uniforms: u,
      vertexShader: hullVert('1.0'),
      fragmentShader: /* glsl */ `uniform float uTime, uSpeed, uOn; void main(){ float pulse = 0.5 + 0.5 * sin(uTime * 4.2 * uSpeed);
        gl_FragColor = vec4(mix(vec3(1.0, 0.85, 0.25), vec3(1.0, 1.0, 0.9), pulse) * 1.2, 1.0); }`,
      side: THREE.BackSide,
    });
    const gmat = new THREE.ShaderMaterial({
      uniforms: u,
      vertexShader: hullVert('3.4'),
      fragmentShader: /* glsl */ `uniform float uTime, uSpeed, uOn; varying vec3 vN; varying vec3 vW;
        void main(){ float pulse = 0.5 + 0.5 * sin(uTime * 4.2 * uSpeed); vec3 V = normalize(cameraPosition - vW);
          float f = pow(1.0 - abs(dot(normalize(vN), V)), 1.5);
          gl_FragColor = vec4(vec3(1.0, 0.8, 0.3) * (1.0 - f) * (0.25 + 0.35 * pulse) * uOn, 1.0); }`,
      side: THREE.BackSide,
      transparent: true,
      blending: THREE.AdditiveBlending,
      depthWrite: false,
    });
    const hull = new THREE.Mesh(geo, mat);
    const glow = new THREE.Mesh(geo, gmat);
    hull.visible = glow.visible = false;
    g.add(glow, hull, m);
    if (i === 3) g.rotation.x = -0.5;
    root.add(g);
    items.push({ g, hull, glow, mat, gmat, baseY: 0.5, on: 0 });
  });
  // 바닥 물결 고리
  const ringMat = new THREE.MeshBasicMaterial({ color: 0xffd36a, transparent: true, opacity: 0, blending: THREE.AdditiveBlending, depthWrite: false });
  const ringGeo = new THREE.RingGeometry(0.55, 0.62, 64).rotateX(-Math.PI / 2);
  const rings = [0, 1].map(() => {
    const m = new THREE.Mesh(ringGeo, ringMat.clone());
    m.position.y = 0.01;
    root.add(m);
    return m;
  });
  let sel = 0;
  let selAt = 0;
  let auto = true;
  let now = 0;
  const pick = (i: number) => {
    sel = (i + items.length) % items.length;
    selAt = now;
  };
  return {
    scene,
    camera: cam,
    update(t, dt) {
      now = t;
      outlineUni.uTime.value = t;
      if (auto && t - selAt > 2.4) pick(sel + 1);
      items.forEach((it, i) => {
        const target = i === sel ? 1 : 0;
        it.on += (target - it.on) * Math.min(1, dt * 9);
        (it.mat.uniforms.uOn as THREE.IUniform).value = it.on;
        it.hull.visible = it.glow.visible = it.on > 0.01;
        const bob = i === sel ? 0.12 + Math.sin(t * 4) * 0.04 : 0;
        it.g.position.y += (it.baseY + bob - it.g.position.y) * Math.min(1, dt * 8);
        it.g.rotation.y += dt * (i === sel ? 1.2 : 0.2);
      });
      const s = items[sel]!.g.position;
      rings.forEach((m, k) => {
        const ph = ((t - selAt) * 0.8 + k * 0.5) % 1;
        m.position.x = s.x;
        m.position.z = s.z;
        m.scale.setScalar(1 + ph * 1.2);
        (m.material as THREE.MeshBasicMaterial).opacity = (1 - ph) * 0.8;
      });
    },
    controls: [
      { type: 'button', label: '다음 것 고르기', on: () => pick(sel + 1) },
      { type: 'toggle', label: '저절로 바꾸기', value: true, on: (v) => (auto = v) },
      { type: 'range', label: '테 두께', min: 0.01, max: 0.1, step: 0.005, value: 0.045, on: (v) => (outlineUni.uW.value = v) },
      { type: 'range', label: '맥박 빠르기', min: 0, max: 2.5, step: 0.1, value: 1, on: (v) => (outlineUni.uSpeed.value = v) },
    ],
    dispose() {
      for (const it of items) {
        it.mat.dispose();
        it.gmat.dispose();
      }
      for (const m of rings) (m.material as THREE.Material).dispose();
      ringMat.dispose();
      freeAll(root);
      (scene.background as THREE.Texture).dispose();
    },
  };
}
```

### i226 견본 항목 — `src/demos/demosLook2.ts:4510`
```ts
  i226: { kind: '3d', caption: '고른 물체만 뒷면 껍데기 테 + 빛무리가 숨 쉬듯 — 바닥 물결 고리, 2.4초마다 다음 것', make: () => makeSelect() }
```

## 관련 기술
- 먼저 알면 좋은 기술: [외곽선 (뒤집은 껍데기)](https://ai-techstudio.web.app/ai/t/u02.md) `u02` · [테두리 빛 (rim light, 프레넬)](https://ai-techstudio.web.app/ai/t/i10.md) `i10`
- 다음에 해 볼 기술: [후처리 외곽선 (깊이 · 법선 경계)](https://ai-techstudio.web.app/ai/t/i03.md) `i03`
