CSE4020 · HANYANG UNIV · 2018 SPRING

컴퓨터그래픽스

직접 만든 ear-clipping 삼각화기와 인터리브드 정점 배열까지 손댄 야심 찬 OBJ 뷰어. 그러나 "법선을 고쳤다"는 이름의 마지막 커밋이 정점 법선을 면 법선으로 무너뜨렸고, 문서는 그 사실을 모른 채 여전히 정점 법선을 쓴다고 적어 두었다.

파일74
커밋62
기간2018.03–06
스택Python 3 · PyOpenGL · glfw
C+종합
소견3 치명적5 중대7 경미합계 15

총평

한 학기 분량의 그래픽스 수업 레포다. 과제 셋(Python 환경 설정, 계층적 렌더링, OBJ 뷰어)과 19개 실습 디렉터리로 구성되고, 진짜 평가 대상은 사실상 assignment3/ 하나다. 이 저자는 남이 준 뼈대를 그대로 제출하지 않았다. 삼각화 알고리즘을 직접 짰고, glVertex 호출 대신 인터리브드 정점 배열을 만들어 glDrawArrays로 넘겼고, Light/Mode/Window/OBJ라는 나름의 추상화 계층을 세웠다. 과제 명세(assignment3/docs/Assignment3-v3.pdf)의 추가 점수 항목 C(혼합 다각형 메시를 glDrawArrays로, +20점)를 정면으로 노린 구성이고, 레포에 들어 있는 샘플 6개는 실제로 전부 로드된다. 나는 OpenGL 호출만 스텁으로 막고 파서와 삼각화기를 numpy 1.24로 직접 돌려 이를 확인했다.

문제는 그 다음이다. 이 레포의 모든 추상화는 자기가 광고하는 것보다 정확히 한 칸씩 덜 정확하다. docs/report.md는 삼각화가 "Seidel's algorithm 기반"이라고 적었지만 코드는 교과서적인 ear clipping이다. README는 "vertex position, normals and faces만으로 메시를 표시"한다고 체크했지만 로더는 각 면의 첫 번째 정점의 법선 하나만 읽어 세 정점에 복사한다. 4개 광원 전부에 GL_SPECULAR를 설정해 놓고 재질 쪽 GL_SPECULAR·GL_SHININESS는 한 번도 설정하지 않아 정반사 하이라이트는 물리적으로 0이다. "Trianglize quad or polygons"라고 적었지만 오목 다각형을 넣으면 obj.py:85에서 ValueError로 죽는다. 레포에 실재하는 labs/lab14/cube.obj(f 1 2 3 4 형식)를 이 뷰어에 떨어뜨리면 로더가 예외를 던진다.

가장 잘한 것 하나: assignment3/lib/obj.py:40–43의 인터리브드 배열 세팅은 산술적으로 정확하다. stride를 6*itemsize로 잡고 법선을 오프셋 0, 정점을 오프셋 3*itemsize에 두고 glDrawArrays의 개수를 array.size/6으로 계산했다. 내가 생성된 배열의 shape((6·면정점수, 3))와 대조해 셋이 서로 맞물린다는 것을 확인했다. 학부 과제에서 이 정도 인덱스 산술을 틀리지 않고 맞추는 경우는 흔하지 않다.

가장 치명적인 것 하나: 마감 이틀 전 커밋 3af4365 "Update lib.obj to correct normal and arange"가 정점별 법선 조회(obj.values.normal[index[2]-1], 면의 각 정점마다 자기 법선 인덱스를 쓰던 코드)를 면당 하나의 법선으로 교체했다. 법선을 "고쳤다"고 이름 붙인 커밋이 실은 명세 B.ii(40점)의 핵심인 정점 법선을 없앤 커밋이다. 부드러운 구가 각진 구로 렌더링되며, 나는 sphere-tri.obj 960개 면 전부가 파일에서는 서로 다른 vn 인덱스 3개를 갖는데 로더 출력에서는 960개 삼각형 전부가 동일 법선 3개를 내놓는다는 것을 수치로 확인했다.

과제주제핵심 판정등급
A1Python / numpy 리뷰명세가 요구한 것이 스크린샷이라 요구를 다 채웠다. 평가할 엔지니어링은 없고, 결과물이 labs/lab1/에 바이트 단위로 중복돼 있다.B
A2Hierarchical Rendering부모–자식 변환 누적은 실제로 동작하나 깊이 2단에서 멈췄고, push/pop 네 쌍 중 세 쌍이 아무 일도 하지 않는다. 투영 행렬이 MODELVIEW 스택에 들어간다.B-
A3OBJ 로더 + 렌더러야심은 최고, 견고성은 최저. 정점 법선 소실, 오목 다각형 크래시, f v·f v/vt·음수 인덱스 전부 미지원.C+
labs주차별 실습 19개과제가 아니라 수업 따라치기 기록. 평가 대상 아님.

Assignment 1 — Python / numpy 리뷰

명세(labs/lab1/Assignment1-MT.pdf)를 뜯어보면 이 과제는 프로그래밍 과제가 아니다. "Python 3.5·NumPy·PyOpenGL·glfw를 설치하고, 인터랙티브 모드에서 버전을 출력해 스크린샷을 찍고(B), 강의 슬라이드 16–27쪽 코드를 입력해 스크린샷을 찍고(C), 빈 파이썬 소스 파일을 만들어…(D)"가 전부다. 제출물은 B.png, C1–C4.png, D1–D4.png 9장과 노트북 하나이며, 저자는 그 9장을 다 만들어 넣었다. 요구는 충족됐다. 따라서 여기서 "numpy를 안 썼다"고 감점하는 것은 명세를 안 읽은 채점이다.

경미

같은 산출물이 두 디렉터리에 바이트 단위로 중복돼 있다

diff -r assignment1 labs/lab1의 유일한 차이는 labs/lab1/ 쪽에만 명세 PDF가 있다는 것뿐이다. 노트북·.py·PNG 9장(약 1.2 MB)이 그대로 두 번 커밋돼 있다. 과제 제출용 사본과 실습 사본을 분리하려던 의도는 읽히지만, 심볼릭 링크도 .gitattributes도 아닌 물리적 복사다.

$ diff -r assignment1 labs/lab1
Only in labs/lab1: Assignment1-MT.pdf
(그 외 차이 없음 — Assignment1.ipynb, Assignment1.py, B/C1-4/D1-4.png 전부 동일)
경미

.py 내보내기가 노트북의 앞부분을 통째로 버렸다

Assignment1.ipynb는 코드 셀 26개인데 Assignment1.py는 24번째 셀("Loop for Sequence Types")부터 시작한다. 즉 이 과제의 유일한 검증 지점인 import numpy / import OpenGL / import glfw와 버전 출력 셀이 .py에는 없다. 노트북 전체에서 np.가 나오는 셀은 print (np.__version__) 한 곳뿐이다(명세가 그것만 요구했으니 정상이지만, .py만 보는 사람에게는 이 파일이 왜 "Python/numpy 리뷰"인지 알 방법이 없다).

Assignment 2 — Hierarchical Rendering

단일 파일 158줄(assignment2/main.py)로, 손으로 좌표를 찍은 "HER_ES" 글자 폴리곤 위에 육각형 몸체와 회전하는 날개 3장으로 된 로고를 얹어 "HEROES"를 완성하는 구성이다. 날개 윤곽은 4점 Bézier 두 개를 detail개로 샘플링해 안쪽·바깥쪽을 잇는 띠로 만든다. 카메라는 A/D/W/S로 조작한다. 계층 구조는 drawHOS() 한 곳에 있다: 육각형을 그린 뒤 1.3배 스케일을 걸고, 그 스케일을 물려받은 자식 좌표계에서 날개 3장을 120°씩 돌려 배치한다. 부모→자식 변환 누적은 실제로 맞게 동작한다. 문제는 그 계층이 딱 거기서 끝난다는 것이다.

중대

투영 행렬을 MODELVIEW 스택에 쌓는다 — 이 파일에 glMatrixMode가 한 번도 없다

고정 파이프라인의 기본 행렬 모드는 GL_MODELVIEW다. assignment2/main.py 전체를 grep해도 glMatrixMode는 0건이므로, gluPerspective가 만든 투영 행렬이 모델뷰 행렬에 곱해지고 GL_PROJECTION은 항등 행렬로 남는다. 최종 클립 좌표는 I · (P·V·M) · v로 같으니 이 과제에서는 화면이 정상으로 보인다. 그러나 모델뷰 행렬은 조명 계산이 일어나는 눈 좌표계를 정의하는 행렬이라, 이 코드에 광원을 하나라도 추가하는 순간 조명이 전부 깨진다. 저자도 이 문제를 나중에 알았다: 같은 레포의 labs/lab17(5월 14일)부터 labs/lab19, lab20, lab22, lab23, lab24, 그리고 assignment3/main.py:50–53까지 모두 glMatrixMode(GL_PROJECTION)/glMatrixMode(GL_MODELVIEW) 분리를 제대로 한다. 즉 4월의 assignment2만 고쳐지지 않은 채 남았다.

