Unity【Live Capture】- 关于人脸捕捉的解决方案(一)

简介: Unity【Live Capture】- 关于人脸捕捉的解决方案(一)

最近项目里有人脸捕捉的需求,刚开始时参考的下面这篇文章,使用官方发布的Facial AR Remote,需要我们自己构建IOS客户端,因此需要准备包括MacOS操作系统、Xcode等开发环境,在Unity构建出Xcode工程后,还要考虑开发许可证等问题,而且在尝试时,我使用的Xcode13版本,在编译上还有一些问题,比较麻烦。

https://www.163.com/dy/article/E70U8CLT0526E124.html

随后发现了另一个解决方案,即Live Capture,IOS客户端已经发布于App Store中,名称Unity Face Capture:

image.gif

Live Capture在Package Manager中通过git url的方式进行添加,地址:

http://com.unity.live-capture

image.gif

Live Capture官方手册地址:

https://docs.unity.cn/Packages/com.unity.live-capture@1.0/manual/index.html

PDF文档下载地址:

https://link.csdn.net/?target=https%3A%2F%2Fforum.unity.com%2Fattachments%2Flive-capture-apps-startup-guide-pdf.961348%2F

文档很详细,也可以参考下面的步骤:

1.新建空物体,添加Take Recoder组件,它依赖Playable Director组件,添加Take Recorder时自动添加Playable Director:

image.gif

2.将制作好的包含人脸的模型拖入场景,挂载ARKit Face Actor组件,然后将其做成Prefab预制体。

image.gif

3.在Take Recoder组件下方点击+按钮,添加ARKit Face Device,并将其Actor设为步骤2中挂载了ARKit Face Actor组件的人脸模型:

image.gif

4.在Project窗口右键,Create / Live Capture / ARKit Face Capture / Mapper,创建一个Head Mapper资产,将其Rig Prefab设为步骤2中生成的Prefab,并设置人脸模型中对应的的Left Eye、RightEye、Head骨骼节点:

image.gif

5.点击Head Mapper资产中的Add Renderer按钮,绑定BlendShape,名称如果与ARKit中要求一致则会自动绑定好,否则需要一一对应设置:

image.gif

6.将编辑好的Head Mapper资产赋值给步骤2挂载的ARKit Face Actor组件中的Mapper:

image.gif

7.Window / Live Capture / Connections 打开Connections窗口,创建服务器,点击Start即可启动:

image.gif

8.启动后打开IOS客户端,点击Connect进行连接,若连接不上,检查一下手机和电脑是否处于同一网段,检查电脑防火墙,最好都关掉。

image.gif

image.gif

另外值得注意的是,服务端的启动是通过在Connections编辑器窗口中点击Start开启的,因此它的使用环境是在Unity编辑器环境中,如果想打包后运行时使用,需要将其从Package Manager中迁移到工程Assets目录下,并创建脚本编写启动方法:

image.gif

创建LiveCaptureServer类,继承Server:

usingSystem;
usingSystem.IO;
usingSystem.Linq;
usingSystem.Text;
usingUnityEngine;
usingSystem.Collections.Generic;
usingUnity.LiveCapture.Networking;
usingUnity.LiveCapture.Networking.Discovery;
namespaceUnity.LiveCapture.CompanionApp{
    [CreateAssetMenu(menuName="Live Capture/Server")]
publicclassLiveCaptureServer : Server    {
constintk_DefaultPort=9000;
/// <summary>/// The server executes this event when a client has connected./// </summary>publicstaticeventAction<ICompanionAppClient>ClientConnected=delegate { };
/// <summary>/// The server executes this event when a client has disconnected./// </summary>publicstaticeventAction<ICompanionAppClient>ClientDisconnected=delegate { };
structConnectHandler        {
publicstringName;
publicDateTimeTime;
publicFunc<ICompanionAppClient, bool>Handler;
        }
readonlyDictionary<string, Type>s_TypeToClientType=newDictionary<string, Type>();
staticreadonlyList<ConnectHandler>s_ClientConnectHandlers=newList<ConnectHandler>();
/// <summary>/// Adds a callback used to take ownership of a client that has connected./// </summary>/// <param name="handler">The callback function. It must return true if it takes ownership of a client.</param>/// <param name="name">The name of the client to prefer. If set, this handler has priority over clients that have the given name.</param>/// <param name="time">The time used to determine the priority of handlers when many are listening for the same/// client <paramref name="name"/>. More recent values have higher priority.</param>/// <exception cref="ArgumentNullException">Thrown if <paramref name="handler"/> is null.</exception>publicstaticvoidRegisterClientConnectHandler(Func<ICompanionAppClient, bool>handler, stringname, DateTimetime)
        {
if (handler==null)
thrownewArgumentNullException(nameof(handler));
DeregisterClientConnectHandler(handler);
s_ClientConnectHandlers.Add(newConnectHandler            {
Name=name,
Time=time,
Handler=handler,
            });
        }
/// <summary>/// Removes a client connection callback./// </summary>/// <param name="handler">The callback to remove.</param>>/// <exception cref="ArgumentNullException">Thrown if <paramref name="handler"/> is null.</exception>publicstaticvoidDeregisterClientConnectHandler(Func<ICompanionAppClient, bool>handler)
        {
if (handler==null)
thrownewArgumentNullException(nameof(handler));
for (vari=0; i<s_ClientConnectHandlers.Count; i++)
            {
if (s_ClientConnectHandlers[i].Handler==handler)
                {
s_ClientConnectHandlers.RemoveAt(i);
                }
            }
        }
publicvoidInit()
        {
foreach (var (type, attributes) inAttributeUtility.GetAllTypes<ClientAttribute>())
            {
if (!typeof(CompanionAppClient).IsAssignableFrom(type))
                {
Debug.LogError($"{type.FullName} must be assignable from {nameof(CompanionAppClient)} to use the {nameof(ClientAttribute)} attribute.");
continue;
                }
foreach (varattributeinattributes)
                {
s_TypeToClientType[attribute.Type] =type;
                }
            }
        }
        [SerializeField, Tooltip("The TCP port on which the server will listen for incoming connections. Changes to the port only take effect after restarting the server.")]
intm_Port=k_DefaultPort;
        [SerializeField, Tooltip("Start the server automatically after entering play mode.")]
boolm_AutoStartOnPlay=true;
readonlyDiscoveryServerm_Discovery=newDiscoveryServer();
readonlyNetworkServerm_Server=newNetworkServer();
readonlyDictionary<Remote, ICompanionAppClient>m_RemoteToClient=newDictionary<Remote, ICompanionAppClient>();
/// <summary>/// The TCP port on which the server will listen for incoming connections./// </summary>/// <remarks>/// Changes to the port only take effect after restarting the server./// </remarks>publicintPort        {
get=>m_Port;
set            {
if (m_Port!=value)
                {
m_Port=value;
OnServerChanged(true);
                }
            }
        }
/// <summary>/// Start the server automatically after entering play mode./// </summary>publicboolAutoStartOnPlay        {
get=>m_AutoStartOnPlay;
set            {
if (m_AutoStartOnPlay!=value)
                {
m_AutoStartOnPlay=value;
OnServerChanged(true);
                }
            }
        }
/// <summary>/// Are clients able to connect to the server./// </summary>publicboolIsRunning=>m_Server.IsRunning;
/// <summary>/// The number of clients currently connected to the server./// </summary>publicintClientCount=>m_RemoteToClient.Count;
/// <inheritdoc/>protectedoverridevoidOnEnable()
        {
base.OnEnable();
m_Server.RemoteConnected+=OnClientConnected;
m_Server.RemoteDisconnected+=OnClientDisconnected;
        }
/// <inheritdoc/>protectedoverridevoidOnDisable()
        {
base.OnDisable();
m_Discovery.Stop();
m_Server.Stop();
m_Server.RemoteConnected-=OnClientConnected;
m_Server.RemoteDisconnected-=OnClientDisconnected;
        }
/// <summary>/// Gets the currently connected clients./// </summary>/// <returns>A new collection containing the client handles.</returns>publicIEnumerable<ICompanionAppClient>GetClients()
        {
returnm_RemoteToClient.Values;
        }
