3D 캔버스 렌더 최적화 8가지를 하나씩 켜서 잰 결과

SVG 로 그리는 구조 모델 3D 캔버스를 마우스로 돌리면 그림이 초당 7.8번만 바뀌었다. 최적화 후보 8개를 실행 시 스위치로 넣고 같은 빌드에서 18가지 조합을 3회씩 재, 3D 회전을 초당 47.5번까지 올렸다. 효과는 그리는 면 수를 줄인 것에서만 나왔고, 입력 합치기와 형상 캐시는 효과가 없었다.

Seobway · · 18분

1. 배경

1.1 문제 상황

구조 계산 앱의 노코드 모델러는 박스 구조물의 해석 모델(절점, 요소, 하중, 경계)과 실제 단면 형상을 한 캔버스에 그린다. 3D로 돌려 볼 수 있게 강관 벽체를 원형 관으로, 박스를 두께가 있는 판으로 그리는데, 빈 곳을 끌어 돌리면 모델이 손을 따라오지 못하고 끊겼다. 아래 영상의 왼쪽이 최적화 전, 오른쪽이 이 글에서 찾은 최적화를 모두 켠 것이다. 두 쪽은 같은 스크립트가 같은 경로로 마우스를 움직였다.

3D 회전(orbit), 왼쪽 최적화 전 · 오른쪽 최적화 8개 전부. 같은 시각인데 왼쪽 모델은 손(주황 점)의 위치보다 한참 늦은 자세에 머문다. 오른쪽 위 검은 상자는 측정 중 1초 창 수치. 최대화 창 1920×1032, 프로덕션 빌드.

3D 기본 자세에서 캔버스 SVG에는 요소가 5,237개 있었다.

SVG 요소 개수 주로 그리는 것
polygon 4,086 강관 둘레 면, 박스 면, 하중 화살표 머리
g 505 묶음
line 410 요소선, 하중 화살표
circle 116 절점
text 98 절점·요소 번호, 하중 수치

강관 요소 하나는 둘레를 24조각으로 나누고 바깥 면 24개, 안쪽 면 24개, 양 끝 마구리 48개로 96개의 다각형을 만든다. 강관 요소가 40개 남짓이라 다각형 대부분이 강관에서 나온다.

1.2 SVG 3D 캔버스가 한 프레임을 그리는 과정

캔버스는 React 상태로 카메라(yaw, pitch, 이동, 배율)를 들고 있다. 마우스가 움직이면 카메라 상태가 바뀌고, 컴포넌트가 다시 실행되면서 모든 면의 꼭짓점을 새 카메라로 투영하고 깊이 순으로 정렬해 SVG 요소로 돌려준다. React는 바뀐 좌표를 DOM 속성에 하나하나 다시 쓴다. SVG에는 깊이 버퍼가 없어서 먼 것부터 칠하는 방식으로 가림을 흉내 낸다.

%% desc: 마우스 이동 한 번이 화면에 반영되기까지. 카메라가 바뀌면 면 전체를 다시 투영하고 정렬해 DOM 속성을 다시 쓴다
flowchart LR
    A[pointermove] --> B[카메라 상태 변경]
    B --> C[면 전체 투영]
    C --> D[깊이 정렬]
    D --> E[React 커밋]
    E --> F[DOM 속성 수천 개 변경]
    F --> G[스타일 계산 · 레이아웃]
    G --> H[래스터 · 합성]

이 파이프라인은 메인스레드 하나에서 JavaScript 실행부터 스타일 계산, 레이아웃, 그리기 명령 생성까지 차례로 돈다[1]. 한 단계라도 16.7 ms를 넘기면 그 프레임에는 그림이 바뀌지 않는다.

1.3 목표와 제약


2. 방법

2.1 측정 장치

앱 코드를 고치지 않고 재려고 Electron 앱을 원격 디버깅 포트로 띄우고 CDP로 붙었다. Node 22에 내장된 WebSocket만 쓰고 추가 의존성은 없다. 기록하는 것은 React 커밋이 일어난 시각과 rAF 콜백이 불린 시각[2]을 중심으로 아래와 같다.

