浏览代码

Merge pull request #145 from Unity-Technologies/parameter-config

Serializing samplers with scenario constants configuration
/main
GitHub 4 年前
当前提交
fd3ebeae
共有 9 个文件被更改,包括 393 次插入86 次删除
  1. 8
      com.unity.perception/CHANGELOG.md
  2. 89
      com.unity.perception/Documentation~/Randomization/Scenarios.md
  3. 20
      com.unity.perception/Editor/Randomization/Editors/RunInUnitySimulationWindow.cs
  4. 22
      com.unity.perception/Editor/Randomization/Editors/ScenarioBaseEditor.cs
  5. 6
      com.unity.perception/Editor/Randomization/Uxml/ScenarioBaseElement.uxml
  6. 211
      com.unity.perception/Runtime/Randomization/Scenarios/Scenario.cs
  7. 61
      com.unity.perception/Runtime/Randomization/Scenarios/ScenarioBase.cs
  8. 21
      com.unity.perception/Runtime/Randomization/Scenarios/UnitySimulationScenario.cs
  9. 41
      com.unity.perception/Tests/Runtime/Randomization/ScenarioTests.cs

8
com.unity.perception/CHANGELOG.md


Added ScenarioConstants base class for all scenario constants objects
Added ScenarioBase.SerializeToConfigFile()
### Changed
Randomizers now access their parent scenario through the static activeScenario property

Replaced ScenarioBase.GenerateRandomSeed() with ScenarioBase.NextRandomSeed()
Replaced ScenarioBase.GenerateRandomSeed() with ScenarioBase.NextRandomState()
ScenarioBase.Serialize() now directly returns the serialized scenario configuration JSON string instead of writing directly to a file (use SerializeToConfigFile() instead)
ScenarioBase.Serialize() now not only serializes scenario constants, but also all sampler member fields on randomizers attached to the scenario
### Deprecated

89
com.unity.perception/Documentation~/Randomization/Scenarios.md


2. Defining a list of randomizers
3. Defining constants that can be configured externally from a built Unity player
By default, the perception package includes one ready-made scenario, the `FixedLengthScenario` class. This scenario runs each iteration for a fixed number of frames and is compatible with the Run in USim window for cloud simulation execution.
By default, the perception package includes one ready-made scenario, the `FixedLengthScenario` class. This scenario runs each iteration for a fixed number of frames and is compatible with the Run in Unity Simulation window for cloud simulation execution.
## Scenario Cloud Execution (USim)
## Scenario Cloud Execution (Unity Simulation)
Users can utilize Unity's Unity Simulation (USim) service to execute a scenario in the cloud through the perception package's Run in USim window. To open this window from the Unity editor using the top menu bar, navigate to `Window -> Run in USim`.
Users can utilize Unity's Unity Simulation service to execute a scenario in the cloud through the perception package's Run in Unity Simulation window. To open this window from the Unity editor using the top menu bar, navigate to `Window -> Run in Unity Simulation`.
From the newly opened editor window, customize the following settings to configure a new USim run:
1. **Run Name** - the name of the USim run (example: TestRun0)
From the newly opened editor window, customize the following settings to configure a new Unity Simulation run:
1. **Run Name** - the name of the Unity Simulation run (example: TestRun0)
3. **Instance Count** - The number of USim worker instances to distribute execution between
3. **Instance Count** - The number of Unity Simulation worker instances to distribute execution between
6. **USim Worker Config** - the type of USim worker instance to execute the scenario with. Determines per instance specifications such as the number of CPU cores, amount of memory, and presence of a GPU for accelerated execution.
6. **Sys-Param** - The system parameters or the hardware configuration of Unity Simulation worker instances to execute the scenario with. Determines per instance specifications such as the number of CPU cores, amount of memory, and presence of a GPU for accelerated execution.
NOTE: To execute a scenario using the Run in USim window, the scenario class must implement the USimScenario class.
NOTE: To execute a scenario using the Run in Unity Simulation window, the scenario class must implement the UnitySimulationScenario class.
## Custom Scenarios

