동기
그림자 만들다가 캐릭터 SelfShadow Pass는 화면에 보이는 부분만 그릴 필요가 있다고 판단하여 관련 알고리즘을 찾아서 정리해보기로 한다.
가장 쉽게 생각할 수 있는 건 Bounds의 가장의 8개점이 각각 카메라 프러스텀 외부에 있는지 판정 후 카메라 프러스텀 안쪽에 밀어넣는 것이다. 그리고 이게 Self Shadow에 한해서는 정답인 것 같고.
이러면 문제는 쉬워진다. Bounds(AABB)의 Corner에 해당하는 world position은 bounds의 center에서 extents를 빼거나 더하는 총 8개의 경우의 수를 이용해 얻을 수 있다.
8개 정점과 View / Projection Matrix를 이용한 알고리즘
요즘은 이런 거 AI 딸깍하면 나오긴 하는데, 항상 그렇지만 난 딸깍으로 나온 결과가 '왜' 나왔는지에 포커싱을 한다. 일단 어떤 점이든 view projection matrix통해 변환된 ndc 공간, clip 공간을 기준으로 일정 범위 값을 넘기면 카메라 외부에 있다고 판단할 수 있다.
ndc공간을 기준으로 한다면, xy는 -1~1사이 값, z값은 플랫폼마다 다른데, opengl은 -1~1, 나머지 vulkan, directx, metal은 0~1사이 값을 z값 범위로 잡는다.
https://docs.unity3d.com/kr/2023.2/ScriptReference/Camera-worldToCameraMatrix.html
Camera-worldToCameraMatrix - Unity 스크립팅 API
Matrix that transforms from world to camera space.
docs.unity3d.com
하지만 일단 unity에서 camera.worldToCameraMatrix(View), camera.projectionMatrix는 모두 openGL 규격을 따른다.
그러므로 cpu에서 계산한 값이 gpu에서 동일하게 쓰일 거라는 기대를 하지 않는 게 좋다.
일단 이번 문제를 해결할 때는 CPU 정보만 가지고 처리해도 무방하기 때문에 GPU 쪽 대응은 신경쓸 필요 없다.
그래도 이참에 view, projection matrix는 알아두는 게 좋겠다.
View Matrix
- 음의 Z축을 Forward로 쓴다
- 월드 공간 상의 position에 view matrix를 곱해 변환했을 경우, 기존에 카메라 앞에 있는 position 값은 -7.4322... 이런 식으로 z값이 음수가 된 채로 들어간다.
Projection Matrix
- Y Flip
- 기본적으로 openGL, 유니티 모두 좌하단을 0,0으로 하는 좌표계를 쓰지만 DirectX 등 언리얼 스타일로 좌상단부터 시작하는 uv 좌표계를 쓰는 플랫폼에 맞춰서 y가 반전되도록 처리할 필요가 있다.
- 텍스처 배열 시작점을 상단부터 처리할 것인지, 하단부터 처리할 것인지를 생각하면 된다. cpu 코드에서 배열을 만든 후 배열에 값을 넣을 때 y * width + x 이런 식으로 배열 인덱싱을 하는데, 이렇게 값이 들어간 텍스처를 unity에서는 좌하단부터 채워진 것으로 처리한다는 뜻이다.
- Z Range
- open GL 스타일은 near = -1, far = 1이 되는 [-1, 1] 범위로 Z값을 매핑한다. 하지만 openGL 빼고는 다른 녀석들은 다 [0,1] 값을 사용한다.
- Reversed Z
- 깊이 정밀도를 더 높게 쓰기 위해 Z버퍼를 0~1로 증가하는 값이 아닌 1~0으로 역으로 가장가까울 때 흰색으로 처리하는 방식을 쓰는 경우가 있다. 이를 Projection Matrix에서 대응해줄 수 있다.
셰이더에서 사용할 때
var V = camera.worldToCameraMatrix;
var P = GL.GetGPUProjectionMatrix(camera.projectionMatrix, /*renderIntoTexture=*/false);
var VP = P * V;
https://docs.unity3d.com/6000.2/Documentation/Manual/SL-PlatformDifferences.html
다만 Projection Matrix를 직접 생성하여 사용하는 경우, 위 링크에서 설명하는대로 Projection Matrix의 reversedZ 반전을 직접 호출해서 처리해주어야 하는 경우가 있다.
// View Matrix에서 Z축 반전하거나, Projection Matrix를 Z축 반전시킨다.
if (SystemInfo.usesReversedZBuffer) {
M.m20 = -M.m20; M.m21 = -M.m21; M.m22 = -M.m22; M.m23 = -M.m23;
}
CPU에서 수학만 돌릴 때
var V = camera.worldToCameraMatrix;
var P = camera.projectionMatrix;
var VP = P * V;
아무튼, 결론적으로 Bounds를 카메라 프러스텀 내부에 맞춰서 자른다는 이번 사양에 맞춘다면 다음과 같이 단순하게 계산하여 처리할 수 있다.
using UnityEngine;
public class BoundsClippingCamera : MonoBehaviour
{
public Camera camera;
public BoxCollider box;
private void OnDrawGizmos()
{
if (camera == null || box == null) return;
var worldCorners = GetBoxColliderWorldCorners(box);
Bounds originalBounds = GetBoundsFromPoints(worldCorners);
Gizmos.color = new Color(1f, 0.6f, 0f, 1f); // 오렌지
DrawBoundsWireCube(originalBounds);
Matrix4x4 V = camera.worldToCameraMatrix;
Matrix4x4 P = camera.projectionMatrix;
Matrix4x4 VP = P * V;
Matrix4x4 invVP = VP.inverse;
Vector3[] clippedWorldPts = new Vector3[8];
int outSide = 0;
for (int i = 0; i < 8; i++)
{
Vector4 wpos = new Vector4(worldCorners[i].x, worldCorners[i].y, worldCorners[i].z, 1f);
Vector4 clip = VP * wpos;
if (clip.w <= 0 || clip.x < -clip.w || clip.x > clip.w || clip.y < -clip.w || clip.y > clip.w ||
clip.z < -clip.w || clip.z > clip.w) outSide++;
clip.x = Mathf.Clamp(clip.x, -clip.w, clip.w);
clip.y = Mathf.Clamp(clip.y, -clip.w, clip.w);
clip.z = Mathf.Clamp(clip.z, -clip.w, clip.w);
Vector4 wClamped = invVP * clip;
wClamped /= wClamped.w;
clippedWorldPts[i] = new Vector3(wClamped.x, wClamped.y, wClamped.z);
Gizmos.DrawSphere(clippedWorldPts[i], 0.05f);
}
Bounds clippedBounds = GetBoundsFromPoints(clippedWorldPts);
// 모든 점이 Frustum 외부에 있음 - 결과 에러
if (outSide == 8)
{
Gizmos.color = Color.red;
}
else
{
Gizmos.color = Color.cyan;
}
DrawBoundsWireCube(clippedBounds);
}
private static Vector3[] GetBoxColliderWorldCorners(BoxCollider bc)
{
Vector3 c = bc.center;
Vector3 e = bc.size * 0.5f;
Vector3[] local = new Vector3[8];
int idx = 0;
for (int xi = -1; xi <= 1; xi += 2)
for (int yi = -1; yi <= 1; yi += 2)
for (int zi = -1; zi <= 1; zi += 2)
{
local[idx++] = new Vector3(
c.x + xi * e.x,
c.y + yi * e.y,
c.z + zi * e.z
);
}
Vector3[] world = new Vector3[8];
for (int i = 0; i < 8; i++)
world[i] = bc.transform.TransformPoint(local[i]);
return world;
}
private static Bounds GetBoundsFromPoints(Vector3[] pts)
{
if (pts == null || pts.Length == 0)
return new Bounds(Vector3.zero, Vector3.zero);
Vector3 min = pts[0];
Vector3 max = pts[0];
for (int i = 1; i < pts.Length; i++)
{
min = Vector3.Min(min, pts[i]);
max = Vector3.Max(max, pts[i]);
}
Bounds b = new Bounds();
b.SetMinMax(min, max);
return b;
}
private static void DrawBoundsWireCube(Bounds b)
{
Gizmos.matrix = Matrix4x4.identity;
Gizmos.DrawWireCube(b.center, b.size);
}
}
8개점을 통한 계산 방식의 한계
다음 그림을 보자.

