레이캐스팅(raycasting)이란?
말 그대로 쓰자면, 한 지점에서 광선을 쏴서 그 광선이 어딘가에 충돌하는지 알아내는 방법이다. 원래는 렌더링을 위한 기법으로 사용되었는데, 반사가 없는 레이 트레이싱으로 생각할 수도 있기 때문이다. 또한, 그림자를 계산하는데 사용될 수도 있다. 생각보다 활용도가 매우 높아서, 게임에서 마우스 클릭시 어떤 오브젝트가 선택되었는지 오브젝트 피킹에 사용되기도 하고, NPC의 시야에 플레이어가 보이는지 체크할 수 도 있다.
렌더링에서의 사용
간단하게 렌더링에서 사용되는 얘길 해보자면, 레이트레이싱하고 비교해볼 수 있다. 레이트레이싱은 카메라로부터 시작한 광선이 물체에 닿게되면 그 위치에서 반사를 계산하여 계속 진행시켜 실제와 같은 빛의 경로를 시뮬레이션하게 된다. 반면에, 레이케스팅은 카메라에서 시작한 광선을 계속 진행시키는데, 교차하는 첫번째 물체만 화면에 보이고 그 뒤에 교차하는 부분들은 가려지게 되므로 은면제거에 사용된다.
게임에서도 렌더링에 이용되어 왔는데, 대표적으로 울프겐슈타인3D가 있다. 이걸 보면 2D이면서 3D를 흉내내는걸 볼 수 있는데, 여기에 레이캐스팅이 사용된다. 플레이어 위치에서 카메라 방향으로 광선의 직선을 그려서 벽에 닿게되면, 그 거리를 이용하여 벽의 높이를 화면에 그리게 된다. 이렇게 하면 2D상에서의 계산을 3D로 그려낼 수 있는 것이다. 사실 진짜 문제는 광선과 벽의 교차를 계산하는 문제인데, 광선을 조금씩 진행시키면서 계산하기에는 벽을 통과할 수도 있고, 계산량이 많아질 수도 있는 문제가 있다. 이에 대한 해결책으로 간격이 일정한 2D 그리드에 픽셀아트처럼 벽을 그려넣게되면, 직선과 그리드의 교차점만 생각하면 되므로 문제가 매우 간단해진다. 이렇게 구현하는 경우, 게임내에서 시야는 위아래로 이동하는식의 조절이 안되는 제한이 있다. 뭐, 실제로는 2D니까 당연하지만. 여기에 추가적으로 상하이동을 구현하여 발전되어 나온 것이 DOOM인데, 기본적으로는 동일한 알고리즘을 이용한다. 이에 대해서는 꽤 잘 설명된 한글 자료가 있다. : https://github.com/365kim/raycasting_tutorial/blob/master/1_what_is_raycasting.md 아마도 원본으로 생각되는 영어 자료도 있다. : https://lodev.org/cgtutor/raycasting.html
Godot 4에서의 레이캐스팅(Godot3와 살짝 다름)
Godot에서 레이캐스팅을 사용하는 방법을 알아보자. 이게 목적이기 때문에 앞에서 너무 대충 서술 했지만, 핵심은 광선을 쏴서 오브젝트와 충돌여부를 검출하는 것이다. 렌더링이든, 2.5D 게임에서 사용하는 것이든 이 충돌을 체크하는 알고리즘을 최적화 시키기 위해 변화한다. 3D 게임엔진에서는 직선과 오브젝트의 충돌체크를 게임엔진이 알아서 해준다. 우리는 광선을 쏘고 게임엔진에게 무엇과 충돌했는지 쿼리를 하면 끝이다.
Godot 에서 레이캐스팅의 사용은 공식 문서를 참고할 수 있다. https://docs.godotengine.org/en/stable/tutorials/physics/ray-casting.html
Godot은 Space라는 곳에 로우 레벨 collision 정보나 물리적 정보들을 저장해놓고 있다. 이 상태정보를 가져오려면, space의 rid를 얻어온다음 PhysicsServer3D에게 이 rid를 넘겨주어 상태정보를 가져올 수 있다. 코드는 다음과 같다.
func _physics_process(delta):
var space_rid = get_world_2d().space
var space_state = PhysicsServer2D.space_get_direct_state(space_rid)
주의할 점은, 코드와 같이 _physics_process()에서만 사용해야 한다는 점이다. Godot의 물리엔진이 멀티 쓰레드로 돌 수도 있기 때문에 다른 곳에서 사용하면 문제가 되기 때문에 space state에 락이 걸린다.
이걸 다음과 같이 줄여서 쓸 수 있다.
func _physics_process(delta):
var space_state = get_world_3d().direct_space_state
이 space_state에게 ray와 교차하는 충돌 오브젝트들을 쿼리를 보내 얻어올 수 있다. 그러기 위해서 우선, 광선(ray)가 필요하다. 마우스 클릭으로 오브젝트 피킹하는 예를 들자면, 마우스가 클릭하는 스크린상의 좌표가 카메라 스크린상의 좌표에 대응된다. 카메라의 origin 포인트로부터 이 카메라 스크린 상의 좌표를 연결해서 나아가면 우리가 원하는 광선(ray)를 얻을 수 있다.
var mouse_position: Vector2 = get_viewport().get_mouse_position()
ray_origin = camera.project_ray_origin(mouse_position) #camera point for perspective
# project_ray_normal is the direction of ray
ray_target = ray_origin + camera.project_ray_normal(mouse_position)*2000
get_vewport()로부터 마우스 위치를 가져오고, camera로부터 카메라의 원점(origin)과 카메라 스크린의 normal vector를 project_ray_progin(), project_ray_normal()로 가져오게된다. 광선의 길이가 무한할 수 없으므로, 적당한 길이를 주어야 하는데, 여기서는 2000을 곱해줘서 광선의 벡터를 만들었다. 이렇게, 시작(ray_origin)과 끝(ray_target)이 있는 광선을 얻었다.
한가지 주의 할점은, Perspective view와 Orthogonal view에 대해 origin/target 벡터가 달라진다는 점이다.

perspective view에서는 ray normal이 카메라 스크린에 직교하지 않고 변하지만, orthogonal view에서는 직교하므로 방향벡터가 일정하게 유지된다. 대신에 origin 위치가 Perspective view에서는 하나로 고정이지만, Orthogonal에서는 매번 달라지게 된다.
이제 이 광선을 이용하여, 앞에서 얻어온 space state에 쿼리를 보내보자.
var query = PhysicsRayQueryParameters3D.create(ray_origin, ray_target)
var result = space_state.intersect_ray(query)
if result:
print("Hit at point: ", result.position)
먼저 광선의 origin/target을 이용하여 PhysicsRayQueryParameter3D 쿼리 오브젝트를 만들었다. 이걸 space_state.intersect_ray()에 넘겨주게 되면, 광선과 교차하는 collision 오브젝트들의 딕셔너리를 result로 넘겨주게 된다.
이 결과 딕셔너리는 다음과 같은 자료구조를 갖는다.
{
position: Vector2 # point in world space for collision
normal: Vector2 # normal in world space for collision
collider: Object # Object collided or null (if unassociated)
collider_id: ObjectID # Object it collided against
rid: RID # RID it collided against
shape: int # shape index of collider
metadata: Variant() # metadata of collider
}
여기까지
ray casting을 이론적으로 파면 훨씬 많은 수학적 내용을 다뤄야 하지만, 내가 잘 모르기도 하고, 여기서는 Godot4에서 사용할 수 있는 방법만 주로 다루게 되었다. 일단은 여기까지.