在处理实时图形时,减少纹理交换可以显著提升性能。实现这一点的一种方法是通过纹理图集:将许多小纹理合并成一个较大的纹理。GPU 无需在渲染过程中绑定数十个单独的纹理,只需使用一个图集,并将 UV 映射到正确的图块即可。这是我在开发ThreeJs浏览器游戏PIP:Skull Demo时所做的事情,并将以此为例展示这种技术。https://bandinopla.github.io/pip-skull-demo/

预构建流程在开发过程中,我们可以使用未优化的资源,并允许每个模型拥有自己的纹理。但一旦我们准备好投入生产,我们可能需要进行优化。这涉及一个预构建步骤(在运行实际构建脚本之前运行的脚本),该步骤将读取原始文件并写入新的优化文件(您可以选择用新文件替换原始 glb 文件,或者直接告诉构建脚本读取优化文件并忽略开发文件)。在本文中,我们将重点介绍 GLB 文件,这是一种非常灵活的格式。我们假设您的所有游戏/应用程序资源均为 glb 格式。

读取和写入新优化的 glb 文件为此,我们将安装以下软件包,它将为我们提供许多有用的优化工具:npm install –save @gltf-transform/core @gltf-transform/extensions @gltf-transform/functions sharp
gltf-transform是一款功能强大的 glTF 文件处理和优化工具包,非常适合预构建流程。您无需手动调整资源,只需编写脚本即可执行纹理压缩、网格简化、重复数据删除和无用数据修剪等转换操作。通过将其集成到预构建步骤中,原始 glTF 文件将保持不变,同时脚本会生成一个全新的优化版本,供运行时使用。这确保了简洁的工作流程,源资源保持可编辑状态,最终导出的模型轻量级、加载速度更快,并针对目标平台进行了定制。
我编写了一个小型预构建脚本来自动化这个过程(你也必须为你自己的项目编写一个脚本,每个项目都不同,需要不同的操作)和一个“createAtlas”函数,允许我为不同的纹理组创建纹理图集(这取决于你的个人组织选择,它是任意的)。在您的收件箱中获取Bandinopla的故事免费加入 Medium 以获取该作者的最新消息。
订阅该函数接收一个纹理列表,将其调整为定义的图块大小,并根据网格布局将它们打包成一个图集。使用sharp,它可以高效地合成所有内容,并将结果保存为 以.png供检查。除了图集图像外,它还返回有关图块位置的元数据,以便稍后在着色器或材质中轻松重新映射资源。/**
* 用于创建纹理图集的实用程序类。你可以使用它来告诉它
* 将一堆纹理打包到单个图集中。
*
*@param{string} atlasName
*@param{Texture[]} Textures
*@param{[number, number]} atlasSize 表示宽度和高度上有多少个 tileSize 的数字。例如:2 = 宽度为 tileSize 的 2 倍。
*@param{[number, number]} tileSize 图集中的每个图块都具有此大小。
*@param{{name:string, top:number, left:number}[]} tiles
*@param
{boolean}savePngToDisk*/
asyncfunctioncreateAtlas(atlasName, Textures, atlasSize, TileSize, Tiles, SavePngToDisk){
const
atlasTextures= tiles.map(tile=> Textures.find(t=>t.getName()==tile.name) );constresizedImages =awaitPromise.all(//仅筛选我们要打包的纹理 atlasTextures .map(asynctex =>awaitsharp(Buffer.from(tex.getImage())) .resize({width: tileSize[0],height: tileSize[1],fit:‘contain’,background: {r:0,g:0,b:0,alpha:0} }) .toFormat(‘png’)//<– 你可以将其更改为 webp… .toBuffer() ) );// 创建图集constatlas =awaitsharp({create: {width: atlasSize[0]*tileSize[0
],
height: atlasSize[1]*tileSize[1 ], channels : 4, background : { r : 0, g : 0, b : 0, alpha : 0 } } }).composite (resizedImages.map ((img
,index)=
>({input:img,left:tiles[index].left*tileSize[0
]
,top:
tiles[index].top*
tileSize[
1]})))
.png().toBuffer
(
);if(savePngToDisk
)//用于调试。它将被打包在 GLB 中,否则
等待fs.writeFile(`./${atlasName}.png`,atlas);
返回{
imageData:atlas,
tiles,
atlasSize,
removeUnusedTextures(){
atlasTextures.forEach(textureToRemove=>{
textureToRemove.detach(); // 从图中取消链接textureToRemove.setImage( null);//清除二进制数据 textureToRemove.dispose(); // 标记为删除 }); } };}
一个很棒的额外功能是removeUnusedTextures()辅助功能。打包后,源纹理可以安全地分离(并且应该如此)并处理掉,从而保持内存使用干净,并确保运行时仅使用优化后的图集(因此新的 glb 文件不会保留旧纹理)。+ 将图集添加到文档中这就是我们现在将图集纹理插入 GLB 文件的方式:consttargetTextures = myDoc.getRoot().listTextures().filter(…);constmyAtlas =createAtlas(…);// 调用实用函数来创建你的图集…//这会将图集图像添加到 glb 中constmyAtlasTexture= myDocument.createTexture(‘MyAtlas’).setImage(myAtlas.imageData).setMimeType(‘image/png’);
通过在构建过程中运行此步骤,我可以发送更少的文件、更小的 GPU 状态变化以及更可预测的资产管理– 所有这些都转化为更流畅的渲染和更快的加载时间。但是UV怎么办?这不会破坏模型吗?是的。再见。文章写完了……

开玩笑的,我们当然会更新UV!而且这还能自动完成!看看这个:上面的实用函数被多次使用,将不同的纹理打包成一张(具体操作取决于你的用例,你可能想按级别、区域等分组……这取决于你)。现在,我们已经将多组纹理打包成一张图像了。接下来我们必须扫描 glb 文件,查找任何可能使用我们打包的纹理的材质,如果是,我们应该更新材质和UV,使其指向图集。具体操作如下……扫描材料// 搜索 glb 文件中的所有材质…
myAssetDocument.getRoot().listMaterials().forEach((material) =>{//…code here})
参考我们的 glb 文件文档,我们将循环它的每一种材料并检查该材料是否存在于我们的图集中,如下所示:// 在此示例中,我们使用了颜色纹理,但您可能需要扫描
// material.getMetallicRoughnessTexture() 和/或
// material.getNormalTexture()
constcolorTexture = material.getBaseColorTexture()
constname = colorTexture.getName();
constatlasUvTile =atlas.tiles.find(tile=>tile.name ==name);if( atlasUvTile ) {// 必须更新此材质! material.setBaseColorTexture( myAtlasTexture ); //<– 此处myAtlasTexture是对 myDoc.createTexture(…) 创建的纹理的引用// 您可以在此处调用函数来扫描文档,以使用此材质查找网格……// (*)}
在上面的代码中,`atlas` 是我们的 `createAtlas` 实用函数返回的对象。如果该图集中的任何一个图块与当前正在扫描的纹理同名,那就意味着我们开始进行替换!更新 UV当多个纹理合并到单个图集中时,使用这些纹理的几何体也必须更新。每个网格最初都引用与单个图像对齐的纹理坐标 (UV)(0 到 1 表示它们所使用的单个图像,现在 0 到 1 表示包含大量纹理的图集的总宽度和高度)。将纹理打包到单个大型图集中后,需要缩放和偏移UV ,以便它们现在正确指向图集中指定的图块。我们有 `atlasUvTile`,它包含进行正确调整所需的必要信息。现在,我们必须找到所有使用此纹理的网格,就像这样,扫描文档以查找网格:// (*)const
w= atlas.atlasSize[0];
consth = atlas.atlasSize[1];
consttx = atlasUvTile.left;constty= atlasUvTile.top;myAssetDocument.getRoot().listMeshes().forEach(mesh=>{ mesh.listPrimitives().forEach(prim=>{if(prim.getMaterial() === material) {constuvs = prim.getAttribute(‘TEXCOORD_0′);if(uvs) {constaccessor =uvs.getArray();for(leti =0; i < accessor.length;i +=2) { accessor[i] = accessor[i] / w + tx / w;// 缩放然后偏移… accessor[i +1]= accessor[i+1] / h + ty / h; } uvs.setArray(accessor); } } } ) ;});
一旦确认某个材质可以使用图集,代码就会查找使用该材质的所有网格和图元。对于每个图元,它会访问其TEXCOORD_0属性(UV 数据)。然后通过应用比例和偏移量对 UV 进行修改:scale缩小 UV 以适应较大图集中单个图块的大小,并将offset它们移动到图集网格布局中的正确位置(tx, )。ty实际上,此步骤可确保优化后的 glTF 中的每个网格都能将其表面正确映射到图集图像的正确部分。如果没有此步骤,纹理会显得杂乱无章,因为原始 UV 将不再与组合后的图集布局匹配。最终结果是优化的 glTF,其中多个纹理被合并为更少的图像,从而减少了运行时的纹理绑定,同时保留了模型的原始视觉保真度。这对于实时渲染至关重要,尤其是在游戏和 Web 应用程序中。完成所有这些之后,您可能想要运行此命令以从新的最终优化 glb 中删除现在未使用的纹理:myAtlas.removeUnusedTextures()
警告:仅限非平铺纹理这种 UV 重映射方法仅适用于非平铺纹理。由于 UV 会被缩小并偏移以适应图集的单个单元,因此任何依赖纹理平铺或重复图案的材质都会中断——重复图案将被剪裁到指定图块的边界内。对于依赖无缝平铺的资源(例如砖块、地板或织物),如果不进行额外处理,此方法并不适用。在这些情况下,通常最好将纹理分开处理或探索更高级的图集技术。就是这样!通过将纹理图集集成到预构建流程中,您不仅可以简化资源管理,还可以显著减少客户端计算机的工作负载。更少的纹理绑定意味着更低的 GPU 开销、更快的加载时间和更流畅的渲染。最终,这种优化可以帮助您交付更精简、更高效的应用程序,从而为用户带来更佳的体验。
https://medium.com/@pablobandinopla/generating-texture-atlases-for-optimized-assets-63fd7a04021f