단순하게 Bounds를 대표할 8개 점을 가지고 계산했을 때 결과인데, 내가 기대했던 것과 결과가 차이가 난다. 박스 메쉬의 Bounds보다 오히려 커진 결과물이라니?
불완전한 Frustum Culling
이 8개 점을 기준으로 clip space를 통해 계산하는 방식의 단점은 8개 점이 모두 카메라 프러스텀 외부에 존재하는 경우 해당 물체가 프러스텀 내부에 있는지 외부에 있는지 알 방법이 없다.

따라서 정확한 Frustum Culling은 카메라의 프러스텀을 구성하는 6개의 수학적 Plane과 AABB 사이의 접점을 계산하는 공식으로 계산해야 한다.
GeometryUtility.TestPlaneAABB 함수가 바로 이러한 알고리즘으로 컬링 계산을 수행한다.
https://gdbooks.gitbooks.io/3dcollisions/content/Chapter2/static_aabb_plane.html
AABB-Plane · 3DCollisions
gdbooks.gitbooks.io
음.. 대략 이런 방식으로 하지 않을까? Frustum을 대표하는 6개 Plane 모두와 AABB가 교차하지 않는다면 제외시켜라와 같은 방식일 것 같다.
아무튼, 지금 컬링을 하는 건 아니니 제껴두고.

8개 Corner 정점을 가지고 단순 클리핑을 해버리면 원래 AABB 범위보다 큰 영역을 감지하게 되는 문제가 있다.
원래 원하는 결과는 다음 느낌이 아닌가?

