Skip to content

流体渲染 - FluidRenderer ​

FluidRenderer 把一组粒子渲染成连续的液体表面(屏幕空间流体渲染,Screen-Space Fluid Rendering)。它适合配合 SPH、FLIP 等粒子流体模拟,用于倾倒、晃动、飞溅的液体。

TIP

FluidRenderer 只负责渲染,不做流体模拟。粒子位置由你提供:可以来自 CPU 上的模拟,也可以直接使用 Compute Shader 写入的 GPU 缓冲。

开启流体渲染 ​

需要在初始化引擎时打开 setting.render.fluid,渲染管线才会加入 FluidPass:

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

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

FluidPass 对场景中每个启用的 FluidRenderer 依次执行:

  1. 把粒子绘制为球形 impostor,得到线性深度图,并以叠加混合得到厚度图;
  2. 平滑深度图,让相邻的球体融合成一张连续表面;同时模糊厚度图;
  3. 由平滑后的深度重建法线,完成折射、吸收、环境反射与高光着色。

流体在不透明物体之后、透明物体之前绘制,因此会被不透明物体正确遮挡,透明物体也能叠加在它上面。

TIP

流体只写入颜色与深度,不写入 G-Buffer,所以像素拾取无法命中流体。需要与液面交互时,可以用射线与一个近似的水平面求交。

基本用法 ​

给一个 Object3D 添加 FluidRenderer,设置粒子直径,再传入粒子数据:

ts
import { Object3D, FluidRenderer } from '@orillusion/core';

const fluidObj = new Object3D();
const fluid = fluidObj.addComponent(FluidRenderer);
// 粒子直径(世界单位)
fluid.particleSize = 0.16;
scene.addChild(fluidObj);

// 每个粒子 4 个 float:x, y, z, 以及一个未使用的分量
const count = 6000;
const positions = new Float32Array(count * 4);
// ... 写入粒子位置
fluid.setDataBuffer(positions);

粒子位置位于所属物体的本地空间(物体在原点且未缩放时即为世界坐标),particleSize 以世界单位计。

粒子数据 ​

CPU 数据:setDataBuffer ​

ts
fluid.setDataBuffer(data: Float32Array, count?: number, layout?: FluidDataLayout);
  • data 会在下一帧上传到 GPU。原地修改数组后需要再次调用 setDataBuffer() 才会重新上传,通常在每帧模拟结束后调用一次;
  • count 为粒子数量,默认按 data 能容纳的数量计算。
ts
class MySimulation extends ComponentBase {
    public fluid: FluidRenderer;
    private positions: Float32Array;

    public onUpdate(): void {
        this.step(1 / 60);                      // 更新 this.positions
        this.fluid.setDataBuffer(this.positions); // 重新上传
    }
}

GPU 数据:setDataBufferGPU ​

粒子由 Compute Shader 模拟时,可以把它写入的存储缓冲直接交给 FluidRenderer,位置数据全程留在 GPU 上,不需要回读:

ts
fluid.setDataBufferGPU(buffer: GPUBufferBase, count: number, layout?: FluidDataLayout);

缓冲需要带 STORAGE 用途(如 StorageGPUBuffer、ComputeGPUBuffer),FluidRenderer 每帧都会直接读取,因此 Compute Shader 的更新无需再次调用即可生效:

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; }
        // ... 更新 positions[i].xyz
    }
