9
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Maya用お手軽ボーンダイナミクスノード「boneDynamicsNode」詳細解説

Last updated at Posted at 2024-04-08

はじめに

boneDynamicsNodeはその名の通りボーンダイナミクスを表現するためのAutodesk Maya用カスタムノードです。通常のjointチェーンにノードをつなげるだけで↓こんなのが出来ます。

MITライセンスでソースコードとビルド済みmllを公開しているので気軽に試していただけます!

本記事執筆時のバージョンは0.2.0です。

ゲーム系でよく使われる軽量な計算手法を使っているので、コリジョンの精度などはそこそこですが高速に動きます。直接リグに組み込んでもいいですし、ツール化してシミュレーションしたい時だけリグに後付けしてもいいです。いろいろな使い方ができると思います。

簡単な説明は上記リポジトリのREADME.mdに書いてあるのですがもう少し細かい説明をした方がいい気がしたので本記事にいろいろ書いていきます。READMEに盛りすぎると長くなってしまうので…。X(Twitter)では英語圏の方からもたくさん反応をいただけたのですがすみません日本語でいきます…🙏

『細かい話はいいから実際のリグにガッチャンコする方法だけ知りたいよ!!』という方は使用例とサンプルスクリプトまですっ飛ばしていただければと思います。

作った経緯

  • 標準機能のカーブシミュレーションは少し扱いにくかった。ジョイントに適用するには余分な仕込みが必要で面倒だった。
  • サードパーティ製のボーンダイナミクスツールはシミュレーションとベイクが一緒になっているものが多く、結果の確認が少々手間だった。また、コリジョンなどの機能が足りないものも多かった。
  • 自分で作ってみたかっただけ。(←ほぼこれ)

特徴

  • 普通のジョイントにつなげるだけの簡単実装
  • 4種のコリジョン(球、カプセル、無限平面、地面)に対応
  • 角度制限機能付き(コリジョンよりも優先度高)
  • 目標姿勢の操作が可能
  • 特定ノードのトランスフォームをキャンセル可
  • セクションごとにスケール可能(不均一スケールは不可)
  • 分岐可
  • フリップ対策済み(真逆を向かない限りフリップしない)
  • レンダリング前にはベイク必須

導入

インストール

ReleasesからboneDynamicsNode.mllをダウンロードし、plug-insフォルダに放り込むだけです。Mayaを起動したらPlug-in ManagerからboneDynamicsNode.mllをロードして下さい。

最小構成

親子関係にあるjointを2つ、boneDynamicsNode1つ、あとtime1ノードを次のように繋ぎます。最低限これだけ繋がっていれば動きます。

  • time1のOutput Time → Time
  • 親jointのTranslate → Bone Translate
  • 親jointのJoint Orient → Bone Joint Orient
  • 親jointのParent Matrix[0] → Bone Parent Matrix
  • 親jointのParent Inverse Matrix[0]→ Bone Parent Inverse Matrix
  • 子jointのTranslate → End Translate
  • Output Rotate → 親jointのRotate
    ※値が変化しないもの(Bone Joint Orient, End Translateなど)は一度接続してから切断しても大丈夫です。
    time1ノードは既にシーンにあります。新規に作らないよう注意してください。

boneDynamicsNode_01_01.png

タイムスライダを1F以降に移動し根元を動かしてみてください。

boneDynamicsNode_01_02.gif

jointの状態にはいくつか要件がありますが、通常通りCreate Jointsで作ってOrient Jointをポチッとしておけば要件は満たされているはずです。

  • Rotate は [0,0,0] で、Joint Orientに値が入っている状態
  • Rotate Order は xyz のみ対応
  • Rotate Axisは [0,0,0] のままに
  • Inherits Transform はオンのままに
  • Offset Parent Matrix はデフォルト値のままに
  • Rotate Pivot, Rotate Pivot Translate, Scale Pivot, Scale Pivot Translate はデフォ値のまま編集しない
  • 親のScaleから Inverse Scale へ接続された状態で、Segment Scale Compensate をオンに

boneDynamicsNode_01_03.png

基本パラメータ

Enable

このチェックがオフになっていると諸々の計算が一切行われなくなり、出力されるRotate値は常時[0,0,0]になります。

Time

