MaxScriptTip: ハッシュテーブル(辞書・連想配列)を使う

12月 21, 2016 0

ハッシュテーブルとは

ハッシュテーブルとは配列のようにキーを指定してアクセス出来るコレクションの一種で、 言語によっては辞書(Dictionary)や連想配列などと呼ばれています。

配列とは違い、数値以外のデータをキーとして指定する事が出来ます。

-- 配列要素へのアクセスの例
-- キーは1以上の整数のみ
ary[1]

-- ハッシュテーブル要素へのアクセスの例
-- 数値以外をキーに出来る
-- もちろん数値もキーに出来る
hash["key"]

残念ながらMaxScriptにはハッシュテーブルが無く、標準では上記のようなアクセスは出来ないのですが…

また、配列はデータの検索を行うとき、要素の先頭から順次検索する必要があるので、非常に遅くなるという問題があります(findItem等)。しかし、ハッシュテーブルではデータが”順番”を持たないという特性があり、要素の検索が極めて高速に行えます。

例えば以下のようなデータを追加したとします。

hash[1] = "Val1"
hash[2] = "Val2"
hash[3] = "Val3"

これが配列であれば、当然、要素”Val1”,”Val2”,”Val3”はその順番どおりに格納されているのですが、ハッシュテーブでは必ずしもその順番で格納されているとは限りません。

ハッシュテーブでは、キーと要素のペアがランダムに格納されており、キーを指定してアクセスすると、その都度ペアとなる要素を内部から検索するような仕組みになっています。

ではなぜ検索が高速に行えるのかというと・・・これは少し複雑な仕組みになっているのですが、簡単に説明すると、キーの値をハッシュ化(重複のない一意な数値化)し、その数値をソートして保持しています。
そうすることで、ハッシュ値を使ってバイナリサーチ(二分探査)を行う事ができ、常に高速な検索が行えるというわけです。

バイナリサーチでは要素数が増えても、ほとんど検索時間は増えません。
その為、キーを整数にする場合でも、膨大なデータから要素を頻繁に検索する必要がある時は
非常に有効です。

MaxScriptでハッシュテーブルを利用する

それでは、実際にMaxScriptでハッシュテーブルを利用する方法を紹介します。
例によって.Netを使用します。

-- ハッシュテーブルの初期化
ht = dotNetObject "System.Collections.Hashtable"

-- 要素の追加
ht.add 1 "val1"  -- 1 がkey, "val1"が値
ht.add 2 "val2"
ht.add "text1" "val3"  -- "text1"がkey, "val3"が値

-- 要素の設定
ht.set_item 1 "val4"
ht.set_item "text2" "val5"

-- 要素の取得
ht.get_item 1       -- "val4"
ht.get_item 2       -- "val2"
ht.get_item "text1" -- "val3"

-- 要素の取得の別の書き方
ht.Item[1]          -- "val4"
ht.Item["text2"]    -- "val5"

-- 存在しないキーはundefinedを返す
ht.get_item 3       -- undefined
ht.Item["text3"]    -- undefined

add, set_itemでは1つ目の引数がキー、2つ目が値になります。
get_itemではキーを指定して値を取得しています。

また、データの取得ではItem[]といった書き方も出来ますが、設定では使えないようです。
set_itemと合わせた時非対称になるので、私はよくget_itemの方を使っています。
オリジナルの.NETの方では存在しないキーにアクセスした時エラーが発生しますが、MaxScriptから使用する時はundefinedを返すようです。

注意点として、addとset_itemは良く似ているように見えますが、既に存在するキーを再度指定した場合、addではエラーが発生するのに対し、set_itemでは値の置き換えが行われます。

全ての要素に順次アクセスする

-- 値の追加
ht = dotNetObject "System.Collections.Hashtable"
ht.set_item 1 "val1"
ht.set_item 2 "val2"
ht.set_item 3 "val3"

-- 順次アクセス
enum = ht.GetEnumerator()
while enum.MoveNext() do
    format "%: %\n" enum.Key enum.Value

-- 出力:
-- 3: val3
-- 2: val2
-- 1: val1

for文を使ってアクセス出来ればいいのですが、残念ながら対応していないようです。
代わりに、getEnumerator関数を使って順次アクセスオブジェクトを取得し、MoveNext関数で全ての要素を取得する事が出来ます。
MoveNextは取得できる要素がこれ以上無くなったときfalseを返すので、自動的にwhile文を抜けます。

ここで、出力の順序がインデックス順にはなっていない事が確認出来ます。

その他の機能

-- 要素数プロパティ
ht.Count

-- 指定した要素を削除
ht.Remove key

-- 全ての要素を削除
ht.Clear()

-- 要素が含まれているか確認する
ht.Contains key      -- 指定したキーが存在すればtrue
ht.ContainsKey key   -- Containsの別名
ht.ContainsValue val -- 指定した値が存在すればtrue

.NETで扱えないオブジェクトを使う

残念ながらMaxScript上の一部のオブジェクトは.NETでは使用できません。
特にノードやマテリアル等のシーンオブジェクトは、頻繁に使う上に代えが効きません。

ではそういったオブジェクトをハッシュテーブル上で使う方法が全く無いのかというと、そういうわけでもありません。

方法として
1. 実データは配列で管理し、配列のインデックスをハッシュに格納する
2. オブジェクトを一意に特定するハンドルを利用する
等があります。

2.の一意に特定するハンドルについては、ノードオブジェクトなら.handleプロパティ、それ以外のオブジェクト(モディファイヤ、コントローラ、マテリアル等)ではAnimHandleを利用できます。
どちらの場合でも、オブジェクトを一意に特定する整数値を取得する事ができます。

以下は1と2の両方を組み合わせて利用した例です。選択したオブジェクトをマテリアル毎にグループ化しています。

ht = dotNetObject "System.Collections.Hashtable"
nodes = #()

-- キーをマテリアルのハンドル、
-- 値をnodesのインデックスとしてハッシュテーブルを構築する
for sel in selection do
(
    mtl = sel.material
    mtlHandle = if mtl != undefined then GetHandleByAnim mtl else 0P

    if ht.Contains mtlHandle then
    (
        nodeIdx = ht.get_item mtlHandle
        append nodes[nodeIdx] sel
    )
    else
    (
        append nodes #(sel)
        ht.set_item mtlHandle nodes.count
    )
)

-- マテリアルスロット1のマテリアルが割り当てられた
-- オブジェクトがあれば、その一覧を取得する
mtlHandle = GetHandleByAnim meditMaterials[1]
if ht.Contains mtlHandle do
(
    nodeIdx = ht.get_item mtlHandle
    mtlObjs = nodes[nodeIdx]
    -- 例として、mtlObjsは #($box001, $box002, ...) のような配列
)

-- マテリアルが割り当てられてないオブジェクトの一覧
if ht.Contains 0P do
(
    nodeIdx = ht.get_item 0P
    mtlObjs = nodes[nodeIdx]
)

注意点として、AnimHandleはそのプロセス内でしか一意性を保たないようになっている為、3dsMaxを再起動したりした場合はハンドルの値が変わるという事があります。
よって、ハンドルをファイルに保存するような使い方には適していません。

対してノードの.handleプロパティでは、同一のシーンオブジェクトであればセッション外であっても同一の値を保持するようです。

パフォーマンスについて補足

これはハッシュテーブルに限った話ではないのですが、何度も呼び出す関数は予めローカル変数に取り出しておく事で、関数呼び出しのパフォーマンスを向上させる事が出来ます。

ht = dotNetObject "System.Collections.Hashtable"

setItem = ht.set_item
for i = 1 to 100 do
    setItem i "value"

enum = ht.GetEnumerator()
moveNext = enum.MoveNext
while moveNext() do
    format "%: %\n" enum.Key enum.Value

MaxScriptTip: 特定のマテリアル下にある全てのサブ要素を取得する

9月 23, 2016 0
今回はマテリアルのサブ要素(マテリアル、テクスチャ)の取得について。

マテリアルは要素の下に別の要素がツリー状に接続される、いわゆる階層構造を持っています。
こういった構造では、再帰処理による取得が強力です。

以下は指定マテリアル下の全てのサブ要素を取得する関数です。
fn getAllSubMtlAndMaps mtl subs:#() =
(
    if mtl != undefined and (appendIfUnique subs mtl) do
    (
        if (superclassof mtl) == material do
        (
            for i = 1 to (getNumSubMtls mtl) do
                getAllSubMtlAndMaps (getSubMtl mtl i) subs:subs
        )
  
        for i = 1 to (getNumSubTexmaps mtl) do
            getAllSubMtlAndMaps (getSubTexmap mtl i) subs:subs
    )
    subs
)
引数のmtlにはマテリアルオブジェクトを指定してください。 例えば・・・
getAllSubMtlAndMaps $.material -- 選択オブジェクトのマテリアル

