Created
April 19, 2025 13:03
-
-
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.
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 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