designpaca/research/three/03-shaders.md
Yun Chan 8808c672dc designpaca 초기 구현 — 스킬 · 설치 CLI · 배포 파이프라인
웹 디자인 파이프라인 스킬과 이를 5개 에이전트에 설치하는 CLI 를 담은 모노레포.

스킬 (packages/skill)
- SKILL.md 261줄 + 참조 문서 16개 3,349줄. progressive disclosure 로
  본문은 절차와 인덱스만, 지식은 references/ 로 분리
- 0~6단계 파이프라인. 규모에 따라 전체·연장·국소 세 경로로 분기
- 하드 게이트 12개는 grep·카운트로 검증 가능한 것만. 취향 판단은 제외
- 미학 프리셋 5종, AI 슬롭 지문 목록, 한글 조판 규칙,
  SVG 필터·three.js·인터랙티브 모션·HTML-in-Canvas 실전 지침

설치 CLI (packages/cli, packages/core)
- npx designpaca 온보딩 TUI. Claude Code · Codex · Cursor · Windsurf · AGENTS.md
- 매니페스트에 설치 시점 해시를 기록해 사용자가 고친 파일은 update 가 건너뛴다
- 타깃별로 본문의 references/ 경로를 실제 설치 위치로 재작성
- AGENTS.md 는 항상 로드되므로 본문 대신 303자 포인터만 주입
- Windsurf 는 12,000자 상한 초과 시 설치를 차단

배포 (build/ci, .forgejo/workflows)
- 태그 v* → 검사·테스트·빌드 → npmjs 배포 + Forgejo 레지스트리 미러
  → draft 릴리스 → Cloudflare Pages. 재실행 멱등

근거 (research/)
- 약 250개 웹 소스 조사 결과와 도그푸딩 검증 2건. 스킬의 모든 수치는 여기서 나온다

테스트 22개 통과 (core 16 · cli 6)
2026-08-20 10:48:00 +09:00

41 KiB
Raw Blame History

03 — GLSL 최소 지식 + 재사용 스니펫

대상: three.js ShaderMaterial (WebGL2 컨텍스트, GLSL ES 1.00 문법 작성). three는 WebGL2에서 GLSL1 코드를 자동으로 ESSL3로 변환한다(attributein, varyingout/in, texture2Dtexture, gl_FragColorpc_fragColor). 따라서 GLSL1 스타일로 쓰는 것이 가장 호환성이 높다. GLSL3 문법을 쓰려면 glslVersion: THREE.GLSL3를 명시해야 한다.


1. 파이프라인 — 딱 이만큼만 알면 된다

정점 데이터(attribute)  ──▶ [ Vertex Shader ]  ──▶ 래스터화 ──▶ [ Fragment Shader ] ──▶ 픽셀
   position, uv, normal        정점당 1회 실행         보간         픽셀당 1회 실행
   커스텀 attribute            gl_Position 출력      (varying)     gl_FragColor 출력
                               varying 출력
Vertex Shader Fragment Shader
실행 횟수 정점 수 (예: 16,384) 화면 픽셀 수 (예: 1920×1080×DPR² = 800만+)
필수 출력 gl_Position (클립 공간 vec4) gl_FragColor (RGBA, 0~1)
할 수 있는 것 정점 이동/변형, 데이터 준비 색 결정, 텍스처 샘플, discard
비용 감각 싸다 여기가 병목이다

최적화의 제1법칙

계산을 fragment에서 vertex로 옮겨라. 정점 16,384개 vs 픽셀 800만 개 — 500배 차이다. 노이즈, 거리 계산, 삼각함수는 가능한 한 vertex에서 하고 varying으로 넘긴다.

좌표 변환 체인

gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
//             └ 카메라 투영     └ view * model    └ 로컬 좌표
공간 얻는 법
로컬(object) position
월드 (modelMatrix * vec4(position, 1.0)).xyz
뷰(카메라) (modelViewMatrix * vec4(position, 1.0)).xyz
클립 projectionMatrix * modelViewMatrix * vec4(position, 1.0)
화면 UV (fragment) gl_FragCoord.xy / uResolution

2. GLSL 문법 최소셋

타입

float f = 1.0;        // 반드시 소수점! `1` 은 int 라서 타입 에러
int   i = 1;
bool  b = true;
vec2  v2 = vec2(1.0, 2.0);
vec3  v3 = vec3(v2, 3.0);          // 조합 가능
vec4  v4 = vec4(v3, 1.0);
vec4  w  = vec4(0.5);              // 전부 0.5
mat2  m2; mat3 m3; mat4 m4;
sampler2D tex;                     // uniform 으로만 선언 가능

스위즐 (swizzle)

vec4 c = vec4(1.0, 0.5, 0.2, 1.0);
c.rgb          // vec3(1.0, 0.5, 0.2)
c.bgr          // vec3(0.2, 0.5, 1.0) — 순서 바꾸기
c.xy           // vec2(1.0, 0.5)  (xyzw / rgba / stpq 모두 동일)
c.xxxx         // vec4(1.0)       — 반복 가능
c.rg = vec2(0.0);                  // 대입도 가능

precision (모바일에서 매우 중요)

precision highp float;    // 32bit. 위치/좌표 계산에 필요
precision mediump float;  // 16bit. 색상/일반 계산에 충분. 모바일에서 ~2배 빠름
precision lowp float;     // 8~10bit. 거의 쓰지 않음

실무 규칙

  • fragment shader 기본은 mediump, 정밀도가 필요한 변수만 개별적으로 highp.
  • vertex shader는 three가 기본 highp를 준다. 건드리지 마라.
  • GPGPU 위치 시뮬레이션은 반드시 highp. mediump면 좌표가 지터한다.
precision mediump float;
uniform highp sampler2D uPositions;   // 개별 지정
varying highp vec3 vWorldPos;

자주 쓰는 내장 함수

함수 의미 예시
mix(a, b, t) 선형 보간 mix(colA, colB, 0.5)
clamp(x, lo, hi) 범위 제한 clamp(n, 0.0, 1.0)
saturate 없다. clamp(x,0.,1.) 직접 써라
step(edge, x) x < edge ? 0 : 1 딱딱한 경계
smoothstep(e0, e1, x) 부드러운 0→1 가장 많이 쓴다
fract(x) 소수부 타일링 fract(uv * 10.0)
floor/ceil/round 셀 인덱스 floor(uv * 10.0)
mod(x, y) 나머지 반복
abs / sign
length(v) / distance(a,b) 원형 마스크
dot(a, b) 내적 프레넬, 조명
cross(a, b) 외적 (vec3만) 노멀
normalize(v) 단위벡터 0 벡터 주의normalize(v + 1e-5)
reflect(I, N) / refract(I, N, eta) 반사/굴절 유리
pow(x, y) pow(x, 2.2) 감마
exp(x) / exp2 감쇠 exp(-d*d)
sin / cos / atan(y, x) 극좌표, 파동
min / max
texture2D(sampler, uv) 텍스처 샘플 GLSL3면 texture()
dFdx / dFdy / fwidth 화면 미분 안티에일리어싱
discard 픽셀 버림 early-Z 깨짐 주의

