了解 StereoKit Ink 應用程式
在上一個單元中,我們探討了 StereoKit Ink 範例專案。 本單元將看看用來建置此應用程式的三個主要程式碼檔案。 該程式碼目的是要簡單明瞭。
Program.cs
此檔案包含應用程式邏輯、手部功能表和應用程式功能表! 這是一個很好的起點,因為應用程式功能表會將所有項目結合在一起。
初始化:Program.cs 指令碼會藉由初始化 StereoKit 來開始。 我們可以在初始化期間準備一些設定,例如 assetsFolder 和 appName。 assetsFolder 是在提供相對資料夾名稱時,StereoKit 將尋找資產的資料夾。 也可以告知設定建立平面螢幕應用程式,或在偏好的初始化模式失敗時的行為。
SKSettings settings = new SKSettings { appName = "StereoKit Ink", assetsFolder = "Assets", }; if (!SK.Initialize(settings)) Environment.Exit(1);
放射狀手部功能表:這是簡單的放射狀功能表,我們將在其中儲存一些快速動作! 其會由抓握動作啟動,並且非常適合用於快速、類似手勢的功能表項目啟用。 其也可以搭配多個 HandRadialLayers 使用,以在將命令在子功能表中建立巢狀。
HandMenuRadial 是步進器物件的範例。 步進器是可實作 IStepper 介面的類別。 一旦新增至 StereoKit 的步進器清單,就會有其步驟方法,稱為每個畫面格! 這是要新增射後不理 (fire-and-forget) 物件或需要更新每個畫面格系統的良好方法。
SK.AddStepper(new HandMenuRadial( new HandRadialLayer("Root", -90, new HandMenuItem("Undo", null, ()=>activePainting?.Undo()), new HandMenuItem("Redo", null, ()=>activePainting?.Redo()))));
依每個畫面格逐步執行應用程式:StereoKit 應用程式為沉浸式 3D 體驗,其運作方式一般是在每次顯示重新整理時重新繪製螢幕。 可惜的是,這可能表示每秒會對不少部分的程式碼基底執行 60 次到 120 次! 您可以在 while 迴圈中逐步執行 StereoKit 以控制此顯示迴圈,直到應用程式完成為止。 在每個步驟期間,您將會呼叫需要繪製或逐步執行的所有程式碼。 在這裡,我們會在每個畫面格中逐步執行目前作用中的繪製物件、調色盤視窗和應用程式功能表視窗。
while (SK.Step(() => { activePainting.Step(Handed.Right, paletteMenu.PaintColor, paletteMenu.PaintSize); paletteMenu.Step(); StepMenuWindow(); }));
應用程式的功能表視窗:此功能表是使用 StereoKit 的內建直接模式 UI 系統建置。 您可以在 UI 指南中閱讀更多有關 StereoKit UI 系統的資訊,但基本概念非常容易遵循!
在這裡,我們將建立以 UI.WindowBegin 和 UI.WindowEnd 呼叫括住的視窗面板。 其可以包含數個 UI 元素,而且可以由使用者抓取和四處移動。 我們可以在視窗中新增不同的 UI 元素,例如 UI.Image 或 UI.Button。 這些會在視窗面板內自動配置。 其他工具,例如 UI.SameLine 和 UI.HSeparator 可用來操作視窗內容的版面配置。
按下時 UI.Button 會傳回 true,因此很容易就能包裝 'if' 陳述式,並根據該動作執行一些程式碼! 您可以看到在這裡執行數個動作,但我們將叫出 Platform.FilePicker 呼叫,做為更有意思的動作。 如果平台未提供 MR 相容的檔案選擇器,Platform.FilePicker 將載入平台原生的檔案選擇器視窗或內建的後援檔案選擇器。 其會使用產生的檔案名稱來呼叫您提供的回撥函式。 這會讓您更易於使用檔案系統中的內容!
static void StepMenuWindow() { UI.WindowBegin("Menu", ref menuPose, UIWin.Body); UI.Image(appLogo, V.XY(UI.LayoutRemaining.x, 0)); if (UI.Button("Undo")) activePainting?.Undo(); UI.SameLine(); if (UI.Button("Redo")) activePainting?.Redo(); if (UI.Button("Save")) Platform.FilePicker(PickerMode.Save, SavePainting, null, ".skp"); UI.SameLine(); if (UI.Button("Load")) Platform.FilePicker(PickerMode.Open, LoadPainting, null, ".skp"); UI.HSeparator(); if (UI.Button("Clear")) activePainting = new Painting(); UI.SameLine(); if (UI.Button("Quit")) SK.Quit(); UI.WindowEnd(); }
PaletteMenu.cs 指令碼
此檔案是控制繪製選項的功能表。 其主要是由內建的 UI 元素組成,並說明如何使用 StereoKit 的版面配置和互動工具來建立自己的項目。
欄位:這些欄位會追蹤 PaletteMenu 的狀態、視窗的姿勢、繪製筆觸的色彩和大小,以及用來驅動 UI 色彩滑桿的色調飽和度值變數。
這裡的模型是用於 UI 的資產。 其中一個是用於裝飾的瓶子,並且將顯示作用中色彩,而另一個是「墨水飛濺」,我們會將其轉換成可按下的按鈕,讓使用者用來挑選色彩。
Model _model = Model.FromFile("InkBottle.glb"); Model _swatchModel = Model.FromFile("InkSplat.glb"); Pose _pose = new Pose(-.4f, 0, -0.4f, Quat.LookDir(1,0,1)); Color _color = Color.White; float _size = 2 * U.cm; float _hue = 0; float _saturation = 0; float _value = 1;
視窗:在 Step 函式中,我們會啟動一個視窗,以包含所有控制項,並從墨水瓶開始,以強調此區域的用途! 這裡提供的大小會在 X 軸上自動填滿,這會將模型置中,而我們會將其高度設為兩行。
UI.WindowBegin("Ink", ref _pose); UI.Model(_model, V.XY(0, UI.LineHeight*2));
色彩樣本:在這裡,我們將顯示預先挑選的色彩樣本清單。 這些色彩樣本是自訂按鈕,因此請稍後查看 SwatchColor 方法。
SwatchColor("White", _hue, 0, 1); UI.SameLine(); SwatchColor("Gray", _hue, 0, .6f); UI.SameLine(); SwatchColor("Blk", _hue, 0, SK.System.displayType == Display.Additive ? 0.25f : 0); UI.SameLine(); SwatchColor("Green", .33f, .9f, 1); UI.SameLine(); SwatchColor("Ylw", .14f, .9f, 1); UI.SameLine(); SwatchColor("Red", 0, .9f, 1); UI.Space(UI.LineHeight*0.5f);
滑桿:樣本永遠嫌不夠! 因此,這裡有一些滑桿可讓使用者以手動對其色彩執行 HSV。 我們會從固定大小的標籤開始,並在同一行新增固定大小的滑桿。 修正此處的大小可協助其在資料行中對齊。
UI.Label("Hue", V.XY(8*U.cm, UI.LineHeight)); UI.SameLine(); if (UI.HSlider("Hue", ref _hue, 0, 1, 0, 22*U.cm, UIConfirm.Pinch)) SetColor(_hue, _saturation, _value); UI.Label("Saturation", V.XY(8*U.cm, UI.LineHeight)); UI.SameLine(); if (UI.HSlider("Saturation", ref _saturation, 0, 1, 0, 22*U.cm, UIConfirm.Pinch)) SetColor(_hue, _saturation, _value); UI.Label("Value", V.XY(8*U.cm, UI.LineHeight)); UI.SameLine(); if (UI.HSlider("Value", ref _value, 0, 1, 0, 22*U.cm, UIConfirm.Pinch)) SetColor(_hue, _saturation, _value); UI.HSeparator();
大小樣本:現在用於筆刷大小! 我們會有一些大小樣本。 首先,這些樣本與色彩樣本相似,不同之處在於其們可控制樣本的大小。
我們也會顯示筆刷筆觸大小的預覽。 我們會保留一個方塊,其可以保存筆刷筆觸的大小上限,並使用縮放至筆刷大小的 unlit 立方體來預覽筆觸。
UI.LayoutReserve(V.XY(8*U.cm,0)); UI.SameLine(); if (SwatchSize("Small", 1*U.cm)) _size = 1 * U.cm; UI.SameLine(); if (SwatchSize("Med", 2*U.cm)) _size = 2 * U.cm; UI.SameLine(); if (SwatchSize("Lrg", 3*U.cm)) _size = 3 * U.cm; UI.SameLine(); if (SwatchSize("Xtra", 4*U.cm)) _size = 4 * U.cm; UI.Label("Size", V.XY(8 * U.cm, UI.LineHeight)); UI.SameLine(); UI.HSlider("Size", ref _size, 0.001f, 0.05f, 0, 22 * U.cm, UIConfirm.Pinch); Bounds linePreview = UI.LayoutReserve(V.XY(0, 0.05f)); linePreview.dimensions.y = _size; linePreview.dimensions.z = U.cm; Mesh.Cube.Draw(Material.Unlit, Matrix.TS(linePreview.center, linePreview.dimensions), _color);
上色:這會更新我們正在繪製的作用中色彩。 若要以視覺方式表示作用中色彩,我們也會變更墨水瓶的材質和 StereoKit 手部材質的色彩。
void SetColor(float hue, float saturation, float value) { _hue = hue; _saturation = saturation; _value = value; _color = Color.HSV(hue,saturation,value); _model.RootNode.Material[MatParamName.ColorTint] = _color; Default.MaterialHand[MatParamName.ColorTint] = _color; }
Painting.cs 指令碼
此類別會擷取手指繪製的整個概念! 其會接受手部輸入,並將其轉換成三維線條。 其也負責載入和儲存繪製檔案。
建立控點的子系:我們會將整個繪製轉換成控點的子系,讓我們可以在作業時將其四處移動! 控點和視窗會將轉換推送至階層堆疊,使得所有後續的位置會與該轉換相對。
public void Step(Handed handed, Color color, float thickness) { UI.HandleBegin("PaintingRoot", ref _pose, new Bounds(Vec3.One * 5 * U.cm), true); UpdateInput(handed, color, thickness); Draw(); UI.HandleEnd(); }
復原堆疊:針對復原/重做,我們會使用過度簡化的復原堆疊。 當我們復原繪製筆觸時,我們會從繪製中移除最後一個筆觸,並將其新增至復原堆疊的頂端。 同樣地,若要重做繪製筆觸,我們會移除復原堆疊上頂端的筆觸,並將其新增回繪製。 這不是強固的實作,但在簡單的互動中稍微可行。
public void Undo() { if (_strokeList.Count == 0) return; _undoStack.Push(_strokeList.Last()); _strokeList.RemoveAt(_strokeList.Count-1); } public void Redo() { if (_undoStack.Count == 0) return; _strokeList.Add(_undoStack.Pop()); }
將指尖座標轉譯成階層本機座標:在這裡,我們會取得手部的指尖、將其轉換為本機空間,並將其平滑,以減少任何不規則的雜訊! 當然,手部的位置資料一律會在世界空間中提供。 不過,由於我們在使用階層堆疊的控點內,我們必須先將指尖的座標轉換成階層的本機座標,然後再使用。
Hand hand = Input.Hand(handed); Vec3 fingertip = hand[FingerId.Index, JointId.Tip].position; fingertip = Hierarchy.ToLocal(fingertip); fingertip = Vec3.Lerp(_prevFingertip, fingertip, 0.3f);
筆觸手勢:在這裡,我們會管理繪製筆觸手勢本身。 如果使用者剛進行捏合動作,且未與 UI 互動,我們就會開始繪製手勢。 我們會在手勢作用中時更新筆觸,然後在使用者停止捏合時結束手勢。
if (hand.IsJustPinched && !UI.IsInteracting(handed)) { BeginStroke(fingertip, color, thickness); _isDrawing = true; } if (_isDrawing) UpdateStroke(fingertip, color, thickness); if (_isDrawing && hand.IsJustUnpinched) { EndStroke(); _isDrawing = false; }
建立繪製筆觸:我們會將兩個初始點新增至筆觸點清單,以開始筆觸手勢! 第一個會從提供的點開始,而第二個則會持續更新為目前的指尖位置。 我們會在到達與最後一個點之間的特定距離之後加入新的點,但以有距離的間隔加入點時,輕率地實作可能會導致彈出效果。 緊接在指尖後面的額外點,可以順利避免此「彈出」成品!
在 UpdateStroke 期間,我們會從計算離最後一個點的目前距離開始,以及手部移動的速度。 然後,我們將使用速度做為筆觸的粗細,在目前的位置建立點!
如果我們距離最後一個點超過 1 公分,就會加入新的點! 這很簡單,但足夠有效。 較高品質的實作可能會使用誤差/變更函式,其也會是角度變更的因素。 否則,筆觸中的最後一個點應該一律位於目前的指尖位置,以避免在加入新的點時「彈出」。
然後,在手勢結束時,我們會將作用中的筆觸新增至繪製,並將其清除,以進行下一個筆觸!
void BeginStroke(Vec3 at, Color32 color, float thickness) { _activeStroke.Add(new LinePoint(at, color, thickness)); _activeStroke.Add(new LinePoint(at, color, thickness)); _prevFingertip = at; } void UpdateStroke(Vec3 at, Color32 color, float thickness) { Vec3 prevLinePoint = _activeStroke[_activeStroke.Count - 2].pt; float dist = Vec3.Distance(prevLinePoint, at); float speed = Vec3.Distance(at, _prevFingertip) / Time.Elapsedf; LinePoint here = new LinePoint(at, color, Math.Max(1 - speed * 0.5f, 0.1f) * thickness); if (dist > 1 * U.cm) _activeStroke.Add(here); else _activeStroke[_activeStroke.Count - 1] = here; } void EndStroke() { _strokeList.Add(_activeStroke.ToArray()); _activeStroke.Clear(); }
載入繪製檔案:我們將對我們的繪製資料使用非常簡單的文字檔案格式。 每一行在此檔案中都是繪製筆觸,而逗號會分隔該筆觸上的每個點。 此外,點內的每個項目都是以空格分隔,這會在 LinePointFromString 中處理。
針對某些內容,以下是一個檔案的簡單範例,其中包含兩個筆觸繪製,第一個筆觸中有兩個點 (白色) 和第二個筆觸中有三個點 (紅色):
0 0 0 255 255 255 0.01, 0.1 0 0 255 255 255 0.01 0 0.1 0 255 0 0 0.02, 0.1 0.1 0 255 0 0 0.02, 0.2 0 0 255 0 0 0.02
public static Painting FromFile(string fileData) { Painting result = new Painting(); result._strokeList = fileData .Split('\n') .Select( textLine => textLine .Split(',') .Select(textPoint => LinePointFromString(textPoint)) .ToArray()) .ToList(); return result; } static LinePoint LinePointFromString(string point) { string[] values = point.Split(' '); LinePoint result = new LinePoint(); result.pt .x = float.Parse(values[0]); result.pt .y = float.Parse(values[1]); result.pt .z = float.Parse(values[2]); result.color.r = byte .Parse(values[3]); result.color.g = byte .Parse(values[4]); result.color.b = byte .Parse(values[5]); result.color.a = 255; result.thickness = float.Parse(values[6]); return result; }
儲存繪製檔案:將此繪製轉換成檔案,甚至比從一個檔案載入更簡單! 首先,我們有 LinePointToString,可以用於每個點,然後我們必須結合所有資料。 然後,每個繪製筆觸行會使用 '\n' 繼續,而該筆觸上的每個點會以逗號分隔。
public string ToFileData() { return string.Join('\n', _strokeList .Select(line => string.Join(',', line .Select(point => LinePointToString(point))))); } static string LinePointToString(LinePoint point) { return string.Format("{0} {1} {2} {3} {4} {5} {6}", point.pt .x, point.pt .y, point.pt .z, point.color.r, point.color.g, point.color.b, point.thickness); }