본문 바로가기
카테고리 없음

(포트폴리오)Sprite Generator - 두번째, SpriteArea 및 MaxRects 알고리즘 구현

by 흥부와놀자 2023. 10. 17.
목차
첫번째, 개요 및 구성
두번째, SpriteArea 및 MaxRects 알고리즘 구현

 

지난 시간에 이어, 실제 이미지가 보여지는 SpriteArea와 MaxRects 알고리즘을 어떻게 구현했는지 알아보겠습니다.


동작 과정

개발사항

Layout

이미지가 많아져서 캔버스 크기를 넘어가더라도 스크롤링하여 처리했기 떄문에 SpriteArea 레이아웃 자체는 고정되게 처리하였습니다. 

이렇게 함으로써 사용자는 SpriteArea에서 나중에 다운로드할 이미지의 본래 크기 자체를 제한없이 확인할 수 있습니다.

또한 이미지를 다운 받았을 때, 캔버스의 여백부분은 투명하게 처리되야 하므로 초기 캔버스 배경은 transparent 처리하였습니다.

 

캔버스 렌더링과 이미지 로딩

드래그앤드롭 Area에 올라간 이미지들은 Jotai의 Atom에 Javascript File형태로 관리됩니다. 만약 이미지가 추가되거나 삭제되어 해당 file Atom이 바뀔때마다 캔버스를 다시 그려주게 됩니다. 

 

이렇게 결정했던건 현재 캔버스 집적률을 위해 이미지 크기에 따라 정렬시키는데, 이로인해 어차피 이미지 배치 순서가 빈번히 바뀌어 버리기 때문입니다.

 

이후에 fileAtom 데이터는 createObjectURL을 통해 blobUrl로 변환하여 화면에 로딩시킵니다. 해당 SpriteArea의 용도 자체가 Sprite 미리보기 목적이므로 그때그때 파싱이 필요한 Base64대신 메모리의 데이터를 그대로 불러오는 blobUrl을 사용하였습니다.

const BlobUrl = URL.createObjectURL(files[i]);
const img = await LoadImage(BlobUrl, files[i].name, files[i].id);

이미지 로딩의 경우 onload 될때 resolve 시키는 Promise를 리턴시키는 LoadImage함수를 따로 만들어 추후에 이미지 로딩 처리가 필요할때 개선할 수 있게 하였습니다.

export function LoadImage(
  src: string,
  name: string,
  id: string,
): Promise<HTMLImageElement> {
  // image onLoad 동기화 기능
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.onload = () => {
      resolve(img);
    };
    img.src = src;
    img.id = id;
    img.title = name;
  });
}

이후, 이미지들의 캔버스 집적률을 높이기 위해 각 이미지의 가로와 세로를 더한 값을 기준으로 정렬하여 MaxRects 알고리즘으로 이미지를 배치시킵니다.

 

정렬된 이미지 배치 (MaxRects)

