웹 디자인 파이프라인 스킬과 이를 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)
41 KiB
03 — GLSL 최소 지식 + 재사용 스니펫
대상: three.js
ShaderMaterial(WebGL2 컨텍스트, GLSL ES 1.00 문법 작성). three는 WebGL2에서 GLSL1 코드를 자동으로 ESSL3로 변환한다(attribute→in,varying→out/in,texture2D→texture,gl_FragColor→pc_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를 지정했다- 모바일 실기기에서 확인했다 (에뮬레이터로는 정밀도 문제를 못 잡는다)