Skip to content

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:

ts
import { Engine3D } from '@orillusion/core';

const engine = await Engine3D.init({
    setting: { render: { fluid: true } }
});

For every enabled FluidRenderer in the scene, FluidPass:

  1. draws the particles as sphere impostors into a linear depth map, and with additive blending into a thickness map;
  2. smooths the depth map so neighboring spheres merge into one continuous surface, and blurs the thickness map;
  3. 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:

ts
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 ​

ts
fluid.setDataBuffer(data: Float32Array, count?: number, layout?: FluidDataLayout);
  • data is uploaded to the GPU on the next frame. After modifying the array in place, call setDataBuffer() again to re-upload it, usually once at the end of each simulation step;
  • count is the number of particles; by default as many as data holds.
ts
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:

ts
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:

ts
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:

ts
// 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 ​

PropertyDefaultDescription
particleSize0.1Particle diameter (world units)
fluidColor(0.085, 0.64, 0.77)Absorption color; light passing through the fluid is tinted toward it
density2Absorption strength; higher is more opaque
refractionStrength1Scale of the refraction offset: 1 follows the refracted ray to the scene behind, 0 disables refraction
fresnelClamp1Upper bound of the fresnel reflection weight, 0 ~ 1
specularPower250Specular exponent; higher gives a smaller, sharper highlight
edgeSoftness0Fades the fluid into the background where it is thinner than this many particles, softening free edges; 0 disables it
minimumThickness0Pixels with less thickness show the background; with useFixedThickness, the thickness used everywhere
useFixedThicknessfalseSkip the thickness pass and use minimumThickness as a constant thickness
dirLightnullLight direction (world space) for the highlight; null uses the scene's first DirectLight
environmentMapundefinedCube 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:

ValueDescription
FluidThicknessMode.AccumulatedDefault. 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.GeometricDistance 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
ts
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.

PropertyDefaultDescription
enableBlurDepthtrueWhether to smooth the depth map
depthFilterBilateralFilter used for smoothing, see below
blurDepthFilterSize7Smoothing radius, relative to the projected particle size
blurDepthNumIterations3Iterations (each is one horizontal and one vertical pass)
blurDepthMaxFilterSize100Upper bound of the smoothing radius in pixels
blurDepthDepthScale10Depth range, in particle radii, across which samples are still blended; larger merges more
blurDepthSizeDivisor1Smoothing runs at the depth map size divided by this
enableBlurThicknesstrueWhether to blur the thickness map
blurThicknessFilterSize5Thickness blur radius (pixels)
blurThicknessNumIterations1Thickness blur iterations
blurThicknessSizeDivisor1Thickness blur runs at the thickness map size divided by this
depthMapSize / thicknessMapSizenullWidth of the depth / thickness map in pixels; the height follows the screen aspect; null is the screen size

depthFilter options:

ValueDescription
FluidDepthFilter.BilateralSeparable bilateral filter: a spatial gaussian weighted by depth similarity
FluidDepthFilter.NarrowRangeNarrow-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:

ts
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.

PropertyDefaultDescription
useAnisotropyfalseWhether to enable anisotropy
anisotropyRadiusnullNeighbor search radius (world units); null uses 1.5 × particleSize
anisotropyMaxRatio4Largest ratio between the longest and the shortest ellipsoid axis
anisotropyMinNeighbors10Particles with fewer neighbors stay spheres (spray, droplets)
anisotropySmoothing0.5How 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:

FluidDebugDescription
DepthTextureLinear depth of the particle spheres
DepthBlurredTextureDepth after smoothing
ThicknessTextureAccumulated particle thickness
ThicknessBlurredTextureThickness after blurring
NormalsView-space normals reconstructed from the smoothed depth
ts
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.

WebGPU is not supported in your browser
Please upgrade to latest Chrome/Edge

<
ts
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.

Released under the MIT License