Navigation

Reading ECharts-GL Bar3D

2025년 4월 3D 웨이퍼맵 PoC를 앞두고 ECharts-GL로 요구사항을 감당할 수 있는지 판단해야 했습니다. 주어진 시간은 열흘 남짓이었고 우선순위는 렌더링 성능과 이벤트 처리였습니다. 당시 메모를 echarts-gl 2.0.9와 claygl 1.3.0 배포 소스에 다시 대조해 계층 구조, 데이터 경로, 좌표계와 이벤트를 정리했습니다.

2026년 5월에 나온 2.1.0에서는 이 글이 다루는 파일 중 진입 파일 echarts-gl.js만 바뀌었습니다. zrender 6에 맞춰 이미지 저장용 덮어쓰기를 걷어 내고 graphicGL을 echarts 네임스페이스에 붙인 변경입니다. 이 글은 라이브러리 소스를 읽은 기록이며 아래 엔진을 제가 만들었다는 뜻은 아닙니다. 이후 Three.js로 옮긴 PoC에서 제가 맡은 범위는 Wafermap 3D에 따로 정리했습니다.

ECharts의 갱신 끝에 WebGL 레이어를 끼우는 구조

ECharts-GL은 별도 차트 엔진이 아닙니다. ECharts의 갱신 주기에 훅을 걸어 WebGL 레이어를 끼워 넣는 확장입니다. 진입 파일은 registerPostUpdate로 갱신이 끝날 때마다(afterupdate) EChartsGL.update를 부릅니다. 이 함수는 시리즈와 컴포넌트의 zlevel마다 LayerGL을 하나씩 만들어 zrender painter에 사용자 레이어로 넣고 좌표계가 가진 ViewGL을 그 레이어에 붙입니다.

계층가진 것2.0.9에서 확인한 역할
EChartsGLzlevel별 LayerGL 목록갱신이 끝날 때 레이어를 만들고 view를 다시 연결
LayerGLclaygl Renderer, 자체 canvas, RayPickingzrender 사용자 레이어. 그리기, 피킹, 쓰지 않는 GPU 자원 정리
ViewGLclaygl Scene, 카메라, 뷰포트좌표계 하나의 장면. grid3D마다 하나
graphicGLclaygl 클래스와 도우미 함수Mesh, Material, Shader를 감싼 파사드와 재질 생성
clayglWebGL1 컨텍스트버퍼 업로드, 셰이더, 렌더 패스
flowchart TD
    SetOption["setOption"] --> Update["ECharts update: 데이터 처리, 좌표계 갱신, layout과 visual"]
    Update --> Render["ChartView.render: geometry를 채우고 groupGL을 ViewGL에 추가"]
    Render --> After["afterupdate: EChartsGL.update"]
    After --> Layer["zlevel별 LayerGL에 ViewGL 연결"]
    Layer --> Refresh["zrender refresh가 LayerGL.refresh 호출"]
    Refresh --> Draw["ViewGL 렌더와 쓰지 않는 GPU 자원 정리"]

당시 메모에는 registerPostInit이 레이어와 텍스처 아틀라스를 만든다고 적혀 있었습니다. 소스에서 postInit이 하는 일은 두 가지뿐이었습니다. painter의 dispose가 GL 레이어도 정리하도록 감싸고 getRenderedCanvas를 바꿔 이미지를 만들 때 GL canvas를 함께 그립니다. 렌더러는 preserveDrawingBuffer: true로 만들어집니다. 그래서 toolbox의 이미지 저장에는 3D 장면도 담겼고 요구사항 가운데 라이브러리만으로 해결되는 드문 항목이었습니다.

LayerGL.refresh에는 부분 갱신이 없습니다. zrender가 다시 그릴 때마다 레이어의 모든 view에서 장면 갱신, 렌더 목록 작성, 렌더, 쓰지 않는 텍스처와 geometry 정리를 반복합니다. hover 하이라이트처럼 작은 변화도 zr.refresh()를 부르는 순간 같은 경로를 탑니다.

Bar3D: 데이터 한 행이 정점이 되기까지

Bar3D의 경로를 ECharts 갱신 단계에 맞춰 보면 다섯 단계입니다.

  1. Bar3DSeries.getInitialData가 좌표계 차원에 맞춰 데이터 목록을 만듭니다.
  2. install이 값 없는 항목을 filterSelf로 지우는 processor를 등록합니다. 다만 대상 타입을 'bar3d'로 적어 'bar3D' 시리즈와 일치하지 않습니다.
  3. 좌표계 갱신에서 축 범위를 시리즈 데이터 범위로 정합니다.
  4. layout이 항목마다 [start, dir, size]를 저장합니다.
  5. Bar3DView.render가 색을 읽고 Bars3DGeometry.addBar로 막대를 geometry 하나에 쌓습니다.

