流体渲染 - FluidRenderer
FluidRenderer 把一组粒子渲染成连续的液体表面(屏幕空间流体渲染,Screen-Space Fluid Rendering)。它适合配合 SPH、FLIP 等粒子流体模拟,用于倾倒、晃动、飞溅的液体。
TIP
FluidRenderer 只负责渲染,不做流体模拟。粒子位置由你提供:可以来自 CPU 上的模拟,也可以直接使用 Compute Shader 写入的 GPU 缓冲。
开启流体渲染
需要在初始化引擎时打开 setting.render.fluid,渲染管线才会加入 FluidPass:
import { Engine3D } from '@orillusion/core';
const engine = await Engine3D.init({
setting: { render: { fluid: true } }
});FluidPass 对场景中每个启用的 FluidRenderer 依次执行:
- 把粒子绘制为球形 impostor,得到线性深度图,并以叠加混合得到厚度图;
- 平滑深度图,让相邻的球体融合成一张连续表面;同时模糊厚度图;
- 由平滑后的深度重建法线,完成折射、吸收、环境反射与高光着色。
流体在不透明物体之后、透明物体之前绘制,因此会被不透明物体正确遮挡,透明物体也能叠加在它上面。
TIP
流体只写入颜色与深度,不写入 G-Buffer,所以像素拾取无法命中流体。需要与液面交互时,可以用射线与一个近似的水平面求交。
基本用法
给一个 Object3D 添加 FluidRenderer,设置粒子直径,再传入粒子数据:
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
fluid.setDataBuffer(data: Float32Array, count?: number, layout?: FluidDataLayout);data会在下一帧上传到 GPU。原地修改数组后需要再次调用setDataBuffer()才会重新上传,通常在每帧模拟结束后调用一次;count为粒子数量,默认按data能容纳的数量计算。
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 上,不需要回读:
fluid.setDataBufferGPU(buffer: GPUBufferBase, count: number, layout?: FluidDataLayout);缓冲需要带 STORAGE 用途(如 StorageGPUBuffer、ComputeGPUBuffer),FluidRenderer 每帧都会直接读取,因此 Compute Shader 的更新无需再次调用即可生效:
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 描述布局,无需另外拷贝一份位置数据:
// 粒子结构:velocity(vec4f) + position(vec4f),共 32 字节
fluid.setDataBufferGPU(particleBuffer, count, {
stride: 32, // 相邻粒子之间的字节数
positionOffset: 16 // 位置在粒子内的字节偏移
});stride 与 positionOffset 都必须是 4 的倍数,且 xyz 三个 float 必须落在 stride 之内,否则会抛出异常。
外观
| 属性 | 默认值 | 说明 |
|---|---|---|
particleSize | 0.1 | 粒子直径(世界单位) |
fluidColor | (0.085, 0.64, 0.77) | 吸收颜色,穿过流体的光被染向该颜色 |
density | 2 | 吸收强度,越大越不透明 |
refractionStrength | 1 | 折射偏移的缩放,1 沿折射光线取背后的场景,0 关闭折射 |
fresnelClamp | 1 | 菲涅尔反射权重上限 0 ~ 1 |
specularPower | 250 | 高光指数,越大高光越小越锐利 |
edgeSoftness | 0 | 厚度不足该数量个粒子的地方逐渐融入背景,用于柔化自由边缘;0 关闭 |
minimumThickness | 0 | 厚度低于该值的像素显示背景;开启 useFixedThickness 时作为全局固定厚度 |
useFixedThickness | false | 跳过厚度计算,以 minimumThickness 作为固定厚度 |
dirLight | null | 高光使用的光线方向(世界空间);null 时使用场景中第一个 DirectLight |
environmentMap | undefined | 流体反射的立方体贴图;undefined 使用场景的 envMap,null 关闭反射 |
流体的高光与反射使用场景中的平行光与环境贴图,不需要额外的光照设置。
厚度
吸收效果取决于视线穿过流体的厚度,thicknessMode 决定厚度的计算方式:
| 取值 | 说明 |
|---|---|
FluidThicknessMode.Accumulated | 默认。把视线上每个粒子的贡献累加起来(每个粒子在中心处贡献 particleThicknessAlpha,默认 0.05)。厚度跟随粒子密度,粒子分布不均匀时会出现浓淡不一的斑块 |
FluidThicknessMode.Geometric | 取视线上流体近表面与远表面之间的距离(世界单位),静止的水体厚度均匀;此时 density 表示每世界单位的吸收。适合 FLIP 等粒子间距不均匀的模拟 |
import { FluidThicknessMode } from '@orillusion/core';
fluid.thicknessMode = FluidThicknessMode.Geometric;
fluid.density = 0.15;表面平滑
粒子本身是一个个球体,需要平滑深度图才能融合成光滑的液面。平滑范围过小会看到球体的颗粒感,过大则会抹掉细节并让分离的液体粘连在一起。
| 属性 | 默认值 | 说明 |
|---|---|---|
enableBlurDepth | true | 是否平滑深度图 |
depthFilter | Bilateral | 平滑使用的滤波器,见下表 |
blurDepthFilterSize | 7 | 平滑半径,相对于粒子投影到屏幕上的大小 |
blurDepthNumIterations | 3 | 迭代次数(每次为一次水平 + 一次垂直滤波) |
blurDepthMaxFilterSize | 100 | 平滑半径的像素上限 |
blurDepthDepthScale | 10 | 以粒子半径计的深度范围,范围内的样本才会混合;越大融合越多 |
blurDepthSizeDivisor | 1 | 在深度图尺寸除以该值的分辨率上进行平滑 |
enableBlurThickness | true | 是否模糊厚度图 |
blurThicknessFilterSize | 5 | 厚度模糊半径(像素) |
blurThicknessNumIterations | 1 | 厚度模糊迭代次数 |
blurThicknessSizeDivisor | 1 | 在厚度图尺寸除以该值的分辨率上进行模糊 |
depthMapSize / thicknessMapSize | null | 深度图 / 厚度图的宽度(像素),高度按屏幕宽高比计算;null 为屏幕尺寸 |
depthFilter 可选:
| 取值 | 说明 |
|---|---|
FluidDepthFilter.Bilateral | 可分离的双边滤波:空间高斯权重乘以深度相似度权重 |
FluidDepthFilter.NarrowRange | 窄范围滤波:只平均跟随表面的一段深度范围内的样本,在较大半径下也能保持轮廓与前后分离的液层更清晰 |
一般经验是让平滑范围覆盖约一个粒子直径,例如:
import { FluidDepthFilter } from '@orillusion/core';
fluid.depthFilter = FluidDepthFilter.NarrowRange;
fluid.blurDepthFilterSize = 15;
fluid.blurDepthNumIterations = 6;各向异性
开启 useAnisotropy 后,每个粒子会根据周围邻居的分布被拉伸成椭球:平坦区域保持平坦,薄层保持薄,液面明显更平滑。它会在 GPU 上每帧额外做一次邻居搜索。
| 属性 | 默认值 | 说明 |
|---|---|---|
useAnisotropy | false | 是否开启各向异性 |
anisotropyRadius | null | 邻居搜索半径(世界单位),null 为 1.5 × particleSize |
anisotropyMaxRatio | 4 | 椭球最长轴与最短轴之比的上限 |
anisotropyMinNeighbors | 10 | 邻居少于该数量的粒子保持球形(水花、液滴) |
anisotropySmoothing | 0.5 | 粒子向邻居加权中心移动的比例 0 ~ 1,用于平滑表面 |
调试
开启 debug 后,画面会显示 debugFeature 选中的中间结果,便于调整平滑与厚度参数:
FluidDebug | 说明 |
|---|---|
DepthTexture | 粒子球体的线性深度 |
DepthBlurredTexture | 平滑后的深度 |
ThicknessTexture | 累加的粒子厚度 |
ThicknessBlurredTexture | 模糊后的厚度 |
Normals | 由平滑深度重建的视图空间法线 |
import { FluidDebug } from '@orillusion/core';
fluid.debug = true;
fluid.debugFeature = FluidDebug.Normals;示例
下面的示例在 CPU 上做了一个极简的粒子运动(重力、阻尼、池壁与粒子间的排斥,并非物理正确的流体求解器),每帧通过 setDataBuffer() 上传。一块粒子落入水池后来回晃动,池中的立柱验证了不透明物体对流体的遮挡。可以在面板中隐藏水池观察流体的自由边缘,或打开 debug 查看各个中间结果。
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 文档。

