LoginSignup
2
2

More than 1 year has passed since last update.

不動産・建築系3Dモデルのビジュアライゼーションを作ってみた

Last updated at Posted at 2020-02-12

PlayCanvas運営事務局の城戸です。

今回はビジュアライゼーションで不動産・建設関係で利用できそうなコンテンツを作りました。

建築系ビジュアライズ

build.gif
こちらでコンテンツを体験できます

とあるビルのオフィス内の案内コンテンツのモックのデモを作成しました。
階層の番号をクリックすることでその階層を一望、3Dモデルのビルを360度俯瞰することもできます。
スポットであるポイントを設置し、クリックするとHTMLで概要が表示される仕組みも作りました。

FlashがWebでよく使われていた頃に、デパートとか施設のマップ案内をするWebページがありました。
それらはFlashで実装されていましたが、2020年いっぱいでFlashはサービスを終了しました。
今では画像やPDFなどで実装されていて、少し見づらくなったように思えます。

二次元の施設のマップ案内も3次元にすることで視覚的にも分かりやすい案内マップを作ることができます。

PlayCanvasに建築系3Dモデルを使用するメリットとは

PlayCanvasにインポートすることで、作成した建築モデルをWeb上で3Dビジュアライゼーションが可能になります。
これにより、建築物の外観や内部の構造をWebからいつでもで確認できるため、営業ツールとして活用することもできます。

また、建築モデルにインタラクティブな機能を付け加えることができ、建物内を自由に移動したり、部屋の中の家具を動かして配置変更を行うことも実現可能です。今回のコンテンツのように改装ごとにプレビューを切り替えることもできます。

インタラクティブな機能を追加したプロジェクトを一つ作成すれば、プロジェクトを複製して他建築モデルに差し替えることもできます。プロジェクトを再利用することで作業効率が向上し、プロジェクトのコストを抑えることができます。

作り方

今回の記事では完成したプロジェクトから紹介しているだけなので、丸々コピーしても正しく動作しない場合があるのでご了承ください。

コードの説明については軽く触れますが詳しく解説しません。ご了承をください。
設定内容やコードなどでご質問などがあれば、コメントください。
また、PlayCanvasユーザーコミュニティがございますので、こちらからお問い合わせいただいても構いません。

レシピ

今回は以下のPlayCanvasと3つ用意すれば問題ありません。

PlayCanvasから新規プロジェクトでテンプレートの「Model Viewer」を選び新規プロジェクトを作成します。
スクリーンショット 2023-03-29 12.32.49.png

3Dモデルを配置

用意した3Dモデルは階層ごとにあらかじめ分けてPlayCanvasにインポートしています。
今回はLightを使用せず、3Dモデルのマテリアルに陰影(AO)を焼き付けてから使用しています。

事前に3Dモデルを用意していない場合は、PlayCanvasで作成可能のプリミティブなBoxなどでも同じようなコンテンツを作成ができます。

以下画像を見ると分かりますが、ヒエラルキーに building を親として子に各階層のEntityを入れています。
スクリーンショット 2020-02-12 15.50.40.png

3Dモデルが移動する位置を決める

先ほど配置した3DモデルのEntityの子に、横にスライドしたときの位置を決めたEntityを配置します。
同じ3Dモデルを使って位置を決めるとわかりやすくなります。決められたらこのEntityは Enabledをfalse にして非表示しておきます。
スクリーンショット 2020-02-12 15.57.14.png

3Dモデルの配置ができたら、次はCameraも移動する位置を決めます。
横にスライドした3Dモデルを俯瞰できるような位置に設置し、これも同様に Enabledをfalse にして非表示にします。
スクリーンショット 2020-02-12 15.57.24.png

Cameraの設定

テンプレートの「Model Viewer」から作っていると、以下のScriptsがセットされていると思います。

  • orbitCamera.js
  • mouseInput.js
  • touchInput.js

orbitCamera.jsFocus Entity を変更しますが、その原点となるEntityを設置します(下部2枚目の画像)
ここでは orbitcamera-genten という原点となる空のEntityを配置しています。
Focus Entity が変更できたらOKです。

スクリーンショット 2020-02-12 16.03.29.png スクリーンショット 2020-02-12 16.03.43.png

autoRotate.js

Cameraに新しく自動回転するスクリプトを作成します。

このautoRotate.jsはテンプレートにあるorbitCamera.jsで設定しているCameraのYawを操作しています。

