# AI 꾸러미 — 블랙홀 (강착 원반 · 중력 렌즈)
> 뒤 별하늘을 먼저 그려 두고 블랙홀 둘레에서 안쪽으로 휘어 읽음(아인슈타인 고리) + 안쪽일수록 뜨겁고 빠른 원반(한쪽이 더 밝음) + 원반 뒤쪽이 위아래로 휘어 보이는 빛 고리 + 빨려 드는 가스 · 제트  
> 견본: https://ai-techstudio.web.app/#t/i517

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

## 주문서

### 만들어 줘: 블랙홀 (강착 원반 · 중력 렌즈)

#### 1. 목표
우주에 「블랙홀 (강착 원반 · 중력 렌즈)」 기술을 깔끔한 기본 느낌으로 넣어 줘. — 뒤 별하늘을 먼저 그려 두고 블랙홀 둘레에서 안쪽으로 휘어 읽음(아인슈타인 고리) + 안쪽일수록 뜨겁고 빠른 원반(한쪽이 더 밝음) + 원반 뒤쪽이 위아래로 휘어 보이는 빛 고리 + 빨려 드는 가스 · 제트

#### 2. 핵심 기술 용어
- 블랙홀 (강착 원반 · 중력 렌즈) — 뒤 별하늘을 먼저 그려 두고 블랙홀 둘레에서 안쪽으로 휘어 읽음(아인슈타인 고리) + 안쪽일수록 뜨겁고 빠른 원반(한쪽이 더 밝음) + 원반 뒤쪽이 위아래로 휘어 보이는 빛 고리 + 빨려 드는 가스 · 제트

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

#### 4. 조건
- 한 프레임 16ms 안 (폰 포함) — 무거운 계산은 처음에 한 번 또는 여러 프레임에 나눠서
- 새 재질 · 셰이더는 첫 화면 전에 미리 컴파일 (renderer.compileAsync)
- 화면 가장자리 흐림 · 비네트로 얼버무리지 않기
- 외부 라이브러리 추가 없이 three.js 와 three/examples 만

#### 5. 완성 기준 (이게 보이면 성공)
- 견본처럼 「뒤 별하늘을 먼저 그려 두고 블랙홀 둘레에서 안쪽으로 휘어 읽음(아인슈타인 고리) + 안쪽일수록 뜨겁고 빠른 원반(한쪽이 더 밝음) + 원반 뒤쪽이 위아래로 휘어 보이는 빛 고리 + 빨려 드는 가스 · 제트」 이(가) 첫 화면에서 바로 보인다
- 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

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

## 원리
- 뒤 별하늘을 먼저 그려 두고 블랙홀 둘레에서 안쪽으로 휘어 읽음(아인슈타인 고리) + 안쪽일수록 뜨겁고 빠른 원반(한쪽이 더 밝음) + 원반 뒤쪽이 위아래로 휘어 보이는 빛 고리 + 빨려 드는 가스 · 제트


## 흔한 실수 · 확인 목록
- (아직 정리된 것이 없어요)

## 완성 기준 체크리스트
- [ ] 견본처럼 「뒤 별하늘을 먼저 그려 두고 블랙홀 둘레에서 안쪽으로 휘어 읽음(아인슈타인 고리) + 안쪽일수록 뜨겁고 빠른 원반(한쪽이 더 밝음) + 원반 뒤쪽이 위아래로 휘어 보이는 빛 고리 + 빨려 드는 가스 · 제트」 이(가) 첫 화면에서 바로 보인다
- [ ] 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- [ ] 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- [ ] 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

