이전 스토리는 아래 링크를 참고해주세요.
벌써 2년
어느새 [개발자 트렌트를 버리다] 라는 글을 적은 후 2년의 세월이 흘렀습니다. 이 번글은 [1년후 소감]에 이어 또 다시 1년 동안 어떤 일이 일어났는지에 대한 이야기와 그 동안 에디터를 만들면서 알게된 나름의 팁들을 같이 정리해볼까 합니다.
이지로직 스튜디오 : https://editor.easylogic.studio
코드
1년전 코드와 다시 한번 비교해보겠습니다.
추가 라인 165,685 — 삭제 라인 160,263
일단 지운 것 보다 5,000 라인 정도가 더 추가 되었는데요. 전체 그래프를 보면 이전 1년에 비해서 변화의 코드량은 많이(?) 줄었지만 상당히 많은 기능을 다시 구현했습니다.
릴리즈
v0.9.47 ~ v0.10.4 까지 총 9개의 릴리즈를 만들었습니다.
릴리즈에는 특별한 것들이 2가지 있습니다.
- Matrix
- FR translation v0.2
프랑스 분이 번역을 넣어주셔서 특이하게 다국어 지원중에 프랑스어 번역이 들어가 있습니다. 그리고 에디터의 기본 좌표변환을 모두 Matrix 기반으로 변경하였습니다. 지금부터가 진짜 에디터가 될 준비를 하는 것 같습니다.
도움 주신 분들
이슈 남겨 주셔서 감사해요. 두둥 ^^
기능
다시 1년이라는 시간이 올 줄 몰랐기 때문에 처음부터 어떻게 해야겠다는 목표를 잡진 않았습니다.
다만 에디터의 퀄리티를 (Figma나 Sketch 같은)벡터기반의 에디터와 유사한 형태로 만들면 좋겠다는 희망은 있었습니다. 에디터마다 특징들이 다르고 구현하는 목표가 다르기 때문에, 저의 경우는 완전히 CSS 기준으로 생각을 해야 했습니다.
그럼에도 불구하고 최대한 벡터 에디터에 충실한 기능을 넣기 위해서 많은 노력했습니다. 그 중에 하나가 Matrix 를 적용하는 것이었습니다.
Matrix 를 적용하지 않고서는 도저히 다른 에디터들을 따라 갈 수가 없습니다. (특히 Figma를 볼 때 마다 알 수 없는 열정에 휩싸이고 있었죠.)
막상 Matrix 를 할려고 보니 Matrix 를 제대로 다뤄본적이 없다는 것과 CSS 로 어디까지 구현이 가능할까에 대한 정의를 내리기가 어려웠습니다. 되는 것이 맞는 것인가? (여기 저기 벤치마킹을 많이 했는데요. 순수 Web Base 로는 UXPin, Framer 가 가장 잘 만들지 않았나 생각해봅니다. Figma 는 캔버스 기반이라 논외로 봅니다.)
그럼 업데이트 된 주요 기능들을 한번 보겠습니다.
- 멀티 아트보드 구현
- Layer 를 움직일 때 아트보드 변경 구현
- 공간 제약 없는 Viewport 구현
- Panzoom 구현 (25% ~ 2500% 확대 가능)
- Ruler 구현
- 드래그로 Layer 복제 기능 구현
- 아트보드 Export 기능 구현
- Layer Group/Ungroup 기능 구현
- Rotate 된 Layer 의 resize 기능 구현
- History 기능 적용
- SVG Path 의 BBox 구현
- Video Layer 추가
- 비동기(async) LOAD () 함수 구현 (컴포넌트 내부 변경되는 html 을 비동기로 적용)
기능의 대부분이 Matrix 기반의 좌표연산으로 인한 구현입니다. 결과물은 여전히 CSS/SVG 형태로 되어 있습니다.
Matrix 기반의 좌표연산으로 바꾸면서 생각보다 시간도 많이 걸리고, 개념 자체를 CSS 기반으로 이해하는데 상당히 어려움이 있었습니다. 그리고 그걸 실제 코드로 녹여내서 원하는 결과값을 만드는 것 또한 엄청 큰 도전이었습니다.
CSS 는 이미 Matrix 형태로 동작하고 있었지만 그걸 처음부터 툴에 녹여낼 생각을 하지 못했습니다. 왜냐하면 Matrix을 제대로 다룰 수 없다고 생각했고, 시도 조차 할 생각을 못 했기 때문입니다. 평소에도 UI 를 개발하면서 Matrix 를 쓴적이 거의 없다 보니 할 수 있는지 두려움이 앞섰습니다.
결국은 제대로 된 좌표 설정을 해야만 하는 몇몇 기능들 때문에 Matrix 기반으로 바꿀 수 밖에 없습니다.
- Viewport/Panzoom 구현하기
- 회전 이후의 드래그 영역에서 Selection 적용하기
- 회전 이후의 크기변경 적용하기
- 회전 이후의 Path 에디팅 적용하기
- 멀티 Selection 이후 회전 적용하기
- Layer 의 부모가 바뀔 때 상대 경로 찾아가기
이런 기능들은 단순히 CSS 의 transform 속성을 바꾼 다고 할 수 있는 것들이 아니였습니다.
예를 들어 [회전 이후의 드래그 영역에서 Selection 적용하기]만 보아도 이미 회전한 상태에서는 Rect 의 영역의 좌표가 완전히 바뀌어 버립니다. transform origin 을 어떻게 설정했느냐에 따라서도 바뀌기 때문에 Rotate 된 상태의 Selection 영역이 필요한 것이죠.
그 말은 이미 변경된 영역과 Selection 영역과의 충돌(Collision)을 계산 할 수 있어야 함을 의미합니다.
충돌(Collision)이라는 생각이 드는 순간 내가 게임을 만들어야 하는 것인가? 하는 생각도 들었습니다. 게임에는 수학적 공식들이 이미 많이 들어있습니다.
어찌보면 에디터의 아주 기본적인 기능 조차도 없는 상태로 만들고 있었던 것이 아닌가 생각됩니다. CSS 에서는 속성 설정에 대한 정의만 할 수 있기 때문에 transform 을 이용한 최종 결과값만 적용할 수가 있었습니다. 즉, 제가 원하는 기능들을 만들려면 사전에 transform 계산을 해야 했습니다. 그래서 CSS 와 연관 없이 matrix 연산을 할 수 있는 구조를 맞추도록 노력했습니다.
실제 구현된 기능들을 한번 보시죠.
Viewport, Panzoom and Ruler
Matrix 를 구현하면서 가장 큰 수확은 Viewport 를 구성할 수 있게 되었다는 사실입니다. 이제 마우스 좌표를 css 의 특정 element 에 있는 bounding rect 로 가지고 오지 않아도 내 마우스 위치를 기준으로 viewport 의 지점을 찾게 되면 좌표를 바로 인지 할 수 있게 되었습니다.
Viewport 는 전체 Pan 중에 내가 어디를 보고 있는지 직접적으로 알 수 있게 해주기 때문에 Ruler 를 그릴 수 있는 사전 정보가 됩니다.
Viewport 가 주어졌기 때문에 Panzoom 기능을 통해서 특정 아트보드나 Layer 로 바로 화면을 옮겨서 보여줄 수 있게도 되었습니다.
Viewport 자체는 숫자로 이루어져있어서 공간 상에 제약은 없습니다. 다만 실제 렌더링 구조 자체는 CSS 로 이루어지기 때문에 공간도 CSS 의 영향을 받습니다.
여기서 가장 고민을 많이 했던 것 중에 하나는 Viewport 가 움직인다고 Layer 를 다시 렌더링 하면 안된다는 것입니다.
Single Selection
Selection 을 하는 구조가 완전히 바뀌었습니다. 기존에는 left, top, width, height 을 기준으로만 생각 했기 때문에 단순하게 구현했는데요. 현재는 matrix 를 기준으로 구현해야하기 때문에 아예 구현 방법이 달라졌습니다.
아래 이미지와 같이 회전된 영역도 선택을 할 수 있어야 한다. 결국은 충돌모델을 사용 할 수 밖에 없는 상태라 레이어 충돌 알고리즘도 적용하게 되었습니다.
selection 에 필요한 8방향의 포인터들과 회전을 위한 포인터, 그리고 아래 크기 표시까지 모두 Matrix 연산을 기반으로 좌표를 구하고 있습니다.
Resize after rotating
Single Selection 을 구현한 이후에 가장 힘들었던 부분이 Layer 를 Resize 하는 부분이었습니다. 기존에는 개념이 width, height 밖에 없었기 때문에 width, height 를 늘리거나 줄이면 됐는데요. Rotate 된 상태에서 크기를 줄이거나 늘리면 사실 left, top 이 전혀 다른 좌표로 구성이 되어야 하는 문제가 있습니다.
왜 그럴까요?
하나의 Layer 를 회전을 하고 resizing 을 한다는 이야기는 width, height 만 바뀌는게 아니라 transform 의 origin 도 같이 바뀌기 때문입니다. 그렇다는 이야기는 사실 left, top 이 고정이 아니고 전체 좌표가 다 같이 바뀌는 것이죠.
이 것을 이해하는데 생각보다 많이 걸렸습니다. (이래서 경험이 중요한 것 같습니다.)
기본 개념은 이렇습니다.
Layer 가 어디에 있든 부모를 가지고 있습니다. Matrix 형태로 표현하면 아래와 같습니다.
[부모 Matrix] * [자식 Matrix]
좀 더 풀어보면 하나의 [자식 Matrix] 는 아래와 같이 이해할 수 있습니다.
[부모] * [자식 translate] * [자식 transform origin] * [자식 transform] * [자식 tranform origin * -1]
기존에 가지고 있던 식이 변경된 이후는 아래와 같이 되는거죠.
[부모] * [자식 New Translate] * [자식 New Transform origin] * [자식 transform] * [자식 New Tranform origin * -1]
이렇게 변경되는 Matrix 연산을 기준으로 [자식 New Translate] 를 계산할 수 있게 됩니다.
Group Selection
single selection 을 해결한 이후 좀 더 골치가 아픈 영역을 만났습니다. group selection 은 전혀 다른 컨셉으로 맞춰야 했습니다.
Group Selection 에서 몇가지 이슈가 있는데 가장 큰 이슈는 3가지 입니다.
- Group 의 Transform Origin 은 전체 영역기준으로 다시 정의됩니다.
- Group 을 Rotate 하게 되면 개별 Layer 의 Origin 과 상관 없는 영역에서 연산이 일어납니다.
- Selection 을 하는 기준은 Layer 의 부모가 Selection 에 포함된 경우 자식은 Group Selection 에 포함하지 않습니다.
Single Selection 경우에서는 Layer 하나를 기준으로 했기 때문에 transform origin 의 기준이 Selection 영역과 동일했습니다. 하지만 Group Selection의 경우는 Group 전체 영역의 가운데를 기준으로 해야한다는게 달라졌습니다.그리고 선택된 각 Layer 별로 부모가 모두 다를 수 있어 각 Layer 별로 모두 다르게 연산을 해야하는 시점에 온 것입니다.
Group Selection 에서 Rotate 를 하게 되면 영역표시를 어떻게 해야할까요?
처음에는 이렇게 특정한 영역을 그대로 돌리도록 했는데요. 그리고 여기서 멈추도록 했는데 문제가 생겼습니다.
다른 벡터 에디터들은 순수하게 벡터만 관리합니다. 즉, 특정 Vertex(점) 만 있으면 그 점을 이으면서 다시 패스를 그리는 형태이기 때문에 Group Rotate 된 상태에서도 크기 변경을 해도 상관이 없었습니다. (물론 이렇게 되면 원래 객체로 돌릴 수가 없게 됩니다.)
하지만 제가 만드는 에디터는 CSS 기반의 Layer 이기 때문에 이미 width, height 가 고정된 상태에서 찌그러짐이 발생을 할 수가 없습니다. 그러다보니 실제로 유저가 영역에 대한 인지를 하기가 엄청 힘들어집니다. 그래서 Group Rotate 할 때만 움직인 형태로 보여주고, Rotate 가 종료되면 선택된 Layer 들의 전체 영역으로 잡아줍니다.
이렇게 결정하기까지 고민을 많이 했는데요. 다른 벡터 에디터들도 모두다 다르게 표현하고 있어서 딱히 정답이라는 개념이 없는 것 같습니다. 이지로직스튜디오 같은 경우는 CSS 에디터에 맞는 최소한을 선택을 한 것 같습니다.
Path Editing
Matrix 를 적용하기 이전부터 가장 큰 고민거리가 Path 편집하는 방법이었습니다. Layer 가 Rotate 된 상태에서 Path 를 어떻게 나열해야할지 몰라서 Path 를 편집하는 툴에 백그라운드를 하얗게 만들었습니다.
하지만 그렇게 끝날 문제가 아니였습니다. Path 에디팅을 SVG Path 말고도 다른 쪽에도 넣어야 했습니다. svg-textpath, clip-path , svg-path, motion-path등등 특정 Layer 를 기준으로 상대좌표 형태로 path 를 그려내야 했습니다.
그렇다는 이야기는 CSS 가 가진 모든 transform 을 적용해도 나와야 하는 거죠. 어떻게 구현해야할지 감을 못 잡다가 Matrix 를 알게된 이후에 자연스럽게 해결 되었습니다.
Layer 는 점 4개로 표현을 하고, path 는 여러개의 점으로 표현한다는 것만 달랐습니다. 같은 영역에서 점을 어떻게 다루는지가 중요해 졌고 그렇게 하나씩 맞출 수 있게 되었습니다.
회전된 Path 를 이젠 아래와 같이 화면상에서 따로 하얀색 화면을 띄우지 않고도 원래 위치에서 편집할 수 있게 되었습니다.
BBox for Path
Path 에디팅을 할 때 Matrix 는 해결이 되었는데요. 문제가 하나 더 있습니다. Path 가 있는 svg 의 실제 영역은 변화가 없다는 것이죠. 그래서 영역을 벗어난 Path 가 됩니다.
특정 레이어에서 Path 가 화면을 벗어나면 아래와 같이 됩니다.
Path 를 그리는 SVG 영역과 Path 의 좌표가 맞지 않는 현상이 발생하게 됩니다. 이런 현상을 Matrix 안에서 연산이 되도록 구조를 맞춰야 했습니다. (Path 의 BBox 를 구해서 해결할 수 있습니다.)
처음에는 SVG Path 가 가지고 있는 browser 에 적용된 getBBox() 함수를 쓸려고 했지만, 현재 구현하는 수학적 계산 자체가 브라우저에 의존하지 않는 구조로 되어 있기 때문에 새로 구현을 해야했습니다.
BBox 구할 때 코드 형태로 가장 도움을 많이 받은 글입니다.
어떤 Path 의 BBox 를 구한다는 것은 path 가 표현하는 점의 집합의 경계점을 구하는 것인데요. 여기서 제일 걸리는 부분이 Curve 영역입니다. Curve 는 어떤 절대값으로 되어 있는 Path 가 아니라 특정 구간에 대한 정의만 있기 때문에 경계값을 알려면 모든 값을 시뮬레이션 해야하는 상태가 됩니다.
하지만 이것도 공식이 있더라구여. Curve 자체는 3차 방정식이지만 실제 경계선(Root)는 2차 방정식이라서, 근의 공식이라서 2개의 값을 가집니다. Root(근)만 알면 경계선을 만들어 낼 수 있어요. (이게 다 무슨 소린지…..)
암튼 어찌 어찌 여차 저차 하다 보니 아래와 같은 형태의 것도 할 수 있게 되었습니다.
저기 선이 막 흔들리는게 보이시나요? 지금까지 계산한 모든 숫자는 float 형태의 실수 체계를 따르기 때문에 중간의 경계값이 모호하게 생깁니다. 이걸 잘 정리해주는게 연구해야할 포인트중에 하나입니다. 최종적으로 어떤 실수값이 존재 한다고 했을 때 1과 1.0000001 은 다르기 때문에 그렇습니다. 그래서 모든 연산들의 값이 차이가 나게 됩니다.
History
사용자가 수행한 모든 Action 에 대해서 히스토리를 남깁니다. 설치형 어플리케이션이나 웹에서 단독으로 수행하는 형태는 괜찮았습니다. 하지만 요즘 유행하는 협업 소프트웨어에는 맞지 않았습니다. 내가 하지 않은 행위들도 더 큰 히스토리 개념 안에서 돌아가야 하기 때문인데요.
아직까지는 Cloud 형태로 서비스를 하고 있지 않기 때문에 정확한 상태로 구현을 할수는 없지만 , 그렇게 될거라고 가정을 하고 History 를 처음부터 다시 설계했습니다.
동시에 여러 유저가 특정한 값을 수정한다고 할 때 어떻게 순서를 맞출 수 있을까요? 그리고 그 상황에 내가 뒤로 가기를 했을 때 어디를 봐야할까요? 만약에 그렇게 된다면 어떤 값을 저장해야할까요?
나중에 Cloud 서비스가 되면 동시편집 엔진은 Yorkie 를 사용할 예정입니다. https://github.com/yorkie-team/yorkie
아직까지는 완전하지는 않지만 히스토리를 위한 행위에 대한 규칙을 작게나마 정의를 해보았습니다.
- 내가 만든 뒤로가기는 로컬에서 편집한 상태로 한정 짓는다.
- 다른 유저가 변경 한 것 까지는 뒤로 돌리지 않는다.
아직까지는 클라우드 형태로 적용되는게 아니다 보니 현재로써는 로컬에 있는 인터랙션을 최대한 다른 행위와 분리 시켜서 히스토리를 남기고 복구 할 수 있도록 구조를 맞추고 있습니다.
하지만 History 를 적용할려고 하다 보니 또(?) 문제가 하나 생겼습니다. (쉽게 흘러가는게 하나도 없네요.)
인터랙션 (클릭, 드래그) 하나를 두고 어떤 값이 변경이 된다고 한다면 History 에 어떤 값이 저장이 되어야 할까요?
처음에는 단순하게 이렇게 생각했습니다. 가만히 보다 보니 Drag 같은 경우는 계속 중간 단계에 머물러 있는 인터랙션이었습니다. 물론 Drag 를 하는 동안 실제 Layer 는 어떤 변화를 가지게 되는데요. 그 모든 변화가 History 에 들어가면 안되는 것이었습니다.
이건 예전에 컬러피커 만들면서도 한번 겪은 적이 있었습니다.
그래서 제가 선택한 방법은? 이미(?) 있는 기능을 사용하는 것이었습니다.
그 방법이 무엇이냐 하면 , 마우스 이벤트는 전역 이벤트 하나만 사용하는 것이었습니다.
컬러피커도 전역 이벤트 하나, range 도 전역 이벤트 하나, path editor 에 Drag 하는 것도 모두 이벤트 하나로 핸들링하는 것이죠.
이렇게 되면 액션을 매번 체크하지 않고도 에디터 입장에서는 마지막 mouseup 하는 시점을 알게 되서 해당 커맨드가 실행 됐을 때 마지막 시점인지 알 수 있게 됩니다. (마우스 이벤트는 전역으로 하나만 씁시다. 정신 건강이 좋아집니다.)
메소드를 조합하는 방식은 예전에 글로 한번 적었습니다.
POINTERSTART 이벤트만 유저 이벤트로 직접 실행하고 MOVE, END 는 전역 이벤트로 수행합니다. (이런 기법을 사용 하실 려면 Sapa 를 사용하세요.)
이렇게 인터랙션 행위의 끝점을 찾았는데요. 문제가 하나 더 있었습니다. 예를 들어 이런 상황이 생기는거죠.
setAttribute({ ‘background-color’: ‘red’ })
컬러피커를 띄우고 색을 변경하면 setAttribute 라는 커맨드를 수행을 하게 됩니다. 이 때 실행되는 커맨드와 history를 저장하는 시간차이가 나거나 어떤 행위로 인해서 다른 시간대에 실행이 된다면 history 는 이전 상태를 제대로 가지고 있지 못하는 형태가 됩니다.
그리고 실제로 이전 상태라는 것은 위에서도 정의했지만 내가 로컬에서 인터랙션 한 것만 가지고 있어야 해요.
즉, 다른 유저의 인터랙션 변경사항이 마지막 시점이 되면 안되는 것이었습니다. 그래서 결국은 setAttribute 라는 커맨드는 그대로 두고 history.setAttribute 라는 커맨드를 새로만들게 되었습니다. 다른 유저가 실행하거나 드래그 하는 동안 수행하는 것들은 setAttribute 를 사용하고, 나머지는 history.setAttribute 를 사용하도록 하는거죠. 이제 history.xxx 형태로 되어 있는 모든 커맨드들은 history 에 이전값과 이후값에 대한 정의를 같이 넣도록 되어 있습니다.
그렇게 하다 보니 이제 겨우 다른 툴들의 history 를 흉내낼 수 있게 되었습니다. 하지만 아직도 모든 행위에 대해서 커맨드로 분리한 상태라가 아니라서 히스토리 쪽도 해야할게 많은 것 같습니다.
마무리
작년에 1년동안 한 것보다 양은 많진 않지만 훨씬 유익한 1년이 된 것 같습니다. 지금부터는 툴이 더 발전할 수 있는 밑바탕을 만들어 놓을 수 있게 되었습니다.
Matrix 를 적용하면서 가장 큰 수확은 화면에 점이 어떻게 위치 하는지 알게 되었다는 것입니다.
처음에는 안 될 것 같았던 영역들도 조금씩 하다 보니 늘어가고 있습니다. Matrix 에 대해서 제대로 몰랐지만 계속 하다 보니, 어느 순간부터는 스스로 익숙해지고 조금 더 발전적인 생각도 할 수 있게 된 것 같습니다.
이제서야 조금은 다른 벡터툴을 따라 갈 수 있게 되었습니다.
그리고 프런트엔드 영역에서도 사고하는 방식이 많이 확장된 것을 느낍니다. SVG, CSS 를 좀 더 깊이 이해하고 다양한 것을 할 수 는 자신감이생겼습니다.
이지로직 스튜디오라는 툴이 다시 1년이 지났을 때 어떻게 발전해있을지 스스로 기대가 되네요. ^^
Matrix 하면서 에디터에는 조금 다른 관점이 생겼는데요. 예전에 만들어뒀던 기능들이 이상하게 동작했지만 에디터를 만들어뒀다는 것이 엄청난 자산이었다는 것을 알게 됐습니다.
아마도 에디터를 만들지 않은 상태로 Matrix 를 했다면 금방 까먹는 지식이 되지 않을까 생각해봅니다. 베이스가 있는 상태에서 Matrix 를 적용하기 위해서 이것 저것 고민을 해본 것들이 실제로 활용되는 패턴을 보다보니, 지금까지 내가 했던 것들이 나쁘지는 않았구나 하는 생각이 들었고, 그런 것들로 인해서 더 발전 하게 됐다는 느낌이 들었습니다.
앞으로 계속 발전적인 에디터가 되면 좋겠네요. ^^
읽어주셔서 감사합니다.
TIP & TIP & TIP
여기서 부터는 에디터를 만들면서 적용했던 부분들을 설명을 해볼까 합니다.
Matrix 연산 라이브러리는 이미 잘 갖춰진 것을 사용하기로 했습니다. Matrix 가 익숙하지 않은 상태에서 개념을 잘 이해못한 함수를 만들면 또 다른 허들이 될 것 같았습니다.
글에서 Matrix 를 표현하기 위해서 기호로 표기 합니다. Matrix 는 [M] 형태로 적고, Matrix 의 연산은 [M] * [A] 형태로 구성을 할 예정입니다. 그 외는 실제 사용된 코드를 바탕으로 설명을 드리도록 하겠습니다.
glMatrix 라이브러리를 활용해서 전체 Matrix 에 대한 연산을 합니다.
Matrix 다루기
에디터에서는 CSS 의 모든 기능에 대응하기 위해 3D 연산도 같이 포함합니다. 그렇기 때문에 모든 연산은 4x4 Matrix 를 사용합니다.
Matrix 를 연산하기 위해서는 실제로 화면상의 점(Vertex)이 필요합니다. CSS 로 Layer 를 표현한다면 보통은 left, top, width, height 로 표현하게 되는데요. 이것을 matrix 로 연산 할 수 있도록 Vertex(꼭지점) 개념으로 바꿉니다. 즉, 4개의 포인트로 바꾸게 됩니다.
하나의 Rect Layer를 대상으로 4개의 점(vertext)으로 변환 한 다음 연산을 하게 됩니다.
코드에 있는 transform origin 은 연산의 편의성을 위해서 사전에 같이 계산해둡니다. transform origin 은 scale, rotate 같은 다른 matrix 연산에 사용되기 대문에 중요합니다. origin 을 맞추지 못하면 전혀 다른 결과값이 나오게 됩니다.
아래는 실제로 Viewport 에서 사용되는 matrix 연산입니다.
Viewport 에서 내부적으로 사용되는 translate, transformOrigin, scale 등을 사전에 연산을 해두고 사용할 수 있습니다.
이렇게 할 수 있는 이유는 matrix 자체가 선형적으로 계산이 되기 때문입니다.
예를 들어 [A] * [B] * [C] * [D] 라는 연산이 있을 때 우리가 A, B, C 를 미리 알 수 있다면 [ABC] * [D] 형태로 미리 계산된 Matrix 만 가지고도 나머지 Matrix 를 연산해서 결과값을 구할 수 있게 됩니다.
그래서 viewport 같은 경우는 특정 조건이 바뀔 때 마다 사전에 matrix 를 합쳐주는 작업을 하게 됩니다.
앞으로는 matrix 연산들은 모두 vertex 에 matrix 를 곱해주는 형태로 진행이 되게 됩니다.
Matrix 를 곱하는 순서는 [A] * [B] * [C]* [D] 라고 A,B,C,D형태로 있을 때 실제로 값을 곱하는 순서는 D, C, B, A 형태로 됩니다. (전 이 개념이 너무 헷갈렸어요.)
Vec3
4x4 Matrix 를 연산하기 위해서 3차원 벡터를 지정합니다.
vec3는 [x, y, z] 형태의 배열이 되기 때문에 { x, y, z } 형태의 key, value 로 가지고 있는 것보다 사용하기 편합니다. gl-matrix 는 기본적으로 저런 패턴으로 연산하도록 되어 있습니다.
Layer 위치 구하기
기본적으로 Layer 는 top, left, width, height 의 영역을 가집니다. 이때 position: absolute; 를 설정하게 되면 부모의 위치부터 상대 위치를 가집니다.
즉, 화면에 하나의 Layer 가 보여지는 위치는 부모의 위치까지 포함을 해야하는 상태가 됩니다.
ArtBoard 안에 Layer 가 있다고 치면 ArtBoard 의 {left, top} 좌표에 Layer 의 {top, left} 좌표에 있어야 하는거죠. 하지만 이건 어디까지나 부모나 자식 Layer 에 아무런 변화가 없을 때만 가능합니다.
자식 레이어는 부모의 영향을 받기 때문에 부모의 전체 Matrix 변환을 알아야지만 정확한 자식의 위치를 구할 수 있습니다.
즉, [부모 Matrix] * [자식 Matrix] 가 되어야 하는 상태입니다.
그 중에 [자식 Matrix] 에 대해서 먼저 알아봅시다.
[자식 Matrix] 를 표현할 때 Vertex 변화에 영향을 줄 수 있는 CSS transform 은 rotateZ 정도 된다고 정리를 하면 사전에 transform 에 대한 matrix 연산만 모아줄 수 있습니다. (rotateZ 말고도 많지만 코드량이 많아서 하나만 하는걸로 하겠습니다.^^;)
해당 transform 의 속성의 경우 transform-origin 을 기준으로 처리 되기 때문에 아래와 같은 형태의 matrix 를 더 연산을 해줘야 합니다.
이유는 Matrix 연산에서 사용되는 좌표는 모두 Vector 를 기준으로 하고 있어서 그렇습니다. Vector 는 기본적으로 (0,0) 원점에서 뻗어나가는 좌표(?)이기 때문에 특정 변환을 적용할려면 원점으로 돌렸다가 변환을 적용하고 다시 원점에서 온 만큼 반대로 움직여 줘야 합니다. (CSS 스펙에도 이미 나열이 되어 있습니다. https://www.w3.org/TR/css-transforms-2/#transform-rendering , 한글로된 간단한 설명을 보실려면 여기를 참고하세요. https://m.blog.naver.com/destiny9720/221401424036 )
이 정도 하면 Layer 의 기본 변환을 적용해서 각각의 꼭지점(Vertex)을 맞출 수 있게 됩니다. 여기에 더해서 left, top 의 위치를 더해주면 점들의 최종 좌표를 맞출 수 있습니다. (대략 https://www.w3.org/TR/css-transforms-2/#accumulated-3d-transformation-matrix-computation 여기 보시면 알 수 있습니다. 해석은 각자의 몫으로….)
위에 있는 CSS 스펙들을 보시면 아시겠지만 이미 Matrix 기준으로 연산을 하고 있습니다. 이것만 있으면 해당 Layer 의 vertex 들의 위치를 찾을 수 있게 됩니다.
하지만 Layer는 중첩될 수 있기 때문에 중첩된 상태에서의 Layer 위치를 찾아야만 실제 화면상의 위치를 알 수 있습니다.
중첩된 Layer 들의 위치 구하기
ArtBoard -> Layer 1 -> Layer 2 -> Layer 3 형태의 중첩된 Layer 들이 있다면 Layer3 의 위치는 어떻게 될까요?
잘 생각해보시면 아래와 같은 형태로 될 것 같습니다.
[ArtBoard] * [Layer 1] * [Layer 2] * [Layer 3]
항상 모든 위치는 부모의 영향을 받기 때문에 위와 같은 형태로 [M] 를 연산해줘야 합니다.
이 때 [M] 는 연산 순서가 중요한데요. 순서를 맞추지 않으면 연산의 일관성이 없어집니다. 이렇게 겹치는 것을 Accumulated 라고 합니다. (https://www.w3.org/TR/css-transforms-2/#accumulated-3d-transformation-matrix-computation 여기에 합치는 방법이 상세히(?) 나와있습니다. 한번 읽어 보시면 좋아요.)
최상위 부모부터 모든 Transform 을 가지고 Matrix 연산을 하게 되면 최종 Layer 의 각각의 꼭지점(vertex) 을 구할 수 있게 됩니다.
2D 연산에서는 Matrix 연산만 해도 괜찮습니다. 다만 3D 연산이 들어가면 CSS 에서 Layer 에 3D 라는걸 알려줘야 합니다.
CSS 는 transform-style: preserve-3d; 속성을 설정해야 3D 공간에서 Matrix 연산을 하게 됩니다. rotateX, rotateY 같은 연산을 적용할때는 필수로 설정을 해야합니다.
이지로직 스튜디오에서는 Layer 가 그려지는 기본적은 좌표를 left, top 을 항상 부모로부터 설정하도록 되어 있습니다.
[translate] * [itemOrigin] * [itemMatrix] * [itemOrigin * -1]
그래서 항상 이런 패턴으로 Matrix 연산을 하게 되니 참고하세요.
Layer 이동하기
이제 실제로 Layer 를 움직여 보겠습니다. Matrix 를 사용하지 않은 상태에서는 보통은 x, y 에 특정 움직인 간격을 더해주는 걸로 해도 됩니다.
하지만 Layer 가 rotate 같은 transform 을 가지고 있거나, 부모가 transform 을 가지고 있게 되면 단순히 x, y 에 어떤 값을 더 한다고 되질 않습니다.
단순히 x, y 에 값을 더하면 아래와 같이 됩니다. 전혀 엉뚱한 곳을 가게 됩니다.
그럼 우리에겐 무엇이 필요할까요? 무엇이 잘 못 된 걸까요? 지금부터 Matrix 기반으로 몇가지를 살펴보도록 하겠습니다.
일단 마우스를 움직일 때 어떤 위치로 움직였는지를 알아야 합니다. 위의 Layer 처럼 회전이 되어 있는 상태를 움직일 때는 내가 어디로 가는지 어떻게 알 수 있을까요?
마우스 드래그를 오른쪽으로 한다고 치면 Layer 가 회전이 되어 있는 상태 그대로 오른쪽으로 움직여야 합니다. 지금까지 봤던 Matrix 가 모두 적용된 Vertex 가 그만큼 움직이는 것이 됩니다. 그런 이후에 다시 CSS 좌표로 복원합니다. 이제 Layer를 움직이는 방향부터 알아봅시다.
방향을 알려면 처음 클릭했던 point 와 드래그 하는 시점의 point 사이의 간격을 알면 됩니다. 다만 이건 Viewport 기준의 좌표입니다. 즉, Layer 가 움직여야 하는 방향과는 상관이 없는 것이죠.
아래 코드를 한번 봐주세요.
it.rectVerties 는 Layer 가 기존에 가지고 있던 viewport 기준 vertex 리스트 입니다. (어디에도 속하지 않은 글로벌 좌표입니다.). 여기에다가 마우스 포인트로 움직인 distVector 를 더해줍니다. 자 그럼 이제 다른 newVerties 들이 나오게 됩니다. 그렇다면 둘 사이의 진짜 차이는? 방향은 어떻게 생각하면 될까요?
어떤 Layer 든 부모를 가진다고 했습니다. 실제로 어떤 공간을 움직인다는 소리는 부모 Layer 안에서 움직이는 것인데요. 그래서 개별 verties 를 parentMatrixInverse(parentMatrix 역행렬)를 통해서 parent 의 상대 좌표로 바꿔줍니다. 그런 다음에 차이를 구하면 실제로 움직인 거리(newDist)가 되죠. 이제는 x, y 에 더해도 됩니다.
Layer 회전하기
Layer 를 움직여 봤으니 Layer 를 현재 상태에서 Rotate(회전) 시켜 보도록 하겠습니다.
회전을 할때 여전히 Viewport 기준으로 좌표를 변환한 다음에 진행합니다. 왜냐하면 그래야 화면상에서 Vertex 가 움직인 정확한 각도를 알 수 있기 때문입니다. Rotate 는 이동하기 보다 조금은 쉬울 수 있습니다. 화면에서 클릭한 지점과 실제 중심점 (transform origin) 과의 관계에서 클릭한 지점을 distVector 움직여서 그 각을 구하면 되기 때문입니다. 그런 다음 원래 가지고 있던 angle 에 마지막에 구한 angle 을 더해줍니다.
쉽죠? 네, 쉬울 줄 알았습니다. 하지만 여긴 함정이 하나 숨어있습니다. 이런 회전이 쉬울려면 클릭할 지점이 있어야 하는데요.
보시면 전혀 엉뚱한 곳에 rotate 를 할 수 있는 포인트가 있습니다. 아(?), 저걸 어디서 구하나요? 지금까지 4방향에 대한 vertex 만 가지고 있었는데 말이죠. ^^;;;;; (다른 벡터 툴들은 4 방향 주변에 마우스를 올리면 rotate 아이콘으로 바뀌면서 회전을 할 수 있습니다. 저도 그런 기능이 가능하지만 유저에게 좀 더 친숙한 방법으로 넣을 수 있도록 가운데를 움직이는 형태로 맞췄습니다.)
뜬금 없는 곳에 있는 포인트를 이제부터 구해봅시다. 잘 보시면 대략적인 패턴이 있습니다. 먼저 공간에서 가운데에 있고 가운데 위의 점에서 약간 떨어져있습니다. 그렇지만 항상 회전을 할 것이므로 가운데만 있지는 않을거에요. 그래서 아래와 같은 좌표를 구해야합니다.
아래 형태로 코드를 만들어 낼 수 있습니다.
먼저 기존에 가지고 있던 verties 를 기준으로 vec3.lerp 함수를 가지고 top, bottom 을 표시 할 수 있는 Vector 를 먼저 구합니다.
bottom -> top 으로 뻗어나가는 길 중간에 rotatePointer 가 있는 것이죠. 그걸 dist 값으로 제어합니다.
즉, bottom -> top 과 bottom -> rotatePointer 는 일직선상에 놓여 있습니다. 네, 직선의 방정식을 사용하면 대략적으로 rotatePointer 를 알 수 있습니다.
vec3.lerp([], start, end, 1 + pointDist/vec3.dist(start, end))
rotatePoiner 는 bottom -> top 의 거리보다 dist 만큼 벌어져있기 때문에 비율로 나타내면 1보다 큽니다. 즉, 1 + dist / (bottom, top 의 거리) 정도 됩니다.
그걸 vec3.lerp 함수를 사용해서 vector 를 구하면 rotate 되는 포인터의 위치를 찾을 수 있습니다.
이 공식을 반대로 응용하면 (top -> bottom 형태) 좌표 정보를 표시해주는 파란색 박스 위치도 만들어 낼 수 있습니다.
Layer 크기(Scale) 변경하기
처음에는 Matrix 를 사용하지 않았기 때문에 크기 변경은 width, height 를 변경하는 것에서 끝났습니다. 그러다 보니 회전한 상태로는 크기 변경을 하면 전혀 다른 형태로 , 다른 위치로 변형이 되고 있었습니다. 이것을 그냥 넘어가고 있었던 것이죠.
회전된 상태에서 크기를 변경 하려면 어떻게 해야할까요?
여기 보시면 크기 조절점이 최소 4개에서 많게는 8개까지 늘어납니다. 각자의 방향이 있는거죠. 예를 들어 Rotate 된 상태에서 right, bottom포인트를 끌면 width, height 가 변경되어야 합니다. 그리고 left, top 은 고정이어야 하죠.
이미지를 봐주시면 동일한 top, left 를 가지고 있는 것 같지만 width, height 가 변함에 따라 실제적으로는 초록색으로 된 transform origin 의 위치가 바뀐 것을 볼 수 있습니다. transformOrigin 이 변경이 되었기 때문에 top, left 가 고정이 아니라 매번 갱신해야 하는 이슈가 있습니다.
그렇다면 변화하는 width, height 에 따라 top, left 를 변경해서 , 눈에 보일 때는 고정되게 할 수 있을까요? 간단하게 식을 하나 세워보겠습니다.
bottom, right 에 있는 pointer 를 움직인다고 먼저 가정을 하겠습니다. 그렇게 하면 눈에는 top, left 가 고정으로 보일 겁니다.
[parentMatrix] * [translate] * [transformOrigin] * [itemTransform] * [transformOrigin * -1]
이건 기본 Layer 들이 가지고 있는 Matrix 입니다. 이것이 아래와 같이 바뀌어야 하는 상태입니다.
[parentMatrix] * [newTranslate] * [newTransformOrigin] * [itemTransform] * [newTransformOrigin * -1]
new 를 가지고 있는 것을 새로 구해야하는 상태입니다. 그럼 2개를 구해야하네요. newTranslate, newTransformOrigin
newTranslate 는 변경되는 top, left 의 x, y 좌표라고 보시면 됩니다. 이건 newTransformOrigin 기준으로 itemTransform 이 일어난 이후에 구할 수 있기 때문에 잠시 보류하고 newTransformOrigin 을 먼저 구해보도록 하겠습니다.
bottom, right 의 점을 드래그 해서 옮긴다고 생각을 하면 viewport 기준의 x, y 좌표를 실제로 Layer 기준의 dx , dy 로 변경해줘야 합니다. 왜냐하면 부모부터 엄청난 transform 이 일어날 가능성이 있기 때문에 보이는 대로 그대로 해줄수가 없습니다.
일단 현재 bottom, right의 vertext 를 viewport 에서 움직인 거리 (distVector) 만큼 더해줍니다. 그런 다음 처음 값과 더한 값을item.accumulatedMatrixInverse 으로 역변환 해줍니다.
즉, Layer 가 가지고 있는 특정 좌표 영역을 처음으로 돌리는거죠. 회전도 돌아가서 평평한 상태로 볼 수 있습니다.
이렇게 되면 돌아간 처음좌표와 그다음 좌표를 가지고 실제로 방향에 상관없이 움직인 realDist[x,y,z] 벡터를 구할 수 있게 됩니다.
이제부터 하나씩 하면 되는데요. realDist 를 구했으니 기존에 가지고 있던 width , height 를 변경할 수 있게 되었습니다.
아하, newWidth, newHeight 가 됐으니 이제 newTransformOrigin 을 구할 수 있게 되었습니다. origin 의 기준이 width, height 이기 때문이죠.
TransformOrigin.scale(origin,width, height);
형태로 origin 의 Vector 를 구할 수 있게 되고 그걸 Matrix 로 변환하면 됩니다.
[newTransformOrigin] = mat4.translate(view, view, originVector)
로 하면 기본 Matrix 형태로 구할 수 있습니다.
자, 그러면 아까전에 봤었던 식을 다시 한번 볼게요.
[parentMatrix] * [translate] * [transformOrigin] * [itemTransform] * [transformOrigin * -1] = [parentMatrix] * [newTranslate] * [newTransformOrigin] * [itemTransform] * [newTransformOrigin * -1]
현재 [newTransformOrigin] 이 구해진 상황이기 때문에 식을 새롭게 모아보겠습니다.
왼쪽에 있던 식은 Layer 가 원래 가지고 있던 Matrix 입니다. 그래서 하나로 합칠 수 있습니다.
[LayerMatrix] = [parentMatrix] * [newTranslate] * [newTransformOrigin] * [itemTransform] * [newTransformOrigin * -1]
여기서 [newTranslate] 만 구하면 되기 때문에 하나만 빼고 다 념겨줍니다. 넘겨줄 때는 꼭 순서대로 넘겨줘야 합니다.
[parentMatrix * -1] *[LayerMatrix] = [newTranslate] * [newTransformOrigin] * [itemTransform] * [newTransformOrigin * -1]
--->
[parentMatrix * -1] *[LayerMatrix]* [newTransformOrigin] = [newTranslate] * [newTransformOrigin] * [itemTransform]
--->
[parentMatrix * -1] *[LayerMatrix]* [newTransformOrigin]* [itemTransform * -1] = [newTranslate] * [newTransformOrigin]
--->
[parentMatrix * -1] *[LayerMatrix]* [newTransformOrigin]* [itemTransform * -1] * [newTransformOrigin * -1] = [newTranslate]
여기까지하면 mat4.getTranslation([], [newTranslate]) 형태로 top, left 의 vector 를 구 할 수 있게 됩니다.
이렇게 해서 bottom, right 의 반대편 top, left 의 좌표와 실제 움직인 거래에 대한 width, height 를 모두 구한 상태가 되어서 화면상에서는 top, left 가 움직이지 않은 상태로 width, heght 만 변경하는 것처럼 보일 수 있게 됩니다.
현재는 방향을 bottom,right 기준으로 하나만 했는데요. 총 8개의 방향에 대해서는 내용이 많이 길어질 수 있어서 1년 더 지난 후에 자세히 다루도록 하겠습니다.
Layer 의 부모 교체하기
지금까지는 Layer 하나를 가지고 translate, rotate, scale 등등이 실제로 마우스로 움직였을 때 어떻게 움직이는지 살펴보았습니다. 이번에 살펴볼 내용은 Layer 의 부모를 교체 했을 때, Layer 각 유저의 눈에는 그 위치 그대로 올 수 있는가에 대한 이야기를 해보겠습니다.
기본적으로 Layer 는 중첩이 가능하고, 특정 아트보드에 소속이 될 수도 있고 아닐 수도 있습니다. 그렇기 때문에 언제든지 부모가 바뀌어도 이상할게 없습니다. 특히나 멀티아트보드 세상에서는 아트보드를 옮겨다닐 수 있어야 하기 때문에 더더욱 주의를 기울여야 합니다.
부모가 바뀐 다는 것은 현재의 상위 위치가 바뀌는 것입니다. 여기에 더해서 보는 관점이 고정이 되어 있어야 하기 때문에 회전이나 기타 다른 영역도 같이 바뀝니다.
간단하게 식을 한번 나열해보면 아래와 같습니다.
[newParentMatrix] * [newLocalMatrix] = [oldParentMatrix] * [oldLocalMatrix]
[old] 는 현재고 [new] 는 새로 바뀌는 부모에 따른 값입니다.
[newLocalMatrix] 는 기본적으로 [newTranslate] * [transformOrigin] * [newItemTransform] * [transformOrigin * -1] 형태와 같습니다.
여기서 구해야할것은 [newLocalMatrix] 에 있는 [newTranslate] 와 [newItemTransform] 입니다. 좀 더 명확히는 [newItemTransform] 에 있는 scale, rotate, translate 를 구해야합니다. 기본적인 과정은 scale 변경하기와 비슷합니다. 다만 부모가 달라졌다는 사실만 잘 염두해 두시면 됩니다.
[newTranslate] 는 위에도 이야기 했지만 항상 [newItemTransform] 이후에만 구할 수 있습니다. 그래서 [newItemTransform] 에 있는 요소를 먼저 구해야합니다. 이제 식을 좀 더 구체적으로 나열해보도록 하겠습니다.
[newParentMatrix] * [newLocalMatrix] = [oldParentMatrix] * [oldLocalMatrix]
---->
[newLocalMatrix] = [newParentMatrix * -1] * [oldParentMatrix] * [oldLocalMatrix]
형태가 됩니다. [newLocalMatrix] 를 구해야 하기 때문에 기존 Layer 가 가지고 있는 Matrix 에서 새로운 부모의 Matrix 의 역변환을 곱해서 값을 맞춥니다. 코드로 나타내면 대략 아래와 같습니다.
[newLocalMatrix] 를 구할 수 있게 되었는데요. 여기서 rotate, scale 등의 값을 뽑아내야 합니다. 이건 어떻게 할 수 있을까요?
대략적인 개념은 CSS 스펙문서에 나와있습니다. 다만 현재 연산 자체가 gl-matrix 를 기준으로 하고 있기 때문에 gl-matrix 기준으로 나열을 해보도록 하겠습니다.
이렇게 하면 새로운 회전량과 크기변경에 대한 값을 구할 수 있습니다. 여기서 식을 다시 볼게요.
[newLocalMatrix] = [newTranslate] * [itemOrigin] * [newItemTransform] * [itemOrigin * -1]
해당 식에서는 [newItemTransform] 을 구한게 됩니다. 그렇다는 이야기는 식대로 나열하면 [newTranslate] 를 구할 수 있게 되는거죠. 다시 나열해보겠습니다.
[newLocalMatrix] = [newTranslate] * [itemOrigin] * [newItemTransform] * [itemOrigin * -1]
---->
[newLocalMatrix] * [itemOrigin]= [newTranslate] * [itemOrigin] * [newItemTransform]
---->
[newLocalMatrix] * [itemOrigin] * [newItemTransform * -1] = [newTranslate] * [itemOrigin]
---->
[newLocalMatrix] * [itemOrigin] * [newItemTransform * -1] * [itemOrigin * -1]= [newTranslate]
자, 이제 [newTranslate] 를 구할 수 있게 되었습니다. 이런 형태로 값을 구하시면 아트보드나 기타 다른 부모로 바뀌더라도 유저 입장에서는 화면상에 좌표 변환이 없는 상태로 보여줄 수 있게 됩니다. 코드로 나타내면 대략 아래와 같습니다.
Viewport 설계하기
이제 실제로 Layer 가 화면에 표시될 때 사용할 수 있는 좌표를 가지고 있는 Viewport 를 만들어 보도록 하겠습니다. Viewport 는 Editor 에서 Canvas 영역 중에서도 실제로 보이는 부분을 지정한다고 보시면 될 것 같습니다. 어떠한 Layer가 놓여있더라도 현재 Viewport 기준하에서 표시된다고 보시면 됩니다.
예를 들어 이렇게 Viewport 내부에 아트보드를 꽉 차게 놓을 수 있는 것도 Viewport 의 좌표를 알고 있기 때문에 가능한 것입니다.
그렇다면 Viewport 는 무엇을 가지고 표현할 수 있게 되는 걸까요? 2D 상에서 간단하게 한번 생각해보도록 하겠습니다.
잘 보시면 Viewport 는 사각형으로 되어 있습니다. 즉, 점 4개로 표현할 수 있습니다. 어? 이거 Layer 하나랑 같네요. 네, Viewport 는 현재 보고 있는 공간 Layer의 다른 표현인 것이죠.
이미지에서 보시는 것과 같이 viewport 는 경계값을 가집니다. top, left, right, bottom 안에서 다른 Layer들을 보여주신다고 보면 됩니다. 다르게 말하면 엄청나게 큰 Board 가 있고 그 안을 자세히 들여다 보는 카메라인 것이죠. (3D에서는 Camera 로 구현을 합니다.)
여기서 중요한 지점이 있는데요. 중간에 숫자 보이시나요? 저기가 바로 Viewport 의 중심입니다. Viewport 의 중심은 엄청 중요한데요. 항상 Viewport 는 중심을 기준으로 보여주기 때문에 그렇습니다.
Viewport 가 오른쪽으로 간다는 것은 중심도 그만큼 움직인다는 것을 말합니다. Viewport 가 확대나 축소가 된다는 것은 중심을 기준으로 확대/축소 되는 것을 말합니다. 즉, Viewport 의 중심을 어떻게 관리하는지가 Viewport 설계하기의 핵심이 됩니다.
Viewport 가 가진 기능중에 가장 중요한 2가지가 있습니다.
- panning: viewport 의 중심을 이동합니다. (스크롤 한다고 보시면 됩니다.)
- zoomming: Viewport 의 중심을 기준으로 확대/축소 하는 것을 말합니다.
그래서 둘을 합쳐서 panzoom 이라는 용어를 사용합니다. panzoom 라이브러리는 많으니 한번 봐주셔도 좋을 것 같아요. 다만 여기서는 에디터에 맞는 형태로 구성을 해야 해서 처음부터 만들게 됩니다.
지금까지 나온 단어를 기준으로 기본 속성을 만들어봅시다.
중심을 위해서 origin 이 필요하고 , 이동을 위한 translate 가 필요하고 , 확대/축소를 위한 scale 이 필요합니다. 아하? 이거 Matrix 로 연산이 되는건가요? 하고 물으신다면 맞습니다. Layer 연산 했던 것을 응용을 하면 Viewport 를 만들어 낼 수 있습니다. 실제 코드로 구현을 들여다 보도록 하겠습니다.
에디터가 처음 초기화 될 때 Viewport 도 같이 초기화 해줍니다. 렌더링 되는 CanvasView 영역의 사이즈로 TransformOrigin 을 적용해줍니다. 이렇게 하면 Viewport 는 스스로의 영역에 Matrix 를 가질 수 있게 됩니다.
보시면 matrixInverse, scaleMatrixInverse 의 역행렬 값을 사전에 미리 가지고 있는데요. 이렇게 하는 이유의 핵심은 마지막줄에 있습니다.
this.verties = vertiesMap(this.cachedViewport, this.matrixInverse);
어떤 Layer 의 좌표 값을 Viewport 기준으로 바꾸기 위해서 그렇습니다. 이걸 할 수 있어야 Viewport 기준으로 Selection 영역을 설정할 수 있습니다.
그렇다면 역행렬곱하는건 어떤 의미가 있는 걸까요? 제일 중요한 지점은 scale 속성 때문에 그렇습니다. 에디터에서 scale 을 지정을 하게 되면 실제 우리가 보는 픽셀 간격이랑 거꾸로 변환이 일어납니다.
예를 들어 scale: 2 가 되면 Layer 가 더 크게 보여야 합니다. 그렇다는 이야기는 실제 Layer 보다 화면상의 Layer 의 좌표값이 2배가 된다고 보시면 됩니다. 그런데 이것이 Viewport 영역의 관점에서는 1/2 이 된거랑 같습니다. 같은 공간에 Layer 를 크게 보여주면 Viewport 의 경계값은 더 작아지기 때문에 그렇습니다.
왼쪽 이미지와 오른쪽 이미지를 보시면 오른쪽이 확대를 한 상태여서 scale 은 늘어났는데 Viewport 의 경계값은 오히려 줄었습니다. 실제 대상과 보는 관점이 서로 반대가 되는 것이죠.
Viewport 의 경계값은 Ruler 의 소스가 되어서 Viewport 가 변경될 때 같이 표현해줄 수 있게 됩니다.
Viewport 의 기본 연산 구조를 알아봤는데요. CSS 코드에서는 어떤식으로 돌아갈까요?
panzoom 이 될 canvas-view 는 transform-origin, translate, scale 3가지 속성으로 움직이는 것을 보실 수 있습니다. 이렇게 하면 Layer 들을 일일이 다 변경하지 않고 canvas-view 영역만 변환해서 화면에 나타낼 수 있습니다.
그럼 화면에 나타내는 Layer 들의 크기나 위치가 안 맞는거 아닌가요? 라고 여쭤볼 수 있는데요. 지금까지 알아본 Matrix 형태를 보시면 CSS 도 동일한 패턴을 사용하고 있기 때문에 Matrix 연산을 그대로 적용하는 것이 가능합니다.
Layer 의 accumulatedMatrix 를 가지고 viewport 의 Matrix 를 적용하게 되면 실제로 Matrix 의 좌표가 되고 그렇게 변환된 자표는 여러가지 영역에 사용할 수 있게 됩니다.
- selection 영역 표시
- ruler 표시
- path editor 좌표 변환
- Guide Line 표시
- Mouse Point 을 Viewport 좌표로 변환
canvas-view 에 그려지지 않지만 좌표를 가져야 하는 모든 것들은 viewport 를 사용하게 될 것입니다. 그렇게 하면 실제로 그려지는 Layer 가 아니지만 동일한 위치에 그릴 수 있게 됩니다.
Canvas 크기가 바뀔 때 Viewport 재정렬하기
Viewport 는 여러모로 상당히 유용한 기능입니다. 하지만 이 Viewport 를 계속 제대로 활용하기 위해서는 Canvas 크기가 바뀔 때 마다 모든 속성을 초기화 해줘야 하는 문제가 있습니다.
즉, window 를 resize 하거나 특정 Layout 이 변경이 일어나서 Viewport 의 크기가 변경이 되면 translate, transformOrigin, scale 등을 다시 설정해줘야 하는것이죠.
몇가지 규칙을 찾으면 여기도 어렵지 않게 구현이 가능합니다.
- Viewport 의 width, height 가 바뀌는 것에 scale 은 영향이 없다.
- 새로운 Viewport 의 width ,height 기준으로 TransformOrigin 을 다시 설정해야한다.
- TransformOrigin 을 다시 설정하기 때문에 Translate 도 다시 설정해야한다.
- 이 부분이 제일 중요한데요. 크기가 변경되더라도 viewport 의 left, top 은 변함이 없다. 즉, 시작지점은 그대로고 bottom, right 영역만 바뀌는 것이죠.
코드를 보시면 기본 컨셉은 모두 비슷합니다. 4 번의 조건 때문에 구조를 맞출 수 있게 됩니다.
[oldTranslate] * [oldTranslateOrigin] * [scale] * [oldTranslateOrigin * -1] = [newTranslate?] * [newTransformOrigin] * [scale] * [newTransformOrigin * -1]
과거의 Viewport 의 left, top 과 새로운 Viewport 의 left, top 은 동일하기 때문에 위와 같은 식으로 만들어낼 수 있습니다.
Viewport 에서 Zooming 할 때 Origin 유지 하기
브라우저를 비롯한 panzoom 을 지원하는 모든 툴들은 특정 마우스 포인터 기준으로 zooming 을 할 때 해당 뷰가 유지가 됩니다. 마우스 위치 기준으로 크기가 변경이 되는 것이죠.
처음에는 이 개념이 그냥 마우스 포인터를 기준으로 TransformOrigin 을 주면 되는줄 알았습니다. 하지만 전혀 엉뚱한 기능이었습니다.
Viewport 에서 TransformOrigin 기준으로 보여준다고 계속 설명을 드렸는데요. 마우스 포인터를 TransformOrigin 으로 변경하게 되면 Viewport 의 중간점이 순식간에 변경이 되어 버려서 Zooming 이 진행되는 동안 점프 점프를 혼자 하게 됩니다. 즉, 전혀 엉뚱한 곳을 바라보고 있게 됩니다.
그래서 TransformOrigin 을 서서히 움직여서 보여줘야 합니다. 그래야 화면이 점프를 하지 않는데요. 제가 세운 규칙은 아래와 같습니다.
- zooming 을 시작하는 마우스 포인트를 등록한다.
- 마우스 포인트는 어떤 시점이든 현재의 transform origin 과의 거리가 있다.
- 확대를 하면 마우스 포인트와 transform origin 의 거리가 좁혀지고 축소를 하면 멀어진다.
3번의 규칙이 메인인데요. 마우스 포인트와 transform origin 의 거리에 따라 새로운 TransformOrigin 을 Viewport 에 설정해줍니다.
그렇게 되면 서서히 늘리고, 줄이고 하는게 가능합니다.
코드는 길지 않지만 상당히 복잡한 연산이 중복되어 있습니다.
먼저 zoomFactor 를 받게 되는데요. zoomFactor 는 이전 scale 과 현재 scale 의 비율입니다. 그 비율에 따라 최종 scale 값이 결정이 됩니다. 그리고 scale 값이 결정이 되면 Viewport 의 전체 Matrix 를 먼저 재구성을 해둡니다.
zoomFactor 가 1이면 이전과 이후가 변경이 없기 때문에 실행을 하지 않고, 1보다 크거나 작을 때만 실행을 합니다.
위에서 마우스 포인트와 transformOrigin 의 거리가 서서히 줄어든다고 했는데요. vec3.lerp() 함수를 사용해서 시작점(mouse 포인트) 와 끝점 (transformOrigin) 의 거리를 1/zoomFactor(scale 비율) 만큼 줄입니다.
그렇게 하면 mouse 포인트와 transformOrign 의 거리가 1이라고 쳤을 때 zoomFactor 가 줄어들면 축소가 되서 거리는 늘어나게 되고 zoomFactor 가 늘어나면 거리는 좁혀집니다.
마우스 포인터로 확대 축소를 정확하게 할 수 있게 되었습니다.
Selection 영역 만들기
Viewport 는 모든 좌표 연산에 영향을 주지는 않지만, 보는 관점의 좌표에는 영향을 줍니다. 화면에 어떤 것이 보인다 하면 모두다 Viewport 의 영향에 있다고 보시면 됩니다. 그 중에 자주 사용하는 기능이 Selection 영역을 만드는 기능입니다.
Viewport 의 transformOrigin 이 바뀌고 scale 이 바뀜에 따라 실제로 보여주는 Layer 의 크기,위치도 변하고 그에 따라 선택된 Selection 영역도 바뀝니다.
Selection 영역은 Layer 에 속해있지 않습니다. 만약에 Layer에 속해 있으면 Layer 가 커질때 Selection 영역은 매번 거로꾸로 연산을 해줘야 해서 복잡해집니다.
그래서 canvas-view 영역에 그려지지는 않지만 새로운 좌표를 그려야 하는 영역에 있습니다. 그렇다는 이야기는 Viewport 의 좌표연산으로 영역을 맞춰야 합니다.
하나의 Selection 영역은 총 8개 방향의 조절점을 가지고, 1개의 rotate 포인터를 가집니다. 즉, 총 9개의 좌표가 필요하죠.
this.$viewport.applyVerties(verties);
먼저 selection 된 Layer 에서 가지고 온 verties 를 viewport 에 적용하면 viewport 용 좌표가 나옵니다. 이 좌표는 viewport 에 맞는 좌표기 때문에 Layer 와 특별히 연결되어 있지는 않습니다.
verties 는 기본적으로 4개의 {top, left}, {top, right}, {bottom, right}, {bottom,left} 좌표를 가집니다. 8방향이 될려면 {top}, {right}, {bottom}, {left} 4 방향의 좌표가 더 필요합니다. 이건 앞서 구한 verties 로 새로 구할 수 있습니다.
vec3.lerp([], pointers[0], pointers[1], 0.5)
개별 영역의 중간지점의 Vector 를 구하면 되는데요. 여기서 vec3.lerp() 를 사용해서 특정 지점의 Vector 를 연산해줍니다.
이제 총 8개의 방향을 만들 수 있게 되었습니다. (회전 조절점은 [Layer 회전하기]에서 구현했기 때문에 생략하도록 하겠습니다.)
에디터마다 다르긴 하지만 현재 에디터는 사각형 형태로 조절점을 그리고 있습니다. (내부적으로는 div element 입니다.)
그런데 그냥 좌표에 그리면 결국 회전 방향이랑 맞지 않는 조절점이 됩니다.
그러다 보니 결국 조절점도 같이 같은 방향으로 rotate 를 해줘야 합니다.
const diff = vec3.subtract([], vec3.lerp([], pointers[0], pointers[1], 0.5), pointers[4]);const rotate = Length.deg(calculateAngle360(diff[0], diff[1]) + 90).round(1000);
회전 포인터를 구한 공식으로 중간 지점에서 회전 포인터까지의 거리를 구해서 실제 angle 을 구할 수 있도록 해줍니다. 그렇게 하면 Layer 와 상관없이 회전한 상태를 알 수 있습니다.
Group Selection
여러개의 Layer 를 동시에 Selection 하는건 기본적으로 Single Selection 과 비슷할 수 있는데요. 몇가지 제한이 있습니다.
- 부모(최초 윗세대) 와 자식을 동시에 Selection 할 수가 없습니다.
- 회전을 하게 되면 마지막 시점에 Selection 영역이 바뀝니다.
먼저 부모,자식간에 동시에 Selection 할 수 없는 이유는 크기와 회전때문에 그렇습니다. 부모의 크기가 바뀌거나 회전이 바뀌게 되면 자식의 상대 좌표도 바뀌게 되는데요. 이 시점에 자식도 부모랑 동일한 패턴으로 돌이가게 되면 사실은 2번 연달아 돌아가게 되어서 전혀 엉뚱한 값을 자식이 가지게 됩니다. (이건 제가 방법을 잘 못찾아서 그럴 수도 있어요.)
일단은 이렇게 해서 부모/자식 간에는 동시에 Selection 을 하지 않습니다.
그리고 Group Selection 을 회전하게 되면 Selection 경계가 마우스로 드래그 할때랑 아닐때랑 다릅니다.
이렇게 정한 것은 Single Selection 과 Group Selection 의 크기변경 기준점이 서로 다르기 때문에 그렇습니다. 일반적으로 Group Selection 이후에 회전을 하고 크기를 변경하게 되면 Rectangle 형태로 그대로 남아있는게 아니라 찌그러진 상태의 Rectangle 이 됩니다.
이지로직 스튜디오의 경우 CSS 기준의 레이어를 잡기 때문에 , top, left, width, height 가 그대로 존재하는 경우입니다. 즉, 어떻게 하던 찌그러 질 수가 없습니다. 그러다 보니 기존의 벡터 에디터랑 조금 다른 현상들이 생깁니다.
어떤 느낌인지 감이 오시나요?
왼쪽은 크기는 그대로 이지만 좌표가 영역 안에 들어오지 않고, 오른쪽은 좌표는 영역안에 들어왔는데 크기의 개념이 없어지고 단순히 선만 이어졌습니다. 즉, 원래대로 돌아 올 수가 없는 상태가 됩니다.
현재 에디터는 CSS 에디팅에 충실하기 위해서 이러한 점을 감수하고 갑니다. (내부적으로는 SVG 의 패스 에디팅을 포함 하고 있기 때문에 완전한 벡터의 사각형을 Figma 처럼 변형하는 것도 가능합니다.)
이렇게 Group 형태로 영역이 정해지면 기본적인 translate, scale, rotate 같은 행위들이 일어나야 합니다.
이 때 몇가지 규칙을 더 만들어야 하는데요.
- translate 할 때는 개별 부모 기준으로 Layer 이동하기를 구현해야합니다.
- scale 을 할 때는 Group 영역을 기준으로 변경된 크기를 비율 형태로 개별 Layer 에 다시 적용해야합니다. (이때 찌그러짐이 발생하죠)
- rotate 할 때는 transform origin 이 개별 Layer 가 아니고 Group 의 transform origin 으로 이루어져야 합니다.
그렇게 해서 Group 의 경우 다음과 같은 패턴으로 Matrix 를 연산하면 됩니다.
- 선택된 각 Layer 들의 matrix 구합니다.
- GroupLayer 의 소속으로 바꿉니다.
- GroupLayer 의 Transform(translate, scale, rotate) 을 적용합니다.
- GroupLayer 에 속한 개별 Layer 들의 Matrix 를 다시 구합니다.
- 개별 Layer 들의 부모를 기준으로 다시 소속을 바꿉니다.
과정이 좀 복잡하긴 하지만 이렇게 하면 CSS 에서도 그나마 Group Selection 에 대한 기능들을 만들어 낼 수 있습니다.
Collision(충돌)
사실 에디터를 만들면서 이런 것까지 할지는 몰랐는데요. Matrix 로 모든 좌표가 변경이 되고 회전을 한 상태에서도 정확한 Selection 을 해야하다보니 충돌상태를 구해야 했었습니다.
기본 충돌 모델에 대한 연산은 자료가 워낙 많으니 찾아 보시면 좋을 것 같습니다.
에디터에서 필요한 충돌 모델은 만의 하나 확장을 위해서 polyPoly 형태로 polygon 대 polygon 이 필요했습니다. 하지만 이것 하나만으로는 안되고 몇가지를 조합해야합니다.
예를 들어 충돌은 완전 포함관계를 알 수 없습니다.
이런 식으로 Selection 영역이 특정 Layer 를 완전히 포함하게 되면 polyPoly 말고도 polyPoint 도 같이 체크를 해서 Layer 가 가지고 있는 특정 vertex 가 selection 영역에 들어있는지도 같이 체크합니다. 이렇게 하면 Layer 가 전체 Selection 영역에 들어있다는 것을 을 쉽게 구현할 수 있습니다.
여기까지 에디터를 만들면서 알게된 몇가지 팁들을 알려드렸습니다. 앞으로도 좀 더 많은 팁들을 전달해 드릴 수 있으면 좋겠습니다.
그럼 내년(이 글은 1년에 한 번 적는 관계로)에 또 뵐게요 ~