2023-11-04 / @syui

vrm , 3d

3d-modelとcardを連携してみた

https://card.syui.ai/ai

動作環境

ios17で動作します。ios16では動作しません。

  • [ok] … ios17.x
  • [no] … ios16.x

safariの以下の機能が必要です。

ios17のデフォルトでは有効になっています。

  • Allow WebGL in Web Workers
  • GPU Process: Canvas Rendering
  • GPU Process: DOM Rendering
  • OffscreenCanvas in Workers
  • OffscreenCanvas

fix motion

例えば、以下はblenderでポーズを編集している様子なんだけど、プレビューと出力結果が異なります。

これは、アニメーションが自動で動作するように設定されているためです。そのままvrmを読み込むとTポーズになりますが、JLChnToZ/vrm-dance-viewerは設定で手を下げて固定します。

// https://github.com/JLChnToZ/vrm-dance-viewer
const LERP_SCALE = 6;

if (node) node.setRotationFromQuaternion(
        rotation3
        .setFromRotationMatrix(node.matrix)
        // ここがポーズをおかしくする要因
        // コメント化すると元通り。ただvrmを自然なポーズに固定する必要がでてくる
        //.slerp(finalRotation, Math.min(deltaTime * LERP_SCALE, 1)),
);

また、blinkを設定すると、顔を標準位置から移動するとおかしくなります。これはthree-vrmの古いバージョンのバグです。

function updateEyeBlink(model: VRM, deltaTime: number) {
	//if (!model.blendShapeProxy) return;
	//let v = blinkDelays.get(model);
	//if (v == null || v < -BLINK_DURATION)
	//  v = MathUtils.randFloat(MIN_BLINK_DELAY, MAX_BLINK_DEALY);
	//else
	//  v -= deltaTime;
	//blinkDelays.set(model, v);
	//model.blendShapeProxy.setValue(
	//  VRMSchema.BlendShapePresetName.Blink,
	//  v > LOOK_CAMERA_THRESHOLD ? 0 : MathUtils.pingpong(-v, BLINK_DURATION / 2) * 2 / BLINK_DURATION,
	//);
}

これをどう自然に動かしていけばいいのか悩み中。全部のvrmをデフォルトのTポーズから変更するのもあんまり良くない。

three.jsの他にbabylon.jsというものもあるらしい。こちらのvirtual-cast/babylon-vrm-loaderでvrmを読み込めます。

add effect

例えば、model/motionを読み込んだ瞬間に紙吹雪が舞う演出を追加してみます。

// 空中に紙吹雪
function tick_sky() {
	let s_rot = 0;
	let s_xp = 10;
	let s_yp = 10;
	let s_zp = 10;
	const s_length = 5000;
	const s_plane_scale = 0.01;
	const s_plane = [];
	for(let i=0; i<s_length; i++){
		var color = "0x" + Math.floor(Math.random() * 16777215).toString(16);
		let geometry = new THREE.PlaneGeometry( s_plane_scale, s_plane_scale );
		var material = new THREE.MeshBasicMaterial({
			color: Number(color),
			opacity: 0.8,
			transparent: true,
			side: THREE.DoubleSide
		});
		s_plane[i] = new THREE.Mesh( geometry, material );
		s_plane[i].position.x = s_xp * (Math.random() - 0.5);
		s_plane[i].position.y = s_yp * (Math.random() - 0.5);
		s_plane[i].position.z = s_zp * (Math.random() - 0.5);
		scene.add(s_plane[i]);
	}
	return s_plane;
}

// 紙吹雪を時間経過で消す処理
function tick_sky_remove(){
	const s_length = 5000;
	var s_plane = tick_sky();
	setTimeout(() => {
		for(let i=0; i<s_length; i++){
			scene.remove(s_plane[i]);
		}
	}, 7000);
}

// サービス名を追加
export function toggleTickSky() {
	if (!navigator.userAgent.match(/iPhone|iPod|iPad|Android.+Mobile/)) {
		tick_sky_remove();
	}
}

// ホストへ追加
WorkerMessageService.host.on({ setLights, toggleLights, toggleTickSky });
// サービス登録
export function toggleTickSky() {
  return void workerService.trigger('toggleTickSky');
}
import { toggleTickSky } from './host';

const el = document.querySelector('#btn-models') as HTMLInputElement | null;
if(el != null) {
    // ボタンを押したときの動作
    el.addEventListener('click', function(){
        // サービス:紙吹雪の演出
		toggleTickSky();
        // サービス:ライトのon/off
        toggleLights();
	});
}
<button id='btn-models'>test</button>

このようにするとmodel/motionにeffectを追加できます。色々なsceneを作っていく予定。

add audio

import * as THREE from 'three';
export const camera = new THREE.PerspectiveCamera(75, 1, 0.1, 1000);
const listener = new THREE.AudioListener();
camera.add( listener );
const sound = new THREE.Audio( listener );
const audioLoader = new THREE.AudioLoader();

