Skip to main content

Computer Graphics and Multimedia: Assignment 2

Computer Graphics and Multimedia
Assignment 2
    • Notifications
    • Privacy
  • Project HomeComputer Graphics and Multimedia
  • Projects
  • Learn more about Manifold

Notes

Show the following:

  • Annotations
  • Resources
Search within:

Adjust appearance:

  • font
    Font style
  • color scheme
  • Margins
table of contents
  1. Module 1
    1. Introduction to Computer Graphics with WebGL
    2. Assignment 1
    3. Assignment 2
  2. Module 2
    1. Working with WebGL and JavaScript
    2. Assignment 1
    3. Assignment 2
  3. Module 3
    1. Animation and Geometric Transformations
    2. Assignment 1
    3. Assignment 2
  4. Module 4
    1. Viewing and Projections
    2. Assignment 1
    3. Assignment 2
  5. Module 5
    1. Lighting and Shading
    2. Assignment 1
    3. Assignment 2
  6. Module 6
    1. Texture Mapping and Matrix Stacks
    2. Assignment 1
    3. Assignment 2
  7. Module 7
    1. Skyboxes and Shadow Maps
    2. Assignment 1
    3. Assignment 2
  8. Module 8
    1. Modeling and Hierarchy - Building Scenes
    2. Assignment 1
    3. Assignment 2

CS 4722 - Computer Graphics and Multimedia

Module #5, Assignment #2


Exercise #1

Modify the given TetraToSphere application so that it becomes the TetraToSphereWithLight application. Right now the program creates a tetrahedron drawn with a wire frame that you can see through. Your first task will be to give it opaque sides with color, and allow it to increase the subdivisions so that it becomes a sphere shape like so:

Next, make it become a true lighted sphere with proper reflections on its various normal surfaces, and with the ability to change its color like so:

You must also add a keypress handler to role the shape in place horizontally and vertically using the arrow keys, and the WASD keys.

And, lastly, you must add animation to the light source's location so that it's x-position moves back and forth from x = -5.0 to x = +5.0.

The partial HTML code is given here: TetraToSphere.html.

The partial Javascript code is given here: TetraToSphere.js.


To accomplish these tasks, here are some of the things you will need to do (along with setting up your buttons and your list box input HTML code). In your Vertex Shader you will need to put in the following light rendering code:


    attribute vec4 vPosition;
    attribute vec3 vNormal;
    varying vec4 fColor;

    uniform vec4 ambientProduct, diffuseProduct, specularProduct;
    uniform mat4 modelViewMatrix;
    uniform mat4 projectionMatrix;
    uniform vec4 lightPosition;
    uniform float shininess;

    void main()
    {
        vec3 pos = -(modelViewMatrix * vPosition).xyz;

        //fixed light postion
        vec3 light = lightPosition.xyz;
        vec3 L = normalize( light - pos );

        vec3 E = normalize( -pos );
        vec3 H = normalize( L + E );

        vec4 NN = vec4(vNormal,0);

        // Transform vertex normal into eye coordinates

        vec3 N = normalize( (modelViewMatrix*NN).xyz);

        // Compute terms in the illumination equation
        vec4 ambient = ambientProduct;

        float Kd = max( dot(L, N), 0.0 );
        vec4  diffuse = Kd*diffuseProduct;

        float Ks = pow( max(dot(N, H), 0.0), shininess );
        vec4  specular = Ks * specularProduct;

        if( dot(L, N) < 0.0 ) {
            specular = vec4(0.0, 0.0, 0.0, 1.0);
        }

        gl_Position = projectionMatrix * modelViewMatrix * vPosition;
        fColor = ambient + diffuse +specular;
        fColor.a = 1.0;
    }


Your Fragment Shader will need to be modified so that it declares a varying vec4 field called fColor and in the main() method it sets the gl_FragColor value to fColor.


Then, in your JavaScript code, you will need to declare the following new fields:


var theta = 0.0;
var phi = 0.0;

var numTimesToSubdivide = 0;

var normalsArray = [];

var lightPosition = vec4(1.0, 1.0, 1.0, 0.0);
var lightAmbient = vec4(0.2, 0.2, 0.2, 1.0);
var lightDiffuse = vec4(1.0, 1.0, 1.0, 1.0);
var lightSpecular = vec4(1.0, 1.0, 1.0, 1.0);

var materialAmbient = [
    vec4(1.0, 0.0, 0.0, 0.0),
    vec4(1.0, 1.0, 0.0, 0.0),
    vec4(0.0, 1.0, 0.0, 0.0),
    vec4(0.0, 0.0, 1.0, 0.0),
    vec4(1.0, 0.0, 1.0, 0.0),
    vec4(0.0, 1.0, 1.0, 0.0)
];

var materialDiffuse = [
    vec4(0.8, 0.0, 0.0, 0.0),
    vec4(1.0, 0.8, 0.0, 0.0),
    vec4(0.0, 0.8, 0.0, 0.0),
    vec4(0.0, 0.0, 0.8, 0.0),
    vec4(1.0, 0.0, 0.8, 0.0),
    vec4(0.0, 1.0, 0.8, 0.0)
];

var materialSpecular = [
    vec4(0.8, 0.0, 0.0, 0.0),
    vec4(1.0, 0.8, 0.0, 0.0),
    vec4(0.0, 0.8, 0.0, 0.0),
    vec4(0.0, 0.0, 0.8, 0.0),
    vec4(1.0, 0.0, 0.8, 0.0),
    vec4(0.0, 1.0, 0.8, 0.0)
];

var materialShininess = 100.0;

var ambientProduct;
var diffuseProduct;
var specularProduct;

var ambientLoc;
var diffuseLoc;
var specularLoc;
var lightLoc;
var shininessLoc;

var colorIndex = 0;


You will then need to initialize and set up buffers for these fields in your init() method:


    var nBuffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, nBuffer);
    gl.bufferData(gl.ARRAY_BUFFER, flatten(normalsArray), gl.STATIC_DRAW);

    var vNormal = gl.getAttribLocation(program, "vNormal");
    gl.vertexAttribPointer(vNormal, 3, gl.FLOAT, false, 0, 0);
    gl.enableVertexAttribArray(vNormal);

    ambientProduct = mult(lightAmbient, materialAmbient[colorIndex]);
    diffuseProduct = mult(lightDiffuse, materialDiffuse[colorIndex]);
    specularProduct = mult(lightSpecular, materialSpecular[colorIndex]);

    ambientLoc = gl.getUniformLocation(program, "ambientProduct");
    diffuseLoc = gl.getUniformLocation(program, "diffuseProduct");
    specularLoc = gl.getUniformLocation(program, "specularProduct");
    lightLoc = gl.getUniformLocation(program, "lightPosition");
    shininessLoc = gl.getUniformLocation(program, "shininess");

    gl.uniform4fv(ambientLoc, flatten(ambientProduct));
    gl.uniform4fv(diffuseLoc, flatten(diffuseProduct));
    gl.uniform4fv(specularLoc, flatten(specularProduct));
    gl.uniform4fv(lightLoc, flatten(lightPosition));
    gl.uniform1f(shininessLoc, materialShininess);


In addition to this, in your init() method you will need to add handlers for the "Increase Subdivisions" and "Decrease Subdivisions" buttons that works the same way they worked in Mod 5 Assignment 1 Exercise 2, limiting the number of subdivisions to 6.


You will also need a handler for your color list box. If your ID for that list box was "colors", then that handler would be something like the following:

    document.getElementById("colors").onchange =
        function (event) {
            colorIndex = event.target.value;

            ambientProduct = mult(lightAmbient, materialAmbient[colorIndex]);
            diffuseProduct = mult(lightDiffuse, materialDiffuse[colorIndex]);
            specularProduct = mult(lightSpecular, materialSpecular[colorIndex]);

            gl.uniform4fv(ambientLoc, flatten(ambientProduct));
            gl.uniform4fv(diffuseLoc, flatten(diffuseProduct));
            gl.uniform4fv(specularLoc, flatten(specularProduct));
        };


And the last handler you will need is the key handler for your arrow keys and your WASD keys. That handler will have the following structure:

    document.addEventListener("keydown",
        function (event) {
            if (event.keyCode == 65 || event.keyCode == 37) {   // A or Left
            }

            if (event.keyCode == 68 || event.keyCode == 39) {   // D or Right
            }

            if (event.keyCode == 87 || event.keyCode == 38) {   // W or Up
            }

            if (event.keyCode == 83 || event.keyCode == 40) {   // S or Down
            }
        }, false);


