2024-10-01 / @syui

vrm

3Dホログラムを作った

この前、ホログラムを作ってみようと思い10分くらいで作ってみた。必要なものは透明板だけ。それを斜めに設置してスマホで黒背景の3Dモデルを表示する。

もともと写真立てが余っててそれに付いてる透明板を使った。

設置にはスマホの箱が便利だった。どちらかに切り込みを入れればいいと思う。

ここまではっきり映るとは思ってなくて驚いた。余ってるスマホを何に使おうと思ってたので、こういうのを表示させておくといいかも。ただし、表記は反転させないといけないみたい。

react + tsx + three-vrm

一応、最小限のコードを載せておきます。反転は対応しています。

{
  "name": "holoai",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "@pixiv/three-vrm": "^3.1.1",
    "@pixiv/three-vrm-animation": "^3.1.1",
    "@react-three/drei": "^9.114.0",
    "@react-three/fiber": "^8.17.9",
    "@react-three/postprocessing": "^2.16.3",
    "@testing-library/jest-dom": "^5.17.0",
    "@testing-library/react": "^13.4.0",
    "@testing-library/user-event": "^13.5.0",
    "@types/jest": "^27.5.2",
    "@types/node": "^16.18.112",
    "@types/react": "^18.3.10",
    "@types/react-dom": "^18.3.0",
    "@types/three": "^0.167.2",
    "react": "^18.3.1",
    "react-dom": "^18.3.1",
    "react-scripts": "5.0.1",
    "three": "^0.167.1",
    "three-stdlib": "^2.30.5",
    "typescript": "^4.9.5",
    "web-vitals": "^2.1.4"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  },
  "eslintConfig": {
    "extends": [
      "react-app",
      "react-app/jest"
    ]
  },
  "browserslist": {
    "production": [
      ">0.2%",
      "not dead",
      "not op_mini all"
    ],
    "development": [
      "last 1 chrome version",
      "last 1 firefox version",
      "last 1 safari version"
    ]
  }
}
.time {
	transform:scale(-1,1);
	position: absolute;
	top: 10px;
	padding: 10px;
	z-index: 100;
	color: #e9ff00;
	font-size: 30px;
	text-align: center;
}
import React, { useState, useEffect } from 'react';

//function reverseString(str: string): string {
//  return str.split('').reverse().join('');
//}

const ScreenTimeCanvas: React.FC = () => {
  const [currentDateTime, setCurrentDateTime] = useState<string>('');
  //const [reversedDateTime, setReversedDateTime] = useState<string>('');

  useEffect(() => {
    const updateDateTime = () => {
      const now = new Date();
      const formatted = now.toLocaleString("ja-JP", {
        year: 'numeric',
        month: '2-digit',
        day: '2-digit',
        hour: '2-digit',
        minute: '2-digit',
        second: '2-digit'
      });
      setCurrentDateTime(formatted);
      //setReversedDateTime(reverseString(formatted));
    };
    updateDateTime();
    const timer = setInterval(updateDateTime, 1000);
    return () => clearInterval(timer);
  }, []);

  return (
    <div className="time">
      <p>{currentDateTime}</p>
    </div>
  );
};

export default ScreenTimeCanvas;
import * as THREE from 'three'
import React, { useState, useEffect, useRef } from 'react';
import { OrbitControls } from '@react-three/drei'
import { useFrame, Canvas } from '@react-three/fiber';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
import { VRM, VRMUtils, VRMLoaderPlugin } from '@pixiv/three-vrm';
import { VRMAnimationLoaderPlugin, VRMAnimation, createVRMAnimationClip } from "@pixiv/three-vrm-animation";

interface ModelProps {
	url: string
	url_anim: string
 scale: [number, number, number]
 position: [number, number, number]
 rotation: [number, number, number]
}

const VRMModel: React.FC<ModelProps> = ({ url, url_anim, position, rotation, scale }) => {

	const [vrm, setVrm] = useState<VRM | null>(null);
	const mixerRef = useRef<THREE.AnimationMixer | null>(null);

	useEffect(() => {
		const loader = new GLTFLoader();
		loader.register((parser) => new VRMLoaderPlugin(parser));
		loader.register((parser) => new VRMAnimationLoaderPlugin(parser));
		loader.load(url, (gltf) => {
			const vrmModel = gltf.userData.vrm as VRM;
			VRMUtils.removeUnnecessaryJoints(vrmModel.scene);
			setVrm(vrmModel);
			const mixer = new THREE.AnimationMixer(vrmModel.scene);
			mixerRef.current = mixer;
			loader.load(url_anim, (animGltf) => {
				const vrmAnimations = animGltf.userData.vrmAnimations as VRMAnimation[];
				if (vrmAnimations && vrmAnimations.length > 0) {
					const clip = createVRMAnimationClip(vrmAnimations[0], vrmModel);
					mixer.clipAction(clip).play();
				}
			});
		});
	}, [url, url_anim]);

	useFrame((state, delta) => {
		if (mixerRef.current) mixerRef.current.update(delta);
		if (vrm) vrm.update(delta);
	});

	return vrm ? <primitive object={vrm.scene} position={position} rotation={rotation} scale={scale}/> : null;
};

export const VRMModelCanvas = () => {
	return (
		<div style={{ height: '100vh', width: '100vw' }}>
		<Canvas
		shadows
		gl={{
			//toneMapping: THREE.ACESFilmicToneMapping,
			//toneMapping: THREE.ReinhardToneMapping,
			toneMapping: THREE.NeutralToneMapping,
				toneMappingExposure: 1,
				alpha: true,
				powerPreference: "high-performance",
				antialias: true,
				//stencil: false,
				//depth: false
		}}
		camera={{ position: [1.2, 0, 0] }}>
		<color attach="background" args={["#000"]} /> {/* Light gray background */}
		<OrbitControls />
		<ambientLight intensity={10} />
		<pointLight position={[10, 10, 10]} />
		<VRMModel url="./models/t.vrm" url_anim="./models/default.vrma" position={[0, -0.6, 0]} rotation={[0, 0, 0]} scale={[1, 1, 1]} />
		</Canvas>
		</div>
	)
}
export default VRMModelCanvas;
import React from 'react'
import VRMModelCanvas from './pages/vrm'
import ScreenTimeCanvas from './pages/time'

const App = () => {
	return (
		<>
		<VRMModelCanvas/>
		<ScreenTimeCanvas/>
		</>
	)
}

export default App;