Skip to content

Instantly share code, notes, and snippets.

@GibsonRuitiari
Created April 19, 2025 13:03
Show Gist options
  • Save GibsonRuitiari/64ef058adfb3bcd8d985648287133e97 to your computer and use it in GitHub Desktop.
Save GibsonRuitiari/64ef058adfb3bcd8d985648287133e97 to your computer and use it in GitHub Desktop.
A kotlin implementation of the scanline filling algorithm. The rendering is done on a terminal, but can be adopted to jetpack compose.
package scanlineFilling
/**
* The basic steps of a scan-line filling algorithm: build the edge table; sort the edges; loop through the scan-lines from yMin to the yMaximum,
* update the x coordinate as you move along, then fill up the regions covered by the active edges (successor and pre-decessor)
*/
data class Edge(val yMaximum: Float,
val yMinimum:Float,
var xOfYmin:Float, val slopeInverse:Float)
data class Point(val x:Int, val y:Int)
/**
* Takes in a list of vertices points (x,y) and returns a list of edges each representing an edge
* of the polygon
*/
fun buildEdgeTable(polygonVertices: List<Point>): List<Edge>{
val edges = mutableListOf<Edge>()
// number of vertices of the polygon
val n = polygonVertices.size
// loop through 0 to n-1 getting each vertex
for (i in 0 until n){
// first vertex, then pair it with the next vertex using [i+1]
val (x1, y1) = polygonVertices[i]
// second vertex. We use the modulo function to ensure the next vertex is within the current polygon
val (x2, y2) = polygonVertices[(i+1) % n]
if (y1==y2) continue // we are interested in edges intersecting with our scanline, if the y coordinates
// of the edges are similar, it means they are horizontal to our scan line such do not contribute to
// our filling logic so we ignore them
// get the yMin, yMax, x of yMin and the slope inverse
val yMin = minOf(y1,y2) // this is the minimum of the edge
val yMax = maxOf(y1,y2) // this is the maximum y value of the edge, using the yMinimum and yMaximum, we can get the vertical
// span of the edge
// the x coordinate of the current edge, it corresponds to the lower value of the y1 and y2. This x coordinate represents
// where the edge starts hence why we pick the lowest of the y1 & y2
val xOfYmin = if (y1<y2) x1.toFloat() else x2.toFloat()
// find the inverse of the slope.Essentially, a slope = change in y/ change in x
// but since we are moving along y not x, we need to find the value of x corresponding to the change in y
// thus, we do an inverse of the slop so slope = 1/m = x/y
// we will use this to increment the x value as we move horizontally
// along our scan line
val slopeInverse = (((x2 - x1).toFloat()) / (y2 - y1).toFloat()).let {
if (it == -0.0f) 0.0f else it
}
edges.add(Edge(yMaximum = yMax.toFloat(), xOfYmin = xOfYmin,
slopeInverse = slopeInverse, yMinimum = yMin.toFloat()))
}
// since our scan lines are processed per their y-values, we should sort with yMin first then if two edges start
// start at the same y-coordinate, sort them using xOfYMin
return edges.sortedWith(compareBy({it.yMinimum},{it.xOfYmin}))
}
// instead of drawing the pixels directly, we can use a 2d buffer array
// the rows are y coordinates, columns are x coordinates so each entry here represents one pixel on the screen ,
// since they are unfilled, all cells would be initialized to 0
typealias PixelBuffer = Array<IntArray>
fun scanlineFill(polygonVertices: List<Point>,
doAnimate: Boolean=false,
drawPixel:(x:Int, y:Int)->Unit,
renderBuffer:(()->Unit)?=null){
//if we have two points it means it is a line, not a polygon
// one point means it is just a dot on the screen
check(polygonVertices.size>2){"not a valid polygon. You have given us a point or a line segment"}
val edges = buildEdgeTable(polygonVertices).toMutableList()
val activeEdges = mutableListOf<Edge>()
// find the minimum and maximum Y to know the scanLine range
val yMin = polygonVertices.minOf { it.y }
val yMax = polygonVertices.maxOf { it.y }
for (scanline in yMin..yMax){
// add the edge where yMin == scanLine because this is where the scan line enters the polygon
val edgesToAdd = edges.filter { it.yMinimum.toInt() == scanline }
activeEdges.addAll(edgesToAdd)
edges.removeAll(edgesToAdd)
// remove edges where yMaximum == scanline because such edges are no longer active
activeEdges.removeAll{it.yMaximum.toInt() == scanline}
// sort active edges using current x intersection
activeEdges.sortBy { it.xOfYmin }
// fill pixels between the pairs of intersections
var i =0 // we need this to track the current edge,
while (i < activeEdges.size-1){
val xStart = activeEdges[i].xOfYmin.toInt()
val xEnd = activeEdges[i+1].xOfYmin.toInt()
for (x in xStart until xEnd){
drawPixel(x, scanline)
}
i +=2 // jump by 2 because we are working on the edges in a pairwise motion
// a vertex has two edges, one going in and one going out, we need to use a step of 2 instead of 1 to
// fill up the area between the edges correctly
}
// update the xOfYmin for next scan line
activeEdges.forEach { it.xOfYmin+=it.slopeInverse }
// print the buffer here
if (renderBuffer!=null && doAnimate){
renderBuffer()
Thread.sleep(200)
}
}
}
const val FillValue =1 // the fill value represents a filled pixel. You can use a byte for a compact rep.
fun bufferedScanlineFill(polygonVertices: List<Point>,
buffer: PixelBuffer,doAnimate: Boolean,
renderBuffer:(()->Unit)?=null){
scanlineFill(polygonVertices, doAnimate = doAnimate, renderBuffer = renderBuffer, drawPixel = {x,scanline->
// on row y which is our scan line, fill a particular color between this x and x
// we need to check bounds to ensure we don't fill sth outside our buffer height. Scan lines outside the height
// ought to be ignored
if (scanline in buffer.indices && x in buffer[0].indices){
buffer[scanline][x] = FillValue
}
})
}
fun main() {
// Point(10, 10), Point(30, 10), Point(30, 30), Point(10, 30)
val vertices=listOf(
Point(5,2), Point(15, 2), Point(10,7)
)
val edgeTable =buildEdgeTable(vertices)
testEdgeTableProduction(edgeTable)
val width = 20
val height = 10
val pixelBuffer = Array(height){ IntArray(width) }
bufferedScanlineFill(vertices, pixelBuffer, doAnimate = true, renderBuffer = {
printBufferOnTerminalScreen(pixelBuffer)
})
}
fun testEdgeTableProduction(edgeTable: List<Edge>){
val firstVerticalEdge=Edge(yMaximum=30.0f, yMinimum=10.0f, xOfYmin=10.0f, slopeInverse=0.0f)
val secondVerticalEdge=Edge(yMaximum=30.0f, yMinimum=10.0f, xOfYmin=30.0f, slopeInverse=0.0f)
// was used for testing
// check(edgeTable.isNotEmpty() && edgeTable.contains(firstVerticalEdge))
// check(edgeTable.isNotEmpty() && edgeTable.contains(secondVerticalEdge))
edgeTable.forEach {
println(it)
}
}
private const val AnsiClearScreenCode ="\u001b[H\u001b[2J"
private const val GreenFilledAnsiCode ="\u001b[32m#\u001b[0m"
private const val GreenUnfilledAnsiCode="\u001b[32m-\u001b[0m"
fun printBufferOnTerminalScreen(buffer: PixelBuffer,
highlightScanLineY:Int =-1){
print(AnsiClearScreenCode)
for ((y, row) in buffer.withIndex()){
if (y==highlightScanLineY){
println(row.joinToString(""){
when(it){
FillValue-> GreenFilledAnsiCode
else->GreenUnfilledAnsiCode
}
})
}else{
println(row.joinToString(""){ if(it == FillValue) "#" else "."})
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment