Hyun1008.log

개발과정

#개발과정 #VanillaJS

포트폴리오 사이트를 만드는 중 프로젝트를 보여줄 때는 가로스크롤을 하는 게 좋을 것 같아서 만들어 보았습니다.

우선 이렇게 구조를 짰습니다.

        <div id="content1">
            <div id="content-scroll">
                <div class="width-fix">
                    <h1>Items.</h1>
                    <div class="overflow-width">
                    </div>
                </div>
            </div>
        </div>

CSS에서는 .overflow-width 에만 position: relative 를 적용해 주었습니다. 그리고 자바스크립트에서는 다음과 같은 코드를 적용했어요.

    document.querySelector('.overflow-width').style.left=`0`;
    // 이게 없으면 후에 style.left 를 불러오지 못합니다.
    document.querySelector('#content-scroll').addEventListener('wheel',function(e){
        if(e.deltaY > 0){
        // 스크롤을 내리고 있으면,
            if (parseInt(document.querySelector('.overflow-width').style.left) > -100) {
                // 가로스크롤이 충분히 진행되지 않았으면(=스크롤이 필요하면)
                e.preventDefault();
                e.stopPropagation();
                // 기존 세로 스크롤을 막습니다.
                document.querySelector('.overflow-width').style.top=`0%`;
                document.querySelector('.overflow-width').style.left = `${parseInt(document.querySelector('.overflow-width').style.left) - 1}%`;
                // 그리고 스크롤 양에 따라 요소의 위치를 옮겨줍니다.
            }
        } else if (e.deltaY < 0) {
        // 스크롤을 올리고 있으면,
            if (parseInt(document.querySelector('.overflow-width').style.left) < 0) {
                // 가로스크롤이 충분히 돌아오지 않았으면(=스크롤이 필요하면)
                // 아래 내용은 동일합니다.
                e.preventDefault();
                e.stopPropagation();
                document.querySelector('.overflow-width').style.top=`0%`;
                document.querySelector('.overflow-width').style.left = `${parseInt(document.querySelector('.overflow-width').style.left) + 1}%`;
            }
        }
    });

남의코드 복붙해야지 히히 하고 들어갔다가 저한테 맞는 방법, 제가 당장 필요한 방법은 따로 있다는 사실을 깨달았습니다.

물론 제가 당장 필요한 방법을 구현해 놓은 포스트가 있었다면 완전 럭키비키겠지만요.

그래도 최대한 제가 만들어보고 남의 손을 빌리는 게 맞는 것 같습니다.

#개발과정 #NodeJS #블로그

Github Pages에서 Nuxt 배포하기

초코스프레드는 별도의 서버 호스팅을 받을 필요 없이 무료로 위키를 올릴 수 있도록, 저예산/소규모 위키를 목표로 하고 있는 프로젝트입니다. (사실상 스프레드시트 파서입니다.)

기존 초코스프레드는 정적 html 페이지를 그대로 Github pages로 deploy했었습니다.

그런데 이렇게 페이지를 만들면 API 키가 전부 노출이 되어 안전하지도 않고, 쿼리스트링 형태로 문서 제목이 붙어 어딘가 너저분한 느낌이 들고 불편했습니다.

그래서 이 두 가지를 개선하고자 Nuxt를 사용하게 된 것입니다.

Nuxt에서는 pages/ 폴더 아래에 vue파일을 집어넣으면 그게 그대로 라우팅이 됩니다. 즉 pages/index.vue/ 로, pages/intro.vue/intro/ 로 라우팅이 되는 형태입니다.

그리고 이게 Github 액션을 사용한 빌드에서도 적용됩니다.

그런데 동적 라우팅, 예를 들어서

jyhyun1008.github.io/posts/123

같은 경우는 어떻게 할까요?

pages/posts 폴더 아래에 _id.vue 와 같이 밑줄+파라미터 이름 을 넣으면 됩니다.

그리고 당연하게도, 정적 페이지 빌드를 하면 이 페이지는 빠지게 됩니다(...)

위키는 대문 페이지를 제외하고는 거의 대부분의 페이지가 동적 페이지로 되어 있는데, 만들었던 위키 페이지를 정적 페이지로 generate 하니, 대문을 제외한 모든 페이지가 날아가 있었습니다.