/// <inheritdoc />publicoverridestringGetName() =>"Companion App Server";
/// <summary>/// Start listening for clients connections./// </summary>publicvoidStartServer()
        {
if (!NetworkUtilities.IsPortAvailable(m_Port))
            {
Debug.LogError($"Unable to start server: Port {m_Port} is in use by another program! Close the other program, or assign a free port using the Live Capture Window.");
return;
            }
if (m_Server.StartServer(m_Port))
            {
// start server discoveryvarconfig=newServerData(
"Live Capture",
Environment.MachineName,
m_Server.ID,
PackageUtility.GetVersion(LiveCaptureInfo.Version)
                );
varendPoints=m_Server.EndPoints.ToArray();
m_Discovery.Start(config, endPoints);
            }
OnServerChanged(false);
        }
/// <summary>/// Disconnects all clients and stop listening for new connections./// </summary>publicvoidStopServer()
        {
m_Server.Stop();
m_Discovery.Stop();
OnServerChanged(false);
        }
/// <inheritdoc/>publicoverridevoidOnUpdate()
        {
m_Server.Update();
m_Discovery.Update();
        }
voidOnClientConnected(Remoteremote)
        {
m_Server.RegisterMessageHandler(remote, InitializeClient, false);
        }
voidOnClientDisconnected(Remoteremote, DisconnectStatusstatus)
        {
if (m_RemoteToClient.TryGetValue(remote, outvarclient))
            {
try                {
ClientDisconnected.Invoke(client);
                }
catch (Exceptione)
                {
Debug.LogError(e);
                }
m_RemoteToClient.Remove(remote);
OnServerChanged(false);
            }
        }
voidInitializeClient(Messagemessage)
        {
try            {
if (message.ChannelType!=ChannelType.ReliableOrdered)
                {
return;
                }
varstreamReader=newStreamReader(message.Data, Encoding.UTF8);
varjson=streamReader.ReadToEnd();
vardata=default(ClientInitialization);
try                {
data=JsonUtility.FromJson<ClientInitialization>(json);
                }
catch (Exception)
                {
Debug.LogError($"{nameof(CompanionAppServer)} failed to initialize client connection! Could not parse JSON: {json}");
return;
                }
if (!s_TypeToClientType.TryGetValue(data.Type, outvarclientType))
                {
Debug.LogError($"Unknown client type \"{data.Type}\" connected to {nameof(CompanionAppServer)}!");
return;
                }
varremote=message.Remote;
varclient=Activator.CreateInstance(clientType, m_Server, remote, data) asCompanionAppClient;
client.SendProtocol();
m_RemoteToClient.Add(remote, client);
AssignOwner(client);
ClientConnected.Invoke(client);
OnServerChanged(false);
            }
catch (Exceptione)
            {
Debug.LogException(e);
            }
finally            {
message.Dispose();
            }
        }
voidAssignOwner(ICompanionAppClientclient)
        {
// connect to the registered handler that was most recently used with this client if possibleforeach (varhandlerins_ClientConnectHandlers.OrderByDescending(h=>h.Time.Ticks))
            {
try                {
if (handler.Name==client.Name)
                    {
if (handler.Handler(client))
return;
                    }
                }
catch (Exceptione)
                {
Debug.LogException(e);
                }
            }
// fall back to the first free device that is compatible with the clientforeach (varhandlerins_ClientConnectHandlers)
            {
try                {
if (handler.Handler(client))
return;
                }
catch (Exceptione)
                {
Debug.LogException(e);
                }
            }
        }
    }
}

image.gif

更改CompanionAppDevice类:

usingSystem;
usingUnityEngine;
namespaceUnity.LiveCapture.CompanionApp{
/// <summary>/// A type of <see cref="LiveCaptureDevice"/> that uses a <see cref="ICompanionAppClient"/> for communication./// </summary>interfaceICompanionAppDevice    {
/// <summary>/// Clears the client assigned to this device./// </summary>voidClearClient();
    }
