Awesome
LightmapRepacker Demo
A demo of splitting & packing of a lightmap. (Unity 2018)
Demo演示步骤
- 打开Scene/test.unity这个测试场景
- 选中需要执行Lightmap重组的Models节点
- 执行Tools/LightmapRepacker/Repack For Selected GOs, lightmap重组,此场景的lightmap,和涉及模型都会重新计算uv,并更新回所有的Renderer
- 如果要再次测试,请不要保存结果,直接重新载入一次场景,再次从步骤2开始执行
实现步骤说明
-
遍历所有需要处理的Renderer,记录Renderer.lightmapScaleOffset
-
读取用于Lightmap采样的那一套纹理,并且做简单计算
- 计算LightmapUV的初始包围盒
- 计算经过lightmapScaleOffset变换后的包围盒lightmapUVBounds,用于后面的像素提取
// 放缩平移变化 _uv.x *= lightmapScaleOffset.x; _uv.y *= lightmapScaleOffset.y; _uv.x += lightmapScaleOffset.z; _uv.y += lightmapScaleOffset.w;
-
计算像素范围,Lightmap像素提取之前,通过选取正确的浮点取整方式来确保UV涵盖到的像素被正确包含
// 用扩大方式来取整,确保相关像素完美包含 int pixMinX = ( int )Math.Max( Mathf.Floor( lightmapUVBounds.x * lightmapData.width ), 0 ); int pixMaxX = ( int )Math.Min( Mathf.Ceil( lightmapUVBounds.z * lightmapData.width ), lightmapData.width ); int pixMinY = ( int )Math.Max( Mathf.Floor( lightmapUVBounds.y * lightmapData.height ), 0 ); int pixMaxY = ( int )Math.Min( Mathf.Ceil( lightmapUVBounds.w * lightmapData.height ), lightmapData.height );
-
使用stbrp_rect库来重新打包LightmapAtlas
// 使用了非常出色的stb万能库之一:stb_rect来制作Atlas // 当然也可用Unity的Texture2D.PackTextures方法,但是要注意放缩问题 // https://github.com/nothings/stb/blob/master/stb_rect_pack.h // simple 2D rectangle packer with decent quality // 获取lightmap之间的间隙宽度,用于填充块与块的右上部位 var Padding = LightmapEditorSettings.padding; // 传入自己的TexturePacker之前,把border大小加上去 var rt = default( NativeAPI.stbrp_rect ); rt.w = ( ushort )( ( pixMaxX - pixMinX ) + Padding ); rt.h = ( ushort )( ( pixMaxY - pixMinY ) + Padding );
演示一下Lightmap块与块之间空隙(32像素),注意最左下方是无缝的
-
重新构建新LightmapAtlas,并计算新的lightScaleOffset
// 经过pack计算后的输出,在输出贴图上的像素位置位置 NativeAPI.stbrp_rect rt; // ... // 原始像素包围盒 int i_minx = lightmapRect.lightmapPixelBounds.x; int i_miny = lightmapRect.lightmapPixelBounds.y; int i_maxx = lightmapRect.lightmapPixelBounds.z; int i_maxy = lightmapRect.lightmapPixelBounds.w; // lightmap像素块copy fixed ( Vector4* _atlas_pixels = atlas_pixels.pixels ) { for ( int y = i_miny; y < i_maxy; ++y ) { int dy = y - i_miny + rt.y; // 纹理坐标需要翻转一下,像素是翻转了的 int _dy = atlasSize - 1 - dy; int _sy = lightmapData.height - 1 - y; int _dy_stride = _dy * atlasSize; int _sy_stride = _sy * lightmapData.width; for ( int x = i_minx; x < i_maxx; ++x ) { int dx = x - i_minx + rt.x; _atlas_pixels[ _dy_stride + dx ] = lightmapData.GetPixel( x, _sy ); } } } var uvBounds = new Vector4d(); uvBounds.x = ( rt.x / ( float )atlasSize ); uvBounds.y = ( rt.y / ( float )atlasSize ); // 计算在新的lightmap纹理下的纹理坐标包围盒,由于pack之前我们人为加了一个Padding,所以这里计算要减去 uvBounds.z = ( ( ( rt.x + rt.w ) - Padding ) / ( float )atlasSize ); uvBounds.w = ( ( ( rt.y + rt.h ) - Padding ) / ( float )atlasSize ); // 重新计算新的scaleOffset renderer.lightmapScaleOffset = CalculateUVScaleOffset( lightmapRect.meshUVBounds, uvBounds );
-
完成,来看看结果
-
处理之前,场景原样
-
处理之前,计算出来的像素区域,浮点像素精度
-
处理之后,场景样貌,注意lightmap出现了明显偏移放缩,而且还出现了黑边
-
处理之后,计算出来的像素区域出现了浮点误差
结果非常糟糕,重新映射后的lightmap不仅位置出现了明显的平移和放缩,而且还有黑边出现,这是显然无法容忍的,要想办法解决
-
解决问题
-
消除平移和放缩误差
这个问题明显出在步骤3,为了去提取lightmap上的涵盖像素,所以从UV计算像素区域时,我们执行了一系列的取整操作,这就丢失了最重要的精度,所以我们要把这部分取整丢失的精度重新补偿回来。
-
计算浮点像素精度的区域
float fMinX = lightmapUVBounds.x * lightmapData.width; float fMaxX = lightmapUVBounds.z * lightmapData.width; float fMinY = lightmapUVBounds.y * lightmapData.height; float fMaxY = lightmapUVBounds.w * lightmapData.height; // 保存结果 lightmapRect.lightmapPixelFBounds = new Vector4d( fMinX, fMinY, fMaxX, fMaxY );
-
浮点补偿
// 关键,把取整后丢失的精度,重新补偿回来,这样UV值相对新的像素位置的偏移就前后一致了 var errorX = lightmapRect.lightmapPixelFBounds.x - lightmapRect.lightmapPixelBounds.x; var errorY = lightmapRect.lightmapPixelFBounds.y - lightmapRect.lightmapPixelBounds.y; var errorZ = lightmapRect.lightmapPixelFBounds.z - lightmapRect.lightmapPixelBounds.z; var errorW = lightmapRect.lightmapPixelFBounds.w - lightmapRect.lightmapPixelBounds.w; uvBounds.x = ( ( rt.x + errorX ) / ( float )atlasSize ); uvBounds.y = ( ( rt.y + errorY ) / ( float )atlasSize ); uvBounds.z = ( ( ( rt.x + rt.w + errorZ ) - Padding ) / ( float )atlasSize ); uvBounds.w = ( ( ( rt.y + rt.h + errorW ) - Padding ) / ( float )atlasSize );
-
结果
处理后,基本上消除了lightmap的偏移和放缩误差,但是黑边依然存在
处理后,像素区域已经非常接近初始值了(除了x坐标有非常非常小的误差)
-
还有误差?
其实到了这里,重新映射后的UV坐标已经非常接近初始值了,但是还是有非常小的误差,很明显,这是不是浮点数自身运算带来的误差呢?
我马上把单精度浮点数换成了双精度来计算,果然药到病除,结果非常完美了:)
-
-
消除黑边
已经消除了lightmap的重组误差,就只剩下黑边问题需要消除了,我们离胜利已经不远了。
通过对比处理前后的lightmap贴图,发现处理之前的Lightmap并不是以黑色为底色的,而像是以某种方式把Lightmap图块像素的颜色扩散到背景上去了,很明显,这是为了消除纹理线性过滤采样到相邻像素而采用的方法。
因为这种像素颜色扩散算法不清楚,所以我打算直接在拷贝像素块的时候扩大一下范围,把原始lightmap上的邻接区域像素也一并拷贝到新的lightmap贴图中去。由于我们lightmap图块之间本来就存在Padding,所以正好我们可以把这个区域的像素瓜分了:)
// 原始像素包围盒,不包含Border var i_minx = ( int )lightmapRect.lightmapPixelBounds.x; var i_miny = ( int )lightmapRect.lightmapPixelBounds.y; var i_maxx = ( int )lightmapRect.lightmapPixelBounds.z; var i_maxy = ( int )lightmapRect.lightmapPixelBounds.w; int rtOffset = Padding / 2; if ( Padding > 0 ) { // 扩大半个边框,瓜分Padding像素消除黑边 i_minx -= Padding / 2; i_miny -= Padding / 2; i_maxx += Padding - Padding / 2; i_maxy += Padding - Padding / 2; } fixed ( Vector4* _atlas_pixels = atlas_pixels.pixels ) { for ( int y = i_miny; y < i_maxy; y++ ) { int dy = y - i_miny + ( rt.y - rtOffset ); if ( dy < 0 || dy >= atlasSize ) { // 防止超界 continue; } // 纹理坐标需要翻转一下,像素是翻转了的 int _dy = atlasSize - 1 - dy; int _sy = lightmapData.height - 1 - y; int _dy_stride = _dy * atlasSize; int _sy_stride = _sy * lightmapData.width; for ( int x = i_minx; x < i_maxx; x++ ) { int dx = x - i_minx + ( rt.x - rtOffset ); if ( dx < 0 || dx >= atlasSize ) { // 防止超界 continue; } // 模拟一下Clamp方式的纹理采样,这里传入的坐标是可以超出原始纹理范围的 _atlas_pixels[ _dy_stride + dx ] = lightmapData.GetPixelClamped( x, _sy ); } } }
Ta-da!
比较理想的消除了黑边
Lightmap原始图块之间的边缘像素也被保留下来了,这是解决黑边的关键。在日常开发中,我们需要设置相对合适的LightmapEditorSettings.padding来消除黑边,考虑到mipmap的存在,这个值稍微大一点比较好,比如(4~8)
最后
为了展示以上介绍的问题解决过程,在LightmapRepacker.cs文件头定义了3个宏开关来重现问题解决之前的结果
// 消除LightmapUV取整后的误差
#define FIXING_LIGHTMAP_UVERROR
// 消除单精度浮点带来的微小误差
#define LIGHTMAPREPACKER_PRECISION_HIGH
// 消除接缝黑边
#define FIXING_LIGHTMAP_UVBORDER
本工程是LightmapRepacker_IssueDemo的问题修复版本。
采用Unity2018开发
使用了stb_rect库来pack纹理
使用了tinyexr来读写HDR格式的图像文件