技术方法与工具
由于这是一个简单的单页静态网站,无需后端支持,我们使用了基于Astro与Vite和Three.js的内部样板文件。对于物理效果,我们选择了Rapier,因为它能高效处理碰撞检测,并且与Three.js兼容。这是我们的主要需求,因为我们不需要模拟或软体计算。
在细胞技术项目中,我们特别希望展示如何在不使用大量功能或组件充斥屏幕的情况下获得令人满意的结果。我们的主要关注点是视觉效果和交互性——为了让用户体验流畅无缝,类似流体的模拟是实现这一目标的好方法。在Unseen,我们经常将这种效果作为附加的交互组件实现。在这个项目中,我们想采用一种略有不同但仍能达到类似结果的方法。
基于设计师的概念,有几个实施方向需要考虑。为了保持体验优化,即使在大型规模下,让GPU处理大部分计算通常是最佳方法。为此,我们需要在着色器中实现效果,并使用更复杂的实现,如打包算法和自定义类Voronoi图案。然而,在测试Rapier库后,我们意识到简单的刚体对象碰撞足以实时重新创建概念。
物理实现
为此,我们需要在3D渲染世界旁边创建一个独立的物理世界,因为Rapier库仅处理物理计算,图形部分由开发人员选择实现。
以下是我们创建刚体部分的代码片段:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
for (let i = 0; i < this.numberOfBodies; i++) {
const x = Math.random() * this.bounds.x - this.bounds.x * 0.5
const y = Math.random() * this.bounds.y - this.bounds.y * 0.5
const z = Math.random() * (this.bounds.z * 0.95) - (this.bounds.z * 0.95) * 0.5
const bodyDesc = RAPIER.RigidBodyDesc.dynamic().setTranslation(x, y, z)
bodyDesc.setGravityScale(0.0) // 禁用重力
bodyDesc.setLinearDamping(0.7)
const body = this.physicsWorld.createRigidBody(bodyDesc)
const radius = MathUtils.mapLinear(Math.random(), 0.0, 1.0, this._cellSizeRange[0], this._cellSizeRange[1])
const colliderDesc = RAPIER.ColliderDesc.ball(radius)
const collider = this.physicsWorld.createCollider(colliderDesc, body)
collider.setRestitution(0.1) // 弹性 0 = 无弹跳, 1 = 完全弹跳
this.bodies.push(body)
this.colliders.push(collider)
}
|
表示身体的网格是单独创建的,在每个tick上,它们的变换会通过物理引擎的变换进行更新。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
// 更新网格位置
for (let i = 0; i < this.numberOfBodies; i++) {
const body = this.bodies[i]
const position = body.translation()
const collider = this.colliders[i]
const radius = collider.shape.radius
this._dummy.position.set(position.x, position.y, position.z)
this._dummy.scale.setScalar(radius)
this._dummy.updateMatrix()
this.mesh.setMatrixAt(i, this._dummy.matrix)
}
this.mesh.instanceMatrix.needsUpdate = true
|
考虑到性能,我们首先尝试了Rapier库的2D版本,但很快就发现,细胞仅分布在一个平面上,视觉效果不够令人信服。在Z平面进行额外计算的性能影响被改进的结果所证明是合理的。
使用后期处理构建视觉效果
显然,后期处理效果在这个项目中扮演了重要角色。到目前为止,最重要的是模糊效果,它使细胞从清晰的简单环转变为流体状的粘稠物质。我们实现了Kawase模糊,它类似于高斯模糊,但使用盒式模糊而不是高斯函数,在较高模糊级别下性能更好。我们仅将其应用于屏幕的某些部分以保持视觉趣味性。
这已经使实现更接近概念。体验的另一个重要部分是色彩分级,我们将颜色映射到场景中元素的亮度。我们忍不住添加了典型的流体模拟,因此颜色会根据流体运动略微偏移。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
if (uFluidEnabled) {
fluidColor = texture2D(tFluid, screenCoords);
fluid = pow(luminance(abs(fluidColor.rgb)), 1.2);
fluid *= 0.28;
}
vec3 color1 = uColor1 - fluid * 0.08;
vec3 color2 = uColor2 - fluid * 0.08;
vec3 color3 = uColor3 - fluid * 0.08;
vec3 color4 = uColor4 - fluid * 0.08;
if (uEnabled) {
// 应用色彩分级
color = getColorRampColor(brightness, uStops.x, uStops.y, uStops.z, uStops.w, color1, color2, color3, color4);
}
color += color * fluid * 1.5;
color = clamp(color, 0.0, 1.0);
color += color * fluidColor.rgb * 0.09;
gl_FragColor = vec4(color, 1.0);
|
性能优化
由于物理引擎所需的计算能力随着所需计算数量的增加而迅速增加,我们旨在使体验尽可能优化。第一步是找到不影响视觉效果的最小细胞数量,即不让细胞过于稀疏。为此,我们最小化了细胞创建的区域,并使细胞略大。
另一个重要步骤是确保没有冗余计算,这意味着每个计算都必须通过屏幕上可见的结果来证明是合理的。为了确保这一点,我们将细胞创建的区域限制为仅覆盖屏幕,无论屏幕大小如何。这基本上意味着场景中的所有细胞在摄像机中都是可见的。通常,这种方法涉及基于摄像机视野和与对象的距离来稍微更复杂地推导边界区域,然而,对于这个项目,我们使用了正交摄像机,这简化了计算。
1
2
3
4
5
6
7
8
9
10
|
this.camera._width = this.camera.right - this.camera.left
this.camera._height = this.camera.top - this.camera.bottom
// .....
this.bounds = {
x: (this.camera._width / this.options.cameraZoom) * 0.5,
y: (this.camera._height / this.options.cameraZoom) * 0.5,
z: 0.5
}
|