シーンにすでにあるtime1ノードを接続します。Reset Timeと比較して姿勢をリセットする用途にしか使っていませんので、Reset Timeより大きな数値を入れておけば実は何も接続しなくても常時動きます。ただしリセット出来なくなるので扱いやすさ的にはtime1を繋げておいたほうがいいと思います。

# シーンにあるtime1ノードを選択します
from maya import cmds
cmds.select('time1') # これをNode Editorに持って行って接続してください。

Reset Time

現在フレーム(=Time値)がこの値以前になるとシミュレーションが走らず初期姿勢でリセットされます。つまりシミュレーションの開始フレームです。初期姿勢はRotation Offset(後述)のみ反映された状態になります。

FPS

シーンのFPSと同じ値を入力しておきます。内部で$\Delta t$の算出に使います。$\Delta t$は速度や力の値に乗算されます。ただ、どのみち正確な物理の計算をしているわけではないのでなんとなくで大丈夫です。下のGIFを見てもわかる通り「30 / 30」と「60 / 60」の結果が一致するわけではありません。

boneDynamicsNode_02_01_fps.gif

ダイナミクスパラメータ

擬似物理シミュレーションに関連するアトリビュートです。複数ありますがDampingとElasticityの2つ(+Gravity Multiply)だけでほぼほぼどうにかなります。

計算内容の詳細はソースコードを見ていただくのが確実なのですが、以下の記事に図解が載っています。やっていることはだいたい同じです。

Damping

速度を抑えます。直前の状態(再生中の場合は前フレの状態)から得られた現在の”速度”をどれだけ抑えるかです。範囲は0〜1です。0にすると速度がそのまま保たれ、1で速度が完全に消されます。低い値だとビヨンビヨンします。

「重力」や「元の姿勢に戻ろうとする力」は完全には抑えられないので他の力が加わっている限り1にしても完全停止はしません。

boneDynamicsNode_03_01_damping.gif

Elasticity

「元の姿勢に戻ろうとする力」です。範囲は0〜∞です。この値が0になってると元の姿勢には戻りません。

ここは重力と同じ”力”の計算をしてます。もし重力も有効になってる場合、結構大きめの値(数百)を入れないと重力に負けます。逆に重力を弱めるという手もあります。

値が大きすぎる(数千↑)と派手に暴れます。

boneDynamicsNode_03_02_elasticity.gif

Stiffness

単語の意味的には剛性ですが「動きにくさ」と言った方が正しいかもしれません。内部で諸々の計算をして次の姿勢を決めるのですが、計算の最後にこの値でスケーリングします。Dampingと違ってこっちは「重力」や「元の姿勢に戻ろうとする力」による影響もすべて抑えます。

範囲は0〜1です。0で効果なし、1にするとせっかく計算した結果が完全に消されます。完全停止しますので1にすることは無いと思います。

boneDynamicsNode_03_03_stiffness.gif

以下は、Dampingとの比較です。得られる効果だけ見るとDampingと結構似てるのでそもそもあまり使わないかもしれないです。

boneDynamicsNode_03_04_damping_vs_stiffness.gif

Mass

質量です。今のところ「元に戻ろうとする力」にだけ影響を与えます。質量が大きいものは動かすのに大きな力が必要になるので、つまりMassを上げるとElasticityが効きにくくなります。範囲は0.001~∞です。正直、今の状態ではElasticityを操作すればいいので1.0固定でいいと思います。あまり気にしなくていいですが計算上の単位はkgです。「スカートは軽いからもっと下げなきゃ!」とか真面目に設定する必要はないです。

Massが小さすぎると力の影響が最大化されて派手に暴れます。

boneDynamicsNode_03_05_mass.gif

Gravity / Gravity Multlply

重力です。Y-upで単位がcentimeterだったとして、GravityにはデフォルトでY軸に-980[cm]が入ってます。Gravity Multlplyの範囲は0〜1で、内部でGravityに乗算されますので重力の影響度として使えます。ちなみに重力は質量(Mass)の影響を受けません。

boneDynamicsNode_03_06_gravity.gif

「重力」に対抗する力として「元に戻ろうとする力(=Elasticity)」も併せて調整すると良いかもしれません。

boneDynamicsNode_03_07_gravity_and_elasticity.gif

各種機能解説

コリジョン

