Skip to content

Commit

Permalink
Improve asset saving performance (#479)
Browse files Browse the repository at this point in the history
* feat: new, more efficient serialization APIs

* fix: unpacking generated assets breaks references

Also implement support for subassets.

* chore: remove dead code and fix tests
  • Loading branch information
bdunderscore authored Nov 28, 2024
1 parent 2dcda1b commit 8212db6
Show file tree
Hide file tree
Showing 19 changed files with 522 additions and 112 deletions.
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`Mesh.RecalculateUVDistributionMetrics` on generated meshes.
- [#478] Added `ProfilerScope` API
- [#480] Added `IExtensionContext.Owner` API. Setting this property will allow errors to be correctly attributed to the
plugin that contains an extension context.
plugin that contains an extension context.
- [#479] Added `IAssetSaver` and `SerializationScope` APIs.
- NDMF plugins are encouraged to use `IAssetSaver.SaveAsset` instead of directly accessing `AssetContainer`. This will
split saved assets across multiple files, to avoid performance degradation as the number of assets in a container
grows.

### Fixed
- [#479] Unpacking generated asset containers can break inter-asset references

### Changed

Expand Down
83 changes: 23 additions & 60 deletions Editor/API/BuildContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using JetBrains.Annotations;
using nadena.dev.ndmf.reporting;
using nadena.dev.ndmf.runtime;
using nadena.dev.ndmf.ui;
Expand Down Expand Up @@ -43,6 +44,7 @@ public void Dispose()
/// The BuildContext is passed to all plugins during the build process. It provides access to the avatar being
/// built, as well as various other context information.
/// </summary>
[PublicAPI]
public sealed partial class BuildContext
{
private readonly GameObject _avatarRootObject;
Expand Down Expand Up @@ -70,8 +72,10 @@ public sealed partial class BuildContext
/// referenced by the avatar to this container when the build completes, but in some cases it can be necessary
/// to manually save assets (e.g. when using AnimatorController builtins).
/// </summary>
public UnityObject AssetContainer { get; private set; }
public UnityObject AssetContainer => AssetSaver.CurrentContainer;

public IAssetSaver AssetSaver { get; }

public bool Successful => !_report.Errors.Any(e => e.TheError.Severity >= ErrorSeverity.Error);

private Dictionary<Type, object> _state = new Dictionary<Type, object>();
Expand Down Expand Up @@ -122,22 +126,17 @@ public BuildContext(GameObject obj, string assetRootPath, bool isClone = true)
#endif

var avatarName = _avatarRootObject.name;

AssetContainer = ScriptableObject.CreateInstance<GeneratedAssets>();

if (assetRootPath != null)
{
// Ensure the target directory exists
Directory.CreateDirectory(assetRootPath);

var pathAvatarName = FilterAvatarName(avatarName);

var avatarPath = Path.Combine(assetRootPath, pathAvatarName) + ".asset";
AssetDatabase.GenerateUniqueAssetPath(avatarPath);
AssetDatabase.CreateAsset(AssetContainer, avatarPath);
if (string.IsNullOrEmpty(AssetDatabase.GetAssetPath(AssetContainer)))
{
throw new Exception("Failed to persist asset container");
}
AssetSaver = new AssetSaver(assetRootPath, avatarName);
}
else
{
AssetSaver = new NullAssetSaver();
}

// Ensure that no prefab instances remain somehow
Expand Down Expand Up @@ -166,44 +165,14 @@ public BuildContext(GameObject obj, string assetRootPath, bool isClone = true)
}
}

private static readonly Regex WindowsReservedFileNames = new Regex(
"(CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])([.].*)?",
RegexOptions.IgnoreCase
);

private static readonly Regex WindowsReservedFileCharacters = new Regex(
"[<>:\"/\\\\|?*\x00-\x1f]",
RegexOptions.IgnoreCase
);

private static readonly Regex StripLeadingTrailingWhitespace = new Regex(
"^[\\s]*((?=\\S).*\\S)[\\s]*$"
);

internal static string FilterAvatarName(string avatarName)
public SerializationScope OpenSerializationScope()
{
avatarName = WindowsReservedFileCharacters.Replace(avatarName, "_");

if (WindowsReservedFileNames.IsMatch(avatarName))
{
avatarName = "_" + avatarName;
}

var match = StripLeadingTrailingWhitespace.Match(avatarName);
if (match.Success)
{
avatarName = match.Groups[1].Value;
} else {
avatarName = Guid.NewGuid().ToString();
}

return avatarName;
return new SerializationScope(AssetSaver);
}

public bool IsTemporaryAsset(UnityObject obj)
{
return !EditorUtility.IsPersistent(obj)
|| AssetDatabase.GetAssetPath(obj) == AssetDatabase.GetAssetPath(AssetContainer);
return AssetSaver.IsTemporaryAsset(obj);
}

public void Serialize()
Expand All @@ -217,15 +186,12 @@ public void Serialize()

try
{
AssetDatabase.StartAssetEditing();

HashSet<UnityObject> _savedObjects =
new HashSet<UnityObject>(
AssetDatabase.LoadAllAssetsAtPath(AssetDatabase.GetAssetPath(AssetContainer)));
HashSet<UnityObject> _savedObjects = new HashSet<UnityObject>(AssetSaver.GetPersistedAssets());

_savedObjects.Remove(AssetContainer);

int index = 0;
List<UnityEngine.Object> assetsToSave = new List<UnityEngine.Object>();
foreach (var asset in _avatarRootObject.ReferencedAssets(traverseSaved: true, includeScene: false))
{
if (asset is MonoScript)
Expand All @@ -252,7 +218,7 @@ public void Serialize()
{
try
{
AssetDatabase.AddObjectToAsset(asset, AssetContainer);
assetsToSave.Add(asset);
}
catch (UnityException ex)
{
Expand All @@ -263,17 +229,17 @@ public void Serialize()
}
}
}

foreach (var assetToHide in AssetDatabase.LoadAllAssetsAtPath(
AssetDatabase.GetAssetPath(AssetContainer)))

foreach (var assetToHide in AssetSaver.GetPersistedAssets().Concat(assetsToSave))
{
if (assetToHide != AssetContainer &&
GeneratedAssetBundleExtractor.IsAssetTypeHidden(assetToHide.GetType()))
if (GeneratedAssetBundleExtractor.IsAssetTypeHidden(assetToHide.GetType()))
{
assetToHide.hideFlags = HideFlags.HideInHierarchy;
}
}

AssetSaver.SaveAssets(assetsToSave);

// Remove obsolete temporary assets
foreach (var asset in _savedObjects)
{
Expand All @@ -288,10 +254,7 @@ public void Serialize()
}
finally
{
AssetDatabase.StopAssetEditing();

// SaveAssets to make sub-assets visible on the Project window
AssetDatabase.SaveAssets();
AssetSaver.Dispose();
}

Profiler.EndSample();
Expand Down
3 changes: 3 additions & 0 deletions Editor/API/Serialization.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

164 changes: 164 additions & 0 deletions Editor/API/Serialization/AssetSaver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
#nullable enable

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using nadena.dev.ndmf.runtime;
using UnityEditor;
using UnityEditor.VersionControl;
using UnityEngine;
using Object = UnityEngine.Object;

namespace nadena.dev.ndmf
{
internal class AssetSaver : IAssetSaver
{
private readonly string subAssetPath, rootAssetPath;
private readonly int assetsPerContainer;

private GeneratedAssets _rootAsset;
private SubAssetContainer _currentSubContainer;
private int _assetCount;
private HashSet<Object> _temporaryAssets = new HashSet<Object>();
private List<SubAssetContainer> _containers = new();

public Object CurrentContainer => _currentSubContainer;

internal AssetSaver(string generatedAssetsRoot, string avatarName, int assetsPerContainer = 256)
{
this.assetsPerContainer = assetsPerContainer;

avatarName = FilterAssetName(avatarName, "avatar");
var rootPath = generatedAssetsRoot + "/" + avatarName;
subAssetPath = rootPath + "/_assets";

this.rootAssetPath = rootPath + "/";

if (AssetDatabase.IsValidFolder(subAssetPath))
{
AssetDatabase.DeleteAsset(rootPath);
}

// Ensure directory exists recursively
if (!AssetDatabase.IsValidFolder(subAssetPath))
{
Directory.CreateDirectory(subAssetPath);
AssetDatabase.Refresh();
}

var rootAssetPath = AssetDatabase.GenerateUniqueAssetPath(rootPath + "/" + avatarName + ".asset");
_rootAsset = ScriptableObject.CreateInstance<GeneratedAssets>();
AssetDatabase.CreateAsset(_rootAsset, rootAssetPath);
_currentSubContainer = CreateAssetContainer();

_assetCount = 0;
}

public void SaveAsset(UnityEngine.Object? obj)
{
if (obj == null || EditorUtility.IsPersistent(obj)) return;

_temporaryAssets.Add(obj);

if (obj is Texture)
{
// Textures can be quite large, so push them off to their own files.
// However, be sure to create them as a subasset, as this appears to be much faster, for some reason...
var texName = FilterAssetName(obj.name, "texture");
var container = CreateAssetContainer(texName);

AssetDatabase.AddObjectToAsset(obj, container);
return;
}

if (_assetCount >= assetsPerContainer)
{
_currentSubContainer = CreateAssetContainer();
_assetCount = 0;
}

AssetDatabase.AddObjectToAsset(obj, _currentSubContainer);
_assetCount++;
}

private SubAssetContainer CreateAssetContainer(string name = "assets")
{
var subContainerPath = AssetDatabase.GenerateUniqueAssetPath(subAssetPath + "/" + name + ".asset");
var subContainer = ScriptableObject.CreateInstance<SubAssetContainer>();
AssetDatabase.CreateAsset(subContainer, subContainerPath);

_containers.Add(subContainer);

return subContainer;
}

public bool IsTemporaryAsset(UnityEngine.Object? obj)
{
if (obj == null) return true;

if (_temporaryAssets.Contains(obj) || !EditorUtility.IsPersistent(obj))
{
return true;
}

var path = AssetDatabase.GetAssetPath(obj);
if (path.StartsWith(rootAssetPath))
{
_temporaryAssets.Add(obj);
return true;
}

return false;
}


private static readonly Regex WindowsReservedFileNames = new Regex(
"(CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])([.].*)?",
RegexOptions.IgnoreCase
);

private static readonly Regex WindowsReservedFileCharacters = new Regex(
"[<>:\"/\\\\|?*\x00-\x1f]",
RegexOptions.IgnoreCase
);

private static readonly Regex StripLeadingTrailingWhitespace = new Regex(
"^[\\s]*((?=\\S).*\\S)[\\s]*$"
);

internal static string FilterAssetName(string assetName, string? fallbackName = null)
{
assetName = WindowsReservedFileCharacters.Replace(assetName, "_");

if (WindowsReservedFileNames.IsMatch(assetName))
{
assetName = "_" + assetName;
}

var match = StripLeadingTrailingWhitespace.Match(assetName);
if (match.Success)
{
assetName = match.Groups[1].Value;
} else {
assetName = fallbackName ?? Guid.NewGuid().ToString();
}

return assetName;
}


public IEnumerable<Object> GetPersistedAssets()
{
return _temporaryAssets.Where(o => o != null);
}

public void Dispose()
{
_rootAsset.SubAssets = _containers;
EditorUtility.SetDirty(_rootAsset);
AssetDatabase.SaveAssets();
}
}
}
3 changes: 3 additions & 0 deletions Editor/API/Serialization/AssetSaver.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit 8212db6

Please sign in to comment.