diff --git a/my-blog/content/posts/2025-11-20-three-cloud.md b/my-blog/content/posts/2025-11-20-three-cloud.md new file mode 100644 index 0000000..547398e --- /dev/null +++ b/my-blog/content/posts/2025-11-20-three-cloud.md @@ -0,0 +1,367 @@ +--- +title: "three.jsでatmosphereを作る" +slug: "three-cloud" +date: "2025-11-20" +tags: ["vrm", "react", "three.js", "webgl"] +language: ["ja", "en"] +draft: false +--- + +今回は、atmpsphere+three-vrmでキャラクターを表示する方法を紹介。 + +非常に良いpackageを見つけたので、それを使います。 + +[https://github.com/takram-design-engineering/three-geospatial](https://github.com/takram-design-engineering/three-geospatial) + +![](/img/three_cloud_vrm_0001.png) + +## three-vrm+vrmaの最小構成 + +vrmを表示の上、animation(.vrma)を適用。 + +```json:package.json +{ + "name": "min-react-vrm", + "version": "1.0.0", + "description": "Minimal VRM Animation Player", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@pixiv/three-vrm": "^3.4.4", + "@pixiv/three-vrm-animation": "^3.4.4", + "@react-three/drei": "^10.7.7", + "@react-three/fiber": "^9.4.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "three": "^0.181.2" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "vite": "^6.0.1" + } +} +``` + +```js:vite.config.js +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], +}) +``` + +```js:src/main.jsx +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + , +) +``` + +```js:src/App.jsx +import React, { useEffect, useRef } from 'react'; +import { Canvas, useFrame, useLoader } from '@react-three/fiber'; +import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'; +import { VRMLoaderPlugin, VRMUtils } from '@pixiv/three-vrm'; +import { createVRMAnimationClip, VRMAnimationLoaderPlugin } from '@pixiv/three-vrm-animation'; +import { AnimationMixer, GridHelper, AxesHelper } from 'three'; +import { OrbitControls } from '@react-three/drei'; + +const VRM_URL = '/ai.vrm'; +const VRMA_URL = '/idle.vrma'; + +function Avatar() { + const mixerRef = useRef(null); + const vrmRef = useRef(null); + const gltf = useLoader(GLTFLoader, VRM_URL, (loader) => { + loader.register((parser) => new VRMLoaderPlugin(parser)); + }); + + const vrma = useLoader(GLTFLoader, VRMA_URL, (loader) => { + loader.register((parser) => new VRMAnimationLoaderPlugin(parser)); + }); + + useEffect(() => { + const vrm = gltf.userData.vrm; + vrmRef.current = vrm; + VRMUtils.removeUnnecessaryJoints(vrm.scene); + vrm.humanoid.resetPose(); + vrm.scene.rotation.y = Math.PI; + if (vrma.userData.vrmAnimations && vrma.userData.vrmAnimations.length > 0) { + const clip = createVRMAnimationClip(vrma.userData.vrmAnimations[0], vrm); + mixerRef.current = new AnimationMixer(vrm.scene); + mixerRef.current.clipAction(clip).play(); + } + }, [gltf, vrma]); + + useFrame((state, delta) => { + if (mixerRef.current) mixerRef.current.update(delta); + if (vrmRef.current) vrmRef.current.update(delta); + }); + + return ; +} + +export default function App() { + return ( +
+ + + + + + + + + + + +
+ ); +} +``` + +```html:index.html + + + + + + VRM Animation Preview + + + +
+ + + +``` + +これで`npm run dev`すれば、VRMが表示され、vrmaのアニメーションが再生されます。 + +## atmosphereの追加 + +```json:package.json +{ + "name": "react-vrm-atmosphere", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@pixiv/three-vrm": "^3.4.4", + "@pixiv/three-vrm-animation": "^3.4.4", + "@react-three/drei": "^10.7.7", + "@react-three/fiber": "^9.4.0", + "@react-three/postprocessing": "^3.0.4", + "@takram/three-atmosphere": "^0.15.1", + "@takram/three-clouds": "^0.5.2", + "react": "^19.0.0-rc.1", + "react-dom": "^19.0.0-rc.1", + "three": "^0.181.2" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.2.1", + "vite": "^5.1.0" + } +} +``` + +```js:src/App.jsx +import React, { useEffect, useRef, Suspense } from 'react'; +import { Canvas, useFrame, useLoader, useThree } from '@react-three/fiber'; +import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'; +import { VRMLoaderPlugin, VRMUtils } from '@pixiv/three-vrm'; +import { createVRMAnimationClip, VRMAnimationLoaderPlugin } from '@pixiv/three-vrm-animation'; +import { AnimationMixer, Vector3 } from 'three'; +import { OrbitControls, PerspectiveCamera } from '@react-three/drei'; +import { EffectComposer, ToneMapping } from '@react-three/postprocessing'; +import { ToneMappingMode } from 'postprocessing'; +import * as THREE from 'three'; + +import { AerialPerspective, Atmosphere } from '@takram/three-atmosphere/r3f'; +import { Clouds, CloudLayer } from '@takram/three-clouds/r3f'; + +const VRM_URL = '/ai.vrm'; +const VRMA_URL = '/fly.vrma'; +const EARTH_RADIUS = 6378137; + +const FIXED_DATE = new Date('2024-06-21T12:00:00'); +function ExposureController() { + const { gl } = useThree(); + useEffect(() => { + gl.toneMapping = THREE.NoToneMapping; + gl.toneMappingExposure = 10.0; + }, [gl]); + return null; +} + +function AtmosphereLayer() { + const cameraRef = useRef(); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +function FlyOverCamera() { + useFrame((state) => { + const t = state.clock.getElapsedTime() * 0.05; + const altitude = 2000; + const radius = 5000; + + state.camera.position.x = Math.sin(t) * radius; + state.camera.position.z = Math.cos(t) * radius; + state.camera.position.y = EARTH_RADIUS + altitude; + + const lookAtTarget = new Vector3( + Math.sin(t + 0.1) * radius, + EARTH_RADIUS + altitude, + Math.cos(t + 0.1) * radius + ); + state.camera.lookAt(lookAtTarget); + }); + return null; +} + +function VrmCharacter() { + const mixerRef = useRef(null); + const vrmRef = useRef(null); + + const gltf = useLoader(GLTFLoader, VRM_URL, (loader) => { + loader.register((parser) => new VRMLoaderPlugin(parser)); + }); + + const vrma = useLoader(GLTFLoader, VRMA_URL, (loader) => { + loader.register((parser) => new VRMAnimationLoaderPlugin(parser)); + }); + + useEffect(() => { + const vrm = gltf.userData.vrm; + vrmRef.current = vrm; + VRMUtils.removeUnnecessaryJoints(vrm.scene); + vrm.humanoid.resetPose(); + vrm.scene.rotation.y = Math.PI; + + if (vrma.userData.vrmAnimations?.[0]) { + const clip = createVRMAnimationClip(vrma.userData.vrmAnimations[0], vrm); + mixerRef.current = new AnimationMixer(vrm.scene); + mixerRef.current.clipAction(clip).play(); + } + }, [gltf, vrma]); + + useFrame((state, delta) => { + mixerRef.current?.update(delta); + vrmRef.current?.update(delta); + }); + + return ; +} + +function AvatarLayer() { + return ( + + + + + + + + + + + + + ); +} + +export default function App() { + const layerStyle = { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + }; + + return ( +
+ +
+ +
+ +
+
+ +
+
+ +
+ ); +} +``` + diff --git a/my-blog/static/img/three_cloud_vrm_0001.png b/my-blog/static/img/three_cloud_vrm_0001.png new file mode 100644 index 0000000..07b6901 Binary files /dev/null and b/my-blog/static/img/three_cloud_vrm_0001.png differ