| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324 |
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <title>three.js webgpu - volumetric fire simulation</title>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
- <meta property="og:title" content="three.js webgpu - volumetric fire simulation">
- <meta property="og:type" content="website">
- <meta property="og:url" content="https://threejs.org/examples/webgpu_volume_fire.html">
- <meta property="og:image" content="https://threejs.org/examples/screenshots/webgpu_volume_fire.jpg">
- <link type="text/css" rel="stylesheet" href="example.css">
- </head>
- <body>
- <div id="info">
- <a href="https://threejs.org/" target="_blank" rel="noopener" class="logo-link"></a>
- <div class="title-wrapper">
- <a href="https://threejs.org/" target="_blank" rel="noopener">three.js</a><span>Volumetric Fire Simulation</span>
- </div>
- <small>3D fluid simulation (semi-Lagrangian advection + curlNoise, buoyancy, Jacobi projection) on the GPU.</br>Drag the teapot to add turbulence.</small>
- </div>
- <script type="importmap">
- {
- "imports": {
- "three": "../build/three.webgpu.js",
- "three/webgpu": "../build/three.webgpu.js",
- "three/tsl": "../build/three.tsl.js",
- "three/addons/": "../examples/jsm/"
- }
- }
- </script>
- <script type="module">
- import * as THREE from 'three/webgpu';
- import {
- vec3, vec4, uvec3, float, Fn, uniform,
- texture3D, textureStore, instanceIndex,
- screenCoordinate, pass,
- smoothstep, mix, min, max, floor,
- mx_noise_float, storage, storageTexture, If, cameraPosition, hue,
- Loop, positionWorld, positionLocal,
- interleavedGradientNoise, frameId, fract,
- saturation, cos, sin, atan
- } from 'three/tsl';
- import { snoise, snoiseVec3 } from 'three/addons/tsl/math/curlNoise.js';
- import { ImprovedNoise } from 'three/addons/math/ImprovedNoise.js';
- import { gaussianBlur } from 'three/addons/tsl/display/GaussianBlurNode.js';
- import { bloom } from 'three/addons/tsl/display/BloomNode.js';
- import { Inspector } from 'three/addons/inspector/Inspector.js';
- import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
- import { DragControls } from 'three/addons/controls/DragControls.js';
- import { TeapotGeometry } from 'three/addons/geometries/TeapotGeometry.js';
- import WebGPU from 'three/addons/capabilities/WebGPU.js';
- if ( WebGPU.isAvailable() === false ) {
- document.body.appendChild( WebGPU.getErrorMessage() );
- throw new Error( 'No WebGPU support' );
- }
- // ---------------------------------------------------------------
- // Globals
- // ---------------------------------------------------------------
- const GRID_SIZE_X = 100;
- const GRID_SIZE_Y = 100;
- const GRID_SIZE_Z = 200;
- const CELL_COUNT = GRID_SIZE_X * GRID_SIZE_Y * GRID_SIZE_Z;
- const PRESSURE_ITERATIONS = 2; // Jacobi iterations (keep even!) // default 6
- const VOLUME_WORLD_SIZE_X = 12;
- const VOLUME_WORLD_SIZE_Y = 12;
- const VOLUME_WORLD_SIZE_Z = 24;
- const VOLUME_WORLD_SIZE_DIAGONAL = Math.sqrt( VOLUME_WORLD_SIZE_X ** 2 + VOLUME_WORLD_SIZE_Y ** 2 + VOLUME_WORLD_SIZE_Z ** 2 );
- const uVolumeWorldSize = uniform( new THREE.Vector3( VOLUME_WORLD_SIZE_X, VOLUME_WORLD_SIZE_Y, VOLUME_WORLD_SIZE_Z ) );
- const TEXEL_X = 1 / GRID_SIZE_X;
- const TEXEL_Y = 1 / GRID_SIZE_Y;
- const TEXEL_Z = 1 / GRID_SIZE_Z;
- let renderer, scene, camera, controls;
- let volumetricMesh, teapot, keyLight, pointLight;
- let renderPipeline;
- let denoiseStrength;
- let params;
- let uKeyLightPos;
- let teapotVerticesBuffer, vertexCount;
- const prevTeapotPos = new THREE.Vector3();
- // sim textures
- let velTexA, velTexB; // velocity field (xyz)
- let dyeTexA, dyeTexB; // x = density (smoke), y = temperature (fire)
- let divTex; // divergence
- let pressTexA, pressTexB; // pressure (Jacobi ping-pong)
- let dyeTexNode, dyeTexWriteNode, curlNoiseTex, curlNoiseTexNode;
- // compute passes
- let advectVelocityPass, divergencePass, jacobiPassAB, jacobiPassBA, projectPass, advectDyePass, emitTeapotPass, computeCurlNoisePass;
- // sim uniforms
- const uDt = uniform( 0.016 );
- const uTime = uniform( 0 );
- const uBuoyancy = uniform( 3.0 ); // hot air rises
- const uWeight = uniform( 0.15 ); // smoke weight (pulls down)
- const uTurbulence = uniform( 3.2 ); // noise force strength
- const uTurbulenceDecay = uniform( 0.1 ); // turbulence decay rate over age
- const uTurbFrequency = uniform( 10.0 ); // noise force frequency
- const uVelDamping = uniform( 0.25 ); // velocity dissipation /s
- const uCooling = uniform( 1.0 ); // temperature cooling /s (default for 1.0s lifespan)
- const uDissipation = uniform( 0.4 ); // smoke dissipation /s (default for 2.5s lifespan)
- const uEmitDensity = uniform( 7.0 );
- const uEmitTemperature = uniform( 5.5 );
- const uTeapotMatrix = uniform( new THREE.Matrix4() );
- const uTeapotSpeed = uniform( 0.0 );
- const uMotionBoost = uniform( 0.25 ); // scales fire and smoke emission when moving
- const uTeapotVelocity = uniform( new THREE.Vector3() );
- const uWindStrength = uniform( 6.5 ); // strength of the wind effect when moving
- const uTeapotPosition = uniform( new THREE.Vector3() );
- // render uniforms
- const uFireIntensity = uniform( 40.0 );
- const uTeapotEmissiveIntensity = uniform( 0.2 );
- const uFireGlowSpread = uniform( 5.0 );
- const uShadowAbsorption = uniform( 2.0 );
- const uShadowAmbient = uniform( 0.5 );
- const uFireStartColor = uniform( new THREE.Color( 0xffe68c ) );
- const uFireMidColor = uniform( new THREE.Color( 0xff7305 ) );
- const uFireEndColor = uniform( new THREE.Color( 0xff0000 ) );
- const uFireHue = uniform( 0.0 );
- const uAsymmetry = uniform( 0.0 );
- const uPowderStrength = uniform( 0.59 );
- const uMultiScattering = uniform( 1.0 );
- const uPointLightVolumeIntensity = uniform( 2.0 );
- const uPointLightSurfaceIntensity = uniform( 10.0 );
- const uLightNearIntensity = uniform( 10.0 );
- const uLightFarIntensity = uniform( 15.0 );
- const uLightFarDistance = uniform( 10.0 );
- const uPointLightProjectionRadius = uniform( 20.0 );
- const uPointLightProjectionFrequency = uniform( 0.2 );
- const uPointLightProjectionNoiseFade = uniform( 17.0 );
- const uPointLightProjectionCenterFade = uniform( 3.25 );
- const uFlameHeight = uniform( 3.5 );
- const uSway = uniform( new THREE.Vector3() );
- const uFlicker = uniform( 1.0 );
- const uColorNoise = uniform( 0.0 );
- const cpuNoise = new ImprovedNoise();
- init();
- // ---------------------------------------------------------------
- // Storage 3D textures (the "voxels")
- // ---------------------------------------------------------------
- function createStorage3D( name ) {
- const texture = new THREE.Storage3DTexture( GRID_SIZE_X, GRID_SIZE_Y, GRID_SIZE_Z );
- texture.name = name;
- texture.format = THREE.RGBAFormat;
- texture.type = THREE.HalfFloatType; // rgba16float -> storage-writable + linearly filterable
- texture.minFilter = THREE.LinearFilter;
- texture.magFilter = THREE.LinearFilter;
- texture.wrapS = THREE.ClampToEdgeWrapping;
- texture.wrapT = THREE.ClampToEdgeWrapping;
- texture.wrapR = THREE.ClampToEdgeWrapping;
- return texture;
- }
- // ---------------------------------------------------------------
- // TSL helpers shared by the compute kernels
- // ---------------------------------------------------------------
- // instanceIndex (1D) -> voxel coordinate (3D)
- const getVoxelCoord = ( id ) => {
- const x = id.mod( GRID_SIZE_X );
- const y = id.div( GRID_SIZE_X ).mod( GRID_SIZE_Y );
- const z = id.div( GRID_SIZE_X * GRID_SIZE_Y );
- return uvec3( x, y, z );
- };
- // voxel coordinate -> normalized uvw at the cell center
- const coordToUVW = ( coord ) => vec3( coord ).add( 0.5 ).div( vec3( GRID_SIZE_X, GRID_SIZE_Y, GRID_SIZE_Z ) );
- // ---------------------------------------------------------------
- // Fluid simulation - compute kernels
- // ---------------------------------------------------------------
- function createComputePasses() {
- // 0) Precompute curl noise into 3D storage texture
- computeCurlNoisePass = Fn( () => {
- const coord = getVoxelCoord( instanceIndex );
- const uvw = coordToUVW( coord );
- const freq = uTurbFrequency; // 10.0
- const e = float( 0.1 ).div( freq );
- const dx = vec3( e, 0.0, 0.0 );
- const dy = vec3( 0.0, e, 0.0 );
- const dz = vec3( 0.0, 0.0, e );
- const p = uvw.mul( vec3( VOLUME_WORLD_SIZE_X / VOLUME_WORLD_SIZE_Y, 1.0, VOLUME_WORLD_SIZE_Z / VOLUME_WORLD_SIZE_Y ) );
- const p_x0 = snoiseVec3( p.sub( dx ).mul( freq ) );
- const p_x1 = snoiseVec3( p.add( dx ).mul( freq ) );
- const p_y0 = snoiseVec3( p.sub( dy ).mul( freq ) );
- const p_y1 = snoiseVec3( p.add( dy ).mul( freq ) );
- const p_z0 = snoiseVec3( p.sub( dz ).mul( freq ) );
- const p_z1 = snoiseVec3( p.add( dz ).mul( freq ) );
- const x = p_y1.z.sub( p_y0.z ).sub( p_z1.y ).add( p_z0.y );
- const y = p_z1.x.sub( p_z0.x ).sub( p_x1.z ).add( p_x0.z );
- const z = p_x1.y.sub( p_x0.y ).sub( p_y1.x ).add( p_y0.x );
- // Analytical curlNoise multiplier is 1.0 / (2.0 * e) = 5.0 (since e = 0.1)
- const noiseVal = vec3( x, y, z ).mul( 5.0 );
- textureStore( curlNoiseTex, coord, vec4( noiseVal, 0.0 ) ).toWriteOnly();
- } )().compute( CELL_COUNT ).setName( 'computeCurlNoise' );
- // 1) Advect velocity + external forces (buoyancy, weight, turbulence)
- // read: velTexA, dyeTexNode -> write: velTexB
- advectVelocityPass = Fn( () => {
- const coord = getVoxelCoord( instanceIndex );
- const uvw = coordToUVW( coord );
- const vel = texture3D( velTexA, uvw, 0 ).xyz;
- // semi-Lagrangian advection: look back along the velocity
- const velUVW = vel.div( uVolumeWorldSize );
- const prevPos = uvw.sub( velUVW.mul( uDt ) );
- const newVel = texture3D( velTexA, prevPos, 0 ).xyz.toVar();
- const dye = dyeTexNode.sample( uvw ).level( 0 );
- const density = dye.r;
- const temperature = dye.g;
- const age = dye.b;
- // buoyancy (hot rises) vs smoke weight (cold falls)
- const buoyancyForce = temperature.mul( uBuoyancy ).sub( density.mul( uWeight ) ).mul( VOLUME_WORLD_SIZE_Y );
- newVel.addAssign( vec3( 0, buoyancyForce, 0 ).mul( uDt ) );
- // turbulence: divergence-free noise force
- // 1) Thermal/Convective turbulence: stronger where it's hot, decaying over age
- const thermalNoisePos = uvw.add( vec3( 0, age.negate().mul( 0.6 ), age.mul( 0.13 ) ).div( uTurbFrequency ) );
- const decay = age.mul( uTurbulenceDecay.negate() ).exp();
- const thermalTurbulence = curlNoiseTexNode.sample( thermalNoisePos ).level( 0 ).xyz.mul( uTurbulence ).mul( temperature ).mul( decay );
- // 2) Ambient/Atmospheric turbulence: lower frequency, weaker, acts on the smoke density (even when cooled down)
- // using uTime so it animates continuously regardless of age
- const ambientNoisePos = uvw.mul( 0.5 ).add( vec3( 0, uTime.mul( 0.25 ), uTime.mul( 0.06 ) ).div( uTurbFrequency ) );
- const ambientTurbulence = curlNoiseTexNode.sample( ambientNoisePos ).level( 0 ).xyz.mul( uTurbulence.mul( 0.2 ) ).mul( density );
- const turbulence = thermalTurbulence.add( ambientTurbulence ).mul( VOLUME_WORLD_SIZE_Y );
- newVel.addAssign( turbulence.mul( uDt ) );
- // damping
- newVel.mulAssign( max( float( 1 ).sub( uVelDamping.mul( uDt ) ), 0 ) );
- // Wind effect: bounding sphere around teapot
- const worldPos = uvw.sub( 0.5 ).mul( uVolumeWorldSize ).add( vec3( 0, VOLUME_WORLD_SIZE_Y / 2, 0 ) );
- const dist = worldPos.distance( uTeapotPosition );
- const teapotRadius = float( 1.0 );
- If( dist.lessThan( teapotRadius ), () => {
- const ratio = dist.div( teapotRadius );
- const falloff = smoothstep( 0.0, 1.0, float( 1.0 ).sub( ratio ) );
- // Wind turbulence scales with uTurbulence and teapot speed, using curlNoise
- const windNoisePos = uvw.add( vec3( 0.0, uTime.mul( 0.5 ), 0.0 ).div( uTurbFrequency ) );
- const windTurbulence = curlNoiseTexNode.sample( windNoisePos ).level( 0 ).xyz.mul( uTurbulence ).mul( uTeapotSpeed );
- const windVel = uTeapotVelocity.mul( uWindStrength ).add( windTurbulence ).mul( uDt ).mul( falloff );
- newVel.addAssign( windVel );
- } );
- // fade velocity near the volume borders (soft boundary condition)
- const edge = min( uvw, vec3( 1 ).sub( uvw ) );
- const boundary = smoothstep( 0.0, 0.08, min( edge.x, min( edge.y, edge.z ) ) );
- newVel.mulAssign( boundary );
- textureStore( velTexB, coord, vec4( newVel, 0 ) ).toWriteOnly();
- } )().compute( CELL_COUNT ).setName( 'advectVelocity' );
- // 2) Divergence of the advected velocity
- // read: velTexB -> write: divTex
- divergencePass = Fn( () => {
- const coord = getVoxelCoord( instanceIndex );
- const uvw = coordToUVW( coord );
- const vR = texture3D( velTexB, uvw.add( vec3( TEXEL_X, 0, 0 ) ), 0 ).x;
- const vL = texture3D( velTexB, uvw.sub( vec3( TEXEL_X, 0, 0 ) ), 0 ).x;
- const vU = texture3D( velTexB, uvw.add( vec3( 0, TEXEL_Y, 0 ) ), 0 ).y;
- const vD = texture3D( velTexB, uvw.sub( vec3( 0, TEXEL_Y, 0 ) ), 0 ).y;
- const vF = texture3D( velTexB, uvw.add( vec3( 0, 0, TEXEL_Z ) ), 0 ).z;
- const vB = texture3D( velTexB, uvw.sub( vec3( 0, 0, TEXEL_Z ) ), 0 ).z;
- const divergence = vR.sub( vL ).add( vU.sub( vD ) ).add( vF.sub( vB ) ).mul( 0.5 );
- textureStore( divTex, coord, vec4( divergence, 0, 0, 0 ) ).toWriteOnly();
- } )().compute( CELL_COUNT ).setName( 'divergence' );
- // 3) Jacobi pressure solve (ping-pong A <-> B)
- const jacobi = ( pressRead, pressWrite, name ) => Fn( () => {
- const coord = getVoxelCoord( instanceIndex );
- const uvw = coordToUVW( coord );
- const pR = texture3D( pressRead, uvw.add( vec3( TEXEL_X, 0, 0 ) ), 0 ).x;
- const pL = texture3D( pressRead, uvw.sub( vec3( TEXEL_X, 0, 0 ) ), 0 ).x;
- const pU = texture3D( pressRead, uvw.add( vec3( 0, TEXEL_Y, 0 ) ), 0 ).x;
- const pD = texture3D( pressRead, uvw.sub( vec3( 0, TEXEL_Y, 0 ) ), 0 ).x;
- const pF = texture3D( pressRead, uvw.add( vec3( 0, 0, TEXEL_Z ) ), 0 ).x;
- const pB = texture3D( pressRead, uvw.sub( vec3( 0, 0, TEXEL_Z ) ), 0 ).x;
- const divergence = texture3D( divTex, uvw, 0 ).x;
- const pressure = pR.add( pL ).add( pU ).add( pD ).add( pF ).add( pB ).sub( divergence ).div( 6 );
- textureStore( pressWrite, coord, vec4( pressure, 0, 0, 0 ) ).toWriteOnly();
- } )().compute( CELL_COUNT ).setName( name );
- jacobiPassAB = jacobi( pressTexA, pressTexB, 'jacobiAB' );
- jacobiPassBA = jacobi( pressTexB, pressTexA, 'jacobiBA' );
- // 4) Project: subtract pressure gradient -> divergence-free velocity
- // read: velTexB, pressTexA -> write: velTexA (final velocity of the frame)
- projectPass = Fn( () => {
- const coord = getVoxelCoord( instanceIndex );
- const uvw = coordToUVW( coord );
- const pR = texture3D( pressTexA, uvw.add( vec3( TEXEL_X, 0, 0 ) ), 0 ).x;
- const pL = texture3D( pressTexA, uvw.sub( vec3( TEXEL_X, 0, 0 ) ), 0 ).x;
- const pU = texture3D( pressTexA, uvw.add( vec3( 0, TEXEL_Y, 0 ) ), 0 ).x;
- const pD = texture3D( pressTexA, uvw.sub( vec3( 0, TEXEL_Y, 0 ) ), 0 ).x;
- const pF = texture3D( pressTexA, uvw.add( vec3( 0, 0, TEXEL_Z ) ), 0 ).x;
- const pB = texture3D( pressTexA, uvw.sub( vec3( 0, 0, TEXEL_Z ) ), 0 ).x;
- const gradient = vec3( pR.sub( pL ), pU.sub( pD ), pF.sub( pB ) ).mul( 0.5 );
- const vel = texture3D( velTexB, uvw, 0 ).xyz.sub( gradient );
- textureStore( velTexA, coord, vec4( vel, 0 ) ).toWriteOnly();
- } )().compute( CELL_COUNT ).setName( 'project' );
- // 5) Advect density / temperature
- // read: dyeTexNode, velTexA -> write: dyeTexWriteNode
- advectDyePass = Fn( () => {
- const coord = getVoxelCoord( instanceIndex );
- const uvw = coordToUVW( coord );
- const vel = texture3D( velTexA, uvw, 0 ).xyz;
- const velUVW = vel.div( uVolumeWorldSize );
- const prevPos = uvw.sub( velUVW.mul( uDt ) );
- const dye = dyeTexNode.sample( prevPos ).level( 0 );
- const density = dye.r.mul( max( float( 1 ).sub( uDissipation.mul( uDt ) ), 0 ) ).toVar();
- const temperature = dye.g.mul( max( float( 1 ).sub( uCooling.mul( uDt ) ), 0 ) ).toVar();
- // Nearest neighbor lookup for age to prevent numerical diffusion
- const gridDims = vec3( GRID_SIZE_X, GRID_SIZE_Y, GRID_SIZE_Z );
- const nearestUVW = floor( prevPos.mul( gridDims ) ).add( 0.5 ).div( gridDims );
- const age = dyeTexNode.sample( nearestUVW ).level( 0 ).b.add( uDt ).toVar();
- temperature.assign( temperature.clamp( 0, 12 ) );
- If( density.lessThanEqual( 0.01 ), () => {
- age.assign( 0.0 );
- } );
- textureStore( dyeTexWriteNode, coord, vec4( density, temperature, age, 1.0 ) ).toWriteOnly();
- } )().compute( CELL_COUNT ).setName( 'advectDye' );
- // 6) Emit density/temperature from teapot vertices
- // write: dyeTexWriteNode
- emitTeapotPass = Fn( () => {
- const vertexPos = teapotVerticesBuffer.element( instanceIndex );
- const worldPos = uTeapotMatrix.mul( vec4( vertexPos, 1.0 ) ).xyz;
- // Map world position to volume box UVW space [0..1]
- const uvw = worldPos.sub( vec3( 0, VOLUME_WORLD_SIZE_Y / 2, 0 ) ).div( uVolumeWorldSize ).add( 0.5 );
- // Check boundary
- If( uvw.x.greaterThanEqual( 0 ).and( uvw.x.lessThanEqual( 1 ) )
- .and( uvw.y.greaterThanEqual( 0 ) ).and( uvw.y.lessThanEqual( 1 ) )
- .and( uvw.z.greaterThanEqual( 0 ) ).and( uvw.z.lessThanEqual( 1 ) ), () => {
- const coord = uvec3( uvw.mul( vec3( GRID_SIZE_X, GRID_SIZE_Y, GRID_SIZE_Z ) ) );
- // Add flicker / animated noise based on local vertex position
- 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 );
- // Baseline emission depends on temperature rate (0 if temperature is 0)
- const baseEmission = uEmitTemperature.greaterThan( 0.0 ).select( float( 1.0 ), float( 0.0 ) );
- // Movement-based emission (boost) scales with speed
- const movementEmission = uTeapotSpeed.mul( uMotionBoost );
- // Unified emission factor (includes movement boost)
- const emissionFactor = baseEmission.add( movementEmission );
- const densityVal = uEmitDensity.mul( float( 1 / 120 ) ).mul( flicker.mul( 0.85 ).add( 0.15 ) ).mul( emissionFactor );
- If( densityVal.greaterThan( 0.0 ), () => {
- const tempVal = uEmitTemperature.mul( float( 1 / 120 ) ).mul( flicker.mul( 0.85 ).add( 0.15 ) ).mul( emissionFactor );
- // Read current dye and add emission
- const currentDye = dyeTexNode.sample( uvw ).level( 0 );
- const newDensity = currentDye.r.add( densityVal );
- const newTemp = currentDye.g.add( tempVal ).clamp( 0.0, 12.0 );
- const currentAge = currentDye.b;
- const newAge = mix( currentAge, float( 0.0 ), densityVal.div( max( newDensity, 0.001 ) ) );
- textureStore( dyeTexWriteNode, coord, vec4( newDensity, newTemp, newAge, 1.0 ) ).toWriteOnly();
- } );
- } );
- } )().compute( vertexCount ).setName( 'emitTeapot' );
- }
- // ---------------------------------------------------------------
- // Init
- // ---------------------------------------------------------------
- function init() {
- renderer = new THREE.WebGPURenderer();
- renderer.setSize( window.innerWidth, window.innerHeight );
- renderer.setAnimationLoop( animate );
- renderer.toneMapping = THREE.ACESFilmicToneMapping;
- renderer.toneMappingExposure = 2;
- renderer.shadowMap.enabled = true;
- renderer.shadowMap.transmitted = true;
- renderer.inspector = new Inspector();
- document.body.appendChild( renderer.domElement );
- scene = new THREE.Scene();
- scene.background = new THREE.Color( 0x000000 );
- camera = new THREE.PerspectiveCamera( 60, window.innerWidth / window.innerHeight, 0.1, 100 );
- camera.position.set( 14, 5.5, 4.4 );
- controls = new OrbitControls( camera, renderer.domElement );
- controls.target.set( 0, - VOLUME_WORLD_SIZE_Y / 2 + 3.6 + VOLUME_WORLD_SIZE_Y / 2, 0 );
- controls.maxDistance = 40;
- controls.minDistance = 2;
- controls.update();
- // Simulation resources
- velTexA = createStorage3D( 'velocity A' );
- velTexB = createStorage3D( 'velocity B' );
- dyeTexA = createStorage3D( 'dye A' );
- dyeTexB = createStorage3D( 'dye B' );
- divTex = createStorage3D( 'divergence' );
- pressTexA = createStorage3D( 'pressure A' );
- pressTexB = createStorage3D( 'pressure B' );
- curlNoiseTex = createStorage3D( 'curlNoise' );
- curlNoiseTex.wrapS = THREE.RepeatWrapping;
- curlNoiseTex.wrapT = THREE.RepeatWrapping;
- curlNoiseTex.wrapR = THREE.RepeatWrapping;
- dyeTexNode = texture3D( dyeTexA );
- dyeTexWriteNode = storageTexture( dyeTexB ).toWriteOnly();
- curlNoiseTexNode = texture3D( curlNoiseTex );
- // Teapot geometry & storage buffer for compute stage
- const teapotGeometry = new TeapotGeometry( 0.8, 28 );
- teapotGeometry.computeBoundingBox();
- const teapotMinY = teapotGeometry.boundingBox.min.y;
- vertexCount = teapotGeometry.attributes.position.count;
- teapotVerticesBuffer = storage( teapotGeometry.attributes.position, 'vec3', vertexCount ).toReadOnly();
- createComputePasses();
- // Precompute curl noise on the GPU
- renderer.computeAsync( computeCurlNoisePass );
- // Volumetric material - ray marches the simulated 3D texture
- const volumetricMaterial = new THREE.VolumeNodeMaterial();
- volumetricMaterial.steps = 16;
- volumetricMaterial.transparent = true;
- volumetricMaterial.blending = THREE.AdditiveBlending;
- volumetricMaterial.depthWrite = false;
- // Dithering to reduce banding
- volumetricMaterial.offsetNode = fract( interleavedGradientNoise( screenCoordinate ).add( float( frameId ).mul( 0.618033988749895 ) ) );
- // blackbody-style fire ramp: start color -> mid color -> end color
- const fireRamp = Fn( ( [ t ] ) => {
- const color = vec3( 0 ).toVar();
- color.assign( mix( vec3( 0.0, 0.0, 0.0 ), uFireEndColor, smoothstep( 0.05, 0.35, t ) ) );
- color.assign( mix( color, uFireMidColor, smoothstep( 0.35, 0.65, t ) ) );
- color.assign( mix( color, uFireStartColor, smoothstep( 0.65, 1.0, t ) ) );
- return color;
- } );
- const henyeyGreenstein = Fn( ( [ cosTheta, g ] ) => {
- const g2 = g.mul( g );
- const denom = float( 1.0 ).add( g2 ).sub( float( 2.0 ).mul( g ).mul( cosTheta ) );
- const oneMinusG2 = float( 1.0 ).sub( g2 );
- // Normalization constant 1 / (4 * PI) is approx 0.079577
- return oneMinusG2.div( denom.pow( 1.5 ) ).mul( 0.079577 );
- } );
- const getVolumeSample = ( { positionRay } ) => {
- // volume box is shifted up -> map ray position to uvw [0..1]
- const uvw = positionRay.sub( vec3( 0, VOLUME_WORLD_SIZE_Y / 2, 0 ) ).div( uVolumeWorldSize ).add( 0.5 ).toVar();
- // 1) Domain Warping: distort coordinates using velocity field over time to make smoke wispy (Option A)
- const noiseDistortion = texture3D( velTexA, uvw, 0 ).xyz.div( uVolumeWorldSize ).mul( 0.15 );
- const distortedUVW = uvw.add( noiseDistortion ).clamp( 0.0, 1.0 ).toVar();
- const sample = dyeTexNode.sample( distortedUVW ).level( 0 );
- const density = sample.r;
- const age = sample.b;
- const temperature = sample.g;
- // 2) High-frequency detail noise modulation (using simplex noise instead of mx_noise)
- const detailNoise = snoise( positionRay.mul( 5.5 ).add( vec3( 0, age.mul( 0.8 ).negate(), 0 ) ) );
- density.mulAssign( detailNoise.mul( 0.35 ).add( 0.85 ) );
- // soften the box edges
- const edge = min( distortedUVW, vec3( 1 ).sub( distortedUVW ) );
- density.mulAssign( smoothstep( 0.0, 0.06, min( edge.x, min( edge.y, edge.z ) ) ) );
- return { density, temperature, age, distortedUVW };
- };
- volumetricMaterial.scatteringNode = Fn( ( { positionRay } ) => {
- const { density } = getVolumeSample( { positionRay } );
- // 3) Key-light Self-Shadowing: raymarch towards uKeyLightPos
- const lightDir = uKeyLightPos.sub( positionRay ).normalize();
- const shadowDensitySum = float( 0.0 ).toVar();
- const shadowStepSize = 0.35;
- for ( let i = 0; i < 2; i ++ ) { // default 5
- const stepDist = ( i + 0.5 ) * shadowStepSize;
- const shadowPos = positionRay.add( lightDir.mul( stepDist ) );
- const shadowUVW = shadowPos.sub( vec3( 0, VOLUME_WORLD_SIZE_Y / 2, 0 ) ).div( uVolumeWorldSize ).add( 0.5 );
- // Fade out shadow density near the volume borders to avoid edge artifacts
- const shadowEdge = min( shadowUVW, vec3( 1 ).sub( shadowUVW ) );
- const shadowFade = smoothstep( 0.0, 0.06, min( shadowEdge.x, min( shadowEdge.y, shadowEdge.z ) ) );
- const shadowSample = texture3D( dyeTexA, shadowUVW, 0 ).r.mul( shadowFade );
- shadowDensitySum.addAssign( shadowSample );
- }
- // Calculate optical thickness (tau)
- const tau = shadowDensitySum.mul( shadowStepSize ).mul( uShadowAbsorption );
- const beer = tau.negate().exp();
- // Multiple Scattering Approximation (Octave 2): lower absorption (e.g. 0.25x) and scaled down contribution (0.5x)
- const multiScatter = tau.mul( 0.25 ).negate().exp().mul( 0.5 );
- // Blend between single and multiple scattering
- const baseTransmittance = mix( beer, beer.add( multiScatter ), uMultiScattering );
- // Apply Beer's Law Powder Effect to simulate edge self-shadowing details
- const powder = float( 1.0 ).sub( tau.mul( 2.0 ).negate().exp() );
- const finalTransmittance = mix( baseTransmittance, baseTransmittance.mul( powder ), uPowderStrength );
- // Apply ambient light in shadowed regions
- const lightTransmittance = finalTransmittance.add( uShadowAmbient ).clamp( 0.0, 1.0 );
- // Henyey-Greenstein Phase Function for directional scattering
- const viewDir = cameraPosition.sub( positionRay ).normalize();
- const cosTheta = viewDir.dot( lightDir ).clamp( - 1.0, 1.0 );
- const phase = henyeyGreenstein( cosTheta, uAsymmetry );
- // Apply shadowing and phase function only to the smoke scattering
- // Multiply phase function by 4 * PI (approx 12.56637) to maintain standard lighting scale
- const smokeScattering = vec3( density ).mul( lightTransmittance ).mul( phase.mul( 12.56637 ) );
- return smokeScattering;
- } );
- volumetricMaterial.scatteringEmissiveNode = Fn( ( { positionRay } ) => {
- const { density, temperature } = getVolumeSample( { positionRay } );
- // fire "emission" (boosted scattering tinted by temperature)
- // Control the spread of the fire core (inverted: higher spread = lower power)
- const firePower = float( 6.0 ).sub( uFireGlowSpread );
- const fire = fireRamp( temperature.clamp( 0, 1 ) ).mul( temperature.pow( firePower ) ).mul( uFireIntensity );
- // Apply hue rotation to the fire color
- const fireColor = hue( fire, uFireHue );
- // Simulate the spotlight distance attenuation (with a constant intensity of 400) to restore the original color/brightness
- const distance = positionRay.sub( uKeyLightPos ).length();
- const attenuation = float( 400.0 ).div( distance.pow( 2.0 ) );
- return fireColor.mul( density.add( 0.15 ) ).mul( attenuation );
- } );
- const volumeCastShadow = Fn( () => {
- const startPos = positionWorld;
- const lightDir = positionWorld.sub( cameraPosition ).normalize();
- const steps = uniform( 'int' ).onRenderUpdate( ( { material, object } ) => material.steps || ( object && object.material && object.material.steps ) || volumetricMaterial.steps );
- const maxDistance = float( VOLUME_WORLD_SIZE_DIAGONAL ); // Diagonal of volume box
- const stepSize = maxDistance.div( steps ).toVar();
- const rayDir = lightDir.toVar();
- const distTravelled = float( 0.0 ).toVar();
- const transmittance = float( 1.0 ).toVar();
- Loop( steps, () => {
- const positionRay = startPos.add( rayDir.mul( distTravelled ) );
- const { density } = getVolumeSample( { positionRay } );
- const absorption = density.mul( uShadowAbsorption ).mul( 0.01 );
- const falloff = absorption.negate().mul( stepSize ).exp();
- transmittance.mulAssign( falloff );
- distTravelled.addAssign( stepSize );
- } );
- // If the ray is completely transparent, discard the fragment
- transmittance.greaterThanEqual( 0.99 ).discard();
- const shadowOpacity = transmittance.oneMinus();
- return vec4( vec3( 0 ), shadowOpacity.mul( 5 ) );
- } );
- volumetricMesh = new THREE.Mesh( new THREE.BoxGeometry( VOLUME_WORLD_SIZE_X, VOLUME_WORLD_SIZE_Y, VOLUME_WORLD_SIZE_Z ), volumetricMaterial );
- volumetricMesh.position.y = VOLUME_WORLD_SIZE_Y / 2 + 0.4;
- volumetricMesh.receiveShadow = true;
- scene.add( volumetricMesh );
- const shadowMaterial = new THREE.VolumeNodeMaterial();
- shadowMaterial.steps = volumetricMaterial.steps;
- shadowMaterial.offsetNode = volumetricMaterial.offsetNode;
- shadowMaterial.castShadowNode = volumeCastShadow();
- shadowMaterial.shadowSide = THREE.FrontSide;
- shadowMaterial.colorWrite = false;
- shadowMaterial.depthWrite = false;
- shadowMaterial.blending = THREE.CustomBlending;
- shadowMaterial.blendEquation = THREE.AddEquation;
- shadowMaterial.blendSrc = THREE.ZeroFactor;
- shadowMaterial.blendDst = THREE.OneMinusSrcAlphaFactor;
- shadowMaterial.blendEquationAlpha = THREE.AddEquation;
- shadowMaterial.blendSrcAlpha = THREE.OneFactor;
- shadowMaterial.blendDstAlpha = THREE.OneMinusSrcAlphaFactor;
- const volumetricShadowMesh = new THREE.Mesh( new THREE.BoxGeometry( VOLUME_WORLD_SIZE_X, VOLUME_WORLD_SIZE_Y, VOLUME_WORLD_SIZE_Z ), shadowMaterial );
- volumetricShadowMesh.position.y = VOLUME_WORLD_SIZE_Y / 2 + 0.4;
- volumetricShadowMesh.castShadow = true;
- scene.add( volumetricShadowMesh );
- // Floor
- const floorPlane = new THREE.Mesh( new THREE.PlaneGeometry( 80, 80 ), new THREE.MeshStandardMaterial( { color: 0x111115, roughness: 0.8 } ) );
- floorPlane.rotation.x = - Math.PI / 2;
- floorPlane.position.y = - VOLUME_WORLD_SIZE_Y / 2 + 0.4 + VOLUME_WORLD_SIZE_Y / 2 + 0.4;
- floorPlane.receiveShadow = true;
- scene.add( floorPlane );
- // Teapot - opaque object inside the smoke, to visualize volumetric transparency / occlusion
- teapot = new THREE.Mesh(
- teapotGeometry,
- new THREE.MeshStandardMaterial( { color: 0x000000, roughness: 1.0, metalness: 1.0 } )
- );
- //teapot.castShadow = true;
- teapot.receiveShadow = true;
- teapot.position.set( 0, floorPlane.position.y - teapotMinY, 0 );
- teapot.visible = true;
- scene.add( teapot );
- prevTeapotPos.copy( teapot.position );
- teapot.updateMatrixWorld();
- uTeapotMatrix.value.copy( teapot.matrixWorld );
- uTeapotPosition.value.copy( teapot.position );
- const isVolume = Fn( ( { material } ) => {
- const isVolumeMaterial = material && material.isVolumeNodeMaterial;
- return float( isVolumeMaterial ? 1.0 : 0.0 );
- } )();
- const pointLightColor = Fn( () => {
- // Shading point position in world space
- const P = positionWorld;
- // Light source bottom position (teapot position)
- const A = uTeapotPosition;
- // 1. Flame column height
- const H = vec3( 0.0, uFlameHeight, 0.0 ); // Direction of vertical propagation
- // Calculate closest point on vertical segment (displaced by sway)
- const V = P.sub( A );
- const t = V.dot( H ).div( H.dot( H ) ).clamp( 0.0, 1.0 );
- const C = A.add( uSway ).add( H.mul( t ) );
- const distToSegment = P.sub( C ).length();
- // Calculate soft cylindrical attenuation (with flame thickness radius r = 1.2)
- const r = float( 1.2 );
- const softAttenuation = float( 1.0 ).div( distToSegment.pow( 2.0 ).add( r.pow( 2.0 ) ) );
- // Recreate standard PointLight distance attenuation for correction/cancellation
- const distToLight = P.sub( A ).length();
- const decayExponent = float( 2.0 ); // Must match PointLight's decay value in the constructor
- const defaultAttenuation = distToLight.pow( decayExponent ).max( 0.01 ).reciprocal();
- // Correction factor to cancel the default point light attenuation and apply volumetric/capsule decay
- // We only cancel the decay when rendering the volume so standard surfaces keep their physical 1/d^2 falloff.
- const attenuationCorrection = isVolume.equal( 1.0 ).select(
- softAttenuation.div( defaultAttenuation ),
- float( 1.0 )
- );
- // Choose intensity based on whether we are shading the volume (smoke) or solid surfaces (reflection)
- const currentIntensity = isVolume.equal( 1.0 ).select( uPointLightVolumeIntensity, uPointLightSurfaceIntensity );
- // 4. Color temperature oscillation: shift color tone slightly over time (uniform for volume)
- const colorT = uEmitTemperature.div( 8.34 ).mul( 0.5 ).add( 0.20 ).add( uColorNoise ).clamp( 0.0, 1.0 );
- const fireColor = fireRamp( colorT );
- const coloredFire = hue( saturation( fireColor, uSaturation ), uFireHue );
- // 5. Projected fire light color on surfaces
- // Calculate relative XZ position from teapot center
- const relP = P.xz.sub( A.xz );
- const angle = atan( relP.y, relP.x );
- const distXZ = relP.length();
- // Radial ray/spoke noise that rotates/flickers over time
- const freqScale = uPointLightProjectionFrequency;
- 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 );
- // Fade out the angle spoke noise near the center to prevent the atan(0,0) seam singularity
- const centerFadeFactor = smoothstep( 0.0, uPointLightProjectionCenterFade, distXZ );
- const cleanAngleNoise = mix( float( 1.0 ), angleNoise, centerFadeFactor );
- // Spatial noise moving outwards/upwards (convective fire behavior)
- const noiseCoord1 = vec3( P.x.mul( float( 0.6 ).mul( freqScale ) ), uTime.mul( 1.2 ), P.z.mul( float( 0.6 ).mul( freqScale ) ) );
- const projN1 = mx_noise_float( noiseCoord1 ).mul( 0.5 ).add( 0.5 );
- const noiseCoord2 = vec3( P.x.mul( float( 1.5 ).mul( freqScale ) ), uTime.mul( 2.5 ), P.z.mul( float( 1.5 ).mul( freqScale ) ) );
- const projN2 = mx_noise_float( noiseCoord2 ).mul( 0.5 ).add( 0.5 );
- // Combine the noises
- const projNoise = projN1.mul( 0.65 ).add( projN2.mul( 0.35 ) );
- // Modulate by radial spoke pattern
- const projectionIntensity = projNoise.mul( cleanAngleNoise.mul( 0.5 ).add( 0.5 ) );
- // Fade out the noise over distance (blend to uniform 1.0)
- const noiseFadeFactor = distToSegment.div( uPointLightProjectionNoiseFade ).clamp( 0.0, 1.0 );
- const finalIntensity = mix( projectionIntensity, float( 1.0 ), noiseFadeFactor );
- // Create a radial temperature gradient from the fire center to project colors realistically
- const radialTemp = float( 1.0 ).sub( distToSegment.div( uPointLightProjectionRadius ) ).clamp( 0.0, 1.0 );
- // Map the radial temperature and noise to the fire colors (Start, Mid, End)
- const colorTProj = radialTemp.mul( finalIntensity ).clamp( 0.0, 1.0 );
- const fireColorProj = fireRamp( colorTProj );
- const coloredFireProj = hue( saturation( fireColorProj, uSaturation ), uFireHue );
- // Select either uniform fire color (volume) or projected fire color (surface)
- const finalFireColor = isVolume.equal( 1.0 ).select( coloredFire, coloredFireProj );
- // Scale intensity by uEmitTemperature (relative to its default 8.34) using Stefan-Boltzmann law (T^4)
- // to make it physically correct (radiant energy is proportional to T^4)
- const tempScale = uEmitTemperature.div( 8.34 ).max( 0.0 );
- const tempFactor = tempScale.pow( 4.0 );
- // Scale by uEmitDensity (relative to default 11.02) to represent fire/smoke size
- const densityScale = uEmitDensity.div( 11.02 ).max( 0.0 );
- // Smooth fade-in of point light intensity during the first 3 seconds of the simulation
- const fadeIn = smoothstep( 0.0, 3.0, uTime );
- const baseColor = finalFireColor.mul( tempFactor ).mul( densityScale ).mul( uFireIntensity ).mul( currentIntensity ).mul( uFlicker ).mul( fadeIn );
- // Blend between near and far light intensity scales
- const distRatio = distToSegment.div( uLightFarDistance ).clamp( 0.0, 1.0 );
- const distanceScale = mix( uLightNearIntensity, uLightFarIntensity, smoothstep( 0.0, 1.0, distRatio ) );
- // Apply distance-based scaling only when shading the volumetric smoke
- const finalScale = isVolume.equal( 1.0 ).select( distanceScale, float( 1.0 ) );
- return baseColor.mul( attenuationCorrection ).mul( finalScale );
- } )();
- pointLight = new THREE.PointLight( 0xffffff, 1, 100, 2 );
- pointLight.colorNode = pointLightColor;
- pointLight.position.set( 0, 0, 0 );
- pointLight.castShadow = false;
- teapot.add( pointLight );
- // DragControls to drag the teapot
- const dragControls = new DragControls( [ teapot ], camera, renderer.domElement );
- dragControls.rotateSpeed = 0;
- dragControls.addEventListener( 'dragstart', function () {
- controls.enabled = false;
- } );
- dragControls.addEventListener( 'drag', function () {
- // Constraint to volume box boundaries
- const limitX = VOLUME_WORLD_SIZE_X / 2 - 1.5;
- const limitZ = VOLUME_WORLD_SIZE_Z / 2 - 1.5;
- teapot.position.x = Math.max( - limitX, Math.min( limitX, teapot.position.x ) );
- teapot.position.y = Math.max( floorPlane.position.y - teapotMinY, Math.min( VOLUME_WORLD_SIZE_Y - 1.5, teapot.position.y ) );
- teapot.position.z = Math.max( - limitZ, Math.min( limitZ, teapot.position.z ) );
- } );
- dragControls.addEventListener( 'dragend', function () {
- controls.enabled = true;
- } );
- // Key light - white spot with shadow, so the smoke receives/shows shadows clearly
- keyLight = new THREE.SpotLight( 0xffffff, 1000 );
- 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 ) );
- keyLight.angle = Math.PI / 5;
- keyLight.penumbra = 1;
- keyLight.decay = 2;
- keyLight.distance = 0;
- keyLight.castShadow = true;
- keyLight.shadow.intensity = .98;
- keyLight.shadow.mapSize.width = 1024;
- keyLight.shadow.mapSize.height = 1024;
- keyLight.shadow.camera.near = 1;
- const maxVolumeSize = Math.max( VOLUME_WORLD_SIZE_X, VOLUME_WORLD_SIZE_Y, VOLUME_WORLD_SIZE_Z );
- keyLight.shadow.camera.far = 20 * ( maxVolumeSize / 8 );
- keyLight.shadow.bias = - 0.001;
- keyLight.shadow.focus = 1;
- keyLight.target.position.set( 1, 0, 0 );
- scene.add( keyLight );
- scene.add( keyLight.target );
- uKeyLightPos = uniform( keyLight.position );
- // Render Pipeline (same structure as the volumetric example)
- renderPipeline = new THREE.RenderPipeline( renderer );
- // Layers
- const LAYER_VOLUMETRIC_LIGHTING = 10;
- const volumetricLayer = new THREE.Layers();
- volumetricLayer.disableAll();
- volumetricLayer.enable( LAYER_VOLUMETRIC_LIGHTING );
- volumetricMesh.layers.disableAll();
- volumetricMesh.layers.enable( LAYER_VOLUMETRIC_LIGHTING );
- keyLight.layers.enable( LAYER_VOLUMETRIC_LIGHTING );
- pointLight.layers.enable( LAYER_VOLUMETRIC_LIGHTING );
- // Scene Pass
- const scenePass = pass( scene, camera ).toInspector( 'Scene' );
- scenePass.name = 'Scene Pass';
- // Volumetric Lighting Pass
- const volumetricPass = pass( scene, camera ).toInspector( 'Volumetric Lighting' );
- volumetricPass.name = 'Volumetric Lighting';
- volumetricPass.setLayers( volumetricLayer );
- volumetricPass.setResolutionScale( 0.5 );
- // Compose and Denoise
- denoiseStrength = uniform( 0.5 );
- const uSaturation = uniform( 1.1 );
- teapot.material.emissiveNode = Fn( () => {
- // Lava flow animation using local position for stability when dragging
- const p = positionLocal.mul( 0.5 );
- const flow = vec3( 0.0, uTime.negate(), 0.0 );
- // 3 Octaves of MaterialX Noise for organic fractal pattern
- const n1 = mx_noise_float( p.add( flow ) ).mul( 0.5 ).add( 0.5 );
- const p2 = p.mul( 2.0 ).sub( flow.mul( 1.5 ) );
- const n2 = mx_noise_float( p2.add( vec3( n1.mul( 0.4 ) ) ) ).mul( 0.5 ).add( 0.5 );
- const p3 = p.mul( 4.0 ).add( flow.mul( 2.5 ) );
- const n3 = mx_noise_float( p3 ).mul( 0.5 ).add( 0.5 );
- const noiseVal = n1.mul( 0.50 ).add( n2.mul( 0.35 ) ).add( n3.mul( 0.15 ) );
- // Apply power function to create sharp glowing lava veins and wide dark crust regions
- const lavaT = noiseVal.pow( 2.5 ).clamp( 0.0, 1.0 );
- // Use fireRamp to map the lava temperature to the blackbody-like fire colors
- const fireColor = fireRamp( lavaT.add( .1 ) );
- const coloredFire = hue( saturation( fireColor, uSaturation ), uFireHue );
- const tempScale = uEmitTemperature.div( 8.34 ).max( 0.0 );
- const tempFactor = tempScale.pow( 4.0 );
- const densityScale = uEmitDensity.div( 11.02 ).max( 0.0 );
- const fadeIn = smoothstep( 0.0, 3.0, uTime );
- // Combine fire parameters with teapot emissive intensity and temporal flicker
- // Boosted by 10.0 to make the glowing cracks stand out clearly on the dark surface
- return coloredFire.mul( tempFactor ).mul( densityScale ).mul( uFireIntensity ).mul( uFlicker ).mul( fadeIn ).mul( uTeapotEmissiveIntensity );
- } )();
- params = {
- resolution: volumetricPass.getResolutionScale(),
- denoise: true,
- simulate: true,
- fireStartColor: '#ffe68c',
- fireMidColor: '#ff7305',
- fireEndColor: '#ff0000',
- fireHue: 0,
- simSpeed: 1.2,
- smokeLifespan: 3.5,
- fireLifespan: 1.3,
- turbulence: 3.2,
- toneMapping: 'ACESFilmic',
- exposure: 2.0,
- bloom: true,
- bloomStrength: 0.1,
- bloomRadius: 1.0,
- bloomThreshold: 0.5
- };
- const blurredVolumetricPass = gaussianBlur( volumetricPass, denoiseStrength, 1 ).toInspector( 'Blurred Volumetric' );
- // GUI
- const gui = renderer.inspector.createParameters( 'Fire Simulation' );
- gui.add( params, 'simulate' ).name( 'Simulate Fluid' );
- gui.add( params, 'simSpeed', 0.0, 2.0, 0.01 ).name( 'Simulation Speed' );
- gui.add( params, 'resolution', .1, 1 ).name( 'Render Resolution' ).onChange( ( resolution ) => {
- volumetricPass.setResolutionScale( resolution );
- } );
- // Quality & Denoise Folder
- const qualityFolder = gui.addFolder( 'Quality & Denoise' );
- qualityFolder.add( volumetricMaterial, 'steps', 4, 42, 1 ).name( 'Raymarch Steps' );
- qualityFolder.add( params, 'denoise' ).name( 'Denoise Enabled' ).onChange( updatePostProcessing );
- qualityFolder.add( denoiseStrength, 'value', 0, 1 ).name( 'Denoise Strength' );
- // Bloom Folder
- const bloomFolder = gui.addFolder( 'Bloom' );
- bloomFolder.add( params, 'bloom' ).name( 'Bloom Enabled' ).onChange( updatePostProcessing );
- bloomFolder.add( params, 'bloomStrength', 0.0, 3.0, 0.01 ).name( 'Bloom Strength' ).onChange( ( value ) => {
- if ( bloomPass ) bloomPass.strength.value = value;
- } );
- bloomFolder.add( params, 'bloomRadius', 0.0, 1.0, 0.01 ).name( 'Bloom Radius' ).onChange( ( value ) => {
- if ( bloomPass ) bloomPass.radius.value = value;
- } );
- bloomFolder.add( params, 'bloomThreshold', 0.0, 1.0, 0.01 ).name( 'Bloom Threshold' ).onChange( ( value ) => {
- if ( bloomPass ) bloomPass.threshold.value = value;
- } );
- let bloomPass = null;
- function updatePostProcessing() {
- let volumetric = volumetricPass;
- if ( params.denoise ) {
- volumetric = blurredVolumetricPass;
- }
- const volumetricRGB = volumetric.rgb;
- const adjustedVolumetricRGB = saturation( volumetricRGB, uSaturation );
- const adjustedVolumetric = vec4( adjustedVolumetricRGB, volumetric.a ).mul( .5 );
- const scenePassColor = scenePass.max( adjustedVolumetric ).add( adjustedVolumetric );
- let output = scenePassColor;
- if ( params.bloom ) {
- if ( bloomPass !== null ) {
- bloomPass.dispose();
- }
- bloomPass = bloom( scenePassColor );
- bloomPass.threshold.value = params.bloomThreshold;
- bloomPass.strength.value = params.bloomStrength;
- bloomPass.radius.value = params.bloomRadius;
- output = scenePassColor.add( bloomPass );
- } else if ( bloomPass !== null ) {
- bloomPass.dispose();
- bloomPass = null;
- }
- renderPipeline.outputNode = output;
- renderPipeline.needsUpdate = true;
- }
- updatePostProcessing();
- // Volume Visuals Folder
- const volumeVisuals = gui.addFolder( 'Volume Visuals' );
- //volumeVisuals.add( uFireIntensity, 'value', 0, 20 ).name( 'Fire Intensity' );
- volumeVisuals.add( uFireGlowSpread, 'value', 1.0, 5.0, 0.1 ).name( 'Glow Spread' );
- volumeVisuals.add( params, 'fireHue', 0, 360, 1 ).name( 'Fire Hue Shift' );
- volumeVisuals.add( uSaturation, 'value', 0.0, 2.0, 0.05 ).name( 'Saturation' );
- volumeVisuals.addColor( params, 'fireStartColor' ).name( 'Fire Start Color' );
- volumeVisuals.addColor( params, 'fireMidColor' ).name( 'Fire Mid Color' );
- volumeVisuals.addColor( params, 'fireEndColor' ).name( 'Fire End Color' );
- // Emitter Controls Folder
- const emitterControls = gui.addFolder( 'Emitter Controls' );
- emitterControls.add( uEmitTemperature, 'value', 0, 8 ).name( 'Temperature Rate' );
- emitterControls.add( uEmitDensity, 'value', 0, 20 ).name( 'Density Rate' );
- emitterControls.add( uMotionBoost, 'value', 0.0, 0.4, 0.01 ).name( 'Movement Boost' );
- emitterControls.add( uWindStrength, 'value', 0.0, 50.0, 0.01 ).name( 'Movement Wind Strength' );
- emitterControls.add( uTeapotEmissiveIntensity, 'value', 0.0, 1.0, 0.001 ).name( 'Teapot Emissive' );
- // Scattering & Shadows Folder
- const scatteringShadows = gui.addFolder( 'Scattering & Shadows' );
- scatteringShadows.add( uAsymmetry, 'value', - 0.99, 0.99, 0.01 ).name( 'Phase Asymmetry (g)' );
- scatteringShadows.add( uPowderStrength, 'value', 0.0, 1.0, 0.01 ).name( 'Powder Effect' );
- scatteringShadows.add( uMultiScattering, 'value', 0.0, 1.0, 0.01 ).name( 'Multi Scattering' );
- scatteringShadows.add( uShadowAbsorption, 'value', 0, 10 ).name( 'Shadow Absorption' );
- scatteringShadows.add( uShadowAmbient, 'value', 0, 1.0 ).name( 'Shadow Ambient' );
- // Fluid Physics Folder
- const fluidPhysics = gui.addFolder( 'Fluid Physics' );
- fluidPhysics.add( uBuoyancy, 'value', 0, 10 ).name( 'Buoyancy (Rise)' );
- fluidPhysics.add( uVelDamping, 'value', 0, 2 ).name( 'Velocity Damping' );
- fluidPhysics.add( params, 'fireLifespan', 0.5, 10.0, 0.1 ).name( 'Fire Lifespan' );
- fluidPhysics.add( params, 'smokeLifespan', 1.0, 100.0, 0.5 ).name( 'Smoke Lifespan' );
- fluidPhysics.add( params, 'turbulence', 0, 5 ).name( 'Turbulence Strength' );
- fluidPhysics.add( uTurbulenceDecay, 'value', 0.0, 1.0, 0.01 ).name( 'Turbulence Decay' );
- fluidPhysics.add( uTurbFrequency, 'value', 1, 10 ).name( 'Turbulence Frequency' );
- // Scene Lights Folder
- const sceneLights = gui.addFolder( 'Scene Lights' );
- sceneLights.add( keyLight, 'intensity', 0, 1500, 1 ).name( 'Key Light Intensity' );
- sceneLights.add( uPointLightVolumeIntensity, 'value', 0.0, 2.0, 0.001 ).name( 'Light Smoke' );
- sceneLights.add( uLightNearIntensity, 'value', 0.0, 20.0, 0.05 ).name( 'Light Near Scale' );
- sceneLights.add( uLightFarIntensity, 'value', 0.0, 20.0, 0.05 ).name( 'Light Far Scale' );
- sceneLights.add( uPointLightSurfaceIntensity, 'value', 0.0, 20.0, 0.001 ).name( 'Light Reflection' );
- sceneLights.add( uPointLightProjectionRadius, 'value', 1.0, 30.0, 0.1 ).name( 'Proj Light Radius' );
- sceneLights.add( uPointLightProjectionFrequency, 'value', 0.1, 1.0, 0.01 ).name( 'Proj Light Freq' );
- sceneLights.add( uPointLightProjectionNoiseFade, 'value', 1.0, 30.0, 0.1 ).name( 'Proj Noise Fade Dist' );
- sceneLights.add( uPointLightProjectionCenterFade, 'value', 0.1, 5.0, 0.05 ).name( 'Proj Center Fade' );
- // Tone Mapping & Exposure Folder
- const toneMappingOptions = {
- None: THREE.NoToneMapping,
- Linear: THREE.LinearToneMapping,
- Reinhard: THREE.ReinhardToneMapping,
- Cineon: THREE.CineonToneMapping,
- ACESFilmic: THREE.ACESFilmicToneMapping,
- AgX: THREE.AgXToneMapping,
- Neutral: THREE.NeutralToneMapping
- };
- const toneMappingFolder = gui.addFolder( 'Tone Mapping & Exposure' );
- toneMappingFolder.add( params, 'toneMapping', Object.keys( toneMappingOptions ) ).name( 'Tone Mapping' ).onChange( ( value ) => {
- renderer.toneMapping = toneMappingOptions[ value ];
- } );
- toneMappingFolder.add( params, 'exposure', 0.1, 2.0, 0.05 ).name( 'Exposure' ).onChange( ( value ) => {
- renderer.toneMappingExposure = value;
- } );
- window.addEventListener( 'resize', onWindowResize );
- }
- function onWindowResize() {
- camera.aspect = window.innerWidth / window.innerHeight;
- camera.updateProjectionMatrix();
- renderer.setSize( window.innerWidth, window.innerHeight );
- }
- // ---------------------------------------------------------------
- // Animation loop
- // ---------------------------------------------------------------
- let simulationTime = 0;
- let lastTime = performance.now();
- let simAccumulator = 0;
- function updateTemporalUniforms( time ) {
- uTime.value = time % 1000;
- const heightNoise = cpuNoise.noise( 0, time * 2.5, 0 );
- uFlameHeight.value = 3.5 + heightNoise * 0.8;
- const swayX = cpuNoise.noise( time * 3.5, 0, 0 ) * 0.4;
- const swayZ = cpuNoise.noise( 0, 0, time * 3.5 ) * 0.4;
- uSway.value.set( swayX, 0, swayZ );
- const slowNoise = cpuNoise.noise( 0, time * 0.8, 0 );
- const fastNoise = cpuNoise.noise( 0, time * 15.0, 0 );
- uFlicker.value = slowNoise * 0.12 + fastNoise * 0.06 + 0.82;
- const colorNoise = cpuNoise.noise( time * 5.0, time * 5.0, 0 ) * 0.08;
- uColorNoise.value = colorNoise;
- teapot.rotation.y = time * 0.25;
- teapot.updateMatrixWorld();
- uTeapotMatrix.value.copy( teapot.matrixWorld );
- }
- function animate() {
- const currentTime = performance.now();
- const delta = Math.min( ( currentTime - lastTime ) * 0.001, 1 / 30 );
- lastTime = currentTime;
- // Calculate teapot speed and velocity vector for wind effect
- const currentPos = teapot.position;
- const dist = currentPos.distanceTo( prevTeapotPos );
- const speed = delta > 0 ? dist / delta : 0;
- const teapotVel = new THREE.Vector3();
- if ( delta > 0 ) {
- teapotVel.subVectors( currentPos, prevTeapotPos ).multiplyScalar( 1 / delta );
- }
- prevTeapotPos.copy( currentPos );
- uTeapotSpeed.value = speed;
- uTeapotVelocity.value.copy( teapotVel );
- uTeapotPosition.value.copy( currentPos );
- if ( params.simulate && params.simSpeed > 0 ) {
- const dt = delta * params.simSpeed;
- simAccumulator += dt;
- const stepTime = 1 / 120;
- const simStep = stepTime * params.simSpeed;
- const maxAccumulator = simStep * 8;
- if ( simAccumulator > maxAccumulator ) {
- simAccumulator = maxAccumulator;
- }
- uDt.value = simStep;
- uTurbulence.value = params.simSpeed > 0 ? params.turbulence / Math.sqrt( params.simSpeed ) : 0;
- if ( params.smokeLifespan >= 100.0 ) {
- uDissipation.value = 0.0;
- } else {
- uDissipation.value = 1.0 / params.smokeLifespan;
- }
- uCooling.value = 1.0 / params.fireLifespan;
- while ( simAccumulator >= simStep ) {
- simulationTime += simStep;
- updateTemporalUniforms( simulationTime );
- // --- fluid simulation steps (compute shaders) ---
- renderer.compute( advectVelocityPass ); // reads dyeTexNode, writes velTexB
- renderer.compute( divergencePass ); // velB -> div
- for ( let i = 0; i < PRESSURE_ITERATIONS; i ++ ) {
- renderer.compute( ( i % 2 === 0 ) ? jacobiPassAB : jacobiPassBA );
- }
- renderer.compute( projectPass ); // velB - grad(p) -> velA
- renderer.compute( advectDyePass ); // reads dyeTexNode, writes dyeTexWriteNode
- renderer.compute( emitTeapotPass ); // inject from teapot vertices -> dyeTexWriteNode
- // Ping-pong dye textures
- const temp = dyeTexNode.value;
- dyeTexNode.value = dyeTexWriteNode.value;
- dyeTexWriteNode.value = temp;
- simAccumulator -= simStep;
- }
- } else {
- updateTemporalUniforms( simulationTime );
- }
- // Update point light range dynamically based on temperature, density (fire size) and fire intensity
- const tempRatio = uEmitTemperature.value / 8.34;
- const densityRatio = uEmitDensity.value / 11.02;
- const intensityRatio = uFireIntensity.value / 5.63;
- const sizeFactor = Math.sqrt( tempRatio * densityRatio * intensityRatio );
- // Smooth fade-in factor over the first 3 seconds of the simulation
- const t = Math.min( Math.max( simulationTime / 3.0, 0.0 ), 1.0 );
- const fadeIn = t * t * ( 3.0 - 2.0 * t );
- pointLight.distance = Math.max( 0.01, 40.0 * Math.max( 0.2, sizeFactor ) * fadeIn );
- uFireStartColor.value.set( params.fireStartColor );
- uFireMidColor.value.set( params.fireMidColor );
- uFireEndColor.value.set( params.fireEndColor );
- uFireHue.value = THREE.MathUtils.degToRad( params.fireHue );
- renderPipeline.render();
- }
- </script>
- </body>
- </html>
|