シンプルなプリミティブ形状のコリジョンが使えます。軽さの代償として精度はそれなりです。対応しているのは「球」「カプセル」「無限平面」と「地面(=水平な無限平面)」の4(3+1)種です。

boneDynamicsNode_04_01.gif

各コリジョン3種に必要な接続は以下の通りです。地面だけは特に接続を必要とせず、地面の高さパラメータとオンオフのみになります。

球:

  • 球の中心のワールドマトリックス(ここから位置とスケールを取ります)
  • 半径

boneDynamicsNode_04_02.png

カプセル:

  • 両端の球の各ワールドマトリックス(ここから位置とスケールを取ります)
  • 各半径

boneDynamicsNode_04_03.png

無限平面:

  • ワールドマトリックス(これでXZ平面をトランスフォームします)

boneDynamicsNode_04_04.png

地面:

boneDynamicsNode_04_05.png

コライダーは maya_expressionCollision (expcol) のcolliderモジュールで作成したものを使えますが、必須ではありません。

expcolでコライダーを作成するコマンド
from expcol import collider
collider.iplane()   # Infinite Plane
collider.sphere()   # Sphere
collider.capsule()  # Capsule
collider.capsule2() # Capsule2

前述の必要パラメータを見たらだいたい分かるかもしれませんが、入力しているのは位置とか半径とか単純なものだけです。見た目がプリミティブである必要すらありません。以下のGIFは、locator1の位置を球の中心とし、distanceDimensionのDistanceを半径に見立てています。そこに球体があるかのように判定が行われているのが分かります。

boneDynamicsNode_04_06.gif

アトリビュートは配列になっているのでコライダーが複数ある場合は[0], [1], [2] …と追加で繋げていけばOKです。

boneDynamicsNode_04_07.png

コリジョンはいずれもエンドジョイントを中心とした球との接触判定になります。「球 × 球」「球 × カプセル」「球 × 無限平面」です。エンドジョイントの半径はRadiusアトリビュートで設定します。

boneDynamicsNode_04_08.png

ボーンの長さに対してRadiusやコライダーが小さいとボーンの間を華麗にすり抜けます。

boneDynamicsNode_04_09.gif

Iterationsはコリジョンの精度です。範囲は0〜10です。0でコリジョンが無効になり、1以降は精度が上がっていきますが、上げるとすこし重くはなります。5くらいあれば十分だと思います。

boneDynamicsNode_04_10.gif

角度制限

初期姿勢(Rotation Offset含む)からどれだけジョイントが回転できるかの最大角度を指定できます。Enable Angle LimitをオンにしてAngle Limitに角度を入れて設定します。範囲は0°〜360°です。制限範囲はコーン状になります。

boneDynamicsNode_05_01.gif

この角度制限は優先度を最強にしてあるので、強い力を加えたりコリジョンで押したりしても制限を超えることはありません。範囲を超えそうになると普通にコリジョンにめり込みますし、目標姿勢が範囲外に出ると範囲内に収まるよう瞬間移動します。

boneDynamicsNode_05_02.gif

目標姿勢の操作

Rotation Offset に接続された回転値が、初期姿勢(≒目標姿勢)に足されます。(※足すと言ってもオイラー角の加算ではなく回転行列の乗算です)

シミュレーション用のジョイントチェーンをまるごと複製し(ここではターゲットチェーンと呼ぶことにします)、ターゲットのRotate値をRotation Offsetに接続します。その後ターゲットチェーンを回転させると、視覚的に分かりやすく追従するような設定ができます。

ちなみに、ツイスト成分はシミュレーションとは直接関係が無いので瞬時に反映されます。あと、Elasticityが0だと目標姿勢に戻る力が無いので効果は得られません。

ターゲットチェーンは普通にキーフレームが打てますので『姿勢Aからシミュをスタートして、姿勢Bを経由したのち、姿勢Cに落ち着いてほしい!』みたいな使い方ができます。

boneDynamicsNode_06_01.png

boneDynamicsNode_06_02.gif

特定ノードのトランスフォームをキャンセル

あるノードのworldMatrixをOffset Matrixに繋ぐと、そのノードの移動/回転/スケールの”変化”が無かったことにされます。具体的には、毎ステップこのMatrixの変化の差分を適用してから物理シミュの計算を始めます。慣性的な動きだけ打ち消されるのでコリジョンなどは生きてます。

