mirror of
https://github.com/MirrorNetworking/Mirror.git
synced 2024-11-18 02:50:32 +00:00
perf: NetworkTransform bare metal position/rotation/scale sync. No more Cmd allocations and avoid redundant transform.transform call
This commit is contained in:
parent
8561f67c12
commit
389cf48064
@ -1,22 +1,6 @@
|
|||||||
// vis2k:
|
// Base class for NetworkTransform and NetworkTransformChild.
|
||||||
// base class for NetworkTransform and NetworkTransformChild.
|
// Simply syncs position/rotation/scale without any interpolation for now.
|
||||||
// New method is simple and stupid. No more 1500 lines of code.
|
// (which means we don't need teleport detection either)
|
||||||
//
|
|
||||||
// Server sends current data.
|
|
||||||
// Client saves it and interpolates last and latest data points.
|
|
||||||
// Update handles transform movement / rotation
|
|
||||||
// FixedUpdate handles rigidbody movement / rotation
|
|
||||||
//
|
|
||||||
// Notes:
|
|
||||||
// * Built-in Teleport detection in case of lags / teleport / obstacles
|
|
||||||
// * Quaternion > EulerAngles because gimbal lock and Quaternion.Slerp
|
|
||||||
// * Syncs XYZ. Works 3D and 2D. Saving 4 bytes isn't worth 1000 lines of code.
|
|
||||||
// * Initial delay might happen if server sends packet immediately after moving
|
|
||||||
// just 1cm, hence we move 1cm and then wait 100ms for next packet
|
|
||||||
// * Only way for smooth movement is to use a fixed movement speed during
|
|
||||||
// interpolation. interpolation over time is never that good.
|
|
||||||
//
|
|
||||||
using System.ComponentModel;
|
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
|
||||||
namespace Mirror
|
namespace Mirror
|
||||||
@ -31,281 +15,53 @@ public abstract class NetworkTransformBase : NetworkBehaviour
|
|||||||
// This component could be on the player object or any object that has been assigned authority to this client.
|
// This component could be on the player object or any object that has been assigned authority to this client.
|
||||||
bool IsClientWithAuthority => hasAuthority && clientAuthority;
|
bool IsClientWithAuthority => hasAuthority && clientAuthority;
|
||||||
|
|
||||||
// Sensitivity is added for VR where human players tend to have micro movements so this can quiet down
|
|
||||||
// the network traffic. Additionally, rigidbody drift should send less traffic, e.g very slow sliding / rolling.
|
|
||||||
[Header("Sensitivity")]
|
|
||||||
[Tooltip("Changes to the transform must exceed these values to be transmitted on the network.")]
|
|
||||||
public float localPositionSensitivity = .01f;
|
|
||||||
[Tooltip("If rotation exceeds this angle, it will be transmitted on the network")]
|
|
||||||
public float localRotationSensitivity = .01f;
|
|
||||||
[Tooltip("Changes to the transform must exceed these values to be transmitted on the network.")]
|
|
||||||
public float localScaleSensitivity = .01f;
|
|
||||||
|
|
||||||
// target transform to sync. can be on a child.
|
// target transform to sync. can be on a child.
|
||||||
protected abstract Transform targetComponent { get; }
|
protected abstract Transform targetComponent { get; }
|
||||||
|
|
||||||
// server
|
|
||||||
Vector3 lastPosition;
|
|
||||||
Quaternion lastRotation;
|
|
||||||
Vector3 lastScale;
|
|
||||||
|
|
||||||
// client
|
|
||||||
public class DataPoint
|
|
||||||
{
|
|
||||||
public float timeStamp;
|
|
||||||
// use local position/rotation for VR support
|
|
||||||
public Vector3 localPosition;
|
|
||||||
public Quaternion localRotation;
|
|
||||||
public Vector3 localScale;
|
|
||||||
public float movementSpeed;
|
|
||||||
}
|
|
||||||
// interpolation start and goal
|
|
||||||
DataPoint start;
|
|
||||||
DataPoint goal;
|
|
||||||
|
|
||||||
// local authority send time
|
// local authority send time
|
||||||
float lastClientSendTime;
|
float lastClientSendTime;
|
||||||
|
|
||||||
// serialization is needed by OnSerialize and by manual sending from authority
|
public override bool OnSerialize(NetworkWriter writer, bool initialState)
|
||||||
// public only for tests
|
|
||||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
|
||||||
public static void SerializeIntoWriter(NetworkWriter writer, Vector3 position, Quaternion rotation, Vector3 scale)
|
|
||||||
{
|
{
|
||||||
// serialize position, rotation, scale
|
// serialize position, rotation, scale
|
||||||
|
// use local position/rotation/scale for VR support
|
||||||
// note: we do NOT compress rotation.
|
// note: we do NOT compress rotation.
|
||||||
// we are CPU constrained, not bandwidth constrained.
|
// we are CPU constrained, not bandwidth constrained.
|
||||||
// the code needs to WORK for the next 5-10 years of development.
|
// the code needs to WORK for the next 5-10 years of development.
|
||||||
writer.WriteVector3(position);
|
writer.WriteVector3(targetComponent.localPosition);
|
||||||
writer.WriteQuaternion(rotation);
|
writer.WriteQuaternion(targetComponent.localRotation);
|
||||||
writer.WriteVector3(scale);
|
writer.WriteVector3(targetComponent.localScale);
|
||||||
}
|
|
||||||
|
|
||||||
public override bool OnSerialize(NetworkWriter writer, bool initialState)
|
|
||||||
{
|
|
||||||
// use local position/rotation/scale for VR support
|
|
||||||
SerializeIntoWriter(writer, targetComponent.transform.localPosition, targetComponent.transform.localRotation, targetComponent.transform.localScale);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// try to estimate movement speed for a data point based on how far it
|
|
||||||
// moved since the previous one
|
|
||||||
// => if this is the first time ever then we use our best guess:
|
|
||||||
// -> delta based on transform.localPosition
|
|
||||||
// -> elapsed based on send interval hoping that it roughly matches
|
|
||||||
static float EstimateMovementSpeed(DataPoint from, DataPoint to, Transform transform, float sendInterval)
|
|
||||||
{
|
|
||||||
Vector3 delta = to.localPosition - (from != null ? from.localPosition : transform.localPosition);
|
|
||||||
float elapsed = from != null ? to.timeStamp - from.timeStamp : sendInterval;
|
|
||||||
// avoid NaN
|
|
||||||
return elapsed > 0 ? delta.magnitude / elapsed : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// serialization is needed by OnSerialize and by manual sending from authority
|
|
||||||
void DeserializeFromReader(NetworkReader reader)
|
|
||||||
{
|
|
||||||
// put it into a data point immediately
|
|
||||||
DataPoint temp = new DataPoint
|
|
||||||
{
|
|
||||||
// deserialize position
|
|
||||||
localPosition = reader.ReadVector3()
|
|
||||||
};
|
|
||||||
|
|
||||||
// deserialize rotation & scale
|
|
||||||
temp.localRotation = reader.ReadQuaternion();
|
|
||||||
temp.localScale = reader.ReadVector3();
|
|
||||||
|
|
||||||
temp.timeStamp = Time.time;
|
|
||||||
|
|
||||||
// movement speed: based on how far it moved since last time
|
|
||||||
// has to be calculated before 'start' is overwritten
|
|
||||||
temp.movementSpeed = EstimateMovementSpeed(goal, temp, targetComponent.transform, syncInterval);
|
|
||||||
|
|
||||||
// reassign start wisely
|
|
||||||
// -> first ever data point? then make something up for previous one
|
|
||||||
// so that we can start interpolation without waiting for next.
|
|
||||||
if (start == null)
|
|
||||||
{
|
|
||||||
start = new DataPoint
|
|
||||||
{
|
|
||||||
timeStamp = Time.time - syncInterval,
|
|
||||||
// local position/rotation for VR support
|
|
||||||
localPosition = targetComponent.transform.localPosition,
|
|
||||||
localRotation = targetComponent.transform.localRotation,
|
|
||||||
localScale = targetComponent.transform.localScale,
|
|
||||||
movementSpeed = temp.movementSpeed
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// -> second or nth data point? then update previous, but:
|
|
||||||
// we start at where ever we are right now, so that it's
|
|
||||||
// perfectly smooth and we don't jump anywhere
|
|
||||||
//
|
|
||||||
// example if we are at 'x':
|
|
||||||
//
|
|
||||||
// A--x->B
|
|
||||||
//
|
|
||||||
// and then receive a new point C:
|
|
||||||
//
|
|
||||||
// A--x--B
|
|
||||||
// |
|
|
||||||
// |
|
|
||||||
// C
|
|
||||||
//
|
|
||||||
// then we don't want to just jump to B and start interpolation:
|
|
||||||
//
|
|
||||||
// x
|
|
||||||
// |
|
|
||||||
// |
|
|
||||||
// C
|
|
||||||
//
|
|
||||||
// we stay at 'x' and interpolate from there to C:
|
|
||||||
//
|
|
||||||
// x..B
|
|
||||||
// \ .
|
|
||||||
// \.
|
|
||||||
// C
|
|
||||||
//
|
|
||||||
else
|
|
||||||
{
|
|
||||||
float oldDistance = Vector3.Distance(start.localPosition, goal.localPosition);
|
|
||||||
float newDistance = Vector3.Distance(goal.localPosition, temp.localPosition);
|
|
||||||
|
|
||||||
start = goal;
|
|
||||||
|
|
||||||
// teleport / lag / obstacle detection: only continue at current
|
|
||||||
// position if we aren't too far away
|
|
||||||
//
|
|
||||||
// local position/rotation for VR support
|
|
||||||
if (Vector3.Distance(targetComponent.transform.localPosition, start.localPosition) < oldDistance + newDistance)
|
|
||||||
{
|
|
||||||
start.localPosition = targetComponent.transform.localPosition;
|
|
||||||
start.localRotation = targetComponent.transform.localRotation;
|
|
||||||
start.localScale = targetComponent.transform.localScale;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// set new destination in any case. new data is best data.
|
|
||||||
goal = temp;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override void OnDeserialize(NetworkReader reader, bool initialState)
|
public override void OnDeserialize(NetworkReader reader, bool initialState)
|
||||||
{
|
{
|
||||||
// deserialize
|
// deserialize
|
||||||
DeserializeFromReader(reader);
|
Vector3 localPosition = reader.ReadVector3();
|
||||||
|
Quaternion localRotation = reader.ReadQuaternion();
|
||||||
|
Vector3 localScale = reader.ReadVector3();
|
||||||
|
|
||||||
|
// apply on client for all players
|
||||||
|
// unless this client has authority over the object. could be
|
||||||
|
// himself or another object that he was assigned authority over
|
||||||
|
if (!IsClientWithAuthority)
|
||||||
|
{
|
||||||
|
ApplyPositionRotationScale(localPosition, localRotation, localScale);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// local authority client sends sync message to server for broadcasting
|
// local authority client sends sync message to server for broadcasting
|
||||||
[Command]
|
[Command]
|
||||||
void CmdClientToServerSync(byte[] payload)
|
void CmdClientToServerSync(Vector3 localPosition, Quaternion localRotation, Vector3 localScale)
|
||||||
{
|
{
|
||||||
// Ignore messages from client if not in client authority mode
|
// Ignore messages from client if not in client authority mode
|
||||||
if (!clientAuthority)
|
if (!clientAuthority)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// deserialize payload
|
|
||||||
using (PooledNetworkReader networkReader = NetworkReaderPool.GetReader(payload))
|
|
||||||
DeserializeFromReader(networkReader);
|
|
||||||
|
|
||||||
// server-only mode does no interpolation to save computations,
|
// server-only mode does no interpolation to save computations,
|
||||||
// but let's set the position directly
|
// but let's set the position directly
|
||||||
if (isServer && !isClient)
|
if (isServer && !isClient)
|
||||||
ApplyPositionRotationScale(goal.localPosition, goal.localRotation, goal.localScale);
|
ApplyPositionRotationScale(localPosition, localRotation, localScale);
|
||||||
|
|
||||||
// set dirty so that OnSerialize broadcasts it
|
|
||||||
SetDirtyBit(1UL);
|
|
||||||
}
|
|
||||||
|
|
||||||
// where are we in the timeline between start and goal? [0,1]
|
|
||||||
static float CurrentInterpolationFactor(DataPoint start, DataPoint goal)
|
|
||||||
{
|
|
||||||
if (start != null)
|
|
||||||
{
|
|
||||||
float difference = goal.timeStamp - start.timeStamp;
|
|
||||||
|
|
||||||
// the moment we get 'goal', 'start' is supposed to
|
|
||||||
// start, so elapsed time is based on:
|
|
||||||
float elapsed = Time.time - goal.timeStamp;
|
|
||||||
// avoid NaN
|
|
||||||
return difference > 0 ? elapsed / difference : 0;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
static Vector3 InterpolatePosition(DataPoint start, DataPoint goal, Vector3 currentPosition)
|
|
||||||
{
|
|
||||||
if (start != null)
|
|
||||||
{
|
|
||||||
// Option 1: simply interpolate based on time. but stutter
|
|
||||||
// will happen, it's not that smooth. especially noticeable if
|
|
||||||
// the camera automatically follows the player
|
|
||||||
// float t = CurrentInterpolationFactor();
|
|
||||||
// return Vector3.Lerp(start.position, goal.position, t);
|
|
||||||
|
|
||||||
// Option 2: always += speed
|
|
||||||
// -> speed is 0 if we just started after idle, so always use max
|
|
||||||
// for best results
|
|
||||||
float speed = Mathf.Max(start.movementSpeed, goal.movementSpeed);
|
|
||||||
return Vector3.MoveTowards(currentPosition, goal.localPosition, speed * Time.deltaTime);
|
|
||||||
}
|
|
||||||
return currentPosition;
|
|
||||||
}
|
|
||||||
|
|
||||||
static Quaternion InterpolateRotation(DataPoint start, DataPoint goal, Quaternion defaultRotation)
|
|
||||||
{
|
|
||||||
if (start != null)
|
|
||||||
{
|
|
||||||
float t = CurrentInterpolationFactor(start, goal);
|
|
||||||
return Quaternion.Slerp(start.localRotation, goal.localRotation, t);
|
|
||||||
}
|
|
||||||
return defaultRotation;
|
|
||||||
}
|
|
||||||
|
|
||||||
static Vector3 InterpolateScale(DataPoint start, DataPoint goal, Vector3 currentScale)
|
|
||||||
{
|
|
||||||
if (start != null)
|
|
||||||
{
|
|
||||||
float t = CurrentInterpolationFactor(start, goal);
|
|
||||||
return Vector3.Lerp(start.localScale, goal.localScale, t);
|
|
||||||
}
|
|
||||||
return currentScale;
|
|
||||||
}
|
|
||||||
|
|
||||||
// teleport / lag / stuck detection
|
|
||||||
// -> checking distance is not enough since there could be just a tiny
|
|
||||||
// fence between us and the goal
|
|
||||||
// -> checking time always works, this way we just teleport if we still
|
|
||||||
// didn't reach the goal after too much time has elapsed
|
|
||||||
bool NeedsTeleport()
|
|
||||||
{
|
|
||||||
// calculate time between the two data points
|
|
||||||
float startTime = start != null ? start.timeStamp : Time.time - syncInterval;
|
|
||||||
float goalTime = goal != null ? goal.timeStamp : Time.time;
|
|
||||||
float difference = goalTime - startTime;
|
|
||||||
float timeSinceGoalReceived = Time.time - goalTime;
|
|
||||||
return timeSinceGoalReceived > difference * 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
// moved since last time we checked it?
|
|
||||||
bool HasEitherMovedRotatedScaled()
|
|
||||||
{
|
|
||||||
// moved or rotated or scaled?
|
|
||||||
// local position/rotation/scale for VR support
|
|
||||||
bool moved = Vector3.Distance(lastPosition, targetComponent.transform.localPosition) > localPositionSensitivity;
|
|
||||||
bool scaled = Vector3.Distance(lastScale, targetComponent.transform.localScale) > localScaleSensitivity;
|
|
||||||
bool rotated = Quaternion.Angle(lastRotation, targetComponent.transform.localRotation) > localRotationSensitivity;
|
|
||||||
|
|
||||||
// save last for next frame to compare
|
|
||||||
// (only if change was detected. otherwise slow moving objects might
|
|
||||||
// never sync because of C#'s float comparison tolerance. see also:
|
|
||||||
// https://github.com/vis2k/Mirror/pull/428)
|
|
||||||
bool change = moved || rotated || scaled;
|
|
||||||
if (change)
|
|
||||||
{
|
|
||||||
// local position/rotation for VR support
|
|
||||||
lastPosition = targetComponent.transform.localPosition;
|
|
||||||
lastRotation = targetComponent.transform.localRotation;
|
|
||||||
lastScale = targetComponent.transform.localScale;
|
|
||||||
}
|
|
||||||
return change;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// set position carefully depending on the target component
|
// set position carefully depending on the target component
|
||||||
@ -322,9 +78,8 @@ void Update()
|
|||||||
// if server then always sync to others.
|
// if server then always sync to others.
|
||||||
if (isServer)
|
if (isServer)
|
||||||
{
|
{
|
||||||
// just use OnSerialize via SetDirtyBit only sync when position
|
// dirty at all times. sync each syncInterval.
|
||||||
// changed. set dirty bits 0 or 1
|
SetDirtyBit(1UL);
|
||||||
SetDirtyBit(HasEitherMovedRotatedScaled() ? 1UL : 0UL);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// no 'else if' since host mode would be both
|
// no 'else if' since host mode would be both
|
||||||
@ -337,87 +92,14 @@ void Update()
|
|||||||
// check only each 'syncInterval'
|
// check only each 'syncInterval'
|
||||||
if (Time.time - lastClientSendTime >= syncInterval)
|
if (Time.time - lastClientSendTime >= syncInterval)
|
||||||
{
|
{
|
||||||
if (HasEitherMovedRotatedScaled())
|
// send to server
|
||||||
{
|
CmdClientToServerSync(targetComponent.transform.localPosition,
|
||||||
// serialize
|
targetComponent.transform.localRotation,
|
||||||
// local position/rotation for VR support
|
targetComponent.transform.localScale);
|
||||||
using (PooledNetworkWriter writer = NetworkWriterPool.GetWriter())
|
|
||||||
{
|
|
||||||
SerializeIntoWriter(writer, targetComponent.transform.localPosition, targetComponent.transform.localRotation, targetComponent.transform.localScale);
|
|
||||||
|
|
||||||
// send to server
|
|
||||||
CmdClientToServerSync(writer.ToArray());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lastClientSendTime = Time.time;
|
lastClientSendTime = Time.time;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// apply interpolation on client for all players
|
|
||||||
// unless this client has authority over the object. could be
|
|
||||||
// himself or another object that he was assigned authority over
|
|
||||||
if (!IsClientWithAuthority)
|
|
||||||
{
|
|
||||||
// received one yet? (initialized?)
|
|
||||||
if (goal != null)
|
|
||||||
{
|
|
||||||
// teleport or interpolate
|
|
||||||
if (NeedsTeleport())
|
|
||||||
{
|
|
||||||
// local position/rotation for VR support
|
|
||||||
ApplyPositionRotationScale(goal.localPosition, goal.localRotation, goal.localScale);
|
|
||||||
|
|
||||||
// reset data points so we don't keep interpolating
|
|
||||||
start = null;
|
|
||||||
goal = null;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// local position/rotation for VR support
|
|
||||||
ApplyPositionRotationScale(InterpolatePosition(start, goal, targetComponent.transform.localPosition),
|
|
||||||
InterpolateRotation(start, goal, targetComponent.transform.localRotation),
|
|
||||||
InterpolateScale(start, goal, targetComponent.transform.localScale));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static void DrawDataPointGizmo(DataPoint data, Color color)
|
|
||||||
{
|
|
||||||
// use a little offset because transform.localPosition might be in
|
|
||||||
// the ground in many cases
|
|
||||||
Vector3 offset = Vector3.up * 0.01f;
|
|
||||||
|
|
||||||
// draw position
|
|
||||||
Gizmos.color = color;
|
|
||||||
Gizmos.DrawSphere(data.localPosition + offset, 0.5f);
|
|
||||||
|
|
||||||
// draw forward and up
|
|
||||||
// like unity move tool
|
|
||||||
Gizmos.color = Color.blue;
|
|
||||||
Gizmos.DrawRay(data.localPosition + offset, data.localRotation * Vector3.forward);
|
|
||||||
|
|
||||||
// like unity move tool
|
|
||||||
Gizmos.color = Color.green;
|
|
||||||
Gizmos.DrawRay(data.localPosition + offset, data.localRotation * Vector3.up);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void DrawLineBetweenDataPoints(DataPoint data1, DataPoint data2, Color color)
|
|
||||||
{
|
|
||||||
Gizmos.color = color;
|
|
||||||
Gizmos.DrawLine(data1.localPosition, data2.localPosition);
|
|
||||||
}
|
|
||||||
|
|
||||||
// draw the data points for easier debugging
|
|
||||||
void OnDrawGizmos()
|
|
||||||
{
|
|
||||||
// draw start and goal points
|
|
||||||
if (start != null) DrawDataPointGizmo(start, Color.gray);
|
|
||||||
if (goal != null) DrawDataPointGizmo(goal, Color.white);
|
|
||||||
|
|
||||||
// draw line between them
|
|
||||||
if (start != null && goal != null) DrawLineBetweenDataPoints(start, goal, Color.cyan);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,24 +0,0 @@
|
|||||||
using NUnit.Framework;
|
|
||||||
using UnityEngine;
|
|
||||||
|
|
||||||
namespace Mirror.Tests
|
|
||||||
{
|
|
||||||
[TestFixture]
|
|
||||||
public class NetworkTransformTest
|
|
||||||
{
|
|
||||||
[Test]
|
|
||||||
public void SerializeIntoWriterTest()
|
|
||||||
{
|
|
||||||
NetworkWriter writer = new NetworkWriter();
|
|
||||||
Vector3 position = new Vector3(1, 2, 3);
|
|
||||||
Quaternion rotation = new Quaternion(0.1f, 0.2f, 0.3f, 0.4f);
|
|
||||||
Vector3 scale = new Vector3(0.5f, 0.6f, 0.7f);
|
|
||||||
|
|
||||||
NetworkTransformBase.SerializeIntoWriter(writer, position, rotation, scale);
|
|
||||||
NetworkReader reader = new NetworkReader(writer.ToArray());
|
|
||||||
Assert.That(reader.ReadVector3(), Is.EqualTo(position));
|
|
||||||
Assert.That(reader.ReadQuaternion(), Is.EqualTo(rotation));
|
|
||||||
Assert.That(reader.ReadVector3(), Is.EqualTo(scale));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,11 +0,0 @@
|
|||||||
fileFormatVersion: 2
|
|
||||||
guid: 3114c5bb742fe41c09ec88e90ac29ef4
|
|
||||||
MonoImporter:
|
|
||||||
externalObjects: {}
|
|
||||||
serializedVersion: 2
|
|
||||||
defaultReferences: []
|
|
||||||
executionOrder: 0
|
|
||||||
icon: {instanceID: 0}
|
|
||||||
userData:
|
|
||||||
assetBundleName:
|
|
||||||
assetBundleVariant:
|
|
Loading…
Reference in New Issue
Block a user