Fluid Rendering - FluidRenderer
FluidRenderer renders a set of particles as a continuous liquid surface (screen-space fluid rendering). It pairs with particle fluid simulations such as SPH or FLIP, for pouring, sloshing and splashing liquids.
TIP
FluidRenderer only renders; it does not simulate the fluid. You provide the particle positions: from a simulation on the CPU, or straight from a GPU buffer written by a compute shader.
Enabling Fluid Rendering
Turn on setting.render.fluid when initializing the engine so the render pipeline adds FluidPass:
import { Engine3D } from '@orillusion/core';
const engine = await Engine3D.init({
setting: { render: { fluid: true } }
});For every enabled FluidRenderer in the scene, FluidPass:
- draws the particles as sphere impostors into a linear depth map, and with additive blending into a thickness map;
- smooths the depth map so neighboring spheres merge into one continuous surface, and blurs the thickness map;
- reconstructs normals from the smoothed depth and shades the surface with refraction, absorption, environment reflection and a specular highlight.
The fluid is drawn after opaque geometry and before transparent geometry, so opaque objects clip it correctly and transparent objects draw over it.
TIP
The fluid only writes color and depth, not the G-Buffer, so pixel picking cannot hit it. To interact with the liquid surface, intersect a ray with an approximate horizontal plane instead.
Basic Usage
Add a FluidRenderer to an Object3D, set the particle diameter and pass in the particle data:
import { Object3D, FluidRenderer } from '@orillusion/core';
const fluidObj = new Object3D();
const fluid = fluidObj.addComponent(FluidRenderer);
// Particle diameter in world units
fluid.particleSize = 0.16;
scene.addChild(fluidObj);
// 4 floats per particle: x, y, z and an unused component
const count = 6000;
const positions = new Float32Array(count * 4);
// ... write the particle positions
fluid.setDataBuffer(positions);Particle positions are in the local space of the owning object (world space when it sits at the origin unscaled); particleSize is in world units.
Particle Data
CPU Data: setDataBuffer
fluid.setDataBuffer(data: Float32Array, count?: number, layout?: FluidDataLayout);datais uploaded to the GPU on the next frame. After modifying the array in place, callsetDataBuffer()again to re-upload it, usually once at the end of each simulation step;countis the number of particles; by default as many asdataholds.
class MySimulation extends ComponentBase {
public fluid: FluidRenderer;
private positions: Float32Array;
public onUpdate(): void {
this.step(1 / 60); // update this.positions
this.fluid.setDataBuffer(this.positions); // re-upload
}
}GPU Data: setDataBufferGPU
When the particles are simulated by a compute shader, hand the storage buffer it writes directly to FluidRenderer. The positions stay on the GPU the whole time, with no read-back:
fluid.setDataBufferGPU(buffer: GPUBufferBase, count: number, layout?: FluidDataLayout);The buffer needs STORAGE usage (for example StorageGPUBuffer or ComputeGPUBuffer). FluidRenderer reads it every frame, so compute shader updates show up without further calls:
import { ComponentBase, ComputeShader, FluidRenderer, StorageGPUBuffer, View3D } from '@orillusion/core';
const simulationWGSL = /* wgsl */ `
@group(0) @binding(0) var<storage, read_write> positions: array<vec4<f32>>;
@compute @workgroup_size(64)
fn CsMain(@builtin(global_invocation_id) id: vec3<u32>) {
let i = id.x;
if (i >= arrayLength(&positions)) { return; }
// ... update positions[i].xyz
}
`;
class GPUSimulation extends ComponentBase {
private count = 100000;
private positions: StorageGPUBuffer;
private shader: ComputeShader;
public start(): void {
// Size in floats: one vec4 per particle
this.positions = new StorageGPUBuffer(this.count * 4);
this.shader = new ComputeShader(simulationWGSL);
this.shader.setStorageBuffer('positions', this.positions);
this.shader.workerSizeX = Math.ceil(this.count / 64);
this.object3D.getComponent(FluidRenderer).setDataBufferGPU(this.positions, this.count);
}
public onCompute(view: View3D, command: GPUCommandEncoder): void {
view.engine3D.context3D.gpuContext.computeCommand(command, [this.shader]);
}
}particleCount can be lowered at any time to draw only the first particles of the buffer.
Data Layout
By default each particle takes 16 bytes (one vec4f) with the xyz position at the start. If the particle struct also holds velocity, density and so on, describe the layout with FluidDataLayout instead of copying the positions out:
// Particle struct: velocity (vec4f) + position (vec4f), 32 bytes
fluid.setDataBufferGPU(particleBuffer, count, {
stride: 32, // bytes between two consecutive particles
positionOffset: 16 // byte offset of the position inside a particle
});stride and positionOffset must both be multiples of 4, and the three xyz floats must fit inside stride; otherwise an error is thrown.
Appearance
| Property | Default | Description |
|---|---|---|
particleSize | 0.1 | Particle diameter (world units) |
fluidColor | (0.085, 0.64, 0.77) | Absorption color; light passing through the fluid is tinted toward it |
density | 2 | Absorption strength; higher is more opaque |
refractionStrength | 1 | Scale of the refraction offset: 1 follows the refracted ray to the scene behind, 0 disables refraction |
fresnelClamp | 1 | Upper bound of the fresnel reflection weight, 0 ~ 1 |
specularPower | 250 | Specular exponent; higher gives a smaller, sharper highlight |
edgeSoftness | 0 | Fades the fluid into the background where it is thinner than this many particles, softening free edges; 0 disables it |
minimumThickness | 0 | Pixels with less thickness show the background; with useFixedThickness, the thickness used everywhere |
useFixedThickness | false | Skip the thickness pass and use minimumThickness as a constant thickness |
dirLight | null | Light direction (world space) for the highlight; null uses the scene's first DirectLight |
environmentMap | undefined | Cube map reflected by the fluid; undefined uses the scene's envMap, null disables reflection |
The fluid's highlight and reflection use the scene's directional light and environment map; no extra lighting setup is needed.
Thickness
Absorption depends on the thickness of fluid along the view ray. thicknessMode decides how it is measured:
| Value | Description |
|---|---|
FluidThicknessMode.Accumulated | Default. Sums the contribution of every particle along the view ray (particleThicknessAlpha at each particle's center, default 0.05). Thickness follows the particle density, so uneven sampling shows as uneven patches |
FluidThicknessMode.Geometric | Distance between the near and the far side of the fluid along the view ray (world units), even inside a still body of fluid; density is then absorption per world unit. Suits simulations with uneven particle spacing such as FLIP |
import { FluidThicknessMode } from '@orillusion/core';
fluid.thicknessMode = FluidThicknessMode.Geometric;
fluid.density = 0.15;Surface Smoothing
Each particle is a sphere; the depth map has to be smoothed for them to merge into a smooth liquid surface. Too little smoothing shows the individual spheres; too much washes out detail and glues separate bodies of liquid together.
| Property | Default | Description |
|---|---|---|
enableBlurDepth | true | Whether to smooth the depth map |
depthFilter | Bilateral | Filter used for smoothing, see below |
blurDepthFilterSize | 7 | Smoothing radius, relative to the projected particle size |
blurDepthNumIterations | 3 | Iterations (each is one horizontal and one vertical pass) |
blurDepthMaxFilterSize | 100 | Upper bound of the smoothing radius in pixels |
blurDepthDepthScale | 10 | Depth range, in particle radii, across which samples are still blended; larger merges more |
blurDepthSizeDivisor | 1 | Smoothing runs at the depth map size divided by this |
enableBlurThickness | true | Whether to blur the thickness map |
blurThicknessFilterSize | 5 | Thickness blur radius (pixels) |
blurThicknessNumIterations | 1 | Thickness blur iterations |
blurThicknessSizeDivisor | 1 | Thickness blur runs at the thickness map size divided by this |
depthMapSize / thicknessMapSize | null | Width of the depth / thickness map in pixels; the height follows the screen aspect; null is the screen size |
depthFilter options:
| Value | Description |
|---|---|
FluidDepthFilter.Bilateral | Separable bilateral filter: a spatial gaussian weighted by depth similarity |
FluidDepthFilter.NarrowRange | Narrow-range filter: averages only samples within a depth range that follows the surface, keeping silhouettes and separate layers sharper at larger radii |
As a rule of thumb, smooth over about one particle diameter, for example:
import { FluidDepthFilter } from '@orillusion/core';
fluid.depthFilter = FluidDepthFilter.NarrowRange;
fluid.blurDepthFilterSize = 15;
fluid.blurDepthNumIterations = 6;Anisotropy
With useAnisotropy on, each particle is stretched into an ellipsoid along the distribution of its neighbors: flat regions stay flat and thin sheets stay thin, giving a noticeably smoother surface. It adds a neighbor search on the GPU each frame.
| Property | Default | Description |
|---|---|---|
useAnisotropy | false | Whether to enable anisotropy |
anisotropyRadius | null | Neighbor search radius (world units); null uses 1.5 × particleSize |
anisotropyMaxRatio | 4 | Largest ratio between the longest and the shortest ellipsoid axis |
anisotropyMinNeighbors | 10 | Particles with fewer neighbors stay spheres (spray, droplets) |
anisotropySmoothing | 0.5 | How far (0 ~ 1) each particle moves toward the weighted mean of its neighbors, smoothing the surface |
Debugging
With debug on, the image shows the intermediate result selected by debugFeature, which helps tune smoothing and thickness:
FluidDebug | Description |
|---|---|
DepthTexture | Linear depth of the particle spheres |
DepthBlurredTexture | Depth after smoothing |
ThicknessTexture | Accumulated particle thickness |
ThicknessBlurredTexture | Thickness after blurring |
Normals | View-space normals reconstructed from the smoothed depth |
import { FluidDebug } from '@orillusion/core';
fluid.debug = true;
fluid.debugFeature = FluidDebug.Normals;Example
The example below runs a minimal particle motion on the CPU (gravity, damping, pool walls and repulsion between particles; not a physically correct fluid solver) and uploads it every frame with setDataBuffer(). A block of particles drops into a pool and sloshes around; the pillar in the pool shows that opaque geometry clips the fluid. Use the panel to hide the pool and look at the fluid's free edges, or turn on debug to inspect each intermediate result.
import { AtmosphericComponent, BitmapTexture2D, BoxGeometry, CameraUtil, Color, ComponentBase, Context3D, DirectLight, Engine3D, FluidDebug, FluidDepthFilter, FluidRenderer, FluidThicknessMode, GeometryBase, HoverCameraController, KelvinUtil, LitMaterial, MeshRenderer, Object3D, PlaneGeometry, Scene3D, View3D } from '@orillusion/core';
import dat from 'dat.gui';
/**
* Screen-space fluid rendering of CPU-simulated particles: a block of particles drops
* into a pool and sloshes around. A pillar in the pool shows that opaque geometry clips the fluid.
*/
class Sample_Fluid {
async run() {
// setting.render.fluid adds the FluidPass that draws every FluidRenderer
const engine = await Engine3D.init({ setting: { render: { fluid: true } } });
const scene = new Scene3D();
scene.addComponent(AtmosphericComponent);
const camera = CameraUtil.createCamera3DObject(scene);
camera.perspective(60, engine.aspect, 0.1, 500);
camera.object3D.addComponent(HoverCameraController).setCamera(30, -35, 7);
const view = new View3D();
view.scene = scene;
view.camera = camera;
engine.startRenderView(view);
const light = new Object3D();
light.rotationX = 50;
light.rotationY = 30;
const dl = light.addComponent(DirectLight);
dl.lightColor = KelvinUtil.color_temperature_to_rgb(5500);
dl.intensity = 3;
dl.castShadow = true;
scene.addChild(light);
// Particles rest at y >= 0.05 with radius 0.08: the floor sits below their lowest point
const floorY = -0.05;
const slab = 0.12;
scene.addChild(mesh(new PlaneGeometry(20, 20), solid(new Color(0.15, 0.15, 0.17), 0.8), 0, floorY - slab - 0.01, 0, true));
scene.addChild(mesh(new BoxGeometry(0.6, 2.5, 0.6), solid(new Color(0.92, 0.92, 0.9), 0.8), 0.8, floorY + 1.25, 0.4, true));
const pool = createPool(scene, engine.context3D, { width: 3.2, depth: 3.2, wallHeight: 0.6, wallThickness: slab, tileSize: 0.1, lanes: 2, x: -1.6, y: floorY, z: -1.6 });
const fluidObj = new Object3D();
const fluid = fluidObj.addComponent(FluidRenderer);
fluid.particleSize = 0.16;
// Blur over about one particle diameter so the spheres merge into a smooth surface
fluid.blurDepthFilterSize = 15;
fluid.blurDepthNumIterations = 6;
fluid.depthFilter = FluidDepthFilter.NarrowRange;
// Stretch particles along their neighbors: flat regions stay flat, thin sheets stay thin
fluid.useAnisotropy = true;
// Fade the thin free edges into the background
fluid.edgeSoftness = 5;
fluid.blurThicknessFilterSize = 10;
fluid.blurThicknessNumIterations = 2;
const sim = fluidObj.addComponent(BoxSloshSimulation, { fluid, count: 6000 });
scene.addChild(fluidObj);
const s = {
showPool: true,
fluidColor: [fluid.fluidColor.r * 255, fluid.fluidColor.g * 255, fluid.fluidColor.b * 255],
reset: () => sim.reset()
};
const gui = new dat.GUI();
const f = gui.addFolder('Fluid');
f.add(fluid, 'enable');
// Hide the pool to look at the fluid's free edges
f.add(s, 'showPool').onChange((v: boolean) => (pool.transform.enable = v));
f.add(fluid, 'particleSize', 0.02, 0.5, 0.01);
f.add(fluid, 'particleThicknessAlpha', 0, 0.3, 0.005);
f.addColor(s, 'fluidColor').onChange((v: number[]) => (fluid.fluidColor = new Color(v[0] / 255, v[1] / 255, v[2] / 255)));
f.add(fluid, 'density', 0, 10, 0.1);
f.add(fluid, 'refractionStrength', 0, 3, 0.05);
f.add(fluid, 'fresnelClamp', 0, 1, 0.01);
f.add(fluid, 'specularPower', 1, 1000, 1);
f.add(fluid, 'edgeSoftness', 0, 20, 0.5);
f.add(fluid, 'depthFilter', { Bilateral: FluidDepthFilter.Bilateral, NarrowRange: FluidDepthFilter.NarrowRange });
f.add(fluid, 'thicknessMode', { Accumulated: FluidThicknessMode.Accumulated, Geometric: FluidThicknessMode.Geometric });
f.add(fluid, 'useAnisotropy');
f.add(fluid, 'debug');
f.add(fluid, 'debugFeature', {
Depth: FluidDebug.DepthTexture,
DepthBlurred: FluidDebug.DepthBlurredTexture,
Thickness: FluidDebug.ThicknessTexture,
ThicknessBlurred: FluidDebug.ThicknessBlurredTexture,
Normals: FluidDebug.Normals
});
f.add(sim, 'paused');
f.add(s, 'reset');
f.open();
const depth = gui.addFolder('Depth blur');
depth.add(fluid, 'enableBlurDepth');
depth.add(fluid, 'blurDepthFilterSize', 0, 60, 1);
depth.add(fluid, 'blurDepthNumIterations', 0, 10, 1);
depth.add(fluid, 'blurDepthDepthScale', 0, 30, 0.5);
const thickness = gui.addFolder('Thickness blur');
thickness.add(fluid, 'enableBlurThickness');
thickness.add(fluid, 'blurThicknessFilterSize', 0, 30, 1);
thickness.add(fluid, 'blurThicknessNumIterations', 0, 10, 1);
// Start collapsed so the panel does not cover the scene
gui.close();
}
}
/**
* Minimal particle motion (gravity, damping, box walls, a pillar and soft particle spreading
* on a hash grid). Not a physical fluid solver; it only produces liquid-like particle motion to render.
*/
class BoxSloshSimulation extends ComponentBase {
public paused = false;
private fluid!: FluidRenderer;
private count = 0;
/** xyzw per particle: the layout FluidRenderer expects by default (16-byte stride, position at offset 0) */
private pos!: Float32Array;
private vel!: Float32Array;
private readonly half = 1.6;
private readonly spacing = 0.1;
public init(param?: { fluid: FluidRenderer; count: number }): void {
if (!param) return;
this.fluid = param.fluid;
this.count = param.count;
this.pos = new Float32Array(this.count * 4);
this.vel = new Float32Array(this.count * 3);
this.reset();
}
public reset(): void {
const n = Math.ceil(Math.cbrt(this.count));
for (let i = 0; i < this.count; i++) {
const x = i % n,
y = Math.floor(i / (n * n)),
z = Math.floor(i / n) % n;
this.pos[i * 4 + 0] = -this.half + 0.2 + x * this.spacing + Math.random() * 0.01;
this.pos[i * 4 + 1] = 1.0 + y * this.spacing;
this.pos[i * 4 + 2] = -this.half + 0.2 + z * this.spacing + Math.random() * 0.01;
this.pos[i * 4 + 3] = 1;
this.vel[i * 3 + 0] = 2.0;
this.vel[i * 3 + 1] = 0;
this.vel[i * 3 + 2] = 1.0;
}
this.fluid.setDataBuffer(this.pos);
}
public onUpdate(): void {
if (this.paused || !this.fluid) return;
const dt = 1 / 60;
for (let s = 0; s < 2; s++) this.step(dt / 2);
// The array was modified in place: call setDataBuffer again to upload it
this.fluid.setDataBuffer(this.pos);
}
private step(dt: number): void {
const { pos, vel, count, half } = this;
const h = this.spacing;
const cell = new Map<number, number[]>();
const key = (x: number, y: number, z: number) => ((x + 512) * 1024 + (y + 512)) * 1024 + (z + 512);
for (let i = 0; i < count; i++) {
const k = key(Math.floor(pos[i * 4] / h), Math.floor(pos[i * 4 + 1] / h), Math.floor(pos[i * 4 + 2] / h));
let list = cell.get(k);
if (!list) cell.set(k, (list = []));
list.push(i);
}
for (let i = 0; i < count; i++) {
const px = pos[i * 4],
py = pos[i * 4 + 1],
pz = pos[i * 4 + 2];
const cx = Math.floor(px / h),
cy = Math.floor(py / h),
cz = Math.floor(pz / h);
let fx = 0,
fy = 0,
fz = 0;
for (let dx = -1; dx <= 1; dx++)
for (let dy = -1; dy <= 1; dy++)
for (let dz = -1; dz <= 1; dz++) {
const list = cell.get(key(cx + dx, cy + dy, cz + dz));
if (!list) continue;
for (const j of list) {
if (j === i) continue;
const rx = px - pos[j * 4],
ry = py - pos[j * 4 + 1],
rz = pz - pos[j * 4 + 2];
const d2 = rx * rx + ry * ry + rz * rz;
if (d2 >= h * h || d2 < 1e-10) continue;
const d = Math.sqrt(d2);
const push = ((h - d) / h) * 60;
fx += (rx / d) * push;
fy += (ry / d) * push;
fz += (rz / d) * push;
}
}
vel[i * 3] = (vel[i * 3] + fx * dt) * 0.995;
vel[i * 3 + 1] = (vel[i * 3 + 1] + (fy - 9.8) * dt) * 0.995;
vel[i * 3 + 2] = (vel[i * 3 + 2] + fz * dt) * 0.995;
}
const r = h * 0.5;
for (let i = 0; i < count; i++) {
let x = pos[i * 4] + vel[i * 3] * dt;
let y = pos[i * 4 + 1] + vel[i * 3 + 1] * dt;
let z = pos[i * 4 + 2] + vel[i * 3 + 2] * dt;
if (y < r) {
y = r;
vel[i * 3 + 1] *= -0.3;
}
if (x < -half + r) {
x = -half + r;
vel[i * 3] *= -0.3;
}
if (x > half - r) {
x = half - r;
vel[i * 3] *= -0.3;
}
if (z < -half + r) {
z = -half + r;
vel[i * 3 + 2] *= -0.3;
}
if (z > half - r) {
z = half - r;
vel[i * 3 + 2] *= -0.3;
}
// Pillar at (0.8, 0.4), half extent 0.3
const ox = x - 0.8,
oz = z - 0.4,
e = 0.3 + r;
if (Math.abs(ox) < e && Math.abs(oz) < e && y < 2.5) {
if (e - Math.abs(ox) < e - Math.abs(oz)) {
x = 0.8 + Math.sign(ox) * e;
vel[i * 3] *= -0.3;
} else {
z = 0.4 + Math.sign(oz) * e;
vel[i * 3 + 2] *= -0.3;
}
}
pos[i * 4] = x;
pos[i * 4 + 1] = y;
pos[i * 4 + 2] = z;
}
}
}
type PoolOptions = { width: number; depth: number; wallHeight: number; wallThickness: number; tileSize: number; lanes: number; x: number; y: number; z: number };
/** Pool: a mosaic-tiled floor slab, light aqua walls and a white coping. The inner volume starts at (x, y, z). */
function createPool(scene: Scene3D, ctx: Context3D, o: PoolOptions): Object3D {
const { width: w, depth: d, wallHeight: h, wallThickness: t, x: x0, y: y0, z: z0 } = o;
const pool = new Object3D();
const floorMat = solid(new Color(1, 1, 1), 0.35);
floorMat.baseMap = paintFloor(ctx, o);
pool.addChild(mesh(new BoxGeometry(w, t, d), floorMat, x0 + w / 2, y0 - t / 2, z0 + d / 2));
const wallMat = solid(new Color(0.55, 0.82, 0.88), 0.4);
const copingMat = solid(new Color(0.95, 0.95, 0.92), 0.6);
const ch = Math.max(0.02, t * 0.4);
const walls: [number, number, number, number][] = [
[w + 2 * t, t, x0 + w / 2, z0 - t / 2],
[w + 2 * t, t, x0 + w / 2, z0 + d + t / 2],
[t, d, x0 - t / 2, z0 + d / 2],
[t, d, x0 + w + t / 2, z0 + d / 2]
];
for (const [sx, sz, cx, cz] of walls) {
pool.addChild(mesh(new BoxGeometry(sx, h + t, sz), wallMat, cx, y0 + (h - t) / 2, cz));
pool.addChild(mesh(new BoxGeometry(sx + t * 0.3, ch, sz + t * 0.3), copingMat, cx, y0 + h + ch / 2, cz));
}
scene.addChild(pool);
return pool;
}
/** Mosaic tiles with light grout and dark-blue lane lines. */
function paintFloor(ctx: Context3D, o: PoolOptions): BitmapTexture2D {
const tilesX = Math.round(o.width / o.tileSize);
const tilesZ = Math.round(o.depth / o.tileSize);
const px = Math.max(8, Math.floor(2048 / Math.max(tilesX, tilesZ)));
const canvas = document.createElement('canvas');
canvas.width = tilesX * px;
canvas.height = tilesZ * px;
const g = canvas.getContext('2d')!;
g.fillStyle = 'rgb(225, 242, 246)';
g.fillRect(0, 0, canvas.width, canvas.height);
const grout = Math.max(1, Math.round(px * 0.08));
for (let i = 0; i < tilesX; i++) {
for (let j = 0; j < tilesZ; j++) {
const k = 0.94 + Math.random() * 0.1;
g.fillStyle = `rgb(${Math.round(110 * k)}, ${Math.round(200 * k)}, ${Math.round(218 * k)})`;
g.fillRect(i * px + grout, j * px + grout, px - 2 * grout, px - 2 * grout);
}
}
g.fillStyle = 'rgb(18, 42, 105)';
const margin = 2 * px;
for (let l = 1; l <= o.lanes; l++) {
const zc = Math.round((l * tilesZ) / (o.lanes + 1)) * px;
g.fillRect(margin, zc - px / 2, canvas.width - 2 * margin, px);
}
const tex = new BitmapTexture2D(true, ctx, 'srgb');
tex.source = canvas;
return tex;
}
function solid(color: Color, roughness: number): LitMaterial {
const m = new LitMaterial();
m.baseColor = color;
m.roughness = roughness;
m.metallic = 0;
return m;
}
function mesh(geometry: GeometryBase, material: LitMaterial, x: number, y: number, z: number, castShadow = false): Object3D {
const obj = new Object3D();
obj.x = x;
obj.y = y;
obj.z = z;
const mr = obj.addComponent(MeshRenderer);
mr.geometry = geometry;
mr.material = material;
mr.receiveShadow = true;
mr.castShadow = castShadow;
return obj;
}
new Sample_Fluid().run();See the API documentation of FluidRenderer for more properties.

