# AI 꾸러미 — SDF 레이마칭 — SDF raymarching (sphere tracing)
> 메시 없이 화면 사각형 하나의 셰이더에서 거리 함수로 장면을 정의하고 광선을 한 걸음씩 진행시켜서, 매끈한 도형과 녹아 붙는 합 · 교 · 차를 그린다.  
> 견본: https://ai-techstudio.web.app/#t/i56

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

## 주문서

### 만들어 줘: SDF 레이마칭 — SDF raymarching (sphere tracing)

#### 1. 목표
집합 연산(합 · 교 · 차) 체험을 SDF 레이마칭으로 그려 줘 — 공과 둥근 상자를 합 · 부드러운 합 · 교 · 차로 바꿔 가며, 그림자 · 구석 그늘까지. 분위기는 밝은 파스텔 바둑판 바닥.

#### 2. 핵심 기술 용어
- **SDF raymarching (sphere tracing)** — 거리 함수 레이마칭 — 남은 거리만큼 성큼성큼
- **Signed distance function** — 점에서 도형 겉면까지 거리 (안쪽은 음수)
- **Smooth minimum (smin)** — 두 모양을 녹아 붙게 합치기
- **CSG union · intersection · subtraction** — 합 min · 교 max · 차 max(a, −b)

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

#### 4. 조건
- 화면을 덮는 사각형 하나 + ShaderMaterial (메시 없이)
- 연산은 합 · 부드러운 합 · 교 · 차 넷, 바꿀 때 거리 값을 섞어(uMix) 툭 끊기지 않게
- 걸음 수 상한(광선 90 · 그림자 28)과 최대 거리로 반복문을 끊는다
- NoToneMapping, 감마는 셰이더에서
- 폰에서는 해상도(픽셀 비율)를 낮춰 프레임을 지킨다 — 반복문이 많아 첫 컴파일도 길 수 있으니 미리 데우기

#### 5. 완성 기준 (이게 보이면 성공)
- 물체 없이 공과 둥근 상자가 바둑판 바닥 위에 그림자 · 구석 그늘과 함께 보인다
- 3초마다 합 → 부드러운 합 → 교 → 차로 부드럽게 바뀌고 위에 연산 이름이 나온다
- 「녹아 붙는 정도 k」를 키우면 부드러운 합의 목이 넓어진다
- 폰에서는 작은 창 · 낮은 해상도로 30fps 이상

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

## 원리
- 거리 함수 map(p) 는 「점 p 에서 가장 가까운 겉면까지 거리」를 돌려준다. 공 = length(p − c) − r, 둥근 상자 = sdBox.
- 집합 연산은 거리끼리 계산: 합 min(a, b) · 교 max(a, b) · 차 max(a, −b) · 부드러운 합 smin(a, b, k) — k(0.45)가 클수록 넓게 녹아 붙는다.
- 픽셀마다 카메라에서 광선을 쏘고 t += map(지금 점) 으로 남은 거리만큼 성큼 간다. 0.001 보다 가까우면 맞음, 최대 90걸음 · 거리 12.
- 법선은 둘레 네 점의 거리 차이(사면체), 그림자는 빛 쪽으로 28걸음 진행하며 가장 좁았던 틈(9h/t)으로 부드러운 그림자, 구석 그늘은 법선 쪽 4점 거리.
- 감마는 셰이더에서 직접(pow 0.4545), 톤 매핑은 끈다.

