用 three.js 捏一滩墨
763 字 · 2 分钟#three.js
想做一个三维版的墨滴:几颗墨在空间里游走,挨近了就黏成一团,分开时拉出藕断丝连的细颈。这种「软融合」靠普通的球体 Mesh 做不出来——两个球再怎么摆也只是穿插,不会融。它需要另一套建模思路:元球(metaball)。
先玩再说,指针伸进去搅:
元球:先有场,后有面
普通建模是直接给出表面;元球反过来,先定义一个空间标量场——每颗球对空间任意一点贡献一份随距离衰减的「浓度」,多颗球的浓度相加——然后取一个等值面:浓度恰好等于某阈值的所有点,构成最终的表面。
融合是这套定义的自然结果:两颗球靠近时,中间区域的浓度是两者之和,原本不到阈值的点被抬过线,表面就自己「长」出了连接的颈。不需要任何专门处理相交的代码,数学本身把这件事办了。
从场到面的算法叫 marching cubes:把空间切成体素网格,逐格检查八个角点在阈值的哪一侧,按 256 种情况查表生成三角形。three.js 的 examples 里有现成实现,不用手写:
import { MarchingCubes } from 'three/examples/jsm/objects/MarchingCubes.js'
const blob = new MarchingCubes(48, material, false, false, 60000)
scene.add(blob)
第一个参数是体素分辨率,也是最重要的性能旋钮:48 意味着每帧要重算 48³ ≈ 11 万个体素的浓度场。调到 80 表面更光滑,但每帧计算量翻五倍;调到 28 帧率飞起,表面开始有可见的棱。48 是我试出来的平衡点。
让墨滴自己游
每帧重建一次场:先 reset() 清空,再把每颗墨滴 addBall() 进去。游走轨迹用的是李萨如曲线——正弦余弦组合,每颗球的频率和相位错开,看起来就是各有心事地漂:
for (let i = 0; i < 5; i++) {
const x = 0.5 + 0.2 * Math.sin(t * (0.6 + i * 0.13) + i * 1.7)
const y = 0.5 + 0.2 * Math.cos(t * (0.8 + i * 0.11) + i * 2.3)
const z = 0.5 + 0.16 * Math.sin(t * (0.4 + i * 0.09) + i)
blob.addBall(x, y, z, 0.62 + i * 0.05, 12)
}
blob.update()
指针交互几乎是白送的:把指针位置换算进场坐标,addBall 一颗跟手的球,它和其他墨滴的融合、拉丝全部自动发生。「搅动」这个交互没有一行专门的逻辑。
质感:墨是深色的镜子
材质上走了个反直觉的路线。画墨第一反应是磨砂哑光,但真实的浓墨表面有一层水光,反而接近深色的漆:
new THREE.MeshStandardMaterial({
color: '#26262d',
roughness: 0.18, // 关键:低粗糙度给出水光
metalness: 0.05,
})
光照配了三盏:主光从右上给形体,一盏冷蓝的轮廓光从背后勾边缘,环境光兜底。深色物体的立体感几乎全靠高光和轮廓光——把 roughness 调到 0.8 试过一次,整团墨糊成了一个剪影。
背景故意用了固定的纸色而不是跟随页面主题:这个 demo 的意象是「纸上一滩墨」,纸就该是纸色的。
帧预算
这个场景每帧的开销大头不在渲染在 CPU:重算浓度场 + marching cubes 生成几何。分辨率 48 时单帧约 2ms,稳。真要更高分辨率,出路是把场计算搬进 GPU(compute 或 raymarching SDF),那就是另一篇的事了。