Skip to content

Instantly share code, notes, and snippets.

@MrPowerGamerBR
Last active July 9, 2025 19:10
Show Gist options
  • Select an option

  • Save MrPowerGamerBR/dcc3879633a8a9eae883f1ba4a933272 to your computer and use it in GitHub Desktop.

Select an option

Save MrPowerGamerBR/dcc3879633a8a9eae883f1ba4a933272 to your computer and use it in GitHub Desktop.
SDF rendering things
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)
}
#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);
}
#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