Skip to content

Commit 84e3c5a

Browse files
DennisSmolekclaude
andcommitted
feat: tsl-vfx-flames + tsl-vfx-tornado — wave 7 complete (57 examples)
Pair 4 closes wave 7. Two major fiber finds: B17 — Canvas-boundary suspension re-runs createRoot on alpha.3 and permanently freezes TSL time graphs (flames pixel-diff sweep exposed THREE latently frozen shipped examples: sprites, tsl-earth, refraction — all repaired + verified animating here); B18 — creator-mode useUniforms setState-in-render when deferred past suspense. New Layer 1 rules: explicit Suspense for every suspending subtree, useUniforms before suspending hooks. AGENTS v0.12, HANDOFF wave-7 table. Follow-up queued: pixel-diff animation assertion. 57 examples, 57/57 smoke + contact sheet green on Metal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 8f63395 commit 84e3c5a

11 files changed

Lines changed: 820 additions & 12 deletions

File tree

AGENTS.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,21 @@ end up as an example fix OR an amendment here (with a changelog entry) — never
192192
- StrictMode double-invokes effects: never `dispose()` a `useMemo`'d instance in an
193193
effect cleanup (kills the memoized instance for good). Use symmetric connect/
194194
disconnect effects — see [src/utils/CameraControls.tsx](src/utils/CameraControls.tsx).
195+
- **Every suspending subtree inside `<Canvas>` gets its own explicit
196+
`<Suspense fallback={null}>` — no exceptions.** Letting suspension reach Canvas's
197+
own boundary re-runs createRoot on fiber alpha.3 (`R3F.createRoot should only be
198+
called once!` console warning) and permanently freezes every TSL `time`-driven
199+
graph at frame one (UPSTREAM B17). The freeze is invisible to the smoke tier
200+
(non-black ≠ animating) — three shipped examples were latently frozen until a
201+
pixel-diff sweep caught them. This supersedes "useGLTF suspends and Canvas
202+
handles it": it doesn't, gate explicitly. (The B15 IBL gate and the dispersion
203+
PMREM-race gate are special cases of this rule.)
204+
- In a component that both suspends (useTexture/useGLTF/useLoader) and calls
205+
`useUniforms`: call `useUniforms` BEFORE the suspending hook. Creator-mode
206+
`useUniforms` writes to the fiber store during render; deferred to the
207+
post-suspense re-render, that write lands after siblings have subscribed
208+
(`useRenderPipeline` calls bare `useThree()`) → React's setState-during-render
209+
warning (UPSTREAM B18; pattern: `tsl-vfx-tornado`).
195210
- **Imperative mesh setup that must precede the first render goes in
196211
`useLayoutEffect`, not `useEffect`.** The WebGPU shader-graph build reads mesh state
197212
ONCE on the first RAF render and caches it (e.g. `morphReference()` caches
@@ -317,6 +332,15 @@ override lands with an UPSTREAM.md entry in the same commit.** Highlights:
317332

318333
## Changelog
319334

335+
- 2026-07-27 — v0.12 amendments from wave-7 pair 4 (tsl-vfx-flames +
336+
tsl-vfx-tornado — wave 7 closes at 57 examples): **the explicit-Suspense rule**
337+
(Canvas-boundary suspension re-runs createRoot on alpha.3 and freezes all TSL
338+
`time` graphs, UPSTREAM B17 — flames' pixel-diff sweep found THREE shipped
339+
examples latently frozen: sprites/tsl-earth/refraction, all repaired this
340+
commit); useUniforms-before-suspending-hooks ordering (setState-in-render via
341+
useRenderPipeline's whole-store subscription, UPSTREAM B18). Follow-up queued:
342+
a two-frame pixel-diff assertion tier — smoke's non-black check cannot see
343+
animation freezes.
320344
- 2026-07-27 — v0.11 amendments from wave-7 pair 2 (postprocessing-pixel +
321345
postprocessing-ao): TRAA/depth-copy passes need `samples: 0` targets (fiber's
322346
MSAA-4x default propagates into pass() — real WebGPU validation error, found and

docs/HANDOFF.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,44 @@
11
# Session Handoff — 2026-07-27 (overnight, continued: repo live + M2 waves 1–2)
22

3+
## Wave 7 (same night)
4+
5+
8 ports, 4 pairs — postprocessing cluster (6) + TSL VFX pair (2). **57 examples
6+
total, 57/57 smoke + contact sheet green on Metal.** Review fixes: 1 across the
7+
wave (outline's seeded initial selection); plus two REAL shipped-bug repairs the
8+
wave's finds triggered (below).
9+
10+
| Example | Notes | Cost |
11+
|---|---|---|
12+
| postprocessing | dotScreen+rgbShift chain; pattern (b); corrected orchestrator's own prompt error (not bloom) | 95k |
13+
| postprocessing-dof | established dynamism pattern (c): const-wrapping factories need user uniform() via return-to-register | 85k |
14+
| postprocessing-pixel | pixelationPass as full-pipeline PassNode; manual ortho + frustum snap; drove minZoom/maxZoom wrapper props | 123k |
15+
| postprocessing-ao | GTAO into ambient via builtinAOContext + TRAA; found the samples:0 rule (fiber MSAA-4x default breaks depth copies) | 157k |
16+
| postprocessing-outline | OutlineNode masks in user TSL; bubbled pointer-event selection; review fix: seed torus selection | 109k |
17+
| postprocessing-afterimage | 50k-sprite spiral; history trails; outputNode-swap+needsUpdate bypass idiom | 112k |
18+
| tsl-vfx-flames | fragment-stage fire; **found B17** (Canvas-boundary suspension freezes TSL time) via pixel-diff bisect | 161k |
19+
| tsl-vfx-tornado | parabola-twisted funnel + bloom; **found B18** (useUniforms-after-suspense setState-in-render) | 140k |
20+
21+
**Shipped-bug repairs this wave:**
22+
- `loader-gltf-dispersion` suite flake was NOT the cold-start transient — it was
23+
the B15-family PMREM destroyed-texture race under suite contention. Suspense
24+
gate fixed it; first back-to-back clean full-suite runs since it landed.
25+
- **B17 latent freezes**: `sprites`, `tsl-earth`, `refraction` shipped with TSL
26+
`time` frozen at frame one (Canvas-boundary suspension; smoke's non-black check
27+
can't see it). All three repaired with explicit Suspense boundaries and
28+
verified animating by pixel-diff (31k–75k px/s).
29+
30+
Wave-7 upstream yield: B17 (fiber createRoot re-run on Canvas-boundary
31+
suspension), B18 (useUniforms setState-in-render), the samples:0 MSAA rule, and
32+
dynamism pattern (c). AGENTS.md v0.9→v0.12.
33+
34+
**Follow-up queued (test-tier gap the wave exposed): a two-frame pixel-diff
35+
"animates" assertion** — smoke's non-black check shipped three frozen examples;
36+
needs a manifest flag for intentionally-static examples (compute-texture, rtt…).
37+
38+
Cumulative: **55 agent ports across 7 waves + 2 gate ports, 1 review fix + 2
39+
systemic-bug repairs this wave, zero manifest clobbers across 26+ concurrent
40+
pair-registrations.**
41+
342
## Wave 6 (same night)
443

544
8 ports, 4 pairs — the cluster wave: glTF loaders closed, TSL showpieces + first

docs/UPSTREAM.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,43 @@ commit** (AGENTS.md points agents at this file).
241241
- **Suggested fix**: use a WGSL-safe separator (`_`, matching useUniforms) and
242242
sanitize both parts (shared fix with B12's validator).
243243

244+
### B17 · fiber: Canvas-boundary suspension re-runs createRoot and freezes TSL `time`
245+
246+
- **What**: when a child suspends all the way up to Canvas's own internal boundary
247+
(no user `<Suspense>` in between), fiber alpha.3 logs `R3F.createRoot should only
248+
be called once!` and every TSL `time`-driven node graph freezes permanently at
249+
frame one. Scenes still render (non-black), so smoke tiers that only assert
250+
pixels miss it entirely.
251+
- **Evidence**: found porting `tsl-vfx-flames`; a pixel-diff sweep (two frames
252+
~1s apart) then showed three ALREADY-SHIPPED corpus examples latently frozen
253+
(`sprites` 1px, `tsl-earth` 2px, `refraction` 0px changed) — all three logged the
254+
createRoot warning, all three had suspending `useTexture` with no explicit
255+
boundary. Adding `<Suspense fallback={null}>` inside Canvas fixes all of them
256+
(post-fix: 12k–75k px/s changing, zero warnings).
257+
- **Workaround in repo**: Layer 1 rule — every suspending subtree inside Canvas
258+
gets an explicit Suspense boundary.
259+
- **Suggested fix**: guard the root-creation path against the re-entry that
260+
Canvas-boundary suspension triggers (likely the Canvas component re-running its
261+
init on the suspense retry); at minimum, make the createRoot warning an error so
262+
the failure is loud.
263+
264+
### B18 · fiber: creator-mode `useUniforms` setState-during-render on post-suspense creation
265+
266+
- **What**: `useUniforms` creator mode calls `store.setState` inside `useMemo`
267+
during render when a uniform is first created. If the component suspends BEFORE
268+
`useUniforms` runs (e.g. `useTexture` called above it), creation defers to the
269+
post-suspense re-render — by then sibling components subscribed to the whole
270+
store (`useRenderPipeline` internally calls bare `useThree()`) are mounted, and
271+
the mid-render write triggers React's "Cannot update a component while rendering
272+
a different component" warning.
273+
- **Evidence**: `tsl-vfx-tornado` (useTexture + useUniforms + useRenderPipeline
274+
sibling); verified against fiber source. Repo workaround: call `useUniforms`
275+
before any suspending hook (Layer 1 rule).
276+
- **Suggested fix**: defer the store write out of render (queue into a
277+
microtask/effect-phase flush), or narrow `useRenderPipeline`'s subscription; the
278+
B8 family (setState-in-render from hooks) keeps growing — a lint-able contract
279+
("no store writes during render") would kill the class.
280+
244281
### B8 · drei (minor, docs-level): `useProgress` subscription can setState during render
245282

246283
- Loaders can start synchronously inside another component's render; a component

src/examples.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,5 +375,19 @@
375375
"tags": ["postprocessing", "outline", "tsl", "obj", "pointer", "shadows", "leva"],
376376
"original": "https://threejs.org/examples/#webgpu_postprocessing_outline",
377377
"credits": "Outline Pass by Prashant Sharma and Ben Houston; tree model from the three.js examples"
378+
},
379+
{
380+
"slug": "tsl-vfx-tornado",
381+
"title": "TSL / VFX Tornado",
382+
"tags": ["tsl", "node-material", "vfx", "noise", "postprocessing", "bloom", "leva"],
383+
"original": "https://threejs.org/examples/#webgpu_tsl_vfx_tornado",
384+
"credits": "Tornado VFX by Bruno Simon, based on Three.js Journey lessons; perlin noise texture from the three.js examples"
385+
},
386+
{
387+
"slug": "tsl-vfx-flames",
388+
"title": "TSL / VFX Flames",
389+
"tags": ["tsl", "node-material", "sprites", "vfx", "noise", "leva"],
390+
"original": "https://threejs.org/examples/#webgpu_tsl_vfx_flames",
391+
"credits": "Flame VFX by Bruno Simon, inspired by @cmzw_; voronoi/perlin noise textures from the three.js examples"
378392
}
379393
]

src/examples/refraction.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
* this example is staged on, and DemoHelpers' world-space grid sits at y=0.002,
3939
* coincident with (and visually clashing against) the room's bottom plane
4040
*/
41-
import { useMemo, useRef } from 'react'
41+
import { Suspense, useMemo, useRef } from 'react'
4242
import { Canvas, useFrame } from '@react-three/fiber/webgpu'
4343
import { useTexture } from '@react-three/drei/webgpu'
4444
import { folder, useControls } from 'leva'
@@ -122,7 +122,12 @@ export default function Refraction() {
122122
return (
123123
<Canvas renderer camera={{ position: [0, 50, 160], fov: 45, near: 1, far: 500 }}>
124124
<OrbitingSphere orbitSpeed={orbitSpeed} />
125-
<RefractorPlane normalScale={normalScale} uvTile={uvTile} />
125+
{/* Explicit boundary: suspending up to Canvas's own boundary re-runs createRoot
126+
on fiber alpha.3 and freezes every TSL `time` graph (see AGENTS.md; found by
127+
tsl-vfx-flames' pixel-diff sweep — this example shipped frozen). */}
128+
<Suspense fallback={null}>
129+
<RefractorPlane normalScale={normalScale} uvTile={uvTile} />
130+
</Suspense>
126131

127132
{/* Room: floor, ceiling, back, and two side walls (front stays open to the camera). */}
128133
<mesh position={[0, 100, 0]} rotation={[Math.PI / 2, 0, 0]}>

src/examples/sprites.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
* UPSTREAM.md entry — flagged here and in the port report as a candidate AGENTS.md
4040
* note (same cast-with-comment convention as the documented fiber typing gaps)
4141
*/
42-
import { useEffect, useMemo, useRef } from 'react'
42+
import { Suspense, useEffect, useMemo, useRef } from 'react'
4343
import { Canvas, useFrame, useThree } from '@react-three/fiber/webgpu'
4444
import { useTexture } from '@react-three/drei/webgpu'
4545
import { folder, useControls } from 'leva'
@@ -151,7 +151,12 @@ export default function Sprites() {
151151

152152
return (
153153
<Canvas renderer background="#000000" camera={{ position: [0, 0, 1500], fov: 60, near: 1, far: 2100 }}>
154-
<SpriteField amount={amount} radius={radius} spinSpeed={spinSpeed} />
154+
{/* Explicit boundary: suspending up to Canvas's own boundary re-runs createRoot
155+
on fiber alpha.3 and freezes every TSL `time` graph (see AGENTS.md; found by
156+
tsl-vfx-flames' pixel-diff sweep — this example shipped frozen). */}
157+
<Suspense fallback={null}>
158+
<SpriteField amount={amount} radius={radius} spinSpeed={spinSpeed} />
159+
</Suspense>
155160
<SceneFog near={near} far={far} fogColor={fogColor} />
156161
<DemoHelpers grid={false} />
157162
</Canvas>

src/examples/tsl-earth.tsx

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
* void with no ground plane; dolly range set to the original OrbitControls'
4040
* `minDistance`/`maxDistance` (0.1 / 50)
4141
*/
42-
import { useEffect, useMemo } from 'react'
42+
import { Suspense, useEffect, useMemo } from 'react'
4343
import { Canvas, useFrame, useUniforms } from '@react-three/fiber/webgpu'
4444
import { useTexture } from '@react-three/drei/webgpu'
4545
import { folder, useControls } from 'leva'
@@ -186,13 +186,18 @@ export default function TslEarth() {
186186
return (
187187
<Canvas renderer background="#000000" camera={{ position: CAMERA_POSITION, fov: 25, near: 0.1, far: 100 }}>
188188
<directionalLight color="#ffffff" intensity={2} position={SUN_POSITION} />
189-
<Globe
190-
atmosphereDayColor={atmosphereDayColor}
191-
atmosphereTwilightColor={atmosphereTwilightColor}
192-
roughnessLow={roughnessLow}
193-
roughnessHigh={roughnessHigh}
194-
rotationSpeed={rotationSpeed}
195-
/>
189+
{/* Explicit boundary: suspending up to Canvas's own boundary re-runs createRoot
190+
on fiber alpha.3 and freezes every TSL `time` graph (see AGENTS.md; found by
191+
tsl-vfx-flames' pixel-diff sweep — this example shipped frozen). */}
192+
<Suspense fallback={null}>
193+
<Globe
194+
atmosphereDayColor={atmosphereDayColor}
195+
atmosphereTwilightColor={atmosphereTwilightColor}
196+
roughnessLow={roughnessLow}
197+
roughnessHigh={roughnessHigh}
198+
rotationSpeed={rotationSpeed}
199+
/>
200+
</Suspense>
196201
<DemoHelpers grid={false} minDistance={0.1} maxDistance={50} />
197202
</Canvas>
198203
)
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
// The two flame sprites and their TSL node graphs — ported near-verbatim from the
2+
// original's flame1/flame2 materials (see the header block in tsl-vfx-flames.tsx for
3+
// the full DEMONSTRATES/DIVERGENCE story). Lives in its own file because it needs
4+
// fiber hooks (`useUniforms`) and therefore must render inside `<Canvas>`.
5+
import { useEffect, useMemo } from 'react'
6+
import { useUniforms } from '@react-three/fiber/webgpu'
7+
import { useTexture } from '@react-three/drei/webgpu'
8+
import {
9+
billboarding,
10+
Fn,
11+
mix,
12+
oneMinus,
13+
sin,
14+
spherizeUV,
15+
step,
16+
texture,
17+
time,
18+
TWO_PI,
19+
uv,
20+
vec2,
21+
vec3,
22+
vec4,
23+
} from 'three/tsl'
24+
import { CanvasTexture, DoubleSide, SpriteNodeMaterial, SRGBColorSpace } from 'three/webgpu'
25+
import type { Node } from 'three/webgpu'
26+
27+
const CDN = 'https://cdn.jsdelivr.net/gh/mrdoob/three.js@r185/examples'
28+
const CELLULAR_URL = `${CDN}/textures/noises/voronoi/grayscale-256x256.png`
29+
const PERLIN_URL = `${CDN}/textures/noises/perlin/rgb-256x256.png`
30+
31+
export interface FlamesProps {
32+
timeScale: number
33+
gradientColors: string[]
34+
}
35+
36+
export function Flames({ timeScale, gradientColors }: FlamesProps) {
37+
const [cellularTexture, perlinTexture] = useTexture([CELLULAR_URL, PERLIN_URL])
38+
39+
const { uTimeScale } = useUniforms({ uTimeScale: timeScale }, 'vfxFlames')
40+
// Cast: fiber's `UniformNode<T>` pins its TSL type param to `unknown` — documented
41+
// upstream typing gap (see header DIVERGENCE).
42+
const uTimeScaleNode = uTimeScale as unknown as Node<'float'>
43+
44+
// 128x1 gradient LUT. The texture object is identity-stable (painted in the effect
45+
// below), so leva color edits never rebuild the node graph.
46+
const gradientTexture = useMemo(() => {
47+
const canvas = document.createElement('canvas')
48+
canvas.width = 128
49+
canvas.height = 1
50+
const lut = new CanvasTexture(canvas)
51+
lut.colorSpace = SRGBColorSpace
52+
return lut
53+
}, [])
54+
55+
useEffect(() => {
56+
const canvas = gradientTexture.image as HTMLCanvasElement
57+
const context = canvas.getContext('2d')
58+
if (!context) return
59+
const fillGradient = context.createLinearGradient(0, 0, canvas.width, 0)
60+
for (let i = 0; i < gradientColors.length; i++) {
61+
fillGradient.addColorStop(i / (gradientColors.length - 1), gradientColors[i])
62+
}
63+
context.fillStyle = fillGradient
64+
context.fillRect(0, 0, canvas.width, canvas.height)
65+
gradientTexture.needsUpdate = true
66+
}, [gradientTexture, gradientColors])
67+
68+
const { flame1Material, flame2Material } = useMemo(() => {
69+
// Every `time` term in the original scales through the live uniform — slow-mo
70+
// with zero graph rebuilds.
71+
const t = time.mul(uTimeScaleNode)
72+
73+
// flame 1 — the gradient-toned core
74+
75+
const flame1Material = new SpriteNodeMaterial({ side: DoubleSide })
76+
77+
flame1Material.colorNode = Fn(() => {
78+
// main UV
79+
const mainUv = uv().toVar()
80+
mainUv.assign(spherizeUV(mainUv, 10).mul(0.6).add(0.2)) // spherize
81+
mainUv.assign(mainUv.pow(vec2(1, 2))) // stretch
82+
mainUv.assign(mainUv.mul(2, 1).sub(vec2(0.5, 0))) // scale
83+
84+
// gradients
85+
const gradient1 = sin(t.mul(10).sub(mainUv.y.mul(TWO_PI).mul(2))).toVar()
86+
const gradient2 = mainUv.y.smoothstep(0, 1).toVar()
87+
mainUv.x.addAssign(gradient1.mul(gradient2).mul(0.2))
88+
89+
// cellular noise
90+
const cellularUv = mainUv.mul(0.5).add(vec2(0, t.negate().mul(0.5))).mod(1)
91+
const cellularNoise = texture(cellularTexture, cellularUv, 0).r.oneMinus().smoothstep(0, 0.5).oneMinus().toVar()
92+
cellularNoise.mulAssign(gradient2)
93+
94+
// shape
95+
const shape = mainUv.sub(0.5).mul(vec2(3, 2)).length().oneMinus().toVar()
96+
shape.assign(shape.sub(cellularNoise))
97+
98+
// gradient color
99+
const gradientColor = texture(gradientTexture, vec2(shape.remap(0, 1, 0, 1), 0))
100+
101+
// output
102+
const color = mix(gradientColor, vec3(1), shape.step(0.8))
103+
const alpha = shape.smoothstep(0, 0.3)
104+
return vec4(color.rgb, alpha)
105+
})()
106+
107+
// flame 2 — the white silhouette flame
108+
109+
const flame2Material = new SpriteNodeMaterial({ side: DoubleSide })
110+
111+
flame2Material.colorNode = Fn(() => {
112+
// main UV
113+
const mainUv = uv().toVar()
114+
mainUv.assign(spherizeUV(mainUv, 10).mul(0.6).add(0.2)) // spherize
115+
mainUv.assign(mainUv.abs().pow(vec2(1, 3)).mul(mainUv.sign())) // stretch
116+
mainUv.assign(mainUv.mul(2, 1).sub(vec2(0.5, 0))) // scale
117+
118+
// perlin noise
119+
const perlinUv = mainUv.add(vec2(0, t.negate().mul(1))).mod(1)
120+
const perlinNoise = texture(perlinTexture, perlinUv, 0).sub(0.5).mul(1)
121+
mainUv.x.addAssign(perlinNoise.x.mul(0.5))
122+
123+
// gradients
124+
const gradient1 = sin(t.mul(10).sub(mainUv.y.mul(TWO_PI).mul(2)))
125+
const gradient2 = mainUv.y.smoothstep(0, 1)
126+
const gradient3 = oneMinus(mainUv.y).smoothstep(0, 0.3)
127+
mainUv.x.addAssign(gradient1.mul(gradient2).mul(0.2))
128+
129+
// displaced perlin noise
130+
const displacementPerlinUv = mainUv.mul(0.5).add(vec2(0, t.negate().mul(0.25))).mod(1)
131+
const displacementPerlinNoise = texture(perlinTexture, displacementPerlinUv, 0).sub(0.5).mul(1)
132+
const displacedPerlinUv = mainUv.add(vec2(0, t.negate().mul(0.5))).add(displacementPerlinNoise).mod(1)
133+
const displacedPerlinNoise = texture(perlinTexture, displacedPerlinUv, 0).sub(0.5).mul(1)
134+
mainUv.x.addAssign(displacedPerlinNoise.mul(0.5))
135+
136+
// cellular noise
137+
const cellularUv = mainUv.add(vec2(0, t.negate().mul(1.5))).mod(1)
138+
const cellularNoise = texture(cellularTexture, cellularUv, 0).r.oneMinus().smoothstep(0.25, 1)
139+
140+
// shape
141+
const shape = step(mainUv.sub(0.5).mul(vec2(6, 1)).length(), 0.5).toVar()
142+
shape.assign(shape.mul(cellularNoise))
143+
shape.mulAssign(gradient3)
144+
shape.assign(step(0.01, shape))
145+
146+
// output
147+
return vec4(vec3(1), shape)
148+
})()
149+
150+
// billboarding — follow the camera rotation only horizontally
151+
flame1Material.vertexNode = billboarding()
152+
flame2Material.vertexNode = billboarding()
153+
154+
return { flame1Material, flame2Material }
155+
}, [cellularTexture, perlinTexture, gradientTexture, uTimeScaleNode])
156+
157+
return (
158+
<>
159+
<sprite material={flame1Material} position={[-0.5, 0, 0]} scale={[0.5, 1, 1]} />
160+
<sprite material={flame2Material} position={[0.5, 0, 0]} />
161+
</>
162+
)
163+
}

0 commit comments

Comments
 (0)