2. **isScenarioComplete** - determines the conditions that cause the end of a scenario
## Constants
Scenarios define constants from which to expose global simulation behaviors like a starting iteration value or a total iteration count. Users can serialize these scenario constants to JSON, modify them in an external program, and finally reimport the JSON constants at runtime to configure their simulation even after their project has been built. Below is an example of the constants class used in the `FixedLengthScenario` class:
## JSON Configuration
Scenarios can be serialized to JSON, modified, and reimported at runtime to configure simulation behavior even after a Unity player has been built. Constants and randomizer sampler settings are the two primary sections generated when serializing a scenario. Note that currently, only numerical samplers are serialized. Below is the contents of a JSON configuration file created when serializing the scenario used in Phase 1 of the [Perception Tutorial](../Tutorial/TUTORIAL.md):
```
{
"constants": {
"framesPerIteration": 1,
"totalIterations": 100,
"instanceCount": 1,
"instanceIndex": 0,
"randomSeed": 123456789
},
"randomizers": {
"HueOffsetRandomizer": {
"hueOffset": {
"value": {
"range": {
"minimum": -180.0,
"maximum": 180.0
}
}
}
},
"RotationRandomizer": {
"rotation": {
"x": {
"range": {
"minimum": 0.0,
"maximum": 360.0
}
},
"y": {
"range": {
"minimum": 0.0,
"maximum": 360.0
}
},
"z": {
"range": {
"minimum": 0.0,
"maximum": 360.0
}
}
}
}
}
}
```
### Constants
Constants can include properties such as starting iteration value or total iteration count, and you can always add your own custom constants. Below is an example of the constants class used in the `FixedLengthScenario` class:
public class Constants : USimConstants
public class Constants : UnitySimulationScenarioConstants
{
public int framesPerIteration = 1;
}

1. The constants class will need to inherit from USimConstants to be compatible with the Run in USim window. Deriving from USimConstants will add a few key properties to the constants class that are needed to coordinate a USim run.
2. Make sure to include the [Serializable] attribute on a constant class. This will ensure that the constants can be manipulated from the Unity inspector.
3. By default, UnityEngine.Object class references cannot be serialized to JSON in a meaningful way. This includes Monobehaviors and SerializedObjects. For more information on what can and can't be serialized, take a look at the [Unity JsonUtility manual](https://docs.unity3d.com/ScriptReference/JsonUtility.html).
4. A scenario class's Serialize() and Deserialized() methods can be overriden to implement custom serialization strategies.
1. The constants class will need to inherit from `UnitySimulationScenarioConstants` to be compatible with the Run in Unity Simulation window. Deriving from `UnitySimulationScenarioConstants` will add a few key properties to the constants class that are needed to coordinate a Unity Simulation run.
2. Make sure to include the `[Serializable]` attribute on a constant class. This will ensure that the constants can be manipulated from the Unity inspector.
3. A scenario class's `SerializeToJson()` and `DeserializeFromJson()` methods can be overriden to implement custom serialization strategies.
Follow the instructions below to generate a constants configuration file to modify your scenario constants in a built player:
1. Click the serialize constants button in the scenario's inspector window. This will generate a constants.json file and place it in the project's Assets/StreamingAssets folder.
2. Build your player. The new player will have a [ProjectName]_Data/StreamingAssets folder. A copy of the constants.json file previously constructed in the editor will be found in this folder.
3. Change the contents of the constants file. Any running player thereafter will utilize the newly authored constants values.
Follow the instructions below to generate a scenario configuration file to modify your scenario constants and randomizers in a built player:
1. Click the serialize constants button in the scenario's inspector window. This will generate a `scenario_configuration.json` file and place it in the project's Assets/StreamingAssets folder.
2. Build your player. The new player will have a [ProjectName]_Data/StreamingAssets folder. A copy of the `scenario_configuration.json` file previously constructed in the editor will be found in this folder.
3. Change the contents of the `scenario_configuration.json` file. Any running player thereafter will utilize the newly authored values.

20
com.unity.perception/Editor/Randomization/Editors/RunInUnitySimulationWindow.cs


using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Unity.Simulation.Client;
using UnityEditor;
using UnityEditor.Build.Reporting;

