了解 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) 物件或需要更新每個畫面格系統的良好方法。

    Screenshot of radial menu.”

    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 將載入平台原生的檔案選擇器視窗或內建的後援檔案選擇器。 其會使用產生的檔案名稱來呼叫您提供的回撥函式。 這會讓您更易於使用檔案系統中的內容!

    Screenshot of On the toggle button to enable “Developer Mode.”

    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 軸上自動填滿,這會將模型置中,而我們會將其高度設為兩行。

    Screenshot of Ink tools window.”

    UI.WindowBegin("Ink", ref _pose);
    
    UI.Model(_model, V.XY(0, UI.LineHeight*2));
    
  • 色彩樣本:在這裡,我們將顯示預先挑選的色彩樣本清單。 這些色彩樣本是自訂按鈕,因此請稍後查看 SwatchColor 方法。

    Screenshot of ink colors in ink tools window.”

    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。 我們會從固定大小的標籤開始,並在同一行新增固定大小的滑桿。 修正此處的大小可協助其在資料行中對齊。

    Screenshot of slider in ink tools window.”

    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 立方體來預覽筆觸。

    Screenshot of brush size slider.”

    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 指令碼

此類別會擷取手指繪製的整個概念! 其會接受手部輸入,並將其轉換成三維線條。 其也負責載入和儲存繪製檔案。

  • 建立控點的子系:我們會將整個繪製轉換成控點的子系,讓我們可以在作業時將其四處移動! 控點和視窗會將轉換推送至階層堆疊,使得所有後續的位置會與該轉換相對。

    Screenshot of handle.”

    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());
    }
    
  • 將指尖座標轉譯成階層本機座標:在這裡,我們會取得手部的指尖、將其轉換為本機空間,並將其平滑,以減少任何不規則的雜訊! 當然,手部的位置資料一律會在世界空間中提供。 不過,由於我們在使用階層堆疊的控點內,我們必須先將指尖的座標轉換成階層的本機座標,然後再使用。

    Screenshot of hand.”

    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);
    }