# 有向距离场的生成与CG相关应用 **Repository Path**: fish_keqing/generation-and-CG-application-of-sdf ## Basic Information - **Project Name**: 有向距离场的生成与CG相关应用 - **Description**: 有向距离场的生成与CG相关应用的探索 - **Primary Language**: Go - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 1 - **Forks**: 0 - **Created**: 2023-11-23 - **Last Updated**: 2025-05-18 ## Categories & Tags **Categories**: Uncategorized **Tags**: Shader, Go语言 ## README # 有向距离场的生成 ## 遍历边界法 **一、遍历边界法** * 对输入图片进行遮罩处理 * 根据遮罩图寻找边界 * 计算各个像素的有向距离场 * 保存有向距离场 **二、优缺点** * 优点:可异步并行处理,实现简单,使用GPU并行运算的情况下速度非常快(力大飞砖) * 缺点:单核情况下速度非常慢,计算时间不稳定(与边界元素个数和计算单元个数有关) ### 生成遮罩 * 输入符号的灰度图,取像素r通道的值 * 使用`weight`控制比较值`compare`的大小,当像素r通道的值小于该值时则认为像素处于符号遮罩里 * 使用`true`和`false`存储遮罩信息 ```go var wg sync.WaitGroup // GetPhotoMask 获取灰度图遮罩数据 func GetPhotoMask(img image.Image, length, width int, weight float64) (masks [][]bool, err error) { if weight < 0 || weight > 1 { return nil, fmt.Errorf("weight must between 0 and 1") } masks = make([][]bool, width) compare := uint32(32767 * weight) for i := 0; i < width; i++ { //异步处理 wg.Add(1) go func(img image.Image, length, lineIdx int, compare uint32) { defer wg.Done() maskArray := traversalArray(img, length, lineIdx, compare) masks[lineIdx] = maskArray }(img, length, i, compare) } wg.Wait() return } // 遍历图片 func traversalArray(img image.Image, length, lineIdx int, compare uint32) []bool { temp := make([]bool, 0, length) for j := 0; j < length; j++ { if r, _, _, _ := img.At(j, lineIdx).RGBA(); r <= compare { temp = append(temp, false) } else { temp = append(temp, true) } } return temp } ``` ### 寻找边界 * 根据遮罩信息判断像素是否在边界,需要从横纵两方向进行遍历 * 情况一:$白\rightarrow 黑$ * 情况二:$黑\rightarrow 白$ * 遍历时需要在二维数组的周围虚拟1个true元素,用于判断数组边缘元素是否属于边界 * 遍历第一个元素时,将`pre`设置为`true` * 遍历第idx+1个元素时,将`cur`设为true * **执行两次遍历时会得到重复的元素需要对元素去重**,golang里常用哈希`map`实现 * 坐标过滤公式:$key=x*width+y$(对于不同的坐标其标记是不同的,对于相同坐标拥有相同的标记) * 为方便后续计算定义结构体存储预计算结果,**计算前需要对`x`和`y`归一化处理(x除以长,y除以宽)** * `x2y2`:$x^2y^2$ * `2x`:$2x$ * `2y`:$2y$ * 数组索引(i,j)映射到坐标(x,y)的公式:$(i,j)→(x,y)=(j,i)$ ```go type BorderBlock struct { NormalizeX2y2 float64 Normalize2x float64 Normalize2y float64 } //构造函数 func createBlock(x, y, length, width int) (block *BorderBlock) { block = &BorderBlock{} //归一化 a := float64(x) / float64(length) b := float64(y) / float64(width) block.NormalizeX2y2 = a*a + b*b block.Normalize2x = a + a block.Normalize2y = b + b return } //寻找边界元素 func FindBorder(masks [][]bool) (border []*BorderBlock) { width := len(masks) length := len(masks[0]) borderChan := make(chan *deliver, length) border = make([]*BorderBlock, 0, length*4) //边界元素收集者 wg2.Add(1) go func() { defer wg2.Done() collector(borderChan, length, &border) }() //异步遍历 for i := 0; i < width; i++ { wg1.Add(1) go func(i int) { defer wg1.Done() traversalL2R(borderChan, masks, width, length, i) }(i) } //异步遍历 for j := 0; j < length; j++ { wg1.Add(1) go func(j int) { defer wg1.Done() traversalT2B(borderChan, masks, width, length, j) }(j) } //同步 wg1.Wait() close(borderChan) wg2.Wait() return } // 左到右遍历 func traversalL2R(borderChan chan *deliver, photoMask [][]bool, width, length, i int) { var pre, cur bool pre = true for j := 0; j <= width; j++ { if j == width { cur = true } else { cur = photoMask[i][j] } if pre && !cur { borderChan <- &deliver{key: i*width + j, block: createBlock(j, i, length, width)} } else if !pre && cur { borderChan <- &deliver{key: i*width + j, block: createBlock(j-1, i, length, width)} } pre = cur } } // 上到下遍历 func traversalT2B(borderChan chan *deliver, photoMask [][]bool, width, length, j int) { var pre, cur bool pre = true for i := 0; i <= width; i++ { if i == width { cur = true } else { cur = photoMask[i][j] } if pre && !cur { borderChan <- &deliver{key: i*width + j, block: createBlock(j, i, length, width)} } else if !pre && cur { borderChan <- &deliver{key: i*width + j, block: createBlock(j, i-1, length, width)} } pre = cur } } ``` ### 计算有向距离场 * 使用坐标的两点间距离公式计算距离,需要对x和y归一化处理 * $distance=\sqrt{(x_1-x)^2+(y_1-y)^2}=x_1^2+y_1^2-2x_1x-2y_1y+x^2+y^2$ * 对于处于遮罩内的像素,距离值需要取反 * 输出时需要将`ndc`空间的值(`[-1,1]`)转为图像空间的值(`[0,1]`),最后输出灰度像素用于存储在图像里 * 数组索引(i,j)映射到坐标(x,y)的公式:$(i,j)→(x,y)=(j,i)$ ```go var wg sync.WaitGroup // GenerateSDF 获取sdf func GenerateSDF(width, length int, borders []*border.BorderBlock, masks [][]bool) [][]float64 { sdf := make([][]float64, len(masks)) for i := 0; i < width; i++ { wg.Add(1) go func(i int) { defer wg.Done() arr := make([]float64, length) for j := 0; j < length; j++ { arr[j] = computeSDF(float64(j), float64(i), float64(length), float64(width), borders, !masks[i][j]) } sdf[i] = arr }(i) } wg.Wait() return sdf } // 计算sdf的值 func computeSDF(x, y, length, width float64, borders []*border.BorderBlock, isMask bool) float64 { a := x / length b := y / width a2b2 := a*a + b*b var _min, temp float64 _min = math.Inf(1) for _, p := range borders { distance := p.NormalizeX2y2 + a2b2 - p.Normalize2x*a - p.Normalize2y*b temp = math.Sqrt(distance) _min = Min(_min, temp) } if isMask { _min *= -1 } //归一化距离 return _min / math.Sqrt2 } //不要使用内置的min,否则不能兼容Go 1.21之前的版本 func Min(a, b float64) float64 { if a < b { return a } return b } ``` ### 保存有向距离场 * 将有向距离场存储在png图像里 * 数组索引(i,j)映射到坐标(x,y)的公式:$(i,j)→(x,y)=(j,i)$ ```go func SaveInPNG(length, width int, sdf [][]float64, address string) error { img := image.NewRGBA(image.Rect(0, 0, length, width)) for i := 0; i < width; i++ { for j := 0; j < length; j++ { //ndc空间转为像素空间 v := uint16(ndc2pixed(sdf[i][j]) * 65535) img.Set(j, i, color.RGBA64{R: v, G: v, B: v, A: 65535}) } } return save(address, img) } func save(address string, img image.Image) error { if address == "" { address = "./unknow.png" } file, err := os.OpenFile(address, os.O_CREATE|os.O_WRONLY, 0777) if err != nil { return err } defer file.Close() b := bufio.NewWriter(file) err = png.Encode(b, img) if err != nil { return err } err = b.Flush() if err != nil { return err } return nil } // ndc空间转为像素空间 [-1,1]->[0,1] func ndc2pixed(num float64) float64 { return (num + 1) / 2 } ``` ## 8SSEDT算法 **一、8SSEDT算法** * 对输入图片进行遮罩处理,同时初始化矩阵存储距离信息 * 需要2次Pass遍历处理,共遍历4次矩阵 * 第一次Pass(纵轴从上到下遍历) * 横轴从左到右遍历,此次遍历主要检测像素是否在边界位置,并小范围扩散sdf * 横轴从右到左遍历,完成较大范围的sdf扩散 * 第二次Pass(纵轴从下到上遍历) * 横轴从左到右遍历,完成更大范围的sdf扩散 * 横轴从右到左遍历,完成全范围的sdf扩散 * 保存有向距离场 **二、优缺点** * 优点:速度快,稳定,适用于图形中间件 * 缺点:单核瓶颈(速度快但由于不能异步并行处理不能实时生成有向距离场) **三、扩散原理** * 在像素的周围取3×3矩阵,并去除中间元素(8个样本) * 在8个样本距离场的基础上加上到中间像素的距离,选取最小值并赋值给中间像素 ### 矩阵初始化 **一、生成遮罩** * 方法和遍历边界法的生成遮罩相同 **二、初始化矩阵** * 每个元素的结构体及其方法 ```go type Point struct { DX float64 //x偏移 DY float64 //y偏移 } //返回距离平方,可以输入x和y的偏移量 func (p Point) DistanceSqu(addx, addy float64) float64 { return (p.DX+addx)*(p.DX+addx) + (p.DY+addy)*(p.DY+addy) } ``` * 初始化矩阵 ```go var wg2 sync.WaitGroup //异步处理 func InitMatrix(length, width int) (m [][]*Point) { m = make([][]*Point, width) for i := 0; i < width; i++ { wg2.Add(1) go func(i int) { defer wg2.Done() temp := make([]*Point, 0, length) for j := 0; j < length; j++ { temp = append(temp, &Point{}) } m[i] = temp }(i) } wg2.Wait() return } ``` ### 计算有向距离场 **一、过滤无效样本** * 处于边界的元素不能取够8个样本,当取到不存在的样本时会导致程序出现异常,因此要过滤 ```go // 检测元素是否存在 func isEsist(i, j, length, width int) bool { if i < 0 || j < 0 { return false } else if i >= width || j >= length { return false } return true } ``` **二、检测边界元素的方法** * 取像素周围的8个样本,检测样本与中间像素的遮罩是否相同,不相同则像素处于边界处 * 若像素处于边界处,基于样本位置设置不同的距离场,其中东西南北优先级比四角高 * 东西:$(0.5,0)$ * 南北:$(0,0.5)$ * 四角:$(0.5,0.5)$ * 设置边界参数x和y都不能同时为0,要求生成的距离场不能存在0值,否则往后的步骤会出现拆东墙补西墙的麻烦 **三、最短距离的计算** * 取像素周围8个样本,检测样本是否存在,不存在则跳过 * 自定义一个`Min()`函数,要求返回最小值和发生变化的布尔信号 * $(num -1; j-- { computeShortestDistance(m, masks, i, j, length, width) } } //2nd pass for j := 0; j < length; j++ { //top to bottom for i := 0; i < width; i++ { computeShortestDistance(m, masks, i, j, length, width) } //bottom to top for i := width - 1; i > -1; i-- { computeShortestDistance(m, masks, i, j, length, width) } } //归一化并返回数组 normalizeParam := math.Sqrt(math.Pow(float64(length), 2) + math.Pow(float64(width), 2)) for i := 0; i < width; i++ { wg.Add(1) go func(i int) { defer wg.Done() temp := make([]float64, length) for j := 0; j < length; j++ { //归一化 temp[j] = math.Sqrt(m[i][j].DistanceSqu(0, 0)) / normalizeParam //设置有向距离 if !masks[i][j] { temp[j] *= -1 } } res[i] = temp }(i) } wg.Wait() return } ``` ## 算法基准测试 | 算法 | 测试样本 | 核数 | 耗时 | | ---------- | --------- | ---- | ----------------- | | 遍历边界法 | S500.png | 2 | 549.678694 ms/op | | 遍历边界法 | S500.png | 5 | 233.342346 ms/op | | 8SSEDT算法 | S500.png | 2 | 55.650370 ms/op | | 8SSEDT算法 | S500.png | 5 | 51.566675 ms/op | | 遍历边界法 | S1000.png | 2 | 4423.387607 ms/op | | 遍历边界法 | S1000.png | 5 | 1769.491201 ms/op | | 8SSEDT算法 | S1000.png | 2 | 267.859178 ms/op | | 8SSEDT算法 | S1000.png | 5 | 221.036966 ms/op | # 有向距离场在图形学的应用 ## 符号相关 ### 重建符号 * 实验内容:生成一张符号的有向距离场,并使用Unity重建符号 ```shaderlab Properties { _MainTex ("Texture", 2D) = "white" {} _Color("_Color",COLOR)=(0,0,0) } SubShader { Tags { "RenderType"="opaque" } LOD 100 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; sampler2D _MainTex; float4 _MainTex_ST; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); return o; } fixed3 _Color; fixed4 frag (v2f i) : SV_Target { fixed sdf = tex2D(_MainTex, i.uv).r; if(sdf>0.5){ return fixed4(1,1,1,1); } return fixed4(_Color,1); } ENDCG } } ``` ### 镂空符号 * 实验内容:生成一张符号的有向距离场,并使用Unity裁剪掉非符号部分 ```shaderlab Properties { _MainTex ("Texture", 2D) = "white" {} _Color("_Color",COLOR)=(0,0,0) _alpha("_alpha",Range(0,1))=0.5 } SubShader { Tags {"Queue"="AlphaTest" "IgnoreProjector"="True" "RenderType"="TransparentCutout"} Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; sampler2D _MainTex; float4 _MainTex_ST; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); return o; } float _alpha; fixed3 _Color; fixed4 frag (v2f i) : SV_Target { fixed sdf = tex2D(_MainTex, i.uv).r; //裁剪剔除 clip(_alpha-sdf); return fixed4(_Color,1); } ENDCG } } ``` ### 符号平滑切换 **一、数据存储** * 假设有两个符号,获取符号的有向距离场 * 两符号的重叠区域设置为1,1默认为与所有符号重叠的参数 * 不与两符号重叠的区域设置为0,0默认为不与所有符号重叠的参数 * 将符号A的数据存储在$[0.1,0.5]$ * 将符号B的数据存储在$[0.5,0.9]$ * 产生bias的原因:shader使用$[0,1]$的范围表示一个像素的范围,而图像单通道的范围为$[0,255]$,缩放成[0,1]时会存在微小偏移 **二、滑动窗口** * 假设有一个0.5长度的滑动窗口,在该窗口范围内的参数可以允许输出像素,窗口最大参数$\alpha$ * 当参数$\alpha$从0到0.5滑动时永远是参数小的部分先出现,参数大的部分后出现 * 当参数$\alpha$从0.5到1滑动时永远是参数小的部分先消失,参数大的部分后消失 **三、混合原理** * 计算两个符号的有向距离场(A,B) * 对于重叠区域和不被所有符号重叠区域不参与混合处理(只有端点值才能使用`=`) * 重叠区域设置参数1 * 不与所有符号重叠的区域设置参数0 * 被符号A覆盖但不被符号B覆盖的区域 * 数据存储在$[0.1,0.5]$ * 混合公式:(根据效果只选一种公式) $$ blend =0.5-\frac{|A|}{|A|+|B|}*0.4 $$ * 被符号B覆盖但不被符号A覆盖的区域 * 数据存储在$[0.5,0.9]$ * 混合公式: $$ blend =0.9-\frac{|B|}{|A|+|B|}*0.4 $$ ```go func BlendChar(charA [][]float64, charB [][]float64, length, width int) (res [][]float64) { res = make([][]float64, width) for i := 0; i < width; i++ { temp := make([]float64, length) for j := 0; j < length; j++ { //c'die区域 if isChar(charA[i][j]) && isChar(charB[i][j]) { temp[j] = 1 //非符合区域 } else if !isChar(charA[i][j]) && !isChar(charB[i][j]) { temp[j] = 0 } else { total := math.Abs(charB[i][j]) + math.Abs(charA[i][j]) //只在B [0.5,0.9] if isChar(charB[i][j]) { temp[j] = 0.9 - math.Abs(charB[i][j])/total*0.4 } else { //只在A [0.1,0.5] temp[j] = 0.5 - math.Abs(charA[i][j])/total*0.4 } } } res[i] = temp } return } func isChar(n float64) bool { return n <= 0 } ``` **四、符号切换Shader实现** * 初始化时将滑动窗口放在A上,使用`_Min`控制滑动窗口 * 裁剪顺序 * 裁剪参数为0的像素(剔除不被符号覆盖的区域) * 将参数1改为0.5(还原重叠的区域) * 剔除低于滑动窗口下界的区域 * 剔除高于滑动窗口上界的区域 * `bias`处理: * 上界剔除处加长$0.01$ * 下界没多大影响不建议修改frag或调整_Min的值$(+0.01)$,否则拆东墙补西墙 ```shaderlab Properties { _MainTex ("Texture", 2D) = "white" {} _Min("_Min",Range(0.1,0.5))=0.1 } SubShader { Tags { "Queue"="AlphaTest" "IgnoreProjector"="True" "RenderType"="TransparentCutout" } Pass { CGPROGRAM ...... float _Min; fixed4 frag (v2f i) : SV_Target { fixed param = tex2D(_MainTex, i.uv).r; //裁剪参数为0的像素 clip(param-0.01); //重合位置居中 param=param==1?0.5:param; //滑动窗口裁剪 //下界 clip(param-_Min); //上界,需要加bias保证符号完整输出 clip(0.4-param+_Min+0.01); return fixed4(0,1,0,1); } ENDCG } } ``` ## 卡通风格脸部阴影 ### 材料准备 * $[0,180°]$区间内不同角度下的`FaceShadow`(一般由艺术家根据某种效果画出来) * 用于渲染时白色表示阴影面,黑色表示受光面 ### facemap生成原理 * 准备若干张`FaceShadow`,并按夹角从小到大排列(白色表示阴影面,黑色表示受光面) * 取出前两张`FaceShadow`,将两张`FaceShadow`进行简单的混合平均,发现得出的图片有一块区域是灰色的,而该灰色的区域是第一张`FaceShadow`过渡到第二张`FaceShadow`需要变化的区域。依此类推可以得到任意一张`FaceShadow`过渡到下一张`FaceShadow`需要变化的区域。当有n张`FaceShadow`时,这种过渡区域有$(n-1)$个 * 为了得到一张从0到1过渡的`facemap`,且特定的夹角要有对应的效果,则需要将$[0,1]$平均分摊到$(n-1)$个过渡区域里,即第$i$个过渡区域的取值范围为$[\frac{N-i-1}{N},\frac{N-i}{N}),N=n-1$,通过该方式确定了在某一过渡区域里的像素值的下限和上限(其中下限的值可取,上限逼近的值) * 在过渡区域里,为了得到平滑且有意义的过渡值(过渡区域像素越靠近白色区域值越大,越靠近黑色区域值越小),则使用有向距离场插值实现,将所有`FaceShadow`转化为sdf图 * 有向距离场插值,假设对过渡区域里某个点进行插值 * 前一张`FaceShadow`的sdf图该点的值$sdf_{pre}$(该点在`FaceShadow`的黑色区域内,所以为负值,表示点到过渡区域的右边界最近的距离) * 后一张`FaceShadow`的sdf图该点的值$sdf_{cur}$(该点在`FaceShadow`的白色区域内,所以为正值,表示点到过渡区域的左边界最近的距离) * 过渡区域值的下限:$\frac{n-i-2}{n-1}$($n$为`FaceShadow`的数量,$n-1$为过渡区域的数量) $$ Grayscale=\frac{n-i-2}{n-1}+\frac{abs(sdf_{cur})}{abs(sdf_{pre})+sdf_{cur}}×\frac{1}{n-1} $$ * 最终值以灰度的形式保存在`facemap`里,基本还原`reference` ### 逻辑实现 **一、生成有向距离场** * `facemap`通常预计算生成,使用遍历边界法生成高质量有向距离场 * 有向距离场使用二维的双浮点数暂存(`[][]float64`) * 不要将有向距离场存储在图像里然后再提取使用,否则会有明显的层次感且过渡不平滑(精度问题) **二、混合** * 获取一张背景纯白色的图 * 依据`facemap`生成原理实现逻辑,混合结果使用灰度保存 * 可以存储在`alpha`通道,但会因为透明通道预处理会导致`RGB`通道发生改变(使用PS把颜色填回去) ```go //main for i := 1; i <= 9; i++ { BlendSDF(sdfs[i-1], sdfs[i], length, width, i, len(sdfs), resultImg) } ``` ```go //grey func BlendSDF(pre [][]float64, cur [][]float64, length, width, idx, photoNum int, outputImg *image.RGBA) { for i := 0; i < width; i++ { for j := 0; j < length; j++ { min := 1 - float64(idx)/float64(photoNum-1) preValue := pre[i][j] curValue := cur[i][j] if preValue*curValue <= 0 { temp := curValue/(math.Abs(preValue)+curValue)/float64(photoNum-1) + min c := uint16(temp * 65535) outputImg.SetRGBA64(j, i, color.RGBA64{R: c, G: c, B: c, A: 65535}) } else if curValue < 0 && preValue < 0 { outputImg.SetRGBA64(j, i, color.RGBA64{R: 0, G: 0, B: 0, A: 65535}) } } } } ``` ```go func BlendSDFToAlpha(pre [][]float64, cur [][]float64, length, width, idx, photoNum int, outputImg *image.RGBA) { for i := 0; i < width; i++ { for j := 0; j < length; j++ { _min := 1 - float64(idx)/float64(photoNum-1) preValue := pre[i][j] curValue := cur[i][j] if preValue*curValue <= 0 { alpha := curValue/(math.Abs(preValue)+curValue)/float64(photoNum-1) + _min outputImg.SetRGBA64(j, i, *GetColor(&color.NRGBA64{R: 65535, G: 65535, B: 65535, A: uint16(alpha * 65535)}, outputImg)) } else if curValue < 0 && preValue < 0 { outputImg.SetRGBA64(j, i, *GetColor(&color.NRGBA64{R: 65535, G: 65535, B: 65535, A: 0}, outputImg)) } } } } //非预乘像素返回RGBA func GetColor(c *color.NRGBA64, img *image.RGBA) *color.RGBA64 { r, g, b, a := img.ColorModel().Convert(c).RGBA() return &color.RGBA64{R: uint16(r), G: uint16(g), B: uint16(b), A: uint16(a)} } ``` **三、保存结果** * 保存为`.png`格式 ```go func SaveInPng(address string, img image.Image) error { if address == "" { address = "./unknow.png" } file, err := os.OpenFile(address, os.O_CREATE|os.O_WRONLY, 0777) if err != nil { return err } defer file.Close() b := bufio.NewWriter(file) err = png.Encode(b, img) if err != nil { return err } err = b.Flush() if err != nil { return err } return nil } ```