|
| 1 | +/** |
| 2 | + * shadowmap-pointlight |
| 3 | + * R3F port of three.js `webgpu_shadowmap_pointlight`, running on WebGPU. |
| 4 | + * Original: https://threejs.org/examples/#webgpu_shadowmap_pointlight (~150 lines of JS) |
| 5 | + * |
| 6 | + * DEMONSTRATES |
| 7 | + * - Omnidirectional (cube) shadow maps from two orbiting `PointLight`s inside a |
| 8 | + * `BackSide` box room — every wall receives shadows from every direction at once, |
| 9 | + * configured entirely via fiber dash-path props (`shadow-bias`, `shadow-radius`, |
| 10 | + * `shadow-mapSize-*`) on `<pointLight castShadow>` |
| 11 | + * - A shadow-casting shell as a real scene-graph CHILD of its own light |
| 12 | + * (`<pointLight><mesh castShadow>`): a perforated sphere (2x2 `CanvasTexture` stripe |
| 13 | + * `alphaMap` + `alphaTest`) wrapped around the emitter, so the light rays out through |
| 14 | + * the cutouts and the rotating shell sweeps striped shadows across the room — the |
| 15 | + * same alpha-test path cuts the SHADOW silhouette too, since the shadow pass shares |
| 16 | + * the material's alpha test |
| 17 | + * - `shadows` (boolean) on `<Canvas>` = PCF shadow mapping, the same default the |
| 18 | + * original's WebGPURenderer uses — which is what keeps `shadow.radius` (a PCF blur |
| 19 | + * knob) live and worth exposing as a control |
| 20 | + * - Imperative per-frame light choreography in `useFrame` (Lissajous orbit + shell |
| 21 | + * rotation), each light on its own accumulated clock so a speed control scales both |
| 22 | + * without time jumps |
| 23 | + * |
| 24 | + * DIVERGENCE from original |
| 25 | + * - The original has no GUI; leva panel added (light colors, intensity, orbit speed, |
| 26 | + * shadow bias/radius) — same rationale as this corpus's other zero-GUI ports |
| 27 | + * (sky, shadowmap). Bias/radius apply live: three's WebGPU ShadowNode reads them |
| 28 | + * as reference nodes, no rebuild needed |
| 29 | + * - Both lights share ONE stripe `CanvasTexture` and one set of sphere geometries; |
| 30 | + * the original builds identical copies per light inside `createLight()` |
| 31 | + * - Speed control accumulates `delta * speed` per light instead of the original's |
| 32 | + * absolute `performance.now()` clock (light 2 keeps its +10000s phase offset) |
| 33 | + * - The light-marker sphere's over-driven color (`color * intensity`, the original's |
| 34 | + * hard-coded `multiplyScalar(200)`) now tracks the leva intensity, so dimming a |
| 35 | + * light dims its visible bulb too |
| 36 | + * - `renderer={{ toneMapping: NoToneMapping }}` — deliberate: the original renders |
| 37 | + * with the WebGPURenderer default (NoToneMapping); fiber's ACESFilmic default would |
| 38 | + * visibly mute the over-driven bulb markers and the walls' lit hot-spots |
| 39 | + * - OrbitControls -> this repo's CameraControls (via DemoHelpers); grid disabled |
| 40 | + * (`grid={false}`) — the room's own floor (y = -5) is a shadow receiver and an |
| 41 | + * infinite grid would float 5 units above it, mid-room |
| 42 | + */ |
| 43 | +import { useMemo, useRef } from 'react' |
| 44 | +import { Canvas, useFrame } from '@react-three/fiber/webgpu' |
| 45 | +import { folder, useControls } from 'leva' |
| 46 | +import { |
| 47 | + BackSide, |
| 48 | + CanvasTexture, |
| 49 | + Color, |
| 50 | + DoubleSide, |
| 51 | + NearestFilter, |
| 52 | + NoToneMapping, |
| 53 | + RepeatWrapping, |
| 54 | + SphereGeometry, |
| 55 | +} from 'three/webgpu' |
| 56 | +import type { PointLight } from 'three/webgpu' |
| 57 | +import { DemoHelpers } from '../utils/DemoHelpers' |
| 58 | + |
| 59 | +// Constant shared assets (not mutable state — same module-scope rationale as |
| 60 | +// lights-pointlights' markerGeometry). The original rebuilds all three per light. |
| 61 | +const bulbGeometry = new SphereGeometry(0.3, 12, 6) |
| 62 | +const shellGeometry = new SphereGeometry(2, 32, 8) |
| 63 | + |
| 64 | +// Ported from the original's `generateTexture()`: a 2x2 canvas, bottom row white, |
| 65 | +// top row transparent — tiled 4.5x vertically it becomes the shell's stripe cutouts. |
| 66 | +function createStripeTexture() { |
| 67 | + const canvas = document.createElement('canvas') |
| 68 | + canvas.width = 2 |
| 69 | + canvas.height = 2 |
| 70 | + const context = canvas.getContext('2d')! |
| 71 | + context.fillStyle = 'white' |
| 72 | + context.fillRect(0, 1, 2, 1) |
| 73 | + |
| 74 | + const texture = new CanvasTexture(canvas) |
| 75 | + texture.magFilter = NearestFilter |
| 76 | + texture.wrapS = RepeatWrapping |
| 77 | + texture.wrapT = RepeatWrapping |
| 78 | + texture.repeat.set(1, 4.5) |
| 79 | + return texture |
| 80 | +} |
| 81 | +const stripeTexture = createStripeTexture() |
| 82 | + |
| 83 | +interface ShadowLightProps { |
| 84 | + color: string |
| 85 | + intensity: number |
| 86 | + speed: number |
| 87 | + bias: number |
| 88 | + radius: number |
| 89 | + /** Phase offset in seconds — the original runs light 2 at `time + 10000`. */ |
| 90 | + offset?: number |
| 91 | +} |
| 92 | + |
| 93 | +// One orbiting point light: cube-shadow caster + over-driven bulb marker + the |
| 94 | +// perforated stripe shell that carves the raying shadows (see header DEMONSTRATES). |
| 95 | +function ShadowLight({ color, intensity, speed, bias, radius, offset = 0 }: ShadowLightProps) { |
| 96 | + const lightRef = useRef<PointLight>(null) |
| 97 | + const clockRef = useRef(offset) |
| 98 | + |
| 99 | + // Original: `material.color.multiplyScalar(intensity)` — an unlit sphere driven far |
| 100 | + // past 1.0 so it reads as the glowing bulb under NoToneMapping. |
| 101 | + const bulbColor = useMemo(() => new Color(color).multiplyScalar(intensity), [color, intensity]) |
| 102 | + |
| 103 | + useFrame((_, delta) => { |
| 104 | + const light = lightRef.current |
| 105 | + if (!light) return |
| 106 | + clockRef.current += delta * speed |
| 107 | + const t = clockRef.current |
| 108 | + |
| 109 | + // Lissajous orbit + shell spin, ported verbatim from the original's animate(). |
| 110 | + light.position.set(Math.sin(t * 0.6) * 9, Math.sin(t * 0.7) * 9 + 6, Math.sin(t * 0.8) * 9) |
| 111 | + light.rotation.x = t |
| 112 | + light.rotation.z = t |
| 113 | + }) |
| 114 | + |
| 115 | + return ( |
| 116 | + <pointLight |
| 117 | + ref={lightRef} |
| 118 | + color={color} |
| 119 | + intensity={intensity} |
| 120 | + distance={20} |
| 121 | + castShadow |
| 122 | + // Original comment: negative bias reduces self-shadowing on double-sided objects. |
| 123 | + shadow-bias={bias} |
| 124 | + shadow-radius={radius} |
| 125 | + shadow-mapSize-width={128} |
| 126 | + shadow-mapSize-height={128} |
| 127 | + > |
| 128 | + <mesh geometry={bulbGeometry}> |
| 129 | + <meshBasicMaterial color={bulbColor} /> |
| 130 | + </mesh> |
| 131 | + <mesh geometry={shellGeometry} castShadow receiveShadow> |
| 132 | + <meshPhongNodeMaterial side={DoubleSide} alphaMap={stripeTexture} alphaTest={0.5} /> |
| 133 | + </mesh> |
| 134 | + </pointLight> |
| 135 | + ) |
| 136 | +} |
| 137 | + |
| 138 | +// The 30x30x30 BackSide box everything happens inside — its inner faces are the |
| 139 | +// shadow receivers this example is about. |
| 140 | +function Room() { |
| 141 | + return ( |
| 142 | + <mesh position={[0, 10, 0]} receiveShadow> |
| 143 | + <boxGeometry args={[30, 30, 30]} /> |
| 144 | + <meshPhongNodeMaterial color="#a0adaf" shininess={10} specular="#111111" side={BackSide} /> |
| 145 | + </mesh> |
| 146 | + ) |
| 147 | +} |
| 148 | + |
| 149 | +export default function ShadowmapPointlight() { |
| 150 | + const { speed, intensity, light1Color, light2Color, bias, radius } = useControls('shadowmap-pointlight', { |
| 151 | + speed: { value: 1, min: 0, max: 3, step: 0.05 }, |
| 152 | + intensity: { value: 200, min: 0, max: 600, step: 10 }, |
| 153 | + light1Color: { value: '#0088ff', label: 'light 1' }, |
| 154 | + light2Color: { value: '#ff8888', label: 'light 2' }, |
| 155 | + shadow: folder({ |
| 156 | + bias: { value: -0.005, min: -0.02, max: 0.02, step: 0.0005 }, |
| 157 | + radius: { value: 10, min: 0, max: 25, step: 0.5 }, |
| 158 | + }), |
| 159 | + }) |
| 160 | + |
| 161 | + return ( |
| 162 | + <Canvas |
| 163 | + // Deliberate NoToneMapping — see header DIVERGENCE. |
| 164 | + renderer={{ toneMapping: NoToneMapping }} |
| 165 | + shadows |
| 166 | + background="#000000" |
| 167 | + camera={{ position: [0, 10, 40], fov: 45, near: 1, far: 1000 }} |
| 168 | + > |
| 169 | + <ambientLight color="#111122" intensity={3} /> |
| 170 | + <ShadowLight color={light1Color} intensity={intensity} speed={speed} bias={bias} radius={radius} /> |
| 171 | + <ShadowLight color={light2Color} intensity={intensity} speed={speed} bias={bias} radius={radius} offset={10000} /> |
| 172 | + <Room /> |
| 173 | + <DemoHelpers grid={false} target={[0, 10, 0]} minDistance={5} maxDistance={120} /> |
| 174 | + </Canvas> |
| 175 | + ) |
| 176 | +} |
0 commit comments