Skip to content

Commit 70599a6

Browse files
DennisSmolekclaude
andcommitted
feat: shadowmap — wave-2 port (agent); fog rule corrected
maskNode discard casting, receivedShadowPositionNode shadow distortion, animated spot/directional pair. Port verified that plain Fog objects are auto-wrapped by NodeManager under WebGPU — AGENTS.md fog rule narrowed (declarative <fog attach> preferred; scene.fogNode only for custom TSL fog). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 8d77ce7 commit 70599a6

6 files changed

Lines changed: 273 additions & 3 deletions

File tree

AGENTS.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,11 @@ end up as an example fix OR an amendment here (with a changelog entry) — never
8787
typed TSL math (`rotate` etc.) may not resolve through them; cast to
8888
`Node<'float'|'vec3'|…>` with a comment (three-side typing gap, UPSTREAM.md B10 —
8989
same cast family as the fiber UniformNode gap).
90-
- Scene-level TSL fog is `scene.fogNode = fog(color, rangeFogFactor(near, far))` (the
91-
legacy `Fog`/`FogExp2` objects are not the WebGPU path) — needs a documented cast,
92-
`@types/three` doesn't declare `fogNode` (UPSTREAM.md B11; pattern in
90+
- Fog, two paths (verified against `NodeManager.updateFog()`): plain `Fog`/`FogExp2`
91+
set declaratively (`<fog attach="fog" args={…} />`) IS auto-wrapped into a fog node
92+
by the WebGPU renderer — prefer it. Only a CUSTOM TSL fog graph needs
93+
`scene.fogNode = fog(color, rangeFogFactor(near, far))`, which needs a documented
94+
cast — `@types/three` doesn't declare `fogNode` (UPSTREAM.md B11; pattern in
9395
src/examples/sprites.tsx).
9496
- Node materials are auto-extended by the `/webgpu` entry: `<meshStandardNodeMaterial>`
9597
etc. just work in JSX.

src/examples.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,5 +69,11 @@
6969
"tags": ["tsl", "node-material", "planet", "leva"],
7070
"original": "https://threejs.org/examples/#webgpu_tsl_earth",
7171
"credits": "Earth textures from Solar System Scope (resized and merged), via the three.js examples"
72+
},
73+
{
74+
"slug": "shadowmap",
75+
"title": "Shadow Map",
76+
"tags": ["tsl", "shadow", "node-material", "leva"],
77+
"original": "https://threejs.org/examples/#webgpu_shadowmap"
7278
}
7379
]

src/examples/shadowmap/Ground.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Ground plane: `receivedShadowPositionNode` perturbs the position used to sample
2+
// incoming shadow maps (wavy shadow edges), independent of `colorNode`'s own noise
3+
// pattern — see the file header DIVERGENCE re: the original's dead perturbation calc
4+
// in colorNode.
5+
import { useMemo } from 'react'
6+
import { Fn, mx_fractal_noise_vec3, positionWorld } from 'three/tsl'
7+
8+
const BASE_COLOR = '#999999'
9+
10+
export function Ground() {
11+
const receivedShadowPositionNode = useMemo(
12+
() =>
13+
Fn(() => {
14+
const pos = positionWorld.toVar()
15+
pos.xz.addAssign(mx_fractal_noise_vec3(positionWorld.mul(2)).saturate().xz)
16+
return pos
17+
})(),
18+
[],
19+
)
20+
21+
const colorNode = useMemo(() => mx_fractal_noise_vec3(positionWorld.mul(2)).saturate().zzz.mul(0.2).add(0.5), [])
22+
23+
return (
24+
<mesh rotation-x={-Math.PI / 2} scale={3} castShadow receiveShadow>
25+
<planeGeometry args={[200, 200]} />
26+
<meshPhongNodeMaterial
27+
color={BASE_COLOR}
28+
shininess={0}
29+
specular="#111111"
30+
colorNode={colorNode}
31+
receivedShadowPositionNode={receivedShadowPositionNode}
32+
/>
33+
</mesh>
34+
)
35+
}

