見出し画像

Pythonでマイクラを作る ⑯レンダリングの高速化

Pythonでマイクラを作る 第16回目です。今回はレンダリングの高速化します。
レンダリングとは、CG(コンピューターグラフィック)をディスプレイ(コンピューター画面)に描画することです。マイクラはリアルタイムでたくさんのブロックを描画しているため、コンピューターに大きい負荷がかかります。負荷が大きすぎると、処理が追いつかず画面がカクカクしてしまいます。
見えないブロックを描画しないなどのレンダリングの高速化を行うと、より大きいワールドで自由に建築できるようになります。

地面を1枚のカードに変更する

"""src/block.py"""
import re
from math import *
from panda3d.core import *


class Block:

修正

    def set_flat_world(self):
        ground_size = self.ground_size
        # for i in range(ground_size):
        #     for j in range(ground_size):
        #         x = i - ground_size // 2
        #         y = j - ground_size // 2
        #         z = -1
        #         self.add_block(x, y, z, 'grass_block')
        ground_card = CardMaker("ground_card")
        ground_card.setFrame(
            -ground_size / 2,
            ground_size / 2,
            -ground_size / 2,
            ground_size / 2)
        ground_card.setUvRange(
            (0, 0),
            (ground_size, ground_size)
        )
        ground = self.base.render.attachNewNode(ground_card.generate())
        ground.setP(-90)
        ground.setZ(-0.01)
        grass_texture = self.base.loader.loadTexture("textures/0-0.png")
        ground.setTexture(grass_texture)

Blockクラスを修正します。
レンダリングの高速化で効果の高い、地面の最適化を行います。フラットワールドにおいて、地面は草ブロックを碁盤状に設置して作られています。大きなワールドを作ると、それだけたくさんのブロックが必要になり、コンピューターの負荷が上がってしまいます。
たくさんの草ブロックを一枚のカードに変更します。set_flat_worldメソッドで、草ブロックを並べて設置するコードをコメントアウトします。そして CardMaker("ground_card")により、ground_cardカードを作成します。setFrame(x の最初値, x の最大値, z の最小値, z の最大値) でカードの大きさを指定します。setUvRange((0, 0), (x 方向の繰り返し回数, z 方向の繰り返し回数))でテクスチャを繰り返し貼り付けることができます。
self.base.render.attachNewNode(ground_card.generate())によりカードをシーングラフのルートrenderに配置します。カードは XZ平面に貼り付けられるので、setP(-90) で X軸中心で -90度回転させて、XY平面に移動します。setZ(-0.01)は、z = -1 にブロックを設置したとき、干渉しないようにカードを少し下に移動するコードです。setTextureメソッドでテクスチャーを貼り付けます。

修正

def can_add_or_remove_block_at(self, position):
    diff_positions = [
        Point3(1, 0, 0),
        Point3(0, 1, 0),
        Point3(0, 0, 1),
        Point3(-1, 0, 0),
        Point3(0, -1, 0),
        Point3(0, 0, -1),
    ]

    for diff_position in diff_positions:
        x, y, z = [floor(value) for value in position + diff_position]
        key = f'{x}_{y}_{z}'
        if key in self.block_dictionary or z == -1:  # 修正
            return True
    else:
        return False

Blockクラスの can_add_or_remove_block_atメソッドを修正します。
地面を草ブロックからカードに変更したため、高さ 0 にブロックを置こうとしてもターゲットブロックが表示されないバグが発生しました。高さ -1 にブロックがなくなったからです。
バグを修正するため、 if key in self.block_dictionary or z == -1: により、条件式を修正して、「対象のブロックがあるか、または対象のブロックの高さが-1 」のときに、ターゲットブロックを表示させるように変更しました。

"""16_01_main.py"""
from math import *
from panda3d.core import *
from src import MC


