NTTドコモ サービスイノベーション部の長田です。去年のアドベントカレンダーでこちらの記事を書いていたように、業務は全体的に3D関連のことをやっています。
今回は Blender を使ったモデリングの話をしたいと思います。テーマはずばり Blender GIS でボクセルゲーム風な歌舞伎町一番街を作る です。
※一枚目: Wikipedia より1
※ 二枚目: 筆者撮影
なぜボクセルゲーム風な歌舞伎町一番街を選んだか
現実の街並みを Blender に取り込めるツールの1つとして Blender GIS というものが有名です。いろいろな方がチュートリアル動画やブログ記事をネットにアップしています。
これを使って街並みを自分でも作ってみたい、というのが最初のきっかけでした。歌舞伎町一番街を選んだ理由は、このあたりをよく散歩していてイメージが頭に入っているから、という極めて個人的な理由です。
ただ、この時点で気づいている方もおられるかもしれませんが、 Blender GIS で作れる街並みには3Dモデルとしては不十分なのでいろいろ修正が必要なんですね。
ご覧頂いてわかるように Blender GIS で作れるデータは
- 建物が単なる箱で表現されてしまっている
- テクスチャがまったくない
の2つが課題です。Blender GIS を使うことによって 建物の大まかな位置とサイズは正確にわかるのですが、それ以外の情報は無い という状況。上の写真は歌舞伎町一丁目あたりをレンダリングしたものになりますが、これだけだとちょっと認識が難しい...。
ボクセルゲーム風なデザインなら Blender GIS でも作れる?
そこで考えました。 「ボクセルゲーム風のデザインなら、この箱が並んだ状態の3Dモデルからでもなんとか作り上げることができるのではないか?」 と。イメージは以下のようなもの。
Calling all young creators! Minecraft: Education Edition is holding its first international competition, asking you to design and build a space to support a healthy environment – for humans and animals!
— Minecraft (@Minecraft) October 3, 2020
↣ https://t.co/CPC2Hy82Fe ↢ pic.twitter.com/JNNjM312Xk
Blender GIS のデータがあまりにもシンプルなのでテクスチャとか立体表現もシンプルにしてしまえばいい、という割と単純な考えからこれを思いついたのですが、実際にやってみた人がいなさそうだったのと、どうやってやるのか調べてみる価値がありそうだなと思ったので今回のテーマに採用しました。
アプローチ
BlenderGIS のデータをボクセル化して、テクスチャを貼れるようにして、そこから先はゲームを操作するような感じでテクスチャを貼り付けて行けばいけるかな?と思ったのでまずは以下のアプローチを取りました。
- BlenderGIS で歌舞伎町一番街周辺をインポート
- Remesh (Blocks) 使いボクセルに変換
- ボクセルの面それぞれを UV 展開
- テクスチャを用意し、マテリアルとして貼り付ける準備をする
- Material Utilities を使ってボクセル化したオブジェクトの面1つ1つにテクスチャを貼り付ける
簡単に書いていますが、 Remesh と UV 展開のところは以下に説明していくようにちょっと小技が必要でした。
あと「まずは以下のアプローチ」と言っているのは、実際にやってみると少し物足りなさを感じてあとで微妙に作戦変更をしたからなのですが、それは追々...
Step 1. Blender GIS で歌舞伎町一番街をインポート
インストール方法や BlenderGIS の使い方の説明についてはこちらの記事などが参考になるかと思います。こちらの記事に書いてあるように、ツール自体のライセンスは GPL v3.0 ですが、地図データのライセンスには注意が必要とのことです。今回は商用利用可の Open Street Map 2 を使います。
Basemap で地図のエリアを選択して...
今回は都市部なので SRTM を使わずにそのまま Get OSM に進み、建物等の情報をインポートします。ちなみにここで、建物のデータを別々のオブジェクトとしてインポートする必要が有ります(そうしないとあとで困るので)。
SRTM は Shuttle Radar Topography Mission の略で、スペースシャトルのレーダーを使って標高を測定するミッションのことだそうです。合成開口レーダー等あまり仕組みを詳しく理解していないのでわかりませんが、歌舞伎町でこのデータを使ってみるとビルの影響を受けているのか、地形が歪んでしまいました(私の知っている地形になっていませんでした)。なので今回は全く使わない方向ですすめます。
Step 2. Blender Python を使って Remesh (BLOCKS) を操作しボクセルに変換
Blender GIS でインポートした街のデータをボクセル化します。ボクセル化する機能として Blender には Remesh (BLOCKS) 機能が備わっているので、早速それを使いたいと思います。
と言いたいところですが、この Remesh (BLOCKS) には1つ技術的に解決しないといけない問題があるんですね。それはボクセルのサイズが一定にならないことです。1つ1つのオブジェクトに対して単純にこれを適用させると、この図のようにボクセルサイズがオブジェクトごとで異なってきてしまいます(わかりやすくするためにチェック柄にしています)。
左の建物はボクセルが細かく、右の建物は大きくなっていることがわかります。そもそもなぜこうなってしまうのか。そこにはこのボクセル化のアルゴリズム自体に理由が隠されています。
Blender の Remesh (BLOCKS) の動作
Blender の Remesh (BLOCKS) が実際にどのように実装されているのか。いろいろ調べた結果、以下のような流れであることがわかりました。
1. オブジェクトの Bounding Box の値を取得
Blender はオブジェクトごとに予め Bounding Box を計算しています。この値は Blender Python では、オブジェクトの dimensions という変数からアクセスできます。 Suzanne の Bounding Box はこのようになっていました。
2. Remesh (BLOCKS) の Scale パラメータの値にしたがって Bounding Box を内包する枠を用意する。
Bounding Box を内包する立方体を用意。ちなみに Scale の値の定義は
- (bounding box の xyz 軸で最も長い辺の長さ) / (Bounding Box を内包する枠の一辺の長さ)
3. Octree Depth の回数だけこの枠を Octree 分割する
Octree Depth を4にしたときの分割は以下の通り。
4. 分割したそれぞれの場所ごとにメッシュ等を確認しボクセル化する
それぞれの場所(セル?)ごとにメッシュ等を確認してボクセル化しています。
ちなみにこれを前から見るとこんな感じ。分割した Octree のサイズとボクセルのサイズが確かに同じであることがわかります。
このような流れで Remesh (BLOCKS) はオブジェクトをボクセル化しています。となると、異なるオブジェクトでボクセルのサイズを一致させるためにやらないといけないことは Octree で分割したもののサイズを一定にすること。そしてそれを実現するためには Remesh (BLOCKS) の Scale パラメータをオブジェクトの bounding box の大きさに合わせて調整する ということになります(Octree の分割は 12 段階しか取れないので、今回はこの値は固定にします)。
Blender の Driver 機能を使って Scale の値をパラメータ表示させる
Scale のパラメータを bounding box のサイズに合わせて調整するには、 Blender の Driver 機能を使います。今回のこのボクセルのサイズを一定にするための方法はここに書かれているので、それにしたがって設定していただくことも可能です。
ただ、このやり方だと全部のオブジェクトに対して Remesh を適用させるのが手間だったので、同じ操作を Python から行いました。
import bpy
selected_obj = bpy.context.selected_objects
active_obj = bpy.context.active_object
for obj in selected_obj:
if obj != active_obj:
bpy.context.view_layer.objects.active = obj
dx = obj.dimensions[0]
dy = obj.dimensions[1]
dz = obj.dimensions[2]
od = 6
bpy.ops.object.modifier_add(type="REMESH")
obj.modifiers["Remesh"].mode = "BLOCKS"
obj.modifiers["Remesh"].octree_depth = od
obj.modifiers["Remesh"].scale = max(dx, dy, dz)/(2**od)
bpy.ops.object.modifier_apply(modifier="Remesh")
オブジェクト数が多くて時間が掛かりますが、これでオブジェクトがすべてボクセル化されたと思います。下図のように、確かにボクセルサイズが一定になっています( checker deselect の関係で一部面積の大きな黒い箇所が見えますが、表示の問題なので大丈夫です)。
Step 3. UV 展開
Step 2 でデータをボクセルにする処理が完了しました。ただ、ここからテクスチャを貼るためにはもう1つ手を加える必要が有ります。それは UV 展開です。
面それぞれに対して UV Unwrap を実行
手っ取り早く UV 展開するには、 Blender Python でそれぞれの面をループで周り、それぞれに対して UV Unwrap を実行すれば良いです。
import bpy
from mathutils import Vector
selected_obj = bpy.context.selected_objects
active_obj = bpy.context.active_object
for obj_i, obj in enumerate(selected_obj):
print("{0}/{1}".format(obj_i, len(selected_obj)-1))
if obj != active_obj:
bpy.context.view_layer.objects.active = obj
# unwrap
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.mesh.select_all(action="SELECT")
bpy.ops.uv.unwrap()
bpy.ops.mesh.select_all(action="DESELECT")
bpy.ops.object.mode_set(mode="OBJECT")
ただ、ここでも問題が出てきてしまいます。今度は、 UV Map の向きが一定になってくれないという問題です。
3D Viewport から見ればわかると思いますが、垂直に立っている面には上下左右が有るので、テクスチャを貼ったときに正しい上下左右の向きで表示させる必要が有ります。しかしながら上記のプログラムのままだとこれがうまく行かない面が多数出てきてしまいます。
この図だと、右側の建物のテクスチャの向きが一部横を向いていることが確認できます。
ということで、 Python スクリプトの修正が必要です。今回はそれぞれのセルに対して normal map の向き、頂点の位置関係から、3D空間上で左下に見える頂点が UV Map でも左下に来るように設定しました。プログラムは以下のとおりです(normal map の計算のあたりが煩雑になっていて、もう少し鮮やかな方法がありそうな気がしますが...)。ちなみに+Z方向を向いている面についてはそのままにしています。
import bpy
from mathutils import Vector
selected_obj = bpy.context.selected_objects
active_obj = bpy.context.active_object
for obj_i, obj in enumerate(selected_obj):
print("{0}/{1}".format(obj_i, len(selected_obj)-1))
if obj != active_obj:
bpy.context.view_layer.objects.active = obj
# unwrap
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.mesh.select_all(action="SELECT")
bpy.ops.uv.unwrap()
bpy.ops.mesh.select_all(action="DESELECT")
bpy.ops.object.mode_set(mode="OBJECT")
# correct uv map
mesh = obj.data
for face in mesh.polygons:
normal = obj.matrix_world @ face.normal
v0_co = obj.matrix_world @ mesh.vertices[face.vertices[0]].co
v0_uv = mesh.uv_layers.active.data[face.loop_indices[0]].uv
v1_co = obj.matrix_world @ mesh.vertices[face.vertices[1]].co
v2_uv = mesh.uv_layers.active.data[face.loop_indices[1]].uv
v2_co = obj.matrix_world @ mesh.vertices[face.vertices[2]].co
v2_uv = mesh.uv_layers.active.data[face.loop_indices[2]].uv
v3_co = obj.matrix_world @ mesh.vertices[face.vertices[3]].co
v3_uv = mesh.uv_layers.active.data[face.loop_indices[3]].uv
vec01 = v1_co - v0_co
vec03 = v3_co - v0_co
if normal.z > 0:
continue
else:
v01_cross = vec01.cross(normal)
v03_cross = vec03.cross(normal)
if v01_cross.z < 0:
if vec03.z > 0:
mesh.uv_layers.active.data[face.loop_indices[0]].uv = Vector([0, 0])
mesh.uv_layers.active.data[face.loop_indices[1]].uv = Vector([1, 0])
mesh.uv_layers.active.data[face.loop_indices[2]].uv = Vector([1, 1])
mesh.uv_layers.active.data[face.loop_indices[3]].uv = Vector([0, 1])
else:
mesh.uv_layers.active.data[face.loop_indices[0]].uv = Vector([0, 1])
mesh.uv_layers.active.data[face.loop_indices[1]].uv = Vector([1, 1])
mesh.uv_layers.active.data[face.loop_indices[2]].uv = Vector([1, 0])
mesh.uv_layers.active.data[face.loop_indices[3]].uv = Vector([0, 0])
elif v01_cross.z > 0:
if vec03.z > 0:
mesh.uv_layers.active.data[face.loop_indices[0]].uv = Vector([1, 0])
mesh.uv_layers.active.data[face.loop_indices[1]].uv = Vector([0, 0])
mesh.uv_layers.active.data[face.loop_indices[2]].uv = Vector([0, 1])
mesh.uv_layers.active.data[face.loop_indices[3]].uv = Vector([1, 1])
else:
mesh.uv_layers.active.data[face.loop_indices[0]].uv = Vector([1, 1])
mesh.uv_layers.active.data[face.loop_indices[1]].uv = Vector([0, 1])
mesh.uv_layers.active.data[face.loop_indices[2]].uv = Vector([0, 0])
mesh.uv_layers.active.data[face.loop_indices[3]].uv = Vector([1, 0])
elif v03_cross.z < 0:
if vec01.z > 0:
mesh.uv_layers.active.data[face.loop_indices[0]].uv = Vector([0, 0])
mesh.uv_layers.active.data[face.loop_indices[1]].uv = Vector([0, 1])
mesh.uv_layers.active.data[face.loop_indices[2]].uv = Vector([1, 1])
mesh.uv_layers.active.data[face.loop_indices[3]].uv = Vector([1, 0])
else:
mesh.uv_layers.active.data[face.loop_indices[0]].uv = Vector([0, 1])
mesh.uv_layers.active.data[face.loop_indices[1]].uv = Vector([0, 0])
mesh.uv_layers.active.data[face.loop_indices[2]].uv = Vector([1, 0])
mesh.uv_layers.active.data[face.loop_indices[3]].uv = Vector([1, 1])
elif v03_cross.z > 0:
if vec01.z > 0:
mesh.uv_layers.active.data[face.loop_indices[0]].uv = Vector([1, 0])
mesh.uv_layers.active.data[face.loop_indices[1]].uv = Vector([1, 1])
mesh.uv_layers.active.data[face.loop_indices[2]].uv = Vector([0, 1])
mesh.uv_layers.active.data[face.loop_indices[3]].uv = Vector([0, 0])
else:
mesh.uv_layers.active.data[face.loop_indices[0]].uv = Vector([1, 1])
mesh.uv_layers.active.data[face.loop_indices[1]].uv = Vector([1, 0])
mesh.uv_layers.active.data[face.loop_indices[2]].uv = Vector([0, 0])
mesh.uv_layers.active.data[face.loop_indices[3]].uv = Vector([0, 1])
上記のプログラムを実行することでこのようにすべての面でテクスチャが正しい向きを向くようになります。
これで UV 展開についても安心。
Step 4. テクスチャを準備
Step 2, Step 3 でボクセル状の歌舞伎町一番街ができたので、あとはそこに貼るテクスチャを用意します。ネット上にライセンス的に問題のない Minecraft のテクスチャは無いかなと探してみましたが使いやすそうなものが見つからず、結局自分で作ることにしました。今回はスマホアプリの 8 bit painter を使って 16×16 の非常に小さなテクスチャ画像を作りました。
- 8 bit painter: https://onetap.jp/8bitpainter/
テクスチャはこのようなものになります。色合いや色の組み合わせについてはすこし気を使いましたが、模様については1枚10秒程度で作ったものなので模様はテキトーです。
ここで用意したテクスチャはマテリアルとして Blender に登録します。 Roughness や Specular などのパラメータも適宜調整するとクオリティが上がりそうですね。
Step 5. テクスチャを貼る
用意したテクスチャを貼ります。テクスチャを貼る操作もプログラムで実装できたりしないかなとちょっと思ったのですが、便利な方法があまり思いつかなかったので今回は手作業で貼ります。手作業で貼るのは結構手間ですが、以下のショートカット機能を使うことで作業効率はそれなりに良くなりました。
- Material Utilities 機能の使い、Edit モードで面を選択 --> Shift-Q からマテリアルを選択しアサイン、という流れで即座にテクスチャを貼り付ける
- Fly/Walk Navigation モードを使い画面の移動をゲーム的な操作(WASDキー)で行う
- Object モードで Shift-H を押し、選択したオブジェクトのみ表示させる / Alt-H を押して元に戻す
- 複数の面を選択するときに Ctrl キーを押しながらクリックし、一個前に選択した面と今選択した面の間も含めて全部選択する (Pick Shortest Path)
Material Utilities によるテクスチャの貼り付け手順は以下。たくさんの面に対していろいろなテクスチャを貼るときには、1枚貼るのにかかる手間を抑えることが重要になるので、このツールはかなり便利でした。
横並びの面を選択するときは Ctrl-クリック で Pick Shortest Path 機能を使います
オブジェクトを選択して Shift-H を押すと周囲のオブジェクトを消してくれるので、細い路地などに有るオブジェクトも編集しやすくなります
作業を進めてみた
歌舞伎町一番街のアーチは Blender GIS ではうまく表現されていなかったので手作業で作りました。それ以外については上記の形で作業を進めていったところ...
!!!
......なんか違う!!
作戦変更
やってみてわかったのですが、 建物の高さが正しくないので現実の建物に即した色塗りが難しい! あと シンプルなデザインとはいえやっぱり多少の凹凸が無いと全然それっぽく見えない! Blender GIS をボクセル化すればそれっぽく見えるんじゃないか、という仮説は見事に砕けました。
あとこの手のデザインは、特徴を捉えることが重要になってくるので看板の表現などは大げさにやらないと全然見ている側に伝わってこないですね。
どうする
ここまでやってしまったし、 Advent Calendar の締切も迫っているのでどうにかしないと....
ということで急遽作戦変更です。 押出機能(Extrude)の利用を解禁します 。 押出機能とかそのあたりを使ってしまうとモデリングの部分も手作業が入ってしまうのでなんかちょっと今回やりたかったことと違うなぁと思いましたが、それなりのものを作ろうと思ったら多少の手作業は免れないのかもしれません。
※ 押出機能のイメージ:
ということで、押出機能を使ってボクセル化した BlenderGIS のデータを修正していきます。
歌舞伎町一番街はやっぱり突き出し看板が無いとダメですね。あと壁面看板などの凹凸も、微妙に表現されているだけでだいぶ印象は変わってきました。これなら雰囲気を出せるかもしれない。
あとは頑張る
後は頑張って手作業で作りました。次やるときはプロシージャルに、自動で色をつけてくれるようにしたいです(どうやってプロシージャルにするかという問題はありますが)。
完成
押出機能で形を整え、テクスチャを作りながら貼り付けていって、締切ギリギリになりながらもいろんな建物に色を塗りたくなってきて、細部にこだわり始めて...
完成したものがこちら。
Environment Texture として使用した venice sunset の HDR 画像 3 がいい雰囲気を出してくれたのであえて残しました。
感想
Blender GIS で街が簡単に作れそうだとなんとなく思っていましたが、やっぱりちょっと難しそうですね。押出機能を使い始めたあたりから全体的に作業量が増えてきてしまった感じがしました。またテクスチャを貼る作業も、貼る事自体はそこまで手間ではありませんでしたが似せていくというところで結構難しさが出てきてしまいました。完全に架空の街を作り上げるのであれば適当にテクスチャを貼っても良いのですが、今回の場合はイメージが先行していたのでそのようにはなかなか行きませんでした。
(そもそも歌舞伎町を選んだのがダメだった説も有ります。歌舞伎町はとにかく色のバリエーションが多くて凹凸も多くてちょっと疲れました。オフィスビル群なら凹凸も少ないので作りやすそう。)
とはいえ、 何も無いところから作るよりかはだいぶマシだった のも事実です。作業をする際のガイド線が与えられているような感じで、私みたいなモデリング専門ではない人でも頑張ればそれっぽい物が作れましたし。
あと、 ボクセルにしたことで建物の高さや形を変形させることが意外と簡単になった 、という当初の想定とは異なるメリットにも気づきました。ボクセルと押出機能の相性が良かったのもあり、 Z 軸方向に伸ばしたいと思ったら該当箇所の面を選択して伸ばすだけでできてしまったのはとても便利でした。
最後に、もし複数のモデルを一定サイズでボクセル化したい、とか UV Map を上下方向に気をつけて設定したい、とかいう方がおられましたらこちら参考にしていただければと思います!
以上!