LoopSubdivision几何体细分

661 阅读8分钟

代码地址:github.com/buglas/grap…

1-几何体细分的概念

几何体细分,就是按照某种规则为几何体增加三角面,并且让几何体更加圆滑,如下图:

image-20220813220412227

上面的几何体我是按照Loop Subdivision 算法细分的,它在不断细分的同时,也变圆滑了。

大家可能还会觉得它变小了,这是我去掉了其棱角后导致的。

接下来,咱们就详细说一下Loop Subdivision 几何细分算法。

2-Loop Subdivision 几何体细分算法

Loop Subdivision 几何体细分要分两步来走:

1.增加三角形数量

2.调整三角形顶点的位置

2-1-增加三角形数量

基于三角形三条边的中点进行细分把每一个三角形分成四个。

4-1 Subdivision

2-2-调整三角形顶点的位置

调整三角形顶点的位置的时候,要对老三角形和新三角的顶点进行区别对待。

2-2-1-设置新三角形顶点的位置

image-20220814102200105

新三角的顶点有两种:

  • 只在一个三角形的单边上的顶点,如E、F、G、H

    以E点为例,其算法如下:

    E=(A+C)/2
    
  • 在两个三角形公共边上的顶点,如O

    O 点算法如下:

    O=3(A+B)/8+(C+D)/8
    

解释一下O 点的算法。

O 点是基于其所在的两个相邻的三角形的顶点进行了加权平均,其步骤如下:

1.计算顶点A、B、C、D的面点FP(Face Point)。

FP=(A+B+C+D)/4

大家可以将面点理解为多个点围成的平面的中点。

简单解释面点公式的由来。

大家应该知道,2个点的中点是这2个点之和除以2,如:

image-20220814105919063

FP=(P1+P2)/2

那n个点的中点呢?

大家应该可以以同理推出,n个点的中点就是这个n个点之和除n

FP=(P1+P2+……+Pn)/n

2.计算两个三角形公共边AB的中点EC(Edge Center)

EC=(A+B)/2

3.取FP和EC的中点O

O=(FP+EC)/2
O=((A+B+C+D)/4+(A+B)/2)/2
O=(A+B+C+D)/8+(A+B)/4
O=A/8+B/8+C/8+D/8+2A/8+2B/8
O=3A/8+3B/8+C/8+D/8
O=3(A+B)/8+(C+D)/8

2-2-2-设置老三角形顶点的位置

还是以之前的图片为例,大家只需要关注老三角形顶点即可。

image-20220814102200105

以点A为例,我们去求点A更新后的点位A'

由图可知,与点A相邻的点有3个:B、C、D

所以点A的度为:

n=3

有了度n之后,我们还可以得到一个加权系数u:

if(n===3){
    u=3/16
}else{
    u=3/8n
}

至于上面的u为什么要这么算,我们先不解释,大家往后看。

求点A的邻点之和s:

s=B+C+D

有了n和u后,我们就可以基于点A和点A的邻点之和求A'了:

A'=(1-n*u)*A+u*s

这么做是为了让A' 即受其初始点位A的影响,也受其相邻顶点的影响。

由上式可知:

  • u 的大部分情况都是和n成反比的,即n越大,u越小。只有当n=3时,其u值才会和n=2时的u值相等。
  • n*u的大部分情况都是3/8 。只有当n=3的时候,才是9/16。

所以,大部分情况下,n越大,A' 受邻点的影响就越小;反之,n越小,A' 受邻点的影响就越大。

其它的B、C、D点的更新也是同样原理。

3-代码实现

3-1-整体代码

1.建立一个Loop.ts 对象

import { BufferAttribute, BufferGeometry, InterleavedBufferAttribute, Vector3 } from "three";

//几何体的顶点索引
let index:ArrayLike<number>
//几何体的顶点数据
let originPositionAttr: BufferAttribute | InterleavedBufferAttribute

/* 
Loop对象
originGeometry 初始几何体,要基于此模型增加面数,需要有顶点索引
geometry 细分后的几何体
level 细分级数,默认为1,level越高,分得越细
*/
export default class Loop{
  originGeometry: BufferGeometry=new BufferGeometry();
  level: number;
  geometry: BufferGeometry=new BufferGeometry();
  constructor(originGeometry:BufferGeometry,level:number=1){
    this.originGeometry=originGeometry
    this.geometry=originGeometry
    this.level=level
    this.update()
  }
  // 更新geometry
  update(){
    const {level}=this
    for(let i=0;i<level;i++){
      const {geometry}=this
      const indexAttr=geometry.getIndex()
      if(!indexAttr){return}
      index=indexAttr.array
      originPositionAttr=geometry.getAttribute('position')
      const geo=subdivide()
      if(geo){
        geo.computeVertexNormals()
        this.geometry=geo
      }
    }
  }
}

/* 细分几何体 */
interface GeoData{
  // 顶点数据
  position:Array<number>
  // 顶点索引
  index?:Array<number>
  // uv坐标,尚未计算
  uv?:Array<number>
}
function subdivide():BufferGeometry|null{
  // 计算新的三角形顶点
  const newGeoData:GeoData=crtNewPoints()

  // 更新旧的三角形顶点
  const oldGeoData:GeoData=updateOldPoints()

  // 将新老顶点合成新的几何体
  return compose(newGeoData,oldGeoData)
}

/* 计算新的三角形顶点 */
function crtNewPoints():GeoData{
  // 新的顶点集合[x,y,z,x,y,z,]
  const newPos:Array<number>=[]
  // 三角形布局
  const newIndex:Array<Array<number>>=[]
  forTriangle(()=>{newIndex.push([])})

  // 新点索引
  let newInd=originPositionAttr.count-1

  // 遍历顶点
  forPoint(({i0,i1,i2,triangleInd,pointInd})=>{
    // 判断点前点位是否在newPos中已经存在相应顶点
    if(newIndex[triangleInd][pointInd]){return}
    newInd++
    // 获取一边的对点
    const counterPoint:Array<number>|null=getCounterPoint(i1,i0)
    const p0=getPointByInd(i0,originPositionAttr)
    const p1=getPointByInd(i1,originPositionAttr)

    // 新点
    let p:Vector3
    
    if(counterPoint){
      // 计算新点位
      const p2=getPointByInd(i2,originPositionAttr)
      const p3=getPointByInd(counterPoint[2],originPositionAttr)
      p=computeNewPoint(p2,p0,p1,p3)
      // 共线顶点的索引
      newIndex[counterPoint[0]][counterPoint[1]]=newInd
    }else{
      // 取i1,i0的中心点作为新点位
      p=computeCenterPoint(p0,p1)
    }
    newPos.push(p.x,p.y,p.z)
    newIndex[triangleInd][pointInd]=newInd
  })
  return {
    position:newPos,
    index:newIndex.flat()
  }
}

/* 更新旧的三角形顶点 */
function updateOldPoints():GeoData{
  // 遍历旧顶点集合
  const oldPoints=[]
  // 遍历旧顶点
  for(let i=0;i<originPositionAttr.count;i++){
    // 计算旧点位
    const oldPoint=computeOldPoint(
      // 当前点
      getPointByInd(i),
      // 根据当前点的顶点索引获取与此点相邻的顶点
      getNearPoints(i)
    )
    oldPoints.push(oldPoint.x,oldPoint.y,oldPoint.z)
  }
  return {
    position:oldPoints
  }
}

/* 将新老顶点合成新的几何体 */
function compose(newGeoData:GeoData,oldGeoData:GeoData):BufferGeometry|null{
  const geo=new BufferGeometry()
  geo.setAttribute(
    'position',
    new BufferAttribute(
      new Float32Array([
        ...oldGeoData.position,
        ...newGeoData.position,
      ]),
      3
    )
  )
  const curIndex:Array<number>=[]
  const newIndex=newGeoData.index
  if(!newIndex){return null}
  let [A1,B1,C1,A2,B2,C2]=[0,0,0,0,0,0];
  forTriangle(({i,triangle})=>{
    [A1,B1,C1]=triangle
    A2=newIndex[i]
    B2=newIndex[i+1]
    C2=newIndex[i+2]
    curIndex.push(
      A1,A2,C2,
      A2,B1,B2,
      A2,B2,C2,
      C2,B2,C1
    )
  })
  geo.setIndex(curIndex)
  return geo
}

// 几何体内公共边上的顶点
function computeNewPoint(A:Vector3,B:Vector3,C:Vector3,D:Vector3):Vector3{
  return A.clone().add(D).multiplyScalar(1/8).add(
    B.clone().add(C).multiplyScalar(3/8)
  )
}

// 几何体边界上的顶点
function computeCenterPoint(A:Vector3,B:Vector3){
  return A.clone().lerp(B,0.5)
}

// 更新老点
function computeOldPoint(oldPoint:Vector3,nearPoints:Array<Vector3>){
  const n=nearPoints.length
  const u=getU(n)
  const old=oldPoint.clone()
  const sumP=sum(nearPoints)
  return old.multiplyScalar(1-n*u).add(sumP.multiplyScalar(u))
}

// 获取u
function getU(n:number){
  if(n===3){
    return 3/16
  }
  return 3/(8*n)
}
/* function getU(n:number){
  const a=3/8+Math.cos(Math.PI*2/n)/4
  return (5/8-a*a)/n
} */

// 多点之和
function sum(points:Array<Vector3>){
  const v=new Vector3()
  points.forEach(p=>{v.add(p)})
  return v
}

// 获取顶点的邻点
function getNearPoints(ind:number):Array<Vector3>{
  const nearPoints:Set<number>=new Set()
  forTriangle(({triangle})=>{
    const pointInd=triangle.indexOf(ind)
    if(pointInd!==-1){
      const arr=[...triangle]
      arr.splice(pointInd,1)
      nearPoints.add(arr[0])
      nearPoints.add(arr[1])
    }
  })
  return [...nearPoints].map(ind=>getPointByInd(ind))
}

// 根据索引值,获取点位
function getPointByInd(ind:number,attr=originPositionAttr){
  return new Vector3(
    attr.getX(ind),
    attr.getY(ind),
    attr.getZ(ind),
  )
}

// 获取一边的对点,及其位置
function getCounterPoint(i0:number,i1:number){
  let data:Array<number>|null=null
  // 遍历顶点
  forPoint(({
    i0:j0,
    i1:j1,
    i2:j2,
    triangleInd,
    pointInd
  })=>{
    if(i0===j0&&i1===j1){
      data=[triangleInd,pointInd,j2]
    }
  })
  return data
}

/* 根据顶点索引遍历三角形 */
interface TriangleData{
  // 三角形在顶点索引中的索引位
  i:number
  // 三角形
  triangle:Array<number>
}
function forTriangle(fn=(param:TriangleData)=>{}){
  for(let i=0,len=index.length;i<len;i+=3){
    fn({i,triangle:[index[i],index[i+1],index[i+2]]})
  }
}

/* 遍历顶点 */
interface PointData{
  // 从当前顶点索引至后的当前三角形的三个顶点索引
  i0:number
  i1:number
  i2:number
  // 当前顶点在平展开的顶点集合中的索引位置
  j:number
  // 三角形在以三角形为单位的顶点索引集合中的索引位
  triangleInd:number
  // 顶点在当前三角形中的索引位
  pointInd:number
}
function forPoint(fn=(param:PointData)=>{}){
  for(let i=0,len=index.length;i<len;i++){
    const a=i%3
    const b=i-a
    fn({
      i0:index[i],
      i1:index[b+(a+1)%3],
      i2:index[b+(a+2)%3],
      j:index[i]*3,
      triangleInd:b/3,
      pointInd:a
    })
  }
}

2.实例化Loop对象

/*初始几何体*/
const ang=Math.PI*2/3
const r=3
const c=Math.cos(ang)*r
const s=Math.sin(ang)*r
const originGeo=new BufferGeometry()
originGeo.setAttribute(
  'position',
  new BufferAttribute(
    new Float32Array([
      0,r,0,
      c,0,s,
      r,0,0,
      c,0,-s,
      0,-r,0,
    ]),
    3
  )
)
originGeo.setIndex([
  0,1,2,2,1,4,
  0,2,3,3,2,4,
  0,3,1,1,3,4
])
originGeo.computeVertexNormals()

/*实例化Loop对象*/
const loop=new Loop(originGeo,3)