In your triangle(a, b, c) method, you will now need to compute the normals for the normalsArray using the cross product of two sides of the triangle to get the normal. You do this by adding the following code to the end of that method:

    var t1 = subtract(a, b);
    var t2 = subtract(a, c);
    var normal = cross(t1, t2);

    normalsArray.push(normal);
    normalsArray.push(normal);
    normalsArray.push(normal);


And for your render() method you will need the following code that normalizes theta and phi, and then computes the correct up vector based on the value of phi:

    if (theta < 0)
        theta += 2 * Math.PI;
    if (theta > 2 * Math.PI)
        theta -= 2 * Math.PI;
    if (phi < 0)
        phi += 2 * Math.PI;
    if (phi > 2 * Math.PI)
        phi -= 2 * Math.PI;

    if (phi > Math.PI / 2 && phi < 3 * Math.PI / 2) {
        up = vec3(0.0, -1.0, 0.0);
    }
    else {
        up = vec3(0.0, 1.0, 0.0);
    }


The other changes that you need to make to the render() method are the same as those you made in Mod 5 Assignment 1 Exercise 2, however, this time you will not be showing a black mesh, so be sure to remove the "gl.LINE_LOOP" code that draws the mesh lines.

And finally, to animate the light source's x position to move it back and forth from -5.0 to 5.0, add code to your render() method that either adds 0.1 to lightPosition[0] when it is moving to the right, or subtracts 0.1 from lightPosition[0] when it is moving to the left. Then when the value of lightPosition[0] is greater than or equal to 5.0, switch direction. Or when the value of lightPosition[0] is less than or equal to -5.0, switch direction. Note that this means you will need to store a boolean variable to determine in what direction the light source is moving. And also note that whenever you modify the lightPosition value, you need to call "gl.uniform4fv(lightLoc,flatten(lightPosition));" to update its associated uniform variable.



Exercise #2

Create a program called Teapot2 that allows you to shade the surface of the Utah Teapot with different colors. This time you will make the light source rotate around the teapot continually, making a full rotation every 3 to 5 seconds. Also, add a keypress handler to rotate your camera eye back and forth with arrow keys, and WASD keys through the XZ plane and through the Y axis, instead of using buttons this time. However, the camera eye should not change where the light source is shining on the teapot. As you move the eye, keep the light source rotating around the teapot in its normal location relative to the teapot, making a full rotation every 3 to 5 seconds.

You'll also add a slider to change the number of subdivisions on the teapot from 1 to 20, and a dropdown menu to allow you to change the color of the Teapot:

The partial HTML code is given here: Teapot1.html.

The partial JavaScript code in three parts is given here: Teapot1.js, vertices.js, patches.js.


To accomplish these tasks, you will need to replace your Vertex Shader code with the following:



    attribute vec4 vPosition;
    attribute vec4 vNormal;
    varying vec4 fColor;

    uniform vec4 ambientProduct, diffuseProduct, specularProduct;
    uniform mat4 modelViewMatrix;
    uniform mat4 projectionMatrix;
    uniform vec4 lightPosition;
    uniform float shininess;

    void main()
    {

        vec3 pos = -(modelViewMatrix * vPosition).xyz;
        vec3 light = lightPosition.xyz;
        vec3 L = normalize( light - pos );
        vec3 E = normalize( -pos );
        vec3 H = normalize( L + E );

        // Transform vertex normal into eye coordinates
        vec3 N = normalize( (modelViewMatrix*vNormal).xyz);

        // Compute terms in the illumination equation
        vec4 ambient = ambientProduct;

        float Kd = max( dot(L, N), 0.0 );
        vec4  diffuse = Kd*diffuseProduct;

        float Ks = pow( max(dot(N, H), 0.0), shininess );
        vec4  specular = Ks * specularProduct;
    
        if( dot(L, N) < 0.0 ) {
            specular = vec4(0.0, 0.0, 0.0, 1.0);
        } 

        gl_Position = projectionMatrix * modelViewMatrix * vPosition;
    
        fColor = ambient + diffuse +specular;
        fColor.a = 1.0;    
    } 


Next, add the "varying vec4 fColor;" declaration to your Fragment Shader code, and change "gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);" to "gl_FragColor = fColor;" in the main() method.


Next, you will need to add the sliders and the color list box shown above to your HTML code in the same way we have added similar controls to previous programs we have written. And then add handlers for these controls to your JavaScript code that will modify the appropriate fields and do the required updates.


An important thing that you should note is that in your JavaScript code some of your attribute and field names are going to need some minor revisions. The first thing you need to do is make sure that all references to "modelView" now become "modelViewMatrix", and make sure the "m" in "model" is never capitalized. This means that "ModelView" should become "modelViewMatrix" and "modelViewLoc" should become "modelViewMatrixLoc". These name changes are necessary to keep naming conventions consistent.

Next, in a similar way, change all references to "projection" so they become "projectionMatrix". So the name "Projection" would become "projectionMatrix" with a lowercase "p", and "projectionLoc" would become "projectionMatrixLoc".


Next, you will also need to declare the following new fields in your JavaScript code:



var normals = [];

var lightPosition = vec4(0.0, 0.0, 5.0, 0.0);   // move it out of scene
var lightAmbient = vec4(0.2, 0.2, 0.2, 1.0);
var lightDiffuse = vec4(1.0, 1.0, 1.0, 1.0);
var lightSpecular = vec4(1.0, 1.0, 1.0, 1.0);

var materialAmbient = [
    vec4(1.0, 0.0, 0.0, 1.0),
    vec4(1.0, 1.0, 0.0, 1.0),
    vec4(0.0, 1.0, 0.0, 1.0),
    vec4(0.0, 0.0, 1.0, 1.0),
    vec4(1.0, 0.0, 1.0, 1.0),
    vec4(0.0, 1.0, 1.0, 1.0)
];

var materialDiffuse = [
    vec4(0.8, 0.0, 0.0, 1.0),
    vec4(1.0, 0.8, 0.0, 1.0),
    vec4(0.0, 0.8, 0.0, 1.0),
    vec4(0.0, 0.0, 0.8, 1.0),
    vec4(1.0, 0.0, 0.8, 1.0),
    vec4(0.0, 1.0, 0.8, 1.0)
];

var materialSpecular = [
    vec4(0.8, 0.0, 0.0, 1.0),
    vec4(1.0, 0.8, 0.0, 1.0),
    vec4(0.0, 0.8, 0.0, 1.0),
    vec4(0.0, 0.0, 0.8, 1.0),
    vec4(1.0, 0.0, 0.8, 1.0),
    vec4(0.0, 1.0, 0.8, 1.0)
];

var materialShininess = 100.0;

var ambientProduct;
var diffuseProduct;
var specularProduct;

var ambientProductLoc;
var diffuseProductLoc;
var specularProductLoc;

var lightPositionLoc;
var materialShininessLoc;

var cindex = 1;

var theta = 0.0;
var phi = 0.0;

const at = vec3(0.0, 0.0, 0.0);
var up = vec3(0.0, 1.0, 0.0);
var eye;


You will also need to initialize and set up buffers for these fields in your init() method, but before you do that, let's organize the code in your init() method a little better by removing and replacing the following lines:



    var h = 1.0 / numDivisions;

    patch = new Array(numTeapotPatches);
    for (var i = 0; i < numTeapotPatches; i++) patch[i] = new Array(16);
    for (var i = 0; i < numTeapotPatches; i++)
        for (j = 0; j < 16; j++) {
            patch[i][j] = vec4([vertices[indices[i][j]][0],
             vertices[indices[i][j]][2],
                vertices[indices[i][j]][1], 1.0]);
        }


    for (var n = 0; n < numTeapotPatches; n++) {

        var data = new Array(numDivisions + 1);
        for (var j = 0; j <= numDivisions; j++) data[j] = new Array(numDivisions + 1);
        for (var i = 0; i <= numDivisions; i++) for (var j = 0; j <= numDivisions; j++) {
            data[i][j] = vec4(0, 0, 0, 1);
            var u = i * h;
            var v = j * h;
            var t = new Array(4);
            for (var ii = 0; ii < 4; ii++) t[ii] = new Array(4);
            for (var ii = 0; ii < 4; ii++) for (var jj = 0; jj < 4; jj++)
                t[ii][jj] = bezier(u)[ii] * bezier(v)[jj];


            for (var ii = 0; ii < 4; ii++) for (var jj = 0; jj < 4; jj++) {
                temp = vec4(patch[n][4 * ii + jj]);
                temp = scale(t[ii][jj], temp);
                data[i][j] = add(data[i][j], temp);
            }
        }

        for (var i = 0; i < numDivisions; i++) for (var j = 0; j < numDivisions; j++) {
            points.push(data[i][j]);
            points.push(data[i + 1][j]);
            points.push(data[i + 1][j + 1]);
            points.push(data[i][j]);
            points.push(data[i + 1][j + 1]);
            points.push(data[i][j + 1]);
            index += 6;
        }
    }