List<AppParam> GenerateAppParamIds(CancellationToken token)
{
var appParamIds = new List<AppParam>();
var scenario = (ScenarioBase)m_ScenarioField.value;
var configuration = JObject.Parse(scenario.SerializeToJson());
var constants = configuration["constants"];
constants["totalIterations"] = m_TotalIterationsField.value;
constants["instanceCount"]= m_InstanceCountField.value;
var appParamId = API.UploadAppParam(appParamName, new UnitySimulationScenarioConstants
{
totalIterations = m_TotalIterationsField.value,
instanceCount = m_InstanceCountField.value,
instanceIndex = i
});
constants["instanceIndex"]= i;
var appParamsString = JsonConvert.SerializeObject(configuration, Formatting.Indented);
var appParamId = API.UploadAppParam(appParamName, appParamsString);
appParamIds.Add(new AppParam()
{
id = appParamId,

}
return appParamIds;
}

22
com.unity.perception/Editor/Randomization/Editors/ScenarioBaseEditor.cs


m_Root = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(
$"{StaticData.uxmlDir}/ScenarioBaseElement.uxml").CloneTree();
var serializeConstantsButton = m_Root.Q<Button>("serialize-constants");
serializeConstantsButton.clicked += () => m_Scenario.Serialize();
var deserializeConstantsButton = m_Root.Q<Button>("deserialize-constants");
deserializeConstantsButton.clicked += () => m_Scenario.Deserialize();
var serializeConstantsButton = m_Root.Q<Button>("serialize");
serializeConstantsButton.clicked += () =>
{
m_Scenario.SerializeToFile();
AssetDatabase.Refresh();
var newConfigFileAsset = AssetDatabase.LoadAssetAtPath<Object>(m_Scenario.defaultConfigFileAssetPath);
EditorGUIUtility.PingObject(newConfigFileAsset);
};
var deserializeConstantsButton = m_Root.Q<Button>("deserialize");
deserializeConstantsButton.clicked += () =>
{
Undo.RecordObject(m_Scenario, "Deserialized scenario configuration");
m_Scenario.DeserializeFromFile(m_Scenario.defaultConfigFilePath);
};
return m_Root;
}

6
com.unity.perception/Editor/Randomization/Uxml/ScenarioBaseElement.uxml


tooltip="A list of parameters for this scenario that will be JSON serialized."/>
<editor:PropertyField name="configuration-file-name" label="Constants File Name" binding-path="serializedConstantsFileName"/>
<VisualElement style="flex-direction: row;">
<Button name="serialize-constants" text="Serialize Constants" style="flex-grow: 1;"/>
<Button name="deserialize-constants" text="Deserialize Constants" style="flex-grow: 1;"/>
<Button name="serialize" text="Serialize To Config File" style="flex-grow: 1;"
tooltip="Serializes scenario constants and randomizer settings to a JSON configuration file"/>
<Button name="deserialize" text="Deserialize From Config File" style="flex-grow: 1;"
tooltip="Deserializes scenario constants and randomizer settings from a scenario_configuration.json file located in the Assets/StreamingAssets project folder"/>
</VisualElement>
</VisualElement>
</VisualElement>

211
com.unity.perception/Runtime/Randomization/Scenarios/Scenario.cs


using System.IO;
using System;
using System.Collections.Generic;
using System.IO;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEngine.Experimental.Perception.Randomization.Parameters;
using UnityEngine.Experimental.Perception.Randomization.Randomizers;
using UnityEngine.Experimental.Perception.Randomization.Samplers;
namespace UnityEngine.Experimental.Perception.Randomization.Scenarios
{

/// </summary>
public T constants = new T();
/// <summary>
/// Returns this scenario's non-typed serialized constants
/// </summary>
/// <inheritdoc/>
/// <summary>
/// Serializes this scenario's constants to a json file in the Unity StreamingAssets folder
/// </summary>
public override void Serialize()
/// <inheritdoc/>
public override string SerializeToJson()
{
var configObj = new JObject
{
["constants"] = SerializeConstants(),
["randomizers"] = SerializeRandomizers()
};
return JsonConvert.SerializeObject(configObj, Formatting.Indented);
}
JObject SerializeConstants()
{
var constantsObj = new JObject();
var constantsFields = constants.GetType().GetFields();
foreach (var constantsField in constantsFields)
constantsObj.Add(new JProperty(constantsField.Name, constantsField.GetValue(constants)));
return constantsObj;
}
JObject SerializeRandomizers()
{
var randomizersObj = new JObject();
foreach (var randomizer in m_Randomizers)
{
var randomizerObj = SerializeRandomizer(randomizer);
if (randomizerObj.Count > 0)
randomizersObj.Add(new JProperty(randomizer.GetType().Name, randomizerObj));
}
return randomizersObj;
}
static JObject SerializeRandomizer(Randomizer randomizer)
{
var randomizerObj = new JObject();
var parameterFields = randomizer.GetType().GetFields();
foreach (var parameterField in parameterFields)
{
if (!IsSubclassOfRawGeneric(typeof(NumericParameter<>), parameterField.FieldType))
continue;
var parameter = (Parameter)parameterField.GetValue(randomizer);
var parameterObj = SerializeParameter(parameter);
if (parameterObj.Count > 0)
randomizerObj.Add(new JProperty(parameterField.Name, parameterObj));
}
return randomizerObj;
}
static JObject SerializeParameter(Parameter parameter)
{
var parameterObj = new JObject();
var samplerFields = parameter.GetType().GetFields();
foreach (var samplerField in samplerFields)
{
if (samplerField.FieldType != typeof(ISampler))
continue;
var sampler = (ISampler)samplerField.GetValue(parameter);
var samplerObj = SerializeSampler(sampler);
parameterObj.Add(new JProperty(samplerField.Name, samplerObj));
}
return parameterObj;
}
static JObject SerializeSampler(ISampler sampler)
{
var samplerObj = new JObject();
var fields = sampler.GetType().GetFields();
foreach (var field in fields)
samplerObj.Add(new JProperty(field.Name, field.GetValue(sampler)));
if (sampler.GetType() != typeof(ConstantSampler))
{
var rangeProperty = sampler.GetType().GetProperty("range");
if (rangeProperty != null)
{
var range = (FloatRange)rangeProperty.GetValue(sampler);
var rangeObj = new JObject
{
new JProperty("minimum", range.minimum),
new JProperty("maximum", range.maximum)
};
samplerObj.Add(new JProperty("range", rangeObj));
}
}
return samplerObj;
}
/// <inheritdoc/>
public override void DeserializeFromFile(string configFilePath)
{
if (string.IsNullOrEmpty(configFilePath))
throw new ArgumentNullException();
if (!File.Exists(configFilePath))
throw new FileNotFoundException($"A scenario configuration file does not exist at path {configFilePath}");
#if UNITY_EDITOR
Debug.Log($"Deserialized scenario configuration from <a href=\"file:///${configFilePath}\">{configFilePath}</a>. " +
"Using undo in the editor will revert these changes to your scenario.");
#else
Debug.Log($"Deserialized scenario configuration from <a href=\"file:///${configFilePath}\">{configFilePath}</a>");
#endif
var jsonText = File.ReadAllText(configFilePath);
DeserializeFromJson(jsonText);
}
/// <inheritdoc/>
public override void DeserializeFromJson(string json)
{
var jsonObj = JObject.Parse(json);
var constantsObj = (JObject)jsonObj["constants"];
DeserializeConstants(constantsObj);
var randomizersObj = (JObject)jsonObj["randomizers"];
DeserializeRandomizers(randomizersObj);
}
void DeserializeConstants(JObject constantsObj)
{
constants = constantsObj.ToObject<T>();
}
void DeserializeRandomizers(JObject randomizersObj)
Directory.CreateDirectory(Application.dataPath + "/StreamingAssets/");
using (var writer = new StreamWriter(serializedConstantsFilePath, false))
writer.Write(JsonUtility.ToJson(constants, true));
var randomizerTypeMap = new Dictionary<string, Randomizer>();
foreach (var randomizer in randomizers)
randomizerTypeMap.Add(randomizer.GetType().Name, randomizer);
foreach (var randomizerPair in randomizersObj)
{
if (!randomizerTypeMap.ContainsKey(randomizerPair.Key))
continue;
var randomizer = randomizerTypeMap[randomizerPair.Key];
var randomizerObj = (JObject)randomizerPair.Value;
DeserializeRandomizer(randomizer, randomizerObj);
}
/// <summary>
/// Deserializes this scenario's constants from a json file in the Unity StreamingAssets folder
/// </summary>
/// <exception cref="ScenarioException"></exception>
public override void Deserialize()
static void DeserializeRandomizer(Randomizer randomizer, JObject randomizerObj)
if (string.IsNullOrEmpty(serializedConstantsFilePath))
foreach (var parameterPair in randomizerObj)
Debug.Log("No constants file specified. Running scenario with built in constants.");
var parameterField = randomizer.GetType().GetField(parameterPair.Key);
if (parameterField == null)
continue;
var parameter = (Parameter)parameterField.GetValue(randomizer);
var parameterObj = (JObject)parameterPair.Value;
DeserializeParameter(parameter, parameterObj);
else if (File.Exists(serializedConstantsFilePath))
}
static void DeserializeParameter(Parameter parameter, JObject parameterObj)
{
foreach (var samplerPair in parameterObj)
var jsonText = File.ReadAllText(serializedConstantsFilePath);
constants = JsonUtility.FromJson<T>(jsonText);
var samplerField = parameter.GetType().GetField(samplerPair.Key);
if (samplerField == null)
continue;
var sampler = (ISampler)samplerField.GetValue(parameter);
var samplerObj = (JObject)samplerPair.Value;
DeserializeSampler(sampler, samplerObj);
else
}
static void DeserializeSampler(ISampler sampler, JObject samplerObj)
{
foreach (var samplerFieldPair in samplerObj)
Debug.LogWarning($"JSON scenario constants file does not exist at path {serializedConstantsFilePath}");
if (samplerFieldPair.Key == "range")
{
var rangeObj = (JObject)samplerFieldPair.Value;
sampler.range = new FloatRange(
rangeObj["minimum"].ToObject<float>(), rangeObj["maximum"].ToObject<float>());
}
else
{
var field = sampler.GetType().GetField(samplerFieldPair.Key);
if (field != null)
field.SetValue(sampler, ((JValue)samplerFieldPair.Value).Value);
}
}
static bool IsSubclassOfRawGeneric(Type generic, Type toCheck) {
while (toCheck != null && toCheck != typeof(object)) {
var cur = toCheck.IsGenericType ? toCheck.GetGenericTypeDefinition() : toCheck;
if (generic == cur) {
return true;
}
toCheck = toCheck.BaseType;
}
return false;
}
}
}

