Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails以外の開発一般

Lorca: ノードで様々な値とアバターの髪色を連動させたい: 実践編

注意!
この記事は前回の記事を読んでいることを前提に作られています。
まだ読んでいない方は下のリンクからどうぞ。

Lorca:ノードで様々な値とアバターの髪色を連動させたい:前提知識編

🔗目次

  1. まえがき
  2. 完成品
  3. Unity側での準備
  4. 実装
  5. あとがき

🔗 まえがき

皆さんどうもお久しぶりです、株式会社ECN所属のFuseです。
前回はノードベースエディタを作れるライブラリ Flume とGoでモダンなHTML5デスクトップアプリを作れるライブラリ Lorca のキャッチアップを行いました。
今回はそれらとOSCを使って様々な情報から色情報を計算しアバターに適用するシステムを作ります。
↑目次に戻る

🔗 完成品

このようなUIを使って、

このように髪色を変えられます。


↑目次に戻る

🔗 Unity側での準備

というわけでまずはシステム用にアバターをセッティングしていきます。
今回も使用するアバターはこちらのハオランというアバター。なんと法人利用可能な無料アバターです。

【オリジナル3Dモデル】-ハオラン-HAOLAN

いつものように導入したら、
Assets/HAOLAN/3.0_Animation/EXmenu/HAOLANEXParameters.assetOSCHairDye_ROSCHairDye_GOSCHairDye_Bをfloat型で追加します。

その後追加したパラメータに応じ髪の色が変わるようにアニメーションを組みます。

動作確認をし、アップロードしたらUnity側の作業は終了です。

↑目次に戻る

🔗実装

それではさっそく実装していきましょう。

LorcaアプリからOSCを送れるようにする

まずはノード関係なくOSC信号をLorcaアプリから送り、ハオラン君の髪色をいじれるようにしてみます。
最初に色データを扱うためのstructを作ります。

types/color_json.go

package types

type ColorJSON struct {
    R float32 `json:"r"`
    G float32 `json:"g"`
    B float32 `json:"b"`
}

そしたらmain.goのmain関数にクライアント生成処理を追加して…

func main() {
    client = osc.NewClient("localhost", 9000)//クライアント生成
    go runGin()

    err := runLorca()
    if err != nil {
        log.Fatal(err)
    }
}

必要なライブラリをインポートして…

go get github.com/hypebeast/go-osc

OSC信号を送る関数を追加したら、

func sendOSC(data string) {//引数は1つまでなので注意
    var c types.ColorJSON
    err := json.Unmarshal([]byte(data), &c) //json文字列から構造体へ
    if err != nil {
        log.Println(err)//エラーが発生したらログ出して中断
        return
    }
    //R,G,Bそれぞれを送信
    msg := osc.NewMessage("/avatar/parameters/OSCHairDye_R")
    msg.Append(float32(c.R)/255)
    client.Send(msg)
    msg = osc.NewMessage("/avatar/parameters/OSCHairDye_G")
    msg.Append(float32(c.G)/255)
    client.Send(msg)
    msg = osc.NewMessage("/avatar/parameters/OSCHairDye_B")
    msg.Append(float32(c.B)/255)
    client.Send(msg)
}

lorcaの機能でフロントエンド(以下FE)から呼び出せるようにします。

// lorcaを起動する
func runLorca() error {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()
    go sync(ctx)
    ui, err := lorca.New("http://localhost:8080", "", 960, 720, "--remote-allow-origins=*") //--remote-allow-origins=*がないと動かないので注意
    ui.Bind("sendOSC", sendOSC)                                                             //バインドする、これでFEから呼び出せるようになる。
    if err != nil {
        return err
    }
    <-ui.Done() //ブラウザの終了を待つ
    return nil
}

ここからは試しにFEから呼び出してみます。
まずは色情報用の型を作り…

lorca_osc_frontend/src/type/Scheme.tsx

export type ColorData = {
    r: number
    g: number
    b: number
}

操作用のコンポーネントを作ります。

lorca_osc_frontend/src/Component/ColorSlider.tsx

import { ColorData } from "../type/Scheme"

interface Props {
    setColor: (c: ColorData) => void
    color: ColorData
}
const ColorSlider = (props: Props) => {
    return (
        <div>
            <div>
                <input type="range" name="red" min="0" max="255" value={props.color.r} onChange={(e) => { props.setColor({ ...props.color, r: Number.parseFloat(e.target.value) }) }} />
                <label htmlFor="red">R</label>
            </div>
            <div>
                <input type="range" name="green" min="0" max="255" value={props.color.g} onChange={(e) => { props.setColor({ ...props.color, g: Number.parseFloat(e.target.value) }) }} />
                <label htmlFor="green">G</label>
            </div>
            <div>
                <input type="range" name="blue" min="0" max="255" value={props.color.b} onChange={(e) => { props.setColor({ ...props.color, b: Number.parseFloat(e.target.value) }) }} />
                <label htmlFor="blue">B</label>
            </div>
        </div>
    )
}
export default ColorSlider

FE単体でもエラーにならないよう、関数をモック化しておきます。

lorca_osc_frontend/src/func/GoFuncMock.ts

const goFuncMock = (s: Function) => {
    if (s == undefined) {
        return () => {
            console.log('invoke go func mock...');
        };
    } else {
        return s;
    }
};
export default goFuncMock

実際に設置して、スライダーに応じステートを変更、ステートが変わったらsendOSCを呼び出すようにします。

lorca_osc_frontend/src/App.tsx

import { NodeEditor, NodeMap, useRootEngine } from "flume";
import { useCallback, useEffect, useState } from "react";
import config from "./const/config";
import engine from "./const/engine";
import ColorSlider from "./Component/ColorSlider";
import goFuncMock from "./func/GoFuncMock";
import { ColorData } from "./type/Scheme";

const App = () => {
  const [nodes, setNodes] = useState<NodeMap>({})
  const [color, setColor] = useState<ColorData>({ r: 0, g: 0, b: 0 })


  //@ts-ignore
  const sendOSC = goFuncMock(window.sendOSC);
  useEffect(() => {
    sendOSC(JSON.stringify(color))
  }, [color])


  useCallback((nodes: NodeMap) => {
    // 無限ループ対策として必ずメモ化する必要がある
    setNodes(nodes)
  }, [])
  const e = useRootEngine(nodes, engine)//先ほど作ったエンジンを使う

  return (
    <div style={{ width: 800, height: 600 }}>
      <ColorSlider
        color={color}
        setColor={setColor}
      />
      <span>計算結果:{e?.result ?? <br />}</span>
      <NodeEditor
        {...config}
        onChange={setNodes}
        defaultNodes={[
          {
            type: "result",
            x: 190,
            y: -150
          }
        ]}
      />
    </div>
  )
}
export default App

これでスライダーで髪色を操作できるようになります。
実際に動かしてみると、このようにスライダーに連動して髪色が変わるようになりました。

色に関する型を追加する

ここからは色に関する型とUIを追加していきます。
まずは先ほどのColorData型を一般的な色に関するコンポーネントで使えるように#RRGGBB形式と相互変換する関数を作ります。

lorca_osc_frontend/src/func/ColorConvert.ts

import { ColorData } from "../type/Scheme";

export const hexToColor = (hex: string) => {
    const red = parseInt(hex.substring(1, 3), 16);
    const green = parseInt(hex.substring(3, 5), 16);
    const blue = parseInt(hex.substring(5, 7), 16);
    return { r: red, g: green, b: blue }
}
export const colorToHex = (color: ColorData) => {
    const hex = (color.b + color.g * 256 + color.r * 256 * 256).toString(16)
    return `#${hex}`//#の付け忘れに注意
}

そしたら次にUIを作成。

lorca_osc_frontend/src/Component/ColorControlComponent.tsx

import { colorToHex, hexToColor } from "../func/ColorConvert"
import { ColorData } from "../type/Scheme"

interface Props {
    value: ColorData
    onChange: (v: ColorData) => void
}
const ColorControlComponent = (props: Props) => {
    return (
        <input
            type="color"
            value={colorToHex(props.value)}
            onChange={(e) => props.onChange(hexToColor(e.target.value))}
        />
    )
}
export default ColorControlComponent

