Interactive Fluid
A compact pressure-projected fluid solver with velocity advection, colorful dye, and pointer or touch stirring.
import type { Gpu, Target } from 'vgpu';
import type { StirInput } from './pointer-input';
import { createFluid, destroyFluid, prepareFluid, renderFluid, stepFluid, type Fluid } from './simulation';
export interface FluidValidationStats {
steps: number;
finite: boolean;
maxSpeed: number;
maxDye: number;
averageDye: number;
}
export async function renderThumb(
gpu: Gpu,
target: Target,
options: {
warmupFrames?: number;
scriptedDrag?: boolean;
soak?: boolean;
onStateValidated?: (stats: FluidValidationStats) => void;
} = {},
): Promise<void> {
const fluid = createFluid(gpu);
try {
await prepareFluid(fluid, target);
if (options.soak) {
const pointer = scriptedInput(0.37, 0.42, 0.4, Infinity);
for (let i = 0; i < 6_000; i++) {
stepFluid(fluid, i < 5_000 ? undefined : pointer);
if ((i + 1) % 500 === 0) {
await gpu.gpu.queue.onSubmittedWorkDone();
options.onStateValidated?.(await readStats(fluid));
}
}
} else {
const pointer = options.scriptedDrag ? scriptedInput(0.08, 0.28, 0.24, 40) : undefined;
for (let i = 0; i < (options.warmupFrames ?? 120); i++) stepFluid(fluid, pointer);
}
renderFluid(fluid, target);
await gpu.gpu.queue.onSubmittedWorkDone();
await gpu.settled();
options.onStateValidated?.(await readStats(fluid));
} finally {
try {
await gpu.gpu.queue.onSubmittedWorkDone();
} finally {
destroyFluid(fluid);
}
}
}
function scriptedInput(rate: number, radiusX: number, radiusY: number, activeSteps: number): StirInput {
let step = 0;
const point = (n: number): [number, number] => [
0.5 + radiusX * Math.sin(n * rate),
0.5 + radiusY * Math.sin(n * rate * 1.61 + 1.2),
];
return {
get active() { return step < activeSteps; },
get from() { return point(step - 1); },
get to() { return point(step); },
get velocity() {
const from = point(step - 1), to = point(step);
return [(to[0] - from[0]) * 60, (to[1] - from[1]) * 60] as [number, number];
},
stroke: 1,
consumeStep() { step++; },
dispose() {},
};
}
async function readStats(fluid: Fluid): Promise<FluidValidationStats> {
const [velocityBytes, dyeBytes] = await Promise.all([fluid.velocity.read.read(), fluid.dye.read.read()]);
const velocity = new Float32Array(velocityBytes);
const dye = new Float32Array(dyeBytes);
let finite = true, maxSpeed = 0, maxDye = 0, dyeSum = 0;
for (let i = 0; i < velocity.length; i += 2) {
finite &&= Number.isFinite(velocity[i]) && Number.isFinite(velocity[i + 1]);
maxSpeed = Math.max(maxSpeed, Math.hypot(velocity[i]!, velocity[i + 1]!));
}
for (const value of dye) {
finite &&= Number.isFinite(value);
maxDye = Math.max(maxDye, value);
dyeSum += value;
}
return { steps: fluid.step, finite, maxSpeed, maxDye, averageDye: dyeSum / dye.length };
}