분기와 루프 — 비용 감각

// 나쁨: 동적 분기. GPU 는 워프 단위로 실행하므로 양쪽 다 계산될 수 있다
if (uMode > 0.5) { heavyA(); } else { heavyB(); }

// 좋음: 산술로 대체
result = mix(a, b, step(0.5, uMode));

// 좋음: 컴파일 타임 분기 (material.defines 로 제어)
#if defined(USE_GLOW)
  color += uGlowColor * glowMask;
#endif
// 루프 카운트는 반드시 상수여야 한다 (GLSL ES 1.0 제약)
#define OCTAVES 4

float sum = 0.0, amp = 0.5, freq = 1.0;
for (int i = 0; i < OCTAVES; i++) {
  sum += snoise(p * freq) * amp;
  freq *= 2.02;
  amp  *= 0.5;
}

// uniform 을 루프 상한으로 쓸 수 없다. 상수 상한 + break 를 쓴다
#define MAX_LIGHTS 8
uniform float uLightCount;
uniform vec3  uLightPos[MAX_LIGHTS];

vec3 lighting = vec3(0.0);
for (int i = 0; i < MAX_LIGHTS; i++) {
  if (float(i) >= uLightCount) break;
  float d = distance(vWorldPos, uLightPos[i]);
  lighting += vec3(1.0) / (1.0 + d * d);
}

3. three가 자동으로 주입하는 것

ShaderMaterial (권장)

three가 아래를 자동으로 앞에 붙여준다. 다시 선언하면 redefinition 에러가 난다.

Vertex shader 자동 제공

// uniform
uniform mat4 modelMatrix;         // object → world
uniform mat4 modelViewMatrix;     // object → view
uniform mat4 projectionMatrix;    // view → clip
uniform mat4 viewMatrix;          // world → view
uniform mat3 normalMatrix;        // 노멀 변환용 (역전치)
uniform vec3 cameraPosition;      // 월드 공간 카메라 위치
uniform bool isOrthographic;

// attribute (기본 지오메트리)
attribute vec3 position;
attribute vec3 normal;
attribute vec2 uv;
// 지오메트리에 있으면: attribute vec4 color; attribute vec2 uv1; ...
// InstancedMesh: attribute mat4 instanceMatrix; attribute vec3 instanceColor;

Fragment shader 자동 제공

uniform mat4 viewMatrix;
uniform vec3 cameraPosition;
uniform bool isOrthographic;
// + 톤매핑/색공간 함수 (toneMapping, linearToOutputTexel)

RawShaderMaterial

아무것도 주입되지 않는다. precision부터 projectionMatrix까지 전부 직접 선언해야 하고, #include <tonemapping_fragment> 같은 청크도 쓸 수 없다. → 웹디자인 작업에서는 쓸 이유가 거의 없다. ShaderMaterial을 써라.

유용한 ShaderChunk

#include <common>                  // PI, RECIPROCAL_PI, saturate 매크로 등 유틸
#include <tonemapping_fragment>    // gl_FragColor 에 톤매핑 적용
#include <colorspace_fragment>     // 출력 색공간 변환 (r154+ 이름. 이전엔 encodings_fragment)
#include <fog_fragment>            // 안개 (fog_pars_fragment 도 필요)
#include <dithering_fragment>      // 밴딩 억제
#include <logdepthbuf_vertex>      // 로그 깊이 버퍼

fragment shader 마지막에 이 순서로:

  gl_FragColor = vec4(color, alpha);
  #include <tonemapping_fragment>
  #include <colorspace_fragment>
}

onBeforeCompile로 내장 머티리얼 확장

MeshStandardMaterial의 조명 계산을 유지한 채 코드를 삽입할 때.

material.onBeforeCompile = (shader) => {
  shader.uniforms.uTime = { value: 0 }

  shader.vertexShader = shader.vertexShader
    .replace('#include <common>', `
      #include <common>
      uniform float uTime;
      varying vec3 vLocalPos;
    `)
    .replace('#include <begin_vertex>', `
      #include <begin_vertex>
      vLocalPos = position;
      transformed.y += sin(position.x * 3.0 + uTime) * 0.1;
    `)
    // 노멀도 바꿔야 조명이 맞는다
    .replace('#include <beginnormal_vertex>', `
      #include <beginnormal_vertex>
      // objectNormal 을 여기서 수정
    `)

  shader.fragmentShader = shader.fragmentShader
    .replace('#include <dithering_fragment>', `
      #include <dithering_fragment>
      gl_FragColor.rgb *= 1.1;
    `)
}
// 필수: 안 하면 셰이더 캐시가 오작동한다
material.customProgramCacheKey = () => 'my-material-v1'

자주 쓰는 훅 포인트

청크 위치 용도
<common> 최상단 uniform/varying/함수 선언
<beginnormal_vertex> vertex objectNormal 수정
<begin_vertex> vertex transformed(정점 위치) 수정
<project_vertex> vertex 끝 mvPosition, gl_Position 이후 처리
<map_fragment> fragment diffuseColor 수정 (알베도)
<alphatest_fragment> fragment discard 삽입
<emissivemap_fragment> fragment totalEmissiveRadiance 수정
<dithering_fragment> fragment 끝 최종 gl_FragColor 후처리

4. uniform 전달 — JS ↔ GLSL 타입 매핑

const uniforms = {
  uFloat:   { value: 1.0 },                              // float
  uInt:     { value: 1 },                                // int
  uBool:    { value: true },                             // bool
  uVec2:    { value: new THREE.Vector2(1, 2) },          // vec2
  uVec3:    { value: new THREE.Vector3(1, 2, 3) },       // vec3
  uColor:   { value: new THREE.Color('#ff0080') },       // vec3 (자동 linear 변환!)
  uVec4:    { value: new THREE.Vector4(1, 2, 3, 4) },    // vec4
  uMat3:    { value: new THREE.Matrix3() },              // mat3
  uMat4:    { value: new THREE.Matrix4() },              // mat4
  uTex:     { value: texture },                          // sampler2D
  uCube:    { value: cubeTexture },                      // samplerCube
  uFloats:  { value: [1, 2, 3] },                        // float[3]
  uVecs:    { value: [new THREE.Vector3(), ...] },       // vec3[N]
}