src/examples/shadowmap/Lights.tsx

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// SpotLight + DirectionalLight, both shadow casters, configured via fiber's dash-path
2+
// props (`shadow-camera-*`, `shadow-mapSize-*`, `shadow-radius`). The directional light
3+
// orbits inside `dirGroup` and bobs along z — the same two independent animations the
4+
// original drives from its bare `animate()` function, now a `useFrame` job.
5+
import { useRef } from 'react'
6+
import { useFrame } from '@react-three/fiber/webgpu'
7+
import type { DirectionalLight, Group } from 'three/webgpu'
8+
9+
export interface LightsProps {
10+
shadowRadius: number
11+
spinSpeed: number
12+
}
13+
14+
export function Lights({ shadowRadius, spinSpeed }: LightsProps) {
15+
const dirGroupRef = useRef<Group>(null)
16+
const dirLightRef = useRef<DirectionalLight>(null)
17+
18+
useFrame((state, delta) => {
19+
const group = dirGroupRef.current
20+
const light = dirLightRef.current
21+
if (!group || !light) return
22+
group.rotation.y += 0.7 * spinSpeed * delta
23+
light.position.z = 17 + Math.sin(state.time * 0.001 * spinSpeed) * 5
24+
})
25+
26+
return (
27+
<>
28+
<ambientLight color="#444444" intensity={2} />
29+
<spotLight
30+
color="#ff8888"
31+
intensity={400}
32+
position={[8, 10, 5]}
33+
angle={Math.PI / 5}
34+
penumbra={0.3}
35+
castShadow
36+
shadow-camera-near={8}
37+
shadow-camera-far={200}
38+
shadow-mapSize-width={2048}
39+
shadow-mapSize-height={2048}
40+
shadow-radius={shadowRadius}
41+
/>
42+
<group ref={dirGroupRef}>
43+
<directionalLight
44+
ref={dirLightRef}
45+
color="#8888ff"
46+
intensity={3}
47+
position={[3, 12, 17]}
48+
castShadow
49+
shadow-camera-near={0.1}
50+
shadow-camera-far={500}
51+
shadow-camera-left={-17}
52+
shadow-camera-right={17}
53+
shadow-camera-top={17}
54+
shadow-camera-bottom={-17}
55+
shadow-mapSize-width={2048}
56+
shadow-mapSize-height={2048}
57+
shadow-radius={shadowRadius}
58+
/>
59+
</group>
60+
</>
61+
)
62+
}