最後にconfigに登録すれば色情報を型として使えるようになります。
拡張子はtsxに変更しておきましょう。

lorca_osc_frontend/src/const/config.tsx(一部抜粋)

    .addPortType({
        type: "color",
        name: "color",
        label: "カラー",
        color: Colors.yellow,
        controls: [
            Controls.custom({
                name: "color",
                label: "カラー",
                defaultValue: { r: 0, g: 0, b: 0 },
                render: (data, onChange) => {
                    return (
                        <ColorControlComponent
                            value={data}
                            onChange={onChange}
                        />
                    )
                }
            })
        ]
    })

次に、値に応じて色を取り出すためにグラデーション型を用意します。
グラデーションについては色と位置の組の配列で管理します。

lorca_osc_frontend/src/type/Scheme.tsx

export type ColorData = {
    r: number
    g: number
    b: number
}
export type GradientItem = {
    color: ColorData
    position: number
}

export type Gradient = GradientItem[]

まずはグラデーションと位置から色を取り出す関数を作成。

lorca_osc_frontend/src/func/GradientFunc.ts

import { ColorData, Gradient } from "../type/Scheme";
import { remap } from "./MathFunc";

export const colorLerp = (color1: ColorData, color2: ColorData, ratio: number) => {
    const r1 = color1.r
    const g1 = color1.g
    const b1 = color1.b
    const r2 = color2.r
    const g2 = color2.g
    const b2 = color2.b
    const r = Math.round(r1 + (r2 - r1) * ratio);
    const g = Math.round(g1 + (g2 - g1) * ratio);
    const b = Math.round(b1 + (b2 - b1) * ratio);
    return { r, g, b }
}
export const getColorFromGradient = (gradient: Gradient, ratio: number) => {
    const sorted = gradient.sort((a, b) => a.position - b.position)
    let result = sorted[sorted.length - 1].color
    sorted.forEach((elem, index) => {
        if (ratio < elem.position) {
            if (index == 0) {
                result = sorted[0].color
                return
            } else {
                const prev = sorted[index - 1]
                result = colorLerp(prev.color, elem.color, remap(ratio, prev.position, elem.position))
                return
            }
        }
    })
    return result
}

その後、カラーピッカー用のライブラリを導入したら

yarn add react-best-gradient-color-picker

UIを作っていきます。

lorca_osc_frontend/src/Component/GradientControlComponent.tsx

import ColorPicker, { useColorPicker } from "react-best-gradient-color-picker";
import { Gradient } from "../type/Scheme"
import { useState } from "react";

interface Props {
    value: Gradient
    onChange: (v: Gradient) => void
}
export const GradientControlComponent = (props: Props) => {
    const [color, setColor] = useState(`linear-gradient(90deg, ${props.value.map(e => `rgb(${e.color.r},${e.color.g},${e.color.b}) ${e.position * 100}%`).join(",")})`);
    const [open, setOpen] = useState<boolean>(false)
    const { getGradientObject } = useColorPicker(color, setColor)
    const obj = getGradientObject()
    const onChangeHandler = (val: string) => {
        if (color == val) return
        setColor(val)
        props.onChange(
            obj?.colors.map((e: any) => {
                const col = e.value
                const rgba = col.replace(/^rgba?\(|\s+|\)$/g, '').split(',');
                return {
                    color: {
                        r: Number.parseInt(rgba[0]),
                        g: Number.parseInt(rgba[1]),
                        b: Number.parseInt(rgba[2]),
                    },
                    position: e.left / 100
                }
            })
        )
    }


    return (
        <>
            <div style={{ background: color, width: 135, height: 15, border: "1px #000 solid" }} onClick={() => setOpen(!open)} />

            <div style={{ display: open ? "block" : "none", position: "absolute", background: "#333", padding: 8 }} onTouchStart={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} onMouseMove={(e) => e.stopPropagation()} onDragStart={(e) => e.stopPropagation()}>
                < ColorPicker
                    width={135}
                    height={135}
                    hidePresets
                    hideOpacity
                    hideGradientType
                    hideGradientAngle
                    value={color}
                    onChange={onChangeHandler}
                    hideColorTypeBtns={true}
                />
            </div>
        </>
    )
}

