How to sync Child Transforms of a GameObject with PUN2 in Unity3D
![How to sync Child Transforms of a GameObject with PUN2 in Unity3D](/static/464dc07883ad8ee02aaa29ae7c7bb5a4/c61e6/sync-child-transforms-unity3d-pun2-lorenzo-herrera.jpg)
Photon Unity Networking v2 (PUN2) is the networking package
you end up using when you realize the Unity3D project you’re working on will need:
- A WebGL build
- Voice Chat support
The alternative, Netcode for GameObjects
, the networking package provided by Unity3D itself, is a decent solution for sure (according to me, having worked on it for only a week). But the voice service provided by Unity - Vivox doesn’t support voice over WebGL.
The point is, PUN2 exists and its voice service works over WebGL! Can you read the excitement in my words? That’s me with 5 years of Game Dev experience now. I’ve heard it’s only gonna get better.
Anyway, you probably know how to set up a prefab
which will be instantiated over the internet.
Or if you don’t - that’s fine. I’ll explain. I’ll skip the steps about importing PUN2 and the App ID setup. You can find that stuff anywhere.
-
Have a prefab in your
Resources
folder. -
Add a
PhotonView
component to your prefab’s root. Let’s assume it’s a basic Cube. We don’t care about syncing animations for now. -
Now add a
PhotonTransformView
component to your alongside thePhotonView
(i.e., on the same root). This component will be responsible for syncing ourTransform
over the network. -
Add this code to instantiate your
prefab
:PhotonNetwork.Instantiate(prefabPath, Vector3.zero, Quaternion.identity);
-
Here
prefabPath
would be the path to your prefab in theResources
folder. And theVector3
andQuaternion
are your position and rotation respectively.
And that’s it. Your Cube (assuming it has a script updating its position/rotation) will reflect the changes to the other players.
There’s a catch here of course.
Say, there’s a Cube with a script that rotates the Cube along the Y-axis
, and there’s a child of this Cube, a Capsule.
Now, it’s fair to assume that the Capsule’s movement should also be reflected to other players over the network.
But of course, that doesn’t work. SMH.
So, what do we do?
Now I’ll pay a little lip-service to the idea of adding another PhotonView
and another PhotonTransformView
to the child (Capsule in our case). But I could never get that solution to work. Maybe it’s supposed to, maybe it’s not.
Either way, I have a different solution.
The PhotonTransformView
implements an interface called IPunObservable
.
public class PhotonTransformView : MonoBehaviour, IPunObservable
That’s what you wanna inherit from if you wanna share data over thenetwork stream
. This gives you the method — OnPhotonSerializeView
, which gets called every time there is a change in the stream
. This method
also provides us with the stream object
itself.
All we need to do is check if the stream is currently reading
or writing
, which is the equivalent of sending/receiving data.
One thing to note here is that the order in which the data is received is the same order in which it is sent.
So here’s the gist of the idea — we handle the syncing ourselves.
Here’s a bad way of doing this:
using Photon.Pun;
using UnityEngine;
public class ChildTransformSync : MonoBehaviourPun, IPunObservable
{
[SerializeField] private Transform childTransform;
public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
{
if (stream.IsWriting)
{
// Send the current position and rotation to other players
stream.SendNext(childTransform.localPosition);
stream.SendNext(childTransform.localRotation);
}
else
{
// Receive the position and rotation from other players
childTransform.localPosition= (Vector3)stream.ReceiveNext();
childTransform.localRotation= (Quaternion)stream.ReceiveNext();
}
}
}
All you gotta do with this component is:
- Attach it to the Capsule.
- Add it to the
Observed Components
list in your parent’s (Cube)PhotonView
.
Here are my issues with this script:
- I gotta add this
component
to everychild
I need. And make sure they are observed by the parent’sPhotonView
. - I’m sending the
position
for this Capsule overstream
— an object which is never going to move. Only rotate. That too, only in theY-axis
. Why should I clog up the stream? - There is no
interpolation
for these sync ups. So, the movement/rotation is gonna snap all over the place.
Let’s first handle the first problem. The code handles the syncing for one child, so let’s DRY it up so that it syncs up all the Transforms
we want.
using UnityEngine;
using Photon.Pun;
public class TransformSync: MonoBehaviourPun, IPunObservable
{
[SerializeField] private Transform[] transforms; // Array of transforms to sync
public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
{
if (stream.IsWriting)
{
// Send data to others
foreach (var t in transforms)
{
if (t != null)
{
stream.SendNext(t.position);
stream.SendNext(t.rotation);
stream.SendNext(t.localScale);
}
}
}
else
{
// Receive data
foreach (var t in transforms)
{
if (t != null)
{
t.position = (Vector3)stream.ReceiveNext();
t.rotation = (Quaternion)stream.ReceiveNext();
t.localScale = (Vector3)stream.ReceiveNext();
}
}
}
}
}
Next, let’s take care of the interpolation
, by making a struct
to hold the data we get from the stream:
[System.Serializable]
public struct TransformData
{
public Vector3 position;
public Quaternion rotation;
public Vector3 scale;
public TransformData(Vector3 pos, Quaternion rot, Vector3 scl)
{
position = pos;
rotation = rot;
scale = scl;
}
}
Now, let’s use an array
of these structs
to hold the received data from the stream instead of setting it right when it’s received.
We’ll also ensure that this array
is populated at the start with the initial positions of these transforms
. We don’t want any goof-ups. Since, we are dealing with structs
here, not classes
, struct
references(when they are not initialized in code) take up default values
which can screw up our calculations.
Once we have the data — we can lerp
to the network positions, instead of snapping to them.
using UnityEngine;
using Photon.Pun;
public class TransformsSync : MonoBehaviourPun, IPunObservable
{
[SerializeField] private Transform[] transforms; // Array of transforms to sync
[SerializeField] private float interpolationSpeed = 10f; // Speed of interpolation
private TransformData[] _targetTransformData; // Target transform data to interpolate towards
void Start()
{
_targetTransformData = new TransformData[transforms.Length];
for (int i = 0; i < transforms.Length; i++)
{
if (transforms[i] != null)
{
_targetTransformData[i] = new TransformData(transforms[i].position, transforms[i].rotation, transforms[i].localScale);
}
}
}
void Update()
{
if (!photonView.IsMine)
{
for (int i = 0; i < transforms.Length; i++)
{
if (transforms[i] != null)
{
transforms[i].position = Vector3.Lerp(transforms[i].position, _targetTransformData[i].position, Time.deltaTime * interpolationSpeed);
transforms[i].rotation = Quaternion.Lerp(transforms[i].rotation, _targetTransformData[i].rotation, Time.deltaTime * interpolationSpeed);
transforms[i].localScale = Vector3.Lerp(transforms[i].localScale, _targetTransformData[i].scale, Time.deltaTime * interpolationSpeed);
}
}
}
}
public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
{
if (stream.IsWriting)
{
// Send data to others
foreach (var childTransform in transforms)
{
if (childTransform != null)
{
stream.SendNext(childTransform.position);
stream.SendNext(childTransform.rotation);
stream.SendNext(childTransform.localScale);
}
}
}
else
{
// Receive data and update target transform data
for (int i = 0; i < transforms.Length; i++)
{
if (transforms[i] != null)
{
_targetTransformData[i].position = (Vector3)stream.ReceiveNext();
_targetTransformData[i].rotation = (Quaternion)stream.ReceiveNext();
_targetTransformData[i].scale = (Vector3)stream.ReceiveNext();
}
}
}
}
}
You might’ve noticed a check in the Update method
:
private void Update()
{
if (!photonView.IsMine)
{
...
}
}
This ensures our interpolation
will only occur for objects belonging to other clients. Of course, we’d be handling our Capsule rotation ourselves so our calculated rotations will be perfectly accurate already.
So interpolation
is resolved. Now, all we need to do is:
- Attach this script to the
prefab root
. - Fill the
transforms
list with theTransform
components we want to sync up. - Get rid of any
PhotonTransformView
components — we don’t need help. - Add this component to the
Observed Components
list in ourPhotonView
.
Now, technically we have achieved, what we set out to do. You can even stop reading at this point. But, let’s see if we can handle limiting the data sent/received over stream
.
Why? Because we are programmers and we love optimizing things.
Let’s first create another struct
called TransformSyncSettings
. This will determine which values we want to sync up. It will consist of 3 bools
— x, y & z. Each determining the axis
we want to sync.
This structure makes sense for position and scale but for rotations we should have an additional w
parameter as they are Quaternions
- though let’s stick with Euler angles
for now.
[System.Serializable]
public struct SyncSettings
{
public bool x, y, z;
}
Let’s get rid of the TransformData
class and rename it to TransformSyncData
. It sounds cleaner.
This struct
will hold a Transform
reference, also a SyncSettings
references for position, rotation and scale, and the 3 Vectors
(NonSerialized
) which will hold on to the network target positions for interpolation
.
[System.Serializable]
public struct TransformSyncData
{
public UnityEngine.Transform transform;
public SyncSettings positionSync;
public SyncSettings rotationSync;
public SyncSettings scaleSync;
[HideInInspector] public Vector3 targetPosition;
[HideInInspector] public Vector3 targetRotation;
[HideInInspector] public Vector3 targetScale;
public TransformSyncData(UnityEngine.Transform transform)
{
this.transform = transform;
positionSync = new SyncSettings { x = true, y = true, z = true };
rotationSync = new SyncSettings { x = true, y = true, z = true };
scaleSync = new SyncSettings { x = true, y = true, z = true };
targetPosition = transform.position;
targetRotation = transform.rotation.eulerAngles;
targetScale = transform.localScale;
}
}
Now, in our TransformsSync
class, let’s adjust the Start
method so we correctly initialize the positions of our synced transforms. We’ll also update the old TransformData
array reference to be of the TransformSyncData
type.
[Header("Transform Sync")]
[SerializeField]
private TransformSyncData[] transformsToSync;
private void Start()
{
for (int i = 0; i < transformsToSync.Length; i++)
{
if (transformsToSync[i].transform == null) continue;
transformsToSync[i].targetPosition = transformsToSync[i].transform.position;
transformsToSync[i].targetRotation = transformsToSync[i].transform.rotation.eulerAngles;
transformsToSync[i].targetScale = transformsToSync[i].transform.localScale;
}
}
Now, let’s take a look at our OnPhotonSerializeView
code.
We wanna make use of our SyncSettings
and only send/receive data which is explicitly defined to be sent through the inspector
.
This is how we’ll handle the sending:
for (int i = 0; i < transformsToSync.Length; i++)
{
if (transformsToSync[i].transform == null) continue;
// Write only the necessary values based on sync settings
if (transformsToSync[i].positionSync.x)
stream.SendNext(transformsToSync[i].transform.position.x);
if (transformsToSync[i].positionSync.y)
stream.SendNext(transformsToSync[i].transform.position.y);
if (transformsToSync[i].positionSync.z)
stream.SendNext(transformsToSync[i].transform.position.z);
if (transformsToSync[i].rotationSync.x)
stream.SendNext(transformsToSync[i].transform.eulerAngles.x);
if (transformsToSync[i].rotationSync.y)
stream.SendNext(transformsToSync[i].transform.eulerAngles.y);
if (transformsToSync[i].rotationSync.z)
stream.SendNext(transformsToSync[i].transform.eulerAngles.z);
if (transformsToSync[i].scaleSync.x)
stream.SendNext(transformsToSync[i].transform.localScale.x);
if (transformsToSync[i].scaleSync.y)
stream.SendNext(transformsToSync[i].transform.localScale.y);
if (transformsToSync[i].scaleSync.z)
stream.SendNext(transformsToSync[i].transform.localScale.z);
}
And this, is how we’ll read that data:
for (int i = 0; i < transformsToSync.Length; i++)
{
if (transformsToSync[i].transform == null) continue;
// Read only the necessary values based on sync settings
Vector3 receivedPosition = new Vector3(
transformsToSync[i].positionSync.x
? (float)stream.ReceiveNext()
: transformsToSync[i].transform.position.x,
transformsToSync[i].positionSync.y
? (float)stream.ReceiveNext()
: transformsToSync[i].transform.position.y,
transformsToSync[i].positionSync.z
? (float)stream.ReceiveNext()
: transformsToSync[i].transform.position.z
);
transformsToSync[i].targetPosition = receivedPosition;
Vector3 receivedRotation = new Vector3(
transformsToSync[i].rotationSync.x
? (float)stream.ReceiveNext()
: transformsToSync[i].transform.eulerAngles.x,
transformsToSync[i].rotationSync.y
? (float)stream.ReceiveNext()
: transformsToSync[i].transform.eulerAngles.y,
transformsToSync[i].rotationSync.z
? (float)stream.ReceiveNext()
: transformsToSync[i].transform.eulerAngles.z
);
transformsToSync[i].targetRotation = receivedRotation;
Vector3 receivedScale = new Vector3(
transformsToSync[i].scaleSync.x
? (float)stream.ReceiveNext()
: transformsToSync[i].transform.localScale.x,
transformsToSync[i].scaleSync.y
? (float)stream.ReceiveNext()
: transformsToSync[i].transform.localScale.y,
transformsToSync[i].scaleSync.z
? (float)stream.ReceiveNext()
: transformsToSync[i].transform.localScale.z
);
transformsToSync[i].targetScale = receivedScale;
}
Moving on to interpolation
, we want to make sure we only interpolate the values which we have explicitly stated to be sent/received making use of the SyncSettings
bools.
for (int i = 0; i < transformsToSync.Length; i++)
{
if (transformsToSync[i].transform == null) continue;
TransformSyncData data = transformsToSync[i];
Vector3 currentPos = data.transform.position;
Vector3 currentRot = data.transform.eulerAngles;
Vector3 currentScale = data.transform.localScale;
float t = Time.deltaTime * PhotonNetwork.SerializationRate;
// Interpolate position if required
if (data.positionSync.x)
{
currentPos.x = Mathf.Lerp(currentPos.x, data.targetPosition.x, t);
}
if (data.positionSync.y)
{
currentPos.y = Mathf.Lerp(currentPos.y, data.targetPosition.y, t);
}
if (data.positionSync.z)
{
currentPos.z = Mathf.Lerp(currentPos.z, data.targetPosition.z, t);
}
// Interpolate rotation if required
if (data.rotationSync.x)
{
currentRot.x = Mathf.LerpAngle(currentRot.x, data.targetRotation.x, t);
}
if (data.rotationSync.y)
{
currentRot.y = Mathf.LerpAngle(currentRot.y, data.targetRotation.y, t);
}
if (data.rotationSync.z)
{
currentRot.z = Mathf.LerpAngle(currentRot.z, data.targetRotation.z, t);
}
// Interpolate scale if required
if (data.scaleSync.x)
{
currentScale.x = Mathf.Lerp(currentScale.x, data.targetScale.x, t);
}
if (data.scaleSync.y)
{
currentScale.y = Mathf.Lerp(currentScale.y, data.targetScale.y, t);
}
if (data.scaleSync.z)
{
currentScale.z = Mathf.Lerp(currentScale.z, data.targetScale.z, t);
}
// Update transform
data.transform.position = currentPos;
data.transform.eulerAngles = currentRot;
data.transform.localScale = currentScale;
}
You’ll notice I’ve gotten rid of the interpolationSpeed
factor here, instead opting for PhotonNetwork.SerializationRate
. I took that cue from the original PhotonTransformView
class.
Can I explain more about it? No, I can’t.
Now, in the inspector add the Cube and the Capsule to the transformsToSync
list. And, adjust the SyncSettings
booleans to your heart’s content.
Finally, at long last, we are done. Really done! 100% absolutely …
Yeah, there’s an issue.
If you run a build with this code you’ll notice that if the Host’s Capsule rotated somewhat before a Client joined — the joining Client would only see the default rotation set in the prefab
itself. Any updates to the rotation afterwards will reflect perfectly on the client but that initial situation is an issue.
Riddle me this: which got called first Update
or OnPhotonSerializeView
?
That’s right. It’s unclear. On some machines, maybe Update
got called first, on others, maybe it was OnPhotonSerializeView
. They are not linked; one is never guaranteed to be called after the other.
So let’s be responsible programmers and handle that initial situation. All we gotta do is make sure our updates, i.e., our interpolation
, only happens once we have started receiving data over the network.
I’m sure you can figure out how to do that… 😉