diff --git a/Sources/UntoldEngine/Renderer/RenderPasses.swift b/Sources/UntoldEngine/Renderer/RenderPasses.swift index 811250c03..3401a3d78 100644 --- a/Sources/UntoldEngine/Renderer/RenderPasses.swift +++ b/Sources/UntoldEngine/Renderer/RenderPasses.swift @@ -1415,7 +1415,7 @@ public enum RenderPasses { let projectionMatrix = renderInfo.perspectiveSpace // Render each cascade into its own depth array slice. - for cascadeIdx in 0 ..< csmCascadeCount { + for cascadeIdx in 0 ..< shadowSystem.cascadeCount { guard cascadeIdx < renderInfo.csmRenderPassDescriptors.count else { break } let descriptor = renderInfo.csmRenderPassDescriptors[cascadeIdx] descriptor.depthAttachment.loadAction = .clear @@ -1528,7 +1528,7 @@ public enum RenderPasses { batchUniforms.cameraPosition = shadingCameraPosition(cameraComponent) batchUniforms.projectionMatrix = renderInfo.perspectiveSpace - for cascadeIdx in 0 ..< csmCascadeCount { + for cascadeIdx in 0 ..< shadowSystem.cascadeCount { guard cascadeIdx < renderInfo.csmRenderPassDescriptors.count else { break } // Batched geometry adds into the existing cascade depth slice (already written by shadowExecution). diff --git a/Sources/UntoldEngine/Shaders/LightShader.metal b/Sources/UntoldEngine/Shaders/LightShader.metal index 2926a22ec..96ea155d8 100644 --- a/Sources/UntoldEngine/Shaders/LightShader.metal +++ b/Sources/UntoldEngine/Shaders/LightShader.metal @@ -305,8 +305,12 @@ EnvironmentLight computeIBLParts(texture2d irradianceTexture, //compute ibl ambient contribution // The angle to the surface, whichever side of it is seen (see specularIBL). float NoV = min(abs(dot(normalMap.xyz, viewVector)), 1.0); - - float3 irradiance=diffuseIBL(normalMap.xyz, irradianceTexture, float3(0.0,1.0,0.0),degreesToRadians(iblRotationAngle)); + // The rotation of the environment, in radians, once; the lookup table's two values at + // this angle and roughness, once, for the reflection and the share it takes from diffuse. + float rotation = degreesToRadians(iblRotationAngle); + float2 brdf = brdfIntegration(NoV, roughness, iblBRDFTexture); + + float3 irradiance=diffuseIBL(normalMap.xyz, irradianceTexture, float3(0.0,1.0,0.0), rotation); float3 diffuse = irradiance*inBaseColor.rgb; @@ -315,7 +319,8 @@ EnvironmentLight computeIBLParts(texture2d irradianceTexture, // grows when the surface is seen at a slant. float3 f0 = mix(0.04, inBaseColor.rgb, metallic); - float3 specular=specularIBL(f0, roughness, normalMap.xyz, viewVector, specularTexture, iblBRDFTexture,float3(0.0,1.0,0.0),degreesToRadians(iblRotationAngle)); + float3 specular = blurredEnvironment(reflect(-viewVector, normalMap.xyz), roughness, specularTexture, float3(0.0,1.0,0.0), rotation) + * environmentReflectance(f0, brdf); // Every surface reflects the environment, a non-metal too: that is the sheen of // polished plastic, glass or lacquer. What it reflects it does not scatter, so the @@ -323,7 +328,7 @@ EnvironmentLight computeIBLParts(texture2d irradianceTexture, // single-bounce share only: the multi-bounce energy compensation in // environmentReflectance exists to correct the specular term's own approximation, // not to measure how much the surface diverts away from diffuse scattering. - float3 reflected = singleBounceReflectance(f0, roughness, NoV, iblBRDFTexture); + float3 reflected = singleBounceReflectance(f0, brdf); EnvironmentLight light; light.diff = max(1.0 - reflected, 0.0) * (1.0 - metallic) * diffuse; @@ -636,8 +641,11 @@ fragment float4 fragmentLightShader(VertexCompositeOutput vertexOut [[stage_in]] color.diff = brdf.diff * (half3)lights.color * (half)lights.intensity; color.spec = brdf.spec*lights.color*lights.intensity; - // Compute shadow using cascaded shadow maps - float shadow = computeCSMShadow(csmShadowArray, csmUniforms, verticesInWorldSpace.xyz, surfaceNormal, lightRayDirection); + // Compute shadow using cascaded shadow maps, only with a sun (see modelShader.metal). + float shadow = 1.0; + if (lights.intensity != 0.0) { + shadow = computeCSMShadow(csmShadowArray, csmUniforms, verticesInWorldSpace.xyz, surfaceNormal, lightRayDirection); + } // shadows affect directional light for now color.diff = color.diff*(half)shadow; @@ -753,15 +761,18 @@ float3 computeDirectLighting( constant PointShadowUniforms &pointShadowUniforms, constant SpotShadowUniforms &spotShadowUniforms ) { - float3 lightRayDirection = normalize(lights.direction); - - LightContribution brdf = computeBRDF(lightRayDirection, viewVector, normal, albedo, float3(1.0), roughness, metallic); - LightContribution color; - color.diff = brdf.diff * (half3)lights.color * (half)lights.intensity; - color.spec = brdf.spec * lights.color * lights.intensity; - color.diff *= (half)sunShadow; - color.spec *= sunShadow; + // A sun at no intensity adds nothing, so its BRDF is not evaluated: the branch is on a + // per-frame constant, uniform across the draw. Any other intensity takes the old path, + // in the old order of products. + if (lights.intensity != 0.0) { + float3 lightRayDirection = normalize(lights.direction); + LightContribution brdf = computeBRDF(lightRayDirection, viewVector, normal, albedo, float3(1.0), roughness, metallic); + color.diff = brdf.diff * (half3)lights.color * (half)lights.intensity; + color.spec = brdf.spec * lights.color * lights.intensity; + color.diff *= (half)sunShadow; + color.spec *= sunShadow; + } LightContribution pointColor; uint lightCount = min(plBlock.count.x, MAX_POINT_LIGHTS); @@ -881,9 +892,13 @@ static inline float4 lightTBDRSample( indirectLighting *= ambientOcclusion * iblParam.ambientIntensity; } - float shadow = indirectFromGBuffer - ? float(gbEmissive.a) - : computeCSMShadow(csmShadowArray, csmUniforms, verticesInWorldSpace.xyz, surfaceNormal, lightRayDirection); + // The sun's shadow: the one the geometry wrote, else the cascades, only with a sun. + float shadow = 1.0; + if (indirectFromGBuffer) { + shadow = float(gbEmissive.a); + } else if (lights.intensity != 0.0) { + shadow = computeCSMShadow(csmShadowArray, csmUniforms, verticesInWorldSpace.xyz, surfaceNormal, lightRayDirection); + } float3 direct = computeDirectLighting( verticesInWorldSpace, viewVector, surfaceNormal, albedo.rgb, roughness, metallic, shadow, lights, plBlock, slBlock, alBlock, ltcMagTexture, ltcMatTexture, pointShadowCube, spotShadowMap, diff --git a/Sources/UntoldEngine/Shaders/ShadersUtils.h b/Sources/UntoldEngine/Shaders/ShadersUtils.h index 5665f23a6..b2cf2d16b 100644 --- a/Sources/UntoldEngine/Shaders/ShadersUtils.h +++ b/Sources/UntoldEngine/Shaders/ShadersUtils.h @@ -165,8 +165,11 @@ float4 specularImportanceMap(float2 texCoords, texture2d environmentTextu float4 BRDFIntegrationMap(float roughness, float NoV); +float2 brdfIntegration(float NoV, float roughness, texture2d brdfMap); +float3 environmentReflectance(float3 F0, float2 brdf); float3 environmentReflectance(float3 F0, float roughness, float NoV, texture2d brdfMap); +float3 singleBounceReflectance(float3 F0, float2 brdf); float3 singleBounceReflectance(float3 F0, float roughness, float NoV, texture2d brdfMap); float3 blurredEnvironment(float3 direction, float roughness, texture2d specularMap, float3 rotationAxis, float rotationAngle); diff --git a/Sources/UntoldEngine/Shaders/ShadersUtils.metal b/Sources/UntoldEngine/Shaders/ShadersUtils.metal index c4a416591..d5d01ab5f 100644 --- a/Sources/UntoldEngine/Shaders/ShadersUtils.metal +++ b/Sources/UntoldEngine/Shaders/ShadersUtils.metal @@ -315,7 +315,9 @@ float2 equirectUVFromCubeDirection(float3 v){ const float2 biases {0.5f,0.5f}; //assume +z is forward. for -x forward, use atan2(v.z,v.x) - float2 uv=float2(atan2(-v.x, v.z),asin(-v.y))*scales+biases; + // The latitude's asin is clamped: a reflection of two unit vectors can round a hair past + // 1 at the poles, and asin of that is NaN. + float2 uv=float2(atan2(-v.x, v.z),asin(clamp(-v.y, -1.0, 1.0)))*scales+biases; return uv; } @@ -498,11 +500,10 @@ float4 BRDFIntegrationMap(float roughness, float NoV){ } -// The share of the light around it that a surface reflects in a single bounce off its -// microfacets: F0 head on (4 % for a non-metal, its own color for a metal), more when it -// is seen at a slant. This is the share diffuse scattering gives up to specular, before -// any later-bounce energy compensation. -float3 singleBounceReflectance(float3 F0, float roughness, float NoV, texture2d brdfMap) { +// The lookup table's two values for a surface seen at an angle (NoV) with a roughness: the +// scale and the bias the reflectance at that slant puts on F0. Read once per pixel and +// handed to the two reflectances below. +float2 brdfIntegration(float NoV, float roughness, texture2d brdfMap) { // The lookup table is read right at its edges (a surface seen head on, a roughness // of 1), so it clamps: wrapping there reads the far edge and leaves a white spot. @@ -511,23 +512,26 @@ float3 singleBounceReflectance(float3 F0, float roughness, float NoV, texture2d< mip_filter::none, address::clamp_to_edge); - float4 brdfIntegration=brdfMap.sample(s,float2(NoV,roughness)); + return brdfMap.sample(s, float2(NoV, roughness)).xy; +} + +// The share of the light around it that a surface reflects in a single bounce off its +// microfacets: F0 head on (4 % for a non-metal, its own color for a metal), more when it +// is seen at a slant. This is the share diffuse scattering gives up to specular, before +// any later-bounce energy compensation. `brdf` is the lookup table's pair for the surface. +float3 singleBounceReflectance(float3 F0, float2 brdf) { + return F0 * brdf.x + brdf.y; +} - return F0 * float(brdfIntegration.x) + float(brdfIntegration.y); +float3 singleBounceReflectance(float3 F0, float roughness, float NoV, texture2d brdfMap) { + return singleBounceReflectance(F0, brdfIntegration(NoV, roughness, brdfMap)); } // The share of the light around it that a surface reflects: F0 head on (4 % for a // non-metal, its own color for a metal), more when it is seen at a slant. -float3 environmentReflectance(float3 F0, float roughness, float NoV, texture2d brdfMap) { +float3 environmentReflectance(float3 F0, float2 brdf) { - constexpr sampler s(coord::normalized, - filter::linear, - mip_filter::none, - address::clamp_to_edge); - - float4 brdfIntegration=brdfMap.sample(s,float2(NoV,roughness)); - - float3 reflectedInOneBounce = F0 * float(brdfIntegration.x) + float(brdfIntegration.y); + float3 reflectedInOneBounce = F0 * brdf.x + brdf.y; // A rough surface bounces part of the light between its own bumps before it lets it // go. The table counts a single bounce and takes the rest for lost: up to two thirds @@ -535,12 +539,16 @@ float3 environmentReflectance(float3 F0, float roughness, float NoV, texture2d brdfMap) { + return environmentReflectance(F0, brdfIntegration(NoV, roughness, brdfMap)); +} + // The environment in a direction, as blurred as a roughness makes it. float3 blurredEnvironment(float3 direction, float roughness, texture2d specularMap, float3 rotationAxis, float rotationAngle) { @@ -556,8 +564,14 @@ float3 blurredEnvironment(float3 direction, float roughness, texture2d sp int mipCount=6; - //Rotate the direction - float3 rotatedDirection=normalize(rotateDirection(direction, rotationAxis, rotationAngle)); + // Rotate the direction, unless the environment is not rotated (the headset's room probe + // never is): the angle is a per-frame constant, so the branch is uniform. Either way the + // direction comes out unit length, which the equirectangular lookup's asin needs: the + // simulator's light pass hands in a G-buffer normal as stored. + float3 rotatedDirection = normalize(direction); + if (rotationAngle != 0.0) { + rotatedDirection = rotateDirection(direction, rotationAxis, rotationAngle); + } float2 uv = equirectUVFromCubeDirection(rotatedDirection); float mipLevel = roughness * float(mipCount - 1); @@ -586,8 +600,11 @@ float3 diffuseIBL(float3 normal, texture2d irradianceMap, float3 rotation mip_filter::linear, address::repeat); - //Rotate the normal - float3 rotatedNormal=rotateDirection(normal, rotationAxis, rotationAngle); + // Rotate the normal, unless the environment is not rotated (see blurredEnvironment). + float3 rotatedNormal = normalize(normal); + if (rotationAngle != 0.0) { + rotatedNormal = rotateDirection(normal, rotationAxis, rotationAngle); + } float2 uv = equirectUVFromCubeDirection(rotatedNormal); return float3(irradianceMap.sample(s,uv).rgb); } diff --git a/Sources/UntoldEngine/Shaders/TAAShader.metal b/Sources/UntoldEngine/Shaders/TAAShader.metal index f59aea799..191c0c7d3 100644 --- a/Sources/UntoldEngine/Shaders/TAAShader.metal +++ b/Sources/UntoldEngine/Shaders/TAAShader.metal @@ -130,17 +130,21 @@ static inline float4 taaResolveColor( const float2 texel = 1.0 / max(taa.physicalSize, float2(1.0)); const float4 current = colorTexture.sample(pointSampler, uv); - // This pixel's position in space, from its depth, and where it was on the previous - // frame's screen and in the history texture. Computed for every pixel, before any pixel - // leaves: the local rates of the two maps are read below from the derivatives of these - // across the 2×2 quad, which need every lane of the quad to have computed them. // A texel at the clear depth has no position: nothing opaque was drawn there — the // background, or a transparent quad over it — and reprojecting it from the far plane // would fetch the wrong history and ghost or wipe it under a moving camera; such a pixel - // keeps its own frame, and in passthrough most of the frame is such pixels. + // keeps its own frame, and in passthrough most of the frame is such pixels. It leaves + // here, before the rate-map decodes and the reprojection: nothing below reads the + // neighbouring lanes (the maps' local rates come from a second decode a texel over, not + // from derivatives across the quad), so a lane that leaves early is safe. const float depth = depthTexture.sample(pointSampler, uv); const bool cleared = (taa.reverseZ != 0u) ? (depth <= 1e-6) : (depth >= 1.0 - 1e-6); + if (taa.historyValid == 0u || cleared) { + return current; + } + // This pixel's position in space, from its depth, and where it was on the previous + // frame's screen and in the history texture. const float2 physical = in.position.xy; const float2 screen = taaScreenFromPhysical(physical, taa.rateMapSizes, rateMap); const float2 screenSize = taa.rateMapSizes.x > 0.0 ? taa.rateMapSizes.xy : taa.physicalSize; @@ -162,24 +166,7 @@ static inline float4 taaResolveColor( const float2 prevScreen = float2((prevNdc.x * 0.5 + 0.5) * prevScreenSize.x, (1.0 - (prevNdc.y * 0.5 + 0.5)) * prevScreenSize.y); const float2 prevPhysical = taaPhysicalFromScreen(prevScreen, taa.prevRateMapSizes, prevRateMap); - // Under foveation the history may be coarser here than this frame: the spot was in the - // periphery of the previous map (one texel spanning several screen pixels) and the gaze - // has moved onto it. Trusting such a history at full weight shows the sharp frame with a - // blurry shadow that fades over twenty frames; its weight is cut by how much coarser it - // was, measured from the two maps' local rates (texels per screen pixel), per axis: a map - // is coarser in one axis than the other wherever its rows and columns differ, so the - // ratio is taken on the axis where the history is coarsest relative to this frame. The - // rates are the derivatives of the mappings across the quad — this frame's screen - // position per texel, and the history's texel per screen pixel — instead of a second - // decode of each map a pixel over. - const float2 screenStep = float2(abs(dfdx(screen.x)), abs(dfdy(screen.y))); - const float2 currentRate = 1.0 / max(screenStep, float2(1e-3)); - const float2 prevScreenStep = float2(abs(dfdx(prevScreen.x)), abs(dfdy(prevScreen.y))); - const float2 prevPhysicalStep = float2(abs(dfdx(prevPhysical.x)), abs(dfdy(prevPhysical.y))); - const float2 prevRate = prevPhysicalStep / max(prevScreenStep, float2(1e-4)); - const float2 ratio = prevRate / currentRate; - - if (taa.historyValid == 0u || cleared || !worldValid || !prevInFront) { + if (!worldValid || !prevInFront) { return current; } const float2 historyUV = prevPhysical * texel; @@ -208,6 +195,26 @@ static inline float4 taaResolveColor( float weight = taa.historyWeight; if (taa.rateMapSizes.x > 0.0 || taa.prevRateMapSizes.x > 0.0) { + // Under foveation the history may be coarser here than this frame: the spot was in + // the periphery of the previous map (one texel spanning several screen pixels) and + // the gaze has moved onto it. Trusting such a history at full weight shows the sharp + // frame with a blurry shadow that fades over twenty frames; its weight is cut by how + // much coarser it was, measured from the two maps' local rates (texels per screen + // pixel), per axis: a map is coarser in one axis than the other wherever its rows and + // columns differ, so the ratio is taken on the axis where the history is coarsest + // relative to this frame. Both rates are read as texels per screen pixel over the + // same screen interval, one pixel on from this pixel's screen position (or back, + // at either screen's far edge), each from its own map a second time: the maps' rows + // and columns are separable, so one decode gives both axes. The same interval for + // both keeps the ratio exactly 1 wherever the two maps agree, zone boundaries + // included. Not from derivatives across the quad: the pixels that left above would + // leave those undefined. Where the maps differ, the band before a boundary weighs a + // little differently than the quad's span did; it is at most a pixel wide. + const float2 delta = select(float2(1.0), float2(-1.0), + screen + 1.0 >= screenSize || prevScreen + 1.0 >= prevScreenSize); + const float2 currentRate = abs(taaPhysicalFromScreen(screen + delta, taa.rateMapSizes, rateMap) - physical); + const float2 prevRate = abs(taaPhysicalFromScreen(prevScreen + delta, taa.prevRateMapSizes, prevRateMap) - prevPhysical); + const float2 ratio = prevRate / max(currentRate, float2(1e-4)); weight *= saturate(min(ratio.x, ratio.y)); } diff --git a/Sources/UntoldEngine/Shaders/TransparencyShader.metal b/Sources/UntoldEngine/Shaders/TransparencyShader.metal index c6d3508b4..f34818ad9 100644 --- a/Sources/UntoldEngine/Shaders/TransparencyShader.metal +++ b/Sources/UntoldEngine/Shaders/TransparencyShader.metal @@ -148,9 +148,13 @@ fragment TransparencyOutput fragmentTransparencyShader( totalLight.diff = brdf.diff * (half3)lights.color * (half)lights.intensity; totalLight.spec = brdf.spec * lights.color * lights.intensity; - float shadow = computeCSMShadow(csmShadowArray, csmUniforms, verticesInWorldSpace.xyz, normal, lightDirection); - totalLight.diff *= (half)shadow; - totalLight.spec *= shadow; + // The sun's shadow only with a sun (see modelShader.metal's emissiveWithIndirect). + float shadow = 1.0; + if (lights.intensity != 0.0) { + shadow = computeCSMShadow(csmShadowArray, csmUniforms, verticesInWorldSpace.xyz, normal, lightDirection); + totalLight.diff *= (half)shadow; + totalLight.spec *= shadow; + } uint pointLightCount = min(plBlock.count.x, MAX_POINT_LIGHTS); for (uint i = 0; i < pointLightCount; ++i) { diff --git a/Sources/UntoldEngine/Shaders/modelShader.metal b/Sources/UntoldEngine/Shaders/modelShader.metal index 84877ab38..67e604b7c 100644 --- a/Sources/UntoldEngine/Shaders/modelShader.metal +++ b/Sources/UntoldEngine/Shaders/modelShader.metal @@ -293,16 +293,20 @@ static inline float2 modelVelocity(float3 currentClip, float3 prevClip) { /// The emissive attachment's value with the indirect light and the sun's shadow folded in. static inline float4 emissiveWithIndirect( float3 emissive, float4 baseColor, float3 normal, float3 worldPosition, float roughness, float metallic, - float3 cameraPosition, + float3 viewVector, texture2d iblIrradiance, texture2d iblSpecular, texture2d iblBRDF, depth2d_array csmShadowArray, constant CSMUniforms &csmUniforms, constant LightParameters &lightParams, constant IBLParamsUniform &iblParam, constant float &iblRotationAngle ) { - float3 viewVector = normalize(cameraPosition - worldPosition); float3 indirect = computeIBLContribution(iblIrradiance, iblSpecular, iblBRDF, iblRotationAngle, iblParam, baseColor, normal, viewVector, roughness, metallic) * iblParam.ambientIntensity; - float shadow = computeCSMShadow(csmShadowArray, csmUniforms, worldPosition, normal, normalize(lightParams.direction)); + // The sun's shadow only ever multiplies the sun's light, so a sun at no intensity needs + // no shadow taps: the branch is on a per-frame constant, uniform across the draw. + float shadow = 1.0; + if (lightParams.intensity != 0.0) { + shadow = computeCSMShadow(csmShadowArray, csmUniforms, worldPosition, normal, normalize(lightParams.direction)); + } return float4(emissive + indirect, shadow); } @@ -311,14 +315,13 @@ static inline float4 emissiveWithIndirect( /// alpha is the coverage the light pass would have written. static inline float4 litWithDirect( float4 emissiveAndShadow, float4 baseColor, float3 normal, float4 worldPosition, float roughness, float metallic, - float3 cameraPosition, + float3 viewVector, constant LightParameters &lightParams, constant PointLightBlock &plBlock, constant SpotLightBlock &slBlock, constant AreaLightBlock &alBlock, texture2d ltcMagTexture, texture2d ltcMatTexture, depthcube pointShadowCube, depth2d spotShadowMap, constant PointShadowUniforms &pointShadowUniforms, constant SpotShadowUniforms &spotShadowUniforms ) { - float3 viewVector = normalize(cameraPosition - worldPosition.xyz); float3 direct = computeDirectLighting( worldPosition, viewVector, normal, baseColor.rgb, roughness, metallic, emissiveAndShadow.a, lightParams, plBlock, slBlock, alBlock, ltcMagTexture, ltcMatTexture, pointShadowCube, spotShadowMap, @@ -472,18 +475,23 @@ fragment GBufferOut fragmentModelShader(VertexOutModel in [[stage_in]], // pixel-to-pixel movement near the height field's silhouette-like transitions. `hasHeight` // is uniform across the whole draw call, so branching between the two sampling forms here // is safe for the implicit-derivative branch. - float4 sampledColor = hasHeight - ? baseColor.sample(baseColorSampler, sampleUV, gradient2d(stDx, stDy)) - : baseColor.sample(baseColorSampler, st, bias(0.25f)); - + // + // Each map is sampled only when the material has it. The presence flags are per-draw + // constants, so each branch is uniform across the draw and the implicit-derivative sample + // inside it is as safe as the hasHeight one; a part without the map paid the sample on a + // nil texture before and threw the value away. // A textured material whose base color factor was never set (all zeros) shows its // texture untinted. Without a texture, zero is a color like any other: black. bool isBaseColorUnset = materialParameter.hasTexture.x == 1 && all(materialParameter.baseColor.rgb < 0.001); float3 tint = isBaseColorUnset ? float3(1.0) : materialParameter.baseColor.rgb; - float4 inBaseColor = (materialParameter.hasTexture.x == 1) - ? float4(sampledColor.rgb * tint, sampledColor.a * materialParameter.baseColor.a) - : float4(tint, materialParameter.baseColor.a); + float4 inBaseColor = float4(tint, materialParameter.baseColor.a); + if (materialParameter.hasTexture.x == 1) { + float4 sampledColor = hasHeight + ? baseColor.sample(baseColorSampler, sampleUV, gradient2d(stDx, stDy)) + : baseColor.sample(baseColorSampler, st, bias(0.25f)); + inBaseColor = float4(sampledColor.rgb * tint, sampledColor.a * materialParameter.baseColor.a); + } if (kModelMayDiscard) { @@ -512,35 +520,38 @@ fragment GBufferOut fragmentModelShader(VertexOutModel in [[stage_in]], float passthroughAlpha = clamp(materialParameter.passthroughAlpha, 0.0, 1.0); - //normal map is in Tangent space - float4 normalSample = hasHeight - ? normalTexture.sample(normalSampler, sampleUV, gradient2d(stDx, stDy)) - : normalTexture.sample(normalSampler, st, bias(0.25f)); - - // Two on-disk encodings: a direct 3-component tangent-space normal (RGB, [0,1] -> - // [-1,1]), or astcenc's `-normal` packing (RGB=X, A=Y) used by texbake.py to get a - // better error metric for vector data — see NativeTexFlags.normalPackedXY. Z is - // reconstructed from the unit-length constraint for the packed case. - // [0,1] -> [-1,1] first, unit length after: normalizing the stored color instead - // shortens it before the remap and leaves every normal tilted (a flat texel, (0.5, - // 0.5, 1), came out 22 degrees off). - float3 normalMapStandard = normalSample.rgb * 2.0 - 1.0; - - float2 packedXY = normalSample.ga * 2.0 - 1.0; - float3 normalMapPackedXY = float3(packedXY, sqrt(saturate(1.0 - dot(packedXY, packedXY)))); - - float3 normalMap = normalIsPackedXY ? normalMapPackedXY : normalMapStandard; - normalMap = applyNormalStrength(normalMap, materialParameter.normalScale); - - //convert to normal map to world space??? - normalMap=(hasNormal==false)?normalize(normalVectorInWorldSpace):normalize(TBN*normalMap); - - float4 roughnessSample = hasHeight - ? roughnessTexture.sample(materialSampler, sampleUV, gradient2d(stDx, stDy)) - : roughnessTexture.sample(materialSampler, st, bias(0.25f)); - float roughness=(materialParameter.hasTexture.y==1) - ? selectTextureChannel(roughnessSample, materialParameter.textureChannels.x) * materialParameter.roughness - : materialParameter.roughness; + // The shading normal: the vertex normal, or the normal map's, in tangent space, brought + // to world space. + float3 normalMap = normalize(normalVectorInWorldSpace); + if (hasNormal) { + float4 normalSample = hasHeight + ? normalTexture.sample(normalSampler, sampleUV, gradient2d(stDx, stDy)) + : normalTexture.sample(normalSampler, st, bias(0.25f)); + + // Two on-disk encodings: a direct 3-component tangent-space normal (RGB, [0,1] -> + // [-1,1]), or astcenc's `-normal` packing (RGB=X, A=Y) used by texbake.py to get a + // better error metric for vector data — see NativeTexFlags.normalPackedXY. Z is + // reconstructed from the unit-length constraint for the packed case. + // [0,1] -> [-1,1] first, unit length after: normalizing the stored color instead + // shortens it before the remap and leaves every normal tilted (a flat texel, (0.5, + // 0.5, 1), came out 22 degrees off). + float3 normalMapStandard = normalSample.rgb * 2.0 - 1.0; + + float2 packedXY = normalSample.ga * 2.0 - 1.0; + float3 normalMapPackedXY = float3(packedXY, sqrt(saturate(1.0 - dot(packedXY, packedXY)))); + + float3 tangentNormal = normalIsPackedXY ? normalMapPackedXY : normalMapStandard; + tangentNormal = applyNormalStrength(tangentNormal, materialParameter.normalScale); + normalMap = normalize(TBN * tangentNormal); + } + + float roughness = materialParameter.roughness; + if (materialParameter.hasTexture.y == 1) { + float4 roughnessSample = hasHeight + ? roughnessTexture.sample(materialSampler, sampleUV, gradient2d(stDx, stDy)) + : roughnessTexture.sample(materialSampler, st, bias(0.25f)); + roughness = selectTextureChannel(roughnessSample, materialParameter.textureChannels.x) * materialParameter.roughness; + } roughness=clamp(roughness, 0.045, 1.0); // Specular anti-aliasing. Normal detail finer than a pixel (a dart's knurl, @@ -559,31 +570,35 @@ fragment GBufferOut fragmentModelShader(VertexOutModel in [[stage_in]], roughness = sqrt(roughness * roughness + kernelRoughness2); } - float4 metallicSample = hasHeight - ? metallicTexture.sample(materialSampler, sampleUV, gradient2d(stDx, stDy)) - : metallicTexture.sample(materialSampler, st, bias(0.25f)); - float metallic=(materialParameter.hasTexture.z==1) - ? selectTextureChannel(metallicSample, materialParameter.textureChannels.y) * materialParameter.metallic - : materialParameter.metallic; + float metallic = materialParameter.metallic; + if (materialParameter.hasTexture.z == 1) { + float4 metallicSample = hasHeight + ? metallicTexture.sample(materialSampler, sampleUV, gradient2d(stDx, stDy)) + : metallicTexture.sample(materialSampler, st, bias(0.25f)); + metallic = selectTextureChannel(metallicSample, materialParameter.textureChannels.y) * materialParameter.metallic; + } metallic=clamp(metallic, 0.0, 1.0); // The light the surface gives off: the emissive color, times the emissive texture when // the material has one (the texture carries the color, the factor its strength). It // shares the base color's sampler: both wrap the same way over the same coordinates. - float4 emissiveSample = hasHeight - ? emissiveTexture.sample(baseColorSampler, sampleUV, gradient2d(stDx, stDy)) - : emissiveTexture.sample(baseColorSampler, st, bias(0.25f)); - float3 emissive = (materialParameter.hasEmissiveTexture == 1) - ? materialParameter.emmissive * emissiveSample.rgb - : materialParameter.emmissive; + float3 emissive = materialParameter.emmissive; + if (materialParameter.hasEmissiveTexture == 1) { + float4 emissiveSample = hasHeight + ? emissiveTexture.sample(baseColorSampler, sampleUV, gradient2d(stDx, stDy)) + : emissiveTexture.sample(baseColorSampler, st, bias(0.25f)); + emissive = materialParameter.emmissive * emissiveSample.rgb; + } float4 color=inBaseColor; float4 base = float4(color.rgb, passthroughAlpha); float4 emissiveOut = float4(emissive, 1.0); + // The view vector once, for the indirect and the direct light alike. + float3 viewVector = normalize(uniforms.cameraPosition - verticesInWorldSpace.xyz); if (kModelIndirectInGBuffer) { emissiveOut = emissiveWithIndirect( - emissive, base, normalMap, verticesInWorldSpace.xyz, roughness, metallic, uniforms.cameraPosition, + emissive, base, normalMap, verticesInWorldSpace.xyz, roughness, metallic, viewVector, iblIrradiance, iblSpecular, iblBRDF, csmShadowArray, csmUniforms, lightParams, iblParam, iblRotationAngle); } if (kModelGBufferOut) { @@ -598,7 +613,7 @@ fragment GBufferOut fragmentModelShader(VertexOutModel in [[stage_in]], if (kModelLitInGBuffer) { gBufferOut.lit = litWithDirect( emissiveOut, base, normalMap, verticesInWorldSpace, roughness, metallic, - uniforms.cameraPosition, lightParams, plBlock, slBlock, alBlock, ltcMagTexture, ltcMatTexture, + viewVector, lightParams, plBlock, slBlock, alBlock, ltcMagTexture, ltcMatTexture, pointShadowCube, spotShadowMap, pointShadowUniforms, spotShadowUniforms); gBufferOut.velocity = modelVelocity(in.currentClip, in.prevClip); } diff --git a/Sources/UntoldEngine/Systems/ShadowSystem.swift b/Sources/UntoldEngine/Systems/ShadowSystem.swift index ec21586ef..10fbc85a9 100644 --- a/Sources/UntoldEngine/Systems/ShadowSystem.swift +++ b/Sources/UntoldEngine/Systems/ShadowSystem.swift @@ -286,6 +286,17 @@ struct ShadowSystem { var cascadeBlendStarts: [Float] = Array(repeating: 0, count: csmCascadeCount) var softnessSettings: ShadowSoftnessSettings = .init() var isActive: Bool = false + /// The cascades the sun's shadow is drawn in and read from, one to `csmCascadeCount`: + /// what `setRendering(.shadowCascades(_:))` asked for, else the platform's default: one + /// on the headset, where the scene is a room or a tabletop and the second cascade's + /// draw of every caster and the cross-fade's double taps cost a frame's budget for a + /// distance it never shows; every cascade elsewhere. `maxShadowCastingDistance` sets how + /// far the cascades reach, so one cascade stays sharp on a small scene. + nonisolated(unsafe) static var cascadesWanted: Int? + var cascadeCount: Int { + let wanted = Self.cascadesWanted ?? (renderInfo.isXRStereoMode ? 1 : csmCascadeCount) + return min(max(wanted, 1), csmCascadeCount) + } /// Legacy accessor used by callers that only need to know "is there a shadow?" var dirLightSpaceMatrix: simd_float4x4? { @@ -328,14 +339,20 @@ struct ShadowSystem { return max(cameraNear, cascadeBlendStart(cascadeIdx: cascadeIdx - 1, splits: splits, blendFraction: blendFraction)) } - /// Fills the 3 fixed GPU slots from a per-cascade array, padding unused slots - /// (beyond csmCascadeCount) with `fallback` so the shader's cascadeCount field - /// controls which slots are actually read. - private static func pack3(_ values: [T], fallback: T) -> (T, T, T) { + /// A light-space matrix that lands every point outside the shadow map's [0, 1] range + /// (at 10, 10, 10 with w 1), where the shader samples no map and takes the point as lit. + static let nowhere = simd_float4x4(columns: ( + simd_float4(0, 0, 0, 0), simd_float4(0, 0, 0, 0), simd_float4(0, 0, 0, 0), simd_float4(10, 10, 10, 1) + )) + + /// Fills the 3 fixed GPU slots from a per-cascade array, padding the slots past the + /// `count` cascades in use with `fallback`, so the shader's cascadeCount field controls + /// which slots are actually read. + private static func pack3(_ values: [T], count: Int, fallback: T) -> (T, T, T) { ( values[0], - csmCascadeCount > 1 ? values[1] : fallback, - csmCascadeCount > 2 ? values[2] : fallback + count > 1 && values.count > 1 ? values[1] : fallback, + count > 2 && values.count > 2 ? values[2] : fallback ) } @@ -344,12 +361,17 @@ struct ShadowSystem { /// left as identity/zero so the shader's cascadeCount field controls which are read. func makeUniforms() -> CSMUniforms { var u = CSMUniforms() - u.lightSpaceMatrices = Self.pack3(cascadeLightSpaceMatrices, fallback: matrix_identity_float4x4) - u.cascadeSplits = Self.pack3(cascadeSplitDistances, fallback: 0) - u.cascadeCount = Int32(csmCascadeCount) - u.cascadeWorldTexelSizes = Self.pack3(cascadeWorldTexelSizes, fallback: cascadeWorldTexelSizes[0]) - u.cascadeDepthSpans = Self.pack3(cascadeDepthSpans, fallback: cascadeDepthSpans[0]) - u.cascadeBlendStarts = Self.pack3(cascadeBlendStarts, fallback: cascadeBlendStarts[0]) + let count = cascadeCount + // A sun that casts no shadow (none active, or castsShadow off) draws no cascades, and + // the maps hold whatever was drawn last: its matrices put every point outside the + // maps, which the shader reads as lit, instead of the stale shadows. + let matrices = isActive ? cascadeLightSpaceMatrices : Array(repeating: Self.nowhere, count: cascadeLightSpaceMatrices.count) + u.lightSpaceMatrices = Self.pack3(matrices, count: count, fallback: matrix_identity_float4x4) + u.cascadeSplits = Self.pack3(cascadeSplitDistances, count: count, fallback: 0) + u.cascadeCount = Int32(count) + u.cascadeWorldTexelSizes = Self.pack3(cascadeWorldTexelSizes, count: count, fallback: cascadeWorldTexelSizes[0]) + u.cascadeDepthSpans = Self.pack3(cascadeDepthSpans, count: count, fallback: cascadeDepthSpans[0]) + u.cascadeBlendStarts = Self.pack3(cascadeBlendStarts, count: count, fallback: cascadeBlendStarts[0]) let softness = Self.sanitizedSoftnessSettings(softnessSettings) let xrScale = renderInfo.isXRStereoMode ? softness.xrRadiusScale : 1.0 u.shadowSoftnessNear = softness.nearRadiusTexels * xrScale @@ -409,7 +431,7 @@ struct ShadowSystem { guard directionalLight.castsShadow else { return } // A sun at no intensity lights nothing, and the shading multiplies its shadow by that // intensity: its cascades would be drawn, two depth maps a frame, for nothing. - guard (scene.get(component: LightComponent.self, for: lightEntity)?.intensity ?? 0) > 0 else { return } + guard (scene.get(component: LightComponent.self, for: lightEntity)?.intensity ?? 0) != 0 else { return } // Rotate into visual-world space to match the root-rotation-aware corners/caster bounds // below -- the light entity's own transform is scene-local, same as every other entity. @@ -453,8 +475,9 @@ struct ShadowSystem { // Practical split scheme (blend of log and uniform, λ=0.5). let splits = computeCascadeSplits(cameraNear: near, cameraFar: cameraFar) - cascadeSplitDistances = splits - for i in 0 ..< csmCascadeCount { + // The arrays keep every slot; the ones past the cascades in use repeat the last split. + cascadeSplitDistances = splits + Array(repeating: splits.last ?? cameraFar, count: csmCascadeCount - splits.count) + for i in 0 ..< cascadeCount { cascadeBlendStarts[i] = Self.cascadeBlendStart( cascadeIdx: i, splits: splits, blendFraction: csmCascadeBlendFraction ) @@ -469,7 +492,7 @@ struct ShadowSystem { lightSpaceBounds(for: $0, lightView: lightView) } - for i in 0 ..< csmCascadeCount { + for i in 0 ..< cascadeCount { let cascadeFar = splits[i] let cascadeNear = Self.cascadeNearDistance( cascadeIdx: i, @@ -571,8 +594,8 @@ struct ShadowSystem { private func computeCascadeSplits(cameraNear: Float, cameraFar: Float) -> [Float] { let lambda: Float = 0.5 var splits: [Float] = [] - for i in 1 ... csmCascadeCount { - let fi = Float(i) / Float(csmCascadeCount) + for i in 1 ... cascadeCount { + let fi = Float(i) / Float(cascadeCount) let logSplit = cameraNear * pow(cameraFar / cameraNear, fi) let uniformSplit = cameraNear + (cameraFar - cameraNear) * fi splits.append(lambda * logSplit + (1.0 - lambda) * uniformSplit) diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.metallib index b102f4690..f406816c3 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.metallib differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-iossim.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-iossim.metallib index bb51c5c68..b2f2ee0f4 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-iossim.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-iossim.metallib differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.metallib index 466d6dca0..c6d6471d8 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.metallib differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.metallib index 07a8cbc10..1a1d8a121 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.metallib differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.metallib index cc3fb18e2..65e00fa2b 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.metallib differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.metallib index e977ac30a..af5e5d35f 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.metallib differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels.metallib index 96ba08e8d..68ab508ff 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels.metallib differ diff --git a/Sources/UntoldEngine/Utils/EngineSettingsAPI.swift b/Sources/UntoldEngine/Utils/EngineSettingsAPI.swift index 5697aaa67..221fe8cd4 100644 --- a/Sources/UntoldEngine/Utils/EngineSettingsAPI.swift +++ b/Sources/UntoldEngine/Utils/EngineSettingsAPI.swift @@ -71,6 +71,13 @@ public enum RenderingProperty: Sendable { /// existing texel budget on it instead, sharpening shadow edges without needing a larger /// shadow map. See `getMaxShadowCastingDistance()` to read the current value. case maxShadowCastingDistance(Float) + /// The cascades the sun's shadow is drawn in and read from, 1 or 2. Each cascade draws + /// every caster once more into its own 2048² depth map, and the band where two meet + /// samples both. The headset's default is one: a room or a tabletop never shows the + /// distance the second covers, and with `maxShadowCastingDistance` set to the scene's + /// size one cascade is sharp on it. Elsewhere the default is two. See + /// `getShadowCascades()`. + case shadowCascades(Int) /// Objects whose bounds are under this many pixels tall on screen are not drawn and /// cast no shadow. A scene with tens of thousands of small parts shows most of them /// at a pixel or less from any distance, and each would still cost a draw. The size @@ -244,6 +251,8 @@ public func setRendering(_ property: RenderingProperty) { applyRenderExtensionProperty(property) case let .maxShadowCastingDistance(distance): RenderPasses.maxShadowCastingDistance = max(distance, 0.001) + case let .shadowCascades(count): + ShadowSystem.cascadesWanted = count case let .smallObjectCulling(pixels): SmallObjectCulling.minimumPixels = pixels.isFinite ? max(pixels, 0) : 0 case let .occlusionCullingMinimumEntities(entities): @@ -298,6 +307,11 @@ public func getFoveatedRenderingEnabled() -> Bool { /// When the XR loop waits for the compositor's optimal input time before it encodes the /// frame. See `RenderingProperty.optimalInputWait`. +/// The cascades the sun's shadow is drawn in and read from. +public func getShadowCascades() -> Int { + shadowSystem?.cascadeCount ?? min(max(ShadowSystem.cascadesWanted ?? csmCascadeCount, 1), csmCascadeCount) +} + public func getOptimalInputWaitMode() -> XRInputWaitMode { xrOptimalInputWaitMode } diff --git a/Tests/UntoldEngineRenderTests/ShadowCascadeCountTests.swift b/Tests/UntoldEngineRenderTests/ShadowCascadeCountTests.swift new file mode 100644 index 000000000..1fc79f31c --- /dev/null +++ b/Tests/UntoldEngineRenderTests/ShadowCascadeCountTests.swift @@ -0,0 +1,120 @@ +// +// ShadowCascadeCountTests.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Metal +import simd +@testable import UntoldEngine +import XCTest + +/// The sun's shadow is drawn in as many cascades as the setting asks for, one on the headset +/// by default and every cascade elsewhere: one cascade reaches the whole shadow distance, the +/// first of two stops short of it, and the shader is told how many to read. +@MainActor +final class ShadowCascadeCountTests: BaseRenderSetup { + override func tearDown() async throws { + ShadowSystem.cascadesWanted = nil + destroyAllEntities() + try await super.tearDown() + } + + override func initializeAssets() { + let camera = createEntity() + createGameCamera(entityId: camera) + CameraSystem.shared.activeCamera = camera + cameraLookAt(entityId: camera, eye: simd_float3(0, 1, 4), target: .zero, up: simd_float3(0, 1, 0)) + let box = createEntity() + setEntityMeshDirect(entityId: box, meshes: BasicPrimitives.createCube(extent: 1.0), assetName: "Box") + translateTo(entityId: box, position: simd_float3(0, 0.5, 0)) + // The base scene's own directional light is the active sun. + } + + private func drawFrames() { + for _ in 0 ..< 2 { + renderer.draw(in: renderer.metalView) + renderInfo.lastCommandBuffer?.waitUntilCompleted() + } + } + + func testTheSettingChoosesTheCascades() { + setRendering(.shadowCascades(1)) + drawFrames() + XCTAssertEqual(getShadowCascades(), 1) + XCTAssertEqual(shadowSystem.makeUniforms().cascadeCount, 1, "the shader reads one") + XCTAssertTrue(shadowSystem.isActive) + let single = shadowSystem.cascadeSplitDistances[0] + + setRendering(.shadowCascades(2)) + drawFrames() + XCTAssertEqual(getShadowCascades(), 2) + XCTAssertEqual(shadowSystem.makeUniforms().cascadeCount, 2) + XCTAssertLessThan(shadowSystem.cascadeSplitDistances[0], single, "the first of two stops short of the shadow distance") + XCTAssertEqual(shadowSystem.cascadeSplitDistances[1], single, accuracy: 1e-3, "the last split is the shadow distance either way") + + setRendering(.shadowCascades(5)) + XCTAssertEqual(getShadowCascades(), csmCascadeCount, "never more than the maps hold") + setRendering(.shadowCascades(0)) + XCTAssertEqual(getShadowCascades(), 1, "never none: a sun casts") + } + + private func readFloats(_ texture: MTLTexture) -> [Float] { + var halves = [UInt16](repeating: 0, count: texture.width * texture.height * 4) + halves.withUnsafeMutableBytes { bytes in + texture.getBytes(bytes.baseAddress!, bytesPerRow: texture.width * 8, from: MTLRegionMake2D(0, 0, texture.width, texture.height), mipmapLevel: 0) + } + return halves.map { Float(Float16(bitPattern: $0)) } + } + + /// The lit frame's pixels that the sun's shadow darkens by a third or more: the floor + /// under and beside the box. + private func shadowedPixels(cascades: Int) throws -> Int { + let sun = try XCTUnwrap(LightingSystem.shared.activeDirectionalLight) + setRendering(.shadowCascades(cascades)) + setLight(entityId: sun, .directional(.castsShadow(true))) + drawFrames() + let shadowed = try readFloats(XCTUnwrap(textureResources.deferredColorMap)) + setLight(entityId: sun, .directional(.castsShadow(false))) + drawFrames() + let unshadowed = try readFloats(XCTUnwrap(textureResources.deferredColorMap)) + setLight(entityId: sun, .directional(.castsShadow(true))) + var count = 0 + for i in stride(from: 0, to: min(shadowed.count, unshadowed.count), by: 4) { + let lit = unshadowed[i] + unshadowed[i + 1] + unshadowed[i + 2] + let dark = shadowed[i] + shadowed[i + 1] + shadowed[i + 2] + if lit > 0.05, dark < 0.67 * lit { count += 1 } + } + return count + } + + func testOneCascadeStillCastsAShadow() throws { + // A floor for the box to cast onto, and a sun that reaches across it. + let floor = createEntity() + setEntityMeshDirect(entityId: floor, meshes: BasicPrimitives.createCube(extent: 6.0), assetName: "Floor") + translateTo(entityId: floor, position: simd_float3(0, -3.0, 0)) + let sun = try XCTUnwrap(LightingSystem.shared.activeDirectionalLight) + rotateTo(entityId: sun, pitch: -55, yaw: 30, roll: 0) + setVisibleEntities() + + let withTwo = try shadowedPixels(cascades: 2) + let withOne = try shadowedPixels(cascades: 1) + XCTAssertGreaterThan(withTwo, 0, "two cascades shadow the floor under the box") + XCTAssertGreaterThan(withOne, 0, "one cascade shadows it too") + XCTAssertGreaterThan(withOne, withTwo / 2, "one cascade over the whole distance shadows about the same pixels") + } + + func testTheHeadsetDefaultsToOneCascadeAndTheDesktopToAll() { + ShadowSystem.cascadesWanted = nil + let saved = renderInfo.isXRStereoMode + defer { renderInfo.isXRStereoMode = saved } + renderInfo.isXRStereoMode = true + XCTAssertEqual(shadowSystem.cascadeCount, 1) + renderInfo.isXRStereoMode = false + XCTAssertEqual(shadowSystem.cascadeCount, csmCascadeCount) + } +} diff --git a/Tests/UntoldEngineTests/ShadowCascadeTests.swift b/Tests/UntoldEngineTests/ShadowCascadeTests.swift index 5e2f353ae..463ec90a5 100644 --- a/Tests/UntoldEngineTests/ShadowCascadeTests.swift +++ b/Tests/UntoldEngineTests/ShadowCascadeTests.swift @@ -124,6 +124,8 @@ final class ShadowSystemMakeUniformsTests: XCTestCase { func testUsedSlotsCarryAssignedMatrices() { var sys = ShadowSystem() + // The matrices of a sun that casts: an updated system. + sys.isActive = true let m0 = sentinel(1.0) let m1 = sentinel(2.0) sys.cascadeLightSpaceMatrices[0] = m0 @@ -140,6 +142,21 @@ final class ShadowSystemMakeUniformsTests: XCTestCase { } } + /// A sun that casts no shadow (none active, or castsShadow off) draws no cascades and + /// the maps hold whatever was drawn last: its matrices land every point outside the maps, + /// which the shader reads as lit, so no stale shadow shows. + func testASunThatDoesNotCastShadowsNothing() { + var sys = ShadowSystem() + sys.cascadeLightSpaceMatrices[0] = sentinel(1.0) + XCTAssertFalse(sys.isActive) + let (r0, r1, _) = sys.makeUniforms().lightSpaceMatrices + XCTAssertEqual(r0.columns.3.x, 10.0, accuracy: 1e-6, "outside the map's [0, 1]") + XCTAssertEqual(r0.columns.3.w, 1.0, accuracy: 1e-6) + if csmCascadeCount > 1 { + XCTAssertEqual(r1.columns.3.x, 10.0, accuracy: 1e-6) + } + } + func testUnusedMatrixSlotIsIdentity() { // When csmCascadeCount < 3 the third matrix slot must default to identity // so the shader does not read garbage when it checks cascadeCount first. diff --git a/docs/Architecture/xrRenderingSystem.md b/docs/Architecture/xrRenderingSystem.md index d8953ce9c..726bc66b8 100644 --- a/docs/Architecture/xrRenderingSystem.md +++ b/docs/Architecture/xrRenderingSystem.md @@ -267,7 +267,7 @@ Selection rays update from interaction events, not continuously as the user look | Frame timing | MTKView drives at display rate | CompositorServices dictates via `optimalInputTime` | | Eyes | 1 | 2 (per-eye loop over `drawable.views`) | | Compute passes | Once per frame | Once per frame (shared across both eyes) | -| Default scene | A camera and a directional light, both active | A camera, the entity the head pose is written into, and no directional light unless the configuration asks for one (`UntoldRendererConfig.defaultSun`): the passthrough room is lit by its probe and the immersive environment by its own light, so a sun is the app's to add (`DirectionalLightNode`, or `createDirLight` and `setDirectionalLight(.active(_:))`) | +| Default scene | A camera and a directional light, both active | A camera, the entity the head pose is written into, and no directional light unless the configuration asks for one (`UntoldRendererConfig.defaultSun`): the passthrough room is lit by its probe and the immersive environment by its own light, so a sun is the app's to add (`DirectionalLightNode`, or `createDirLight` and `setDirectionalLight(.active(_:))`), and it casts in one cascade by default (`setRendering(.shadowCascades(_:))`) | | View matrix | Camera entity transform | `(originFromDevice × deviceFromView)⁻¹` from ARKit anchor | | Projection | Camera component FOV | `drawable.computeProjection()` — asymmetric per-eye | | Present call | `commandBuffer.present(drawable)` | `drawable.encodePresent(commandBuffer:)` — encoded as GPU command | diff --git a/docs/proposals/PresentationTimeAnimation.md b/docs/proposals/PresentationTimeAnimation.md index 0ebe5e016..3628aef68 100644 --- a/docs/proposals/PresentationTimeAnimation.md +++ b/docs/proposals/PresentationTimeAnimation.md @@ -169,7 +169,20 @@ The heavy configuration (MSAA 4× with TAA, the occlusion cull on, foveated 2048 What did not hold, and why: the half-size pyramid, its single encoder and the first eye's copy skipped matter only to multi-object scenes; the transparency pass's cost was a guess an order of magnitude too high; moving the grade into the temporal resolve changes the history's space; float to half across the forward shader is not safe without a device check. -What remains is the Forward pass's fragment work (7 of its 10 ms) and the temporal resolve, whose levers are in the shaders: the sun's shadow taps and BRDF evaluated with the intensity at zero, the two equirectangular lookups with `atan2`/`asin` and a rotation per pixel, five anisotropic samples taken before the presence flags are read, normalizations of per-draw constants, and the resolve's rate-map decodes and reprojection done for the cleared pixels that are most of a passthrough frame. Every `.metal` file is included by `UntoldEngineKernels.metal` and only the prebuilt metallibs ship, so those cuts rebuild the kernel libraries and are a separate change. On the viewer's side, MSAA 2× under TAA costs 1.5 to 2 ms less than 4× for edges the temporal accumulation already resolves. +The device run of those cuts (the sun off, 90 Hz): the frame held the full rate, 8.4 to 9.0 ms a frame, no refresh skipped after the placement, the pacing never engaged; Forward 10.40 to 8.02 ms, TAA 2.50 to 2.06, Post 0.76 to 0.45, the shadows and the transparency pass gone, the cull 0.14 to 0.01. + +What remained was the Forward pass's fragment work (5.5 of its 8 ms) and the temporal resolve, whose levers are in the shaders. Every `.metal` file is included by `UntoldEngineKernels.metal` and only the prebuilt metallibs ship, so those cuts rebuild the kernel libraries; they are on `feature/xr_shader_cost`, stacked on the cost branch, after twelve reviewers (Metal semantics, picture equivalence, performance, three per cut) tried to refute each: + +- **The sun by its intensity.** The cascade shadow taps and the sun's Cook-Torrance BRDF are evaluated only when the sun's intensity is not zero, in the forward shader, the two light passes and the transparency shader, on a per-frame constant, so the branch is uniform; any other intensity takes the old path in the old order of products, bit for bit. The CPU's cascade gate says the same. Reviewers' estimate: 1 to 1.5 ms with the sun off. +- **Each material map sampled only when the material has it**, the normal map's decode and tangent transform included; the flags are per-draw constants, so the implicit-derivative samples stay in uniform control flow, as the height-map branch already was. The reviewers put this at nothing measurable (the removed samples were on nil textures); it stays as a cleanup. +- **Once per pixel instead of twice**: the view vector, the environment's rotation angle in radians, and the BRDF lookup table's pair, from which both reflectances derive (`brdfIntegration`); the rotation of the lookup direction is skipped at angle zero, the headset's room probe's case, with the normalize kept, since the simulator's light pass hands in a G-buffer normal as stored and the equirectangular latitude's `asin` is not scale-invariant (the reviewers' one blocker); that `asin` is clamped now, which removes a pre-existing chance of a NaN pixel at the poles. +- **The temporal resolve leaves early** for a pixel with no history or at the clear depth, before the rate-map decodes, the reprojection and the velocity fetch, which is most of a passthrough frame; the foveation rates are read as texels per screen pixel over the same screen interval from both maps, a second decode of each, instead of derivatives across the quad (which forbade any early return): the ratio is exactly 1 wherever the two maps agree, zone boundaries included, and where they differ a band at most a pixel wide before a boundary weighs a little differently than the quad's span did. Reviewers' estimate: 0.2 to 0.4 ms. + +The picture tests (the material shading, per-fragment lighting, glass, probe, post-effects and temporal tests, 148 in the targeted run) pass on the rebuilt library. On the viewer's side, MSAA 2× under TAA costs 1.5 to 2 ms less than 4× for edges the temporal accumulation already resolves. + +The device run of the shader cuts, the sun off, the headset at 100 Hz this time: Forward 8.02 to 6.56 ms (the fragment part 5.51 to 4.15), TAA 2.06 to 1.92 (on the empty frame 1.81 to 1.47), the frame 6.8 ms against a 10 ms refresh, no late frame after the placement. With the sun on, the frame no longer fitted (10 to 12 ms at full rate, then paced to an even 51 frames a second), and the shadow-tap count made no difference (Forward 9.48 with eight taps, 9.75 with four): the Forward pass's vertex stage had risen too, 2.41 to 3.35 ms on the same geometry, which is the device's GPU clock falling as it heats through a long session, so the sun's real cost is about 1.5 ms, 0.85 of it the two cascades and the rest the taps' setup and the sun's BRDF, as its arithmetic says. + +**The cheaper sun.** The headset starts without a sun (`UntoldRendererConfig.defaultSun`), and a project that switches one on gets it in one cascade: `setRendering(.shadowCascades(1 or 2))`, the headset's default one and the desktop's two. A room or a tabletop never shows the distance the second cascade covers, and with `maxShadowCastingDistance` set to the scene's size one cascade is sharp on it; the second cascade's draw of every caster and the cross-fade's double taps go. The cascade count flows from the setting through the splits, the fitting, the passes and the shader's uniforms; the depth array keeps its slices. ### The true late latch (not done: a refactor, for a decision)