Skip to content

Commit 66f6d73

Browse files
DennisSmolekclaude
andcommitted
feat: clipping + loader-gltf — wave-3 pair 2 (agents); controlsRef escape hatch
clipping: nested clippingGroup JSX intrinsics (auto-derived from three/webgpu exports, no extend needed), clip shadows + intersection + alphaToCoverage. loader-gltf: live KhronosGroup catalog dropdown (148 models), animations via raw mixer (documented exception to play-by-name — arbitrary catalog models). Review fix: grid off (was slicing the helmet). CameraControls/DemoHelpers gain controlsRef — escape hatch to the live camera-controls instance (fitToBox/setLookAt); flagged by loader-gltf which could not Box3-frame models (external camera.position writes are overwritten by update() every frame). Follow-up: wire auto-framing in loader-gltf. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 0c7b02e commit 66f6d73

6 files changed

Lines changed: 371 additions & 3 deletions

File tree

AGENTS.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,10 @@ end up as an example fix OR an amendment here (with a changelog entry) — never
192192
- `<DemoHelpers>` ([src/utils/DemoHelpers.tsx](src/utils/DemoHelpers.tsx)) goes in every
193193
example: grid + CameraControls baseline, toggleable via props (`grid={false}` etc.
194194
when the original look demands it). It also carries the render-readiness signal —
195-
include it even with everything visual turned off.
195+
include it even with everything visual turned off. For imperative camera moves
196+
(`fitToBox`, `setLookAt` — e.g. Box3 auto-framing of loaded models) use the
197+
`controlsRef` escape hatch; writing `camera.position` directly is futile,
198+
camera-controls' `update()` overwrites it every frame.
196199
- Controls via leva `useControls('<group>', { … })`. Direct value controls beat
197200
buttons that hide state (e.g. weight sliders instead of crossfade buttons).
198201
- Assets: hotlink jsdelivr pinned to the three.js release —

src/examples.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,5 +129,18 @@
129129
"title": "Morph Targets",
130130
"tags": ["morphtargets", "geometry", "leva"],
131131
"original": "https://threejs.org/examples/#webgpu_morphtargets"
132+
},
133+
{
134+
"slug": "clipping",
135+
"title": "Clipping Planes",
136+
"tags": ["clipping", "shadows", "node-material", "leva"],
137+
"original": "https://threejs.org/examples/#webgpu_clipping"
138+
},
139+
{
140+
"slug": "loader-gltf",
141+
"title": "Loader / glTF",
142+
"tags": ["gltf", "loader", "environment", "hdr", "animation", "leva"],
143+
"original": "https://threejs.org/examples/#webgpu_loader_gltf",
144+
"credits": "glTF sample models from the Khronos Group glTF-Sample-Assets repository; quarry_01 HDR from the three.js examples (Poly Haven)"
132145
}
133146
]