layout의 핵심은 값을 공간의 길이로 바꾸는 부분입니다. 아래는 2.0.9 cartesian3DLayout의 계산을 줄여 새로 쓴 예입니다.

cartesian3D 막대 layout 요약ts
type Vec3 = [number, number, number]

function layoutBar(
  x: number, y: number, value: number,
  base: number, // stacked: stackedValue - value, otherwise 0 or the z-axis min
  dataToPoint: (d: Vec3) => Vec3,
  barSize: [number, number], // one size for the whole series
) {
  const start = dataToPoint([x, y, base])
  const end = dataToPoint([x, y, value])
  const height = Math.hypot(end[0] - start[0], end[1] - start[1], end[2] - start[2]) || 0.1
  const dir: Vec3 = [0, end[1] < start[1] ? -1 : 1, 0] // always vertical
  return { start, dir, size: [barSize[0], height, barSize[1]] as Vec3 }
}

여기서 세 가지 제약이 나왔습니다. dir은 늘 수직이고 막대의 옆 방향인 orient는 시리즈 전체에 [1, 0, 0] 하나입니다. barSize도 시리즈 단위입니다. 항목마다 회전이나 크기가 다른 사각형을 Bar3D 옵션만으로는 그릴 수 없었습니다. barSize를 비우면 값 축에서 너비를 Math.round(boxWidth / Math.sqrt(n)) * 0.6으로 정합니다. 기본 박스 100에서는 항목이 4만 개를 넘는 순간 너비가 0이 됩니다. 깊이 계산에는 boxDepth가 아니라 size[1], 즉 boxHeight를 씁니다.

Bars3DGeometry는 시리즈 전체를 정점 버퍼 하나에 담습니다. 막대 하나의 정점 수는 조건에 따라 다릅니다. 기본값 shading: 'color'에서는 normal이 없어 8개이고 조명 셰이딩(lambert, realistic)에서는 면마다 normal을 따로 두느라 24개입니다. bevel을 켜면 (s + 1) * 4 * (s + 1) * 2개로 늘어납니다(s는 bevelSmoothness). 정점이 65,535개를 넘으면 인덱스 배열이 Uint16Array에서 Uint32Array로 바뀌므로 색 셰이딩은 8,192개, 조명 셰이딩은 2,731개 막대부터 32비트 인덱스를 씁니다. 정점마다 데이터 인덱스를 적은 _dataIndices도 함께 만들어집니다.

막대 10만 개를 소스의 식대로 계산하면 아래와 같습니다. 실행해서 잰 값이 아니라 버퍼 크기를 가늠하려고 계산한 값입니다(1MB는 10^6바이트).

항목shading: 'color'조명 셰이딩
정점800,0002,400,000
삼각형1,200,0001,200,000
position, normal, color 속성22.4MB96MB
인덱스14.4MB14.4MB
색 버퍼12.8MB38.4MB

View는 데이터를 두 번 순회합니다. 첫 번째 순회에서 색과 투명도를 모아 막대 수를 세고 setBarCount로 버퍼를 잡습니다. 두 번째 순회에서 layout과 색으로 addBar를 부르고 데이터 인덱스와 막대 인덱스의 대응표를 만듭니다. 그런데 첫 순회는 값 있는 항목의 색만 배열 앞에서부터 채우고 두 번째 순회는 데이터 인덱스 위치에서 색을 읽습니다. 값 없는 항목이 미리 걸러져야 맞는 구조인데 앞에서 본 processor가 동작하지 않으니 중간에 빈 값이 있으면 그 뒤 막대가 이웃 항목의 색을 받게 됩니다. 소스를 읽어 추론한 동작이고 실행해 재현하지는 않았습니다.

투명도가 0.99보다 낮은 항목이 하나라도 있으면 재질의 깊이 쓰기를 끄고 삼각형을 정렬합니다. 정렬은 여러 프레임에 나눠 진행됩니다.

좌표계: 데이터의 z가 화면의 위쪽이 됩니다

claygl은 Y축이 위를 향합니다. Cartesian3D.dataToPoint는 데이터 x를 GL x에, 데이터 y를 GL z에, 데이터 z를 GL y에 둡니다. 축 범위는 grid3DCreator가 정합니다. x는 [-boxWidth/2, boxWidth/2], 데이터 y는 GL z의 [boxDepth/2, -boxDepth/2]로 가까운 쪽에서 먼 쪽을 향하고 데이터 z는 GL y의 [-boxHeight/2, boxHeight/2]입니다. 세 박스 크기의 기본값은 모두 100입니다.

flowchart LR
    Data["데이터 x, y, z"] --> Scale["축 scale: 데이터 범위 또는 min, max"]
    Scale --> Box["박스 좌표: 기본 100"]
    Box --> World["GL 월드: 데이터 y는 GL z, 데이터 z는 GL y"]
    World --> Camera["OrbitControl: alpha, beta, distance"]
    Camera --> NDC["투영과 NDC"]
    NDC --> Viewport["grid3D 뷰포트: 아래쪽 원점과 dpr"]

웨이퍼처럼 원형이 중요한 데이터는 이 경로에서 모양이 바뀔 수 있었습니다. 값 축은 min, max를 비우면 각 축의 데이터 범위로 따로 정해지고 박스 크기는 옵션으로 따로 정해집니다. 원을 원으로 유지하려면 x축과 y축의 min, max를 같게 고정하고 boxWidth와 boxDepth도 같게 맞춰야 했습니다. 뷰포트는 grid3D의 left, top, width, height로 정하되 GL 기준인 아래쪽 원점으로 뒤집습니다. 포인터가 view 안에 있는지 검사하는 containPoint도 y를 뒤집어 비교합니다.

반대 방향, 즉 포인터에서 데이터로 가는 길은 grid3D 축 포인터 코드에 있었습니다. viewGL.castRay로 카메라에서 ray를 만들고 ray.intersectPlane으로 박스 면과의 교차점을 구한 뒤 cartesian.pointToData로 데이터 좌표를 얻습니다. 영역 선택에 필요한 좌표 변환이 바로 이 순서였습니다. 엔진과 무관하게 쓰면 다음과 같습니다.

포인터를 바닥 평면의 데이터 좌표로ts
interface Ray { origin: Vec3; dir: Vec3 }

function toNdc(px: number, py: number, rect: DOMRect): [number, number] {
  return [((px - rect.left) / rect.width) * 2 - 1, -((py - rect.top) / rect.height) * 2 + 1]
}

function intersectFloor(ray: Ray, floorY: number): Vec3 | null {
  if (Math.abs(ray.dir[1]) < 1e-6) return null // parallel to the floor
  const t = (floorY - ray.origin[1]) / ray.dir[1]
  if (t < 0) return null // behind the camera
  return [ray.origin[0] + ray.dir[0] * t, floorY, ray.origin[2] + ray.dir[2] * t]
}

function pickFloor(
  e: PointerEvent, rect: DOMRect, floorY: number,
  castRay: (ndc: [number, number]) => Ray, // from the current camera
  pointToData: (p: Vec3) => Vec3, // inverse of the axis mapping
) {
  const hit = intersectFloor(castRay(toNdc(e.clientX, e.clientY, rect)), floorY)
  return hit ? pointToData(hit) : null
}

화면 비율을 축 범위에 곧바로 곱하는 방식은 카메라가 정확히 위에서 직교 투영으로 내려다보고 grid가 canvas를 꽉 채울 때만 맞습니다. 원근 투영이나 회전이 들어가면 ray와 평면의 교차점을 구해야 합니다.

Scatter3D는 다른 경로로 그립니다

같은 grid3D 위에 있어도 Scatter3D는 Bar3D와 다른 경로로 그립니다. layout 단계는 데이터 구간마다 Float32Array 하나에 GL 좌표를 채웁니다. 시리즈 기본값이 progressive: 1e5, progressiveThreshold: 1e5라 10만 개를 넘으면 구간마다 PointsBuilder를 따로 만들어 나눠 그립니다. 점은 SDF 텍스처를 입힌 gl.POINTS sprite이고 크기는 화면 픽셀 단위입니다. PointsBuilder는 이 mesh의 frustumCulling을 끕니다.

피킹도 다릅니다. 삼각형이 없으므로 geometry에 직접 pick을 달았습니다. 카메라가 바뀔 때마다 모든 점의 NDC 좌표를 CPU에서 다시 계산해 두고 포인터가 점마다 그 크기만큼의 사각형 안에 있는지 비교합니다. 카메라를 돌리면 OrbitControl의 update가 grid3DChangeCamera 액션을 보내고 이 액션이 시리즈의 updateCamera를 불러 재계산을 일으킵니다.

