Rendering a Sphere on a Quad

Making the Sphere Impostor Feel More Competent

Image for post
Image for post

My First Sphere Impostor

float sphIntersect( float3 ro, float3 rd, float4 sph )
{
float3 oc = ro - sph.xyz;
float b = dot( oc, rd );
float c = dot( oc, oc ) - sph.w*sph.w;
float h = b*b - c;
if( h<0.0 ) return -1.0;
h = sqrt( h );
return -b - h;
}
float3 worldPos = mul(unity_ObjectToWorld, v.vertex);
float3 rayDir = _WorldSpaceCameraPos.xyz - worldPos;

Depth Finder

Image for post
Image for post
It looks like a sphere, but it’s actually a cube.
Image for post
Image for post
A very round cube. Make all the boy cubes go *whaaah!*.

Texturing a Sphere

Equirectangular UVs

float2 uv = float2(
// atan returns a value between -pi and pi
// so we divide by pi * 2 to get -0.5 to 0.5
atan2(normal.z, normal.x) / (UNITY_PI * 2.0),
// acos returns 0.0 at the top, pi at the bottom
// so we flip the y to align with Unity's OpenGL style
// texture UVs so 0.0 is at the bottom
acos(-normal.y) / UNITY_PI
);
fixed4 col = tex2D(_MainTex, uv);
Image for post
Image for post
Earth the final frontier.
Image for post
Image for post
Is that the Greenwich Mean Line?

Unseamly

// -0.5 to 0.5 range
float phi = atan2(worldNormal.z, worldNormal.x) / (UNITY_PI * 2.0);
// 0.0 to 1.0 range
float phi_frac = frac(phi);
float2 uv = float2(
// uses a small bias to prefer the first 'UV set'
fwidth(phi) < fwidth(phi_frac) - 0.001 ? phi : phi_frac,
acos(-worldNormal.y) / UNITY_PI
);
Image for post
Image for post
I promise it’s not hiding on the other side.

Crunchy Edges (aka Derivatives Strike Again)

Image for post
Image for post
Crunchy “outline” on the impostor.
Image for post
Image for post
The Hidden UV Seam
Image for post
Image for post
// same sphere intersection function
float rayHit = sphIntersect(rayOrigin, rayDir, float4(0,0,0,0.5));
// clip if -1.0 to hide sphere on miss
clip(rayHit);
// dot product gets ray length at position closest to sphere
rayHit = rayHit < 0.0 ? dot(rayDir, spherePos - rayOrigin) : rayHit;
Image for post
Image for post
Image for post
Image for post
Image for post
Image for post
No longer seamful.

Object Scale & Rotation

// vertex shader
float3 worldSpaceRayDir = worldPos - _WorldSpaceCameraPos.xyz;
// only want to rotate and scale the dir vector, so w = 0
o.rayDir = mul(unity_WorldToObject, float4(worldSpaceRayDir, 0.0));
// need to apply full transform to the origin vector
o.rayOrigin = mul(unity_WorldToObject, float4(_WorldSpaceCameraPos.xyz, 1.0));
// fragment shader
float3 spherePos = float3(0,0,0);
// now gets an object space surface position instead of world space
float3 objectSpacePos = rayDir * rayHit + rayOrigin;
// still need to normalize this in object space for the UVs
float3 objectSpaceNormal = normalize(objectSpacePos);
float3 worldNormal = UnityObjectToWorldNormal(objectSpaceNormal);
float3 worldPos = mul(unity_ObjectToWorld, float4(objectSpacePos, 1.0));
Image for post
Image for post
Big, little, and terrible sandwich Earths.

Using a Quad

Billboard Shader

View Facing Billboard

// get object's world space pivot from the transform matrix
float3 worldSpacePivot = unity_ObjectToWorld._m03_m13_m23;
// transform into view space
float3 viewSpacePivot = mul(UNITY_MATRIX_V, float4(worldSpacePivot, 1.0));
// object space vertex position + view pivot = billboarded quad
float3 viewSpacePos = v.vertex.xyz + viewSpacePivot;
// calculate the object space ray dir from the view space position
o.rayDir = mul(unity_WorldToObject,
mul(UNITY_MATRIX_I_V, float4(viewSpacePos, 0.0))
);
// apply projection matrix to get clip space position
o.pos = mul(UNITY_MATRIX_P, float4(viewSpacePos, 1.0));
Image for post
Image for post
Thinking too far outside the box.
Image for post
Image for post
Artist’s Recreation of the Crime Scene
Image for post
Image for post

Camera Facing Billboard