最後に同じようにconfigに登録します。

lorca_osc_frontend/src/const/config.tsx(一部抜粋)

    .addPortType({
        type: "gradient",
        name: "gradient",
        label: "グラデーション",
        color: Colors.purple,
        controls: [
            Controls.custom({
                name: "gradient",
                label: "グラデーション",
                defaultValue: [{ color: { r: 0, g: 0, b: 0 }, position: 0 }, { color: { r: 255, g: 255, b: 255 }, position: 1 }],
                render: (data, onChange) => {
                    return (
                        <GradientControlComponent
                            value={data}
                            onChange={onChange}
                        />
                    )
                }
            })
        ]
    })

これでグラデーションと色をノードで扱えるようになりました。

ノードを定義する

次にノードをいくつか追加で定義します。

lorca_osc_frontend/src/const/config.tsx(一部抜粋)

    .addNodeType({
        type: "gradient",
        label: "グラデーション",
        description: "数値とグラデーションから色を取得する。",
        initialWidth: 155,
        inputs: ports => [
            ports.gradient({ name: "gradient", label: "グラデーション" }),
            ports.number({ name: "position", label: "位置" }),
        ],
        outputs: ports => [
            ports.color({ name: "result", label: "結果" })
        ]
    })
    .addNodeType({
        type: "remap",
        label: "リマップ",
        description: "与えられた数値を0~1にリマップする。",
        inputs: ports => [
            ports.number({ name: "input", label: "入力" }),
            ports.number({ name: "min", label: "最低値" }),
            ports.number({ name: "max", label: "最大値" }),
        ],
        outputs: ports => [
            ports.number({ name: "result", label: "結果" })
        ]
    })
    .addNodeType({
        type: "speed",
        label: "速度",
        description: "アバターの移動速度を取得する。",
        outputs: ports => [
            ports.number({ name: "result", label: "結果" })
        ]
    })
    .addRootNodeType({
        type: "result",には一意の文字列を
        label: "結果",
        initialWidth: 170,
        inputs: ports => [
            ports.color({
                name: "result",
                label: "結果"
            }),
        ]
    })

基本的なやり方は前回と同じです。typeには一意の文字列を、labelには表示名を、入力と出力は配列で指定…
型は増えてもやることは変わりません。

作ったノードのロジックを作る

ここからは新たに作ったノードのロジックをengineに追加していきます。
さっき作った関数も使ってロジック面を完成させましょう。

lorca_osc_frontend/src/const/engine.ts

//@ts-nocheck
import { RootEngine, InputData, FlumeNode } from 'flume'
import config from './config'
import { getColorFromGradient } from '../func/GradientFunc'

const resolvePorts = (portType: string, data: InputData) => {
    switch (portType) {
        case 'string':
            return data.string
        case 'boolean':
            return data.boolean
        case 'number':
            return data.number
        case 'color':
            return data.color
        case 'gradient':
            return data.gradient
        default:
            return data
    }
}
const resolveNodes = (node: FlumeNode, inputValues: InputData) => {
    switch (node.type) {
        case 'number':
            return { number: inputValues.number }
        case 'add':
            return { result: inputValues.a + inputValues.b }
        case 'gradient':
            return { result: getColorFromGradient(inputValues.gradient, inputValues.position) }
        case 'remap':
            return { result: (inputValues.input - inputValues.min) / (inputValues.max - inputValues.min) }
        case 'speed':
            return { result: window.speed }
        default:
            return inputValues
    }
}
const engine = new RootEngine(config, resolvePorts, resolveNodes)

export default engine

様々な環境の値をノードに代入できるようにする

加工と出力はできたのであとは入力値を変化させることができれば完成です。
今回は試しにアバターの移動速度をOSCで受信し入力ノードに反映させてみます。
ノードは既に定義してあるので、window.speedに移動速度を反映出来れば完成するはずです。

main.go(一部抜粋)