갱신 규칙

// ✅ .value 를 바꾼다. needsUpdate 불필요
uniforms.uFloat.value = t
uniforms.uVec2.value.set(x, y)        // 새 객체 생성 X — GC 압력 감소
uniforms.uColor.value.set('#00ff88')

// ❌ 이렇게 하면 매 프레임 객체를 만든다
uniforms.uVec2.value = new THREE.Vector2(x, y)

// 텍스처를 교체할 때만 needsUpdate
uniforms.uTex.value = newTexture
material.needsUpdate = true   // uniform 개수/타입이 바뀔 때만. 값 변경엔 불필요

배열 uniform은 크기가 고정이다

#define MAX_POINTS 8
uniform vec3  uPoints[MAX_POINTS];
uniform int   uPointCount;
uniform float uRadius;

// 여러 개의 인터랙션 포인트가 만드는 영향력의 합
float influence = 0.0;
for (int i = 0; i < MAX_POINTS; i++) {
  if (i >= uPointCount) break;
  float d = distance(vWorldPos, uPoints[i]);
  influence += smoothstep(uRadius, 0.0, d);
}
influence = clamp(influence, 0.0, 1.0);

배열 크기를 바꾸려면 셰이더를 재컴파일해야 한다(defines 변경 + needsUpdate = true).


5. 스니펫 라이브러리 — src/shaders/lib/

각 파일은 include guard가 있어 중복 #include해도 안전하다.

5.1 lib/uv.glsl — UV 조작

#ifndef LIB_UV_GLSL
#define LIB_UV_GLSL

const float PI  = 3.141592653589793;
const float TAU = 6.283185307179586;

/** object-fit: cover. 이미지 비율을 유지하며 플레인을 채운다 */
vec2 coverUv(vec2 uv, vec2 planeSize, vec2 imageSize) {
  vec2 ratio = vec2(
    min((planeSize.x / planeSize.y) / (imageSize.x / imageSize.y), 1.0),
    min((planeSize.y / planeSize.x) / (imageSize.y / imageSize.x), 1.0)
  );
  return vec2(uv.x * ratio.x + (1.0 - ratio.x) * 0.5,
              uv.y * ratio.y + (1.0 - ratio.y) * 0.5);
}

/** object-fit: contain */
vec2 containUv(vec2 uv, vec2 planeSize, vec2 imageSize) {
  vec2 ratio = vec2(
    max((planeSize.x / planeSize.y) / (imageSize.x / imageSize.y), 1.0),
    max((planeSize.y / planeSize.x) / (imageSize.y / imageSize.x), 1.0)
  );
  return vec2(uv.x * ratio.x + (1.0 - ratio.x) * 0.5,
              uv.y * ratio.y + (1.0 - ratio.y) * 0.5);
}

/** 화면 비율 보정: 짧은 축 기준으로 -1..1 정규화. 노이즈가 늘어지지 않는다 */
vec2 aspectUv(vec2 fragCoord, vec2 resolution) {
  return (fragCoord * 2.0 - resolution) / min(resolution.x, resolution.y);
}

/** 중심 기준 회전 */
vec2 rotateUv(vec2 uv, float angle, vec2 center) {
  float s = sin(angle), c = cos(angle);
  uv -= center;
  uv = mat2(c, -s, s, c) * uv;
  return uv + center;
}

/** 중심 기준 스케일 (>1 = 축소, <1 = 확대) */
vec2 scaleUv(vec2 uv, float scale, vec2 center) {
  return (uv - center) * scale + center;
}

/** 타일링. .xy = 셀 내부 UV, .zw = 셀 인덱스 */
vec4 tileUv(vec2 uv, vec2 count) {
  vec2 g = uv * count;
  return vec4(fract(g), floor(g));
}

/** 극좌표. x = 각도(0..1), y = 반지름 */
vec2 polarUv(vec2 uv, vec2 center) {
  vec2 d = uv - center;
  return vec2(atan(d.y, d.x) / TAU + 0.5, length(d));
}

/** 만화경 */
vec2 kaleidoUv(vec2 uv, vec2 center, float segments) {
  vec2 p = polarUv(uv, center);
  float seg = 1.0 / segments;
  p.x = abs(mod(p.x, seg * 2.0) - seg) / seg;
  float a = p.x * TAU / segments;
  return center + vec2(cos(a), sin(a)) * p.y;
}

/** 배럴/핀쿠션 왜곡. k > 0 배럴, k < 0 핀쿠션 */
vec2 barrelUv(vec2 uv, float k) {
  vec2 c = uv - 0.5;
  float r2 = dot(c, c);
  return 0.5 + c * (1.0 + k * r2);
}

/** 중심에서 퍼지는 물결 */
vec2 rippleUv(vec2 uv, vec2 center, float time, float amplitude, float frequency, float speed) {
  vec2 d = uv - center;
  float r = length(d);
  float wave = sin(r * frequency - time * speed) * amplitude * exp(-r * 3.0);
  return uv + normalize(d + 1e-5) * wave;
}

/** 픽셀화 */
vec2 pixelateUv(vec2 uv, vec2 pixels) {
  return floor(uv * pixels) / pixels;
}

#endif

5.2 lib/noise.glsl — 해시 · 밸류 노이즈 · 심플렉스 · FBM

#ifndef LIB_NOISE_GLSL
#define LIB_NOISE_GLSL

/* ===== 해시 (의사난수) ================================================= */

float hash11(float p) {
  p = fract(p * 0.1031);
  p *= p + 33.33;
  return fract((p + p) * p);
}

float hash21(vec2 p) {
  vec3 p3 = fract(vec3(p.xyx) * 0.1031);
  p3 += dot(p3, p3.yzx + 33.33);
  return fract((p3.x + p3.y) * p3.z);
}

vec2 hash22(vec2 p) {
  vec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973));
  p3 += dot(p3, p3.yzx + 33.33);
  return fract((p3.xx + p3.yz) * p3.zy);
}

float hash31(vec3 p) {
  p = fract(p * 0.1031);
  p += dot(p, p.yzx + 33.33);
  return fract((p.x + p.y) * p.z);
}

vec3 hash33(vec3 p) {
  p = fract(p * vec3(0.1031, 0.1030, 0.0973));
  p += dot(p, p.yxz + 33.33);
  return fract((p.xxy + p.yxx) * p.zyx);
}

/* ===== Value noise (싸다. simplex 의 1/3 비용) ========================= */