무엇을 어떻게
React 커밋 수와 시각 문서 생성 전에 __REACT_DEVTOOLS_GLOBAL_HOOK__를 심는다. React는 이 훅이 있으면 커밋마다 onCommitFiberRoot를 부른다
컴포넌트별 렌더 횟수 커밋마다 파이버 트리를 돌며 이번에 실행된 컴포넌트를 센다(건너뛴 하위 트리는 내려가지 않는다)
프레임 rAF 콜백이 불린 시각
DOM 변경량 MutationObserver가 받은 변경 기록 수
메인스레드 시간 Performance.getMetrics의 스크립트, 스타일, 레이아웃, 태스크 시간 차
마우스 입력 Input.dispatchMouseEvent로 125 Hz 주입, 벽시계 기준 8자 경로(한 바퀴 3초)[3]
영상 Page.startScreencast 프레임을 도착 간격 그대로 이어 붙인 mp4[4]
// probe.js (문서 생성 전에 주입)
window.__REACT_DEVTOOLS_GLOBAL_HOOK__ = {
  renderers, supportsFiber: true, isDisabled: false,
  inject(internals) { renderers.set(++rid, internals); return rid },
  onCommitFiberRoot(_id, root) {
    if (!P.on) return
    P.commits.push({ t: performance.now(), ms: root.current.actualDuration ?? null })
    walk(root.current.child)   // 이번 커밋에 실행된 컴포넌트를 센다
  },
  checkDCE() {}, onCommitFiberUnmount() {}, onPostCommitFiberRoot() {}
}

마우스 경로를 벽시계 기준으로 둔 이유는, 앱이 느려도 손은 같은 속도로 움직이기 때문이다. 앱이 이벤트를 처리하는 속도에 맞춰 입력을 늦추면 느린 버전이 덜 움직인 것으로 재진다.

시나리오는 네 개다. 핵심은 빈 곳을 끌어 모델을 돌리는 3D 회전과 화면을 옮기는 화면 이동이다. 시나리오마다 3D 뷰 버튼과 「화면 맞춤」을 눌러 카메라를 같은 자리로 되돌린 뒤 시작한다.

시나리오 조작 길이
idle 3D 뷰에서 가만히 (기준선, 창 가려짐 확인) 2초
orbit 빈 곳을 좌클릭으로 끌어 회전 6초
pan 가운데 버튼으로 끌어 이동 4초
viewTween 뷰 버튼 5번(측면, 평면, 3D, 정면, 3D). 각 전환은 0.42초 애니메이션 4초

측정 패스와 녹화 패스를 나눴다. 녹화는 프레임마다 JPEG 인코딩을 하고 화면 위에 수치 상자를 띄워 성능을 깎으므로, 본문의 수치는 전부 녹화 없는 측정 패스에서 나왔다.

2.2 지표: FPS가 아니라 화면 갱신/초

처음에는 rAF 횟수를 FPS로 보고 비교했다. rAF는 그림이 바뀌지 않아도 모니터 주기마다 불린다. 한 버전에서 FPS는 46.1인데 커밋은 초당 24번이었다. 프레임 절반은 같은 그림을 다시 보여 준 것이다. 그래서 주 지표를 그림이 실제로 바뀐 프레임 수로 바꿨다.

화면 갱신/초=∣{ i:∃ 커밋 시각 t∈(fi−1,fi] }∣측정 시간\text{화면 갱신/초} = \frac{\left|\{\, i : \exists\ \text{커밋 시각 } t \in (f_{i-1}, f_i] \,\}\right|}{\text{측정 시간}}

fif_i는 ii번째 rAF 시각이다. 직전 프레임과 이번 프레임 사이에 커밋이 하나라도 있으면 그 프레임에서 그림이 바뀐 것으로 센다. 60 Hz에서 최댓값은 60이다. 보조 지표로 갱신 간격 p95, 커밋당 DOM 변경 수, 메인스레드 사용 ms/초를 같이 적는다.

// modeler-orbit.mjs (summarize)
for (let i = 1, ci = 0; i < raw.frames.length; i++) {
  let hit = false
  while (ci < cts.length && cts[ci] <= raw.frames[i]) {
    if (cts[ci] > raw.frames[i - 1]) hit = true
    ci++
  }
  if (hit) upd.push(raw.frames[i])
}

2.3 최적화 후보 8개

8개 모두 perfFlags.ts의 스위치 뒤에 넣었다. 측정 장치가 문서 생성 전에 window.__PERF_OPTS__를 심고, 아무것도 심지 않으면 전부 꺼져 기존과 같은 그림과 동작이 된다. 스위치마다 커밋을 하나씩 만들어 코드 수정량을 git show --numstat으로 셌다. 후보는 세 부류다. 그리는 요소 수를 줄이는 것(뒷면 제거, 분할 축소, 마구리 제거, 움직이는 동안 상세도를 낮추는 LOD), 다시 계산하는 양을 줄이는 것(이동 transform, 형상 캐시), 다시 실행하는 컴포넌트와 횟수를 줄이는 것(카메라 격리, 입력 합치기)이다.

스위치 하는 일 겨누는 병목 코드(+/−)
cull 법선이 카메라를 등진 강관 면을 그리지 않는다 면 수 +36 / −5
pipeSeg 강관 둘레 분할 24 → 12 면 수 +5 / −2
capJoin 같은 단면 강관이 일직선으로 이어지는 절점의 마구리를 그리지 않는다 면 수 +30 / −1
lod 끄는 동안과 뷰 전환 중에 강관 안쪽 면, 마구리, 하중, 글자, 경계 육각형을 숨긴다 면 수 · 글자 +17 / −3
panXform 이동을 좌표에 굽지 않고 <g transform="translate"> 하나로. 면 JSX를 메모 재투영 +61 / −35
camLocal 끄는 동안 카메라를 부모 화면(모델러 전체)으로 올리지 않고 놓을 때 한 번 패널 재렌더 +5 / −1
rafMove 카메라 갱신을 프레임당 한 번으로 합친다 입력 폭주 +39 / −7
geomCache 카메라와 무관한 면 형상을 월드 좌표로 캐시하고 매 프레임 투영만 한다 투영 계산 +28 / −2

panXform의 줄 수에는 면 JSX 블록 28줄을 메모 안으로 옮긴 것이 들어 있다.

// Canvas.tsx — d 가 커지는 방향(화면 안쪽)이 V. 법선·V > 0 이면 등진 면
const V = { x: sy * cp, y: cy * cp, z: -sp }
all = all.filter((f) => !f.n || f.n.x * V.x + f.n.y * V.y + f.n.z * V.z < 0)

강관 바깥 면의 법선은 관 축 가운데에서 면 가운데로 향하는 벡터, 안쪽 면은 그 반대, 마구리는 축 방향이다. 뒷면 제거는 3D 그래픽스에서 오래 쓰인 기법이다[5]. 이 캔버스는 면을 반투명(불투명도 0.55~0.85)으로 칠해 지금은 뒷면이 비쳐 보이므로, 켜면 모양이 조금 달라진다.

2.4 실험 설계

스위치를 하나씩 켜고 조합해 각각의 영향을 따로 재는 절제 실험이다.


3. 결과

3.1 기준선: 무엇이 1초를 쓰는가

최적화 전 버전(base)에서 3D 회전 6초 동안의 값이다.

항목 orbit pan viewTween
화면 갱신/초 7.8 15.8 8.7
FPS (rAF) 20.7 17.1 20.4
입력 처리/초 (주입 125) 19.9 16.1 -
커밋당 DOM 변경 12,355 6,074 9,972
메인스레드 ms/초 996.6 997.5 852.7
스크립트 ms/초 439.0 557.6 391.2
스타일 계산 ms/초 296.5 57.5 248.0
레이아웃 ms/초 40.6 84.7 39.6

스타일 계산과 레이아웃만으로 orbit에서 337 ms/초다. JavaScript를 0으로 만들어도 DOM 변경량이 그대로면 이 비용은 남는다.

