0xAA55 发表于 2025-7-19 20:34:32

【VB6】【全网唯一】纯 CPU 光追地形渲染,60 fps 流畅实时渲染

# 使用 Visual Basic 6.0 实现纯 CPU 光追地形渲染,60 fps 流畅实时渲染

![效果示例](https://www.0xaa55.com/misc/altitudemap.gif)

## 仓库地址

(https://github.com/0xAA55/VB6_CPU_AltitudeMap3D)
(https://gitee.com/a5k3rn3l/altitude-map)
两个仓库是完全同步的,如果你不能访问 GitHub 上的仓库,那就用 Gitee。

## Raymarch 光追算法原理

Raymarch 光追技术是一种对每个像素,计算出视线方向,然后进行有限次数的循环步进方式去计算视线与**置换纹理**(displacement texture)所表达的高度图的相交点,在图形学上用于简易光线追踪方案。

由于这个算法的复杂度可以被优化得很低,即使是古老的编程语言 Visual Basic 6.0,也能仅依赖纯 CPU 进行流畅的实时光追渲染。

这种光追算法仅用于计算视线与物体的相交点,不提供物理光学计算(漫反射统计等),是一种取代传统三角面几何的新渲染方式。

Raymarch 光追算法使用多种方式来计算视线向量的步进量,针对特定几何体可以使用针对几何体设计的 SDF (Signed Distance Function)函数来计算距离值,遍历整个场景的所有几何体,选择最小步进值进行步进。

我们当前的代码示例用的是别的算法:针对 Displacement 贴图进行的相交计算。Displacement 贴图的每个像素表达的是该点距离平面的高度值。

针对 Displacement 如何设计合理的步进值呢?最无脑的算法就是固定一个步进值,然后判断视线是否埋入了物体(地形)。

但是也有更聪明的算法,如图:

![算法示意图](https://www.0xaa55.com/misc/raymarch_demo.png)

我们遍历 Displacement 贴图的每一个像素,再遍历这个像素周围的所有像素是否比当前像素高,如果有,则根据该像素的距离计算陡峭度,并最终存储最高的陡峭度,做成**K-map**,它的每个像素值为从该像素垂直向上发出的圆锥的锥度。

然后视线向前步进的时候,从当前视线所在的二维方向采样 K-map 得到圆锥的锥度,然后让视线与圆锥进行相交,以此做到**优雅的步进**。

## 地图制作

### 使用 PBR 纹理作为地图源

一套 PBR 纹理,包含颜色、法向量、 **置换纹理** (Displacement)、金属度、粗糙度、环境光遮蔽等各个通道(每个存储为 JPG,而置换纹理因为需要高精度,使用 HDR 或者 EXR 方式存储)

但是因为懒得写 JPEG/HDR/EXR 的解析器了,我就干脆使用 BMP 格式来加载所有的纹理。这个时候出现了问题:BMP 的精度不够,它的灰阶是 256 阶的,作为置换纹理,显然有点 **锯齿感**。好办,加载的时候应用一个小半径的高斯模糊就行了,就可以平滑过渡。为了加快加载的速度,我把处理好的 **置换纹理** 按照每个像素一个 `float` 值(在 VB6 是 `Single` 值)的方式,存储为 `altmap.bin`。

按照 Adobe 的命名风格,「置换纹理」的名字被叫做「高度图」,当时写的时候我没有意识到这个东西更合理的名字是「置换纹理」。

当年虚幻引擎为了吸引用户,使用虚幻账号登录 Quixel Bridge 的时候,所有的 PBR 表面都可以免费下载。后来不免费的时候,我[使用 Stable-Diffusion 配合 Adobe Substance 也可以生成 PBR 纹理](https://www.0xaa55.com/thread-27496-1-1.html)。

### 光追加速:生成圆锥图(K-map)

首先 VB6 不是那么的对现代 CPU 友好,它没有 SSE 优化,生成的 CPU 指令比较原始。所以固定步长的 Raymarch 显然是不合适的,需要借助一点 **数学的魔法** ,精确计算最合适的步进步长。

生成圆锥图的过程中,我 **借助了 OpenGL** 的 Fragment Shader,对置换纹理的每个像素,遍历其周围的像素,找到「陡峭度」最高的像素(陡峭度 = 高度差 ÷ 距离),然后存储「陡峭度」作为圆锥的锥度,从而生成 K-map。

在这个过程中,我顺手[给 VB6 搓了个 GLEW 的 Wrapper](https://www.0xaa55.com/thread-25994-1-1.html),这样我就能在VB6 里调用 OpenGL 了。

### 交互加速:针对用户操作的角色状态,生成物理碰撞图

物理碰撞图,同样也是使用 Raymarch 的方式,计算用户操作的角色的射线与地形相交的点或者距离,来限制用户操作的角色的位置,使用户角色不会陷入地里。

给高度图叠加一层角色身高值,再使用「表面模糊」使更高的表面可以向四周扩张,以这种方式可以制造出角色的「走路图」(`walkmap`),然后为了实现 Raymarch,需要再给「走路图」生成圆锥图,「走路 K-map」。

### 光照模型:典型 N·L

虽然用了光追,但毕竟这是 VB6,纯 CPU,使用光追只是为了计算视线到地形的交点。得到交点后,采取 `(x, z)` 坐标,从纹理中采样。同时采取 `albedo`(颜色贴图)和法线图,根据预设的太阳位置,计算出亮度,然后把亮度叠加到颜色贴图上(乘算),进行着色处理。

因为地形是无限延伸的,简单的 `N · L` 只能渲染出非常失真的画面。为了体现远近效果,我在之前的光照上,再追加了 Fog(雾)效果。也就是根据像素到眼睛的距离,进行线性插值,距离越远的像素,越接近雾的颜色,超过视距的像素就完全是雾的颜色了。

有了雾的颜色,那还要计算天空的颜色。这个也很简单,越接近地平线部分的天空,颜色越接近雾(同样使用线性插值);越接近天顶的部分的天空,颜色越接近天顶(~~废话~~)。最后就是对太阳的处理——算了,我懒得弄,就假定太阳光是垂直往下照向地面的,也省得处理阴影了(太阳光垂直往下照的时候,置换贴图表达的地形是无法产生阴影的)

## 渲染加速:在 VB6 使用线程池

### 如何在 VB6 开启多线程

[详细请参阅此处](https://www.0xaa55.com/thread-26630-1-1.html)

首先[原生的 VB6 是支持多线程的](https://www.0xaa55.com/thread-26715-1-1.html),原理是使用 **Active-EXE** 工程,在工程属性界面编辑为「以独立方式启动」「每个对象对应一个线程」,然后用 `CreateObject()` 创建对象,就可以创建「线程」。这种方式是 VB6 官方提供的多线程方案,稳定可靠,虽然能以多线程的方式运行(使用多个 CPU 线程来运行每个对象实例),但是每个线程之间的内存空间不是共享的,而是独立的,对通常的 Windows 开发者而言,比较反直觉。实际上差不多就是 Linux/Unix 的 `fork()` 方式提供的多 CPU 线程服务的那种感觉。

为了能实现符合直觉的内存共享的多线程方案,有个作弊的手法,就是使我们的 VB6 程序使用 `CreateThread` 启动线程时,该线程的行为是假装自己是 ActiveX Dll 然后调用 MSVBVM60.DLL 的 Dll 相关初始化代码(`UserDllMain` 和 `VBDllGetClassObject`),就能完成当前线程内 VB6 运行时环境的初始化。

需要注意:每次初始化时,`Sub Main()` 都会被调用一次。逻辑上,需要避免 `Sub Main()` 被重复调用,或者被重复调用后检测到重复调用,再用 `Exit Sub` 退出过程。我的做法是编写一个假的 `Sub Main()`,改名为 `Sub DummySubMain()`,在创建线程的时候,找到 EXE 中的 `VBHeader` 结构体,将其中的 `lpSubMain` 的值指向 `AddressOf DummySubMain` 就可以了。

### VB6 的线程池封装

光是解决了启动多线程的问题还只是第一步,如何调度每个线程进行工作才是之后的重点。 **低效的调度会导致性能反而不如单线程计算** 。首先得确保实时运行时的线程数可控,不能无脑调用 `CreateThread()`,其次确保每个线程被安排上了任务。减少线程调度造成的 Gap,可以达到优化性能和降低 CPU 使用率(提高调度的有效性)的目标。

因此我设计两个模块:`modMTMain.bas` 用于帮助线程函数初始化 VB6 环境,`modMTPool.bas` 用于维护线程池。使用 `GetSystemInfo()` 获取当前处理器的线程数,以此作为创建线程池时的线程数的大小参考。然后因为 VB6 并不能方便而灵活地通过一个函数指针来动态调用指定的函数,我采用 `CallWindowProc()` 的方式来调用函数指针,这样的话就会导致需要函数必须要接受四个参数并且退出的时候自己平栈。使用一个结构体用于存储函数指针和四个函数,然后根据线程池的大小,维护这个结构体数组。线程池的空闲线程不断遍历数组,取出待调用的函数和参数,使用 `CallWindowProc()` 调用函数,再返回,然后重新遍历数组,以这种方式维护线程池。(虽说效率比较低,但是简单粗暴)

```
Private Function ThreadPoolProc(ByVal Params As Long) As Long
ThreadInit
Do
    If MT_Status = 1 Then
      Dim Work As Long
      If MT_WorksToDo > 0 Then
            Work = InterlockedDecrement(MT_WorksToDo)
            If Work >= 0 Then
                CallWindowProc MT_Works(Work).FuncPtr, MT_Works(Work).Param1, MT_Works(Work).Param2, MT_Works(Work).Param3, MT_Works(Work).Param4
                InterlockedIncrement MT_WorksFinished
            Else
                Sleep 0
            End If
      Else
            Sleep 0
      End If
    Else
      Sleep 0
    End If
Loop While MT_Status >= 0
ThreadQuit
ExitThread 0
End Function
```

## 数学计算调优

其实我可以实现在运行时,使用 `VirtualProtect` 把数学库的所有函数的二进制指令都改成可写,然后写入预先写好的 SSE Shellcode,来达到向量计算的优化。但是既然当前的性能已经足够(测出帧数达到 60 FPS,高刷屏不考虑),那就不用再优化了。我要体现的是 Raymarch 算法本身的优化性能,莫得必要画足添蛇。

## 图形渲染调优

不过在图形渲染上依然有可以让 CPU 得以偷懒的地方——因为地形的渲染存在横向分辨率的不敏感特性,我把横向的分辨率 ÷ 4,然后再在渲染到窗口时横向拉伸 4,节省了 4 倍的地形 Raymarch 成本。此外我参考了古时的隔行扫描技术。隔行扫描其实会严重降低动态的观感,我反正不喜欢,但是可以采用相近的策略—— **「隔像素扫描」** 。虽然也影响动态的观感,但是至少看起来操作更跟手一些,不容易导致 3D 眩晕(对我而言)。

除了隔像素扫描,还有一种就是弱化旧帧——新帧并不完全 100% 覆盖旧帧,而是以线性插值的方式融合旧帧,这样的话可以减少隔像素扫描的突兀感。但是实际测试了一下感觉好像更糊了,我就没有开启这个功能。

## 结论

在古老的 Visual Basic 6.0 上,我通过各种作弊的方式成功实现了流畅的 Raymarch 光追渲染。既然 Visual Basic 6.0 都能做到流畅的 Raymarch 渲染,那么反过来思考,如果这项渲染运行在 GPU 呢?显然是非常可行而且成本低廉的。实际上我也在 Shadertoy 上投了稿,展示了「[动态生成 K-map,将电影作为地形图进行渲染](https://www.shadertoy.com/view/WlsfDj)」的效果(嗷嗷叫的老马表示这个画面令人感到恶心,就像是大型动物的肠道内壁褶皱的蠕动的感觉)

!(https://www.0xaa55.com/misc/raymarch_gpu.gif)

在 Shadertoy 上,我通过增量式的 K-map 的陡峭度值扩散的功能,解决了计算 K-map 的高计算密集性问题,实现 GTX 1080 接 2560x1440 屏幕的 144 Hz 刷新率实时渲染。

woeoio 发表于 2025-7-19 22:11:19

沙发

woeoio 发表于 2025-7-19 22:19:35

big old

戈登走過去 发表于 2025-7-20 13:36:34

好酷

gujin163 发表于 2025-7-22 09:12:12

太强大了,感谢分享!!
页: [1]
查看完整版本: 【VB6】【全网唯一】纯 CPU 光追地形渲染,60 fps 流畅实时渲染