To complete this workshop you will need:
Hardware:
Languages used:
At the core of the WebAR experience is the library AR.Js. Based on the open source library ARToolkit, AR.Js can be used with Three.js or AFrame and, like the last one, its Hello World scene requires just few lines of HTML to run.
Since version 3.0.0, AR.js has two different sources: one using NFT Image tracking, and a second one using Marker Based Tracking. The latter one is used in this workshop.
<!--AFrame version 1.2.0-->
<script src="https://aframe.io/releases/1.2.0/aframe.min.js"></script>
<!--AR.js version 3.1.0-->
<script src="https://raw.githack.com/AR-js-org/AR.js/3.1.0/aframe/build/aframe-ar.js">
Add in the body
of the webpage the tag a-scene
with the following components:
embedded
arjs
with the properties:debugUIEnabled: false;
sourceType: webcam;
trackingMethod: best;
color-space="sRGB"
vr-mode-ui="enabled: false;"
to remove the VR buttonrenderer
of the scene, with the properties:logarithmicDepthBuffer: false;
physicallyCorrectLights: true;
colorManagement: true;
<a-scene
embedded
arjs="debugUIEnabled: false; sourceType: webcam; trackingMethod: best;"
color-space="sRGB"
vr-mode-ui="enabled: false"
renderer="logarithmicDepthBuffer: false; physicallyCorrectLights: true; colorManagement: true; "
>
Add a camera entity with a unique ID
<a-entity id='userCamera' camera> </a-entity>
and the a-marker
tag with the attribute preset="hiro"
<a-marker preset="hiro"> </a-marker>
AR.js contains the pattern of two markers: Hiro, the image can be download here, and Kanji, the image can be download here.
When AR.js detects the marker, the entities within <a-marker> </a-marker>
are set to visible.
<a-marker preset="hiro">
<a-box position='0 0 0' material='color: blue;'></a-box>
</a-marker>
Here the complete index.html for the Hello WebAR scene
<!DOCTYPE html>
<html lang="en">
<head>
<title>WebAR</title>
<!--AFrame version 1.2.0-->
<script src="https://aframe.io/releases/1.2.0/aframe.min.js"></script>
<!--AR.js version 3.1.0-->
<script src="https://raw.githack.com/AR-js-org/AR.js/3.1.0/aframe/build/aframe-ar.js"></script>
</head>
<body>
<a-scene
embedded
arjs="debugUIEnabled: false; sourceType: webcam; trackingMethod: best;"
color-space="sRGB"
vr-mode-ui="enabled: false"
renderer="logarithmicDepthBuffer: false; physicallyCorrectLights: true; colorManagement: true; "
>
<a-entity id='userCamera' camera> </a-entity>
<a-marker preset="hiro">
<a-box position='0 0 0' material='color: blue;'></a-box>
</a-marker>
</a-scene>
</body>
</html>
To test your code using the inbuilt webcam of your laptop, or an external webcam, in VSCode run the live server (restart VSCode if the live server is not available).
To test your code using a smartphone, run the Node.JS command ws --https
in the folder of your project (you can open a terminal directly in VSCode from the top menu).
Point your camera or smartphone to the Hiro marker to display the blue cube
The renderer
component in the a-scene
tag is used to control the final appearance of the AR cube: e.g. without physically correct lighting...
...and with physically correct lighting
It is possible to create custom markers using theonline Marker Training tool.
Choose the right image is a process of trial and error, here some minimum requirements:
ar.js
component of the a-scene
tag, therefore, if multiple markers are used in the same AR experience, they will need to have the same pattern ratio to be correctly detectedPrepare two images (below two samples) and upload them, one at the time, to online Marker Training tool. Set the pattern ratio to 85%
and press Download Marker to download the PATT
file (the pattern recognised by the source camera), Download Image to download the image with the added black border.
a-marker
from preset="hiro"
to type="pattern"
and add the location of the .patt
file just downloadedFrom:
<a-marker preset="hiro">
<a-box position='0 0.5 0' material='color: blue;'></a-box>
</a-marker>
To:
<a-marker id="marker1" type="pattern" url="resources/patt/pattern-imgage1.patt">
<a-box position='0 0 0' material='color: blue;'></a-box>
</a-marker>
<a-marker id="marker2" type="pattern" url="./resources/patt/pattern-image2.patt">
<a-box position='0 0 0' material='color: red;'></a-box>
</a-marker>
arjs
the patternRatio
(e.g. 85% pattern ratio, 15% black border => 0.85).<a-scene
embedded
arjs="debugUIEnabled: false; sourceType: webcam; trackingMethod: best; patternRatio: 0.85"
color-space="sRGB"
vr-mode-ui="enabled: false"
renderer="physicallyCorrectLights: true; colorManagement: true; "
>
The markers can be used to display any kind of AFrame entity (e.g. primitive geometries) as well as external assets (e.g. images, videos, 3d models).
a-scene
tag<a-scene [...] >
<assets>
<a-asset-item
id="plant_gltf"
src="resources/models/plant/scene.gltf">
</a-asset-item>
<a-asset-item
id="display_glb"
src="resources/models/display.glb">
</a-asset-item>
</assets>
replace the content of the two markers with the 3D models of the plant and of the display. The a-marker
, as well as the other a-entity
can have nested elements.
<a-marker id="marker1" type="pattern" url="./resources/patt/pattern-image1.patt">
<!--<a-box position='0 0 0' material='color: blue;'></a-box>-->
<a-entity
id="platPot"
gltf-model="#plant_gltf"
scale="0.2 0.2 0.2"
position="0 0 0"
rotation="0 0 0">
</a-entity>
</a-marker>
<a-marker id="marker2" type="pattern" url="./resources/patt/pattern-image2.patt">
<!--<a-box position='0 0 0' material='color: red;'></a-box>-->
<a-entity
id="plantDisplay"
gltf-model="#display_glb"
scale="0.2 0.2 0.2"
position="0 0 0"
rotation="0 0 0">
</a-entity>
</a-marker>
It is possible to create a complex AR experience just using HTML, however, each new marker needs to be added manually to the scene. It is possible to automate this process using custom Aframe components. Moreover, custom Aframe components allow us to provide additional features to the application.
We are going to use an external json file to store the information about our markers
marker.json
file in the resources folder{
"info": {
"version": "1.0",
"title": "WebAR-MultiMarkers",
"markers":2
},
"content": [
{
"markerName": "pattern-image1",
"topic": "1",
"textContent": "ONE"
},
{
"markerName": "pattern-image2",
"topic": "2",
"textContent": "TWO"
}
]
}
The markerName field need to match the name of the patt file without file extension
markerManager.js
file and create a component registerevents
. This component will listen to the markerFound
and markerLost
eventsAFRAME.registerComponent('registerevents', {
init: function () {
const handler=this.el;
handler.addEventListener("markerFound", (event) => {
let markerId = event.target.id;
console.log('Marker Found: ', markerId);
});
handler.addEventListener("markerLost", (event) => {
let markerId = event.target.id;
console.log('Marker Lost: ', markerId);
});
},
});
registerevents
to the a-scene
tag. Once a marker is detected, the event
fired by the component will contain the name of the marker itself.markerManager.js
file, another component markers_start_json
//component to create the marker a-entity from the content/json
AFRAME.registerComponent('markers_start_json', {
init: function () { }
})
init
function we are going to create a variable to reference the a-scene
and parse the .json
file using the fetch() functioninit: function () {
console.log('Add markers to the scene');
let sceneEl = document.querySelector('a-scene');
//index.json contains the list of markers and content
fetch("./resources/markers.json")
.then(response => response.json())
.then(json => {
console.log(json.content);
})
index.html
add a new script tag just after the libraries<script src="./resources/js/markerManager.js"></script>
and attach also this component markers_start_json
Component to the a-scene
<a-scene
embedded
arjs="debugUIEnabled: false; sourceType: webcam; trackingMethod: best; patternRatio: 0.85"
color-space="sRGB"
vr-mode-ui="enabled: false"
renderer="physicallyCorrectLights: true; colorManagement: true; "
registerevents
markers_start_json
>
Array [ {...}, {...} ]
0: Object { markerName: "pattern-image1", topic: "1", textContent: "ONE", ... }
1: Object { markerName: "pattern-image1", topic: "2", textContent: "TWO", ... }
and a log every time a marker is detected
a-entity
tags we will need. The pseudo-structure of the a-marker
will be the following<a-marker>
<a-entity plantcontainer>
<a-entity plant 3D model>
<a-entity display 3Dmodel></a-entity>
</a-entity>
<a-entity text></a-entity>
</a-entity>
</a-marker>
forEach
loop after the console.log(json.content)
console.log(json.content);
json.content.forEach(el => { });
Inside the loop, for each element el
(i.e. the two markers) the script needs to create and setup the following variables
//0. createa a string that contain the URL of the PATT file
let markerURL = './resources/patt/' + el.markerName + '.patt';
//1. Create and add a a-marker to scene
let markerEl = document.createElement('a-marker');
markerEl.setAttribute('type', 'pattern');
markerEl.setAttribute('url', markerURL);
markerEl.setAttribute('id', el.topic); //the topic from the json file
sceneEl.appendChild(markerEl); //Add the marker to the scene, this was declare outside the loop
//2. Add a text entity to each marker
let textEl = document.createElement('a-entity');
textEl.setAttribute('id', 'text' + el.textContent); //the text from the json file
textEl.setAttribute('text', { color: 'red', align: 'center', value: el.textContent, width: '6' });
textEl.object3D.position.set(-0.0, 0.1, 0.5);
textEl.setAttribute('rotation', { x: -90, y: 0, z: 0 });
markerEl.appendChild(textEl); //add the text to the marker
//Create the Plant Model and Panel for the data
//3. The main container
let plantRT = document.createElement('a-entity');
plantRT.setAttribute('id', 'planRT_' + el.topic);
plantRT.setAttribute('rotation', { x: -90, y: 0, z: 0 });
plantRT.object3D.position.set(0, 0, 0.2);
plantRT.object3D.scale.set(1, 1, 1);
markerEl.appendChild(plantRT);
//4. the 3D model of the Display.glb
let screen = document.createElement('a-entity');
screen.setAttribute('id', 'screen_' + el.topic);
screen.setAttribute('gltf-model', '#display_glb');
screen.object3D.position.set(0.3, 0.41, -0.01);
screen.object3D.scale.set(0.3, 0.3, 1);
screen.setAttribute('rotation', { x: 0, y: 0, z: 0 });
plantRT.appendChild(screen);
//5. the 3D model of the plant
let modelplant = document.createElement('a-entity');
modelplant.setAttribute('id', 'modelplant_' + el.topic);
modelplant.setAttribute('gltf-model', '#plant_gltf');
modelplant.object3D.position.set(-0.2, 0, 0);
modelplant.object3D.scale.set(0.2, 0.2, 0.2);
modelplant.setAttribute('rotation', { x: 0, y: 180, z: 0 });
plantRT.appendChild(modelplant);
We can now add new plants just by adding the patter file to the patt folder and new entries in the json file
We can now add the text on the virtual display, first let's add a clock on the top right corner of the display.
timenow
//Component to visualise Time on a a-text entity
AFRAME.registerComponent('timenow',
{
init: function () {
// Set up the tick throttling. Slow down to 500ms
this.tick = AFRAME.utils.throttleTick(this.tick, 500, this);
},
tick: function () {
this.el.setAttribute('value', this.getTime());
},
getTime: function () {
var d = new Date();
return d.toLocaleTimeString();
}
})
This component is going to be attached to a new a-entity
text we are going to add to the markers_start_json
component. After the model plant element
//5. the 3D model of the plant
let modelplant = document.createElement('a-entity');
modelplant.setAttribute('id', 'modelplant_' + el.topic);
modelplant.setAttribute('gltf-model', '#plant_gltf');
modelplant.object3D.position.set(-0.2, 0, 0);
modelplant.object3D.scale.set(0.2, 0.2, 0.2);
modelplant.setAttribute('rotation', { x: 0, y: 180, z: 0 });
plantRT.appendChild(modelplant);
//6. Date.time component
let timenowText = document.createElement('a-text');
timenowText.setAttribute('id', 'timenowText_' + el.topic);
timenowText.setAttribute('timenow', '');
timenowText.setAttribute('value', 't');
timenowText.setAttribute('width', '3.2');
timenowText.setAttribute('anchor', 'left');
timenowText.object3D.position.set(0.35, 0.85, 0.02);
screen.appendChild(timenowText); //add the text to the screen
Let's now create the labels and placeholder texts use to visualise the MQTT plant sensor values. In the markers_start_json
component add after the timenowText element
//7. Sensor Moisture
let moistureText = document.createElement('a-text');
moistureText.setAttribute('id', 'moisture-text_' + el.topic);
moistureText.setAttribute('value', 'Moisture:');
moistureText.setAttribute('width', '4');
moistureText.setAttribute('anchor', 'left');
moistureText.object3D.position.set(-0.9, 0.5, 0.02);
screen.appendChild(moistureText); //add the text to the screen
//8. Sensor Temperature
let temperatureText = document.createElement('a-text');
temperatureText.setAttribute('id', 'temperature-text_' + el.topic);
temperatureText.setAttribute('value', 'Temperature:');
temperatureText.setAttribute('width', '4');
temperatureText.setAttribute('anchor', 'left');
temperatureText.object3D.position.set(-0.9, 0.1, 0.02);
screen.appendChild(temperatureText); //add the text to the screen
//9. Sensor Humidity
let humidityText = document.createElement('a-text');
humidityText.setAttribute('id', 'humidity-text_' + el.topic);
humidityText.setAttribute('value', 'Humidity:');
humidityText.setAttribute('width', '4');
humidityText.setAttribute('anchor', 'left');
humidityText.object3D.position.set(-0.9, -0.3, 0.02);
screen.appendChild(humidityText); //add the text to the screen
Finally, we can add the MQTT.js library to connect the AR experience with the plant sensors
Add the mqtt.js library in the index.html, just after the Aframe and AR.js library
<!--MQTT.js-->
<script type="text/javascript" src="https://unpkg.com/mqtt@2.18.3/dist/mqtt.min.js"></script>
We are going to update the registerevents
by adding the MQTT client connection to the broker and two sub-function startMqtt
(to subscribe to a specific topic) and stopMqtt
(to disconnect from the broker) when the markers are detected or lost
//Listen to the markers
AFRAME.registerComponent('registerevents', {
init: function () {
let clients=[]; //a list of all MQTT clients
const handler=this.el;
handler.addEventListener("markerFound", (event) => {
var markerId = event.target.id;
console.log('Marker Found: ', markerId);
//Create a new client with ID of the marker
let client = mqtt.connect('ws://BROKER.ADDRESS:8080',{clientId:event.target.id});
clients.push(client); //add the client to the Array
client.on('connect', function () {
console.log('Connected');
});
client.on('error', function (error) {
console.log(error);
});
this.startMqtt(event.target,client);
});
handler.addEventListener("markerLost", (event) => {
var markerId = event.target.id;
console.log('Marker Lost: ', markerId);
//Find the client according to its ID
let clientToStop = clients.find(client => client.options.clientId === event.target.id);
//remove the client from the Array
let index = clients.indexOf(clientToStop);
clients.splice(index,1);
//pass the client to stopMQTT to end the connection
this.stopMqtt(clientToStop);
});
},
startMqtt: function (marker,client) {
if (!client.connected) {
client.reconnect()}
client.subscribe('TOPIC/TO/SUBSCRIBE/Moisture');
client.subscribe('TOPIC/TO/SUBSCRIBE/Temperature');
client.subscribe('TOPIC/TO/SUBSCRIBE/Humidity')
client.on('message', function (topic, message) {
//Called each time a message is received
console.log('Received message:', topic, message.toString());
if (topic.includes('inHumidity')) {
marker.querySelector('#moisture-text_' + marker.id).setAttribute('value', 'Moisture: ' + message.toString() + '%');}
if (topic.includes('appTemp_C')) { //temperature
marker.querySelector('#temperature-text_' + marker.id).setAttribute('value', 'Temperature: ' + parseFloat(message.toString()).toFixed(2).toString() + '°C');}
if (topic.includes('outHumidity')) { //humidity
marker.querySelector('#humidity-text_' + marker.id).setAttribute('value', 'Humidity: ' + message.toString() + '%');}
})
},
stopMqtt: function (client) {
client.end(true);
console.log('connection closed');
}
});