Fire Effect (Animated)
Simple but catchy fire effect using a scrolling texture density. The density is mapped to a color pattern for the fire.
Animated 2d fire effect - example of what the output looks like.
Functions Used: requestAdapter(), getPreferredCanvasFormat(), createCommandEncoder(), beginRenderPass(), setPipeline(), draw(), end(), submit(), getCurrentTexture(), createView(), createShaderModule()
The implementation demo uses a double buffering (buf0 and buf1) which are 'ping-pong' back and forth with the density. During the update one is used as the read and the other write.
After each update the data is written to a texture storage output buffer which is copied to the canvas output.
Complete Code
<?php
let div = document . createElement ( 'div' );
document . body . appendChild ( div );
div . style [ 'font-size' ] = '20pt' ;
function log ( s )
{
console . log ( s );
let args = [... arguments ]. join ( ' ' );
div . innerHTML += args + '<br><br>' ;
}
log ( 'WebGPU Compute Example' );
if (! navigator . gpu ) { log ( "WebGPU is not supported (or is it disabled? flags/settings)" ); return; }
const adapter = await navigator . gpu . requestAdapter ();
const device = await adapter . requestDevice ();
const imgWidth = 256 ;
const imgHeight = imgWidth ;
const bufferSize = imgWidth * imgHeight ;
// ----------------------------------------------------------
// Basic canvas which will be used to display the output from the compute shader
let canvasa = document . createElement ( 'canvas' );
document . body . appendChild ( canvasa ); canvasa . height = canvasa . width = imgWidth ;
const context = canvasa . getContext ( 'webgpu' );
const presentationFormat = navigator . gpu . getPreferredCanvasFormat ();
console . log ( 'presentationFormat:' , presentationFormat );
context . configure ({ device : device ,
usage : GPUTextureUsage . RENDER_ATTACHMENT | GPUTextureUsage . COPY_SRC | GPUTextureUsage . COPY_DST ,
format : "rgba8unorm" /*presentationFormat*/ });
let canvasTexture = context . getCurrentTexture ();
// ----------------------------------------------------------
const texture1 = device . createTexture ({
size : [ imgWidth , imgHeight , 1 ],
format : "rgba8unorm" ,
usage : GPUTextureUsage . COPY_DST | GPUTextureUsage . COPY_SRC | GPUTextureUsage . TEXTURE_BINDING | GPUTextureUsage . STORAGE_BINDING
});
// ----------------------------------------------------------
const timerUniformBuffer = device . createBuffer ({
size : 4 ,
usage : GPUBufferUsage . UNIFORM | GPUBufferUsage . COPY_DST
});
const timestep = new Float32Array ( [ 0.0 ] );
device . queue . writeBuffer ( timerUniformBuffer , 0 , timestep );
// ----------------------------------------------------------
function Colour ( r , g , b ) {
this . r = r ;
this . g = g ;
this . b = b ;
this . a = 255 ;
}
let colors = [];
for ( let i = 0 ; i < 256 ; i ++) {
colors [ i ] = new Colour ( 0 , 0 , 0 );
}
for ( let i = 0 ; i < 32 ; ++ i ) {
// black to blue
colors [ i ]. b = i << 1 ;
// blue to red
colors [ i + 32 ]. r = i << 3 ;
colors [ i + 32 ]. b = 64 - ( i << 1 );
// red to yellow
colors [ i + 64 ]. r = 255 ;
colors [ i + 64 ]. g = i << 3 ;
// yellow to white
colors [ i + 96 ]. r = 255 ;
colors [ i + 96 ]. g = 255 ;
colors [ i + 96 ]. b = i << 2 ;
colors [ i + 128 ]. r = 255 ;
colors [ i + 128 ]. g = 255 ;
colors [ i + 128 ]. b = 64 + ( i << 2 );
colors [ i + 160 ]. r = 255 ;
colors [ i + 160 ]. g = 255 ;
colors [ i + 160 ]. b = 128 + ( i << 2 );
colors [ i + 192 ]. r = 255 ;
colors [ i + 192 ]. g = 255 ;
colors [ i + 192 ]. b = 192 + i ;
colors [ i + 224 ]. r = 255 ;
colors [ i + 224 ]. g = 255 ;
colors [ i + 224 ]. b = 224 + i ;
}
colors = colors . map ( ( a )=>{ return Object . values ( a ); } ). flat ();
const colorArray = new Float32Array ( colors ); // vec4<f32>
const colorBuffer = device . createBuffer ({ size : colorArray . byteLength , usage : GPUBufferUsage . STORAGE | GPUBufferUsage . COPY_DST | GPUBufferUsage . COPY_SRC });
device . queue . writeBuffer ( colorBuffer , 0 , colorArray );
// ----------------------------------------------------------
const bufArray0 = new Float32Array ( imgWidth * imgHeight );
const bufBuffer0 = device . createBuffer ({ size : bufArray0 . byteLength , usage : GPUBufferUsage . STORAGE | GPUBufferUsage . COPY_DST | GPUBufferUsage . COPY_SRC });
device . queue . writeBuffer ( bufBuffer0 , 0 , colorArray );
const bufBuffer1 = device . createBuffer ({ size : bufArray0 . byteLength , usage : GPUBufferUsage . STORAGE | GPUBufferUsage . COPY_DST | GPUBufferUsage . COPY_SRC });
device . queue . writeBuffer ( bufBuffer1 , 0 , colorArray );
// ----------------------------------------------------------
const GCOMPUTE = GPUShaderStage . COMPUTE ;
// Bind group layout and bind group
const bindGroupLayout = device . createBindGroupLayout ({
entries : [ { binding : 0 , visibility : GCOMPUTE , buffer : { type : "uniform" } },
{ binding : 1 , visibility : GPUShaderStage . COMPUTE , buffer : { type : "read-only-storage" } },
{ binding : 2 , visibility : GPUShaderStage . COMPUTE , buffer : { type : "storage" } },
{ binding : 3 , visibility : GPUShaderStage . COMPUTE , buffer : { type : "storage" } },
{ binding : 4 , visibility : GCOMPUTE , storageTexture : { format : "rgba8unorm" , access : "write-only" , viewDimension : "2d" } }
]
});
const bindGroup0 = device . createBindGroup ({
layout : bindGroupLayout ,
entries : [ { binding : 0 , resource : { buffer : timerUniformBuffer } },
{ binding : 1 , resource : { buffer : colorBuffer } },
{ binding : 2 , resource : { buffer : bufBuffer0 } }, // swap buf0/1
{ binding : 3 , resource : { buffer : bufBuffer1 } },
{ binding : 4 , resource : texture1 . createView () }
]
});
const bindGroup1 = device . createBindGroup ({
layout : bindGroupLayout ,
entries : [ { binding : 0 , resource : { buffer : timerUniformBuffer } },
{ binding : 1 , resource : { buffer : colorBuffer } },
{ binding : 2 , resource : { buffer : bufBuffer1 } }, // swap buf0/1
{ binding : 3 , resource : { buffer : bufBuffer0 } },
{ binding : 4 , resource : texture1 . createView () }
]
});
// Compute shader code
const computeShader = `
@group(0) @binding(0) var<uniform> mytimer : f32; // timer increments each frame
@group(0) @binding(1) var<storage, read> mycolors : array< vec4<f32>, 256 >;
@group(0) @binding(2) var<storage, read_write> buf0 : array< f32, ${ bufArray0 . length } >; // width x height
@group(0) @binding(3) var<storage, read_write> buf1 : array< f32, ${ bufArray0 . length } >; // width x height
@group(0) @binding(4) var myTexture1: texture_storage_2d<rgba8unorm, write>; // output image
const imgWidth = u32( ${ imgWidth } );
const imgHeight = u32( ${ imgHeight } );
//const imgSize = vec2<f32>(imgWidth, imgHeight);
// Simple random number generator based on the index
fn rand(seed: u32) -> f32 {
var x = seed * 1234567u + 987654321u;
x = (x >> 16u) ^ x;
x = x * 1103515245u + 12345u;
return f32(x & 0x7fffffffu) / f32(0x7fffffffu);
}
fn random(uv: vec2<f32>) -> f32 {
return fract(sin(dot(uv, vec2<f32>(12.9898, 78.233))) * 43758.5453);
}
@compute @workgroup_size(8, 8)
fn main(@builtin(global_invocation_id) globalId : vec3<u32>,
@builtin(local_invocation_id) localId : vec3<u32>,
@builtin(workgroup_id) workgroupId : vec3<u32>,
@builtin(num_workgroups) workgroupSize : vec3<u32>
)
{
var coords = vec2<u32>(globalId.xy);
if (coords.x >= imgWidth || coords.y >= imgHeight) {
return;
}
let index = coords.y * imgWidth + coords.x;
// Simulate fire effect
if (coords.y == imgHeight - 1)
{
// Randomize the bottom row of the fire array
if ( random( vec2<f32>(f32(index)/1024.0, mytimer*0.1 ) ) > 0.5) {
buf1[index] = 255.0;
} else {
buf1[index] = 0;
}
}
else
{
// Move fire upwards
var temp = f32(buf0[index]);
temp += f32(buf0[index + 1]);
temp += f32(buf0[index - 1]);
temp += f32(buf0[index + imgWidth]);
temp /= 3.975;
if (temp > 1.0) {
temp -= 1.0;
}
buf1[index] = f32(clamp(temp, 0.0, 255.0));
}
var finalcolor = mycolors[ u32( buf0[ index ] ) ];
finalcolor *= 1.0/255;
finalcolor.w = 1.0;
// Store the result in the output texture
textureStore(myTexture1, vec2<i32>(globalId.xy), finalcolor);
}
`;
// Pipeline setup
const computePipeline = device . createComputePipeline ({
layout : device . createPipelineLayout ({ bindGroupLayouts : [ bindGroupLayout ]}),
compute : { module : device . createShaderModule ({ code : computeShader }),
entryPoint : "main" }
});
async function frame ()
{
timestep [ 0 ] = timestep [ 0 ] + 0.01 ;
device . queue . writeBuffer ( timerUniformBuffer , 0 , timestep );
// Commands submission
const commandEncoder = device . createCommandEncoder ();
{
const passEncoder = commandEncoder . beginComputePass ();
passEncoder . setPipeline ( computePipeline );
passEncoder . setBindGroup ( 0 , bindGroup0 );
// workgroup size of 8x8 on the wgsl shader
passEncoder . dispatchWorkgroups ( imgWidth / 8 , imgWidth / 8 );
await passEncoder . end ();
}
{
const passEncoder = commandEncoder . beginComputePass ();
passEncoder . setPipeline ( computePipeline );
passEncoder . setBindGroup ( 0 , bindGroup1 );
// workgroup size of 8x8 on the wgsl shader
passEncoder . dispatchWorkgroups ( imgWidth / 8 , imgWidth / 8 );
await passEncoder . end ();
}
canvasTexture = context . getCurrentTexture ();
await
commandEncoder . copyTextureToTexture ( { texture : texture1 },
{ texture : canvasTexture },
{ width : imgWidth , height : imgHeight , depthOrArrayLayers : 1 } );
// Submit GPU commands.
const gpuCommands = commandEncoder . finish ();
await device . queue . submit ([ gpuCommands ]);
requestAnimationFrame ( frame );
}
frame ();
Animated 2d fire effect - fire moves up slowly - generated on the bottom row of pixels.
Things to Try
• Instead of adding the random emitter for the fire at the bottom (emit it in a circular shape in the middle of the screen)
• Experiment with different noise and color patterns
• Adding noise to the direction (not just limited to up - but wiggles left/right more)
Resources and Links
• WebGPU Lab Demo [LINK ]
• Notebook Demo [LINK ]