1-几何体细分的概念
几何体细分,就是按照某种规则为几何体增加三角面,并且让几何体更加圆滑,如下图:
上面的几何体我是按照Loop Subdivision 算法细分的,它在不断细分的同时,也变圆滑了。
大家可能还会觉得它变小了,这是我去掉了其棱角后导致的。
接下来,咱们就详细说一下Loop Subdivision 几何细分算法。
2-Loop Subdivision 几何体细分算法
Loop Subdivision 几何体细分要分两步来走:
1.增加三角形数量
2.调整三角形顶点的位置
2-1-增加三角形数量
基于三角形三条边的中点进行细分把每一个三角形分成四个。
2-2-调整三角形顶点的位置
调整三角形顶点的位置的时候,要对老三角形和新三角的顶点进行区别对待。
2-2-1-设置新三角形顶点的位置
新三角的顶点有两种:
-
只在一个三角形的单边上的顶点,如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,如:
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-设置老三角形顶点的位置
还是以之前的图片为例,大家只需要关注老三角形顶点即可。
以点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)
效果如下:
3-2-Loop对象详解
3-2-1-Loop对象适用的几何体
当前的Loop对象只适用于具备以下特性的BufferGeometry:
- 具备顶点索引index
- 所有的公共顶点都焊接在一起
解释一下公共顶点都焊接在一起的意思。
在下图中,点B、点C 是两个两个相邻三角形的公共顶点。
- 当点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
]
此时的两个三角形是断开的:
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()
}
}
用示意图解释一下上面的代码。
上图中,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
}
上面代码的示意图如下:
关于Loop 对象的整体代码,我就说到这,对于其中涉及到的细节不部分,大家可以自己看代码,我都有详细的注释。若不理解,可以随时跟我联系(wx:1051904257)。
扩展
在Loop Subdivision 之前,还有 Catmull-Clark 和 Doo-Sabin 算法,大家可以在基维百科里找到,我就不做详解了。
后面我还会再去研究几何体的简化,以及规律化。