GODOT 셰이더 · 2강

툰 라이팅

빛을 받는 각도 하나로 밝기가 정해진다 — 내적(dot product)이 그 각도를 숫자로 바꾼다. 여기에 1강의 양자화를 얹으면 셀 셰이딩(툰), 디더링을 얹으면 팔레트보다 많은 색을 흉내 낸다.

01 / 벡터

벡터 — 두 점의 차이

점에서 점을 빼면 벡터가 된다 — Q − P는 P에서 Q로 가는 화살표다. 거꾸로 점에 벡터를 더하면 다시 점이 된다: P + v = Q. 벡터의 길이는 피타고라스 정리 그대로, 성분을 제곱해 더하고 제곱근을 씌운 값이다.

정규화(normalize)는 벡터를 자신의 길이로 나누는 것 — 방향은 그대로 두고 길이만 정확히 1로 만든다. 셰이더에서 법선(NORMAL)이나 빛 방향(LIGHT_DIRECTION)이 항상 normalize()를 거치는 이유가 이것이다. 아래에서 두 점 P, Q를 직접 드래그해 보라.

Q − P
길이 |Q−P|
정규화 방향

P나 Q를 드래그하면 벡터(청록 화살표)와 길이, 정규화된 방향(보라 화살표, 길이 1로 고정)이 실시간으로 갱신된다. 방향은 같아도 길이는 항상 1이다.

02 / 내적

내적 — 겹치는 정도를 재는 자

내적에는 두 가지 정의가 있고 둘은 완전히 같다. 계산 정의는 성분끼리 곱해서 더하는 것 — a·b = ax·bx + ay·by. 기하 정의a·b = |a||b|cosθ. 직관적으로는 'b가 a 위에 드리운 그림자(투영) 길이 × |a|'다.

cosθ가 그대로 방향의 일치 정도다. θ=0°(같은 방향)면 cosθ=1로 내적이 가장 크고, θ=90°(수직)면 cosθ=0으로 내적이 0, θ=180°(반대 방향)면 cosθ=−1로 내적이 음수가 된다. 슬라이더로 θ를 돌려 부호가 바뀌는 순간을 직접 확인하라.

a·b (성분곱합)
|a||b|·cosθ (기하)
cosθ
θ(도)60°
투영 길이

점선은 b를 a 방향으로 투영한 그림자다. 두 계산 방식(성분곱합과 |a||b|cosθ)은 항상 같은 값을 낸다 — 이것이 내적의 두 정의가 하나라는 증거다.

03 / 람베르트 조명

람베르트 조명 — 법선과 빛의 내적

람베르트 조명은 셰이더 한 줄로 요약된다: max(0, dot(NORMAL, L)). 표면의 법선 N과 빛 방향 L 사이의 내적 하나가 그 지점의 밝기다. 빛을 정면으로 받는 곳은 N과 L이 같은 방향이라 내적이 1(가장 밝음), 90°로 스치는 곳은 내적이 0, 빛을 등진 뒤쪽은 내적이 음수가 되므로 max(0, ...)로 0에 클램프한다.

빛 방향 핸들을 드래그해 구 주위를 돌려 보라. 표면 각 지점의 법선(구 중심에서 바깥으로 향하는 방향)과 빛의 내적이 실시간으로 밝기 그라데이션을 만든다.

빛 각도
표시점 dot(N,L)

표시된 점(흰 테두리 점)에서 법선과 빛의 내적을 따로 읽을 수 있다. 빛이 정면이면 1, 옆을 스치면 0, 뒤로 넘어가면 음수라 검게 클램프된다.

🛠 Godot 실습 — 첫 3D 조명: 람베르트

  1. MeshInstance3D를 추가하고 Mesh에 New SphereMesh(구)를 넣는다. Material Override에 New ShaderMaterial → Shader에 New Shader(.gdshader).
  2. 아래 코드 붙여넣기:
    shader_type spatial;
    
    void light() {
    	float ndotl = max(dot(normalize(NORMAL), normalize(LIGHT)), 0.0);
    	DIFFUSE_LIGHT += ndotl * ATTENUATION * LIGHT_COLOR * ALBEDO;
    }
  3. 씬에 DirectionalLight3D를 추가하고, 라이트를 회전시키며 구의 밝은 면이 빛을 따라 도는지 확인.

⚠️ 막힘 주의: 씬에 빛이 하나도 없으면 light()가 실행되지 않아 구가 새까맣다 — DirectionalLight3D를 꼭 추가하라. light()를 직접 정의하면 Godot 기본 조명 공식 대신 이 코드가 쓰인다.

🔍 코드 뜯어보기 (새로 나온 것 위주)

void light()
light()는 씬의 빛 하나마다 실행되는 처리 함수다. fragment()가 표면색(ALBEDO)을 정한다면, light()는 그 표면이 이 빛을 얼마나 받는지를 계산해 최종 조명에 더한다. 이 함수를 직접 쓰면 Godot 기본 조명 대신 우리 공식이 쓰인다.
NORMAL
이 지점의 표면이 향하는 방향(길이 1의 단위 벡터), view 공간 기준. 구라면 중심에서 바깥으로 뻗는 방향이다.
LIGHT
이 지점에서 빛을 향하는 방향 벡터(view 공간). Godot가 이미 정규화해서 넘겨준다.
normalize(...)
벡터를 길이 1로 다시 맞추는 내장 함수(01의 정규화 그것). 각도만 필요한 내적 앞에서 길이의 영향을 지우려고 쓴다.
dot(a, b)
두 벡터의 내적을 구하는 내장 함수(02에서 본 그 내적). 둘 다 단위 벡터면 결과는 곧 cosθ — 두 방향이 얼마나 일치하는지다.
max(a, b)
두 값 중 큰 쪽을 돌려주는 내장 함수. max(내적, 0.0)으로 빛을 등진(내적이 음수인) 뒷면을 0으로 잘라낸다.
LIGHT_COLOR
이 빛의 색에 에너지(밝기)가 이미 곱해진 vec3다. 여기에 밝기 계수(ndotl)와 표면색(ALBEDO)을 곱하면 이 빛 하나가 표면에 주는 기여분이 된다.
ATTENUATION
거리 감쇠와 그림자를 하나로 담은 0~1 계수. DirectionalLight3D는 보통 1에 가깝다. 곱해주면 그림자 진 곳이 어두워진다.
DIFFUSE_LIGHT += …
DIFFUSE_LIGHT는 이 표면에 쌓이는 확산광 결과값이다. +=로 이 빛의 기여분을 누적한다 — 빛이 여러 개면 light()가 그만큼 여러 번 돌며 계속 더해진다. 그래서 =가 아니라 +=다.

📸 블로그용 캡처: DirectionalLight3D를 회전시키며 구의 밝은 면이 빛을 따라 도는 GIF. 빛을 완전히 등졌을 때 뒷면이 검게 죽는 순간 스샷 1장.

04 / 툰 밴딩

툰 밴딩 — 람베르트 + 양자화

람베르트 값은 0에서 1 사이로 부드럽게 이어진다. 여기에 1강의 양자화 공식을 그대로 얹으면 — floor(lambert·n)/n — 부드러운 그라데이션이 정확히 n개의 계단으로 끊긴다. 이게 셀 셰이딩(툰 셰이딩)의 전부다.

floor는 항상 아래로 내림하므로 각 밴드가 살짝 어두운 쪽으로 치우친다. round로 바꾸면 반올림이라 경계가 밴드 중앙으로 옮겨가고 밝은 영역이 조금 더 넓어 보인다 — 어느 쪽이 정답은 아니고, 취향과 아트 방향의 문제다.

밴드 수4
방식floor

n을 낮출수록 계단이 굵어지고 '툰'다워진다. floor와 round를 번갈아 눌러 밝은 영역의 폭이 어떻게 달라지는지 비교해 보라.

🛠 Godot 실습 — 셀 셰이딩: 밴딩 얹기

  1. s03의 셰이더를 복제하거나 이어서 편집한다.
  2. 맨 위에 bands 유니폼을 추가하고, light() 안에서 밝기값을 아래처럼 양자화한다:
    shader_type spatial;
    
    uniform int bands : hint_range(2, 6) = 4;
    
    void light() {
    	float ndotl = max(dot(normalize(NORMAL), normalize(LIGHT)), 0.0);
    	float toon = floor(ndotl * float(bands)) / float(bands);
    	DIFFUSE_LIGHT += toon * ATTENUATION * LIGHT_COLOR * ALBEDO;
    }
  3. Inspector의 bands 슬라이더를 2→6으로 움직이며 매끄럽던 그라데이션이 계단으로 끊기는 것 확인.

💡 floorround로 바꾸면 데모의 round 모드처럼 밝은 밴드가 넓어진다. 둘 다 정답 — 아트 취향의 문제다.

🔍 코드 뜯어보기 (밴딩만)

float toon = floor(ndotl * float(bands)) / float(bands);
1강의 양자화 공식 floor(x·n)/n을 이번엔 UV가 아니라 밝기값 ndotl에 그대로 적용한 것이다. 0~1로 이어지던 밝기가 정확히 bands개의 계단으로 끊긴다. float(bands)는 정수 bands를 실수 나눗셈에 쓰기 위한 형변환(1강과 같은 이유). 나머지 조명 줄(ATTENUATION · LIGHT_COLOR · ALBEDO 곱)은 s03과 똑같고, 밝기 계수만 ndotl에서 toon으로 바뀐다.

📸 블로그용 캡처: bands를 2→6으로 바꾸는 GIF(계단이 촘촘해짐). floorround에서 밝은 밴드 폭이 달라지는 before/after 스샷 2장.

05 / 디더링 (보너스)

Bayer 디더링 — 켜지는 순번표

Bayer 행렬은 '몇 번째로 켜질지' 정해둔 순번표다. 2×2 기본 행렬은 0 2 / 3 1 — 칸마다 0부터 3까지 서로 다른 순번이 매겨져 있다. 한 픽셀의 밝기가 순번 / N²이라는 문턱값을 넘으면 그 칸은 켜지고, 못 넘으면 꺼진다.

4×4는 2×2의 재귀다 — 전체를 2×2로 4등분한 뒤, 각 칸의 순번에 4를 곱하고 그 안을 다시 2×2 패턴으로 채운다. 8×8도 같은 방식으로 4×4를 4등분해 만든다. 이 순번표는 화면 좌표(FRAGCOORD)를 그대로 인덱스로 쓰기 때문에, 물체가 아니라 화면에 달라붙는다 — 6강에서 다시 만날 개념이다.

행렬 크기(N)4×4
문턱값rank / 16
on 비율

같은 밝기라도 칸의 순번이 다르면 켜지는 시점이 다르다 — 그 결과 색이 두 개(켜짐/꺼짐)뿐이어도 점의 밀도로 더 많은 밝기 단계를 흉내 낸다.

🛠 Godot 실습 — Bayer 스크린 디더링

  1. MeshInstance3D를 추가하고 Mesh에 New QuadMesh(또는 PlaneMesh)를 넣는다. Material Override에 New ShaderMaterialNew Shader(spatial).
  2. 아래 코드 붙여넣기:
    shader_type spatial;
    render_mode unshaded;
    
    const float bayer4[16] = float[](
    	 0.0,  8.0,  2.0, 10.0,
    	12.0,  4.0, 14.0,  6.0,
    	 3.0, 11.0,  1.0,  9.0,
    	15.0,  7.0, 13.0,  5.0
    );
    
    uniform float brightness : hint_range(0.0, 1.0) = 0.5;
    
    void fragment() {
    	ivec2 p = ivec2(FRAGCOORD.xy) % 4;
    	float threshold = (bayer4[p.y * 4 + p.x] + 0.5) / 16.0;
    	ALBEDO = vec3(brightness > threshold ? 1.0 : 0.0);
    }
  3. Inspector의 brightness 슬라이더를 0→1로 움직이며 점 밀도가 변하는지 확인. 카메라를 움직여도 점 패턴이 물체가 아니라 화면에 붙어 있는지(FRAGCOORD가 화면 좌표라서) 관찰.