61
com.unity.perception/Runtime/Randomization/Scenarios/ScenarioBase.cs


using System;
using System.Collections.Generic;
using System.IO;
using Unity.Simulation;
using UnityEngine;
using UnityEngine.Experimental.Perception.Randomization.Randomizers;

/// <summary>
/// The name of the Json file this scenario's constants are serialized to/from.
/// </summary>
public virtual string serializedConstantsFileName => "constants";
public virtual string configFileName => "scenario_configuration";
/// <summary>
/// Returns the active parameter scenario in the scene

}
/// <summary>
/// Returns the file location of the JSON serialized constants
/// Returns the asset location of the JSON serialized configuration.
/// This API is used for finding the config file using the AssetDatabase API.
/// </summary>
public string defaultConfigFileAssetPath =>
"Assets/StreamingAssets/" + configFileName + ".json";
/// <summary>
/// Returns the absolute file path of the JSON serialized configuration
public string serializedConstantsFilePath =>
Application.dataPath + "/StreamingAssets/" + serializedConstantsFileName + ".json";
public string defaultConfigFilePath =>
Application.dataPath + "/StreamingAssets/" + configFileName + ".json";
/// <summary>
/// Returns this scenario's non-typed serialized constants

}
/// <summary>
/// Serializes the scenario's constants to a JSON file located at serializedConstantsFilePath
/// Serializes the scenario's constants and randomizer settings to a JSON string
/// </summary>
/// <returns>The scenario configuration as a JSON string</returns>
public abstract string SerializeToJson();
/// <summary>
/// Serializes the scenario's constants and randomizer settings to a JSON file located at the path resolved by
/// the defaultConfigFilePath scenario property
/// </summary>
public void SerializeToFile()
{
Directory.CreateDirectory(Application.dataPath + "/StreamingAssets/");
using (var writer = new StreamWriter(defaultConfigFilePath, false))
writer.Write(SerializeToJson());
}
/// <summary>
/// Overwrites this scenario's randomizer settings and scenario constants from a JSON serialized configuration
public abstract void Serialize();
/// <param name="json">The JSON string to deserialize</param>
public abstract void DeserializeFromJson(string json);
/// Deserializes constants saved in a JSON file located at serializedConstantsFilePath
/// Overwrites this scenario's randomizer settings and scenario constants using a configuration file located at
/// the provided file path
public abstract void Deserialize();
/// <param name="configFilePath">The file path to the configuration file to deserialize</param>
public abstract void DeserializeFromFile(string configFilePath);
/// <summary>
/// Overwrites this scenario's randomizer settings and scenario constants using a configuration file located at
/// this scenario's defaultConfigFilePath
/// </summary>
public void DeserializeFromFile()
{
DeserializeFromFile(defaultConfigFilePath);
}
/// <summary>
/// This method executed directly after this scenario has been registered and initialized