getAllSubMtlAndMaps meditMaterials[1] -- マテリアルスロット1のマテリアル
といった感じです。

オプション引数のsubsは内部使用専用なので、基本的には何も指定しないでください。

内部的な処理を簡単に説明すると、マテリアルのサブマテリアルとサブテクスチャを全て検索し、それらの要素に対してgetAllSubMtlAndMapsを再帰的に実行しています。
ただし、この時サブ要素がループ接続されている可能性を考慮し(実際にそういう状況になるのかは知りません…)、appendIfUniqueで要素がまだ追加されていない時だけ再帰しています。
よって、subsに最初から要素を追加しておいた場合、そのマテリアル以下は検索されない事になります。

ただし、取得されるサブ要素は、マテリアルとテクスチャマップが混ざってしまっているので、個別に必要な場合は自力で分類する必要があります。
local subs = getAllSubMtlAndMaps $.material
local mtls = #()
local maps = #()
for s in subs do
(
    if (superclassof s) == material then
        append mtls s
    else
        append maps s
)
こんな感じです。

余談ですが、自分が初めて再帰関数を使ったとき、再帰内でループする事に気付かず、処理がフリーズする原因を見つけられずに3日くらい悩んだ思い出があります。
再帰処理は非常に強力ではありますが、度々バグの原因になったりして扱いの難しさを時々実感します。

MaxScript配布: TR Camera Plane

9月 04, 2016 0
TR Camera Planeは、3dsMax上で簡単にイメージプレーンを配置出来る、プリミティブタイプのプラグインです。

カメラから任意の距離で、カメラに連動して動くイメージプレーンを配置する事が出来ます。 また、カメラ前に配置しない場合でも、より簡単にセットアップ出来るイメージプレーンとしても使用できます。

類似のスクリプトは幾つかありますが、シンプルさと使い勝手を重視しています。


動画・スクリーンショット





機能・特徴

  • カメラを指定して連動させる事が出来ます。
  • カメラの視野角によって自動的にプレーンサイズを調整。
  • 平行投影に対応。
  • レンダー解像度を変更すると自動的にサイズを調整。
  • イメージを指定すると、自動的にWidth, Heightを設定します。
  • "Drop image here"に画像をドラッグ&ドロップするだけで読み込み可。
  • カメラを設定していない時でも、単純なイメージプレーンとして使用可能。
  • プレーンの透明度をロールアウト上から調整可能。
等々...

Download

TR Camera Plane 最新版

インストール

ダウンロードした.zip内にあるReadmeに従ってください。
インストール方法はMZPパッケージか手動インストールから選択出来ます。

MaxScriptTip: クリップボード操作が遅いという話

8月 15, 2016 0
MaxScriptでクリップボードを操作するには、クリップボードにテキストを送るsetClipboardTextとテキストを取得するgetClipboardTextの2つの関数を使用できます。

しかしこの2つの関数、使ってみると異常に遅いです。

どうしてこんなに遅いのか分からないのですが、数千文字送るだけで数秒程かかります。(面倒なので正確な速度計測はしていませんが…)

流石にその速度だと実用性に難があったので、色々試してみたのですが、最終的にはいつもの様に.NETを使う事にしました。
.NET万歳。
local ss = StringStream ""

-- 文字列をStringStreamに書き込み

local clipboardCls = dotNetClass "System.Windows.Forms.Clipboard"
clipboardCls.SetText (ss as string)
free ss
StringStreamを使って文字列を高速に結合した後、.NETのClipboardクラスのsetTextで転送しています。

もちろん転送データが最初から文字列な場合はそのまま転送すればいいのですが、まぁ普通はデータを整形する工程があると思うので、このタイミングで行ってしまいます。

StringStreamへの書き込みはappendを使うか、formatでtoオプションを指定します。
append ss "The text"
format "Val: %\n" theValue to:ss
これで文字列を高速にクリップボードに送る事が出来ます。
逆に文字列を取得する時は以下のようにします。
local clipboardCls = dotNetClass "System.Windows.Forms.Clipboard"
local theText = clipboardCls.GetText()
まぁWindowsアプリ用に作られた.NETですから、こういった処理はネイティブのWindowsアプリとほぼ同等の速度が出せます。

