-
Notifications
You must be signed in to change notification settings - Fork 17
fix: evict only the changed GLTF on LSD hot reload instead of draining every cache #9667
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
8251beb
8ecafde
4f6a10f
f1233ef
7d6e06b
80156d7
79cc500
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,6 +1,7 @@ | ||||||
| using Arch.Core; | ||||||
| using Cysharp.Threading.Tasks; | ||||||
| using DCL.Character.Components; | ||||||
| using DCL.Ipfs; | ||||||
| using DCL.ResourcesUnloading; | ||||||
| using ECS.LifeCycle.Components; | ||||||
| using ECS.SceneLifeCycle.Components; | ||||||
|
|
@@ -44,19 +45,19 @@ public ECSReloadScene(IScenesCache scenesCache, | |||||
| var foundEntity = FindSceneEntity(sceneInCache); | ||||||
| if (foundEntity == Entity.Null) return null; | ||||||
|
|
||||||
| await DisposeAndRestartAsync(foundEntity, sceneInCache, ct); | ||||||
| await DisposeAndRestartAsync(foundEntity, sceneInCache, null, ct); | ||||||
|
|
||||||
| return sceneInCache; | ||||||
| } | ||||||
|
|
||||||
| public async UniTask<ISceneFacade?> TryReloadSceneAsync(CancellationToken ct, string sceneId) | ||||||
| public async UniTask<ISceneFacade?> TryReloadSceneAsync(CancellationToken ct, string sceneId, string? changedModelSrc = null) | ||||||
| { | ||||||
| if (!scenesCache.TryGetBySceneId(sceneId, out var sceneInCache)) return null; | ||||||
|
|
||||||
| var foundEntity = FindSceneEntity(sceneInCache!); | ||||||
| if (foundEntity == Entity.Null) return null; | ||||||
|
|
||||||
| await DisposeAndRestartAsync(foundEntity, sceneInCache!, ct); | ||||||
| await DisposeAndRestartAsync(foundEntity, sceneInCache!, changedModelSrc, ct); | ||||||
|
|
||||||
| return sceneInCache; | ||||||
| } | ||||||
|
|
@@ -74,10 +75,15 @@ private Entity FindSceneEntity(ISceneFacade targetScene) | |||||
| return sceneEntity; | ||||||
| } | ||||||
|
|
||||||
| private async UniTask DisposeAndRestartAsync(Entity entity, ISceneFacade currentScene, CancellationToken ct) | ||||||
| private async UniTask DisposeAndRestartAsync(Entity entity, ISceneFacade currentScene, string? changedModelSrc, CancellationToken ct) | ||||||
| { | ||||||
| ct.ThrowIfCancellationRequested(); | ||||||
|
|
||||||
| // Captured before the teardown below strips the scene entity's components. | ||||||
| SceneEntityDefinition? definition = localSceneDevelopment | ||||||
| ? world.Get<SceneDefinitionComponent>(entity).Definition | ||||||
| : null; | ||||||
|
|
||||||
| //There is a lingering promise we need to remove, and add the DeleteEntityIntention to make the standard unload flow. | ||||||
| world.Add<DeleteEntityIntention>(entity); | ||||||
|
|
||||||
|
|
@@ -96,11 +102,23 @@ private async UniTask DisposeAndRestartAsync(Entity entity, ISceneFacade current | |||||
| world.Query(in new QueryDescription().WithAll<RealmComponent>(), | ||||||
| (ref StaticScenePointers staticScenePointers) => { staticScenePointers.Promise = null; }); | ||||||
|
|
||||||
| // Force-drain dereferenced caches on LSD reload. The local dev server derives hashes | ||||||
| // from the file path, not content, so an updated model keeps the same hash and cache | ||||||
| // hits would return stale assets. Draining guarantees fresh loads. | ||||||
| cacheCleaner.UnloadCache(budgeted: false); | ||||||
| Resources.UnloadUnusedAssets(); | ||||||
| if (changedModelSrc != null | ||||||
| && TryResolveContentHash(definition, changedModelSrc, out string contentHash) | ||||||
| && IsRawGltfModel(definition, contentHash)) | ||||||
| { | ||||||
| // The dev server named the exact model that changed. In raw-GLTF development its | ||||||
| // cache key is the bare content hash, so evict just that asset and let every other | ||||||
| // cache stay warm across the reload. | ||||||
| cacheCleaner.EvictGltfModel(contentHash); | ||||||
| } | ||||||
| else | ||||||
| { | ||||||
| // Force-drain dereferenced caches on LSD reload. The local dev server derives hashes | ||||||
| // from the file path, not content, so an updated model keeps the same hash and cache | ||||||
| // hits would return stale assets. Draining guarantees fresh loads. | ||||||
| cacheCleaner.UnloadCache(budgeted: false); | ||||||
| Resources.UnloadUnusedAssets(); | ||||||
| } | ||||||
|
|
||||||
| await WaitUntilNewSceneIsFullyLoadedAsync(); | ||||||
| } | ||||||
|
|
@@ -134,5 +152,72 @@ await UniTask.WaitUntil(() => | |||||
| }, cancellationToken: ct); | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| /// <summary> | ||||||
| /// Resolve the changed file to the hash the caches are actually keyed on. The reload | ||||||
| /// message's own hash is minted from the watcher-relative path, while every cache key | ||||||
| /// derives from the content-mapping hash (minted from the absolute path) — the two never | ||||||
| /// match, so the file must be joined to the definition's content list by its src instead. | ||||||
| /// </summary> | ||||||
| internal static bool TryResolveContentHash(SceneEntityDefinition? definition, string src, out string hash) | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] Nullability annotation — same pattern as
Suggested change
|
||||||
| { | ||||||
| hash = string.Empty; | ||||||
|
|
||||||
| ContentDefinition[]? content = definition?.content; | ||||||
|
|
||||||
| if (content == null || string.IsNullOrEmpty(src)) | ||||||
| return false; | ||||||
|
|
||||||
| foreach (ContentDefinition entry in content) | ||||||
| { | ||||||
| if (ContentPathEquals(entry.file, src)) | ||||||
| { | ||||||
| hash = entry.hash; | ||||||
| return true; | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| return false; | ||||||
| } | ||||||
|
|
||||||
| /// <summary> | ||||||
| /// Case- and separator-insensitive content path comparison. Content mappings always spell | ||||||
| /// paths with '/', while the local dev server's file watcher reports the platform separator — | ||||||
| /// on Windows that is '\', so an ordinal comparison never matches there even though the two | ||||||
| /// name the same file. Compared in place to keep the reload path allocation-free. | ||||||
| /// </summary> | ||||||
| private static bool ContentPathEquals(string? contentFile, string src) | ||||||
| { | ||||||
| if (contentFile == null || contentFile.Length != src.Length) | ||||||
| return false; | ||||||
|
|
||||||
| for (var i = 0; i < contentFile.Length; i++) | ||||||
| { | ||||||
| char a = contentFile[i]; | ||||||
| char b = src[i]; | ||||||
|
|
||||||
| if (a == '\\') a = '/'; | ||||||
| if (b == '\\') b = '/'; | ||||||
|
|
||||||
| if (a != b && char.ToLowerInvariant(a) != char.ToLowerInvariant(b)) | ||||||
| return false; | ||||||
| } | ||||||
|
|
||||||
| return true; | ||||||
| } | ||||||
|
|
||||||
| /// <summary> | ||||||
| /// True when the hash addresses a raw GLTF, i.e. no asset-bundle manifest maps it. The GLTF | ||||||
| /// container cache is keyed by <see cref="AssetBundleManifestVersion.ComposeCacheKey" />, | ||||||
| /// which returns the bare hash only in that case; under <c>--local-ab</c> the key differs and | ||||||
| /// the model lives in the asset-bundle caches instead, so scoped eviction must not be used. | ||||||
| /// </summary> | ||||||
| internal static bool IsRawGltfModel(SceneEntityDefinition? definition, string hash) | ||||||
|
dalkia marked this conversation as resolved.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] Nullability annotation (repeat from previous review) —
Suggested change
|
||||||
| { | ||||||
| if (definition == null || string.IsNullOrEmpty(hash)) | ||||||
| return false; | ||||||
|
|
||||||
| return definition.AssetBundleManifestVersionOrFailed.ComposeCacheKey(hash) == hash; | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| using DCL.Ipfs; | ||
| using ECS.SceneLifeCycle; | ||
| using NUnit.Framework; | ||
|
|
||
| namespace DCL.SceneLifeCycle.Tests | ||
| { | ||
| public class ECSReloadSceneShould | ||
| { | ||
| [Test] | ||
| public void TreatDefinitionWithoutManifestAsRawGltf() | ||
| { | ||
| //Arrange: no asset-bundle manifest -> the container cache is keyed by the bare hash | ||
| SceneEntityDefinition definition = CreateDefinition(); | ||
|
|
||
| //Act & Assert | ||
| Assert.That(ECSReloadScene.IsRawGltfModel(definition, "b64-somehash"), Is.True); | ||
| } | ||
|
|
||
| [Test] | ||
| public void NotTreatMissingDefinitionOrEmptyHashAsRawGltf() | ||
| { | ||
| SceneEntityDefinition definition = CreateDefinition(); | ||
|
|
||
| Assert.That(ECSReloadScene.IsRawGltfModel(null, "b64-somehash"), Is.False); | ||
| Assert.That(ECSReloadScene.IsRawGltfModel(definition, string.Empty), Is.False); | ||
| Assert.That(ECSReloadScene.IsRawGltfModel(definition, null!), Is.False); | ||
| } | ||
|
|
||
| [Test] | ||
| public void ResolveContentHashBySrcIgnoringCase() | ||
| { | ||
| //Arrange | ||
| SceneEntityDefinition definition = CreateDefinition( | ||
| new ContentDefinition { file = "models/shark.glb", hash = "b64-content-hash" }); | ||
|
|
||
| //Act | ||
| bool resolved = ECSReloadScene.TryResolveContentHash(definition, "Models/Shark.GLB", out string hash); | ||
|
|
||
| //Assert | ||
| Assert.That(resolved, Is.True); | ||
| Assert.That(hash, Is.EqualTo("b64-content-hash")); | ||
| } | ||
|
|
||
| [Test] | ||
| public void ResolveContentHashWhenSrcUsesWindowsSeparators() | ||
| { | ||
| //Arrange: content mappings always spell paths with '/', but the local dev server's file | ||
| //watcher reports the platform separator — on Windows that is '\'. | ||
| SceneEntityDefinition definition = CreateDefinition( | ||
| new ContentDefinition { file = "assets/models/out/models/BenchStreet.glb", hash = "b64-content-hash" }); | ||
|
|
||
| //Act | ||
| bool resolved = ECSReloadScene.TryResolveContentHash(definition, @"assets\models\out\models\BenchStreet.glb", out string hash); | ||
|
|
||
| //Assert | ||
| Assert.That(resolved, Is.True); | ||
| Assert.That(hash, Is.EqualTo("b64-content-hash")); | ||
| } | ||
|
|
||
| [Test] | ||
| public void NotResolveContentHashWhenSrcIsUnknownOrMissing() | ||
| { | ||
| //Arrange | ||
| SceneEntityDefinition definition = CreateDefinition( | ||
| new ContentDefinition { file = "models/shark.glb", hash = "b64-content-hash" }); | ||
|
|
||
| //Act & Assert | ||
| Assert.That(ECSReloadScene.TryResolveContentHash(definition, "models/monster.glb", out _), Is.False); | ||
| Assert.That(ECSReloadScene.TryResolveContentHash(definition, string.Empty, out _), Is.False); | ||
| Assert.That(ECSReloadScene.TryResolveContentHash(null, "models/shark.glb", out _), Is.False); | ||
| } | ||
|
|
||
| private static SceneEntityDefinition CreateDefinition(params ContentDefinition[] content) => | ||
| new ("test-scene", new SceneMetadata()) | ||
| { | ||
| pointers = new[] { "0,0" }, | ||
| content = content, | ||
| }; | ||
| } | ||
| } |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Uh oh!
There was an error while loading. Please reload this page.