function audio_sword() {
	audioLoader.load( './audio/sword.mp3', function( buffer ) {
		sound.setBuffer( buffer );
		sound.setLoop( false );
		sound.setVolume( 0.2 );
		sound.play();
	});
}

audio_sword();

add floor

先程の応用でランダムで動き続ける背景を設定してみます。

function tick() {
	let s_rot = 0;
	let s_xp = 30;
    // 0にして床のみ設定する
	let s_yp = 0;
	let s_zp = 30;
	const s_length = 15000;
    // 大きさもランダムにする
	const s_plane_scale = Math.floor(Math.random() * 0.09) + 0.01;
	const s_plane = [];
	for(let i=0; i<s_length; i++){
		var color = "0x" + Math.floor(Math.random() * 16777215).toString(16);
		let geometry = new THREE.PlaneGeometry( s_plane_scale, s_plane_scale );
		var material = new THREE.MeshBasicMaterial({
			color: Number(color),
			opacity: 0.8,
			transparent: true,
			side: THREE.DoubleSide
		});
		s_plane[i] = new THREE.Mesh( geometry, material );
        // 向きをランダムに変える
		s_plane[i].rotation.x = Math.PI / 2 * Math.random();
		s_plane[i].position.x = s_xp * (Math.random() - 0.5);
		s_plane[i].position.y = s_yp * (Math.random() - 0.5);
		s_plane[i].position.z = s_zp * (Math.random() - 0.5);
		scene.add(s_plane[i]);
	}
	return s_plane;
}

function tick_remove(){
	const s_length = 15000;
	var s_plane = tick();
	setTimeout(() => {
		for(let i=0; i<s_length; i++){
			scene.remove(s_plane[i]);
		}
	}, 2000);
}

// これで床のキラキラが更新を続ける
export function toggleTick() {
    const s = tick();
    tick_remove();
    var num = Math.floor(Math.random() * 1990) + 1790;
    for(let i=0; i<100; i++){
        var num = Math.floor(Math.random() * 1990) + 1790;
        setTimeout(() => { tick_remove(); }, num * i);
    }
}

add hdr

hdr画像を設定します。ただし、backgroundはつけないでください。床はつけてもいいです。

//背景をHDR
import * as THREE from 'three';
import { RGBELoader } from "three/examples/jsm/loaders/RGBELoader";
let uk_0 = "/img/t.hdr"

new RGBELoader().load(uk_0, function (texture) {
  texture.mapping = THREE.EquirectangularReflectionMapping;
  scene.background = texture;
  //scene.environment = texture;
});

// scene.background.set(bgColor);

hdr : https://polyhaven.com/hdris

ボタンを押すと場所を移動するように設定するにはこんな感じ。

const bgColor = new Color(0x000000);

function getTels(){
	let hdr = "/img/syferfontein_0d_clear_puresky_4k.hdr";
	new RGBELoader().load(hdr, function (texture) {
		texture.mapping = THREE.EquirectangularReflectionMapping;
		scene.background = texture;
		//scene.environment = texture;
	});
}

function floor_default(){
	const floor = new Mesh(
		new PlaneBufferGeometry(100, 100),
		new MeshLambertMaterial({
			color: 0x999999,
			depthWrite: true,
		})
	);
	floor.position.y = -0.5;
	floor.rotation.x = -Math.PI / 2;
	//const { y } = floor.position;
	//floor.position.set(0, 0, 0);
	scene.add(floor);
	return floor;
}

function floor_grid(){
	const grid = new GridHelper(50, 100, 0xAAAAAA, 0xAAAAAA);
	scene.add(grid);
	grid.position.set(Math.round(0), 0, Math.round(0));
	return grid;
}

function floor_bg(){
	const bgColor = new Color(0xffffff);
	scene.background = new Color(bgColor);
	scene.fog = new Fog(bgColor, 3, 10);
	scene.fog?.color.set(bgColor);
	if (scene.background instanceof Color)
		scene.background.set(bgColor);
	else
		scene.background = bgColor.clone();
}

export const fl_de = floor_default();
export const fl_gr = floor_grid();
export const fl_bg = floor_bg();

function floor_default_remove(int: number){
	if (int == 1){
		scene.remove(fl_de);
		scene.remove(fl_gr);
		getTels();
	}
}

floor_default_remove(0);

export function toggleTel(int: number) {
	floor_default_remove(int)
}

WorkerMessageService.host.on({ toggleTel });
export function toggleTel(int: number) {
  return void workerService.trigger('toggleTel', [int]);
}
import { toggleTel } from './host';
const el_tel = document.querySelector('#btn-models_tel') as HTMLInputElement | null;
if(el_tel != null) {
	el_tel.addEventListener('click', (e:Event) => toggleTel(1));
}