Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
b62f498
feat: progress is now reported with byte-weighted tracking
alejandro-jimenez-dcl Mar 18, 2026
7fcd792
fix: wip
alejandro-jimenez-dcl Mar 20, 2026
20f96d2
fix: update progress computation and handle unknown-size
alejandro-jimenez-dcl Apr 3, 2026
7628ed2
chore: code review
alejandro-jimenez-dcl Apr 8, 2026
66fa0d8
feat: convert the head request into a HeadOp to be part of the webreq…
alejandro-jimenez-dcl Apr 8, 2026
24002eb
Merge remote-tracking branch 'origin/dev' into feat/6849-loading-scre…
alejandro-jimenez-dcl May 15, 2026
dd5bb7d
refactor: simplify byte-weighted loading progress
alejandro-jimenez-dcl May 18, 2026
83b57cf
Merge branch 'dev' into feat/6849-loading-screen-by-data
alejandro-jimenez-dcl May 18, 2026
9e21fd7
refactor: skip ReadLoadingState for finished scene assets
alejandro-jimenez-dcl May 19, 2026
65c5125
feat: gate byte-weighted loading progress behind feature flag
alejandro-jimenez-dcl May 20, 2026
9f5b98f
fix: test compilation
alejandro-jimenez-dcl May 21, 2026
681ad3b
chore: missing meta
alejandro-jimenez-dcl May 21, 2026
5bb5cd8
refactor: simplify byte progress smoothing, add tracker tests
alejandro-jimenez-dcl May 21, 2026
b5bc906
fix: code convention
alejandro-jimenez-dcl May 21, 2026
6daec5c
feat: add feature flag
alejandro-jimenez-dcl May 21, 2026
d522d5c
Merge branch 'dev' into feat/6849-loading-screen-by-data
alejandro-jimenez-dcl May 22, 2026
5a26fef
Merge remote-tracking branch 'origin/dev' into feat/6849-loading-scre…
alejandro-jimenez-dcl Jun 4, 2026
f282fff
fix: handle HEAD timeout exceptions
alejandro-jimenez-dcl Jun 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using ECS.Abstract;
using ECS.Groups;
using ECS.SceneLifeCycle.Reporting;
using ECS.StreamableLoading.Common.Components;
using ECS.Unity.GLTFContainer.Components;
using ECS.Unity.Transforms.Components;
using SceneRunner.Scene;
Expand Down Expand Up @@ -43,6 +44,13 @@ public partial class GatherGltfAssetsSystem : BaseUnityLoopSystem
private readonly ILoadingStatus loadingStatus;
private readonly Entity sceneContainerEntity;

// Byte-weighted progress tracking — LoadingState is co-located so dead entities retain their last-known state
private Dictionary<Entity, (long ContentLength, LoadingState State)>? entityLoadingData;
Comment thread
alejandro-jimenez-dcl marked this conversation as resolved.
Outdated
private long completedBytes;
private long totalBytesExpected;
private int entitiesWithKnownSize;
private float maxReportedProgress;

internal GatherGltfAssetsSystem(World world, ISceneReadinessReportQueue readinessReportQueue,
ISceneData sceneData, EntityEventBuffer<GltfContainerComponent> eventsBuffer,
ISceneStateProvider sceneStateProvider, MemoryBudget memoryBudget,
Expand All @@ -63,6 +71,7 @@ internal GatherGltfAssetsSystem(World world, ISceneReadinessReportQueue readines
public override void Initialize()
{
entitiesUnderObservation = HashSetPool<Entity>.Get();
entityLoadingData = DictionaryPool<Entity, (long, LoadingState)>.Get();
startTime = Time.time;
}

Expand All @@ -73,6 +82,13 @@ protected override void OnDispose()
HashSetPool<Entity>.Release(entitiesUnderObservation);
entitiesUnderObservation = null;
}

if (entityLoadingData != null)
{
DictionaryPool<Entity, (long, LoadingState)>.Release(entityLoadingData);
entityLoadingData = null;
}

sceneData.SceneLoadingConcluded = true;
}

Expand All @@ -96,37 +112,59 @@ protected override void Update(float t)
concluded = true;

List<Entity> toDelete = ListPool<Entity>.Get();

// iterate over entities
long inProgressWeightedBytes = 0;