/// <summary>/// A type of <see cref="LiveCaptureDevice"/> that uses a <see cref="ICompanionAppClient"/> for communication./// </summary>/// <typeparam name="TClient">The type of client this device communicates with.</typeparam>publicabstractclassCompanionAppDevice<TClient> : LiveCaptureDevice, ICompanionAppDevicewhereTClient : class, ICompanionAppClient    {
boolm_ClientRegistered;
boolm_Recording;
TClientm_Client;
readonlySlateChangeTrackerm_SlateChangeTracker=newSlateChangeTracker();
readonlyTakeNameFormatterm_TakeNameFormatter=newTakeNameFormatter();
stringm_LastAssetName;
boolTryGetInternalClient(outICompanionAppClientInternalclient)
        {
client=m_ClientasICompanionAppClientInternal;
returnclient!=null;
        }
/// <summary>/// This function is called when the object becomes enabled and active./// </summary>protectedvirtualvoidOnEnable()
        {
CompanionAppServer.ClientDisconnected+=OnClientDisconnected;
LiveCaptureServer.ClientDisconnected+=OnClientDisconnected;
RegisterClient();
        }
/// <summary>/// This function is called when the behaviour becomes disabled./// </summary>/// <remaks>/// This is also called when the object is destroyed and can be used for any cleanup code.///  When scripts are reloaded after compilation has finished, OnDisable will be called, followed by an OnEnable after the script has been loaded./// </remaks>protectedvirtualvoidOnDisable()
        {
CompanionAppServer.ClientDisconnected-=OnClientDisconnected;
CompanionAppServer.DeregisterClientConnectHandler(OnClientConnected);
LiveCaptureServer.ClientConnected-=OnClientDisconnected;
LiveCaptureServer.DeregisterClientConnectHandler(OnClientConnected);
StopRecording();
UnregisterClient();
        }
/// <summary>/// This function is called when the behaviour gets destroyed./// </summary>protectedoverridevoidOnDestroy()
        {
base.OnDestroy();
ClearClient();
        }
/// <inheritdoc/>publicoverrideboolIsReady()
        {
returnm_Client!=null;
        }
/// <inheritdoc/>publicoverrideboolIsRecording()
        {
returnm_Recording;
        }
/// <inheritdoc/>publicoverridevoidStartRecording()
        {
if (!m_Recording)
            {
m_Recording=true;
OnRecordingChanged();
SendRecordingState();
            }
        }
/// <inheritdoc/>publicoverridevoidStopRecording()
        {
if (m_Recording)
            {
m_Recording=false;
OnRecordingChanged();
SendRecordingState();
            }
        }
/// <summary>/// Gets the client currently assigned to this device./// </summary>/// <returns>The assigned client, or null if none is assigned.</returns>publicTClientGetClient()
        {
returnm_Client;
        }
/// <summary>/// Assigns a client to this device./// </summary>/// <param name="client">The client to assign, or null to clear the assigned client.</param>/// <param name="rememberAssignment">Try to auto-assign the client to this device when it reconnects in the future.</param>publicvoidSetClient(TClientclient, boolrememberAssignment)
        {
if (m_Client!=client)
            {
UnregisterClient();
if (m_Client!=null)
                {
ClientMappingDatabase.DeregisterClientAssociation(this, m_Client, rememberAssignment);
                }
m_Client=client;
if (m_Client!=null)
                {
// if any device is also using this client, we must clear the client from the previous device.if (ClientMappingDatabase.TryGetDevice(client, outvarpreviousDevice))
                    {
previousDevice.ClearClient();
                    }
ClientMappingDatabase.RegisterClientAssociation(this, m_Client, rememberAssignment);
                }
RegisterClient();
            }
        }
