Octree.hsp

HSPで3D衝突判定を高速化!モートン番号を使ったOctree(八分木)モジュール

こんにちは。HSPで3Dゲームやシミュレーションを作っている皆さん、今日はOctree(八分木)を使った高速衝突判定のテクニックをご紹介します。3D空間で大量のオブジェクト(例: 球やモデル)が存在する場合、すべての組み合わせをチェックする総当たり判定では計算量がO(N²)となり、数百個を超えるとすぐに重くなってしまいます。そこで有効なのが空間分割です。中でもOctreeは、空間を再帰的に8分割していく木構造で、衝突の可能性があるペアだけを効率的に抽出できます。このコードは、以前紹介したモートン番号テーブルを基盤に、3DのOctreeをHSPで実装したモジュールです。特徴は以下の通りです。モートン番号(Z-order曲線)で空間を1次元化し、範囲検索を高速化
大きなオブジェクト(複数セルを占有するもの)にも対応
使用状況に応じて自動的に分割レベルを調整(葉空間の占有率で上下)
ブロードフェーズ(可能性のあるペア抽出)とナローフェーズ(精密衝突判定)を分離

//--"Octree.hsp"
#ifndef Octree
#include "MortonNo.hsp" ; ←モートン番号モジュールをインクルードしてください。
//八分木モジュール
#module Octree data, dn, EnabledSpace, en , _EnabledSpace, _en, level, MaxLevel, LeafSpace, GridMax, table, areax, areay, areaz, areasx, areasy, areasz, divsizex, divsizey, divsizez, count
#const MAXPARTITIONLEVEL 6 ; (0~8)
#const DEFPARTITIONLEVEL 3 ; (0~↑の値まで)
#const FRAMES 60 ; 何フレームごとに分割レベル調整を行うか
#const DOWNLEVEL 30 ; 有効空間のうち葉空間が占める割合が何パーセント以下なら分割レベルを下げるか
#const   UPLEVEL 57 ; 有効空間のうち葉空間が占める割合が何パーセント以上なら分割レベルを上げるか
#const DIMENSIONALITY 3 ; 次元数
#const DATASIZE 2 + DIMENSIONALITY * 2
#modinit double p1, double p2, double p3, double p4, double p5, double p6
    MaxLevel = -1
    level = DEFPARTITIONLEVEL
    gosub *SetArea
*LevelChange
    gosub *SetDivSize
    LeafSpace = 1 << level * DIMENSIONALITY ; この数値未満は子を持つ空間、この数値以上は葉空間
    MakeMortonNoTable DIMENSIONALITY, level, table
    if level > MaxLevel{
        MaxLevel = level
        sdim data,, LeafSpace << 1
        dim dn, LeafSpace << 1
    }
    return
#modfunc SetOctreeArea double p1, double p2, double p3, double p4, double p5, double p6
    gosub *SetArea
*SetDivSize
    GridMax = 1 << level
    divsizex = areasx / GridMax
    divsizey = areasy / GridMax
    divsizez = areasz / GridMax
    GridMax-
    return
*SetArea
    if p1 < p4{ areax = p1: areasx = p4 - p1} else{ areax = p4: areasx = p1 - p4}
    if p2 < p5{ areay = p2: areasy = p5 - p2} else{ areay = p5: areasy = p2 - p5}
    if p3 < p6{ areaz = p3: areasz = p6 - p3} else{ areaz = p6: areasz = p3 - p6}
    return
#modfunc AddOctree int ID, double p1, double p2, double p3, double p4, double p5, double p6
    x  = limit((p1 - areax) / divsizex, 0, GridMax)
    y  = limit((p2 - areay) / divsizey, 0, GridMax)
    z  = limit((p3 - areaz) / divsizez, 0, GridMax)
    xn = limit((p4 - areax) / divsizex, 0, GridMax)
    yn = limit((p5 - areay) / divsizey, 0, GridMax)
    zn = limit((p6 - areaz) / divsizez, 0, GridMax)
    N = GetLinerNo3d(table, x, y, z, xn, yn, zn) ; オブジェクトが所属すべき空間番号
    index = dn(N) * DATASIZE
    memexpand data(N), index + DATASIZE
    if N < LeafSpace{
        lpoke data(N), index, ID | limit(x, 0, xn) << 16 | abs(xn - x) + 1 << 24
        lpoke data(N), index + 4, limit(y, 0, yn) | abs(yn - y) + 1 << 8 | limit(z, 0, zn) << 16 | abs(zn - z) + 1 << 24
        if dn(N): else: EnabledSpace(en) = N: en+
    }else{
        wpoke data(N), index, ID
        if dn(N): else: _EnabledSpace(_en) = N: _en+
    }
    dn(N)+
    return