class Game(MC):
    def __init__(self):
        # MCを継承する
        MC.__init__(self, ground_size=256, mode='debug')

        # プレイヤーの位置を変更
        self.player.position = Point3(0, -10, 0)

        # 座標軸
        self.axis = self.loader.loadModel('models/zup-axis')
        self.axis.setPos(0, 0, 0)
        self.axis.setScale(1.5)
        self.axis.reparentTo(self.render)

        # 箱
        for i in range(3):
            for j in range(3):
                for k in range(3):
                    self.block.add_block(i, j, k, 'gold_block')


game = Game()
game.run()
地面の最適化

16_01_main.py を新規作成し、地面のサイズが 256x256 のフラットワールドを作ります。self.player.position = Point3(0, -10, 0)を指定すると、ゲーム開始時のプレイヤーの位置を指定できます。目印として、3x3 の箱を原点に設置しました。
地面サイズが大きくなったので、かなり走っても端が見えなくなりました。今回の改造で、z = -1 より下の地下の世界ではクラフトできなくなりましたが、広大な世界で走り回ることができるようになりました。

インスタンス化

レンダリング高速化の第2弾として、インスタンス化(Instancing)を行います。reparentToメソッドと instanceToメソッドを図を使って説明します。

reparentToメソッド

今まで、ブロックモデルを placeholderノードに reparentToメソッドを使って、一つずつ配置していました。全く同じモデルを placeholder の個数用意して配置することは冗長な計算となり、コンピューターに負荷がかかります(上図)。

instanceToメソッド

今後はレンダリングの高速化のため、ブロックモデルを placeholderノードに instanceToメソッドを使って配置します。こちらの方法は同じブロックを複数回、用意する必要がなく、共用のモデルを使いまわせるため、コンピュータの負荷を減らすことができます(上図)。

"""src/block.py"""
import re
from math import *
from panda3d.core import *


class Block:

    def __init__(self, base, ground_size):
        self.base = base
        self.ground_size = ground_size
        self.block_dictionary = {}
        self.block_models = {}  # 追記
        # ブロックノード
        self.base.block_node = self.base.render.attachNewNode(PandaNode('block_node'))

Blockクラスを修正します。
インスタンス変数block_models を追加して、共用するブロックモデルを保存するための辞書として使います。

"""src/block.py"""

修正

def add_block_model(self, x, y, z, block_id):
    key = f'{floor(x)}_{floor(y)}_{floor(z)}'
    self.base.set(key, self.base.block_node.attachNewNode(PandaNode(key)))
    placeholder = self.base.get(key)
    placeholder.setPos(floor(x), floor(y), floor(z))
    # block = self.base.loader.loadModel(f'models/{block_id}')
    # block.reparentTo(placeholder)
    if block_id in self.block_models:
        block_model = self.block_models[block_id]
    else:
        block_model = self.base.loader.loadModel(f'models/{block_id}')
        self.block_models[block_id] = block_model
    block_model.instanceTo(placeholder)

add_block_modelメソッドを修正します。
placeholder ごとにブロックモデルを読み込んで、reparentTo
メソッドで配置していた部分のコードをコメントアウトします。
代わりに instanceToメソッドを使って、共用のブロックモデルを配置する方法に改めます。インスタンス変数block_models のキーに block_id がない時は、キー = block_id、値 = self.base.loader.loadModel(f'models/{block_id}') として辞書に登録します。キーに block_id があるときは辞書から読み取ったブロックモデルを instanceToメソッドで placeholder に配置します。

16_01_main.py を実行して、レンダリングの高速化がなされているか確認します。インスタンス化の効果は同じブロックをたくさん配置したときに現れます。先ほどコメントアウトした reparentToメソッドを使ったコードと、instantToメソッドを使うコードを切り替えて、プレイヤーの動きを見比べてみましょう。

見えないブロックを非表示にする

レンダリング高速化第3弾は、見えないブロックを非表示にすることです。例えば 100x100x100 の箱を考えた場合、100,000個のブロックが必要になります。見えないブロックを非表示にしたとき、98x98x98 = 86,436個のブロックを非表示にできますから、約86パーセントの効率化が果たされます。同様に 1000x1000x1000 の箱を考えたときは、約99パーセントのブロックを非表示にできます。

対象のブロックが6つのブロックで囲まれているか

ブロックを非表示にする手順を考えます。ある場所にブロックを設置(破壊)したとき、上下左右前後の6ヶ所にブロックが存在すれば、それが対象のブロックになります。
対象のブロックについて、上下左右前後の6ヶ所全てにブロックが存在するときは対象のブロックは囲まれているので非表示にします。一つでもブロックがないときは表示にします。
この処理をプログラミングで表現します。

追加

    def hide_invisible_blocks(self, position):
        diff_positions = [
            Point3(1, 0, 0),
            Point3(0, 1, 0),
            Point3(0, 0, 1),
            Point3(-1, 0, 0),
            Point3(0, -1, 0),
            Point3(0, 0, -1),
        ]

        # 設置または削除したブロックの周辺ブロックをチェック(6ヶ所)
        print('POSITION', position)
        for diff_position1 in diff_positions:
            block_position = position + diff_position1
            x, y, z = [floor(value) for value in block_position]
            key = f'{x}_{y}_{z}'
            placeholder = self.base.get(key)
            if placeholder:
                # 周辺ブロックが6つのブロックで囲まれているとき(見えないとき)、その周辺ブロックを隠す
                is_surrounded_by_six_blocks = True
                for diff_position2 in diff_positions:
                    check_position = block_position + diff_position2
                    if not self.is_block_at(check_position):
                        is_surrounded_by_six_blocks = False
                        break

                if is_surrounded_by_six_blocks:
                    if not placeholder.isHidden():
                        print('hide', x, y, z)
                        placeholder.hide()
                else:
                    if placeholder.isHidden():
                        print('show', x, y, z)
                        placeholder.show()

Blockクラスに hide_invisible_blocksメソッドを追加します。hide_invisible_blocksメソッドは、上下左右前後全て囲まれたブロックを見つけて、対象のブロックを非表示にします。



        # 設置または削除したブロックの周辺ブロックをチェック(6ヶ所)
        print('POSITION', position)
        for diff_position1 in diff_positions:
            block_position = position + diff_position1
            x, y, z = [floor(value) for value in block_position]
            key = f'{x}_{y}_{z}'
            placeholder = self.base.get(key)
            if placeholder:

上記のコードが設置(破壊)したブロックの上下左右前後にブロックが存在するか判定するコードです。リストdiff_positions により、前後左右上下の6ヶ所を全て調べます。


            if placeholder:
                # 周辺ブロックが6つのブロックで囲まれているとき(見えないとき)、その周辺ブロックを隠す
                is_surrounded_by_six_blocks = True
                for diff_position2 in diff_positions:
                    check_position = block_position + diff_position2
                    if not self.is_block_at(check_position):
                        is_surrounded_by_six_blocks = False
                        break

                if is_surrounded_by_six_blocks:
                    if not placeholder.isHidden():
                        print('hide', x, y, z)
                        placeholder.hide()
                else:
                    if placeholder.isHidden():
                        print('show', x, y, z)
                        placeholder.show()

こちらは対象のブロックが存在したとき、6つのブロックで囲まれているか判定し、全て囲まれていたら非表示に、そうでなければ表示にするコードです。
変数is_surrounded_by_six_blocks は6つのブロックで囲まれているか真偽値で保存しています。対象のブロックの上下左右前後を調べて一つでもブロックがあれば、Falseにします。
最後、変数is_surrounded_by_six_blocks の値によって、ブロックを表示 / 非表示を切り替えます。isHiddenメソッドを使って、hideメソッド、showメソッドが必要な時のみ実行されるようにしています。

