webgpu_volume_fire.html 53 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324
  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <title>three.js webgpu - volumetric fire simulation</title>
  5. <meta charset="utf-8">
  6. <meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
  7. <meta property="og:title" content="three.js webgpu - volumetric fire simulation">
  8. <meta property="og:type" content="website">
  9. <meta property="og:url" content="https://threejs.org/examples/webgpu_volume_fire.html">
  10. <meta property="og:image" content="https://threejs.org/examples/screenshots/webgpu_volume_fire.jpg">
  11. <link type="text/css" rel="stylesheet" href="example.css">
  12. </head>
  13. <body>
  14. <div id="info">
  15. <a href="https://threejs.org/" target="_blank" rel="noopener" class="logo-link"></a>
  16. <div class="title-wrapper">
  17. <a href="https://threejs.org/" target="_blank" rel="noopener">three.js</a><span>Volumetric Fire Simulation</span>
  18. </div>
  19. <small>3D fluid simulation (semi-Lagrangian advection + curlNoise, buoyancy, Jacobi projection) on the GPU.</br>Drag the teapot to add turbulence.</small>
  20. </div>
  21. <script type="importmap">
  22. {
  23. "imports": {
  24. "three": "../build/three.webgpu.js",
  25. "three/webgpu": "../build/three.webgpu.js",
  26. "three/tsl": "../build/three.tsl.js",
  27. "three/addons/": "../examples/jsm/"
  28. }
  29. }
  30. </script>
  31. <script type="module">
  32. import * as THREE from 'three/webgpu';
  33. import {
  34. vec3, vec4, uvec3, float, Fn, uniform,
  35. texture3D, textureStore, instanceIndex,
  36. screenCoordinate, pass,
  37. smoothstep, mix, min, max, floor,
  38. mx_noise_float, storage, storageTexture, If, cameraPosition, hue,
  39. Loop, positionWorld, positionLocal,
  40. interleavedGradientNoise, frameId, fract,
  41. saturation, cos, sin, atan
  42. } from 'three/tsl';
  43. import { snoise, snoiseVec3 } from 'three/addons/tsl/math/curlNoise.js';
  44. import { ImprovedNoise } from 'three/addons/math/ImprovedNoise.js';
  45. import { gaussianBlur } from 'three/addons/tsl/display/GaussianBlurNode.js';
  46. import { bloom } from 'three/addons/tsl/display/BloomNode.js';
  47. import { Inspector } from 'three/addons/inspector/Inspector.js';
  48. import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
  49. import { DragControls } from 'three/addons/controls/DragControls.js';
  50. import { TeapotGeometry } from 'three/addons/geometries/TeapotGeometry.js';
  51. import WebGPU from 'three/addons/capabilities/WebGPU.js';
  52. if ( WebGPU.isAvailable() === false ) {
  53. document.body.appendChild( WebGPU.getErrorMessage() );
  54. throw new Error( 'No WebGPU support' );
  55. }
  56. // ---------------------------------------------------------------
  57. // Globals
  58. // ---------------------------------------------------------------
  59. const GRID_SIZE_X = 100;
  60. const GRID_SIZE_Y = 100;
  61. const GRID_SIZE_Z = 200;
  62. const CELL_COUNT = GRID_SIZE_X * GRID_SIZE_Y * GRID_SIZE_Z;
  63. const PRESSURE_ITERATIONS = 2; // Jacobi iterations (keep even!) // default 6
  64. const VOLUME_WORLD_SIZE_X = 12;
  65. const VOLUME_WORLD_SIZE_Y = 12;
  66. const VOLUME_WORLD_SIZE_Z = 24;
  67. const VOLUME_WORLD_SIZE_DIAGONAL = Math.sqrt( VOLUME_WORLD_SIZE_X ** 2 + VOLUME_WORLD_SIZE_Y ** 2 + VOLUME_WORLD_SIZE_Z ** 2 );
  68. const uVolumeWorldSize = uniform( new THREE.Vector3( VOLUME_WORLD_SIZE_X, VOLUME_WORLD_SIZE_Y, VOLUME_WORLD_SIZE_Z ) );
  69. const TEXEL_X = 1 / GRID_SIZE_X;
  70. const TEXEL_Y = 1 / GRID_SIZE_Y;
  71. const TEXEL_Z = 1 / GRID_SIZE_Z;
  72. let renderer, scene, camera, controls;
  73. let volumetricMesh, teapot, keyLight, pointLight;
  74. let renderPipeline;
  75. let denoiseStrength;
  76. let params;
  77. let uKeyLightPos;
  78. let teapotVerticesBuffer, vertexCount;
  79. const prevTeapotPos = new THREE.Vector3();
  80. // sim textures
  81. let velTexA, velTexB; // velocity field (xyz)
  82. let dyeTexA, dyeTexB; // x = density (smoke), y = temperature (fire)
  83. let divTex; // divergence
  84. let pressTexA, pressTexB; // pressure (Jacobi ping-pong)
  85. let dyeTexNode, dyeTexWriteNode, curlNoiseTex, curlNoiseTexNode;
  86. // compute passes
  87. let advectVelocityPass, divergencePass, jacobiPassAB, jacobiPassBA, projectPass, advectDyePass, emitTeapotPass, computeCurlNoisePass;
  88. // sim uniforms
  89. const uDt = uniform( 0.016 );
  90. const uTime = uniform( 0 );
  91. const uBuoyancy = uniform( 3.0 ); // hot air rises
  92. const uWeight = uniform( 0.15 ); // smoke weight (pulls down)
  93. const uTurbulence = uniform( 3.2 ); // noise force strength
  94. const uTurbulenceDecay = uniform( 0.1 ); // turbulence decay rate over age
  95. const uTurbFrequency = uniform( 10.0 ); // noise force frequency
  96. const uVelDamping = uniform( 0.25 ); // velocity dissipation /s
  97. const uCooling = uniform( 1.0 ); // temperature cooling /s (default for 1.0s lifespan)
  98. const uDissipation = uniform( 0.4 ); // smoke dissipation /s (default for 2.5s lifespan)
  99. const uEmitDensity = uniform( 7.0 );
  100. const uEmitTemperature = uniform( 5.5 );
  101. const uTeapotMatrix = uniform( new THREE.Matrix4() );
  102. const uTeapotSpeed = uniform( 0.0 );
  103. const uMotionBoost = uniform( 0.25 ); // scales fire and smoke emission when moving
  104. const uTeapotVelocity = uniform( new THREE.Vector3() );
  105. const uWindStrength = uniform( 6.5 ); // strength of the wind effect when moving
  106. const uTeapotPosition = uniform( new THREE.Vector3() );
  107. // render uniforms
  108. const uFireIntensity = uniform( 40.0 );
  109. const uTeapotEmissiveIntensity = uniform( 0.2 );
  110. const uFireGlowSpread = uniform( 5.0 );
  111. const uShadowAbsorption = uniform( 2.0 );
  112. const uShadowAmbient = uniform( 0.5 );
  113. const uFireStartColor = uniform( new THREE.Color( 0xffe68c ) );
  114. const uFireMidColor = uniform( new THREE.Color( 0xff7305 ) );
  115. const uFireEndColor = uniform( new THREE.Color( 0xff0000 ) );
  116. const uFireHue = uniform( 0.0 );
  117. const uAsymmetry = uniform( 0.0 );
  118. const uPowderStrength = uniform( 0.59 );
  119. const uMultiScattering = uniform( 1.0 );
  120. const uPointLightVolumeIntensity = uniform( 2.0 );
  121. const uPointLightSurfaceIntensity = uniform( 10.0 );
  122. const uLightNearIntensity = uniform( 10.0 );
  123. const uLightFarIntensity = uniform( 15.0 );
  124. const uLightFarDistance = uniform( 10.0 );
  125. const uPointLightProjectionRadius = uniform( 20.0 );
  126. const uPointLightProjectionFrequency = uniform( 0.2 );
  127. const uPointLightProjectionNoiseFade = uniform( 17.0 );
  128. const uPointLightProjectionCenterFade = uniform( 3.25 );
  129. const uFlameHeight = uniform( 3.5 );
  130. const uSway = uniform( new THREE.Vector3() );
  131. const uFlicker = uniform( 1.0 );
  132. const uColorNoise = uniform( 0.0 );
  133. const cpuNoise = new ImprovedNoise();
  134. init();
  135. // ---------------------------------------------------------------
  136. // Storage 3D textures (the "voxels")
  137. // ---------------------------------------------------------------
  138. function createStorage3D( name ) {
  139. const texture = new THREE.Storage3DTexture( GRID_SIZE_X, GRID_SIZE_Y, GRID_SIZE_Z );
  140. texture.name = name;
  141. texture.format = THREE.RGBAFormat;
  142. texture.type = THREE.HalfFloatType; // rgba16float -> storage-writable + linearly filterable
  143. texture.minFilter = THREE.LinearFilter;
  144. texture.magFilter = THREE.LinearFilter;
  145. texture.wrapS = THREE.ClampToEdgeWrapping;
  146. texture.wrapT = THREE.ClampToEdgeWrapping;
  147. texture.wrapR = THREE.ClampToEdgeWrapping;
  148. return texture;
  149. }
  150. // ---------------------------------------------------------------
  151. // TSL helpers shared by the compute kernels
  152. // ---------------------------------------------------------------
  153. // instanceIndex (1D) -> voxel coordinate (3D)
  154. const getVoxelCoord = ( id ) => {
  155. const x = id.mod( GRID_SIZE_X );
  156. const y = id.div( GRID_SIZE_X ).mod( GRID_SIZE_Y );
  157. const z = id.div( GRID_SIZE_X * GRID_SIZE_Y );
  158. return uvec3( x, y, z );
  159. };
  160. // voxel coordinate -> normalized uvw at the cell center
  161. const coordToUVW = ( coord ) => vec3( coord ).add( 0.5 ).div( vec3( GRID_SIZE_X, GRID_SIZE_Y, GRID_SIZE_Z ) );
  162. // ---------------------------------------------------------------
  163. // Fluid simulation - compute kernels
  164. // ---------------------------------------------------------------
  165. function createComputePasses() {
  166. // 0) Precompute curl noise into 3D storage texture
  167. computeCurlNoisePass = Fn( () => {
  168. const coord = getVoxelCoord( instanceIndex );
  169. const uvw = coordToUVW( coord );
  170. const freq = uTurbFrequency; // 10.0
  171. const e = float( 0.1 ).div( freq );
  172. const dx = vec3( e, 0.0, 0.0 );
  173. const dy = vec3( 0.0, e, 0.0 );
  174. const dz = vec3( 0.0, 0.0, e );
  175. const p = uvw.mul( vec3( VOLUME_WORLD_SIZE_X / VOLUME_WORLD_SIZE_Y, 1.0, VOLUME_WORLD_SIZE_Z / VOLUME_WORLD_SIZE_Y ) );
  176. const p_x0 = snoiseVec3( p.sub( dx ).mul( freq ) );
  177. const p_x1 = snoiseVec3( p.add( dx ).mul( freq ) );
  178. const p_y0 = snoiseVec3( p.sub( dy ).mul( freq ) );
  179. const p_y1 = snoiseVec3( p.add( dy ).mul( freq ) );
  180. const p_z0 = snoiseVec3( p.sub( dz ).mul( freq ) );
  181. const p_z1 = snoiseVec3( p.add( dz ).mul( freq ) );
  182. const x = p_y1.z.sub( p_y0.z ).sub( p_z1.y ).add( p_z0.y );
  183. const y = p_z1.x.sub( p_z0.x ).sub( p_x1.z ).add( p_x0.z );
  184. const z = p_x1.y.sub( p_x0.y ).sub( p_y1.x ).add( p_y0.x );
  185. // Analytical curlNoise multiplier is 1.0 / (2.0 * e) = 5.0 (since e = 0.1)
  186. const noiseVal = vec3( x, y, z ).mul( 5.0 );
  187. textureStore( curlNoiseTex, coord, vec4( noiseVal, 0.0 ) ).toWriteOnly();
  188. } )().compute( CELL_COUNT ).setName( 'computeCurlNoise' );
  189. // 1) Advect velocity + external forces (buoyancy, weight, turbulence)
  190. // read: velTexA, dyeTexNode -> write: velTexB
  191. advectVelocityPass = Fn( () => {
  192. const coord = getVoxelCoord( instanceIndex );
  193. const uvw = coordToUVW( coord );
  194. const vel = texture3D( velTexA, uvw, 0 ).xyz;
  195. // semi-Lagrangian advection: look back along the velocity
  196. const velUVW = vel.div( uVolumeWorldSize );
  197. const prevPos = uvw.sub( velUVW.mul( uDt ) );
  198. const newVel = texture3D( velTexA, prevPos, 0 ).xyz.toVar();
  199. const dye = dyeTexNode.sample( uvw ).level( 0 );
  200. const density = dye.r;
  201. const temperature = dye.g;
  202. const age = dye.b;
  203. // buoyancy (hot rises) vs smoke weight (cold falls)
  204. const buoyancyForce = temperature.mul( uBuoyancy ).sub( density.mul( uWeight ) ).mul( VOLUME_WORLD_SIZE_Y );
  205. newVel.addAssign( vec3( 0, buoyancyForce, 0 ).mul( uDt ) );
  206. // turbulence: divergence-free noise force
  207. // 1) Thermal/Convective turbulence: stronger where it's hot, decaying over age
  208. const thermalNoisePos = uvw.add( vec3( 0, age.negate().mul( 0.6 ), age.mul( 0.13 ) ).div( uTurbFrequency ) );
  209. const decay = age.mul( uTurbulenceDecay.negate() ).exp();
  210. const thermalTurbulence = curlNoiseTexNode.sample( thermalNoisePos ).level( 0 ).xyz.mul( uTurbulence ).mul( temperature ).mul( decay );
  211. // 2) Ambient/Atmospheric turbulence: lower frequency, weaker, acts on the smoke density (even when cooled down)
  212. // using uTime so it animates continuously regardless of age
  213. const ambientNoisePos = uvw.mul( 0.5 ).add( vec3( 0, uTime.mul( 0.25 ), uTime.mul( 0.06 ) ).div( uTurbFrequency ) );
  214. const ambientTurbulence = curlNoiseTexNode.sample( ambientNoisePos ).level( 0 ).xyz.mul( uTurbulence.mul( 0.2 ) ).mul( density );
  215. const turbulence = thermalTurbulence.add( ambientTurbulence ).mul( VOLUME_WORLD_SIZE_Y );
  216. newVel.addAssign( turbulence.mul( uDt ) );
  217. // damping
  218. newVel.mulAssign( max( float( 1 ).sub( uVelDamping.mul( uDt ) ), 0 ) );
  219. // Wind effect: bounding sphere around teapot
  220. const worldPos = uvw.sub( 0.5 ).mul( uVolumeWorldSize ).add( vec3( 0, VOLUME_WORLD_SIZE_Y / 2, 0 ) );
  221. const dist = worldPos.distance( uTeapotPosition );
  222. const teapotRadius = float( 1.0 );
  223. If( dist.lessThan( teapotRadius ), () => {
  224. const ratio = dist.div( teapotRadius );
  225. const falloff = smoothstep( 0.0, 1.0, float( 1.0 ).sub( ratio ) );
  226. // Wind turbulence scales with uTurbulence and teapot speed, using curlNoise
  227. const windNoisePos = uvw.add( vec3( 0.0, uTime.mul( 0.5 ), 0.0 ).div( uTurbFrequency ) );
  228. const windTurbulence = curlNoiseTexNode.sample( windNoisePos ).level( 0 ).xyz.mul( uTurbulence ).mul( uTeapotSpeed );
  229. const windVel = uTeapotVelocity.mul( uWindStrength ).add( windTurbulence ).mul( uDt ).mul( falloff );
  230. newVel.addAssign( windVel );
  231. } );
  232. // fade velocity near the volume borders (soft boundary condition)
  233. const edge = min( uvw, vec3( 1 ).sub( uvw ) );
  234. const boundary = smoothstep( 0.0, 0.08, min( edge.x, min( edge.y, edge.z ) ) );
  235. newVel.mulAssign( boundary );
  236. textureStore( velTexB, coord, vec4( newVel, 0 ) ).toWriteOnly();
  237. } )().compute( CELL_COUNT ).setName( 'advectVelocity' );
  238. // 2) Divergence of the advected velocity
  239. // read: velTexB -> write: divTex
  240. divergencePass = Fn( () => {
  241. const coord = getVoxelCoord( instanceIndex );
  242. const uvw = coordToUVW( coord );
  243. const vR = texture3D( velTexB, uvw.add( vec3( TEXEL_X, 0, 0 ) ), 0 ).x;
  244. const vL = texture3D( velTexB, uvw.sub( vec3( TEXEL_X, 0, 0 ) ), 0 ).x;
  245. const vU = texture3D( velTexB, uvw.add( vec3( 0, TEXEL_Y, 0 ) ), 0 ).y;
  246. const vD = texture3D( velTexB, uvw.sub( vec3( 0, TEXEL_Y, 0 ) ), 0 ).y;
  247. const vF = texture3D( velTexB, uvw.add( vec3( 0, 0, TEXEL_Z ) ), 0 ).z;
  248. const vB = texture3D( velTexB, uvw.sub( vec3( 0, 0, TEXEL_Z ) ), 0 ).z;
  249. const divergence = vR.sub( vL ).add( vU.sub( vD ) ).add( vF.sub( vB ) ).mul( 0.5 );
  250. textureStore( divTex, coord, vec4( divergence, 0, 0, 0 ) ).toWriteOnly();
  251. } )().compute( CELL_COUNT ).setName( 'divergence' );
  252. // 3) Jacobi pressure solve (ping-pong A <-> B)
  253. const jacobi = ( pressRead, pressWrite, name ) => Fn( () => {
  254. const coord = getVoxelCoord( instanceIndex );
  255. const uvw = coordToUVW( coord );
  256. const pR = texture3D( pressRead, uvw.add( vec3( TEXEL_X, 0, 0 ) ), 0 ).x;
  257. const pL = texture3D( pressRead, uvw.sub( vec3( TEXEL_X, 0, 0 ) ), 0 ).x;
  258. const pU = texture3D( pressRead, uvw.add( vec3( 0, TEXEL_Y, 0 ) ), 0 ).x;
  259. const pD = texture3D( pressRead, uvw.sub( vec3( 0, TEXEL_Y, 0 ) ), 0 ).x;
  260. const pF = texture3D( pressRead, uvw.add( vec3( 0, 0, TEXEL_Z ) ), 0 ).x;
  261. const pB = texture3D( pressRead, uvw.sub( vec3( 0, 0, TEXEL_Z ) ), 0 ).x;
  262. const divergence = texture3D( divTex, uvw, 0 ).x;
  263. const pressure = pR.add( pL ).add( pU ).add( pD ).add( pF ).add( pB ).sub( divergence ).div( 6 );
  264. textureStore( pressWrite, coord, vec4( pressure, 0, 0, 0 ) ).toWriteOnly();
  265. } )().compute( CELL_COUNT ).setName( name );
  266. jacobiPassAB = jacobi( pressTexA, pressTexB, 'jacobiAB' );
  267. jacobiPassBA = jacobi( pressTexB, pressTexA, 'jacobiBA' );
  268. // 4) Project: subtract pressure gradient -> divergence-free velocity
  269. // read: velTexB, pressTexA -> write: velTexA (final velocity of the frame)
  270. projectPass = Fn( () => {
  271. const coord = getVoxelCoord( instanceIndex );
  272. const uvw = coordToUVW( coord );
  273. const pR = texture3D( pressTexA, uvw.add( vec3( TEXEL_X, 0, 0 ) ), 0 ).x;
  274. const pL = texture3D( pressTexA, uvw.sub( vec3( TEXEL_X, 0, 0 ) ), 0 ).x;
  275. const pU = texture3D( pressTexA, uvw.add( vec3( 0, TEXEL_Y, 0 ) ), 0 ).x;
  276. const pD = texture3D( pressTexA, uvw.sub( vec3( 0, TEXEL_Y, 0 ) ), 0 ).x;
  277. const pF = texture3D( pressTexA, uvw.add( vec3( 0, 0, TEXEL_Z ) ), 0 ).x;
  278. const pB = texture3D( pressTexA, uvw.sub( vec3( 0, 0, TEXEL_Z ) ), 0 ).x;
  279. const gradient = vec3( pR.sub( pL ), pU.sub( pD ), pF.sub( pB ) ).mul( 0.5 );
  280. const vel = texture3D( velTexB, uvw, 0 ).xyz.sub( gradient );
  281. textureStore( velTexA, coord, vec4( vel, 0 ) ).toWriteOnly();
  282. } )().compute( CELL_COUNT ).setName( 'project' );
  283. // 5) Advect density / temperature
  284. // read: dyeTexNode, velTexA -> write: dyeTexWriteNode
  285. advectDyePass = Fn( () => {
  286. const coord = getVoxelCoord( instanceIndex );
  287. const uvw = coordToUVW( coord );
  288. const vel = texture3D( velTexA, uvw, 0 ).xyz;
  289. const velUVW = vel.div( uVolumeWorldSize );
  290. const prevPos = uvw.sub( velUVW.mul( uDt ) );
  291. const dye = dyeTexNode.sample( prevPos ).level( 0 );
  292. const density = dye.r.mul( max( float( 1 ).sub( uDissipation.mul( uDt ) ), 0 ) ).toVar();
  293. const temperature = dye.g.mul( max( float( 1 ).sub( uCooling.mul( uDt ) ), 0 ) ).toVar();
  294. // Nearest neighbor lookup for age to prevent numerical diffusion
  295. const gridDims = vec3( GRID_SIZE_X, GRID_SIZE_Y, GRID_SIZE_Z );
  296. const nearestUVW = floor( prevPos.mul( gridDims ) ).add( 0.5 ).div( gridDims );
  297. const age = dyeTexNode.sample( nearestUVW ).level( 0 ).b.add( uDt ).toVar();
  298. temperature.assign( temperature.clamp( 0, 12 ) );
  299. If( density.lessThanEqual( 0.01 ), () => {
  300. age.assign( 0.0 );
  301. } );
  302. textureStore( dyeTexWriteNode, coord, vec4( density, temperature, age, 1.0 ) ).toWriteOnly();
  303. } )().compute( CELL_COUNT ).setName( 'advectDye' );
  304. // 6) Emit density/temperature from teapot vertices
  305. // write: dyeTexWriteNode
  306. emitTeapotPass = Fn( () => {
  307. const vertexPos = teapotVerticesBuffer.element( instanceIndex );
  308. const worldPos = uTeapotMatrix.mul( vec4( vertexPos, 1.0 ) ).xyz;
  309. // Map world position to volume box UVW space [0..1]
  310. const uvw = worldPos.sub( vec3( 0, VOLUME_WORLD_SIZE_Y / 2, 0 ) ).div( uVolumeWorldSize ).add( 0.5 );
  311. // Check boundary
  312. If( uvw.x.greaterThanEqual( 0 ).and( uvw.x.lessThanEqual( 1 ) )
  313. .and( uvw.y.greaterThanEqual( 0 ) ).and( uvw.y.lessThanEqual( 1 ) )
  314. .and( uvw.z.greaterThanEqual( 0 ) ).and( uvw.z.lessThanEqual( 1 ) ), () => {
  315. const coord = uvec3( uvw.mul( vec3( GRID_SIZE_X, GRID_SIZE_Y, GRID_SIZE_Z ) ) );
  316. // Add flicker / animated noise based on local vertex position
  317. const flicker = mx_noise_float( vertexPos.mul( 9.0 ).add( vec3( 0.0, uTime.negate().mul( 2.5 ), uTime.mul( 0.7 ) ) ) ).mul( 0.5 ).add( 0.5 );
  318. // Baseline emission depends on temperature rate (0 if temperature is 0)
  319. const baseEmission = uEmitTemperature.greaterThan( 0.0 ).select( float( 1.0 ), float( 0.0 ) );
  320. // Movement-based emission (boost) scales with speed
  321. const movementEmission = uTeapotSpeed.mul( uMotionBoost );
  322. // Unified emission factor (includes movement boost)
  323. const emissionFactor = baseEmission.add( movementEmission );
  324. const densityVal = uEmitDensity.mul( float( 1 / 120 ) ).mul( flicker.mul( 0.85 ).add( 0.15 ) ).mul( emissionFactor );
  325. If( densityVal.greaterThan( 0.0 ), () => {
  326. const tempVal = uEmitTemperature.mul( float( 1 / 120 ) ).mul( flicker.mul( 0.85 ).add( 0.15 ) ).mul( emissionFactor );
  327. // Read current dye and add emission
  328. const currentDye = dyeTexNode.sample( uvw ).level( 0 );
  329. const newDensity = currentDye.r.add( densityVal );
  330. const newTemp = currentDye.g.add( tempVal ).clamp( 0.0, 12.0 );
  331. const currentAge = currentDye.b;
  332. const newAge = mix( currentAge, float( 0.0 ), densityVal.div( max( newDensity, 0.001 ) ) );
  333. textureStore( dyeTexWriteNode, coord, vec4( newDensity, newTemp, newAge, 1.0 ) ).toWriteOnly();
  334. } );
  335. } );
  336. } )().compute( vertexCount ).setName( 'emitTeapot' );
  337. }
  338. // ---------------------------------------------------------------
  339. // Init
  340. // ---------------------------------------------------------------
  341. function init() {
  342. renderer = new THREE.WebGPURenderer();
  343. renderer.setSize( window.innerWidth, window.innerHeight );
  344. renderer.setAnimationLoop( animate );
  345. renderer.toneMapping = THREE.ACESFilmicToneMapping;
  346. renderer.toneMappingExposure = 2;
  347. renderer.shadowMap.enabled = true;
  348. renderer.shadowMap.transmitted = true;
  349. renderer.inspector = new Inspector();
  350. document.body.appendChild( renderer.domElement );
  351. scene = new THREE.Scene();
  352. scene.background = new THREE.Color( 0x000000 );
  353. camera = new THREE.PerspectiveCamera( 60, window.innerWidth / window.innerHeight, 0.1, 100 );
  354. camera.position.set( 14, 5.5, 4.4 );
  355. controls = new OrbitControls( camera, renderer.domElement );
  356. controls.target.set( 0, - VOLUME_WORLD_SIZE_Y / 2 + 3.6 + VOLUME_WORLD_SIZE_Y / 2, 0 );
  357. controls.maxDistance = 40;
  358. controls.minDistance = 2;
  359. controls.update();
  360. // Simulation resources
  361. velTexA = createStorage3D( 'velocity A' );
  362. velTexB = createStorage3D( 'velocity B' );
  363. dyeTexA = createStorage3D( 'dye A' );
  364. dyeTexB = createStorage3D( 'dye B' );
  365. divTex = createStorage3D( 'divergence' );
  366. pressTexA = createStorage3D( 'pressure A' );
  367. pressTexB = createStorage3D( 'pressure B' );
  368. curlNoiseTex = createStorage3D( 'curlNoise' );
  369. curlNoiseTex.wrapS = THREE.RepeatWrapping;
  370. curlNoiseTex.wrapT = THREE.RepeatWrapping;
  371. curlNoiseTex.wrapR = THREE.RepeatWrapping;
  372. dyeTexNode = texture3D( dyeTexA );
  373. dyeTexWriteNode = storageTexture( dyeTexB ).toWriteOnly();
  374. curlNoiseTexNode = texture3D( curlNoiseTex );
  375. // Teapot geometry & storage buffer for compute stage
  376. const teapotGeometry = new TeapotGeometry( 0.8, 28 );
  377. teapotGeometry.computeBoundingBox();
  378. const teapotMinY = teapotGeometry.boundingBox.min.y;
  379. vertexCount = teapotGeometry.attributes.position.count;
  380. teapotVerticesBuffer = storage( teapotGeometry.attributes.position, 'vec3', vertexCount ).toReadOnly();
  381. createComputePasses();
  382. // Precompute curl noise on the GPU
  383. renderer.computeAsync( computeCurlNoisePass );
  384. // Volumetric material - ray marches the simulated 3D texture
  385. const volumetricMaterial = new THREE.VolumeNodeMaterial();
  386. volumetricMaterial.steps = 16;
  387. volumetricMaterial.transparent = true;
  388. volumetricMaterial.blending = THREE.AdditiveBlending;
  389. volumetricMaterial.depthWrite = false;
  390. // Dithering to reduce banding
  391. volumetricMaterial.offsetNode = fract( interleavedGradientNoise( screenCoordinate ).add( float( frameId ).mul( 0.618033988749895 ) ) );
  392. // blackbody-style fire ramp: start color -> mid color -> end color
  393. const fireRamp = Fn( ( [ t ] ) => {
  394. const color = vec3( 0 ).toVar();
  395. color.assign( mix( vec3( 0.0, 0.0, 0.0 ), uFireEndColor, smoothstep( 0.05, 0.35, t ) ) );
  396. color.assign( mix( color, uFireMidColor, smoothstep( 0.35, 0.65, t ) ) );
  397. color.assign( mix( color, uFireStartColor, smoothstep( 0.65, 1.0, t ) ) );
  398. return color;
  399. } );
  400. const henyeyGreenstein = Fn( ( [ cosTheta, g ] ) => {
  401. const g2 = g.mul( g );
  402. const denom = float( 1.0 ).add( g2 ).sub( float( 2.0 ).mul( g ).mul( cosTheta ) );
  403. const oneMinusG2 = float( 1.0 ).sub( g2 );
  404. // Normalization constant 1 / (4 * PI) is approx 0.079577
  405. return oneMinusG2.div( denom.pow( 1.5 ) ).mul( 0.079577 );
  406. } );
  407. const getVolumeSample = ( { positionRay } ) => {
  408. // volume box is shifted up -> map ray position to uvw [0..1]
  409. const uvw = positionRay.sub( vec3( 0, VOLUME_WORLD_SIZE_Y / 2, 0 ) ).div( uVolumeWorldSize ).add( 0.5 ).toVar();
  410. // 1) Domain Warping: distort coordinates using velocity field over time to make smoke wispy (Option A)
  411. const noiseDistortion = texture3D( velTexA, uvw, 0 ).xyz.div( uVolumeWorldSize ).mul( 0.15 );
  412. const distortedUVW = uvw.add( noiseDistortion ).clamp( 0.0, 1.0 ).toVar();
  413. const sample = dyeTexNode.sample( distortedUVW ).level( 0 );
  414. const density = sample.r;
  415. const age = sample.b;
  416. const temperature = sample.g;
  417. // 2) High-frequency detail noise modulation (using simplex noise instead of mx_noise)
  418. const detailNoise = snoise( positionRay.mul( 5.5 ).add( vec3( 0, age.mul( 0.8 ).negate(), 0 ) ) );
  419. density.mulAssign( detailNoise.mul( 0.35 ).add( 0.85 ) );
  420. // soften the box edges
  421. const edge = min( distortedUVW, vec3( 1 ).sub( distortedUVW ) );
  422. density.mulAssign( smoothstep( 0.0, 0.06, min( edge.x, min( edge.y, edge.z ) ) ) );
  423. return { density, temperature, age, distortedUVW };
  424. };
  425. volumetricMaterial.scatteringNode = Fn( ( { positionRay } ) => {
  426. const { density } = getVolumeSample( { positionRay } );
  427. // 3) Key-light Self-Shadowing: raymarch towards uKeyLightPos
  428. const lightDir = uKeyLightPos.sub( positionRay ).normalize();
  429. const shadowDensitySum = float( 0.0 ).toVar();
  430. const shadowStepSize = 0.35;
  431. for ( let i = 0; i < 2; i ++ ) { // default 5
  432. const stepDist = ( i + 0.5 ) * shadowStepSize;
  433. const shadowPos = positionRay.add( lightDir.mul( stepDist ) );
  434. const shadowUVW = shadowPos.sub( vec3( 0, VOLUME_WORLD_SIZE_Y / 2, 0 ) ).div( uVolumeWorldSize ).add( 0.5 );
  435. // Fade out shadow density near the volume borders to avoid edge artifacts
  436. const shadowEdge = min( shadowUVW, vec3( 1 ).sub( shadowUVW ) );
  437. const shadowFade = smoothstep( 0.0, 0.06, min( shadowEdge.x, min( shadowEdge.y, shadowEdge.z ) ) );
  438. const shadowSample = texture3D( dyeTexA, shadowUVW, 0 ).r.mul( shadowFade );
  439. shadowDensitySum.addAssign( shadowSample );
  440. }
  441. // Calculate optical thickness (tau)
  442. const tau = shadowDensitySum.mul( shadowStepSize ).mul( uShadowAbsorption );
  443. const beer = tau.negate().exp();
  444. // Multiple Scattering Approximation (Octave 2): lower absorption (e.g. 0.25x) and scaled down contribution (0.5x)
  445. const multiScatter = tau.mul( 0.25 ).negate().exp().mul( 0.5 );
  446. // Blend between single and multiple scattering
  447. const baseTransmittance = mix( beer, beer.add( multiScatter ), uMultiScattering );
  448. // Apply Beer's Law Powder Effect to simulate edge self-shadowing details
  449. const powder = float( 1.0 ).sub( tau.mul( 2.0 ).negate().exp() );
  450. const finalTransmittance = mix( baseTransmittance, baseTransmittance.mul( powder ), uPowderStrength );
  451. // Apply ambient light in shadowed regions
  452. const lightTransmittance = finalTransmittance.add( uShadowAmbient ).clamp( 0.0, 1.0 );
  453. // Henyey-Greenstein Phase Function for directional scattering
  454. const viewDir = cameraPosition.sub( positionRay ).normalize();
  455. const cosTheta = viewDir.dot( lightDir ).clamp( - 1.0, 1.0 );
  456. const phase = henyeyGreenstein( cosTheta, uAsymmetry );
  457. // Apply shadowing and phase function only to the smoke scattering
  458. // Multiply phase function by 4 * PI (approx 12.56637) to maintain standard lighting scale
  459. const smokeScattering = vec3( density ).mul( lightTransmittance ).mul( phase.mul( 12.56637 ) );
  460. return smokeScattering;
  461. } );
  462. volumetricMaterial.scatteringEmissiveNode = Fn( ( { positionRay } ) => {
  463. const { density, temperature } = getVolumeSample( { positionRay } );
  464. // fire "emission" (boosted scattering tinted by temperature)
  465. // Control the spread of the fire core (inverted: higher spread = lower power)
  466. const firePower = float( 6.0 ).sub( uFireGlowSpread );
  467. const fire = fireRamp( temperature.clamp( 0, 1 ) ).mul( temperature.pow( firePower ) ).mul( uFireIntensity );
  468. // Apply hue rotation to the fire color
  469. const fireColor = hue( fire, uFireHue );
  470. // Simulate the spotlight distance attenuation (with a constant intensity of 400) to restore the original color/brightness
  471. const distance = positionRay.sub( uKeyLightPos ).length();
  472. const attenuation = float( 400.0 ).div( distance.pow( 2.0 ) );
  473. return fireColor.mul( density.add( 0.15 ) ).mul( attenuation );
  474. } );
  475. const volumeCastShadow = Fn( () => {
  476. const startPos = positionWorld;
  477. const lightDir = positionWorld.sub( cameraPosition ).normalize();
  478. const steps = uniform( 'int' ).onRenderUpdate( ( { material, object } ) => material.steps || ( object && object.material && object.material.steps ) || volumetricMaterial.steps );
  479. const maxDistance = float( VOLUME_WORLD_SIZE_DIAGONAL ); // Diagonal of volume box
  480. const stepSize = maxDistance.div( steps ).toVar();
  481. const rayDir = lightDir.toVar();
  482. const distTravelled = float( 0.0 ).toVar();
  483. const transmittance = float( 1.0 ).toVar();
  484. Loop( steps, () => {
  485. const positionRay = startPos.add( rayDir.mul( distTravelled ) );
  486. const { density } = getVolumeSample( { positionRay } );
  487. const absorption = density.mul( uShadowAbsorption ).mul( 0.01 );
  488. const falloff = absorption.negate().mul( stepSize ).exp();
  489. transmittance.mulAssign( falloff );
  490. distTravelled.addAssign( stepSize );
  491. } );
  492. // If the ray is completely transparent, discard the fragment
  493. transmittance.greaterThanEqual( 0.99 ).discard();
  494. const shadowOpacity = transmittance.oneMinus();
  495. return vec4( vec3( 0 ), shadowOpacity.mul( 5 ) );
  496. } );
  497. volumetricMesh = new THREE.Mesh( new THREE.BoxGeometry( VOLUME_WORLD_SIZE_X, VOLUME_WORLD_SIZE_Y, VOLUME_WORLD_SIZE_Z ), volumetricMaterial );
  498. volumetricMesh.position.y = VOLUME_WORLD_SIZE_Y / 2 + 0.4;
  499. volumetricMesh.receiveShadow = true;
  500. scene.add( volumetricMesh );
  501. const shadowMaterial = new THREE.VolumeNodeMaterial();
  502. shadowMaterial.steps = volumetricMaterial.steps;
  503. shadowMaterial.offsetNode = volumetricMaterial.offsetNode;
  504. shadowMaterial.castShadowNode = volumeCastShadow();
  505. shadowMaterial.shadowSide = THREE.FrontSide;
  506. shadowMaterial.colorWrite = false;
  507. shadowMaterial.depthWrite = false;
  508. shadowMaterial.blending = THREE.CustomBlending;
  509. shadowMaterial.blendEquation = THREE.AddEquation;
  510. shadowMaterial.blendSrc = THREE.ZeroFactor;
  511. shadowMaterial.blendDst = THREE.OneMinusSrcAlphaFactor;
  512. shadowMaterial.blendEquationAlpha = THREE.AddEquation;
  513. shadowMaterial.blendSrcAlpha = THREE.OneFactor;
  514. shadowMaterial.blendDstAlpha = THREE.OneMinusSrcAlphaFactor;
  515. const volumetricShadowMesh = new THREE.Mesh( new THREE.BoxGeometry( VOLUME_WORLD_SIZE_X, VOLUME_WORLD_SIZE_Y, VOLUME_WORLD_SIZE_Z ), shadowMaterial );
  516. volumetricShadowMesh.position.y = VOLUME_WORLD_SIZE_Y / 2 + 0.4;
  517. volumetricShadowMesh.castShadow = true;
  518. scene.add( volumetricShadowMesh );
  519. // Floor
  520. const floorPlane = new THREE.Mesh( new THREE.PlaneGeometry( 80, 80 ), new THREE.MeshStandardMaterial( { color: 0x111115, roughness: 0.8 } ) );
  521. floorPlane.rotation.x = - Math.PI / 2;
  522. floorPlane.position.y = - VOLUME_WORLD_SIZE_Y / 2 + 0.4 + VOLUME_WORLD_SIZE_Y / 2 + 0.4;
  523. floorPlane.receiveShadow = true;
  524. scene.add( floorPlane );
  525. // Teapot - opaque object inside the smoke, to visualize volumetric transparency / occlusion
  526. teapot = new THREE.Mesh(
  527. teapotGeometry,
  528. new THREE.MeshStandardMaterial( { color: 0x000000, roughness: 1.0, metalness: 1.0 } )
  529. );
  530. //teapot.castShadow = true;
  531. teapot.receiveShadow = true;
  532. teapot.position.set( 0, floorPlane.position.y - teapotMinY, 0 );
  533. teapot.visible = true;
  534. scene.add( teapot );
  535. prevTeapotPos.copy( teapot.position );
  536. teapot.updateMatrixWorld();
  537. uTeapotMatrix.value.copy( teapot.matrixWorld );
  538. uTeapotPosition.value.copy( teapot.position );
  539. const isVolume = Fn( ( { material } ) => {
  540. const isVolumeMaterial = material && material.isVolumeNodeMaterial;
  541. return float( isVolumeMaterial ? 1.0 : 0.0 );
  542. } )();
  543. const pointLightColor = Fn( () => {
  544. // Shading point position in world space
  545. const P = positionWorld;
  546. // Light source bottom position (teapot position)
  547. const A = uTeapotPosition;
  548. // 1. Flame column height
  549. const H = vec3( 0.0, uFlameHeight, 0.0 ); // Direction of vertical propagation
  550. // Calculate closest point on vertical segment (displaced by sway)
  551. const V = P.sub( A );
  552. const t = V.dot( H ).div( H.dot( H ) ).clamp( 0.0, 1.0 );
  553. const C = A.add( uSway ).add( H.mul( t ) );
  554. const distToSegment = P.sub( C ).length();
  555. // Calculate soft cylindrical attenuation (with flame thickness radius r = 1.2)
  556. const r = float( 1.2 );
  557. const softAttenuation = float( 1.0 ).div( distToSegment.pow( 2.0 ).add( r.pow( 2.0 ) ) );
  558. // Recreate standard PointLight distance attenuation for correction/cancellation
  559. const distToLight = P.sub( A ).length();
  560. const decayExponent = float( 2.0 ); // Must match PointLight's decay value in the constructor
  561. const defaultAttenuation = distToLight.pow( decayExponent ).max( 0.01 ).reciprocal();
  562. // Correction factor to cancel the default point light attenuation and apply volumetric/capsule decay
  563. // We only cancel the decay when rendering the volume so standard surfaces keep their physical 1/d^2 falloff.
  564. const attenuationCorrection = isVolume.equal( 1.0 ).select(
  565. softAttenuation.div( defaultAttenuation ),
  566. float( 1.0 )
  567. );
  568. // Choose intensity based on whether we are shading the volume (smoke) or solid surfaces (reflection)
  569. const currentIntensity = isVolume.equal( 1.0 ).select( uPointLightVolumeIntensity, uPointLightSurfaceIntensity );
  570. // 4. Color temperature oscillation: shift color tone slightly over time (uniform for volume)
  571. const colorT = uEmitTemperature.div( 8.34 ).mul( 0.5 ).add( 0.20 ).add( uColorNoise ).clamp( 0.0, 1.0 );
  572. const fireColor = fireRamp( colorT );
  573. const coloredFire = hue( saturation( fireColor, uSaturation ), uFireHue );
  574. // 5. Projected fire light color on surfaces
  575. // Calculate relative XZ position from teapot center
  576. const relP = P.xz.sub( A.xz );
  577. const angle = atan( relP.y, relP.x );
  578. const distXZ = relP.length();
  579. // Radial ray/spoke noise that rotates/flickers over time
  580. const freqScale = uPointLightProjectionFrequency;
  581. const angleNoise = mx_noise_float( vec3( cos( angle ).mul( float( 1.5 ).mul( freqScale ) ), sin( angle ).mul( float( 1.5 ).mul( freqScale ) ), uTime.mul( 0.6 ) ) ).mul( 0.5 ).add( 0.5 );
  582. // Fade out the angle spoke noise near the center to prevent the atan(0,0) seam singularity
  583. const centerFadeFactor = smoothstep( 0.0, uPointLightProjectionCenterFade, distXZ );
  584. const cleanAngleNoise = mix( float( 1.0 ), angleNoise, centerFadeFactor );
  585. // Spatial noise moving outwards/upwards (convective fire behavior)
  586. const noiseCoord1 = vec3( P.x.mul( float( 0.6 ).mul( freqScale ) ), uTime.mul( 1.2 ), P.z.mul( float( 0.6 ).mul( freqScale ) ) );
  587. const projN1 = mx_noise_float( noiseCoord1 ).mul( 0.5 ).add( 0.5 );
  588. const noiseCoord2 = vec3( P.x.mul( float( 1.5 ).mul( freqScale ) ), uTime.mul( 2.5 ), P.z.mul( float( 1.5 ).mul( freqScale ) ) );
  589. const projN2 = mx_noise_float( noiseCoord2 ).mul( 0.5 ).add( 0.5 );
  590. // Combine the noises
  591. const projNoise = projN1.mul( 0.65 ).add( projN2.mul( 0.35 ) );
  592. // Modulate by radial spoke pattern
  593. const projectionIntensity = projNoise.mul( cleanAngleNoise.mul( 0.5 ).add( 0.5 ) );
  594. // Fade out the noise over distance (blend to uniform 1.0)
  595. const noiseFadeFactor = distToSegment.div( uPointLightProjectionNoiseFade ).clamp( 0.0, 1.0 );
  596. const finalIntensity = mix( projectionIntensity, float( 1.0 ), noiseFadeFactor );
  597. // Create a radial temperature gradient from the fire center to project colors realistically
  598. const radialTemp = float( 1.0 ).sub( distToSegment.div( uPointLightProjectionRadius ) ).clamp( 0.0, 1.0 );
  599. // Map the radial temperature and noise to the fire colors (Start, Mid, End)
  600. const colorTProj = radialTemp.mul( finalIntensity ).clamp( 0.0, 1.0 );
  601. const fireColorProj = fireRamp( colorTProj );
  602. const coloredFireProj = hue( saturation( fireColorProj, uSaturation ), uFireHue );
  603. // Select either uniform fire color (volume) or projected fire color (surface)
  604. const finalFireColor = isVolume.equal( 1.0 ).select( coloredFire, coloredFireProj );
  605. // Scale intensity by uEmitTemperature (relative to its default 8.34) using Stefan-Boltzmann law (T^4)
  606. // to make it physically correct (radiant energy is proportional to T^4)
  607. const tempScale = uEmitTemperature.div( 8.34 ).max( 0.0 );
  608. const tempFactor = tempScale.pow( 4.0 );
  609. // Scale by uEmitDensity (relative to default 11.02) to represent fire/smoke size
  610. const densityScale = uEmitDensity.div( 11.02 ).max( 0.0 );
  611. // Smooth fade-in of point light intensity during the first 3 seconds of the simulation
  612. const fadeIn = smoothstep( 0.0, 3.0, uTime );
  613. const baseColor = finalFireColor.mul( tempFactor ).mul( densityScale ).mul( uFireIntensity ).mul( currentIntensity ).mul( uFlicker ).mul( fadeIn );
  614. // Blend between near and far light intensity scales
  615. const distRatio = distToSegment.div( uLightFarDistance ).clamp( 0.0, 1.0 );
  616. const distanceScale = mix( uLightNearIntensity, uLightFarIntensity, smoothstep( 0.0, 1.0, distRatio ) );
  617. // Apply distance-based scaling only when shading the volumetric smoke
  618. const finalScale = isVolume.equal( 1.0 ).select( distanceScale, float( 1.0 ) );
  619. return baseColor.mul( attenuationCorrection ).mul( finalScale );
  620. } )();
  621. pointLight = new THREE.PointLight( 0xffffff, 1, 100, 2 );
  622. pointLight.colorNode = pointLightColor;
  623. pointLight.position.set( 0, 0, 0 );
  624. pointLight.castShadow = false;
  625. teapot.add( pointLight );
  626. // DragControls to drag the teapot
  627. const dragControls = new DragControls( [ teapot ], camera, renderer.domElement );
  628. dragControls.rotateSpeed = 0;
  629. dragControls.addEventListener( 'dragstart', function () {
  630. controls.enabled = false;
  631. } );
  632. dragControls.addEventListener( 'drag', function () {
  633. // Constraint to volume box boundaries
  634. const limitX = VOLUME_WORLD_SIZE_X / 2 - 1.5;
  635. const limitZ = VOLUME_WORLD_SIZE_Z / 2 - 1.5;
  636. teapot.position.x = Math.max( - limitX, Math.min( limitX, teapot.position.x ) );
  637. teapot.position.y = Math.max( floorPlane.position.y - teapotMinY, Math.min( VOLUME_WORLD_SIZE_Y - 1.5, teapot.position.y ) );
  638. teapot.position.z = Math.max( - limitZ, Math.min( limitZ, teapot.position.z ) );
  639. } );
  640. dragControls.addEventListener( 'dragend', function () {
  641. controls.enabled = true;
  642. } );
  643. // Key light - white spot with shadow, so the smoke receives/shows shadows clearly
  644. keyLight = new THREE.SpotLight( 0xffffff, 1000 );
  645. keyLight.position.set( - 3 * ( VOLUME_WORLD_SIZE_X / 8 ), 6 * ( VOLUME_WORLD_SIZE_Y / 8 ) + VOLUME_WORLD_SIZE_Y / 2 + 0.4, 3 * ( VOLUME_WORLD_SIZE_Z / 8 ) );
  646. keyLight.angle = Math.PI / 5;
  647. keyLight.penumbra = 1;
  648. keyLight.decay = 2;
  649. keyLight.distance = 0;
  650. keyLight.castShadow = true;
  651. keyLight.shadow.intensity = .98;
  652. keyLight.shadow.mapSize.width = 1024;
  653. keyLight.shadow.mapSize.height = 1024;
  654. keyLight.shadow.camera.near = 1;
  655. const maxVolumeSize = Math.max( VOLUME_WORLD_SIZE_X, VOLUME_WORLD_SIZE_Y, VOLUME_WORLD_SIZE_Z );
  656. keyLight.shadow.camera.far = 20 * ( maxVolumeSize / 8 );
  657. keyLight.shadow.bias = - 0.001;
  658. keyLight.shadow.focus = 1;
  659. keyLight.target.position.set( 1, 0, 0 );
  660. scene.add( keyLight );
  661. scene.add( keyLight.target );
  662. uKeyLightPos = uniform( keyLight.position );
  663. // Render Pipeline (same structure as the volumetric example)
  664. renderPipeline = new THREE.RenderPipeline( renderer );
  665. // Layers
  666. const LAYER_VOLUMETRIC_LIGHTING = 10;
  667. const volumetricLayer = new THREE.Layers();
  668. volumetricLayer.disableAll();
  669. volumetricLayer.enable( LAYER_VOLUMETRIC_LIGHTING );
  670. volumetricMesh.layers.disableAll();
  671. volumetricMesh.layers.enable( LAYER_VOLUMETRIC_LIGHTING );
  672. keyLight.layers.enable( LAYER_VOLUMETRIC_LIGHTING );
  673. pointLight.layers.enable( LAYER_VOLUMETRIC_LIGHTING );
  674. // Scene Pass
  675. const scenePass = pass( scene, camera ).toInspector( 'Scene' );
  676. scenePass.name = 'Scene Pass';
  677. // Volumetric Lighting Pass
  678. const volumetricPass = pass( scene, camera ).toInspector( 'Volumetric Lighting' );
  679. volumetricPass.name = 'Volumetric Lighting';
  680. volumetricPass.setLayers( volumetricLayer );
  681. volumetricPass.setResolutionScale( 0.5 );
  682. // Compose and Denoise
  683. denoiseStrength = uniform( 0.5 );
  684. const uSaturation = uniform( 1.1 );
  685. teapot.material.emissiveNode = Fn( () => {
  686. // Lava flow animation using local position for stability when dragging
  687. const p = positionLocal.mul( 0.5 );
  688. const flow = vec3( 0.0, uTime.negate(), 0.0 );
  689. // 3 Octaves of MaterialX Noise for organic fractal pattern
  690. const n1 = mx_noise_float( p.add( flow ) ).mul( 0.5 ).add( 0.5 );
  691. const p2 = p.mul( 2.0 ).sub( flow.mul( 1.5 ) );
  692. const n2 = mx_noise_float( p2.add( vec3( n1.mul( 0.4 ) ) ) ).mul( 0.5 ).add( 0.5 );
  693. const p3 = p.mul( 4.0 ).add( flow.mul( 2.5 ) );
  694. const n3 = mx_noise_float( p3 ).mul( 0.5 ).add( 0.5 );
  695. const noiseVal = n1.mul( 0.50 ).add( n2.mul( 0.35 ) ).add( n3.mul( 0.15 ) );
  696. // Apply power function to create sharp glowing lava veins and wide dark crust regions
  697. const lavaT = noiseVal.pow( 2.5 ).clamp( 0.0, 1.0 );
  698. // Use fireRamp to map the lava temperature to the blackbody-like fire colors
  699. const fireColor = fireRamp( lavaT.add( .1 ) );
  700. const coloredFire = hue( saturation( fireColor, uSaturation ), uFireHue );
  701. const tempScale = uEmitTemperature.div( 8.34 ).max( 0.0 );
  702. const tempFactor = tempScale.pow( 4.0 );
  703. const densityScale = uEmitDensity.div( 11.02 ).max( 0.0 );
  704. const fadeIn = smoothstep( 0.0, 3.0, uTime );
  705. // Combine fire parameters with teapot emissive intensity and temporal flicker
  706. // Boosted by 10.0 to make the glowing cracks stand out clearly on the dark surface
  707. return coloredFire.mul( tempFactor ).mul( densityScale ).mul( uFireIntensity ).mul( uFlicker ).mul( fadeIn ).mul( uTeapotEmissiveIntensity );
  708. } )();
  709. params = {
  710. resolution: volumetricPass.getResolutionScale(),
  711. denoise: true,
  712. simulate: true,
  713. fireStartColor: '#ffe68c',
  714. fireMidColor: '#ff7305',
  715. fireEndColor: '#ff0000',
  716. fireHue: 0,
  717. simSpeed: 1.2,
  718. smokeLifespan: 3.5,
  719. fireLifespan: 1.3,
  720. turbulence: 3.2,
  721. toneMapping: 'ACESFilmic',
  722. exposure: 2.0,
  723. bloom: true,
  724. bloomStrength: 0.1,
  725. bloomRadius: 1.0,
  726. bloomThreshold: 0.5
  727. };
  728. const blurredVolumetricPass = gaussianBlur( volumetricPass, denoiseStrength, 1 ).toInspector( 'Blurred Volumetric' );
  729. // GUI
  730. const gui = renderer.inspector.createParameters( 'Fire Simulation' );
  731. gui.add( params, 'simulate' ).name( 'Simulate Fluid' );
  732. gui.add( params, 'simSpeed', 0.0, 2.0, 0.01 ).name( 'Simulation Speed' );
  733. gui.add( params, 'resolution', .1, 1 ).name( 'Render Resolution' ).onChange( ( resolution ) => {
  734. volumetricPass.setResolutionScale( resolution );
  735. } );
  736. // Quality & Denoise Folder
  737. const qualityFolder = gui.addFolder( 'Quality & Denoise' );
  738. qualityFolder.add( volumetricMaterial, 'steps', 4, 42, 1 ).name( 'Raymarch Steps' );
  739. qualityFolder.add( params, 'denoise' ).name( 'Denoise Enabled' ).onChange( updatePostProcessing );
  740. qualityFolder.add( denoiseStrength, 'value', 0, 1 ).name( 'Denoise Strength' );
  741. // Bloom Folder
  742. const bloomFolder = gui.addFolder( 'Bloom' );
  743. bloomFolder.add( params, 'bloom' ).name( 'Bloom Enabled' ).onChange( updatePostProcessing );
  744. bloomFolder.add( params, 'bloomStrength', 0.0, 3.0, 0.01 ).name( 'Bloom Strength' ).onChange( ( value ) => {
  745. if ( bloomPass ) bloomPass.strength.value = value;
  746. } );
  747. bloomFolder.add( params, 'bloomRadius', 0.0, 1.0, 0.01 ).name( 'Bloom Radius' ).onChange( ( value ) => {
  748. if ( bloomPass ) bloomPass.radius.value = value;
  749. } );
  750. bloomFolder.add( params, 'bloomThreshold', 0.0, 1.0, 0.01 ).name( 'Bloom Threshold' ).onChange( ( value ) => {
  751. if ( bloomPass ) bloomPass.threshold.value = value;
  752. } );
  753. let bloomPass = null;
  754. function updatePostProcessing() {
  755. let volumetric = volumetricPass;
  756. if ( params.denoise ) {
  757. volumetric = blurredVolumetricPass;
  758. }
  759. const volumetricRGB = volumetric.rgb;
  760. const adjustedVolumetricRGB = saturation( volumetricRGB, uSaturation );
  761. const adjustedVolumetric = vec4( adjustedVolumetricRGB, volumetric.a ).mul( .5 );
  762. const scenePassColor = scenePass.max( adjustedVolumetric ).add( adjustedVolumetric );
  763. let output = scenePassColor;
  764. if ( params.bloom ) {
  765. if ( bloomPass !== null ) {
  766. bloomPass.dispose();
  767. }
  768. bloomPass = bloom( scenePassColor );
  769. bloomPass.threshold.value = params.bloomThreshold;
  770. bloomPass.strength.value = params.bloomStrength;
  771. bloomPass.radius.value = params.bloomRadius;
  772. output = scenePassColor.add( bloomPass );
  773. } else if ( bloomPass !== null ) {
  774. bloomPass.dispose();
  775. bloomPass = null;
  776. }
  777. renderPipeline.outputNode = output;
  778. renderPipeline.needsUpdate = true;
  779. }
  780. updatePostProcessing();
  781. // Volume Visuals Folder
  782. const volumeVisuals = gui.addFolder( 'Volume Visuals' );
  783. //volumeVisuals.add( uFireIntensity, 'value', 0, 20 ).name( 'Fire Intensity' );
  784. volumeVisuals.add( uFireGlowSpread, 'value', 1.0, 5.0, 0.1 ).name( 'Glow Spread' );
  785. volumeVisuals.add( params, 'fireHue', 0, 360, 1 ).name( 'Fire Hue Shift' );
  786. volumeVisuals.add( uSaturation, 'value', 0.0, 2.0, 0.05 ).name( 'Saturation' );
  787. volumeVisuals.addColor( params, 'fireStartColor' ).name( 'Fire Start Color' );
  788. volumeVisuals.addColor( params, 'fireMidColor' ).name( 'Fire Mid Color' );
  789. volumeVisuals.addColor( params, 'fireEndColor' ).name( 'Fire End Color' );
  790. // Emitter Controls Folder
  791. const emitterControls = gui.addFolder( 'Emitter Controls' );
  792. emitterControls.add( uEmitTemperature, 'value', 0, 8 ).name( 'Temperature Rate' );
  793. emitterControls.add( uEmitDensity, 'value', 0, 20 ).name( 'Density Rate' );
  794. emitterControls.add( uMotionBoost, 'value', 0.0, 0.4, 0.01 ).name( 'Movement Boost' );
  795. emitterControls.add( uWindStrength, 'value', 0.0, 50.0, 0.01 ).name( 'Movement Wind Strength' );
  796. emitterControls.add( uTeapotEmissiveIntensity, 'value', 0.0, 1.0, 0.001 ).name( 'Teapot Emissive' );
  797. // Scattering & Shadows Folder
  798. const scatteringShadows = gui.addFolder( 'Scattering & Shadows' );
  799. scatteringShadows.add( uAsymmetry, 'value', - 0.99, 0.99, 0.01 ).name( 'Phase Asymmetry (g)' );
  800. scatteringShadows.add( uPowderStrength, 'value', 0.0, 1.0, 0.01 ).name( 'Powder Effect' );
  801. scatteringShadows.add( uMultiScattering, 'value', 0.0, 1.0, 0.01 ).name( 'Multi Scattering' );
  802. scatteringShadows.add( uShadowAbsorption, 'value', 0, 10 ).name( 'Shadow Absorption' );
  803. scatteringShadows.add( uShadowAmbient, 'value', 0, 1.0 ).name( 'Shadow Ambient' );
  804. // Fluid Physics Folder
  805. const fluidPhysics = gui.addFolder( 'Fluid Physics' );
  806. fluidPhysics.add( uBuoyancy, 'value', 0, 10 ).name( 'Buoyancy (Rise)' );
  807. fluidPhysics.add( uVelDamping, 'value', 0, 2 ).name( 'Velocity Damping' );
  808. fluidPhysics.add( params, 'fireLifespan', 0.5, 10.0, 0.1 ).name( 'Fire Lifespan' );
  809. fluidPhysics.add( params, 'smokeLifespan', 1.0, 100.0, 0.5 ).name( 'Smoke Lifespan' );
  810. fluidPhysics.add( params, 'turbulence', 0, 5 ).name( 'Turbulence Strength' );
  811. fluidPhysics.add( uTurbulenceDecay, 'value', 0.0, 1.0, 0.01 ).name( 'Turbulence Decay' );
  812. fluidPhysics.add( uTurbFrequency, 'value', 1, 10 ).name( 'Turbulence Frequency' );
  813. // Scene Lights Folder
  814. const sceneLights = gui.addFolder( 'Scene Lights' );
  815. sceneLights.add( keyLight, 'intensity', 0, 1500, 1 ).name( 'Key Light Intensity' );
  816. sceneLights.add( uPointLightVolumeIntensity, 'value', 0.0, 2.0, 0.001 ).name( 'Light Smoke' );
  817. sceneLights.add( uLightNearIntensity, 'value', 0.0, 20.0, 0.05 ).name( 'Light Near Scale' );
  818. sceneLights.add( uLightFarIntensity, 'value', 0.0, 20.0, 0.05 ).name( 'Light Far Scale' );
  819. sceneLights.add( uPointLightSurfaceIntensity, 'value', 0.0, 20.0, 0.001 ).name( 'Light Reflection' );
  820. sceneLights.add( uPointLightProjectionRadius, 'value', 1.0, 30.0, 0.1 ).name( 'Proj Light Radius' );
  821. sceneLights.add( uPointLightProjectionFrequency, 'value', 0.1, 1.0, 0.01 ).name( 'Proj Light Freq' );
  822. sceneLights.add( uPointLightProjectionNoiseFade, 'value', 1.0, 30.0, 0.1 ).name( 'Proj Noise Fade Dist' );
  823. sceneLights.add( uPointLightProjectionCenterFade, 'value', 0.1, 5.0, 0.05 ).name( 'Proj Center Fade' );
  824. // Tone Mapping & Exposure Folder
  825. const toneMappingOptions = {
  826. None: THREE.NoToneMapping,
  827. Linear: THREE.LinearToneMapping,
  828. Reinhard: THREE.ReinhardToneMapping,
  829. Cineon: THREE.CineonToneMapping,
  830. ACESFilmic: THREE.ACESFilmicToneMapping,
  831. AgX: THREE.AgXToneMapping,
  832. Neutral: THREE.NeutralToneMapping
  833. };
  834. const toneMappingFolder = gui.addFolder( 'Tone Mapping & Exposure' );
  835. toneMappingFolder.add( params, 'toneMapping', Object.keys( toneMappingOptions ) ).name( 'Tone Mapping' ).onChange( ( value ) => {
  836. renderer.toneMapping = toneMappingOptions[ value ];
  837. } );
  838. toneMappingFolder.add( params, 'exposure', 0.1, 2.0, 0.05 ).name( 'Exposure' ).onChange( ( value ) => {
  839. renderer.toneMappingExposure = value;
  840. } );
  841. window.addEventListener( 'resize', onWindowResize );
  842. }
  843. function onWindowResize() {
  844. camera.aspect = window.innerWidth / window.innerHeight;
  845. camera.updateProjectionMatrix();
  846. renderer.setSize( window.innerWidth, window.innerHeight );
  847. }
  848. // ---------------------------------------------------------------
  849. // Animation loop
  850. // ---------------------------------------------------------------
  851. let simulationTime = 0;
  852. let lastTime = performance.now();
  853. let simAccumulator = 0;
  854. function updateTemporalUniforms( time ) {
  855. uTime.value = time % 1000;
  856. const heightNoise = cpuNoise.noise( 0, time * 2.5, 0 );
  857. uFlameHeight.value = 3.5 + heightNoise * 0.8;
  858. const swayX = cpuNoise.noise( time * 3.5, 0, 0 ) * 0.4;
  859. const swayZ = cpuNoise.noise( 0, 0, time * 3.5 ) * 0.4;
  860. uSway.value.set( swayX, 0, swayZ );
  861. const slowNoise = cpuNoise.noise( 0, time * 0.8, 0 );
  862. const fastNoise = cpuNoise.noise( 0, time * 15.0, 0 );
  863. uFlicker.value = slowNoise * 0.12 + fastNoise * 0.06 + 0.82;
  864. const colorNoise = cpuNoise.noise( time * 5.0, time * 5.0, 0 ) * 0.08;
  865. uColorNoise.value = colorNoise;
  866. teapot.rotation.y = time * 0.25;
  867. teapot.updateMatrixWorld();
  868. uTeapotMatrix.value.copy( teapot.matrixWorld );
  869. }
  870. function animate() {
  871. const currentTime = performance.now();
  872. const delta = Math.min( ( currentTime - lastTime ) * 0.001, 1 / 30 );
  873. lastTime = currentTime;
  874. // Calculate teapot speed and velocity vector for wind effect
  875. const currentPos = teapot.position;
  876. const dist = currentPos.distanceTo( prevTeapotPos );
  877. const speed = delta > 0 ? dist / delta : 0;
  878. const teapotVel = new THREE.Vector3();
  879. if ( delta > 0 ) {
  880. teapotVel.subVectors( currentPos, prevTeapotPos ).multiplyScalar( 1 / delta );
  881. }
  882. prevTeapotPos.copy( currentPos );
  883. uTeapotSpeed.value = speed;
  884. uTeapotVelocity.value.copy( teapotVel );
  885. uTeapotPosition.value.copy( currentPos );
  886. if ( params.simulate && params.simSpeed > 0 ) {
  887. const dt = delta * params.simSpeed;
  888. simAccumulator += dt;
  889. const stepTime = 1 / 120;
  890. const simStep = stepTime * params.simSpeed;
  891. const maxAccumulator = simStep * 8;
  892. if ( simAccumulator > maxAccumulator ) {
  893. simAccumulator = maxAccumulator;
  894. }
  895. uDt.value = simStep;
  896. uTurbulence.value = params.simSpeed > 0 ? params.turbulence / Math.sqrt( params.simSpeed ) : 0;
  897. if ( params.smokeLifespan >= 100.0 ) {
  898. uDissipation.value = 0.0;
  899. } else {
  900. uDissipation.value = 1.0 / params.smokeLifespan;
  901. }
  902. uCooling.value = 1.0 / params.fireLifespan;
  903. while ( simAccumulator >= simStep ) {
  904. simulationTime += simStep;
  905. updateTemporalUniforms( simulationTime );
  906. // --- fluid simulation steps (compute shaders) ---
  907. renderer.compute( advectVelocityPass ); // reads dyeTexNode, writes velTexB
  908. renderer.compute( divergencePass ); // velB -> div
  909. for ( let i = 0; i < PRESSURE_ITERATIONS; i ++ ) {
  910. renderer.compute( ( i % 2 === 0 ) ? jacobiPassAB : jacobiPassBA );
  911. }
  912. renderer.compute( projectPass ); // velB - grad(p) -> velA
  913. renderer.compute( advectDyePass ); // reads dyeTexNode, writes dyeTexWriteNode
  914. renderer.compute( emitTeapotPass ); // inject from teapot vertices -> dyeTexWriteNode
  915. // Ping-pong dye textures
  916. const temp = dyeTexNode.value;
  917. dyeTexNode.value = dyeTexWriteNode.value;
  918. dyeTexWriteNode.value = temp;
  919. simAccumulator -= simStep;
  920. }
  921. } else {
  922. updateTemporalUniforms( simulationTime );
  923. }
  924. // Update point light range dynamically based on temperature, density (fire size) and fire intensity
  925. const tempRatio = uEmitTemperature.value / 8.34;
  926. const densityRatio = uEmitDensity.value / 11.02;
  927. const intensityRatio = uFireIntensity.value / 5.63;
  928. const sizeFactor = Math.sqrt( tempRatio * densityRatio * intensityRatio );
  929. // Smooth fade-in factor over the first 3 seconds of the simulation
  930. const t = Math.min( Math.max( simulationTime / 3.0, 0.0 ), 1.0 );
  931. const fadeIn = t * t * ( 3.0 - 2.0 * t );
  932. pointLight.distance = Math.max( 0.01, 40.0 * Math.max( 0.2, sizeFactor ) * fadeIn );
  933. uFireStartColor.value.set( params.fireStartColor );
  934. uFireMidColor.value.set( params.fireMidColor );
  935. uFireEndColor.value.set( params.fireEndColor );
  936. uFireHue.value = THREE.MathUtils.degToRad( params.fireHue );
  937. renderPipeline.render();
  938. }
  939. </script>
  940. </body>
  941. </html>
粤ICP备19079148号