atmosphere/demo/webgl/demo.js

This file is a Javascript transcription of the original C++ code of the demo, where the code related to the atmosphere model initialisation is replaced with code to load precomputed textures and shaders from the network (those are precomputed with precompute.cc).

The following constants must have the same values as in constants.h and demo.cc:

const TRANSMITTANCE_TEXTURE_WIDTH = 256;
const TRANSMITTANCE_TEXTURE_HEIGHT = 64;
const SCATTERING_TEXTURE_WIDTH = 256;
const SCATTERING_TEXTURE_HEIGHT = 128;
const SCATTERING_TEXTURE_DEPTH = 32;
const IRRADIANCE_TEXTURE_WIDTH = 64;
const IRRADIANCE_TEXTURE_HEIGHT = 16;

const kSunAngularRadius = 0.00935 / 2;
const kSunSolidAngle = Math.PI * kSunAngularRadius * kSunAngularRadius;
const kLengthUnitInMeters = 1000;

As in the C++ version, the code consists in a single class. Its constructor initializes the WebGL canvas, declares the fields of the class, sets up the event handlers and starts the resource loading and the render loop:

class Demo {
  constructor(rootElement) {
    this.canvas = rootElement.querySelector('#glcanvas');
    this.canvas.style.width = `${rootElement.clientWidth}px`;
    this.canvas.style.height = `${rootElement.clientHeight}px`;
    this.canvas.width = rootElement.clientWidth * window.devicePixelRatio;
    this.canvas.height = rootElement.clientHeight * window.devicePixelRatio;
    this.help = rootElement.querySelector('#help');
    this.gl = this.canvas.getContext('webgl2');

    this.vertexBuffer = null;
    this.transmittanceTexture = null;
    this.scatteringTexture = null;
    this.irradianceTexture = null;
    this.vertexShaderSource = null;
    this.fragmentShaderSource = null;
    this.atmosphereShaderSource = null;
    this.program = null;

    this.viewFromClip = new Float32Array(16);
    this.modelFromView = new Float32Array(16);
    this.viewDistanceMeters = 9000;
    this.viewZenithAngleRadians = 1.47;
    this.viewAzimuthAngleRadians = -0.1;
    this.sunZenithAngleRadians = 1.3;
    this.sunAzimuthAngleRadians = 2.9;
    this.exposure = 10;

    this.drag = undefined;
    this.previousMouseX = undefined;
    this.previousMouseY = undefined;

    rootElement.addEventListener('keypress', (e) => this.onKeyPress(e));
    rootElement.addEventListener('mousedown', (e) => this.onMouseDown(e));
    rootElement.addEventListener('mousemove', (e) => this.onMouseMove(e));
    rootElement.addEventListener('mouseup', (e) => this.onMouseUp(e));
    rootElement.addEventListener('wheel', (e) => this.onMouseWheel(e));

    this.init();
    requestAnimationFrame(() => this.onRender());
  }

The init method creates a vertex buffer for a full screen quad, and loads the precomputed textures and the shaders for the demo (using utility methods defined in the Utils class below):

  init() {
    const gl = this.gl;
    this.vertexBuffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
    gl.bufferData(gl.ARRAY_BUFFER,
       new Float32Array([-1, -1, +1, -1, -1, +1, +1, +1]), gl.STATIC_DRAW);

    Utils.loadTextureData('transmittance.dat', (data) => {
      this.transmittanceTexture =
          Utils.createTexture(gl, gl.TEXTURE0, gl.TEXTURE_2D);
      gl.texImage2D(gl.TEXTURE_2D, 0,
          gl.getExtension('OES_texture_float_linear') ? gl.RGBA32F : gl.RGBA16F,
          TRANSMITTANCE_TEXTURE_WIDTH, TRANSMITTANCE_TEXTURE_HEIGHT, 0, gl.RGBA,
          gl.FLOAT, data);
    });
    Utils.loadTextureData('scattering.dat', (data) => {
      this.scatteringTexture =
          Utils.createTexture(gl, gl.TEXTURE1, gl.TEXTURE_3D);
      gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_WRAP_R, gl.CLAMP_TO_EDGE);
      gl.texImage3D(gl.TEXTURE_3D, 0, gl.RGBA16F, SCATTERING_TEXTURE_WIDTH,
          SCATTERING_TEXTURE_HEIGHT, SCATTERING_TEXTURE_DEPTH, 0, gl.RGBA,
          gl.FLOAT, data);
    });
    Utils.loadTextureData('irradiance.dat', (data) => {
      this.irradianceTexture =
          Utils.createTexture(gl, gl.TEXTURE2, gl.TEXTURE_2D);
      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, IRRADIANCE_TEXTURE_WIDTH,
          IRRADIANCE_TEXTURE_HEIGHT, 0, gl.RGBA, gl.FLOAT, data);
    });

    Utils.loadShaderSource('vertex_shader.txt', (source) => {
      this.vertexShaderSource = source;
    });
    Utils.loadShaderSource('fragment_shader.txt', (source) => {
      this.fragmentShaderSource = source;
    });
    Utils.loadShaderSource('atmosphere_shader.txt', (source) => {
      this.atmosphereShaderSource = source;
    });
  }

The WebGL program cannot be created before all the shaders are loaded. This is done in the following method, which is called at each frame (the precomputed shaders are OpenGL 3.3 shaders, so they must be adapted for WebGL2. In particular, the version id must be changed, and the fragment shaders must be concatenated because WebGL2 does not support multiple shaders of the same type):

  maybeInitProgram() {
    if (this.program ||
        !this.vertexShaderSource ||
        !this.fragmentShaderSource ||
        !this.atmosphereShaderSource) {
      return;
    }
    const gl = this.gl;
    const vertexShader =
        Utils.createShader(gl, gl.VERTEX_SHADER,
            this.vertexShaderSource.replace('#version 330', '#version 300 es'));
    const fragmentShader = Utils.createShader(
        gl,
        gl.FRAGMENT_SHADER,
        this.atmosphereShaderSource
            .replace('#version 330',
                     '#version 300 es\n' +
                     'precision highp float;\n' +
                     'precision highp sampler3D;') +
        this.fragmentShaderSource
            .replace('#version 330', '')
            .replace('const float PI = 3.14159265;', '')
        );
    this.program = gl.createProgram();
    gl.attachShader(this.program, vertexShader);
    gl.attachShader(this.program, fragmentShader);
    gl.linkProgram(this.program);
  }

The render loop body sets the program attributes and uniforms, and renders a full screen quad with it:

  onRender() {
    const gl = this.gl;
    gl.clearColor(0, 0, 0, 1);
    gl.clear(gl.COLOR_BUFFER_BIT);

    this.maybeInitProgram();
    if (!this.program) {
      requestAnimationFrame(() => this.onRender());
      return;
    }

    const kFovY = 50 / 180 * Math.PI;
    const kTanFovY = Math.tan(kFovY / 2);
    const aspectRatio = this.canvas.width / this.canvas.height;
    this.viewFromClip.set([
        kTanFovY * aspectRatio, 0, 0, 0,
        0, kTanFovY, 0, 0,
        0, 0, 0, -1,
        0, 0, 1, 1]);

    const cosZ = Math.cos(this.viewZenithAngleRadians);
    const sinZ = Math.sin(this.viewZenithAngleRadians);
    const cosA = Math.cos(this.viewAzimuthAngleRadians);
    const sinA = Math.sin(this.viewAzimuthAngleRadians);
    const viewDistance = this.viewDistanceMeters / kLengthUnitInMeters;
    this.modelFromView.set([
        -sinA, -cosZ * cosA,  sinZ * cosA, sinZ * cosA * viewDistance,
        cosA, -cosZ * sinA, sinZ * sinA, sinZ * sinA * viewDistance,
        0, sinZ, cosZ, cosZ * viewDistance,
        0, 0, 0, 1]);

    const program = this.program;
    gl.useProgram(program);
    gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
    gl.vertexAttribPointer(
        gl.getAttribLocation(program, 'vertex'),
        /*numComponents=*/ 2,
        /*type=*/ this.gl.FLOAT,
        /*normalize=*/ false,
        /*stride=*/ 0,
        /*offset=*/ 0);
    gl.enableVertexAttribArray(gl.getAttribLocation(program, 'vertex'));
    gl.uniformMatrix4fv(gl.getUniformLocation(program, 'view_from_clip'),
        true,  this.viewFromClip);
    gl.uniformMatrix4fv(gl.getUniformLocation(program, 'model_from_view'),
        true,  this.modelFromView);
    gl.uniform1i(gl.getUniformLocation(program, 'transmittance_texture'), 0);
    gl.uniform1i(gl.getUniformLocation(program, 'scattering_texture'), 1);
    // Unused texture sampler, but bind a 3D texture to it anyway, just in case.
    gl.uniform1i(
        gl.getUniformLocation(program, 'single_mie_scattering_texture'), 1);
    gl.uniform1i(gl.getUniformLocation(program, 'irradiance_texture'), 2);
    gl.uniform3f(gl.getUniformLocation(program, 'camera'),
        this.modelFromView[3], this.modelFromView[7], this.modelFromView[11]);
    gl.uniform3f(gl.getUniformLocation(program, 'white_point'), 1, 1, 1);
    gl.uniform1f(gl.getUniformLocation(program, 'exposure'), this.exposure);
    gl.uniform3f(gl.getUniformLocation(program, 'earth_center'),
        0, 0, -6360000 / kLengthUnitInMeters);
    gl.uniform3f(gl.getUniformLocation(program, 'sun_direction'),
        Math.cos(this.sunAzimuthAngleRadians) *
            Math.sin(this.sunZenithAngleRadians),
        Math.sin(this.sunAzimuthAngleRadians) *
            Math.sin(this.sunZenithAngleRadians),
        Math.cos(this.sunZenithAngleRadians));
    gl.uniform2f(gl.getUniformLocation(program, 'sun_size'),
        Math.tan(kSunAngularRadius), Math.cos(kSunAngularRadius));
    gl.drawArrays(gl.TRIANGLE_STRIP, /*offset=*/ 0, /*vertexCount=*/ 4);
    requestAnimationFrame(() => this.onRender());
  }