float vnoise2(vec2 p) {
  vec2 i = floor(p), f = fract(p);
  f = f * f * (3.0 - 2.0 * f);                 // smoothstep 보간
  return mix(mix(hash21(i),               hash21(i + vec2(1.0, 0.0)), f.x),
             mix(hash21(i + vec2(0.0, 1.0)), hash21(i + vec2(1.0, 1.0)), f.x), f.y);
}

float vnoise3(vec3 p) {
  vec3 i = floor(p), f = fract(p);
  f = f * f * (3.0 - 2.0 * f);
  return mix(
    mix(mix(hash31(i + vec3(0,0,0)), hash31(i + vec3(1,0,0)), f.x),
        mix(hash31(i + vec3(0,1,0)), hash31(i + vec3(1,1,0)), f.x), f.y),
    mix(mix(hash31(i + vec3(0,0,1)), hash31(i + vec3(1,0,1)), f.x),
        mix(hash31(i + vec3(0,1,1)), hash31(i + vec3(1,1,1)), f.x), f.y), f.z);
}

/* ===== Simplex noise 3D (Ian McEwan, Ashima Arts / stegu — MIT) ========
 * https://github.com/stegu/webgl-noise
 * 반환 범위 대략 -1..1
 * ===================================================================== */

vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec4 mod289(vec4 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec4 permute(vec4 x) { return mod289(((x * 34.0) + 10.0) * x); }
vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; }

float snoise(vec3 v) {
  const vec2 C = vec2(1.0 / 6.0, 1.0 / 3.0);
  const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);

  vec3 i  = floor(v + dot(v, C.yyy));
  vec3 x0 = v - i + dot(i, C.xxx);

  vec3 g = step(x0.yzx, x0.xyz);
  vec3 l = 1.0 - g;
  vec3 i1 = min(g.xyz, l.zxy);
  vec3 i2 = max(g.xyz, l.zxy);

  vec3 x1 = x0 - i1 + C.xxx;
  vec3 x2 = x0 - i2 + C.yyy;
  vec3 x3 = x0 - D.yyy;

  i = mod289(i);
  vec4 p = permute(permute(permute(
             i.z + vec4(0.0, i1.z, i2.z, 1.0))
           + i.y + vec4(0.0, i1.y, i2.y, 1.0))
           + i.x + vec4(0.0, i1.x, i2.x, 1.0));

  float n_ = 0.142857142857;
  vec3 ns = n_ * D.wyz - D.xzx;

  vec4 j = p - 49.0 * floor(p * ns.z * ns.z);

  vec4 x_ = floor(j * ns.z);
  vec4 y_ = floor(j - 7.0 * x_);

  vec4 x = x_ * ns.x + ns.yyyy;
  vec4 y = y_ * ns.x + ns.yyyy;
  vec4 h = 1.0 - abs(x) - abs(y);

  vec4 b0 = vec4(x.xy, y.xy);
  vec4 b1 = vec4(x.zw, y.zw);

  vec4 s0 = floor(b0) * 2.0 + 1.0;
  vec4 s1 = floor(b1) * 2.0 + 1.0;
  vec4 sh = -step(h, vec4(0.0));

  vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
  vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww;

  vec3 p0 = vec3(a0.xy, h.x);
  vec3 p1 = vec3(a0.zw, h.y);
  vec3 p2 = vec3(a1.xy, h.z);
  vec3 p3 = vec3(a1.zw, h.w);

  vec4 norm = taylorInvSqrt(vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)));
  p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w;

  vec4 m = max(0.5 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0);
  m = m * m;
  return 105.0 * dot(m * m, vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)));
}

/** 2D 편의 함수: z 를 시간축으로 쓴다 */
float snoise2(vec2 p) { return snoise(vec3(p, 0.0)); }

/* ===== FBM (fractal brownian motion) ================================== */

#ifndef FBM_OCTAVES
  #define FBM_OCTAVES 4
#endif

float fbm3(vec3 p) {
  float sum = 0.0, amp = 0.5, freq = 1.0;
  for (int i = 0; i < FBM_OCTAVES; i++) {
    sum += snoise(p * freq) * amp;
    freq *= 2.02;      // 2.0 정확히 쓰면 격자 패턴이 보인다
    amp  *= 0.5;
  }
  return sum;
}

float fbm2(vec2 p) {
  float sum = 0.0, amp = 0.5, freq = 1.0;
  for (int i = 0; i < FBM_OCTAVES; i++) {
    sum += vnoise2(p * freq) * amp;
    freq *= 2.02;
    amp  *= 0.5;
  }
  return sum;
}

/** 능선 노이즈: 산맥/번개 같은 날카로운 결 */
float ridged3(vec3 p) {
  float sum = 0.0, amp = 0.5, freq = 1.0;
  for (int i = 0; i < FBM_OCTAVES; i++) {
    float n = 1.0 - abs(snoise(p * freq));
    sum += n * n * amp;
    freq *= 2.02;
    amp  *= 0.5;
  }
  return sum;
}

/** 도메인 워프: 노이즈로 좌표를 흔든 뒤 다시 노이즈. "유기적"의 핵심 */
float warpedFbm3(vec3 p, float strength) {
  vec3 q = vec3(fbm3(p), fbm3(p + vec3(5.2, 1.3, 3.7)), fbm3(p + vec3(9.1, 7.4, 2.8)));
  return fbm3(p + q * strength);
}

/* ===== Voronoi / Worley (셀 패턴) ===================================== */

/** x = 가장 가까운 점까지의 거리, y = 셀 ID */
vec2 voronoi2(vec2 p) {
  vec2 n = floor(p), f = fract(p);
  float minDist = 8.0;
  float cellId = 0.0;
  for (int j = -1; j <= 1; j++) {
    for (int i = -1; i <= 1; i++) {
      vec2 g = vec2(float(i), float(j));
      vec2 o = hash22(n + g);
      vec2 r = g + o - f;
      float d = dot(r, r);
      if (d < minDist) { minDist = d; cellId = hash21(n + g); }
    }
  }
  return vec2(sqrt(minDist), cellId);
}

/** 셀 경계선까지의 거리 (테두리 그리기용) */
float voronoiEdge2(vec2 p) {
  vec2 n = floor(p), f = fract(p);
  vec2 mg, mr;
  float md = 8.0;
  for (int j = -1; j <= 1; j++) for (int i = -1; i <= 1; i++) {
    vec2 g = vec2(float(i), float(j));
    vec2 o = hash22(n + g);
    vec2 r = g + o - f;
    float d = dot(r, r);
    if (d < md) { md = d; mr = r; mg = g; }
  }
  md = 8.0;
  for (int j = -2; j <= 2; j++) for (int i = -2; i <= 2; i++) {
    vec2 g = mg + vec2(float(i), float(j));
    vec2 o = hash22(n + g);
    vec2 r = g + o - f;
    if (dot(mr - r, mr - r) > 0.00001) {
      md = min(md, dot(0.5 * (mr + r), normalize(r - mr)));
    }
  }
  return md;
}

#endif

5.3 lib/curl.glsl — 컬 노이즈 (발산 없는 유동 벡터장)

#ifndef LIB_CURL_GLSL
#define LIB_CURL_GLSL

#include './noise.glsl'

vec3 snoiseVec3(vec3 x) {
  return vec3(
    snoise(vec3(x.x,          x.y,          x.z)),
    snoise(vec3(x.y - 19.1,   x.z + 33.4,   x.x + 47.2)),
    snoise(vec3(x.z + 74.2,   x.x - 124.5,  x.y + 99.4))
  );
}

/**
 * 컬 노이즈: 포텐셜장의 회전(curl)을 취해 발산이 0인 벡터장을 만든다.
 * 파티클이 뭉치거나 흩어지지 않고 "유체처럼" 흐른다.
 * 비용: snoise 18회. 매우 비싸다. GPGPU 시뮬 패스에서만 써라.
 */
vec3 curlNoise(vec3 p) {
  const float e = 0.1;
  vec3 dx = vec3(e, 0.0, 0.0);
  vec3 dy = vec3(0.0, e, 0.0);
  vec3 dz = vec3(0.0, 0.0, e);

  vec3 px0 = snoiseVec3(p - dx), px1 = snoiseVec3(p + dx);
  vec3 py0 = snoiseVec3(p - dy), py1 = snoiseVec3(p + dy);
  vec3 pz0 = snoiseVec3(p - dz), pz1 = snoiseVec3(p + dz);

  float x = py1.z - py0.z - pz1.y + pz0.y;
  float y = pz1.x - pz0.x - px1.z + px0.z;
  float z = px1.y - px0.y - py1.x + py0.x;

  const float divisor = 1.0 / (2.0 * e);
  return normalize(vec3(x, y, z) * divisor);
}

/**
 * 싼 버전: 3방향 노이즈. 진짜 curl 은 아니지만 육안으로 거의 구분 안 된다.
 * 비용: snoise 3회. GPGPU 가 아닌 vertex shader 에서 쓰기 좋다.
 */
vec3 pseudoCurl(vec3 p) {
  return vec3(
    snoise(p),
    snoise(p + vec3(31.416, 0.0, 0.0)),
    snoise(p + vec3(0.0, 17.777, 0.0))
  );
}

#endif

5.4 lib/easing.glsl — 이징 · 리매핑 · 페이드

#ifndef LIB_EASING_GLSL
#define LIB_EASING_GLSL

float saturate_(float x) { return clamp(x, 0.0, 1.0); }
vec3  saturate_(vec3 x)  { return clamp(x, 0.0, 1.0); }

/** 범위 재매핑 */
float remap(float v, float inMin, float inMax, float outMin, float outMax) {
  return outMin + (v - inMin) * (outMax - outMin) / (inMax - inMin);
}

/** 범위 재매핑 + 클램프 */
float remapc(float v, float inMin, float inMax, float outMin, float outMax) {
  return clamp(remap(v, inMin, inMax, outMin, outMax), min(outMin, outMax), max(outMin, outMax));
}

/** 0→1→0 곡선. 구간 중앙에서 최대 */
float pulse(float x, float center, float width) {
  return smoothstep(center - width, center, x) - smoothstep(center, center + width, x);
}

/** smoothstep 보다 부드러운 5차 보간 */
float quintic(float t) { return t * t * t * (t * (t * 6.0 - 15.0) + 10.0); }

/* --- 표준 이징 (Robert Penner) --- */
float easeInQuad(float t)    { return t * t; }
float easeOutQuad(float t)   { return t * (2.0 - t); }
float easeInOutQuad(float t) { return t < 0.5 ? 2.0*t*t : -1.0 + (4.0 - 2.0*t)*t; }

float easeInCubic(float t)   { return t * t * t; }
float easeOutCubic(float t)  { float f = t - 1.0; return f*f*f + 1.0; }
float easeInOutCubic(float t){ return t < 0.5 ? 4.0*t*t*t : (t-1.0)*(2.0*t-2.0)*(2.0*t-2.0)+1.0; }

float easeInExpo(float t)    { return t <= 0.0 ? 0.0 : pow(2.0, 10.0 * (t - 1.0)); }
float easeOutExpo(float t)   { return t >= 1.0 ? 1.0 : 1.0 - pow(2.0, -10.0 * t); }

float easeOutBack(float t) {
  const float c1 = 1.70158, c3 = c1 + 1.0;
  float f = t - 1.0;
  return 1.0 + c3 * f * f * f + c1 * f * f;
}

float easeOutElastic(float t) {
  const float c4 = 6.283185307179586 / 3.0;
  if (t <= 0.0) return 0.0;
  if (t >= 1.0) return 1.0;
  return pow(2.0, -10.0 * t) * sin((t * 10.0 - 0.75) * c4) + 1.0;
}

/** 화면 픽셀 단위 안티에일리어싱 경계. SDF 와 함께 쓴다 */
float aastep(float threshold, float value) {
  float afwidth = fwidth(value) * 0.7;
  return smoothstep(threshold - afwidth, threshold + afwidth, value);
}

/** 지수 감쇠 (프레임레이트 독립). GLSL 안에서 시간 적분할 때 */
float damp(float current, float target, float lambda, float dt) {
  return mix(current, target, 1.0 - exp(-lambda * dt));
}

#endif

5.5 lib/color.glsl — 팔레트 · 색공간 · 그레인 · 디더

#ifndef LIB_COLOR_GLSL
#define LIB_COLOR_GLSL

/**
 * Inigo Quilez 코사인 팔레트.
 * col = a + b * cos(2π(c*t + d))
 * https://iquilezles.org/articles/palettes/
 *
 * 대표 조합:
 *  rainbow : a(.5,.5,.5) b(.5,.5,.5) c(1,1,1)   d(0,.33,.67)
 *  warm    : a(.5,.5,.5) b(.5,.5,.5) c(1,1,1)   d(0,.1,.2)
 *  teal    : a(.5,.5,.5) b(.5,.5,.5) c(1,1,.5)  d(.8,.9,.3)
 *  purple  : a(.8,.5,.4) b(.2,.4,.2) c(2,1,1)   d(0,.25,.25)
 */
vec3 palette(float t, vec3 a, vec3 b, vec3 c, vec3 d) {
  return a + b * cos(6.283185307179586 * (c * t + d));
}

