Search code examples
javascriptthree.jsglslshader

Question about screen space coordinates for sun/background rendering


I'm trying to render a background and sun with a custom shader in three.js. The idea is to compute a screen space position for the sun and use these coordinates in the fragment shader for rendering. The expected behavior is that the sun is always rendered at the horizon at (0,1000,-1000). When you run the live example and just look up, it seems this is actually the case.

However, when you move the camera around (so it looks along the (0,-1,1) vector), you will notice that the sun is suddenly mirrored and flipped along the XY plane. Why is this happening? Is this related to the approach how screen space coordinates are computed and evaluated in the shader.

The live example is actually a reduced test case of the this GitHub issue.

var container;

var camera, cameraFX, scene, sceneFX, renderer;

var uniforms;

var sunPosition = new THREE.Vector3( 0, 1000, - 1000 );
var screenSpacePosition = new THREE.Vector3();

init();
animate();

function init() {

	container = document.getElementById( 'container' );

	camera = new THREE.PerspectiveCamera( 70, window.innerWidth / window.innerHeight, 0.1, 2000 );
	camera.position.set( 0, 0, 10 );
	
	cameraFX = new THREE.OrthographicCamera( - 1, 1, 1, - 1, 0, 1 );

	scene = new THREE.Scene();
	
	scene.add( new THREE.AxesHelper( 5 ) );
	
	sceneFX = new THREE.Scene();

	var geometry = new THREE.PlaneBufferGeometry( 2, 2 );

	uniforms = {
		"aspect": { value: window.innerWidth / window.innerHeight },
		"sunPositionScreenSpace": { value: new THREE.Vector2() }
	};

	var material = new THREE.ShaderMaterial( {

		uniforms: uniforms,
		vertexShader: document.getElementById( 'vertexShader' ).textContent,
		fragmentShader: document.getElementById( 'fragmentShader' ).textContent

	} );

	var quad = new THREE.Mesh( geometry, material );
	sceneFX.add( quad );

	renderer = new THREE.WebGLRenderer();
	renderer.setSize( window.innerWidth, window.innerHeight );
	renderer.autoClear = false;
	container.appendChild( renderer.domElement );

	var controls = new THREE.OrbitControls( camera, renderer.domElement );

}


//

function animate( timestamp ) {

	requestAnimationFrame( animate );
	
	renderer.clear();
	
	// background/sun pass

	screenSpacePosition.copy( sunPosition ).project( camera );
	
	screenSpacePosition.x = ( screenSpacePosition.x + 1 ) / 2;
	screenSpacePosition.y = ( screenSpacePosition.y + 1 ) / 2;

	uniforms[ "sunPositionScreenSpace" ].value.copy( screenSpacePosition );

	renderer.render( sceneFX, cameraFX );
	
	// beauty pass
	
	renderer.clearDepth();
	renderer.render( scene, camera );

}
body {
  margin: 0;
}
canvas {
  display: block;
}
<script src="https://cdn.jsdelivr.net/npm/three@0.116.1/build/three.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.116.1/examples/js/controls/OrbitControls.js"></script>

<div id="container">

</div>
<script id="vertexShader" type="x-shader/x-vertex">

			varying vec2 vUv;

			void main()	{

				vUv = uv;

				gl_Position = vec4( position, 1.0 );

			}

</script>

<script id="fragmentShader" type="x-shader/x-fragment">

			varying vec2 vUv;

			uniform vec2 sunPositionScreenSpace;
			uniform float aspect;

			const vec3 sunColor = vec3( 1.0, 0.0, 0.0 );
			const vec3 bgColor = vec3( 1.0, 1.0, 1.0 );

			void main() {

				vec2 diff = vUv - sunPositionScreenSpace;
				diff.x *= aspect;

				// background/sun drawing

				float prop = clamp( length( diff ) / 0.5, 0.0, 1.0 );
				prop = 0.35 * pow( 1.0 - prop, 3.0 );

				gl_FragColor.rgb = mix( sunColor, bgColor, 1.0 - prop );
				gl_FragColor.a = 1.0;

			}

</script>


Solution

  • The issue is caused, because the sun is behind the point of view. Note at perspective projection the the viewing volume is a Frustum. Each point is projected along a ray through the camera position on the viewport. If the point is behind the point of view, it is mirrored, because it projected along this ray.
    In general that doesn't matter, because all the geometry in front of the near plane is clipped.

    The clipspace coordinate is a Homogeneous coordinate. You have to compute the clipspace cooridante, and evaluate if the z component is less than 0.
    Note, you cannot use Vector3.project, because project computes the normalized device space coordinate. In NDC it cannot be distinguished if the position is in front of or behind the camera, because after the Perspective divide the singe of the z component is lost. Clipping is performed in clip space and the clipping rule is:

    -w <= x, y, z <= w. 
    

    Define the sun position in by Homogeneous direction:

    var sunPosition = new THREE.Vector4( 0, 1, - 1, 0 );
    

    Compute the clip space coordinate and evaluate if the z component is negative:

    let clipPosition = sunPosition
       .clone()
       .applyMatrix4(camera.matrixWorldInverse)
       .applyMatrix4(camera.projectionMatrix);
    
    screenSpacePosition.x = ( clipPosition.x / clipPosition.w + 1 ) / 2;
    screenSpacePosition.y = ( clipPosition.y / clipPosition.w + 1 ) / 2;
    
    if (clipPosition.z < 0.0) {
        // [...]
    }