## 핵심 코드 — 거리 함수 · 집합 연산 · 레이마칭 반복 (요지)
(발췌: demos/demosShader.ts sdfDemo() 의 조각 셰이더를 정리)
```glsl
uniform vec2 uRes; uniform float uTime, uOp, uK;
float sdBox(vec3 p, vec3 b, float r){ vec3 q = abs(p) - b; return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0) - r; }
float smin(float a, float b, float k){ float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0); return mix(b, a, h) - k * h * (1.0 - h); }
float opF(float a, float b, float op){
  if (op < 0.5) return min(a, b);        // 합
  if (op < 1.5) return smin(a, b, uK);   // 부드러운 합
  if (op < 2.5) return max(a, b);        // 교
  return max(a, -b);                     // 차
}
float map(vec3 p){
  float a = length(p - vec3(sin(uTime * 0.9) * 0.55, 0.0, 0.0)) - 0.72;            // 공
  float b = sdBox(p - vec3(-sin(uTime * 0.9) * 0.45, 0.0, 0.0), vec3(0.48), 0.07); // 둥근 상자
  return min(opF(a, b, uOp), p.y + 0.95);                                          // + 바닥
}
vec3 nrm(vec3 p){ vec2 e = vec2(0.0015, -0.0015);
  return normalize(e.xyy * map(p + e.xyy) + e.yyx * map(p + e.yyx) + e.yxy * map(p + e.yxy) + e.xxx * map(p + e.xxx)); }
void main(){
  vec2 p = (gl_FragCoord.xy - 0.5 * uRes) / min(uRes.y, uRes.x * 0.8);
  vec3 ro = vec3(5.6 * sin(uTime * 0.25 + 0.6), 2.3, 5.6 * cos(uTime * 0.25 + 0.6));
  vec3 fw = normalize(vec3(0.0, -0.05, 0.0) - ro), rt = normalize(cross(fw, vec3(0.0, 1.0, 0.0))), up = cross(rt, fw);
  vec3 rd = normalize(p.x * rt + p.y * up + 1.5 * fw);
  vec3 col = vec3(0.8, 0.85, 1.0);
  float t = 0.0;
  for (int i = 0; i < 90; i++) {         // 남은 거리만큼 성큼성큼
    float h = map(ro + rd * t);
    if (h < 0.001) { vec3 n = nrm(ro + rd * t); col = vec3(0.25, 0.72, 1.0) * (0.28 + 0.85 * max(dot(n, normalize(vec3(0.6, 0.85, 0.35))), 0.0)); break; }
    t += h; if (t > 12.0) break;
  }
  gl_FragColor = vec4(pow(col, vec3(0.4545)), 1.0); // 감마는 직접 (NoToneMapping)
}
```

## 흔한 실수 · 확인 목록
- [ ] **반복문 셰이더는 첫 화면에서 몇 초 멈출 수 있다** — 윈도는 반복문이 많은 셰이더를 D3D 로 바꾸는 데 오래 걸린다(보물 동굴 17~20초). 걸음 수를 줄이고 compileAsync 로 미리 데운다.
- [ ] **폰 전체 화면에서 그대로 돌리면 프레임이 크게 떨어진다** — 픽셀마다 수백 번 계산이라 해상도를 낮추거나 작은 창에서만 쓴다.
- [ ] **톤 매핑을 켠 채 직접 감마를 하면 색이 두 번 바뀐다** — 견본처럼 tone: NoToneMapping 으로 두고 셰이더에서 pow(col, 0.4545) 한 번만.
- [ ] **연산을 툭 바꾸면 모양이 튄다** — 이전 연산 · 새 연산의 거리 값을 uMix 로 0.6초 섞는다.

## 완성 기준 체크리스트
- [ ] 물체 없이 공과 둥근 상자가 바둑판 바닥 위에 그림자 · 구석 그늘과 함께 보인다
- [ ] 3초마다 합 → 부드러운 합 → 교 → 차로 부드럽게 바뀌고 위에 연산 이름이 나온다
- [ ] 「녹아 붙는 정도 k」를 키우면 부드러운 합의 목이 넓어진다
- [ ] 폰에서는 작은 창 · 낮은 해상도로 30fps 이상

