|
| 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