Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
@@ -1,33 +1,29 @@
// 11x11 separable Gaussian blur for VSM shadow map
// Separable Gaussian blur for EVSM shadow map moments
// BLUR_HORIZONTAL - horizontal pass
// BLUR_VERTICAL - vertical pass

Texture2D<float2> InputTexture : register(t0);
RWTexture2D<float2> OutputTexture : register(u0);

// Gaussian weights for 11-tap kernel (sigma ~= 2.5)
static const float weights[6] = {
0.198596, // center
0.175713, // +/- 1
0.121703, // +/- 2
0.065984, // +/- 3
0.028002, // +/- 4
0.009302 // +/- 5
Texture2D<float4> InputTexture : register(t0);
RWTexture2D<float4> OutputTexture : register(u0);

cbuffer BlurCB : register(b0)
{
uint BlurRadius;
uint _pad[3];
};

#define KERNEL_RADIUS 5
#define MAX_KERNEL_RADIUS 32
#define GROUP_SIZE 128

// Shared memory for efficient loading
// We need GROUP_SIZE + 2 * KERNEL_RADIUS elements
groupshared float2 g_cache[GROUP_SIZE + 2 * KERNEL_RADIUS];
// We need GROUP_SIZE + 2 * MAX_KERNEL_RADIUS elements
groupshared float4 g_cache[GROUP_SIZE + 2 * MAX_KERNEL_RADIUS];

#if defined(BLUR_HORIZONTAL)
[numthreads(GROUP_SIZE, 1, 1)] void main(uint3 groupID : SV_GroupID, uint3 groupThreadID : SV_GroupThreadID, uint3 dispatchThreadID : SV_DispatchThreadID) {
uint width, height;
InputTexture.GetDimensions(width, height);

int2 baseCoord = int2(groupID.x * GROUP_SIZE - KERNEL_RADIUS, groupID.y);
int2 baseCoord = int2(groupID.x * GROUP_SIZE - MAX_KERNEL_RADIUS, groupID.y);
int localIdx = groupThreadID.x;

// Load main data
Expand All @@ -36,7 +32,7 @@ groupshared float2 g_cache[GROUP_SIZE + 2 * KERNEL_RADIUS];
g_cache[localIdx] = InputTexture[coord];

// Load extra data for kernel overlap
if (localIdx < 2 * KERNEL_RADIUS) {
if (localIdx < 2 * MAX_KERNEL_RADIUS) {
coord = baseCoord + int2(GROUP_SIZE + localIdx, 0);
coord.x = clamp(coord.x, 0, (int)width - 1);
g_cache[GROUP_SIZE + localIdx] = InputTexture[coord];
Expand All @@ -48,24 +44,29 @@ groupshared float2 g_cache[GROUP_SIZE + 2 * KERNEL_RADIUS];
if (dispatchThreadID.x >= width || dispatchThreadID.y >= height)
return;

// Apply horizontal blur
float2 result = g_cache[localIdx + KERNEL_RADIUS] * weights[0];
// Apply horizontal blur with dynamic radius
uint radius = min(BlurRadius, (uint)MAX_KERNEL_RADIUS);
float sigma = max(float(radius) * 0.5, 0.5);
float rcpTwoSigma2 = rcp(2.0 * sigma * sigma);

float4 result = g_cache[localIdx + MAX_KERNEL_RADIUS];
float totalWeight = 1.0;

[unroll] for (int i = 1; i <= KERNEL_RADIUS; i++)
{
result += g_cache[localIdx + KERNEL_RADIUS - i] * weights[i];
result += g_cache[localIdx + KERNEL_RADIUS + i] * weights[i];
for (uint i = 1; i <= radius; i++) {
float w = exp(-float(i * i) * rcpTwoSigma2);
result += (g_cache[localIdx + MAX_KERNEL_RADIUS - i] + g_cache[localIdx + MAX_KERNEL_RADIUS + i]) * w;
totalWeight += 2.0 * w;
}

OutputTexture[dispatchThreadID.xy] = result;
OutputTexture[dispatchThreadID.xy] = result * rcp(totalWeight);
}

#elif defined(BLUR_VERTICAL)
[numthreads(1, GROUP_SIZE, 1)] void main(uint3 groupID : SV_GroupID, uint3 groupThreadID : SV_GroupThreadID, uint3 dispatchThreadID : SV_DispatchThreadID) {
uint width, height;
InputTexture.GetDimensions(width, height);

int2 baseCoord = int2(groupID.x, groupID.y * GROUP_SIZE - KERNEL_RADIUS);
int2 baseCoord = int2(groupID.x, groupID.y * GROUP_SIZE - MAX_KERNEL_RADIUS);
int localIdx = groupThreadID.y;

// Load main data
Expand All @@ -74,7 +75,7 @@ groupshared float2 g_cache[GROUP_SIZE + 2 * KERNEL_RADIUS];
g_cache[localIdx] = InputTexture[coord];

// Load extra data for kernel overlap
if (localIdx < 2 * KERNEL_RADIUS) {
if (localIdx < 2 * MAX_KERNEL_RADIUS) {
coord = baseCoord + int2(0, GROUP_SIZE + localIdx);
coord.y = clamp(coord.y, 0, (int)height - 1);
g_cache[GROUP_SIZE + localIdx] = InputTexture[coord];
Expand All @@ -86,15 +87,20 @@ groupshared float2 g_cache[GROUP_SIZE + 2 * KERNEL_RADIUS];
if (dispatchThreadID.x >= width || dispatchThreadID.y >= height)
return;

// Apply vertical blur
float2 result = g_cache[localIdx + KERNEL_RADIUS] * weights[0];
// Apply vertical blur with dynamic radius
uint radius = min(BlurRadius, (uint)MAX_KERNEL_RADIUS);
float sigma = max(float(radius) * 0.5, 0.5);
float rcpTwoSigma2 = rcp(2.0 * sigma * sigma);

float4 result = g_cache[localIdx + MAX_KERNEL_RADIUS];
float totalWeight = 1.0;

[unroll] for (int i = 1; i <= KERNEL_RADIUS; i++)
{
result += g_cache[localIdx + KERNEL_RADIUS - i] * weights[i];
result += g_cache[localIdx + KERNEL_RADIUS + i] * weights[i];
for (uint i = 1; i <= radius; i++) {
float w = exp(-float(i * i) * rcpTwoSigma2);
result += (g_cache[localIdx + MAX_KERNEL_RADIUS - i] + g_cache[localIdx + MAX_KERNEL_RADIUS + i]) * w;
totalWeight += 2.0 * w;
}

OutputTexture[dispatchThreadID.xy] = result;
OutputTexture[dispatchThreadID.xy] = result * rcp(totalWeight);
}
#endif
Original file line number Diff line number Diff line change
@@ -1,19 +1,43 @@
Texture2DArray<float> InputTexture : register(t0);
Texture2DArray<float> ESRAMShadow : register(t1);
RWTexture2D<float2> OutputTexture : register(u0);
RWTexture2D<float4> OutputTexture : register(u0);
SamplerState LinearSampler : register(s0);

float2 GetVSMMoments(in float depth)
cbuffer EVSMLinearizeCB : register(b0)
{
return float2(depth, depth * depth);
float CascadeNear;
float CascadeFar;
float GlobalNear;
float GlobalFar;
float ExponentPositive;
float ExponentNegative;
};

// Convert orthographic shadow map depth [0,1] to globally-normalized linear depth [0,1].
// Shadow map depth is linear within each cascade: worldZ = near + depth * (far - near).
// We then remap to a global range shared by both cascades so exponents behave consistently.
float NormalizeDepth(float depth)
{
float worldZ = CascadeNear + depth * (CascadeFar - CascadeNear);
return (worldZ - GlobalNear) / (GlobalFar - GlobalNear);
}

// Warp depth into EVSM moments: (e^(c*d), e^(2c*d), e^(-c*d), e^(-2c*d))
// Positive exponent detects front-face occlusion, negative detects back-face (light bleeding).
float4 WarpDepth(float depth)
{
float d = NormalizeDepth(depth);
float posWarp = exp(ExponentPositive * d);
float negWarp = exp(-ExponentNegative * d);
return float4(posWarp, posWarp * posWarp, negWarp, negWarp * negWarp);
}

float2 ReduceMoments(float2 a, float2 b, float2 c, float2 d)
float4 ReduceMoments(float4 a, float4 b, float4 c, float4 d)
{
return (a + b + c + d) * 0.25;
}

groupshared float2 g_scratchDepths[8][8];
groupshared float4 g_scratchDepths[8][8];

#if defined(DOWNSAMPLE_SHADOW_MIP0)
static const uint CASCADE = 1;
Expand Down Expand Up @@ -47,12 +71,12 @@ static const uint CASCADE = 0;
float4 esramDepths = ESRAMShadow.GatherRed(LinearSampler, float3(uv, CASCADE));
depths = min(depths, esramDepths);

float2 vsmDepth = 0;
float4 evsmMoments = 0;
for (uint i = 0; i < 4; i++)
vsmDepth += GetVSMMoments(depths[i]);
vsmDepth *= 0.25;
evsmMoments += WarpDepth(depths[i]);
evsmMoments *= 0.25;

g_scratchDepths[groupThreadID.x][groupThreadID.y] = vsmDepth;
g_scratchDepths[groupThreadID.x][groupThreadID.y] = evsmMoments;

GroupMemoryBarrierWithGroupSync();

Expand Down
Original file line number Diff line number Diff line change
@@ -1,40 +1,70 @@
#ifndef __VOLUMETRIC_SHADOWS_HLSLI__
#define __VOLUMETRIC_SHADOWS_HLSLI__

// Variance Shadow Maps (VSM)
// Chebyshev's inequality on filtered depth moments

namespace VolumetricShadows
{
Texture2D<float2> SharedShadowMap : register(t18);

static const float VSM_MIN_VARIANCE = 0.00001;
static const float VSM_BLEEDING_REDUCTION = 0.2;
Texture2D<float4> SharedShadowMap : register(t18);

// EVSM exponents — must match values set in C++ (VolumetricShadows.h)
static const float EVSM_EXPONENT_POS = 40.0;
static const float EVSM_EXPONENT_NEG = 5.0;
static const float EVSM_VARIANCE_BIAS = 0.001;
static const float EVSM_LIGHT_BLEED_REDUCTION = 0.3;
Comment on lines +8 to +12

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Hardcoded EVSM exponents won't respect runtime settings.

The constants EVSM_EXPONENT_POS = 40.0 and EVSM_EXPONENT_NEG = 5.0 are compile-time values that ignore the user's runtime settings (VolumetricShadows::Settings::ExponentPositive and ExponentNegative). The comment claims they "must match values set in C++," but the C++ values are runtime-configurable, not compile-time constants.

More critically, these hardcoded values are used in ComputeEVSM (lines 44-45) during shadow sampling, while DownsampleShadowCS.hlsl uses the runtime exponents from EVSMLinearizeCB during moment generation. If a user changes the exponent settings, moments will be generated with one exponent but sampled with another, producing completely incorrect shadow results.

🐛 Recommended fix: read exponents from DirectionalShadowLightData

Since the exponents are already uploaded to the GPU via EVSMLinearizeCB during downsample and stored in DirectionalShadowLightData during deferred light upload, you can pass them through to the sampling functions.

Option 1: Add exponent fields to DirectionalShadowLightData and pass them to ComputeEVSM:

In ShadowSampling.hlsli:

 struct DirectionalShadowLightData
 {
 	column_major float4x4 ShadowProj[2];
 	column_major float4x4 InvShadowProj[2];
 	float2 EndSplitDistances;
 	float2 StartSplitDistances;
 	float4 CascadeDepthParams;
-	float4 GlobalDepthParams;  // x=globalNear, y=globalFar, zw=unused
+	float4 GlobalDepthParams;  // x=globalNear, y=globalFar, z=ExponentPositive, w=ExponentNegative
 };

In VolumetricShadows.hlsli, update ComputeEVSM:

-float ComputeEVSM(float4 moments, float depth, float cascadeNear, float cascadeFar, float globalNear, float globalFar)
+float ComputeEVSM(float4 moments, float depth, float cascadeNear, float cascadeFar, float globalNear, float globalFar, float exponentPos, float exponentNeg)
 {
 	float d = NormalizeDepth(depth, cascadeNear, cascadeFar, globalNear, globalFar);
-	float posWarp = exp(EVSM_EXPONENT_POS * d);
-	float negWarp = exp(-EVSM_EXPONENT_NEG * d);
+	float posWarp = exp(exponentPos * d);
+	float negWarp = exp(-exponentNeg * d);
 	// ... rest unchanged
}

Then thread the exponents through all call sites.

Option 2: Add a separate constant buffer for EVSM sampling parameters.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@features/Volumetric`
Shadows/Shaders/VolumetricShadows/VolumetricShadows.hlsli around lines 8 - 12,
The hardcoded EVSM_EXPONENT_POS/NEG in VolumetricShadows.hlsli cause sampling to
ignore runtime settings — update the shader to read the exponents from GPU state
instead of using compile-time constants: add fields for
exponentPositive/exponentNegative to DirectionalShadowLightData (or create a
small EVSM sampling CB populated from EVSMLinearizeCB), remove/replace
EVSM_EXPONENT_POS and EVSM_EXPONENT_NEG, thread those exponent values into
ComputeEVSM and all call sites that perform EVSM sampling (ensure
DownsampleShadowCS.hlsl and the deferred light upload path supply the same
runtime exponents), and keep EVSM_VARIANCE_BIAS/EVSM_LIGHT_BLEED_REDUCTION as
needed.


// Convert orthographic shadow projection depth to globally-normalized [0,1].
// positionLS.z from mul(ShadowProj, worldPos) is orthographic [0,1] within the cascade.
// We remap through world space to the same global range used during moment generation.
float NormalizeDepth(float depth, float cascadeNear, float cascadeFar, float globalNear, float globalFar)
{
float worldZ = cascadeNear + depth * (cascadeFar - cascadeNear);
return (worldZ - globalNear) / (globalFar - globalNear);
}

// Chebyshev upper bound on P(X >= t)
// moments.x = mean(z), moments.y = mean(z^2)
float ComputeVSM(float2 moments, float depth)
// Chebyshev upper bound: P(x >= t) <= variance / (variance + (t - mean)^2)
// Returns visibility [0,1] where 1 = fully lit
float ChebyshevUpperBound(float mean, float meanSq, float testValue)
{
float variance = max(moments.y - moments.x * moments.x, VSM_MIN_VARIANCE);
float d = depth - moments.x;
float variance = max(meanSq - mean * mean, EVSM_VARIANCE_BIAS);

float d = testValue - mean;
float pMax = variance / (variance + d * d);
return (depth <= moments.x) ? 1.0 : pMax;

// Reduce light bleeding by remapping [bleedReduction..1] -> [0..1]
pMax = saturate((pMax - EVSM_LIGHT_BLEED_REDUCTION) / (1.0 - EVSM_LIGHT_BLEED_REDUCTION));

// If the test value is behind the mean, it's fully lit
return (testValue <= mean) ? 1.0 : pMax;
}

// Reduces light bleeding by remapping shadow values below a threshold to zero
float ReduceBleeding(float shadow, float amount)
// Compute EVSM shadow from stored moments
// moments = (E[e^cz], E[e^2cz], E[e^-cz], E[e^-2cz])
float ComputeEVSM(float4 moments, float depth, float cascadeNear, float cascadeFar, float globalNear, float globalFar)
{
return saturate((shadow - amount) / (1.0 - amount));
float d = NormalizeDepth(depth, cascadeNear, cascadeFar, globalNear, globalFar);
float posWarp = exp(EVSM_EXPONENT_POS * d);
float negWarp = exp(-EVSM_EXPONENT_NEG * d);

// Positive exponent test (standard front-face shadow)
float posShadow = ChebyshevUpperBound(moments.x, moments.y, posWarp);

// Negative exponent test (back-face light bleed suppression)
float negShadow = ChebyshevUpperBound(moments.z, moments.w, negWarp);

return min(posShadow, negShadow);
}
Comment on lines +39 to 54

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Update ComputeEVSM to use runtime exponents instead of hardcoded constants.

Lines 44-45 use the hardcoded compile-time constants EVSM_EXPONENT_POS and EVSM_EXPONENT_NEG, which will not match the runtime exponents used during moment generation in DownsampleShadowCS.hlsl. See the comment on lines 8-12 for the full explanation and recommended fix.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@features/Volumetric`
Shadows/Shaders/VolumetricShadows/VolumetricShadows.hlsli around lines 39 - 54,
ComputeEVSM currently uses compile-time constants EVSM_EXPONENT_POS and
EVSM_EXPONENT_NEG; update it to accept runtime exponents so the same values used
during moment generation are applied during evaluation. Modify the ComputeEVSM
signature to add two parameters (e.g., float evsmExpPos, float evsmExpNeg) and
replace EVSM_EXPONENT_POS/EVSM_EXPONENT_NEG with those parameters, then ensure
callers (where ComputeEVSM is invoked) forward the runtime exponents produced in
DownsampleShadowCS.hlsl (or read from the same shader uniform/buffer) so moment
generation and shadow evaluation use identical exponent values. Ensure the
parameter names match usages in callers and keep the rest of the logic
(posWarp/negWarp and ChebyshevUpperBound calls) unchanged.


// Sample a single cascade for VSM shadow
float SampleVSMCascade3D(
// Sample a single cascade for EVSM shadow (3D ray march)
float SampleEVSMCascade3D(
uint cascadeIndex,
float noise,
uint sampleCount,
float rcpSampleCount,
float3 startPositionLS,
float3 endPositionLS,
float cascadeNear,
float cascadeFar,
float globalNear,
float globalFar,
out float firstSample)
{
float shadow = 0.0;
Expand All @@ -45,8 +75,8 @@ namespace VolumetricShadows
float t = (float(k) + noise) * rcpSampleCount;
float3 samplePosLS = lerp(endPositionLS, startPositionLS, t);

float2 moments = SharedShadowMap.SampleLevel(LinearSampler, samplePosLS.xy, 1u - cascadeIndex);
float lit = ComputeVSM(moments, samplePosLS.z);
float4 moments = SharedShadowMap.SampleLevel(LinearSampler, samplePosLS.xy, 1u - cascadeIndex);
float lit = ComputeEVSM(moments, samplePosLS.z, cascadeNear, cascadeFar, globalNear, globalFar);

// Last to set firstSample is start position
firstSample = lit;
Expand Down Expand Up @@ -88,16 +118,24 @@ namespace VolumetricShadows
uint primaryCascade = uint(cascadeSelect);
bool needsBlending = (cascadeSelect > 0.0) && (cascadeSelect < 1.0);

float4 depthParams = directionalShadowLightData.CascadeDepthParams;
float4 globalParams = directionalShadowLightData.GlobalDepthParams;
float globalNear = globalParams.x;
float globalFar = globalParams.y;

// Transform ray to light space for primary cascade
float4x4 shadowProj = directionalShadowLightData.ShadowProj[primaryCascade];
float3 startLS = mul(shadowProj, float4(startPosition, 1)).xyz;
float3 endLS = mul(shadowProj, float4(endPosition, 1)).xyz;
startLS.xy = saturate(startLS.xy);
endLS.xy = saturate(endLS.xy);

float primaryNear = primaryCascade == 0 ? depthParams.x : depthParams.z;
float primaryFar = primaryCascade == 0 ? depthParams.y : depthParams.w;

// Sample primary cascade
float primaryFirstSample;
float shadow = SampleVSMCascade3D(primaryCascade, noise, sampleCount, rcpSampleCount, startLS, endLS, primaryFirstSample);
float shadow = SampleEVSMCascade3D(primaryCascade, noise, sampleCount, rcpSampleCount, startLS, endLS, primaryNear, primaryFar, globalNear, globalFar, primaryFirstSample);
surfaceShadow = primaryFirstSample;

// Blend with secondary cascade if needed
Expand All @@ -111,8 +149,11 @@ namespace VolumetricShadows
startLS.xy = saturate(startLS.xy);
endLS.xy = saturate(endLS.xy);

float secondaryNear = secondaryCascade == 0 ? depthParams.x : depthParams.z;
float secondaryFar = secondaryCascade == 0 ? depthParams.y : depthParams.w;

float secondaryFirstSample;
float shadowBlend = SampleVSMCascade3D(secondaryCascade, noise, sampleCount, rcpSampleCount, startLS, endLS, secondaryFirstSample);
float shadowBlend = SampleEVSMCascade3D(secondaryCascade, noise, sampleCount, rcpSampleCount, startLS, endLS, secondaryNear, secondaryFar, globalNear, globalFar, secondaryFirstSample);
shadow = lerp(shadow, shadowBlend, cascadeSelect);
surfaceShadow = lerp(surfaceShadow, secondaryFirstSample, cascadeSelect);
}
Expand All @@ -123,11 +164,11 @@ namespace VolumetricShadows
return lerp(1.0, shadow, fadeFactor);
}

// Sample a single cascade for VSM shadow (2D point sample)
float SampleVSMCascade2D(uint cascadeIndex, float3 positionLS)
// Sample a single cascade for EVSM shadow (2D point sample)
float SampleEVSMCascade2D(uint cascadeIndex, float3 positionLS, float cascadeNear, float cascadeFar, float globalNear, float globalFar)
{
float2 moments = SharedShadowMap.SampleLevel(LinearSampler, positionLS.xy, 1u - cascadeIndex);
return ComputeVSM(moments, positionLS.z);
float4 moments = SharedShadowMap.SampleLevel(LinearSampler, positionLS.xy, 1u - cascadeIndex);
return ComputeEVSM(moments, positionLS.z, cascadeNear, cascadeFar, globalNear, globalFar);
}

float GetVSMShadow2D(float3 position, out float detailedShadow)
Expand Down Expand Up @@ -155,12 +196,20 @@ namespace VolumetricShadows
uint primaryCascade = uint(cascadeSelect);
bool needsBlending = (cascadeSelect > 0.0) && (cascadeSelect < 1.0);

float4 depthParams = directionalShadowLightData.CascadeDepthParams;
float4 globalParams = directionalShadowLightData.GlobalDepthParams;
float globalNear = globalParams.x;
float globalFar = globalParams.y;

// Transform position to light space for primary cascade
float3 positionLS = mul(directionalShadowLightData.ShadowProj[primaryCascade], float4(positionWS, 1)).xyz;
positionLS.xy = saturate(positionLS.xy);

float primaryNear = primaryCascade == 0 ? depthParams.x : depthParams.z;
float primaryFar = primaryCascade == 0 ? depthParams.y : depthParams.w;

// Sample primary cascade
float shadow = SampleVSMCascade2D(primaryCascade, positionLS);
float shadow = SampleEVSMCascade2D(primaryCascade, positionLS, primaryNear, primaryFar, globalNear, globalFar);

// Blend with secondary cascade if needed
[branch] if (needsBlending)
Expand All @@ -170,14 +219,17 @@ namespace VolumetricShadows
positionLS = mul(directionalShadowLightData.ShadowProj[secondaryCascade], float4(positionWS, 1)).xyz;
positionLS.xy = saturate(positionLS.xy);

float shadowBlend = SampleVSMCascade2D(secondaryCascade, positionLS);
float secondaryNear = secondaryCascade == 0 ? depthParams.x : depthParams.z;
float secondaryFar = secondaryCascade == 0 ? depthParams.y : depthParams.w;

float shadowBlend = SampleEVSMCascade2D(secondaryCascade, positionLS, secondaryNear, secondaryFar, globalNear, globalFar);
shadow = lerp(shadow, shadowBlend, cascadeSelect);
}

// Apply distance fade
float fadeFactor = 1.0 - pow(fade * fade, 8);
detailedShadow = lerp(1.0, ReduceBleeding(shadow, VSM_BLEEDING_REDUCTION), fadeFactor);
return lerp(1.0, shadow, fadeFactor);
detailedShadow = lerp(1.0, shadow, fadeFactor);
return detailedShadow;
}
}

Expand Down
Loading
Loading