二つのシェーダーとその間
한국어 원문으로 표시합니다.
목표
정점 셰이더와 프래그먼트 셰이더를 파이썬 함수로 쓰고, 그 사이에 래스터라이저를 놓아 실제로 그림을 뽑습니다. 이 실습이 끝나면 GLSL 코드를 읽을 때 각 값이 어디서 와서 어디로 가는지 보이게 됩니다.
왜 중요한가
셰이더는 파이프라인 아무 데나 열어 준 것이 아닙니다. 구조는 그대로 두고 정점 단계와 프래그먼트 단계만 열었습니다. 그래야 나머지(래스터라이즈, 깊이 판정, 혼합)를 하드웨어가 계속 병렬로 처리할 수 있습니다.
그 제약이 셰이더의 규칙을 만듭니다. 정점 셰이더는 정점을 만들거나 없앨 수 없고, 프래그먼트 셰이더는 이웃 픽셀의 결과를 볼 수 없습니다. 대신 두 단계 사이의 통로가 명확합니다 — 정점 셰이더가 varying 으로 내보내면 래스터라이저가 무게중심 좌표로 섞어 프래그먼트 셰이더에 넘겨 줍니다. 이 구조를 손으로 한 번 만들어 보면 GPU 프로그래밍의 제약이 왜 그 모양인지 이해됩니다.
단계
/root/stages에 도구 상자를 놓습니다./root/stages/vs.py— 정점 셰이더./root/stages/varying.py— 값 보간./root/stages/fs.py와pipeline.py— 프래그먼트 셰이더와out/uv.png./root/stages/out/u1.png,u2.png,u3.png— 유니폼만 바꾸기./root/stages/out/discard.png— 프래그먼트 버리기./root/stages/out/final.png와out/07-counts.txt— 호출 횟수.
참고
- 그림은
nohup python3 -m http.server 8080 -d /root/stages/out &로 띄우고 웹 미리보기 에서http://localhost:8080/을 엽니다. - 한 장을 그리는 데 몇 초 걸립니다. 프래그먼트 셰이더가 2만 번 넘게 불리기 때문인데, 그 느림 자체가 이 실습에서 볼 것 중 하나입니다.
- 흔한 실수 하나:
varyings를 보간하지 않고 첫 정점의 값을 그대로 쓰는 것입니다. 그러면 삼각형이 한 가지 색으로 칠해집니다. - 흔한 실수 둘: 화면 좌표로 옮길 때 y 뒤집기를 빠뜨리는 것입니다. 그림이 위아래로 뒤집혀 나옵니다.
그리기 도구 상자를 놓는다
/root/stages/gfxlib.py 를 예시 그대로 저장하고, /root/stages/check.py 로 시험 무늬를 그려 /root/stages/out/00-check.png 를 만드십시오. 무늬는 64x64 검은 배경에 (0,0)에서 (63,63)까지 흰색(255,255,255) 대각선을 긋고, 그 뒤에 (0,32)에서 (63,32)까지 빨강(255,0,0) 가로선을 겹쳐 그은 것입니다.
이 실습부터는 PNG 인코더를 다시 만들지 않습니다. 첫 실습에서 손으로 만든 것과 같은 코드를 도구로 내려 줍니다 — 여기서 배울 것은 파일 형식이 아니기 때문입니다.
실습 파드에는 볼륨이 없어서 앞 실습에서 만든 파일이 남아 있지 않습니다. 그래서 실습마다 도구 상자를 다시 놓는 것으로 시작합니다.
Canvas(w, h, bg) 를 만들고 line(x0, y0, x1, y1, rgb) 로 두 선을 그은 뒤 write_png(path) 로 저장하십시오. 가로선을 나중에 그어야 교차점 (32,32) 가 빨강이 됩니다.
이 실습에서는 셰이더가 만든 픽셀을 그림으로 내보내는 데 씁니다.
정점 셰이더
/root/stages/vs.py 에 vertex_shader(attr, uniforms) 를 만드십시오. attr 은 {"position": (x,y,z), "uv": (u,v)}, uniforms 는 {"mvp": 4x4 행렬, "scale": 실수} 입니다. 위치에 scale 을 곱한 뒤 mvp 를 적용한 네 성분을 gl_Position 으로, uv 를 그대로 담은 딕셔너리를 varyings 로 하여 {"gl_Position": ..., "varyings": {...}} 를 돌려줍니다.
정점 셰이더는 정점 하나를 받아 정점 하나를 내놓습니다. 만들거나 없앨 수 없습니다. 그래야 하드웨어가 몇 개를 처리할지 미리 알고 병렬로 나눌 수 있습니다.
attribute 는 정점마다 다른 값이고 uniform 은 드로우 콜 전체에서 같은 값입니다. 이 구분이 GPU 가 값을 어디에 두는지를 정합니다.
행렬 곱은 m[r][0]*x + m[r][1]*y + m[r][2]*z + m[r][3]*1 을 r=0..3 에 대해 계산하면 됩니다. 점이므로 w 는 1 입니다.
varyings 는 프래그먼트 단계로 넘길 값들입니다. 여기서는 uv 하나뿐이지만 실제 셰이더에서는 법선·색·접선 등이 함께 실립니다.
래스터라이저가 값을 섞는다
/root/stages/varying.py 에 interpolate(bary, v0, v1, v2) 를 만드십시오. bary 는 (u, v, w) 세 실수, v0·v1·v2 는 같은 열쇠를 가진 varyings 딕셔너리입니다. 값이 튜플이면 성분별로, 실수면 그대로 u*v0 + v*v1 + w*v2 를 계산해 같은 모양의 딕셔너리를 돌려줍니다.
이 단계는 셰이더가 아니라 래스터라이저의 일입니다. 프로그램할 수 없는 고정 단계인데, 우리가 직접 만들어 보는 이유는 정점 셰이더의 출력이 프래그먼트 셰이더의 입력이 되는 통로가 여기이기 때문입니다.
값의 종류를 가려야 합니다. isinstance(x, (tuple, list)) 로 나누면 됩니다.
무게중심 좌표의 합이 1 이므로, 세 꼭짓점의 값이 모두 같으면 결과도 같은 값이 나옵니다. 이 성질로 구현을 확인할 수 있습니다.
프래그먼트 셰이더와 첫 그림
/root/stages/fs.py 에 fragment_shader(varyings, uniforms) 를 만드십시오. u, v = varyings["uv"], k = uniforms["brightness"] 로 두고 (255*(0.2+0.8*u)*k, 255*(0.2+0.8*v)*k, 255*(0.2+0.8*(1-u))*k) 를 돌려줍니다. 그리고 /root/stages/pipeline.py 로 정점 셋 (-0.8,-0.8,0)/uv(0,0), (0.8,-0.8,0)/uv(1,0), (-0.8,0.8,0)/uv(0,1) 을 단위 행렬 mvp, scale 1, brightness 1 로 그려 /root/stages/out/uv.png(256x256, 검은 배경)를 만드십시오. 화면 좌표는 sx=(x*0.5+0.5)*256, sy=(1-(y*0.5+0.5))*256 입니다.
파이프라인의 순서는 이렇습니다. 정점마다 정점 셰이더를 부르고, 클립 좌표를 화면 좌표로 옮기고, 삼각형의 경계 상자를 훑으며 무게중심 좌표를 구하고, 안쪽이면 varying 을 보간해 프래그먼트 셰이더를 부릅니다.
프래그먼트 셰이더는 픽셀마다 불립니다. 이 삼각형은 화면의 3분의 1 가까이를 덮으므로 2만 번 넘게 불립니다. 정점 셰이더는 세 번뿐입니다. 이 차이가 성능 이야기의 출발점입니다.
색 계산에 0.2 를 더해 둔 이유는 uv 가 0 인 자리에서도 색이 0 이 되지 않게 하려는 것입니다. 다음 단계에서 밝기를 바꿔 가며 비교할 때 필요합니다.
gl_Position 의 w 가 1 이므로 여기서는 나눗셈이 항등입니다. 원근이 들어가면 그 나눗셈이 살아납니다.
유니폼만 바꾼다
기하는 그대로 두고 brightness 만 1.0, 0.6, 0.3 으로 바꿔 /root/stages/out/u1.png, u2.png, u3.png 세 장을 만드십시오.
정점 데이터도 삼각형도 그대로입니다. 유니폼 하나만 바뀝니다.
세 그림에서 색이 칠해진 자리(검지 않은 픽셀의 집합)는 완전히 같아야 합니다. 기하가 바뀌지 않았으니까요. 달라졌다면 정점 셰이더나 래스터라이저가 유니폼에 영향을 받고 있는 것입니다.
반면 색은 눈에 띄게 달라야 합니다. 이것이 유니폼의 쓰임입니다 — 같은 메시를 다른 재질이나 다른 조명으로 여러 번 그릴 때, 정점 데이터를 다시 보내지 않고 유니폼만 바꿔 그립니다.
색 계산에 0.2 가 더해져 있어 밝기 0.3 에서도 픽셀이 검정이 되지는 않습니다.
프래그먼트를 버린다
같은 삼각형을 그리되 보간된 uv 가 u*u + v*v > 0.36 인 프래그먼트는 버려서 /root/stages/out/discard.png 를 만드십시오(brightness 는 1.0).
버리기는 프래그먼트 셰이더만 할 수 있는 일입니다. 이 프래그먼트를 아예 쓰지 않겠다고 선언하면 색 버퍼에도 깊이 버퍼에도 아무것도 남지 않습니다.
나뭇잎이나 철망처럼 구멍이 뚫린 표면을 표현할 때 씁니다. 반투명과 다릅니다 — 반투명은 뒤가 비쳐 보이도록 섞는 것이고, 버리기는 그 자리에 아무것도 그리지 않는 것입니다.
버리기는 공짜가 아닙니다. discard 가 있는 셰이더는 색을 계산해 봐야 살아남는지 알 수 있어서, 깊이를 미리 판정하는 최적화를 쓸 수 없게 만듭니다. 그래서 구멍 뚫린 표면이 완전히 불투명한 것보다 비쌉니다.
버려진 자리는 배경(검정)이 그대로 남습니다.
어느 단계가 몇 번 불렸나
버리기를 켠 상태로 다시 그려 /root/stages/out/final.png 를 만들고, /root/stages/out/07-counts.txt 에 vertices=, triangles=, fragments_in=, fragments_out= 네 줄을 적으십시오. fragments_in 은 삼각형 안쪽으로 들어온 프래그먼트 수, fragments_out 은 버리고 남은 수입니다.
정점 셰이더는 정점 수만큼, 프래그먼트 셰이더는 화면을 덮은 넓이만큼 불립니다. 이 삼각형은 정점 셋인데 프래그먼트는 2만 개가 넘습니다.
그래서 무거운 계산은 가능하면 정점 셰이더로 옮기고 보간에 맡깁니다. 다만 보간은 선형이라, 정규화가 필요한 값은 프래그먼트 쪽에서 다시 정규화해야 합니다.
fragments_out 은 최종 그림에서 검지 않은 픽셀의 개수와 정확히 같아야 합니다. 채점기가 그림을 열어 세어 맞춰 봅니다. 다르면 어느 한쪽이 틀린 것입니다.
숫자를 지어내지 말고 반복문 안에서 세십시오.