3.2 스위치 하나씩

스위치 하나만 켠 8개 버전의 화면 갱신/초다. 막대는 60 Hz 대비 비율이라 1.000이 초당 60번이다.

3D 회전기준선3D 회전 · 기준선: 0.1300.130lod3D 회전 · lod: 0.3850.385capJoin3D 회전 · capJoin: 0.2280.228pipeSeg3D 회전 · pipeSeg: 0.2200.220cull3D 회전 · cull: 0.2020.202panXform3D 회전 · panXform: 0.1350.135camLocal3D 회전 · camLocal: 0.1370.137geomCache3D 회전 · geomCache: 0.1230.123rafMove3D 회전 · rafMove: 0.1130.113화면 이동기준선화면 이동 · 기준선: 0.2630.263lod화면 이동 · lod: 0.5320.532capJoin화면 이동 · capJoin: 0.3170.317pipeSeg화면 이동 · pipeSeg: 0.3430.343cull화면 이동 · cull: 0.3470.347panXform화면 이동 · panXform: 0.3280.328camLocal화면 이동 · camLocal: 0.2520.252geomCache화면 이동 · geomCache: 0.2450.245rafMove화면 이동 · rafMove: 0.2380.238뷰 전환기준선뷰 전환 · 기준선: 0.1450.145lod뷰 전환 · lod: 0.2950.295capJoin뷰 전환 · capJoin: 0.2180.218pipeSeg뷰 전환 · pipeSeg: 0.2300.230cull뷰 전환 · cull: 0.2020.202panXform뷰 전환 · panXform: 0.1500.150camLocal뷰 전환 · camLocal: 0.1450.145geomCache뷰 전환 · geomCache: 0.1370.137rafMove뷰 전환 · rafMove: 0.1300.130
그림 1. 스위치 하나만 켰을 때 화면 갱신 비율(화면 갱신/초 ÷ 60). 주황이 기준선. 3회 중앙값, 최대화 창 1920×1032, 프로덕션 빌드. 데이터: ablation_results.json
화면 갱신/초 orbit pan viewTween 3D SVG 요소 커밋당 DOM 변경(orbit)
기준선 7.8 15.8 8.7 5,237 12,355
lod 23.1 31.9 17.7 5,237 (끄는 동안 1,424) 3,458
capJoin 13.7 19.0 13.1 3,509 7,628
pipeSeg 13.2 20.6 13.8 3,413 7,443
cull 12.1 20.8 12.1 3,413 7,376
panXform 8.1 19.7 9.0 5,237 12,276
camLocal 8.2 15.1 8.7 5,237 12,217
geomCache 7.4 14.7 8.2 5,237 12,326
rafMove 6.8 14.3 7.8 5,237 12,321
기준선 (끝에 다시) 6.9 14.4 8.1 5,237 12,294

3.3 조합

면 수를 줄이는 세 개(cull, pipeSeg, capJoin)를 묶어 faces라 부르고, 그 위에 나머지를 하나씩 얹었다.