foreach (Entity entityRef in entitiesUnderObservation!)
{
// if entity has died
// or entity no longer contains GltfContainerComponent
// continue
if (!World.IsAlive(entityRef)
|| !World.TryGet(entityRef, out GltfContainerComponent gltfContainerComponent))
{
// Entity died — it either finished loading or was cancelled
toDelete.Add(entityRef);
continue;
}

// if Gltf Container Component has finished loading at least once (it can be reconfigured, we don't care)
UpdateEntityData(entityRef, in gltfContainerComponent);

if (gltfContainerComponent.State == LoadingState.Loading)
// if at least one entity is still loading, we are not done.
{
concluded = false;

if (entityLoadingData!.TryGetValue(entityRef, out var data) && data.ContentLength > 0)
inProgressWeightedBytes += (long)(GetEntityProgress(entityRef, in gltfContainerComponent) * data.ContentLength);
}
else
// remove entity from list - it's loaded, we don't need to check it anymore
{
toDelete.Add(entityRef);
}
}

for (var i = 0; i < toDelete.Count; i++)
{
Entity entity = toDelete[i];

if (entityLoadingData!.TryGetValue(entity, out var data) && data.ContentLength > 0)
{
completedBytes += data.ContentLength;
}
else
{
// Entity had no known content-length; credit it with the average size estimate
if (entitiesWithKnownSize > 0 && totalBytesExpected > 0)
completedBytes += totalBytesExpected / entitiesWithKnownSize;
}

entityLoadingData!.Remove(entity);
}

assetsResolved += toDelete.Count;
float progress = totalAssetsToResolve != 0 ? assetsResolved / (float)totalAssetsToResolve : 1;
float progress = ComputeProgress(inProgressWeightedBytes);
maxReportedProgress = Mathf.Max(maxReportedProgress, progress);

for (var i = 0; i < reports!.Value.Count; i++)
{
AsyncLoadProcessReport report = reports.Value[i];
report.SetProgress(progress);
report.SetProgress(maxReportedProgress);
}

entitiesUnderObservation.ExceptWith(toDelete);
Expand All @@ -150,6 +188,12 @@ protected override void Update(float t)

if (concluded)
{
for (var i = 0; i < reports!.Value.Count; i++)
{
AsyncLoadProcessReport report = reports.Value[i];
report.SetProgress(1);
}

reports.Value.Dispose();
reports = null;
Conclude();
Expand All @@ -168,6 +212,55 @@ void Conclude()
}
}

private void UpdateEntityData(Entity entityRef, in GltfContainerComponent gltfContainerComponent)
{
Entity promiseEntity = gltfContainerComponent.Promise.Entity;

if (!World.IsAlive(promiseEntity)
|| !World.TryGet(promiseEntity, out StreamableLoadingState loadingState))
return;

bool alreadyTracked = entityLoadingData!.TryGetValue(entityRef, out var existing);

if (!alreadyTracked && loadingState.ContentLength > 0)
{
entityLoadingData[entityRef] = (loadingState.ContentLength, gltfContainerComponent.State);
totalBytesExpected += loadingState.ContentLength;
entitiesWithKnownSize++;
}
else if (alreadyTracked)
{
entityLoadingData[entityRef] = (existing.ContentLength, gltfContainerComponent.State);
}
}

private float GetEntityProgress(Entity entityRef, in GltfContainerComponent gltfContainerComponent)
{
Entity promiseEntity = gltfContainerComponent.Promise.Entity;

if (!World.IsAlive(promiseEntity)
|| !World.TryGet(promiseEntity, out StreamableLoadingState loadingState))
Comment thread
alejandro-jimenez-dcl marked this conversation as resolved.
Outdated
return 0f;

return loadingState.Progress;
}

private float ComputeProgress(long inProgressWeightedBytes)
{
// Fallback: count-based progress when no byte data is available
if (entitiesWithKnownSize <= 0 || totalBytesExpected <= 0)
return totalAssetsToResolve != 0 ? assetsResolved / (float)totalAssetsToResolve : 1f;

// Estimate unknown assets using average size of known assets
long avgSize = totalBytesExpected / entitiesWithKnownSize;
int unknownCount = totalAssetsToResolve - entitiesWithKnownSize;
long effectiveTotal = totalBytesExpected + avgSize * Math.Max(0, unknownCount);

return effectiveTotal > 0
? Mathf.Clamp01((float)(completedBytes + inProgressWeightedBytes) / effectiveTotal)
: 0f;
}

