|
| 1 | +import { useEffect, useRef } from 'react' |
| 2 | +import * as THREE from 'three' |
| 3 | +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' |
| 4 | +import { createPlayerObject, applySkinToPlayerObject, PlayerObjectType } from '../../../renderer/viewer/lib/createPlayerObject' |
| 5 | + |
| 6 | +const setupMaterialTransparency = (material: THREE.Material): void => { |
| 7 | + if ( |
| 8 | + material instanceof THREE.MeshStandardMaterial || |
| 9 | + material instanceof THREE.MeshBasicMaterial || |
| 10 | + material instanceof THREE.MeshPhongMaterial |
| 11 | + ) { |
| 12 | + const hasAlpha = material.alphaMap || |
| 13 | + (material.opacity !== undefined && material.opacity < 1) || |
| 14 | + (material.map && material.map.format === THREE.RGBAFormat) |
| 15 | + if (hasAlpha) { |
| 16 | + material.transparent = true |
| 17 | + material.alphaTest = 0.01 |
| 18 | + material.side = THREE.DoubleSide |
| 19 | + } else { |
| 20 | + material.transparent = false |
| 21 | + material.side = THREE.FrontSide |
| 22 | + } |
| 23 | + material.needsUpdate = true |
| 24 | + } |
| 25 | +} |
| 26 | + |
| 27 | +export function PlayerModelViewer ({ width, height }: { width: number; height: number }) { |
| 28 | + const containerRef = useRef<HTMLDivElement>(null) |
| 29 | + const stateRef = useRef<{ |
| 30 | + renderer: THREE.WebGLRenderer |
| 31 | + camera: THREE.PerspectiveCamera |
| 32 | + scene: THREE.Scene |
| 33 | + controls: OrbitControls |
| 34 | + playerObject: PlayerObjectType |
| 35 | + wrapper: THREE.Object3D |
| 36 | + disposed: boolean |
| 37 | + } | null>(null) |
| 38 | + |
| 39 | + useEffect(() => { |
| 40 | + const container = containerRef.current |
| 41 | + if (!container) return |
| 42 | + |
| 43 | + // Scene |
| 44 | + const scene = new THREE.Scene() |
| 45 | + scene.background = null |
| 46 | + |
| 47 | + // Camera |
| 48 | + const camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 1000) |
| 49 | + camera.position.set(0, 0, 3) |
| 50 | + |
| 51 | + // Renderer |
| 52 | + const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true }) |
| 53 | + renderer.useLegacyLights = false |
| 54 | + renderer.outputColorSpace = THREE.LinearSRGBColorSpace |
| 55 | + renderer.setPixelRatio(window.devicePixelRatio || 1) |
| 56 | + renderer.setSize(width, height) |
| 57 | + container.appendChild(renderer.domElement) |
| 58 | + |
| 59 | + // Controls |
| 60 | + const controls = new OrbitControls(camera, renderer.domElement) |
| 61 | + controls.minPolarAngle = Math.PI / 2 |
| 62 | + controls.maxPolarAngle = Math.PI / 2 |
| 63 | + controls.enableDamping = true |
| 64 | + controls.dampingFactor = 0.05 |
| 65 | + |
| 66 | + // Lights |
| 67 | + const ambientLight = new THREE.AmbientLight(0xff_ff_ff, 3) |
| 68 | + scene.add(ambientLight) |
| 69 | + const cameraLight = new THREE.PointLight(0xff_ff_ff, 0.6) |
| 70 | + camera.add(cameraLight) |
| 71 | + scene.add(camera) |
| 72 | + |
| 73 | + // Player model |
| 74 | + const { playerObject, wrapper } = createPlayerObject({ scale: 1 }) |
| 75 | + playerObject.ears.visible = false |
| 76 | + playerObject.cape.visible = false |
| 77 | + |
| 78 | + wrapper.traverse((child) => { |
| 79 | + if (child instanceof THREE.Mesh && child.material) { |
| 80 | + if (Array.isArray(child.material)) { |
| 81 | + for (const mat of child.material) setupMaterialTransparency(mat) |
| 82 | + } else { |
| 83 | + setupMaterialTransparency(child.material) |
| 84 | + } |
| 85 | + } |
| 86 | + }) |
| 87 | + |
| 88 | + // Scale to fit camera view |
| 89 | + const box = new THREE.Box3().setFromObject(wrapper) |
| 90 | + const size = box.getSize(new THREE.Vector3()) |
| 91 | + const center = box.getCenter(new THREE.Vector3()) |
| 92 | + const cameraDistance = camera.position.z |
| 93 | + const fov = camera.fov * Math.PI / 180 |
| 94 | + const visibleHeight = 2 * Math.tan(fov / 2) * cameraDistance |
| 95 | + const visibleWidth = visibleHeight * (width / height) |
| 96 | + const scaleFactor = Math.min(visibleHeight / size.y, visibleWidth / size.x) |
| 97 | + wrapper.scale.multiplyScalar(scaleFactor) |
| 98 | + wrapper.position.sub(center.multiplyScalar(scaleFactor)) |
| 99 | + wrapper.rotation.set(0, 0, 0) |
| 100 | + scene.add(wrapper) |
| 101 | + |
| 102 | + // Render helper |
| 103 | + const render = () => { renderer.render(scene, camera) } |
| 104 | + |
| 105 | + // Apply skin |
| 106 | + const skinUrl = (appViewer?.playerState?.reactive as any)?.playerSkin ?? '' |
| 107 | + void applySkinToPlayerObject(playerObject, skinUrl).then(() => { render() }) |
| 108 | + |
| 109 | + // Render on orbit change |
| 110 | + controls.addEventListener('change', render) |
| 111 | + render() |
| 112 | + |
| 113 | + // Cursor following |
| 114 | + let waitingRender = false |
| 115 | + const handlePointerMove = (event: PointerEvent) => { |
| 116 | + const el = containerRef.current |
| 117 | + if (!el) return |
| 118 | + |
| 119 | + const rect = el.getBoundingClientRect() |
| 120 | + const centerX = rect.left + rect.width / 2 |
| 121 | + const centerY = rect.top + rect.height / 2 |
| 122 | + const normalizedX = (event.clientX - centerX) / (rect.width / 2) |
| 123 | + const normalizedY = (event.clientY - centerY) / (rect.height / 2) |
| 124 | + |
| 125 | + const maxAngle = Math.PI * (60 / 180) |
| 126 | + const clampedX = THREE.MathUtils.clamp(normalizedX, -1, 1) |
| 127 | + const clampedY = THREE.MathUtils.clamp(normalizedY, -1, 1) |
| 128 | + const headYaw = clampedX * maxAngle |
| 129 | + const headPitch = clampedY * maxAngle |
| 130 | + |
| 131 | + playerObject.skin.head.rotation.y = THREE.MathUtils.lerp(playerObject.skin.head.rotation.y, headYaw, 0.1) |
| 132 | + playerObject.skin.head.rotation.x = THREE.MathUtils.lerp(playerObject.skin.head.rotation.x, headPitch, 0.1) |
| 133 | + playerObject.rotation.y = THREE.MathUtils.lerp(playerObject.rotation.y, headYaw * 0.3, 0.05) |
| 134 | + |
| 135 | + if (!waitingRender) { |
| 136 | + requestAnimationFrame(() => { |
| 137 | + render() |
| 138 | + waitingRender = false |
| 139 | + }) |
| 140 | + waitingRender = true |
| 141 | + } |
| 142 | + } |
| 143 | + window.addEventListener('pointermove', handlePointerMove) |
| 144 | + |
| 145 | + stateRef.current = { renderer, camera, scene, controls, playerObject, wrapper, disposed: false } |
| 146 | + |
| 147 | + return () => { |
| 148 | + if (stateRef.current) stateRef.current.disposed = true |
| 149 | + window.removeEventListener('pointermove', handlePointerMove) |
| 150 | + controls.removeEventListener('change', render) |
| 151 | + controls.dispose() |
| 152 | + |
| 153 | + wrapper.traverse((child) => { |
| 154 | + if (child instanceof THREE.Mesh) { |
| 155 | + if (Array.isArray(child.material)) { |
| 156 | + for (const mat of child.material) mat.dispose() |
| 157 | + } else { |
| 158 | + child.material?.dispose() |
| 159 | + } |
| 160 | + child.geometry?.dispose() |
| 161 | + } |
| 162 | + }) |
| 163 | + if (playerObject.skin.map) { |
| 164 | + (playerObject.skin.map as unknown as THREE.Texture).dispose() |
| 165 | + } |
| 166 | + renderer.dispose() |
| 167 | + renderer.domElement?.remove() |
| 168 | + stateRef.current = null |
| 169 | + } |
| 170 | + }, []) |
| 171 | + |
| 172 | + // Handle resize |
| 173 | + useEffect(() => { |
| 174 | + const s = stateRef.current |
| 175 | + if (!s || s.disposed) return |
| 176 | + s.renderer.setSize(width, height) |
| 177 | + s.camera.aspect = width / height |
| 178 | + s.camera.updateProjectionMatrix() |
| 179 | + s.renderer.render(s.scene, s.camera) |
| 180 | + }, [width, height]) |
| 181 | + |
| 182 | + return <div ref={containerRef} style={{ width, height, overflow: 'hidden', pointerEvents: 'auto' }} /> |
| 183 | +} |
0 commit comments