항목Bar3DScatter3D
도형삼각형 상자를 mesh 하나에 병합화면을 향한 점 sprite
항목 크기시리즈 단위 너비와 깊이, 높이는 값화면 픽셀 단위 symbolSize
피킹ray와 삼각형 교차 검사미리 계산한 NDC 사각형 비교
카메라 회전 비용다시 그리기모든 점의 NDC 재계산과 다시 그리기
대량 데이터버퍼 하나에 전부10만 개 단위로 나눠 그리기

밀도를 보여 주기에는 Scatter3D가 가벼웠습니다. 하지만 점에는 높이가 없어서 값을 기둥 높이로 보여 줘야 하는 요구에는 맞지 않았습니다.

이벤트: 클릭은 되는데 우클릭은 왜 안 되는가

zrender는 사용자 레이어에 이벤트를 넘길 때 layer['on' + eventName]을 부르고 layer.trigger도 호출합니다. LayerGL은 이 이름으로 onmousedown, onmousemove, onmouseup, onclick을 구현합니다. 각 핸들러는 pickObject로 포인터 아래 물체를 찾습니다. 포인터를 포함하는 view마다 claygl RayPicking.pickAll을 돌려 가장 가까운 교차점을 고르는 방식입니다.

sequenceDiagram
    participant ZR as zrender Handler
    participant Layer as LayerGL
    participant Pick as RayPicking
    participant Mesh as Bar3D mesh
    participant Chart as chart.on
    ZR->>Layer: onmousemove(e)
    Layer->>Pick: view마다 pickAll(x, y)
    Pick-->>Layer: 거리순 교차점과 삼각형
    Layer->>Mesh: trigger('mousemove')
    Mesh->>Mesh: 삼각형 정점에서 dataIndex 조회, 하이라이트
    Layer->>Chart: 프록시 요소에 dataIndex를 실어 이벤트 전달

Bar3D의 dataIndex는 mesh의 mousemove 핸들러가 정합니다. 교차한 삼각형의 첫 정점으로 getDataIndexOfVertex를 조회해 barMesh.dataIndex에 넣습니다. LayerGL은 이 값을 프록시 사각형의 ECData에 실어 zrender로 다시 보내고 그 덕에 chart.on('click')이 params.dataIndex를 받습니다. 클릭의 데이터는 직전 mousemove가 남긴 값인 셈입니다.

이 구조에서 빈 곳이 보였습니다. LayerGL에는 oncontextmenu가 없습니다. 막대를 우클릭해도 시리즈 정보가 담긴 차트 이벤트가 나오지 않습니다. 클릭 처리 함수는 onclick과 함께 dblclick이라는 이름에도 할당돼 있는데 zrender가 찾는 이름은 ondblclick이라 더블클릭은 피킹을 거치지 않습니다. 버튼을 누른 위치와 뗀 위치가 20px 넘게 떨어지면 클릭으로 치지 않습니다. 메모에 적힌 chart.on('contextmenu') 예시는 2.0.9에서 시리즈 데이터를 받지 못합니다.

당시 회의에서 나온 질문 하나도 소스로 일부 설명할 수 있었습니다. 렌더링이 끝나 GPU만 쓰는 시점에 왜 성능 문제가 생기는가 하는 질문이었고 소스에서 찾은 답 가운데 하나는 상호작용 경로였습니다. 병합된 mesh는 bounding box가 장면 대부분을 덮어 거의 모든 포인터가 상자 검사를 통과합니다. 그 뒤 claygl은 모든 삼각형을 CPU에서 하나씩 검사합니다. 막대 10만 개면 mousemove 한 번에 삼각형 120만 개입니다. 하이라이트는 setColor로 한 막대의 정점 색만 고치지만 dirtyAttribute('color')가 버퍼 단위로 표시되어 claygl 1.3.0은 색 버퍼 전체를 bufferData로 다시 올립니다. 해당 코드에는 bufferSubData를 쓰자는 TODO가 남아 있었습니다. 마지막으로 zr.refresh()가 레이어 전체를 다시 그립니다.

영역 선택은 라이브러리에 없었습니다. 선택하는 동안 카메라 회전은 공개 옵션 viewControl.rotateSensitivity를 0으로 바꿔 멈추고 시점은 alpha, beta로 고정할 수 있었습니다. 남은 일은 드래그 좌표를 위의 ray와 평면 교차로 데이터 좌표로 바꾸고 데이터 범위에서 항목을 거르는 것이었습니다.

커스텀 시리즈로 넓혀 볼 때의 확장 경계

새 도형이 필요하면 시리즈를 직접 등록해야 합니다. 확장 지점은 Bar3D의 install이 그대로 보여 줍니다.

커스텀 3D 시리즈를 등록하는 자리ts
import * as echarts from 'echarts/core'
import 'echarts-gl' // side effect: registers the post-update hook for GL layers