3D 회전기준선3D 회전 · 기준선: 0.1300.130faces3D 회전 · faces: 0.3470.347+camLocal3D 회전 · +camLocal: 0.4580.458+panXform3D 회전 · +panXform: 0.3870.387+rafMove3D 회전 · +rafMove: 0.3730.373+lod3D 회전 · +lod: 0.6970.697+lod+pan3D 회전 · +lod+pan: 0.6730.673stage13D 회전 · stage1: 0.4250.425all3D 회전 · all: 0.7920.792화면 이동기준선화면 이동 · 기준선: 0.2630.263faces화면 이동 · faces: 0.4480.448+camLocal화면 이동 · +camLocal: 0.4630.463+panXform화면 이동 · +panXform: 0.6370.637+rafMove화면 이동 · +rafMove: 0.4530.453+lod화면 이동 · +lod: 0.9200.920+lod+pan화면 이동 · +lod+pan: 0.9500.950stage1화면 이동 · stage1: 0.5800.580all화면 이동 · all: 0.9400.940뷰 전환기준선뷰 전환 · 기준선: 0.1450.145faces뷰 전환 · faces: 0.2970.297+camLocal뷰 전환 · +camLocal: 0.2950.295+panXform뷰 전환 · +panXform: 0.2930.293+rafMove뷰 전환 · +rafMove: 0.2980.298+lod뷰 전환 · +lod: 0.4770.477+lod+pan뷰 전환 · +lod+pan: 0.4720.472stage1뷰 전환 · stage1: 0.3020.302all뷰 전환 · all: 0.4620.462
그림 2. 조합별 화면 갱신 비율. faces = cull + pipeSeg + capJoin, stage1 = faces + panXform + camLocal + rafMove, all = 8개 전부. 3회 중앙값. 데이터: ablation_results.json
화면 갱신/초 orbit pan viewTween 갱신 간격 p95 (orbit / pan) 코드(+/−)
faces 20.8 26.9 17.8 67 / 50 ms +71 / −8
faces + camLocal 27.5 27.8 17.7 50 / 50 ms +76 / −9
faces + panXform 23.2 38.2 17.6 67 / 50 ms +132 / −43
faces + rafMove 22.4 27.2 17.9 67 / 50 ms +110 / −15
faces + lod 41.8 55.2 28.6 33 / 22 ms +88 / −11
faces + lod + panXform 40.4 57.0 28.3 33 / 17 ms +149 / −46
stage1 25.5 34.8 18.1 67 / 50 ms +176 / −51
all 47.5 56.4 27.7 33 / 17 ms +221 / −56
최적화 전8개 전부0.000.250.500.751.003D 회전3D 회전 최적화 전: 0.1303D 회전 8개 전부: 0.7920.130 → 0.792화면 이동화면 이동 최적화 전: 0.263화면 이동 8개 전부: 0.9400.263 → 0.940뷰 전환뷰 전환 최적화 전: 0.145뷰 전환 8개 전부: 0.4620.145 → 0.462
그림 3. 화면 갱신 비율, 최적화 전(base)에서 8개 전부(all)까지. 1.000 = 초당 60번.

3.4 영상으로 본 차이

영상은 녹화 패스에서 스크린캐스트 프레임이 도착한 간격 그대로 이어 붙였다. 새 프레임이 안 만들어진 동안은 화면이 멈춘 채로 보인다. 좌우는 같은 스크립트가 같은 시각에 같은 경로로 조작했다. 주황 점이 마우스 위치, 빨간 점은 누르고 있는 상태다.

화면 이동(pan), 왼쪽 최적화 전(초당 15.8번) · 오른쪽 faces + lod + panXform(57.0번). 왼쪽은 모델이 한 박자씩 늦게 순간 이동한다.

3.5 FPS로 판정하면 camLocal이 해롭게 보인다

faces와 faces + camLocal을 두 지표로 나란히 놓으면 방향이 반대다.

orbit faces faces + camLocal 변화
FPS (rAF) 46.1 32.9 −29%
화면 갱신/초 20.8 27.5 +32%
pan 화면 갱신/초 26.9 27.8 변화 없음
viewTween 화면 갱신/초 17.8 17.7 변화 없음

camLocal은 끄는 동안 부모 화면을 다시 그리지 않게 한다. 기준선 위에서 camLocal 하나만 켰을 때 orbit 중 컴포넌트 실행 수(상위 15개 합)가 18,283회에서 4,408회로 줄었다. 커밋 하나가 가벼워져 커밋이 더 자주 일어나고, 커밋이 든 프레임마다 스타일 계산과 그리기가 붙어 빈 프레임은 줄어든다. 그래서 rAF 횟수는 떨어지는데 그림이 바뀐 횟수는 늘었다. camLocal은 회전에만 관여하는 스위치라 pan과 viewTween이 그대로인 것이 대조군 역할을 한다.

3.6 panXform 단독으로는 왜 안 올랐나

