|
| 1 | +/** |
| 2 | + * loader-gltf |
| 3 | + * R3F port of three.js `webgpu_loader_gltf`, running on WebGPU. |
| 4 | + * Original: https://threejs.org/examples/#webgpu_loader_gltf (~225 lines of JS) |
| 5 | + * |
| 6 | + * DEMONSTRATES |
| 7 | + * - Live model catalog: fetches KhronosGroup's `glTF-Sample-Assets` `model-index.json` |
| 8 | + * at runtime (same external index the original itself fetches) and feeds the |
| 9 | + * resulting names into a leva dropdown via `useControls`'s deps-array schema |
| 10 | + * recreation — the schema's `options` list only exists once the fetch resolves |
| 11 | + * - Variant resolution: mirrors the original's `loadModel` logic exactly (prefer the |
| 12 | + * `glTF-Binary` variant, fall back to `glTF`) to build each model's download URL |
| 13 | + * - `useGLTF` + `Suspense`, keyed on the resolved model URL, as a hot-swappable model |
| 14 | + * loader — swapping the leva dropdown unmounts the old model subtree and suspends a |
| 15 | + * fresh `useGLTF` fetch, replacing the original's manual traverse-and-dispose teardown |
| 16 | + * in `loadModel` with a React unmount + drei's suspense cache |
| 17 | + * - A raw `THREE.AnimationMixer` playing EVERY clip on the loaded model (matching the |
| 18 | + * original's `for (const animation of gltf.animations) mixer.clipAction(animation) |
| 19 | + * .play()`) instead of `useAnimations`' by-name pattern used elsewhere in this repo — |
| 20 | + * appropriate here because clip names are unknowable ahead of time for an arbitrary, |
| 21 | + * user-selectable community model catalog |
| 22 | + * - drei's `Environment` (`/webgpu`) background with a reactive `backgroundBlurriness` |
| 23 | + * leva control, replacing the original's manual HDR loader + `scene.backgroundBlurriness` |
| 24 | + * dat.gui wiring |
| 25 | + * - `renderer.toneMapping` set via the `<Canvas renderer={{ toneMapping }}>` prop |
| 26 | + * |
| 27 | + * DIVERGENCE from original |
| 28 | + * - HDR swapped: the original's `royal_esplanade_2k.hdr.jpg` is an UltraHDR JPEG that |
| 29 | + * needs three.js's `UltraHDRLoader`; drei's `/webgpu` `Environment`/`useEnvironment` |
| 30 | + * doesn't wire that loader up. Uses `quarry_01_1k.hdr` (plain Radiance HDR, also from |
| 31 | + * the three.js examples, r185-pinned) instead — same IBL/background technique, a |
| 32 | + * different environment. |
| 33 | + * - Per-model auto camera framing (the original's Box3-based `fitCameraToSelection`, |
| 34 | + * which recomputes camera position + orbit distance limits on every model swap) is |
| 35 | + * DROPPED — a real, verified gap, not routed around silently: |
| 36 | + * 1) `camera-controls` v3's `update()` unconditionally overwrites `camera.position`/ |
| 37 | + * `lookAt()` every frame from its own internal spherical/target state (verified |
| 38 | + * against `node_modules/camera-controls` dist source) — external `camera.position` |
| 39 | + * mutation is silently discarded on the next frame; only the instance's own |
| 40 | + * `fitToBox`/`setLookAt`/`moveTo` methods can reframe it. |
| 41 | + * 2) Fiber's `<Canvas camera={{ position, fov, near, far }}>` config is applied ONCE |
| 42 | + * at Canvas creation (verified against `renderer.tsx`'s `configure()`) — passing a |
| 43 | + * new `camera` prop object on re-render is a no-op. |
| 44 | + * `src/utils/CameraControls.tsx` doesn't expose an imperative fit/moveTo escape hatch |
| 45 | + * (and this port is scoped to not modify `src/utils/`), so there is no reactive path |
| 46 | + * to reframe per model. The camera stays fixed at the original's own DamagedHelmet- |
| 47 | + * tuned framing (position/fov/target/min-max distance below, identical to the |
| 48 | + * original's OrbitControls defaults for its default model). Swapping to a very |
| 49 | + * differently-scaled sample model (BoomBox, LittlestTokyo, ABeautifulGame, ... all |
| 50 | + * confirmed loadable) may render very small/large until the user manually dollies — |
| 51 | + * flagged as a CameraControls gap (needs a `fitToBox`/`moveTo` escape hatch), not a bug. |
| 52 | + * - Manual per-mesh geometry/material/texture disposal on model swap (the original's |
| 53 | + * teardown before loading the next model) is dropped in favor of React unmount + |
| 54 | + * drei's `useGLTF` suspense cache, consistent with this repo's existing simplification |
| 55 | + * pattern (`instance-mesh`) — repeatedly swapping through many large models in one |
| 56 | + * session grows GPU memory faster than the original; a real tradeoff, not a bug. |
| 57 | + * - `renderer.inspector.createParameters` dat.gui panel replaced by leva: `model` |
| 58 | + * dropdown (sourced from the live-fetched index) and `blurriness` slider — the same |
| 59 | + * two parameters the original exposes. |
| 60 | + */ |
| 61 | +import { Suspense, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' |
| 62 | +import { Canvas, useFrame } from '@react-three/fiber/webgpu' |
| 63 | +import { Environment, useGLTF } from '@react-three/drei/webgpu' |
| 64 | +import { useControls } from 'leva' |
| 65 | +import { AnimationMixer, ACESFilmicToneMapping } from 'three/webgpu' |
| 66 | +import { DemoHelpers } from '../utils/DemoHelpers' |
| 67 | + |
| 68 | +const MODEL_INDEX_URL = |
| 69 | + 'https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/main/Models/model-index.json' |
| 70 | +const MODEL_BASE_URL = 'https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/main/Models' |
| 71 | +const DEFAULT_MODEL_URL = `${MODEL_BASE_URL}/DamagedHelmet/glTF-Binary/DamagedHelmet.glb` |
| 72 | +const HDR_URL = 'https://cdn.jsdelivr.net/gh/mrdoob/three.js@r185/examples/textures/equirectangular/quarry_01_1k.hdr' |
| 73 | + |
| 74 | +interface GltfSampleModel { |
| 75 | + name: string |
| 76 | + variants: Record<string, string> |
| 77 | +} |
| 78 | + |
| 79 | +// Mirrors the original's `loadModel`: prefer the single-file glTF-Binary variant, |
| 80 | +// fall back to the multi-file glTF variant; the variant's own extension picks the |
| 81 | +// KhronosGroup repo folder ('glTF-Binary' vs 'glTF'). |
| 82 | +function resolveModelUrl(model: GltfSampleModel): string { |
| 83 | + const variant = model.variants['glTF-Binary'] ?? model.variants['glTF'] |
| 84 | + const folder = variant.endsWith('.glb') ? 'glTF-Binary' : 'glTF' |
| 85 | + return `${MODEL_BASE_URL}/${model.name}/${folder}/${variant}` |
| 86 | +} |
| 87 | + |
| 88 | +// Plays every animation clip on the loaded model via a raw AnimationMixer — the |
| 89 | +// original's own behavior (`for (const animation of gltf.animations) ...play()`). |
| 90 | +// Not drei's `useAnimations`: clip names are unknown ahead of time for an arbitrary, |
| 91 | +// user-picked community model, so there's no "by name" list to play selectively. |
| 92 | +function Model({ url }: { url: string }) { |
| 93 | + const { scene, animations } = useGLTF(url) |
| 94 | + const mixerRef = useRef<AnimationMixer | null>(null) |
| 95 | + |
| 96 | + useLayoutEffect(() => { |
| 97 | + if (animations.length === 0) return |
| 98 | + const mixer = new AnimationMixer(scene) |
| 99 | + for (const clip of animations) mixer.clipAction(clip).play() |
| 100 | + mixerRef.current = mixer |
| 101 | + return () => { |
| 102 | + mixer.stopAllAction() |
| 103 | + mixerRef.current = null |
| 104 | + } |
| 105 | + }, [scene, animations]) |
| 106 | + |
| 107 | + useFrame((_, delta) => { |
| 108 | + mixerRef.current?.update(delta) |
| 109 | + }) |
| 110 | + |
| 111 | + return <primitive object={scene} /> |
| 112 | +} |
| 113 | + |
| 114 | +export default function LoaderGltf() { |
| 115 | + const [modelIndex, setModelIndex] = useState<GltfSampleModel[]>([]) |
| 116 | + |
| 117 | + useEffect(() => { |
| 118 | + let cancelled = false |
| 119 | + fetch(MODEL_INDEX_URL) |
| 120 | + .then((res) => res.json()) |
| 121 | + .then((models: GltfSampleModel[]) => { |
| 122 | + if (!cancelled) setModelIndex(models) |
| 123 | + }) |
| 124 | + return () => { |
| 125 | + cancelled = true |
| 126 | + } |
| 127 | + }, []) |
| 128 | + |
| 129 | + const modelNames = useMemo(() => modelIndex.map((m) => m.name), [modelIndex]) |
| 130 | + |
| 131 | + const { model, blurriness } = useControls( |
| 132 | + 'loader-gltf', |
| 133 | + { |
| 134 | + model: { value: 'DamagedHelmet', options: modelNames.length > 0 ? modelNames : ['DamagedHelmet'] }, |
| 135 | + blurriness: { value: 0, min: 0, max: 1, step: 0.01 }, |
| 136 | + }, |
| 137 | + [modelNames], |
| 138 | + ) |
| 139 | + |
| 140 | + const modelInfo = modelIndex.find((m) => m.name === model) |
| 141 | + const modelUrl = modelInfo ? resolveModelUrl(modelInfo) : DEFAULT_MODEL_URL |
| 142 | + |
| 143 | + return ( |
| 144 | + <Canvas |
| 145 | + renderer={{ toneMapping: ACESFilmicToneMapping }} |
| 146 | + camera={{ position: [-1.8, 0.6, 2.7], fov: 45, near: 0.25, far: 20 }} |
| 147 | + > |
| 148 | + <Suspense fallback={null}> |
| 149 | + <Environment files={HDR_URL} background backgroundBlurriness={blurriness} /> |
| 150 | + <Model key={modelUrl} url={modelUrl} /> |
| 151 | + </Suspense> |
| 152 | + {/* Grid off: HDR environment fills the frame and the helmet floats at origin — |
| 153 | + the infinite grid renders through both (same call as tonemapping/bloom). */} |
| 154 | + <DemoHelpers grid={false} target={[0, 0, -0.2]} minDistance={2} maxDistance={10} /> |
| 155 | + </Canvas> |
| 156 | + ) |
| 157 | +} |
0 commit comments