π» 1024 programmer section
Moving lens π·
Link to previous article: [OpenGL learning notes β₯] - 3D transformation [rotating cube] β Earth Moon system β Rotate + translate + zoom].
Link to next article: π§ π§…
β Description: realize the forward, backward, left, right, up and down functions of the camera in turn.
1, Perspective matrix (perspective projection)
β before setting up a camera, we need to learn perspective projection.
β first, let's take a brief look at the difference between perspective projection and non perspective projection: [it can be found that the cube on the right is more natural]
β perspective: just like the scene of real life, things farther away from us look smaller. This strange effect is called perspective. The perspective effect is particularly obvious when we look at an infinite highway or railway, as shown in the following picture:
β note: due to perspective, the two lines seem to intersect at a distance. This is exactly the effect that perspective projection wants to imitate. It is completed by using perspective projection matrix.
β to create a perspective projection matrix, we need to use the glm::perspective() function, which creates a large frustum defining the visual space. Anything outside the frustum will not appear in the volume of the clipping space and will be clipped. (a perspective frustum can be regarded as a box with uneven shape, and each coordinate inside the box will be mapped to a point in the clipping space). The following is a picture of a perspective frustum:
β a perspective projection matrix can be created in GLM as follows:
glm::mat4 projection_1 = glm::perspective(glm::radians(45.0f), (float)width/(float)height, 0.1f, 100.0f);
β Parameter Description: (all vertices in the near plane and far plane and in the frustum body will be rendered)
β the first parameter: indicates the value of fov (abbreviation of Field of View), that is, the size of observation space. It is usually set to 45.0f (a real observation effect).
β‘ second parameter: aspect ratio. It can be obtained by dividing the width of the viewport by the height.
β’ the third parameter: the near plane of the flat truncated body, which is usually set to 0.1f.
β£ the fourth parameter: the far plane of the frustum body, which is usually set to 100.0f.
2, Observation matrix (camera)
β after learning the perspective matrix, the lens taken by the camera we made next will be very "natural".
β OpenGL itself has no concept of camera, but we can "simulate" the camera by moving all objects in the scene in the opposite direction. (just like we sit in a motor car, when the motor car opposite the window moves backward, we will feel that "our motor car is moving forward")
β when we want to set up a camera (or observation space), we need to transform all world coordinates into observation coordinates relative to the camera, that is, we create a coordinate system with three unit axes perpendicular to each other and taking the position of the camera as the origin.
1.1 camera position
β camera position is simply a vector pointing to the camera position in world space. We set the camera position as follows, as shown in Figure 1.4:
glm::vec3 cameraPos = glm::vec3(0.0f, 1.0f, -5.0f);
β note: the z-axis points to the inside of the screen (the left-hand coordinate system set by Xiaobian's own exploration, see [OpenGL learning notes β₯] - 3D transformation [rotating cube] β Earth Moon system β Rotate + translate + zoom] ), if we want the camera to move backward, we move in the negative direction of the z axis.
1.2 camera orientation vector
β camera direction: refers to which direction the camera points. Now let's point the camera to the scene origin: (0, 0, 0).
glm::vec3 cameraTarget = glm::vec3(0.0f, 0.0f, 0.0f);
1.3 front view vector of camera
β Xiao Bian hasn't understood this vector for a long time. Let's call it "the front view vector in the world coordinate system". [under almost all normal conditions, it is generally set to (0, 1, 0), that is, the front view effect]
glm::vec3 cameraUp = glm::vec3(0.0f, 1.0f, 0.0f);
1.4 Z, X and Y axis vectors of camera world
β in the perspective of the camera, we take the camera as the "new coordinate origin" and establish the following camera coordinate system:
β codes are as follows:
camera_Z_axis = glm::normalize(cameraTarget); camera_X_axis = glm::normalize(glm::cross(cameraTarget, cameraUp)); camera_Y_axis = glm::normalize(glm::cross(camera_X_axis, cameraTarget));
β note: normalize() is the normalization function, and cross() is the cross multiplication function [follow the right-hand principle here]. In addition, the idea of cross multiplication is really wonderful. If you can feel the beauty of the above three formulas like me, that's great π€!
1.5 LookAt matrix
β although we have set the camera position and three coordinate vectors, next, we need to define a "transformation matrix" (generally called LookAt matrix on the official website) , use it to transform the position and vector of things in the world coordinate system into the observation space of the camera. We use three mutually perpendicular axes and the world coordinates of the camera to create a LookAt matrix:
glm::mat4 view_1 = LookAt(this->position, this->position + this->camera_Z_axis, this->camera_Y_axis) // Observation matrix
β Parameter Description: [Note: the function is in "Camera class", so there is "this - >", which will be described in detail later]
β first parameter: camera position [position in world coordinate system]
β‘ second parameter: target position [i.e. the viewing position of the camera lens, which is generally set to (0, 0, 0), i.e. the viewing center position]
β’ the third parameter: the front view vector in the camera coordinate system [generally set to (0, 1, 0)]
β if we want to achieve the effect in the figure below, the relevant parameters of the camera need to be set as follows: [Note: the default position of the camera at the beginning is (0, 0, 0), looking at the negative direction of the z-axis, which is the same as the computer screen looking at us]
First parameter = glm::vec3(0.0f, 1.0f, -5.0f); Second parameter = glm::vec3(0.0f, 0.0f, 0.0f); Third parameter = glm::vec3(0.0f, 1.0f, 0.0f);
3, Keyboard interaction (let the camera move with the keyboard)
3.1 realization of front, back, left, right, up and down movement
β to make the camera move, we must first set up a camera system. view_1 is an "observation matrix", which has the same status as transform. Now the LookAt function is:
glm::mat4 view_1 = glm::lookAt(cameraPos, cameraPos + cameraFront, cameraUp); // Observation matrix
β Description: we first set the camera position to the cameraPos defined previously. The direction is the current position plus the direction vector we just defined. This ensures that the camera will look at the target direction no matter how we move.
β to realize the above functions, we need to write the following 6 Codes:
β in the main function, this is a global variable. bool variables are only used to store whether a key is pressed or not. Pressing is true and not pressing is false. The specific purpose is β’.
bool keys[1024]; // Dedicated storage of pressed keys (defined as global variables)
β‘ this function is defined in main() to tell glfw that there will be keyboard input in the window to facilitate subsequent linkage response [KeyCallback is a function name, see β’ for details]
glfwSetKeyCallback(window_1, KeyCallback);
β’ define the KeyCallback() function. Note that this is a fixed format. (similar to the cmp built-in function definition process of the sort() function of the quick queue)
void KeyCallback(GLFWwindow *window, int key, int scancode, int action, int mode) // Fixed format { if( key == GLFW_KEY_ESCAPE && action == GLFW_PRESS ) { glfwSetWindowShouldClose(window, GL_TRUE); // Mechanism to trigger window closing cout << "Window close key pressed esc = "<< key << endl; } if( key >= 0 && key <= 1024 ) { if( action == GLFW_PRESS ) keys[key] = true; // true means the key was pressed else if( action == GLFW_RELEASE ) keys[key] = false; // true indicates that the key was released } }
β£ define a calling function linked with the "camera class" in the "while loop of drawing loop drawing" (the camera class will be described later), so camera is an instance of the "camera class", and ProcessKeyboard() is a key processing function in the "camera class", which is also defined by ourselves, and is described in detail in β€.
void Key_Movement() { if( keys[GLFW_KEY_Q]) // Forward, press the Q key camera.ProcessKeyboard(FORWARD, deltaTime); if( keys[GLFW_KEY_E] ) // Back, press the E key camera.ProcessKeyboard(BACKWARD, deltaTime); if( keys[GLFW_KEY_A] ) // To the left, press the A key camera.ProcessKeyboard(LEFT, deltaTime); if( keys[GLFW_KEY_D] ) // To the right, press D camera.ProcessKeyboard(RIGHT, deltaTime); if( keys[GLFW_KEY_W] ) // Up, press the W key camera.ProcessKeyboard(UPWARD, deltaTime); if( keys[GLFW_KEY_S] ) // Down, press the S key camera.ProcessKeyboard(DOWNWARD, deltaTime); }
β€ Camera_Movement is an enumeration type, which is in the "Camera.h" header file. ProcessKeyboard() is in the "camera class". deltaTime is a time variable, which is calculated in the main function and detailed in β₯. Note: velocity is "speed" in English.
enum Camera_Movement { // Enumeration type FORWARD, // forward BACKWARD, // backward LEFT, // towards the left RIGHT, // towards the right UPWARD, // Up DOWNWARD // down }; void ProcessKeyboard(Camera_Movement direction, float deltaTime) { float velocity = this->movementSpeed * deltaTime; if(direction == FORWARD) this->position += this->camera_Z_axis * velocity; if(direction == BACKWARD) this->position -= this->camera_Z_axis * velocity; if(direction == LEFT) this->position -= this->camera_X_axis * velocity; if(direction == RIGHT) this->position += this->camera_X_axis * velocity; if(direction == UPWARD) this->position += this->camera_Y_axis * velocity; if(direction == DOWNWARD) this->position -= this->camera_Y_axis * velocity; }
β principle description: when we press any of the W, S, Q, E, A and D keys, the position of the camera will be updated accordingly.
< 1 > move forward: When front position Set towards amount + Z axis of single position towards amount β speed degree Current position vector + unit vector of Z axis * speed Current position vector + Z-axis unit vector * speed
< 2 > move backward: When front position Set towards amount β Z axis of single position towards amount β speed degree Current position vector - unit vector of Z axis * speed Current position vector − unit vector * velocity of Z axis
< 3 > move left: When front position Set towards amount β X axis of single position towards amount β speed degree Current position vector - unit vector of X axis * velocity Current position vector − unit vector * velocity of X axis
< 4 > move right: When front position Set towards amount + X axis of single position towards amount β speed degree Current position vector + unit vector of X axis * velocity Current position vector + unit vector * speed of X axis
< 5 > move up: When front position Set towards amount + Y axis of single position towards amount β speed degree Current position vector + unit vector of Y axis * velocity Current position vector + unit vector * speed of Y axis
< 6 > move down: When front position Set towards amount β Y axis of single position towards amount β speed degree Current position vector - unit vector of Y axis * velocity Current position vector − unit vector * velocity of Y axis
β₯deltaTime and lastTime are global variables in the main function. Through the following processing, we can realize the function of "when pressing the key, the camera moves all the time".
GLfloat deltaTime = 0.0f; GLfloat lastTime = 0.0f; /* The following functions are in the while loop of drawing in draw loop */ GLfloat currentTime = glfwGetTime(); deltaTime = currentTime - lastTime; lastTime = currentTime;
3.2 processing in vertex shader
β in the vertex shader, we need to transfer "view_1 in 3.1" into the vertex shader, so as to realize "key → camera movement". g l _ P o s t i o n = view Observe Moment front M v i e w Γ Thoroughly regard throw shadow Moment front p r o j e c t i o n Γ change change Moment front t r a n s f o r m Γ primary beginning that 's ok towards amount gl\_Postion = _\times _ \times _ \times original row vector gl_Postion = observation matrix Mview × Perspective projection matrix × transform matrix × Original row vector
#version 330 core layout (location = 0) in vec3 position; layout(location = 1) in vec3 color; // The vertex attribute position value of the color variable is 1 out vec3 ourColor; uniform mat4 transform_1; uniform mat4 projection_1; uniform mat4 view_1; void main() { ourColor = color; gl_Position = projection_1 * view_1 * transform_1 * vec4(position, 1.0f); // Note that matrix multiplication should be read from right to left }
β note: only the variable name "gl_Position" can be used here. OpenGL will automatically perform perspective division and clipping later.
4, Camera class
β we have understood the various principles of the camera above. Next, we can directly encapsulate it into a class for our later calls. We named it "Camera.h" and set it as the header file.
#include <iostream> using namespace std; #include <glew.h> #include <glfw3.h> #include "glm\glm.hpp" #include "glm\gtc\matrix_transform.hpp" enum Camera_Movement { // Enumeration type FORWARD, // forward BACKWARD, // backward LEFT, // towards the left RIGHT, // towards the right UPWARD, // Up DOWNWARD // down }; const GLfloat SPEED = 6.0f; // Initial velocity class Camera { public: // Constructor Camera(glm::vec3 position = glm::vec3(0.0f, 0.0f, 5.0f) ,glm::vec3 target = glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3 up = glm::vec3(0.0f, 1.0f, 0.0f)) :movementSpeed(SPEED) { this->position = position; this->camera_Z_axis = target; this->camera_Y_axis = up; this->camera_X_axis = glm::normalize(glm::cross(this->camera_Z_axis, this->camera_Y_axis)); this->updateCameraVectors(); // Real time update } // Observation matrix glm::mat4 GetViewMatrix() { return glm::lookAt(this->position, this->position + this->camera_Z_axis, this->camera_Y_axis); } void ProcessKeyboard(Camera_Movement direction, float deltaTime) { float velocity = this->movementSpeed * deltaTime; if(direction == FORWARD) this->position += this->camera_Z_axis * velocity; if(direction == BACKWARD) this->position -= this->camera_Z_axis * velocity; if(direction == LEFT) this->position -= this->camera_X_axis * velocity; if(direction == RIGHT) this->position += this->camera_X_axis * velocity; if(direction == UPWARD) this->position += this->camera_Y_axis * velocity; if(direction == DOWNWARD) this->position -= this->camera_Y_axis * velocity; } private: // Camera properties glm::vec3 position; // Current camera position glm::vec3 camera_Z_axis; // The Z-axis vector of the camera glm::vec3 camera_X_axis; // The X-axis vector of the camera glm::vec3 camera_Y_axis; // The Y-axis vector of the camera GLfloat movementSpeed; // Lens moving speed void updateCameraVectors() { this->camera_Z_axis = glm::normalize(this->camera_Z_axis); this->camera_X_axis = glm::normalize(glm::cross(this->camera_Z_axis, this->camera_Y_axis)); this->camera_Y_axis = glm::normalize(glm::cross(this->camera_X_axis, this->camera_Z_axis)); } };
5, Complete code
β chip shader codes are as follows:
#version 330 core in vec3 ourColor; out vec4 FragColor; void main() { FragColor = vec4(ourColor,1.0f); }
β another header file Shader.h still follows the code in chapter β’ [OpenGL learning notes β’]—— β Shader [GLSL Uniform color triangle color changing square] β The main functions are as follows:
/* Import corresponding library */ #include <iostream> using namespace std; #define GLEW_STATIC #include"Shader.h" #include"Camera.h" #include<glew.h> // Note: this part should be set according to personal conditions #include<glfw3.h> #include"SOIL2\include\stb_image.h" #include"SOIL2\include\SOIL2.h" #include"glm\glm.hpp" #include"glm\gtc\matrix_transform.hpp" #include"glm\gtc\type_ptr.hpp" /* Write each vertex position */ float vertices_1[] = { // x. y, z coordinates // color -0.5f, -0.5f, -0.5f, 1.0f, 0.0f, 0.0f, // red face 0.5f, -0.5f, -0.5f, 1.0f, 0.0f, 0.0f, 0.5f, 0.5f, -0.5f, 1.0f, 0.0f, 0.0f, 0.5f, 0.5f, -0.5f, 1.0f, 0.0f, 0.0f, -0.5f, 0.5f, -0.5f, 1.0f, 0.0f, 0.0f, -0.5f, -0.5f, -0.5f, 1.0f, 0.0f, 0.0f, -0.5f, -0.5f, 0.5f, 0.0f, 1.0f, 0.0f, // Green green face 0.5f, -0.5f, 0.5f, 0.0f, 1.0f, 0.0f, 0.5f, 0.5f, 0.5f, 0.0f, 1.0f, 0.0f, 0.5f, 0.5f, 0.5f, 0.0f, 1.0f, 0.0f, -0.5f, 0.5f, 0.5f, 0.0f, 1.0f, 0.0f, -0.5f, -0.5f, 0.5f, 0.0f, 1.0f, 0.0f, -0.5f, 0.5f, 0.5f, 0.0f, 0.0f, 1.0f, // blue face -0.5f, 0.5f, -0.5f, 0.0f, 0.0f, 1.0f, -0.5f, -0.5f, -0.5f, 0.0f, 0.0f, 1.0f, -0.5f, -0.5f, -0.5f, 0.0f, 0.0f, 1.0f, -0.5f, -0.5f, 0.5f, 0.0f, 0.0f, 1.0f, -0.5f, 0.5f, 0.5f, 0.0f, 0.0f, 1.0f, 0.5f, 0.5f, 0.5f, 1.0f, 1.0f, 0.0f, // yellow side 0.5f, 0.5f, -0.5f, 1.0f, 1.0f, 0.0f, 0.5f, -0.5f, -0.5f, 1.0f, 1.0f, 0.0f, 0.5f, -0.5f, -0.5f, 1.0f, 1.0f, 0.0f, 0.5f, -0.5f, 0.5f, 1.0f, 1.0f, 0.0f, 0.5f, 0.5f, 0.5f, 1.0f, 1.0f, 0.0f, -0.5f, -0.5f, -0.5f, 1.0f, 0.0f, 1.0f, // purple face 0.5f, -0.5f, -0.5f, 1.0f, 0.0f, 1.0f, 0.5f, -0.5f, 0.5f, 1.0f, 0.0f, 1.0f, 0.5f, -0.5f, 0.5f, 1.0f, 0.0f, 1.0f, -0.5f, -0.5f, 0.5f, 1.0f, 0.0f, 1.0f, -0.5f, -0.5f, -0.5f, 1.0f, 0.0f, 1.0f, -0.5f, 0.5f, -0.5f, 0.0f, 1.0f, 1.0f, // cyan face 0.5f, 0.5f, -0.5f, 0.0f, 1.0f, 1.0f, 0.5f, 0.5f, 0.5f, 0.0f, 1.0f, 1.0f, 0.5f, 0.5f, 0.5f, 0.0f, 1.0f, 1.0f, -0.5f, 0.5f, 0.5f, 0.0f, 1.0f, 1.0f, -0.5f, 0.5f, -0.5f, 0.0f, 1.0f, 1.0f, }; const GLint WIDTH = 600, HEIGHT = 600; bool keys[1024]; // Dedicated storage of pressed keys Camera camera(glm::vec3(1.0f, 1.0f, -5.0f),glm::vec3(-1.0f, -1.0f, 5.0f), glm::vec3(0.0f, 1.0f, 0.0f)); void KeyCallback(GLFWwindow *window, int key, int scancode, int action, int mode); // Response function to keyboard void Key_Movement(); // Functions that interact with the Camera class GLfloat deltaTime = 0.0f; GLfloat lastTime = 0.0f; int main() { /* Initialize glfw */ glfwInit(); glfwWindowHint(GLFW_RESIZABLE, GL_FALSE); // Zoom off /* Window capture and processing */ GLFWwindow* window_1 = glfwCreateWindow(WIDTH, HEIGHT, "NJUPT_Learn OpenGL Key Test", nullptr, nullptr); int screenWidth_1, screenHeight_1; glfwGetFramebufferSize(window_1, &screenWidth_1, &screenHeight_1); cout << "screenWidth_1 = " << screenWidth_1 << ", screenHeight = " << screenHeight_1 << endl; glfwMakeContextCurrent(window_1); glfwSetKeyCallback(window_1, KeyCallback); // Register in glfw and make linkage response /* Initialize glew */ glewInit(); /* Opening depth test */ glEnable(GL_DEPTH_TEST); /* Pass in the shader text we set ourselves */ Shader ourShader = Shader("shader_v.txt", "shader_f.txt"); // Relative path /* Set vertex buffer object (VBO) + set vertex array object (VAO) */ GLuint VAO, VBO; glGenVertexArrays(1, &VAO); glGenBuffers(1, &VBO); glBindVertexArray(VAO); glBindBuffer(GL_ARRAY_BUFFER, VBO); glBufferData(GL_ARRAY_BUFFER, sizeof(vertices_1), vertices_1, GL_STATIC_DRAW); /* Set linked vertex attributes */ glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6*sizeof(GLfloat), (GLvoid*)0); glEnableVertexAttribArray(0); // Channel 0 open glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6*sizeof(GLfloat), (GLvoid*)(3*sizeof(GLfloat))); glEnableVertexAttribArray(1); // Channel 1 open /* draw loop Drawing cycle */ while (!glfwWindowShouldClose(window_1)) { GLfloat currentTime = glfwGetTime(); deltaTime = currentTime - lastTime; lastTime = currentTime; /* Viewport + time */ glViewport(0, 0, screenWidth_1, screenHeight_1); glfwPollEvents(); // Get keyboard and mouse Key_Movement(); // Get keyboard actions /* Render + clear color buffer */ glClearColor(0.5f, 0.8f, 0.5f, 1.0f); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); /* Drawing graphics */ ourShader.Use(); // Call shader program glBindVertexArray(VAO); // Bind VAO for(int i = 0; i < 2; i++) // Prepare to draw two cubes { glm::mat4 transform_1; glm::mat4 view_1 = camera.GetViewMatrix(); // Obtain the observation matrix if( i == 0 ) // Heavy Cube { transform_1 = glm::translate(transform_1, glm::vec3(0.0f, 0.0f, 0.0f)); float new_size = cos(currentTime) * 0.2f + 0.8f; transform_1 = glm::scale(transform_1, glm::vec3(new_size, new_size, new_size)); } else // Small cube { transform_1 = glm::translate(transform_1, glm::vec3(0.0f, 1.0f, 0.0f)); transform_1 = glm::rotate(transform_1, currentTime, glm::vec3(0.2f, 1.0f, 0.0f)); transform_1 = glm::scale(transform_1, glm::vec3(0.15f, 0.15f, 0.15f)); } glm::mat4 projection_1 = glm::perspective(glm::radians(45.0f), (float)screenWidth_1/(float)screenHeight_1, 0.1f, 100.0f); int transform_1_Location = glGetUniformLocation(ourShader.Program, "transform_1"); glUniformMatrix4fv(transform_1_Location, 1, GL_FALSE, glm::value_ptr(transform_1)); int projection_1_Location = glGetUniformLocation(ourShader.Program, "projection_1"); glUniformMatrix4fv(projection_1_Location, 1, GL_FALSE, glm::value_ptr(projection_1)); int view_1_Location = glGetUniformLocation(ourShader.Program, "view_1"); glUniformMatrix4fv(view_1_Location, 1, GL_FALSE, glm::value_ptr(view_1)); // The first parameter is in vs.txt, and the second parameter is in the main function glDrawArrays(GL_TRIANGLES, 0, 36); } glBindVertexArray(0); // Unbound VAO /* Swap buffer */ glfwSwapBuffers(window_1); } /* Release resources */ glDeleteVertexArrays(1, &VAO); glDeleteBuffers(1, &VBO); glfwTerminate(); // end return 0; } void KeyCallback(GLFWwindow *window, int key, int scancode, int action, int mode) { if( key == GLFW_KEY_ESCAPE && action == GLFW_PRESS ) { glfwSetWindowShouldClose(window, GL_TRUE); cout << "Off key pressed esc = "<< key << endl; } if( key >= 0 && key <= 1024 ) { if( action == GLFW_PRESS ) keys[key] = true; // true means the key was pressed else if( action == GLFW_RELEASE ) keys[key] = false; } } void Key_Movement() { if( keys[GLFW_KEY_Q]) // forward camera.ProcessKeyboard(FORWARD, deltaTime); if( keys[GLFW_KEY_E] ) // backward camera.ProcessKeyboard(BACKWARD, deltaTime); if( keys[GLFW_KEY_A] ) // towards the left camera.ProcessKeyboard(LEFT, deltaTime); if( keys[GLFW_KEY_D] ) // towards the right camera.ProcessKeyboard(RIGHT, deltaTime); if( keys[GLFW_KEY_W] ) // Up camera.ProcessKeyboard(UPWARD, deltaTime); if( keys[GLFW_KEY_S] ) // down camera.ProcessKeyboard(DOWNWARD, deltaTime); }
β operation results:
6, Refer to appendix:
[1] Learn OpenGL - Camera
Link: https://learnopengl-cn.github.io/01%20Getting%20started/09%20Camera/.
[2] Learn OpenGL - coordinate system
Link: https://learnopengl-cn.github.io/01%20Getting%20started/08%20Coordinate%20Systems/.
[3] <Understanding glm::lookAt()>
Link: https://stackoverflow.com/questions/21830340/understanding-glmlookat.
[3] Detailed explanation of normalize function
Link: https://blog.csdn.net/qq_36930777/article/details/78301433.
Link to previous article: [OpenGL learning notes β₯] - 3D transformation [rotating cube] β Earth Moon system β Rotate + translate + zoom].
Link to next article: π§ π§…
βοΈ βοΈ