# AI 꾸러미 — 등고선 3D 지형 (높이 띠) — Contour lines in fragment shader (fract + fwidth)
> 지형 높이를 셰이더에서 간격으로 나눠 높이별 색 띠와 늘 같은 굵기의 등고선을 그리고, 오르내리는 물 높이와 만나는 선을 빛나게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i279

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

## 주문서

### 만들어 줘: 등고선 3D 지형 (높이 띠) — Contour lines in fragment shader (fract + fwidth)

#### 1. 목표
산 · 언덕 지형에 등고선 3D 지형을 만들어 줘 — 높이별 색 띠와 늘 같은 굵기의 등고선(다섯 줄마다 굵게), 옆면 지층 단면, 오르내리는 물 높이와 만나는 빛나는 선이 보이고, 위에서 보면 등고선 지도가 되게. 분위기는 어두운 바탕 지형 모형.

#### 2. 핵심 기술 용어
- **Contour lines in fragment shader (fract + fwidth)** — 높이 ÷ 간격의 소수 부분으로 등고선
- **Hypsometric tinting (elevation bands)** — 높이별 색 띠
- **Index contour** — 다섯 줄마다 굵은 계곡선
- **three.js ShaderMaterial** — 직접 짠 셰이더 재질

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

#### 4. 조건
- 등고선 굵기는 fwidth 로 나눠 화면 픽셀 기준으로 (월드 단위 굵기면 멀리서 사라지고 가까이서 뭉툭)
- 잡음은 CPU 에서 정점 높이로 한 번만 — 셰이더 안에 잡음 반복문 넣지 않기
- 색 띠 · 높이 과장(uExag) · 간격은 uniform 으로 — 바꿔도 셰이더를 다시 컴파일하지 않게
- 옆면(지층 단면)은 같은 셰이더에 uSide 로 등고선을 끄고 줄무늬만
- 물 높이 판은 반투명 · depthWrite: false

#### 5. 완성 기준 (이게 보이면 성공)
- 산에 높이별 색 띠와 가는 등고선, 다섯 줄마다 굵은 선이 보인다
- 카메라가 다가가거나 멀어져도 등고선 굵기가 같다
- 물 높이가 오르내리면 물과 만나는 노란 선이 등고선을 따라 움직인다
- 「위에서 보기」를 켜면 그대로 등고선 지도가 된다

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

## 원리
- 150×150 조각 평면의 정점 높이를 언덕 두 개(가우스) + 잡음으로 정하고, 옆면 네 변을 바닥까지 막아 단면을 만든다.
- 조각 셰이더에서 lv = 높이 ÷ 간격. 색 띠는 floor(lv) 단계의 가운데 높이로 색 사다리에서 고른다 (끄면 높이 그대로 부드럽게).
- 등고선: lv 의 정수에서 얼마나 떨어졌나를 fwidth(lv) 로 나누면 화면 픽셀 단위 거리 — 그래서 가까이서도 멀리서도 같은 굵기.
- 정수 lv 가 5 의 배수면 굵은 계곡선(1.6px), 나머지는 0.9px.
- 물 높이 uSlice 와의 차이 ÷ fwidth(높이) 가 작은 곳을 노랗게 빛내면, 물이 오르내릴 때 「등고선 한 줄 = 같은 물 높이」가 보인다.