그래서 이 문제를 해결하고자 했는데, 공식 문서 를 찾아보는 것으로 의외로 간단하게 해결이 됐습니다.

아래는 공식 문서에서 제시하는 예시입니다.

//nuxt.config.js
import axios from 'axios'

export default {
  generate: {
    routes(callback) {
      axios
        .get('https://my-api/users')
        .then(res => {
          const routes = res.data.map(user => {
            return '/users/' + user.id
          })
          callback(null, routes)
        })
        .catch(callback)
    }
  }
}

generate → routes 아래에 fetch를 통해 어떤 페이지를 라우팅할 지 배열을 불러와서 저장하는 맥락입니다.

그러니 사실은...

동적 라우팅처럼 보이는 것에 가까울 것 같습니다. 유저가 접속할 수 있는 모든 경우의 수의 페이지만큼 정적 파일을 생성하고, 이걸 deploy하는 것이기 때문입니다.

저는 axios는 사용 안하고... 그냥 async + await fetch 로 했습니다. 아래 코드에서 secret이라든지 저에게 맞추어진 id같은 부분은 줄을 그었습니다.

구글 아이디 인증을 하고, 스프레드 시트를 불러와서 시트의 제목마다 라우터를 만들어주는 구조입니다.

//nuxt.config.js

  generate: {
    async routes(callback) {
      var secretKey = process.env.PRIVATE_KEY.replace(/\\n/gm, '\n')

      const token = jwt.sign(
          { "iss": "-------------", "scope": "https://www.googleapis.com/auth/spreadsheets", "aud": "https://oauth2.googleapis.com/token" },
          secretKey,
          { algorithm: 'RS256', expiresIn: "1h", keyid: "------------" }
      );

      const googleAuthUrl = 'https://oauth2.googleapis.com/token'
      const googleAuthParam = {
              method: 'POST',
              headers: {
                  'content-type': "application/x-www-form-urlencoded",
              },
              body: querystring.stringify({
                  grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
                  assertion: token
              })
          }

      var authData = await fetch(googleAuthUrl, googleAuthParam)
      var authRes = await authData.json()

      const googleSheetUrl = `https://sheets.googleapis.com/v4/spreadsheets/------------/`
      const googleSheetParam = {
          method: 'GET',
          headers: {
              "content-type": "application/json",
              Authorization: "Bearer " + authRes.access_token,
          },
      }
      var sheetData3 = await fetch(googleSheetUrl, googleSheetParam)
      var sheetRes3 = await sheetData3.json()
      var wikiListArray = sheetRes3.sheets
      var wikiList = []
      for (let i=0; i<wikiListArray.length; i++) {
          wikiList.push('/'+encodeURIComponent(wikiListArray[i].properties.title))
      }

      const routes = wikiList
      callback(null, routes)
    }
  }

#개발과정 #NodeJS #블로그

제가 쓰는 Nuxt 버전은 다음과 같습니다.

    "nuxt": "^2.15.8",
    "vue": "^2.7.10",

원래 평소와 똑같이 marked 를 임포트해서 처리하려고 했는데... 계속 계속 오류가 뜨길래 Nuxt에서 마크다운을 렌더링하는 기본적인 방법이 없나 찾아보았습니다.

결국 끝장을 보긴 했어요.

일단 @nuxtjs/markdownit 모듈을 깔아줍니다.

npm i @nuxtjs/markdownit

그다음 nuxt.config.js에서 모듈 부분을 다음과 같이 바꿉니다.

//nuxt.config.js
  // Modules: https://go.nuxtjs.dev/config-modules
  modules: ['@nuxtjs/markdownit'],
  markdownit: {
    runtime: true // Support `$md()`
  },

마크다운 렌더를 원하는 부분에 $md.render() 를 붙이면 끝.

// index.vue
<template>
    <div v-html="$md.render(wikiBody1)"></div>
</template>

다만 marked.js보다는 별로라고 생각합니다. 아주 기본적인 마크다운 렌더는 지원하지만, 체크리스트 같은 게 제대로 렌더되지 않았다는 점을 고려해 보면, <sub> 등의 구문도, 접었다 펼치는 것도 지원하지 않을 거라고 생각해요.

이 부분은 어차피 replace 같은 걸로 따로 구현할 수 있는데, 따로 구현할지 아니면 그냥 둘지는 고민입니다.

#개발과정 #NodeJS #블로그

초코스프레드 위키를 Nuxt.js로 옮기게 되었습니다.

왜 Next.js가 아니라 Nuxt.js 였냐 하면....별 이유는 없고 그냥 깃허브 액션에 Next가 있다는 것을 늦게 보았습니다 (...)

하여튼, Nuxt.js는 뷰 기반이니만큼 뷰 기준으로 설명하겠습니다.

//index.vue
<template>
  <div>{{ wikiBody }}</div>
</template>

템플릿에는 가져온 스프레드시트의 컨텐츠를 담을 수 있게 준비했습니다.

//index.vue
<script>
const jwt = require('jsonwebtoken');
const querystring = require("querystring");

const secretKey = "(private_key)"

index.vue가 아니어도 상관없지만, nuxt 기준으로 pages 아래에 있는 뷰 파일에다가 작업을 해 주어야 합니다.

jsonwebtoken 이라는 모듈을 설치하고 불러옵시다. 참고로 이 jsonwebtoken의 경우, 9.0.0이 아니라 8.5.1 버전을 설치해야 합니다. 그렇지 않으면 오류가 납니다. querystring 모듈은 나중에 POST 요청을 보내기 위해 필요합니다.

secretKey 변수에 받아온 json 파일의 private key를 집어넣습니다. 일단 로컬에서 실행하기 때문에 그대로 넣어주었습니다. 이후 env 파일로 옮길 예정입니다. 이대로 커밋만 안 하면 됩니다(...)

참고로 이 json 파일은 구글 API에서 새 서비스 계정을 생성해서 받아왔습니다.

//index.vue
const token = jwt.sign(
    { "iss": "(client_email)", "scope": "https://www.googleapis.com/auth/spreadsheets", "aud": "https://oauth2.googleapis.com/token" },
    secretKey,
    { algorithm: 'RS256', expiresIn: "1h", keyid: "(private_key_id)" }
    );

jsonwebtoken 모듈을 사용해서 사인을 해봅니다. 간단합니다.

jwt.sign(payload, privateKey, options)

Google에서 요구하는 JWT는 헤더에 alg (RS256), kid가 포함되어야 하고, 페이로드에 iss, scope, aud, iat, exp가 포함되어 있어야 합니다.

이걸 일일이 변수로 만들어줘도 되지만, jwt 모듈에서는 그냥 option에 필요한 값만 넣어주면 알아서 사인을 해줍니다.

예를 들어 algalgorithm, kidkeyid 입니다. expexpiresIn 이에요. iat는 자동으로 넣어줍니다.

만들어진 JWT는 https://jwt.io/ 에서 검증할 수 있습니다만, 구글 JWT의 경우 아마 유효하지 않다고 나올 겁니다. 하지만 이게 맞습니다. 구글에서 예시로 던져준 JWT도 유효하지 않다고 나옵니다.

//index.vue
export default {
    async asyncData () {

        const googleAuthUrl = 'https://oauth2.googleapis.com/token'
        const googleAuthParam = {
                                    method: 'POST',
                                    headers: {
                                        'content-type': "application/x-www-form-urlencoded",
                                    },
                                    body: querystring.stringify({
                                        grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
                                        assertion: token
                                    })
                                }

        var authData = await fetch(googleAuthUrl, googleAuthParam)
        var authRes = await authData.json()

그 다음에 fetch를 하는 단계 입니다. nuxt이기 때문에 asyncData() 훅을 사용해서 데이터를 받아온 다음 템플릿에 붙일 수 있게 해 보았습니다.

fetch는 다른 사이트로의 fetch들과 똑같이 진행하면 되는데요(그야, 그냥 평범한 POST니까요...), body 에서 grant_type 이 자주 오류가 나더라구요.

이유는 잘 모르겠지만 JSON.stringifyquerystring.stringify 로 바꾸었더니 잘 인증이 되었습니다.

//index.vue
        const googleSheetUrl = 'https://sheets.googleapis.com/v4/spreadsheets/(구글 시트 주소)/values/(시트 및 범위)'
        const googleSheetParam = {
            method: 'GET',
            headers: {
                "content-type": "application/json",
                Authorization: "Bearer " + authRes.access_token,
            },
        }
        var sheetData = await fetch(googleSheetUrl, googleSheetParam)
        var sheetRes = await sheetData.json()
        var wikiBody = sheetRes.values[sheetRes.values.length - 1][2]
        return { wikiBody }
    }
}
</script>

내친김에 받은 액세스 토큰으로 구글 시트까지 가져와 보았습니다.

가장 어려운 인증(...) 부분을 뚫었으니 이제는 다른 사이트와 똑같이 제대로 된 엔드포인트에다가 값만 잘 넘겨 주면 됩니다. 특히 시트 값 가져오는 건 GET이니까요.

초코숲을 만들다가 또 막히는 일이 있으면 포스트를 써 보도록 하겠습니다.

#개발과정 #NodeJS #버튜버

1. 모티베이션

VMC Mixer 까지는 만들었는데, 이 신호를 모션캡쳐 파일로 저장해줄만한 괜찮은 소프트웨어가 없었습니다. BVH 저장이 가능한 일반 소프트웨어들은 Hips 부터 시작해서 Root 본이 누락되어 있었고(그래서 정작 중요한 캐릭터의 위치가 잡히지 않습니다), 블렌더 애드온인 VMC4B는 각 내삽에 오일러 각을 사용하는지 자주 뒤집히며 불완전했습니다.

깃허브에 VMCtoBVH 소프트웨어가 있었지만, .exe 파일이라서 맥에서는 기록이 불가능했습니다.

어쩔 수 없이 BVH 포맷에 대해 찾아보았고, 이정도면 만들기 수월하겠는데? 싶어서 결국 직접 만드는 데까지 이르게 되었습니다.

2. BVH 포맷

BVH 파일의 생김새입니다.

2.1 HIERARCHY

관절 목록이 적혀 있는 부분입니다.

맨 앞의 관절은 ROOT, 그 다음부터는 쭉 JOINT로 이어져 있습니다.

각 JOINT에는 오프셋과 채널 목록이 적혀 있는데, 오프셋은 그 joint의 크기를 결정한다고 봐도 될 것 같고, 채널 목록은 후술할 모션을 받아들이는 채널입니다.

주로 로테이션 값이 지정되어 있으며, 루트 본만 6채널로 해서 따로 포지션을 저장할 수 있게 되어 있습니다.

이 채널 목록의 순서가 매우 중요합니다!

2.2 MOTION

먼저 첫줄에는 프레임 숫자, 두번째 줄에는 프레임당 몇 초를 소요할 것인지(그래서, 0.0333334 라면 초당 30프레임을 뜻합니다) 적혀 있습니다.

세번째 줄부터는 실제 모션이 들어가는데, 앞에서 선언한 채널의 순서대로 값을 지정할 수 있습니다. 한 줄에 채널 전체 숫자만큼 숫자가 들어가며, 다음 프레임으로 넘어갈 때 줄바꿈이 들어갑니다.

제가 받은 파일에서는, 소수점 아래 6자리까지 지정되어 있고, (–) 부호가 붙지 않은 경우 스페이스가 하나 들어가 있어서 그것도 똑같이 구현했습니다.

참고로, 이거 꽤 오래된 포맷이라고 합니다. 그래서인지, 시간 단위는 , 각도 단위는 degree 입니다.

3. 쿼터니온을 BVH degree로 변환하기

이건, vmc2bvh 리포지토리의 코드 에서 가져왔습니다.

원래는 Quaternion.js의 함수를 사용하려고 했는데, 오일러 변환 중 XYZ의 순서를 어떻게 지정해야 할지 잘 모르기 때문에 기존의 성공적인 코드를 빌려올 수밖에 없었습니다...

function qtod_rot(r11, r12, r21, r31, r32) {
    F = (180.0 / Math.PI);

	// zyx
	var zrot   = Math.atan2(r31, r32) * F;
	var yrot = Math.asin(r21) * F;
	var xrot  = Math.atan2(r11, r12) * F;

    return [xrot, yrot, zrot]
}

function qtod(x, y, z, w) {

    var angles = qtod_rot(-2 * (y * z - w * x), w * w - x * x - y * y + z * z, 2 * (x * z + w * y), -2 * (x * y - w * z), w * w + x * x - y * y - z * z);

    return angles
}

원본은 위키백과에 있는 다음 식인 것 같습니다.

(Y값 구하는 데 두번째 공식 사용)

참고로 이 포맷 계산에서는 원본 쿼터니온에서 Qx와 Qy에 -1을 곱한 값을 사용하며, (아마 cw와 ccw의 차이일 것 같습니다.) 루트 계산에서는 이 쿼터니온 값을 제곱한 새로운 쿼터니온을 사용하더군요.

이걸 구한 다음 Xrotation Yrotation Zrotation 순서대로 파일에 저장해 주었습니다.

#개발과정 #NodeJS #버튜버

Slerp 개념 이해하기

사실, 지금껏 '평균' 이라고 했지만 '내삽' 이라는 좀더 일반적이고 깔끔한 용어가 있었다는 것을 잊고 있었습니다.

지난 포스트에서 쿼터니온을 최대한 건드리지 않고 오일러 각도로 변환한 뒤 각도의 평균을 내 주는 방법을 사용했다고 했죠.

그런데 이게 부정확했던 겁니다. 계속 값이 튀었어요. 모듈로 계산도 써 보고 머리를 싸매다가, 결국 스택오버플로우에서 검색을 해봤어요.

Yes, only quaternions are appropriate for inter/extrapolation.

네? 내삽/외삽을 게산하는 데 쿼터니온만이 적합하다고 합니다.

Quaternion Slerps are commonly used to construct smooth animation curves ...

아... 그랬던 겁니다. 애초에 제가 하는 스무딩 작업은 오일러 각도이든 축각 각도이든 한계가 있었고, 쿼터니온의 Slerp 라는 방법을 써야 각도의 내삽이 가능했던 거였어요.

다음은 위키백과의 Slerp 문서에서 가져온 사진입니다.

네.. 제가 원한 게 바로 이거였거든요. 두 개의 벡터의 단순 평균을 내 주는 게 아니라, 각도의 평균을 내 주는 작업이죠.

쿼터니온의 Slerp은 다음과 같은 계산과정을 거치는 모양입니다.

하지만 여기서는 Slerp의 개념만 이해하고 넘어가기로 했습니다. 계산과정은 모듈에게 맡기기로 합니다.

quaternion.js 모듈에 내삽과 관련된 함수가 있는지 찾아봅시다. (없으면 구현해야 하니까요...-_–)

아예 대놓고 Slerp 함수가 있었네요.... 지금이라도 찾아서 다행이라고 생각하며 적용해 보았어요.

적용해보기

우선 오일러 각으로 변환하던 기존 코드들을 전부 주석처리한 뒤,

var q1, q2
q1 = new Quaternion(result1[6], result1[3], result1[4], result1[5])
q2 = new Quaternion(result2[6], result2[3], result2[4], result2[5])

//평균을 내 줘야 하는 곳에서
var q3 = q1.slerp(result2[6], result2[3], result2[4], result2[5])(0.5)

//0.5 부분에는 0-1까지의 float가 들어갈 수 있습니다.
var index = [1,2,3,4,5,6,7,8,9]
for await (let i of index) {
    var qResult = qformer.slerp(q3_w, q3_xyz[0], q3_xyz[1], q3_xyz[2])(i/9)
}

쿼터니온의 성분은 기존 코드에서 가져왔습니다.

근데 저렇게 하면 q2 변수는 왜 필요한거냐...? 사실은 오일러각 계산에 필요해서 저장해두고 있습니다.

모든 오일러각을 전부 없앤 게 아니고, 오일러각으로 다루는 게 편한 부분은 오일러각으로, 쿼터니온으로 다루는 게 편한 부분은 쿼터니온으로, 둘 다 비슷하다면 쿼터니온으로 계산하고 있어요.

#개발과정 #NodeJS #버튜버

1. VMC 프로토콜

대략적으로 설명하자면, VMC프로토콜이란 모션캡쳐 데이터를 여러 소프트웨어 간에 전달할 수 있는 프로토콜입니다. TDPT, Webcam Motion Capture, VSeeFace, 심지어 블렌더까지 다양한 소프트웨어에서 지원을 하는데요.