## 이 기술 정보
- id: `i56` · 분류: 이펙트 · 연출 › 화려한 효과 (VFX) · 3D · 난이도 어려움 · 폰 부담 무거움 — 픽셀마다 광선 90걸음 + 그림자 28걸음 + 법선 4번 + 그늘 4번 거리 계산. 1080p 면 200만 픽셀 × 수백 번 — 폰은 픽셀 비율을 낮추거나 작은 창으로.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i56
- 쓰면 좋을 때: 합 · 교 · 차 같은 집합 연산을 매끈하게 보여 줄 때 / 녹아 붙는 말랑한 모양 · 수학 곡면처럼 메시로 만들기 어려운 것
- 쓰지 말 때: 폰 전체 화면 — 픽셀마다 90걸음 + 그림자 28걸음이라 무겁다. 작은 창이나 낮은 해상도로 / 다른 3D 물체와 섞어야 하는 장면 — 깊이가 없어 겹치기 어렵다. 그땐 SDF 를 메시로 굽기(i446)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### sdfDemo — `src/demos/demosShader.ts:966`
```ts
function sdfDemo(): Scene3D {
  const u = { uRes: { value: new THREE.Vector2(1, 1) }, uTime: { value: 0 }, uOp: { value: 0 }, uPrev: { value: 3 }, uMix: { value: 1 }, uK: { value: 0.45 } };
  const q = fsq(
    /* glsl */ `uniform vec2 uRes; uniform float uTime; uniform float uOp; uniform float uPrev; uniform float uMix; uniform float uK;
    varying vec2 vUv;
    float sdBox(vec3 p, vec3 b, float r){ vec3 q = abs(p) - b; return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0) - r; }
    float smin(float a, float b, float k){ float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0); return mix(b, a, h) - k * h * (1.0 - h); }
    float opF(float a, float b, float op){ if (op < 0.5) return min(a, b); if (op < 1.5) return smin(a, b, uK); if (op < 2.5) return max(a, b); return max(a, -b); }
    mat2 rot(float a){ float c = cos(a), s = sin(a); return mat2(c, -s, s, c); }
    float A(vec3 p){ return length(p - vec3(sin(uTime * 0.9) * 0.55, 0.0, 0.0)) - 0.72; }
    float B(vec3 p){ vec3 q = p - vec3(-sin(uTime * 0.9) * 0.45, 0.0, 0.0); q.xz = rot(uTime * 0.5) * q.xz; q.xy = rot(0.4) * q.xy; return sdBox(q, vec3(0.48), 0.07); }
    vec2 map(vec3 p){
      float a = A(p); float b = B(p);
      float d = mix(opF(a, b, uPrev), opF(a, b, uOp), uMix);
      float m = clamp(0.5 + 0.5 * (b - a) / 0.25, 0.0, 1.0);
      float fl = p.y + 0.95;
      if (fl < d) return vec2(fl, -1.0);
      return vec2(d, m);
    }
    vec3 nrm(vec3 p){ vec2 e = vec2(0.0015, -0.0015);
      return normalize(e.xyy * map(p + e.xyy).x + e.yyx * map(p + e.yyx).x + e.yxy * map(p + e.yxy).x + e.xxx * map(p + e.xxx).x); }
    float shadow(vec3 ro, vec3 rd){ float r = 1.0; float t = 0.03; for (int i = 0; i < 28; i++){ float h = map(ro + rd * t).x; r = min(r, 9.0 * h / t); t += clamp(h, 0.02, 0.25); if (r < 0.01 || t > 5.0) break; } return clamp(r, 0.0, 1.0); }
    void main(){
      vec2 p = (gl_FragCoord.xy - 0.5 * uRes) / min(uRes.y, uRes.x * 0.8);
      float an = uTime * 0.25 + 0.6;
      vec3 ro = vec3(5.6 * sin(an), 2.3, 5.6 * cos(an));
      vec3 ta = vec3(0.0, -0.05, 0.0);
      vec3 fw = normalize(ta - ro); vec3 rt = normalize(cross(fw, vec3(0.0, 1.0, 0.0))); vec3 up = cross(rt, fw);
      vec3 rd = normalize(p.x * rt + p.y * up + 1.5 * fw);
      vec3 bg = mix(vec3(0.95, 0.9, 1.0), vec3(0.62, 0.78, 1.0), clamp(p.y + 0.5, 0.0, 1.0));
      vec3 col = bg;
      float t = 0.0; vec2 h = vec2(1.0); bool hit = false;
      for (int i = 0; i < 90; i++){
        vec3 x = ro + rd * t;
        h = map(x);
        if (h.x < 0.001){ hit = true; break; }
        t += h.x; if (t > 12.0) break;
      }
      if (hit){
        vec3 x = ro + rd * t; vec3 n = nrm(x);
        vec3 L = normalize(vec3(0.6, 0.85, 0.35));
        vec3 base;
        if (h.y < 0.0){ float ch = mod(floor(x.x * 2.0) + floor(x.z * 2.0), 2.0); base = mix(vec3(0.86, 0.84, 0.96), vec3(0.95, 0.94, 1.0), ch); }
        else base = mix(vec3(0.25, 0.72, 1.0), vec3(1.0, 0.42, 0.6), h.y);
        float dif = max(dot(n, L), 0.0) * shadow(x + n * 0.01, L);
        float ao = 0.0; float sc = 1.0; for (int k = 1; k <= 4; k++){ float hh = 0.04 * float(k); ao += (hh - map(x + n * hh).x) * sc; sc *= 0.7; }
        ao = clamp(1.0 - 3.0 * ao, 0.0, 1.0);
        vec3 H = normalize(L - rd);
        float spec = pow(max(dot(n, H), 0.0), 48.0) * dif * (h.y < 0.0 ? 0.0 : 0.8);
        float rim = pow(1.0 - max(dot(n, -rd), 0.0), 3.0) * (h.y < 0.0 ? 0.0 : 0.5);
        col = base * (0.28 * ao + 0.85 * dif) + vec3(spec) + vec3(0.7, 0.85, 1.0) * rim * ao;
        col = mix(col, bg, smoothstep(5.0, 11.0, t));
      }
      gl_FragColor = vec4(pow(col, vec3(0.4545)), 1.0);
    }`,
    u,
  );
  const NAMES = ['합집합 A ∪ B', '부드러운 합 (smooth min)', '교집합 A ∩ B', '차집합 A − B'];
  const tags = new Tags([NAMES[0]!]);
  let auto = true;
  let fixed = 1;
  return {
    scene: q.scene,
    camera: q.camera,
    tone: THREE.NoToneMapping,
    update(t) {
      u.uTime.value = t;
      if (auto) {
        const idx = Math.floor(t / 3) % 4;
        u.uOp.value = idx;
        u.uPrev.value = (idx + 3) % 4;
        u.uMix.value = Math.min(1, (t % 3) / 0.6);
        tags.set(0, NAMES[idx]!);
      } else {
        u.uOp.value = u.uPrev.value = fixed;
        u.uMix.value = 1;
        tags.set(0, NAMES[fixed]!);
      }
    },
    render(r, w, h) {
      u.uRes.value.set(w, h);
      r.render(q.scene, q.camera);
      tags.draw(r, w, h, [[0.5, 0.08]]);
    },
    controls: [
      { type: 'range', label: '녹아 붙는 정도 k', min: 0.05, max: 1.2, step: 0.05, value: 0.45, on: (v) => (u.uK.value = v) },
      { type: 'toggle', label: '연산 자동으로 바꾸기', value: true, on: (v) => (auto = v) },
      { type: 'range', label: '연산 고르기 (0 합 · 1 부드러운 합 · 2 교 · 3 차)', min: 0, max: 3, step: 1, value: 1, on: (v) => (fixed = v) },
    ],
    dispose() {
      q.dispose();
      tags.dispose();
    },
  };
}
```

### i56 견본 항목 — `src/demos/demosShader.ts:2385`
```ts
  i56: { kind: '3d', caption: '물체 없이 거리 함수만으로 그린 장면 — 합 · 부드러운 합 · 교 · 차가 차례로 바뀌어요', make: () => sdfDemo() }
```

## 관련 기술
- 다음에 해 볼 기술: [SDF 조각 모델링 (부드럽게 합치기)](https://ai-techstudio.web.app/ai/t/i446.md) `i446`
- 참고 문서: [Inigo Quilez — distance functions](https://iquilezles.org/articles/distfunctions/) · [Inigo Quilez — smooth minimum](https://iquilezles.org/articles/smin/)