追記

    def add_block(self, x, y, z, block_id):
        self.add_block_dictionary(x, y, z, block_id)
        self.add_block_model(x, y, z, block_id)
        self.hide_invisible_blocks(Point3(x, y, z))  # 追記def remove_block(self, x, y, z):
        self.remove_block_dictionary(x, y, z)
        self.remove_block_model(x, y, z)
        self.hide_invisible_blocks(Point3(x, y, z))  # 追記

add_blockメソッド、remove_blockメソッドを修正します。ブロックを設置(破壊)したときに、先ほど作成した hide_invisible_blocksメソッドが実行されるようにしました。以上で完成です。

見えないブロックの非表示

ブロックが非表示になるか確認するため、16_01_main.py を実行してください。ターミナルに「hide 1 1 1」が表示されたら、見えないブロックが非表示になったということです。
プレイヤーを動かして、位置 (1, 0, 1) のブロックを破壊してみましょう。ターミナルに「show 1 1 1」が表示され、非表示だったブロックが表示に切り替わりました。成功です!

巨大な半球ドームを作成

"""16_01_main.py"""
from math import *
from panda3d.core import *
from src import MC


class Game(MC):
    def __init__(self):
        # MCを継承する
        MC.__init__(self, ground_size=256, mode='debug')

        # プレイヤーの位置を変更
        self.player.position = Point3(0, -30, 0)  # 修正

        # 座標軸
        self.axis = self.loader.loadModel('models/zup-axis')
        self.axis.setPos(0, 0, 0)
        self.axis.setScale(1.5)
        self.axis.reparentTo(self.render)

        # # 箱
        # for i in range(3):
        #     for j in range(3):
        #         for k in range(3):
        #             self.block.add_block(i, j, k, 'gold_block')

        # ドーム  # 追記
        radius = 20  # 追記
        block_id = 'gold_block'  # 追記
        for i in range(-radius, radius + 1):  # 追記
            for j in range(-radius, radius + 1):  # 追記
                for k in range(radius + 1):  # 追記
                    if i**2 + j**2 + k**2 <= radius**2:  # 追記
                        self.block.add_block(i, j, k, block_id)  # 追記


game = Game()
game.run()
巨大な半球ドーム

レンダリングの高速化の効果を確認します。16_01_main.py を修正して、3x3x3 の箱の代わりに、半径20の巨大な半球ドームを設置しました。ドームの中に取り残されないように、プレイヤーの初期位置を Point3(0, -30, 0)に移動しました。
お使いのパソコンの能力にもよりますが、この巨大な建築が含まれるワールドでもカクツキもなくスムーズな移動が実現できました。著者は現在、Macbook Air (m1) で作業しておりますが、少し止まる時もありますが快適なゲームプレイができています。レンダリングの高速化がなされていないときは完全に止まっていたので、高速化の効果は実感できました。

今回はレンダリングの高速化を実装しました。高速化のお陰で、やっとまともに遊べるレベルのゲームを作ることができました。
さらに高速化を進めるとしたら、プレイヤー視点を考えて、見えないブロックのを非表示にすることができます。ブロック同士で接しているも非表示にできます。
しかし、さらなる高速化はモデルの全面的な作り直しなど、大変な手間がかかることから、今回は断念しました。今後の課題とさせてください。

本家のマイクラは比較的、低機能パソコンでも遊べるなど、レンダリングの高速化が徹底しています。今回レンダリングの高速化に挑戦して、本家マイクラの素晴らしさが再認識されました。

さて次回は建築MODを実装します。Architectureクラスを作成し、道路や家などを簡単に建築できるようにします。お楽しみに。


前の記事
Pythonでマイクラを作る ⑮物理シミュレーションの実装
次の記事
Pythonでマイクラを作る ⑰建築MODで村を作る

その他のタイトルはこちら


この記事が気に入ったらサポートをしてみませんか?