Skip to content

Commit e313025

Browse files
feat: smooth transition between LODs (#8949)
* feat: Only enable the LOD 0 when all assets are instantiated * fix: now renderers are supressed instead of gameobjects * fix: tests
1 parent 7a86022 commit e313025

5 files changed

Lines changed: 126 additions & 0 deletions

File tree

Explorer/Assets/DCL/Infrastructure/ECS/Unity/GLTFContainer/Asset/Components/GltfContainerAsset.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,17 @@ public void SetCastingShadows(bool newValue)
8383
Renderers[i].shadowCastingMode = newValue ? ShadowCastingMode.On : ShadowCastingMode.Off;
8484
}
8585

86+
/// <summary>
87+
/// Suppresses or restores rendering without touching the GameObject's active state, so colliders
88+
/// stay registered. Uses <see cref="Renderer.forceRenderingOff" />, the same mechanism the emote
89+
/// and landscape culling paths rely on.
90+
/// </summary>
91+
public void ToggleRendering(bool newState)
92+
{
93+
for (var i = 0; i < Renderers.Count; i++)
94+
Renderers[i].forceRenderingOff = !newState;
95+
}
96+
8697
public void ToggleAnimationState(bool newState)
8798
{
8899
foreach (Animation animation in Animations)

Explorer/Assets/DCL/LOD/Components/InitialSceneStateLOD.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,13 @@ private void Clear()
6767
foreach (ISSStoredAsset gltfContainerAsset in Assets)
6868
{
6969
if (gltfContainerAsset.succeded)
70+
{
71+
// Restore rendering before handing the asset back: on an aborted run RevealAssembledAssets
72+
// never ran, so the asset would otherwise return to the cache with forceRenderingOff stuck
73+
// on and reappear invisible on its next reuse (bridge handoff or fresh instantiate).
74+
gltfContainerAsset.Asset.ToggleRendering(true);
7075
gltfCache.Dereference(gltfContainerAsset.AssetHash, gltfContainerAsset.Asset, AssetsShouldGoToTheBridge);
76+
}
7177
}
7278

7379
Assets.Clear();
@@ -122,6 +128,21 @@ private void EnsureParentContainer(string sceneID, Vector3 sceneGeometryBaseParc
122128
ParentContainer.transform.position = sceneGeometryBaseParcelPosition;
123129
}
124130

131+
/// <summary>
132+
/// Atomically reveals the fully assembled LOD_0 once <see cref="AllAssetsInstantiated" /> is true.
133+
/// Each asset streamed in with rendering suppressed (but its GameObject active, so colliders stay
134+
/// registered); this restores rendering so the LODGroup the caller wires up in the same frame can
135+
/// cull LOD_1 by distance. No overlap window, no empty frame.
136+
/// </summary>
137+
public void RevealAssembledAssets()
138+
{
139+
foreach (ISSStoredAsset storedAsset in Assets)
140+
{
141+
if (storedAsset.succeded)
142+
storedAsset.Asset.ToggleRendering(true);
143+
}
144+
}
145+
125146
public void AddFailedAsset(string creationHelperAssetHash)
126147
{
127148
Assets.Add(new ISSStoredAsset

Explorer/Assets/DCL/LOD/Systems/InstantiateSceneLODInfoSystem.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ private void ResolveInitialSceneStateDescriptorLOD(in SceneDefinitionComponent s
8181
{
8282
if (sceneLODInfo.InitialSceneStateLOD.AllAssetsInstantiated())
8383
{
84+
// Reveal LOD_0 in the same frame it joins the LODGroup. The container streamed in hidden,
85+
// so up to this point the player has been seeing LOD_1; from here the LODGroup culls LOD_1
86+
// by distance. No overlap, no empty frame.
87+
sceneLODInfo.InitialSceneStateLOD.RevealAssembledAssets();
88+
8489
sceneLODInfo.AddSuccessLOD(sceneLODInfo.InitialSceneStateLOD.ParentContainer, null, defaultFOV, defaultLodBias,
8590
realmPartitionSettings.MaxLoadingDistanceInParcels, sceneDefinitionComponent.Parcels.Count);
8691
sceneLODInfo.InitialSceneStateLOD.CurrentState = InitialSceneStateLOD.State.RESOLVED;

Explorer/Assets/DCL/LOD/Systems/ResolveISSLODSystem.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,12 @@ private static void PositionAsset(InitialSceneStateLOD initialSceneStateLOD, ISS
168168

169169
asset.ToggleAnimationState(false);
170170

171+
// Suppress rendering while LOD_0 streams in asset-by-asset so the half-assembled LOD_0 never
172+
// draws on top of the still-visible LOD_1. The GameObject stays active so its colliders remain
173+
// registered; RevealAssembledAssets restores rendering once every asset is in place and the
174+
// LODGroup takes over distance-based culling.
175+
asset.ToggleRendering(false);
176+
171177
// Store under the digest-aware cache key so the eventual Dereference in InitialSceneStateLOD.Clear
172178
// matches what the SDK runtime would look up — that's what allows bridging round-trips between
173179
// LOD and the real scene without spawning a second copy of the same asset.

Explorer/Assets/DCL/LOD/Tests/EditMode/ResolveISSLODSystemShould.cs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,72 @@ public void RecordFailedAssetWithoutTouchingCache()
201201
Assert.That(cache.Outstanding("MISSING_HASH"), Is.EqualTo(0));
202202
}
203203

204+
[Test]
205+
public void SuppressRenderingButKeepObjectsActiveUntilRevealed()
206+
{
207+
// Guards the LOD_1 -> LOD_0 atomic-swap fix: while descriptor assets stream in, their rendering
208+
// must be suppressed so the half-assembled LOD_0 never draws on top of the still-visible LOD_1.
209+
// Crucially the GameObjects stay active so colliders remain registered; the reveal (owned by
210+
// InstantiateSceneLODInfoSystem at the swap) restores rendering, not GameObject activation.
211+
const string HASH_A = "ITEM_A";
212+
const string HASH_B = "ITEM_B";
213+
214+
GltfContainerAsset assetA = MakeFakeGltfWithRenderer(HASH_A, out Renderer rendererA);
215+
GltfContainerAsset assetB = MakeFakeGltfWithRenderer(HASH_B, out Renderer rendererB);
216+
cache.Stash(HASH_A, assetA);
217+
cache.Stash(HASH_B, assetB);
218+
219+
var descriptor = ISSDescriptor.CreateUninitialized();
220+
221+
descriptor.MarkResolved(new[]
222+
{
223+
NewDescriptorEntry(HASH_A),
224+
NewDescriptorEntry(HASH_B),
225+
});
226+
227+
InitialSceneStateLOD lod = CreateLODEntity(descriptor);
228+
229+
system.Update(0);
230+
231+
Assert.That(lod.AllAssetsInstantiated(), Is.True);
232+
233+
Assert.That(rendererA.forceRenderingOff, Is.True, "Rendering must be suppressed while LOD_0 assembles");
234+
Assert.That(rendererB.forceRenderingOff, Is.True, "Rendering must be suppressed while LOD_0 assembles");
235+
Assert.That(assetA.Root.activeInHierarchy, Is.True, "GameObject must stay active so colliders survive");
236+
Assert.That(assetB.Root.activeInHierarchy, Is.True, "GameObject must stay active so colliders survive");
237+
238+
lod.RevealAssembledAssets();
239+
240+
Assert.That(rendererA.forceRenderingOff, Is.False, "Reveal must hand rendering back so the LODGroup can cull by distance");
241+
Assert.That(rendererB.forceRenderingOff, Is.False, "Reveal must hand rendering back so the LODGroup can cull by distance");
242+
243+
lod.Dispose(world);
244+
}
245+
246+
[Test]
247+
public void RestoreRenderingWhenAbortedBeforeReveal()
248+
{
249+
// An aborted run (ForgetLoading while PROCESSING) dereferences positioned assets back to the
250+
// cache. They must not return with forceRenderingOff stuck on, or they reappear invisible on reuse.
251+
const string HASH = "ITEM_A";
252+
253+
GltfContainerAsset asset = MakeFakeGltfWithRenderer(HASH, out Renderer renderer);
254+
cache.Stash(HASH, asset);
255+
256+
var descriptor = ISSDescriptor.CreateUninitialized();
257+
descriptor.MarkResolved(new[] { NewDescriptorEntry(HASH) });
258+
259+
InitialSceneStateLOD lod = CreateLODEntity(descriptor);
260+
261+
system.Update(0);
262+
Assert.That(renderer.forceRenderingOff, Is.True);
263+
264+
lod.Dispose(world);
265+
266+
Assert.That(renderer.forceRenderingOff, Is.False,
267+
"Clear must restore rendering before the asset is handed back to the cache");
268+
}
269+
204270
[Test]
205271
public void ReleaseBridgeSlotOnCacheHit()
206272
{
@@ -263,6 +329,15 @@ private static ISSDescriptorAsset NewDescriptorEntry(string hash) =>
263329
private static GltfContainerAsset MakeFakeGltf(string label) =>
264330
GltfContainerAsset.Create(new GameObject($"fake_{label}"), IStreamableRefCountData.Null.INSTANCE);
265331

332+
private static GltfContainerAsset MakeFakeGltfWithRenderer(string label, out Renderer renderer)
333+
{
334+
var go = new GameObject($"fake_{label}");
335+
renderer = go.AddComponent<MeshRenderer>();
336+
GltfContainerAsset asset = GltfContainerAsset.Create(go, IStreamableRefCountData.Null.INSTANCE);
337+
asset.Renderers.Add(renderer);
338+
return asset;
339+
}
340+
266341
/// <summary>
267342
/// Pool-style stub mirroring <see cref="GltfContainerAssetsCache" />: <c>TryGet</c> removes,
268343
/// <c>Dereference</c> returns. Per-key counters are what the tests assert against.
@@ -309,6 +384,14 @@ public void Dereference(in string key, GltfContainerAsset asset, bool putInBridg
309384
stash[key] = entries = new Stack<GltfContainerAsset>();
310385

311386
entries.Push(asset);
387+
388+
// Mirror GltfContainerAssetsCache.DereferenceFinalOperation: the real cache reparents the
389+
// asset's Root out of the LOD container when it is returned to the pool. Without this the
390+
// pooled asset stays a child of InitialSceneStateLOD.ParentContainer, so Dispose's
391+
// SafeDestroy(ParentContainer) cascades into it and destroys its renderers — the asset (and
392+
// its renderers) must survive intact for reuse.
393+
if (asset.Root != null)
394+
asset.Root.transform.SetParent(null, true);
312395
}
313396

314397
public void Unload(IPerformanceBudget frameTimeBudget, int maxUnloadAmount) { }

0 commit comments

Comments
 (0)