## 핵심 코드 — 조각 셰이더 — 높이 띠 + 같은 굵기 등고선 + 물 높이 선
(발췌: demos/demosMapB.ts i279 fs 를 정리 (ramp 는 같은 셰이더의 6색 사다리))
```glsl
uniform float uInterval;   // 등고선 간격 (0.25)
uniform float uBands;      // 1 = 색 띠
uniform float uSlice;      // 물 높이
uniform float uMaxH;
uniform vec3 uLight;
uniform float uSide;       // 1 = 옆면 단면
varying float vH;          // 정점 셰이더가 넘긴 원래 높이
varying vec3 vN;
void main() {
  float lv = vH / uInterval;
  float band = floor(lv);
  float k = uBands > 0.5 ? (band + 0.5) * uInterval / uMaxH : vH / uMaxH;
  vec3 col = ramp(k);
  if (uSide > 0.5) col *= 0.75 + 0.1 * step(0.5, fract(lv * 0.5)); // 옆면 지층 줄무늬
  col *= 0.45 + 0.75 * max(dot(normalize(vN), uLight), 0.0);
  // 등고선: 정수까지 거리 ÷ fwidth = 화면 픽셀 거리 → 늘 같은 굵기
  float f = abs(fract(lv - 0.5) - 0.5) / max(fwidth(lv), 1e-4);
  bool idx = mod(floor(lv + 0.5), 5.0) < 0.5;       // 다섯 줄마다 굵게
  float wdt = idx ? 1.6 : 0.9;
  float line = 1.0 - smoothstep(wdt - 0.6, wdt + 0.6, f);
  if (uSide < 0.5 && vH > 0.02) col = mix(col, idx ? vec3(0.25, 0.14, 0.07) : vec3(0.36, 0.22, 0.12), line * (idx ? 0.95 : 0.7));
  // 물 높이와 만나는 빛나는 선
  float sd = abs(vH - uSlice) / max(fwidth(vH), 1e-4);
  col = mix(col, vec3(1.0, 0.95, 0.55), 1.0 - smoothstep(0.5, 2.5, sd));
  gl_FragColor = vec4(col, 1.0);
}
```

## 흔한 실수 · 확인 목록
- [ ] **등고선 굵기를 월드 단위로 정하면 멀리서는 사라지고 가까이서는 뭉툭하다** — fwidth(lv) 로 나눠 화면 픽셀 기준 굵기로 그린다.
- [ ] **셰이더 안에서 잡음을 반복해 부르면 윈도에서 컴파일이 수십 초 멈춘다** — 높이는 CPU 에서 정점에 한 번 구워 넣고, 셰이더는 그 높이만 읽는다.
- [ ] **높이 과장을 정점 y 에만 곱하면 빛(법선)이 어긋난다** — 견본처럼 법선 x · z 에도 과장 값을 곱해 다시 normalize 한다.
- [ ] **평평한 바닥(높이 0 근처)에 등고선이 지저분하게 깔린다** — 높이 0.02 이하는 등고선을 그리지 않는다.

## 완성 기준 체크리스트
- [ ] 산에 높이별 색 띠와 가는 등고선, 다섯 줄마다 굵은 선이 보인다
- [ ] 카메라가 다가가거나 멀어져도 등고선 굵기가 같다
- [ ] 물 높이가 오르내리면 물과 만나는 노란 선이 등고선을 따라 움직인다
- [ ] 「위에서 보기」를 켜면 그대로 등고선 지도가 된다

