翻译说明:
原标题: How we implemented 3D cards in Revolut
原文地址: (https://medium.com/@afeozzz/how-we-implemented
原文作者: Ilnar Karimov
在 Revolut,我们将客户体验置于我们所做的一切的核心,旨在通过简单的设计和谨慎的执行带来愉悦。然后,当我们介绍卡片订单流程的更新时,您可以想象我们的兴奋。在最新版本的 Revolut 应用程序中,您将能够从交互式3D模型中选择您的卡。
这对我们来说是一个有趣的挑战,因为这是我们第一次使用基于3D物理的引擎来创建一个功能。我们认为结果非常好!
进入应用程序的卡片订单部分,您将可以选择两种材料 - 塑料和金属。从那里,您将能够选择一种颜色,以及您是否需要 Visa 或万事达卡(取决于您所在的国家/地区)。
让我们来看看我们如何达到这个技术高度,并探索一路上的一些挑战。
渲染
从哪里开始? 首先,我们尝试使用 GLSurfaceView,创建我们自己的渲染器并使用 OpenGL ES 绘制卡片。但这种方法有一些缺点:
- 并非所有移动开发人员都熟悉 OpenGL,这意味着花在培训和可持续性问题上的时间更多
- Android已经支持OpenGL ES 3.1,但仍然有一个糟糕的API。结果?大量的样板,数学和头发拉动
所以我们认为我们会找到更好的解决方案。一些搜索引导我们选择几个方面:
- min3d - 是一个轻量级的3D库/框架,适用于 Android,使用 Java 和 OpenGL ES,目标是兼容 Android v1.5 / OpenGL ES 1.0及更高版本。此外,min3d有一个更好的API,但是建于2010年,不再受支持
- Libgdx - 是一个完全Java游戏开发框架。它提供了许多功能,但对于只想旋转3D卡的 FinTech 应用程序而言,它太大了
- Filament - 是一款基于实时物理的渲染引擎,适用于 Android,iOS,Windows,Linux,macOS和WASM / WebGL。它为开发人员提供了一组工具和API,以帮助他们轻松创建高质量的2D和3D渲染。你可以使用它渲染令人难以置信的图像,我们强烈建议你使用它。
Sceneform 支持以下格式的3D资源:
要将我们的卡片模型包含到项目中,我们需要链接我们的资源并.sfb通过 Android Studio 插件将它们转换为文件。
作为资产的一部分,我们应该创建自己的材料。材质定义表面的视觉外观。它是一种着色器。
我们 .mat
看起来像这样:
material {
name : "Card material",
parameters : [
{
type : sampler2d,
name : baseColorMap
},
{
type : sampler2d,
name : normalMap
},
{
type : sampler2d,
name : roughnessMap
},
{
type : sampler2d,
name : metallicMap
},
{
type : sampler2d,
name : reflectanceMap
}
],
requires : [
uv0
],
shadingModel : lit,
}
fragment {
void material(inout MaterialInputs material) {
vec3 normal = texture(materialParams_normalMap, getUV0()).xyz;
material.normal = normal * 2.0 - 1.0; //bump mapping
prepareMaterial(material);
material.baseColor = texture(materialParams_baseColorMap, getUV0());
material.roughness = texture(materialParams_roughnessMap, getUV0()).r;
material.metallic = texture(materialParams_metallicMap, getUV0()).r;
material.reflectance = texture(materialParams_reflectanceMap, getUV0()).r;
}
}
baseColor
- 定义对象的感知颜色roughness
- 控制表面的感知光滑度。metallic
- 定义表面是金属还是非金属reflectance
- 此属性可用于控制镜面反射强度。它只影响非金属表面。
定义了这个,我们为每个属性的UV映射创建了纹理,你可以在下面看到其中一个:
要创建每个纹理,我们使用了这个 Texture.builder()
类,您需要使用以下 Texture.Usage
常量之一传递资源和使用类型的来源: COLOR, NORMAL, DATA
internal fun Context.loadTexture(
sourceUri: Uri,
usage: Texture.Usage
): Texture.Builder =
Texture.builder()
.setSource(this, Uri.parse(uri))
.setUsage(usage)
.setSampler(
Texture.Sampler.builder()
.setMagFilter(Texture.Sampler.MagFilter.LINEAR)
.setMinFilter(Texture.Sampler.MinFilter.LINEAR_MIPMAP_LINEAR)
.build()
)
接下来,我们可以收集所有必要的纹理并将它们应用到我们加载的卡片模型:
val cardTextures = availableTextures.map { texture -> loadTexture(texture.path, texture.usage) }
ModelRenderable.builder()
.setSource(context, Uri.parse(MODEL_SFB_PATH))
.build()
.thenApply { model ->
cardTextures.forEach { result -> model.material.setTexture(result.name, result.texture) }
}
就是这样!现在我们准备建立自己的场景。
限定 layout.xml
<com.google.ar.sceneform.SceneView
android:id="@+id/sceneView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
并将卡节点添加到现有场景:
private val card3dNode = Node().apply {
localPosition = Vector3(CARD_POSITION_X_AXIS, CARD_POSITION_Y_AXIS, CARD_POSITION_Z_AXIS)
localRotation = getRotationQuaternion(CARD_STARTING_Y_AXIS_ANGLE.toFloat())
name = CARD_ID
}
fun addCardToScene(modelRenderable: ModelRenderable, currentCard: CardRender) {
modelRenderable.material = currentCard.value
with(card3dNode) {
setParent(sceneView.scene)
renderable = modelRenderable
localScale = modelRenderable.computeScaleVector(targetSize = 1.5f)
currentCard.renderCard()
}
with(sceneView.scene) {
camera.localScale = Vector3(CAMERA_SCALE_WIDTH, CAMERA_SCALE_HEIGHT, CAMERA_FOCAL_LENGTH)
camera.localPosition = Vector3(CAMERA_POSITION_X_AXIS, CAMERA_POSITION_Y_AXIS, CAMERA_POSITION_Z_AXIS)
sunlight?.let {
it.worldPosition = Vector3.back()
it.light = cardSceneSunLight
}
addChild(card3dNode)
}
}
虚拟卡
对于虚拟卡,我们希望实现透明的外观。为此,我们需要创建自定义材料:
material {
"name" : "VirtualCard",
"parameters" : [
{
type : sampler2d,
name : baseColorMap
}
],
requires: [
"uv0"
],
shadingModel: "lit",
blending: "transparent",
transparency : "twoPassesTwoSides",
doubleSided: true,
depthWrite : true
}
fragment {
void material(inout MaterialInputs material) {
prepareMaterial(material);
material.baseColor = texture(materialParams_baseColorMap, getUV0());
}
}
这里最有趣的部分是 blending
。Transparent
使用 Porter-Duff
的 source over 规则定义材质的输出与渲染目标的 alpha 合成。
正如您在一次性卡上所注意到的那样,卡号(或PAN)具有数字变化动画。对于这个技巧,我们每秒都改变卡片的漫反射纹理。其中有3个。
一切都不可能是完美的,所以我们面临一些问题和限制:
- 在具有 CompletableFuture 的模型的适当加载中,Sceneform 要求minSdkVersion≥24。
- 动态纹理。
material.setTexture
不允许在运行时更改纹理。工作解决方案是创建一个假对象并将此材质复制到真实对象 - 在
v1.8
SDK之前,没有办法设置背景的白色。我们通过额外的节点和自定义材料解决了这个问题。
动画
正如您所注意到的,该卡具有物理基本动画。Android 提供通过支持库来实现。
compile "com.android.support:support-dynamic-animation:28.0.0"
在 FlingAnimation 类,您可以为对象创建一扔动画。要构建一个 fling 动画,请创建一个 FlingAnimation 类的实例,并提供一个对象和要设置动画的对象属性。
abstract class CardProperty(name: String) : FloatPropertyCompat<Node>(name)
private val rotationProperty: CardProperty = object : CardProperty("rotation") {
override fun setValue(card: Node, value: Float) {
card.localRotation = getRotationQuaternion(value)
}
override fun getValue(card: Node): Float = card.localRotation.y
}
private var animation: FlingAnimation = FlingAnimation(card3dNode, rotationProperty).apply {
friction = FLING_ANIMATION_FRICTION
minimumVisibleChange = DynamicAnimation.MIN_VISIBLE_CHANGE_ROTATION_DEGREES
}
在 fling 手势检测器中,我们在 onFling
没有任何更新侦听器的情况下运行动画。只需设定速度即可离开。
class FlingGestureDetector : GestureDetector.SimpleOnGestureListener() {
override fun onScroll(e1: MotionEvent, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
val deltaX = -(distanceX / screenDensity) / CARD_ROTATION_FRICTION
card3dNode.localRotation = getRotationQuaternion(lastDeltaYAxisAngle + deltaX)
return true
}
override fun onFling(e1: MotionEvent, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean {
if (Math.abs(velocityX) > SWIPE_THRESHOLD_VELOCITY) {
val deltaVelocity = (velocityX / screenDensity) / CARD_ROTATION_FRICTION
startAnimation(deltaVelocity)
}
return true
}
}
private fun startAnimation(velocity: Float) {
if (!animation.isRunning) {
animation.setStartVelocity(velocity)
animation.setStartValue(lastDeltaYAxisAngle)
animation.start()
}
}
对于卡片旋转,我们使用了一个 localRotation
利用四元数的属性。Sceneform 有一个静态方法,它使用轴角表示并通过 axisAngle 和所需的向量计算四元数。在我们的情况下 Vector3(0.0f, 1.0f, 0.0f)
。
但是这会在每个动画帧中创建冗余对象,因此我们需要使用现有的四元数和向量复制此方法:
private val quaternion = Quaternion()
private val rotateVector = Vector3.up()
private fun getRotationQuaternion(deltaYAxisAngle: Float): Quaternion {
lastDeltaYAxisAngle = deltaYAxisAngle
return quaternion.apply {
val arc = toRadians(deltaYAxisAngle)
val axis = sin(arc / 2.0)
x = rotateVector.x * axis
y = rotateVector.y * axis
z = rotateVector.z * axis
w = cos(arc / 2.0)
normalize()
}
}
结论
Sceneform 是一个非常新鲜的库,但它已经具有广泛的功能:优化渲染,强大的API和小型运行时。所有这些功能帮助我们快速实现3D,而无需学习OpenGL。
感谢所有参与这一挑战的人,特别是:
Denis Kovalev, 令人难以置信的UI / UX。 Dmitry Kovalev,他创造了3D模型和纹理。 George Robson,他是Premium团队的天才所有者。 Ilia Kisliakovskii,我们的后端英雄。 Mikhail Koltsov和Igor Dudenkov,我们心爱的iOS人员。