WebGPU SPH シミュレーション ( Chrome Canary )
WebGPUを使って、SPH(Smoothed Particle Hydrodynamics)のシミュレーションを行いました。
メインPCをMacBook Pro(13in.)に変えました。Chrome Canaryを使って表示して
います。
SPHの計算は、前回のjuliaによる計算と同様ですが、ここではcompute shaderを
使って、GPUで計算しています。
compute shaderでの計算は、Vulkanによるcompute shaderを用いた計算(2d)を
参考にしました。
SPH simulation in Vulkan compute shader
https://github.com/multiprecision/sph_vulkan
sphの計算は、以下の様に行います。
1 粒子の密度(density), 圧力(pressure)を計算する。
2 1で計算したdensity, pressureを使って、力の圧力項(puressure_force)と力の
粘性項(viscosity_force)を計算する。
3 2で計算したpuressure_forceとviscosity_forceに、重力項(gravity)、境界の壁
から受ける力(adj)を加えて、新しい速度と新しい位置を計算する。
これをcompute shaderで行うには、1つのshaderでは出来ません。3つのshaderに分けて行います。
1つ目のshader(compute_density_pressure)
densityとpressureの式を記述し、dispatchを行う。
2つ目のshader(compute_force)
bufferに保存されたdensityとpressureを使って、puressure_forceと
viscosity_forceの式を記述し、dispatchを行う。
3つ目のshader(compute_integrate)
bufferに保存されたpuressure_force、viscosity_forceと重力、壁による力を
加えた加速度を使って、新しい速度と位置を計算する式を記述し、dispatchを
行う。
プログラムには、以下のライブラリを使用しています。
1 point(cubeで表現)を描画するためのcubeプログラム utils.js
WebGPU Examples https://github.com/tsherif/webgpu-examples
2 cameraプログラム webgl-util.min.js
WebGPU Experiments https://github.com/Twinklebear/webgpu-experiments
3 matrix and vector計算ライブラリ gl-matrix.js
https://github.com/toji/gl-matrix
プログラムの注意点
1 forceBuffer の buffer size
size: 16 * NUM_PARTICLES と 16 にする。(shader では vec3 force[] と設定)
2 pipeline の vertexBuffers - arrayStride
粒子 position の buffer size は 16 * NUM_PARTICLES としているので、
vertexBuffers の arrayStride も 16 にする。
(粒子は instance として表示している。)
最近、WebGPUの仕様変更が2つありました。
1 createBufferMapped()の廃止
createBufferMapped()が使えなくなりましたので、次の様に変更します。
const [positionBuffer, positionBufferMap] = device.createBufferMapped({ size: positionData.byteLength, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST | GPUBufferUsage.STORAGE }); new Float32Array(positionBufferMap).set(positionData); positionBuffer.unmap(); -> const positionBuffer = device.createBuffer({ size: positionData.byteLength, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST | GPUBufferUsage.STORAGE }); device.defaultQueue.writeBuffer(positionBuffer, 0, positionData); または、 const positionBuffer = device.createBuffer({ mappedAtCreation: true, size: positionData.byteLength, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST | GPUBufferUsage.STORAGE }); var mapping = positionBuffer.getMappedRange(); new Float32Array(mapping).set(positionData); positionBuffer.unmap();
2 setIndexBuffer()の変更
renderPass.setIndexBuffer(planeindexBuf);(uint16はpipelineのvertexStateで設定) -> renderPass.setIndexBuffer(planeindexBuf, "uint16");
プログラム
sph-3d.html
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>WebGPU</title> <script src="gl-matrix.js"></script> <script src="webgl-util.min.js"></script> <script src="utils.js"></script> </head> <body> <canvas id="webgpu-canvas" width="800" height="600"></canvas> <script> (async () => { // Get GPU adapter and device // Load glslang Module const [adapter, glslang] = await Promise.all([ navigator.gpu.requestAdapter(), import("https://unpkg.com/@webgpu/glslang@0.0.15/dist/web-devel/glslang.js").then(m => m.default()) ]); const device = await adapter.requestDevice(); // Get canvas and context const canvas = document.getElementById("webgpu-canvas"); const context = canvas.getContext("gpupresent"); // Constants const MATH_PI = Math.PI; const NUM_PARTICLES = 1000; const POINT_SIZE = 0.005; const REST_DENSITY = 1000.0; const L0 = 0.01; const N0 = 12; const PARTICLE_MASS = REST_DENSITY * L0**3; const SMOOTHING_LENGTH = (3.0 * N0 / (4 * MATH_PI))**(1.0/3.0) * L0; const WEIGHT_RHO_COEF = 315.0 / (64.0 * MATH_PI * SMOOTHING_LENGTH**3); const WEIGHT_PRESSURE_COEF = 45.0 / (MATH_PI * SMOOTHING_LENGTH**4); const WEIGHT_VISCOSITY_COEF = 45.0 / (MATH_PI * SMOOTHING_LENGTH**5); const PRESSURE_STIFFNESS = 15.0; const VISC = 3.0; // boundary condition ( penalty method ) const PARTICLE_RADIUS = L0 / 2; const EPSIRON = 0.0001; const EXT_STIFF = 10000.0; const EXT_DAMP = 250.0; const DT = 0.0025; // particle min. and max. position const pos_min = [-1.0, -1.0, -1.0]; const pos_max = [-0.7, 1.0, -0.9]; // camera const eye = [-0.8, -0.7, -0.2]; const center = [-0.8, -1.0, -1.2]; const up = [0, 1, 0]; // initial particle data // position var positionData = new Float32Array(4 * NUM_PARTICLES); var x = 0; var y = 0; var z = 0; for (let i = 0; i < positionData.length; i += 4) { positionData[i] = pos_min[0] + L0 * x; positionData[i + 1] = pos_min[1] + L0 * y; positionData[i + 2] = pos_min[2] + L0 * z; positionData[i + 3] = 1; x++; if (x >= 6) { x = 0; z++; } if (z >= 10) { x = 0; z = 0; y++; } } //console.log(“positionData: ", positionData); // velocity var velocityData = new Float32Array(4 * NUM_PARTICLES); for (let i = 0; i < velocityData.length; i += 4) { velocityData[i] = 0.0; velocityData[i + 1] = 0.0; velocityData[i + 2] = 0.0; velocityData[i + 3] = 1; } //console.log(“velocityData: ", velocityData); // Shaders // compute shader const compute_density_pressure = ` #version 450 // constants const int particles = ${NUM_PARTICLES}; const float mass = ${PARTICLE_MASS}; const float h = ${SMOOTHING_LENGTH}; const float rest_density = ${REST_DENSITY}; const float pressure_stiffness = ${PRESSURE_STIFFNESS}; const float w_rho_coef = ${WEIGHT_RHO_COEF}; layout(std430, binding = 0) buffer position_block { vec3 position[]; }; layout(std430, binding = 1) buffer velocity_block { vec3 velocity[]; }; layout(std430, binding = 2) buffer force_block { vec3 force[]; }; layout(std430, binding = 3) buffer density_block { float density[]; }; layout(std430, binding = 4) buffer pressure_block { float pressure[]; }; void main() { uint i = gl_GlobalInvocationID.x; // compute density float density_sum = 0.0; for (uint j = 0; j < particles; j++) { vec3 delta = position[i] - position[j]; float r = length(delta); if (r < h) { float rh = r / h; float rh2 = 1 - rh * rh; density_sum += mass * w_rho_coef * rh2 * rh2 * rh2; } } density[i] = density_sum; // compute pressure pressure[i] = max(pressure_stiffness * (density[i] - rest_density), 0.0); } `.trim(); const compute_force = ` #version 450 // constants const int NUM_PARTICLES = ${NUM_PARTICLES}; const float mass = ${PARTICLE_MASS}; const float h = ${SMOOTHING_LENGTH}; const float visc = ${VISC}; const float w_pressure_coef = ${WEIGHT_PRESSURE_COEF}; const float w_visc_coef = ${WEIGHT_VISCOSITY_COEF}; //const vec3 gravity = vec3(0, -9806.65, 0.0); const vec3 gravity = vec3(0, -9.8, 0.0); layout(std430, binding = 0) buffer position_block { vec3 position[]; }; layout(std430, binding = 1) buffer velocity_block { vec3 velocity[]; }; layout(std430, binding = 2) buffer force_block { vec3 force[]; }; layout(std430, binding = 3) buffer density_block { float density[]; }; layout(std430, binding = 4) buffer pressure_block { float pressure[]; }; void main() { uint i = gl_GlobalInvocationID.x; // forces vec3 pressure_force = vec3(0, 0, 0); vec3 viscosity_force = vec3(0, 0, 0); for (uint j = 0; j < NUM_PARTICLES; j++) { if (i == j) { continue; } vec3 delta = position[i] - position[j]; float r = length(delta); if (r < h) { float rh = 1 - r / h; pressure_force += 0.5 * mass * (pressure[i] + pressure[j]) / density[j] * w_pressure_coef * rh * rh * normalize(delta); vec3 vji = velocity[j] - velocity[i]; viscosity_force += visc * mass * vji / density[j] * w_visc_coef * rh; } } vec3 external_force = density[i] * gravity; force[i] = pressure_force + viscosity_force + external_force; } `.trim(); const compute_integrate = ` #version 450 // constants const float TIME_STEP = ${DT}; const float radius = ${PARTICLE_RADIUS}; const float epsiron = ${EPSIRON}; const float extstiff = ${EXT_STIFF}; const float extdamp = ${EXT_DAMP}; const vec3 min_position = vec3(${pos_min[0]}, ${pos_min[1]}, ${pos_min[2]}); const vec3 max_position = vec3(${pos_max[0]}, ${pos_max[1]}, ${pos_max[2]}); layout(std430, binding = 0) buffer position_block { vec3 position[]; }; layout(std430, binding = 1) buffer velocity_block { vec3 velocity[]; }; layout(std430, binding = 2) buffer force_block { vec3 force[]; }; layout(std430, binding = 3) buffer density_block { float density[]; }; layout(std430, binding = 4) buffer pressure_block { float pressure[]; }; void main() { uint i = gl_GlobalInvocationID.x; vec3 acceleration = force[i] / density[i]; vec3 current_pos = position[i]; vec3 current_vel = velocity[i]; vec3 diff_min = 2.0 * radius - (current_pos - min_position); vec3 diff_max = 2.0 * radius - (max_position - current_pos); // boundary conditions if (diff_min.x > epsiron) { vec3 normal = vec3( 1.0, 0.0, 0.0 ); float adj = extstiff * diff_min.x - extdamp * dot( normal, current_vel ); acceleration += adj * normal; } if (diff_max.x > epsiron) { vec3 normal = vec3( -1.0, 0.0, 0.0 ); float adj = extstiff * diff_max.x - extdamp * dot( normal, current_vel ); acceleration += adj * normal; } if (diff_min.y > epsiron) { vec3 normal = vec3( 0.0, 1.0, 0.0 ); float adj = extstiff * diff_min.y - extdamp * dot( normal, current_vel ); acceleration += adj * normal; } if (diff_max.y > epsiron) { vec3 normal = vec3( 0.0, -1.0, 0.0 ); float adj = extstiff * diff_max.y - extdamp * dot( normal, current_vel ); acceleration += adj * normal; } if (diff_min.z > epsiron) { vec3 normal = vec3( 0.0, 0.0, 1.0 ); float adj = extstiff * diff_min.z - extdamp * dot( normal, current_vel ); acceleration += adj * normal; } if (diff_max.z > epsiron) { vec3 normal = vec3( 0.0, 0.0, -1.0 ); float adj = extstiff * diff_max.z - extdamp * dot( normal, current_vel ); acceleration += adj * normal; } vec3 new_velocity = current_vel + TIME_STEP * acceleration; vec3 new_position = current_pos + TIME_STEP * new_velocity; velocity[i] = new_velocity; position[i] = new_position; } `.trim(); // Setup shader modules var compModule_density = device.createShaderModule({ code: glslang.compileGLSL(compute_density_pressure, "compute") }); var computeStage_density = { module: compModule_density, entryPoint: "main" }; var compModule_force = device.createShaderModule({ code: glslang.compileGLSL(compute_force, "compute") }); var computeStage_force = { module: compModule_force, entryPoint: "main" }; var compModule_integrate = device.createShaderModule({ code: glslang.compileGLSL(compute_integrate, "compute") }); var computeStage_integrate = { module: compModule_integrate, entryPoint: "main" }; const positionBuffer = device.createBuffer({ size: positionData.byteLength, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST | GPUBufferUsage.STORAGE }); device.defaultQueue.writeBuffer(positionBuffer, 0, positionData); const velocityBuffer = device.createBuffer({ size: velocityData.byteLength, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST | GPUBufferUsage.STORAGE }); device.defaultQueue.writeBuffer(velocityBuffer, 0, velocityData); const forceBuffer = device.createBuffer({ size: 16 * NUM_PARTICLES, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST }); const densityBuffer = device.createBuffer({ size: 4 * NUM_PARTICLES, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST }); const pressureBuffer = device.createBuffer({ size: 4 * NUM_PARTICLES, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST }); // compute bind group const computeBindGroupLayout = device.createBindGroupLayout({ entries: [ { binding: 0, visibility: GPUShaderStage.COMPUTE, type: "storage-buffer" }, { binding: 1, visibility: GPUShaderStage.COMPUTE, type: "storage-buffer" }, { binding: 2, visibility: GPUShaderStage.COMPUTE, type: "storage-buffer" }, { binding: 3, visibility: GPUShaderStage.COMPUTE, type: "storage-buffer" }, { binding: 4, visibility: GPUShaderStage.COMPUTE, type: "storage-buffer" }, ] }); const computeBindGroup = device.createBindGroup({ layout: computeBindGroupLayout, entries: [ { binding: 0, resource: { buffer: positionBuffer } }, { binding: 1, resource: { buffer: velocityBuffer } }, { binding: 2, resource: { buffer: forceBuffer } }, { binding: 3, resource: { buffer: densityBuffer } }, { binding: 4, resource: { buffer: pressureBuffer } }, ] }); const computePipeline_density = device.createComputePipeline({ layout: device.createPipelineLayout({bindGroupLayouts: [computeBindGroupLayout]}), computeStage: computeStage_density, }); const computePipeline_force = device.createComputePipeline({ layout: device.createPipelineLayout({bindGroupLayouts: [computeBindGroupLayout]}), computeStage: computeStage_force, }); const computePipeline_integrate = device.createComputePipeline({ layout: device.createPipelineLayout({bindGroupLayouts: [computeBindGroupLayout]}), computeStage: computeStage_integrate, }); // vertex shader const vs_point = ` #version 450 layout(location=0) in vec3 vertexPosition; layout(location=2) in vec3 position; layout(set = 0, binding = 0, std140) uniform ViewParams { mat4 proj_view; }; const float scale = ${POINT_SIZE}; void main() { mat4 scaleMTX = mat4( scale, 0, 0, 0, 0, scale , 0, 0, 0, 0, scale, 0, position, 1 ); gl_Position = proj_view * scaleMTX * vec4(vertexPosition, 1.0); } `.trim(); const fs_point = ` #version 450 layout(location=0) out vec4 fragColor; void main() { fragColor = vec4(1.0, 0.0, 0.0, 1.0); } `.trim(); // plane const vs_plane = ` #version 450 layout(location=0) in vec4 vertexPosition; layout(location=1) in vec4 color; layout(location=0) out vec4 vColor; layout(set = 0, binding = 0, std140) uniform ViewParams { mat4 proj_view; }; void main() { vColor = color; gl_Position = proj_view * vertexPosition; } `.trim(); // fragment shader const fs_plane = ` #version 450 layout(location=0) in vec4 vColor; layout(location=0) out vec4 fragColor; void main() { fragColor = vColor; } `.trim(); // Setup shader modules var vertModule_point = device.createShaderModule({code: glslang.compileGLSL(vs_point, "vertex")}); var vertexStage_point = { module: vertModule_point, entryPoint: "main" }; var vertModule_plane = device.createShaderModule({code: glslang.compileGLSL(vs_plane, "vertex")}); var vertexStage_plane = { module: vertModule_plane, entryPoint: "main" }; var fragModule_point = device.createShaderModule({code: glslang.compileGLSL(fs_point, "fragment")}); var fragmentStage_point = { module: fragModule_point, entryPoint: "main" }; var fragModule_plane = device.createShaderModule({code: glslang.compileGLSL(fs_plane, "fragment")}); var fragmentStage_plane = { module: fragModule_plane, entryPoint: "main" }; const cubeData = utils.createCube(); const numVertices = cubeData.positions.length / 3; const vertexBuffer = device.createBuffer({ size: cubeData.positions.byteLength, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST }); device.defaultQueue.writeBuffer(vertexBuffer, 0, cubeData.positions); // Setup vertexState var vertexState_point = { vertexBuffers: [ { arrayStride: 12, attributes: [{ shaderLocation: 0, format: "float3", offset: 0 }] }, { arrayStride: 16, stepMode: "instance", attributes: [{ shaderLocation: 2, format: "float3", offset: 0 }] }, ] }; // planes const planeData = new Float32Array([ // bottom -1.0, -1.0, 1.0, 1.0, // position v0 0.6, 0.6, 0.6, 1.0, //color -1.0, -1.0, -1.0, 1.0, // v1 0.6, 0.6, 0.6, 1.0, 1.0, -1.0, -1.0, 1.0, // v2 0.6, 0.6, 0.6, 1.0, 1.0, -1.0, 1.0, 1.0, // v3 0.6, 0.6, 0.6, 1.0, // back -1.0, 1.0, -1.0, 1.0, // v4 0.0, 0.7, 0.7, 1.0, -1.0, -1.0, -1.0, 1.0, // v5 0.0, 0.7, 0.7, 1.0, 1.0, -1.0, -1.0, 1.0, // v6 0.0, 0.7, 0.7, 1.0, 1.0, 1.0, -1.0, 1.0, // v7 0.0, 0.7, 0.7, 1.0, // left -1.0, 1.0, 1.0, 1.0, // v8 0.0, 0.9, 0.9, 1.0, -1.0, -1.0, 1.0, 1.0, // v9 0.0, 0.9, 0.9, 1.0, -1.0, -1.0, -1.0, 1.0, // v10 0.0, 0.9, 0.9, 1.0, -1.0, 1.0, -1.0, 1.0, // v12 0.0, 0.9, 0.9, 1.0, ]); var planedataBuf = device.createBuffer({ mappedAtCreation: true, size: planeData.byteLength, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST }); var mapping = planedataBuf.getMappedRange(); new Float32Array(mapping).set(planeData); planedataBuf.unmap(); const planeindexData = new Uint16Array([ 0, 1, 2, 0, 2, 3, // bottom 4, 5, 6, 4, 6, 7, // back 8, 9, 10, 8, 10, 11, // left ]); var planeindexBuf = device.createBuffer({ mappedAtCreation: true, size: planeindexData.byteLength, usage: GPUBufferUsage.INDEX }); var mapping = planeindexBuf.getMappedRange(); new Uint16Array(mapping).set(planeindexData); planeindexBuf.unmap(); var vertexState_plane = { vertexBuffers: [ { arrayStride: 2 * 4 * 4, attributes: [ { format: "float4", offset: 0, shaderLocation: 0 }, { format: "float4", offset: 4 * 4, shaderLocation: 1 } ] } ], }; // Setup render outputs ( swapcahain depth texture) var swapChainFormat = "bgra8unorm"; var swapChain = context.configureSwapChain({ device, format: swapChainFormat, usage: GPUTextureUsage.OUTPUT_ATTACHMENT }); var depthFormat = "depth24plus-stencil8"; var depthTexture = device.createTexture({ size: { width: canvas.width, height: canvas.height, depth: 1 }, format: depthFormat, usage: GPUTextureUsage.OUTPUT_ATTACHMENT }); // Create bind group layout (uniform buffer) var bindGroupLayout = device.createBindGroupLayout({ entries: [ { binding: 0, visibility: GPUShaderStage.VERTEX, type: "uniform-buffer" } ] }); // Create render pipeline var layout = device.createPipelineLayout({bindGroupLayouts: [bindGroupLayout]}); var pipeline_point = device.createRenderPipeline({ layout: layout, vertexStage: vertexStage_point, fragmentStage: fragmentStage_point, primitiveTopology: "triangle-list", vertexState: vertexState_point, colorStates: [{ format: swapChainFormat }], depthStencilState: { format: depthFormat, depthWriteEnabled: true, depthCompare: "less" } }); // plane var pipeline_plane = device.createRenderPipeline({ layout: layout, vertexStage: vertexStage_plane, fragmentStage: fragmentStage_plane, primitiveTopology: "triangle-list", vertexState: vertexState_plane, colorStates: [{ format: swapChainFormat }], depthStencilState: { format: depthFormat, depthWriteEnabled: true, depthCompare: "less" } }); // Setup renderPassDesc var renderPassDesc = { colorAttachments: [{ attachment: undefined, loadValue: [0.3, 0.3, 0.3, 1] }], depthStencilAttachment: { attachment: depthTexture.createView(), depthLoadValue: 1.0, depthStoreOp: "store", stencilLoadValue: 0, stencilStoreOp: "store" } }; // Create uniform buffer var viewParamsBuffer = device.createBuffer({ size: 16 * 4, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); // Create bind group (setup uniform buffer) var bindGroup = device.createBindGroup({ layout: bindGroupLayout, entries: [ { binding: 0, resource: { buffer: viewParamsBuffer } } ] }); // Create arcball camera and view projection matrix var camera = new ArcballCamera(eye, center, up, 0.5, [canvas.width, canvas.height]); var projection = mat4.perspective(mat4.create(), 50 * Math.PI / 180.0, canvas.width / canvas.height, 0.1, 100); // projection_view matrix var projView = mat4.create(); // Controller var controller = new Controller(); controller.mousemove = function(prev, cur, evt) { if (evt.buttons == 1) { camera.rotate(prev, cur); } else if (evt.buttons == 2) { camera.pan([cur[0] - prev[0], prev[1] - cur[1]]); } }; controller.wheel = function(amt) { camera.zoom(amt * 3.0); }; controller.registerForCanvas(canvas); var canvasVisible = false; var observer = new IntersectionObserver(function(e) { if (e[0].isIntersecting) { canvasVisible = true; } else { canvasVisible = false; } }, {threshold: [0]}); observer.observe(canvas); // render requestAnimationFrame(function frame() { // command buffer var commandEncoder = device.createCommandEncoder(); // compute pass var computePass = commandEncoder.beginComputePass(); computePass.setPipeline(computePipeline_density); computePass.setBindGroup(0, computeBindGroup); computePass.dispatch(NUM_PARTICLES); computePass.setPipeline(computePipeline_force); computePass.setBindGroup(0, computeBindGroup); computePass.dispatch(NUM_PARTICLES); computePass.setPipeline(computePipeline_integrate); computePass.setBindGroup(0, computeBindGroup); computePass.dispatch(NUM_PARTICLES); computePass.endPass(); // render pass // SwapChain framebuffer renderPassDesc.colorAttachments[0].attachment = swapChain.getCurrentTexture().createView(); // write projView to viewParamsBuffer projView = mat4.mul(projView, projection, camera.camera); device.defaultQueue.writeBuffer(viewParamsBuffer, 0, projView); var renderPass = commandEncoder.beginRenderPass(renderPassDesc); // draw points renderPass.setPipeline(pipeline_point); renderPass.setVertexBuffer(0, vertexBuffer); renderPass.setVertexBuffer(1, positionBuffer); renderPass.setBindGroup(0, bindGroup); renderPass.draw(numVertices, NUM_PARTICLES, 0, 0); // draw plane renderPass.setPipeline(pipeline_plane); renderPass.setVertexBuffer(0, planedataBuf); renderPass.setIndexBuffer(planeindexBuf, "uint16"); renderPass.drawIndexed(planeindexData.length, 1, 0, 0, 0); renderPass.endPass(); device.defaultQueue.submit([commandEncoder.finish()]); requestAnimationFrame(frame); }); })(); </script> </body> </html>