클리핑한 점이 정말로 AABB 내부에 있는지 체크해서 제외해도, 아직 Bounds를 재구성하기엔 정보가 부족하다.
결국 정확한 Bounds를 얻기 위해서는 정점 뿐만 아니라 AABB의 Edge(선분) 요소까지 고려해서 계산해야 한다는 결론을 얻을 수 있다.
따라서 다음과 같이 알고리즘을 설계했다.
- AABB의 Corner 8개를 ndc 공간 기준 클리핑한 후, AABB 내부에 있을 경우 point 추가
- AABB를 구성하는 Edge와 Frustum Plane 간의 교차점을 찾고, 교차점이 Frustum 내부인 경우 point 추가
using UnityEngine;
using System.Collections.Generic;
public class BoundsClippingCamera : MonoBehaviour
{
public Camera camera;
public BoxCollider box;
[Header("Visualization")]
public bool showOriginalBounds = true;
public bool showClippedBounds = true;
public bool showIntersectionPoints = true;
public bool showAABBCornersInsideFrustum = true;
public bool showAABBCornersClipped = true;
public bool showEdgePlaneIntersections = true;
public bool showFrustumCornersInsideAABB = true;
[Header("Colors")]
public Color originalBoundsColor = new Color(1f, 0.6f, 0f, 1f);
public Color clippedBoundsColor = Color.cyan;
public Color aabbInsideFrustumColor = Color.green;
public Color aabbClippedColor = Color.yellow;
public Color edgePlaneColor = Color.red;
public Color frustumInsideAABBColor = Color.blue;
private static readonly (int a, int b)[] kEdges = new (int, int)[]
{
(0,1),(0,2),(0,4),
(1,3),(1,5),
(2,3),(2,6),
(3,7),
(4,5),(4,6),
(5,7),
(6,7)
};
// 디버깅용 분류된 점들
private List<Vector3> _aabbCornersInsideFrustum = new List<Vector3>();
private List<Vector3> _aabbCornersClipped = new List<Vector3>();
private List<Vector3> _edgePlaneIntersections = new List<Vector3>();
private List<Vector3> _frustumCornersInsideAABB = new List<Vector3>();
private void OnDrawGizmos()
{
if (camera == null || box == null) return;
// 초기화
_aabbCornersInsideFrustum.Clear();
_aabbCornersClipped.Clear();
_edgePlaneIntersections.Clear();
_frustumCornersInsideAABB.Clear();
// 원래 박스
var worldCorners = GetBoxColliderWorldCorners(box);
var originalBounds = GetBoundsFromPoints(worldCorners);
if (showOriginalBounds)
{
Gizmos.color = originalBoundsColor;
DrawBoundsWireCube(originalBounds);
}
// View-Projection Matrix
Matrix4x4 vp = camera.projectionMatrix * camera.worldToCameraMatrix;
Matrix4x4 invVP = vp.inverse;
// Frustum planes
var planes = GeometryUtility.CalculateFrustumPlanes(camera);
// 완전 Outside 체크
if (FullyOutsideFrustum(worldCorners, planes))
return;
// 교차점 수집
var pts = new List<Vector3>(64);
// ===== (A) AABB Corner 처리 (Clip Space 포함) =====
for (int i = 0; i < 8; i++)
{
Vector3 worldPos = worldCorners[i];
Vector4 clipPos = vp * new Vector4(worldPos.x, worldPos.y, worldPos.z, 1f);
if (Mathf.Abs(clipPos.w) > 0.0001f)
{
Vector3 ndc = new Vector3(clipPos.x / clipPos.w, clipPos.y / clipPos.w, clipPos.z / clipPos.w);
// Frustum 내부에 있는지 확인
bool isInside = ndc.x >= -1f && ndc.x <= 1f &&
ndc.y >= -1f && ndc.y <= 1f &&
ndc.z >= -1f && ndc.z <= 1f;
if (isInside)
{
// Frustum 내부 → 원래 corner 추가
if (!IsDuplicatePoint(worldPos, pts))
{
pts.Add(worldPos);
_aabbCornersInsideFrustum.Add(worldPos);
}
}
else
{
// Frustum 외부 → NDC clamp 후 world로 역변환
Vector3 clampedNDC = new Vector3(
Mathf.Clamp(ndc.x, -1f, 1f),
Mathf.Clamp(ndc.y, -1f, 1f),
Mathf.Clamp(ndc.z, -1f, 1f)
);
Vector4 clampedClip = new Vector4(
clampedNDC.x * clipPos.w,
clampedNDC.y * clipPos.w,
clampedNDC.z * clipPos.w,
clipPos.w
);
Vector4 clampedWorld = invVP * clampedClip;
if (Mathf.Abs(clampedWorld.w) > 0.0001f)
{
Vector3 clampedWorldPos = new Vector3(
clampedWorld.x / clampedWorld.w,
clampedWorld.y / clampedWorld.w,
clampedWorld.z / clampedWorld.w
);
// 원래 AABB 내부에 있는지 확인
if (PointInBoxColliderOBB(clampedWorldPos, box))
{
if (!IsDuplicatePoint(clampedWorldPos, pts))
{
pts.Add(clampedWorldPos);
_aabbCornersClipped.Add(clampedWorldPos);
}
}
}
}
}
}
// ===== (B) AABB Edge × Frustum Plane 교차 =====
for (int e = 0; e < kEdges.Length; e++)
{
var A = worldCorners[kEdges[e].a];
var B = worldCorners[kEdges[e].b];
for (int p = 0; p < planes.Length; p++)
{
if (SegmentPlane(A, B, planes[p], out var X))
{
// 교차점이 Frustum 내부에 있는지 확인
if (InsideAllPlanes(X, planes))
{
if (!IsDuplicatePoint(X, pts))
{
pts.Add(X);
_edgePlaneIntersections.Add(X);
}
}
}
}
}
// ===== (C) Frustum Corner 중 AABB 내부 =====
var frustumCorners = GetFrustumWorldCorners(camera);
for (int i = 0; i < frustumCorners.Length; i++)
{
if (PointInBoxColliderOBB(frustumCorners[i], box))
{
if (!IsDuplicatePoint(frustumCorners[i], pts))
{
pts.Add(frustumCorners[i]);
_frustumCornersInsideAABB.Add(frustumCorners[i]);
}
}
}
// 중복 제거
MergeNearby(ref pts, 1e-4f);
if (pts.Count == 0) return;
// 결과 바운즈
var clipped = GetBoundsFromPoints(pts.ToArray());
if (showClippedBounds)
{
Gizmos.color = clippedBoundsColor;
DrawBoundsWireCube(clipped);
}
// 점들 시각화
if (showAABBCornersInsideFrustum)
{
Gizmos.color = aabbInsideFrustumColor;
foreach (var p in _aabbCornersInsideFrustum)
Gizmos.DrawSphere(p, 0.05f);
}
if (showAABBCornersClipped)
{
Gizmos.color = aabbClippedColor;
foreach (var p in _aabbCornersClipped)
Gizmos.DrawSphere(p, 0.05f);
}
if (showEdgePlaneIntersections)
{
Gizmos.color = edgePlaneColor;
foreach (var p in _edgePlaneIntersections)
Gizmos.DrawSphere(p, 0.04f);
}
if (showFrustumCornersInsideAABB)
{
Gizmos.color = frustumInsideAABBColor;
foreach (var p in _frustumCornersInsideAABB)
Gizmos.DrawSphere(p, 0.06f);
}
}
// ====== 보조 함수들 ======
static bool FullyOutsideFrustum(Vector3[] corners, Plane[] planes)
{
for (int i = 0; i < planes.Length; i++)
{
int outside = 0;
for (int j = 0; j < 8; j++)
if (planes[i].GetDistanceToPoint(corners[j]) < 0f) outside++;
if (outside == 8) return true;
}
return false;
}
static bool InsideAllPlanes(Vector3 p, Plane[] planes)
{
for (int i = 0; i < planes.Length; i++)
if (planes[i].GetDistanceToPoint(p) < -0.0001f) return false;
return true;
}
static bool SegmentPlane(Vector3 A, Vector3 B, Plane pl, out Vector3 X)
{
float da = pl.GetDistanceToPoint(A);
float db = pl.GetDistanceToPoint(B);
X = default;
if ((da > 0f && db > 0f) || (da < 0f && db < 0f)) return false;
float denom = (da - db);
if (Mathf.Approximately(denom, 0f)) return false;
float t = da / (da - db);
if (t < -0.0001f || t > 1.0001f) return false;
X = A + t * (B - A);
return true;
}
static Vector3[] GetFrustumWorldCorners(Camera cam)
{
var corners = new Vector3[8];
int i = 0;
float[] z = { cam.nearClipPlane, cam.farClipPlane };
for (int zi = 0; zi < 2; zi++)
{
float dist = z[zi];
corners[i++] = cam.ViewportToWorldPoint(new Vector3(0f, 0f, dist));
corners[i++] = cam.ViewportToWorldPoint(new Vector3(1f, 0f, dist));
corners[i++] = cam.ViewportToWorldPoint(new Vector3(1f, 1f, dist));
corners[i++] = cam.ViewportToWorldPoint(new Vector3(0f, 1f, dist));
}
return corners;
}
static bool PointInBoxColliderOBB(Vector3 p, BoxCollider bc)
{
Vector3 lp = bc.transform.InverseTransformPoint(p) - bc.center;
Vector3 e = bc.size * 0.5f;
return Mathf.Abs(lp.x) <= e.x + 1e-6f &&
Mathf.Abs(lp.y) <= e.y + 1e-6f &&
Mathf.Abs(lp.z) <= e.z + 1e-6f;
}
static bool IsDuplicatePoint(Vector3 point, List<Vector3> points, float threshold = 0.0001f)
{
foreach (var p in points)
{
if ((point - p).sqrMagnitude <= threshold * threshold)
return true;
}
return false;
}
static void MergeNearby(ref List<Vector3> pts, float eps)
{
if (pts.Count < 2) return;
var outList = new List<Vector3>(pts.Count);
for (int i = 0; i < pts.Count; i++)
{
bool dup = false;
for (int j = 0; j < outList.Count; j++)
{
if ((pts[i] - outList[j]).sqrMagnitude <= eps * eps) { dup = true; break; }
}
if (!dup) outList.Add(pts[i]);
}
pts = outList;
}
private static Vector3[] GetBoxColliderWorldCorners(BoxCollider bc)
{
Vector3 c = bc.center;
Vector3 e = bc.size * 0.5f;
Vector3[] local = new Vector3[8];
int idx = 0;
for (int xi = -1; xi <= 1; xi += 2)
for (int yi = -1; yi <= 1; yi += 2)
for (int zi = -1; zi <= 1; zi += 2)
local[idx++] = new Vector3(c.x + xi * e.x, c.y + yi * e.y, c.z + zi * e.z);
Vector3[] world = new Vector3[8];
for (int i = 0; i < 8; i++) world[i] = bc.transform.TransformPoint(local[i]);
return world;
}
private static Bounds GetBoundsFromPoints(Vector3[] pts)
{
if (pts == null || pts.Length == 0) return new Bounds(Vector3.zero, Vector3.zero);
Vector3 min = pts[0], max = pts[0];
for (int i = 1; i < pts.Length; i++) { min = Vector3.Min(min, pts[i]); max = Vector3.Max(max, pts[i]); }
var b = new Bounds(); b.SetMinMax(min, max); return b;
}
private static void DrawBoundsWireCube(Bounds b)
{
Gizmos.matrix = Matrix4x4.identity;
Gizmos.DrawWireCube(b.center, b.size);
}
}
하지만 이 방식도 예외 상황이 존재했다. 카메라 near Plane이 AABB의 Corner, Edge와 교차하지 않는 경우 다음과 같이 비정상적인 Bounds 결과가 출력된다.

즉, 이번엔 Frustum의 Edge와 AABB Plane 간의 교차도 고려해야 하는 것이다. 점점 복잡해진다...
using UnityEngine;
using System.Collections.Generic;
public class BoundsClippingCamera : MonoBehaviour
{
public Camera camera;
public BoxCollider box;
[Header("Visualization")]
public bool showOriginalBounds = true;
public bool showClippedBounds = true;
public bool showAABBCornersInsideFrustum = true;
public bool showAABBCornersClipped = true;
public bool showEdgePlaneIntersections = true;
public bool showFrustumCornersInsideAABB = true;
public bool showFrustumEdgeAABBPlaneIntersections = true; // ← 추가
[Header("Colors")]
public Color originalBoundsColor = new Color(1f, 0.6f, 0f, 1f);
public Color clippedBoundsColor = Color.cyan;
public Color aabbInsideFrustumColor = Color.green;
public Color aabbClippedColor = Color.yellow;
public Color edgePlaneColor = Color.red;
public Color frustumInsideAABBColor = Color.blue;
public Color frustumEdgeAABBPlaneColor = Color.magenta; // ← 추가
private static readonly (int a, int b)[] kEdges = new (int, int)[]
{
(0,1),(0,2),(0,4),
(1,3),(1,5),
(2,3),(2,6),
(3,7),
(4,5),(4,6),
(5,7),
(6,7)
};
// 디버깅용 분류된 점들
private List<Vector3> _aabbCornersInsideFrustum = new List<Vector3>();
private List<Vector3> _aabbCornersClipped = new List<Vector3>();
private List<Vector3> _edgePlaneIntersections = new List<Vector3>();
private List<Vector3> _frustumCornersInsideAABB = new List<Vector3>();
private List<Vector3> _frustumEdgeAABBPlaneIntersections = new List<Vector3>(); // ← 추가
private void OnDrawGizmos()
{
if (camera == null || box == null) return;
// 초기화
_aabbCornersInsideFrustum.Clear();
_aabbCornersClipped.Clear();
_edgePlaneIntersections.Clear();
_frustumCornersInsideAABB.Clear();
_frustumEdgeAABBPlaneIntersections.Clear(); // ← 추가
// 원래 박스
var worldCorners = GetBoxColliderWorldCorners(box);
var originalBounds = GetBoundsFromPoints(worldCorners);
if (showOriginalBounds)
{
Gizmos.color = originalBoundsColor;
DrawBoundsWireCube(originalBounds);
}
// View-Projection Matrix
Matrix4x4 vp = camera.projectionMatrix * camera.worldToCameraMatrix;
Matrix4x4 invVP = vp.inverse;
// Frustum planes
var planes = GeometryUtility.CalculateFrustumPlanes(camera);
// 완전 Outside 체크
if (FullyOutsideFrustum(worldCorners, planes))
return;
// 교차점 수집
var pts = new List<Vector3>(64);
// ===== (A) AABB Corner 처리 (Clip Space 포함) =====
for (int i = 0; i < 8; i++)
{
Vector3 worldPos = worldCorners[i];
Vector4 clipPos = vp * new Vector4(worldPos.x, worldPos.y, worldPos.z, 1f);
if (Mathf.Abs(clipPos.w) > 0.0001f)
{
Vector3 ndc = new Vector3(clipPos.x / clipPos.w, clipPos.y / clipPos.w, clipPos.z / clipPos.w);
bool isInside = ndc.x >= -1f && ndc.x <= 1f &&
ndc.y >= -1f && ndc.y <= 1f &&
ndc.z >= -1f && ndc.z <= 1f;
if (isInside)
{
if (!IsDuplicatePoint(worldPos, pts))
{
pts.Add(worldPos);
_aabbCornersInsideFrustum.Add(worldPos);
}
}
else
{
Vector3 clampedNDC = new Vector3(
Mathf.Clamp(ndc.x, -1f, 1f),
Mathf.Clamp(ndc.y, -1f, 1f),
Mathf.Clamp(ndc.z, -1f, 1f)
);
Vector4 clampedClip = new Vector4(
clampedNDC.x * clipPos.w,
clampedNDC.y * clipPos.w,
clampedNDC.z * clipPos.w,
clipPos.w
);
Vector4 clampedWorld = invVP * clampedClip;
if (Mathf.Abs(clampedWorld.w) > 0.0001f)
{
Vector3 clampedWorldPos = new Vector3(
clampedWorld.x / clampedWorld.w,
clampedWorld.y / clampedWorld.w,
clampedWorld.z / clampedWorld.w
);
if (PointInBoxColliderOBB(clampedWorldPos, box))
{
if (!IsDuplicatePoint(clampedWorldPos, pts))
{
pts.Add(clampedWorldPos);
_aabbCornersClipped.Add(clampedWorldPos);
}
}
}
}
}
}
// ===== (B) AABB Edge × Frustum Plane 교차 =====
for (int e = 0; e < kEdges.Length; e++)
{
var A = worldCorners[kEdges[e].a];
var B = worldCorners[kEdges[e].b];
for (int p = 0; p < planes.Length; p++)
{
if (SegmentPlane(A, B, planes[p], out var X))
{
if (InsideAllPlanes(X, planes))
{
if (!IsDuplicatePoint(X, pts))
{
pts.Add(X);
_edgePlaneIntersections.Add(X);
}
}
}
}
}
// ===== (C) Frustum Corner 중 AABB 내부 =====
var frustumCorners = GetFrustumWorldCorners(camera);
for (int i = 0; i < frustumCorners.Length; i++)
{
if (PointInBoxColliderOBB(frustumCorners[i], box))
{
if (!IsDuplicatePoint(frustumCorners[i], pts))
{
pts.Add(frustumCorners[i]);
_frustumCornersInsideAABB.Add(frustumCorners[i]);
}
}
}
// ===== (D) Frustum Edge × AABB Plane 교차 ===== ← 추가
Plane[] aabbPlanes = GetAABBPlanes(box);
var frustumEdges = GetFrustumEdges(frustumCorners);
for (int e = 0; e < frustumEdges.Length; e++)
{
var A = frustumEdges[e].Item1;
var B = frustumEdges[e].Item2;
for (int p = 0; p < aabbPlanes.Length; p++)
{
if (SegmentPlane(A, B, aabbPlanes[p], out var X))
{
if (PointInBoxColliderOBB(X, box))
{
if (!IsDuplicatePoint(X, pts))
{
pts.Add(X);
_frustumEdgeAABBPlaneIntersections.Add(X); // ← 추가
}
}
}
}
}
// 중복 제거
MergeNearby(ref pts, 1e-4f);
if (pts.Count == 0) return;
// 결과 바운즈
var clipped = GetBoundsFromPoints(pts.ToArray());
if (showClippedBounds)
{
Gizmos.color = clippedBoundsColor;
DrawBoundsWireCube(clipped);
}
// 점들 시각화
if (showAABBCornersInsideFrustum)
{
Gizmos.color = aabbInsideFrustumColor;
foreach (var p in _aabbCornersInsideFrustum)
Gizmos.DrawSphere(p, 0.05f);
}
if (showAABBCornersClipped)
{
Gizmos.color = aabbClippedColor;
foreach (var p in _aabbCornersClipped)
Gizmos.DrawSphere(p, 0.05f);
}
if (showEdgePlaneIntersections)
{
Gizmos.color = edgePlaneColor;
foreach (var p in _edgePlaneIntersections)
Gizmos.DrawSphere(p, 0.04f);
}
if (showFrustumCornersInsideAABB)
{
Gizmos.color = frustumInsideAABBColor;
foreach (var p in _frustumCornersInsideAABB)
Gizmos.DrawSphere(p, 0.06f);
}
// ← 추가
if (showFrustumEdgeAABBPlaneIntersections)
{
Gizmos.color = frustumEdgeAABBPlaneColor;
foreach (var p in _frustumEdgeAABBPlaneIntersections)
Gizmos.DrawSphere(p, 0.055f);
}
}
// ====== 보조 함수들 ======
static bool FullyOutsideFrustum(Vector3[] corners, Plane[] planes)
{
for (int i = 0; i < planes.Length; i++)
{
int outside = 0;
for (int j = 0; j < 8; j++)
if (planes[i].GetDistanceToPoint(corners[j]) < 0f) outside++;
if (outside == 8) return true;
}
return false;
}
static bool InsideAllPlanes(Vector3 p, Plane[] planes)
{
for (int i = 0; i < planes.Length; i++)
if (planes[i].GetDistanceToPoint(p) < -0.0001f) return false;
return true;
}
static bool SegmentPlane(Vector3 A, Vector3 B, Plane pl, out Vector3 X)
{
float da = pl.GetDistanceToPoint(A);
float db = pl.GetDistanceToPoint(B);
X = default;
if ((da > 0f && db > 0f) || (da < 0f && db < 0f)) return false;
float denom = (da - db);
if (Mathf.Approximately(denom, 0f)) return false;
float t = da / (da - db);
if (t < -0.0001f || t > 1.0001f) return false;
X = A + t * (B - A);
return true;
}
static Vector3[] GetFrustumWorldCorners(Camera cam)
{
var corners = new Vector3[8];
int i = 0;
float[] z = { cam.nearClipPlane, cam.farClipPlane };
for (int zi = 0; zi < 2; zi++)
{
float dist = z[zi];
corners[i++] = cam.ViewportToWorldPoint(new Vector3(0f, 0f, dist));
corners[i++] = cam.ViewportToWorldPoint(new Vector3(1f, 0f, dist));
corners[i++] = cam.ViewportToWorldPoint(new Vector3(1f, 1f, dist));
corners[i++] = cam.ViewportToWorldPoint(new Vector3(0f, 1f, dist));
}
return corners;
}
static bool PointInBoxColliderOBB(Vector3 p, BoxCollider bc)
{
Vector3 lp = bc.transform.InverseTransformPoint(p) - bc.center;
Vector3 e = bc.size * 0.5f;
return Mathf.Abs(lp.x) <= e.x + 1e-6f &&
Mathf.Abs(lp.y) <= e.y + 1e-6f &&
Mathf.Abs(lp.z) <= e.z + 1e-6f;
}
// ← 추가
static Plane[] GetAABBPlanes(BoxCollider bc)
{
Vector3 center = bc.transform.TransformPoint(bc.center);
Vector3 right = bc.transform.right * bc.size.x * 0.5f;
Vector3 up = bc.transform.up * bc.size.y * 0.5f;
Vector3 forward = bc.transform.forward * bc.size.z * 0.5f;
return new Plane[]
{
new Plane(-bc.transform.right, center - right), // X min (안쪽 향함)
new Plane(bc.transform.right, center + right), // X max (안쪽 향함)
new Plane(-bc.transform.up, center - up), // Y min (안쪽 향함)
new Plane(bc.transform.up, center + up), // Y max (안쪽 향함)
new Plane(-bc.transform.forward, center - forward), // Z min (안쪽 향함)
new Plane(bc.transform.forward, center + forward) // Z max (안쪽 향함)
};
}
// ← 추가
static (Vector3, Vector3)[] GetFrustumEdges(Vector3[] corners)
{
return new (Vector3, Vector3)[]
{
// Near plane edges
(corners[0], corners[1]),
(corners[1], corners[2]),
(corners[2], corners[3]),
(corners[3], corners[0]),
// Far plane edges
(corners[4], corners[5]),
(corners[5], corners[6]),
(corners[6], corners[7]),
(corners[7], corners[4]),
// Connecting edges
(corners[0], corners[4]),
(corners[1], corners[5]),
(corners[2], corners[6]),
(corners[3], corners[7])
};
}
static bool IsDuplicatePoint(Vector3 point, List<Vector3> points, float threshold = 0.0001f)
{
foreach (var p in points)
{
if ((point - p).sqrMagnitude <= threshold * threshold)
return true;
}
return false;
}
static void MergeNearby(ref List<Vector3> pts, float eps)
{
if (pts.Count < 2) return;
var outList = new List<Vector3>(pts.Count);
for (int i = 0; i < pts.Count; i++)
{
bool dup = false;
for (int j = 0; j < outList.Count; j++)
{
if ((pts[i] - outList[j]).sqrMagnitude <= eps * eps) { dup = true; break; }
}
if (!dup) outList.Add(pts[i]);
}
pts = outList;
}
private static Vector3[] GetBoxColliderWorldCorners(BoxCollider bc)
{
Vector3 c = bc.center;
Vector3 e = bc.size * 0.5f;
Vector3[] local = new Vector3[8];
int idx = 0;
for (int xi = -1; xi <= 1; xi += 2)
for (int yi = -1; yi <= 1; yi += 2)
for (int zi = -1; zi <= 1; zi += 2)
local[idx++] = new Vector3(c.x + xi * e.x, c.y + yi * e.y, c.z + zi * e.z);
Vector3[] world = new Vector3[8];
for (int i = 0; i < 8; i++) world[i] = bc.transform.TransformPoint(local[i]);
return world;
}
private static Bounds GetBoundsFromPoints(Vector3[] pts)
{
if (pts == null || pts.Length == 0) return new Bounds(Vector3.zero, Vector3.zero);
Vector3 min = pts[0], max = pts[0];
for (int i = 1; i < pts.Length; i++) { min = Vector3.Min(min, pts[i]); max = Vector3.Max(max, pts[i]); }
var b = new Bounds(); b.SetMinMax(min, max); return b;
}
private static void DrawBoundsWireCube(Bounds b)
{
Gizmos.matrix = Matrix4x4.identity;
Gizmos.DrawWireCube(b.center, b.size);
}
}