~~~(中略)~~~
// OSCを受信できるようにする
func reciveOSC(ui lorca.UI) {
    addr := "localhost:9001"
    d := osc.NewStandardDispatcher()

    d.AddMsgHandler("/avatar/parameters/VelocityMagnitude", func(msg *osc.Message) {
        val := msg.Arguments[0]
        ui.Eval(fmt.Sprintf("window.speed=%f", val))
    })
    server := &osc.Server{
        Addr:       addr,
        Dispatcher: d,
    }
    server.ListenAndServe()
}
~~~(中略)~~~
// lorcaを起動する
func runLorca() error {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()
    go sync(ctx)
    ui, err := lorca.New("http://localhost:8080", "", 960, 720, "--remote-allow-origins=*") //--remote-allow-origins=*がないと動かないので注意
    ui.Bind("sendOSC", sendOSC)                                                             //バインドする、これでFEから呼び出せるようになる。
    go reciveOSC(ui)
    if err != nil {
        return err
    }
    <-ui.Done() //ブラウザの終了を待つ
    return nil
}

osc.NewStandardDispatcher()を使ってVRCからOSCメッセージを受信します。送られてきた値をwindowオブジェクト経由でFEに渡します。
最後に一定時間ごとにデータを更新するためのフックを作って…

lorca_osc_frontend/src/hooks/useOSCValue.ts

import { useEffect, useState } from "react";

interface OSCValue {
    speed: number
}

const useOSCValue = () => {
    //@ts-ignore
    const speed = window.speed
    const [value, setValue] = useState<OSCValue>({
        speed: speed ?? 0,
    });
    const [id, setId] = useState(0)

    const handle = () => {
        setValue({
            speed: speed ?? 0,
        });
    };

    useEffect(() => {
        setId(window.setInterval(() => {
            handle()
        }, 50))
        return () => {
            window.clearInterval(id)
        }
    }, []);

    return value;
};


export default useOSCValue;

lorca_osc_frontend/src/App.tsxに置けば…

import { NodeEditor, NodeMap, useRootEngine } from "flume";
import { useCallback, useEffect, useState } from "react";
import config from "./const/config";
import engine from "./const/engine";
import goFuncMock from "./func/GoFuncMock";
import { ColorData } from "./type/Scheme";
import useOSCValue from "./hooks/useOSCValue";

const App = () => {
  const [nodes, setNodes] = useState<NodeMap>({})
  const [color, setColor] = useState<ColorData>({ r: 0, g: 0, b: 0 })
  const val = useOSCValue()
  console.log(val)


  //@ts-ignore
  const sendOSC = goFuncMock(window.sendOSC);
  useEffect(() => {
    sendOSC(JSON.stringify(color))
  }, [color])


  useCallback((nodes: NodeMap) => {
    // 無限ループ対策として必ずメモ化する必要がある
    setNodes(nodes)
  }, [])
  const e = useRootEngine(nodes, engine)//先ほど作ったエンジンを使う
  console.log(JSON.stringify(e))
  useEffect(() => {
    if (e.result as ColorData) {
      setColor(e.result)
    }
  }, [JSON.stringify(e)])

  return (
    <div style={{ width: 800, height: 600 }}>
      <NodeEditor
        {...config}
        disableZoom
        onChange={setNodes}
        defaultNodes={[
          {
            type: "result",
            x: 190,
            y: -150
          }
        ]}
      />
    </div>
  )
}
export default App

このようにUIが表示され…

このようにノードをつなげば…

このように髪色が速度に応じで変わるはずです。
↑目次に戻る

🔗あとがき

いかがでしたか?最後のほうはかなり駆け足になってしまいましたが、大体のやり方は伝わったはずです。アイデアが思いつき次第、くだらないことから普通に勉強になることまでいろいろやっていこうと思いますので、今後ともよろしくお願いいたします。
↑目次に戻る


株式会社ECNはPHP、JavaScriptを中心にお客様のご要望に合わせたwebサービス、システム開発を承っております。
ビジネスの最初から最後までサポートを行い
お客様のイメージに合わせたWebサービス、システム開発、デザインを行います。

関連記事

Lorca:ノードで様々な値とアバターの髪色を連動させたい:前提知識編


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。