# AI 꾸러미 — 노드 재질 (TSL — 코드로 셰이더 짜기) — TSL (Three.js Shading Language)
> 셰이더를 GLSL 글자 대신 TSL 함수 조립으로 짜서, 같은 재질 코드가 WebGPU 와 WebGL2 에서 모두 돌게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i494

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

## 주문서

### 만들어 줘: 노드 재질 (TSL — 코드로 셰이더 짜기) — TSL (Three.js Shading Language)

#### 1. 목표
용암 · 홀로그램 · 물결 · 디졸브 재질 넷을 TSL 노드 재질로 만들어 줘 — 셰이더 글자 없이 함수 조립으로, WebGPU · WebGL2 둘 다에서. 분위기는 어두운 무대 위 빛나는 재질.

#### 2. 핵심 기술 용어
- **TSL (Three.js Shading Language)** — three.js 노드 셰이더 언어 — 자바스크립트 함수로 셰이더를 조립
- **Node material (MeshStandardNodeMaterial)** — colorNode · emissiveNode · positionNode 를 끼우는 재질
- **WebGPURenderer (three/webgpu)** — WebGPU 가 없으면 WebGL2 로 자동 대체하는 렌더러
- **uniform() · mx_noise_float()** — 바꿀 수 있는 값 · 내장 잡음 함수

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

#### 4. 조건
- three/webgpu 와 three/tsl 을 함께 불러오고, 재질은 MeshStandardNodeMaterial 같은 Node 재질만 쓴다 (보통 재질은 노드를 못 받는다)
- renderer = new WebGPURenderer() 뒤 반드시 await renderer.init() — 안 기다리면 첫 그리기가 실패한다
- 시간 값은 uniform 하나로 만들고 매 프레임 .value 만 바꾸기 — 노드를 프레임마다 새로 만들지 않는다
- 반복문 잡음 대신 mx_noise_float 를 두세 번만 — 잡음을 여러 겹 부르면 컴파일이 길어진다
- WebGPU 가 없는 기기에서도 WebGL2 로 같은 화면이 나와야 한다 (forceWebGL 로 시험)

#### 5. 완성 기준 (이게 보이면 성공)
- 용암(흐르는 잡음 줄기 빛) · 홀로그램(가장자리 빛 + 흐르는 줄) · 물결(정점이 출렁) · 디졸브(잡음으로 구멍 + 주황 테두리) 넷이 나란히 보인다
- 구석에 지금 백엔드(WebGPU / WebGL2)가 표시되고, 「WebGL2 로 강제」해도 모양이 같다
- 「시간 빠르기」 슬라이더로 넷이 함께 빨라지거나 멈춘다
- 셰이더 문자열이 코드 어디에도 없다

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

## 원리
- TSL 에서는 positionLocal · normalView 같은 노드를 .mul() · .add() · mix() 로 이어 붙여 식을 만든다.
- 재질의 colorNode(색) · emissiveNode(빛) · positionNode(정점 위치) · opacityNode 에 그 식을 끼운다.
- 렌더러가 노드 그래프를 WebGPU 면 WGSL, WebGL2 면 GLSL 로 번역하므로 같은 코드가 둘 다에서 돈다.
- 시간처럼 매 프레임 바뀌는 값은 uniform(0) 으로 만들고 T.value 만 바꾼다 (셰이더는 다시 안 만든다).
- 버리기(discard)는 Fn(() => { Discard(조건); return 색; })() 처럼 함수 노드 안에서.

## 핵심 코드 — TSL 로 용암 · 디졸브 재질 조립하기
(발췌: demos/demosWebGPU.ts i494 build() 를 정리)
```ts
import * as GPU from 'three/webgpu';
import { Fn, Discard, uniform, positionLocal, mx_noise_float, mix, color, smoothstep, sin, vec3, abs, oneMinus } from 'three/tsl';

const renderer = new GPU.WebGPURenderer({ antialias: true }); // WebGPU 없으면 WebGL2 로
await renderer.init();
const T = uniform(0); // 매 프레임 T.value = 시간

// 용암 — 흐르는 잡음 두 겹, 0 근처를 가는 줄기로
const flow = vec3(0, T.mul(-0.35), T.mul(0.12));
const nl = mx_noise_float(positionLocal.mul(2.2).add(flow))
  .add(mx_noise_float(positionLocal.mul(4.6).sub(flow.mul(1.7))).mul(0.45));
const vein = oneMinus(abs(nl)).pow(6);
const lava = new GPU.MeshStandardNodeMaterial({ roughness: 0.85, metalness: 0 });
lava.colorNode = mix(color(0x1a0c07), color(0x3b2218), nl.mul(0.5).add(0.5));
lava.emissiveNode = mix(color(0xff2a00), color(0xffd36a), vein).mul(vein.mul(4.2));

// 디졸브 — 잡음이 문턱보다 낮은 곳은 버리고, 경계만 주황으로 빛나게
const thr = sin(T.mul(0.9)).mul(0.45).add(0.5);
const nd = mx_noise_float(positionLocal.mul(3.2)).mul(0.5).add(0.5);
const dis = new GPU.MeshStandardNodeMaterial({ roughness: 0.32, metalness: 0.35, side: GPU.DoubleSide });
dis.colorNode = Fn(() => {
  Discard(nd.lessThan(thr));
  return color(0x8f74ff);
})();
dis.emissiveNode = color(0xff7a2a).mul(smoothstep(thr.add(0.08), thr, nd).mul(5));

// 프레임마다: T.value += dt; renderer.render(scene, camera);
```

## 흔한 실수 · 확인 목록
- [ ] **await renderer.init() 을 빼면 첫 프레임이 실패한다** — WebGPURenderer 는 어댑터를 비동기로 얻는다. init 이 끝난 뒤에 장면을 그린다.
- [ ] **보통 MeshStandardMaterial 에 colorNode 를 넣으면 아무 일도 안 생긴다** — 노드를 받는 것은 MeshStandardNodeMaterial · MeshBasicNodeMaterial 같은 Node 재질뿐이다.
- [ ] **공용 WebGL 렌더러와 한 페이지에 두면 GPU 메모리가 빠듯하다** — 견본은 픽셀 비율을 1.5 까지로 묶고, 카드(작은 화면)에서는 렌더러를 아예 만들지 않는다.
- [ ] **TSL 노드의 타입이 너무 복잡해 TypeScript 가 버거워한다** — 견본은 TSL 노드를 any 로 다루고, three 0.186 에 실제로 있는 함수만 쓴다.

## 완성 기준 체크리스트
- [ ] 용암(흐르는 잡음 줄기 빛) · 홀로그램(가장자리 빛 + 흐르는 줄) · 물결(정점이 출렁) · 디졸브(잡음으로 구멍 + 주황 테두리) 넷이 나란히 보인다
- [ ] 구석에 지금 백엔드(WebGPU / WebGL2)가 표시되고, 「WebGL2 로 강제」해도 모양이 같다
- [ ] 「시간 빠르기」 슬라이더로 넷이 함께 빨라지거나 멈춘다
- [ ] 셰이더 문자열이 코드 어디에도 없다

## 이 기술 정보
- id: `i494` · 분류: 그래픽 · 셰이더 › WebGPU · 최신 렌더링 · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의) — 재질 비용은 GLSL 과 같다. 다만 three/webgpu 를 따로 불러와야 하고(번들 커짐), 첫 번역 · 컴파일이 GLSL 보다 조금 길다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i494
- 쓰면 좋을 때: WebGPU 로 옮길 장면에서 재질을 새로 짤 때 / onBeforeCompile 로 GLSL 문자열을 바꾸는 일이 너무 많아 고치기 어려울 때 / 같은 효과를 여러 재질에 조합해 붙일 때
- 쓰지 말 때: 이미 WebGLRenderer 로 잘 도는 게임 — 노드 재질은 WebGPURenderer 에서만 돈다. 그대로 두고 onBeforeCompile(u08)을 쓴다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i494 — `src/demos/demosWebGPU.ts:876`
```ts
const i494 = gpuDemo<{ speed: number }>({
  caption: 'TSL 로 짠 재질 넷 — 셰이더 글자 대신 함수를 조립 (같은 코드가 WebGPU · WebGL2 모두에서)',
  state: () => ({ speed: 1 }),
  card: card494,
  controls: (st) => [{ type: 'range', label: '시간 빠르기', min: 0, max: 3, step: 0.05, value: 1, on: (v) => (st.speed = v) }],
  async build(ctx, st) {
    const { GPU, TSL, renderer } = ctx;
    const {
      Fn,
      Discard,
      uniform,
      positionLocal,
      positionWorld,
      normalLocal,
      normalView,
      positionViewDirection,
      mx_noise_float,
      mix,
      color,
      smoothstep,
      sin,
      vec3,
      abs,
      dot,
      oneMinus,
      step,
      fract,
    } = TSL;
    renderer.toneMapping = GPU.ACESFilmicToneMapping;
    renderer.toneMappingExposure = 1;
    const scene = new GPU.Scene();
    scene.background = new GPU.Color(0x0b0912);
    const envRT = await roomEnv(ctx, 0.06);
    scene.environment = envRT.texture;
    scene.environmentIntensity = 0.6;
    const cam = new GPU.PerspectiveCamera(32, ctx.w / ctx.h, 0.1, 100);
    cam.position.set(0, 0, 10);
    const T = uniform(0);
    const time = T;

    // 용암
    const flow = vec3(0, time.mul(-0.35), time.mul(0.12));
    const n1 = mx_noise_float(positionLocal.mul(2.2).add(flow));
    const n2 = mx_noise_float(positionLocal.mul(4.6).sub(flow.mul(1.7)));
    const nl = n1.add(n2.mul(0.45));
    const vein = oneMinus(abs(nl)).pow(6);
    const lava = new GPU.MeshStandardNodeMaterial({ roughness: 0.85, metalness: 0 });
    lava.colorNode = mix(color(0x1a0c07), color(0x3b2218), nl.mul(0.5).add(0.5));
    lava.emissiveNode = mix(color(0xff2a00), color(0xffd36a), vein).mul(vein.mul(4.2));

    // 홀로그램
    const fres = oneMinus(abs(dot(normalView, positionViewDirection))).pow(2);
    const scan = sin(positionWorld.y.mul(70).sub(time.mul(6))).mul(0.5).add(0.5).pow(3);
    const glitch = step(0.92, fract(time.mul(0.55))).mul(sin(positionLocal.y.mul(25).add(time.mul(50))).mul(0.07));
    const holo = new GPU.MeshBasicNodeMaterial({ transparent: true, depthWrite: false, side: GPU.DoubleSide, blending: GPU.AdditiveBlending });
    holo.colorNode = color(0x46e8ff).mul(fres.mul(1.7).add(scan.mul(0.55)).add(0.06));
    holo.opacityNode = fres.add(scan.mul(0.35)).add(0.1).clamp(0, 1);
    holo.positionNode = positionLocal.add(vec3(glitch, 0, 0));

    // 물결
    const wv = sin(positionLocal.y.mul(18).add(time.mul(4))).add(sin(positionLocal.x.mul(11).add(time.mul(2.5))).mul(0.6));
    const water = new GPU.MeshPhysicalNodeMaterial({ color: 0x0b4f8f, roughness: 0.05, metalness: 0, clearcoat: 1, clearcoatRoughness: 0.03 });
    water.positionNode = positionLocal.add(normalLocal.mul(wv.mul(0.02)));
    water.emissiveNode = color(0x8fefff)
      .mul(wv.mul(0.31).add(0.5).clamp(0, 1).pow(6).mul(0.9))
      .add(color(0x1a78ff).mul(fres.mul(0.7)));

    // 디졸브
    const thr = sin(time.mul(0.9)).mul(0.45).add(0.5);
    const nd = mx_noise_float(positionLocal.mul(3.2)).mul(0.5).add(0.5);
    const dis = new GPU.MeshStandardNodeMaterial({ roughness: 0.32, metalness: 0.35, side: GPU.DoubleSide });
    dis.colorNode = Fn(() => {
      Discard(nd.lessThan(thr));
      return color(0x8f74ff);
    })();
    dis.emissiveNode = color(0xff7a2a).mul(smoothstep(thr.add(0.08), thr, nd).mul(5));

    const { RoundedBoxGeometry } = await import('three/examples/jsm/geometries/RoundedBoxGeometry.js');
    const objs: Any[] = [
      new GPU.Mesh(new GPU.SphereGeometry(0.85, 128, 96), lava),
      new GPU.Mesh(new GPU.TorusKnotGeometry(0.5, 0.17, 220, 32), holo),
      new GPU.Mesh(new GPU.SphereGeometry(0.85, 160, 120), water),
      new GPU.Mesh(new (RoundedBoxGeometry as Any)(1.25, 1.25, 1.25, 6, 0.18), dis),
    ];
    objs.forEach((o) => scene.add(o));
    // 받침 빛 (재질 색에 맞춘 바닥 원)
    const padGeo = new GPU.CircleGeometry(0.75, 48);
    const pads = CODE494.map((c) => {
      const m = new GPU.Mesh(padGeo, new GPU.MeshBasicMaterial({ color: new GPU.Color(c.col).multiplyScalar(0.35), transparent: true, opacity: 0.6, blending: GPU.AdditiveBlending, depthWrite: false }));
      m.rotation.x = -Math.PI / 2;
      scene.add(m);
      return m;
    });
    const key = new GPU.DirectionalLight(0xffffff, 1.6);
    key.position.set(2, 4, 5);
    scene.add(key, new GPU.AmbientLight(0x404060, 0.6));

    // 코드 표
    const row = el('div', 'position:absolute;left:12px;right:12px;bottom:12px;display:flex;gap:10px;');
    for (const c of CODE494) {
      const card = el('div', `flex:1;min-width:0;background:rgba(10,8,22,.82);border:1px solid rgba(255,255,255,.12);border-top:2px solid ${c.col};border-radius:10px;padding:7px 9px;`);
      card.append(el('div', `color:${c.col};font-weight:800;font-size:12.5px;margin-bottom:4px;`, c.title));
      card.append(el('pre', `margin:0;white-space:pre-wrap;word-break:break-all;font-family:${MONO};font-size:10.5px;line-height:1.45;color:#dcd6f2;`, c.code));
      row.appendChild(card);
    }
    ctx.layer.appendChild(row);

    const place = (w: number, h: number): void => {
      const dist = cam.position.z;
      const hh = Math.tan((cam.fov * Math.PI) / 360) * dist;
      const hw = hh * (w / h);
      const codeH = row.offsetHeight || 170;
      // 위 HUD(≈80px)와 아래 코드 표 사이 가운데에 놓는다
      const top = 80;
      const bottom = h - codeH - 24;
      const yc = (top + bottom) / 2;
      const upY = hh * (1 - (2 * yc) / h);
      const regionW = ((bottom - top) / h) * 2 * hh;
      const s = Math.min(1, (hw * 0.5) / 1.15, (regionW * 0.92) / 1.8);
      objs.forEach((o, i) => {
        o.position.set(hw * (-0.75 + i * 0.5), upY, 0);
        o.scale.setScalar(s);
        pads[i]!.position.set(o.position.x, o.position.y - 1.05 * s, 0);
        pads[i]!.scale.setScalar(s);
      });
    };
    place(ctx.w, ctx.h);
    let tt = 0;
    return {
      scene,
      camera: cam,
      frame(_t, dt) {
        tt += dt * st.speed;
        T.value = tt;
        objs[0].rotation.y = tt * 0.2;
        objs[1].rotation.set(tt * 0.3, tt * 0.5, 0);
        objs[2].rotation.y = tt * 0.15;
        objs[3].rotation.set(tt * 0.25, tt * 0.35, 0);
      },
      resize: place,
      dispose() {
        envRT.dispose();
      },
      note: () => '아래 코드가 위 재질을 만든 TSL 실제 줄 (줄임) — 렌더러가 WGSL 또는 GLSL 로 번역',
    };
  },
});
```

## 관련 기술
- 먼저 알면 좋은 기술: [재질에 셰이더 끼워 넣기](https://ai-techstudio.web.app/ai/t/u08.md) `u08`
- 다음에 해 볼 기술: [GPU 계산 입자 (수십만 개)](https://ai-techstudio.web.app/ai/t/i495.md) `i495` · [수천 캐릭터 군중 (구운 뼈 애니메이션 텍스처)](https://ai-techstudio.web.app/ai/t/i498.md) `i498`
- 참고 문서: [three.js 위키 — Three.js Shading Language](https://github.com/mrdoob/three.js/wiki/Three.js-Shading-Language)
