ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

VoxelPixel体素大师:从密度场到三维场景构建与优化实战

VoxelPixel体素大师:从密度场到三维场景构建与优化实战 1. 项目概览为什么一堆小方块能做出大作体素Voxel这个词最早是从“体积像素”引申出来的本质上就是三维空间里一个个带有属性信息的小格子。你可以把它理解成2D图片像素的3D版孪生兄弟像素是二维平面上的最小显示单位而体素则是三维空间中的最小体积单位。这些年只要聊到《我的世界》、RTX显卡的体素渲染演示、或是医学影像的三维重建背后都离不开这套概念。VoxelPixel体素大师这个项目核心就是用三维像素化的手段去构建、编辑、渲染场景让普通开发者也能在Web端或本地方案里快速拿到一套可用的体素场景生成能力。这个项目解决的是什么问题呢传统三维场景构建往往依赖建模师在Blender、Maya里手动拉模型、刷材质周期长、上手门槛高。而体素化方案把一切问题都转换成了“你只需要决定每一个小格子放什么、放不放”配合程序化生成规则和噪波算法一分钟就能搭出风格化极强的场景骨架。再叠加体积光、边缘描边、雾效后处理视觉上立刻就有“独立游戏宣传图”内味儿。适合的人群也很明确独立游戏开发者、AR/VR原型设计师、想做风格化场景展示的图形学爱好者以及那些对程序化生成有兴趣、但还没找到落地入口的初学者。我最初接触到VoxelPixel时正是被它“参数驱动场景”这套思路吸引的——不需要一块砖一块砖去摆而是让高度图、噪声、密度场来决定每一个体素的去留。文章会围绕这个项目从算法选型、数据结构、场景构建实操到性能优化的完整链路展开最后把我在实机运行中遇到的几个坑也一并写出来。内容尽量保持“拿来就能用”的颗粒度希望给想入坑体素的朋友省点弯路。2. 设计思路与整体架构拆解2.1 从像素思维到体素思维的关键跃迁做2D像素画的时候你操作的是一张二维网格每个像素点有颜色值、有透明度图层之间还有上下遮挡关系。到了三维体素场景问题变成了三个维度每个体素至少需要存储坐标位置x, y, z、颜色或材质索引、以及一个状态标记是被空气占据还是有实体占据。听起来只是“多了一维”但数据量和复杂度是立方级别的增长。举个例子一个64×64×64的体素场景总共有262144个体素点位哪怕每个体素只存一个字节的占用标记也需要256KB的内存如果再加颜色、法线、材质索引轻轻松松上MB级别。这才只是小场景要是做到256³分辨率体素数量直接超过1600万简单数组存储已经不够用了必须上压缩数据结构和空间索引。VoxelPixel的处理方式很务实它在架构上把体素场景看作一个“规则密度的场”而不是一个“任意的模型集合”。什么意思呢就是你不用一开始就把所有体素都塞进内存而是通过一个生成函数实时地决定“这个坐标点是否属于固体”。这样做的好处是场景在逻辑层面是可解析的不需要像网格模型那样先凹好顶点再烘焙到显存。真正落地到代码里就是用隐式函数描述地形或物体给定一个三维空间坐标函数返回一个标量值大于阈值的就是实体小于阈值的就是空气或空腔。这也是后续所有体素操作挖洞、填充、地形生成的统一入口算得上整个项目最值得先理解清楚的设计点。2.2 场景构建的技术选型为什么不用直接建模如果只是想要“体素风”的画面其实还有一条偷懒的路把高模或多边形模型栅格化转换到体素空间形成近似表示。这种方式确实简单粗暴很多点云处理的库也有现成接口但问题在于第一转换过程容易丢失细节尤其是曲面和薄壁结构第二让艺术家先做高模再转体素等于把工作流程倒过来没能发挥体素本身“程序化生成”的优势。VoxelPixel从一开始就不搞模型导入转换这一套而是以生成器Generator为核心把高度图、Perlin噪声、分形布朗运动、圆锥射线等模块统一封装成体素生成器链让场景本身直接从参数里“长”出来。这个选型背后有一个很关键的原因体素场景天然适合分层生成。你可以把山体生成器挂在第一层把洞穴挖空器挂在第二层再把植被点缀生成器挂在第三层每一层接收上一层的输出体素状态并叠加自己的修改。这样做的工程价值极其明显——所有处理节点都能独立测试、独立复用后面想加一种地形风格不需要推翻重写只需新增一个生成器节点。我在自己项目里验证过另一条路线纯用距离函数SDF做体素雕刻虽然精细度更高、边缘更平滑但计算量非常大尤其在JavaScript这类非AOT编译环境里一帧内如果跑几千次SDF采样帧率很难保住。所以VoxelPixel最终采用的分层密度场方案是综合了表现力和性能之后的一个取舍。如果想追求光滑表面可以在体素化之后接一个Surface Nets或Marching Cubes的等值面提取后续会在实验章节专门聊这个。2.3 数据结构的选择稀疏体素哈希与八叉树场景一大最怕的就是拿着一个三维数组硬扛。三维数组虽然读取快、索引直观但内存浪费严重一个256³的场景假设每个体素只存一个字节占用标记也得16MB这还不算颜色、法线和多材质。真实场景里大部分空间都是空的比如空中、地下、洞穴内部这些空气体素存下来纯属浪费。所以在数据结构层面VoxelPixel采用了稀疏体素哈希Sparse Voxel Hash的思路只有被标记为“非空”的体素才真正分配存储空间用一个哈希函数把三维坐标x, y, z映射到一维桶数组里。这样内存占用基本上只跟实际表面体素数量成正比而不是跟包围盒体积成正比。对于典型地形场景表面体素大约是总体积的百分之几内存能省一大截。不过哈希表也有代价坐标寻址不再是O(1)的数组索引而是要算哈希、走冲突链虽然均摊下来还是常数级但常数变大不少。所以我在项目里兼顾了两层结构顶层用八叉树做粗粒度空间划分每个八叉树叶子节点对应一个小体素块比如8×8×8的Voxel Block块内再用紧凑数组存储。这样做的好处是查询一个体素时先走八叉树定位到块再在块内做数组索引既有空间稀疏性的优势又避免了纯哈希方案里每个体素都做一次哈希运算的额外开销。实测下来场景生成速度比纯哈希方案快了不少。表三种体素存储方案的对比方案内存效率查询速度实现复杂度适用场景三维数组低极高极低小型固定场景 64³八叉树高中中中等场景有粗粒度稀疏特征稀疏哈希极高中高中高大规模场景大量空洞八叉树块数组高高较高大规模体素场景推荐3. 核心实现链路从密度场到可见体素3.1 密度场生成让算法决定哪一块放砖体素场景的源头是“密度场”——空间里每个坐标点都有一个密度值密度大于阈值的区域被判定为实体。地形场景里最常见的做法是先把二维高度图按坐标采样得到某一列体素的基准高度然后在基准高度上下叠加细节噪声。高度图本身可以用Perlin噪声、Simplex噪声或者Fractional Brownian Motion分形布朗运动来生成。我个人的经验是不要只用一层Perlin噪声那样出来的地形会非常“绵软”山不山、坡不坡像一坨揉皱的纸。正确的做法是用分形叠加也就是FBM取多个不同频率、不同振幅的噪声层叠起来低频层定大形高频层加细节。频率和振幅的配比通常遵循一个“倍频递减”的规律每增加一倍频率俗称一个Octave振幅减半或者按你的风格系数衰减。这样既能保证山脉的连续起伏又不会让地表细节显得过于平滑或过于散乱。明确到工程造价上我自己常用的FBM参数是基础频率0.02到0.04Octave层数在4到6层衰减系数0.5频率倍增系数2。以64×64×64的区块为例单线程生成整块地形大约需要100到200毫秒如果用了三层生成器链山体、洞穴、植被整体耗时大概会涨到400毫秒左右。这个速度在做预处理生成时完全够用但如果是运行时动态加载新区块就要考虑把生成任务丢给Web Worker或者提前预生成。体素编辑器里常用的几个操作本质上也是对密度场的修改雕刻笔刷是在局部区域把密度值抬升或扣减平滑笔刷是对局部区域做均值滤波让密度变化更柔和而填充操作是设定一个范围然后强制把所有体素标记为实体或空气。这一层抽象非常统一任何时候你想新增一种笔刷只需要定义“这个笔刷对一定半径内体素的密度值做怎样的数学修改”然后丢给核心调度器就行。3.2 邻域查询与表面识别从一堆方块里找出“皮”体素数据生成之后紧接着要回答一个问题哪些体素是“暴露在空气里的表面”这个问题直接决定了我们渲染哪些体素、怎么给体素添加光照、以及如何生成可导出的网格。判断逻辑其实很朴素遍历所有非空体素检查它的六个轴向邻居正负X、正负Y、正负Z是否存在空体素。如果至少有一个邻居为空那这个体素就属于表面体素如果六个邻居全是实体那它纯粹是内部填充物渲染时可以直接跳过。这个步骤看着简单但却是整个系统性能的关键点之一。假如你暴力地遍历整个场景中的所有体素每个体素做六次邻居查询那么体素总数为N时时间复杂度是O(N)。听上去不算差可当N是百万量级时每帧都干这活儿绝对吃不消。所以正式实现里要做两件事第一只在场景数据发生变化的区域执行局部重算而不是全量扫描第二把邻域查询下沉到数据结构的块级操作尽量避免每一次都走哈希函数。还有一个细节容易被忽略体素的“面朝向”。表面判断是确定有哪些面需要绘制而朝向决定了该面的法线方向。比如一个表面体素的Y邻居是空气那就说明它的顶面是暴露的需要一个顶面Quad如果-X邻居是空气则需要一个左侧面Quad。六个方向分别处理一次性生成该体素需要绘制的全部Quad顶点。这个面片化过程其实就是在把体素数据转换为标准的渲染图元。你不需要每个体素画六个面只画露出来的面能省掉大量三角面片。这也是体素引擎性能优化的第一课面片数要尽可能少哪怕数据是“实心”的。3.3 从体素到MeshSurface Nets与Marching Cubes的取舍有了体素模型之后接下来有两个截然不同的走向一种是把体素直接渲染成“方块感”保留那种棱角分明的像素美学另一种是提取等值面把体素转成光滑网格获得类似雕塑的圆润表面。VoxelPixel在默认风格上保留了方块感但如果你想做光滑风格比如沙丘、流体、角色模型生成Mesh的底层方案仍然是必备的。最有名的两个算法是Marching CubesMC和Surface Nets。Marching Cubes的原理是把每个体素看作一个立方体单元检查它的八个顶点密度值如果密度等值面穿过了这个单元就根据“哪几个顶点大于阈值、哪几个小于阈值”查表生成三角面片。MC有一个著名的“含糊面”Ambiguous Face问题处理不当会在网格里出现孔洞或裂缝标准解法是额外做渐变性判断或采用改进变体。Surface Nets是另一个思路不生成三角面片拓扑而是对每个穿过等值面的体素单元在单元内部计算一个“最优顶点位置”再用连线把相邻单元里的顶点连起来。这个方法的好处是顶点数量更少、网格结构更规则、过渡更自然特别适合体素数据本身带有噪声或动画的情况。我在自己项目里做角色流体效果时明显感觉Surface Nets后处理出来的网格拓扑更干净而且性能也好于MC。如果你只是想做地形和建筑风格化渲染我个人建议别急着上光滑提取先跑通方块渲染管线把颜色、光照、雾效调顺再考虑要不要加Mesh化导出。因为MC和Surface Nets都涉及顶点焊接、法线重算、UV映射工程量不小投资收益比得仔细权衡。4. 实操过程与完整步骤手把手搭建一个体素场景4.1 环境与依赖准备在装环境前先说一句VoxelPixel本身不依赖重型游戏引擎但如果你想看到实时画面还是需要一个渲染后端来显示结果。我用的方案是TypeScript WebGL2这样浏览器里直接跑方便调试和分享桌面端也可以用Three.js或Babylon.js作为渲染层但要注意它们提供的Voxel组件很少最终还是得自己写数据结构。依赖清单如下Node.js 16 和 npm/yarnTypeScript 4.x可选但强烈推荐体素代码类型一多纯JavaScript很难维护WebGL2支持环境Chrome/Edge/Firefox都行可选的Mesh优化库meshoptimizer导出glTF时很有用如果你不想折腾前端工程化也可以直接用原生JavaScript写一个demo页面把所有功能封装在一个类里。但真实工程里体素数据结构和渲染器最好分离数据层不跟WebGL绑定渲染器只是“读取体素数据并画出来”。这样后续换渲染后端、做离线烘焙、做物理碰撞检测都方便得多。4.2 核心数据结构实现哈希八叉树怎么写这里给出一个简化的核心代码骨架展示怎么组织上文的哈希八叉树结构。不追求完整工程但关键思路都在// 体素数据的基本类型 type VoxelData { color: [number, number, number]; // RGB颜色 material: number; // 材质索引0代表空气 density: number; // 密度值用于等值面提取 }; // 8x8x8的小块内部用数组存储 class VoxelBlock { static SIZE 8; data: Uint8Array; // 材质索引 密度打包 colors: Uint8Array; // 颜色索引 } // 稀疏八叉树节点 class SparseOctree { children: Mapnumber, SparseOctree | VoxelBlock; depth: number; getVoxel(x: number, y: number, z: number): VoxelData | null { // 按八叉树下沉到叶子块 } setVoxel(x: number, y: number, z: number, data: VoxelData): void { // 修改体素同时标记脏区域供后续面片重建 } }这里有个工程经验值得强调getVoxel和setVoxel尽量做成内联函数避免频繁入栈出栈。体素引擎动辄几百万次体素访问函数调用开销会被放大得很明显。C或Rust实现里甚至可以宏展开/模板内联TypeScript里虽然做不了那么极致但减少闭包捕获、使用扁平数组而非对象数组也能带来可见的速度提升。表面识别和quad生成的核心逻辑可以封装成一个Mesher类遍历所有非空体素这一步要维护一个“活跃体素列表”而不是扫描全世界。判断六个邻居把可见面ID收集好。根据面ID生成顶点坐标、法线和颜色。把所有quad顶点推入缓冲区等待送入GPU。4.3 地形生成器从参数到山体的完整链路实操里我最常用的一套参数组合是这样的const terrainGenerator { seed: 20240001, baseHeight: 24, fbmOctaves: 5, fbmLacunarity: 2.0, fbmGain: 0.5, frequency: 0.025, amplitude: 1.0, caveThreshold: 0.1, treeDensity: 0.02 };每一步按顺序执行使用seed初始化伪随机数流保证每次生成可复现。对每个(x, z)采样FBM噪声算出该列的地表高度h。对每个(x, y, z)比较y和h如果y h则该体素初始为石头或草皮材质如果y h则为空气。追加洞穴生成器以3D Simplex噪声产生一个洞穴密度函数在阈值区间内把原有的实体体素改成空气。追加植被生成器在符合坡度条件的实体表面上以一定概率生成树干体素和树叶体素。这套链路跑完之后一个基础的可探索地形就算成型了。实际测试中64×64×32的体素区块共131072个坐标点在核心i5处理器上生成耗时在150毫秒到350毫秒之间主要浮动取决于洞穴层和植被层的Octave数。如果把这个过程放到Web Worker里异步执行UI线程完全不会卡顿玩家体验会好很多。4.4 渲染与后处理让方块有“温度”方块只是几何体的骨架真正让体素场景有视觉张力的是光照和色调映射。我在这套项目里主要做了四个后处理环节体素AO环境光遮蔽对每个表面体素采样其邻域的空隙程度计算一个从0到1的遮蔽系数。这项技术效果非常明显看似只是微弱的暗角瞬间能让方块堆叠显得更立体、更真实。雾效给远处体素混合一种统一的雾色既能隐藏区块边缘的“断层”又能营造空间纵深。边缘发光线在邻域高度变化剧烈的地方叠加一条亮色描边强化体素风的“马赛克剪影”感。色调映射把高动态范围的HDR颜色压缩到LDR我用的是ACES拟合曲线色彩过渡比简单Clamp自然得多。表渲染管线的关键参数参考参数推荐值说明AO采样半径1超过1后效果不显著性能开销增加雾起点60区块尺寸的一半较合适雾终点140超出场景包围盒即可边缘发光阈值0.2法线差异超过该值才描边色调映射曝光1.2可依据场景明暗微调灯光方面我用了一个方向光模拟太阳加一个半球光模拟天空散射。体素法线是轴向的所以传统逐顶点光照在平整表面上容易产生“斑马纹”需要一些法线扰动。经验做法是在像素着色阶段基于体素世界坐标的哈希值给法线添加一个微小扰动能有效消除过度平整感让表面看起来有细节。5. 常见问题与排查技巧实录5.1 场景一运行就内存飙升多半是数据结构出了问题这是体素引擎新手的通病把场景初始化为一个大三维数组然后所有体素都存了一个完整的颜色结构体。64³的场景可能还能撑住256³直接崩。排查思路也简单先统计一下“非实体体素”的比例——如果超过80%都是空气必然是存储浪费。解决办法就是上文提到的稀疏化存储把空体素从内存里剔除。一个额外技巧用Uint8Array/Float32Array这样的TypedArray替代普通JavaScript数组内存占用能再降40%左右。5.2 帧率低得离谱先查面片数量再查Shader帧率低最常见的逻辑链是场景里所有体素都画了六个面导致三角面片爆炸。一个64³的立方体如果六面全画光展示性地盒的GPU面片就多达数十万何况是复杂地形。解决办法很粗暴开启表面识别只绘制暴露面。如果做完表面剔除还是卡那就需要查Draw Call数量和Shader复杂度。每块VoxelBlock如果单独作为一个渲染批次场景里有几千个块那么Draw Call就会变成瓶颈。这时要么做批次合并Batch Merging把静态地形合成到一张大纹理和顶点缓冲区要么用实例化绘制Instanced Rendering把每块内相同材质的体素合成一个实例组。5.3 地形生成后同一坐标每次颜色不同种子设置问题这种情况几乎可以确定是随机函数没有正确使用种子。如果你调用的是全局Math.random()那每次刷新当然不一样。解决办法是实现一个带种子的伪随机数生成器比如mulberry32并把种子存入场景配置对象。所有生成器都从同一个上下文里取随机数这样即便拆分成多个Worker并行生成区块只要种子一致区块边界就能严丝合缝地拼合在一起。5.4 洞穴生成后地面塌陷阈值方向搞反了洞穴生成器的逻辑是在某个噪声值小于阈值时把实体替换成空气。但如果你把条件写反阈值判断成了“大于阈值变空气”那么在地表厚实的地方反而会挖出大片空洞洞穴连成一片导致结构失稳。排查时先在2D切片视图里打印密度分布肉眼确认阈值方向比直接看3D渲染要快得多。5.5 WebGL纹理数量太多纹理图集是必选项体素场景的材质种类通常不止个位数草地、石头、木头、树叶、水面各有不同颜色和细节。如果每一类材质都单独传一张纹理纹理绑定次数直接爆掉移动端根本扛不住。解决办法是把所有小纹理拼到一张纹理图集里通过UV偏移来采样。还要注意各图集子图之间不能有纹理出血记得留2到4像素的填充边距并在Shader里做Clamp到Edge否则会出现边缘色块污染。6. 进阶优化方向从“能跑”到“丝滑”6.1 多线程生成与流式区块加载做开放世界风格的地形时体素区块必须按需生成。我的方案是把世界切成区块Chunk每个区块由独立的Web Worker执行生成任务。主线程只负责接收“已生成的体素数据”然后送进渲染管线。区块的卸载也简单参考玩家视角中心的距离超过一定半径的区块直接销毁并释放内存。实践下来以64³区块为例双Worker环境下区块生成吞吐量大约是每秒15到20个基本可以满足步行探索的速度。6.2 体素物理与碰撞检测用AABB还是BVH体素场景的碰撞检测比网格场景简单得多因为每个体素天然就是轴对齐包围盒AABB。对于角色碰撞你可以只获取角色脚下和高度的体素占用情况逐轴进行位置修正而不是做复杂的三角面片求交。但如果你要做子弹或射线击中检测建议在数据结构之上单独建一个粗略的BVH加速结构把射线检测从“逐体素步进”优化到“跳过空块”。6.3 导出与生态对接从体素到glTFVoxelPixel里我加了一个导出模块能把体素场景Mesh化成glTF格式这一步的意义在于让体素场景能无缝进入其他DCC工具或游戏引擎。Mesh化的关键是顶点的去重和索引化。相邻方块共享的顶点必须合并否则顶点数会膨胀到无法接受。去重时可以用一个哈希表记录“位置 → 顶点索引”的映射逐个顶点合并。合并后再重算法线。这样导出的模型在Blender、Unity、Unreal里都能正常使用。个人实践心得与几个小经验整个项目做下来我最想强调的其实是“克制”两个字。体素引擎的诱惑很多烧钱的全局光照、逼真的物理模拟、无缝的大世界流式加载……每一样听上去都很酷但每一样都会吃掉你大量时间。如果目标是打磨一个风格化场景构建工具尽量把核心放在密度场生成、数据结构效率和渲染表现这三层上后处理、导出这些做成可选模块就好。有几个小经验值得分享法线扰动不要用太复杂的哈希函数一个简单的坐标异或加伪随机表就够了视觉上完全够用。体素场景的编辑器操作比如笔刷雕刻一定要支持撤销栈而且撤销栈只记录“发生变化的体素坐标和旧值”不要整块备份否则几个操作下来内存就爆了。调试体素场景时把“显示空气体素的包围盒”做成一个开关对排查碰撞问题有奇效。颜色设计上体素场景由于表面是离散的色调要尽量拉开明度差相邻材质之间的亮度差不够远处看就是一坨浆糊。可以刻意让草更亮、石更沉、土更暖。如果在实机测试中遇到具体报错或者性能瓶颈拿着数据来对比我上面给的参数区间大概率能定位到是存储结构、面片剔除还是渲染批次的问题。体素这条路上没有太多捷径但每解决一个问题你对三维空间的理解就会上一个台阶——这本身就是做这个项目最大的回报。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进