The last part of the Demo class are the event handler methods, which are directly adapted from the C++ code:

  onKeyPress(event) {
    const key = event.key;
    if (key == 'h') {
      const hidden = this.help.style.display == 'none';
      this.help.style.display = hidden ? 'block' : 'none';
    } else if (key == '+') {
      this.exposure *= 1.1;
    } else if (key == '-') {
      this.exposure /= 1.1;
    } else if (key == '1') {
      this.setView(9000, 1.47, 0, 1.3, 3, 10);
    } else if (key == '2') {
      this.setView(9000, 1.47, 0, 1.564, -3, 10);
    } else if (key == '3') {
      this.setView(7000, 1.57, 0, 1.54, -2.96, 10);
    } else if (key == '4') {
      this.setView(7000, 1.57, 0, 1.328, -3.044, 10);
    } else if (key == '5') {
      this.setView(9000, 1.39, 0, 1.2, 0.7, 10);
    } else if (key == '6') {
      this.setView(9000, 1.5, 0, 1.628, 1.05, 200);
    } else if (key == '7') {
      this.setView(7000, 1.43, 0, 1.57, 1.34, 40);
    } else if (key == '8') {
      this.setView(2.7e6, 0.81, 0, 1.57, 2, 10);
    } else if (key == '9') {
      this.setView(1.2e7, 0.0, 0, 0.93, -2, 10);
    }
  }

  setView(viewDistanceMeters, viewZenithAngleRadians, viewAzimuthAngleRadians,
      sunZenithAngleRadians, sunAzimuthAngleRadians, exposure) {
    this.viewDistanceMeters = viewDistanceMeters;
    this.viewZenithAngleRadians = viewZenithAngleRadians;
    this.viewAzimuthAngleRadians = viewAzimuthAngleRadians;
    this.sunZenithAngleRadians = sunZenithAngleRadians;
    this.sunAzimuthAngleRadians = sunAzimuthAngleRadians;
    this.exposure = exposure;
  }

  onMouseDown(event) {
    this.previousMouseX = event.offsetX;
    this.previousMouseY = event.offsetY;
    this.drag = event.ctrlKey ? 'sun' : 'camera';
  }

  onMouseMove(event) {
    const kScale = 500;
    const mouseX = event.offsetX;
    const mouseY = event.offsetY;
    if (this.drag == 'sun') {
      this.sunZenithAngleRadians -= (this.previousMouseY - mouseY) / kScale;
      this.sunZenithAngleRadians =
        Math.max(0, Math.min(Math.PI, this.sunZenithAngleRadians));
      this.sunAzimuthAngleRadians += (this.previousMouseX - mouseX) / kScale;
    } else if (this.drag == 'camera') {
      this.viewZenithAngleRadians += (this.previousMouseY - mouseY) / kScale;
      this.viewZenithAngleRadians =
          Math.max(0, Math.min(Math.PI / 2, this.viewZenithAngleRadians));
      this.viewAzimuthAngleRadians += (this.previousMouseX - mouseX) / kScale;
    }
    this.previousMouseX = mouseX;
    this.previousMouseY = mouseY;
  }

  onMouseUp(event) {
    this.drag = undefined;
  }

  onMouseWheel(event) {
    this.viewDistanceMeters *= event.deltaY > 0 ? 1.05 : 1 / 1.05;
  }
}

The Utils class used above provides 4 methods, to load shader and texture data using XML http requests, and to create WebGL shader and texture objects from them:

class Utils {

  static loadShaderSource(shaderName, callback) {
    const xhr = new XMLHttpRequest();
    xhr.open('GET', shaderName);
    xhr.responseType = 'text';
    xhr.onload = (event) => callback(xhr.responseText.trim());
    xhr.send();
  }

  static createShader(gl, type, source) {
    const shader = gl.createShader(type);
    gl.shaderSource(shader, source);
    gl.compileShader(shader);
    return shader;
  }

  static loadTextureData(textureName, callback) {
    const xhr = new XMLHttpRequest();
    xhr.open('GET', textureName);
    xhr.responseType = 'arraybuffer';
    xhr.onload = (event) => {
      const data = new DataView(xhr.response);
      const array =
          new Float32Array(data.byteLength / Float32Array.BYTES_PER_ELEMENT);
      for (var i = 0; i < array.length; ++i) {
        array[i] = data.getFloat32(i * Float32Array.BYTES_PER_ELEMENT, true);
      }
      callback(array);
    };
    xhr.send();
  }

  static createTexture(gl, textureUnit, target) {
    const texture = gl.createTexture();
    gl.activeTexture(textureUnit);
    gl.bindTexture(target, texture);
    gl.texParameteri(target, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
    gl.texParameteri(target, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
    gl.texParameteri(target, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
    gl.texParameteri(target, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
    return texture;
  }
}