223 lines
6.2 KiB
Markdown
223 lines
6.2 KiB
Markdown
|
+++
|
||
|
date = "2024-10-01"
|
||
|
tags = ["vrm"]
|
||
|
title = "3Dホログラムを作った"
|
||
|
+++
|
||
|
|
||
|
この前、ホログラムを作ってみようと思い10分くらいで作ってみた。必要なものは透明板だけ。それを斜めに設置してスマホで黒背景の3Dモデルを表示する。
|
||
|
|
||
|
もともと写真立てが余っててそれに付いてる透明板を使った。
|
||
|
|
||
|
設置にはスマホの箱が便利だった。どちらかに切り込みを入れればいいと思う。
|
||
|
|
||
|
ここまではっきり映るとは思ってなくて驚いた。余ってるスマホを何に使おうと思ってたので、こういうのを表示させておくといいかも。ただし、表記は反転させないといけないみたい。
|
||
|
|
||
|
<video loop autoplay muted controls style="width:100%;"><source src="/img/holo.mov"></video>
|
||
|
|
||
|
## react + tsx + three-vrm
|
||
|
|
||
|
一応、最小限のコードを載せておきます。反転は対応しています。
|
||
|
|
||
|
```json:package.json
|
||
|
{
|
||
|
"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"
|
||
|
]
|
||
|
}
|
||
|
}
|
||
|
```
|
||
|
|
||
|
```css:src/index.css
|
||
|
.time {
|
||
|
transform:scale(-1,1);
|
||
|
position: absolute;
|
||
|
top: 10px;
|
||
|
padding: 10px;
|
||
|
z-index: 100;
|
||
|
color: #e9ff00;
|
||
|
font-size: 30px;
|
||
|
text-align: center;
|
||
|
}
|
||
|
```
|
||
|
|
||
|
```ts:src/pages/time.tsx
|
||
|
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;
|
||
|
```
|
||
|
|
||
|
```ts:src/pages/vrm.tsx
|
||
|
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;
|
||
|
```
|
||
|
|
||
|
```ts:src/App.tsx
|
||
|
import React from 'react'
|
||
|
import VRMModelCanvas from './pages/vrm'
|
||
|
import ScreenTimeCanvas from './pages/time'
|
||
|
|
||
|
const App = () => {
|
||
|
return (
|
||
|
<>
|
||
|
<VRMModelCanvas/>
|
||
|
<ScreenTimeCanvas/>
|
||
|
</>
|
||
|
)
|
||
|
}
|
||
|
|
||
|
export default App;
|
||
|
```
|