voidRegisterClient()
        {
if (!isActiveAndEnabled||m_ClientRegistered)
            {
return;
            }
LiveCaptureServer.DeregisterClientConnectHandler(OnClientConnected);
CompanionAppServer.DeregisterClientConnectHandler(OnClientConnected);
m_SlateChangeTracker.Reset();
if (TryGetInternalClient(outvarclient))
            {
client.SetDeviceMode+=ClientSetDeviceMode;
client.StartRecording+=ClientStartRecording;
client.StopRecording+=ClientStopRecording;
client.StartPlayer+=ClientStartPlayer;
client.StopPlayer+=ClientStopPlayer;
client.PausePlayer+=ClientPausePlayer;
client.SetPlayerTime+=ClientSetPlayerTime;
client.SetSelectedTake+=ClientSetSelectedTake;
client.SetTakeData+=ClientSetTakeData;
client.DeleteTake+=ClientDeleteTake;
client.SetIterationBase+=ClientSetIterationBase;
client.ClearIterationBase+=ClientClearIterationBase;
client.TexturePreviewRequested+=OnTexturePreviewRequested;
OnClientAssigned();
client.SendInitialize();
UpdateClient();
m_ClientRegistered=true;
            }
else            {
ClientMappingDatabase.TryGetClientAssignment(this, outvarclientName, outvartime);
LiveCaptureServer.RegisterClientConnectHandler(OnClientConnected, clientName, time);
CompanionAppServer.RegisterClientConnectHandler(OnClientConnected, clientName, time);
            }
        }
voidUnregisterClient()
        {
if (!m_ClientRegistered)
            {
return;
            }
if (TryGetInternalClient(outvarclient))
            {
OnClientUnassigned();
client.SendEndSession();
client.SetDeviceMode-=ClientSetDeviceMode;
client.StartRecording-=ClientStartRecording;
client.StopRecording-=ClientStopRecording;
client.StartPlayer-=ClientStartPlayer;
client.StopPlayer-=ClientStopPlayer;
client.PausePlayer-=ClientPausePlayer;
client.SetPlayerTime-=ClientSetPlayerTime;
client.SetSelectedTake-=ClientSetSelectedTake;
client.SetTakeData-=ClientSetTakeData;
client.DeleteTake-=ClientDeleteTake;
client.SetIterationBase-=ClientSetIterationBase;
client.ClearIterationBase-=ClientClearIterationBase;
client.TexturePreviewRequested-=OnTexturePreviewRequested;
m_ClientRegistered=false;
            }
        }
/// <inheritdoc />publicvoidClearClient()
        {
SetClient(null, true);
        }
/// <summary>/// Called to send the device state to the client./// </summary>publicvirtualvoidUpdateClient()
        {
vartakeRecorder=GetTakeRecorder();
if (takeRecorder!=null)
            {
SendDeviceState(takeRecorder.IsLive());
varslate=takeRecorder.GetActiveSlate();
varhasSlate=slate!=null;
varslateChanged=m_SlateChangeTracker.Changed(slate);
vartake=hasSlate?slate.Take : null;
varassetName=GetAssetName();
varassetNameChanged=assetName!=m_LastAssetName;
m_LastAssetName=assetName;
if (TryGetInternalClient(outvarclient))
                {
client.SendFrameRate(takeRecorder.IsLive() ||take==null?takeRecorder.FrameRate : take.FrameRate);
client.SendHasSlate(hasSlate);
client.SendSlateDuration(hasSlate?slate.Duration : 0d);
client.SendSlateIsPreviewing(takeRecorder.IsPreviewPlaying());
client.SendSlatePreviewTime(takeRecorder.GetPreviewTime());
if (slateChanged||assetNameChanged)
                    {
if (hasSlate)
m_TakeNameFormatter.ConfigureTake(slate.SceneNumber, slate.ShotName, slate.TakeNumber);
elsem_TakeNameFormatter.ConfigureTake(0, "Shot", 0);
client.SendNextTakeName(m_TakeNameFormatter.GetTakeName());
client.SendNextAssetName(m_TakeNameFormatter.GetAssetName());
                    }
                }
if (slateChanged)
                {
SendSlateDescriptor(slate);
                }
            }
SendRecordingState();
        }
/// <summary>/// Gets the name used for the take asset name./// </summary>/// <returns>The name of the asset.</returns>protectedvirtualstringGetAssetName() { returnname; }
/// <summary>/// The device calls this method when a new client is assigned./// </summary>protectedvirtualvoidOnClientAssigned() {}
/// <summary>/// The device calls this method when the client is unassigned./// </summary>protectedvirtualvoidOnClientUnassigned() {}
/// <summary>/// The device calls this method when the recording state has changed./// </summary>protectedvirtualvoidOnRecordingChanged() {}
/// <summary>/// The device calls this method when the slate has changed./// </summary>protectedvirtualvoidOnSlateChanged(ISlateslate) {}
voidClientStartRecording()
        {
vartakeRecorder=GetTakeRecorder();
if (takeRecorder!=null)
            {
takeRecorder.StartRecording();
            }
Refresh();
        }
