메시지

누구나 보낼 수 있는 형식 있는 이벤트, 누가 처리하는지, 오브젝트 채널, 그리고 실제 통신량.

업데이트 2026-09-10

메시지 선언하기

메시지는 필드와, 그것들이 어떻게 실릴지 정하는 두 메서드를 가진 클래스입니다.

public class OpenDoor : NetworkEvent
{
    public int doorId;

    public override void Serialize(ByteWriter writer)
    {
        writer.WriteInt32(doorId);
    }

    public override void Deserialize(ByteReader reader)
    {
        doorId = reader.ReadInt32();
    }
}

담는 방식을 직접 쓰는 이유는, 알아서 추측하게 하는 편이 더 비싸기 때문입니다. Udon 에는 리플렉션이 없어서, 대신 직렬화해 주는 장치에도 결국 모든 필드 설명을 넘겨야 합니다. 필드 없는 신호라면 두 메서드 모두 재정의하지 않습니다.

바람과 사실을 나누세요. OpenDoorRequestDoorOpened 라는 두 형식은 한 형식을 두 번 보내는 것보다 거의 언제나 낫습니다. 핸들러가 형식만 보고 어느 쪽인지 알 수 있고, 누군가의 요청을 이미 정해진 일처럼 처리할 수 없습니다.

메시지가 갈 수 있는 두 곳

메시지는 룸 전체로 가거나, 어떤 오브젝트 자신의 채널로 갑니다. 선택지는 이것뿐이고, 어떻게 쓰느냐가 어느 쪽인지 말해 줍니다.

Room.Send(new OpenDoor { doorId = 3 });   // 룸. 모든 클라이언트로
this.Send(new Touched());                 // 이 오브젝트 자신의 채널
channel.Send(new Touched());              // 참조한 다른 오브젝트의 채널

this.SendRoom.Send 가 아닙니다. 비헤이비어가 올라가 있는 오브젝트 자신의 채널로 보내므로, 어느 오브젝트 이야기인지 알리는 id 를 실을 필요가 없습니다. Room.Send 가 룸 전체로 가는 쪽이고, static 이라 this 가 없는 평범한 클래스나 추상 클래스에서도 부를 수 있습니다.

구독도 그대로 대응합니다.

Room.Subscribe<OpenDoor>(OnOpenDoor);     // 룸에서 그 형식을 받기
this.Subscribe<Touched>(OnTouched);       // 이 오브젝트의 채널에서 받기
channel.Subscribe<Touched>(OnTouched);    // 다른 오브젝트의 채널에서 받기
이렇게 쓰면 어디로 가고 누가 받는가
Room.Send(evt) 모든 클라이언트에서 그 형식을 구독한 모든 것
this.Send(evt) 이 오브젝트 이 오브젝트에서 그 형식을 구독한 모든 것
channel.Send(evt) 그 오브젝트 그 채널에서 그 형식을 구독한 모든 것

누구나 보낼 수 있게 하려면 룸입니다. 오브젝트 자신의 채널은 소유자가 하나라, this.Send 에는 그 오브젝트의 소유가 필요합니다.

누가 처리하는지

주소가 정하는 것은 누가 처리하는지이지, 누가 보내도 되는지가 아닙니다.

Room.Send(message, NetworkTarget.All);         // 모두. 기본값
Room.Send(message, NetworkTarget.Master);      // 조율하는 클라이언트만
Room.Send(message, NetworkTarget.Others);      // 보낸 쪽 빼고 모두
Room.Send(message, player);                    // 한 사람에게

어느 클라이언트든 어떤 주소를 붙여서든 보낼 수 있고, 바이트는 어차피 모두에게 갑니다. NetworkTarget.Master 는 거르개이지 허가가 아니므로, 진짜 권한이 필요한 월드는 보낸 사람을 확인합니다.

private void OnOpenDoorRequest(VRCPlayerApi sender, OpenDoorRequest message)
{
    if (!Room.IsMaster || !Allowed(from))
        return;

    Room.Send(new DoorOpened { doorId = message.doorId });
}

이것이 마스터를 서버처럼 두는 모양입니다. 클라이언트가 묻고, 조율하는 쪽이 정하고, 그쪽에서 모두에게 보낸 메시지만이 실제로 무언가를 바꿉니다.

보장되는 것

순서대로, 한 번씩, 보낸 쪽까지. 보낸 클라이언트는 곧바로 자기 것을 처리합니다. 자기가 쓴 것은 자기에게 돌아오지 않으므로, 그러지 않으면 무슨 일이 있었는지 아는 유일한 클라이언트만 듣지 못하게 됩니다.

같은 묶음이 두 번 와도 한 번만 처리합니다. 메시지마다 일련번호가 있고 읽는 쪽은 어디까지 처리했는지 기억합니다. VRChat 이 한 번 거절해서 다시 보낸 것도 아무것도 바꾸지 않습니다.