float3 worldSpacePivot = unity_ObjectToWorld._m03_m13_m23;// offset between pivot and camera
float3 worldSpacePivotToCamera = _WorldSpaceCameraPos.xyz - worldSpacePivot;
// camera up vector
// used as a somewhat arbitrary starting up orientation
float3 up = UNITY_MATRIX_I_V._m01_m11_m2;
// forward vector is the normalized offset
// this it the direction from the pivot to the camera
float3 forward = normalize(worldSpacePivotToCamera);
// cross product gets a vector perpendicular to the input vectors
float3 right = normalize(cross(forward, up));
// another cross product ensures the up is perpendicular to both
up = cross(right, forward);
// construct the rotation matrix
float3x3 rotMat = float3x3(right, up, forward);
// the above rotate matrix is transposed, meaning the components are
// in the wrong order, but we can work with that by swapping the
// order of the matrix and vector in the mul()
float3 worldPos = mul(v.vertex.xyz, rotMat) + worldSpacePivot;
// ray direction
float3 worldRayDir = worldPos - _WorldSpaceCameraPos.xyz;
o.rayDir = mul(unity_WorldToObject, float4(worldRayDir, 0.0));
// clip space position output
o.pos = UnityWorldToClipPos(worldPos);
Image for post
Image for post
float3 worldPos = mul(float3(v.vertex.xy, 0.3), rotMat) + worldSpacePivot;
Image for post
Image for post

Perfect Perspective Billboard Scaling

// get the sine of the right triangle with the hypotenuse being the // sphere pivot distance and the opposite using the sphere radius
float sinAngle = 0.5 / length(viewOffset);
// convert to cosine
float cosAngle = sqrt(1.0 - sinAngle * sinAngle);
// convert to tangent
float tanAngle = sinAngle / cosAngle;
// those previous two lines are the equivalent of this, but faster
// tanAngle = tan(asin(sinAngle));
// get the opposite face of the right triangle with the 90 degree
// angle at the sphere pivot, multiplied by 2 to get the quad size
float quadScale = tanAngle * length(viewOffset) * 2.0;
// scale the quad by the calculated size
float3 worldPos = mul(float3(v.vertex.xy, 0.0) * quadScale, rotMat) + worldSpacePivot;
Image for post
Image for post

Accounting for Object Scale

// get the object scale
float3 scale = float3(
length(unity_ObjectToWorld._m00_m10_m20),
length(unity_ObjectToWorld._m01_m11_m21),
length(unity_ObjectToWorld._m02_m12_m22)
);
float maxScale = max(abs(scale.x), max(abs(scale.y), abs(scale.z)));
// multiply the sphere radius by the max scale
float maxRadius = maxScale * 0.5;
// update our sine calculation using the new radius
float sinAngle = maxRadius / length(viewOffset);
// do the rest of the scaling code

Ellipsoid Bounds?

Frustum Culling

Shadow Casting

Orthographic Pain

