# AI 꾸러미 — 대기 산란 하늘 — Atmospheric scattering sky (Preetham model)
> 레일리 · 미 산란 하늘(three Sky)에 해 높이 하나를 넣어, 낮 파랑 → 노을 → 땅거미 → 별 · 은하수 밤이 저절로 이어지게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i224

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

## 주문서

### 만들어 줘: 대기 산란 하늘 — Atmospheric scattering sky (Preetham model)

#### 1. 목표
산맥 풍경에 three Sky 산란 하늘을 깔고 해 높이 하나로 낮 → 노을 → 밤까지 하늘 · 빛 · 별이 함께 바뀌게 해 줘.

#### 2. 핵심 기술 용어
- **Atmospheric scattering sky (Preetham model)** — 공기 알갱이가 햇빛을 흩뜨려 생기는 하늘색
- **three.js Sky (addons)** — turbidity · rayleigh · mieCoefficient · sunPosition 으로 그리는 하늘 셰이더
- **Rayleigh / Mie scattering** — 작은 알갱이가 파랑을 흩뜨림 / 먼지가 해 둘레를 하얗게
- **Aerial perspective** — 먼 산일수록 그 방향 하늘색에 묻히는 공기 원근

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

#### 4. 조건
- Sky 는 three/examples/jsm/objects/Sky.js, scale 15000 · 카메라 far 30000
- 시작 값 turbidity 8 · rayleigh 2.4 · mieCoefficient 0.005 · mieDirectionalG 0.8
- 해 높이(elev, °) 하나로 하늘 · 해빛 색 · 세기 · 하늘빛 · 별 · 달 밝기를 함께 (smoothstep 구간으로)
- ACESFilmic 톤 매핑 + 노출 낮게(견본 0.34) — 산란 하늘은 아주 밝은 값을 낸다
- 공기 원근용 하늘 큐브에는 해 원반을 빼고 굽는다 (showSunDisc 0 → 굽고 → 1)

#### 5. 완성 기준 (이게 보이면 성공)
- 해가 저절로 오르내리면 하늘이 낮 파랑 → 지평선 노을 → 땅거미 남색 → 별 · 은하수 · 달빛 밤으로 이어진다
- 해가 낮을수록 산이 붉게 물들고, 지평선 아래면 해빛이 0 이 되고 달빛 푸른빛만 남는다
- 먼 산일수록 하늘색에 묻혀 흐려진다 — 「공기 원근」 슬라이더로 정도가 바뀐다
- 「탁함」 · 「레일리」 슬라이더로 하늘이 뿌옇게 · 더 파랗게 바뀐다

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

## 원리
- Sky 는 커다란 상자 안쪽에 산란 하늘을 그리는 셰이더다. 해 방향(sunPosition) 하나만 넣으면 하늘색이 계산된다.
- 레일리(작은 공기 분자)는 파랑을 많이 흩뜨려 낮 하늘이 파랗고, 해가 낮으면 빛이 공기를 길게 지나 붉은 노을이 된다.
- 해 높이(°)로 해빛 색 · 세기, 하늘에서 오는 빛, 별 · 은하수 · 달 밝기를 함께 움직인다 — 하늘만 바뀌고 땅이 그대로면 어색하다.
- 먼 산은 그 방향 하늘색으로 녹아들게 한다(공기 원근). 하늘을 작은 큐브(64px)에 구워 산 셰이더가 읽는다.

