Search code examples
javascriptthree.jsglslshader

creating a day/night shader that follows a light souce


I have a sphere lit by a DirectionalLight to mimic the sun shining on the earth. I am trying to add a shader that will show the earth at night on the unlit parts of the globe, and the earth during the day for the lit parts. I'm planning on eventually having the DirectionalLight rotate around the globe, updating the shader to show the parts of the earth that are currently in shadow. I came across the following codepen that partially does what I want: https://codepen.io/acauamontiel/pen/yvJoVv

In the codepen above, the day/night textures shown are based on the camera's position in relation to the globe, and I need those to stay fixed relative to the light source's position, rather than the camera's.

    constructor(selector) {
        this.selector = selector;
        this.width = window.innerWidth;
        this.height = window.innerHeight;
        this.frameEvent = new Event('frame');

        this.textureLoader = new THREE.TextureLoader();
    }

    setScene() {
        this.scene = new THREE.Scene();
        this.scenary = new THREE.Object3D;

        this.scene.add(this.scenary);
    }

    setCamera() {
        this.camera = new THREE.PerspectiveCamera(50, this.width/this.height, 1, 20000);
        this.camera.position.y = 25;
        this.camera.position.z = 300;
    }

    setRenderer() {
        this.renderer = new THREE.WebGLRenderer({
            antialias: true
        });
        this.renderer.setSize(this.width, this.height);
        this.canvas = document.querySelector(this.selector).appendChild(this.renderer.domElement);
    }

    setControls() {
        this.controls = new THREE.OrbitControls(this.camera, this.canvas);
        this.controls.maxDistance = 500;
        this.controls.minDistance = 200;
    }

    addHelpers() {
        this.axes = new THREE.AxesHelper(500);
        this.scenary.add(this.axes);
    }

    addLights() {
        this.ambientLight = new THREE.AmbientLight(0x555555);
        this.directionalLight = new THREE.DirectionalLight(0xffffff);
        this.directionalLight.position.set(10, 0, 10).normalize();

        this.scenary.add(this.ambientLight);
        this.scenary.add(this.directionalLight);
    }

    render() {
        this.renderer.render(this.scene, this.camera);
        this.canvas.dispatchEvent(this.frameEvent);
        this.frameRequest = window.requestAnimationFrame(this.render.bind(this));
    }

    destroy() {
        window.cancelAnimationFrame(this.frameRequest);
        this.scene.children = [];
        this.canvas.remove();
    }

    addSky() {
        let radius = 400,
            segments = 50;

        this.skyGeometry = new THREE.SphereGeometry(radius, segments, segments);
        this.skyMaterial = new THREE.MeshPhongMaterial({
            color: 0x666666,
            side: THREE.BackSide,
            shininess: 0
        });
        this.sky = new THREE.Mesh(this.skyGeometry, this.skyMaterial);

        this.scenary.add(this.sky);

        this.loadSkyTextures();
    }

    loadSkyTextures() {
        this.textureLoader.load('https://acaua.gitlab.io/webgl-with-threejs/img/textures/earth/sky-texture.jpg', texture => {
            this.skyMaterial.map = texture;
            this.skyMaterial.needsUpdate = true;
        });
    }

    addEarth() {
        let radius = 100,
            segments = 50;

        this.earthGeometry = new THREE.SphereGeometry(radius, segments, segments);
        this.earthMaterial = new THREE.ShaderMaterial({
            bumpScale: 5,
            specular: new THREE.Color(0x333333),
            shininess: 50,
            uniforms: {
                sunDirection: {
                    value: new THREE.Vector3(1, 1, .5)
                },
                dayTexture: {
                    value: this.textureLoader.load('https://acaua.gitlab.io/webgl-with-threejs/img/textures/earth/earth-texture.jpg')
                },
                nightTexture: {
                    value: this.textureLoader.load('https://acaua.gitlab.io/webgl-with-threejs/img/textures/earth/earth-night.jpg')
                }
            },
            vertexShader: this.dayNightShader.vertex,
            fragmentShader: this.dayNightShader.fragment
        });
        this.earth = new THREE.Mesh(this.earthGeometry, this.earthMaterial);

        this.scenary.add(this.earth);

        this.loadEarthTextures();
        this.addAtmosphere();
    }

    loadEarthTextures() {
        this.textureLoader.load('https://acaua.gitlab.io/webgl-with-threejs/img/textures/earth/earth-texture.jpg', texture => {
            this.earthMaterial.map = texture;
            this.earthMaterial.needsUpdate = true;
        });
        this.textureLoader.load('https://acaua.gitlab.io/webgl-with-threejs/img/textures/earth/earth-bump.jpg', texture => {
            this.earthMaterial.bumpMap = texture;
            this.earthMaterial.needsUpdate = true;
        });
        this.textureLoader.load('https://acaua.gitlab.io/webgl-with-threejs/img/textures/earth/earth-specular.jpg', texture => {
            this.earthMaterial.specularMap = texture;
            this.earthMaterial.needsUpdate = true;
        });
    }

    addAtmosphere() {
        this.innerAtmosphereGeometry = this.earthGeometry.clone();
        this.innerAtmosphereMaterial = THREEx.createAtmosphereMaterial();
        this.innerAtmosphereMaterial.uniforms.glowColor.value.set(0x88ffff);
        this.innerAtmosphereMaterial.uniforms.coeficient.value = 1;
        this.innerAtmosphereMaterial.uniforms.power.value = 5;
        this.innerAtmosphere = new THREE.Mesh(this.innerAtmosphereGeometry, this.innerAtmosphereMaterial);
        this.innerAtmosphere.scale.multiplyScalar(1.008);

        this.outerAtmosphereGeometry = this.earthGeometry.clone();
        this.outerAtmosphereMaterial = THREEx.createAtmosphereMaterial();
        this.outerAtmosphereMaterial.side = THREE.BackSide;
        this.outerAtmosphereMaterial.uniforms.glowColor.value.set(0x0088ff);
        this.outerAtmosphereMaterial.uniforms.coeficient.value = .68;
        this.outerAtmosphereMaterial.uniforms.power.value = 10;
        this.outerAtmosphere = new THREE.Mesh(this.outerAtmosphereGeometry, this.outerAtmosphereMaterial);
        this.outerAtmosphere.scale.multiplyScalar(1.06);

        this.earth.add(this.innerAtmosphere);
        this.earth.add(this.outerAtmosphere);
    }

    get dayNightShader() {
        return {
            vertex: `
                varying vec2 vUv;
                varying vec3 vNormal;

                void main() {
                    vUv = uv;
                    vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
                    vNormal = normalMatrix * normal;
                    gl_Position = projectionMatrix * mvPosition;
                }
            `,
            fragment: `
                uniform sampler2D dayTexture;
                uniform sampler2D nightTexture;

                uniform vec3 sunDirection;

                varying vec2 vUv;
                varying vec3 vNormal;

                void main(void) {
                    vec3 dayColor = texture2D(dayTexture, vUv).rgb;
                    vec3 nightColor = texture2D(nightTexture, vUv).rgb;

                    float cosineAngleSunToNormal = dot(normalize(vNormal), sunDirection);

                    cosineAngleSunToNormal = clamp(cosineAngleSunToNormal * 5.0, -1.0, 1.0);

                    float mixAmount = cosineAngleSunToNormal * 0.5 + 0.5;

                    vec3 color = mix(nightColor, dayColor, mixAmount);

                    gl_FragColor = vec4(color, 1.0);
                }
            `
        }
    }

    animate() {
        this.canvas.addEventListener('frame', () => {
            this.scenary.rotation.x += 0.0001;
            this.scenary.rotation.y -= 0.0005;
        });
    }

    init() {
        this.setScene();
        this.setCamera();
        this.setRenderer();
        this.setControls();
        this.addLights();
        this.render();
        this.addSky();
        this.addEarth();
        this.animate();
    }
}