updateでthis.orbitCamera.yaw += this.speed;と記述しているのが自動回転する処理です。
this.timer += dt;とかは Attributes の wait の秒数と比較していて、急に回転を始めるのではなく、ちょっと待ってから自動回転を始めるようになります。

var AutoRotate = pc.createScript('autoRotate');

AutoRotate.attributes.add('speed', {type: 'number',default: 1,title: 'Speed',description: 'The rotate speed of camera.'});
AutoRotate.attributes.add('wait', {type: 'number',default: 5,title: 'Wait',description: 'Enable auto rotate after seconds.'});

AutoRotate.prototype.initialize = function() {
    this.timer = 0;
    this.orbitCamera = this.entity.script.orbitCamera;
};

AutoRotate.prototype.resetTimer = function() {
    this.timer = 0;
};

AutoRotate.prototype.update = function(dt) {
    if(vueApp.rotateFlag) return false;
    if(!vueApp.openfloor&&!vueApp.clickAnimation){
        this.timer += dt;

        if (this.timer > this.wait) {
            this.orbitCamera.yaw += this.speed;
        }
    }
};

DOMを追加

次はHTMLやCSSの設定に進みます。

Vue.jsを使う

PlayCanvasでVue.jsとかのCDNを使う場合には、SETTINGSの EXTERNAL SCRIPTS を使います。
スクリーンショット 2020-02-12 16.24.17.png

詳しい解説はこちらから

changeFloor.js

orbitCamera.jsがCameraの動きの中枢でしたが、changeFloor.jsはコンテンツの柱となるためコードが多くなります。

DOMを配置するためにdiv要素wrapperを配置や、CSSはgetFileUrl()を使いlink要素で読み込むなど処理をしていますが、選択された階層に対するCameraと3Dモデルの移動が肝となる処理になります。

vueAppの箇所がVue.jsで操作する箇所になります。

tween()はPlayCanvasのTweenライブラリです。
これにより、CameraのPositionやEulerAngleと、3DモデルのPositionを処理してます。

このTweenの処理を true/flase(Boolean)のFlag で管理しています。
階層がOpen、Closeを判断し、対応した処理を行なっています。

このFlagは先ほどのautoRotate.jsの以下のコードも見ています。
ここではCloseの状態でTweenのアニメーションが切れていれば autoRotate するようにしています。

if(vueApp.rotateFlag) return false;
    if(!vueApp.openfloor&&!vueApp.clickAnimation){
/*jshint esversion: 6, asi: true, laxbreak: true*/
const ChangeFloor = pc.createScript('changeFloor');

ChangeFloor.attributes.add("baseHtml", {type:"asset", assetType:"html"});
ChangeFloor.attributes.add("setCSS", {type:"asset", assetType:"css"});
ChangeFloor.attributes.add("target", {type:"entity"});
ChangeFloor.attributes.add("cameraTarget", {type:"entity"});

ChangeFloor.prototype.initialize = function() {
    let self = this;
    let canvas = document.getElementsByTagName("canvas")[0];
    canvas.classList.add("pcCanvas");
    let wrapper = document.createElement("div");
    wrapper.classList.add("wrapper");
    wrapper.innerHTML = self.baseHtml._resources[0];
    canvas.parentNode.appendChild(wrapper);

    let css = document.createElement("link");
    css.setAttribute("href", this.setCSS.getFileUrl());
    css.setAttribute("rel", "stylesheet");
    document.head.appendChild(css);

    let t_camera = self.cameraTarget;
    let cameraPosOri = Object.assign({},t_camera.getLocalPosition());
    let cameraRotOri = Object.assign({},t_camera.getLocalEulerAngles());
    
    let tHead = "とあるビルのオフィス内";
    let tContent = "PlayCanvasでビル内の各階層を3Dで観ることができます。 <br>気になる箇所をクリックすることで詳細が観れます。";
    
    vueApp = new Vue({
        el: '#app',
        data: {
            openfloor: false,
            clickAnimation: false,
            floors: self.target.children,
            lastTarget: null,
            targetPosOri: [],
            DOMhead: tHead,
            DOMcontent: tContent,
            DOMraycastFlag: false,
            DOMraycastHead: "",
            DOMraycastContent: "",
            rotateFlag: false,
        },
        methods: {
            onfloorClick: function(target,index) {
                const v_self = this;
                if(v_self.clickAnimation || v_self.openfloor) return;
                cameraPosOri = Object.assign({},t_camera.getLocalPosition());
                cameraRotOri = Object.assign({},t_camera.getLocalEulerAngles());
                v_self.clickAnimation = true;
                v_self.openfloor = true;

                v_self.lastTarget = index;
                v_self.targetPosOri[index] = Object.assign({}, target.getLocalPosition());

                let cameraPos,cameraRot,targetPos;

                for(let i=0; i<target.children.length; i++) {
                    if(target.children[i].name != "RootNode"){
                        if(target.children[i].camera){
                            cameraPos = Object.assign({}, target.children[i].getLocalPosition());
                            cameraRot = Object.assign({}, target.children[i].getLocalEulerAngles());
                        }else if(target.children[i].tags._list[0] === "tween"){
                            targetPos = Object.assign({}, target.children[i].getLocalPosition());
                        }
                    }
                }
                
                // カメラ移動
                t_camera.tween(t_camera.getLocalPosition()).to({
                    x:target.getLocalPosition().x+cameraPos.x,
                    y:target.getLocalPosition().y+cameraPos.y,
                    z:target.getLocalPosition().z+cameraPos.z
                }, 1, pc.SineOut).start();
                t_camera.tween(t_camera.getLocalEulerAngles()).rotate(cameraRot, 1, pc.Linear).start();
                // ビル階層移動
                target.tween(target.getLocalPosition()).to({
                    x:target.getLocalPosition().x+targetPos.x,
                    y:target.getLocalPosition().y+targetPos.y,
                    z:target.getLocalPosition().z+targetPos.z
                }, 1, pc.SineOut).on("complete",function(){
                    target.tags.add("isopen");
                    v_self.clickAnimation = false;
                }).start();
                
                v_self.DOMhead = "ただいま、" + target.name + "";
                v_self.DOMcontent = "ここは" + target.name + "階です。ここにはその階に応じた説明文を記入する感じになります。";
            },
            oncloseClick: function() {
                const v_self = this;
                v_self.floors[v_self.lastTarget].tags.remove("isopen");
                // カメラ移動
                t_camera.tween(t_camera.getLocalPosition()).to({
                    x:cameraPosOri.x,
                    y:cameraPosOri.y,
                    z:cameraPosOri.z
                }, 1, pc.SineOut).start();
                t_camera.tween(t_camera.getLocalEulerAngles()).rotate(cameraRotOri, 1, pc.SineOut).start();
                v_self.clickAnimation = true;
                // ビル階層移動
                v_self.floors[v_self.lastTarget].tween(v_self.floors[v_self.lastTarget].getLocalPosition()).to(v_self.targetPosOri[v_self.lastTarget], 1, pc.SineOut)
                    .on("complete",function(){ v_self.clickAnimation = false; })
                    .start();
                v_self.openfloor = false;
            
                v_self.DOMhead = tHead;
                v_self.DOMcontent = tContent;
            },
            oncloseRcClick: function() {
                const v_self = this;
                v_self.DOMraycastFlag = false;
                v_self.DOMraycastHead = "";
                v_self.DOMraycastContent = "";
            }
        }
    });
    
};

Attributesに設定は以下画像の通り
スクリーンショット 2020-02-12 16.30.12.png

DOMを操作する系のスクリプトはRootに登録しています。Rootの方が管理がしやすいからと言うのが理由ですね。
スクリーンショット 2020-02-12 16.29.50.png

Attributesに登録されているHTMLとCSSのコードを次に解説します。

base.html

Vueの構文が色々書いていますが、階層がOpenかCloseかをv-ifで見ていたり、どの階層をクリックしたかを@clickでイベント取得したりしています。
class="detail is-ray"の要素は、レイキャストでクリックしたスポットの情報を表示するDOMです。

<div id="app" class="wrapper">
    <nav v-if="!rotateFlag" class="select" :class="openfloor ? '' : 'is-open'">
        <div class="select__inner">
            <div class="item" v-for="(floor,index) in floors" @click="onfloorClick(floor,index)"><span>{{floor.name}}</span></div>
        </div>
    </nav>
    <div v-if="!rotateFlag" class="detail" :class="{'is-open':openfloor}">
        <div class="closeBtn" v-if="openfloor&&!DOMraycastFlag" @click="oncloseClick()"></div>
        <div class="detail__inner">
            <p class="domhead" v-html="DOMhead"></p>
            <p class="domcontent" v-html="DOMcontent"></p>
        </div>
    </div>
    <div class="detail is-ray" v-if="DOMraycastFlag">
        <div class="closeBtn" @click="oncloseRcClick()"></div>
        <div class="detail__inner">
            <p class="domhead" v-html="DOMraycastHead"></p>
            <p class="domcontent" v-html="DOMraycastContent"></p>
        </div>
    </div>
    <div class="rotateCheck" v-if="!openfloor"><label for="rotate"><input type="checkbox" id="rotate" v-model="rotateFlag" /><span>カメラ操作切り替え</span></label></div>
</div>

style.css

本来はreset.cssを記述していますが、特筆するものはないのでここでは割愛します。

body {
    background-color: #b1b1b1;
}

.wrapper {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    z-index: 10;
}
.select {
    position: absolute;
    top: 0;
    right: 0;
    z-index: 5;
    transform: translateX(400px);
    transition: transform .4s;
}
.select.is-open {
    transform: translateX(0);
}
.select__inner {
    position: absolute;
    top: 0;
    right: 0;
    width: 330px;
    padding: 5px;
    border-radius: 10px;
    background-color: rgba(255,255,255, .5);
}
.item {
    display: inline-block;
    vertical-align: middle;
    margin: 10px;
    padding: 10px;
    border: 1px solid #cccccc;
    border-radius: 50%;
    background-color: #cccccc;
    text-align: center;
    transition: background .3s;
    cursor: pointer;
}
.item span {
    color: #eeeeee;
    font-size: 2rem;
    line-height: 1;
    transition: color .3s;
}
.item:hover {
    background-color: #eeeeee;
}
.item:hover span {
    color: #333333;
}
.closeBtn {
    position: absolute;
    top: -10px;
    right: 0;
    width: 50px;
    height: 50px;
    background-color: #333333;
    transition: background .3s;
    cursor: pointer;
}
.closeBtn:before,.closeBtn:after {
    content: "";
    position: absolute;
    top: 50%;
    left: 50%;
    width: 30px;
    height: 2px;
    border-radius: 25%;
    background-color: #eeeeee;
    transition: background .3s;
}
.closeBtn:before {
    transform: translate(-50%,-50%) rotate(45deg);
}
.closeBtn:after {
    transform: translate(-50%,-50%) rotate(135deg);
}
.closeBtn:hover {
    background-color: #eeeeee;
}
.closeBtn:hover:before,.closeBtn:hover:after {
    background-color: #333333;
}
.detail {
    position: absolute;
    bottom: 50%;
    left: 50%;
    z-index: 2;
    transform: translate(-50%,50%);
    max-width: 800px;
    min-width: 420px;
    width: 100%;
    padding: 50px 20px;
    transition: all .4s;
    pointer-events: none;
}
.detail.is-ray {
    pointer-events: auto;
}
.detail * {
    transition: all .6s;
}
.detail__inner {
    padding: 100px 30px;
    border-radius: 10%;
    background-color: rgba(35,35,35, .7);
    text-align: center;
}
.domhead {
    margin-bottom: .5em;
    color: #eeeeee;
    margin-bottom: 10px;
    font-size: 2rem;
    font-weight: bold;
    line-height: 2;
}
.domcontent {
    color: #eeeeee;
    font-size: 1.6rem;
    line-height: 2;
}
.rotateCheck {
    position: fixed;
    bottom: 0;
    right: 0;
    z-index: 10;
    pointer-events: auto;
}
.rotateCheck label {
    display: block;
    cursor: pointer;
}
.rotateCheck label input {
    display: none;
}
.rotateCheck label span {
    display: inline-block;
    padding: 1rem;
    background-color: #cccccc;
    font-size: 1rem;
    line-height: 1;
    transition: color .2s, background .2s;
}
.rotateCheck label input:checked+span {
    color: #eeeeee;
    background-color: #333333;
    pointer-events: none;
}

.detail.is-open {
    bottom: 0;
    left: 0;
    transform: translate(0,0);
    max-width: 100%;
    min-width: 0;
    width: 100%;
    padding: 20px;
    pointer-events: auto;
}
.detail.is-open .detail__inner {
    padding: 20px;
    border-radius: 0;
    background-color: rgba(255,255,255, .7);
    text-align: left;
}
.detail.is-open .domhead {
    color: #111111;
    font-size: 1.2rem;
}
.detail.is-open .domcontent {
    color: #111111;
    font-size: 1rem;
}

レイキャスト

レイキャストについてはこちら

各階層のスポットを紹介するためにレイキャストでクリックできるポインターを設置します。
各階層の子としてEntityを追加します。真上からの視点で配置させるとやりやすいです。
スクリーンショット 2020-02-12 17.14.55.png

domRaycast.js

ポインターを追加できたら、次はスクリプトを作ります。
今回はDOMを使ったものになっているためstyleのコードばかりとなりますが、updateで書いているコードがレイキャストの処理になります。

DOMでのレイキャスト以外にも方法あります

/*jshint esversion: 6, asi: true, laxbreak: true*/
const DomRaycast = pc.createScript('domRaycast');

DomRaycast.attributes.add("cameraEntity", {type: "entity", title: "Camera Entity"});
DomRaycast.attributes.add("a_domhead", {type: "string", title: "DOM Head"});
DomRaycast.attributes.add("a_domcontent", {type: "string", title: "DOM Content"});

// g_domRC = {};
// g_domRC.flag = false;
// g_domRC.domhead = "test";
// g_domRC.domcontent = "testest";

DomRaycast.prototype.initialize = function() { // init
    let self = this;
    self.directionToCamera = new pc.Vec3();
    self.defaultForwardDirection = self.entity.forward.clone();
    
    self.btn = document.createElement("div");
    document.getElementsByTagName("canvas")[0].parentNode.appendChild(self.btn);
    self.btn.style.position = "absolute";
    self.btn.style.width = "30px";
    self.btn.style.height = "30px";
    self.btn.style.borderRadius = "50%";
    self.btn.style.background = "#111111";
    self.btn.style.transition = "opacity .5s";
    self.btn.style.zIndex = 10;
    self.btn.style.cursor = "pointer";
    self.btn.style.pointerEvents = "none";
    self.btn.addEventListener("mouseover",function(){ this.style.background = "#555555"; });
    self.btn.addEventListener("mouseout",function(){ this.style.background = "#111111"; });
    self.btn.addEventListener("mousedown",function(){ this.style.background = "#aaaaaa"; });
    self.btn.addEventListener("mouseup",function(){
        vueApp.DOMraycastFlag = true;
        vueApp.DOMraycastHead = self.a_domhead;
        vueApp.DOMraycastContent = self.a_domcontent;
        this.style.background = "#111111";
    });
};

DomRaycast.prototype.update = function(dt) { // update
    let worldPos = this.entity.getPosition();
    let screenPos = new pc.Vec3();
    
    this.cameraEntity.camera.worldToScreen(worldPos, screenPos);
    
    this.directionToCamera.sub2(this.cameraEntity.getPosition(), this.entity.getPosition());
    this.directionToCamera.normalize();
    // let dot = this.directionToCamera.dot(this.defaultForwardDirection);
    if (this.entity.parent.tags._list[0] === "isopen" && !vueApp.DOMraycastFlag) {
        this.btn.style.pointerEvents = "auto";
        this.btn.style.opacity = 1;
    } else {
        this.btn.style.pointerEvents = "none";
        this.btn.style.opacity = 0;
    }
    
    this.btn.style.transform = "translate(" + screenPos.x + "px," + screenPos.y + "px)";
};

AttributesではCameraとモーダルで表示させるテキスト情報を登録できます。
スクリプトを一つ作って各Entityである階層に登録していけるのがPlayCanvasの良いところです。

スクリーンショット 2020-02-12 17.16.33.png

背景透過

ここまでで処理は完成していますが、おまけでPlayCanvasのコンテンツ背景を透過させます。
Cameraの設定のClear ColorのAlphaを0に。
スクリーンショット 2020-02-12 17.23.40.png
SETTINGSのRENDERINGの設定内にあるTransparent Canvasをtrueにします。

スクリーンショット 2020-02-12 17.23.56.png

PlayCanvasでのcanvasの透過方法ですが、これも別記事で説明しています。
詳しくはそちらでご参照ください

完成

ここまで設定できたら完成です!
Vue.jsを使うことでデータの受け渡しが楽になるので良いです。ライブラリ感覚で使えるのも良いところですね。
スクリーンショット 2020-02-12 17.29.06.png

処理のフロー

コードについてあまり説明をできていませんが、流れについて説明しておきます。

今回の根幹はchangeFloor.jsですが、基本的にはopenする階層を選択したら移動するというのが主な処理です。
これを行うためには、各それぞれの要素がどんな動きをしているのか、どんな状態で待機しているのかを管理する必要がありました。
今回では、autoRotate.jsは自動回転を始めてしまいますので、階層をopenにした後にも自動回転してしまっては思った挙動になりません。
DOMについても表示すべき場面で表示させないといけません。
そのため、changeFloor.jsは階層を変える処理を行うのですが、他の処理をコントロールする中枢の処理も担っています。
スクリーンショット 2.png

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2