// forward in view space is -z, so we want the negative vector
float3 worldSpaceViewForward = -UNITY_MATRIX_I_V._m02_m12_m22;
float4x4 inverse(float4x4 m) {
float n11 = m[0][0], n12 = m[1][0], n13 = m[2][0], n14 = m[3][0];
float n21 = m[0][1], n22 = m[1][1], n23 = m[2][1], n24 = m[3][1];
float n31 = m[0][2], n32 = m[1][2], n33 = m[2][2], n34 = m[3][2];
float n41 = m[0][3], n42 = m[1][3], n43 = m[2][3], n44 = m[3][3];
float t11 = n23 * n34 * n42 - n24 * n33 * n42 + n24 * n32 * n43 - n22 * n34 * n43 - n23 * n32 * n44 + n22 * n33 * n44;// ... hold up, how many more lines are there of this?!

The Nearly View Plane

// transform world space vertex position into view space
float4 viewSpacePos = mul(UNITY_MATRIX_V, float4(worldPos, 1.0));
// flatten the view space position to be on the camera plane
viewSpacePos.z = 0.0;
// transform back into world space
float4 worldRayOrigin = mul(UNITY_MATRIX_I_V, viewSpacePos);
// orthographic ray dir
float3 worldRayDir = worldSpaceViewForward;
// and to object space
o.rayDir = mul(unity_WorldToObject, float4(worldRayDir, 0.0));
o.rayOrigin = mul(unity_WorldToObject, worldRayOrigin);
float3 worldSpaceViewPos = UNITY_MATRIX_I_V._m03_m13_m23;
float3 worldSpaceViewForward = -UNITY_MATRIX_I_V._m02_m12_m2;
// originally the perspective ray dir
float3 worldCameraToPos = worldPos - worldSpaceViewPos;
// orthographic ray dir
float3 worldRayDir = worldSpaceViewForward * -dot(worldCameraToPos, worldSpaceViewForward);
// orthographic ray origin
float3 worldRayOrigin = worldPos - worldRayDir;
o.rayDir = mul(unity_WorldToObject, float4(worldRayDir, 0.0));
o.rayOrigin = mul(unity_WorldToObject, float4(worldRayOrigin, 1.0));

Light Facing Billboard

A Point of Perspective

bool isOrtho = UNITY_MATRIX_P._m33 == 1.0;// billboard code
float3 forward = isOrtho ? -worldSpaceViewForward : normalize(worldSpacePivotToCamera);
// do the rest of the billboard code
// quad scaling code
float quadScale = maxScale;
if (!isOrtho)
{
// do that perfect scaling code
}
// ray direction and origin code
float3 worldRayOrigin = worldSpaceViewPos;
float3 worldRayDir = worldPos - worldSpaceRayOrigin;
if (isOrtho)
{
worldRayDir = worldSpaceViewForward * -dot(worldRayDir, worldSpaceViewForward);
worldRayOrigin = worldPos - worldRayDir;
}
o.rayDir = mul(unity_WorldToObject, float4(worldRayDir, 0.0));
o.rayOrigin = mul(unity_WorldToObject, float4(worldRayOrigin, 1.0));
// don't worry, I'll show the whole vertex shader later

Shadow Bias

Image for post
Image for post
Yeah, really still just a quad.
Tags { "LightMode" = "ShadowCaster" }ZWrite On ZTest LEqualCGPROGRAM
#pragma vertex vert
#pragma fragment frag_shadow
#pragma multi_compile_shadowcaster// yes, I know the vertex function is missingfixed4 frag_shadow (v2f i,
out float outDepth : SV_Depth
) : SV_Target
{
// ray origin
float3 rayOrigin = i.rayOrigin;
// normalize ray vector
float3 rayDir = normalize(i.rayDir);
// ray sphere intersection
float rayHit = sphIntersect(rayOrigin, rayDir, float4(0,0,0,0.5));
// above function returns -1 if there's no intersection
clip(rayHit);
// calculate object space position
float3 objectSpacePos = rayDir * rayHit + rayOrigin;
// output modified depth
// yes, we pass in objectSpacePos as both arguments
// second one is for the object space normal, which in this case
// is the normalized position, but the function transforms it
// into world space and normalizes it so we don't have to
float4 clipPos = UnityClipSpaceShadowCasterPos(objectSpacePos, objectSpacePos);
clipPos = UnityApplyLinearShadowBias(clipPos);
outDepth = clipPos.z / clipPos.w;
return 0;
}
ENDCG

Shadow Receiving

Lighting it Up

// world space surface normal and position
float3 worldNormal = UnityObjectToWorldNormal(objectSpaceNormal);
float3 worldPos = mul(unity_ObjectToWorld, float4(objectSpacePos, 1.0));
// basic lighting
half3 worldLightDir = UnityWorldSpaceLightDir(worldPos);
half ndotl = saturate(dot(worldNormal, worldLightDir));
half3 lighting = _LightColor0 * ndotl;
// ambient lighting
half3 ambient = ShadeSH9(float4(worldNormal, 1));
lighting += ambient;
// apply lighting
col.rgb *= lighting;
// dummy struct
struct shadowInput {
SHADOW_COORDS(0)
);
// world space position and clip space position
float3 worldPos = mul(unity_ObjectToWorld, float4(surfacePos, 1.0));
float4 clipPos = UnityWorldToClipPos(float4(worldPos, 1.0));
#if defined (SHADOWS_SCREEN)
// setup shadow struct for screen space shadows
shadowInput shadowIN;
#if defined(UNITY_NO_SCREENSPACE_SHADOWS)
// mobile shadows
shadowIN._ShadowCoord = mul(unity_WorldToShadow[0], float4(worldPos, 1.0));
#else
// screen space shadows
shadowIN._ShadowCoord = ComputeScreenPos(clipPos);
#endif // UNITY_NO_SCREENSPACE_SHADOWS
#else
float shadowIN = 0;
#endif // SHADOWS_SCREEN
// macro creates a variable named atten with the shadow
UNITY_LIGHT_ATTENUATION(atten, shadowIN, worldPos);
// multiply the directional lighting by atten
half3 lighting = _LightColor0 * ndotl * atten;
Image for post
Image for post
Catching shade.

Multiple Lights

Image for post
Image for post
RTX Off!

Fragmented Depth

Conservative Depth Output