let canvas = new Canvas('#canvas');
canvas.init();

From what I can tell, it looks like the shader is being updated by the camera inside of get dayNightShader(). It looks like the modelViewMatrix, projectionMatrix, and normalMatrix are all based on the camera based on what I could find in the documentation for three.js, and I've tried changing these to a fixed vector position, but the only thing I've seen it do is hide the globe and show the atmosphere texture. Is there a way to use the light source's position to determine what the shader shows, rather than the camera?


Solution

  • The issue is the line

    float cosineAngleSunToNormal = dot(normalize(vNormal), sunDirection); 
    

    in the fragment shader.
    vNormal is a direction in view space, because it is transformed by the normalMatrix in the vertex shader, but sunDirection is a world space direction.

    To solve the issue you've to transform the sun light direction by the view matrix in the vertex shader and to pass the transformed direction vector to the fragment shader.

    vSunDir = mat3(viewMatrix) * sunDirection;
    

    Note, the viewMatrix transforms from world space to view space. It is important to use the viewMatrix rather than the normalMatrix, because the normalMatrix transforms from model space to world space.

    Vertex shader:

    varying vec2 vUv;
    varying vec3 vNormal;
    varying vec3 vSunDir;
    
    uniform vec3 sunDirection;
    
    void main() {
        vUv = uv;
        vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
    
        vNormal = normalMatrix * normal;
        vSunDir = mat3(viewMatrix) * sunDirection;
    
        gl_Position = projectionMatrix * mvPosition;
    }
    

    Fragment shader:

    uniform sampler2D dayTexture;
    uniform sampler2D nightTexture;
    
    varying vec2 vUv;
    varying vec3 vNormal;
    varying vec3 vSunDir;
    
    void main(void) {
        vec3 dayColor = texture2D(dayTexture, vUv).rgb;
        vec3 nightColor = texture2D(nightTexture, vUv).rgb;
    
        float cosineAngleSunToNormal = dot(normalize(vNormal), normalize(vSunDir));
    
        cosineAngleSunToNormal = clamp(cosineAngleSunToNormal * 5.0, -1.0, 1.0);
    
        float mixAmount = cosineAngleSunToNormal * 0.5 + 0.5;
    
        vec3 color = mix(nightColor, dayColor, mixAmount);
    
        gl_FragColor = vec4(color, 1.0);
    }
    

    class Canvas {
    	constructor(selector) {
    		this.selector = selector;
    		this.width = window.innerWidth;
    		this.height = window.innerHeight;
    		this.frameEvent = new Event('frame');
    
    		this.textureLoader = new THREE.TextureLoader();
    	}
    
    	setScene() {
    		this.scene = new THREE.Scene();
    		this.scenary = new THREE.Object3D;
    
    		this.scene.add(this.scenary);
    	}
    
    	setCamera() {
    		this.camera = new THREE.PerspectiveCamera(50, this.width/this.height, 1, 20000);
    		this.camera.position.y = 25;
    		this.camera.position.z = 300;
    	}
    
    	setRenderer() {
    		this.renderer = new THREE.WebGLRenderer({
    			antialias: true
    		});
        this.renderer.setSize(this.width, this.height);
        var container = document.getElementById(this.selector);
        this.canvas = container.appendChild(this.renderer.domElement);
    		//this.canvas = document.querySelector(this.selector).appendChild(this.renderer.domElement);
    	}
    
    	setControls() {
    		this.controls = new THREE.OrbitControls(this.camera, this.canvas);
    		this.controls.maxDistance = 500;
    		this.controls.minDistance = 200;
    	}
    
    	addHelpers() {
    		this.axes = new THREE.AxesHelper(500);
    		this.scenary.add(this.axes);
    	}
    
    	addLights() {
    		this.ambientLight = new THREE.AmbientLight(0x555555);
    		this.directionalLight = new THREE.DirectionalLight(0xffffff);
    		this.directionalLight.position.set(10, 0, 10).normalize();
    
    		this.scenary.add(this.ambientLight);
    		this.scenary.add(this.directionalLight);
    	}
    
    	render() {
    		this.renderer.render(this.scene, this.camera);
    		this.canvas.dispatchEvent(this.frameEvent);
    		this.frameRequest = window.requestAnimationFrame(this.render.bind(this));
    	}
    
    	destroy() {
    		window.cancelAnimationFrame(this.frameRequest);
    		this.scene.children = [];
    		this.canvas.remove();
    	}
    
    	addSky() {
    		let radius = 400,
    			segments = 50;
    
    		this.skyGeometry = new THREE.SphereGeometry(radius, segments, segments);
    		this.skyMaterial = new THREE.MeshPhongMaterial({
    			color: 0x666666,
    			side: THREE.BackSide,
    			shininess: 0
    		});
    		this.sky = new THREE.Mesh(this.skyGeometry, this.skyMaterial);
    
    		this.scenary.add(this.sky);
    
    		this.loadSkyTextures();
    	}
    
    	loadSkyTextures() {
    		this.textureLoader.load('https://acaua.gitlab.io/webgl-with-threejs/img/textures/earth/sky-texture.jpg', texture => {
    			this.skyMaterial.map = texture;
    			this.skyMaterial.needsUpdate = true;
    		});
    	}
    
    	addEarth() {
    		let radius = 100,
    			segments = 50;
    
    		this.earthGeometry = new THREE.SphereGeometry(radius, segments, segments);
    		this.earthMaterial = new THREE.ShaderMaterial({
    			bumpScale: 5,
    			specular: new THREE.Color(0x333333),
    			shininess: 50,
    			uniforms: {
    				sunDirection: {
    					value: new THREE.Vector3(1, 1, .5)
    				},
    				dayTexture: {
    					value: this.textureLoader.load('https://acaua.gitlab.io/webgl-with-threejs/img/textures/earth/earth-texture.jpg')
    				},
    				nightTexture: {
    					value: this.textureLoader.load('https://acaua.gitlab.io/webgl-with-threejs/img/textures/earth/earth-night.jpg')
    				}
    			},
    			vertexShader: this.dayNightShader.vertex,
    			fragmentShader: this.dayNightShader.fragment
    		});
    		this.earth = new THREE.Mesh(this.earthGeometry, this.earthMaterial);
    
    		this.scenary.add(this.earth);
    
    		this.loadEarthTextures();
    		this.addAtmosphere();
    	}
    
    	loadEarthTextures() {
    		this.textureLoader.load('https://acaua.gitlab.io/webgl-with-threejs/img/textures/earth/earth-texture.jpg', texture => {
    			this.earthMaterial.map = texture;
    			this.earthMaterial.needsUpdate = true;
    		});
    		this.textureLoader.load('https://acaua.gitlab.io/webgl-with-threejs/img/textures/earth/earth-bump.jpg', texture => {
    			this.earthMaterial.bumpMap = texture;
    			this.earthMaterial.needsUpdate = true;
    		});
    		this.textureLoader.load('https://acaua.gitlab.io/webgl-with-threejs/img/textures/earth/earth-specular.jpg', texture => {
    			this.earthMaterial.specularMap = texture;
    			this.earthMaterial.needsUpdate = true;
    		});
    	}
    
    	addAtmosphere() {
        /*
    		this.innerAtmosphereGeometry = this.earthGeometry.clone();
    		this.innerAtmosphereMaterial = THREEx.createAtmosphereMaterial();
    		this.innerAtmosphereMaterial.uniforms.glowColor.value.set(0x88ffff);
    		this.innerAtmosphereMaterial.uniforms.coeficient.value = 1;
    		this.innerAtmosphereMaterial.uniforms.power.value = 5;
    		this.innerAtmosphere = new THREE.Mesh(this.innerAtmosphereGeometry, this.innerAtmosphereMaterial);
    		this.innerAtmosphere.scale.multiplyScalar(1.008);
    
    		this.outerAtmosphereGeometry = this.earthGeometry.clone();
    		this.outerAtmosphereMaterial = THREEx.createAtmosphereMaterial();
    		this.outerAtmosphereMaterial.side = THREE.BackSide;
    		this.outerAtmosphereMaterial.uniforms.glowColor.value.set(0x0088ff);
    		this.outerAtmosphereMaterial.uniforms.coeficient.value = .68;
    		this.outerAtmosphereMaterial.uniforms.power.value = 10;
    		this.outerAtmosphere = new THREE.Mesh(this.outerAtmosphereGeometry, this.outerAtmosphereMaterial);
    		this.outerAtmosphere.scale.multiplyScalar(1.06);
    
    		this.earth.add(this.innerAtmosphere);
    		this.earth.add(this.outerAtmosphere);
        */
    	}
    
    	get dayNightShader() {
    		return {
    			vertex: `
    				varying vec2 vUv;
            varying vec3 vNormal;
            varying vec3 vSunDir;
    
            uniform vec3 sunDirection;
    
    				void main() {
    					vUv = uv;
    					vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
              vNormal = normalMatrix * normal;
              vSunDir = mat3(viewMatrix) * sunDirection;
    					gl_Position = projectionMatrix * mvPosition;
    				}
    			`,
    			fragment: `
    				uniform sampler2D dayTexture;
    				uniform sampler2D nightTexture;
    
    				varying vec2 vUv;
            varying vec3 vNormal;
            varying vec3 vSunDir;
    
    				void main(void) {
    					vec3 dayColor = texture2D(dayTexture, vUv).rgb;
    					vec3 nightColor = texture2D(nightTexture, vUv).rgb;
    
    					float cosineAngleSunToNormal = dot(normalize(vNormal), normalize(vSunDir));
    
    					cosineAngleSunToNormal = clamp(cosineAngleSunToNormal * 5.0, -1.0, 1.0);
    
    					float mixAmount = cosineAngleSunToNormal * 0.5 + 0.5;
    
    					vec3 color = mix(nightColor, dayColor, mixAmount);
    
    					gl_FragColor = vec4(color, 1.0);
    				}
    			`
    		}
    	}
    
    	animate() {
    		this.canvas.addEventListener('frame', () => {
    			this.scenary.rotation.x += 0.0001;
    			this.scenary.rotation.y -= 0.0005;
    		});
    	}
    
    	init() {
    		this.setScene();
    		this.setCamera();
    		this.setRenderer();
    		this.setControls();
    		this.addLights();
    		this.render();
    		this.addSky();
    		this.addEarth();
    		this.animate();
    	}
    }
    
    let canvas = new Canvas('container');
    canvas.init();
    <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/106/three.min.js"></script>
    <script src="https://threejs.org/examples/js/controls/OrbitControls.js"></script>
    <div id="container"></div>