assignment2/main.py:99–106
def render():
    global gCamAng, gCamHeight
    glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT)
    glEnable(GL_DEPTH_TEST)
    glPolygonMode(GL_FRONT_AND_BACK, GL_LINE)
    glLoadIdentity()                 ← 모드는 여전히 GL_MODELVIEW
    gluPerspective(25, 1, 1, 10)     ← 투영이 모델뷰에 곱해짐
    gluLookAt(5*np.sin(gCamAng), gCamHeight, 5*np.cos(gCamAng), 0,0,0, 0,1,0)
중대

행렬 스택 네 쌍 중 세 쌍이 아무 일도 하지 않는다 — 계층 깊이가 2단에서 멈춘 이유

push/pop 짝은 전부 맞다(28↔39, 54↔57, 69↔84, 74↔82, 76↔80). 그러나 실제로 변환을 격리하는 쌍은 drawHOS 안의 76↔80 하나뿐이다. 나머지는 push와 pop 사이에 행렬을 건드리는 호출이 아예 없다. 특히 drawWing의 54↔57은 날개의 두 번째 레이어(z=.1)를 감싸고 있는데, 여기가 바로 3단계 계층이 들어갈 자리다. 스캐폴딩만 세워 놓고 변환은 넣지 않았다는 뜻이고, 결과적으로 이 "Hierarchical Rendering" 과제의 계층은 육각형 → 날개 3장 2단이 전부다.

assignment2/main.py:54–57
glPushMatrix()
_draw(np.array([(10, -2), (20, -5), (55, 10), (61, 45)])/100,
        np.array([(10, -2), (25, -10), (60, -5), (76, 30)])/100, .1, detail)
glPopMatrix()   ← 사이에 glRotate/glTranslate/glScale 없음. 항등 push/pop.
경미

Bernstein 기저의 지수가 뒤집혀 있다 (기하학적으로는 무해)

표준 Bernstein 기저는 C(n,i)·t^i·(1-t)^(n-i)인데 코드는 t(1-t)의 지수를 맞바꿨다. C(n,i) = C(n,n-i)이므로 이는 제어점 순서를 뒤집은 것과 같고, 기저의 합은 여전히 1이다. 내가 drawWing이 실제로 쓰는 제어점으로 확인한 결과 t=0에서 마지막 제어점 (0.60, 0.50), t=1에서 첫 제어점 (0.00, 0.00)이 나왔고, t=0.5에서는 표준식과 소수점 아래까지 일치했다. 곡선의 모양은 같고 진행 방향만 반대다. 안쪽·바깥쪽 곡선이 똑같이 뒤집히므로 띠 폴리곤도 깨지지 않는다. 다만 이 코드를 재사용해 t로 애니메이션을 태우거나 곡선 시작점을 특정 좌표에 맞추려는 순간 조용히 틀린다.

assignment2/main.py:14–16
def bpoly(i, n, t):
    comb = lambda n, k: factorial(n) / factorial(k) / factorial(n - k)
    return comb(n, i) * (t**(n-i)) * (1 - t)**i   ← 표준: t**i * (1-t)**(n-i)
경미

애니메이션이 프레임 카운터에 묶여 있어 기기마다 속도가 다르다

main()의 루프가 매 프레임 counter += 1을 하고(:149), 날개 회전각(:77)과 인트로 줌 스케일(:113–116)이 모두 이 값에서 나온다. glfw.swap_interval 호출이 없어 vsync도 보장되지 않는다. 60 Hz 기기와 144 Hz 기기에서 같은 장면이 2.4배 다른 속도로 재생된다. 참고로 assignment3/main.py:119에서는 glfw.swap_interval(1)을 넣었다 — 여기서도 배운 것이 뒤 과제에만 반영됐다.

잘한 것

글자 목록의 ('O', [])(:32)는 실수가 아니라 구성이다. 나머지 글자들은 x=197부터 1109까지 찍혀 있고 R이 x=656에서 끝나 다음 E가 x=804에서 시작하는데, 그 빈칸의 중심은 글자 변환식 (x-620)/1080로 환산하면 약 0.102다. 그리고 로고를 놓는 glTranslatef(.09, -.025, .0)(:111)의 x가 0.09다. 즉 육각형+날개 로고가 "HEROES"의 O 자리를 차지하도록 좌표를 맞춰 둔 것이다. 글자 좌표 100여 개를 손으로 찍어 넣은 노동량도 그렇지만, 렌더 순서(글자 → 이동 → 로고)까지 그 배치에 맞춰 짠 것은 자기 출력물을 실제로 보면서 작업했다는 증거다.

Assignment 3 — OBJ 로더 + 렌더러

명세(assignment3/docs/Assignment3-v3.pdf)는 드래그앤드롭으로 OBJ를 읽어 다중 광원으로 렌더링하는 뷰어를 요구한다. 배점은 카메라 10점, OBJ 로드/렌더 70점, 조명 10점, 보고서 10점이고, 추가 점수는 A(혼합 다각형 메시 +10) / B(glDrawArrays +10) / C(둘 다 +20) 중 하나만 인정된다. 저자는 C를 노렸고, lib/obj.py 146줄 안에 파싱·삼각화·정점 배열 생성을 전부 넣었다. 나는 OpenGL 임포트만 스텁으로 막고 read_objtrianglize를 numpy 1.24.4로 직접 호출해 아래 내용을 전부 실측했다.

치명적

정점 법선을 버리고 면당 법선 하나를 쓴다 — 그리고 그렇게 만든 커밋의 이름이 "correct normal"이다

obj.py:138은 각 면의 첫 번째 정점의 법선 인덱스(faces[:, 0, 2])만 읽는다. obj.py:143은 그 하나를 np.stack((x,x,x))로 세 번 복사해 삼각형의 세 정점에 똑같이 붙인다. 결과적으로 파일이 아무리 부드러운 정점 법선을 담고 있어도 렌더링은 평면 셰이딩이 된다. 명세 B.ii(40점)가 "vertex positions, vertex normals, faces 정보만으로 메시를 표시하라"고 못박은 바로 그 항목이다.

실측: obj/sphere-tri.obj는 정점 482개에 법선 482개를 가진 매끄러운 구이고, 파일 안의 면 960개 전부가 서로 다른 vn 인덱스 3개를 지정한다(f 478//1 21//2 479//3). 그런데 로더가 만든 배열에서는 삼각형 960개 전부의 세 법선이 동일하다. 방출된 법선과 구의 참 법선(원점 기준 반지름 방향) 사이 각도는 평균 7.81°, 최대 15.86°였다.

더 나쁜 것은 git 이력이다. 5월 26일 버전은 면의 각 정점마다 자기 법선을 조회하고 있었다. 6월 11일 커밋 3af4365 "Update lib.obj to correct normal and arange"가 그 코드를 지우고 지금 형태로 바꿨다. 법선을 고쳤다는 커밋이 정확히 법선을 망가뜨린 커밋이다. 대회 마감 이틀 전 리팩터링을 육안 확인 없이 밀어 넣으면 이렇게 된다.

assignment3/lib/obj.py:138, :143
obj.buffer.normal   = npa(obj.values.normal.__getitem__, 0, obj.faces[:, 0, 2]-1) if obj.values.normal.size else None
...
ovn = npa(lambda x: np.stack((x,x,x)), 1, obj.buffer.normal).reshape(w, h)
git show 3af4365 — 사라진 정점별 조회2018-06-11
-  obj.buffer.normal[value] = obj.values.normal[index[2]-1] if obj.values.normal.size else 0
+  obj.buffer.normal   = npa(obj.values.normal.__getitem__, 0, obj.faces[:, 0, 2]-1)
검증 출력numpy 1.24.4, OpenGL 스텁
triangles whose 3 emitted normals are identical: 960 / 960
faces in FILE with 3 DIFFERENT vn indices:          960 / 960
angle(emitted normal, true radial normal): mean 7.81 deg, max 15.86 deg
치명적

오목 다각형 삼각화가 ValueError로 죽는다 — "Trianglize quad or polygons" 체크박스가 거짓이 되는 지점

obj.py:85은 리스트에서 point(튜플)를 지우려던 자리에 p(numpy 배열)를 넘긴다. 바로 윗줄의 조건은 point in vertex인데 삭제 인자만 다르다. list.remove가 ndarray와 튜플을 ==로 비교하면서 원소별 불린 배열이 나오고 파이썬이 그 진리값을 판단하지 못해 예외를 던진다.

이 분기는 볼록 다각형에서는 도달하지 않는다. 볼록 다각형은 모든 꼭짓점이 귀(ear)라서 "귀였던 점이 더 이상 귀가 아니게 되는" elif가 실행될 일이 없기 때문이다. 그래서 레포의 샘플 6개(사각형만 섞인 구·실린더·정육면체)는 전부 통과한다. 반대로 말하면 삼각화가 실제로 필요한 유일한 경우 — 오목 다각형 — 에서만 죽는다. XY 평면의 L자 육각형 하나로 재현했다.

assignment3/lib/obj.py:81–85
if _ear(p, q, r, polygon):
    if point not in vertex:
        vertex.append(point)
elif point in vertex:
    vertex.remove(p)        ← point 여야 한다. p는 ndarray.
재현: L자 육각형 (v 0 0 0 / 2 0 0 / 2 1 0 / 1 1 0 / 1 2 0 / 0 2 0)
File "lib/obj.py", line 132, in read_obj
    for tri in OBJ.trianglize(p)])
File "lib/obj.py", line 85, in trianglize
    vertex.remove(p)
ValueError: The truth value of an array with more than one element is ambiguous.
치명적

face 문법을 한 가지만 안다 — 이 레포 안의 labs/lab14/cube.obj조차 로드하지 못한다

obj.py:106f 줄의 각 토큰을 /로 쪼개 그대로 튜플로 만든다. _type(:92–95)이 빈 문자열을 0으로 바꿔 주므로 f v//vnf v/vt/vn은 길이 3의 튜플이 되어 잘 동작한다. 그러나 f v는 길이 1, f v/vt는 길이 2가 되고, :138faces[:, 0, 2]가 존재하지 않는 축을 인덱싱한다. OBJ 명세에서 법선이 선택 항목이라는 점을 감안하면 이것은 예외가 아니라 흔한 입력이다.

결정적으로, 이 레포 자신이 그런 파일을 갖고 있다. labs/lab14/cube.objf 1 2 3 4 형식의 평범한 큐브다. 그 파일을 자기 뷰어에 드래그앤드롭하면 로더가 예외를 던진다. 드롭 콜백(main.py:97–104)에 try/except가 없으므로 사용자에게는 아무 메시지도 없이 콜백이 터진다.

음수(상대) 인덱스도 마찬가지다. OBJ 명세가 허용하는 f -3//-1 -2//-1 -1//-1을 넣으면 :137의 무조건적인 -1 보정이 -3-4로 만들어 IndexError가 난다.

실측 — 레포 내 모든 obj 파일 + 합성 케이스numpy 1.24.4
OK   obj/cube-tri.obj                  faces=12    array=(72, 3)
OK   obj/cube-tri-quad.obj             faces=12    array=(72, 3)
OK   obj/cylinder-tri.obj              faces=124   array=(744, 3)
OK   obj/cylinder-tri-quad-n.obj       faces=124   array=(744, 3)
OK   obj/sphere-tri.obj                faces=960   array=(5760, 3)
OK   obj/sphere-tri-quad.obj           faces=960   array=(5760, 3)
FAIL labs/lab14/cube.obj  (f v, 사각형, 법선 없음)   AxisError: axis 1 is out of bounds for array of dimension 0
FAIL f 1/1 2/2 3/3       (f v/vt)                   AxisError: axis 1 is out of bounds for array of dimension 0
FAIL f -3//-1 -2//-1 -1//-1 (음수 상대 인덱스)        IndexError: index -4 is out of bounds for axis 0 with size 3
중대

다각형을 2D로 투영할 때 "값이 일정한 축"만 찾는다 — 축에 정렬되지 않은 평면에서 조용히 빈 결과를 낸다

ear clipping은 2D에서만 성립하므로 3D 다각형을 평면에 투영해야 한다. 올바른 방법은 면 법선의 절댓값이 가장 큰 성분에 해당하는 축을 버리는 것이다. 이 코드는 대신 "세 좌표 중 값이 모든 정점에서 동일한 축"을 찾고, 못 찾으면 or [0]무조건 X축을 버린다(:124–128). 즉 축에 정렬된 평면만 제대로 처리한다.

다행히 대부분의 기울어진 평면은 YZ 투영으로도 볼록성이 보존되어 살아남는다(기울어진 정오각형으로 확인: 삼각형 3개, 면적 합 2.3776 = 참값 2.3776). 그러나 평면이 X축을 포함하는 경우 — 예를 들어 법선이 (0, 1, 1)인 평면 — YZ 투영은 다각형을 직선으로 뭉갠다. 이때 trianglize는 예외 없이 빈 배열을 돌려주고, 그 면은 newface에 아무것도 남기지 않으며, 몇 줄 뒤 :137이 1차원이 된 배열을 3차원으로 인덱싱하다 죽는다. 침묵 실패가 두 단계 뒤 엉뚱한 줄의 IndexError로 나타나는 최악의 형태다.

또한 np.where(...)[0] or [0] 자체가 위태롭다. 일정한 축이 두 개 이상이면(퇴화한 선분 면) ndarray의 진리값 판정에서 ValueError가 난다. 마침 축 0이 유일한 일정 축일 때는 array([0])이 falsy라 or[0]을 돌려주고 결과적으로 같은 축을 버리게 되어 우연히 맞는다.

assignment3/lib/obj.py:124–128
# vectorize function to detect plane
f = np.vectorize(lambda x: not np.any(m[:,x]-m[:,x][0]))
...
p = np.delete(m, (np.where(f(np.arange(3)) == True)[0] or [0])[0], 1)
                                     ↑ 일정한 축이 없으면 무조건 X를 버림
재현: 법선 (0, 1, 1) 평면 위의 사각형
File "lib/obj.py", line 137, in read_obj
    obj.buffer.vertex = npa(..., obj.faces[:, :, 0].flatten()-1) ...
IndexError: too many indices for array: array is 1-dimensional, but 3 were indexed
중대

4개 광원 전부에 정반사 색을 설정하지만 정반사 하이라이트는 나올 수 없다

Light._render()는 광원마다 GL_AMBIENT·GL_DIFFUSE·GL_SPECULAR를 모두 설정한다(light.py:39–41). 그런데 재질 쪽은 light.py:56 한 줄이 전부이고, 거기서 설정하는 것은 GL_AMBIENT_AND_DIFFUSE뿐이다. 레포 전체에 GL_SHININESS나 재질 GL_SPECULAR를 설정하는 코드는 없다(grep으로 확인). 고정 파이프라인의 기본 재질 정반사색은 (0,0,0,1), 기본 shininess는 0이므로 정반사 항은 항상 0이다. 광원 쪽 specular 값 네 벌은 계산에 전혀 기여하지 못한다.

같은 맥락에서 Light.objectColor()(:43–47)는 ambient·specular 인자를 받아 저장하지만 그 둘은 어디서도 읽히지 않는다. 그리고 glMaterialfv(GL_FRONT, ...)만 호출하므로 후면은 GL 기본 재질로 그려진다 — 컬링을 켜지 않았고 다운로드한 메시가 워터타이트하다는 보장이 없으므로 실제로 보이는 면이다.

assignment3/lib/light.py:36–41, :56
def _render(self):
    glEnable(self.index)
    glLightfv(self.index, GL_POSITION, self.pos)
    glLightfv(self.index, GL_AMBIENT,  self.light.ambient)
    glLightfv(self.index, GL_DIFFUSE,  self.light.diffuse)
    glLightfv(self.index, GL_SPECULAR, self.light.specular)
...
    glMaterialfv(GL_FRONT, GL_AMBIENT_AND_DIFFUSE, Light.obj.diffuse)
    ← 재질 GL_SPECULAR / GL_SHININESS를 설정하는 코드가 레포 어디에도 없음
중대

줌이 카메라가 아니라 물체를 스케일한다 — 명세 위반이자 조명 버그

명세 A.ii는 "카메라를 원점 쪽으로 이동(zoom in) 또는 멀어지게 이동(zoom out)"이라고 적혀 있다. 구현은 gluLookAt의 eye 위치를 고정한 채 물체에 glScalef를 건다. 원근 투영에서 결과가 비슷해 보일 뿐 같은 연산이 아니다 — 클리핑 평면(1~10)과의 관계가 달라지고, 무엇보다 법선이 함께 스케일된다.

고정 파이프라인은 법선에 모델뷰 상단 3×3의 역전치를 곱해 주므로 방향 자체는 균등 스케일에서 보존된다. 그러나 길이는 1/s배가 되고, GL은 GL_NORMALIZEGL_RESCALE_NORMAL이 켜져 있을 때만 다시 정규화한다. 레포 전체 grep 결과 둘 다 0건이다. 따라서 A/S 키로 줌할 때마다 확산광 세기가 함께 변한다 — 줌아웃하면 모델이 밝아지고 줌인하면 어두워진다. 게다가 cam.zoom에는 하한이 없어(main.py:92–93) S를 열 번 누르면 0, 더 누르면 음수가 되어 모델이 뒤집힌다.

assignment3/main.py:65–72, :90–93
glPushMatrix()
# camera zoom
glScalef(self.cam.zoom, self.cam.zoom, self.cam.zoom)   ← 카메라가 아니라 물체
...
elif key == glfw.KEY_A:
    self.cam.zoom += .1
elif key == glfw.KEY_S:
    self.cam.zoom -= .1                                 ← 하한 없음: 0 → 음수
경미

유일한 테스트 파일이 존재한 적 없는 속성을 검사한다

tests/test_obj.pyobj._vertices, obj._normals, obj._texcoords, obj._faces를 단언한다. OBJ 인스턴스의 실제 속성은 filename, array, index, indexes, faces, values, buffer, face_info이고 밑줄 접두 이름은 하나도 없다. 이 테스트는 6월 2일 커밋(60bc26f "Update tests.obj for load object")에 들어온 뒤 lib/obj.py가 세 번 갈아엎히는 동안 단 한 번도 갱신되지 않았다. 즉 한 번도 실행되지 않았다. 그리고 이 테스트가 살아 있었다면 위의 법선 붕괴는 잡혔을 것이다.

assignment3/tests/test_obj.py:15–20
obj = OBJ.read_obj('./obj/cube-tri.obj')

assert all(isinstance(it, tuple) for it in obj._vertices)
assert all(isinstance(it, tuple) for it in obj._normals)
실행 결과: obj._vertices -> AttributeError (does not exist)
실제 속성: ['filename','array','index','indexes','faces','values','buffer','face_info']
경미

texcoord 버퍼가 정점 인덱스로 채워진다 (두 인덱스가 우연히 같아 드러나지 않음)

obj.py:139faces[:, 0, 0], 즉 정점 인덱스 자리를 읽어 텍스처 좌표 배열을 만든다. vt 인덱스는 슬롯 1에 있다. 레포의 Sylvanas.obj·Varianwrynn.obj는 면이 f 1/1/1 2/2/2 3/3/3 형태라 세 인덱스가 항상 같아 잘못된 값이 나오지 않고, render()가 texcoord를 쓰지도 않는다. 명세도 vt를 무시하라고 했으니 실질 피해는 없다. 다만 슬롯 0을 슬롯 1로 착각한 것은 사실이고, 세 줄 연속(:137–139)에서 [:, :, 0], [:, 0, 2], [:, 0, 0]이 나열되는 구조 자체가 어느 축이 무엇인지 헷갈리기 딱 좋게 생겼다.

경미

Mode.get(index=0)은 절대 0번을 돌려주지 않는다

mode.py:7index = index or self.index는 명시적으로 넘긴 0을 falsy로 보고 버린다. 실측: Mode(['A','B','C'], starts=2).get(index=0)'C'를 돌려준다. 현재 호출부 중 index=0을 넘기는 곳이 없어 잠복 버그지만, 이 클래스는 광원 슬롯(GL_LIGHT0~7)과 폴리곤 모드 양쪽에서 재사용되는 공용 유틸이다. 덧붙여 except:로 모든 예외를 삼키고 Exception('index out of range')로 바꿔 던지는 것(:10–11)과, finally에서 실패한 호출도 인덱스를 증가시키는 것도 같은 성질의 문제다.

assignment3/lib/mode.py:6–15
def get(self, index=None, next=False, block=False):
    index = index or self.index     ← index=0 이 self.index 로 대체됨
    try:
        return self.modes[index]
    except:
        raise Exception('index out of range')
    finally:
        self.index+=next
잘한 것

인터리브드 정점 배열 세팅(obj.py:40–43)이 정확하다. [법선3, 정점3] 레이아웃에 대해 stride를 6*itemsize, 법선을 오프셋 0, 정점 포인터를 array.ctypes.data + 3*itemsize, 그리기 개수를 array.size/6으로 잡았다 — 넷이 서로 정합적이고, 내가 실제 생성된 배열 shape과 대조해 확인했다. 명세 추가 점수 B(+10)를 위해 glDrawArrays를 쓰라는 요구를 정직하게 충족한다. 그리고 __str__(:28–34)이 출력하는 다섯 항목(파일명 / 전체 면 수 / 정점 3개 면 / 4개 면 / 5개 이상 면)은 명세 B.iv가 요구한 다섯 항목과 정확히 일치한다 — 명세를 읽고 항목 단위로 맞춘 흔적이다. 성능도 실측했는데, np.append를 줄마다 호출하는 O(n²) 파싱(:97–100)에도 불구하고 34,344면짜리 Mercy.obj가 1.13초, Tracer.obj가 0.37초에 로드됐다. 느리지만 사용 불가능하지는 않다.

labs/ — 과제가 아니라 수업 기록

19개 디렉터리에 주차별 실습 코드가 들어 있다. 대부분 강의 슬라이드의 예제를 따라 친 것이고(labs/lab20/20-code.py는 파일 이름부터 배포본이다), 일부는 저자가 확장했다(lab22 406줄, lab19 363줄). 이건 평가할 과제가 아니라 연습 기록이므로 등급을 매기지 않는다. 다만 두 가지는 짚어 둘 만하다. 첫째, labs/lab1assignment1과 바이트 단위로 같은 파일 11개를 담고 있다. 둘째, 앞서 지적한 glMatrixMode 분리를 저자가 lab17(5월 14일)에 처음 제대로 쓰기 시작했다는 사실은, 4월에 제출한 assignment2의 문제가 무지가 아니라 돌아가서 고치지 않은 것임을 보여준다. 레포 전체에서 과거 과제를 되돌아보며 고친 커밋은 하나도 없다.

반복되는 패턴

  1. 문서가 코드보다 한 칸 앞서간다. docs/report.md:64의 "Seidel's algorithm 기반"은 사실이 아니다 — 코드는 _ear/_contain/_inside로 구성된 ear clipping이고, Seidel의 사다리꼴 분할과 공통점이 없다. report.md:29의 "I made a great class for lighting"은 정반사가 물리적으로 나올 수 없는 클래스를 가리킨다. README.md:14의 "normals"는 면 법선이다. 세 주장 모두 코드가 뒷받침하지 않는다.
  2. 볼록한 입력만 있는 세계를 가정한다. ear clipping이 오목 다각형에서 죽고(obj.py:85), 2D 투영이 축 정렬 평면만 제대로 처리하고(:128), _inside가 면적을 부동소수점 ==로 비교한다(:58). 셋 다 "레포에 들어 있는 샘플 파일에서는 통과"라는 같은 기준으로 통과했다. 테스트 입력이 곧 명세가 된 사례다.
  3. 추상화를 세우고 그 안을 비워 둔다. assignment2의 항등 glPushMatrix/glPopMatrix 세 쌍, Light.objectColor()의 읽히지 않는 ambient·specular 인자, OBJ.__init__의 사용되지 않는 self.index·self.indexes, 그리고 존재한 적 없는 속성을 검사하는 tests/test_obj.py. 형태는 다 갖췄는데 안이 비어 있다.
  4. 마감 직전 리팩터링을 육안 확인 없이 밀어 넣는다. 6월 10–11일 이틀에 커밋 21개가 몰려 있고, 그 중 47781b6main.py를 514줄 바꾸며 326줄을 지웠고 3af4365obj.py의 삼각화·법선 경로를 통째로 교체했다. 정점 법선이 사라진 것이 바로 이 시점이다. 저자가 자기 뷰어로 구를 한 번만 다시 띄워 봤다면 각진 실루엣을 놓칠 수 없었다.
  5. 배운 것을 앞 과제에 역적용하지 않는다. glMatrixMode 분리(lab17 이후), glfw.swap_interval(1)(assignment3), 예외 메시지 출력 — 모두 나중 코드에만 있다. 커밋 이력에 이전 과제로 돌아간 흔적이 없다.

지금 손본다면

  1. obj.py:138을 면별이 아닌 정점별 법선으로 되돌린다. faces[:, 0, 2]faces[:, :, 2].flatten()으로 바꾸고 :143np.stack((x,x,x)) 복제를 없애면 된다. 두 줄이고, 명세 40점 항목의 핵심이 복구되며, 부드러운 메시가 부드럽게 보인다.
  2. obj.py:85vertex.remove(p)vertex.remove(point)로 고친다. 한 글자. "Trianglize quad or polygons"라는 체크박스를 참으로 만든다.
  3. face 토큰 파싱을 길이 1·2·3 모두 받도록 정규화한다. obj.py:106에서 쪼갠 리스트를 항상 길이 3으로 패딩하고, 인덱스 보정을 i-1 if i > 0 else len(buffer)+i로 바꾸면 f v, f v/vt, 음수 상대 인덱스가 한꺼번에 해결된다. 그리고 main.py:101OBJ.read_objtry/except로 감싸 파싱 실패가 뷰어를 죽이지 않게 한다.
  4. 2D 투영 축을 면 법선의 최대 성분으로 고른다. obj.py:124–128의 "값이 일정한 축 찾기"를 np.argmax(np.abs(normal))로 대체한다. 세 줄이 한 줄이 되고, 축에 정렬되지 않은 임의 평면에서도 성립하며, or [0]의 진리값 판정 지뢰도 함께 사라진다.
  5. 재질 정반사와 GL_NORMALIZE를 켠다. light.py:56 옆에 glMaterialfv(GL_FRONT_AND_BACK, GL_SPECULAR, ...)glMaterialf(..., GL_SHININESS, ...)를 추가하고, main.py의 초기화에 glEnable(GL_NORMALIZE)를 넣는다. 그러면 광원 네 개에 이미 설정해 둔 specular 값이 실제로 화면에 나타나고, 줌할 때 밝기가 변하는 문제도 사라진다. 줌 자체는 glScalef를 지우고 gluLookAt의 eye 거리를 5/zoom으로 바꾸면 명세 문구대로 카메라 이동이 된다.
  6. tests/test_obj.py를 실제 속성으로 다시 쓰거나 지운다. read_obj가 순수 함수라 OpenGL 없이도 돌아간다는 것은 내가 이미 확인했다 — 스텁 두 줄이면 CI에서 돌릴 수 있다. 위 결함 중 법선 붕괴, 오목 다각형 크래시, f v 미지원 셋은 전부 단위 테스트 세 개로 잡힌다.