저 같은 경우에는 아이폰 버전 TDPT의 보다 자연스러운 데이터를 전신 모션캡쳐에 사용했고, Webcam Motion Capture의 표정, 손가락 캡쳐 데이터를 추가로 사용했어요. 두 신호를 믹스하는 것은 VSeeFace에서 처리했습니다. VSeeFace에서 보조 신호를 받을 수 있어서, 부위별로 신호들을 섞을 수 있거든요.

자, 여기까지 간다면 일반 버튜버-적인 설명이겠지요. 이걸로 끝이었으면 제가 이 포스트를 적고 있지도 않았을 겁니다.

따라서 포트 관련 셋팅은 다 되어있다고 가정하고 이야기할 겁니다!!

2. 모티베이션

저는 조금 더 높은 퀄리티의 모션캡쳐를 위해 여러 가지를 알아보고 있었습니다.

  • 일반 모션캡쳐 수트: 가격은 둘째치고 살부터 빼야 합니다...
  • 저렴이 모션캡쳐 수트: 15분-30분에 한번씩 칼리브레이션을 해줘야 하고 중요한 손가락과 표정 캡쳐를 위해서라면 어차피 광학적 캡쳐를 해야 합니다.
  • 광학-AI 방식: 제가 지금 하고 있는 것인데 일반적인 모노캠 방식은 당연히 한계가 있습니다.
  • 듀얼캠-AI 방식: Rokoko 에서 지원하는 것인데, 돈이 드는 건 둘째치고 역시 손가락과 표정 캡쳐가 안되는데다 실시간 지원이 안 됩니다(방송 불가능)

이걸 전부 고려해 보았는데, 역시 Rokoko의 듀얼캠을 VMC내에서 구현할 수는 없을까... 라는 생각이 들었습니다. 무지 수학적이고 어렵겠지만 괜찮아요. 천문학과에서 내내 하는 게 좌표변환이거든요 일단은 도전을 해 보았습니다.

3. OSC 프로토콜

따로 3D 관련 지식이 있어야 하느냐? 아닐 거라고 생각했습니다. VMCP로 수치 데이터만 전송하면 되는 거잖아요? 그래서 VMCP 를 알아보았습니다. 홈페이지에 들어가서 자료를 찾아봤어요. 일단 예제가 있어야 이해하기 쉬우니 예제를 눌러봤습니다.

....? cs...? 처음보는 확장자인데.....?

.....C# 이다 끼야악!!!!!

아무튼 이런 이유로 저는 혹시 다른 언어로 작성된 예제 스크립트가 있는지 알아보았습니다.

파이썬 버전이 있길래 부랴부랴 실행해봤습니다. colab에서는 잘 실행되지만 colab 특성상 포트가 안 열리고, 로컬에서는 왠지 모듈 불러오는 데서부터 에러가 났습니다. 근데 뭘 어떻게 고쳐야 하는건지 잘 몰랐습니다.

저는 제가 python을 나름 할 줄 안다고 생각했는데 아니었나 봅니다. 여러분, 연구-코딩은 개발이 아닙니다. 아셨죠? >.0

익숙한 Node.js 모듈은 없나 찾아봤습니다. 어... VMC와 관련된 노드 모듈은 없었습니다. 절망하던 도중 저는 VMCP 홈페이지의 그림을 찬찬히 살펴보고서야 답을 얻었습니다!

위 이미지들에서 보시다시피 OSC라는 프로토콜 상에서 사용할 수 있습니다. 그러니 OSC 프로토콜을 Node로 구현하면 VMC도 당연히 수신하고 송신할 수 있다!!! 라는 결론에 이르러서 OSC 모듈을 찾아봤습니다. osc-js와 osc.js 두 종류가 있더라구요. 둘 다 깔아봤는데 제가 성공한 버전은 후자 쪽입니다.

4. 단순 토스 서버 만들기

처음이니까, Webcam Motion Capture(39539)의 데이터를 Blender(39538)로 보내주는 코드를 짜보도록 합시다.

npm init

으로 적당히 셋팅해줍니다. 시작은 index.js로 되어있는데 저는 app.js로 변경해서 셋팅했습니다.

//app.js

const osc = require('osc')

var getIPAddresses = function () {
    var os = require("os"),
        interfaces = os.networkInterfaces(),
        ipAddresses = [];

    for (var deviceName in interfaces) {
        var addresses = interfaces[deviceName];
        for (var i = 0; i < addresses.length; i++) {
            var addressInfo = addresses[i];
            if (addressInfo.family === "IPv4" && !addressInfo.internal) {
                ipAddresses.push(addressInfo.address);
            }
        }
    }

    return ipAddresses;
};

