PlayCanvas運営事務局の城戸です。
今回はビジュアライゼーションで不動産・建設関係で利用できそうなコンテンツを作りました。
建築系ビジュアライズ
とあるビルのオフィス内の案内コンテンツのモックのデモを作成しました。
階層の番号をクリックすることでその階層を一望、3Dモデルのビルを360度俯瞰することもできます。
スポットであるポイントを設置し、クリックするとHTMLで概要が表示される仕組みも作りました。
FlashがWebでよく使われていた頃に、デパートとか施設のマップ案内をするWebページがありました。
それらはFlashで実装されていましたが、2020年いっぱいでFlashはサービスを終了しました。
今では画像やPDFなどで実装されていて、少し見づらくなったように思えます。
二次元の施設のマップ案内も3次元にすることで視覚的にも分かりやすい案内マップを作ることができます。
PlayCanvasに建築系3Dモデルを使用するメリットとは
PlayCanvasにインポートすることで、作成した建築モデルをWeb上で3Dビジュアライゼーションが可能になります。
これにより、建築物の外観や内部の構造をWebからいつでもで確認できるため、営業ツールとして活用することもできます。
また、建築モデルにインタラクティブな機能を付け加えることができ、建物内を自由に移動したり、部屋の中の家具を動かして配置変更を行うことも実現可能です。今回のコンテンツのように改装ごとにプレビューを切り替えることもできます。
インタラクティブな機能を追加したプロジェクトを一つ作成すれば、プロジェクトを複製して他建築モデルに差し替えることもできます。プロジェクトを再利用することで作業効率が向上し、プロジェクトのコストを抑えることができます。
作り方
今回の記事では完成したプロジェクトから紹介しているだけなので、丸々コピーしても正しく動作しない場合があるのでご了承ください。
コードの説明については軽く触れますが詳しく解説しません。ご了承をください。
設定内容やコードなどでご質問などがあれば、コメントください。
また、PlayCanvasユーザーコミュニティがございますので、こちらからお問い合わせいただいても構いません。
レシピ
今回は以下のPlayCanvasと3つ用意すれば問題ありません。
- PlayCanvas
- 使用する3Dモデル(使用したビルの階層は事前に用意していたものになります)
-
Vue.js (CDN:
https://cdn.jsdelivr.net/npm/vue@2.6.14
) - PlayCanvas Tweenライブラリ
PlayCanvasから新規プロジェクトでテンプレートの「Model Viewer」を選び新規プロジェクトを作成します。
3Dモデルを配置
用意した3Dモデルは階層ごとにあらかじめ分けてPlayCanvasにインポートしています。
今回はLightを使用せず、3Dモデルのマテリアルに陰影(AO)を焼き付けてから使用しています。
事前に3Dモデルを用意していない場合は、PlayCanvasで作成可能のプリミティブなBoxなどでも同じようなコンテンツを作成ができます。
以下画像を見ると分かりますが、ヒエラルキーに building を親として子に各階層のEntityを入れています。
3Dモデルが移動する位置を決める
先ほど配置した3DモデルのEntityの子に、横にスライドしたときの位置を決めたEntityを配置します。
同じ3Dモデルを使って位置を決めるとわかりやすくなります。決められたらこのEntityは Enabledをfalse にして非表示しておきます。
3Dモデルの配置ができたら、次はCameraも移動する位置を決めます。
横にスライドした3Dモデルを俯瞰できるような位置に設置し、これも同様に Enabledをfalse にして非表示にします。
Cameraの設定
テンプレートの「Model Viewer」から作っていると、以下のScriptsがセットされていると思います。
- orbitCamera.js
- mouseInput.js
- touchInput.js
orbitCamera.js
の Focus Entity を変更しますが、その原点となるEntityを設置します(下部2枚目の画像)
ここでは orbitcamera-genten という原点となる空のEntityを配置しています。
Focus Entity が変更できたらOKです。


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 を使います。
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 = "";
}
}
});
};
DOMを操作する系のスクリプトはRootに登録しています。Rootの方が管理がしやすいからと言うのが理由ですね。
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を追加します。真上からの視点で配置させるとやりやすいです。
domRaycast.js
ポインターを追加できたら、次はスクリプトを作ります。
今回はDOMを使ったものになっているためstyle
のコードばかりとなりますが、update
で書いているコードがレイキャストの処理になります。
/*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の良いところです。

背景透過
ここまでで処理は完成していますが、おまけでPlayCanvasのコンテンツ背景を透過させます。
Cameraの設定のClear ColorのAlphaを0に。
SETTINGSのRENDERINGの設定内にあるTransparent Canvas
をtrueにします。

PlayCanvasでのcanvasの透過方法ですが、これも別記事で説明しています。
詳しくはそちらでご参照ください
完成
ここまで設定できたら完成です!
Vue.jsを使うことでデータの受け渡しが楽になるので良いです。ライブラリ感覚で使えるのも良いところですね。
処理のフロー
コードについてあまり説明をできていませんが、流れについて説明しておきます。
今回の根幹はchangeFloor.js
ですが、基本的にはopenする階層を選択したら移動するというのが主な処理です。
これを行うためには、各それぞれの要素がどんな動きをしているのか、どんな状態で待機しているのかを管理する必要がありました。
今回では、autoRotate.js
は自動回転を始めてしまいますので、階層をopenにした後にも自動回転してしまっては思った挙動になりません。
DOMについても表示すべき場面で表示させないといけません。
そのため、changeFloor.js
は階層を変える処理を行うのですが、他の処理をコントロールする中枢の処理も担っています。