Skip to content

Commit e882f96

Browse files
DennisSmolekclaude
andcommitted
feat: camera-array + backdrop-area — wave-5 pair 2 (agents)
camera-array: ArrayCamera handed straight to Canvas — fiber default render job multiplexes it natively, no phase takeover (verified against fiber source); camera.manual stops resize fights; Canvas remounts on grid-size change. backdrop-area: hashBlur/depth/checker/pixel backdrop material switcher on the glass box. Review fix: DemoHelpers grid off (example draws its own radially-fading grid floor). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 0524eb6 commit e882f96

3 files changed

Lines changed: 351 additions & 0 deletions

File tree

src/examples.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,5 +238,18 @@
238238
"tags": ["lights", "phong", "tsl", "node-material", "fog", "leva"],
239239
"original": "https://threejs.org/examples/#webgpu_lights_phong",
240240
"credits": "roughness_map and Water_1_M_Normal textures from the three.js examples"
241+
},
242+
{
243+
"slug": "backdrop-area",
244+
"title": "Backdrop Area",
245+
"tags": ["tsl", "node-material", "backdrop", "depth", "gltf", "leva"],
246+
"original": "https://threejs.org/examples/#webgpu_backdrop_area",
247+
"credits": "Michelle model from the three.js examples"
248+
},
249+
{
250+
"slug": "camera-array",
251+
"title": "Camera Array",
252+
"tags": ["camera", "arraycamera", "shadows", "node-material", "leva"],
253+
"original": "https://threejs.org/examples/#webgpu_camera_array"
241254
}
242255
]

