メインコンテンツまでスキップ

2023-01-27 QvPen2Pen の振り返り

注記

このページは AI(Claude)によって翻訳されています。翻訳に誤りが含まれる可能性があります。

友達が VR の中から、私の 3D プリンターを使って絵を描けるようにしました。

始まり​

3D プリンターを使い始めて数か月が経ち、何とかして VRChat のワールドとつなげて、友達がプリンターに取り付けたペンを操作できないかと考え始めました。

ただ、それまでは、スライサーソフトが出力した gcode ファイルを SD カードに入れ、それをプリンターに直接挿して印刷するだけでした。

最大限の効果を出すために、描いた絵が、できるだけ低いレイテンシでリアルタイムに現れるようにしたいと考えました。

Twitter で、基本的なアイデアを添えて質問してみました。

https://twitter.com/vr_hai/status/1618541782899519489

すぐに、Prusa MK3S+ プリンターの USB-B ポートを使ったシリアルポート通信で可能だと教えてもらいました。ありがたいことに、3D プリンターはメインのPCのすぐそばにあるので、さっそく接続して試してみました。

先行事例​

gcode をリアルタイムに送る方法についてアドバイスを求めるツイートをしたところ、先行事例をいくつか教えてもらえて、とてもありがたかったです! これはプロッターを使ったものです。

getting an autograph from a vtuber!?? 😱
VShojo #ENVtuber Vshojo:Twitter ⯈ https://twitter.com/VShojoOfficialInstagram ⯈ https://www.instagram.com/vshojo/Website ⯈ https://www.vshojo.com/VShojo tal...

計画​

ペンで描いた線(自分や他のプレイヤーが描いたもの)をプリンター上に実体化するために、QvPen(ペンのプレハブ)を改造して、Udon を使ってペンの線の位置をゲームのログに出力するようにします。そのうえで、ログパーサーを書き、ペンの位置を gcode の命令としてシリアルポート経由でプリンターに継続的に送信します。

Debug.Log でペンの位置/クリック/リリースを出力 → ログパーサー → gcode に変換 → シリアルポート

プリンターの様子はワールドにライブ配信で戻すので、効果を高めるために、これはできるだけ低いレイテンシで行う必要があります。

シリアルポート通信と gcode の学習​

ざっと検索したあと、デバイスマネージャーを見て、3D プリンターの通信ポート(COM ポート)を確認しました。

次に、プリンターのシリアルポート設定(ボーレートなど)を調べたところ、それに言及している OctoPrint のログにたどり着きました。

_port = new SerialPort(portName, 115200, Parity.None, 8, StopBits.One);
_port.Open();

それから、基本的な gcode の命令を調べました。プリントヘッドを X 軸と Y 軸(プリントベッドの軸)、そして Z 軸(上下)に動かす方法と、移動速度を制御する方法です。主に使う gcode は G1 です。

private const int InternalFastestMillimetersPerSecond = 200;
private const int FastestMillimetersPerMinute = InternalFastestMillimetersPerSecond * 60;

// Editor's note: SafeWriteLine and other functions are described in the next section

public void AsFastAsPossible()
{
SafeWriteLine($"G1 F{FastestMillimetersPerMinute}");
}

public void MoveTo(float xMillimeters, float yMillimeters)
{
if (IsFloatIllegalRange(xMillimeters) || IsFloatIllegalRange(yMillimeters))
{
throw new ArgumentOutOfRangeException($"{xMillimeters} {yMillimeters}");
}
SafeWriteLine($"G1 X{xMillimeters:0.000} Y{yMillimeters:0.000}");
}

public void PressPen(float amount01)
{
if (amount01 < 0 || amount01 > 1)
{
throw new ArgumentOutOfRangeException();
}

if (amount01 < 0.001f)
{
MoveZToAndIgnoreLess(Retracted);
}
else
{
MoveZToAndIgnoreLess(FullyPressed * amount01 + BarelyPressed * (1 - amount01));
}
}

public void ReleasePen()
{
MoveZToAndIgnoreLess(Retracted);
}

public void MoveZToAndIgnoreLess(float zMillimeters)
{
if (IsFloatIllegalRange(zMillimeters))
{
throw new ArgumentOutOfRangeException();
}

if (zMillimeters < 9f)
{
return;
}
SafeWriteLine($"G1 Z{zMillimeters:0.000}");
}

// Editor's note: SafeWriteLine and other functions are described in the next section

失敗​

学習の過程で、プリンターが物理的に誤作動するような失敗をいくつかしました。誤作動は次のような形で現れました。

  • Z 軸(プリントヘッドが下がる動き)で、ベッドが動いている最中にノズルがベッドに衝突し、プリントベッドが物理的に損傷しました。再キャリブレーションのあとにテスト印刷をしたところ、問題なく動いているように見えますが、まだ気づいていない損傷があるかもしれません。

https://twitter.com/Ybalrid/status/1618583370199609348

  • X 軸(プリントヘッドの横方向の動き)が何度も左側に衝突し、モーターがガタガタと音を立てました。
  • X 軸がプリントベッドと平行でなくなりました。これはレベリングの工程で自動検出された問題です。私のプリンターには、可能な限り上まで移動することで自分自身を修正する工程があります。

これらの誤作動の原因は次のとおりです。

  • 除算の問題のせいで、うっかり NaN(非数)をプリンターの軸に送ってしまっていました。これは、Z 方向の移動を要求していないのに Z 軸が動いてしまうことさえあり、ノズルがベッドに衝突する原因になりました。
  • プリンターとの通信方法が間違っていました。プリンターに操作を送るたびに、プリンターが ok というメッセージをプログラムに返してくるのを待つ必要があります。

これらの失敗から学んだ教訓は次のとおりです。

  • 本番で初めて試すときは、リセットボタンを押すか、プリンターの電源を切れるようにしておく。
  • 新しいことを試すときは、プリンターを使って Z 軸を手動で中間あたりまで上げておく(プリンターで Settings > Move axis > Z を選び、ダイヤルで上げる)。Z 軸の移動は X 軸や Y 軸より遅いので、プリンターが望ましくない動作をしたときの猶予時間になります。
  • プリンターへの出力を防御的に検証する。 出力が想定範囲外の数値や、NaN や無限大といった不正な数値を送ろうとしている場合は、ただちにプログラムを終了する。なお、加熱の操作は一切行っておらず、常にプリンターから目を離さないので、先にプリンターのヒーターを切ることは気にしなくてよいです。
    通常のプログラミングでは、関数の出力ではなく入力だけを検証するのが普通ですが、今回は物理的な結果を伴う低レベルのハードウェアを扱うので、防御的なアプローチを取るほうがよいと考えるようになりました。
    private bool IsFloatIllegalRange(float value)
{
if (IsFloatInvalid(value)) return true;

return value < 0 || value > 210;
}

private bool IsFloatInvalid(float value)
{
return float.IsNaN(value) || float.IsInfinity(value);
}
  • プリンターに送る gcode はすべて、ok が返ってくるまでブロックする関数を通す。
    private void SafeWriteLine(string message)
{
_port.WriteLine(message);
WaitForOkResponse();
}

private void WaitForOkResponse()
{
string readLine;
do
{
readLine = _port.ReadLine();
Console.WriteLine($"Read line: {readLine}");
} while (!readLine.EndsWith("ok")); // FIXME: Maybe don't use EndsWith
}

QvPen をログに出力させる​

QvPen を改造して、毎フレーム、トレイルレンダラーの位置を出力するようにしました。QvPen は通常、線が完成したあとに描かれたすべての線を同期して、インスタンス内の全員に同じ絵が見えるようにします。しかし私は、描画の正確さを犠牲にしてでも、低レイテンシの効果を高めるために、描いている最中の線を出力する方法を選びました。

私は VRCDN を使って 3D プリンターの映像をワールドにライブ配信しているので、レイテンシは 1 秒未満で、とても素晴らしいです。

QvPen の変更点は次のとおりです。

// QVPen_Pen.cs
private void LateUpdate()
{
// ...
if (trailRenderer.gameObject.activeSelf)
{
var pos = inkPositionChild.position;
Debug.Log($"[GCODETRANSFER]::POS {pos.x:0.000000} {pos.y:0.000000} {pos.z:0.000000}");
}
}

private void StartDrawing()
{
// ...
Debug.Log($"[GCODETRANSFER]::START");
}

private void FinishDrawing()
{
// ...
Debug.Log($"[GCODETRANSFER]::FINISH");
}

これをログに記録するには、自分がワールドのインスタンス内にいる必要があります。

また、ペンの消しゴム機能をすぐに無効にするために、次の行も変更しました。

// QVPen_Pen.cs
private const float clickTimeInterval = 0.0f; // was 0.2 before

プレイヤーには正方形のホワイトボードに描いてもらうので、ペンがホワイトボードの平面に吸着するようにしました。

// QVPen_Pen.cs
public Collider[] alwaysBypassColliderMode;

private void OnTriggerEnter(Collider other)
{
var isAlwaysColliderMode = useSurftraceMode;
if (!isAlwaysColliderMode)
{
foreach (var bypass in alwaysBypassColliderMode)
{
if (other == bypass)
{
isAlwaysColliderMode = true;
break;
}
}
}

// Edit the following line to replace `useSurftraceMode` with `isAlwaysColliderMode`
if (isUser && isAlwaysColliderMode && VRC.SDKBase.Utilities.IsValid(other) && !other.isTrigger)
// ...

ホワイトボードの Box Collider を alwaysBypassColliderMode にドラッグします。

gcode への変換​

QvPen は、絵の中のどこに当たるかとは無関係に、ペンのワールド絶対座標を出力します。ワールド座標からプリンター座標への変換は、個人的な選択として、外部プログラム側で行っています。このステップで変換する利点は、精度が十分であれば、後からソフトウェア上でいくつかの誤りを補正できることです。

ログのパースは、FileStream、StreamReader、FileSystemWatcher を使って継続的に行っています。ほかにも方法はありますが、これといった好みがあるわけではなく、結果的にこれに落ち着きました。

QvPen が出力した行を絞り込めば、作業はほとんど終わったようなものです。

注意すべき点が 1 つあります。ユーザーが描き始めたときに、すぐにペンを押し付けてはいけません。次の位置を受信するまで待ち、そこへ移動してから、ペンを押し付ける必要があります。

処理全体はブロッキングで、非同期の魔法は使っていません。有効な行を読み取り、すぐにプリンターに送り、プリンターが ok と答えるのを待ちます。それから次の行を読み取り、というのを繰り返します。特に、受信したデータの後処理は一切行っていません。QvPen が出力したすべての位置が、そのまま gcode に変換されます。 簡略化のための工夫は何もしていません。本当に単純ですが、これでちゃんと動きます。

// Log parser
bool willPress = false;
void ParseLine(string line)
{
if (line.StartsWith("POS"))
{
var strings = line.Split(" ");
var x = float.Parse(strings[1]);
var y = float.Parse(strings[2]);
// FIXME: Whiteboard bounds
var xRel = Math.Clamp(x + 0.5f, 0f, 1f);
var yRel = Math.Clamp(y - 0.25f, 0f, 1f);
prusa.DrawTo(xRel, yRel);
if (willPress)
{
prusa.PressPen(1f);
willPress = false;
}
}
if (line.StartsWith("START"))
{
// The pen needs to move to the next drawing position before pressing
willPress = true;
}
if (line.StartsWith("FINISH"))
{
prusa.ReleasePen();
willPress = false;
}
}

ペンの取り付け​

ペンはマスキングテープで固定しています。そのうち、もっと頑丈な取り付け具を 3D プリントするかもしれません。キャリブレーションは手作業で行っています。ソフトウェア上でペンを押し付けたり離したりするのは、Z 軸をごくわずかに動かすことで行っています。

結果​

友達を招いて描いてもらったところ、ちゃんと動きました! ホワイトボードの座標をプリンターの座標に変換するところで間違いがありましたが、結果的にはうまくいきました。

ワールドには、低レイテンシを有効にした私の VRCDN アカウントにつないだライブ配信プレイヤーを置きました。レイテンシは驚くほど低く、効果をさらに高めています。

https://twitter.com/vr_hai/status/1618915110415896576