// usual clip space at the end of the shader
o.pos = UnityWorldToClipPos(worldPos);
// get a position maxRadius closer from the sphere pivot along the
// forward view vector
float4 nearerClip = UnityWorldToClipPos(worldSpacePivotPos — worldSpaceViewForward * maxRadius);
// convert apply "pespective divide" to get real depth Z
float nearerZ = nearerClip.z / nearerClip.w
// replace the original clip space z with the new one
o.pos.z = nearerZ * o.pos.w;
// clamp to just inside the near clip plane
o.pos.z = min(o.pos.w - 1.0e-6f, nearerZ * o.pos.w);
Image for post
Image for post
// this pushes the vertices towards the camera
// add just before the UnityWorldToClipPos line in the vertex shader
worldPos += worldSpaceRayDir / dot(normalize(viewOffset), worldSpaceRayDir) * maxRadius;
// usual clip space at the end of the shader
o.pos = UnityWorldToClipPos(worldPos);
Image for post
Image for post
// update the fragment shader functions like this
half4 frag_(forward/shadow) (v2f i
#if UNITY_REVERSED_Z && SHADER_TARGET > 40
, out float outDepth : SV_DepthLessEqual
#else
// the device probably can't use conservative depth
, out float outDepth : SV_Depth
#endif
) : SV_Target

Finishing Touches

“Per Vertex” Non-Important Lights

#if defined(VERTEXLIGHT_ON)
// "per vertex" non-important lights
half3 vertexLighting = Shade4PointLights(
unity_4LightPosX0, unity_4LightPosY0, unity_4LightPosZ0,
unity_LightColor[0].rgb, unity_LightColor[1].rgb,
unity_LightColor[2].rgb, unity_LightColor[3].rgb,
unity_4LightAtten0,
worldPos, worldNormal);
lighting += vertexLighting;
#endif
#pragma multi_compile _ VERTEXLIGHT_ON

Fog

// fog
float fogCoord = clipPos.z;
#if (SHADER_TARGET < 30) || defined(SHADER_API_MOBILE)
// macro calculates fog falloff
// and creates a unityFogFactor variable to hold it
UNITY_CALC_FOG_FACTOR(fogCoord);
fogCoord = unityFogFactor;
#endif
UNITY_APPLY_FOG(fogCoord, col);

Instancing

The Finished Shader

Additional Thoughts

Surface Shaders & Shader Graph

Anti-Aliasing

Image for post
Image for post
4x MSAA with the original shader vs using Alpha to Coverage.
// add this to the pass outside of the CGPROGRAM to enable
// alpha to coverage
AlphaToMask On
// ray to sphere pivot distance
float rayToPointDist = length(rayDir * dot(rayDir, -rayOrigin) + rayOrigin);
// fwidth gets the sum of the ddx & ddy partial derivatives
// float fDist = fwidth(rayToPointDist);
// fwidth is a coarse approximation of this
float fDist = length(float2(ddx(rayToPointDist), ddy(rayToPointDist)));
// sharpen ray to point distance
// centered on sphere radius, +/- half a pixel based on derivatives
float alpha = (0.5 - rayToPointDist) / max(fDist, 0.0001) + 0.5;
// clip based on sharpened alpha
// don't clip based on ray hit miss
clip(alpha);
// clamp alpha to a 0 to 1 range and apply to output alpha after
// sampling the texture
col.a = saturate(alpha);
Image for post
Image for post
4x MSAA with the original shader vs using Alpha to Coverage. Note intersections are identical with both approaches. Rasterized surfaces that are aligned with the view plane show aliasing on intersections with impostor. Rasterized surfaces viewed at an angle show anti-aliasing, but it is equivalent to intersecting a view plane aligned surface.
Image for post
Image for post
4x MSAA with the original shader vs shader forced to render per subsample.
Image for post
Image for post
4x MSAA with the original shader vs shader forced to render per subsample. Note intersections for the super sampled case are all properly anti-aliased.
// update the v2f struct to use the sample modifier for the
// interpolated ray dir and origin vectors to force fragment
// shader to run for each subsample and for the interpolated
// values to get computed uniquely for each subsample position
struct v2f
{
float4 pos : SV_POSITION;
sample float3 rayDir : TEXCOORD0;
sample float3 rayOrigin : TEXCOORD1;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
// add this inside the CGPROGRAM blocks for the passes as the
// sample modifier is a Shader Model 5.0 feature
#pragma target 5.0
// and you may want to bias the texture mip level
// because why not if we're already super sampling!
half4 col = tex2Dbias(_MainTex, float4(uv, 0, -1));
Image for post
Image for post
4x MSAA with Alpha to Coverage vs shader forced to render per subsample.
Image for post
Image for post
4x MSAA with original shader vs Alpha to Coverage vs Forced Super Sampling intersection comparison.

Deferred Rendering

Written by

Tech Artist & Graphics Programmer lately focused on Unity VR game dev.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store