Last active
July 9, 2025 19:10
-
-
Save MrPowerGamerBR/dcc3879633a8a9eae883f1ba4a933272 to your computer and use it in GitHub Desktop.
SDF rendering things
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| package net.perfectdreams.lookatmycursor | |
| import kotlinx.serialization.json.Json | |
| import kotlinx.serialization.json.encodeToStream | |
| import net.perfectdreams.harmony.gl.debug.OpenGLDebugging | |
| import net.perfectdreams.harmony.gl.shaders.ShaderManager | |
| import net.perfectdreams.harmony.gl.textures.TextureManager | |
| import net.perfectdreams.harmony.logging.HarmonyLoggerFactory | |
| import net.perfectdreams.harmony.logging.slf4j.HarmonyLoggerCreatorSLF4J | |
| import org.lwjgl.BufferUtils | |
| import org.lwjgl.Version | |
| import org.lwjgl.glfw.Callbacks.glfwFreeCallbacks | |
| import org.lwjgl.glfw.GLFW.* | |
| import org.lwjgl.glfw.GLFWErrorCallback | |
| import org.lwjgl.glfw.GLFWVidMode | |
| import org.lwjgl.glfw.GLFWWindowSizeCallbackI | |
| import org.lwjgl.opengl.ARBShaderImageLoadStore.* | |
| import org.lwjgl.opengl.GL | |
| import org.lwjgl.opengl.GL11.* | |
| import org.lwjgl.opengl.GL15.* | |
| import org.lwjgl.opengl.GL20.* | |
| import org.lwjgl.opengl.GL43.GL_COMPUTE_SHADER | |
| import org.lwjgl.opengl.GL43.glDispatchCompute | |
| import org.lwjgl.system.MemoryStack.stackPush | |
| import org.lwjgl.system.MemoryUtil.NULL | |
| import java.awt.Color | |
| import java.awt.Font | |
| import java.awt.Rectangle | |
| import java.awt.image.BufferedImage | |
| import java.io.File | |
| import java.nio.ByteBuffer | |
| import java.nio.IntBuffer | |
| import javax.imageio.ImageIO | |
| class FontGeneratorCompute { | |
| // The window handle | |
| private var window: Long = 0 | |
| val shaderManager = ShaderManager() | |
| val textureManager = TextureManager() | |
| var mouseX = 0.0 | |
| var mouseY = 0.0 | |
| fun start() { | |
| HarmonyLoggerFactory.setLoggerCreator(HarmonyLoggerCreatorSLF4J()) | |
| println("Hello LWJGL " + Version.getVersion() + "!") | |
| init() | |
| loop() | |
| // Free the window callbacks and destroy the window | |
| glfwFreeCallbacks(window) | |
| glfwDestroyWindow(window) | |
| // Terminate GLFW and free the error callback | |
| glfwTerminate() | |
| glfwSetErrorCallback(null)!!.free() | |
| } | |
| private fun init() { | |
| // Setup an error callback. The default implementation | |
| // will print the error message in System.err. | |
| GLFWErrorCallback.createPrint(System.err).set() | |
| // Initialize GLFW. Most GLFW functions will not work before doing this. | |
| check(glfwInit()) { "Unable to initialize GLFW" } | |
| // Configure GLFW | |
| glfwDefaultWindowHints() // optional, the current window hints are already the default | |
| glfwWindowHint(GLFW_VISIBLE, GLFW_FALSE) // the window will stay hidden after creation | |
| glfwWindowHint(GLFW_RESIZABLE, GLFW_TRUE) // the window will be resizable | |
| // Create the window | |
| window = glfwCreateWindow(1280, 720, "Gessy: The Trials", NULL, NULL) | |
| if (window == NULL) throw RuntimeException("Failed to create the GLFW window") | |
| stackPush().use { stack -> | |
| val pWidth: IntBuffer = stack.mallocInt(1) // int* | |
| val pHeight: IntBuffer = stack.mallocInt(1) // int* | |
| // Get the window size passed to glfwCreateWindow | |
| glfwGetWindowSize(window, pWidth, pHeight) | |
| // Get the resolution of the primary monitor | |
| val vidmode: GLFWVidMode = glfwGetVideoMode(glfwGetPrimaryMonitor())!! | |
| // Center the window | |
| glfwSetWindowPos( | |
| window, | |
| (vidmode.width() - pWidth.get(0)) / 2, | |
| (vidmode.height() - pHeight.get(0)) / 2 | |
| ) | |
| } | |
| glfwSetWindowSizeCallback(window, object : GLFWWindowSizeCallbackI { | |
| override fun invoke(window: Long, width: Int, height: Int) { | |
| glViewport(0, 0, width, height) | |
| } | |
| }) | |
| glfwSetCursorPosCallback(window) { window, xpos, ypos -> | |
| this.mouseX = xpos | |
| this.mouseY = ypos | |
| } | |
| // Make the OpenGL context current | |
| glfwMakeContextCurrent(window) | |
| // Enable v-sync | |
| glfwSwapInterval(1) | |
| // Make the window visible | |
| glfwShowWindow(window) | |
| } | |
| private fun loop() { | |
| // This line is critical for LWJGL's interoperation with GLFW's | |
| // OpenGL context, or any context that is managed externally. | |
| // LWJGL detects the context that is current in the current thread, | |
| // creates the GLCapabilities instance and makes the OpenGL | |
| // bindings available for use. | |
| GL.createCapabilities() | |
| OpenGLDebugging.enableReportGLErrors() | |
| val input = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.,;:?!'\"()[]{}<>-@#\$%^&*~|_+=ÀÁÂÃÄÅÇÈÉÊËÌÍÎÏÑÒÓÔÕÖÙÚÛÜÝàáâãäåçèéêëìíîïñòóôõöùúûüýÿ" | |
| val atlas = BufferedImage(2048, 2048, BufferedImage.TYPE_INT_ARGB) | |
| val atlasGraphics = atlas.createGraphics() | |
| val entryWidth = 128 | |
| val entryHeight = 128 | |
| var x = 0 | |
| var y = 0 | |
| // Create and compile compute shader | |
| val computeShader = glCreateShader(GL_COMPUTE_SHADER) | |
| val computeShaderSource = File("assets/shaders/sdf_gen.comp").readText() | |
| glShaderSource(computeShader, computeShaderSource) | |
| glCompileShader(computeShader) | |
| // Check shader compilation | |
| var success = glGetShaderi(computeShader, GL_COMPILE_STATUS) | |
| if (success != GL_TRUE) { | |
| val infoLog: String? = glGetShaderInfoLog(computeShader) | |
| System.err.println("Compute shader compilation failed: " + infoLog) | |
| return | |
| } | |
| // Create compute shader program | |
| val computeProgram = glCreateProgram() | |
| glAttachShader(computeProgram, computeShader) | |
| glLinkProgram(computeProgram) | |
| // Check program linking | |
| success = glGetProgrami(computeProgram, GL_LINK_STATUS) | |
| if (success != GL_TRUE) { | |
| val infoLog: String? = glGetProgramInfoLog(computeProgram) | |
| System.err.println("Compute program linking failed: " + infoLog) | |
| return | |
| } | |
| // Delete shader as it's linked into our program now | |
| glDeleteShader(computeShader) | |
| // Create texture to write to | |
| val texture = glGenTextures() | |
| glBindTexture(GL_TEXTURE_2D, texture) | |
| glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR) | |
| glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR) | |
| glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE) | |
| glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE) | |
| val TEXTURE_WIDTH = 512 | |
| val TEXTURE_HEIGHT = 512 | |
| // Allocate texture storage | |
| glTexImage2D( | |
| GL_TEXTURE_2D, | |
| 0, | |
| GL_RGBA8, | |
| TEXTURE_WIDTH, | |
| TEXTURE_HEIGHT, | |
| 0, | |
| GL_RGBA, | |
| GL_UNSIGNED_BYTE, | |
| null as ByteBuffer? | |
| ) | |
| val font = Font.createFont(Font.TRUETYPE_FONT, File("ComicRelief-Bold.ttf")).deriveFont(360f) | |
| val characters = mutableMapOf<Char, FontAtlasAttributes.CharacterAttributes>() | |
| for (character in input) { | |
| // Create a BufferedImage with transparency | |
| val image = BufferedImage(TEXTURE_WIDTH, TEXTURE_HEIGHT, BufferedImage.TYPE_INT_ARGB) | |
| val g2d = image.createGraphics() | |
| g2d.color = Color.BLACK | |
| // Enable anti-aliasing for smoother text | |
| // g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) | |
| // g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON) | |
| // Set the font and color | |
| g2d.font = font | |
| g2d.color = Color.BLACK | |
| // Get font metrics to center the character | |
| val fm = g2d.fontMetrics | |
| val textHeight = fm.height | |
| println("Height: $textHeight") | |
| println("Ascent: ${fm.ascent}") | |
| println("Descent: ${fm.descent}") | |
| println("Width: ${fm.stringWidth(character.toString())}") | |
| val stringWidth = fm.stringWidth(character.toString()) | |
| // The y position is the baseline! | |
| // Draw the character | |
| val baselineY = ((TEXTURE_HEIGHT / 2) + (fm.ascent / 2) - (fm.descent / 2)) | |
| g2d.drawString( | |
| character.toString(), | |
| ((TEXTURE_WIDTH / 2) - (stringWidth / 2)).toFloat(), | |
| baselineY.toFloat() | |
| ) | |
| if (x >= atlas.width) { | |
| x = 0 | |
| y += entryHeight | |
| if (y >= atlas.height) { | |
| error("Texture atlas is too small!") | |
| } | |
| } | |
| // The UV is a bit tricky to calculate | |
| val boundingBox = calculateNonTransparentBoundingBox(image)!! | |
| val boundingBoxXRelativeTo128x128 = boundingBox.x / 4f | |
| val boundingBoxYRelativeTo128x128 = boundingBox.y / 4f | |
| val boundingBoxWidthRelativeTo128x128 = boundingBox.width / 4f | |
| val boundingBoxHeightRelativeTo128x128 = boundingBox.height / 4f | |
| // descent: (boundingBox.y + boundingBox.height) - baselineY | |
| // OpenGL's top left is 0.0, 1.0 | |
| characters[character] = FontAtlasAttributes.CharacterAttributes( | |
| FontAtlasAttributes.CharacterAttributes.TextureCoordinate( | |
| (x.toFloat() + boundingBoxXRelativeTo128x128) / atlas.width, | |
| 1.0f - ((y.toFloat() + boundingBoxYRelativeTo128x128) / atlas.height), | |
| (x.toFloat() + boundingBoxXRelativeTo128x128 + boundingBoxWidthRelativeTo128x128) / atlas.width, | |
| 1.0f - ((y.toFloat() + boundingBoxYRelativeTo128x128 + boundingBoxHeightRelativeTo128x128) / atlas.height) | |
| ), | |
| FontAtlasAttributes.CharacterAttributes.TextureCoordinate( | |
| x.toFloat() / atlas.width, | |
| 1.0f - (y.toFloat() / atlas.height), | |
| (x.toFloat() + entryWidth) / atlas.width, | |
| 1.0f - ((y.toFloat() + entryHeight) / atlas.height), | |
| ), | |
| (boundingBox.y - baselineY) / 512f | |
| ) | |
| // Bind the texture to the compute shader | |
| val characterTexture = loadTexture(image) | |
| glBindImageTexture(0, characterTexture, 0, false, 0, GL_READ_ONLY, GL_RGBA8) | |
| glBindImageTexture(1, texture, 0, false, 0, GL_WRITE_ONLY, GL_RGBA8) | |
| // Use the compute shader program | |
| glUseProgram(computeProgram) | |
| // Dispatch compute shader with enough work groups to cover the texture | |
| val workGroupsX: Int = TEXTURE_WIDTH / 16 | |
| val workGroupsY: Int = TEXTURE_HEIGHT / 16 | |
| glDispatchCompute(workGroupsX, workGroupsY, 1) | |
| // Wait for compute shader to finish | |
| glMemoryBarrier(GL_SHADER_IMAGE_ACCESS_BARRIER_BIT) | |
| // Read back the texture data | |
| val pixels = BufferUtils.createByteBuffer(TEXTURE_WIDTH * TEXTURE_HEIGHT * 4) | |
| glBindTexture(GL_TEXTURE_2D, texture) | |
| glGetTexImage(GL_TEXTURE_2D, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels) | |
| // Create a BufferedImage and fill it with the pixel data | |
| val fromGlImage = BufferedImage(TEXTURE_WIDTH, TEXTURE_HEIGHT, BufferedImage.TYPE_INT_ARGB) | |
| for (y in 0..<TEXTURE_HEIGHT) { | |
| for (x in 0..<TEXTURE_WIDTH) { | |
| val i: Int = (y * TEXTURE_WIDTH + x) * 4 | |
| // Read RGBA values from buffer | |
| val r = pixels.get(i).toInt() and 0xFF | |
| val g = pixels.get(i + 1).toInt() and 0xFF | |
| val b = pixels.get(i + 2).toInt() and 0xFF | |
| val a = pixels.get(i + 3).toInt() and 0xFF | |
| // ARGB format for BufferedImage | |
| val argb = (a shl 24) or (r shl 16) or (g shl 8) or b | |
| // BufferedImage has y-axis flipped | |
| fromGlImage.setRGB(x, TEXTURE_HEIGHT - 1 - y, argb) | |
| } | |
| } | |
| if (character == 'A') { | |
| ImageIO.write(fromGlImage, "png", File("character_${character}_${character.isUpperCase()}_sdf.png")) | |
| } | |
| val targetScaled = BufferedImage(entryWidth, entryHeight, BufferedImage.TYPE_INT_ARGB) | |
| targetScaled.createGraphics().drawImage(fromGlImage, 0, 0, entryWidth, entryHeight, null) | |
| println("Drawing $character at $x, $y") | |
| atlasGraphics.drawImage( | |
| targetScaled, | |
| x, | |
| y, | |
| null | |
| ) | |
| x += entryWidth | |
| } | |
| println("Done! :3") | |
| ImageIO.write(atlas, "png", File("atlas.png")) | |
| File("font.json") | |
| .writeText( | |
| Json.encodeToString( | |
| FontAtlasAttributes( | |
| characters = characters | |
| ) | |
| ) | |
| ) | |
| } | |
| fun loadTexture(image: BufferedImage): Int { | |
| // Get width and height of the image | |
| val width = image.getWidth() | |
| val height = image.getHeight() | |
| // Convert image to RGBA format (4 bytes per pixel) | |
| val pixels = IntArray(width * height) | |
| image.getRGB(0, 0, width, height, pixels, 0, width) | |
| // Create a ByteBuffer | |
| val buffer = BufferUtils.createByteBuffer(width * height * 4) | |
| // Convert pixels to proper format for OpenGL | |
| for (y in height - 1 downTo 0) { | |
| for (x in 0..<width) { | |
| val pixel = pixels[y * width + x] | |
| // Extract RGBA components | |
| buffer.put(((pixel shr 16) and 0xFF).toByte()) // Red | |
| buffer.put(((pixel shr 8) and 0xFF).toByte()) // Green | |
| buffer.put((pixel and 0xFF).toByte()) // Blue | |
| buffer.put(((pixel shr 24) and 0xFF).toByte()) // Alpha | |
| } | |
| } | |
| // Flip the buffer to prepare for reading | |
| buffer.flip() | |
| // Generate a texture ID | |
| val textureID = glGenTextures() | |
| // Bind the texture | |
| glBindTexture(GL_TEXTURE_2D, textureID) | |
| // Set texture parameters | |
| glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE) | |
| glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE) | |
| glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST) | |
| glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST) | |
| // Upload the texture data | |
| glTexImage2D( | |
| GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, | |
| GL_RGBA, GL_UNSIGNED_BYTE, buffer | |
| ) | |
| return textureID | |
| } | |
| } | |
| fun main() { | |
| val m = FontGeneratorCompute() | |
| m.start() | |
| } | |
| /** | |
| * Calculates the bounding box containing all non-transparent pixels in the image. | |
| * @param color the background color | |
| * @param image The BufferedImage to analyze | |
| * @return Rectangle representing the bounding box, or null if image is fully transparent | |
| */ | |
| fun calculateNonTransparentBoundingBox( | |
| backgroundColor: Color, | |
| image: BufferedImage | |
| ): Rectangle? { | |
| var minX = image.width | |
| var minY = image.height | |
| var maxX = -1 | |
| var maxY = -1 | |
| // Scan each pixel of the image | |
| for (y in 0 until image.height) { | |
| for (x in 0 until image.width) { | |
| val pixel = image.getRGB(x, y) | |
| val alpha = (pixel shr 24) and 0xff | |
| // If pixel matches our background color | |
| // Special case: Check the alpha ignoring the entire packed rgb | |
| if (pixel == backgroundColor.rgb || (alpha == 0 && alpha == backgroundColor.alpha)) { | |
| minX = minOf(minX, x) | |
| minY = minOf(minY, y) | |
| maxX = maxOf(maxX, x) | |
| maxY = maxOf(maxY, y) | |
| } | |
| } | |
| } | |
| // If no non-transparent pixels were found | |
| if (maxX < 0) { | |
| return null | |
| } | |
| // Return the bounding box as a Rectangle | |
| return Rectangle(minX, minY, maxX - minX + 1, maxY - minY + 1) | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #version 430 core | |
| in vec2 vTexCoords; | |
| uniform sampler2D uTexture; | |
| out vec4 FragColor; | |
| void main() { | |
| if (false) { | |
| FragColor = texture(uTexture, vTexCoords); | |
| return; | |
| } | |
| // Edge threshold for determining the text edge | |
| float edgeThreshold = 0.5; | |
| // Width of the smoothing band | |
| float smoothWidth = 0.1; | |
| // Sample the distance field | |
| float distance = texture(uTexture, vTexCoords).r; | |
| // Apply threshold with smoothing | |
| float alpha = smoothstep(edgeThreshold - smoothWidth, edgeThreshold + smoothWidth, distance); | |
| // Output the final color | |
| FragColor = vec4(1.0, 1.0, 1.0, alpha); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #version 430 core | |
| layout(local_size_x = 16, local_size_y = 16) in; | |
| layout(rgba8, binding = 0) uniform image2D inputImage; | |
| layout(rgba8, binding = 1) uniform image2D outputTexture; | |
| uniform int searchRadius = 32; | |
| void main() { | |
| ivec2 pixelCoords = ivec2(gl_GlobalInvocationID.xy); | |
| ivec2 imageSize = imageSize(inputImage); | |
| // Read a pixel from the image | |
| vec4 inputPixel = imageLoad(inputImage, pixelCoords); | |
| // The input is BLACK (inside the glyph) and TRANSPARENT (outside the glyph)! | |
| // We need to calculate BOTH, and values 0.5 SHOULD be at the glyph's edge! | |
| bool isInsideGlyph = inputPixel.a == 1.0; | |
| float alphaToBeChecked = 1.0; | |
| if (isInsideGlyph) | |
| alphaToBeChecked = 0.0; | |
| // Search around us... | |
| float squaredDistance = 32 * 32; | |
| int startX = max(0, pixelCoords.x - searchRadius); | |
| int startY = max(0, pixelCoords.y - searchRadius); | |
| int endX = min(imageSize.x, pixelCoords.x + searchRadius + 1); | |
| int endY = min(imageSize.y, pixelCoords.y + searchRadius + 1); | |
| int ix = startX; | |
| int iy = startY; | |
| while (true) { | |
| // The second check is "No need to check self" | |
| if (ix == endX || (ix == pixelCoords.x && iy == pixelCoords.y)) { | |
| ix = startX; | |
| iy++; | |
| if (iy == endY) | |
| break; | |
| } | |
| vec4 distancePixel = imageLoad(inputImage, ivec2(ix, iy)); | |
| if (distancePixel.a == alphaToBeChecked) { | |
| // We are inside the glyph! | |
| // But how far are we from the glyph? | |
| float dx = (ix - pixelCoords.x); | |
| float dy = (iy - pixelCoords.y); | |
| float thisSquaredDistance = (dx * dx) + (dy * dy); | |
| squaredDistance = min(squaredDistance, thisSquaredDistance); | |
| } | |
| ix++; | |
| } | |
| float distanceToEdge = sqrt(squaredDistance); | |
| // Value from 0.5 to 1.0 | |
| float sdfValueAsRange = clamp(0.5 - (distanceToEdge / (2.0 * float(searchRadius))), 0.0, 1.0); | |
| if (isInsideGlyph) { | |
| // Don't ask me why do we need to use 0.98 instead of 1.0 | |
| // I did it like this because there was a visible "jump" on the edges of the SDF | |
| imageStore(outputTexture, pixelCoords, vec4(0.98 - sdfValueAsRange, 0.98 - sdfValueAsRange, 0.98 - sdfValueAsRange, 1.0)); | |
| } else { | |
| imageStore(outputTexture, pixelCoords, vec4(sdfValueAsRange, sdfValueAsRange, sdfValueAsRange, 1.0)); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment