# AI 꾸러미 — 밤 장면 (어둠 속 빛나는 것만) — Night scene lighting (high contrast)
> 장면 전체를 아주 어둡게 깔고 켜진 창 · 가로등 · 반딧불만 밝게 해, 어둠과 빛의 대비로 밤 분위기를 만든다.  
> 견본: https://ai-techstudio.web.app/#t/i432

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

## 주문서

### 만들어 줘: 밤 장면 (어둠 속 빛나는 것만) — Night scene lighting (high contrast)

#### 1. 목표
작은 마을을 따뜻한 등불 밤 밤 장면으로 바꿔 줘 — 전체는 어둡게, 켜진 창 · 등만 밝게 대비를 주인공으로.

#### 2. 핵심 기술 용어
- **Night scene lighting (high contrast)** — 어둠 속 빛만 밝은 밤 조명
- **Emissive windows + glow sprites** — 스스로 빛나는 창 재질 + 빛무리 스프라이트
- **Fake light pool (additive decal)** — 바닥 빛 웅덩이를 더하기 판으로 흉내
- **Moonlight (cool directional light)** — 약한 푸른 방향 빛 하나

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

#### 4. 조건
- 바탕은 어둡게: 반구 0x2a3766 / 0x0a0c18 세기 0.32, 달빛 0x8aa6ff 세기 0.45
- 빛나는 것은 발광 세기(창 2.6) + 빛무리 스프라이트(AdditiveBlending · depthWrite false)
- 가로등 바닥 빛은 바닥 위 판(y 0.1, 크기 3.2, 투명도 0.45)으로, 진짜 점광은 3개까지
- 대비 끔(비교용)은 반구 1.6 으로 고르게 — 켬/끔을 나란히 확인할 것
- 빛 번짐은 문턱을 높게 — 켜진 것만 번지고 나머지는 또렷하게

#### 5. 완성 기준 (이게 보이면 성공)
- 마을이 짙은 남색으로 어둡고 창 · 가로등만 주황으로 또렷이 빛난다
- 창이 하나씩 차례로 깜빡이며 켜지고, 8초마다 꺼졌다 다시 켜진다
- 「대비」를 끄면 전체가 고르게 어두운 회색이 되어 밤 느낌이 사라진다 (켬과 비교)
- 반딧불이 떠다니며 반짝이고, 폰에서도 60fps

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

## 원리
- 밤을 「다 어둡게」 하면 아무것도 안 보인다. 바탕(반구 0.32 · 달빛 0.45)은 아주 어둡게, 빛나는 것만 밝게 해야 밤처럼 보인다.
- 창은 발광 재질 세기만 올리고, 둘레에 더하기 섞기 빛무리 스프라이트를 겹쳐 번지는 빛을 흉내 낸다.
- 가로등 바닥 빛은 진짜 빛 대신 바닥에 둥근 빛 그림(더하기, 0.45)을 깐다. 진짜 점광은 앞쪽 3개만.
- 창이 하나씩 켜지는 순서를 섞고 처음 0.28초는 깜빡이게 해 사람이 사는 느낌을 준다.