`;

class GPUSimulation extends ComponentBase {
    private count = 100000;
    private positions: StorageGPUBuffer;
    private shader: ComputeShader;

    public start(): void {
        // 大小以 float 为单位:每个粒子一个 vec4
        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 可以随时调小,只绘制缓冲中的前若干个粒子。

数据布局 ​

默认每个粒子占 16 字节(一个 vec4f),位置 xyz 位于开头。若粒子结构中还有速度、密度等字段,可以用 FluidDataLayout 描述布局,无需另外拷贝一份位置数据:

ts
// 粒子结构:velocity(vec4f) + position(vec4f),共 32 字节
fluid.setDataBufferGPU(particleBuffer, count, {
    stride: 32,         // 相邻粒子之间的字节数
    positionOffset: 16  // 位置在粒子内的字节偏移
});

stride 与 positionOffset 都必须是 4 的倍数,且 xyz 三个 float 必须落在 stride 之内,否则会抛出异常。

外观 ​

属性默认值说明
particleSize0.1粒子直径(世界单位)
fluidColor(0.085, 0.64, 0.77)吸收颜色,穿过流体的光被染向该颜色
density2吸收强度,越大越不透明
refractionStrength1折射偏移的缩放,1 沿折射光线取背后的场景,0 关闭折射
fresnelClamp1菲涅尔反射权重上限 0 ~ 1
specularPower250高光指数,越大高光越小越锐利
edgeSoftness0厚度不足该数量个粒子的地方逐渐融入背景,用于柔化自由边缘;0 关闭
minimumThickness0厚度低于该值的像素显示背景;开启 useFixedThickness 时作为全局固定厚度
useFixedThicknessfalse跳过厚度计算,以 minimumThickness 作为固定厚度
dirLightnull高光使用的光线方向(世界空间);null 时使用场景中第一个 DirectLight
environmentMapundefined流体反射的立方体贴图;undefined 使用场景的 envMap,null 关闭反射

流体的高光与反射使用场景中的平行光与环境贴图,不需要额外的光照设置。

厚度 ​

吸收效果取决于视线穿过流体的厚度,thicknessMode 决定厚度的计算方式:

取值说明
FluidThicknessMode.Accumulated默认。把视线上每个粒子的贡献累加起来(每个粒子在中心处贡献 particleThicknessAlpha,默认 0.05)。厚度跟随粒子密度,粒子分布不均匀时会出现浓淡不一的斑块
FluidThicknessMode.Geometric取视线上流体近表面与远表面之间的距离(世界单位),静止的水体厚度均匀;此时 density 表示每世界单位的吸收。适合 FLIP 等粒子间距不均匀的模拟
ts
import { FluidThicknessMode } from '@orillusion/core';

fluid.thicknessMode = FluidThicknessMode.Geometric;
fluid.density = 0.15;

表面平滑 ​

粒子本身是一个个球体,需要平滑深度图才能融合成光滑的液面。平滑范围过小会看到球体的颗粒感,过大则会抹掉细节并让分离的液体粘连在一起。

属性默认值说明
enableBlurDepthtrue是否平滑深度图
depthFilterBilateral平滑使用的滤波器,见下表
blurDepthFilterSize7平滑半径,相对于粒子投影到屏幕上的大小
blurDepthNumIterations3迭代次数(每次为一次水平 + 一次垂直滤波)
blurDepthMaxFilterSize100平滑半径的像素上限
blurDepthDepthScale10以粒子半径计的深度范围,范围内的样本才会混合;越大融合越多
blurDepthSizeDivisor1在深度图尺寸除以该值的分辨率上进行平滑
enableBlurThicknesstrue是否模糊厚度图
blurThicknessFilterSize5厚度模糊半径(像素)
blurThicknessNumIterations1厚度模糊迭代次数
blurThicknessSizeDivisor1在厚度图尺寸除以该值的分辨率上进行模糊
depthMapSize / thicknessMapSizenull深度图 / 厚度图的宽度(像素),高度按屏幕宽高比计算;null 为屏幕尺寸

depthFilter 可选:

取值说明
FluidDepthFilter.Bilateral可分离的双边滤波:空间高斯权重乘以深度相似度权重
FluidDepthFilter.NarrowRange窄范围滤波:只平均跟随表面的一段深度范围内的样本,在较大半径下也能保持轮廓与前后分离的液层更清晰

一般经验是让平滑范围覆盖约一个粒子直径,例如:

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

fluid.depthFilter = FluidDepthFilter.NarrowRange;
fluid.blurDepthFilterSize = 15;
fluid.blurDepthNumIterations = 6;

各向异性 ​

开启 useAnisotropy 后,每个粒子会根据周围邻居的分布被拉伸成椭球:平坦区域保持平坦,薄层保持薄,液面明显更平滑。它会在 GPU 上每帧额外做一次邻居搜索。

属性默认值说明
useAnisotropyfalse是否开启各向异性
anisotropyRadiusnull邻居搜索半径(世界单位),null 为 1.5 × particleSize
anisotropyMaxRatio4椭球最长轴与最短轴之比的上限
anisotropyMinNeighbors10邻居少于该数量的粒子保持球形(水花、液滴)
anisotropySmoothing0.5粒子向邻居加权中心移动的比例 0 ~ 1,用于平滑表面

调试 ​

开启 debug 后,画面会显示 debugFeature 选中的中间结果,便于调整平滑与厚度参数:

FluidDebug说明
DepthTexture粒子球体的线性深度
DepthBlurredTexture平滑后的深度
ThicknessTexture累加的粒子厚度
ThicknessBlurredTexture模糊后的厚度
Normals由平滑深度重建的视图空间法线
ts
import { FluidDebug } from '@orillusion/core';

fluid.debug = true;
fluid.debugFeature = FluidDebug.Normals;

示例 ​

下面的示例在 CPU 上做了一个极简的粒子运动(重力、阻尼、池壁与粒子间的排斥,并非物理正确的流体求解器),每帧通过 setDataBuffer() 上传。一块粒子落入水池后来回晃动,池中的立柱验证了不透明物体对流体的遮挡。可以在面板中隐藏水池观察流体的自由边缘,或打开 debug 查看各个中间结果。

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();

更多属性请参考 FluidRenderer 的 API 文档。