⚠️ 막힘 주의: 이 셰이더는 shader_type spatial이라 ColorRect(2D)엔 못 붙인다 — MeshInstance3D에 붙여라. brightness가 0이면 전부 검정, 1이면 전부 흰색이라 중간값(0.3~0.7)에서 점 패턴이 가장 잘 보인다.

🔍 코드 뜯어보기 (새로 나온 것 위주)

const float bayer4[16] = float[](…);
const는 값이 바뀌지 않는 상수다 — uniform과 달리 Inspector에 안 나오고 컴파일 시점에 고정된다. float[16]은 실수 16개짜리 배열, float[](…)는 그 배열을 채우는 생성자 문법이다. 여기선 4×4 Bayer 행렬을 한 줄로 편 순번표를 담았다.
FRAGCOORD
이 픽셀의 화면 좌표(vec4, xy가 픽셀 위치)다. UV가 물체 표면에 붙는 0~1 좌표라면, FRAGCOORD는 화면 픽셀에 붙는 정수 단위 좌표다 — 그래서 패턴이 물체가 아니라 화면에 고정된다.
ivec2 p = ivec2(FRAGCOORD.xy) % 4;
ivec2는 정수 2성분 벡터(vec2의 정수판)다. ivec2(FRAGCOORD.xy)로 화면 좌표를 정수로 바꾼 뒤 % 4를 한다. %는 나머지(모듈로) 연산이고, 벡터에 쓰면 x·y 각 성분에 따로 적용돼 값이 0~3으로 접힌다 — 4×4 타일이 화면 전체에 반복되는 원리다.
bayer4[p.y * 4 + p.x]
1차원 배열을 2차원 격자처럼 읽는 관용식이다. 행 p.y에 폭 4를 곱하고 열 p.x를 더해 (행, 열)을 하나의 인덱스로 접는다. 꺼낸 순번(0~15)에 0.5를 더하고 16으로 나눠 0~1 문턱값을 만든 뒤, brightness가 그 값을 넘으면 흰색(켜짐), 못 넘으면 검정(꺼짐)이 된다.

📸 블로그용 캡처: brightness 0→1 슬라이더로 점 밀도가 변하는 GIF. 카메라나 메시를 움직여도 점 패턴이 화면에 고정돼 있는 모습(FRAGCOORD 특성)을 담은 스샷/GIF.

06 / 과제

과제

  1. 1
    밴드 수 실험 — 04에서 밴드 수를 2부터 하나씩 올려보라. 몇 단쯤부터 '툰답게' 보이기 시작하는지, 그 경계가 왜 그 지점에서 느껴지는지 스스로 답을 찾아보라. floor와 round를 번갈아 눌러보면서 round가 왜 밝은 영역을 더 넓게 만드는지도 함께 생각해 보라.
  2. 2
    림 라이트 스케치 — 구의 가장자리는 법선이 시선(카메라) 방향과 거의 90°를 이룬다 — 그러니 시선 벡터와 법선의 내적은 0에 가깝다. 여기서 1 − dot을 계산하면 가장자리일수록 1에 가까워져 하얗게 빛난다. 이게 림 라이트의 원리이자, 8강에서 만날 프레넬(Fresnel)의 예고편이다. 종이에 원을 하나 그리고, 가장자리를 따라 밝기가 어떻게 변할지 손으로 스케치해 보라.