forked from BilalY/Rasagar
474 lines
21 KiB
C#
474 lines
21 KiB
C#
|
using Unity.Collections;
|
||
|
using Unity.Mathematics;
|
||
|
|
||
|
namespace UnityEngine.Rendering
|
||
|
{
|
||
|
// 6-component representation of a (infinite length) line in 3D space
|
||
|
internal struct Line
|
||
|
{
|
||
|
// for the line to be valid, dot(m, t) == 0
|
||
|
public float3 m;
|
||
|
public float3 t;
|
||
|
|
||
|
internal static Line LineOfPlaneIntersectingPlane(float4 a, float4 b)
|
||
|
{
|
||
|
// planes do not need to have a unit length normal
|
||
|
return new Line {
|
||
|
m = a.w*b.xyz - b.w*a.xyz,
|
||
|
t = math.cross(a.xyz, b.xyz),
|
||
|
};
|
||
|
}
|
||
|
|
||
|
internal static float4 PlaneContainingLineAndPoint(Line a, float3 b)
|
||
|
{
|
||
|
// the resulting plane will not have a unit length normal (and the normal will be approximately zero when no plane exists)
|
||
|
return new float4(a.m + math.cross(a.t, b), -math.dot(a.m, b));
|
||
|
}
|
||
|
|
||
|
internal static float4 PlaneContainingLineWithNormalPerpendicularToVector(Line a, float3 b)
|
||
|
{
|
||
|
// the resulting plane will not have a unit length normal (and the normal will be approximately zero when no plane exists)
|
||
|
return new float4(math.cross(a.t, b), -math.dot(a.m, b));
|
||
|
}
|
||
|
}
|
||
|
|
||
|
internal struct ReceiverPlanes
|
||
|
{
|
||
|
public NativeList<Plane> planes;
|
||
|
public int lightFacingPlaneCount;
|
||
|
|
||
|
private static bool IsSignBitSet(float x)
|
||
|
{
|
||
|
uint i = math.asuint(x);
|
||
|
return (i >> 31) != 0;
|
||
|
}
|
||
|
|
||
|
internal NativeArray<Plane> SilhouettePlaneSubArray()
|
||
|
{
|
||
|
return planes.AsArray().GetSubArray(lightFacingPlaneCount, planes.Length - lightFacingPlaneCount);
|
||
|
}
|
||
|
|
||
|
internal NativeArray<Plane> CopyLightFacingFrustumPlanes(Allocator allocator)
|
||
|
{
|
||
|
var facingPlanes = new NativeArray<Plane>(lightFacingPlaneCount, allocator, NativeArrayOptions.UninitializedMemory);
|
||
|
for (int i = 0; i < lightFacingPlaneCount; ++i)
|
||
|
facingPlanes[i] = planes[i];
|
||
|
return facingPlanes;
|
||
|
}
|
||
|
|
||
|
internal static ReceiverPlanes CreateEmptyForTesting(Allocator allocator)
|
||
|
{
|
||
|
return new ReceiverPlanes()
|
||
|
{
|
||
|
planes = new NativeList<Plane>(allocator),
|
||
|
lightFacingPlaneCount = 0,
|
||
|
};
|
||
|
}
|
||
|
|
||
|
internal void Dispose()
|
||
|
{
|
||
|
planes.Dispose();
|
||
|
}
|
||
|
|
||
|
internal static ReceiverPlanes Create(in BatchCullingContext cc, Allocator allocator)
|
||
|
{
|
||
|
var result = new ReceiverPlanes()
|
||
|
{
|
||
|
planes = new NativeList<Plane>(allocator),
|
||
|
lightFacingPlaneCount = 0,
|
||
|
};
|
||
|
|
||
|
if (cc.viewType == BatchCullingViewType.Light && cc.receiverPlaneCount != 0)
|
||
|
{
|
||
|
bool isLightOrthographic = false;
|
||
|
if (cc.cullingSplits.Length > 0)
|
||
|
{
|
||
|
Matrix4x4 m = cc.cullingSplits[0].cullingMatrix;
|
||
|
isLightOrthographic = m[15] == 1.0f && m[11] == 0.0f && m[7] == 0.0f && m[3] == 0.0f;
|
||
|
}
|
||
|
if (isLightOrthographic)
|
||
|
{
|
||
|
Vector3 lightDir = -cc.localToWorldMatrix.GetColumn(2);
|
||
|
|
||
|
// cache result for each plane, add planes facing towards the light
|
||
|
int planeSignBits = 0;
|
||
|
for (int i = 0; i < cc.receiverPlaneCount; ++i)
|
||
|
{
|
||
|
var plane = cc.cullingPlanes[cc.receiverPlaneOffset + i];
|
||
|
float facingTerm = Vector3.Dot(plane.normal, lightDir);
|
||
|
if (IsSignBitSet(facingTerm))
|
||
|
planeSignBits |= (1 << i);
|
||
|
else
|
||
|
result.planes.Add(plane);
|
||
|
}
|
||
|
result.lightFacingPlaneCount = result.planes.Length;
|
||
|
|
||
|
// assume ordering +/-x, +/-y, +/-z for frustum planes, test pairs for silhouette edges
|
||
|
if (cc.receiverPlaneCount == 6)
|
||
|
{
|
||
|
for (int i = 0; i < cc.receiverPlaneCount; ++i)
|
||
|
{
|
||
|
for (int j = i + 1; j < cc.receiverPlaneCount; ++j)
|
||
|
{
|
||
|
// skip pairs that are from the same frustum axis (i.e. both xs, both ys or both zs)
|
||
|
if ((i / 2) == (j / 2))
|
||
|
continue;
|
||
|
|
||
|
// silhouette edges occur when the planes have opposing signs
|
||
|
int signCheck = ((planeSignBits >> i) ^ (planeSignBits >> j)) & 1;
|
||
|
if (signCheck == 0)
|
||
|
continue;
|
||
|
|
||
|
// process in consistent order for consistent plane normal in the result
|
||
|
var (indexA, indexB) = (((planeSignBits >> i) & 1) == 0) ? (i, j) : (j, i);
|
||
|
var planeA = cc.cullingPlanes[cc.receiverPlaneOffset + indexA];
|
||
|
var planeB = cc.cullingPlanes[cc.receiverPlaneOffset + indexB];
|
||
|
|
||
|
// construct a plane that contains the light origin and this silhouette edge
|
||
|
var planeEqA = new float4(planeA.normal, planeA.distance);
|
||
|
var planeEqB = new float4(planeB.normal, planeB.distance);
|
||
|
var silhouetteEdge = Line.LineOfPlaneIntersectingPlane(planeEqA, planeEqB);
|
||
|
var silhouettePlaneEq = Line.PlaneContainingLineWithNormalPerpendicularToVector(silhouetteEdge, lightDir);
|
||
|
|
||
|
// try to normalize
|
||
|
silhouettePlaneEq = silhouettePlaneEq / math.length(silhouettePlaneEq.xyz);
|
||
|
if (!math.any(math.isnan(silhouettePlaneEq)))
|
||
|
result.planes.Add(new Plane(silhouettePlaneEq.xyz, silhouettePlaneEq.w));
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
else
|
||
|
{
|
||
|
var lightPos = cc.localToWorldMatrix.GetPosition();
|
||
|
|
||
|
// cache result for each plane, add planes facing towards the light
|
||
|
int planeSignBits = 0;
|
||
|
for (int i = 0; i < cc.receiverPlaneCount; ++i)
|
||
|
{
|
||
|
var plane = cc.cullingPlanes[cc.receiverPlaneOffset + i];
|
||
|
float distance = plane.GetDistanceToPoint(lightPos);
|
||
|
if (IsSignBitSet(distance))
|
||
|
planeSignBits |= (1 << i);
|
||
|
else
|
||
|
result.planes.Add(plane);
|
||
|
}
|
||
|
result.lightFacingPlaneCount = result.planes.Length;
|
||
|
|
||
|
// assume ordering +/-x, +/-y, +/-z for frustum planes, test pairs for silhouette edges
|
||
|
if (cc.receiverPlaneCount == 6)
|
||
|
{
|
||
|
for (int i = 0; i < cc.receiverPlaneCount; ++i)
|
||
|
{
|
||
|
for (int j = i + 1; j < cc.receiverPlaneCount; ++j)
|
||
|
{
|
||
|
// skip pairs that are from the same frustum axis (i.e. both xs, both ys or both zs)
|
||
|
if ((i / 2) == (j / 2))
|
||
|
continue;
|
||
|
|
||
|
// silhouette edges occur when the planes have opposing signs
|
||
|
int signCheck = ((planeSignBits >> i) ^ (planeSignBits >> j)) & 1;
|
||
|
if (signCheck == 0)
|
||
|
continue;
|
||
|
|
||
|
// process in consistent order for consistent plane normal in the result
|
||
|
var (indexA, indexB) = (((planeSignBits >> i) & 1) == 0) ? (i, j) : (j, i);
|
||
|
var planeA = cc.cullingPlanes[cc.receiverPlaneOffset + indexA];
|
||
|
var planeB = cc.cullingPlanes[cc.receiverPlaneOffset + indexB];
|
||
|
|
||
|
// construct a plane that contains the light origin and this silhouette edge
|
||
|
var planeEqA = new float4(planeA.normal, planeA.distance);
|
||
|
var planeEqB = new float4(planeB.normal, planeB.distance);
|
||
|
var silhouetteEdge = Line.LineOfPlaneIntersectingPlane(planeEqA, planeEqB);
|
||
|
var silhouettePlaneEq = Line.PlaneContainingLineAndPoint(silhouetteEdge, lightPos);
|
||
|
|
||
|
// try to normalize
|
||
|
silhouettePlaneEq = silhouettePlaneEq / math.length(silhouettePlaneEq.xyz);
|
||
|
if (!math.any(math.isnan(silhouettePlaneEq)))
|
||
|
result.planes.Add(new Plane(silhouettePlaneEq.xyz, silhouettePlaneEq.w));
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return result;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
internal struct FrustumPlaneCuller
|
||
|
{
|
||
|
internal struct PlanePacket4
|
||
|
{
|
||
|
public float4 nx;
|
||
|
public float4 ny;
|
||
|
public float4 nz;
|
||
|
public float4 d;
|
||
|
// Store absolute values of plane normals to avoid recalculating per instance
|
||
|
public float4 nxAbs;
|
||
|
public float4 nyAbs;
|
||
|
public float4 nzAbs;
|
||
|
|
||
|
public PlanePacket4(NativeArray<Plane> planes, int offset, int limit)
|
||
|
{
|
||
|
Plane p0 = planes[Mathf.Min(offset + 0, limit)];
|
||
|
Plane p1 = planes[Mathf.Min(offset + 1, limit)];
|
||
|
Plane p2 = planes[Mathf.Min(offset + 2, limit)];
|
||
|
Plane p3 = planes[Mathf.Min(offset + 3, limit)];
|
||
|
nx = new float4(p0.normal.x, p1.normal.x, p2.normal.x, p3.normal.x);
|
||
|
ny = new float4(p0.normal.y, p1.normal.y, p2.normal.y, p3.normal.y);
|
||
|
nz = new float4(p0.normal.z, p1.normal.z, p2.normal.z, p3.normal.z);
|
||
|
d = new float4(p0.distance, p1.distance, p2.distance, p3.distance);
|
||
|
nxAbs = math.abs(nx);
|
||
|
nyAbs = math.abs(ny);
|
||
|
nzAbs = math.abs(nz);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
internal struct SplitInfo
|
||
|
{
|
||
|
public int packetCount;
|
||
|
}
|
||
|
|
||
|
public NativeArray<PlanePacket4> planePackets;
|
||
|
public NativeArray<SplitInfo> splitInfos;
|
||
|
|
||
|
internal static FrustumPlaneCuller Create(in BatchCullingContext cc, NativeArray<Plane> receiverPlanes, in ReceiverSphereCuller receiverSphereCuller, Allocator allocator)
|
||
|
{
|
||
|
int splitCount = cc.cullingSplits.Length;
|
||
|
|
||
|
int totalPacketCount = 0;
|
||
|
for (int splitIndex = 0; splitIndex < splitCount; ++splitIndex)
|
||
|
{
|
||
|
int planeCount = receiverPlanes.Length + cc.cullingSplits[splitIndex].cullingPlaneCount;
|
||
|
totalPacketCount += (planeCount + 3)/4;
|
||
|
}
|
||
|
|
||
|
FrustumPlaneCuller result = new FrustumPlaneCuller()
|
||
|
{
|
||
|
planePackets = new NativeArray<PlanePacket4>(totalPacketCount, allocator, NativeArrayOptions.UninitializedMemory),
|
||
|
splitInfos = new NativeArray<SplitInfo>(splitCount, allocator, NativeArrayOptions.UninitializedMemory),
|
||
|
};
|
||
|
var tmpPlanes = new NativeList<Plane>(Allocator.Temp);
|
||
|
int packetBase = 0;
|
||
|
for (int splitIndex = 0; splitIndex < splitCount; ++splitIndex)
|
||
|
{
|
||
|
CullingSplit split = cc.cullingSplits[splitIndex];
|
||
|
|
||
|
tmpPlanes.Clear();
|
||
|
|
||
|
// use all culling planes
|
||
|
for (int i = 0; i < split.cullingPlaneCount; ++i)
|
||
|
tmpPlanes.Add(cc.cullingPlanes[split.cullingPlaneOffset + i]);
|
||
|
|
||
|
// conditionally use receiver planes
|
||
|
if (receiverSphereCuller.UseReceiverPlanes())
|
||
|
tmpPlanes.AddRange(receiverPlanes);
|
||
|
|
||
|
int packetCount = (tmpPlanes.Length + 3)/4;
|
||
|
result.splitInfos[splitIndex] = new SplitInfo()
|
||
|
{
|
||
|
packetCount = packetCount,
|
||
|
};
|
||
|
|
||
|
for (int i = 0; i < packetCount; ++i)
|
||
|
result.planePackets[packetBase + i] = new PlanePacket4(tmpPlanes.AsArray(), 4*i, tmpPlanes.Length - 1);
|
||
|
packetBase += packetCount;
|
||
|
}
|
||
|
tmpPlanes.Dispose();
|
||
|
return result;
|
||
|
}
|
||
|
|
||
|
internal static uint ComputeSplitVisibilityMask(NativeArray<PlanePacket4> planePackets, NativeArray<SplitInfo> splitInfos, in AABB bounds)
|
||
|
{
|
||
|
float4 cx = bounds.center.xxxx;
|
||
|
float4 cy = bounds.center.yyyy;
|
||
|
float4 cz = bounds.center.zzzz;
|
||
|
|
||
|
float4 ex = bounds.extents.xxxx;
|
||
|
float4 ey = bounds.extents.yyyy;
|
||
|
float4 ez = bounds.extents.zzzz;
|
||
|
|
||
|
uint splitVisibilityMask = 0;
|
||
|
|
||
|
int packetBase = 0;
|
||
|
int splitCount = splitInfos.Length;
|
||
|
for (int splitIndex = 0; splitIndex < splitCount; ++splitIndex)
|
||
|
{
|
||
|
SplitInfo splitInfo = splitInfos[splitIndex];
|
||
|
|
||
|
bool4 isCulled = new bool4(false);
|
||
|
for (int i = 0; i < splitInfo.packetCount; ++i)
|
||
|
{
|
||
|
PlanePacket4 p = planePackets[packetBase + i];
|
||
|
float4 distances = p.nx*cx + p.ny*cy + p.nz*cz + p.d;
|
||
|
float4 radii = p.nxAbs*ex + p.nyAbs*ey + p.nzAbs*ez;
|
||
|
|
||
|
isCulled = isCulled | (distances + radii < float4.zero);
|
||
|
}
|
||
|
if (!math.any(isCulled))
|
||
|
splitVisibilityMask |= 1U << splitIndex;
|
||
|
|
||
|
packetBase += splitInfo.packetCount;
|
||
|
}
|
||
|
|
||
|
return splitVisibilityMask;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
internal struct ReceiverSphereCuller
|
||
|
{
|
||
|
internal struct SplitInfo
|
||
|
{
|
||
|
public float4 receiverSphereLightSpace;
|
||
|
public float cascadeBlendCullingFactor;
|
||
|
}
|
||
|
|
||
|
public NativeArray<SplitInfo> splitInfos;
|
||
|
public float3x3 worldToLightSpaceRotation;
|
||
|
|
||
|
internal static ReceiverSphereCuller CreateEmptyForTesting(Allocator allocator)
|
||
|
{
|
||
|
return new ReceiverSphereCuller()
|
||
|
{
|
||
|
splitInfos = new NativeArray<SplitInfo>(0, allocator),
|
||
|
worldToLightSpaceRotation = float3x3.identity,
|
||
|
};
|
||
|
}
|
||
|
|
||
|
internal void Dispose()
|
||
|
{
|
||
|
splitInfos.Dispose();
|
||
|
}
|
||
|
|
||
|
internal bool UseReceiverPlanes()
|
||
|
{
|
||
|
// only use receiver planes if there are no receiver spheres
|
||
|
// (if spheres are present, then this is directional light cascades and Unity has already added receiver planes to the culling planes)
|
||
|
return splitInfos.Length == 0;
|
||
|
}
|
||
|
|
||
|
internal static ReceiverSphereCuller Create(in BatchCullingContext cc, Allocator allocator)
|
||
|
{
|
||
|
int splitCount = cc.cullingSplits.Length;
|
||
|
|
||
|
// only set up sphere culling when there are multiple splits and all splits have valid spheres
|
||
|
bool allSpheresValid = (splitCount > 1);
|
||
|
for (int splitIndex = 0; splitIndex < splitCount; ++splitIndex)
|
||
|
{
|
||
|
// ensure that NaN is not considered as valid
|
||
|
if (!(cc.cullingSplits[splitIndex].sphereRadius > 0.0f))
|
||
|
allSpheresValid = false;
|
||
|
}
|
||
|
if (!allSpheresValid)
|
||
|
splitCount = 0;
|
||
|
|
||
|
float3x3 lightToWorldSpaceRotation = (float3x3)(float4x4)cc.localToWorldMatrix;
|
||
|
ReceiverSphereCuller result = new ReceiverSphereCuller()
|
||
|
{
|
||
|
splitInfos = new NativeArray<SplitInfo>(splitCount, allocator, NativeArrayOptions.UninitializedMemory),
|
||
|
worldToLightSpaceRotation = math.transpose(lightToWorldSpaceRotation),
|
||
|
};
|
||
|
|
||
|
for (int splitIndex = 0; splitIndex < splitCount; ++splitIndex)
|
||
|
{
|
||
|
var cullingSplit = cc.cullingSplits[splitIndex];
|
||
|
|
||
|
float4 receiverSphereLightSpace = new float4(
|
||
|
math.mul(result.worldToLightSpaceRotation, cullingSplit.sphereCenter),
|
||
|
cullingSplit.sphereRadius);
|
||
|
|
||
|
result.splitInfos[splitIndex] = new SplitInfo()
|
||
|
{
|
||
|
receiverSphereLightSpace = receiverSphereLightSpace,
|
||
|
cascadeBlendCullingFactor = cullingSplit.cascadeBlendCullingFactor,
|
||
|
};
|
||
|
}
|
||
|
|
||
|
return result;
|
||
|
}
|
||
|
|
||
|
internal static float DistanceUntilCylinderFullyCrossesPlane(
|
||
|
float3 cylinderCenter,
|
||
|
float3 cylinderDirection,
|
||
|
float cylinderRadius,
|
||
|
Plane plane)
|
||
|
{
|
||
|
float cosEpsilon = 0.001f; // clamp the cosine of glancing angles
|
||
|
|
||
|
// compute the distance until the center intersects the plane
|
||
|
float cosTheta = math.max(math.abs(math.dot(plane.normal, cylinderDirection)), cosEpsilon);
|
||
|
float heightAbovePlane = math.dot(plane.normal, cylinderCenter) + plane.distance;
|
||
|
float centerDistanceToPlane = heightAbovePlane/cosTheta;
|
||
|
|
||
|
// compute the additional distance until the edge of the cylinder intersects the plane
|
||
|
float sinTheta = math.sqrt(math.max(1.0f - cosTheta*cosTheta, 0.0f));
|
||
|
float edgeDistanceToPlane = cylinderRadius*sinTheta/cosTheta;
|
||
|
|
||
|
return centerDistanceToPlane + edgeDistanceToPlane;
|
||
|
}
|
||
|
|
||
|
internal static uint ComputeSplitVisibilityMask(
|
||
|
NativeArray<Plane> lightFacingFrustumPlanes,
|
||
|
NativeArray<SplitInfo> splitInfos,
|
||
|
float3x3 worldToLightSpaceRotation,
|
||
|
in AABB bounds)
|
||
|
{
|
||
|
float3 casterCenterWorldSpace = bounds.center;
|
||
|
float3 casterCenterLightSpace = math.mul(worldToLightSpaceRotation, bounds.center);
|
||
|
float casterRadius = math.length(bounds.extents);
|
||
|
|
||
|
// push the (light-facing) frustum planes back by the caster radius, then intersect with a line through the caster capsule center,
|
||
|
// to compute the length of the shadow that will cover all possible receivers within the whole frustum (not just this split)
|
||
|
float3 shadowDirection = math.transpose(worldToLightSpaceRotation).c2;
|
||
|
float shadowLength = math.INFINITY;
|
||
|
for (int i = 0; i < lightFacingFrustumPlanes.Length; ++i)
|
||
|
{
|
||
|
shadowLength = math.min(shadowLength, DistanceUntilCylinderFullyCrossesPlane(
|
||
|
casterCenterWorldSpace,
|
||
|
shadowDirection,
|
||
|
casterRadius,
|
||
|
lightFacingFrustumPlanes[i]));
|
||
|
}
|
||
|
shadowLength = math.max(shadowLength, 0.0f);
|
||
|
|
||
|
uint splitVisibilityMask = 0;
|
||
|
int splitCount = splitInfos.Length;
|
||
|
for (int splitIndex = 0; splitIndex < splitCount; ++splitIndex)
|
||
|
{
|
||
|
SplitInfo splitInfo = splitInfos[splitIndex];
|
||
|
float3 receiverCenterLightSpace = splitInfo.receiverSphereLightSpace.xyz;
|
||
|
float receiverRadius = splitInfo.receiverSphereLightSpace.w;
|
||
|
float3 receiverToCasterLightSpace = casterCenterLightSpace - receiverCenterLightSpace;
|
||
|
|
||
|
// compute the light space z coordinate where the caster sphere and receiver sphere just intersect
|
||
|
float sphereIntersectionMaxDistance = casterRadius + receiverRadius;
|
||
|
float zSqAtSphereIntersection = math.lengthsq(sphereIntersectionMaxDistance) - math.lengthsq(receiverToCasterLightSpace.xy);
|
||
|
|
||
|
// if this is negative, the spheres do not overlap as circles in the XY plane, so cull the caster
|
||
|
if (zSqAtSphereIntersection < 0.0f)
|
||
|
continue;
|
||
|
|
||
|
// if the caster is outside of the receiver sphere in the light direction, it cannot cast a shadow on it, so cull it
|
||
|
if (receiverToCasterLightSpace.z > 0.0f && math.lengthsq(receiverToCasterLightSpace.z) > zSqAtSphereIntersection)
|
||
|
continue;
|
||
|
|
||
|
// render the caster in this split
|
||
|
splitVisibilityMask |= 1U << splitIndex;
|
||
|
|
||
|
// culling assumes that shaders will always sample from the cascade with the lowest index,
|
||
|
// so if the caster capsule is fully contained within the "core" sphere where only this split index is sampled,
|
||
|
// then cull this caster from all the larger index splits (break from this loop)
|
||
|
// (it is sufficient to test that only the capsule start and end spheres are within the "core" receiver sphere)
|
||
|
float coreRadius = receiverRadius * splitInfo.cascadeBlendCullingFactor;
|
||
|
float3 receiverToShadowEndLightSpace = receiverToCasterLightSpace + new float3(0.0f, 0.0f, shadowLength);
|
||
|
float capsuleMaxDistance = coreRadius - casterRadius;
|
||
|
float capsuleDistanceSq = math.max(math.lengthsq(receiverToCasterLightSpace), math.lengthsq(receiverToShadowEndLightSpace));
|
||
|
if (capsuleMaxDistance > 0.0f && capsuleDistanceSq < math.lengthsq(capsuleMaxDistance))
|
||
|
break;
|
||
|
}
|
||
|
return splitVisibilityMask;
|
||
|
}
|
||
|
}
|
||
|
}
|