Once these lines are removed, replace them with the following lines:

    gl.enable(gl.DEPTH_TEST);

    buildTeapot();

Then below your init() method, add the following two new methods:



function buildTeapot() {
    points = [];
    normals = [];
    
    var h = 1.0 / numDivisions;
    var patch = new Array(numTeapotPatches);
    for (var i = 0; i < numTeapotPatches; i++)
        patch[i] = new Array(16);
    for (var i = 0; i < numTeapotPatches; i++)
        for (j = 0; j < 16; j++) {
            patch[i][j] = vec4([vertices[indices[i][j]][0],
                            vertices[indices[i][j]][2],
                              vertices[indices[i][j]][1], 1.0]);
        }

    for (var n = 0; n < numTeapotPatches; n++) {
        var data = new Array(numDivisions + 1);
        for (var j = 0; j <= numDivisions; j++)
            data[j] = new Array(numDivisions + 1);
        for (var i = 0; i <= numDivisions; i++)
            for (var j = 0; j <= numDivisions; j++) {
                data[i][j] = vec4(0, 0, 0, 1);
                var u = i * h;
                var v = j * h;
                var t = new Array(4);
                for (var ii = 0; ii < 4; ii++)
                    t[ii] = new Array(4);
                for (var ii = 0; ii < 4; ii++)
                    for (var jj = 0; jj < 4; jj++)
                        t[ii][jj] = bezier(u)[ii] * bezier(v)[jj];

                for (var ii = 0; ii < 4; ii++) for (var jj = 0; jj < 4; jj++) {
                    var temp = vec4(patch[n][4 * ii + jj]);
                    temp = scale(t[ii][jj], temp);
                    data[i][j] = add(data[i][j], temp);
                }
            }

        for (var i = 0; i < numDivisions; i++)
            for (var j = 0; j < numDivisions; j++) {
                points.push(data[i][j]);
                points.push(data[i + 1][j]);
                points.push(data[i + 1][j + 1]);
                points.push(data[i][j]);
                points.push(data[i + 1][j + 1]);
                points.push(data[i][j + 1]);

                var t1 = subtract(data[i + 1][j], data[i][j]);
                var t2 = subtract(data[i + 1][j + 1], data[i][j]);
                var normal = cross(t1, t2);
                normal[3] = 0;

                normals.push(normal);
                normals.push(normal);
                normals.push(normal);
                normals.push(normal);
                normals.push(normal);
                normals.push(normal);
            }
    }
}

function bezier(u) {
    var b = new Array(4);
    var a = 1 - u;
    b[3] = a * a * a;
    b[2] = 3 * a * a * u;
    b[1] = 3 * a * u * u;
    b[0] = u * u * u;
    return b;
}


