[译]我们如何在Revolut中实现3D卡

1,128 阅读6分钟

翻译说明:

原标题: 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());
    }
}

这里最有趣的部分是 blendingTransparent 使用 Porter-Duff 的 source over 规则定义材质的输出与渲染目标的 alpha 合成。

正如您在一次性卡上所注意到的那样,卡号(或PAN)具有数字变化动画。对于这个技巧,我们每秒都改变卡片的漫反射纹理。其中有3个。


一切都不可能是完美的,所以我们面临一些问题和限制:

  • 在具有 CompletableFuture 的模型的适当加载中,Sceneform 要求minSdkVersion≥24。
  • 动态纹理。material.setTexture 不允许在运行时更改纹理。工作解决方案是创建一个假对象并将此材质复制到真实对象
  • v1.8SDK之前,没有办法设置背景的白色。我们通过额外的节点和自定义材料解决了这个问题。

动画

正如您所注意到的,该卡具有物理基本动画。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人员。


欢迎关注 Kotlin 中文社区!

中文官网:www.kotlincn.net/

中文官方博客:www.kotliner.cn/

公众号:Kotlin

知乎专栏:Kotlin

CSDN:Kotlin中文社区

掘金:Kotlin中文社区

简书:Kotlin中文社区