빛을 받는 각도 하나로 밝기가 정해진다 — 내적(dot product)이 그 각도를 숫자로 바꾼다. 여기에 1강의 양자화를 얹으면 셀 셰이딩(툰), 디더링을 얹으면 팔레트보다 많은 색을 흉내 낸다.
점에서 점을 빼면 벡터가 된다 — Q − P는 P에서 Q로 가는 화살표다. 거꾸로 점에 벡터를 더하면 다시 점이 된다: P + v = Q. 벡터의 길이는 피타고라스 정리 그대로, 성분을 제곱해 더하고 제곱근을 씌운 값이다.
정규화(normalize)는 벡터를 자신의 길이로 나누는 것 — 방향은 그대로 두고 길이만 정확히 1로 만든다. 셰이더에서 법선(NORMAL)이나 빛 방향(LIGHT_DIRECTION)이 항상 normalize()를 거치는 이유가 이것이다. 아래에서 두 점 P, Q를 직접 드래그해 보라.
P나 Q를 드래그하면 벡터(청록 화살표)와 길이, 정규화된 방향(보라 화살표, 길이 1로 고정)이 실시간으로 갱신된다. 방향은 같아도 길이는 항상 1이다.
내적에는 두 가지 정의가 있고 둘은 완전히 같다. 계산 정의는 성분끼리 곱해서 더하는 것 — 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로 내적이 음수가 된다. 슬라이더로 θ를 돌려 부호가 바뀌는 순간을 직접 확인하라.
점선은 b를 a 방향으로 투영한 그림자다. 두 계산 방식(성분곱합과 |a||b|cosθ)은 항상 같은 값을 낸다 — 이것이 내적의 두 정의가 하나라는 증거다.
람베르트 조명은 셰이더 한 줄로 요약된다: max(0, dot(NORMAL, L)). 표면의 법선 N과 빛 방향 L 사이의 내적 하나가 그 지점의 밝기다. 빛을 정면으로 받는 곳은 N과 L이 같은 방향이라 내적이 1(가장 밝음), 90°로 스치는 곳은 내적이 0, 빛을 등진 뒤쪽은 내적이 음수가 되므로 max(0, ...)로 0에 클램프한다.
빛 방향 핸들을 드래그해 구 주위를 돌려 보라. 표면 각 지점의 법선(구 중심에서 바깥으로 향하는 방향)과 빛의 내적이 실시간으로 밝기 그라데이션을 만든다.
표시된 점(흰 테두리 점)에서 법선과 빛의 내적을 따로 읽을 수 있다. 빛이 정면이면 1, 옆을 스치면 0, 뒤로 넘어가면 음수라 검게 클램프된다.
🛠 Godot 실습 — 첫 3D 조명: 람베르트
MeshInstance3D를 추가하고 Mesh에 New SphereMesh(구)를 넣는다. Material Override에 New ShaderMaterial → Shader에 New Shader(.gdshader).shader_type spatial;
void light() {
float ndotl = max(dot(normalize(NORMAL), normalize(LIGHT)), 0.0);
DIFFUSE_LIGHT += ndotl * ATTENUATION * LIGHT_COLOR * ALBEDO;
}
⚠️ 막힘 주의: 씬에 빛이 하나도 없으면 light()가 실행되지 않아 구가 새까맣다 — DirectionalLight3D를 꼭 추가하라. light()를 직접 정의하면 Godot 기본 조명 공식 대신 이 코드가 쓰인다.
🔍 코드 뜯어보기 (새로 나온 것 위주)
void light() ⭐light()는 씬의 빛 하나마다 실행되는 처리 함수다. fragment()가 표면색(ALBEDO)을 정한다면, light()는 그 표면이 이 빛을 얼마나 받는지를 계산해 최종 조명에 더한다. 이 함수를 직접 쓰면 Godot 기본 조명 대신 우리 공식이 쓰인다.NORMALLIGHTnormalize(...)dot(a, b)max(a, b)max(내적, 0.0)으로 빛을 등진(내적이 음수인) 뒷면을 0으로 잘라낸다.LIGHT_COLORvec3다. 여기에 밝기 계수(ndotl)와 표면색(ALBEDO)을 곱하면 이 빛 하나가 표면에 주는 기여분이 된다.ATTENUATIONDIFFUSE_LIGHT += …DIFFUSE_LIGHT는 이 표면에 쌓이는 확산광 결과값이다. +=로 이 빛의 기여분을 누적한다 — 빛이 여러 개면 light()가 그만큼 여러 번 돌며 계속 더해진다. 그래서 =가 아니라 +=다.📸 블로그용 캡처: DirectionalLight3D를 회전시키며 구의 밝은 면이 빛을 따라 도는 GIF. 빛을 완전히 등졌을 때 뒷면이 검게 죽는 순간 스샷 1장.
람베르트 값은 0에서 1 사이로 부드럽게 이어진다. 여기에 1강의 양자화 공식을 그대로 얹으면 — floor(lambert·n)/n — 부드러운 그라데이션이 정확히 n개의 계단으로 끊긴다. 이게 셀 셰이딩(툰 셰이딩)의 전부다.
floor는 항상 아래로 내림하므로 각 밴드가 살짝 어두운 쪽으로 치우친다. round로 바꾸면 반올림이라 경계가 밴드 중앙으로 옮겨가고 밝은 영역이 조금 더 넓어 보인다 — 어느 쪽이 정답은 아니고, 취향과 아트 방향의 문제다.
n을 낮출수록 계단이 굵어지고 '툰'다워진다. floor와 round를 번갈아 눌러 밝은 영역의 폭이 어떻게 달라지는지 비교해 보라.
🛠 Godot 실습 — 셀 셰이딩: 밴딩 얹기
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;
}
💡 floor를 round로 바꾸면 데모의 round 모드처럼 밝은 밴드가 넓어진다. 둘 다 정답 — 아트 취향의 문제다.
🔍 코드 뜯어보기 (밴딩만)
float toon = floor(ndotl * float(bands)) / float(bands);floor(x·n)/n을 이번엔 UV가 아니라 밝기값 ndotl에 그대로 적용한 것이다. 0~1로 이어지던 밝기가 정확히 bands개의 계단으로 끊긴다. float(bands)는 정수 bands를 실수 나눗셈에 쓰기 위한 형변환(1강과 같은 이유). 나머지 조명 줄(ATTENUATION · LIGHT_COLOR · ALBEDO 곱)은 s03과 똑같고, 밝기 계수만 ndotl에서 toon으로 바뀐다.📸 블로그용 캡처: bands를 2→6으로 바꾸는 GIF(계단이 촘촘해짐). floor와 round에서 밝은 밴드 폭이 달라지는 before/after 스샷 2장.
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강에서 다시 만날 개념이다.
같은 밝기라도 칸의 순번이 다르면 켜지는 시점이 다르다 — 그 결과 색이 두 개(켜짐/꺼짐)뿐이어도 점의 밀도로 더 많은 밝기 단계를 흉내 낸다.
🛠 Godot 실습 — Bayer 스크린 디더링
MeshInstance3D를 추가하고 Mesh에 New QuadMesh(또는 PlaneMesh)를 넣는다. Material Override에 New ShaderMaterial → New Shader(spatial).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);
}
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 행렬을 한 줄로 편 순번표를 담았다.FRAGCOORDvec4, 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]p.y에 폭 4를 곱하고 열 p.x를 더해 (행, 열)을 하나의 인덱스로 접는다. 꺼낸 순번(0~15)에 0.5를 더하고 16으로 나눠 0~1 문턱값을 만든 뒤, brightness가 그 값을 넘으면 흰색(켜짐), 못 넘으면 검정(꺼짐)이 된다.📸 블로그용 캡처: brightness 0→1 슬라이더로 점 밀도가 변하는 GIF. 카메라나 메시를 움직여도 점 패턴이 화면에 고정돼 있는 모습(FRAGCOORD 특성)을 담은 스샷/GIF.