/*基于Loop对象中的geometry对象建立mesh对象*/
const geometry = loop.geometry
const material = new MeshNormalMaterial({
    polygonOffset: true,
    polygonOffsetFactor: 1,
    polygonOffsetUnits: 1,
})
const mesh = new Mesh(geometry, material)

效果如下:

image-20220814213411063

3-2-Loop对象详解

3-2-1-Loop对象适用的几何体

当前的Loop对象只适用于具备以下特性的BufferGeometry:

  • 具备顶点索引index
  • 所有的公共顶点都焊接在一起

解释一下公共顶点都焊接在一起的意思。

在下图中,点B、点C 是两个两个相邻三角形的公共顶点。

image-20220815071316180

  • 当点B、点C为焊接状态时,几何体的顶点集合position 和顶点索引index 是这样定义的:
position=[
    0,  2, -1, //A
    -1, 0,  1, //B
    1,  0,  1, //C
    0, -2, -1, //D
]
index=[
  0,1,2,
  2,1,3
]
  • 当点B、点C为断开状态时,几何体的顶点集合position 和顶点索引index 是这样定义的:
position=[
    0,  2, -1, //A-1
    -1, 0,  1, //B-1
    1,  0,  1, //C-1
    1,  0,  1, //C-2
    -1, 0,  1, //B-2
    0, -2, -1, //D-2
]
index=[
  0,1,2,
  3,4,5
]

此时的两个三角形是断开的:

image-20220815073548463

3-2-2-Loop对象的建立步骤

1.声明2个公共变量,用于暂存顶点数据。

//几何体的顶点索引
let index:ArrayLike<number>
//几何体的顶点数据
let originPositionAttr: BufferAttribute | InterleavedBufferAttribute

2.用class 封装Loop对象

export default class Loop{
  originGeometry: BufferGeometry=new BufferGeometry();
  level: number;
  geometry: BufferGeometry=new BufferGeometry();
  constructor(originGeometry:BufferGeometry,level:number=1){
    this.originGeometry=originGeometry
    this.geometry=originGeometry
    this.level=level
    this.update()
  }
  // 更新geometry
  update(){
    const {level}=this
    for(let i=0;i<level;i++){
      const {geometry}=this
      const indexAttr=geometry.getIndex()
      if(!indexAttr){return}
      index=indexAttr.array
      originPositionAttr=geometry.getAttribute('position')
      const geo=subdivide()
      if(geo){
        geo.computeVertexNormals()
        this.geometry=geo
      }
    }
  }
}

Loop对象有3个属性:

  • originGeometry 初始几何体,要基于此模型增加面数,需要有顶点索引
  • geometry 细分后的几何体
  • level 细分级数,默认为1,level越高,分得越细

update() 会基于上面的originGeometry和level 更新geometry 对象。

2.上面的subdivide()便是细分几何体的方法。

/* 细分几何体 */
interface GeoData{
  // 顶点数据
  position:Array<number>
  // 顶点索引
  index?:Array<number>
  // uv坐标,尚未计算
  uv?:Array<number>
}
function subdivide():BufferGeometry|null{
  // 计算新的三角形顶点
  const newGeoData:GeoData=crtNewPoints()

  // 更新旧的三角形顶点
  const oldGeoData:GeoData=updateOldPoints()

  // 将新老顶点合成新的几何体
  return compose(newGeoData,oldGeoData)
}

几何体的细分就是按照3步走的:

​ (1) 计算新的三角形顶点。

​ (2) 更新旧的三角形顶点。

​ (3) 将新老顶点合成新的几何体。

当前,我尚未对UV坐标进行同步更新,在此我先简单说个思路,等我以后有时间了,或者工作需要了,我会再去完善。

多数的贴图并不适用于无缝几何体。

因此,当几何体有接缝的时候,接缝处的顶点点位和uv坐标的计算要兵分两路:

  • 接缝处的顶点点位要按照几何体内公共边上的顶点来计算。
  • 接缝处的uv坐标要按照几何体边界上的顶点来计算。

3.计算新的三角形顶点。