panXform은 이동 중 좌표 재계산을 없애 커밋당 DOM 변경을 6,074건에서 1건으로, 메인스레드를 997.5 ms/초에서 248.1 ms/초로 줄였다. 그런데 화면 갱신은 15.8에서 19.7로만 올랐고 입력 처리도 초당 20번에 머물렀다. 메인스레드가 75% 쉬는데도 느린 것은 병목이 JavaScript가 아니라 반투명 다각형 5천 개를 다시 칠하는 래스터 단계에 있기 때문이라고 본다. Chromium은 입력 이벤트를 프레임에 맞춰 배달하므로[7], 프레임이 안 나오면 입력 처리도 같이 묶인다.

pan 화면 갱신/초 메인스레드 ms/초 커밋당 DOM 변경
기준선 15.8 997.5 6,074
panXform 19.7 248.1 1
faces 26.9 979.2 2,976
faces + panXform 38.2 393.4 1
faces + lod 55.2 814.5 1,194
faces + lod + panXform 57.0 351.6 4.5

면을 줄인 뒤에야 panXform이 +42%를 냈다. faces + lod 위에서는 화면 갱신이 이미 55.2라 더 오를 여지가 적지만, 메인스레드를 814.5에서 351.6 ms/초로 줄여 갱신 간격 p95를 22 ms에서 17 ms(한 프레임)로 맞췄다.


4. 논의

4.1 판정 요약

스위치 판정 근거
faces (cull + pipeSeg + capJoin) 채택 orbit 7.8 → 20.8, SSIM 0.9991, +71줄
lod 채택 (UX 확인 필요) faces 위 orbit 20.8 → 41.8. 끄는 동안 하중·글자가 사라진다
camLocal 채택 faces 위 orbit +32%, +5줄. 뷰 버튼의 「자유」 표시가 놓을 때 바뀐다
panXform 채택 faces 위 pan +42%, faces + lod 위 pan 갱신 간격 p95 17 ms
rafMove 탈락 단독, faces 위 모두 흔들림 범위 안
geomCache 탈락 단독 흔들림 범위 안. 스크립트 시간 439 → 428 ms/초

rafMove가 효과 없는 것은 Chromium이 이미 이동 입력을 프레임에 맞춰 합쳐 배달하기 때문으로 보인다[7]. geomCache는 프로덕션 빌드에서 면 좌표 계산이 원래 싼 부분이었다. 비용은 계산이 아니라 DOM 반영과 그리기에 있었다.

권장 조합은 faces + lod + camLocal + panXform이다. 이 조합 자체는 아직 재지 않았다. 가장 가까운 all(여기에 rafMove, geomCache 추가)이 orbit 47.5, pan 56.4, viewTween 27.7이다.

4.2 실험 과정의 오류

4.3 한계


5. 결론


부록 A. 원시 데이터

본문의 모든 수치는 아래 파일에서 나왔다. 버전마다 원래 영상 4개가 있지만(156개, 53 MB) 용량 때문에 본문의 비교 영상 4개만 올렸다.

raw/*.json의 구조는 다음과 같다.

runs[시나리오] = [회차마다 {
  rep, secs,                      측정 창 길이(초, ms 정밀도)
  frames: [rAF 시각],             ms, performance.now
  commits: [[시각, 렌더 ms]],     프로덕션 빌드는 렌더 ms 가 null
  moves: [pointermove 시각],
  inputToFrame: [입력 → 다음 rAF 지연 ms],
  longtasks: [[시작, 길이 ms]],
  domMutations, renders{컴포넌트: {n, ms}}, perf{script, layout, style, task}
}]

측정 조건은 Electron 33, React 18, 프로덕션 빌드, 창 1920×1032(최대화), 60 Hz, 125 Hz 합성 입력, 버전마다 3회다.

참고

  1. [1] Rendering performance — web.dev
  2. [2] Window: requestAnimationFrame() method — MDN Web Docs
  3. [3] Input.dispatchMouseEvent — Chrome DevTools Protocol
  4. [4] Page.startScreencast — Chrome DevTools Protocol
  5. [5] glCullFace — OpenGL 4 Reference Pages, Khronos
  6. [6] webContents.setBackgroundThrottling — Electron Documentation
  7. [7] Aligning input events — Chrome for Developers

관련 글