echarts.use({
  install(registers) {
    registers.registerSeriesModel(ConeSeries) // type 'series.cone3D', defaultOption, getInitialData
    registers.registerChartView(ConeView) // __ecgl__: true, adds groupGL to coordSys.viewGL
    registers.registerLayout((ecModel) => {
      ecModel.eachSeriesByType('cone3D', (series) => {
        // write [start, dir, size] with the same shape for every coordinate system
      })
    })
  },
})

View에는 __ecgl__: true 표시가 있어야 EChartsGL이 레이어에 연결합니다. 도형은 claygl Geometry를 확장한 클래스에 addBar처럼 항목을 쌓는 메서드와 정점에서 데이터 인덱스를 찾는 대응표를 두는 방식이 Bar3D의 패턴이었습니다.

검토 자료 중에는 Bar3D를 본떠 원뿔을 그리는 시리즈 초안이 있었습니다. 시리즈 모델은 Bar3D 파일을 옮긴 것이라 원본의 visualStyleAccessPathvisu 오타까지 그대로였습니다. 소스와 대조해 보니 확장 경계를 벗어난 지점이 여럿이었습니다.

커스텀 시리즈는 layout 출력 형식, geometry의 정점과 데이터 대응, 카메라 조작을 공개 옵션과 액션의 범위 안에 둬야 했습니다. 내부 필드를 직접 잡는 순간 같은 2.0.9 안에서도 동작을 장담하기 어려웠습니다.

메모와 소스가 달랐던 지점, Three.js로 옮긴 뒤 달라진 것

짧은 일정 때문에 당시 분석 문서 일부는 생성형 AI 답변을 옮겨 적은 초안이었고 검증은 뒤로 미뤄 둔 상태였습니다. 이번에 버전을 고정한 소스와 대조하니 아래 항목이 달랐습니다.

메모의 서술2.0.9 소스
postInit에서 LayerGL과 ViewGL을 만든다LayerGL은 postUpdate에서 zlevel별로 만들고 ViewGL은 좌표계가 만든다
ViewGL.pickObject로 피킹한다LayerGL.pickObject가 view마다 RayPicking을 돌린다
chart.on('contextmenu')로 시리즈 데이터를 받는다LayerGL에 우클릭 피킹이 없다
막대는 정점 8개와 UV 좌표로 만든다정점 8개 또는 24개, UV 속성은 없다
debug.showFPS, enableInstancing 같은 성능 옵션소스에 없다
Cartesian3D가 x, y, z를 그대로 쓴다데이터 y와 z가 GL z와 y로 바뀐다

나중에 정리된 요구사항 초안에 이 제약을 대 보면 갈림길이 분명했습니다. 사각형 shot은 수백에서 천 개 정도였고 항목마다 회전과 간격이 달랐습니다. 다각기둥 형태의 측정점은 십만 개가 넘었고 값을 높이로 보여 줘야 했습니다. 영역 선택은 사각형, 원형, 도넛 세 모양이었고 우클릭 메뉴도 있었습니다. 이미지 저장과 라벨은 라이브러리로 해결됐습니다. 하지만 항목별 변환, 대량 피킹, 우클릭과 영역 선택은 Bar3D 옵션 밖이었고 커스텀 시리즈로 넓히면 앞 절의 경계에 부딪혔습니다.

전환을 누가 어떤 근거로 최종 결정했는지는 기록에 남아 있지 않습니다. 이후 요구사항 표의 구현 방안은 Three.js의 InstancedMesh, Raycaster와 직접 만든 geometry를 전제로 했습니다. Three.js 0.176 소스로 확인한 차이는 다음과 같습니다. InstancedMesh는 인스턴스마다 행렬과 색을 따로 둘 수 있어 shot별 회전과 크기를 표현합니다. BufferAttribute.addUpdateRange로 버퍼의 일부 구간만 올릴 수 있습니다. 그렇다고 피킹이 GPU로 넘어가지는 않습니다. InstancedMesh.raycast도 전체 경계 구를 검사한 뒤 인스턴스를 CPU에서 하나씩 검사합니다. 그래서 영역 선택은 ray로 바닥 평면의 좌표를 구하고 데이터 좌표에서 항목을 거르는 쪽이 맞았습니다.

이 대조는 정적 소스 읽기입니다. 실제 비용을 잰다면 데이터 수를 1천, 1만, 10만 개로 고정하고 Spector.js로 드로우콜과 버퍼 업로드 크기를 캡처해야 합니다. Performance 패널로는 mousemove 핸들러의 시간을 따로 봐야 합니다.

참고 자료