## 핵심 코드 — three Sky + 해 높이 하나로 하늘과 빛 함께
(발췌: demos/demosLook2.ts makeSky() 의 하늘 설정 · update 를 정리)
```ts
import * as THREE from 'three';
import { Sky } from 'three/examples/jsm/objects/Sky.js';

const sky = new Sky();
sky.scale.setScalar(15000);
scene.add(sky);
const su = sky.material.uniforms;
su['turbidity']!.value = 8;        // 먼지 (뿌연 정도)
su['rayleigh']!.value = 2.4;       // 파란 산란
su['mieCoefficient']!.value = 0.005;
su['mieDirectionalG']!.value = 0.8;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.34; // 산란 하늘은 밝은 값을 내니 노출을 낮게

const sunDir = new THREE.Vector3();
const sunCol = new THREE.Color();
const smooth = (a: number, b: number, x: number): number => { const t = Math.min(1, Math.max(0, (x - a) / (b - a))); return t * t * (3 - 2 * t); };
const lerp = (a: number, b: number, k: number): number => a + (b - a) * k;

function update(t: number): void {
  const elev = -9 + 47 * (0.5 + 0.5 * Math.cos(t * 0.26)); // 해 높이 -9° ~ 38°
  const phi = THREE.MathUtils.degToRad(90 - elev);
  // 낮게 뜨면 앞쪽(산 뒤 노을), 높이 뜨면 옆 뒤로
  sunDir.setFromSphericalCoords(1, phi, THREE.MathUtils.degToRad(lerp(-150, -75, smooth(6, 30, elev))));
  su['sunPosition']!.value.copy(sunDir);
  // 해빛: 낮을수록 붉게, 지평선 아래면 0 — 땅 · 산 재질도 같은 값을 받는다
  sunCol.setRGB(1, lerp(0.42, 0.96, smooth(0, 25, elev)), lerp(0.18, 0.9, smooth(0, 30, elev)));
  sunCol.multiplyScalar(9 * smooth(-2, 6, elev));
  const nightK = 1 - smooth(-7, 2, elev);   // 은하수 · 달빛 세기
  const starK = 1 - smooth(-8, 1, elev);    // 별 밝기
  // sun.color.copy(sunCol) · 별/은하수 재질 uK = nightK · starK …
}
```

## 흔한 실수 · 확인 목록
- [ ] **공기 원근 큐브에 해 원반까지 구우면 산 한쪽이 깜빡인다** — 64px 큐브에 수만 배 밝은 해 원반이 들어가 흐린 밉이 하얗게 번졌다. 굽는 동안만 showSunDisc 를 0 으로.
- [ ] **해 쪽 하늘색을 그대로 안개 색으로 쓰면 해 쪽 들판이 하얗게 덮인다** — 해 쪽 하늘은 다른 곳보다 수십 배 밝다. 머리 위 하늘 밝기의 2.2배까지만 쓰도록 눌렀다.
- [ ] **하늘만 바꾸고 해빛 색 · 세기를 그대로 두면 노을인데 땅이 한낮이다** — 해 높이 하나로 하늘 · 해빛 · 하늘빛 · 별을 모두 움직인다.
- [ ] **톤 매핑 없이 쓰면 하늘이 하얗게 날아간다** — 산란 하늘은 1 을 넘는 값을 낸다. ACESFilmic + 노출을 낮게.

## 완성 기준 체크리스트
- [ ] 해가 저절로 오르내리면 하늘이 낮 파랑 → 지평선 노을 → 땅거미 남색 → 별 · 은하수 · 달빛 밤으로 이어진다
- [ ] 해가 낮을수록 산이 붉게 물들고, 지평선 아래면 해빛이 0 이 되고 달빛 푸른빛만 남는다
- [ ] 먼 산일수록 하늘색에 묻혀 흐려진다 — 「공기 원근」 슬라이더로 정도가 바뀐다
- [ ] 「탁함」 · 「레일리」 슬라이더로 하늘이 뿌옇게 · 더 파랗게 바뀐다