## 핵심 코드 — 어두운 바탕 + 창 발광 · 빛무리 + 바닥 빛 판
(발췌: demos/demosCamLight.ts demoNight() 를 정리)
```ts
import * as THREE from 'three';

// 1) 바탕은 아주 어둡게 — 달빛 하나
hemi.color.set(0x2a3766);
hemi.groundColor.set(0x0a0c18);
hemi.intensity = 0.32;
sun.color.set(0x8aa6ff);
sun.intensity = 0.45;

const warm = 0xffb35a;
const glow = glowTex(); // 가운데가 하얗고 바깥으로 투명해지는 둥근 그림
const add = (o: THREE.SpriteMaterialParameters & THREE.MeshBasicMaterialParameters) => ({ map: glow, blending: THREE.AdditiveBlending, depthWrite: false, transparent: true, ...o });
const poolGeo = new THREE.PlaneGeometry(1, 1).rotateX(-Math.PI / 2);

lamps.forEach((p: THREE.Vector3, i: number) => {
  const halo = new THREE.Sprite(new THREE.SpriteMaterial(add({ color: warm })));
  halo.position.copy(p);
  halo.scale.setScalar(1.6);
  // 바닥 빛 웅덩이 — 진짜 빛 대신 더하기 판
  const pool = new THREE.Mesh(poolGeo, new THREE.MeshBasicMaterial(add({ color: warm, opacity: 0.45 })));
  pool.position.set(p.x, 0.1, p.z);
  pool.scale.setScalar(3.2);
  scene.add(halo, pool);
  if (i < 3) { // 진짜 점광은 앞쪽 몇 개만
    const l = new THREE.PointLight(warm, 5, 5, 2);
    l.position.copy(p);
    scene.add(l);
  }
});

// 2) 창은 발광 세기만 — 켜지는 시각을 섞고 처음 0.28초는 깜빡
function windowLevel(c: number, t0: number, i: number): number {
  let k = c > t0 ? 1 : 0;
  if (c > t0 && c < t0 + 0.28) k = hash1(Math.floor(c * 30) + i) > 0.45 ? 1 : 0.15;
  return k; // material.emissiveIntensity = k * 2.6
}
```

## 흔한 실수 · 확인 목록
- [ ] **켜진 것까지 어둡게 칠하면 밤이 아니라 흐린 날이 된다** — 버섯 마을에서 지적받은 점 — 빛나는 것 말고는 다 어둡게, 등불 빛은 좁게. 대비가 핵심이다.
- [ ] **꺼진 물체를 파랗게 물들이면 색이 엉뚱해진다** — 꺼진 것은 같은 색을 어둡게만 (버섯 갓에서 겪음).
- [ ] **등마다 점광을 두면 느려진다** — 바닥 빛은 더하기 판으로 흉내, 진짜 점광은 앞쪽 3개만. 점광 수는 늘 같게.

## 완성 기준 체크리스트
- [ ] 마을이 짙은 남색으로 어둡고 창 · 가로등만 주황으로 또렷이 빛난다
- [ ] 창이 하나씩 차례로 깜빡이며 켜지고, 8초마다 꺼졌다 다시 켜진다
- [ ] 「대비」를 끄면 전체가 고르게 어두운 회색이 되어 밤 느낌이 사라진다 (켬과 비교)
- [ ] 반딧불이 떠다니며 반짝이고, 폰에서도 60fps

## 이 기술 정보
- id: `i432` · 분류: 그래픽 · 셰이더 › 빛 · 환경 · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의) — 진짜 점광 3개(그림자 없음) + 빛무리 스프라이트 · 바닥 빛 판(더하기) + 반딧불 46점. 점광을 등 수만큼 늘리면 무거워진다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i432
- 쓰면 좋을 때: 밤 마을 · 숲 · 버섯 마을 같은 무대 / 불 켜기 · 끄기가 놀이인 퍼즐 (켜진 것이 잘 보여야 함) / 반딧불 · 등불 연출
- 쓰지 말 때: 낮 장면을 그냥 어둡게만 — 그러면 흐릿한 회색이 된다. 대비가 핵심 / 점광을 등마다 하나씩 — 대신 바닥 빛 판 + 점광 몇 개

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### demoNight — `src/demos/demosCamLight.ts:1764`
```ts
function demoNight(): Scene3D {
  const { scene, cam, sky, vil, sun, hemi } = villageWorld({ perWindow: true });
  sky.u.uTop.value.set(0x050a22);
  sky.u.uHor.value.set(0x18224a);
  sky.u.uBot.value.set(0x0e1430);
  sky.u.uSunK.value = 0;
  sky.u.uMoonK.value = 1;
  sky.u.uStars.value = 1;
  const moonDir = new THREE.Vector3(-0.45, 0.55, -0.7).normalize();
  sky.u.uSun.value.copy(moonDir).negate();
  sun.position.copy(moonDir).multiplyScalar(20);
  sun.color.set(0x8aa6ff);
  const glow = glowTex();
  const warm = 0xffb35a;
  const lampHalos: THREE.Sprite[] = [];
  const pools: THREE.Mesh[] = [];
  const lights: THREE.PointLight[] = [];
  const poolGeo = new THREE.PlaneGeometry(1, 1).rotateX(-Math.PI / 2);
  vil.lamps.forEach((p, i) => {
    const s = new THREE.Sprite(new THREE.SpriteMaterial({ map: glow, color: warm, blending: THREE.AdditiveBlending, depthWrite: false, transparent: true }));
    s.position.copy(p);
    s.scale.setScalar(1.6);
    scene.add(s);
    lampHalos.push(s);
    const pm = new THREE.Mesh(poolGeo, new THREE.MeshBasicMaterial({ map: glow, color: warm, blending: THREE.AdditiveBlending, depthWrite: false, transparent: true, opacity: 0.45 }));
    pm.position.set(p.x, 0.1, p.z);
    pm.scale.setScalar(3.2);
    scene.add(pm);
    pools.push(pm);
    if (i < 3) {
      const l = new THREE.PointLight(warm, 5, 5, 2);
      l.position.copy(p);
      scene.add(l);
      lights.push(l);
    }
  });
  const wp = new THREE.Vector3();
  const winHalos: THREE.Sprite[] = vil.windows.map((w) => {
    const s = new THREE.Sprite(new THREE.SpriteMaterial({ map: glow, color: 0xffc46b, blending: THREE.AdditiveBlending, depthWrite: false, transparent: true }));
    w.getWorldPosition(wp);
    s.position.copy(wp);
    s.scale.setScalar(1.0);
    scene.add(s);
    return s;
  });
  const order = vil.windows.map((_, i) => i).sort((a, b) => hash1(a * 3.1) - hash1(b * 3.1));
  const onAt = new Map<number, number>();
  order.forEach((wi, k) => onAt.set(wi, 0.6 + (k / order.length) * 3.2));
  /* 반딧불 */
  const N = 46;
  const fg = new THREE.BufferGeometry();
  const fp = new Float32Array(N * 3);
  const fph = new Float32Array(N);
  const r0 = rng(21);
  for (let i = 0; i < N; i++) {
    const a = r0() * TAU;
    const rr = 2.2 + r0() * 4.3;
    fp.set([Math.cos(a) * rr, 0.3 + r0() * 1.4, Math.sin(a) * rr], i * 3);
    fph[i] = r0() * 100;
  }
  fg.setAttribute('position', new THREE.BufferAttribute(fp, 3));
  fg.setAttribute('aPh', new THREE.BufferAttribute(fph, 1));
  const fu = { uTime: { value: 0 }, uPx: { value: 300 } };
  const flies = new THREE.Points(
    fg,
    new THREE.ShaderMaterial({
      uniforms: fu,
      transparent: true,
      depthWrite: false,
      blending: THREE.AdditiveBlending,
      vertexShader: /* glsl */ `
        attribute float aPh; uniform float uTime, uPx; varying float vA;
        void main(){
          vec3 p = position + vec3(sin(uTime*0.7+aPh)*0.5, sin(uTime*1.1+aPh*1.7)*0.25, cos(uTime*0.6+aPh*0.8)*0.5);
          vec4 mv = modelViewMatrix * vec4(p,1.0);
          vA = 0.35 + 0.65 * pow(0.5 + 0.5*sin(uTime*2.3 + aPh*3.0), 3.0);
          gl_PointSize = uPx * 0.09 / -mv.z;
          gl_Position = projectionMatrix * mv;
        }`,
      fragmentShader: /* glsl */ `
        varying float vA;
        void main(){
          float d = length(gl_PointCoord - 0.5);
          float a = smoothstep(0.5, 0.0, d);
          a = a*a;
          gl_FragColor = vec4(vec3(0.85, 1.0, 0.45) * a * vA * 1.6, 1.0);
        }`,
    }),
  );
  flies.frustumCulled = false;
  scene.add(flies);
  const hud = new Hud();
  const st = { contrast: true, moon: 0.45, win: 2.6, flies: true };
  return {
    scene,
    camera: cam,
    update(t) {
      fu.uTime.value = t;
      sky.u.uTime.value = t;
      const c = t % 8;
      const off = c > 7.3 ? ease((c - 7.3) / 0.5) : 0;
      const on = st.contrast;
      sun.intensity = on ? st.moon : st.moon * 0.6;
      hemi.color.set(0x2a3766);
      hemi.groundColor.set(0x0a0c18);
      hemi.intensity = on ? 0.32 : 1.6;
      vil.windows.forEach((w, i) => {
        const t0 = onAt.get(i) ?? 1;
        let k = c > t0 ? 1 : 0;
        if (c > t0 && c < t0 + 0.28) k = hash1(Math.floor(c * 30) + i) > 0.45 ? 1 : 0.15;
        k *= 1 - off;
        if (!on) k = 0;
        (w.material as THREE.MeshStandardMaterial).emissiveIntensity = k * st.win;
        (winHalos[i]!.material as THREE.SpriteMaterial).opacity = k * 0.75;
      });
      vil.lampMat.emissiveIntensity = on ? 3.2 : 0;
      lampHalos.forEach((s, i) => {
        s.visible = on;
        s.scale.setScalar(1.5 + Math.sin(t * 3 + i) * 0.06);
      });
      pools.forEach((p) => (p.visible = on));
      lights.forEach((l) => (l.intensity = on ? 5 : 0));
      flies.visible = st.flies;
      const yaw = 0.45 + Math.sin(t * 0.12) * 0.25;
      cam.position.set(Math.sin(yaw) * 13.5, 7.2, Math.cos(yaw) * 13.5);
      cam.lookAt(0, 0.3, 0);
    },
    render(r, w, h) {
      fu.uPx.value = h;
      withState(r, () => {
        r.render(scene, cam);
        const u = Math.min(1.9, w / 280, h / 175);
        hud.label('a', st.contrast ? '켜진 창 · 등만 밝게' : '비교: 고르게 어둡게', 10 * u, 10 * u, 16 * u, { bg: st.contrast ? 'rgba(14,20,44,0.75)' : 'rgba(200,60,80,0.85)' });
        hud.draw(r, w, h);
      });
    },
    controls: [
      { type: 'toggle', label: '대비 (끄면 전체를 고르게 어둡게)', value: true, on: (v) => (st.contrast = v) },
      { type: 'range', label: '달빛 세기', min: 0, max: 1.5, step: 0.05, value: 0.45, on: (v) => (st.moon = v) },
      { type: 'range', label: '창 불빛', min: 0.5, max: 5, step: 0.1, value: 2.6, on: (v) => (st.win = v) },
      { type: 'toggle', label: '반딧불', value: true, on: (v) => (st.flies = v) },
    ] as Control[],
    dispose() {
      disposeTree(scene);
      poolGeo.dispose();
      glow.dispose();
      hud.dispose();
    },
  };
}
```

### i432 견본 항목 — `src/demos/demosCamLight.ts:2482`
```ts
  i432: { kind: '3d', caption: '전체는 어둡게, 켜진 창 · 가로등만 밝게 — 대비가 주인공', make: () => demoNight() }
```

## 관련 기술
- 먼저 알면 좋은 기술: [하루 시간 흐름 (해 · 하늘색)](https://ai-techstudio.web.app/ai/t/i431.md) `i431` · [스포트라이트 · 움직이는 점광](https://ai-techstudio.web.app/ai/t/u15.md) `u15`
- 다음에 해 볼 기술: [빛 번짐 (UnrealBloom)](https://ai-techstudio.web.app/ai/t/u20.md) `u20` · [실내 조명 (창 빛 · 전등)](https://ai-techstudio.web.app/ai/t/i433.md) `i433`