"The random seed used to initialize the random state of the simulation. Only triggered once per simulation.",
Guid.Parse("14adb394-46c0-47e8-a3f0-99e754483b76"));
DatasetCapture.ReportMetric(randomSeedMetricDefinition, new[] { genericConstants.randomSeed });
Deserialize();
#if !UNITY_EDITOR
if (File.Exists(defaultConfigFilePath))
DeserializeFromFile();
else
Debug.Log($"No configuration file found at {defaultConfigFilePath}. " +
"Proceeding with built in scenario constants and randomizer settings.");
#endif
}
void Update()

21
com.unity.perception/Runtime/Randomization/Scenarios/UnitySimulationScenario.cs


/// <typeparam name="T">The type of constants to serialize</typeparam>
public abstract class UnitySimulationScenario<T> : Scenario<T> where T : UnitySimulationScenarioConstants, new()
{
/// <summary>
/// Returns whether the entire scenario has completed
/// </summary>
/// <inheritdoc/>
/// <summary>
/// Progresses the current scenario iteration
/// </summary>
/// <inheritdoc/>
/// <summary>
/// Deserializes this scenario's constants from the Unity Simulation AppParams Json file
/// </summary>
public sealed override void Deserialize()
/// <inheritdoc/>
public sealed override void DeserializeFromFile(string configFilePath)
if (Configuration.Instance.IsSimulationRunningInCloud())
constants = Configuration.Instance.GetAppParams<T>();
else
base.Deserialize();
base.DeserializeFromFile(Configuration.Instance.IsSimulationRunningInCloud()
? new Uri(Configuration.Instance.SimulationConfig.app_param_uri).LocalPath
: configFilePath);
currentIteration = constants.instanceIndex;
}
}

41
com.unity.perception/Tests/Runtime/Randomization/ScenarioTests.cs


using System.IO;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.Experimental.Perception.Randomization.Randomizers.SampleRandomizers;
using UnityEngine.Perception.GroundTruth;
using UnityEngine.Experimental.Perception.Randomization.Scenarios;
using UnityEngine.TestTools;

}
[UnityTest]
public IEnumerator OverwritesConstantsOnSerialization()
public IEnumerator ScenarioConfigurationSerializesProperly()
{
yield return CreateNewScenario(10, 10);
var scenario = m_Scenario.GetComponent<FixedLengthScenario>();
scenario.CreateRandomizer<HueOffsetRandomizer>();
const string expectedConfig = @"{
""constants"": {
""framesPerIteration"": 10,
""totalIterations"": 10,
""instanceCount"": 1,
""instanceIndex"": 0,
""randomSeed"": 539662031
},
""randomizers"": {
""HueOffsetRandomizer"": {
""hueOffset"": {
""value"": {
""range"": {
""minimum"": -180.0,
""maximum"": 180.0
}
}
}
}
}
}";
Assert.AreEqual(expectedConfig, scenario.SerializeToJson());
}
[UnityTest]
public IEnumerator ScenarioConfigurationOverwrittenDuringDeserialization()
{
yield return CreateNewScenario(10, 10);

// Serialize some values
m_Scenario.constants = constants;
m_Scenario.Serialize();
var serializedConfig = m_Scenario.SerializeToJson();
m_Scenario.Deserialize();
m_Scenario.DeserializeFromJson(serializedConfig);
// Clean up serialized constants
File.Delete(m_Scenario.serializedConstantsFilePath);
yield return null;
}

正在加载...
取消
保存