#modcfunc OctreeListing array list
    lpoke ln
    //子空間を持つすべての有効な空間に対して ( 子空間との組み合わせと空間内総当たり )
    repeat en
        dup S, EnabledSpace(cnt) ; この空間に対して作業開始
        dup ndata, data(S)
        dup NN, dn(S)
        poke UnScanned, S ; 自空間は走査済みとする
        lpoke index

        //現在空間の全てのオブジェクトに対して
        repeat NN, 1

            //オブジェクト情報取得
            ID = wpeek(ndata, index) << 16
            x  = peek(ndata, index + 2)
            xn = peek(ndata, index + 3)
            y  = peek(ndata, index + 4)
            yn = peek(ndata, index + 5)

            //オブジェクトが占有する全ての葉空間に対して
            repeat peek(ndata, index + 7), peek(ndata, index + 6)
                i = cnt
                repeat yn, y
                    dup t, table(0, cnt, i)
                    repeat xn, x
                        N = t(cnt) ; 葉空間番号
                        *@ //自空間に向かってループ開始
                        repeat dn(N) 
                            list(ln) = ID | wpeek(data(N), cnt * DATASIZE): ln+
                        loop
                        N >> 1 ; 親空間に移動
                        if peek(UnScanned, N){ ; 未走査なら
                            poke UnScanned, N ; 空間を走査済みにして
                            Checked(cn) = N: cn+ ; 走査済みにした空間を記録して
                            goto *@b ; ループ
                        }
                    loop
                loop
            loop
            //次のオブジェクトのために記録をもとに走査情報クリア
            repeat cn
                poke UnScanned, Checked(cnt), 1
            loop
            lpoke cn
            //現在空間内オブジェクト同士
            repeat NN - cnt, cnt
                list(ln) = ID | wpeek(ndata, cnt * DATASIZE): ln+
            loop
            index += DATASIZE ; 次のオブジェクトへ
        loop
        poke UnScanned, S, 1 ; 次の空間のために自空間を未走査に戻す
    loop
    repeat en
        lpoke dn(EnabledSpace(cnt))
    loop
    //全ての有効な葉空間に対して ( 空間内総当たりのみ )
    repeat _en 
        dup ndata, data(_EnabledSpace(cnt))
        dup NN, dn(_EnabledSpace(cnt))
        repeat NN - 1, 1
            ID = wpeek(ndata, cnt * DATASIZE - DATASIZE) << 16
            repeat NN - cnt, cnt
                list(ln) = ID | wpeek(ndata, cnt * DATASIZE): ln+
            loop
        loop
        lpoke NN
    loop
    //分割レベル調整
    if count \ FRAMES: else{
        if _en: else: if en: else: return 0
        rate = _en * 100 / (en + _en)
        if  rate >= UPLEVEL{
            if level < MAXPARTITIONLEVEL{
                level+
                gosub *LevelChange
            }
        }else: if rate <= DOWNLEVEL{
            level-
            gosub *LevelChange
        }
    }
    lpoke en
    lpoke _en
    count+
    return ln
#modcfunc GetOctreeLevel
return level
#deffunc InitOctree
    i = 1 << MAXPARTITIONLEVEL * DIMENSIONALITY
    sdim UnScanned, i
    memset UnScanned, 1, i - 1, 1
    return
#global
InitOctree
#endif
//--"Octree.hsp" --End Of File
//八分木のテスト
#include "d3m.hsp"
gsel , 1
//対象範囲
sx = -10000.
sy = -10000.
sz = -10000.
ex = 10000.
ey = 10000.
ez = 10000.
//八分木作成
newmod oct, octree, sx, sy, sz, ex, ey, ez
N = 300 ; 球の数
repeat N ; 球生成
    r(cnt) = 350. ; 球の半径
    px(cnt) = 0. + rnd(18000) - 9000
    py(cnt) = 0. + rnd(18000) - 9000
    pz(cnt) = 0. + rnd(18000) - 9000
    spx(cnt) = (1. + 1./ (rnd(1000) + 1)) * (rnd(2) * 2 - 1)
    spy(cnt) = (1. + 1./ (rnd(1000) + 1)) * (rnd(2) * 2 - 1)
    spz(cnt) = (1. + 1./ (rnd(1000) + 1)) * (rnd(2) * 2 - 1)
    col(cnt) = $FFFFFF