Next, go back inside of your init() method, and add the following lines above the "render();" line at the end of your init() method:



    var nBuffer = gl.createBuffer();
    gl.bindBuffer( gl.ARRAY_BUFFER, nBuffer );
    gl.bufferData( gl.ARRAY_BUFFER, flatten(normals), gl.STATIC_DRAW );
    
    var vNormal = gl.getAttribLocation( program, "vNormal" );
    gl.vertexAttribPointer( vNormal, 4, gl.FLOAT, false, 0, 0 );
    gl.enableVertexAttribArray( vNormal );
        
    ambientProduct = mult(lightAmbient, materialAmbient[cindex]);
    diffuseProduct = mult(lightDiffuse, materialDiffuse[cindex]);
    specularProduct = mult(lightSpecular, materialSpecular[cindex]);

    ambientProductLoc = gl.getUniformLocation(program, "ambientProduct");
    gl.uniform4fv(ambientProductLoc,flatten(ambientProduct));

    diffuseProductLoc = gl.getUniformLocation(program, "diffuseProduct");
    gl.uniform4fv(diffuseProductLoc, flatten(diffuseProduct));

    specularProductLoc = gl.getUniformLocation(program, "specularProduct");
    gl.uniform4fv(specularProductLoc, flatten(specularProduct));

    lightPositionLoc = gl.getUniformLocation(program, "lightPosition");
    gl.uniform4fv(lightPositionLoc , flatten(lightPosition));

    materialShininessLoc = gl.getUniformLocation(program, "shininess");
    gl.uniform1f(materialShininessLoc , materialShininess);

    document.addEventListener("keydown",
        function (event) {
            if (event.keyCode == 65 || event.keyCode == 37) {   // A or Left
            }

            if (event.keyCode == 68 || event.keyCode == 39) {   // D or Right
            }

            if (event.keyCode == 87 || event.keyCode == 38) {   // W or Up
            }

            if (event.keyCode == 83 || event.keyCode == 40) {   // S or Down
            }
        }, false);

    document.getElementById("subdivisionsID").onchange =
        function (event) {
            numDivisions = Number(event.target.value);

            // you should know what goes here
        };

    document.getElementById("colorID").onchange =
        function (event) {
            cindex = Number(event.target.value);

            // you should know what goes here
        };


Next, in your render() method you need to remove and replace the following lines:



    modelView = mat4();

    gl.uniformMatrix4fv(modelViewLoc, false, flatten(modelView));

    for (var i = 0; i < index; i += 3)
       gl.drawArrays(gl.LINE_LOOP, i, 3);


Replace these lines with the following lines that normalize theta and phi, and the angle of rotation for the light source, and that then set up the normalMatrix:



    if (theta < 0)
        theta += 2 * Math.PI;
    if (theta > 2 * Math.PI)
        theta -= 2 * Math.PI;
    if (phi < 0)
        phi += 2 * Math.PI;
    if (phi > 2 * Math.PI)
        phi -= 2 * Math.PI;

    if (phi > Math.PI / 2 && phi < 3 * Math.PI / 2) {
        up = vec3(0.0, -1.0, 0.0);
    }
    else {
        up = vec3(0.0, 1.0, 0.0);
    }

    eye = vec3(Math.sin(theta) * Math.cos(phi),
                Math.sin(phi),
                Math.cos(theta) * Math.cos(phi));

    modelViewMatrix = lookAt(eye, at, up);
    gl.uniformMatrix4fv(modelViewMatrixLoc, false, flatten(modelViewMatrix));

    // Offsetting the light to compensate for rotation of eye
    var lightPosition2 = mult(modelViewMatrix, lightPosition);
    gl.uniform4fv(lightPositionLoc, flatten(lightPosition2));

    gl.drawArrays( gl.TRIANGLES, 0, points.length);


Your last task will be to figure out how to add another angle field for your light source that will take the light through a full rotation around the teapot. Let's call that field, "lightAng". And notice that the light source is currently at position (0,0,5). That means it is at a radius of 5 from the origin, so let's create another field called "lightRad" with that value. So you need to declare these two global fields like so:

var lightAng = 0;
var lightRad = 5;

And then in your render() method right BEFORE where you are "Offsetting the light to compensate for rotation of eye", you need to add the following lines to rotate the light around the Teapot in the XZ plane:

    lightAng += 0.02;
    lightPosition[0] = lightRad * Math.sin(lightAng);
    lightPosition[2] = lightRad * Math.cos(lightAng);


After completing that your light source should now be moving and you should be all done.


Add a Comment block section to the top of your Javascript program in this assignment with the following information filled in using the following format:

/*
 * Course: CS 4722
 * Section: .....
 * Name: ......
 * Professor: ......
 * Assignment #: ......
 */


Be sure your program runs without error.

Deliverables

Turn in the files:

  • TetraToSphereWithLight.zip (with your HTML and JS files)
  • Teapot2.zip (with your HTML and JS files)

Do this by uploading the file as an attachment to this Module's assignment drop box in D2L Brightspace.

Annotate

Next Chapter
Texture Mapping and Matrix Stacks
PreviousNext
Powered by Manifold Scholarship. Learn more at
Opens in new tab or windowmanifoldapp.org