MaxScriptTip: 座標系の変換

7月 18, 2016 0
MaxScriptでは座標系の制御にin coordsysコンテキストが使えますが、場合によってはこのコンテキストが作用しないケースがあります。

例えば法線編集モディファイヤで扱う法線情報は、常にローカル座標としてやり取りされます。
また、各モディファイヤのギズモ関係も多くの場合in coordsysが効きません。
しかしそうなってくると困るのが、異なるオブジェクト/モディファイヤ間での座標のやり取りです。
そうういったケースでは、座標を自力で変換する必要があります。

ローカル→ワールド

特定のオブジェクトのローカル座標をワールド座標に変換するには、対象オブジェクトの行列を取得して、ローカル座標に行列変換をかけます。
local mtx = in coordsys #world obj.transform
local worldVerts = (for v in localVerts collect v * mtx)
座標(point3)への行列変換は、単に行列(matrix3)を掛けるだけです。


ワールド→ローカル

逆にワールド座標系をローカルに戻す場合は、対象オブジェクトの逆行列を取得します。
逆行列とは読んで字の如く、特定の行列と全く逆の変換をする行列です。
逆行列は、行列オブジェクトに対してinverse関数を使う事で取得出来ます。
local invMtx = in coordsys #world inverse obj.transform
local localVerts = (for v in worldVerts collect v * invMtx)
分かってしまえば簡単ですね。


ローカル→ローカル

最後に異なるローカル間での変換について。
上の2つの例では行列の取得を#worldコンテキストで行っていますが、ローカル間で直接変換する場合は変換先オブジェクトの座標コンテキストで取得します。
local mtx = in coordsys targetObj selfObj.transform
local targetVerts = (for v in selfVerts collect v * mtx)
もちろんワールド座標を経由して変換しても同じ座標を取得する事が出来ますが、変換回数が1回増えるのでどちらがいいかはケースバイケースですね。

(補足)法線変換の注意点

変換対象が座標の時はいいのですが、対象が法線のような方向ベクトルの時、注意点があります。
法線には移動オフセットの概念が無いので、行列に移動変換が入っていると正しい計算結果になりません。
そういう訳で、その場合は行列の移動変換をクリアしてやる必要があります。

それともう一つ、変換にスケールが入っていると変換後の法線が単位ベクトル(長さ1.0のベクトル)にならない事があります。 これについても変換後に正規化する事で対応する事ができます。
local mtx = in coordsys #world obj.transform
mtx.row4 = [0,0,0]
local newNormals = (for n in oldNormals collect normalize (n * mtx))

MaxScriptTip: 指定ディレクトリをエクスプローラで開く

5月 29, 2016 0
ディレクトリ(フォルダ)をエクスプローラで開くには、リファレンスにある通りShellLaunchを使用します。 DOSCommandを利用しても同じ事が出来ますが、DOSウィンドウを開いたりしたくないのでこちらを利用します。

今回はせっかくなので、指定したファイルを選択状態にして開くオプションも付けてみます。
-- エクスプローラで対象を開く
-- thePath  select
-- File   true  : 指定したファイルを選択状態でディレクトリを開く。
-- File   false : 指定ファイルを関連付けられたプログラムで開く。
-- Directory  true  : 指定ディレクトリを選択状態で1つ上のディレクトリを開く。
-- Directory  false : 指定ディレクトリそのものを無選択状態で開く。
fn openWithExploer thePath select:true =
(
 thePath = pathConfig.normalizePath thePath
 if select then
  ShellLaunch "explorer.exe" (stringFormat "/e,/select,\"{0}\"" thePath)
 else
  ShellLaunch "explorer.exe" (stringFormat "/e,\"{0}\"" thePath)
)
使ってみる。
-- notepad.exeを選択した状態でWindowsフォルダを開く
openWithExploer @"C:/Windows/notepad.exe" select:true
-- Autodeskフォルダを選択した状態でProgram Filesフォルダを開く
openWithExploer @"C:/Program Files/Autodesk" select:true
-- 単にAutodeskフォルダを開く
openWithExploer @"C:/Program Files/Autodesk" select:false
どうでしょうか?

ちなみに、select:false状態でファイル等を指定すると、関連付けられたプログラムで単にそのファイルが開かれます。
select:trueでディレクトリを指定すると、その1つ上のフォルダが開かれるのもミソですね。