loop
カメラ移動 = 1
球運動 = 1
八分木 = 1
title "左クリックで球一時停止 右クリックでカメラ一時停止 Enterで判定モード変更"
pt = -d3timer()
*main
    nt = limit(pt + d3timer(), 0, 100)
    pt = -d3timer()
    stick key
    if key & 512: カメラ移動 ^ 1
    if key & 256: 球運動 ^ 1
    if key & 32 : 八分木 ^ 1
    if カメラ移動{
        count += nt
        d3setcam cos(double(count) / 6500) * 20000, sin(double(count) / 6600) * 20000, cos(double(count) / 6700) * 20000
    }
    if 球運動{
        repeat N
            dup dr, r(cnt)
            dup dpx, px(cnt)
            dup dpy, py(cnt)
            dup dpz, pz(cnt)
            dpx += spx(cnt) * nt
            dpy += spy(cnt) * nt
            dpz += spz(cnt) * nt
            //壁跳ね返り処理
                  if dpx + dr > ex{  spx(cnt) = -absf(spx(cnt))
            }else:if dpx - dr < sx:  spx(cnt) =  absf(spx(cnt))
                  if dpy + dr > ey{  spy(cnt) = -absf(spy(cnt))
            }else:if dpy - dr < sy:  spy(cnt) =  absf(spy(cnt))
                  if dpz + dr > ez{  spz(cnt) = -absf(spz(cnt))
            }else:if dpz - dr < sz:  spz(cnt) =  absf(spz(cnt))
        loop
    }
    if 八分木{
        bp = -d3timer()
        repeat N
            dup dr, r(cnt)
            //八分木にオブジェクト登録
            AddOctree oct, cnt, px(cnt) - dr, py(cnt) - dr, pz(cnt) - dr, px(cnt) + dr, py(cnt) + dr, pz(cnt) + dr
            ;              ┃  ┗━━━━━━━━┳━━━━━━━━━━┛┗━━━━━━━━┳━━━━━━━━━━┛
            ;              ID               占有範囲起点                              占有範囲終点
        loop
        //八分木からリスト取得
        判定数 = OctreeListing(oct, list)
        bp += d3timer()
        np = -d3timer()
        repeat 判定数
            ID1 = wpeek(list(cnt), 2) ; 上位2バイトと
            ID2 = wpeek(list(cnt)   ) ; 下位2バイトに衝突可能性ありのIDペアが入っている
            あたり判定
        loop
        np += d3timer()
    }else{
        判定数 = N * (N - 1) / 2
        bp = 0
        np = -d3timer()
        repeat N - 1
            ID1 = cnt
            repeat N - cnt - 1, cnt + 1
                ID2 = cnt
                あたり判定
            loop
        loop
        np += d3timer()
    }
    //以下描画処理
    color: boxf ; 画面初期化
    color $66, $FF, $FF: d3box sx, sy, sz, ex, ey, ez ; エリアボックス描画
    //球描画
    repeat N
        color col(cnt) >> 16, col(cnt) >> 8 & 255, col(cnt) & 255
        d3circle px(cnt), py(cnt), pz(cnt), r(cnt)
        col(cnt) = $FFFFFF
    loop
    //情報描画
    pos 0,0: color 0, 255
    if 八分木{
        mes "八分木で判定中 : 分割レベル = " + GetOctreeLevel(oct)
    }else{
        mes "総当たりで判定中"
    }
    mes "球の数 = " + N
    mes "ブロードフェーズ: " + bp + " ms"
    mes "ナローフェーズ: " + np + " ms"
    mes "判定回数 = " + 判定数
    mes "衝突数 = " + 衝突: 衝突 = 0
    mes "FPS = " + d3getfps()
    redraw
    redraw 2
    await 16
goto *main
#deffunc あたり判定
    if powf(px(ID1) - px(ID2), 2) + powf(py(ID1) - py(ID2), 2) + powf(pz(ID1) - pz(ID2), 2) <= powf(r(ID1) + r(ID2), 2){
        col(ID1) = $FF0000
        col(ID2) = $FF0000
        衝突+
    }
    return

ポイント解説

自動レベル調整:葉空間の占有率が低いと分割を粗く(高速化)、高いと細かく(精度向上)します。
モートン番号の活用:範囲を占有可能な空間を一気にGetLinerNo3dで取得。
大きなオブジェクトは親ノードに登録され、子空間との組み合わせも効率的に処理。

HSPのような軽量言語でも、こうした最適化で数百〜数千オブジェクトの3D衝突判定がリアルタイムで可能になります。物理エンジンの基盤や、粒子シミュレーション、3Dシューティングゲームなどにぜひ活用してみてください!質問や「もっとオブジェクト増やしてみた」報告など、コメントお待ちしています。楽しいHSPプログラミングを!

AI(Grok)さんにソースコードに寄せて文書を書いてもらいました(2025/12)

コメント

タイトルとURLをコピーしました