휴... 이렇게 하면 일단 모든 경우의 수를 대응할 수 있을 것 같다.
다른 방법 - 3 plane intersection
https://gdbooks.gitbooks.io/3dcollisions/content/Chapter1/three_plane_intersection.html
AABB Corner Clip Space Clipping, AABB Edge & Frustum Plane 교차점, AABB Plane & Frustum Edge 교차점을 통해 AABB를 자르는 알고리즘보다 과연 더 빠른 방식인가 하면 그건 아닌 것 같지만 개념적으로 더 단순하게 접근하는 방법이 존재하는 것 같아서 알아보고자 한다.
using UnityEngine;
public class AABBFrustumClippingVisualizer : MonoBehaviour
{
[Header("Input")]
public Camera targetCamera;
public Renderer targetRenderer;
[Header("Visualization Options")]
public bool showOriginalAABB = true;
public bool showFrustum = true;
public bool showAABBCorners = true;
public bool showFrustumCorners = true;
public bool showAABBCornersInsideFrustum = true;
public bool showFrustumCornersInsideAABB = true;
public bool show3PlaneIntersections = true;
public bool showClippedBounds = true;
[Header("Colors")]
public Color originalAABBColor = new Color(0.5f, 0.5f, 0.5f, 0.5f);
public Color frustumColor = Color.yellow;
public Color aabbCornerColor = Color.white;
public Color frustumCornerColor = Color.white;
public Color aabbInsideFrustumColor = Color.green;
public Color frustumInsideAABBColor = Color.blue;
public Color planeIntersectionColor = Color.red;
public Color clippedBoundsColor = Color.cyan;
[Header("Settings")]
public float pointSize = 0.1f;
public bool updateEveryFrame = true;
// 캐시된 데이터
private Bounds _originalAABB;
private Vector3[] _frustumCorners;
private Vector3[] _aabbCorners;
private Vector3[] _aabbCornersInsideFrustum;
private Vector3[] _frustumCornersInsideAABB;
private Vector3[] _planeIntersectionPoints;
private Bounds _clippedBounds;
private bool _hasValidResult;
private struct Plane3D
{
public Vector3 normal;
public float distance;
public Plane3D(Vector3 n, float d)
{
normal = n;
distance = d;
}
}
private void Update()
{
if (updateEveryFrame)
{
CalculateClipping();
}
}
[ContextMenu("Calculate Clipping")]
public void CalculateClipping()
{
if (targetCamera == null || targetRenderer == null)
{
Debug.LogWarning("Camera or Renderer is null!");
_hasValidResult = false;
return;
}
_originalAABB = targetRenderer.bounds;
Matrix4x4 vp = targetCamera.projectionMatrix * targetCamera.worldToCameraMatrix;
// Frustum planes 추출 (사용자 코드 방식 그대로)
Plane3D[] frustumPlanes = ExtractFrustumPlanes(vp);
// AABB planes 추출
Plane3D[] aabbPlanes = GetAABBPlanes(_originalAABB.min, _originalAABB.max);
// 교차점 계산
var intersectionPoints = new System.Collections.Generic.List<Vector3>();
// 1. AABB corners
_aabbCorners = GetAABBCorners(_originalAABB.min, _originalAABB.max);
var aabbCornersInside = new System.Collections.Generic.List<Vector3>();
foreach (var corner in _aabbCorners)
{
if (IsPointInsideFrustum(corner, frustumPlanes))
{
aabbCornersInside.Add(corner);
intersectionPoints.Add(corner);
}
}
_aabbCornersInsideFrustum = aabbCornersInside.ToArray();
// 2. Frustum corners
_frustumCorners = CalculateFrustumCorners(frustumPlanes);
var frustumCornersInside = new System.Collections.Generic.List<Vector3>();
foreach (var corner in _frustumCorners)
{
if (IsPointInsideAABB(corner, _originalAABB.min, _originalAABB.max))
{
frustumCornersInside.Add(corner);
intersectionPoints.Add(corner);
}
}
_frustumCornersInsideAABB = frustumCornersInside.ToArray();
// 3. 3-plane intersections
var planeIntersections = new System.Collections.Generic.List<Vector3>();
// AABB 2개 + Frustum 1개
for (int a1 = 0; a1 < 6; a1++)
{
for (int a2 = a1 + 1; a2 < 6; a2++)
{
for (int f = 0; f < 6; f++)
{
Vector3 point = IntersectThreePlanes(aabbPlanes[a1], aabbPlanes[a2], frustumPlanes[f]);
if (!float.IsNaN(point.x) && !float.IsInfinity(point.x) &&
IsPointInsideAABB(point, _originalAABB.min, _originalAABB.max) &&
IsPointInsideFrustum(point, frustumPlanes))
{
if (!IsDuplicatePoint(point, planeIntersections))
{
planeIntersections.Add(point);
intersectionPoints.Add(point);
}
}
}
}
}
// AABB 1개 + Frustum 2개
for (int a = 0; a < 6; a++)
{
for (int f1 = 0; f1 < 6; f1++)
{
for (int f2 = f1 + 1; f2 < 6; f2++)
{
Vector3 point = IntersectThreePlanes(aabbPlanes[a], frustumPlanes[f1], frustumPlanes[f2]);
if (!float.IsNaN(point.x) && !float.IsInfinity(point.x) &&
IsPointInsideAABB(point, _originalAABB.min, _originalAABB.max) &&
IsPointInsideFrustum(point, frustumPlanes))
{
if (!IsDuplicatePoint(point, planeIntersections))
{
planeIntersections.Add(point);
intersectionPoints.Add(point);
}
}
}
}
}
_planeIntersectionPoints = planeIntersections.ToArray();
// 최종 Clipped Bounds 계산
if (intersectionPoints.Count > 0)
{
Vector3 min = intersectionPoints[0];
Vector3 max = intersectionPoints[0];
foreach (var point in intersectionPoints)
{
min = Vector3.Min(min, point);
max = Vector3.Max(max, point);
}
_clippedBounds = new Bounds((min + max) * 0.5f, max - min);
_hasValidResult = true;
}
else
{
_hasValidResult = false;
}
}
private Plane3D[] ExtractFrustumPlanes(Matrix4x4 vp)
{
Plane3D[] planes = new Plane3D[6];
// 사용자 코드와 동일하게 행 추출
// Matrix4x4.mXY에서 X는 행, Y는 열
Vector4 row0 = new Vector4(vp.m00, vp.m01, vp.m02, vp.m03);
Vector4 row1 = new Vector4(vp.m10, vp.m11, vp.m12, vp.m13);
Vector4 row2 = new Vector4(vp.m20, vp.m21, vp.m22, vp.m23);
Vector4 row3 = new Vector4(vp.m30, vp.m31, vp.m32, vp.m33);
planes[0] = NormalizePlane(row3 + row0); // Left
planes[1] = NormalizePlane(row3 - row0); // Right
planes[2] = NormalizePlane(row3 + row1); // Bottom
planes[3] = NormalizePlane(row3 - row1); // Top
planes[4] = NormalizePlane(row3 + row2); // Near
planes[5] = NormalizePlane(row3 - row2); // Far
return planes;
}
private Plane3D NormalizePlane(Vector4 plane)
{
Vector3 normal = new Vector3(plane.x, plane.y, plane.z);
float length = normal.magnitude;
if (length > 0.0001f)
{
normal = normal / length;
float distance = plane.w / length;
return new Plane3D(normal, distance);
}
return new Plane3D(normal, plane.w);
}
private Plane3D[] GetAABBPlanes(Vector3 min, Vector3 max)
{
Plane3D[] planes = new Plane3D[6];
// 사용자 코드와 동일하게
planes[0] = new Plane3D(Vector3.right, -min.x); // Left (내부 향함)
planes[1] = new Plane3D(Vector3.left, max.x); // Right (내부 향함)
planes[2] = new Plane3D(Vector3.up, -min.y); // Bottom (내부 향함)
planes[3] = new Plane3D(Vector3.down, max.y); // Top (내부 향함)
planes[4] = new Plane3D(Vector3.forward, -min.z); // Back (내부 향함)
planes[5] = new Plane3D(Vector3.back, max.z); // Front (내부 향함)
return planes;
}
private Vector3[] GetAABBCorners(Vector3 min, Vector3 max)
{
return new Vector3[]
{
new Vector3(min.x, min.y, min.z),
new Vector3(max.x, min.y, min.z),
new Vector3(max.x, max.y, min.z),
new Vector3(min.x, max.y, min.z),
new Vector3(min.x, min.y, max.z),
new Vector3(max.x, min.y, max.z),
new Vector3(max.x, max.y, max.z),
new Vector3(min.x, max.y, max.z)
};
}
private Vector3[] CalculateFrustumCorners(Plane3D[] planes)
{
Vector3[] corners = new Vector3[8];
// 사용자 코드와 동일한 순서
var nearPlane = planes[4];
var farPlane = planes[5];
var leftPlane = planes[0];
var rightPlane = planes[1];
var bottomPlane = planes[2];
var topPlane = planes[3];
// Near plane의 4개 corner
corners[0] = IntersectThreePlanes(nearPlane, leftPlane, bottomPlane);
corners[1] = IntersectThreePlanes(nearPlane, rightPlane, bottomPlane);
corners[2] = IntersectThreePlanes(nearPlane, rightPlane, topPlane);
corners[3] = IntersectThreePlanes(nearPlane, leftPlane, topPlane);
// Far plane의 4개 corner
corners[4] = IntersectThreePlanes(farPlane, leftPlane, bottomPlane);
corners[5] = IntersectThreePlanes(farPlane, rightPlane, bottomPlane);
corners[6] = IntersectThreePlanes(farPlane, rightPlane, topPlane);
corners[7] = IntersectThreePlanes(farPlane, leftPlane, topPlane);
return corners;
}
private Vector3 IntersectThreePlanes(Plane3D p1, Plane3D p2, Plane3D p3)
{
Vector3 n1 = p1.normal;
Vector3 n2 = p2.normal;
Vector3 n3 = p3.normal;
float d1 = p1.distance;
float d2 = p2.distance;
float d3 = p3.distance;
Vector3 cross = Vector3.Cross(n2, n3);
float det = Vector3.Dot(n1, cross);
if (Mathf.Abs(det) < 0.0001f)
return new Vector3(float.NaN, float.NaN, float.NaN);
Vector3 point = (-d1 * cross - d2 * Vector3.Cross(n3, n1) - d3 * Vector3.Cross(n1, n2)) / det;
return point;
}
private bool IsPointInsideFrustum(Vector3 point, Plane3D[] planes)
{
foreach (var plane in planes)
{
float distance = Vector3.Dot(plane.normal, point) + plane.distance;
if (distance < -0.0001f) return false;
}
return true;
}
private bool IsPointInsideAABB(Vector3 point, Vector3 min, Vector3 max)
{
return point.x >= min.x - 0.0001f && point.x <= max.x + 0.0001f &&
point.y >= min.y - 0.0001f && point.y <= max.y + 0.0001f &&
point.z >= min.z - 0.0001f && point.z <= max.z + 0.0001f;
}
private bool IsDuplicatePoint(Vector3 point, System.Collections.Generic.List<Vector3> points)
{
foreach (var p in points)
{
if (Vector3.Distance(point, p) < 0.0001f)
return true;
}
return false;
}
private void OnDrawGizmos()
{
if (targetCamera == null || targetRenderer == null) return;
// 원본 AABB
if (showOriginalAABB)
{
Gizmos.color = originalAABBColor;
Gizmos.DrawWireCube(_originalAABB.center, _originalAABB.size);
}
// Frustum
if (showFrustum && _frustumCorners != null && _frustumCorners.Length == 8)
{
Gizmos.color = frustumColor;
// Near plane
Gizmos.DrawLine(_frustumCorners[0], _frustumCorners[1]);
Gizmos.DrawLine(_frustumCorners[1], _frustumCorners[2]);
Gizmos.DrawLine(_frustumCorners[2], _frustumCorners[3]);
Gizmos.DrawLine(_frustumCorners[3], _frustumCorners[0]);
// Far plane
Gizmos.DrawLine(_frustumCorners[4], _frustumCorners[5]);
Gizmos.DrawLine(_frustumCorners[5], _frustumCorners[6]);
Gizmos.DrawLine(_frustumCorners[6], _frustumCorners[7]);
Gizmos.DrawLine(_frustumCorners[7], _frustumCorners[4]);
// Connecting lines
Gizmos.DrawLine(_frustumCorners[0], _frustumCorners[4]);
Gizmos.DrawLine(_frustumCorners[1], _frustumCorners[5]);
Gizmos.DrawLine(_frustumCorners[2], _frustumCorners[6]);
Gizmos.DrawLine(_frustumCorners[3], _frustumCorners[7]);
}
// AABB corners (전체)
if (showAABBCorners && _aabbCorners != null)
{
Gizmos.color = aabbCornerColor;
foreach (var corner in _aabbCorners)
{
Gizmos.DrawWireSphere(corner, pointSize * 0.5f);
}
}
// Frustum corners (전체)
if (showFrustumCorners && _frustumCorners != null)
{
Gizmos.color = frustumCornerColor;
foreach (var corner in _frustumCorners)
{
Gizmos.DrawWireSphere(corner, pointSize * 0.5f);
}
}
// AABB corners inside frustum
if (showAABBCornersInsideFrustum && _aabbCornersInsideFrustum != null)
{
Gizmos.color = aabbInsideFrustumColor;
foreach (var corner in _aabbCornersInsideFrustum)
{
Gizmos.DrawSphere(corner, pointSize);
}
}
// Frustum corners inside AABB
if (showFrustumCornersInsideAABB && _frustumCornersInsideAABB != null)
{
Gizmos.color = frustumInsideAABBColor;
foreach (var corner in _frustumCornersInsideAABB)
{
Gizmos.DrawSphere(corner, pointSize);
}
}
// 3-plane intersection points
if (show3PlaneIntersections && _planeIntersectionPoints != null)
{
Gizmos.color = planeIntersectionColor;
foreach (var point in _planeIntersectionPoints)
{
Gizmos.DrawSphere(point, pointSize);
}
}
// Clipped bounds
if (showClippedBounds && _hasValidResult)
{
Gizmos.color = clippedBoundsColor;
Gizmos.DrawWireCube(_clippedBounds.center, _clippedBounds.size);
}
}
}
클리핑된 Bounds는 그림자용으로 적합할까?
그림자용으로 쓸 Bounds는 near, far plane에 의한 클리핑을 고려하지 않아야 할 것 같다. 어쨌든 메쉬가 잘리지 않고 렌더링 되어야 하니까.

'TA' 카테고리의 다른 글
| 텍스처 Block화 관련 추가적인 코멘트 (0) | 2025.05.25 |
|---|---|
| [Unity] Skeletal Animation 동작에 대하여 (0) | 2025.02.28 |
| (작성중)[Unity] Custom Shadow System 제작하기 (3) - Shadow Map 샘플링 (2) | 2025.02.25 |
| [Unity] Custom Shadow System 제작하기 (2) - Directional Light Shadow Map 그리기 (0) | 2025.02.15 |
| (작성중) [Unity] URP환경에서 GPU Instancing Tool 만들기 (0) | 2025.01.20 |