Blender, Three.js & AR.js

Par Benoit, le 09/03/2018

3D, android, blender, entreprise, three.js

Introduction

Cela fait quelques temps que je souhaite tester les possibilités AR (augmented reality) en web pur. Je vous fais donc part de mon expérience.

Les outils

Blender

Pour les modélisations 3D j'ai l'habitude d'utiliser blender (https://www.blender.org/), c'est un outil très puissant même si sa learning curve est un peu raide au démarrage. En effet, la modélisation pure, le texturing, en passant par le rendu photo réaliste et même l'animation, exigent de blender un très large éventail de fonctionnalités.

AR.js

Pour le rendu web j'ai choisi AR.js (https://github.com/jeromeetienne/AR.js). Cette librairie javascript est au dessus de three.js (https://threejs.org/). Elle comporte de bonnes optimisations pour les plates-formes à ressources limitées telles que les smart-phone.

A-FRAME

Il existe une librairie au dessus de three.js nommée a-frame https://aframe.io/. Elle permet de construire une scène en pseudo-html. Exemple:

<a-scene>
    <a-box position="-1 0.5 -3" rotation="0 45 0" color="#4CC3D9"></a-box>
    <a-sphere position="0 1.25 -5" radius="1.25" color="#EF2D5E"></a-sphere>
    <a-cylinder position="1 0.75 -3" radius="0.5" height="1.5" 
                color="#FFC65D"></a-cylinder>
    <a-plane position="0 0 -4" rotation="-90 0 0" width="4" height="4" 
             color="#7BC8A4"></a-plane>
    <a-sky color="#ECECEC"></a-sky>
</a-scene>

Il est même possible de créer son propre tag, pour charger des éléments dans la scène. Mes essais avec cette librairie ne m'ont pas satisfait. Le FPS était trop bas sur la plate-forme de test (galaxy tab 4). J'ai donc supprimé cette couche pour utiliser directement ArToolkitSource de AR.js.

glTF-Blender-Exporter

Enfin pour charger le modèle 3D avec les animations, j'ai choisi de passer par le format GLTF2 (https://github.com/KhronosGroup/glTF) un format libre pour les scènes 3D. Il faut savoir qu'il existe un add-on blender dans le dépôt three.js utilisant le format json. Mais celui-ci n'est plus activement maintenu, d'ailleurs les développeurs de three.js se demandent si cet addon doit rester dans le dépôt GIT de three.js (issue github). De mon coté j'ai choisi l'addon: glTF-Blender-Exporter

Du code

Voici un essai fonctionnant avec le marker HIRO

hiro marker

var container, stats, controls, clock, mixer;
var camera, scene, renderer, light;
var arToolkitContext;
var smoothedControls;
var clock = new THREE.Clock();
var onRenderFcts= [];
var SHADOW = false;

function initAR(){

    var arToolkitSource = new THREEx.ArToolkitSource({
        sourceType : 'webcam'
    })

    arToolkitSource.init(function onReady(){
        onResize()
    })

    window.addEventListener('resize', function(){
        onResize()
    })

    function onResize(){
        arToolkitSource.onResize()      
        arToolkitSource.copySizeTo(renderer.domElement) 
        if( arToolkitContext.arController !== null ){
            arToolkitSource.copySizeTo(arToolkitContext.arController.canvas)    
        }       
    }

    // create atToolkitContext
    arToolkitContext = new THREEx.ArToolkitContext({
        cameraParametersUrl: THREEx.ArToolkitContext.baseURL + 
            '../data/data/camera_para.dat',
        detectionMode: 'mono',
        maxDetectionRate: 30,
        canvasWidth: 80*3,
        canvasHeight: 60*3,
    })
    // initialize it
    arToolkitContext.init(function onCompleted(){
        // copy projection matrix to camera
        camera.projectionMatrix.copy( arToolkitContext.getProjectionMatrix() );
    })

    // update artoolkit on every frame
    onRenderFcts.push(function(){
        if( arToolkitSource.ready === false )   return

        arToolkitContext.update( arToolkitSource.domElement )
    })

    var markerRoot = new THREE.Group
    scene.add(markerRoot)
    var artoolkitMarker = new THREEx.ArMarkerControls(arToolkitContext, markerRoot, {
        type : 'pattern',
        patternUrl : THREEx.ArToolkitContext.baseURL + '../data/data/patt.hiro'
    })

    // build a smoothedControls
    var smoothedRoot = new THREE.Group()
    scene.add(smoothedRoot)
    smoothedControls = new THREEx.ArSmoothedControls(smoothedRoot, {
        lerpPosition: 0.4,
        lerpQuaternion: 0.3,
        lerpScale: 1,
    })
    onRenderFcts.push(function(delta){
        smoothedControls.update(markerRoot)
    })

    var arWorldRoot = smoothedRoot;
    return arWorldRoot;
}