vec3 rgb2hsv(vec3 c) {
  vec4 K = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0);
  vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g));
  vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r));
  float d = q.x - min(q.w, q.y);
  float e = 1.0e-10;
  return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x);
}

vec3 hsv2rgb(vec3 c) {
  vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
  vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
  return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}

/** 수동 색공간 변환 (three 청크를 못 쓰는 상황용) */
vec3 srgbToLinear(vec3 c) {
  return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(0.04045, c));
}
vec3 linearToSrgb(vec3 c) {
  return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));
}

/** ACES 근사 톤매핑 (Narkowicz) */
vec3 acesApprox(vec3 x) {
  const float a = 2.51, b = 0.03, c = 2.43, d = 0.59, e = 0.14;
  return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0);
}

/** 채도 조절 */
vec3 saturation(vec3 c, float amount) {
  float l = dot(c, vec3(0.2126, 0.7152, 0.0722));
  return mix(vec3(l), c, amount);
}

/** 콘트라스트 (pivot 0.5 기준) */
vec3 contrast(vec3 c, float amount) {
  return (c - 0.5) * amount + 0.5;
}

/** 필름 그레인. time 을 넣으면 매 프레임 달라진다 */
float grain(vec2 fragCoord, float time) {
  return fract(sin(dot(fragCoord + time, vec2(12.9898, 78.233))) * 43758.5453);
}

/** 8bit 프레임버퍼 밴딩 제거. 그라디언트에는 거의 필수 */
vec3 dither8(vec3 color, vec2 fragCoord) {
  float d = fract(sin(dot(fragCoord, vec2(12.9898, 78.233))) * 43758.5453);
  return color + (d - 0.5) / 255.0;
}

/** 4x4 Bayer 디더 (레트로 룩) */
float bayer4(vec2 fragCoord) {
  int x = int(mod(fragCoord.x, 4.0));
  int y = int(mod(fragCoord.y, 4.0));
  int index = x + y * 4;
  float m[16];
  m[0]=0.0;  m[1]=8.0;  m[2]=2.0;  m[3]=10.0;
  m[4]=12.0; m[5]=4.0;  m[6]=14.0; m[7]=6.0;
  m[8]=3.0;  m[9]=11.0; m[10]=1.0; m[11]=9.0;
  m[12]=15.0;m[13]=7.0; m[14]=13.0;m[15]=5.0;
  for (int i = 0; i < 16; i++) { if (i == index) return m[i] / 16.0; }
  return 0.0;
}

/** 비네트 */
float vignette(vec2 uv, float offset, float darkness) {
  vec2 c = (uv - 0.5) * 2.0;
  return clamp(1.0 - dot(c, c) * darkness + offset, 0.0, 1.0);
}

/** 스캔라인 */
float scanline(vec2 uv, float count, float intensity, float time) {
  return 1.0 - intensity * (0.5 + 0.5 * sin((uv.y + time) * count * 3.14159265));
}

#endif

5.6 lib/sdf.glsl — 2D 시그니처 거리 함수

출처 개념: iquilezles.org/articles/distfunctions2d 반환값 < 0 → 내부, = 0 → 경계, > 0 → 외부

#ifndef LIB_SDF_GLSL
#define LIB_SDF_GLSL

float sdCircle(vec2 p, float r) { return length(p) - r; }

float sdBox(vec2 p, vec2 b) {
  vec2 d = abs(p) - b;
  return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
}

float sdRoundBox(vec2 p, vec2 b, float r) {
  return sdBox(p, b - r) - r;
}

float sdSegment(vec2 p, vec2 a, vec2 b) {
  vec2 pa = p - a, ba = b - a;
  float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
  return length(pa - ba * h);
}

float sdTriangleIso(vec2 p, vec2 q) {
  p.x = abs(p.x);
  vec2 a = p - q * clamp(dot(p, q) / dot(q, q), 0.0, 1.0);
  vec2 b = p - q * vec2(clamp(p.x / q.x, 0.0, 1.0), 1.0);
  float s = -sign(q.y);
  vec2 d = min(vec2(dot(a, a), s * (p.x * q.y - p.y * q.x)),
               vec2(dot(b, b), s * (p.y - q.y)));
  return -sqrt(d.x) * sign(d.y);
}

float sdHexagon(vec2 p, float r) {
  const vec3 k = vec3(-0.866025404, 0.5, 0.577350269);
  p = abs(p);
  p -= 2.0 * min(dot(k.xy, p), 0.0) * k.xy;
  p -= vec2(clamp(p.x, -k.z * r, k.z * r), r);
  return length(p) * sign(p.y);
}

float sdStar5(vec2 p, float r, float rf) {
  const vec2 k1 = vec2(0.809016994, -0.587785252);
  const vec2 k2 = vec2(-k1.x, k1.y);
  p.x = abs(p.x);
  p -= 2.0 * max(dot(k1, p), 0.0) * k1;
  p -= 2.0 * max(dot(k2, p), 0.0) * k2;
  p.x = abs(p.x);
  p.y -= r;
  vec2 ba = rf * vec2(-k1.y, k1.x) - vec2(0.0, 1.0);
  float h = clamp(dot(p, ba) / dot(ba, ba), 0.0, r);
  return length(p - ba * h) * sign(p.y * ba.x - p.x * ba.y);
}

/* --- 불리언 연산 --- */
float opUnion(float d1, float d2)        { return min(d1, d2); }
float opSubtract(float d1, float d2)     { return max(-d1, d2); }
float opIntersect(float d1, float d2)    { return max(d1, d2); }

/** 부드러운 합집합 (메타볼). k = 블렌딩 폭 */
float opSmoothUnion(float d1, float d2, float k) {
  float h = clamp(0.5 + 0.5 * (d2 - d1) / k, 0.0, 1.0);
  return mix(d2, d1, h) - k * h * (1.0 - h);
}

float opSmoothSubtract(float d1, float d2, float k) {
  float h = clamp(0.5 - 0.5 * (d2 + d1) / k, 0.0, 1.0);
  return mix(d2, -d1, h) + k * h * (1.0 - h);
}

/** 외곽선. width 만큼의 링 */
float opOutline(float d, float width) { return abs(d) - width; }

/** SDF → 안티에일리어싱된 채움 마스크 */
float sdfFill(float d) { return 1.0 - smoothstep(-fwidth(d), fwidth(d), d); }

/** SDF → 안티에일리어싱된 선 */
float sdfStroke(float d, float width) {
  float w = fwidth(d);
  return 1.0 - smoothstep(width - w, width + w, abs(d));
}