늦게 들어온 사람은 놓친 것을 받지 않습니다. 각 묶음은 언제 만들어졌는지를 지니고 있고, 나중에 들어온 클라이언트는 자기가 오기 전 것을 놓친 싸움의 재생이 아니라 버릴 것으로 다룹니다. 동기화 값에 바라는 것과는 정반대이고, 이벤트에 바라는 것 그대로입니다. 늦게 온 사람이 현재 상태를 알아야 한다면 동기화 값을 쓰세요.

거절된 전송에도 잃지 않습니다. VRChat 은 한 비헤이비어가 한 번에 보낼 양을 제한하고, 넘는 묶음은 잘리는 대신 거절됩니다. 거절된 묶음은 그대로 두었다가 다시 보냅니다. Room.Bus.Pending 이 계속 0 보다 크다면 회선이 받아주는 것보다 빠르게 보내고 있는 것입니다.

오브젝트 자신의 채널

특정 오브젝트에 관한 메시지는 룸 대신 그 오브젝트로 보낼 수 있고, 그러면 어느 오브젝트인지 알리는 id 를 실을 필요가 없습니다. 넣을 컴포넌트도 참조도 없이, 같은 두 동사를 비헤이비어 자신 위에 씁니다.

public override void Start()
{
    this.Subscribe<Touched>(OnTouched);
}

public override void Interact()
{
    this.Send(new Touched());
}

핸들러가 플레이어까지 받으면 누가 보냈는지도 알려줍니다.

this.Subscribe<Touched>((sender, message) => Log.Info(sender.displayName + " 가 만졌다"));

보내려면 그 오브젝트를 소유해야 합니다. 복제되는 것은 소유자의 쓰기뿐이라, 소유자가 아닌 쪽은 메시지가 나가지 않았다는 것을 나중에 아는 대신 지금 듣습니다. 아무 클라이언트나 보낼 수 있게 하려면 먼저 Ownership.Claim(gameObject) 으로 소유를 가져오세요. 픽업이나 interact 라면 대개 이미 가져와 있습니다.

다른 오브젝트의 채널을 밖에서 읽으려면 그 오브젝트에 NetworkBus 가 필요하고, 참조에 대해 같은 동사가 그대로 동작합니다.

public NetworkBus channel;

channel.Subscribe<Touched>(OnTouched);
channel.Send(new Touched());

어느 쪽이든 오브젝트 채널은 소유자가 하나라, 두 사람이 같은 채널로 보내면 서로 다툽니다. 오브젝트의 소유자가 보낼 때는 오브젝트 채널을, 누구나 보낼 때는 룸을 쓰세요.

통신량

메시지 하나에 붙는 것은 3 바이트입니다. 누가 처리하는지 1, 길이 1, 어떤 형식인지 1. 특정 플레이어에게 보내면 그 플레이어의 id 도 함께 실립니다.

묶음에는 언제 만들어졌는지를 나타내는 4 바이트가 한 번만 붙습니다. 메시지마다가 아니라 묶음마다이므로, 여러 개를 함께 보낼수록 옅어집니다.

실제로 걸리는 한도는 한 비헤이비어의 동기화 상태에 대한 VRChat 의 제한이고, 이것은 메시지 하나가 아니라 묶음 전체에 걸립니다. this.Pending() 이 그것이 다가오는 것을 보는 방법입니다. 거의 언제나 0 이고, 0 보다 큰 상태로 머문다면 한 번의 동기화가 실어갈 양보다 묶음이 크다는 뜻입니다.

이 패키지 없이 쓰는 커스텀 이벤트

VRChat 자신의 이벤트 호출은 위의 것을 쓰든 말든 그대로 있습니다. 컴파일러가 그대로 넘겨주기 때문입니다.

this.SendCustomNetworkEvent(NetworkEventTarget.All, nameof(Ring));

public void Ring()
{
    bell.Play();
}

public 메서드를 이름으로 모든 클라이언트에서 부르고, NetworkEventTarget.Owner 면 소유자에게만 부릅니다. 데이터는 싣지 못하며, 형식 있는 메시지가 있는 이유가 그것입니다. SendCustomEventDelayedSecondsSendCustomEventDelayedFrames 는 로컬 호출을 미룰 뿐 아무것도 보내지 않습니다.

OnOwnershipRequest 를 재정의해 false 를 돌려주면 이전을 거부할 수 있고, OnOwnershipTransferred 는 소유자가 바뀔 때 모든 클라이언트에서 돕니다. ClientSim 은 소유권 요청을 일으키지 않으니 이 둘은 업로드한 월드에서 확인해야 합니다.

아무것도 오지 않을 때

짐작하게 두지 않고, 비헤이비어마다 한 번씩 알려줍니다.

  • 씬에 버스가 없는데 보내서 메시지가 아무 데도 가지 않았을 때.
  • 구독했는데 10 초가 지나도 읽을 버스가 어디에도 없을 때.

둘 다 실제로는 같은 뜻입니다. 룸 메시지를 싣는 오브젝트가 없거나 꺼져 있습니다. 처음 보낼 때 자동으로 추가되므로, 대개는 다른 것과 함께 꺼진 것이 원인입니다.

문제가 발생했습니다 새로 고침