Next, we'll terraform our sphere into a globe of the Earth by rendering a texture onto the surface of the sphere.
Shaders can get quite complex, implementing all kinds of specular highlights, reflections, shadows, and so on. A simpler algorithm that still makes use of a color texture and lighting is a diffuse material. This is what we'll use here. The word diffuse refers to the fact that light diffuses across the surface, as opposed to being reflective or shiny (specular lighting).
A texture is just an image file (for example, .jpg
) that can be mapped (projected) onto a geometric surface. Since a sphere isn't easily flattened or unpeeled into a two-dimensional map (as centuries of cartographers can attest), the texture image will look distorted. The following is the texture we'll use for the Earth. (A copy of this file is provided with the download files for this book and similar ones can be found on the Internet at http://www.solarsystemscope.com/nexus/textures/):
res/drawable
folder. If necessary, create this folder now.earth_tex.png
file to it.The earth_tex
texture is shown in the following image:
We now need a function to load the texture into our app. We can add it to MainActivity
. Or, you can add it directly to the RenderObject
class of your RenderBox
lib. (It's fine in MainActivity
for now, and we'll move it along with our other extensions to the library at the end of this chapter.) Add the code, as follows:
public static int loadTexture(final int resourceId){ final int[] textureHandle = new int[1]; GLES20.glGenTextures(1, textureHandle, 0); if (textureHandle[0] != 0) { final BitmapFactory.Options options = new BitmapFactory.Options(); options.inScaled = false; // No pre-scaling // Read in the resource final Bitmap bitmap = BitmapFactory.decodeResource(RenderBox.instance.mainActivity.getResources(), resourceId, options); // Bind to the texture in OpenGL GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureHandle[0]); // Set filtering GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_NEAREST); GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_NEAREST); // Load the bitmap into the bound texture. GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0); // Recycle the bitmap, since its data has been loaded // into OpenGL. bitmap.recycle(); } if (textureHandle[0] == 0) { throw new RuntimeException("Error loading texture."); } return textureHandle[0]; }
The loadTexture
method returns an integer handle that can be used to reference the loaded texture data.
As you may now be familiar, we are going to create a new Material
, which uses new shaders. We'll write the shaders now. Create the two files in the res/raw
folder named diffuse_lighting_vertex.shader
and diffuse_lighting_fragment.shader
, and define them as follows.
File: res/raw/diffuse_lighting_vertex.shader
uniform mat4 u_MVP; uniform mat4 u_MV; attribute vec4 a_Position; attribute vec3 a_Normal; attribute vec2 a_TexCoordinate; varying vec3 v_Position; varying vec3 v_Normal; varying vec2 v_TexCoordinate; void main() { // vertex in eye space v_Position = vec3(u_MV * a_Position); // pass through the texture coordinate. v_TexCoordinate = a_TexCoordinate; // normal's orientation in eye space v_Normal = vec3(u_MV * vec4(a_Normal, 0.0)); // final point in normalized screen coordinates gl_Position = u_MVP * a_Position; }
File: res/raw/diffuse_lighting_fragment.shader
precision highp float; // default high precision for floating point ranges of the planets uniform vec3 u_LightPos; // light position in eye space uniform vec4 u_LightCol; uniform sampler2D u_Texture; // the input texture varying vec3 v_Position; varying vec3 v_Normal; varying vec2 v_TexCoordinate; void main() { // distance for attenuation. float distance = length(u_LightPos - v_Position); // lighting direction vector from the light to the vertex vec3 lightVector = normalize(u_LightPos - v_Position); // dot product of the light vector and vertex normal. // If the normal and light vector are // pointing in the same direction then it will get max // illumination. float diffuse = max(dot(v_Normal, lightVector), 0.01); // Add a tiny bit of ambient lighting (this is outerspace) diffuse = diffuse + 0.025; // Multiply the color by the diffuse illumination level and // texture value to get final output color gl_FragColor = texture2D(u_Texture, v_TexCoordinate) * u_LightCol * diffuse; }
These shaders add attributes to a light source and utilize geometry normal vectors on the vertices to calculate the shading. You might have noticed that the difference between this and the solid color shader is the use of texture2D, which is a
sampler function. Also, note that we declared u_Texture
as sampler2D. This variable type and function make use of the texture units, which are built into the GPU hardware, and can be used with UV coordinates to return the color values from a texture image. There are a fixed number of texture units, depending on graphics hardware. You can query the number of texture units using OpenGL. A good rule of thumb for mobile GPUs is to expect eight texture units. This means that any shader may use up to eight textures simultaneously.
Now we can write a Material
to use a texture and shaders. In the materials/
folder, create a new Java class, DiffuseLightingMaterial
, as follows:
public class DiffuseLightingMaterial extends Material { private static final String TAG = "diffuselightingmaterial";
Add the variables for the texture ID, program references, and buffers, as shown in the following code:
int textureId; static int program = -1; //Initialize to a totally invalid value for setup state static int positionParam; static int texCoordParam; static int textureParam; static int normalParam; static int MVParam; static int MVPParam; static int lightPosParam; static int lightColParam; FloatBuffer vertexBuffer; FloatBuffer texCoordBuffer; FloatBuffer normalBuffer; ShortBuffer indexBuffer; int numIndices;
Now we can add a constructor, which sets up the shader program and loads the texture for the given resource ID, as follows:
public DiffuseLightingMaterial(int resourceId){ super(); setupProgram(); this.textureId = MainActivity.loadTexture(resourceId); }
As we've seen earlier, the setupProgram
method creates the shader program and obtains references to its parameters:
public static void setupProgram(){ //Already setup? if (program != -1) return; //Create shader program program = createProgram(R.raw.diffuse_lighting_vertex, R.raw.diffuse_lighting_fragment); RenderBox.checkGLError("Diffuse Texture Color Lighting shader compile"); //Get vertex attribute parameters positionParam = GLES20.glGetAttribLocation(program, "a_Position"); normalParam = GLES20.glGetAttribLocation(program, "a_Normal"); texCoordParam = GLES20.glGetAttribLocation(program, "a_TexCoordinate"); //Enable them (turns out this is kind of a big deal ;) GLES20.glEnableVertexAttribArray(positionParam); GLES20.glEnableVertexAttribArray(normalParam); GLES20.glEnableVertexAttribArray(texCoordParam); //Shader-specific parameters textureParam = GLES20.glGetUniformLocation(program, "u_Texture"); MVParam = GLES20.glGetUniformLocation(program, "u_MV"); MVPParam = GLES20.glGetUniformLocation(program, "u_MVP"); lightPosParam = GLES20.glGetUniformLocation(program, "u_LightPos"); lightColParam = GLES20.glGetUniformLocation(program, "u_LightCol"); RenderBox.checkGLError("Diffuse Texture Color Lighting params"); }
Likewise, we add a setBuffers
method that is called by the RenderObject
component (Sphere
):
public void setBuffers(FloatBuffer vertexBuffer, FloatBuffer normalBuffer, FloatBuffer texCoordBuffer, ShortBuffer indexBuffer, int numIndices){ //Associate VBO data with this instance of the material this.vertexBuffer = vertexBuffer; this.normalBuffer = normalBuffer; this.texCoordBuffer = texCoordBuffer; this.indexBuffer = indexBuffer; this.numIndices = numIndices; }
Lastly, add the draw
code, which will be called from the Camera
component, to render the geometry prepared in the buffers (via setBuffers
). The draw
method looks like this:
@Override public void draw(float[] view, float[] perspective) { GLES20.glUseProgram(program); // Set the active texture unit to texture unit 0. GLES20.glActiveTexture(GLES20.GL_TEXTURE0); // Bind the texture to this unit. GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId); // Tell the texture uniform sampler to use this texture in // the shader by binding to texture unit 0. GLES20.glUniform1i(textureParam, 0); //Technically, we don't need to do this with every draw //call, but the light could move. //We could also add a step for shader-global parameters //which don't vary per-object GLES20.glUniform3fv(lightPosParam, 1, RenderBox.instance.mainLight.lightPosInEyeSpace, 0); GLES20.glUniform4fv(lightColParam, 1, RenderBox.instance.mainLight.color, 0); Matrix.multiplyMM(modelView, 0, view, 0, RenderObject.lightingModel, 0); // Set the ModelView in the shader, used to calculate // lighting GLES20.glUniformMatrix4fv(MVParam, 1, false, modelView, 0); Matrix.multiplyMM(modelView, 0, view, 0, RenderObject.model, 0); Matrix.multiplyMM(modelViewProjection, 0, perspective, 0, modelView, 0); // Set the ModelViewProjection matrix for eye position. GLES20.glUniformMatrix4fv(MVPParam, 1, false, modelViewProjection, 0); //Set vertex attributes GLES20.glVertexAttribPointer(positionParam, 3, GLES20.GL_FLOAT, false, 0, vertexBuffer); GLES20.glVertexAttribPointer(normalParam, 3, GLES20.GL_FLOAT, false, 0, normalBuffer); GLES20.glVertexAttribPointer(texCoordParam, 2, GLES20.GL_FLOAT, false, 0, texCoordBuffer); GLES20.glDrawElements(GLES20.GL_TRIANGLES, numIndices, GLES20.GL_UNSIGNED_SHORT, indexBuffer); RenderBox.checkGLError("Diffuse Texture Color Lighting draw"); } }
Comparing this with the SolidColorLightingMaterial
class that we defined earlier, you will notice that it's quite similar. We've replaced the single color with a texture ID, and we've added the requirements for a texture coordinate buffer (texCoordBuffer
) given by a Sphere
component. Also, note that we are setting the active texture unit to GL_TEXTURE0
and binding the texture.
To add the new material to the Sphere
component, we'll make an alternative constructor that receives a texture handle. It then creates an instance of the DiffuseLightingMaterial
class and sets the buffers from the sphere.
Let's add the material to the Sphere
component by defining a new constructor (Sphere
) that takes the texture ID and calls a new helper method named createDiffuseMaterial
, as follows:
public Sphere(int textureId){ super(); allocateBuffers(); createDiffuseMaterial(textureId); } public Sphere createDiffuseMaterial(int textureId){ DiffuseLightingMaterial mat = new DiffuseLightingMaterial(textureId); mat.setBuffers(vertexBuffer, normalBuffer, texCoordBuffer, indexBuffer, numIndices); material = mat; return this; }
Now, we can use the textured material.
To add the Earth texture to our sphere, modify the setup
method of MainActivity
to specify the texture resource ID instead of a color, as follows:
@Override public void setup() { sphere = new Transform(); sphere.addComponent(new Sphere(R.drawable.earth_tex)); sphere.setLocalPosition(2.0f, -2.f, -2.0f); }
There you have it, Home Sweet Home!
That looks really cool. Oops, it's upside down! Although there's not really a specific up versus down in outer space, our Earth looks upside down from what we're used to seeing. Let's flip it in the setup
method so that it starts at the correct orientation, and while we're at it, let's take advantage of the fact that the Transform
methods return themselves, so we can chain the calls, as follows:
public void setup() { sphere = new Transform() .setLocalPosition(2.0f, -2.f, -2.0f) .rotate(0, 0, 180f) .addComponent(new Sphere(R.drawable.earth_tex)); }
Naturally, the Earth is supposed to spin. Let's animate it to rotate it like we'd expect the Earth to do. Add this to the preDraw
method, which gets called before each new frame. It uses the Time
class's getDeltaTime
method, which returns the current fraction of a second change since the previous frame. If we want it to rotate, say, -10 degrees per second, we use -10 * deltaTime:
public void preDraw() { float dt = Time.getDeltaTime(); sphere.rotate( 0, -10f * dt, 0); }
That looks good to me! How about you?
One more thing. We seem to be looking at the Earth in line with the light source. Let's move the camera view so that we can see the Earth from the side. That way, we can see the lighted shading better.
Suppose we leave the light source position at the origin, (0,0,0) as if it were the Sun at the center of the Solar System. The Earth is 147.1 million km from the Sun. Let's place the sphere that many units to the right of the origin, and place the camera at the same relative position. Now, the setup
method looks like the following code:
public void setup() { sphere = new Transform() .setLocalPosition(147.1f, 0, 0) .rotate(0, 0, 180f) .addComponent(new Sphere(R.drawable.earth_tex)); RenderBox.mainCamera.getTransform().setLocalPosition(147.1f, 2f, 2f); }
Run it and this is what you will see:
Does that look virtually realistic or what? NASA would be proud!