src/examples/clipping.tsx

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
/**
2+
* clipping
3+
* R3F port of three.js `webgpu_clipping`, running on WebGPU.
4+
* Original: https://threejs.org/examples/#webgpu_clipping (~155 lines of JS)
5+
*
6+
* DEMONSTRATES
7+
* - `ClippingGroup` (a `THREE.Group` subclass, WebGPURenderer-only): clipping state
8+
* lives in the scene graph instead of a global `renderer.clippingPlanes` array —
9+
* every descendant inherits its nearest ancestor `ClippingGroup`'s `clippingPlanes`
10+
* - Nested `ClippingGroup`s composing: an outer group clips everything (knot + ground)
11+
* with one world-space plane; an inner group (torus knot only) clips with two more
12+
* planes AND `clipIntersection` — the *intersection* of half-spaces (carves a notch)
13+
* rather than the default union
14+
* - Per-group `enabled`/`clipShadows` toggles — `clipShadows` clips the shadow pass
15+
* independently of whether the visible geometry is being clipped
16+
* - `alphaToCoverage` on `MeshPhongNodeMaterial`: MSAA-based antialiasing of the clip
17+
* edge instead of a hard, aliased cut
18+
*
19+
* DIVERGENCE from original
20+
* - `renderer.inspector`'s dat.gui-style panel (`createParameters`/`addFolder`)
21+
* replaced with leva controls — same mapping: per-group Enabled/Plane, the knot
22+
* group's extra Shadows/Intersection toggles, and the shared alphaToCoverage flag
23+
* - OrbitControls replaced with this repo's CameraControls wrapper (via DemoHelpers),
24+
* `target` kept at the original's `[0, 1, 0]`
25+
* - `Date.now()`-based `startTime`/elapsed-time bookkeeping dropped; `useFrame`'s
26+
* `state.elapsed` drives the knot's spin/bob/scale directly
27+
*/
28+
import { useEffect, useMemo, useRef } from 'react'
29+
import { Canvas, useFrame } from '@react-three/fiber/webgpu'
30+
import { folder, useControls } from 'leva'
31+
import { DoubleSide, Plane, Vector3 } from 'three/webgpu'
32+
import type { ClippingGroup, Mesh, MeshPhongNodeMaterial } from 'three/webgpu'
33+
import { DemoHelpers } from '../utils/DemoHelpers'
34+
35+
interface ClippingSceneProps {
36+
alphaToCoverage: boolean
37+
knotEnabled: boolean
38+
knotShadows: boolean
39+
knotIntersection: boolean
40+
knotPlane: number
41+
globalEnabled: boolean
42+
globalPlane: number
43+
}
44+
45+
function ClippingScene({
46+
alphaToCoverage,
47+
knotEnabled,
48+
knotShadows,
49+
knotIntersection,
50+
knotPlane,
51+
globalEnabled,
52+
globalPlane,
53+
}: ClippingSceneProps) {
54+
const knotRef = useRef<Mesh>(null)
55+
const knotMaterialRef = useRef<MeshPhongNodeMaterial>(null)
56+
const groundMaterialRef = useRef<MeshPhongNodeMaterial>(null)
57+
const globalGroupRef = useRef<ClippingGroup>(null)
58+
59+
// Plain three.js `Plane` instances, mutated in place — a `ClippingGroup` reads the
60+
// same Plane objects every frame, and Plane has no reactive JSX representation.
61+
const globalPlaneObj = useMemo(() => new Plane(new Vector3(-1, 0, 0), 0.1), [])
62+
const localPlane1 = useMemo(() => new Plane(new Vector3(0, -1, 0), 0.8), [])
63+
const localPlane2 = useMemo(() => new Plane(new Vector3(0, 0, -1), 0.1), [])
64+
65+
const globalClippingPlanes = useMemo(() => [globalPlaneObj], [globalPlaneObj])
66+
const knotClippingPlanes = useMemo(() => [localPlane1, localPlane2], [localPlane1, localPlane2])
67+
68+
useEffect(() => {
69+
globalPlaneObj.constant = globalPlane
70+
}, [globalPlaneObj, globalPlane])
71+
72+
useEffect(() => {
73+
localPlane1.constant = knotPlane
74+
}, [localPlane1, knotPlane])
75+
76+
useEffect(() => {
77+
const knotMat = knotMaterialRef.current
78+
const groundMat = groundMaterialRef.current
79+
if (knotMat) {
80+
knotMat.alphaToCoverage = alphaToCoverage
81+
knotMat.needsUpdate = true
82+
}
83+
if (groundMat) {
84+
groundMat.alphaToCoverage = alphaToCoverage
85+
groundMat.needsUpdate = true
86+
}
87+
}, [alphaToCoverage])
88+
89+
useFrame((state) => {
90+
const knot = knotRef.current
91+
if (!knot) return
92+
const time = state.elapsed
93+
knot.position.y = 0.8
94+
knot.rotation.x = time * 0.5
95+
knot.rotation.y = time * 0.2
96+
knot.scale.setScalar(Math.cos(time) * 0.125 + 0.875)
97+
})
98+
99+
return (
100+
<>
101+
<ambientLight color="#cccccc" />
102+
<spotLight
103+
color="#ffffff"
104+
intensity={60}
105+
angle={Math.PI / 5}
106+
penumbra={0.2}
107+
position={[2, 3, 3]}
108+
castShadow
109+
shadow-camera-near={3}
110+
shadow-camera-far={10}
111+
shadow-mapSize-width={2048}
112+
shadow-mapSize-height={2048}
113+
shadow-radius={4}
114+
/>
115+
<directionalLight
116+
color="#55505a"
117+
intensity={3}
118+
position={[0, 3, 0]}
119+
castShadow
120+
shadow-camera-near={1}
121+
shadow-camera-far={10}
122+
shadow-camera-left={-1}
123+
shadow-camera-right={1}
124+
shadow-camera-top={1}
125+
shadow-camera-bottom={-1}
126+
shadow-mapSize-width={1024}
127+
shadow-mapSize-height={1024}
128+
/>
129+
130+
{/* Outer group: clips knot + ground with one global-space plane. */}
131+
<clippingGroup ref={globalGroupRef} clippingPlanes={globalClippingPlanes} enabled={globalEnabled}>
132+
{/* Inner group: knot only, two planes, intersection (notch) instead of union. */}
133+
<clippingGroup
134+
clippingPlanes={knotClippingPlanes}
135+
clipIntersection={knotIntersection}
136+
clipShadows={knotShadows}
137+
enabled={knotEnabled}
138+
>
139+
<mesh ref={knotRef} castShadow>
140+
<torusKnotGeometry args={[0.4, 0.08, 95, 20]} />
141+
<meshPhongNodeMaterial ref={knotMaterialRef} color="#80ee10" shininess={0} side={DoubleSide} alphaToCoverage />
142+
</mesh>
143+
</clippingGroup>
144+
145+
<mesh rotation-x={-Math.PI / 2} receiveShadow>
146+
<planeGeometry args={[9, 9, 1, 1]} />
147+
<meshPhongNodeMaterial ref={groundMaterialRef} color="#a0adaf" shininess={150} alphaToCoverage />
148+
</mesh>
149+
</clippingGroup>
150+
</>
151+
)
152+
}
153+
154+
export default function Clipping() {
155+
const { alphaToCoverage, knotEnabled, knotShadows, knotIntersection, knotPlane, globalEnabled, globalPlane } =
156+
useControls('clipping', {
157+
alphaToCoverage: true,
158+
knot: folder({
159+
knotEnabled: { value: true, label: 'Enabled' },
160+
knotShadows: { value: false, label: 'Shadows' },
161+
knotIntersection: { value: true, label: 'Intersection' },
162+
knotPlane: { value: 0.8, min: 0.3, max: 1.25, step: 0.01, label: 'Plane' },
163+
}),
164+
global: folder({
165+
globalEnabled: { value: true, label: 'Enabled' },
166+
globalPlane: { value: 0.1, min: -0.4, max: 3, step: 0.01, label: 'Plane' },
167+
}),
168+
})
169+
170+
return (
171+
<Canvas renderer shadows background="#000000" camera={{ position: [0, 1.3, 3], fov: 36, near: 0.25, far: 16 }}>
172+
<ClippingScene
173+
alphaToCoverage={alphaToCoverage}
174+
knotEnabled={knotEnabled}
175+
knotShadows={knotShadows}
176+
knotIntersection={knotIntersection}
177+
knotPlane={knotPlane}
178+
globalEnabled={globalEnabled}
179+
globalPlane={globalPlane}
180+
/>
181+
<DemoHelpers target={[0, 1, 0]} />
182+
</Canvas>
183+
)
184+
}

src/examples/loader-gltf.tsx

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
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

Comments
 (0)