init();
function init() {
    container = document.createElement( 'div' );
    document.body.appendChild( container );

    scene = new THREE.Scene();

    light = new THREE.DirectionalLight( 0xffffff ,2);
    light.position.set(  20, 20, 20 );

    if(SHADOW){
        light.castShadow = true;      
        light.shadow.mapSize.width = 512;  // default
        light.shadow.mapSize.height = 512; // default
        light.shadow.camera.near = 0.5;       // default
        light.shadow.camera.far = 500      // default
    }

    camera = new THREE.PerspectiveCamera();

    var loader = new THREE.GLTF2Loader();
    loader.load( 'tram2.gltf', function ( gltf ) {

        for(var index in gltf.scene.children){
            var obj = gltf.scene.children[index];
            if(SHADOW){
                if(obj.name == "Plane"){
                    obj.castShadow = false;
                    obj.receiveShadow = true;
                }else{
                    obj.castShadow = true;
                    obj.receiveShadow = true;
                }
            }
        }

        scene.add(camera);

        console.debug("camera", gltf.cameras)
        console.debug("camera", camera )
        console.debug("gltf", gltf)
        console.debug("scene", scene)

        root = initAR();

        var geometry = new THREE.CubeGeometry(1,1,1);
        var material = new THREE.MeshNormalMaterial({
            transparent : true,
            opacity: 0.5,
            side: THREE.DoubleSide
        }); 
        var mesh = new THREE.Mesh( geometry, material );

        root.add( light );
        root.add( gltf.scene );

        var animationClip = gltf.animations[ 0 ];
        mixer = new THREE.AnimationMixer( scene );
        mixer.clipAction( animationClip ).play();

        animate();

    } );

    renderer = new THREE.WebGLRenderer({
        antialias: true,
        alpha: true
    });

    renderer.setPixelRatio( window.devicePixelRatio );
    renderer.setSize( window.innerWidth, window.innerHeight );
    renderer.gammaOutput = true;
    if(SHADOW){
        renderer.shadowMap.enabled = true;
        renderer.shadowMap.type = THREE.PCFSoftShadowMap;
    }

    container.appendChild( renderer.domElement );
    window.addEventListener( 'resize', onWindowResize, false );
    stats = new Stats();

    container.appendChild( stats.dom );
}
function onWindowResize() {
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize( window.innerWidth, window.innerHeight );
}

var lastTimeMsec= null;
function animate(nowMsec) {
    requestAnimationFrame( animate );
    renderer.render( scene, camera );
    stats.update();

    lastTimeMsec        = lastTimeMsec || nowMsec-1000/60
    var deltaMsec       = Math.min(200, nowMsec - lastTimeMsec)
    lastTimeMsec        = nowMsec

    onRenderFcts.forEach(function(onRenderFct){
        onRenderFct(deltaMsec/1000, nowMsec/1000)
    })

    render()
}

function render() {
    var delta = 0.75 * clock.getDelta();
    mixer.update( delta );
    renderer.render( scene, camera );
}

Benoit