MaxRects 배치 알고리즘은 해당 블로그(https://tibyte.kr/240)를 참고하여 구현하였습니다.

1. LinkedList와 Rects 세팅
각각의 이미지들이 들어갈 영역은 Rect 자료구조로 변환됩니다. 

export interface Rect {
  x: number;
  y: number;
  w: number;
  h: number;
}

x,y 좌표는 해당 영역의 좌측상위의 좌표가 되고 w는 너비, h는 높이값을 나타냅니다.

캔버스 안에서 모든 영역들은 이 4가지 속성으로 어디에 배치될지 계산됩니다.

 

그리고 이러한 Rect들은 직접 구현한 LinkedRectList 자료구조에 저장됩니다. 해당 자료구조의 각 노드엔 Rect데이터를 갖고 있고, 

getContainer / remove / append / foreach 메소드를 구현하였습니다.

getContainer 메소드는 파라미터로 condition 함수를 받아서 원하는 노드(개발시엔 컨테이너라고 명명하였음)를 리턴합니다. 

remove 메소드는 파라미터로 넘긴 Rect와 같은 값을 갖는 노드들을 전부 삭제시킵니다.

append 메소드는 (0,0) 좌표와 가장 거리가 가까운 순으로 추가해줍니다. sqrt(x^2 + y^2)으로 계산해줍니다.

const itemDistance = Math.sqrt(Math.pow(item.x, 2) + Math.pow(item.y, 2));

foreach 메소드는 callback함수를 넘겨 모든 노드를 순환하며 callback함수를 호출해줍니다.

이렇게 linkedRectList가 준비되었으면 현재 캔버스 전체영역을 초기값으로 append해줍니다. 

 

2. 배치 할 수 있는 영역 확인

linkedRectList의 getCondition 메소드를 써서 리스트(linkedRectList를 지칭)의 앞에서 부터 넣을 이미지의 영역을 완전히 포함할수 있는 영역이 있는가를 체크하고 있으면 그것을, 없으면 리스트의 젤 첫번째 영역을 선택합니다. 

// target 영역을 포함할 수 있는 영역 찾기
let container = linkedList.getContainer(target, getContainerCondition);
// 없으면 첫번째 영역 가져오기
container = linkedList.getFirst();

처음엔 당연히 초기에 넣은 전체 캔버스 영역을 가져오게 됩니다.

하지만 만약에 추가하려는 이미지가 전체 캔버스 영역 보다 커지면 어떻게 될까요?

위의 빨간 부분과 같이 추가적으로 SpriteArea내부의 캔버스 크기를 늘려줘야 합니다. 물론 늘려진 부분은 따로 스크롤 하지 않으면 가려져 있습니다.  만약 이미지의 세로는 캔버스 영역안에 있지만 가로만 크다면? 캔버스의 가로만 늘려줍니다. 세로도 마찬가지 입니다.

 

이때 캔버스는 width와 height값이 변하면 초기화 되는 특성이 있으므로 기존에 그려진 이미지 데이터를 따로 버퍼에 유지시켜 준 후 캔버스 사이즈가 조절되면 버퍼에 유지한 이미지데이터를 덮어씌워줍니다.
newCanvasCtx.putImageData(prevCtxImageData, 0, 0);​

그리고 캔버스의 기본 레이아웃이 가로가 세로보다 더 긴 형태이기 때문에, 배치될 이미지의 가로가 세로보다 더 크다면 

이렇게 하단 영역에 추가가 되어야 하고,(빨간 영역이 새로운 이미지를 위치시킬 영역이 됩니다.) 세로가 가로보다 더 크다면 

이렇게 우측에 추가가 되어야 할겁니다.

 

만약 캔버스 크기를 안넘는다고 해도 List에 들어있는 공간들중에서 현재 넣을 이미지의 공간을 포함할 수 있는 공간이 없을 수 있습니다.

그럴때도 일단 List의 첫번째 공간을 가져옵니다.

 

 

3. 선택한 영역에서 이미지가 배치될 부분 외의 영역 분할하기

또한 MaxRects 알고리즘은 하나의 영역에 이미지를 위치시킨 뒤 곂치는 영역을 아래의 같이 이미지 외의 영역을 분할해 줍니다.

MaxRects를 참고했던 블로그의 순서와 조금 다른데, 해당 블로그를 참고하여 제 식에 맞게 수정하였습니다.

아래의 코드는 이미지를 분할하는 DivideSpace함수입니다. 

function DivideSpace(
  linkedListRef: LinkedRectList,
  target: Rect,
  container: Rect,
) {
  // 파라미터로 받은 target 을 container 의 기준으로 영역 나눠서 링크드 리스트에 추기하기.
  // 오른쪽 부터 시계방향으로 linkedList 에 추가
  const right: Rect = {
    x: target.x + target.w,
    y: container.y,
    w: container.x + container.w - (target.x + target.w),
    h: container.h,
  };
  const bottom: Rect = {
    x: container.x,
    y: target.y + target.h,
    w: container.w,
    h: container.y + container.h - (target.y + target.h),
  };
  const left: Rect = {
    x: container.x,
    y: container.y,
    w: target.x - container.x,
    h: container.h,
  };
  const top: Rect = {
    x: container.x,
    y: container.y,
    w: container.w,
    h: target.y - container.y,
  };
  if (right.w > 0 && right.h > 0) {
    linkedListRef.append(right);
  }
  if (bottom.w > 0 && bottom.h > 0) {
    linkedListRef.append(bottom);
  }
  if (left.w > 0 && left.h > 0) {
    linkedListRef.append(left);
  }
  if (top.w > 0 && top.h > 0) {
    linkedListRef.append(top);
  }
}

여기서 target이 현재 추가할 이미지 영역이고 container는 이전단계에서 고른 이미지를 위치시킬 영역입니다. 컨테이너내에서 이미지의 영역을 제외한 상하좌우 공간을 나누어 줍니다. 

 

만약 이미지 사이즈가 캔버스 보다 커서 캔버스 크기 자체가 커진다면 추가된 영역도 위와 같이 나눠준 후 각 Rect들을 List에 append 해줍니다.  

 

4. 실제 이미지를 캔버스에 그리고 위치시킨 컨테이너 제거하기

 

// 이미지 그리기
ctx.drawImage(images[i], container.x, container.y, target.w, target.h);
// 위치시킨 container 제거
linkedList.remove(container);

영역이 분할된 뒤에 캔버스에 실제 이미지를 그리고 이미지를 배치시키기 위해 선택했던 상위 영역을 List에서 제거해줍니다. 

상위 영역에서 이미 이미지가 들어갈 위치외의 영역들은 다 분할되어 List에 추가되었기 때문에 제거해도 상관없습니다.

 

5. 그려진 이미지와 곂치는 영역이 있는지 확인 후 추가 분할

아까 2번에서 완전히 포함하는 영역이 없을때 그냥 List의 첫번째 컨테이너를 가지고 왔을겁니다.

이러한 경우 아래와 같이 곂치는 영역에 대해서 추가 분할을 해야 합니다.

참고(https://tibyte.kr/244)

곂치는 영역에 대한 구현(isIntersection)은 참고한 블로그에서 찾아서 사용했습니다. (https://tibyte.kr/240)

function isIntersection(target1: Rect, target2: Rect) {
  // 두 영역이 곂치는지 확인 후 곂치지 않으면 null, 곂치면 곂치는 영역 리턴
  if (target1.x >= target2.x + target2.w) {
    return null;
  }
  if (target2.x >= target1.x + target1.w) {
    return null;
  }
  if (target1.y >= target2.y + target2.h) {
    return null;
  }
  if (target2.y >= target1.y + target1.h) {
    return null;
  }
  const rect: Rect = {
    x: Math.max(target1.x, target2.x),
    y: Math.max(target1.y, target2.y),
    w: Math.min(target1.x + target1.w, target2.x + target2.w),
    h: Math.min(target1.y + target1.h, target2.y + target2.h),
  };
  return rect;
}
linkedList.foreach((cur, idx) => {
  const _container = cur.item;
  const intersection = isIntersection(_container, target);
  if (intersection) {
    // 곂치는 container 에 대해서 다시 영역 나누기
    DivideSpace(linkedList, intersection, _container);
    // 삭제할 LinkedList 컨테이너 Idx 담기
    linkedList.remove(_container);
  }
});

List를 순회하며 곂치는 부분을 분할하고 분할된 상위 컨테이너는 삭제시켜 줍니다.

위와 같이 추가 분할이 된다면 야채 사진의 원래 하단 컨테이너가 사라지고 해당 컨테이너의 상하좌우로 또 다시 분할됩니다.

 

추가적으로 개선할 점들 입니다.

1. SpriteArea의 배경 설정 
- 현재 단순 하얀색으로 되어있기 때문에 만약 하얀색 이미지가 들어가면 구분하기 힘들어집니다.
2. 이미지 사이의 패딩값 설정
- 이미지들의 구분을 더 잘 명시하기 위해 패딩값을 설정할 필요가 있습니다.
3. 이미지 로딩 UI 추가
- 큰 사이즈 이미지의 로딩시에 로딩바 UI 추가
3. 전체 UI 디자인 수정
- 현재의 밋밋한 디자인을 수정하고 좀 더 괜찮은 사용자 경험을 향상 시킬 필요가 있습니다.

 

개발 후기

Sprite 생성으로 주제를 정한 이유는 이왕이면 누구나 쓸수 있는 도구를 만들고 싶었고, 또한 이미지를 쌓는 알고리즘 구현을 시도해 보고 싶은 생각이 있어서 였습니다.

 

참고했던 블로그의 구현과 결과적으론 좀 달라졌고 개선할점도 많이 남았지만 누구에게도 구애받지 않고 온전히 재밌게 만들어 볼 수 있었던것 같습니다. 앞으로도 꾸준히 유지보수하면서 나중엔 제대로된 저만의 오픈소스를 만들어 보고 싶습니다.  

 


 

* 배포버전 보러가기

https://sprite.ohwonjae.site/

 

SpriteGenerator

 

sprite.ohwonjae.site

 

* Github

https://github.com/OhWonjae/SpriteGenerator

 

* Reference

구글링 하면 처음으로 나오는 스프라이트 이미지 제작 사이트를 참고하여 만들었습니다.

(https://www.toptal.com/developers/css/sprite-generator)