# AI 꾸러미 — 오로라 (커튼 · 호수 반사) — Aurora curtain (ribbon mesh)
> 하늘을 가로지르며 굽이치는 띠 메시에 잡음을 가로로만 읽어 세로 빛줄을 넣고, 장면을 위아래 뒤집은 복사본 위에 반투명 호수를 덮어 오로라와 호수 반사를 만든다.  
> 견본: https://ai-techstudio.web.app/#t/i533

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

## 주문서

### 만들어 줘: 오로라 (커튼 · 호수 반사) — Aurora curtain (ribbon mesh)

#### 1. 목표
극지방 밤하늘에 오로라 커튼을 넣어 줘 — 세로 빛줄 · 아래 초록 위 보라 · 호수에 거꾸로 비치게, 느낌은 사진처럼 장엄하게.

#### 2. 핵심 기술 용어
- **Aurora curtain (ribbon mesh)** — 굽이치는 커튼 띠 메시
- **Vertical rays from 1D noise** — 잡음을 가로 방향으로만 읽어 생긴 세로 결
- **Fake reflection (mirrored scene, scale.y = -1)** — 장면을 뒤집은 복사본으로 물 반사 흉내
- **Fold brightening** — 커튼을 옆에서 볼수록 겹쳐 밝게

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

#### 4. 조건
- 커튼은 PlaneGeometry(1, 1, 400, 1) 한 장 — 모양은 정점 셰이더의 길 함수로
- 세로 결은 잡음을 가로로만 촘촘히 읽기 (세로 좌표는 아주 조금만 바꿈)
- AdditiveBlending · DoubleSide · depthWrite false · frustumCulled false
- 반사 묶음은 scale.y = -1, 같은 기하 · 재질을 공유 (새로 만들지 않기)
- 호수 판은 반투명(0.5) · depthWrite false · renderOrder 를 가장 크게

#### 5. 완성 기준 (이게 보이면 성공)
- 하늘에 커튼 두 장이 굽이치며 일렁이고, 아래 가장자리가 또렷한 초록이다
- 위로 갈수록 보라로 옅어지고 가는 세로 빛줄이 보인다
- 커튼이 접혀 옆으로 보이는 곳이 더 밝다
- 호수에 오로라 · 별 · 산 그림자가 거꾸로 비치고, 「호수에 비친 모습」을 끄면 사라진다

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

## 원리
- 커튼 = 가로 400칸 · 세로 1칸 판 하나. 정점 셰이더가 가로 u 를 따라 하늘을 비스듬히 지나는 길(S자 굽이 + 잔 접힘)을 만들고 세로로 높이를 세운다.
- 세로 빛줄: 잡음 텍스처를 가로(u × 29 · × 83)로는 촘촘히, 세로로는 거의 안 바뀌게 읽으면 세로 결이 생긴다.
- 색: 아래 가장자리는 또렷한 초록(0.12, 1.0, 0.42), 위로 갈수록 붉은 보라로 · 옅게 (exp(-3v)). 밝기가 띠를 따라 천천히 흘러간다.
- 커튼 길을 옆에서 보는 곳(접힌 곳)은 빛이 겹쳐 보이므로 밝게 — 길 방향과 시선의 외적 크기로 0.45 ~ 2.8 배.
- 반사: 산 · 나무 · 커튼 · 별을 scale.y = -1 묶음에 한 번 더 넣고, 그 위에 반투명(0.5) 짙은 호수 판을 덮는다.

## 핵심 코드 — 커튼 길 (정점) + 세로 빛줄 · 색 (조각)
(발췌: demos/demosSpace.ts AUR_VS · AUR_FS 를 정리)
```glsl
// ── 정점 ──
uniform float uTime, uSeed, uZ, uW, uX, uBot, uTilt, uH, uMove;
varying vec2 vUv; varying float vFold;
vec3 path(float u, float tm){   // 커튼 아래 가장자리: 비스듬히 가로지르며 S자로 굽이치고 잘게 접힘
  float x = uX + (u - 0.5) * uW;
  float z = uZ + 60.0 * sin(u * 4.4 + uSeed * 6.0 + tm * 0.06) + 16.0 * sin(u * 12.0 - tm * 0.15 + uSeed * 3.0) + 4.0 * sin(u * 31.0 + tm * 0.33);
  float y = uBot + uTilt * (u - 0.5) + 5.0 * sin(u * 5.0 + tm * 0.08 + uSeed);
  return vec3(x, y, z);
}
void main(){
  vUv = uv; float tm = uTime * uMove;
  vec3 p = path(uv.x, tm);
  vec3 T = normalize(mat3(modelMatrix) * (path(uv.x + 0.002, tm) - p));
  vec3 V = normalize((modelMatrix * vec4(p, 1.0)).xyz - cameraPosition);
  vFold = clamp(0.32 / (length(cross(T, V)) + 0.1), 0.45, 2.8); // 옆에서 보면 겹쳐 밝다
  p.y += uv.y * uH; p.x += uv.y * uH * 0.12;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
}
// ── 조각 ──
uniform sampler2D uN2; uniform float uI;
void main(){
  float u = vUv.x, v = vUv.y, tm = uTime * uMove;
  float a1 = texture(uN2, vec2(u * 29.0 + v * 0.12 + tm * 0.004 + uSeed, 0.31 + v * 0.015)).a; // 가로로만 촘촘히
  float a2 = texture(uN2, vec2(u * 83.0 - v * 0.2 - tm * 0.007, 0.67 + v * 0.02)).r;
  float rays = 0.4 + 1.1 * a1 * a1 + 0.6 * a2 * a2 * a2;
  float flow = 0.25 + 1.1 * smoothstep(0.3, 0.8, texture(uN2, vec2(u * 1.6 - tm * 0.025, 0.15 + uSeed)).g);
  float edge = smoothstep(0.0, 0.012, v) * (1.0 + 2.2 * exp(-v * 28.0));   // 또렷한 아래 가장자리
  float decay = exp(-v * 3.0) * (1.0 - smoothstep(0.55, 1.0, v));
  vec3 col = mix(vec3(0.12, 1.0, 0.42), vec3(0.5, 0.14, 0.62), smoothstep(0.22, 0.75, v));
  float ends = smoothstep(0.0, 0.14, u) * smoothstep(1.0, 0.86, u);
  gl_FragColor = vec4(col * edge * decay * rays * flow * vFold * ends * uI * 0.55, 1.0);
}
```

## 흔한 실수 · 확인 목록
- [ ] **잡음을 세로로도 촘촘히 읽으면 빛줄이 아니라 얼룩 구름이 된다** — 세로 좌표는 아주 조금만(× 0.015) 바꿔 세로 결을 살린다.
- [ ] **반사 복사본에 기하 · 재질을 새로 만들면 메모리가 두 배다** — 같은 기하 · 재질을 공유하는 Mesh 만 새로 (new Mesh(geo, mat)).
- [ ] **뒤집은 복사본만 두면 반사가 너무 또렷하다** — 그 위에 짙은 반투명 호수 판(0.5)을 덮어 물빛으로 누른다.

## 완성 기준 체크리스트
- [ ] 하늘에 커튼 두 장이 굽이치며 일렁이고, 아래 가장자리가 또렷한 초록이다
- [ ] 위로 갈수록 보라로 옅어지고 가는 세로 빛줄이 보인다
- [ ] 커튼이 접혀 옆으로 보이는 곳이 더 밝다
- [ ] 호수에 오로라 · 별 · 산 그림자가 거꾸로 비치고, 「호수에 비친 모습」을 끄면 사라진다

## 이 기술 정보
- id: `i533` · 분류: 그래픽 · 셰이더 › 우주 표현 · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의) — 커튼 2장(정점 800개씩, 잡음 텍스처 3번) + 반사 복사본으로 그리기 두 배. 대신 반사용 렌더 타깃은 없다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i533
- 쓰면 좋을 때: 극지방 · 겨울 밤 배경 / 호수 · 바다에 비친 밤하늘 / 움직이는 빛으로 메뉴 화면을 살릴 때
- 쓰지 말 때: 물결치는 물 — 뒤집은 복사본은 거울처럼 판판하다. 대신 물 셰이더(u22) / 물체가 아주 많은 장면 — 복사본이 그리기를 두 배로

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i533 견본 항목 — `src/demos/demosSpace.ts:2633`
```ts
  i533: {
    kind: '3d',
    caption: '오로라 — 휘어진 커튼 띠에 세로 빛줄(잡음을 가로로만 읽음), 아래 가장자리는 또렷한 초록 · 위로 갈수록 붉은 보라로 옅어지고, 호수에 거꾸로 비친다',
    make(): Scene3D {
      const bin = new Bin();
      const scene = new THREE.Scene();
      const cam = new THREE.PerspectiveCamera(70, 1.6, 0.5, 2000);
      cam.position.set(0, 2.2, 0);
      cam.rotation.order = 'YXZ';
      const US = { uGlow: { value: 1 } };
      const sky = new THREE.Mesh(new THREE.SphereGeometry(900, 48, 24), new THREE.ShaderMaterial({ uniforms: US, vertexShader: DIR_VS, fragmentShader: SKY_FS, side: THREE.BackSide, depthWrite: false }));
      sky.renderOrder = -3;
      scene.add(sky);
      const S = makeStars(16000, 800, 131, { magMax: 7.2, upOnly: true, tw: 0.6 });
      scene.add(S.pts);
      const world = new THREE.Group();
      scene.add(world);
      const mirror = new THREE.Group();
      mirror.scale.y = -1;
      scene.add(mirror);
      const hills = mountainRing(260, 9, 0.032, 0x020306, -1.6, 1.6, -0.3);
      world.add(hills);
      mirror.add(new THREE.Mesh(hills.geometry, hills.material));
      const pine = new THREE.MeshBasicMaterial({ color: 0x010203 });
      const r = rng(41);
      for (let i = 0; i < 22; i++) {
        const side = i % 2 ? 1 : -1;
        const x = side * (60 + r() * 70);
        const z = -150 - r() * 50;
        const h = 6 + r() * 7;
        const tree = new THREE.Group();
        for (let k = 0; k < 4; k++) {
          const c = new THREE.Mesh(new THREE.ConeGeometry(h * 0.26 * (1 - k * 0.2), h * 0.42, 7), pine);
          c.position.y = h * (0.25 + k * 0.2);
          tree.add(c);
        }
        tree.position.set(x, 0, z);
        world.add(tree);
        const tm = tree.clone();
        mirror.add(tm);
      }
      const N2 = noise2D();
      const curtains: { uTime: { value: number }; uI: { value: number }; uMove: { value: number } }[] = [];
      const CUR = [
        { z: -210, w: 420, x: 30, bot: 62, tilt: 70, h: 85, seed: 0.1, i: 1.0 },
        { z: -330, w: 380, x: -170, bot: 70, tilt: -35, h: 110, seed: 0.47, i: 0.5 },
      ];
      const ag = new THREE.PlaneGeometry(1, 1, 400, 1);
      ag.translate(0.5, 0.5, 0);
      for (const c of CUR) {
        const U = { uN2: { value: N2 }, uTime: { value: 0 }, uSeed: { value: c.seed }, uZ: { value: c.z }, uW: { value: c.w }, uX: { value: c.x }, uTilt: { value: c.tilt }, uBot: { value: c.bot }, uH: { value: c.h }, uI: { value: c.i }, uMove: { value: 1 } };
        curtains.push(U);
        const mat = new THREE.ShaderMaterial({ uniforms: U, vertexShader: AUR_VS, fragmentShader: AUR_FS, side: THREE.DoubleSide, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending });
        const m = new THREE.Mesh(ag, mat);
        m.frustumCulled = false;
        m.renderOrder = 1;
        world.add(m);
        const mm = new THREE.Mesh(ag, mat);
        mm.frustumCulled = false;
        mirror.add(mm);
      }
      const ms = new THREE.Points(S.pts.geometry, S.pts.material);
      ms.frustumCulled = false;
      mirror.add(ms);
      const lake = new THREE.Mesh(new THREE.PlaneGeometry(900, 600), new THREE.MeshBasicMaterial({ color: 0x01030a, transparent: true, opacity: 0.5, depthWrite: false }));
      lake.rotation.x = -Math.PI / 2;
      lake.position.z = -280;
      lake.renderOrder = 4;
      scene.add(lake);
      bin.scene(scene);
      let refl = true;
      return {
        scene,
        camera: cam,
        tone: THREE.ACESFilmicToneMapping,
        update(t) {
          S.U.uTime.value = t;
          for (const c of curtains) c.uTime.value = t;
          cam.rotation.set(0.24 + Math.sin(t * 0.05) * 0.03, Math.sin(t * 0.04) * 0.15, 0);
          mirror.visible = refl;
        },
        resize: (_w, h) => (S.U.uScale.value = h / 900),
        controls: [
          range('오로라 세기', 0, 2.5, 0.05, 1, (v) => curtains.forEach((c, i) => (c.uI.value = v * CUR[i]!.i))),
          range('커튼 움직임', 0, 5, 0.1, 1, (v) => curtains.forEach((c) => (c.uMove.value = v))),
          tog('호수에 비친 모습', true, (v) => (refl = v)),
        ],
        dispose: () => {
          bin.dispose();
          S.dispose();
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [은하수 띠 (먼지 줄 · 밝은 팽대부)](https://ai-techstudio.web.app/ai/t/i523.md) `i523` · [평면 반사 (Reflector)](https://ai-techstudio.web.app/ai/t/i14.md) `i14`
- 다음에 해 볼 기술: [초신성 · 충격파 고리](https://ai-techstudio.web.app/ai/t/i534.md) `i534`
- 참고 문서: [Wikipedia — Aurora](https://en.wikipedia.org/wiki/Aurora)