voidClientStopRecording()
        {
vartakeRecorder=GetTakeRecorder();
if (takeRecorder!=null)
            {
takeRecorder.StopRecording();
            }
Refresh();
        }
voidClientSetDeviceMode(DeviceModedeviceMode)
        {
vartakeRecorder=GetTakeRecorder();
if (takeRecorder!=null)
            {
takeRecorder.SetLive(deviceMode==DeviceMode.LiveStream);
SendDeviceState(takeRecorder.IsLive());
            }
        }
voidClientStartPlayer()
        {
vartakeRecorder=GetTakeRecorder();
if (takeRecorder!=null)
            {
takeRecorder.PlayPreview();
            }
Refresh();
        }
voidClientStopPlayer()
        {
vartakeRecorder=GetTakeRecorder();
if (takeRecorder!=null)
            {
takeRecorder.PausePreview();
takeRecorder.SetPreviewTime(0d);
            }
Refresh();
        }
voidClientPausePlayer()
        {
vartakeRecorder=GetTakeRecorder();
if (takeRecorder!=null)
            {
takeRecorder.PausePreview();
            }
Refresh();
        }
voidClientSetPlayerTime(doubletime)
        {
vartakeRecorder=GetTakeRecorder();
if (takeRecorder!=null)
            {
takeRecorder.SetPreviewTime(time);
            }
Refresh();
        }
voidSendDeviceState()
        {
vartakeRecorder=GetTakeRecorder();
if (takeRecorder!=null)
            {
SendDeviceState(takeRecorder.IsLive());
            }
        }
voidSendDeviceState(boolisLive)
        {
if (TryGetInternalClient(outvarclient))
            {
client.SendDeviceMode(isLive?DeviceMode.LiveStream : DeviceMode.Playback);
            }
        }
voidSendRecordingState()
        {
if (TryGetInternalClient(outvarclient))
            {
client.SendRecordingState(IsRecording());
            }
        }
voidSendSlateDescriptor()
        {
vartakeRecorder=GetTakeRecorder();
if (takeRecorder!=null)
            {
SendSlateDescriptor(takeRecorder.GetActiveSlate());
            }
        }
voidSendSlateDescriptor(ISlateslate)
        {
if (TryGetInternalClient(outvarclient))
            {
client.SendSlateDescriptor(SlateDescriptor.Create(slate));
            }
OnSlateChanged(slate);
        }
voidClientSetSelectedTake(Guidguid)
        {
vartakeRecorder=GetTakeRecorder();
if (takeRecorder!=null)
            {
TakeManager.Default.SelectTake(takeRecorder.GetActiveSlate(), guid);
SendSlateDescriptor();
Refresh();
            }
        }
voidClientSetTakeData(TakeDescriptordescriptor)
        {
TakeManager.Default.SetTakeData(descriptor);
SendSlateDescriptor();
Refresh();
        }
voidClientDeleteTake(Guidguid)
        {
TakeManager.Default.DeleteTake(guid);
SendSlateDescriptor();
Refresh();
        }
voidClientSetIterationBase(Guidguid)
        {
vartakeRecorder=GetTakeRecorder();
if (takeRecorder!=null)
            {
varslate=takeRecorder.GetActiveSlate();
TakeManager.Default.SetIterationBase(slate, guid);
SendSlateDescriptor(slate);
Refresh();
            }
        }
voidClientClearIterationBase()
        {
vartakeRecorder=GetTakeRecorder();
if (takeRecorder!=null)
            {
varslate=takeRecorder.GetActiveSlate();
TakeManager.Default.ClearIterationBase(slate);
SendSlateDescriptor(slate);
Refresh();
            }
        }
voidOnTexturePreviewRequested(Guidguid)
        {
vartexture=TakeManager.Default.GetAssetPreview<Texture2D>(guid);
if (texture!=null&&TryGetInternalClient(outvarclient))
            {
client.SendTexturePreview(guid, texture);
            }
        }
