2D 그래픽스 변환 행렬 치트시트
이 문서는 2D 그래픽스에서 쓰는 affine 변환 행렬을 기준으로 합니다.
이 행렬로 표현할 수 있는 변환은 다음입니다.
- 이동, translation
- 스케일, scale
- 회전, rotation
- 기울임, shear/skew
- 반사/뒤집기, reflection/flip
- 위 변환들의 조합
- 특정 점을 기준으로 한 회전/스케일
- 좌표계 변환, local ↔ world, screen ↔ local
단, 다음은 일반적인 2D affine 행렬의 범위를 벗어납니다.
- 원근 변환, perspective/projective transform
- 곡선형 왜곡, nonlinear warp
- 클리핑, 마스크, opacity, blur 같은 렌더링 효과
1. 기준 행렬 형태
이 문서는 열벡터 기준입니다.
점은 다음처럼 본다고 가정합니다.
p = [x, y, 1]^T
변환은 다음처럼 적용합니다.
p' = M * p
2D affine 행렬은 다음 형태입니다.
| a c tx |
| b d ty |
| 0 0 1 |
좌표 변환식은 다음과 같습니다.
x' = a*x + c*y + tx
y' = b*x + d*y + ty
2. 성분 의미
| 성분 | 의미 |
|---|---|
a, b, c, d |
선형변환 부분 |
tx, ty |
이동 부분 |
0, 0, 1 |
2D affine 동차좌표 고정 부분 |
a, b, c, d에는 다음 변환이 섞여 들어갈 수 있습니다.
- 스케일
- 회전
- 기울임
- 반사
따라서 일반적인 행렬에서는 a와 d만 보고 스케일이라고 단정하면 안 됩니다.
다만 다음처럼 b = 0, c = 0인 축 정렬 변환에서는:
| a 0 tx |
| 0 d ty |
| 0 0 1 |
a는 x 방향 스케일, d는 y 방향 스케일로 볼 수 있습니다.
3. TypeScript 기본 코드
type Point = { x: number; y: number };
type Mat2D = {
a: number; b: number;
c: number; d: number;
tx: number; ty: number;
};
const identity = (): Mat2D => ({
a: 1, b: 0,
c: 0, d: 1,
tx: 0, ty: 0,
});
const apply = (m: Mat2D, p: Point): Point => ({
x: m.a * p.x + m.c * p.y + m.tx,
y: m.b * p.x + m.d * p.y + m.ty,
});
apply()는 행렬을 점에 적용해서 변환된 점을 반환합니다.
apply(identity(), { x: 2, y: 3 });
// { x: 2, y: 3 }
4. 행렬 곱셈
열벡터 기준에서 multiply(A, B)는 다음을 의미합니다.
A * B
const multiply = (A: Mat2D, B: Mat2D): Mat2D => ({
a: A.a * B.a + A.c * B.b,
b: A.b * B.a + A.d * B.b,
c: A.a * B.c + A.c * B.d,
d: A.b * B.c + A.d * B.d,
tx: A.a * B.tx + A.c * B.ty + A.tx,
ty: A.b * B.tx + A.d * B.ty + A.ty,
});
열벡터 기준에서는 오른쪽 행렬이 먼저 적용됩니다.
M = T * R * S
이면 실제 적용 순서는 다음입니다.
1. S
2. R
3. T
즉, scale → rotate → translate 순서로 적용됩니다.
5. 변환 행렬 생성 함수
const translation = (tx: number, ty: number): Mat2D => ({
a: 1, b: 0,
c: 0, d: 1,
tx, ty,
});
const scaling = (sx: number, sy: number): Mat2D => ({
a: sx, b: 0,
c: 0, d: sy,
tx: 0, ty: 0,
});
const rotation = (rad: number): Mat2D => {
const cos = Math.cos(rad);
const sin = Math.sin(rad);
return {
a: cos, b: sin,
c: -sin, d: cos,
tx: 0, ty: 0,
};
};
const shearXMatrix = (k: number): Mat2D => ({
a: 1, b: 0,
c: k, d: 1,
tx: 0, ty: 0,
});
const shearYMatrix = (k: number): Mat2D => ({
a: 1, b: k,
c: 0, d: 1,
tx: 0, ty: 0,
});
6. 변환 누적 함수
호출 순서와 실제 적용 순서를 같게 만들려면, 새 변환을 기존 행렬의 왼쪽에 곱합니다.
const thenTranslate = (m: Mat2D, tx: number, ty: number): Mat2D =>
multiply(translation(tx, ty), m);
const thenScale = (m: Mat2D, sx: number, sy: number): Mat2D =>
multiply(scaling(sx, sy), m);
const thenRotate = (m: Mat2D, rad: number): Mat2D =>
multiply(rotation(rad), m);
const thenShearX = (m: Mat2D, k: number): Mat2D =>
multiply(shearXMatrix(k), m);
const thenShearY = (m: Mat2D, k: number): Mat2D =>
multiply(shearYMatrix(k), m);
사용 예시는 다음처럼 읽으면 됩니다.
let M = identity();
M = thenScale(M, 2, 1);
M = thenRotate(M, Math.PI / 2);
M = thenTranslate(M, 1, 2);
apply(M, { x: 2, y: 3 });
// approximately { x: -2, y: 6 }
실제 적용 순서도 코드와 같습니다.
1. x 2배, y 1배 스케일
2. 90도 회전
3. (1, 2) 이동
7. 이동 Translation
행렬
| 1 0 tx |
| 0 1 ty |
| 0 0 1 |
예시
const M = translation(5, 2);
apply(M, { x: 2, y: 3 });
// { x: 7, y: 5 }
계산:
x' = 1*2 + 0*3 + 5 = 7
y' = 0*2 + 1*3 + 2 = 5
조정 규칙
| 원하는 동작 | 조정 |
|---|---|
| 오른쪽 이동 | tx 증가 |
| 왼쪽 이동 | tx 감소 |
| 위/아래 이동 | 좌표계에 따라 ty 조정 |
수학 좌표계는 보통 y가 위로 증가합니다.
화면 좌표계는 보통 y가 아래로 증가합니다.
8. 스케일 Scale
행렬
| sx 0 0 |
| 0 sy 0 |
| 0 0 1 |
예시
const M = scaling(2, 0.5);
apply(M, { x: 2, y: 3 });
// { x: 4, y: 1.5 }
계산:
x' = 2*2 = 4
y' = 0.5*3 = 1.5
조정 규칙
회전/기울임이 없는 경우:
| 원하는 동작 | 조정 |
|---|---|
| x 방향 확대/축소 | a 조정 |
| y 방향 확대/축소 | d 조정 |
| x 방향 반전 | a를 음수로 설정 |
| y 방향 반전 | d를 음수로 설정 |
예:
const M: Mat2D = {
a: 3, b: 0,
c: 0, d: 2,
tx: 0, ty: 0,
};
apply(M, { x: 2, y: 3 });
// { x: 6, y: 6 }
9. 회전 Rotation
행렬
원점 기준 회전입니다.
수학 좌표계, 즉 y가 위로 증가하는 좌표계에서는 양의 각도가 반시계 방향입니다.
| cosθ -sinθ 0 |
| sinθ cosθ 0 |
| 0 0 1 |
즉:
a = cosθ
b = sinθ
c = -sinθ
d = cosθ
예시: 90도 회전
const M = rotation(Math.PI / 2);
apply(M, { x: 2, y: 3 });
// approximately { x: -3, y: 2 }
계산:
cos(90°) = 0
sin(90°) = 1
x' = 0*2 + (-1)*3 + 0 = -3
y' = 1*2 + 0*3 + 0 = 2
회전은 a, b, c, d가 함께 바뀝니다.
따라서 회전이 들어간 행렬에서는 a와 d만 보고 스케일이라고 하면 안 됩니다.
10. 화면 좌표계에서의 회전 방향
위 회전 행렬은 수학 좌표계, 즉 y가 위로 증가하는 좌표계를 기준으로 합니다.
많은 2D 그래픽스 환경에서는 화면 좌표계가 다음과 같습니다.
x: 오른쪽으로 증가
y: 아래쪽으로 증가
이 경우 양의 각도를 넣었을 때 화면상으로는 회전 방향이 반대로 보일 수 있습니다.
| 좌표계 | 양의 각도 |
|---|---|
| 수학 좌표계, y-up | 반시계 방향 |
| 화면 좌표계, y-down | 시각적으로 시계 방향처럼 보일 수 있음 |
11. X 방향 기울임 Shear X
행렬
| 1 k 0 |
| 0 1 0 |
| 0 0 1 |
변환식:
x' = x + k*y
y' = y
즉 y 값이 x에 섞입니다.
예시
const M = shearXMatrix(2);
apply(M, { x: 2, y: 3 });
// { x: 8, y: 3 }
계산:
x' = 2 + 2*3 = 8
y' = 3
조정 규칙
| 원하는 동작 | 조정 |
|---|---|
| y 값에 따라 x를 밀기 | c 조정 |
12. Y 방향 기울임 Shear Y
행렬
| 1 0 0 |
| k 1 0 |
| 0 0 1 |
변환식:
x' = x
y' = k*x + y
즉 x 값이 y에 섞입니다.
예시
const M = shearYMatrix(0.5);
apply(M, { x: 2, y: 3 });
// { x: 2, y: 4 }
계산:
x' = 2
y' = 0.5*2 + 3 = 4
조정 규칙
| 원하는 동작 | 조정 |
|---|---|
| x 값에 따라 y를 밀기 | b 조정 |
13. 반사 / 뒤집기 Reflection / Flip
좌우 반전
y축 기준으로 좌우 반전합니다.
| -1 0 0 |
| 0 1 0 |
| 0 0 1 |
const flipX = (): Mat2D => ({
a: -1, b: 0,
c: 0, d: 1,
tx: 0, ty: 0,
});
apply(flipX(), { x: 2, y: 3 });
// { x: -2, y: 3 }
상하 반전
x축 기준으로 상하 반전합니다.
| 1 0 0 |
| 0 -1 0 |
| 0 0 1 |
const flipY = (): Mat2D => ({
a: 1, b: 0,
c: 0, d: -1,
tx: 0, ty: 0,
});
apply(flipY(), { x: 2, y: 3 });
// { x: 2, y: -3 }
14. 특정 점을 기준으로 회전
원점이 아니라 (cx, cy)를 기준으로 회전하려면 다음 순서로 합성합니다.
M = T(cx, cy) * R(rad) * T(-cx, -cy)
실제 적용 순서:
1. 기준점을 원점으로 옮김
2. 회전
3. 다시 원래 위치로 이동
const rotateAround = (
cx: number,
cy: number,
rad: number
): Mat2D =>
multiply(
translation(cx, cy),
multiply(rotation(rad), translation(-cx, -cy))
);
const thenRotateAround = (
m: Mat2D,
cx: number,
cy: number,
rad: number
): Mat2D =>
multiply(rotateAround(cx, cy, rad), m);
예시
let M = identity();
M = thenRotateAround(M, 10, 5, Math.PI / 2);
apply(M, { x: 12, y: 5 });
// approximately { x: 10, y: 7 }
15. 특정 점을 기준으로 스케일
원점이 아니라 (cx, cy)를 기준으로 스케일하려면 다음 순서로 합성합니다.
M = T(cx, cy) * S(sx, sy) * T(-cx, -cy)
실제 적용 순서:
1. 기준점을 원점으로 옮김
2. 스케일
3. 다시 원래 위치로 이동
const scaleAround = (
cx: number,
cy: number,
sx: number,
sy: number
): Mat2D =>
multiply(
translation(cx, cy),
multiply(scaling(sx, sy), translation(-cx, -cy))
);
const thenScaleAround = (
m: Mat2D,
cx: number,
cy: number,
sx: number,
sy: number
): Mat2D =>
multiply(scaleAround(cx, cy, sx, sy), m);
예시
let M = identity();
M = thenScaleAround(M, 10, 5, 2, 3);
apply(M, { x: 12, y: 6 });
// { x: 14, y: 8 }
설명:
중심점: (10, 5)
원래 점: (12, 6)
중심에서 원래 점까지의 차이: (2, 1)
x 2배, y 3배 스케일:
(2, 1) -> (4, 3)
중심에 다시 더함:
(10, 5) + (4, 3) = (14, 8)
16. 임의의 2D affine 변환
다음 행렬 하나로 2D affine 변환을 모두 표현할 수 있습니다.
| a c tx |
| b d ty |
| 0 0 1 |
표현 가능한 조합:
- 이동
- 균일 스케일
- 비균일 스케일
- 회전
- shear
- flip
- 회전 + 스케일
- 회전 + shear
- flip + 이동
- 중심점 기준 변환
- local 좌표계에서 world 좌표계로의 변환
예:
const M: Mat2D = {
a: 0, b: 2,
c: -3, d: 0,
tx: 10, ty: 20,
};
apply(M, { x: 2, y: 3 });
// { x: 1, y: 24 }
계산:
x' = 0*2 + (-3)*3 + 10 = 1
y' = 2*2 + 0*3 + 20 = 24
17. 기존 행렬에서 값 읽기
이동 읽기
const getTranslation = (m: Mat2D) => ({
tx: m.tx,
ty: m.ty,
});
tx, ty는 항상 이동 성분입니다.
스케일 크기 읽기
회전 + 스케일 조합이고 shear가 없다는 전제에서는 다음처럼 읽을 수 있습니다.
sx = sqrt(a*a + b*b)
sy = sqrt(c*c + d*d)
const getScale = (m: Mat2D) => ({
sx: Math.hypot(m.a, m.b),
sy: Math.hypot(m.c, m.d),
});
예시:
getScale({
a: 0, b: 2,
c: -3, d: 0,
tx: 0, ty: 0,
});
// { sx: 2, sy: 3 }
단, shear가 섞여 있으면 이 값은 단순한 직관적 스케일 크기일 뿐, 완전한 행렬 분해는 아닙니다.
회전 각도 읽기
shear가 없고 flip이 없다는 전제에서는 다음처럼 읽을 수 있습니다.
const getRotation = (m: Mat2D) =>
Math.atan2(m.b, m.a);
반환값은 radian입니다.
determinant 읽기
det = a*d - b*c
const determinant = (m: Mat2D) =>
m.a * m.d - m.b * m.c;
의미는 다음과 같습니다.
| 값 | 의미 |
|---|---|
det > 0 |
방향 유지 |
det < 0 |
반사/뒤집힘 포함 |
det = 0 |
한 축 이상이 붕괴된 비가역 변환 |
abs(det) |
면적 스케일 비율 |
예를 들어 x 2배, y 3배 스케일이면 determinant는 6입니다.
면적이 6배가 됩니다.
18. 역행렬 Inverse Transform
역행렬은 변환을 되돌릴 때 씁니다.
대표적인 사용처:
- screen 좌표를 local 좌표로 변환
- 마우스 좌표가 오브젝트 안에 있는지 검사
- world 좌표를 object 좌표로 되돌리기
const invert = (m: Mat2D): Mat2D => {
const det = m.a * m.d - m.b * m.c;
if (Math.abs(det) < 1e-12) {
throw new Error("Matrix is not invertible");
}
return {
a: m.d / det,
b: -m.b / det,
c: -m.c / det,
d: m.a / det,
tx: (m.c * m.ty - m.d * m.tx) / det,
ty: (m.b * m.tx - m.a * m.ty) / det,
};
};
예시:
const M = translation(10, 20);
const inv = invert(M);
const world = apply(M, { x: 2, y: 3 });
// { x: 12, y: 23 }
const local = apply(inv, world);
// { x: 2, y: 3 }
19. 사각형 변환
회전이나 shear가 들어가면 사각형의 x, y, width, height만 직접 변환하면 안 됩니다.
네 꼭짓점을 변환한 뒤 다시 bounds를 구해야 합니다.
type Rect = { x: number; y: number; w: number; h: number };
const transformRect = (m: Mat2D, r: Rect): Rect => {
const points = [
apply(m, { x: r.x, y: r.y }),
apply(m, { x: r.x + r.w, y: r.y }),
apply(m, { x: r.x, y: r.y + r.h }),
apply(m, { x: r.x + r.w, y: r.y + r.h }),
];
const xs = points.map(p => p.x);
const ys = points.map(p => p.y);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
return {
x: minX,
y: minY,
w: maxX - minX,
h: maxY - minY,
};
};
20. 빠른 판별 코드
부동소수점 오차를 고려해서 === 0보다는 epsilon 비교를 쓰는 편이 안전합니다.
const nearlyZero = (v: number, eps = 1e-9) =>
Math.abs(v) < eps;
const isAxisAligned = (m: Mat2D) =>
nearlyZero(m.b) && nearlyZero(m.c);
const hasTranslation = (m: Mat2D) =>
!nearlyZero(m.tx) || !nearlyZero(m.ty);
const hasFlip = (m: Mat2D) =>
determinant(m) < 0;
const isSingular = (m: Mat2D) =>
nearlyZero(determinant(m));
판별 기준:
| 조건 | 해석 |
|---|---|
tx, ty만 변함 |
이동 |
b = 0, c = 0 |
x/y 축이 서로 섞이지 않음 |
b = 0, c = 0, a > 0, d > 0 |
축 정렬 스케일 |
det < 0 |
반사/뒤집힘 포함 |
det = 0 |
비가역, 한 축 이상 붕괴 |
b 또는 c가 0이 아님 |
회전 또는 shear 가능성 |
21. 행벡터 시스템과의 관계
이 문서는 열벡터 기준입니다.
열벡터 기준 행렬:
| a c tx |
| b d ty |
| 0 0 1 |
적용 방식:
p' = M * p
행벡터 기준에서는 점을 다음처럼 둡니다.
p = [x, y, 1]
그리고 보통 행렬은 다음처럼 전치된 형태로 씁니다.
| a b 0 |
| c d 0 |
| tx ty 1 |
적용 방식:
p' = p * M
이 경우에도 좌표 변환식은 동일하게 볼 수 있습니다.
x' = a*x + c*y + tx
y' = b*x + d*y + ty
다만 행렬의 배치와 합성 순서가 달라지므로, 문서나 엔진에서 어떤 규약을 쓰는지 반드시 확인해야 합니다.
22. 요약
행렬 형태
| a c tx |
| b d ty |
| 0 0 1 |
변환식
x' = a*x + c*y + tx
y' = b*x + d*y + ty
핵심 규칙
| 하고 싶은 일 | 방법 |
|---|---|
| 이동 | tx, ty 조정 |
| x/y 축 정렬 스케일 | a, d 조정 |
| 회전 | a,b,c,d를 cos, sin으로 함께 설정 |
| x 방향 shear | c 조정 |
| y 방향 shear | b 조정 |
| 좌우 반전 | a를 음수로 설정 |
| 상하 반전 | d를 음수로 설정 |
| 특정 점 기준 회전 | T(center) * R * T(-center) |
| 특정 점 기준 스케일 | T(center) * S * T(-center) |
| 변환 되돌리기 | inverse matrix 사용 |
| 사각형 변환 | 네 꼭짓점 변환 후 bounds 계산 |
가장 중요한 결론
단순 스케일 행렬에서는 a와 d가 스케일이다.
하지만 회전이나 기울임이 섞이면
a, b, c, d 전체가 하나의 선형변환 성분이다.
실무 코드에서는 다음 구조가 가장 다루기 좋습니다.
let M = identity();
M = thenScale(M, 2, 1);
M = thenRotate(M, Math.PI / 2);
M = thenTranslate(M, 1, 2);
const p = apply(M, { x: 2, y: 3 });
// approximately { x: -2, y: 6 }