Without refactoring the code the first thing would be to avoid repeatedly setting uniforms that don't change such as:
shaders.SetUniform("textureSampler", 0);
shaders.SetUniform("projection", camera->projectionMatrix);
And put those outside the loop after the shader is selected.
But to really fix the performance issue you need to write all sprites to a VBO rather than issue a draw call for each single sprite, either pre-calculate the position & scale or write them as vertex attribute for the vertex shader to calculate, and let the GPU deal with clipping.
You will also need to put all your individual sprite textures into a single texture atlas (or multiple if they won't fit) you can also use texture arrays for more options https://www.khronos.org/opengl/wiki/Array_Texture
If you have under 100 million quads you can easily let a modern GPU clip them out and still reach 60 fps. Entirely deleting the isVisible check. Frustum culling is only worth doing on the CPU for entire level section batches for 2D sprites.
You can use Instancing to write the position and scale to a separate VBO that will repeat 4 (or 6 for 2 triangles) times so you only need to write each value once.
glVertexAttribDivisor(x, 6);
This way you can reduce the amount of data transferred to the GPU each frame by making the updated VBO smaller.
See the official OpenGL site for more : https://www.khronos.org/opengl/wiki/Vertex_Specification
glDrawElementsInstanced - https://registry.khronos.org/OpenGL-Refpages/gl4/html/glDrawElementsInstanced.xhtml
glDrawArraysInstanced - https://registry.khronos.org/OpenGL-Refpages/gl4/html/glDrawArraysInstanced.xhtml
These functions are specifically meant to draw multiple copies of identical (or similar) objects efficiently.