src/examples/shadowmap/Shapes.tsx

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// The rotating torus knot (its own maskNode-punched shadow caster, see the file header
2+
// DEMONSTRATES) plus the four static pillars around it.
3+
import { useMemo, useRef } from 'react'
4+
import { useFrame, useUniforms } from '@react-three/fiber/webgpu'
5+
import { mx_fractal_noise_float, positionLocal } from 'three/tsl'
6+
import type { Mesh, Node } from 'three/webgpu'
7+
8+
const BASE_COLOR = '#999999'
9+
const PILLAR_POSITIONS: [number, number, number][] = [
10+
[8, 3.5, 8],
11+
[8, 3.5, -8],
12+
[-8, 3.5, 8],
13+
[-8, 3.5, -8],
14+
]
15+
16+
export interface TorusKnotProps {
17+
maskThreshold: number
18+
spinSpeed: number
19+
}
20+
21+
// The torus knot's material doubles as its own shadow-caster material: `maskNode`
22+
// discards fragments below the noise threshold, and that discard applies to the shadow
23+
// depth pass too, punching matching holes in the shadow (header DEMONSTRATES).
24+
export function TorusKnot({ maskThreshold, spinSpeed }: TorusKnotProps) {
25+
const meshRef = useRef<Mesh>(null)
26+
const { threshold } = useUniforms({ threshold: maskThreshold }, 'shadowmapMask')
27+
28+
// Cast: fiber's `UniformNode<T>` pins the value type to `unknown` (documented fiber
29+
// typing gap, see tsl-halftone/skinning-instancing) — this uniform really is a float.
30+
const maskNode = useMemo(
31+
() => mx_fractal_noise_float(positionLocal.mul(0.1)).x.greaterThan(threshold as unknown as Node<'float'>),
32+
[threshold],
33+
)
34+
35+
useFrame((_, delta) => {
36+
const mesh = meshRef.current
37+
if (!mesh) return
38+
mesh.rotation.x += 0.25 * spinSpeed * delta
39+
mesh.rotation.y += 0.5 * spinSpeed * delta
40+
mesh.rotation.z += 1 * spinSpeed * delta
41+
})
42+
43+
return (
44+
<mesh ref={meshRef} scale={1 / 18} position={[0, 3, 0]} castShadow receiveShadow>
45+
<torusKnotGeometry args={[25, 8, 75, 80]} />
46+
<meshPhongNodeMaterial color={BASE_COLOR} shininess={0} specular="#222222" transparent maskNode={maskNode} />
47+
</mesh>
48+
)
49+
}
50+
51+
export function Pillars() {
52+
return (
53+
<>
54+
{PILLAR_POSITIONS.map((position, i) => (
55+
<mesh key={i} position={position} castShadow>
56+
<cylinderGeometry args={[0.75, 0.75, 7, 32]} />
57+
<meshPhongNodeMaterial color={BASE_COLOR} shininess={0} specular="#222222" />
58+
</mesh>
59+
))}
60+
</>
61+
)
62+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/**
2+
* shadowmap
3+
* R3F port of three.js `webgpu_shadowmap`, running on WebGPU.
4+
* Original: https://threejs.org/examples/#webgpu_shadowmap (~175 lines of JS)
5+
*
6+
* DEMONSTRATES
7+
* - `NodeMaterial.maskNode` (Shapes.tsx): a fractal-noise discard mask (MaterialX's
8+
* `mx_fractal_noise_float`) punched through the torus knot's fragment shader — since
9+
* the shadow pass shares the same material graph, discarded fragments cast no shadow
10+
* either, unlike legacy alpha-test setups that need a separate depth material to keep
11+
* a caster's silhouette and its shadow in sync
12+
* - `NodeMaterial.receivedShadowPositionNode` (Ground.tsx): perturbing the position the
13+
* ground material samples shadow maps at (`mx_fractal_noise_vec3`), producing a
14+
* wavy/distorted shadow edge with no extra vertex displacement
15+
* - Two independent shadow-casting lights (`SpotLight` + `DirectionalLight`, Lights.tsx)
16+
* configured via fiber's dash-path props (`shadow-camera-*`, `shadow-mapSize-*`,
17+
* `shadow-radius`)
18+
* - `useUniforms` feeding a leva-controlled threshold straight into the discard mask's
19+
* TSL graph (same "feed a value in, keep the graph identity stable" pattern as
20+
* tsl-halftone), tying a live control directly to the maskNode technique above
21+
* - Legacy `<fog attach="fog">` (a plain `THREE.Fog`, not a TSL `fogNode`) rendering
22+
* correctly under the WebGPU node-material pipeline — three's `NodeManager.updateFog()`
23+
* auto-wraps `scene.fog` into a fog node every frame, so the declarative fiber `<fog>`
24+
* idiom (used elsewhere in this corpus for legacy WebGL-style fog) needs no cast or
25+
* TSL rewrite here; see header DIVERGENCE for why this refines an existing AGENTS.md note
26+
*
27+
* DIVERGENCE from original
28+
* - Folder pattern: split by scene role into `Lights.tsx` (spot + directional, orbit +
29+
* bob animation), `Shapes.tsx` (torus knot + pillars), and `Ground.tsx` (the noise-
30+
* shaded/shadow-perturbed plane) — the flat file exceeded the ~200-line threshold
31+
* - OrbitControls (`target`, `minDistance`, `maxDistance`, no pan lock) becomes
32+
* DemoHelpers' camera-controls v3 wrapper; grid disabled (`grid={false}`) — the
33+
* original's own 600x600 noise-shaded ground plane IS the subject of this example
34+
* (`receivedShadowPositionNode`), and an infinite world grid at the same height would
35+
* visually compete with it
36+
* - `THREE.Timer` dropped; `useFrame`'s `state.delta`/`state.time` drive the per-frame
37+
* rotation/orbit/bob directly, same translation as every other port in this corpus
38+
* - The four pillars are four independent JSX `meshPhongNodeMaterial` instances instead
39+
* of the original's one shared material + three `.clone()`s — declarative simplicity,
40+
* the four extra material instances are negligible next to the torus knot / ground
41+
* node graphs
42+
* - The ground's `colorNode` drops the original's dead position-perturbation calc (a
43+
* `pos.xz` noise offset computed via `toVar()`/`addAssign()` but never read before the
44+
* node returns — apparent copy/paste from `receivedShadowPositionNode` just above it
45+
* in the original source); the perturbation is kept where it's actually used
46+
* - `renderer.inspector`'s dat.gui-less imperative setup gains a small leva panel
47+
* (`maskThreshold`, `shadowRadius`, `spinSpeed`, `exposure`) — the original has no UI
48+
* at all; added for interactivity/pedagogy, same rationale as this corpus's other
49+
* zero-GUI ports (sky, sprites)
50+
* - `spinSpeed` uniformly scales the torus knot's three rotation axes, the directional
51+
* light group's orbit, and the light's z-bob frequency together (one dial, not the
52+
* original's four independent hard-coded rates)
53+
* - `renderer.toneMappingExposure` driven from leva (same escape hatch as
54+
* `sky`/`postprocessing-bloom-emissive`: a WebGPURenderer property, not a TSL uniform)
55+
*/
56+
import { useEffect } from 'react'
57+
import { Canvas, useThree } from '@react-three/fiber/webgpu'
58+
import { useControls, folder } from 'leva'
59+
import { ACESFilmicToneMapping } from 'three/webgpu'
60+
import { DemoHelpers } from '../../utils/DemoHelpers'
61+
import { Ground } from './Ground'
62+
import { Lights } from './Lights'
63+
import { Pillars, TorusKnot } from './Shapes'
64+
65+
// renderer.toneMappingExposure is a WebGPURenderer property, not a TSL uniform — set
66+
// imperatively (same pattern as sky.tsx / postprocessing-bloom-emissive.tsx).
67+
function ToneMappingExposure({ exposure }: { exposure: number }) {
68+
const renderer = useThree((s) => s.renderer)
69+
70+
useEffect(() => {
71+
renderer.toneMappingExposure = exposure
72+
}, [renderer, exposure])
73+
74+
return null
75+
}
76+
77+
export default function Shadowmap() {
78+
const { maskThreshold, shadowRadius, spinSpeed, exposure } = useControls('shadowmap', {
79+
maskThreshold: { value: 0, min: -1, max: 1, step: 0.01 },
80+
shadow: folder({
81+
shadowRadius: { value: 4, min: 0, max: 10, step: 0.5 },
82+
}),
83+
spinSpeed: { value: 1, min: 0, max: 3, step: 0.05 },
84+
exposure: { value: 1, min: 0, max: 2, step: 0.01 },
85+
})
86+
87+
return (
88+
<Canvas
89+
renderer={{ toneMapping: ACESFilmicToneMapping }}
90+
shadows
91+
background="#222244"
92+
camera={{ position: [0, 10, 20], fov: 45, near: 1, far: 1000 }}
93+
>
94+
<fog attach="fog" args={['#222244', 50, 100]} />
95+
<Lights shadowRadius={shadowRadius} spinSpeed={spinSpeed} />
96+
<TorusKnot maskThreshold={maskThreshold} spinSpeed={spinSpeed} />
97+
<Pillars />
98+
<Ground />
99+
<ToneMappingExposure exposure={exposure} />
100+
<DemoHelpers grid={false} target={[0, 2, 0]} minDistance={7} maxDistance={40} />
101+
</Canvas>
102+
)
103+
}

0 commit comments

Comments
 (0)