Skip to content

Commit eb2c86c

Browse files
DennisSmolekclaude
andcommitted
feat: reflection — wave-2 port (agent); CameraControls autoRotate
reflector() floor with normal-mapped UV offset, recursive instanced-box tree with effector-driven nodes, depth blur + vignette pipeline. TWEEN dependency replaced with a hand-rolled ramp in useFrame. CameraControls gains autoRotate/autoRotateSpeed (OrbitControls-parity azimuth increment; third wrapper gap flagged by ports) - reflection now auto-orbits like its original. UPSTREAM B11 broadened: fogNode/ backgroundNode/emissiveNode are one family of undeclared duck-typed properties; AGENTS.md pattern note + instancedBufferAttribute type-arg note. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 1e4e346 commit eb2c86c

9 files changed

Lines changed: 538 additions & 12 deletions

File tree

AGENTS.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,15 @@ 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+
- Duck-typed `*Node` properties beyond a subclass's declared types are a PATTERN, not
91+
one-offs: `scene.backgroundNode`, `scene.fogNode`, `material.emissiveNode` on
92+
non-Standard node materials — the runtime reads them generically
93+
(`NodeMaterial.setupOutgoingLight`, `NodeManager`) but `@types/three` declares them
94+
narrowly. Cast with a comment; verify against the runtime source in
95+
`reference/three.js/src/renderers/common/` first (UPSTREAM.md B11).
96+
- `instancedBufferAttribute<T>(array, itemSize)` needs its explicit type argument
97+
under strict tsc (infers `unknown` otherwise) — same "typed TSL surface doesn't
98+
infer" family as the Fn-param cast.
9099
- Fog, two paths (verified against `NodeManager.updateFog()`): plain `Fog`/`FogExp2`
91100
set declaratively (`<fog attach="fog" args={…} />`) IS auto-wrapped into a fog node
92101
by the WebGPU renderer — prefer it. Only a CUSTOM TSL fog graph needs

docs/UPSTREAM.md

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -148,16 +148,21 @@ commit** (AGENTS.md points agents at this file).
148148
chars) or throw early from `useUniforms` with a clear message naming the offending
149149
scope/key. Silent pass-through into codegen is the worst of the options.
150150

151-
### B11 · @types/three: `Scene.fogNode` missing
152-
153-
- **What**: `Scene.fogNode` is a real webgpu runtime property (read by
154-
`renderers/common/nodes/NodeManager.js` and `NodeMaterial`) — the TSL replacement
155-
for legacy `Fog`/`FogExp2` — but `@types/three` doesn't declare it.
156-
- **Evidence**: hit porting `webgpu_sprites` (scene-level `fog(color, rangeFogFactor)`).
157-
- **Local workaround**: `(scene as unknown as { fogNode: Node | null }).fogNode = …`
158-
with a comment (src/examples/sprites.tsx).
159-
- **Suggested fix**: add `fogNode: Node | null` to the `Scene` declaration in
160-
@types/three (or three's own types if that's where Scene now lives).
151+
### B11 · @types/three: duck-typed `*Node` properties undeclared (fogNode, backgroundNode, emissiveNode…)
152+
153+
- **What**: the WebGPU renderer reads several `*Node` properties generically at
154+
runtime that `@types/three` declares narrowly or not at all:
155+
`Scene.fogNode`, `Scene.backgroundNode` (read by
156+
`renderers/common/nodes/NodeManager.js`), and `emissiveNode` on ALL NodeMaterial
157+
subclasses (`NodeMaterial.setupOutgoingLight()` reads it generically; @types only
158+
declares it on `MeshStandardNodeMaterial`).
159+
- **Evidence**: hit three times across ports — `sprites` (fogNode), `reflection`
160+
(backgroundNode + emissiveNode on MeshPhongNodeMaterial).
161+
- **Local workaround**: documented casts (see src/examples/sprites.tsx,
162+
src/examples/reflection/).
163+
- **Suggested fix**: declare `fogNode`/`backgroundNode` on `Scene` and move
164+
`emissiveNode` (and friends read by `setupOutgoingLight`) up to the shared
165+
`NodeMaterial` declaration.
161166

162167
### B8 · drei (minor, docs-level): `useProgress` subscription can setState during render
163168

src/examples.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,5 +81,12 @@
8181
"title": "Procedural Texture",
8282
"tags": ["tsl", "render-to-texture", "gaussian-blur", "leva"],
8383
"original": "https://threejs.org/examples/#webgpu_procedural_texture"
84+
},
85+
{
86+
"slug": "reflection",
87+
"title": "Reflection + Recursive Tree Cubes",
88+
"tags": ["tsl", "reflection", "instancing", "post-processing", "shadows", "fog", "leva"],
89+
"original": "https://threejs.org/examples/#webgpu_reflection",
90+
"credits": "Recursive Tree Cubes concept by oosmoxiecode; floor checkerboard textures from the three.js examples"
8491
}
8592
]