例えば、キャラクターがものすごい高速移動をしている場合、かなり強い力で揺れものが後ろに引っ張られて(取り残されて)しまうのですが、キャラのルートを接続するとこの高速移動分を無視できたりします。完全無視してしまうのでそれはそれで良い見栄えにはならないかもしれませんが…

boneDynamicsNode_07_01.png

boneDynamicsNode_07_02.gif

セクションごとのスケール対応

スケール関連のアトリビュートを3つ追加接続することで、スケールに対応できます。セクションごとにスケールするつもりがないならこれら3つには何も接続しなくていいです。不均一スケールはおかしな結果になるので非推奨になります。

  • 親jointのScale → Bone Scale
  • 親jointのInverseScale→ Bone Inverse Scale
  • 子jointのScale → End Scale

エンドジョイントは親からInverse Scaleを受け取り、Segment Scale Compensateがオンになっている必要があります。普通にジョイントを作るとこうなっているのであえて壊さなければ条件は満たされているはずです。

boneDynamicsNode_08_01.png

boneDynamicsNode_08_02.gif

目標姿勢を別の複製ジョイントで操作している場合、複製ジョイントからスケールを接続しておくと使いやすいかもしれません。

boneDynamicsNode_08_03.png

ちなみに、これら3種のスケール接続をしていなくても、ジョイントチェーンのさらに親で全体スケールをすることはできます。この場合でも不均一スケールは非推奨です。

boneDynamicsNode_08_04.gif

分岐

そもそも「ジョイント2つとboneDynamicsNodeで1セクション分のダイナミクスが出来る」というものなので、1セクションを連ねていけば分岐は何ら問題なくできます。

boneDynamicsNode_09_01.gif

ただし、”子が複数あり且つ子の位置が異なるジョイント”は面積(または体積)を持ってしまい棒状では無くなるので、動きはしますが(主にコリジョン判定が)あまり望ましい結果にはならないと思います。

boneDynamicsNode_09_02.png

boneDynamicsNode_09_03.png

使用例とサンプルスクリプト

Case1. リグに直接組み込む例

組み込み方はパターンがいろいろあるのでここで挙げているのはあくまで一例です。リグに組み込むのでリグを使う人はプラグインのインストールが必要になります。

とりあえずシンプルに『シミュモードでいろいろ設定してFKコントローラにベイクし、その後はキーフレームを直接編集できる構造』を作ってみます。

boneDynamicsNode_10_01.png

こちらの動画でFKリグに組み込む過程を解説しています。

【追記 2024/04/27】
動画の 03:05~ でベイクを実行していますが、ベイクする前に対象のアトリビュート(この動画の場合はfk_**_ctlのRotateX,Y,Z)に1つだけでいいのでキーを作成しておいてください。キーが1つもないとベイク時にPairBlendが消えてしまいます。
動画中ではカットしてしまっていました。失礼いたしました🙏

動画中で使用しているスクリプトは以下に置いておきます。

選択したジョイントチェーンにboneDynamicsNodeを接続するスクリプト(動画00:55付近で使用)
# =====================================================
# 選択したジョイントチェーンにboneDynamicsNodeを接続する
# =====================================================
from maya import cmds

cmds.loadPlugin("boneDynamicsNode.mll", qt=True)

def create_dynamics_node(bone, end):

    if not bone in cmds.listRelatives(end, p=True):
        print("Exit: {} is not {}'s parent.".format(bone, end))
        return

    boneDynamicsNode = cmds.createNode("boneDynamicsNode")

    cmds.connectAttr('time1.outTime', boneDynamicsNode + '.time', force=True)
    cmds.connectAttr(bone + '.translate', boneDynamicsNode + '.boneTranslate', f=True)
    cmds.connectAttr(bone + '.parentMatrix[0]', boneDynamicsNode + '.boneParentMatrix', f=True)
    cmds.connectAttr(bone + '.parentInverseMatrix[0]', boneDynamicsNode + '.boneParentInverseMatrix', f=True)
    cmds.connectAttr(bone + '.jointOrient', boneDynamicsNode + '.boneJointOrient', f=True)
    cmds.connectAttr(end + '.translate', boneDynamicsNode + '.endTranslate', f=True)

    cmds.connectAttr(boneDynamicsNode + '.outputRotate', bone + '.rotate', f=True)

    # collision radius
    radius_sphere = cmds.createNode("implicitSphere")
    cmds.connectAttr(boneDynamicsNode + '.radius', radius_sphere + '.radius', f=True)
    radius_sphere_tm = cmds.listRelatives(radius_sphere, p=True)[0]
    cmds.parent(radius_sphere_tm, end, r=True)
    cmds.setAttr(radius_sphere_tm + '.overrideEnabled', 1)
    cmds.setAttr(radius_sphere_tm + '.overrideDisplayType', 2)
    
    return boneDynamicsNode

if __name__ == "__main__":
    
    # Select in order from root to tip of the joint-chain.
    joints = cmds.ls(sl=True)
    
    set_name = "boneDynamicsNodeSet"
    if not cmds.objExists(set_name):
        cmds.select(cl=True)
        cmds.sets(name=set_name)

    for bone, end in zip(joints[:-1], joints[1:]):
        boneDynamicsNode = create_dynamics_node(bone, end)
        if boneDynamicsNode:
            cmds.sets(boneDynamicsNode, addElement=set_name)
Orient Constraint をしつつPairBlendを間に挟むスクリプト(動画01:49付近で使用)
# =====================================================
# Orient Constraint をしつつPairBlendを間に挟む
# =====================================================
from maya import cmds

def orient_const_with_pair_blend(master, slave, switch=None):
    cons = cmds.orientConstraint(master, slave, mo=True)[0]
    pb = cmds.createNode('pairBlend')

    if switch :
        cmds.connectAttr(switch, '{}.weight'.format(pb), f=True)
    
    for at in 'XYZ':
        cmds.connectAttr('{}.constraintRotate{}'.format(cons, at), '{}.inRotate{}2'.format(pb, at), f=True)
        cmds.connectAttr('{}.outRotate{}'.format(pb, at), '{}.rotate{}'.format(slave, at), f=True)

if __name__ == "__main__":
    sel = cmds.ls(sl=True)
    orient_const_with_pair_blend(sel[0], sel[1]) # 同時にスイッチを接続する場合は引数に"ノード名.アトリビュート名"を追記
jointにシェイプを親子付けしてコントローラ化するスクリプト(動画07:28付近で使用)
# =====================================================
# jointにシェイプを親子付けしてコントローラ化する
# =====================================================
from maya import cmds

jnt = "joint1_target" # ジョイント名
crv = "nurbsCircle1"  # カーブシェイプ名

crv_shapes = cmds.listRelatives(crv, c=True, f=True)
cmds.parent(crv_shapes, jnt, s=True, add=True)
cmds.setAttr(jnt + ".drawStyle", 2)
cmds.delete(crv)

Case2. アニメーション工程で使う例

アニメーション工程でリグに後付けするパターンです。汎用性はこちらの方が高いと思います。シミュに使ったノードはベイク後にすぐ捨ててしまえばシーンにプラグインは残りません。

こちらも解説動画を…と思ったのですが、実際のところ前項のものとほぼ同じなので動画は割愛します。

先ほどの動画の途中でdynamics_grpというグループが出来ていますが、この階層とコライダーがダイナミクス関連ノードになっています。ベイク後にこれらのノード群を丸ごと削除してしまえば、アニメーション工程での使い方に転用できます。尚、切り替えスイッチは不要かと思いますのでコンストレイントにPair Blendを挟む必要はありません。

boneDynamicsNode_10_02.png

先ほどの動画の例ではバインドジョイントを複製してダイナミクス用ジョイントとしましたが、新規で作成する場合は、以下のスクリプトで選択したコントローラ上にジョイントチェーンを作成することができます。作成したダイナミクス用ジョイントでコントローラを拘束するところまでが行われます。

選択したコントローラに位置合わせしたジョイントチェーンを新規作成するスクリプト
# =====================================================
# 選択したコントローラに位置合わせしたジョイントチェーンを新規作成する
# =====================================================
from maya import cmds

def create_dynamics_chain(nodes, constraint=True):

    if not nodes:
        return 
    
    # create root
    joint_root = cmds.createNode("transform", n="dynamics_grp")

    # constraint root
    node_parent = cmds.listRelatives(nodes[0], p=True)
    if node_parent:
        world_mtx = cmds.xform(node_parent[0], q=True, ws=True, m=True)
        cmds.xform(joint_root, ws=True, m=world_mtx)
        cmds.parentConstraint(node_parent[0], joint_root, mo=True)

    # create dynamics joint-chain
    joint_list = []
    pr = joint_root
    for i, node in enumerate(nodes):
        cmds.select(cl=True)

        world_pos = cmds.xform(node, q=True, ws=True, t=True)
        
        jnt = cmds.joint(n='dynamics_{}_jnt'.format(str(i+1).zfill(2)), p=world_pos)
        cmds.setAttr(jnt + '.displayLocalAxis', True)
        cmds.setAttr(jnt + '.radius', .2)
        cmds.setAttr(jnt + '.useOutlinerColor', True)
        cmds.setAttr(jnt + '.outlinerColor', 0, 1, .5)
        cmds.setAttr(jnt + '.overrideEnabled', True)
        cmds.setAttr(jnt + '.overrideDisplayType', 0)
        cmds.setAttr(jnt + '.overrideRGBColors', False)
        cmds.setAttr(jnt + '.overrideColor', 27)

        if pr:
            cmds.parent(jnt, pr, r=False)
        pr = jnt

        joint_list.append([node, jnt])

    # orient joint
    for _, jnt in joint_list:
        if cmds.listRelatives(jnt, c=True):
            cmds.joint(jnt, e=True, oj='xzy', sao='zup', zso=True)
        else:
            cmds.joint(jnt, e=True, oj='none')

    # constraint
    if constraint:
        for node, jnt in joint_list:
            cmds.parentConstraint(jnt, node, mo=True)

    return [jnt for _, jnt in joint_list]

if __name__ == "__main__":
    joints = create_dynamics_chain(cmds.ls(sl=True, tr=True))

ちなみに、対象が(見た目的に)チェーン状であればFKコントローラでなくても構いません。以下はmGearの「chain_IK_spline_variable_FK_01」で作成したIKコントローラにダイナミクスを被せてベイクする例です。※この動画内では上記のスクリプトは使用せず手でジョイントを配置しています。

「アニメーション工程で使うスクリプト」はもう少し整えてしっかりツール化しようと思います。いずれ...

FAQ

ツイストはどうやるの?

boneDynamicsNodeでシミュった結果の回転値はツイスト要素が入らないように曲げ要素だけの最短ルートで回転するようになってます。意図的にツイストを加えたいときは次の2択になります。

  • Rotation Offsetで捻る
  • 出力結果に追加で捻りを加える

あまり細かく比較してないですがどちらも結果は変わらないはずです。お好みでいいかと思います。

後からコライダーを追加したり除外したりしたいのだけど?

コライダーのアトリビュート配列を足したり消したりすれば良いだけですが、0.2.0以前は「配列が途中欠損していると正常に反映されない」バグがあります…

すみません、今後修正予定です。
【追記 2024/04/11】0.2.1で修正されました。

ベイク必須なの?だる…

ごめんなさい…。「軽量なシミュレーション」というコンセプトを崩さないためには必要なんです…。細かい話をすると、前の状態を参照する特性上フレームがジャンプすると結果が変わります。なのでシミュレーション時は開始フレームから1フレームずつ順番に送って結果を蓄積していく必要があります。

なんかエラー出るんだけど…

すみません…。動作確認はしているのですが、あまり丁寧にエラーハンドリングは出来ていないと思います。GitHubでIssuesを立てるか、Xなどで直接ご連絡ください。可能な限り対応します。

長いチェーンに適用したら暴れるんだけど?

【追記 2024/04/15】

コメントにて質問いただきました。ありがとうございます!

子ジョイントは親ジョイントの影響を受けて振り回されます。長いジョイントチェーンにダイナミクスを適用すると、子に行くにつれて速度が増幅され、末端ジョイントが大暴れします。これはパラメータを調整することで軽減できます。

たとえば、30ジョイントからなるチェーンの場合、下のような値に変更するといい具合に抑えられます。

  • Damping:0.5 前後
  • Elasticity:500 以上
  • Stiffness:0 ~ 0.3

以上です。長々と失礼いたしました!

9
5
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?