import '../../../../src/styles.css' import { ACESFilmicToneMapping, Color, Matrix3, PerspectiveCamera, Scene, SRGBColorSpace, Timer, Vector2, Vector3, } from 'three' import { RenderPipeline, WebGPURenderer } from 'three/webgpu' import type { Node } from 'three/webgpu' import { pass, uniform } from 'three/tsl' import { attachPointerSplats, chromaticDistortion, FluidSimulation, fluidOverlay, rgbShiftDistortion, simpleDistortion, type FluidOverlayStyle, waterCausticsDistortion, waterDistortion, } from 'three-fluid-fx/tsl' import { Backdrop } from '../../../extras/backgrounds/tsl/Backdrop' import { Slideshow } from '../../../extras/backgrounds/tsl/Slideshow' import { DEFAULT_SLIDESHOW_PATHS } from '../../../extras/backgrounds/defaults' import { attachBackgroundSwitcher } from '../../../extras/backgrounds/attachBackgroundSwitcher' import { resolveBackground } from '../../../extras/backgrounds/resolveBackground' import { createControlsPane } from '../../../extras/controls/createControlsPane' import { RANGES, SCALE } from '../../../extras/controls/paramRanges' import { MorphFlowParticles } from '../../../extras/particles/tsl/MorphFlowParticles' import { resolveProfile } from '../../../extras/resolveProfile' import { attachDemoManualTakeover, createDemoSplatDriver, setupDemoReel, } from '../../../extras/demo/reel' import { asNode, asTsl, setPipelineOutput, type UniformValue } from '../../shared/nodeInterop' type OverlayStyle = FluidOverlayStyle type DistortionStyle = 'simple' | 'rgbShift' | 'chromatic' | 'water' | 'waterCaustics' interface MegaParams { particlesEnabled: boolean morphEnabled: boolean overlayEnabled: boolean distortionEnabled: boolean splatRadius: number splatForce: number pressureIterations: number curlStrength: number velocityDissipation: number densityDissipation: number dyeDissipation: number pressureDissipation: number enableVorticity: boolean bfecc: boolean reflectWalls: boolean flowStrength: number depthLift: number flowThreshold: number maxFlowSpeed: number responseGamma: number perpendicularAngle: number sideVariation: number depthAttenuationScale: number spring: number zeta: number dragLin: number dragQuad: number aMax: number vMaxScale: number pointSize: number rotationSpeed: number particleScale: number holdSeconds: number morphSeconds: number overlayStyle: OverlayStyle overlayIntensity: number overlayOpacity: number overlayVelocityScale: number cursorColor: { r: number; g: number; b: number } vibrance: number liquidColor: { r: number; g: number; b: number } distortionStyle: DistortionStyle distortionIntensity: number } const CAMERA_FOV = 45 const CAMERA_Z = 6.4 const FIXED_FLUID_DT = 1 / 60 const MAX_FLUID_SUBSTEPS = 4 const OVERLAY_OPTIONS: Record = { Default: 'default', 'Volume Cursor': 'volumeCursor', Trail: 'trail', Oil: 'oil', Velocity: 'velocity', Colorful: 'colorful', 'Rainbow Fish': 'rainbowFish', Glaze: 'glaze', Burn: 'burn', Smoke: 'smoke', 'Art Ink': 'artInk', 'Rainbow Ink': 'rainbowInk', 'Color Water': 'colorWater', 'Liquid Lens': 'liquidLens', } const DISTORTION_OPTIONS: Record = { Simple: 'simple', 'RGB Shift': 'rgbShift', Chromatic: 'chromatic', Water: 'water', 'Water + Caustics': 'waterCaustics', } const OVERLAY_STYLE_DEFAULTS: Record< OverlayStyle, { intensity: number velocityScale: number } > = { default: { intensity: 0.85, velocityScale: 1 }, volumeCursor: { intensity: 0.85, velocityScale: 1 }, trail: { intensity: 1.2, velocityScale: 1 }, oil: { intensity: 1.15, velocityScale: 1 }, velocity: { intensity: 0.25, velocityScale: 0.55 }, colorful: { intensity: 1.0, velocityScale: 1 }, rainbowFish: { intensity: 0.6, velocityScale: 0.3 }, glaze: { intensity: 0.9, velocityScale: 1 }, burn: { intensity: 1.15, velocityScale: 1 }, smoke: { intensity: 0.85, velocityScale: 1 }, artInk: { intensity: 0.85, velocityScale: 1 }, rainbowInk: { intensity: 0.85, velocityScale: 1 }, colorWater: { intensity: 1.05, velocityScale: 1 }, liquidLens: { intensity: 0.9, velocityScale: 1 }, } const DEFAULTS: MegaParams = { particlesEnabled: true, morphEnabled: true, overlayEnabled: true, distortionEnabled: true, splatRadius: 14, splatForce: 7, pressureIterations: 10, curlStrength: 0.18, velocityDissipation: 0.99, densityDissipation: 0.94, dyeDissipation: 0.965, pressureDissipation: 0.8, enableVorticity: false, bfecc: true, reflectWalls: false, flowStrength: 1.05, depthLift: 0.95, flowThreshold: 50, maxFlowSpeed: 12, responseGamma: 4, perpendicularAngle: 1.25, sideVariation: 1, depthAttenuationScale: 2, spring: 4, zeta: 1.15, dragLin: 0.28, dragQuad: 0.05, aMax: 24, vMaxScale: 1, pointSize: 10, rotationSpeed: 0.08, particleScale: 1, holdSeconds: 6.5, morphSeconds: 4.8, overlayStyle: 'artInk', overlayIntensity: OVERLAY_STYLE_DEFAULTS.artInk.intensity, overlayOpacity: 0.5, overlayVelocityScale: OVERLAY_STYLE_DEFAULTS.artInk.velocityScale, cursorColor: { r: 0.85, g: 0.95, b: 1 }, vibrance: 0.5, liquidColor: { r: 0.85, g: 0.25, b: 1 }, distortionStyle: 'simple', distortionIntensity: 0.45, } const stage = document.getElementById('stage') if (!(stage instanceof HTMLElement)) throw new Error('Missing #stage element') if (typeof navigator === 'undefined' || !('gpu' in navigator)) { stage.textContent = 'WebGPU is not available in this browser. The TSL example needs a WebGPU-capable browser.' throw new Error('WebGPU unavailable') } const params: MegaParams = { ...DEFAULTS, cursorColor: { ...DEFAULTS.cursorColor }, liquidColor: { ...DEFAULTS.liquidColor }, } const demo = setupDemoReel('TSL Mega Demo') const renderer = new WebGPURenderer({ antialias: true, forceWebGL: false }) renderer.outputColorSpace = SRGBColorSpace renderer.toneMapping = ACESFilmicToneMapping renderer.toneMappingExposure = 1 renderer.setClearColor(new Color('#07080b'), 1) renderer.domElement.style.position = 'absolute' renderer.domElement.style.inset = '0' stage.appendChild(renderer.domElement) await renderer.init() const scene = new Scene() const camera = new PerspectiveCamera(CAMERA_FOV, 1, 0.1, 100) camera.position.set(0, 0, CAMERA_Z) camera.updateMatrixWorld(true) const profile = resolveProfile('balanced') const fluid = new FluidSimulation(renderer, { profile, splatRadius: params.splatRadius * SCALE.splatRadius, splatForce: params.splatForce, pressureIterations: params.pressureIterations, curlStrength: params.curlStrength, velocityDissipation: params.velocityDissipation, densityDissipation: params.densityDissipation, pressureDissipation: params.pressureDissipation, enableVorticity: params.enableVorticity, bfecc: params.bfecc, reflectWalls: params.reflectWalls, }) fluid.enableDye = true const particles = new MorphFlowParticles(renderer, { size: 64, holdSeconds: params.holdSeconds, morphSeconds: params.morphSeconds, }) scene.add(particles.mesh) const switcher = attachBackgroundSwitcher({ scene, initial: resolveBackground('dark', { skipStorage: true }), persist: false, factories: { dark: () => new Backdrop(camera, 'dark'), bright: () => new Backdrop(camera, 'bright'), slideshow: () => new Slideshow({ camera, paths: DEFAULT_SLIDESHOW_PATHS }), }, }) const overlayIntensity = uniform(params.overlayIntensity) const overlayOpacity = uniform(params.overlayOpacity) const overlayVelocityScale = uniform(params.overlayVelocityScale) const distortionIntensity = uniform(params.distortionIntensity) const elapsedTime = uniform(0) const dyeTexel = uniform(new Vector2(1 / 512, 1 / 512)) const cursorColor = uniform( new Color(params.cursorColor.r, params.cursorColor.g, params.cursorColor.b), ) const vibrance = uniform(params.vibrance) const scenePass = pass(scene, camera) function buildDistortion(style: DistortionStyle, sceneNode: Node): Node { const fluidNode = asNode(fluid.densityNode) const i = asNode(distortionIntensity) const t = asNode(elapsedTime) switch (style) { case 'simple': return simpleDistortion(sceneNode, fluidNode, i) case 'rgbShift': return rgbShiftDistortion(sceneNode, fluidNode, i) case 'chromatic': return chromaticDistortion(sceneNode, fluidNode, i) case 'water': return waterDistortion(sceneNode, fluidNode, i) case 'waterCaustics': return waterCausticsDistortion(sceneNode, fluidNode, i, t) } } function buildOverlay(style: OverlayStyle, sceneNode: Node): Node { return fluidOverlay( style, sceneNode, asNode(fluid.densityNode), asNode(fluid.dyeNode), asNode(fluid.velocityNode), { intensity: asNode(overlayIntensity), opacity: asNode(overlayOpacity), time: asNode(elapsedTime), texel: asNode(dyeTexel), cursorColor: asNode(cursorColor), vibrance: asNode(vibrance), velocityScale: asNode(overlayVelocityScale), }, ) } function buildOutput(): Node { let output = asNode(scenePass) if (params.distortionEnabled) { output = buildDistortion(params.distortionStyle, output) } if (params.overlayEnabled) { output = buildOverlay(params.overlayStyle, output) } return output } const pipeline = new RenderPipeline(renderer) function setOutput(): void { setPipelineOutput(pipeline, buildOutput()) } setOutput() const usesCursorColor = (style: OverlayStyle): boolean => style === 'trail' || style === 'default' || style === 'volumeCursor' || style === 'artInk' const usesVibrance = (style: OverlayStyle): boolean => style !== 'smoke' && style !== 'velocity' const usesVelocityScale = (style: OverlayStyle): boolean => style === 'velocity' || style === 'rainbowFish' type VisibilityBinding = { hidden: boolean } let overlayVelocityScaleBinding: VisibilityBinding | undefined let cursorColorBinding: VisibilityBinding | undefined let vibranceBinding: VisibilityBinding | undefined let liquidColorBinding: VisibilityBinding | undefined function syncOverlayBindingVisibility(): void { if (overlayVelocityScaleBinding) { overlayVelocityScaleBinding.hidden = !usesVelocityScale(params.overlayStyle) } if (cursorColorBinding) { cursorColorBinding.hidden = !usesCursorColor(params.overlayStyle) } if (vibranceBinding) { vibranceBinding.hidden = !usesVibrance(params.overlayStyle) } if (liquidColorBinding) { liquidColorBinding.hidden = params.overlayStyle !== 'liquidLens' } } const controls = createControlsPane('TSL ยท Mega Demo', params, (pane, p) => { const layers = pane.addFolder({ title: 'Layers' }) layers.addBinding(p, 'particlesEnabled', { label: 'particles' }) layers.addBinding(p, 'morphEnabled', { label: 'morph' }) layers.addBinding(p, 'overlayEnabled', { label: 'overlay' }).on('change', () => { setOutput() }) layers.addBinding(p, 'distortionEnabled', { label: 'distortion' }).on('change', () => { setOutput() }) const splat = pane.addFolder({ title: 'Splat', expanded: false }) splat.addBinding(p, 'splatRadius', { ...RANGES.splatRadius, label: 'radius' }) splat.addBinding(p, 'splatForce', { ...RANGES.splatForce, label: 'force' }) const sim = pane.addFolder({ title: 'Fluid sim', expanded: false }) sim.addBinding(p, 'pressureIterations', { ...RANGES.pressureIterations, label: 'pressure' }) sim.addBinding(p, 'curlStrength', { ...RANGES.curlStrength, label: 'curl' }) sim.addBinding(p, 'velocityDissipation', { ...RANGES.velocityDissipation, label: 'vel diss' }) sim.addBinding(p, 'densityDissipation', { ...RANGES.densityDissipation, label: 'dens diss' }) sim.addBinding(p, 'dyeDissipation', { ...RANGES.densityDissipation, label: 'dye diss' }) sim.addBinding(p, 'pressureDissipation', { ...RANGES.pressureDissipation, label: 'pres diss' }) sim.addBinding(p, 'enableVorticity', { label: 'vorticity' }) sim.addBinding(p, 'bfecc', { label: 'BFECC' }) sim.addBinding(p, 'reflectWalls', { label: 'reflect walls' }) const influence = pane.addFolder({ title: 'Particle influence', expanded: false }) influence.addBinding(p, 'flowStrength', { ...RANGES.flowStrength, label: 'flow' }) influence.addBinding(p, 'depthLift', { ...RANGES.depthLift, label: '3D lift' }) influence.addBinding(p, 'flowThreshold', { ...RANGES.flowThreshold, label: 'thresh' }) influence.addBinding(p, 'maxFlowSpeed', { ...RANGES.maxFlowSpeed, label: 'max speed' }) influence.addBinding(p, 'responseGamma', { ...RANGES.responseGamma, label: 'response' }) influence.addBinding(p, 'depthAttenuationScale', { ...RANGES.depthAttenuationScale, label: 'depth scale', }) influence.addBinding(p, 'perpendicularAngle', { ...RANGES.perpendicularAngle, label: 'perp angle', }) influence.addBinding(p, 'sideVariation', { ...RANGES.sideVariation, label: 'side var' }) const physics = pane.addFolder({ title: 'Particle physics', expanded: false }) physics.addBinding(p, 'spring', { ...RANGES.spring, label: 'spring' }) physics.addBinding(p, 'zeta', { ...RANGES.zeta, label: 'damping' }) physics.addBinding(p, 'dragLin', { ...RANGES.dragLin, label: 'drag lin' }) physics.addBinding(p, 'dragQuad', { ...RANGES.dragQuad, label: 'drag quad' }) physics.addBinding(p, 'aMax', { ...RANGES.aMax, label: 'a max' }) physics.addBinding(p, 'vMaxScale', { ...RANGES.vMaxScale, label: 'v max' }) const particlesFolder = pane.addFolder({ title: 'Particle render', expanded: false }) particlesFolder.addBinding(p, 'pointSize', { ...RANGES.pointSize, label: 'point size' }) particlesFolder.addBinding(p, 'rotationSpeed', { ...RANGES.rotationSpeed, label: 'spin' }) particlesFolder.addBinding(p, 'particleScale', { label: 'scale', min: 0.4, max: 1.8, step: 0.01, }) const morph = pane.addFolder({ title: 'Morph', expanded: false }) morph.addBinding(p, 'holdSeconds', { label: 'hold', min: 0.5, max: 16, step: 0.1, }) morph.addBinding(p, 'morphSeconds', { label: 'duration', min: 0.5, max: 12, step: 0.1, }) const overlay = pane.addFolder({ title: 'Overlay', expanded: false }) overlay .addBinding(p, 'overlayStyle', { label: 'style', options: OVERLAY_OPTIONS, }) .on('change', () => { const defaults = OVERLAY_STYLE_DEFAULTS[p.overlayStyle] p.overlayIntensity = defaults.intensity p.overlayVelocityScale = defaults.velocityScale syncOverlayBindingVisibility() pane.refresh() setOutput() }) overlay.addBinding(p, 'overlayIntensity', { ...RANGES.intensity, max: 3, label: 'intensity', }) overlay.addBinding(p, 'overlayOpacity', { ...RANGES.opacity, label: 'opacity', }) overlayVelocityScaleBinding = overlay.addBinding(p, 'overlayVelocityScale', { label: 'velocity scale', min: 0.05, max: 2, step: 0.01, }) cursorColorBinding = overlay.addBinding(p, 'cursorColor', { label: 'cursor color', color: { type: 'float' }, }) vibranceBinding = overlay.addBinding(p, 'vibrance', { label: 'vibrance', min: 0, max: 1, step: 0.01, }) liquidColorBinding = overlay.addBinding(p, 'liquidColor', { label: 'liquid color', color: { type: 'float' }, }) const distortion = pane.addFolder({ title: 'Distortion', expanded: false }) distortion .addBinding(p, 'distortionStyle', { label: 'style', options: DISTORTION_OPTIONS, }) .on('change', () => { setOutput() }) distortion.addBinding(p, 'distortionIntensity', { ...RANGES.intensity, max: 3, label: 'intensity', }) syncOverlayBindingVisibility() }) const liquidLensColorize = (dx: number, dy: number): [number, number, number] | undefined => { if (!controls.params.overlayEnabled || controls.params.overlayStyle !== 'liquidLens') { return undefined } const lc = controls.params.liquidColor const sx = Math.min(Math.abs(dx) / 25, 1) const sy = Math.min(Math.abs(dy) / 25, 1) const speed = Math.hypot(sx, sy) const base = 0.4 + speed * 0.6 return [(lc.r * base + sx * 0.5) * 0.3, lc.g * base * 0.3, (lc.b * base + sy * 0.5) * 0.3] } const detachPointerSplats = demo.enabled ? attachDemoManualTakeover(demo, renderer.domElement, () => attachPointerSplats(renderer.domElement, fluid, { coloredStrokes: true, colorize: liquidLensColorize, }), ) : attachPointerSplats(renderer.domElement, fluid, { coloredStrokes: true, colorize: liquidLensColorize, }) const driveDemoSplats = createDemoSplatDriver(fluid, { colorize: liquidLensColorize }) function syncParams(): void { const p = controls.params particles.mesh.visible = p.particlesEnabled particles.holdSeconds = p.holdSeconds particles.morphSeconds = p.morphSeconds fluid.splatRadius = p.splatRadius * SCALE.splatRadius fluid.splatForce = p.splatForce fluid.pressureIterations = p.pressureIterations fluid.curlStrength = p.curlStrength fluid.velocityDissipation = p.velocityDissipation fluid.densityDissipation = p.densityDissipation fluid.dyeDissipation = p.dyeDissipation fluid.pressureDissipation = p.pressureDissipation fluid.enableVorticity = p.enableVorticity fluid.bfecc = p.bfecc fluid.reflectWalls = p.reflectWalls asTsl>(overlayIntensity).value = p.overlayIntensity asTsl>(overlayOpacity).value = p.overlayOpacity asTsl>(overlayVelocityScale).value = p.overlayVelocityScale asTsl>(distortionIntensity).value = p.distortionIntensity asTsl>(vibrance).value = p.vibrance asTsl>(cursorColor).value.setRGB( p.cursorColor.r, p.cursorColor.g, p.cursorColor.b, ) } function syncDyeTexel(): void { const img = fluid.dyeTexture.image as { width?: number; height?: number } const w = img.width ?? 512 const h = img.height ?? 512 asTsl>(dyeTexel).value.set(1 / w, 1 / h) } function getWorldViewport(): { width: number; height: number } { const height = 2 * CAMERA_Z * Math.tan((CAMERA_FOV * Math.PI) / 360) return { height, width: height * camera.aspect, } } function layoutParticles(): void { const viewport = getWorldViewport() const baseScale = Math.min(1.35, Math.max(0.58, (viewport.height * 0.82) / 4)) particles.mesh.position.set(0, 0, 0) particles.mesh.scale.setScalar(baseScale * controls.params.particleScale) } const resize = (): void => { const w = Math.max(1, stage.clientWidth) const h = Math.max(1, stage.clientHeight) const dpr = Math.min(window.devicePixelRatio || 1, 2) renderer.setPixelRatio(dpr) renderer.setSize(w, h, false) camera.aspect = w / h camera.fov = CAMERA_FOV camera.position.set(0, 0, CAMERA_Z) camera.updateProjectionMatrix() camera.updateMatrixWorld(true) fluid.resize(w, h) syncDyeTexel() layoutParticles() } resize() window.addEventListener('resize', resize) const clock = new Timer() const cameraRight = new Vector3() const cameraUp = new Vector3() const modelRotation = new Matrix3() let spinAngle = 0 let fluidAccumulator = 0 let morphTime = 0 renderer.setAnimationLoop(() => { clock.update() const frameDt = Math.min(Math.max(clock.getDelta(), 1e-6), FIXED_FLUID_DT * MAX_FLUID_SUBSTEPS) const elapsed = clock.getElapsed() const p = controls.params asTsl>(elapsedTime).value = elapsed syncParams() layoutParticles() if (p.morphEnabled) morphTime += frameDt spinAngle += p.rotationSpeed * frameDt particles.mesh.rotation.y = spinAngle particles.mesh.updateMatrixWorld(true) modelRotation.setFromMatrix4(particles.mesh.matrixWorld) if (demo.enabled) driveDemoSplats(demo.elapsed()) fluidAccumulator += frameDt let substeps = 0 while (fluidAccumulator >= FIXED_FLUID_DT && substeps < MAX_FLUID_SUBSTEPS) { fluid.step(FIXED_FLUID_DT) fluidAccumulator -= FIXED_FLUID_DT substeps += 1 } if (substeps === MAX_FLUID_SUBSTEPS) fluidAccumulator = 0 if (p.particlesEnabled) { camera.updateMatrixWorld() cameraRight.setFromMatrixColumn(camera.matrixWorld, 0) cameraUp.setFromMatrixColumn(camera.matrixWorld, 1) particles.step( { dt: frameDt, velocityField: fluid.velocityTexture, viewMatrix: camera.matrixWorldInverse, projectionMatrix: camera.projectionMatrix, modelMatrix: particles.mesh.matrixWorld, cameraRight, cameraUp, modelRotation, pointSize: p.pointSize, spring: p.spring, zeta: p.zeta, dragLin: p.dragLin, dragQuad: p.dragQuad, aMax: p.aMax, vMaxScale: p.vMaxScale, flowStrength: p.flowStrength, depthLift: p.depthLift, flowThreshold: p.flowThreshold * SCALE.flowThreshold, maxFlowSpeed: p.maxFlowSpeed, responseGamma: p.responseGamma, perpendicularAngle: p.perpendicularAngle, sideVariation: p.sideVariation, depthAttenuationScale: p.depthAttenuationScale, }, morphTime, ) } switcher.update(frameDt, elapsed) pipeline.render() }) window.addEventListener('pagehide', () => { renderer.setAnimationLoop(null) window.removeEventListener('resize', resize) scene.remove(particles.mesh) particles.dispose() pipeline.dispose() detachPointerSplats?.() switcher.dispose() controls.dispose() fluid.dispose() renderer.dispose() renderer.domElement.remove() })