src/examples/reflection/PostFX.tsx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Post-process: a depth-driven `gaussianBlur` (blur radius grows with scene depth,
2+
// same technique as `skinning-instancing`/`shadow-contact`) composited with a
3+
// `screenUV` vignette via `blendOverlay` — three TSL nodes combined into one
4+
// `outputNode`, no manual RenderTarget/QuadMesh plumbing (`useRenderPipeline` owns it).
5+
import { useEffect } from 'react'
6+
import { useRenderPipeline, useUniforms } from '@react-three/fiber/webgpu'
7+
import { gaussianBlur } from 'three/addons/tsl/display/GaussianBlurNode.js'
8+
import { blendOverlay, screenUV } from 'three/tsl'
9+
import type { Node } from 'three/webgpu'
10+
11+
export interface PostFXProps {
12+
/** Vignette darkening offset (leva `vignetteStrength`; 0.2 matches the original). */
13+
vignetteStrength: number
14+
}
15+
16+
export function PostFX({ vignetteStrength }: PostFXProps) {
17+
const { uVignette } = useUniforms(() => ({ uVignette: vignetteStrength }))
18+
19+
useEffect(() => {
20+
uVignette.value = vignetteStrength
21+
}, [uVignette, vignetteStrength])
22+
23+
useRenderPipeline(({ renderPipeline, passes }) => {
24+
if (!renderPipeline) return
25+
26+
const sceneColor = passes.scenePass.getTextureNode()
27+
const sceneDepth = passes.scenePass.getLinearDepthNode().remapClamp(0.3, 0.7)
28+
29+
const blurredColor = gaussianBlur(sceneColor)
30+
blurredColor.directionNode = sceneDepth
31+
32+
// Cast: fiber's `UniformNode<T>` pins the TSL node-type param to `unknown` (documented
33+
// fiber typing gap, see skinning-instancing/rtt/shadow-contact/Tree.tsx).
34+
const vignette = screenUV
35+
.distance(0.5)
36+
.mul(1.25)
37+
.clamp()
38+
.oneMinus()
39+
.sub(uVignette as unknown as Node<'float'>)
40+
41+
renderPipeline.outputNode = blendOverlay(blurredColor, vignette)
42+
})
43+
44+
return null
45+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// The reflective floor: a `reflector()` TSL node renders the scene from a mirrored
2+
// camera into its own render target, sampled through a normal-map-perturbed UV and
3+
// blended additively into `MeshPhongNodeMaterial.emissiveNode` (not `colorNode` — the
4+
// reflection ADDS onto the checkerboard diffuse, it doesn't replace it).
5+
import { useMemo } from 'react'
6+
import { useTexture } from '@react-three/drei/webgpu'
7+
import { reflector, texture, uv } from 'three/tsl'
8+
import { BoxGeometry, Mesh, MeshPhongNodeMaterial, RepeatWrapping, SRGBColorSpace } from 'three/webgpu'
9+
import type { Node } from 'three/webgpu'
10+
11+
const FLOOR_COLOR_URL =
12+
'https://cdn.jsdelivr.net/gh/mrdoob/three.js@r185/examples/textures/floors/FloorsCheckerboard_S_Diffuse.jpg'
13+
const FLOOR_NORMAL_URL =
14+
'https://cdn.jsdelivr.net/gh/mrdoob/three.js@r185/examples/textures/floors/FloorsCheckerboard_S_Normal.jpg'
15+
16+
// Reflector resolution scale is fixed at the original's hardcoded value: it sizes an
17+
// internal render target at construction time (not a reactive uniform), so it isn't
18+
// exposed as a leva control here — see reflection.tsx's header DIVERGENCE note.
19+
const REFLECTION_RESOLUTION_SCALE = 0.2
20+
21+
export function ReflectiveFloor() {
22+
const [floorColor, floorNormal] = useTexture([FLOOR_COLOR_URL, FLOOR_NORMAL_URL])
23+
24+
const { mesh, reflectionTarget } = useMemo(() => {
25+
floorColor.wrapS = RepeatWrapping
26+
floorColor.wrapT = RepeatWrapping
27+
floorColor.colorSpace = SRGBColorSpace
28+
floorColor.repeat.set(15, 15)
29+
30+
floorNormal.wrapS = RepeatWrapping
31+
floorNormal.wrapT = RepeatWrapping
32+
floorNormal.repeat.set(15, 15)
33+
34+
const floorUV = uv().mul(15)
35+
const floorNormalOffset = texture(floorNormal, floorUV).xy.mul(2).sub(1).mul(0.02)
36+
37+
const reflection = reflector({ resolutionScale: REFLECTION_RESOLUTION_SCALE })
38+
reflection.target.rotateX(-Math.PI / 2)
39+
// Non-null: `reflector()` always constructs with a default `uvNode` (`screenUV.flipX()`)
40+
// — @types/three declares the field nullable for the general `TextureNode` case, but
41+
// this instance is never null at runtime.
42+
reflection.uvNode = reflection.uvNode!.add(floorNormalOffset)
43+
44+
const material = new MeshPhongNodeMaterial()
45+
material.colorNode = texture(floorColor, floorUV)
46+
// Cast: `emissiveNode` is read generically by `NodeMaterial`'s base
47+
// `setupOutgoingLight()` for every subclass (confirmed against
48+
// `three/src/materials/nodes/NodeMaterial.js`), but @types/three only declares it on
49+
// `MeshStandardNodeMaterial` — same class of duck-typed-property gap as
50+
// `scene.fogNode`/`scene.backgroundNode` (see reflection.tsx header DIVERGENCE).
51+
;(material as unknown as { emissiveNode: Node | null }).emissiveNode = reflection.mul(0.25)
52+
material.normalMap = floorNormal
53+
material.normalScale.set(0.2, -0.2)
54+
55+
const mesh = new Mesh(new BoxGeometry(50, 0.001, 50), material)
56+
mesh.receiveShadow = true
57+
58+
return { mesh, reflectionTarget: reflection.target }
59+
}, [floorColor, floorNormal])
60+
61+
return (
62+
<>
63+
<primitive object={mesh} />
64+
<primitive object={reflectionTarget} />
65+
</>
66+
)
67+
}

0 commit comments

Comments
 (0)