// VMC 데이터를 수신하는 포트
var udpPort = new osc.UDPPort({
    localAddress: "127.0.0.1",
    localPort: 39539 // ← webcam motion capture
});

// VMC 데이터를 송신하는 포트. localPort가 아니라 remotePort입니다.
var sendPort = new osc.UDPPort({
    localAddress: "127.0.0.1",
    remotePort: 39538 // ← blender
});

// 우선 송신부부터 열어줍니다.
sendPort.open();

// 수신 포트가 열리면 다음과 같이 콘솔을 찍습니다.
udpPort.on("ready", function () {
    var ipAddresses = getIPAddresses();

    console.log("Listening for OSC over UDP.");
    ipAddresses.forEach(function (address) {
        console.log(" Host:", address + ", Port:", udpPort.options.localPort);
    });
});

// 송신 포트가 열리면 본격적으로 내용을 실행합니다.
sendPort.on("ready", function () {
    var ipAddresses = getIPAddresses();

    console.log("Listening for OSC over UDP.");
    ipAddresses.forEach(function (address) {
        console.log(" Host:", address + ", Port:", sendPort.options.localPort);
    });

    udpPort.on("message", function (oscMessage) {
         // 수신한 message를 바탕으로 적당히 가공한 뒤
         // 송신하는 코드를 여기에 작성합니다. 예를 들면
        sendPort.send(oscMessage)
        
    });

// 에러 처리... 이건 수신부에 대해서만 입니다.
    udpPort.on("error", function (err) {
        console.log(err);
    });

// 수신부 포트를 열어줍니다.
    udpPort.open();
})

이렇게 한 후,

node app.js

로 실행하면 webcam motion capture의 데이터가 블렌더로 잘 전송됩니다. 네. 이게 VMC프로토콜의 전부....

는 아니죠, 아직 안을 안 까봤잖아요.

5. 여러 신호의 평균을 내 주는 서버 만들기

우선 저는, Webcam Motion Capture와 TDPT의 신호를 말 그대로 섞을 겁니다. 프레임률은 꽤 떨어지겠지만 특정 시간단위마다 신호들을 모아서 평균을 낸 다음 Blender로 전송하고자 합니다. 그러기 위해서는 VMC 프로토콜 안의 신호가 어떻게 생겼는지 봐야 합니다.

   udpPort.on("message", function (oscMessage) {
         // 수신한 message를 바탕으로 적당히 가공한 뒤
         // 송신하는 코드를 여기에 작성합니다. 예를 들면
        sendPort.send(oscMessage)
        
    });

여기 코드를

   udpPort.on("message", function (oscMessage) {
         // 수신한 message를 바탕으로 적당히 가공한 뒤
         // 송신하는 코드를 여기에 작성합니다. 예를 들면
        console.log(oscMessage)
        
    });

로 변경하면, 어떤 형태의 데이터가 들어오는지 알 수 있게 됩니다. 일단 표정 신호는 섞을 수 없으니, 포즈 데이터에 해당하는 '/VMC/Ext/Bone/Pos' 만을 보여드릴게요.

{
  address: '/VMC/Ext/Bone/Pos',
  args: [
    'Head',
    -4.18985948158479e-8,
    0.0694720596075058,
    0.008954105898737907,
    -4.593485614010968e-22,
    1.7898570958666765e-22,
    5.895302718405536e-23,
    1
  ]
}

다행히도 아주 편리한 JSON 데이터입니다. 처리하기 매우 쉽죠.

포즈 데이터는 무조건 address '/VMC/Ext/Bone/Pos' 쪽으로 들어옵니다. 어떤 본에 해당하는 데이터인지는 args에 적혀 있는데, args의 index 0에 그 문자열이 찍힙니다. 나머지 7개의 데이터는, 위치 X, Y, Z, 사원수 X, Y, Z, W 입니다.

이 데이터를 운용하는 방식은 저도 공부를 해 보고 나중에 적어보겠습니다만, 적어도 여기서 해볼 신호의 평균값을 내는 데에는 무리가 없을 것 같습니다. 왜냐? float 데이터 니까요...