src/examples/backdrop-area.tsx

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/**
2+
* backdrop-area
3+
* R3F port of three.js `webgpu_backdrop_area`, running on WebGPU.
4+
* Original: https://threejs.org/examples/#webgpu_backdrop_area (~140 lines of JS)
5+
*
6+
* DEMONSTRATES
7+
* - `viewportLinearDepth` (already-rendered scene depth) compared against `linearDepth()`
8+
* (the mesh currently being shaded) into a distance field (`depthDistance`) that drives
9+
* both a soft depth-based alpha falloff and a `hashBlur()`-blurred
10+
* `viewportSharedTexture()` sample — the same `backdropNode` escape hatch family as
11+
* `backdrop`/`backdrop-water`, here demonstrating FOUR alternate
12+
* `MeshBasicNodeMaterial.backdropNode` graphs on ONE box, switchable at runtime
13+
* (depth-tinted blurred glass, raw depth silhouette, a `checker()`-masked blur, and a
14+
* pixelated `viewportSharedTexture(screenUV...floor...)` sample)
15+
* - `modelScale` — a TSL builtin that reads the mesh's live `scale` each frame, feeding
16+
* the checker material's UV tiling; the box's leva scale sliders need no manual
17+
* uniform sync, just a plain `<mesh scale={...}>`
18+
* - `scene.backgroundNode` — a `screenUV.y` sky gradient rotated over time via `hue()`
19+
* (same cast pattern as `backdrop`'s `SceneBackground`, `time`-driven here)
20+
* - `positionWorld.xz.distance(0)` on the floor's `opacityNode` — a world-space radial
21+
* falloff that fades the floor to nothing at its edge instead of a hard clip
22+
*
23+
* DIVERGENCE from original
24+
* - The material switcher + box scale sliders move from `renderer.inspector` (three.js's
25+
* internal debug GUI, not wired in this repo — same gap noted in
26+
* `backdrop`/`backdrop-water`/`refraction`/`reflection`) to leva
27+
* - DemoHelpers baseline (grid + camera-controls orbit) added; `target` matches the
28+
* original's `camera.lookAt`/`controls.target` of `(0, 1, 0)`
29+
* - `NeutralToneMapping` / `toneMappingExposure: 0.9` set via `<Canvas renderer={{...}}>`
30+
* (Layer 1 rule) instead of imperative `renderer.toneMapping` assignment
31+
* - `renderer.inspector` dropped entirely (same gap as above)
32+
*/
33+
import { Suspense, useEffect, useMemo } from 'react'
34+
import { Canvas, useThree } from '@react-three/fiber/webgpu'
35+
import { useAnimations, useGLTF } from '@react-three/drei/webgpu'
36+
import { useControls } from 'leva'
37+
import { hashBlur } from 'three/addons/tsl/display/hashBlur.js'
38+
import {
39+
checker,
40+
color,
41+
hue,
42+
linearDepth,
43+
modelScale,
44+
positionWorld,
45+
screenUV,
46+
time,
47+
uv,
48+
viewportLinearDepth,
49+
viewportSharedTexture,
50+
} from 'three/tsl'
51+
import { DoubleSide, MeshBasicNodeMaterial, NeutralToneMapping } from 'three/webgpu'
52+
import type { Node } from 'three/webgpu'
53+
import { DemoHelpers } from '../utils/DemoHelpers'
54+
55+
const MICHELLE_URL = 'https://cdn.jsdelivr.net/gh/mrdoob/three.js@r185/examples/models/gltf/Michelle.glb'
56+
57+
// scene.backgroundNode cast — @types/three's Scene doesn't declare it even though the
58+
// webgpu renderer reads it directly off the live scene instance (same duck-typed gap as
59+
// backdrop's SceneBackground, with a hue() rotation over time added here).
60+
function SceneBackground() {
61+
const scene = useThree((s) => s.scene)
62+
63+
useEffect(() => {
64+
const withBackgroundNode = scene as unknown as { backgroundNode: Node | null }
65+
withBackgroundNode.backgroundNode = hue(screenUV.y.mix(color(0x66bbff), color(0x4466ff)), time.mul(0.1))
66+
return () => {
67+
withBackgroundNode.backgroundNode = null
68+
}
69+
}, [scene])
70+
71+
return null
72+
}
73+
74+
function Michelle() {
75+
const { scene, animations } = useGLTF(MICHELLE_URL)
76+
const { actions } = useAnimations(animations, scene)
77+
78+
useEffect(() => {
79+
// Michelle.glb ships a single clip — same "first action" idiom as backdrop/
80+
// backdrop-water (no named-clip ambiguity to worry about here).
81+
const first = Object.values(actions)[0]
82+
first?.play()
83+
}, [actions])
84+
85+
return <primitive object={scene} />
86+
}
87+
88+
// Four alternate `backdropNode` graphs for the box, switchable via leva — see header
89+
// DEMONSTRATES. Built once: none of these graphs depend on React state (the checker
90+
// material's tiling reads the box's live scale through `modelScale`, not a uniform we
91+
// manage).
92+
function useAreaMaterials() {
93+
return useMemo(() => {
94+
const depthDistance = viewportLinearDepth.distance(linearDepth())
95+
const depthAlphaNode = depthDistance.oneMinus().smoothstep(0.9, 2).mul(10).saturate()
96+
const depthBlurred = hashBlur(viewportSharedTexture(), depthDistance.smoothstep(0, 0.6).mul(40).clamp().mul(0.1))
97+
98+
const blurred = new MeshBasicNodeMaterial()
99+
blurred.backdropNode = depthBlurred.add(depthAlphaNode.mix(color(0x003399).mul(0.3), 0))
100+
blurred.transparent = true
101+
blurred.side = DoubleSide
102+
103+
const depth = new MeshBasicNodeMaterial()
104+
depth.backdropNode = depthAlphaNode
105+
depth.transparent = true
106+
depth.side = DoubleSide
107+
108+
const checkerMat = new MeshBasicNodeMaterial()
109+
checkerMat.backdropNode = hashBlur(viewportSharedTexture(), 0.05)
110+
checkerMat.backdropAlphaNode = checker(uv().mul(3).mul(modelScale.xy))
111+
checkerMat.opacityNode = checkerMat.backdropAlphaNode
112+
checkerMat.transparent = true
113+
checkerMat.side = DoubleSide
114+
115+
const pixel = new MeshBasicNodeMaterial()
116+
pixel.backdropNode = viewportSharedTexture(screenUV.mul(100).floor().div(100))
117+
pixel.transparent = true
118+
119+
return { blurred, depth, checker: checkerMat, pixel }
120+
}, [])
121+
}
122+
123+
function Scene() {
124+
const materials = useAreaMaterials()
125+
const { material, scaleX, scaleY } = useControls('backdrop-area', {
126+
material: { value: 'blurred', options: Object.keys(materials) },
127+
scaleX: { value: 1, min: 0.1, max: 2, step: 0.01, label: 'box scale x' },
128+
scaleY: { value: 1, min: 0.1, max: 2, step: 0.01, label: 'box scale y' },
129+
})
130+
131+
return (
132+
<>
133+
<ambientLight intensity={2.5} />
134+
<Suspense fallback={null}>
135+
<Michelle />
136+
</Suspense>
137+
<mesh
138+
position={[0, 1, 0]}
139+
scale={[scaleX, scaleY, 1]}
140+
material={materials[material as keyof typeof materials]}
141+
renderOrder={1}
142+
>
143+
<boxGeometry args={[2, 2, 2]} />
144+
</mesh>
145+
<mesh position={[0, 0, 0]}>
146+
<boxGeometry args={[5, 0.01, 5]} />
147+
<meshBasicNodeMaterial
148+
color={0xff6600}
149+
opacityNode={positionWorld.xz.distance(0).oneMinus().clamp()}
150+
transparent
151+
depthWrite={false}
152+
/>
153+
</mesh>
154+
</>
155+
)
156+
}
157+
158+
export default function BackdropArea() {
159+
return (
160+
<Canvas
161+
renderer={{ toneMapping: NeutralToneMapping, toneMappingExposure: 0.9 }}
162+
camera={{ position: [3, 2, 3], fov: 50, near: 0.25, far: 25 }}
163+
>
164+
<SceneBackground />
165+
<Scene />
166+
{/* Grid off: the example draws its own radially-fading grid floor — the
167+
DemoHelpers infinite grid double-exposes against it. */}
168+
<DemoHelpers grid={false} target={[0, 1, 0]} />
169+
</Canvas>
170+
)
171+
}