private void GatherEntities(Entity entity, GltfContainerComponent component)
{
// No matter to which state component has changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,14 @@ private async UniTask<AssetBundleData[]> LoadDependenciesAsync(GetAssetBundleInt

protected override async UniTask<StreamableLoadingResult<AssetBundleData>> FlowInternalAsync(GetAssetBundleIntention intention, StreamableLoadingState state, IPartitionComponent partition, CancellationToken ct)
{
AssetBundleLoadingResult assetBundleResult = await webRequestController
.GetAssetBundleAsync(intention.CommonArguments, new GetAssetBundleArguments(loadingMutex, intention.cacheHash), ct, GetReportCategory(),
state.SetProgress(0);
state.SetContentLength(-1);
AssetBundleLoadingResult assetBundleResult = await webRequestController.GetAssetBundleAsync(
intention.CommonArguments,
new GetAssetBundleArguments(loadingMutex, intention.cacheHash),
ct,
GetReportCategory(),
progressHandler: state,
suppressErrors: true); // Suppress errors because here we have our own error handling

AssetBundle? assetBundle = assetBundleResult.AssetBundle;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using DCL.Optimization.PerformanceBudgeting;
using DCL.Optimization.Pools;
using DCL.WebRequests;
using ECS.StreamableLoading.Cache.Disk;
using System;
using System.Runtime.CompilerServices;
Expand All @@ -11,7 +12,7 @@ namespace ECS.StreamableLoading.Common.Components
/// <summary>
/// Common state for all streamable types
/// </summary>
public class StreamableLoadingState
public class StreamableLoadingState : IStreamableLoadingProgressHandler
{
public enum Status : byte
{
Expand Down Expand Up @@ -46,6 +47,8 @@ public enum Status : byte
{
state.disposed = false;
state.Value = Status.NotStarted;
state.Progress = 0f;
state.ContentLength = 0;
},
actionOnRelease: state =>
{
Expand Down Expand Up @@ -76,6 +79,9 @@ internal StreamableLoadingState() { }
/// </summary>
public PartialLoadingState? PartialDownloadingData { get; internal set; }

public float Progress { get; private set; }
public long ContentLength { get; private set; }

public ReadOnlyMemory<byte> GetFullyDownloadedData()
{
Assert.IsTrue(PartialDownloadingData is { FullyDownloaded: true });
Expand Down Expand Up @@ -167,5 +173,8 @@ public void Dispose()

POOL.Release(this);
}

public void SetProgress(float progress) => Progress = progress;
public void SetContentLength(long length) => ContentLength = length;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ public ArtificialDelayWebRequestController(IWebRequestController origin, IReadOn

public async UniTask<TResult?> SendAsync<TWebRequest, TWebRequestArgs, TWebRequestOp, TResult>(
RequestEnvelope<TWebRequest, TWebRequestArgs> envelope,
TWebRequestOp op
TWebRequestOp op,
IStreamableLoadingProgressHandler? progressHandler = null
)
where TWebRequest: struct, ITypedWebRequest
where TWebRequestArgs: struct
Expand All @@ -28,7 +29,7 @@ TWebRequestOp op
if (useDelay)
await UniTask.Delay(TimeSpan.FromSeconds(delaySeconds));

return await origin.SendAsync<TWebRequest, TWebRequestArgs, TWebRequestOp, TResult>(envelope, op);
return await origin.SendAsync<TWebRequest, TWebRequestArgs, TWebRequestOp, TResult>(envelope, op, progressHandler);
}

IRequestHub IWebRequestController.RequestHub => origin.RequestHub;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,12 @@ public DebugMetricsWebRequestController(IWebRequestController origin,
}

public async UniTask<TResult?> SendAsync<TWebRequest, TWebRequestArgs, TWebRequestOp, TResult>(
RequestEnvelope<TWebRequest, TWebRequestArgs> envelope, TWebRequestOp op)
RequestEnvelope<TWebRequest, TWebRequestArgs> envelope, TWebRequestOp op, IStreamableLoadingProgressHandler? progressHandler = null)
where TWebRequest: struct, ITypedWebRequest
where TWebRequestArgs: struct
where TWebRequestOp: IWebRequestOp<TWebRequest, TResult>
{
try { return await origin.SendAsync<TWebRequest, TWebRequestArgs, TWebRequestOp, TResult>(envelope, op); }
try { return await origin.SendAsync<TWebRequest, TWebRequestArgs, TWebRequestOp, TResult>(envelope, op, progressHandler); }
catch (Exception e) when (e is not OperationCanceledException)
{
if (e.Message.Contains(WebRequestUtils.CANNOT_CONNECT_ERROR))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using Cysharp.Threading.Tasks;
using System.Threading;

namespace DCL.WebRequests
{
public readonly struct GetDecompressedContentLengthOp : IWebRequestOp<GenericHeadRequest, long>
{
private const string DECOMPRESSED_CONTENT_LENGTH_HEADER = "x-decompressed-content-length";

public UniTask<long> ExecuteAsync(GenericHeadRequest webRequest, CancellationToken ct)
{
string header = webRequest.UnityWebRequest.GetResponseHeader(DECOMPRESSED_CONTENT_LENGTH_HEADER);

if (header != null && long.TryParse(header, out long contentLength))
return UniTask.FromResult(contentLength);

return UniTask.FromResult(-1L);
}
}
}

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

10 changes: 10 additions & 0 deletions Explorer/Assets/DCL/WebRequests/IStreamableLoadingProgress.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace DCL.WebRequests
{
public interface IStreamableLoadingProgressHandler
{
float Progress { get; }
long ContentLength { get; }
void SetProgress(float progress);
void SetContentLength(long length);
}
}

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

40 changes: 36 additions & 4 deletions Explorer/Assets/DCL/WebRequests/ITypedWebRequest.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using Cysharp.Threading.Tasks;
using System;
using Cysharp.Threading.Tasks;
using System.Threading;
using UnityEngine.Networking;

Expand All @@ -20,7 +19,40 @@ public interface ITypedWebRequest

public static class TypedWebRequestExtensions
{
public static UniTask SendRequest<T>(this T typedWebRequest, CancellationToken token) where T: ITypedWebRequest =>
typedWebRequest.UnityWebRequest.SendWebRequest()!.WithCancellation(token);
public static async UniTask SendRequest<T>(this T typedWebRequest, CancellationToken token, IStreamableLoadingProgressHandler? progressHandler = null) where T: ITypedWebRequest
{
if (progressHandler == null)
{
// WithCancellation has a special overload for UnityWebRequestAsyncOperation
// that checks request.result and throws UnityWebRequestException on failure automatically.
await typedWebRequest.UnityWebRequest.SendWebRequest().WithCancellation(token);
return;
}

long contentLength = progressHandler.ContentLength;
UnityWebRequestAsyncOperation op = typedWebRequest.UnityWebRequest.SendWebRequest();

while (!op.isDone)
{
if (token.IsCancellationRequested)
return;

if (contentLength > 0)
progressHandler.SetProgress(op.webRequest.downloadedBytes / (float)contentLength);
else
progressHandler.SetProgress(op.progress); // Unreliable for on-the-fly compressed responses, but best-effort fallback

await UniTask.Yield();
}

progressHandler.SetProgress(1f);

// The manual polling loop above bypasses UniTask's automatic error handling,
// so we must check the result and throw explicitly here.
UnityWebRequest request = typedWebRequest.UnityWebRequest;
Comment thread
dalkia marked this conversation as resolved.

if (request.result != UnityWebRequest.Result.Success) // This is really important. Other systems react to this exception being triggered.
throw new UnityWebRequestException(request);
}
}
}
5 changes: 4 additions & 1 deletion Explorer/Assets/DCL/WebRequests/IWebRequestController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ public interface IWebRequestController

public IRequestHub RequestHub { get; }

public UniTask<TResult?> SendAsync<TWebRequest, TWebRequestArgs, TWebRequestOp, TResult>(RequestEnvelope<TWebRequest, TWebRequestArgs> envelope, TWebRequestOp op)
public UniTask<TResult?> SendAsync<TWebRequest, TWebRequestArgs, TWebRequestOp, TResult>(
RequestEnvelope<TWebRequest, TWebRequestArgs> envelope,
TWebRequestOp op,
IStreamableLoadingProgressHandler? progressHandler = null)
where TWebRequestArgs: struct
where TWebRequest: struct, ITypedWebRequest
where TWebRequestOp: IWebRequestOp<TWebRequest, TResult>;
Expand Down
Loading
Loading