Update The below code snippets assumes you are using Vue 2. Inline templates were removed in Vue 3 so you can’t directly use Vue in a Liquid template. Instead you will need to pass Liquid variables to Vue using props.
Solving requirements
One of the main requirements for the TP Toys’ store was the ability to combine augmented reality with 3D models so customers could see the product in their garden. At the same time they needed to be able to see an updated model seamlessly as they changed variant, or product builder configuration.
In the original specification we were planning on switching the 3D model file entirely whenever a new variant was selected. It soon became apparent this wasn’t viable; 3D files were no smaller than 5 MB and some products had more than 20 configurations meaning a customer might end up downloading 100 MB just to preview the product model.
Shopify uses the model-viewer API to display 3D models in the browser and in AR. Upon reviewing the documentation, and in consultation with TP Toys’ 3D artist, we decided to use different animations in the same file to represent each variant using a special naming convention.
Example 3D model Open the 360 view and then change the selected variant.Development
The product page was built using a combination of Liquid templates and Vue components. This could have been entirely built using Vue templating, but I always prefer to use inline templates so I can leverage Liquid rendering, this makes sense as Liquid objects and language strings are still easily available.
First I had to check if a product had a 3D model file uploaded to its media and then assign it in Liquid. Each product only supports a single 3D model.
1{% assign models = product.media | where: 'media_type', 'model' %}
2
3{% if models.size > 0 %}
4 {% assign model = models | first %}
5
6 {% for model_source in model.sources %}
7 {% if model_source.format == 'glb' %}
8 {% assign model_url = model_source.url %}
9 {% elsif model_source.format == 'usdz' %}
10 {% assign model_url_usdz = model_source.url %}
11 {% endif %}
12 {% endfor %}
13{% endif %}
Once this is set I needed to output the model-viewer
component. I decided not to use the base Shopify implementation so I could customise its display with Vue, this means I also had to separately call the model-viewer JS.
1<model-viewer
2 ref="model"
3 class="product-gallery__model"
4 alt="{{ model.alt }}"
5 animation-crossfade-duration="0"
6 ar
7 ar-models="webxr scene-viewer quick-look"
8 ar-scale="fixed"
9 camera-controls
10 interaction-prompt="none"
11 ios-src="{{ model_url_usdz }}"
12 loading="lazy"
13 quick-look-browsers="safari"
14 reveal="manual"
15 shadow-intensity="1"
16 src="{{ model_url }}"
17 tabindex="-1"
18 @load="handleModelLoad()"
19></model-viewer>
The handleModelLoad()
function is used to update the Vue state once the load
event is fired by model-viewer. This function also runs the getVariantModelAnimation()
function but first let’s look at other the Vue states.
Every variant has a unique SKU and as these SKUs are selected a currentSkus
computed array is updated and sorted alphanumerically. It’s possible to have multiple current SKUs due to the product builder hence why it’s an array.
Each configuration is represented as an animation in the model, and each animation is named as follows:
- 001.SKU1
- 002.SKU2
- 003.SKU1-SKU2
- 004.SKU1-SKU2-SKU2
- etc.
Using a Vue watch on currentSkus
I run getVariantModelAnimation()
every time it’s updated, this function is used to look through available animations and then exactly match it with the currentSkus
array.
1/**
2 * Get current SKUs matching model animation.
3 * - If no matching animation then set to first animation.
4 */
5getVariantModelAnimation() {
6 if (
7 !this.model.loaded ||
8 !this.$refs.model.availableAnimations.length
9 ) {
10 return;
11 }
12
13 /**
14 * Check to see if there is a matching animation with the same number of
15 * SKUs and which contains all current SKUs.
16 */
17 const match = this.$refs.model.availableAnimations.find((animation) => {
18 let animationSkus = animation.split('.')[1].split('-');
19
20 if (animationSkus.length !== this.currentSkus.length) {
21 return false;
22 }
23
24 animationSkus = animationSkus.sort();
25
26 return animationSkus.every((sku, index) => {
27 return sku === sortedCurrentSkus[index];
28 });
29 });
30
31 /**
32 * Update model animation and set time to end.
33 */
34 this.$refs.model.animationName = (
35 match ||
36 this.$refs.model.availableAnimations[0]
37 );
38
39 this.$refs.model.currentTime = 1.0;
40},
As you can see in the code (lines 34-37) match
is then used to update the current animation on the model. The currentTime
is also set to the end (line 39) otherwise it might show a transitional state between two animations.
Thanks to a bit of co-operation and Vue this was ultimately a fairly straightforward challenge when it could have been a much bigger problem.