src/examples/camera-array.tsx

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
/**
2+
* camera-array
3+
* R3F port of three.js `webgpu_camera_array`, running on WebGPU.
4+
* Original: https://threejs.org/examples/#webgpu_camera_array (~110 lines of JS)
5+
*
6+
* DEMONSTRATES
7+
* - `THREE.ArrayCamera` handed to `<Canvas camera={...}>` as fiber's own default
8+
* camera: WebGPURenderer natively multiplexes ONE `render(scene, camera)` call
9+
* across `camera.cameras[]`, each sub-camera drawn into its own `.viewport` rect —
10+
* no manual scissor/viewport loop or render-phase takeover needed. (Contrast
11+
* `camera/CameraRig.tsx`'s `{ phase: 'render' }` takeover, which exists because
12+
* THAT example is about the manual scissor mechanism itself — verified fiber's
13+
* default render job is exactly `renderer.render(state.scene, state.camera)`,
14+
* `packages/fiber/src/core/renderer.tsx`, so an ArrayCamera reproduces the
15+
* original's `renderer.render(scene, camera)` call unmodified.)
16+
* - `camera.manual = true` — fiber's per-camera escape hatch
17+
* (`ThreeCamera = (Orthographic|Perspective) & { manual?: boolean }`,
18+
* `packages/fiber/types/renderer.d.ts`) — so fiber's resize-driven aspect/
19+
* projection update leaves the array's sub-camera projections alone; this
20+
* example's own effect owns them instead, exactly like the original's
21+
* `updateCameras()`.
22+
* - fiber's reactive `size` (via `useThree`) recomputing every sub-camera's
23+
* viewport + aspect on resize, replacing the original's manual
24+
* `window.addEventListener('resize', ...)` handler.
25+
*
26+
* DIVERGENCE from original
27+
* - Grid size (`AMOUNT`, 6 -> 36 sub-cameras) is a leva control instead of a
28+
* hardcoded constant. Changing sub-camera COUNT means a new `ArrayCamera`
29+
* instance, so the `<Canvas>` remounts via `key={amount}` alongside it — simpler
30+
* and safer than trying to hot-swap fiber's default camera object mid-session.
31+
* - Cylinder rotation is delta-scaled (`delta * rotationSpeed * ...`) instead of the
32+
* original's fixed per-tick increment (`+= 0.005`) — frame-rate independent, and
33+
* `rotationSpeed` is a leva control (default 1 reproduces the original's pace).
34+
* - Cylinder/background colors are leva controls instead of hardcoded hex — direct-
35+
* value controls over hidden state, per this repo's controls convention.
36+
* - Background plane and lights use the `*NodeMaterial` family
37+
* (`meshPhongNodeMaterial`) instead of the original's classic `MeshPhongMaterial`
38+
* — this repo's `/webgpu` convention (materials-basic.tsx, lights-pointlights.tsx),
39+
* auto-extended into JSX, not a behavior change.
40+
* - DemoHelpers' grid AND orbit camera-controls are both disabled (`grid={false}
41+
* controls={false}`): there is no single user-navigable camera here — the whole
42+
* point is the 36-camera grid rendered in one pass — so CameraControls has
43+
* nothing sensible to attach to (same rationale as `camera/camera.tsx`).
44+
* DemoHelpers stays mounted for the readiness signal.
45+
*/
46+
import { useEffect, useMemo, useRef } from 'react'
47+
import { useControls } from 'leva'
48+
import { Canvas, useFrame, useThree } from '@react-three/fiber/webgpu'
49+
import { ArrayCamera, PerspectiveCamera, Vector4 } from 'three/webgpu'
50+
import type { Mesh } from 'three/webgpu'
51+
import { DemoHelpers } from '../utils/DemoHelpers'
52+
53+
const BACKGROUND_SIZE = 100
54+
55+
// fiber's `ThreeCamera` union types `.manual` on Orthographic/PerspectiveCamera; ArrayCamera
56+
// (a PerspectiveCamera subclass, `reference/three.js/src/cameras/ArrayCamera.js`) inherits the
57+
// runtime behavior but isn't itself in that union — same duck-typed-property cast family as
58+
// AGENTS.md's `*Node` field casts (UPSTREAM.md B11), local to this file.
59+
type ManualArrayCamera = ArrayCamera & { manual?: boolean }
60+
61+
// Builds the ArrayCamera + its `amount * amount` sub-cameras (original's `init()`). Each
62+
// sub-camera gets its own `.viewport` — a plain bolt-on `Vector4`, exactly like the original;
63+
// `@types/three`'s base `Camera` declares `viewport?: Vector4` for it. Positions/aspect are
64+
// filled in reactively by `CameraArrayRig`'s size effect below.
65+
function createArrayCamera(amount: number): ManualArrayCamera {
66+
const subCameras: PerspectiveCamera[] = []
67+
for (let i = 0; i < amount * amount; i++) {
68+
const subCamera = new PerspectiveCamera(40, 1, 0.1, 10)
69+
subCamera.viewport = new Vector4()
70+
subCameras.push(subCamera)
71+
}
72+
73+
const arrayCamera = new ArrayCamera(subCameras) as ManualArrayCamera
74+
arrayCamera.position.z = 3
75+
arrayCamera.manual = true
76+
77+
return arrayCamera
78+
}
79+
80+
interface CameraArrayRigProps {
81+
amount: number
82+
rotationSpeed: number
83+
cylinderColor: string
84+
}
85+
86+
// Owns the two things the original's `updateCameras()`/`animate()` mutate every frame or
87+
// resize: sub-camera viewport+aspect (size-driven), and the tracked cylinder's spin.
88+
function CameraArrayRig({ amount, rotationSpeed, cylinderColor }: CameraArrayRigProps) {
89+
const camera = useThree((state) => state.camera) as ManualArrayCamera
90+
const size = useThree((state) => state.size)
91+
const meshRef = useRef<Mesh>(null)
92+
93+
// Sub-camera viewport + aspect follow the CANVAS size — fiber's reactive `size` replaces
94+
// the original's manual `window.addEventListener('resize', ...)` handler. Positions/lookAt
95+
// don't actually depend on size, but the original recomputes them on every call to
96+
// `updateCameras()` too (init AND resize) — cheap, and keeps this the single source of
97+
// truth for the whole rig instead of splitting it across a one-time effect and a resize one.
98+
useEffect(() => {
99+
const aspect = size.width / size.height
100+
const width = size.width / amount
101+
const height = size.height / amount
102+
103+
camera.aspect = aspect
104+
camera.updateProjectionMatrix()
105+
106+
for (let y = 0; y < amount; y++) {
107+
for (let x = 0; x < amount; x++) {
108+
const subCamera = camera.cameras[amount * y + x]
109+
subCamera.copy(camera) // fov/aspect/near/far from the root camera
110+
111+
// Non-null: every sub-camera got a `.viewport` in `createArrayCamera` above.
112+
subCamera.viewport!.set(Math.floor(x * width), Math.floor(y * height), Math.ceil(width), Math.ceil(height))
113+
subCamera.updateProjectionMatrix()
114+
115+
subCamera.position.x = x / amount - 0.5
116+
subCamera.position.y = 0.5 - y / amount
117+
subCamera.position.z = 1.5 + (x + y) * 0.5
118+
subCamera.position.multiplyScalar(2)
119+
120+
subCamera.lookAt(0, 0, 0)
121+
subCamera.updateMatrixWorld()
122+
}
123+
}
124+
}, [camera, amount, size.width, size.height])
125+
126+
useFrame((_state, delta) => {
127+
const mesh = meshRef.current
128+
if (!mesh) return
129+
// 0.3/0.6 rad/s reproduces the original's fixed `+= 0.005`/`+= 0.01` per-frame pace at
130+
// 60fps, now frame-rate independent via `delta`.
131+
mesh.rotation.x += delta * rotationSpeed * 0.3
132+
mesh.rotation.z += delta * rotationSpeed * 0.6
133+
})
134+
135+
return (
136+
<mesh ref={meshRef} castShadow receiveShadow>
137+
<cylinderGeometry args={[0.5, 0.5, 1, 32]} />
138+
<meshPhongNodeMaterial color={cylinderColor} />
139+
</mesh>
140+
)
141+
}
142+
143+
export default function CameraArrayExample() {
144+
const { amount, rotationSpeed, cylinderColor, backgroundColor } = useControls('camera-array', {
145+
amount: { value: 6, min: 2, max: 8, step: 1, label: 'Grid size (N×N)' },
146+
rotationSpeed: { value: 1, min: 0, max: 3, step: 0.1 },
147+
cylinderColor: { value: '#ff0000', label: 'cylinder color' },
148+
backgroundColor: { value: '#000066', label: 'background color' },
149+
})
150+
151+
// Rebuilt whenever `amount` changes — see DIVERGENCE for why the Canvas remounts
152+
// (`key={amount}`) alongside it rather than hot-swapping fiber's default camera.
153+
const camera = useMemo(() => createArrayCamera(amount), [amount])
154+
155+
return (
156+
<Canvas key={amount} renderer shadows background="#000000" camera={camera}>
157+
<ambientLight color="#999999" />
158+
<directionalLight position={[0.5, 0.5, 1]} intensity={3} castShadow shadow-camera-zoom={4} />
159+
<mesh position={[0, 0, -1]} receiveShadow>
160+
<planeGeometry args={[BACKGROUND_SIZE, BACKGROUND_SIZE]} />
161+
<meshPhongNodeMaterial color={backgroundColor} />
162+
</mesh>
163+
<CameraArrayRig amount={amount} rotationSpeed={rotationSpeed} cylinderColor={cylinderColor} />
164+
<DemoHelpers grid={false} controls={false} />
165+
</Canvas>
166+
)
167+
}

0 commit comments

Comments
 (0)