#endif

6. 자주 쓰는 조합 레시피

6.1 화면 UV와 종횡비 (거의 모든 배경 셰이더의 첫 줄)

uniform vec2 uResolution;

void main() {
  // 0..1 스크린 UV
  vec2 uv = gl_FragCoord.xy / uResolution;

  // 짧은 축 기준 -1..1 (원이 타원으로 안 찌그러진다)
  vec2 p = (gl_FragCoord.xy * 2.0 - uResolution) / min(uResolution.x, uResolution.y);

  // 종횡비 보정된 0..1 (그리드용)
  vec2 g = uv;
  g.x *= uResolution.x / uResolution.y;
}

6.2 텍스처 디스플레이스먼트 + 색수차 (P8/P9의 핵심)

uniform sampler2D uTexture;
uniform float uAmount;      // 0 ~ 0.02
uniform vec2  uDirection;   // 정규화된 방향

vec3 rgbShift(sampler2D tex, vec2 uv, vec2 dir, float amount) {
  float r = texture2D(tex, uv + dir * amount).r;
  float g = texture2D(tex, uv).g;
  float b = texture2D(tex, uv - dir * amount).b;
  return vec3(r, g, b);
}

/** 방사형 색수차 (렌즈처럼 가장자리만) */
vec3 radialAberration(sampler2D tex, vec2 uv, float amount) {
  vec2 dir = uv - 0.5;
  float d = dot(dir, dir);          // 중심에서 멀수록 큼
  float r = texture2D(tex, uv - dir * amount * d).r;
  float g = texture2D(tex, uv).g;
  float b = texture2D(tex, uv + dir * amount * d).b;
  return vec3(r, g, b);
}

6.3 정점 변위 후 노멀 재계산 (조명이 필요할 때 필수)

정점을 노이즈로 움직이면 원래 노멀이 틀린다. 유한차분으로 다시 계산한다.

#include './lib/noise.glsl'

uniform float uTime;
uniform float uAmplitude;
uniform float uFrequency;

varying vec3 vNormal;
varying vec3 vWorldPos;

/** 위치 → 변위 높이 */
float displace(vec3 p) {
  return snoise(p * uFrequency + vec3(0.0, 0.0, uTime * 0.2)) * uAmplitude;
}

void main() {
  vec3 pos = position;
  float d = displace(pos);
  pos += normal * d;

  // 접평면의 두 방향으로 아주 조금 이동시켜 새 노멀을 구한다
  float eps = 0.01;
  vec3 tangent  = normalize(cross(normal, vec3(0.0, 1.0, 0.0) + 1e-4));
  vec3 bitangent = normalize(cross(normal, tangent));

  vec3 pT = position + tangent * eps;
  vec3 pB = position + bitangent * eps;
  pT += normal * displace(pT);
  pB += normal * displace(pB);

  vec3 newNormal = normalize(cross(pT - pos, pB - pos));
  // 방향이 뒤집힐 수 있으므로 원래 노멀과 부호를 맞춘다
  newNormal *= sign(dot(newNormal, normal));

  vNormal = normalize(normalMatrix * newNormal);
  vWorldPos = (modelMatrix * vec4(pos, 1.0)).xyz;

  gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}

비용: displace()를 3번 호출한다(노이즈 3배). vertex shader이므로 대부분 감당 가능하다.

6.4 프레넬 (가장자리 발광 — "고급스러움"의 90%)

// vertex
varying vec3 vNormalW;
varying vec3 vViewDir;

void main() {
  vec4 worldPos = modelMatrix * vec4(position, 1.0);
  vNormalW = normalize(mat3(modelMatrix) * normal);
  vViewDir = normalize(cameraPosition - worldPos.xyz);
  gl_Position = projectionMatrix * viewMatrix * worldPos;
}
// fragment
uniform vec3  uColor;
uniform vec3  uRimColor;
uniform float uRimPower;      // 1.5 ~ 5.0
uniform float uRimIntensity;  // 0.3 ~ 2.0

varying vec3 vNormalW;
varying vec3 vViewDir;

void main() {
  vec3 n = normalize(vNormalW);
  vec3 v = normalize(vViewDir);

  float fresnel = pow(1.0 - clamp(dot(n, v), 0.0, 1.0), uRimPower);

  vec3 col = mix(uColor, uRimColor, fresnel * uRimIntensity);

  gl_FragColor = vec4(col, 1.0);
  #include <tonemapping_fragment>
  #include <colorspace_fragment>
}

6.5 깊이 기반 페이드 (파티클/안개)

// vertex
varying float vDepth;
void main() {
  vec4 mv = modelViewMatrix * vec4(position, 1.0);
  vDepth = -mv.z;                              // 카메라로부터의 거리
  gl_Position = projectionMatrix * mv;
}

// fragment
uniform float uFogNear;
uniform float uFogFar;
varying float vDepth;
void main() {
  float fog = smoothstep(uFogNear, uFogFar, vDepth);
  vec3 col = mix(baseColor, uFogColor, fog);
  gl_FragColor = vec4(col, 1.0 - fog);
}

6.6 시간 기반 순차 애니메이션 (스태거)

uniform float uProgress;   // 0 ~ 1 (JS에서 GSAP 로 트윈)
uniform float uTotal;      // 전체 개수
uniform float uStagger;    // 0 ~ 0.9. 개체 간 지연의 총 폭

attribute float aIndex;    // 개체 인덱스 (InstancedBufferAttribute)

varying float vLocal;

void main() {
  // 인덱스가 클수록 늦게 시작한다
  float delay = (aIndex / max(uTotal, 1.0)) * uStagger;
  float local = clamp((uProgress - delay) / max(1.0 - uStagger, 1e-4), 0.0, 1.0);
  local = local * local * (3.0 - 2.0 * local);   // smoothstep

  vec3 pos = position;
  pos.y += (1.0 - local) * -2.0;                 // 아래에서 올라온다
  pos *= mix(0.4, 1.0, local);                   // 작게 시작해서 커진다

  vLocal = local;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}

fragment에서 vLocal로 알파까지 맞춘다.

varying float vLocal;
uniform vec3 uColor;

void main() {
  gl_FragColor = vec4(uColor, vLocal);
  #include <tonemapping_fragment>
  #include <colorspace_fragment>
}

7. LYGIA — 대규모 셰이더 함수 라이브러리

노이즈/필터/색공간/SDF/조명 수백 개 함수를 #include로 가져온다. GLSL/HLSL/WGSL/MSL 지원.

설치 (로컬 번들 — 프로덕션 권장)

# 서브모듈로 추가
git submodule add https://github.com/patriciogonzalezvivo/lygia.git src/shaders/lygia
# 또는 그냥 clone
git clone --depth 1 https://github.com/patriciogonzalezvivo/lygia.git src/shaders/lygia
#include "lygia/generative/snoise.glsl"
#include "lygia/generative/curl.glsl"
#include "lygia/generative/fbm.glsl"
#include "lygia/space/ratio.glsl"
#include "lygia/space/rotate.glsl"
#include "lygia/color/palette/hue.glsl"
#include "lygia/draw/circle.glsl"
#include "lygia/sdf/circleSDF.glsl"
#include "lygia/filter/gaussianBlur.glsl"
#include "lygia/math/map.glsl"

void main() {
  vec2 st = ratio(gl_FragCoord.xy / u_resolution, u_resolution);
  float n = fbm(vec3(st * 3.0, u_time * 0.1));
  gl_FragColor = vec4(vec3(n), 1.0);
}

vite-plugin-glsl#include를 인라인해준다. 경로는 프로젝트 루트가 아니라 셰이더 파일 기준 상대경로여야 하므로, src/shaders/lygia/에 두고 #include './lygia/...'로 쓰는 게 안전하다.

런타임 해석 (프로토타입용)

<script src="https://lygia.xyz/resolve.js"></script>
fragmentShader = resolveLygia(fragmentShader)   // 원격에서 의존성 fetch

프로덕션에서 쓰지 마라. 네트워크 왕복이 첫 렌더를 지연시킨다.

자주 쓰는 LYGIA 모듈

경로 용도
generative/snoise.glsl simplex 노이즈 (1D~4D 오버로드)
generative/pnoise.glsl 주기적(타일링 가능) perlin
generative/curl.glsl 컬 노이즈
generative/fbm.glsl FBM
generative/worley.glsl 셀 노이즈
space/ratio.glsl 종횡비 보정
space/rotate.glsl 회전
space/scale.glsl 스케일
sdf/*.glsl 2D/3D SDF 전체
draw/circle.glsl, draw/stroke.glsl 안티에일리어싱된 도형
color/palette/*.glsl 팔레트 (IQ 코사인 등)
color/space/*.glsl 색공간 변환
filter/gaussianBlur.glsl 블러 (분리 가능)
lighting/fresnel.glsl 프레넬
math/map.glsl, math/lerp.glsl 유틸

8. 디버깅

8.1 색으로 값 확인하기

// 0..1 값 확인: 흑백
gl_FragColor = vec4(vec3(value), 1.0); return;

// -1..1 값 확인: 음수는 빨강, 양수는 초록
gl_FragColor = vec4(max(-value, 0.0), max(value, 0.0), 0.0, 1.0); return;

// UV 확인: 좌하단 검정, 우상단 노랑
gl_FragColor = vec4(vUv, 0.0, 1.0); return;

// 노멀 확인
gl_FragColor = vec4(normalize(vNormal) * 0.5 + 0.5, 1.0); return;

// NaN 확인: NaN 이면 조건이 모두 false → 검정
gl_FragColor = vec4(value == value ? vec3(0,1,0) : vec3(1,0,0), 1.0); return;

8.2 흔한 에러와 원인

증상 원인 해결
'assign' : cannot convert from 'int' to 'float' float x = 1; float x = 1.0;
'constructor' : too many arguments vec3(1.0, 2.0, 3.0, 4.0) 인자 수 확인
undeclared identifier 'texture' GLSL1인데 texture() 사용 texture2D() 사용
'gl_FragColor' : undeclared glslVersion: GLSL3 지정됨 out vec4 fragColor; 선언
redefinition of 'position' 자동 주입 attribute 재선언 선언 제거
화면이 검정 gl_Position 미설정, 카메라 뒤, depthTest 문제 gl_FragColor = vec4(1,0,0,1)로 격리 테스트
색이 이상하게 밝음/어두움 톤매핑 또는 색공간 청크 누락/중복 #include <tonemapping_fragment> + <colorspace_fragment> 확인
모바일에서만 깨짐 highp 미지원 또는 정밀도 부족 precision highp float 명시, 좌표 범위 축소
그라디언트에 줄무늬(밴딩) 8bit 프레임버퍼 dither8() 추가
파티클이 갑자기 사라짐 frustum culling mesh.frustumCulled = false 또는 큰 boundingSphere
텍스처가 뒤집힘 flipY 차이 texture.flipY = false (glTF 텍스처는 이미 false)

8.3 셰이더 컴파일 로그 보기

renderer.debug.checkShaderErrors = true   // 기본값 true. 프로덕션에서 false 로 하면 빨라짐

// 컴파일된 최종 셰이더 소스 출력
material.onBeforeCompile = (shader) => {
  console.log('--- VERTEX ---\n', shader.vertexShader)
  console.log('--- FRAGMENT ---\n', shader.fragmentShader)
}

8.4 성능 측정

// GPU 시간 측정 (WebGL2 EXT_disjoint_timer_query_webgl2 필요)
import Stats from 'three/addons/libs/stats.module.js'
const stats = new Stats()
document.body.appendChild(stats.dom)
// 렌더 루프에서 stats.update()

// 또는 stats-gl (GPU 시간까지 표시)
// npm i stats-gl
import Stats from 'stats-gl'
const stats = new Stats({ trackGPU: true })
await stats.init(renderer)

프래그먼트 비용 격리 테스트: 캔버스를 200×200으로 줄여보고 프레임이 회복되면 fragment 병목, 그대로면 vertex/드로우콜 병목이다.


9. 셰이더 작성 체크리스트

  • float 리터럴에 소수점을 찍었다 (1.0, 0.5)
  • fragment 상단에 precision mediump float; (좌표 계산은 highp)
  • 자동 주입되는 uniform/attribute를 재선언하지 않았다
  • fragment 마지막에 <tonemapping_fragment><colorspace_fragment> 순서로 넣었다
  • 색은 JS에서 THREE.Color로 넘겼다 (하드코딩된 vec3는 linear 값임을 인지)
  • 그라디언트가 있으면 디더를 넣었다
  • 무거운 계산을 fragment → vertex로 옮길 수 있는지 검토했다
  • 루프 카운트가 상수다 (#define)
  • normalize() 인자가 0이 될 수 있으면 + 1e-5를 더했다
  • discard를 쓴다면 정말 필요한지 검토했다 (early-Z 손실)
  • 종횡비 보정을 했다 (원이 타원이 되지 않는가)
  • onBeforeCompile을 썼다면 customProgramCacheKey를 지정했다
  • 모바일 실기기에서 확인했다 (에뮬레이터로는 정밀도 문제를 못 잡는다)