## 이 기술 정보
- id: `i279` · 분류: 지도 · 길찾기 › 지도 보기 · 표현 · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 정점 약 2만 3천 개 평면 하나 + 조각 셰이더의 나눗셈 · fract · fwidth 몇 번뿐. 반복문 · 잡음이 셰이더에 없어 컴파일도 빠르다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i279
- 쓰면 좋을 때: 등고선 지도 읽기를 입체로 보여 줄 때 / 지형 높이를 색 띠로 읽게 할 때 / 높이 과장 · 위에서 보기를 비교할 때
- 쓰지 말 때: 2D 지도의 등고선만 필요할 때 — 대신 마칭 스퀘어(i259) / 귀여운 블록 섬 — 대신 디오라마 지도(i275)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i279 견본 항목 — `src/demos/demosMapB.ts:5363`
```ts
  i279: {
    kind: '3d',
    caption: '같은 높이를 이은 선(등고선)과 높이별 색 띠 — 산을 위에서 내려다보면 그대로 등고선 지도가 돼요 (물 높이 = 등고선 한 줄)',
    make(T) {
      const scene = new T.Scene();
      scene.background = gradTex(T, '#101a30', '#1d2c4a');
      const cam = new T.PerspectiveCamera(36, 1.6, 0.1, 200);
      const SEG = 150;
      const SZ = 10;
      const MAXH = 3.2;
      const hAt = (x: number, z: number): number => {
        const p1 = Math.exp(-((x - 1.4) ** 2 + (z + 0.8) ** 2) / 5.5) * 2.6;
        const p2 = Math.exp(-((x + 2.2) ** 2 + (z - 1.6) ** 2) / 3.2) * 1.8;
        const n = fbm(x * 0.32 + 10, z * 0.32 + 10, 5, 4) - 0.45;
        return clamp(p1 + p2 + n * 1.1 + 0.35, 0, MAXH);
      };
      const geo = new T.PlaneGeometry(SZ, SZ, SEG, SEG);
      geo.rotateX(-Math.PI / 2);
      const pos = geo.attributes.position!;
      for (let i = 0; i < pos.count; i++) pos.setY(i, hAt(pos.getX(i), pos.getZ(i)));
      geo.computeVertexNormals();
      // 옆면 (지층 단면)
      const skirtPos: number[] = [];
      const BASE = -0.5;
      const edge = (x0: number, z0: number, x1: number, z1: number): void => {
        for (let k = 0; k < SEG; k++) {
          const ax = lerp(x0, x1, k / SEG);
          const az = lerp(z0, z1, k / SEG);
          const bx = lerp(x0, x1, (k + 1) / SEG);
          const bz = lerp(z0, z1, (k + 1) / SEG);
          const ha = hAt(ax, az);
          const hb = hAt(bx, bz);
          skirtPos.push(ax, ha, az, ax, BASE, az, bx, hb, bz, bx, hb, bz, ax, BASE, az, bx, BASE, bz);
        }
      };
      const H = SZ / 2;
      edge(-H, H, H, H);
      edge(H, H, H, -H);
      edge(H, -H, -H, -H);
      edge(-H, -H, -H, H);
      const skirt = new T.BufferGeometry();
      skirt.setAttribute('position', new T.Float32BufferAttribute(skirtPos, 3));
      skirt.computeVertexNormals();
      const uni = {
        uInterval: { value: 0.25 },
        uBands: { value: 1 },
        uSlice: { value: 1.0 },
        uExag: { value: 1.0 },
        uMaxH: { value: MAXH },
        uLight: { value: new T.Vector3(0.5, 0.8, 0.3).normalize() },
        uSide: { value: 0 },
      };
      const vs = `
        uniform float uExag; varying float vH; varying vec3 vN; varying vec3 vW;
        void main(){
          vH = position.y;
          vec3 p = position; p.y *= uExag;
          vN = normalize(vec3(normal.x * uExag, normal.y, normal.z * uExag));
          vec4 w = modelMatrix * vec4(p, 1.0); vW = w.xyz;
          gl_Position = projectionMatrix * viewMatrix * w;
        }`;
      const fs = `
        uniform float uInterval; uniform float uBands; uniform float uSlice; uniform float uMaxH; uniform vec3 uLight; uniform float uSide;
        varying float vH; varying vec3 vN; varying vec3 vW;
        vec3 ramp(float k){
          vec3 c0 = vec3(0.16,0.45,0.30); vec3 c1 = vec3(0.47,0.70,0.33); vec3 c2 = vec3(0.86,0.80,0.45);
          vec3 c3 = vec3(0.78,0.52,0.30); vec3 c4 = vec3(0.55,0.42,0.36); vec3 c5 = vec3(0.97,0.97,0.98);
          k = clamp(k, 0.0, 1.0) * 5.0;
          if (k < 1.0) return mix(c0, c1, k);
          if (k < 2.0) return mix(c1, c2, k - 1.0);
          if (k < 3.0) return mix(c2, c3, k - 2.0);
          if (k < 4.0) return mix(c3, c4, k - 3.0);
          return mix(c4, c5, k - 4.0);
        }
        void main(){
          float lv = vH / uInterval;
          float band = floor(lv);
          float k = uBands > 0.5 ? (band + 0.5) * uInterval / uMaxH : vH / uMaxH;
          vec3 col = ramp(k);
          if (uSide > 0.5) col *= 0.75 + 0.1 * step(0.5, fract(lv * 0.5));
          float ndl = max(dot(normalize(vN), uLight), 0.0);
          col *= 0.45 + 0.75 * ndl;
          // 등고선 (화면에서 늘 같은 굵기)
          float fw = fwidth(lv);
          float f = abs(fract(lv - 0.5) - 0.5) / max(fw, 1e-4);
          bool idx = mod(floor(lv + 0.5), 5.0) < 0.5;
          float wdt = idx ? 1.6 : 0.9;
          float line = 1.0 - smoothstep(wdt - 0.6, wdt + 0.6, f);
          if (uSide < 0.5 && vH > 0.02) col = mix(col, idx ? vec3(0.25,0.14,0.07) : vec3(0.36,0.22,0.12), line * (idx ? 0.95 : 0.7));
          // 물 높이와 만나는 선 — 빛나는 등고선 한 줄
          float sd = abs(vH - uSlice) / max(fwidth(vH), 1e-4);
          float glow = 1.0 - smoothstep(0.5, 2.5, sd);
          col = mix(col, vec3(1.0, 0.95, 0.55), glow);
          gl_FragColor = vec4(col, 1.0);
        }`;
      const mat = new T.ShaderMaterial({ uniforms: uni, vertexShader: vs, fragmentShader: fs });
      const sideUni = { ...uni, uSide: { value: 1 } };
      const sideMat = new T.ShaderMaterial({ uniforms: sideUni, vertexShader: vs, fragmentShader: fs, side: T.DoubleSide });
      const terrain = new T.Mesh(geo, mat);
      const sides = new T.Mesh(skirt, sideMat);
      const baseM = new T.Mesh(new T.BoxGeometry(SZ + 0.02, 0.2, SZ + 0.02), new T.MeshBasicMaterial({ color: 0x2a2018 }));
      baseM.position.y = BASE - 0.1;
      const grp = new T.Group();
      grp.add(terrain, sides, baseM);
      scene.add(grp);
      // 물 높이 판
      const water = new T.Mesh(new T.BoxGeometry(SZ, 1, SZ), new T.MeshBasicMaterial({ color: 0x3aa8ff, transparent: true, opacity: 0.32, depthWrite: false }));
      grp.add(water);
      let interval = 0.25;
      let bands = true;
      let topView = false;
      let autoTilt = true;
      let exag = 1;
      let moveWater = true;
      let ang = 0.5;
      let tilt = 0.6;
      return {
        scene,
        camera: cam,
        update(t, dt) {
          uni.uInterval.value = interval;
          uni.uBands.value = bands ? 1 : 0;
          uni.uExag.value = exag;
          const slice = moveWater ? 0.15 + (0.5 - 0.5 * Math.cos(t * 0.7)) * (MAXH * 0.75) : 0.6;
          uni.uSlice.value = slice;
          const wh = slice * exag - BASE;
          water.scale.y = Math.max(0.001, wh);
          water.position.y = BASE + wh / 2;
          ang += dt * 0.15;
          const want = topView ? 1.5 : autoTilt ? lerp(0.55, 1.5, smooth(0.55, 0.95, 0.5 + 0.5 * Math.sin(t * 0.5))) : 0.6;
          tilt = lerp(tilt, want, 0.05);
          const R = 15.5;
          const a = ang;
          cam.position.set(Math.cos(a) * Math.cos(tilt) * R, Math.sin(tilt) * R + 0.5, Math.sin(a) * Math.cos(tilt) * R);
          cam.up.set(0, 1, 0);
          if (tilt > 1.45) cam.up.set(-Math.cos(a), 0, -Math.sin(a));
          cam.lookAt(0, 0.6, 0);
        },
        controls: [
          { type: 'range', label: '등고선 간격', min: 0.1, max: 0.6, step: 0.05, value: 0.25, on: (x) => (interval = x) },
          { type: 'toggle', label: '높이별 색 띠 (끄면 부드럽게)', value: true, on: (x) => (bands = x) },
          { type: 'toggle', label: '위에서 보기', value: false, on: (x) => (topView = x) },
          { type: 'toggle', label: '자동으로 눕혔다 세우기', value: true, on: (x) => (autoTilt = x) },
          { type: 'range', label: '높이 과장', min: 0.4, max: 2.2, step: 0.1, value: 1, on: (x) => (exag = x) },
          { type: 'toggle', label: '물 높이 오르내리기', value: true, on: (x) => (moveWater = x) },
        ] as Control[],
        dispose() {
          disposeAll(scene);
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [마칭 스퀘어 (등고선 · 경계)](https://ai-techstudio.web.app/ai/t/i259.md) `i259` · [디오라마 지도 (3D 섬 판)](https://ai-techstudio.web.app/ai/t/i275.md) `i275`
- 다음에 해 볼 기술: [구면 → 평면 지도 투영](https://ai-techstudio.web.app/ai/t/i261.md) `i261`
- 참고 문서: [Wikipedia — Contour line](https://en.wikipedia.org/wiki/Contour_line)
