前言

我实现了一个SDL3KMP绑定:sdl-kmp。之后又基于这个绑定写了一个软光追渲染器:KotlinNativeFortuneRenderer

其中软光追部分是基于这个教学视频合集实现的,我主要是使用Kotlin Multiplatform重新实现了一遍,并且接入了自己写的sdl-kmp作为窗口和像素显示层。

效果如下:

sdl-kmp

sdl-kmp是一个SDL3Kotlin Multiplatform绑定。它的目标不是把SDL3的所有底层细节都暴露出来,而是在commonMain中提供一套比较好用的公共API,让窗口创建、事件处理、渲染器、纹理、颜色、矩形这些常用能力可以直接跨平台使用。

它的底层实现分成两部分:

  • JVM平台使用LWJGL提供的SDL3绑定。
  • Kotlin/Native平台使用cinterop调用SDL3,并且会为不同平台编译SDL3静态库。

Native这边比较重要的一点是,SDL3静态库会被嵌入到发布的klib中,所以使用者不需要再手动准备SDL3动态库。对使用者来说,只要添加依赖,就可以在commonMain中直接使用。

目前支持的平台包括JVMmacOSLinuxWindowsiOStvOS。不支持Android,因为LWJGL本身不支持AndroidJS/WASM也不在这个项目的范围内。

使用方式如下:

implementation("cn.enaium.sdl:sdl-kmp:1.0.2")

之后就可以使用SDL.initSDL.createWindowSDL.createRendererSDL.pollEvent这些接口。项目中还提供了一个简单的example模块,用来测试窗口、事件和基本绘制。

比如创建一个什么都不画的空窗口,代码大概是这样:

import cn.enaium.sdl.*

fun main() {
    SDL.setMainReady()

    if (!SDL.init(SDLInitFlags.VIDEO)) {
        error("SDL_Init failed: ${SDL.error()}")
    }

    SDL.createWindow("hello sdl-kmp", 800, 600).use { window ->
        SDL.createRenderer(window).use { renderer ->
            var running = true
            while (running) {
                while (true) {
                    val event = SDL.pollEvent() ?: break
                    when (event) {
                        is SDLEvent.Quit -> running = false
                        is SDLEvent.Key -> {
                            if (event.down && event.keycode == SDLKeycode.ESCAPE) {
                                running = false
                            }
                        }
                        else -> Unit
                    }
                }

                renderer.drawColor = SDLColor(18, 18, 24)
                renderer.clear()
                renderer.present()

                SDL.delay(16)
            }
        }
    }

    SDL.quit()
}

这里的流程基本就是初始化SDL,创建窗口和渲染器,循环读取事件,然后每帧清空窗口并提交。use用于自动释放窗口和渲染器资源,按关闭按钮或者ESC时退出循环。

Fortune Renderer

KotlinNativeFortuneRenderer是我用sdl-kmp写的一个软光追渲染器。这个项目本身也是KMP项目,支持JVMmacosArm64linuxX64

它的commonMain主要用了这些依赖:

commonMain {
    dependencies {
        implementation("cn.enaium.sdl:sdl-kmp:1.0.2")
        implementation("cn.enaium.glm:glm-kmp:1.0.0")
        implementation("io.github.vinceglb:filekit-core:0.15.0")
        implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
        implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0")
    }
}

其中sdl-kmp负责窗口、事件、纹理和像素显示;glm-kmp负责向量、矩阵这些数学计算;filekit-core用于跨平台读取场景文件;kotlinx-serialization-json用于解析JSON场景配置;kotlinx-coroutines-core则用于渲染任务和异步流程的组织。

这个项目的主要目的不是做一个完整的商业渲染器,而是验证几件事:

  • sdl-kmp是否能支撑真实的跨平台图形程序。
  • Kotlin/Native是否可以比较方便地编写本地窗口程序。
  • 纯软件路径追踪能否在合理的优化下达到可接受的速度。

渲染器最终输出的是一块像素缓冲区,SDL负责把这块像素数据更新到纹理中并显示到窗口。这样渲染部分完全不依赖GPU光栅化管线,所有光线求交、材质计算、采样和递归追踪都在CPU中完成。

渲染能力

目前渲染器已经支持基本的路径追踪流程,包括多重采样、直接光照、阴影、间接光和金属反射。

几何体方面支持:

  • 球体
  • 三角形
  • 圆盘

材质方面支持:

  • Lambert漫反射材质
  • Conductor金属材质

灯光方面支持:

  • 方向光
  • 点光源
  • 聚光灯

场景使用JSON配置,可以在配置文件中设置相机、物体、材质和灯光。这样调整场景时不需要重新修改代码,只需要改配置文件即可。

性能

软光追的计算量比较大,如果单线程逐像素计算会非常慢。所以这个项目中使用了多线程渲染,每个 worker 从一个原子计数器中取像素下标,然后计算对应像素。

这样做的好处是很直接:

  • 每个像素之间基本没有依赖,适合并行。
  • 不需要复杂的任务调度系统。
  • 已经渲染出来的像素可以立刻显示到窗口中。

窗口显示部分使用SDL的流式纹理,每帧把当前像素缓冲区更新到纹理中。也就是说渲染不是等整张图片完成后才显示,而是可以看到画面逐渐收敛。

项目还支持headless模式,在没有显示环境的服务器或者CI中,可以通过SDL_VIDEO_DRIVER=dummy运行。

总结

这次主要做了两个项目。第一个是sdl-kmp,用于让Kotlin Multiplatform项目可以方便地调用SDL3。第二个是KotlinNativeFortuneRenderer,它基于sdl-kmp实现了一个跨平台软光追渲染器。

整体做下来,KMP写这类跨平台本地程序是可行的。SDL负责窗口和输入输出,Kotlin负责上层逻辑和渲染算法,Kotlin/Native负责生成本地程序。之后如果继续优化,可以加入BVH、分块调度、更多材质和更好的采样策略。

项目地址: