# AI 꾸러미 — 휘는 지평선 (굴러가는 세계) — Curved world shader (vertex bend)
> 정점 셰이더에서 카메라와의 수평 거리² 만큼 높이를 내려, 평평한 맵이 둥근 작은 행성처럼 굴러가 보이게 한다 — 맵 · 충돌은 평평한 그대로.  
> 견본: https://ai-techstudio.web.app/#t/i537

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

## 주문서

### 만들어 줘: 휘는 지평선 (굴러가는 세계) — Curved world shader (vertex bend)

#### 1. 목표
끝없이 걷는 마을 길에 휘는 지평선(정점 셰이더 휨)을 넣어 줘 — 멀수록 아래로 내려가 아기자기한 동화 마을 작은 행성처럼 보이게.

#### 2. 핵심 기술 용어
- **Curved world shader (vertex bend)** — 정점을 거리² 만큼 내려 지평선을 휘게
- **onBeforeCompile (project_vertex 바꾸기)** — 표준 재질에 휘기 코드 끼워 넣기
- **customProgramCacheKey** — 고친 재질이 같은 셰이더 하나를 돌려 쓰게 하는 열쇠

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

#### 4. 조건
- 모든 재질에 같은 onBeforeCompile 을 — 땅 · 집 · 나무 · 그림자 판 · 캐릭터까지
- #include <project_vertex> 를 바꿔 넣는다 (인스턴스면 instanceMatrix 를 먼저 곱한다)
- customProgramCacheKey 를 같은 문자열로 (견본 curved-world) — 셰이더 하나를 돌려 쓰게
- 바닥은 촘촘한 격자 (견본 풀밭 60 × 4 칸) — 판 하나면 양 끝만 내려가 집이 뜬다
- 휘는 물체는 frustumCulled = false, 카메라 자리 uCam 은 매 프레임 갱신

#### 5. 완성 기준 (이게 보이면 성공)
- 길 앞쪽은 평평하고 멀리 갈수록 아래로 굽어 작은 행성 위를 걷는 것처럼 보인다
- 휨 켬 ↔ 끔을 번갈아 보면 같은 맵이 평평했다 둥글었다 한다 (집 · 나무가 땅에 붙은 채로)
- 「휨 세기」 · 「옆으로도 휨」 조절이 바로 반영된다
- 걸어가도 물체가 공중에 뜨거나 땅에 묻히지 않는다

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

## 원리
- 정점의 세계 위치에서 카메라의 수평 자리(xz)를 뺀 거리 d 를 구한다.
- 높이를 y -= k × (d.z² + 옆 비율 × d.x²) 만큼 내린다 (견본 k 0.012 · 옆 0.35). 멀수록 크게 내려가 둥근 지평선이 된다.
- 휘기는 그리기 직전 정점에서만 일어난다. 물체 위치 · 충돌 · 길찾기는 평평한 맵 그대로라 게임 코드는 손대지 않는다.
- 모든 재질에 같은 휘기를 끼워야 한다 — 땅만 휘면 집 · 나무가 공중에 뜬다.

## 핵심 코드 — 모든 재질에 끼우는 휘기 (project_vertex 바꾸기)
(발췌: demos/demosPlanet.ts makeCurved() bend() 를 정리)
```ts
import * as THREE from 'three';

const U = { uBend: { value: 0.012 }, uSide: { value: 0.35 }, uCam: { value: new THREE.Vector2() } };

function bend<M extends THREE.Material>(m: M): M {
  m.onBeforeCompile = (sh) => {
    Object.assign(sh.uniforms, U);
    sh.vertexShader = 'uniform float uBend; uniform float uSide; uniform vec2 uCam;' + sh.vertexShader.replace(
      '#include <project_vertex>',
      [
        'vec4 mvPosition = vec4( transformed, 1.0 );',
        '#ifdef USE_INSTANCING',
        '  mvPosition = instanceMatrix * mvPosition;',
        '#endif',
        'vec4 wpB = modelMatrix * mvPosition;',
        'vec2 dB = wpB.xz - uCam;',
        'wpB.y -= uBend * (dB.y * dB.y + uSide * dB.x * dB.x);', // 멀수록 아래로
        'mvPosition = viewMatrix * wpB;',
        'gl_Position = projectionMatrix * mvPosition;',
      ].join('\n'),
    );
  };
  m.customProgramCacheKey = () => 'curved-world'; // 같은 셰이더 하나를 돌려 쓰게
  return m;
}

// 땅은 촘촘한 격자여야 물체와 같이 휜다
const grass = new THREE.Mesh(new THREE.PlaneGeometry(60, 4, 60, 4).rotateX(-Math.PI / 2), bend(new THREE.MeshStandardMaterial({ color: 0x9ad86e })));
grass.frustumCulled = false; // 화면 밖 판정이 휘기 전 자리로 되므로

function update(): void {
  U.uCam.value.set(camera.position.x, camera.position.z);
}
```

## 흔한 실수 · 확인 목록
- [ ] **바닥을 판 하나로 두면 양 끝만 내려가 집 · 나무가 뜬다** — 휨은 정점에서만 일어난다. 바닥을 촘촘한 격자로 쪼갠다 (견본 주석에 적힌 실수).
- [ ] **한 재질만 휘면 그 물체만 따로 논다** — 그림자 판 · 캐릭터 재질까지 모두 bend() 로 감싼다 (캐릭터는 clone 해서).
- [ ] **화면 밖 판정 때문에 가장자리 물체가 깜빡 사라진다** — 휜 자리와 판정 자리가 달라서다. 휘는 물체는 frustumCulled = false.

## 완성 기준 체크리스트
- [ ] 길 앞쪽은 평평하고 멀리 갈수록 아래로 굽어 작은 행성 위를 걷는 것처럼 보인다
- [ ] 휨 켬 ↔ 끔을 번갈아 보면 같은 맵이 평평했다 둥글었다 한다 (집 · 나무가 땅에 붙은 채로)
- [ ] 「휨 세기」 · 「옆으로도 휨」 조절이 바로 반영된다
- [ ] 걸어가도 물체가 공중에 뜨거나 땅에 묻히지 않는다

## 이 기술 정보
- id: `i537` · 분류: 그래픽 · 셰이더 › 셰이더 · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 정점마다 곱셈 몇 번. 대신 화면 밖 판정이 휘기 전 자리로 되므로 frustumCulled 를 끈다 — 물체가 많으면 그리기 수가 는다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i537
- 쓰면 좋을 때: 끝없이 이어지는 길 · 마을을 걷는 장면 / 멀리 있는 것을 자연스럽게 숨기고 싶을 때 (지평선 너머로) / 쿼터뷰 게임에 아기자기한 느낌을 줄 때
- 쓰지 말 때: 정확한 거리 · 높이를 보여 줘야 하는 체험 (자 · 각도) / 바닥 판이 큰 판 하나뿐일 때 — 정점이 적으면 안 휜다 (격자로 쪼갤 것)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### makeCurved — `src/demos/demosPlanet.ts:821`
```ts
function makeCurved(): Scene3D {
  const scene = new THREE.Scene();
  const bg = pastelSky('#7cc8ff', '#c6ebff', '#ffe8cf');
  scene.background = bg;
  scene.fog = new THREE.Fog(0xd8eefc, 30, 75);
  const cam = new THREE.PerspectiveCamera(50, 1.6, 0.1, 200);
  scene.add(new THREE.HemisphereLight(0xeaf6ff, 0xa08c74, 1.3));
  const sunL = new THREE.DirectionalLight(0xfff0d8, 2.1);
  sunL.position.set(-6, 10, 4);
  scene.add(sunL);
  const U = { uBend: { value: 0.012 }, uSide: { value: 0.35 }, uCam: { value: new THREE.Vector2() } };
  // 모든 재질에 같은 휨 — 카메라와의 수평 거리² 만큼 아래로
  const bend = (m: THREE.Material): THREE.Material => {
    m.onBeforeCompile = (sh) => {
      Object.assign(sh.uniforms, U);
      sh.vertexShader =
        'uniform float uBend; uniform float uSide; uniform vec2 uCam;\n' +
        sh.vertexShader.replace(
          '#include <project_vertex>',
          `vec4 mvPosition = vec4( transformed, 1.0 );
          #ifdef USE_INSTANCING
            mvPosition = instanceMatrix * mvPosition;
          #endif
          vec4 wpB = modelMatrix * mvPosition;
          vec2 dB = wpB.xz - uCam;
          wpB.y -= uBend * (dB.y * dB.y + uSide * dB.x * dB.x);
          mvPosition = viewMatrix * wpB;
          gl_Position = projectionMatrix * mvPosition;`,
        );
    };
    m.customProgramCacheKey = () => 'curved-world';
    return m;
  };
  const mats: THREE.Material[] = [];
  const M = (c: number, rough = 0.85): THREE.Material => {
    const m = bend(new THREE.MeshStandardMaterial({ color: c, roughness: rough }));
    mats.push(m);
    return m;
  };
  const vc = bend(new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.85 }));
  mats.push(vc);
  const geos: THREE.BufferGeometry[] = [];
  // 한 줄(길이 4) 조각: 풀밭 · 길 · 인도 · 집 · 나무 · 가로등 · 울타리 — 줄마다 다른 모양
  const ROW = 4;
  const NROW = 28;
  const grassM = M(0x9ad86e);
  const roadM = M(0xf3e3c3, 0.95);
  const walkM = M(0xd9c6b0);
  const lineM = M(0xffffff, 0.7);
  // 휨은 정점에서만 일어난다 — 바닥은 촘촘한 격자여야 땅이 물체와 같이 휜다 (판 하나면 양 끝만 내려가 집 · 나무가 뜬다)
  const grassG = new THREE.PlaneGeometry(60, ROW, 60, 4).rotateX(-Math.PI / 2);
  const roadG = new THREE.PlaneGeometry(5, ROW, 6, 4).rotateX(-Math.PI / 2).translate(0, 0.03, 0);
  const walkG = new THREE.BoxGeometry(1.3, 0.12, ROW, 2, 1, 4);
  const lineG = new THREE.BoxGeometry(0.12, 0.06, 1.6).translate(0, 0.04, 0);
  geos.push(grassG, roadG, walkG, lineG);
  const ROOF = [0xff7b6b, 0x6aa8ff, 0xffb84d, 0x8ad07a, 0xc08bff, 0xff8fb8];
  const WALL = [0xfff3e2, 0xffe2c4, 0xe9f4ff, 0xfdf0f6];
  const house = (wc: number, rc: number, w: number, d: number, hgt: number): THREE.BufferGeometry => {
    const parts: [THREE.BufferGeometry, number][] = [
      [new THREE.BoxGeometry(w, hgt, d).translate(0, hgt / 2, 0), wc],
      [new THREE.ConeGeometry(Math.max(w, d) * 0.78, hgt * 0.75, 4).rotateY(Math.PI / 4).translate(0, hgt + hgt * 0.37, 0), rc],
      [new THREE.BoxGeometry(0.34, 0.6, 0.06).translate(0, 0.3, d / 2 + 0.01), 0x9b6b48],
    ];
    for (const s of [-1, 1]) parts.push([new THREE.BoxGeometry(0.3, 0.3, 0.06).translate(s * w * 0.27, hgt * 0.62, d / 2 + 0.01), 0x8fd0ff]);
    parts.push([new THREE.BoxGeometry(0.18, 0.5, 0.18).translate(w * 0.25, hgt * 1.3, -d * 0.1), 0xb07a5a]);
    return mergeColored(parts);
  };
  const tree = (): THREE.BufferGeometry =>
    mergeColored([
      [new THREE.CylinderGeometry(0.08, 0.11, 0.7, 7).translate(0, 0.35, 0), 0x9a6a45],
      [new THREE.IcosahedronGeometry(0.55, 2).translate(0, 1.05, 0), 0x6cc35a],
      [new THREE.IcosahedronGeometry(0.38, 2).translate(0.28, 0.85, 0.1), 0x5fb35a],
    ]);
  const lamp = mergeColored([
    [new THREE.CylinderGeometry(0.04, 0.05, 1.6, 6).translate(0, 0.8, 0), 0x55607a],
    [new THREE.SphereGeometry(0.14, 12, 8).translate(0, 1.66, 0), 0xfff2b0],
  ]);
  const fence = mergeColored([
    ...[-1.5, -0.5, 0.5, 1.5].map((z): [THREE.BufferGeometry, number] => [new THREE.BoxGeometry(0.08, 0.5, 0.08).translate(0, 0.25, z), 0xffffff]),
    [new THREE.BoxGeometry(0.05, 0.07, ROW).translate(0, 0.38, 0), 0xffffff],
    [new THREE.BoxGeometry(0.05, 0.07, ROW).translate(0, 0.18, 0), 0xffffff],
  ]);
  const shadowTex = (() => {
    const c = document.createElement('canvas');
    c.width = c.height = 64;
    const g = c.getContext('2d')!;
    const gr = g.createRadialGradient(32, 32, 2, 32, 32, 31);
    gr.addColorStop(0, 'rgba(40,50,30,0.42)');
    gr.addColorStop(1, 'rgba(40,50,30,0)');
    g.fillStyle = gr;
    g.fillRect(0, 0, 64, 64);
    return new THREE.CanvasTexture(c);
  })();
  const blobM = bend(new THREE.MeshBasicMaterial({ map: shadowTex, transparent: true, depthWrite: false }));
  mats.push(blobM);
  const blobG = new THREE.PlaneGeometry(1, 1).rotateX(-Math.PI / 2);
  geos.push(lamp, fence, blobG);
  const r0 = rng(12);
  const rows: THREE.Group[] = [];
  for (let i = 0; i < NROW; i++) {
    const row = new THREE.Group();
    row.add(new THREE.Mesh(grassG, grassM), new THREE.Mesh(roadG, roadM));
    for (const s of [-1, 1]) {
      const wk = new THREE.Mesh(walkG, walkM);
      wk.position.set(s * 3.15, 0.06, 0);
      row.add(wk);
    }
    if (i % 2 === 0) row.add(new THREE.Mesh(lineG, lineM));
    for (const s of [-1, 1]) {
      const pick = r0();
      const x0 = s * (5.2 + r0() * 1.2);
      const blob = (x: number, z: number, size: number): void => {
        const b = new THREE.Mesh(blobG, blobM);
        b.position.set(x, 0.02, z);
        b.scale.setScalar(size);
        row.add(b);
      };
      if (pick < 0.55) {
        const w = 1.6 + r0() * 0.8;
        const g = house(WALL[Math.floor(r0() * WALL.length)]!, ROOF[Math.floor(r0() * ROOF.length)]!, w, 1.6 + r0() * 0.5, 1.1 + r0() * 0.7);
        geos.push(g);
        const hm = new THREE.Mesh(g, vc);
        hm.position.set(x0 + s * 0.6, 0, 0);
        hm.rotation.y = s > 0 ? -Math.PI / 2 : Math.PI / 2;
        row.add(hm);
        blob(x0 + s * 0.6, 0, w * 1.9);
      } else {
        for (let k = 0; k < 2; k++) {
          const g = tree();
          geos.push(g);
          const tm = new THREE.Mesh(g, vc);
          const x = x0 + s * r0() * 2;
          const z = (r0() - 0.5) * 3;
          tm.position.set(x, 0, z);
          tm.scale.setScalar(0.8 + r0() * 0.6);
          row.add(tm);
          blob(x, z, 1.6);
        }
      }
      if (i % 3 === 0) {
        const lm = new THREE.Mesh(lamp, vc);
        lm.position.set(s * 2.7, 0, 0.5);
        row.add(lm);
      }
      const fm = new THREE.Mesh(fence, vc);
      fm.position.set(s * 4.1, 0, 0);
      if (r0() < 0.6) row.add(fm);
      // 먼 쪽 덤불 · 나무 줄
      for (let k = 0; k < 2; k++) {
        const g = tree();
        geos.push(g);
        const tm = new THREE.Mesh(g, vc);
        tm.position.set(s * (10 + r0() * 14), 0, (r0() - 0.5) * 3);
        tm.scale.setScalar(1 + r0() * 0.9);
        row.add(tm);
      }
    }
    row.traverse((o) => (o.frustumCulled = false));
    row.position.z = -i * ROW;
    rows.push(row);
    scene.add(row);
  }
  // 앞서 걷는 꼬마
  const buddy = makeBuddy();
  buddy.root.traverse((o) => {
    const m = o as THREE.Mesh;
    if (m.isMesh) {
      m.material = bend((m.material as THREE.Material).clone());
      mats.push(m.material as THREE.Material);
      m.frustumCulled = false;
    }
  });
  buddy.root.scale.setScalar(1.3);
  buddy.root.rotation.y = Math.PI;
  scene.add(buddy.root);
  const bb = new THREE.Mesh(blobG, blobM);
  bb.scale.setScalar(1.1);
  bb.frustumCulled = false;
  scene.add(bb);
  let k = 0.012;
  let side = true;
  let walk = true;
  let z = 0;
  let isBig = false;
  const overlay = new Overlay();
  return {
    scene,
    camera: cam,
    update(t, dt) {
      if (walk) z -= dt * 4.2;
      // 카드: 4초마다 휨 켬 ↔ 끔
      const on = isBig ? 1 : Math.sin(t * 0.8) > -0.2 ? 1 : 0;
      U.uBend.value = lerp(U.uBend.value, k * on, 1 - Math.exp(-dt * 5));
      U.uSide.value = side ? 0.35 : 0;
      cam.position.set(0, 7.5, z + 9);
      cam.lookAt(0, -3.2, z - 5);
      U.uCam.value.set(cam.position.x, cam.position.z);
      for (const r of rows) if (r.position.z > z + 12) r.position.z -= NROW * ROW;
      const ph = t * 9;
      buddy.root.position.set(Math.sin(t * 0.6) * 0.6, Math.abs(Math.sin(ph)) * 0.12, z - 1.5);
      buddy.feet.forEach((f, i) => {
        f.position.z = 0.03 + Math.sin(ph * 0.5 + i * Math.PI) * 0.12;
      });
      bb.position.set(buddy.root.position.x, 0.03, z - 1.5);
    },
    render(r, w, h) {
      isBig = w >= 700;
      r.render(scene, cam);
      const b = U.uBend.value;
      overlay.draw(r, w, h, [
        { text: b > 0.001 ? `휨 켬 — 높이 −= ${k.toFixed(3)} × 거리²` : '휨 끔 — 평평한 길', x: 0.03, y: 0.05, ax: 0, ay: 0, bg: b > 0.001 ? 'rgba(40,120,90,0.72)' : 'rgba(60,70,90,0.7)' },
        { text: '맵은 평평한 직선 길 — 셰이더가 보여 줄 때만 휜다', x: 0.5, y: 0.95, ax: 0.5, ay: 1 },
      ]);
    },
    controls: [
      { type: 'range', label: '휨 세기 (0 = 끔)', min: 0, max: 0.03, step: 0.001, value: 0.012, on: (v) => (k = v) },
      { type: 'toggle', label: '옆으로도 휘기 (작은 공 행성처럼)', value: true, on: (v) => (side = v) },
      { type: 'toggle', label: '앞으로 걷기', value: true, on: (v) => (walk = v) },
    ],
    dispose() {
      overlay.dispose();
      buddy.dispose();
      for (const g of geos) g.dispose();
      for (const m of mats) m.dispose();
      shadowTex.dispose();
      bg.dispose();
    },
  };
}
```

### i537 견본 항목 — `src/demos/demosPlanet.ts:1631`
```ts
  i537: { kind: '3d', caption: '맵은 평평한 직선 길 — 정점 셰이더가 카메라에서 먼 만큼(거리²) 아래로 내려 둥근 작은 행성처럼 보이게', make: () => makeCurved() }
```

## 관련 기술
- 먼저 알면 좋은 기술: [재질에 셰이더 끼워 넣기](https://ai-techstudio.web.app/ai/t/u08.md) `u08` · [정점 흔들기 (바람 · 물결)](https://ai-techstudio.web.app/ai/t/i13.md) `i13`
- 다음에 해 볼 기술: [접촉 그림자 (Contact shadow)](https://ai-techstudio.web.app/ai/t/i15.md) `i15`
