September 10th, 2023
Over a month ago, I started a rewrite of Starship Fights II in C# and .NET Core using OpenTK (binding to OpenGL, GLFW, and OpenAL), SkiaSharp (binding to Skia, a 2D graphics library) and BulletSharp P/Invoke (binding to Bullet, a physics engine).
The reason why I'm doing this is twofold: first, to improve performance, and second, to facilitate binding to other native libraries that I might use.
In terms of performance, well-written C# has well-written Java/Kotlin/other JVM languages beat. This is because the CLR, that .NET languages such as C# run on top of, has built-in support for value types: user-defined structures that are allocated on the stack and behave like structs do in C or C++, complete with copy semantics. JVM languages such as Java or Kotlin do not have value types; on the JVM, every user-defined data structure is allocated on the heap, which makes things like vector math quite painful in terms of memory garbage generation, since for every vector or matrix object you create, you allocate another object on the heap that needs to be garbage-collected at some point.
Also, C# allows programmers to manipulate pointers just like in C or C++, when writing code inside of unsafe blocks, which is also really good for performance.
Furthermore, C# has a better pattern to bind to native libraries than Java or Kotlin do. C# uses Platform Invoke (P/Invoke), which lets you define a method in C# alone that calls a function in a native library, and the CLR handles marshalling between managed and unmanaged memory. The JVM, on the other hand, uses Java Native Interface (JNI), which forces programmers to write glue code in C just so the JVM can know what code to run when calling into the native side. It's... not as good as P/Invoke.
A pretty nice bonus of this rewrite is that I get to write my own UI toolkit using SkiaSharp. This may seem like a chore to some, but I enjoy doing it, and I also enjoy the fact that it gives me more control over the user interface than a 3rd-party UI library could. I implemented my own scroll pane, check box, radio button, text input, action button, and even a flow widget!
One of many things that I sought to improve in this rewrite was the rendering of spheres, since that is necessary for the campaign map and I didn't really like the distortion at the poles back when I used jMonkeyEngine with Kotlin. To get rid of the distortion at the poles, I got rid of the sphere mesh's 2D UV coordinates entirely, instead using the sphere vertex positions as 3D texture coordinates. The vertex shader passes vertex positions as-is into out vec3 Tex
. The fragment shader is where I calculate the 2D UV coordinates, using the following GLSL code:
float uCoord = atan(Tex.x, Tex.z) / TAU + 0.5;
float vCoord = atan(Tex.y, length(Tex.xz)) * 2 / TAU + 0.5;
vec2 uv = vec2(uCoord, vCoord);
Of course, this resulted in a problem: a weird seam appeared along a single meridian of the sphere. The seam was a similar color to the average color of the texture as a whole, but I didn't realize that until I figured out the solution. One thing that I noticed was that, when I debugged it in RenderDoc, the seam wasn't in individual pixels, but instead in 2x2 pixel blocks, which implied a connection to OpenGL's fragment derivatives, since those are calculated in 2x2 blocks as well!
For a while, I just decided to tolerate the seam, until a few days ago...
I decided to make a Google search of "weird seam on opengl sphere" (or something like that, I don't remember the exact wording) and, once I sifted past the results that were just people messing up their 2D UV coordinates, I found a Reddit link where someone was in a similar situation as mine.
The link is at https://reddit.com/r/opengl/comments/vrnqd3/seams_showing_up_when_using_gl_repeat_on_a_single/ and archived on this archive.today page.
As it turns out, as revealed by the top comment there, OpenGL uses the derivatives of the uv coordinates passed into the texture(sampler, vec2 uv)
function to pick the mipmap level. This explains everything about the seam! The two-argument arctangent atan(y, x)
function has a discontinuity where it jumps from -τ/2 to +τ/2, and at this discontinuity, the derivative that OpenGL was calculating must've been huge, making it think that the texture was really far away, and so it picked the smallest mipmap level with a size of 1x1!
Luckily, that same comment gave me a solution: calculate the derivative manually and pass it into textureGrad(sampler, vec2 uv, vec2 duv_dx, vec2 duv_dy)
. Of course, calculating the derivative manually would be challenging. I couldn't just write dFdx(uv), dFdy(uv)
, with vec2 uv
being the calculated 2D coordinates, and call it a day, since OpenGL was already doing that, which caused the seam problem in the first place.
Instead, I had to write dFdx(Tex), dFdy(Tex)
where in vec3 Tex
is the input 3D spherical texture coordinate, which gave me the derivative of the 3D position with respect to the fragment's screen location. So how do I get the correct derivative of the 2D UV coordinate? The answer is the multivariable chain rule, by calculating two Jacobian matrix derivatives and multiplying them with standard matrix multiplication rules.
So then I calculated, by hand, the derivative of the 2D UV coordinates with respect with the 3D sphere coordinates. Once I finished, I wrote the results of my calculation into the fragment shader, as can be seen below:
float Tex_w = length(Tex.xz);
mat2x3 dTex_dScreen = mat2x3(dFdx(Tex), dFdy(Tex));
mat3x2 dUv_dTex = mat3x2(
vec2(Tex.z / Tex_w / TAU, 2 / TAU * -(Tex.x * Tex.y) / Tex_w),
vec2(0, 2 / TAU * Tex_w),
vec2(-Tex.x / Tex_w / TAU, 2 / TAU * -(Tex.y * Tex.z) / Tex_w)
);
mat2 dUv_dScreen = dUv_dTex * dTex_dScreen;
vec2 dUv_dX = dUv_dScreen[0];
vec2 dUv_dY = dUv_dScreen[1];
The math used to derive these results is below: