From fcaf022c43cb6c435082744d23cd490ba0309cc9 Mon Sep 17 00:00:00 2001
From: Shi Yan mega texture is also called the virtualized texture
cropped_image = resized_image.crop((x*256-padding, y*256-padding, (x+1)*256+padding,(y+1)* 256+padding))
# Save the tile into a png file.
cropped_image.save('../crab_nebula/crab_'+str(lv)+'_'+str(y)+'_'+str(x)+'.png')
- notice that for easy loading in the code, we name our tiles using this pattern notice that I have added a padding to all tiles, i.e. the actual size is 4 pixels larger than 256 along each dimension. I will explain the necessity later.5.4 Mega Texture
crab_<level>_<index y>_<index x>
. level is the detail level, y is the vertical index of all tiles of the same level, x is the horizontal index.
first, let's configure some constants. here is the relevant code:
const imageWidth = 10752;
+
notice that for easy loading in the code, we name our tiles using this pattern crab_<level>_<index y>_<index x>
. level is the detail level, y is the vertical index of all tiles of the same level, x is the horizontal index.
notice that I have added a padding to all tiles, i.e. the actual size is 4 pixels larger than 256 along each dimension. I will explain the necessity later.
first, let's configure some constants. here is the relevant code:
const imageWidth = 10752;
const imageHeight = 9216;
const tileSizeWithoutPadding = 256;
const textureSizeWithoutPadding = 2048;
@@ -206,7 +206,7 @@ 5.4 Mega Texture
mega texture is also called the virtualized texture
for (let i = 0; i < levelCount; ++i) {
overallTileCount += levelTileCount[i * 4] * levelTileCount[i * 4 + 1];
}
-
Here imageWidth and imageHeight hardcode the raw image size. textureSizeWithoutPadding holds the texture map we will use to hold visible tiles. maxVisibleTileCountOnTexture is the maximum possible visible tiles, this number is limited by the size of the texture map.
levelCount is the total number of levels. This number is the count we need to halve a tile of size 256 down to a single pixel. tileH and tileV are the horizontal and vertical tile counts on the raw image.
levelTileCount is an array recording the horizontal and vertical tile counts for all levels, plus each level's tile size on the original image. overallTileCount is the sum of the tile counts of all levels.
function keyToLevel(key) {
+
Here imageWidth and imageHeight hardcode the raw image size. textureSizeWithoutPadding holds the texture map we will use to hold visible tiles. maxVisibleTileCountOnTexture is the maximum possible visible tiles, this number is limited by the size of the texture map.
levelCount is the total number of levels. This number is the count we need to halve a tile of size 256 down to a single pixel. tileH and tileV are the horizontal and vertical tile counts on the raw image.
levelTileCount is an array recording the horizontal and vertical tile counts for all levels, plus each level's tile size on the original image. overallTileCount is the sum of the tile counts of all levels.
function keyToLevel(key) {
let keyRemain = key;
let level = 0;
@@ -230,7 +230,7 @@ 5.4 Mega Texture
mega texture is also called the virtualized texture
return y * tileH + x + base;
}
-
in our program, we will use a key value store to function like our tile cache. we need to define two function to serialize a tile's info, including a tile's horizontal and vertical coordinates and level, into a key, and conversely, we also need a function to convert a key to recover the corresponding tile info.
the key used in our program is a single index that uniquely identify a tile. if we view all the tiles on all levels as a pyramid, the way we assign an index to each tile is that we stretch the pyramid into a single string, starting from level 0 to level 8. on certain level, a tile at the location x,y (we will call them the level coordinates in the following text.) has its id calculated by tileH * y + x + the count of all tiles from levels under it.
similarly, given a key, we can recover the corresponding tile's x,y and level.
the first task we need to perform is visibility test. in the first pass of rendering, we want to determine which tiles are actually visible. let's look at the visibility test shader:
@group(0) @binding(0)
+
in our program, we will use a key value store to function like our tile cache. we need to define two function to serialize a tile's info, including a tile's horizontal and vertical coordinates and level, into a key, and conversely, we also need a function to convert a key to recover the corresponding tile info.
the key used in our program is a single index that uniquely identify a tile. if we view all the tiles on all levels as a pyramid, the way we assign an index to each tile is that we stretch the pyramid into a single string, starting from level 0 to level 8. on certain level, a tile at the location x,y (we will call them the level coordinates in the following text.) has its id calculated by tileH * y + x + the count of all tiles from levels under it.
similarly, given a key, we can recover the corresponding tile's x,y and level.
the first task we need to perform is visibility test. in the first pass of rendering, we want to determine which tiles are actually visible. let's look at the visibility test shader:
@group(0) @binding(0)
var<uniform> transform: mat4x4<f32>;
@group(0) @binding(1)
var<uniform> projection: mat4x4<f32>;
@@ -284,7 +284,7 @@ 5.4 Mega Texture
mega texture is also called the virtualized texture
discard;
return vec4(1.0,0.0,0.0,1.0);
}
-
The vertex shader is relatively simple to explain. Here we have two inputs, inPos one of the vertices of a 256x256 tile positioned at the origin and the tile's texture coordinates. loc is the tile's level coordinates, i.e. tileH and tileV. What the vertex shader does is applying an offset to the tile's vertices based on tileH and tileV, so that the tile is positioned correctly.
The vertex shader passes three types of information to the fragment shader. the clip position, the texture coordinates and the current tile's level coordinates. notice that, we have a @interpolate(flat)
decoration, because the tile coordinates are integers, we don't want the graphics pipeline performs any interpolation on them.
what bears for more explanation is the fragment shader. What this shader does is actually exactly the same as the built-in function textureSample does. The reason we have to manually implement this function is that we want to explicitly get the texture level.
an advanced concept of this shader is the derivative functions dpdx and dpdy. they are functions to measure how fast a value p changes along the x and y axis. for example, dpdx(in.tex * tileSizeWithoutPadding) means how fast the value in.tex * tileSizeWithoutPadding between two horizontal nearby fragments. as we know, the way fragment shaders work is identical to the compute shader. at the same time, there are many invocations of the fragment shader executing in parallel processing different fragments. what's conter-intuitive about these derivative functions is that only looking at a fragment alone, we can't measure the how fast the value changes. the current fragment execution needs to work with other fragment shader executions to return the value, i.e. there is thread synchronization involved in the process. previously we have learned that when thread synchronization is involved, it is required that the relevant code is in a uniform control flow. same requirements are needed for the derivative functions too.
in the chapter about mipmaps, we haven't answered the question why the textureSample function has to be in uniform control flow. now the answer should be clear, because internally textureSample also rely on the derivative functions to obtain the texture level.
but we haven't explained why measuring how fast in.tex * tileSizeWithoutPadding changes can help us get the texture level. as we can see, the in.tex is the texture coordinates, its value span from 0.0 to 1.0. and the tileSizeWithoutPadding is 256, hence tileSizeWithoutPadding times the texture coordinates, we should be in the range of [0.0, 256.0].
no looking at a single 256x256 tile parallel to the screen space. if there is no zooming at all or zooming in, the value change of tileSizeWithoutPadding*in.tex across two horizontal adjacent fragments should be less or equal to 1.0. in this scenario, we want to choose the texture level 0 = max(log2(d),0) for sampling, because only the level 0 can give us the best rendering quality. no imagine if we zoom out, the 256x256 tile will be rendered on screen in a smaller size. in this case, two adjacent fragments will have a value change faster than 1.0. but the same principle applies, we can simply get the level by max(log2(d),0). in the most extreme case, if we zoom out enough, so much so that the entire 256x256 tile is smaller or equal to a single pixel, two adjacent fragments will have a derivative larger than 256. hence we will need to sample from the log2(256)=8 level.
dx and dy may change in different speed. in the shader, we don't consider the dx and dy separately, but picking the maximum change among the two.
once we have figured out the level. the next step is obtain the key in the same manner we have seen before. The first step is getting the level coordinates x and y. This is done by first calculating the position of the current fragment on the original image, then divide the position by the level tile size. the level coordinates can give us the index of the tile on that level. tile count of each level is passed in as level_tile_count, with it, we can count the number of tiles on all levels below. and together, we can get the unique tile id for the current fragment.
finally, we need to update the visibility table var
now let's look at the corresponding javascript code that works with this shader.
const positionAttribDesc = {
+
The vertex shader is relatively simple to explain. Here we have two inputs, inPos one of the vertices of a 256x256 tile positioned at the origin and the tile's texture coordinates. loc is the tile's level coordinates, i.e. tileH and tileV. What the vertex shader does is applying an offset to the tile's vertices based on tileH and tileV, so that the tile is positioned correctly.
The vertex shader passes three types of information to the fragment shader. the clip position, the texture coordinates and the current tile's level coordinates. notice that, we have a @interpolate(flat)
decoration, because the tile coordinates are integers, we don't want the graphics pipeline performs any interpolation on them.
what bears for more explanation is the fragment shader. What this shader does is actually exactly the same as the built-in function textureSample does. The reason we have to manually implement this function is that we want to explicitly get the texture level.
an advanced concept of this shader is the derivative functions dpdx and dpdy. they are functions to measure how fast a value p changes along the x and y axis. for example, dpdx(in.tex * tileSizeWithoutPadding) means how fast the value in.tex * tileSizeWithoutPadding between two horizontal nearby fragments. as we know, the way fragment shaders work is identical to the compute shader. at the same time, there are many invocations of the fragment shader executing in parallel processing different fragments. what's conter-intuitive about these derivative functions is that only looking at a fragment alone, we can't measure the how fast the value changes. the current fragment execution needs to work with other fragment shader executions to return the value, i.e. there is thread synchronization involved in the process. previously we have learned that when thread synchronization is involved, it is required that the relevant code is in a uniform control flow. same requirements are needed for the derivative functions too.
in the chapter about mipmaps, we haven't answered the question why the textureSample function has to be in uniform control flow. now the answer should be clear, because internally textureSample also rely on the derivative functions to obtain the texture level.
but we haven't explained why measuring how fast in.tex * tileSizeWithoutPadding changes can help us get the texture level. as we can see, the in.tex is the texture coordinates, its value span from 0.0 to 1.0. and the tileSizeWithoutPadding is 256, hence tileSizeWithoutPadding times the texture coordinates, we should be in the range of [0.0, 256.0].
no looking at a single 256x256 tile parallel to the screen space. if there is no zooming at all or zooming in, the value change of tileSizeWithoutPadding*in.tex across two horizontal adjacent fragments should be less or equal to 1.0. in this scenario, we want to choose the texture level 0 = max(log2(d),0) for sampling, because only the level 0 can give us the best rendering quality. no imagine if we zoom out, the 256x256 tile will be rendered on screen in a smaller size. in this case, two adjacent fragments will have a value change faster than 1.0. but the same principle applies, we can simply get the level by max(log2(d),0). in the most extreme case, if we zoom out enough, so much so that the entire 256x256 tile is smaller or equal to a single pixel, two adjacent fragments will have a derivative larger than 256. hence we will need to sample from the log2(256)=8 level.
dx and dy may change in different speed. in the shader, we don't consider the dx and dy separately, but picking the maximum change among the two.
once we have figured out the level. the next step is obtain the key in the same manner we have seen before. The first step is getting the level coordinates x and y. This is done by first calculating the position of the current fragment on the original image, then divide the position by the level tile size. the level coordinates can give us the index of the tile on that level. tile count of each level is passed in as level_tile_count, with it, we can count the number of tiles on all levels below. and together, we can get the unique tile id for the current fragment.
finally, we need to update the visibility table var
now let's look at the corresponding javascript code that works with this shader.
const positionAttribDesc = {
shaderLocation: 0, // @location(0)
offset: 0,
format: 'float32x4'
@@ -348,7 +348,7 @@ 5.4 Mega Texture
mega texture is also called the virtualized texture
passEncoder.end();
commandEncoder.copyBufferToBuffer(tile.tileVisibilityBuffer, 0,
tile.tileVisibilityBufferRead, 0, overallTileCount * 4);
-
as we have seen in the shader, there are two vertex attributes. The position buffer holds the four vertices of a 256x256 tile. The tileLocBufferLayoutDesc holds a set of level coordinates x and y. this attribute is a per instance attribute. We will use the instancing technique to duplicate the 256x256 tile for each set of level coordinates.
the tileVisibilityBuffer contains the output array. before each round of rendering, we need to clear this buffer. hence we have tileVisibilityBufferZeros with zeros of the same size. we will use it to clear tileVisibilityBuffer. tileVisibilityBufferRead is used for result readback.
for command encoding, we will draw the tile for tileH * tileV instances. This will cover the entire image.
await tile.tileVisibilityBufferRead.mapAsync(GPUMapMode.READ, 0, overallTileCount * 4);
+
as we have seen in the shader, there are two vertex attributes. The position buffer holds the four vertices of a 256x256 tile. The tileLocBufferLayoutDesc holds a set of level coordinates x and y. this attribute is a per instance attribute. We will use the instancing technique to duplicate the 256x256 tile for each set of level coordinates.
the tileVisibilityBuffer contains the output array. before each round of rendering, we need to clear this buffer. hence we have tileVisibilityBufferZeros with zeros of the same size. we will use it to clear tileVisibilityBuffer. tileVisibilityBufferRead is used for result readback.
for command encoding, we will draw the tile for tileH * tileV instances. This will cover the entire image.
await tile.tileVisibilityBufferRead.mapAsync(GPUMapMode.READ, 0, overallTileCount * 4);
let vb = tile.tileVisibilityBufferRead.getMappedRange(0, overallTileCount * 4);
@@ -363,7 +363,7 @@ 5.4 Mega Texture
mega texture is also called the virtualized texture
await visibleTiles.assembleTexture(device, imageWidth,
imageHeight, vt);
-
After buffer submission, we read back the resulting buffer. and we extract the indices of visible tiles and hand the result to a hash table visibleTiles to assemble the actual texture map. now let's look at how the texture map is assembled:
class KeyIdManager {
+
After buffer submission, we read back the resulting buffer. and we extract the indices of visible tiles and hand the result to a hash table visibleTiles to assemble the actual texture map. now let's look at how the texture map is assembled:
class KeyIdManager {
constructor() {
this.used = new Map();
@@ -420,7 +420,7 @@ 5.4 Mega Texture
mega texture is also called the virtualized texture
return result;
}
}
-
first, let's look at a helper class called the KeyId manager. recall that our actual texture map has a size of 2048x2048, and our tile size is 256x256. hence the actual texture map can hold at most 64 tiles. we need a class to manage which ones of the 64 (maxVisibleTileCountOnTexture) spots have been occupied by visible tiles. And after a new round of visibility test, the manager will recycle invisible tiles and load visible ones into the texture map. in a more advanced version, we could implement a LRU cache, such that we will retire the least used tiles first. here, for simplicity, we always retire a tile if it is not visible.
in the class, available
is a list of available spots' ids. at the beginning, all 64 posts are available, hence we push all ids into the list. the used
variable is a map from a tile's key to its id on the texture map.
the key function is generate. The inputs are the keys of all visible tiles and a helper function that can paste a tile into the texture map.
the logic of this function is not difficult to understand. first, we load all keys into a hash table (a set). because next, we need to perform a lot existence queries. next, we visit all visible tiles of the previous round, if any has become invisible in this round, we recycle its id into the available
list. next, we visit all visible tiles of this round, if the tile was also visible in the previous round, we skip the loading step. otherwise, we utilize the texture loading utility function to paste the tile onto an available spot on the texture.
now, let's look at the implementation of the loading utility function:
async loadTileIntoTexture(device, bufferUpdate, imageWidth, imageHeight, x, y, level, tileKey, id) {
+
first, let's look at a helper class called the KeyId manager. recall that our actual texture map has a size of 2048x2048, and our tile size is 256x256. hence the actual texture map can hold at most 64 tiles. we need a class to manage which ones of the 64 (maxVisibleTileCountOnTexture) spots have been occupied by visible tiles. And after a new round of visibility test, the manager will recycle invisible tiles and load visible ones into the texture map. in a more advanced version, we could implement a LRU cache, such that we will retire the least used tiles first. here, for simplicity, we always retire a tile if it is not visible.
in the class, available
is a list of available spots' ids. at the beginning, all 64 posts are available, hence we push all ids into the list. the used
variable is a map from a tile's key to its id on the texture map.
the key function is generate. The inputs are the keys of all visible tiles and a helper function that can paste a tile into the texture map.
the logic of this function is not difficult to understand. first, we load all keys into a hash table (a set). because next, we need to perform a lot existence queries. next, we visit all visible tiles of the previous round, if any has become invisible in this round, we recycle its id into the available
list. next, we visit all visible tiles of this round, if the tile was also visible in the previous round, we skip the loading step. otherwise, we utilize the texture loading utility function to paste the tile onto an available spot on the texture.
now, let's look at the implementation of the loading utility function:
async loadTileIntoTexture(device, bufferUpdate, imageWidth, imageHeight, x, y, level, tileKey, id) {
const writeArray = new Float32Array(bufferUpdate.getMappedRange(tileKey * 2 * 4, 8));
writeArray.set([(tileSizeWithoutPadding / textureSizeWithoutPadding) * (id % (textureSizeWithoutPadding / tileSizeWithoutPadding)),
(tileSizeWithoutPadding / textureSizeWithoutPadding) * Math.floor(id / (textureSizeWithoutPadding / tileSizeWithoutPadding))]);
@@ -437,7 +437,7 @@ 5.4 Mega Texture
mega texture is also called the virtualized texture
origin: { x: (padding * 2 + tileSizeWithoutPadding) * (id % (textureSizeWithoutPadding / tileSizeWithoutPadding)), y: (padding * 2 + tileSizeWithoutPadding) * Math.floor(id / (textureSizeWithoutPadding / tileSizeWithoutPadding)) }
}, { width: tileSizeWithoutPadding + padding * 2, height: tileSizeWithoutPadding + padding * 2 });
}
-
the function accomplishes two things. first, it updates the texture lookup table. for each tile in the lookup table, there is a corresponding entry of two float numbers. the two numbers are the texture coordinates of this tile's upper left corner on the texture.
next, we use the fetch API to load the corresponding tile image into a imageBitmap. our filename comes handy now. we rely on the filename to fetch the right tile image.
finally, we utilize the copyExternalImageToTexture function to paste the imageBitmap onto the texture map. the actual tiles have paddings and so does the texture map. hence when calculating the tile's position on the texture map, we need to consider the paddings.
Next, let's look at the class VisibleTileHashTable
. This is the wrapper of everything related to texture update.
class VisibleTileHashTable {
+
the function accomplishes two things. first, it updates the texture lookup table. for each tile in the lookup table, there is a corresponding entry of two float numbers. the two numbers are the texture coordinates of this tile's upper left corner on the texture.
next, we use the fetch API to load the corresponding tile image into a imageBitmap. our filename comes handy now. we rely on the filename to fetch the right tile image.
finally, we utilize the copyExternalImageToTexture function to paste the imageBitmap onto the texture map. the actual tiles have paddings and so does the texture map. hence when calculating the tile's position on the texture map, we need to consider the paddings.
Next, let's look at the class VisibleTileHashTable
. This is the wrapper of everything related to texture update.
class VisibleTileHashTable {
constructor() {
this.texture = null;
this.tileTexCoordBuffer = null;
@@ -468,7 +468,7 @@ 5.4 Mega Texture
mega texture is also called the virtualized texture
this.tileTexCoordBufferUpdate.unmap();
}
}
-
the class contains four members. the texture, the lookup table, and an additional buffer for updating the lookup table. finally the keyIdManager.
After we perform a new round of visibility test, we will need to call the assembleTexture function to build up the new texture. this function maps the update buffer for the lookup table and pass it to other helper function we have explained above.
finally, with the visibility test done, we will run the second pass to actually render the tiles. this is accomplished by the following shader:
@group(0)
+
the class contains four members. the texture, the lookup table, and an additional buffer for updating the lookup table. finally the keyIdManager.
After we perform a new round of visibility test, we will need to call the assembleTexture function to build up the new texture. this function maps the update buffer for the lookup table and pass it to other helper function we have explained above.
finally, with the visibility test done, we will run the second pass to actually render the tiles. this is accomplished by the following shader:
@group(0)
@binding(2)
var<uniform> level_tile_count: array<vec4<u32>, 8>; //must align to 16bytes
@group(0)
@@ -502,7 +502,7 @@ 5.4 Mega Texture
mega texture is also called the virtualized texture
((y-floor(y))*tileSizeWithoutPadding + padding) * tileSizeWithoutPadding/ ((padding*2 + tileSizeWithoutPadding)*textureSizeWithoutPadding)
), 0);
}
-
this shader is very similar to the visibility test shader. in fact, the vertex shader is exactly the same, hence I omit the vertex shader here. The fragment shader is also not too different. first to notice is the hash table has become read only now. we still perform the same calculation to obtain the key of each visible tile.
the calculation looks intimidating. let's break it down. first, let's assume that there is no padding at all. x-floor(x) is the texture coordinates on the current visible tile for the current fragment. But since the current visible tile is only a part of the texture, we need to convert this local texture coordinates the the global texture coordinates on the texture.
recall that our lookup table contains the correspondence of tile keys to tiles' upper left corner texture coordinates on the texture map. hence hash[base] will give us the global texture coordinates of the upper left corner. now, the global texture coordinates can be obtained by hash[base] + local_coordinates * tile_size / texture_size;
this works when there is no padding. with padding, we need to slightly update the local coordinates. first, we times the local coordinates with tileSizeWithoutPadding to get local coordinates in pixels. Then we add the paddings to it and divide this adjusted coordinates with the tile size With coordinates to get a new local coordinates. The rest of the calculation is the same as above.
With this process, we can get the precise texture coordinates for each fragment. We use it to look up for the color value from the texture map.
The javascript code that sets up the second rendering pass is shared with the first pass. we will omit the details. but it is worth checking the navigation code.
let translateMatrix = glMatrix.mat4.lookAt(glMatrix.mat4.create(),
+
this shader is very similar to the visibility test shader. in fact, the vertex shader is exactly the same, hence I omit the vertex shader here. The fragment shader is also not too different. first to notice is the hash table has become read only now. we still perform the same calculation to obtain the key of each visible tile.
the calculation looks intimidating. let's break it down. first, let's assume that there is no padding at all. x-floor(x) is the texture coordinates on the current visible tile for the current fragment. But since the current visible tile is only a part of the texture, we need to convert this local texture coordinates the the global texture coordinates on the texture.
recall that our lookup table contains the correspondence of tile keys to tiles' upper left corner texture coordinates on the texture map. hence hash[base] will give us the global texture coordinates of the upper left corner. now, the global texture coordinates can be obtained by hash[base] + local_coordinates * tile_size / texture_size;
this works when there is no padding. with padding, we need to slightly update the local coordinates. first, we times the local coordinates with tileSizeWithoutPadding to get local coordinates in pixels. Then we add the paddings to it and divide this adjusted coordinates with the tile size With coordinates to get a new local coordinates. The rest of the calculation is the same as above.
With this process, we can get the precise texture coordinates for each fragment. We use it to look up for the color value from the texture map.
The javascript code that sets up the second rendering pass is shared with the first pass. we will omit the details. but it is worth checking the navigation code.
let translateMatrix = glMatrix.mat4.lookAt(glMatrix.mat4.create(),
glMatrix.vec3.fromValues(0, 0, 10), glMatrix.vec3.fromValues(0, 0, 0), glMatrix.vec3.fromValues(0.0, 1.0, 0.0));
let orthProjMatrix = glMatrix.mat4.ortho(glMatrix.mat4.create(), canvas.width * -0.5 * scale, canvas.width * 0.5 * scale, canvas.height * 0.5 * scale, canvas.height * -0.5 * scale, -1000.0, 1000.0);
@@ -563,7 +563,7 @@ 5.4 Mega Texture
mega texture is also called the virtualized texture
updatedProjectionMatrix = glMatrix.mat4.ortho(glMatrix.mat4.create(), pivotX - canvas.width * 0.5 * scale, pivotX + canvas.width * 0.5 * scale, pivotY + canvas.height * 0.5 * scale, pivotY - canvas.height * 0.5 * scale, -1000.0, 1000.0);
}
}
-
Panning is achieved by updating the translation Matrix. We use the lookAt function to derive the updated matrix. At the beginning, we look at the origin. and mouse move will update both the lookAt's from and to parameters.
Zooming is achieved by updating the projection matrix. For image viewing, we use only the orthogonal matrix. When zoom in and out, we adjust the viewing range of the orthogonal matrix.
In the end, let's discuss what's gonna happen if we don't include a padding in the texture. to see the effect, we can set the padding to zero. As we can see, we can easily see seams between the tiles. the seams are caused by numerical errors when sampling the texture map. sampling at a coordinates close to the boarder of a tile might read from a nearby tile. to avoid this, we add an extra buffer between tiles, so that sampling close to the border of a tile won't read nearby tiles, but from the buffering area.
Panning is achieved by updating the translation Matrix. We use the lookAt function to derive the updated matrix. At the beginning, we look at the origin. and mouse move will update both the lookAt's from and to parameters.
Zooming is achieved by updating the projection matrix. For image viewing, we use only the orthogonal matrix. When zoom in and out, we adjust the viewing range of the orthogonal matrix.
In the end, let's discuss what's gonna happen if we don't include a padding in the texture. to see the effect, we can set the padding to zero. As we can see, we can easily see seams between the tiles. the seams are caused by numerical errors when sampling the texture map. sampling at a coordinates close to the boarder of a tile might read from a nearby tile. to avoid this, we add an extra buffer between tiles, so that sampling close to the border of a tile won't read nearby tiles, but from the buffering area.