webgl实现一个高性能的点阵地球
很久没有写博客了,最近依然在学习一些webgl、glsl知识,今天和大家共同学习一个堪称优化到极限的shader代码
为什么说Cobe堪称极限
首先是Cobe这个库大小只有5KB,并且在移动端上也有非常惊艳的效果,这全部得益于Cobe本身就是基于标准webgl来开发没有使用类似Three.js等大型webgl封装库,以及在shader中利用数学公式来反向推导实现了点阵的效果!没有使用高清纹理图片没有使用cpu来递归循环!
1.一切从圆开始!
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
float aspect = u_resolution.x / u_resolution.y;
vec2 p = uv * 2. - 1.;
p.x *= aspect;
float r = 0.5;
// p.x * p.x + p.y * p.y
float d2 = dot(p, p);
if (d2 < r * r) {
outColor = vec4(vec3(0.0), 1.0);
} else {
outColor = vec4(vec3(0.141), 1.0);
}
}
根据勾股定理我们知道 ,我们利用dot计算出来了,然后判断小于半径的我们给一个黑色outColor = vec4(vec3(0.0), 1.0)大于半径的我们给一个背景色outColor = vec4(vec3(0.141), 1.0),这样就实现了一个半径0.5的圆~
2.给圆形贴上纹理
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
float aspect = u_resolution.x / u_resolution.y;
vec2 p = uv * 2. - 1.;
p.x *= aspect;
float r = 0.5;
// p.x * p.x + p.y * p.y
float d2 = dot(p, p);
if (d2 < r * r) {
// 1. 计算球面上该点的法线 (nor)
// 在正交投影下,z = sqrt(r^2 - x^2 - y^2)
float z = sqrt(r * r - d2);
vec3 nor = normalize(vec3(p.x, p.y, z));
// 2. 让球转动起来,方便观察两极
nor = rotateY(u_time * 0.5) * rotateX(u_time * 0.3) * nor;
float u_lon = atan(nor.x, nor.z) / (2.0 * PI) + 0.5;
float v_lat = asin(nor.y) / PI + 0.5;
float mapValue = texture(u_texture, vec2(u_lon, v_lat)).r;
// 5. 颜色输出
outColor = vec4(vec3(mapValue), 1.0);
} else {
outColor = vec4(vec3(0.141), 1.0);
}
}
我们首先传入一个1280 × 712大小95KB的纹理贴图,然后把我们第一步画好的圆升维到3D的圆,根据3D圆勾股定理,之前我们已经有float d2 = dot(p, p)也就是也知道了所以,现在我们有了x、y、z,就可以归一化之后运用等距柱状投影来把纹理图贴到球体上!
经度映射 (U 坐标)
- 公式推导:使用 atan(x, z) 计算向量在 平面(水平面)上的极坐标角度。其范围是 。
- 归一化:除以 后,范围变成 。加上 后,范围变成 。
- 物理意义:这对应了地球的经度,也就是绕着 轴转了一圈。
纬度映射
- (V 坐标)公式推导: asin(y)(反正弦)计算的是向量与 平面的夹角。由于是单位圆,其范围是 (即 到 )。
- 归一化:除以 后,范围变成 。加上 后,范围变成 。
- 物理意义: 这对应了地球的纬度,从南极 (0) 经过赤道 (0.5) 到达北极 (1)。
3.实现一个均分点阵
vec2 mappingUV(vec3 nor) {
// 1. 计算纬度角 theta (从北极 0 到 南极 PI)
float theta = acos(nor.y);
// 2. 计算经度角 phi (从 -PI 到 PI)
float phi = atan(nor.z, nor.x);
// 3. 将其映射到 [0, 1] 区间以便取索引或上色
float u = (phi + PI) / (2.0 * PI);
float v = theta / PI;
return vec2(u, v);
}
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
float aspect = u_resolution.x / u_resolution.y;
vec2 p = uv * 2. - 1.;
p.x *= aspect;
float r = 0.5;
// p.x * p.x + p.y * p.y
float d2 = dot(p, p);
if (d2 < r * r) {
// 1. 计算球面上该点的法线 (nor)
// 在正交投影下,z = sqrt(r^2 - x^2 - y^2)
float z = sqrt(r * r - d2);
vec3 nor = normalize(vec3(p.x, p.y, z));
// 2. 让球转动起来,方便观察两极
nor = rotateY(u_time * 0.5) * rotateX(u_time * 0.3) * nor;
// float u_lon = atan(nor.x, nor.z) / (2.0 * PI) + 0.5;
// float v_lat = asin(nor.y) / PI + 0.5;
// float mapValue = texture(u_texture, vec2(u_lon, v_lat)).r;
// 3. 使用你的 mappingUV 映射到二维
vec2 st = mappingUV(nor);
// 4. 制作点阵
// st.x 是经度 (0-1),st.y 是纬度 (0-1)
// 我们在经度方向分 30 份,纬度方向分 15 份
vec2 grid = vec2(60.0, 30.0);
vec2 ipos = floor(st * grid);// 单元格 ID
vec2 fpos = fract(st * grid);// 单元格内坐标 (0-1)
// 在每个格子里画一个圆点
float dist = distance(fpos, vec2(0.5));
float dotMask = smoothstep(0.1, 0.09, dist);
// 5. 颜色输出
outColor = vec4(vec3(dotMask), 1.0);
} else {
outColor = vec4(vec3(0.141), 1.0);
}
}
这个点阵的实现是根据经纬度均分排列所以我们能看到在南北极的区域点特别密集,在赤道上就会比较稀疏!
4.实现一个点阵地球
...
...
// 3. 使用你的 mappingUV 映射到二维
vec2 st = mappingUV(nor);
// 4. 制作点阵
// st.x 是经度 (0-1),st.y 是纬度 (0-1)
// 我们在经度方向分 30 份,纬度方向分 15 份
vec2 grid = vec2(60.0, 30.0);
vec2 ipos = floor(st * grid);// 单元格 ID
vec2 fpos = fract(st * grid);// 单元格内坐标 (0-1)
// 在每个格子里画一个圆点
float dist = distance(fpos, vec2(0.5));
float dotMask = smoothstep(0.1, 0.09, dist);
float mapValue = texture(u_texture, st).r;
// 5. 颜色输出
outColor = vec4(vec3(dotMask * mapValue), 1.0);
dotMask * mapValue因为步骤2我们的纹理贴图是0或者1,步骤3我们的点阵也是0或者1,所以二者相乘我们就得到了一个最简单的点阵地球!同时大家应该还记得我们用的贴图是一个1280 × 712大小95KB的纹理贴图,但是我们其实只需要纹理中的0或者1的色值!所以我们替换一张1KB的纹理看看效果!
效果肉眼基本看不出差别!这也是Cobe这个库只有5KB大小的一个核心
5.斐波那契点阵
步骤3的时候我们的点阵方案采用的是经纬度均分,但是步骤3的方案很明显有一个缺点是南极北极的点特别密集集中而赤道的点会相对稀疏,所以我们应该采用斐波那契数列生成的点!关于斐波那契数列有一下资料可以参考
总结一下斐波那契数列的特点
在 3D 图形学中,要生成这些点,通常使用以下公式:高度(垂直方向):将球体的高度 均匀分成 份。角度(水平旋转):每个点相对于上一个点旋转一个黄金角度。黄金角度 ,即 ,其中 是黄金分割比。这样分布的点,既不会在极点堆积,也不会在赤道稀疏,视觉上极其平衡。
其实我之前的纯CSS实现Soul星球也是对于斐波那契数列的运用,但是我们的目标是在shader中使用,这就涉及到shader的特性了,是同时计算点的位置和颜色而不是通过循环计算生成,但是关于斐波那契数列是没有一个简单的逆向计算公式的!所以这里采用的是Spherical fibonacci mapping提出的计算方法。
- 先估算当前点在整个
斐波那契数列中的维度k - 计算格网坐标 ,也就是
斐波那契数列形成的倾斜网格 - 小范围遍历(检查格子周围的 4 个点)
/**
* 基于斐波那契点阵的快速最近邻搜索
* p: 输入的球面采样方向(单位向量)
* m: 输出参数,记录到最近点的距离
* 返回值: 距离输入点 p 最近的斐波那契点的 3D 坐标
*/
vec3 nearestFibonacciLattice(vec3 p, out float m) {
// 1. 坐标系转换:将常见的 y-up 转换成 z-up 逻辑进行内部计算
p = p.xzy;
// 2. 自适应层级计算:根据当前点的纬度(p.z)决定搜索密度
// sqrt5, dots, PI 等常数决定了点阵的总规模
float k = max(2., floor(log2(sqrt5 * dots * PI * (1. - p.z * p.z)) * byLogPhiPlusOne));
// 3. 构造斐波那契网格的基向量 (Basis Vectors)
// 利用黄金分割比 kPhi 的幂次来定位该层级的格网结构
vec2 f = floor(pow(kPhi, k)/sqrt5*vec2(1, kPhi)+.5);
vec2 br1 = fract((f+1.) * phiMinusOne)*kTau - twoPiOnPhi;
vec2 br2 = -2.*f;
// 4. 将球面坐标转为简单的 2D 极坐标表示 (方位角 theta, 高度 h)
vec2 sp = vec2(atan(p.y, p.x), p.z-1.);
// 5. 逆矩阵变换:通过矩阵运算,直接推算出目标点在斐波那契网格中的“格子坐标” c
// 这样就不用遍历几千个点,而是直接锁定目标所在的局部区域
vec2 c = floor(vec2(br2.y * sp.x - br1.y * (sp.y * dots + 1.), -br2.x * sp.x + br1.x * (sp.y * dots + 1.)) / (br1.x*br2.y-br2.x*br1.y));
float mindist = PI; // 初始化最小距离为最大可能值
vec3 minip;
// 6. 局部 4 邻域搜索:检查目标格子周围的 4 个候选点
for (float s = 0.; s < 4.; s+=1.) {
vec2 o = vec2(mod(s, 2.), floor(s*.5)); // 生成偏移量 (0,0), (1,0), (0,1), (1,1)
float idx = dot(f, c + o); // 根据网格坐标计算出该点的唯一索引 idx
if (idx > dots) continue; // 超过总点数则跳过
// --- 核心优化:快速计算经度 (Theta) ---
// 下面这一长串 if 实际上是在对 idx 进行二进制拆解,计算黄金序列的分数值
// 这是一种为了避免浮点精度丢失而手动实现的快速计算方法
float tidx = idx;
float fracV = 0.;
...
...
float theta = fract(fracV) * kTau; // 最终得到该索引对应的旋转角度
// 7. 还原候选点的 3D 坐标
float cosphi = 1. - 2. * idx * byDots; // 映射到 Z 轴 [-1, 1]
float sinphi = sqrt(1. - cosphi * cosphi); // 根据勾股定理计算水平半径
vec3 sample2 = vec3(cos(theta) * sinphi, sin(theta) * sinphi, cosphi);
// 8. 距离判定
float dist = length(p - sample2);
if (dist < mindist) {
mindist = dist; // 更新最小距离
minip = sample2; // 记录最近的点
}
}
m = mindist; // 将距离存入输出变量
return minip.xzy; // 转回原坐标系方向并返回结果
}
有了nearestFibonacciLattice方法之后我们就可以替换掉之前的mappingUV方法
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
float aspect = u_resolution.x / u_resolution.y;
vec2 p = uv * 2. - 1.;
p.x *= aspect;
float r = 0.5;
// p.x * p.x + p.y * p.y
float d2 = dot(p, p);
if (d2 < r * r) {
// 1. 计算球面上该点的法线 (nor)
// 在正交投影下,z = sqrt(r^2 - x^2 - y^2)
float z = sqrt(r * r - d2);
vec3 nor = normalize(vec3(p.x, p.y, z));
// 2. 让球转动起来,方便观察两极
nor = rotateY(u_time * 0.5) * rotateX(u_time * 0.3) * nor;
// float u_lon = atan(nor.x, nor.z) / (2.0 * PI) + 0.5;
// float v_lat = asin(nor.y) / PI + 0.5;
// float mapValue = texture(u_texture, vec2(u_lon, v_lat)).r;
float dis;
vec3 gP = nearestFibonacciLattice(nor, dis);
float gPhi = asin(gP.y);
float gTheta = atan(gP.z, gP.x);
vec2 dotUV = vec2(0.5 + gTheta / kTau, 0.5 + gPhi / PI);
float isOnLand = step(0.45, texture(u_texture, dotUV).r);
float v = smoothstep(.008, .0, dis);
outColor = vec4(vec3(v * isOnLand), 1.0);
} else {
outColor = vec4(vec3(0.141), 1.0);
}
}
6.优化整体效果
现在我们的点阵已经改为了斐波那契数列实现解决了南极北极点过于密集的问题,但是此时我们还是可以发先球体在旋转的过程边缘的地方还是会产生一些类似摩尔纹的效果,所以我们在边缘的地方实现一下菲涅尔效果,同时添加一下光照计算
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
float aspect = u_resolution.x / u_resolution.y;
vec2 p = uv * 2. - 1.;
p.x *= aspect;
float r = 0.5;
// p.x * p.x + p.y * p.y
float d2 = dot(p, p);
float glowFactor = 0.;
vec4 color = vec4(0);
float l = dot(p, p);
float rSquared = r * r;
if (d2 < r * r) {
// 1. 计算球面上该点的法线 (nor)
// 在正交投影下,z = sqrt(r^2 - x^2 - y^2)
float z = sqrt(r * r - d2);
vec3 nor = normalize(vec3(p.x, p.y, z));
vec3 rawNor = normalize(vec3(p.x, p.y, z));
// 2. 让球转动起来,方便观察两极
nor = rotateY(u_time * 0.5) * rotateX(u_time * 0.3) * nor;
vec4 layer = vec4(0);
vec3 light = vec3(0, 0, 1);
float dotNL = dot(rawNor, light);
// float u_lon = atan(nor.x, nor.z) / (2.0 * PI) + 0.5;
// float v_lat = asin(nor.y) / PI + 0.5;
// float mapValue = texture(u_texture, vec2(u_lon, v_lat)).r;
float dis;
vec3 gP = nearestFibonacciLattice(nor, dis);
float gPhi = asin(gP.y);
float gTheta = atan(gP.z, gP.x);
vec2 dotUV = vec2(0.5 + gTheta / kTau, 0.5 + gPhi / PI);
float mapColor = step(0.5, texture(u_texture, dotUV).r);
float v = smoothstep(.008, .0, dis);
float lighting = pow(dotNL, 3.0)*dotsBrightness;
float sample2 = mapColor * v * lighting;
float colorFactor = mix((1. - sample2) * pow(dotNL, .4), sample2, dark) + .1;
layer += vec4(baseColor * colorFactor, 1.);
layer.xyz += pow(1. - dotNL, 4.) * glowColor;
color += layer * (1. + opacity) * .5;
glowFactor = pow(dot(normalize(vec3(-uv, sqrt(1.- l))), vec3(0, 0, 1)), 4.) * smoothstep(0., 1., .2/(l-rSquared));
} else {
float outD = sqrt(.2/(l - rSquared));
glowFactor = smoothstep(0.5, 1., outD / (outD + 1.));
}
outColor = color + vec4(glowFactor * glowColor, glowFactor);
}
光照的核心部分就是计算点阵的颜色以及基础色和最后的扩散的颜色~
结束语
最近有些懒惰了,更新博客也不勤快了!希望2026年能坚持下去