## 이 기술 정보
- id: `i517` · 분류: 이펙트 · 연출 › 화려한 효과 (VFX) · 3D · 난이도 어려움 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i517
- 쓰면 좋을 때: 쓰면 좋을 곳 — 우주 · 중력 · 블랙홀 체험

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i517 견본 항목 — `src/demos/demosVfx3.ts:4420`
```ts
  i517: {
    kind: '3d',
    caption: '뒤 별빛이 블랙홀 둘레에서 휘어 고리처럼 비치고(중력 렌즈), 안쪽일수록 뜨겁고 빠른 원반 · 위아래로 휘어 보이는 원반 뒤쪽 · 빨려 드는 가스 · 제트',
    make(): Scene3D {
      const S = makeStage({ bg: 0x000000, cam: [0, 1, 9], look: [0, 0, 0], fov: 45 });
      const lab = S.hud.label('tl');
      const space = new THREE.Mesh(
        new THREE.SphereGeometry(60, 48, 24),
        new THREE.ShaderMaterial({
          uniforms: { uNoise: { value: S.noise } },
          vertexShader: /* glsl */ `varying vec3 vD; void main(){ vD = normalize(position); vec4 p = projectionMatrix * modelViewMatrix * vec4(position, 1.0); gl_Position = p.xyww; }`,
          fragmentShader: /* glsl */ `uniform sampler2D uNoise; varying vec3 vD;
            float h31(vec3 p){ return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453); }
            void main(){ vec3 d = normalize(vD);
              vec2 uv = vec2(atan(d.z, d.x) / 6.2831853, asin(clamp(d.y, -1.0, 1.0)) / 3.14159 + 0.5);
              float n = texture2D(uNoise, uv * vec2(2.0, 1.0)).r; float n2 = texture2D(uNoise, uv * vec2(4.0, 2.0) + 0.3).g;
              float band = exp(-pow((d.y + 0.25 * sin(uv.x * 6.28)) * 2.4, 2.0));
              vec3 col = vec3(0.0);
              col += mix(vec3(0.015, 0.008, 0.04), vec3(0.06, 0.02, 0.09), n) * smoothstep(0.4, 0.95, n * n2 * 1.8) * (0.3 + band);
              col += vec3(0.03, 0.06, 0.13) * smoothstep(0.55, 0.95, n2) * 0.5;
              col += vec3(0.5, 0.42, 0.35) * band * smoothstep(0.5, 1.0, n) * 0.12;
              for (int k = 0; k < 2; k++) {
                float sc = k == 0 ? 160.0 : 60.0;
                vec3 q = d * sc; vec3 c = floor(q); float h = h31(c + float(k) * 17.0);
                vec3 f = fract(q) - 0.5; float s = step(k == 0 ? 0.965 : 0.992, h) * exp(-dot(f, f) * (k == 0 ? 40.0 : 22.0));
                vec3 tint = mix(vec3(1.0, 0.8, 0.6), vec3(0.7, 0.85, 1.0), fract(h * 13.0));
                col += tint * s * (k == 0 ? 1.4 : 4.0) * (0.4 + fract(h * 37.0));
              }
              gl_FragColor = vec4(col, 1.0); ${TONE} }`,
          side: THREE.BackSide,
          depthWrite: false,
        }),
      );
      space.renderOrder = -10;
      S.scene.add(space);
      const bh = new THREE.Group();
      S.scene.add(bh);
      const RS = 0.75;
      const rt = new THREE.WebGLRenderTarget(4, 4, { type: THREE.HalfFloatType });
      const lensMat = new THREE.ShaderMaterial({
        uniforms: { uRT: { value: rt.texture }, uRes: { value: new THREE.Vector2(4, 4) }, uC: { value: new THREE.Vector2(0.5, 0.5) }, uRs: { value: 0.1 }, uK: { value: 1 }, uAsp: { value: 1 } },
        vertexShader: FLAT_VS,
        fragmentShader: /* glsl */ `uniform sampler2D uRT; uniform vec2 uRes, uC; uniform float uRs, uK, uAsp; varying vec2 vUv;
          void main(){ vec2 suv = gl_FragCoord.xy / uRes;
            vec2 d = suv - uC; d.x *= uAsp; float r = max(length(d), 1e-5);
            float rs = uRs;
            float rsrc = r - uK * 2.2 * rs * rs / r;
            vec2 dir = d / r;
            vec2 sp = uC + vec2(dir.x / uAsp, dir.y) * rsrc;
            float e = length(vUv - 0.5) * 2.0;
            float blend = 1.0 - smoothstep(0.6, 1.0, e);
            vec2 uv = mix(suv, sp, blend);
            vec3 col = texture2D(uRT, clamp(uv, 0.001, 0.999)).rgb;
            col *= smoothstep(rs * 0.98, rs * 1.03, r);
            col += vec3(1.0, 0.72, 0.42) * exp(-pow((r - rs * 1.07) / (rs * 0.035), 2.0)) * 2.5;
            gl_FragColor = vec4(col, 1.0); ${TONE} gl_FragColor.a = 1.0; }`,
        depthWrite: false,
        transparent: true,
      });
      const lens = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), lensMat);
      lens.scale.setScalar(RS * 9);
      lens.renderOrder = 1;
      bh.add(lens);
      const shadowM = new THREE.Mesh(new THREE.CircleGeometry(RS * 0.98, 48), new THREE.MeshBasicMaterial({ color: 0x000000, colorWrite: false }));
      shadowM.renderOrder = 2;
      bh.add(shadowM);
      const tiltG = new THREE.Group();
      bh.add(tiltG);
      const diskMat = new THREE.ShaderMaterial({
        uniforms: { uTime: { value: 0 }, uNoise: { value: S.noise }, uIn: { value: RS * 1.6 }, uOut: { value: RS * 4.8 } },
        vertexShader: /* glsl */ `varying vec2 vP; varying vec3 vW; varying vec3 vT; void main(){ vP = position.xy; vT = normalize(mat3(modelMatrix) * normalize(vec3(-position.y, position.x, 0.0))); vec4 w = modelMatrix * vec4(position, 1.0); vW = w.xyz; gl_Position = projectionMatrix * viewMatrix * w; }`,
        fragmentShader: /* glsl */ `uniform sampler2D uNoise; uniform float uTime, uIn, uOut; varying vec2 vP; varying vec3 vW; varying vec3 vT;
          void main(){ float r = length(vP); float a = atan(vP.y, vP.x);
            float x = (r - uIn) / (uOut - uIn);
            float w = 1.4 / pow(r, 1.5);
            float ar = a + uTime * w;
            float n = texture2D(uNoise, vec2(ar / 6.2831853 * 4.0, r * 0.55)).r;
            float n2 = texture2D(uNoise, vec2(ar / 6.2831853 * 9.0, r * 1.6 + 0.3)).g;
            float streak = 0.45 + 0.9 * n * n2;
            vec3 hot = vec3(1.0, 0.95, 0.88) * 6.0; vec3 mid = vec3(1.0, 0.62, 0.25) * 2.6; vec3 cold = vec3(0.7, 0.18, 0.05) * 0.9;
            vec3 col = mix(hot, mid, smoothstep(0.0, 0.3, x)); col = mix(col, cold, smoothstep(0.3, 1.0, x));
            vec3 tw = normalize(vT);
            float beam = 1.0 + 0.8 * dot(tw, normalize(cameraPosition - vW));
            float alpha = smoothstep(0.0, 0.06, x) * (1.0 - smoothstep(0.7, 1.0, x)) * streak;
            gl_FragColor = vec4(col * beam * streak, 1.0); ${TONE}
            gl_FragColor.a = clamp(alpha, 0.0, 1.0); }`,
        transparent: true,
        depthWrite: false,
        blending: THREE.AdditiveBlending,
        side: THREE.DoubleSide,
      });
      const disk = new THREE.Mesh(new THREE.RingGeometry(RS * 1.6, RS * 4.8, 160, 8), diskMat);
      disk.rotation.x = -Math.PI / 2;
      disk.renderOrder = 3;
      tiltG.add(disk);
      // 원반 뒤쪽이 휘어 위아래로 보이는 고리 (카메라를 보는 판)
      const haloMat = new THREE.ShaderMaterial({
        uniforms: { uTime: { value: 0 }, uNoise: { value: S.noise }, uIn: { value: RS * 1.12 }, uOut: { value: RS * 1.9 }, uTilt: { value: 0.18 } },
        vertexShader: /* glsl */ `varying vec2 vP; void main(){ vP = position.xy; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
        fragmentShader: /* glsl */ `uniform sampler2D uNoise; uniform float uTime, uIn, uOut, uTilt; varying vec2 vP;
          void main(){ float r = length(vP); float a = atan(vP.y, vP.x);
            float x = (r - uIn) / (uOut - uIn);
            float top = pow(abs(sin(a)), 0.6) * (vP.y > 0.0 ? 1.0 : 0.55 + uTilt);
            float n = texture2D(uNoise, vec2(a / 6.2831853 * 5.0 - uTime * 0.25, r * 0.9)).r;
            vec3 col = mix(vec3(1.0, 0.9, 0.75) * 4.0, vec3(1.0, 0.5, 0.18) * 1.6, smoothstep(0.0, 0.8, x));
            float alpha = smoothstep(0.0, 0.12, x) * (1.0 - smoothstep(0.45, 1.0, x)) * top * (0.6 + 1.0 * n) * 1.5;
            gl_FragColor = vec4(col, 1.0); ${TONE} gl_FragColor.a = clamp(alpha, 0.0, 1.0); }`,
        transparent: true,
        depthWrite: false,
        blending: THREE.AdditiveBlending,
      });
      const halo = new THREE.Mesh(new THREE.RingGeometry(RS * 1.12, RS * 1.9, 128, 4), haloMat);
      halo.renderOrder = 2;
      bh.add(halo);
      // 제트
      const jetMat = new THREE.ShaderMaterial({
        uniforms: { uTime: { value: 0 }, uNoise: { value: S.noise }, uA: { value: 1 } },
        vertexShader: /* glsl */ `varying vec2 vUv; varying float vF; void main(){ vUv = uv; vec3 n = normalize(normalMatrix * normal); vec4 mv = modelViewMatrix * vec4(position, 1.0); vF = abs(dot(n, normalize(-mv.xyz))); gl_Position = projectionMatrix * mv; }`,
        fragmentShader: /* glsl */ `uniform sampler2D uNoise; uniform float uTime, uA; varying vec2 vUv; varying float vF;
          void main(){ float n = texture2D(uNoise, vec2(vUv.x * 3.0, vUv.y * 2.0 - uTime * 1.5)).r;
            float a = pow(vF, 1.5) * (0.4 + 0.9 * n) * (1.0 - smoothstep(0.3, 1.0, vUv.y)) * smoothstep(0.0, 0.05, vUv.y) * uA;
            gl_FragColor = vec4(vec3(0.55, 0.75, 1.0) * 1.6, 1.0); ${TONE} gl_FragColor.a = clamp(a * 0.6, 0.0, 1.0); }`,
        transparent: true,
        depthWrite: false,
        blending: THREE.AdditiveBlending,
        side: THREE.DoubleSide,
      });
      const jetGeo = new THREE.CylinderGeometry(0.22, 0.04, 4.5, 24, 1, true).translate(0, 2.25, 0);
      const jUp = new THREE.Mesh(jetGeo, jetMat);
      const jDn = new THREE.Mesh(jetGeo, jetMat);
      jDn.rotation.x = Math.PI;
      jUp.renderOrder = jDn.renderOrder = 4;
      tiltG.add(jUp, jDn);
      const gas = S.pool(1600, 5);
      bh.add(gas.mesh);
      const GAS = flat(0xffa050, 2.2, 0.7);
      const GASH = flat(0xffe0b0, 4, 0.9);
      const JETP = ramp([[0, 0xd8ecff, 4, 1, 1], [1, 0x4a80ff, 1, 0, 1]]);
      const jetP = S.pool(400, 6);
      bh.add(jetP.mesh);
      const NG = 520;
      const G = Array.from({ length: NG }, () => ({ r: R(1.7, 6) * RS, a: R(0, TAU), h: R(-0.04, 0.04) }));
      let K = 1;
      let tilt = 0.17;
      let jets = true;
      const controls: Control[] = [
        ctrlRange('렌즈 세기', 0, 2, 0.05, 1, (v) => (K = v)),
        ctrlRange('원반 기울기', 0.02, 0.6, 0.01, 0.17, (v) => (tilt = v)),
        ctrlTog('제트', true, (v) => (jets = v)),
      ];
      const P0 = new THREE.Vector3();
      const ndc = new THREE.Vector3();
      const up = new THREE.Vector3();
      let W = 4;
      let Hh = 4;
      return {
        scene: S.scene,
        camera: S.cam,
        controls,
        update(t, dt0) {
          const dt = Math.min(dt0, 0.05);
          const ang = t * 0.06;
          S.base.set(Math.sin(ang) * 9, 0.9 + Math.sin(t * 0.11) * 0.5, Math.cos(ang) * 9);
          S.tick(dt);
          S.cam.updateMatrixWorld();
          tiltG.rotation.set(0, 0, 0);
          tiltG.lookAt(S.cam.position);
          tiltG.rotateX(tilt);
          lens.lookAt(S.cam.position);
          shadowM.lookAt(S.cam.position);
          halo.lookAt(S.cam.position);
          diskMat.uniforms.uTime!.value = t;
          haloMat.uniforms.uTime!.value = t;
          haloMat.uniforms.uTilt!.value = tilt;
          jetMat.uniforms.uTime!.value = t;
          jUp.visible = jDn.visible = jets;
          // 렌즈 중심 · 크기 (화면 좌표)
          ndc.copy(P0).project(S.cam);
          (lensMat.uniforms.uC!.value as THREE.Vector2).set(ndc.x * 0.5 + 0.5, ndc.y * 0.5 + 0.5);
          const dist = S.cam.position.length();
          lensMat.uniforms.uRs!.value = RS / (2 * dist * Math.tan(THREE.MathUtils.degToRad(S.cam.fov / 2)));
          lensMat.uniforms.uK!.value = K;
          lensMat.uniforms.uAsp!.value = W / Hh;
          // 빨려 드는 가스
          gas.clear();
          tiltG.updateMatrixWorld();
          const m = tiltG.matrixWorld;
          for (const g of G) {
            const w = 1.4 / Math.pow(g.r / RS, 1.5) / RS;
            g.a += w * dt * 1.6;
            g.r -= dt * (0.12 + 0.5 / (g.r / RS)) * RS * 0.5;
            if (g.r < RS * 1.25) {
              g.r = R(4.5, 6.5) * RS;
              g.a = R(0, TAU);
            }
            const x = Math.cos(g.a) * g.r;
            const z = -Math.sin(g.a) * g.r;
            up.set(x, g.h, z).applyMatrix4(m);
            const tx = -Math.sin(g.a);
            const tz = -Math.cos(g.a);
            const vx = tx * g.r * w * 0.15;
            const vz = tz * g.r * w * 0.15;
            const vv = new THREE.Vector3(vx, 0, vz).transformDirection(m).multiplyScalar(Math.hypot(vx, vz));
            const hot = g.r < RS * 2.4;
            gas.spawn({ x: up.x, y: up.y, z: up.z, vx: vv.x, vy: vv.y, vz: vv.z, life: 1, s0: hot ? 0.05 : 0.07, ramp: hot ? GASH : GAS, br: hot ? 1 : 0.6, st: 1, shape: 0 });
          }
          gas.upload();
          if (jets && Math.random() < dt * 30) {
            const s = Math.random() < 0.5 ? 1 : -1;
            const nrm = new THREE.Vector3(0, s, 0).transformDirection(m);
            const sp = R(5, 8);
            jetP.spawn({ x: nrm.x * 0.3, y: nrm.y * 0.3, z: nrm.z * 0.3, vx: nrm.x * sp, vy: nrm.y * sp, vz: nrm.z * sp, life: R(0.5, 0.9), s0: 0.06, ramp: JETP, st: 0.05, shape: 2 });
          }
          jetP.tick(dt);
          lab.set('중력 렌즈 — 뒤 별빛이 휘어 고리처럼 · 강착 원반은 안쪽일수록 뜨겁고 빠름', '#ffb060');
        },
        render(r, w, h) {
          W = w;
          Hh = h;
          if (rt.width !== w || rt.height !== h) rt.setSize(w, h);
          (lensMat.uniforms.uRes!.value as THREE.Vector2).set(w, h);
          lensMat.uniforms.uAsp!.value = w / h;
          bh.visible = false;
          r.setRenderTarget(rt);
          r.clear();
          r.render(S.scene, S.cam);
          r.setRenderTarget(null);
          bh.visible = true;
          r.render(S.scene, S.cam);
        },
        resize: (w, h) => S.resize(w, h),
        dispose: () => {
          S.dispose();
          rt.dispose();
        },
      };
    },
  }
```

## 관련 기술
- 이 효과를 만든 기술: [블랙홀 (중력 렌즈 · 강착 원반 도플러)](https://ai-techstudio.web.app/ai/t/i529.md) `i529`
