🔗目次
🔗 まえがき
皆さんどうもお久しぶりです、株式会社ECN所属のFuseです。
前回はノードベースエディタを作れるライブラリ Flume とGoでモダンなHTML5デスクトップアプリを作れるライブラリ Lorca のキャッチアップを行いました。
今回はそれらとOSCを使って様々な情報から色情報を計算しアバターに適用するシステムを作ります。
↑目次に戻る
🔗 完成品
このようなUIを使って、
このように髪色を変えられます。
↑目次に戻る
🔗 Unity側での準備
というわけでまずはシステム用にアバターをセッティングしていきます。
今回も使用するアバターはこちらのハオランというアバター。なんと法人利用可能な無料アバターです。
いつものように導入したら、
Assets/HAOLAN/3.0_Animation/EXmenu/HAOLANEXParameters.asset
にOSCHairDye_R
とOSCHairDye_G
とOSCHairDye_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サービス、システム開発、デザインを行います。
この記事は前回の記事を読んでいることを前提に作られています。
まだ読んでいない方は下のリンクからどうぞ。