## 이 기술 정보
- id: `i224` · 분류: 그래픽 · 셰이더 › 빛 · 환경 · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의) — 하늘 셰이더는 화면 픽셀마다 산란 계산. 공기 원근용 큐브(64² × 6면)를 매 장면 굽고, 별 1만 1천 점 · 산 4겹 · 나무 900 인스턴스.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i224
- 쓰면 좋을 때: 시간 흐름 · 해 고도를 보여 주는 장면 / 넓은 바깥 풍경의 배경 하늘 / 노을 · 밤을 한 장면에서 오가야 할 때
- 쓰지 말 때: 그림책 · 툰 하늘 — 대신 그린 배경이나 그러데이션 하늘(i431 의 셰이더 하늘) / 실내 · 동굴

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### makeSky — `src/demos/demosLook2.ts:602`
```ts
function makeSky(): Scene3D {
  const scene = new THREE.Scene();
  const cam = new THREE.PerspectiveCamera(48, 1.6, 1, 30000);
  cam.position.set(0, 70, 420);
  cam.lookAt(0, 150, -1400);
  const sky = new Sky();
  sky.scale.setScalar(15000);
  scene.add(sky);
  const su = sky.material.uniforms;
  su.turbidity!.value = 8;
  su.rayleigh!.value = 2.4;
  su.mieCoefficient!.value = 0.005;
  su.mieDirectionalG!.value = 0.8;
  if (su.cloudCoverage) su.cloudCoverage.value = 0.25;

  // ── 굽는 잡음 (산 무늬 · 은하수) ──
  const NS = 256;
  const nd = new Uint8Array(NS * NS * 4);
  {
    const r = rng(11);
    const grid = (o: number): number[] => Array.from({ length: o * o }, () => r());
    const lv = [4, 8, 16, 32, 64].map((o) => ({ o, g: grid(o) }));
    const lv2 = [6, 12, 24, 48].map((o) => ({ o, g: grid(o) }));
    const sample = (L: { o: number; g: number[] }[], u: number, v: number): number => {
      let s = 0;
      let a = 0.5;
      let tot = 0;
      for (const { o, g } of L) {
        const x = u * o;
        const y = v * o;
        const xi = Math.floor(x);
        const yi = Math.floor(y);
        const fx = x - xi;
        const fy = y - yi;
        const sx = fx * fx * (3 - 2 * fx);
        const sy = fy * fy * (3 - 2 * fy);
        const at = (i: number, j: number): number => g[(((j % o) + o) % o) * o + (((i % o) + o) % o)]!;
        s += a * lerp(lerp(at(xi, yi), at(xi + 1, yi), sx), lerp(at(xi, yi + 1), at(xi + 1, yi + 1), sx), sy);
        tot += a;
        a *= 0.5;
      }
      return s / tot;
    };
    for (let y = 0; y < NS; y++)
      for (let x = 0; x < NS; x++) {
        const i = (y * NS + x) * 4;
        nd[i] = Math.round(sample(lv, x / NS, y / NS) * 255);
        nd[i + 1] = Math.round(sample(lv2, x / NS, y / NS) * 255);
        nd[i + 2] = Math.round(r() * 255);
        nd[i + 3] = 255;
      }
  }
  const noiseTex = new THREE.DataTexture(nd, NS, NS);
  noiseTex.wrapS = noiseTex.wrapT = THREE.RepeatWrapping;
  noiseTex.magFilter = THREE.LinearFilter;
  noiseTex.minFilter = THREE.LinearMipmapLinearFilter;
  noiseTex.generateMipmaps = true;
  noiseTex.needsUpdate = true;

  // ── 밤하늘 (은하수 · 대기광) — 하늘 위에 더한다 ──
  const nightMat = new THREE.ShaderMaterial({
    uniforms: { uK: { value: 0 }, uNoise: { value: noiseTex } },
    vertexShader: /* glsl */ `varying vec3 vP; void main(){ vP = normalize(position); gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
    fragmentShader: /* glsl */ `
      uniform float uK; uniform sampler2D uNoise; varying vec3 vP;
      float tri(vec3 p, float s, int ch){ vec3 w = abs(p); w /= (w.x + w.y + w.z);
        vec4 a = texture2D(uNoise, p.yz * s), b = texture2D(uNoise, p.xz * s), c = texture2D(uNoise, p.xy * s);
        vec4 m = a * w.x + b * w.y + c * w.z; return ch == 0 ? m.r : m.g; }
      void main(){
        vec3 d = normalize(vP);
        float h = max(d.y, 0.0);
        // 밤 하늘빛: 머리 위 짙은 남색 → 지평선 쪽 옅은 대기광
        vec3 c = mix(vec3(0.16, 0.20, 0.36), vec3(0.015, 0.02, 0.06), pow(h, 0.45));
        // 은하수 띠: 큰 원 둘레 + 잡음 결 + 가운데 먼지 줄 + 밝은 핵
        vec3 bn = normalize(vec3(0.63, -0.71, -0.32));
        float b = dot(d, bn);
        float n1 = tri(d, 0.9, 0), n2 = tri(d * 1.7 + 3.0, 1.3, 1), n3 = tri(d * 3.1 + 7.0, 2.6, 0);
        // 넓고 옅은 띠 + 잔 결(별구름) — 가운데로 갈수록 진하게
        float band = exp(-b * b * 30.0) * (0.45 + 0.9 * n1) * (0.6 + 0.6 * n3);
        // 먼지 줄: 띠 한가운데를 가늘게 지나는 어두운 갈래 (가장자리는 부드럽게)
        float dust = exp(-pow(b + 0.025 * (n2 - 0.5), 2.0) * 520.0) * smoothstep(0.4, 0.62, n3 * 0.65 + n2 * 0.35);
        vec3 coreDir = normalize(vec3(-0.42, 0.24, -0.88));
        float core = pow(max(dot(d, coreDir), 0.0), 18.0);
        vec3 mw = vec3(0.55, 0.6, 0.82) * band * (1.0 - dust * 0.8) + vec3(1.0, 0.84, 0.66) * core * (0.6 + 0.6 * n3) * (1.0 - dust * 0.9);
        c += mw * 0.42 * smoothstep(0.0, 0.16, d.y);
        gl_FragColor = vec4(c * uK, 1.0);
      }`,
    side: THREE.BackSide,
    transparent: true,
    blending: THREE.AdditiveBlending,
    depthWrite: false,
  });
  const night = new THREE.Mesh(new THREE.SphereGeometry(9000, 48, 24), nightMat);
  night.renderOrder = -1;
  scene.add(night);

  // ── 공기 원근용 하늘 큐브 (하늘 · 밤 하늘빛만, 매 장면 64px) ──
  const skyScene = new THREE.Scene();
  const sky2 = new Sky();
  sky2.material = sky.material;
  sky2.scale.setScalar(15000);
  skyScene.add(sky2);
  const night2 = new THREE.Mesh(night.geometry, nightMat);
  skyScene.add(night2);
  const cubeRT = new THREE.WebGLCubeRenderTarget(64, { type: THREE.HalfFloatType, generateMipmaps: true, minFilter: THREE.LinearMipmapLinearFilter });
  const cubeCam = new THREE.CubeCamera(1, 30000, cubeRT);
  cubeCam.position.copy(cam.position);

  // ── 산 재질 (바위 · 풀 · 눈 + 해 · 달 · 하늘빛 + 공기 원근) ──
  const mountUniforms = {
    uSunDir: { value: new THREE.Vector3(0, 1, 0) },
    uSunCol: { value: new THREE.Color(1, 1, 1) },
    uMoonDir: { value: new THREE.Vector3(0, 1, 0) },
    uMoonCol: { value: new THREE.Color(0, 0, 0) },
    uSkyAmb: { value: new THREE.Color(0.3, 0.4, 0.6) },
    uGndAmb: { value: new THREE.Color(0.08, 0.08, 0.06) },
    uEnv: { value: cubeRT.texture },
    uNoise: { value: noiseTex },
    uFog: { value: 0.00024 },
    uSnow: { value: 1150 },
  };
  const mountMat = (tree: boolean): THREE.ShaderMaterial =>
    new THREE.ShaderMaterial({
      uniforms: { ...mountUniforms, uTree: { value: tree ? 1 : 0 } },
      vertexShader: /* glsl */ `
        attribute float aH; varying vec3 vW; varying vec3 vN; varying float vH;
        void main(){
          vec4 lp = vec4(position, 1.0); vec3 nn = normal;
          #ifdef USE_INSTANCING
            lp = instanceMatrix * lp; nn = mat3(instanceMatrix) * nn;
          #endif
          vec4 wp = modelMatrix * lp; vW = wp.xyz; vN = normalize(mat3(modelMatrix) * nn); vH = aH;
          gl_Position = projectionMatrix * viewMatrix * wp;
        }`,
      fragmentShader: /* glsl */ `
        uniform vec3 uSunDir, uSunCol, uMoonDir, uMoonCol, uSkyAmb, uGndAmb; uniform samplerCube uEnv; uniform sampler2D uNoise;
        uniform float uFog, uSnow, uTree; varying vec3 vW; varying vec3 vN; varying float vH;
        void main(){
          vec3 n = normalize(vN);
          float e = 0.6, hx = texture2D(uNoise, (vW.xz + vec2(e, 0.0)) * 0.006).g - texture2D(uNoise, (vW.xz - vec2(e, 0.0)) * 0.006).g;
          float hz = texture2D(uNoise, (vW.xz + vec2(0.0, e)) * 0.006).g - texture2D(uNoise, (vW.xz - vec2(0.0, e)) * 0.006).g;
          if (uTree < 0.5) n = normalize(n + vec3(-hx, 0.0, -hz) * 9.0 * (1.0 - smoothstep(0.85, 0.98, n.y)));
          float n1 = texture2D(uNoise, vW.xz * 0.0011).r, n2 = texture2D(uNoise, vW.xz * 0.009).g, n3 = texture2D(uNoise, vec2(vW.x * 0.004, vW.y * 0.03)).r;
          // 바위: 층 무늬(높이 따라 띠) + 결
          vec3 rock = mix(vec3(0.12, 0.11, 0.105), vec3(0.27, 0.24, 0.21), n2) * (0.75 + 0.5 * n3);
          vec3 grass = mix(vec3(0.05, 0.09, 0.04), vec3(0.14, 0.17, 0.07), n1);
          float flat_ = smoothstep(0.72, 0.9, n.y);
          // 풀은 낮은 곳 · 완만한 곳, 눈은 실제 높이(해발) 기준 — 낮은 언덕엔 눈이 없다
          vec3 alb = mix(rock, grass, flat_ * (1.0 - smoothstep(260.0, 520.0, vW.y + (n1 - 0.5) * 160.0)));
          // 눈: 높은 곳 + 완만한 곳, 경계는 잡음으로 들쭉날쭉
          float snow = smoothstep(uSnow - 60.0, uSnow + 50.0, vW.y + (n1 - 0.5) * 260.0 + (n2 - 0.5) * 90.0) * smoothstep(0.42, 0.68, n.y);
          alb = mix(alb, vec3(0.88, 0.9, 0.95), snow);
          if (uTree > 0.5) alb = vec3(0.03, 0.055, 0.035) * (0.8 + 0.4 * n2);
          float dif = max(dot(n, uSunDir), 0.0);
          float mdif = max(dot(n, uMoonDir), 0.0);
          vec3 amb = mix(uGndAmb, uSkyAmb, n.y * 0.5 + 0.5);
          vec3 col = alb * (uSunCol * dif + uMoonCol * mdif + amb);
          // 공기 원근 — 그 방향 하늘색으로 녹아든다 (높을수록 공기가 옅다)
          vec3 v = vW - cameraPosition; float d = length(v); vec3 dir = v / d;
          vec3 fd = normalize(vec3(dir.x, max(dir.y, 0.0) * 0.6 + 0.04, dir.z));
          vec3 fogC = textureLod(uEnv, fd, 3.6).rgb; // 흐린 밉맵 — 해 원반이 그대로 박히면 빛기둥이 생긴다
          // 해 쪽 하늘은 다른 곳보다 수십 배 밝다 — 그대로 안개 색으로 쓰면 해 쪽 앞 들판 · 나무까지 하얗게 덮인다.
          // 머리 위 하늘 밝기의 몇 배까지만 (해 쪽이 조금 더 밝은 느낌은 남긴다)
          vec3 lw = vec3(0.2126, 0.7152, 0.0722);
          vec3 zen = textureLod(uEnv, normalize(vec3(fd.x * 0.25, 1.0, fd.z * 0.25)), 5.0).rgb;
          float capL = 2.2 * dot(zen, lw) + 1e-5;
          float fL = dot(fogC, lw);
          fogC *= min(1.0, capL / max(fL, 1e-5));
          float hf = exp(-max(vW.y, 0.0) * 0.0009);
          // 가까운 곳(앞 들판 · 기슭 숲)은 공기가 얇다 — 300 너머부터 안개
          float fog = 1.0 - exp(-max(d - 300.0, 0.0) * uFog * hf);
          col = mix(col, fogC, clamp(fog, 0.0, 1.0));
          gl_FragColor = vec4(col, 1.0);
          #include <tonemapping_fragment>
          #include <colorspace_fragment>
        }`,
    });
  const matM = mountMat(false);
  const matT = mountMat(true);

  // 능선 잡음 (ridged multifractal) — 날카로운 봉우리와 골짜기
  const vrand = rng(23);
  const perm = Array.from({ length: 512 }, () => vrand());
  const vn = (x: number, y: number): number => {
    const xi = Math.floor(x);
    const yi = Math.floor(y);
    const fx = x - xi;
    const fy = y - yi;
    const h = (i: number, j: number): number => perm[(((i * 73 + j * 151) % 512) + 512) % 512]!;
    const sx = fx * fx * (3 - 2 * fx);
    const sy = fy * fy * (3 - 2 * fy);
    return lerp(lerp(h(xi, yi), h(xi + 1, yi), sx), lerp(h(xi, yi + 1), h(xi + 1, yi + 1), sx), sy);
  };
  const ridged = (x: number, y: number, oct: number): number => {
    let s = 0;
    let a = 0.5;
    let f = 1;
    let w = 1;
    for (let o = 0; o < oct; o++) {
      let v = 1 - Math.abs(vn(x * f, y * f) * 2 - 1);
      v *= v * w;
      w = clamp(v * 2, 0, 1);
      s += v * a;
      f *= 2.03;
      a *= 0.5;
    }
    return s;
  };
  /** 산 한 겹: 가운데 깊이 z0, 폭 W, 깊이 D, 높이 H. 앞뒤 가장자리는 낮아져 능선 띠가 된다 */
  const geos: THREE.BufferGeometry[] = [];
  /** 산 한 겹의 높이 (겹 안 좌표 x · z) — 메시와 나무 놓기가 같은 식을 쓴다 */
  const layerY = (x: number, z: number, D: number, H: number, sc: number, seed: number): number => {
    const q = z / (D / 2); // -1 (뒤) … 1 (앞)
    const prof = Math.exp(-((q + 0.15) ** 2) * 2.2);
    const big = 0.55 + 0.45 * vn(x * sc * 0.25 + seed, seed * 3);
    const r = ridged(x * sc + seed, z * sc + seed * 7, 6);
    return Math.max(-20, H * prof * big * (r * 1.25 - 0.1));
  };
  const layer = (z0: number, W: number, D: number, H: number, sc: number, seed: number, nx: number, nz: number): THREE.Mesh => {
    const g = new THREE.PlaneGeometry(W, D, nx, nz).rotateX(-Math.PI / 2);
    const p = g.attributes.position as THREE.BufferAttribute;
    const aH = new Float32Array(p.count);
    for (let i = 0; i < p.count; i++) {
      const x = p.getX(i);
      const z = p.getZ(i);
      const y = layerY(x, z, D, H, sc, seed);
      p.setY(i, y);
      aH[i] = clamp(y / H, 0, 1);
    }
    g.setAttribute('aH', new THREE.BufferAttribute(aH, 1));
    g.computeVertexNormals();
    geos.push(g);
    const m = new THREE.Mesh(g, matM);
    m.position.z = z0;
    scene.add(m);
    return m;
  };
  layer(-4200, 22000, 2600, 2100, 0.00042, 1.7, 220, 40);
  layer(-2600, 13000, 1600, 1250, 0.0007, 5.1, 220, 40);
  layer(-1500, 8000, 1100, 640, 0.0012, 9.3, 200, 40);
  const near = layer(-650, 4600, 900, 230, 0.0021, 2.2, 180, 40);
  // 앞 들판 (완만한 언덕)
  const fieldY = (x: number, z: number): number => (vn(x * 0.004, z * 0.004) - 0.5) * 40 + (vn(x * 0.013 + 4, z * 0.013) - 0.5) * 10 - 10;
  const fg = new THREE.PlaneGeometry(3000, 900, 120, 40).rotateX(-Math.PI / 2);
  {
    const p = fg.attributes.position as THREE.BufferAttribute;
    const aH = new Float32Array(p.count);
    for (let i = 0; i < p.count; i++) {
      const x = p.getX(i);
      const z = p.getZ(i);
      p.setY(i, fieldY(x, z));
      aH[i] = 0;
    }
    fg.setAttribute('aH', new THREE.BufferAttribute(aH, 1));
    fg.computeVertexNormals();
    geos.push(fg);
  }
  const field = new THREE.Mesh(fg, matM);
  field.position.z = 0;
  scene.add(field);
  // 소나무 (원뿔 셋 겹친 실루엣) — 앞 들판 · 가까운 산 기슭에
  const treeG = new THREE.ConeGeometry(1, 2.2, 7, 1).translate(0, 1.1, 0);
  const t2 = new THREE.ConeGeometry(0.78, 1.8, 7, 1).translate(0, 2.0, 0);
  const t3 = new THREE.ConeGeometry(0.55, 1.4, 7, 1).translate(0, 2.8, 0);
  const pine = (() => {
    const parts = [treeG, t2, t3];
    const pos: number[] = [];
    const nor: number[] = [];
    for (const pg of parts) {
      const ng = pg.toNonIndexed();
      pos.push(...(ng.attributes.position!.array as Float32Array));
      nor.push(...(ng.attributes.normal!.array as Float32Array));
      ng.dispose();
      pg.dispose();
    }
    const g = new THREE.BufferGeometry();
    g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
    g.setAttribute('normal', new THREE.Float32BufferAttribute(nor, 3));
    g.setAttribute('aH', new THREE.Float32BufferAttribute(new Float32Array(pos.length / 3), 1));
    return g;
  })();
  geos.push(pine);
  const NT = 900;
  const trees = new THREE.InstancedMesh(pine, matT, NT);
  {
    const m4 = new THREE.Matrix4();
    // 위에서 내려본 땅 높이 = 들판(|x| ≤ 1500, |z| ≤ 450) · 가까운 산(z0 −650, 4600 × 900) 중 높은 쪽 — 광선 6천 번(1초) 대신 식으로
    const groundAt = (x: number, z: number): number => {
      let y = -Infinity;
      if (Math.abs(x) <= 1500 && Math.abs(z) <= 450) y = fieldY(x, z);
      if (Math.abs(x) <= 2300 && Math.abs(z + 650) <= 450) y = Math.max(y, layerY(x, z + 650, 900, 230, 0.0021, 2.2));
      return y;
    };
    const q = new THREE.Quaternion();
    const s = new THREE.Vector3();
    const tr = rng(41);
    field.updateMatrixWorld();
    near.updateMatrixWorld();
    let k = 0;
    for (let tries = 0; tries < 6000 && k < NT; tries++) {
      const onNear = tr() < 0.45;
      const x = (tr() - 0.5) * (onNear ? 4200 : 2600);
      const z = onNear ? -650 + (tr() - 0.2) * 700 : -380 + tr() * 520;
      // 무리 지어 자라게 (잡음이 높은 곳만)
      if (vn(x * 0.006 + 9, z * 0.006) < 0.5) continue;
      const gy = groundAt(x, z);
      if (!Number.isFinite(gy)) continue;
      // 경사: 주변 높이 차로 법선 y (0.8 아래면 너무 가팔라 나무 없음)
      const e = 6;
      const dx = groundAt(x + e, z) - groundAt(x - e, z);
      const dz = groundAt(x, z + e) - groundAt(x, z - e);
      const ny = (2 * e) / Math.hypot(dx, 2 * e, dz);
      if (!Number.isFinite(ny) || ny < 0.8) continue;
      const hit = { point: new THREE.Vector3(x, gy, z) };
      const sz = (onNear ? 14 : 7) * (0.7 + tr() * 0.6);
      q.setFromAxisAngle(new THREE.Vector3(0, 1, 0), tr() * 6.28);
      s.set(sz * (0.8 + tr() * 0.3), sz, sz * (0.8 + tr() * 0.3));
      m4.compose(hit.point, q, s);
      trees.setMatrixAt(k++, m4);
    }
    trees.count = k;
    trees.instanceMatrix.needsUpdate = true;
  }
  scene.add(trees);

  // ── 별 (색온도 · 밝기 · 반짝임) ──
  const NSTAR = 11000;
  const sp = new Float32Array(NSTAR * 3);
  const ss = new Float32Array(NSTAR);
  const sc = new Float32Array(NSTAR * 3);
  const sph = new Float32Array(NSTAR);
  {
    const r0 = rng(5);
    const bn = new THREE.Vector3(0.63, -0.71, -0.32).normalize();
    const tmp = new THREE.Vector3();
    const temps = [
      [0.62, 0.72, 1.0],
      [0.82, 0.88, 1.0],
      [1.0, 1.0, 1.0],
      [1.0, 0.94, 0.8],
      [1.0, 0.8, 0.56],
      [1.0, 0.66, 0.46],
    ];
    for (let i = 0; i < NSTAR; i++) {
      // 40% 는 은하수 띠 근처에 몰리게
      for (;;) {
        tmp.set(r0() * 2 - 1, r0() * 2 - 1, r0() * 2 - 1);
        const l = tmp.length();
        if (l > 1 || l < 0.05) continue;
        tmp.divideScalar(l);
        if (i % 5 < 2 && Math.abs(tmp.dot(bn)) > 0.12 + r0() * 0.1) continue;
        break;
      }
      if (tmp.y < -0.05) tmp.y = -tmp.y;
      sp.set([tmp.x * 8000, tmp.y * 8000, tmp.z * 8000], i * 3);
      const mag = Math.pow(r0(), 9); // 대부분 어둡고 몇 개만 밝다
      ss[i] = 0.8 + mag * 5.2;
      const t = temps[Math.min(5, Math.floor(Math.pow(r0(), 1.4) * 6))]!;
      const br = 0.35 + mag * 2.4 + r0() * 0.25;
      sc.set([t[0]! * br, t[1]! * br, t[2]! * br], i * 3);
      sph[i] = r0() * 100;
    }
  }
  const sg = new THREE.BufferGeometry();
  sg.setAttribute('position', new THREE.BufferAttribute(sp, 3));
  sg.setAttribute('aSize', new THREE.BufferAttribute(ss, 1));
  sg.setAttribute('aCol', new THREE.BufferAttribute(sc, 3));
  sg.setAttribute('aPh', new THREE.BufferAttribute(sph, 1));
  const starMat = new THREE.ShaderMaterial({
    uniforms: { uK: { value: 0 }, uTime: { value: 0 }, uPx: { value: 1 } },
    vertexShader: /* glsl */ `
      attribute float aSize, aPh; attribute vec3 aCol; uniform float uTime, uPx; varying vec3 vC; varying float vTw;
      void main(){
        vec4 mv = modelViewMatrix * vec4(position, 1.0);
        gl_Position = projectionMatrix * mv;
        vec3 wd = normalize((modelMatrix * vec4(position, 1.0)).xyz);
        float ext = smoothstep(-0.02, 0.25, wd.y); // 지평선 가까운 별은 공기에 가려 흐리게
        vTw = (0.72 + 0.28 * sin(uTime * (2.0 + mod(aPh, 3.0)) + aPh)) * ext;
        vC = aCol;
        gl_PointSize = aSize * uPx * (0.6 + 0.4 * ext);
      }`,
    fragmentShader: /* glsl */ `
      uniform float uK; varying vec3 vC; varying float vTw;
      void main(){
        vec2 q = gl_PointCoord - 0.5; float r = length(q);
        float core = smoothstep(0.5, 0.0, r);
        float a = core * core;
        gl_FragColor = vec4(vC * a * vTw * uK * 2.2, 1.0);
      }`,
    transparent: true,
    blending: THREE.AdditiveBlending,
    depthWrite: false,
  });
  const stars = new THREE.Points(sg, starMat);
  scene.add(stars);

  // ── 달 (크레이터 무늬 캔버스 + 달무리) ──
  const moonCanvas = document.createElement('canvas');
  moonCanvas.width = moonCanvas.height = 256;
  {
// … (146줄 더 — 전체는 라이브 견본 페이지의 「코드」 탭)
```

### i224 견본 항목 — `src/demos/demosLook2.ts:4508`
```ts
  i224: { kind: '3d', caption: 'three Sky (레일리 · 미 산란) + 공기 원근 산맥 — 낮 파랑 → 노을 → 땅거미 → 별 · 은하수 · 달빛 밤', make: () => makeSky() }
```

## 관련 기술
- 먼저 알면 좋은 기술: [3점 조명 (반구 + 해 + 테두리)](https://ai-techstudio.web.app/ai/t/u14.md) `u14` · [환경 반사 (RoomEnvironment + PMREM)](https://ai-techstudio.web.app/ai/t/u12.md) `u12`
- 다음에 해 볼 기술: [하루 시간 흐름 (해 · 하늘색)](https://ai-techstudio.web.app/ai/t/i431.md) `i431` · [은하수 띠 (먼지 줄 · 밝은 팽대부)](https://ai-techstudio.web.app/ai/t/i523.md) `i523`
- 참고 문서: [three.js 예제 — webgl_shaders_sky](https://threejs.org/examples/#webgl_shaders_sky) · [three.js 소스 — Sky](https://github.com/mrdoob/three.js/blob/dev/examples/jsm/objects/Sky.js)