function crtNewPoints():GeoData{
  // 新的顶点集合
  const newPos:Array<number>=[]
  // 新的三角形集合
  const newTirangles:Array<Array<number>>=[]
  forTriangle(()=>{newTirangles.push([])})

  // 新点索引
  let newInd=originPositionAttr.count-1

  // 遍历顶点
  forPoint(({i0,i1,i2,triangleInd,pointInd})=>{
    // 判断点前点位是否在newPos中已经存在相应顶点
    if(newTirangles[triangleInd][pointInd]){return}
    newInd++
    // 获取一边的对点
    const counterPoint:Array<number>|null=getCounterPoint(i1,i0)
    const p0=getPointByInd(i0,originPositionAttr)
    const p1=getPointByInd(i1,originPositionAttr)

    // 新点
    let p:Vector3
    
    if(counterPoint){
      // 计算新点位
      const p2=getPointByInd(i2,originPositionAttr)
      const p3=getPointByInd(counterPoint[2],originPositionAttr)
      p=computeNewPoint(p2,p0,p1,p3)
      // 共线顶点的索引
      newTirangles[counterPoint[0]][counterPoint[1]]=newInd
    }else{
      // 取i1,i0的中心点作为新点位
      p=computeCenterPoint(p0,p1)
    }
    newPos.push(p.x,p.y,p.z)
    newTirangles[triangleInd][pointInd]=newInd
  })
  return {
    position:newPos,
    index:newTirangles.flat()
  }
}

用示意图解释一下上面的代码。

image-20220815103525276

上图中,P0、P1、P2、P3 是老三角形的顶点,P4、P5、P6、P7、P8 是新三角形的顶点。

顶点p后面的数字代表顶点索引。

在上面的代码中:

  • newPos 的数据结构如下:
[
    -0.5,  1,  0,   //p4
    0,        0,  0.5, //p5
    0.5,   1,  0,   //p6
    -0.5, -1,  0,   //p7
    0.5,  -1,  0    //p8
]
  • newTirangles 的数据结构如下:
[
    [4, 5, 6],
    [5, 7, 8]
]

将newTirangles 平展开后,就是新三角形的顶点索引:

[4, 5, 6, 5, 7, 8]

4.更新旧的三角形顶点

function updateOldPoints():GeoData{
  // 遍历旧顶点集合
  const oldPoints=[]
  // 遍历老顶点
  for(let i=0;i<originPositionAttr.count;i++){
    // 更新老顶点
    const oldPoint=computeOldPoint(
      // 当前点
      getPointByInd(i),
      // 根据当前点的顶点索引获取与此点相邻的顶点
      getNearPoints(i)
    )
    oldPoints.push(oldPoint.x,oldPoint.y,oldPoint.z)
  }
  return {
    position:oldPoints
  }
}

上面的原理就是在老顶点集合中,遍历老顶点,并寻找与其相邻的老顶点,然后用我们之前说过的算法更新老顶点。

5.将新老顶点合成新的几何体

function compose(newGeoData:GeoData,oldGeoData:GeoData):BufferGeometry|null{
  const geo=new BufferGeometry()
  geo.setAttribute(
    'position',
    new BufferAttribute(
      new Float32Array([
        ...oldGeoData.position,
        ...newGeoData.position,
      ]),
      3
    )
  )
  const curIndex:Array<number>=[]
  const newIndex=newGeoData.index
  if(!newIndex){return null}
  let [p0,p1,p2,p3,p4,p5]=[0,0,0,0,0,0];
  // 遍历三角形
  forTriangle(({i,triangle})=>{
    [p0,p1,p2]=triangle
    p3=newIndex[i]
    p4=newIndex[i+1]
    p5=newIndex[i+2]
    curIndex.push(
      p0,p3,p5,
      p3,p1,p4,
      p3,p4,p5,
      p5,p4,p2
    )
  })
  geo.setIndex(curIndex)
  return geo
}

上面代码的示意图如下:

image-20220815151925215

关于Loop 对象的整体代码,我就说到这,对于其中涉及到的细节不部分,大家可以自己看代码,我都有详细的注释。若不理解,可以随时跟我联系(wx:1051904257)。

扩展

在Loop Subdivision 之前,还有 Catmull-Clark 和 Doo-Sabin 算法,大家可以在基维百科里找到,我就不做详解了。

后面我还会再去研究几何体的简化,以及规律化。