저는 일단 시험 삼아, 'Spine', 'Chest', 'Neck', 'Head' 네 개의 데이터만을 처리해 보았습니다.

우선 함수를 하나 만들 겁니다. 이 함수는 몇 밀리초간 받아서 축적해온 데이터들의 Array의, 딱 평균값에 해당하는 Array를 계산하여 반환..하지는 않고 바로 블렌더 측에 전송하는 함수입니다.

// 데이터의 평균을 내 주는 함수입니다. 
// part는 문자열('Head' 같은), 
// receiveArray는 그동안 받아서 축적해온 데이터들의 Array 입니다.
// 참고로 receiveArray에는 part의 이름이 없습니다. slice(1) 로 뺄겁니다.
    function averager(part, receiveArray) {
        if (receiveArray.length > 0) {

            var result3 = [part]
           //result3 는 내보낼 args 데이터 입니다.

            for(var i = 0; i < receiveArray[0].length; i++){
                var num = 0;
                for(var j = 0; j < receiveArray.length; j++){ 
                    num += receiveArray[j][i];
                }
                result3.push(num / receiveArray.length)
             // 앞의 array의 내용물을 index별로 평균내서
             // result3에 집어넣어줍니다.
            }
            //송신부
            sendPort.send({
                address: '/VMC/Ext/Bone/Pos',
                args: result3
            })
// 어떤 데이터가 나가고 있는지 찍고 싶으면 찍으세요..
         //   console.log({
         //       address: '/VMC/Ext/Bone/Pos',
         //       args: result3
         //   })
        }
    }

그리고 Array들이랑 변수들을 선언했습니다.

    var triggered = false
    var receive = false

    var receiveSpineArray = []
    var receiveChestArray = []
    var receiveNeckArray = []
    var receiveHeadArray = []

receive가 false가 되면 수신을 중단하고 지금까지 받았던 내용들의 평균을 계산해서 보낼 것입니다. 그리고 receive는 (일단 테스트니까) 450ms째에 false가 되고, 50ms째에 true가 되는 것을 게속 반복할 것입니다.

그리고 그 트리거가 되는 것은 첫번째 데이터의 수신입니다.

    udpPort.on("message", function (oscMessage) {
// triggered가 false일 경우 트리거를 당겨줄겁니다.
        if (!triggered) {
            setInterval(() => {
                receive = true
                setTimeout(() => {
                    receive = false
                }, 450);
            }, 500)
// 500밀리초 간격으로 true와 false를 반복해서 실행.
        }
        triggered = true

        if (oscMessage.address == '/VMC/Ext/Bone/Pos') {
            if (oscMessage.args[0] == 'Spine') {
                if (receive == true) {
                    receiveSpineArray.push(oscMessage.args.slice(1))
// receive가 true라면 args에서 앞의 'Spine' 부분을 자르고 push합니다.
// 계산을 용이하게 하기 위해서 입니다.
                } else if (receive == false) {
                    averager('Spine', receiveSpineArray)
                    receiveSpineArray = []
// receive가 false라면 지금까지 받았던 array의 평균값을 계산하고,
// 블렌더로 데이터를 보냅니다.
// 그리고 기존 array를 초기화 합니다.
                }
//아래는 부위만 다르고 동일합니다.
            } else if (oscMessage.args[0] == 'Chest') {
                if (receive == true) {
                    receiveChestArray.push(oscMessage.args.slice(1))
                } else if (receive == false) {
                    averager('Chest', receiveChestArray)
                    receiveChestArray = []
                }
            } else if (oscMessage.args[0] == 'Neck') {
                if (receive == true) {
                    receiveNeckArray.push(oscMessage.args.slice(1))
                } else if (receive == false) {
                    averager('Neck', receiveNeckArray)
                    receiveNeckArray = []
                }
            } else if (oscMessage.args[0] == 'Head') {
                if (receive == true) {
                    receiveHeadArray.push(oscMessage.args.slice(1))
                } else if (receive == false) {
                    averager('Head', receiveHeadArray)
                    receiveHeadArray = []
                }
            }
        }
    });

저장하고 실행해 보면 블렌더 상에서 움직이는 아바타를 보실 수 있습니다. 일단 후기 말씀드릴게요.

웹 서버 여는것보다 쉬운것같아요!