今回は短めですが、こんな感じで。

MaxScriptTip: 頂点を特定平面上に投影(整列)する

5月 05, 2016 0
今回は平面への投影処理について。 

通常、特定の軸(XYZ)平面上への投影は、単に頂点の値を揃えるだけで出来ます。
例えば、全ての頂点をYZ平面上に投影したければ、X座標を0.0に揃えるだけで整列できます。

しかし、これが単純な軸平面上ではない場合、もしくは任意の移動方向に制限して投影する場合、一気に話が難しくなります。

以下は頂点座標を、任意の平面上に任意の方向から投影する関数です。
PROJ_THRESHOLD = 0.0001  -- 1

fn projToPlane plnNrm plnOfs vert vec =
(
    local vecAng = dot plnNrm vec  -- 2

    if (abs vecAng) > PROJ_THRESHOLD then  -- 3
    (
        local closestDist = dot plnNrm (plnOfs - vert)  -- 4
        local dist = closestDist / vecAng  -- 5
        vec * dist + vert  -- 6
    )
    else
        vert  -- 7
)

引数説明

plnNrm
平面の法線です。予め正規化しておく必要があります。
法線というのは面から垂直に伸びている単位ベクトルの事で、例えばYZ平面であればZ軸の方向を向いているので[1,0,0]になります。
正規化するにはnormalize関数を使用します。
plnOfs
平面のオフセット値です。平面上にある一点の座標を指定します。
この一点は平面上に有りさえすれば、何処を指定していても問題ありません。
vert
投影前の頂点座標です。
vec
投影を行う方向ベクトルです。
このベクトルも正規化されている必要があります。
またこのベクトルが面と完全に平行だった場合、処理はキャンセルされます。(後述)


処理内容説明

1. PROJ_THRESHOLD = 0.0001
面とvecが平行だった時、どの程度までなら処理するかの許容値を設定しています。
1.0を完全に垂直、0.0を完全に平行とし、0.0以外の値を設定します。 イメージしてみると分かりますが、面と移動ベクトルが完全に平行だと、頂点は面にぶつからず処理不能となります。
2. local vecAng = dot plnNrm vec
plnNrmとvecがどの程度同じ向きを向いているかを計算しています。
全く同じ向きなら1.0、正反対を向いていれば-1.0、直角なら0.0を返します。
いわゆる内積演算です。
3. if (abs vecAng) > PROJ_THRESHOLD then
2の計算結果の絶対値がPROJ_THRESHOLDより小さければ、処理をキャンセルしています。
4. local closestDist = dot plnNrm (plnOfs - vert)
頂点と平面との再接近距離を計算しています。
具体的にはplnOfsからvertへ向かうベクトルを計算し、plnNrmと内積を取っています。
こうすることで面と頂点との距離を計算しています。不思議ですね。
5. local dist = closestDist / vecAng
再接近距離をvecAngで割る事で、vec方向に移動した時の衝突距離を計算しています。
そもそもvecAngは移動ベクトルと面法線の一致度である為、この2つが違えば違う程、小さな値(0に近い値)になっていきます。 結果、distはより大きな値になります。
ただし0.0の時(完全に平行な時)は結果が無限大になってしまう為、処理不可です。
6. vec * dist + vert
最終的な移動先座標を計算しています。
単位ベクトルであるvecと移動距離distを掛ける事によって、移動オフセットを計算する事が出来ます。
7. vert
vecが面と完全に平行だった時、引数vertをそのまま返しています。
ここは、必要に応じて例外処理等に変更しても良いと思います。

実際に使ってみます。
thePoly = selection[1]
plnNrm = normalize [0.5, 0, 1]
plnOfs = [0, 0, 10]
vec = z_axis

in coordsys #world
(
    for vi = 1 to (polyop.getNumVerts thePoly) do
    (
        local v = polyop.getVert thePoly vi
        polyop.setVert thePoly vi (projToPlane plnNrm plnOfs v vec)
    )
)
面の向きはXに傾いたZ方向、面のオフセットはZ+に10ずらしています。
また、移動ベクトルはZ軸に指定しています。



どうでしょうか?
傾いた面上に整列しているのが分かるかと思います。
また面は原点を通っておらず、Z方向にオフセットされている事も確認出来ます。



本来であればもっと画像を多用して解説すべき記事ですが、管理人の体力が限界なのでこの辺で。