boolOnClientConnected(ICompanionAppClientclient)
        {
if (m_Client==null&&clientisTClientc&& (!ClientMappingDatabase.TryGetClientAssignment(this, outvarclientName, out_) ||c.Name==clientName))
            {
SetClient(c, false);
returntrue;
            }
returnfalse;
        }
voidOnClientDisconnected(ICompanionAppClientclient)
        {
if (m_Client==client)
            {
SetClient(null, false);
            }
        }
    }
}

image.gif

本人打包后成功运行,测试脚本:

usingUnityEngine;
usingUnity.LiveCapture.CompanionApp;
publicclassLiveCaptureExample : MonoBehaviour{
LiveCaptureServerserver;
privatevoidAwake()
    {
server=Resources.Load<LiveCaptureServer>("Live Capture Server");
server.Init();
server.StartServer();
    }
privatevoidUpdate()
    {
server.OnUpdate();
    }
privatevoidOnDestroy()
    {
server.StopServer();
    }
}

image.gif

目录
相关文章
|
XML 编解码 编译器
Unity跨平台UI解决方案:可能是最全的FairyGUI系列教程-第八天
众所周知,人生是一个漫长的流程,不断克服困难,不断反思前进的过程。在这个过程中会产生很多对于人生的质疑和思考,于是我决定将自己的思考,经验和故事全部分享出来,以此寻找共鸣!!!
1493 0
|
4月前
|
存储 自然语言处理 监控
【Unity 实用工具篇】| 游戏多语言解决方案,官方插件Localization 实现本地化及多种语言切换
Unity的多语言本地化是一个很实用的功能,它可以帮助游戏支持多种语言,让不同语言的玩家都能够更好地体验游戏。 而实现本地化的方案也有很多种,各个方案之间也各有优劣,后面也会对多个方案进行介绍学习。 本文就来介绍一个专门作用于多语言本地化的Unity官方插件:Localization 。 这个插件方便进行游戏的多语言本地化,让游戏支持多种语言,下面就来看看该插件的使用方法吧!
|
7月前
|
编解码 开发工具 图形学
Unity环境下RTMP推流+RTMP播放低延迟解决方案
在本文之前,我们发布了Unity环境下的RTMP推流(Windows平台+Android平台)和RTMP|RTSP拉流(Windows平台+Android平台+iOS平台)低延迟的解决方案,今天做个整体汇总,权当抛砖引玉。
446 0
|
Web App开发 编解码 前端开发
Unity Render Streaming 云渲染解决方案
Unity Render Streaming 云渲染解决方案
954 1
Unity Render Streaming 云渲染解决方案
|
vr&ar 开发工具 图形学
Unity【HTC Vive & Noitom】- 关于动作捕捉的两种解决方案
Unity【HTC Vive & Noitom】- 关于动作捕捉的两种解决方案
301 0
Unity【HTC Vive & Noitom】- 关于动作捕捉的两种解决方案
|
图形学 iOS开发
Unity【Face Cap】- 关于人脸捕捉的解决方案(二)
Unity【Face Cap】- 关于人脸捕捉的解决方案(二)
675 0
Unity【Face Cap】- 关于人脸捕捉的解决方案(二)
|
4月前
|
C# 图形学
【Unity 3D】元宇宙案例之虚拟地球信息射线实战(附源码、演示视频和步骤 超详细)
【Unity 3D】元宇宙案例之虚拟地球信息射线实战(附源码、演示视频和步骤 超详细)
50 0
|
4月前
|
人工智能 自然语言处理 区块链
【Unity 3D】元宇宙概念、应用前景、价值链等概述
【Unity 3D】元宇宙概念、应用前景、价值链等概述
52 0
|
4月前
|
vr&ar C# 图形学
【Unity 3D】VR飞机拆装后零件说明功能案例实战(附源码和演示视频 超详细)
【Unity 3D】VR飞机拆装后零件说明功能案例实战(附源码和演示视频 超详细)
38 0
|
4月前
|
vr&ar C# 图形学
【Unity 3D】VR飞机动态拆装及引擎开关控制案例(附源码和演示视频 超详细)
【Unity 3D】VR飞机动态拆装及引擎开关控制案例(附源码和演示视频 超详细)
40 0