以观书法
108.85M · 2026-02-05
在浏览器沙盒这种资源受限的微型世界里,如何让 40MB 的深度学习模型与 60FPS 的流体物理引擎和谐共存?今天,我们将通过 Rust 与 WebAssembly,将一张静止的图片实时解构为具备物理灵魂的赛博粒子,开启一场视听一体的交互盛宴。
想象一下:你点击屏幕中一只晶莹的玻璃杯,它不再只是一堆像素,而是瞬间被 AI 识别,随后伴随着清脆的碎裂声,化作上千颗闪烁的粒子随重力倾泻而下。
这背后的核心理念是物质解构——利用 MobileSAM 赋予机器视觉之眼,利用 Rust WASM 构建物理之魂。自此,图像不再是扁平的平面,而是可触摸、可粉碎、可聆听的实体物质。
觉得这波“物质解构”的视觉冲击力如何? 别急,这套打通了 AI 视觉感知与高性能流体动力学的实战方案,其完整的 Rust 核心代码、MobileSAM 模型权重及环境配置文档均已为你打包就绪。跟随文末的 GitHub 链接,你也能在浏览器中亲手开启这场视听一体的交互盛宴。
为了实现这种极致的实时反馈,我们构建了一套分层协作的架构:
系统流转逻辑:
用户点击 (UI 坐标)
↓
【视觉之眼】MobileSAM 毫秒级推理 → 提取像素级掩码 (Mask)
↓
【物质之魂】RGB→HSL 属性映射 → 赋予密度 / 音色 / 弹性
↓
【动力之源】WASM 粒子引擎 → PBD 动力学算法解算
↓
【感官中枢】WebGL 渲染 + Web Audio 合成 → 声画共振
MobileSAM 的精妙之处在于将昂贵的图像编码器(Image Encoder)与轻量级的掩码解码器(Mask Decoder)进行剥离。我们在图像加载时预先计算 Embedding,用户点击时仅触发毫秒级的 Decoder 推理,从而瞬间捕获物体的精确轮廓。
如何让 AI 区分“金属”与“玻璃”?我们通过 RGB 到 HSL 的色彩空间转换,利用亮度(Lightness)和色相(Hue)建立启发式推断模型,动态赋予粒子不同的物理特征。
在浏览器中模拟数千个粒子的物理碰撞时,JavaScript 的垃圾回收(GC)机制往往是掉帧的罪魁祸首。Rust 的内存确定性确保了 PBD 算法能以极致性能运行,让物理步进(Step)与屏幕刷新完美同步。
利用 Web Audio API 实现了从物理参数到声学信号的实时映射,通过程序化合成(Procedural Synthesis)让每一颗粒子都拥有独特的声纹。系统摒弃预录采样,转而根据材质属性动态调制振荡器,并由粒子碰撞动量驱动增益。通过物理线程的高精度时间戳调度,实现与 WebGL 的零延迟同步,确保声画严丝合缝。
在模型进入浏览器前,需将其转换为 Candle 友好的 safetensors 格式。相比传统的 .pth,它支持零拷贝(Zero-copy)加载,具备更高的安全性和加载速度。
# convert/model.py: 将 PyTorch 权重转换为 Safetensors
def download_and_convert():
model_url = (
"https://github.com/ChaoningZhang/MobileSAM/raw/master/weights/mobile_sam.pt"
)
pt_path = "./model/mobile_sam.pt"
output_path = "./model/mobile_sam.safetensors"
if not os.path.exists("./model"):
os.makedirs("./model")
# 1. 下载官方权重
if not os.path.exists(pt_path):
print(f" 正在从官方 GitHub 下载 MobileSAM 权重 (约 40MB)...")
response = requests.get(model_url, stream=True)
with open(pt_path, "wb") as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
print(" 下载完成")
# 2. 转换为 Safetensors
print(f" 正在转换为 Safetensors 格式...")
# map_location='cpu' 确保在没有 GPU 的机器上也能转换
checkpoint = torch.load(pt_path, map_location="cpu")
# 清理并保存
# MobileSAM 的权重字典通常在 'state_dict' 键下,或者直接是字典
state_dict = checkpoint.get("state_dict", checkpoint)
save_file(state_dict, output_path)
print(f" 转换成功!文件已保存至: {output_path}")
利用 Candle 框架,在 Rust 中驱动 MobileSAM。这里的挑战在于处理不同版本模型权重的 Key 名映射。
// inference_engine.rs: 执行 Mask 推理
pub fn get_mask_at(&mut self, x_norm: f32, y_norm: f32) -> Result<Vec<u8>> {
let embedding = self.current_image_embedding.as_ref().ok_or_else(|| {
candle::Error::Msg("No image embedding found. Did you call set_image?".to_string())
})?;
// 输出分辨率设为 256, 256,这要求输入点也按比例缩放到 256 空间内
let (w, h) = self.img_dims;
let x_sam = (x_norm * (w as f32 / 4.0)) as f64;
let y_sam = (y_norm * (h as f32 / 4.0)) as f64;
let points = &[(x_sam, y_sam, true)];
// 运行轻量解码器,输出尺寸设为 256,与 FluidEngine 的 1/4 (256) 假设对齐
let (low_res_mask, _iou) = self.model.forward_for_embeddings(
embedding, 256, // original_h
256, // original_w
points, false, // multimask_output
)?;
// 后处理:将 Tensor 转换为一维的字节数组 (0/1 或 0/255)
self.post_process_mask(low_res_mask)
}
当 MobileSAM 定位出物体像素后,FluidEngine 负责在掩码区域内动态注入粒子流。
// fluid_engine.rs: 基于 Mask 的粒子注入
pub fn inject_material(
&mut self,
mask: &[u8],
img_w: u32,
img_h: u32,
scaled_w: u32,
scaled_h: u32,
offset_x: f32,
offset_y: f32,
unit_scale_x: f32,
unit_scale_y: f32,
material: MaterialProperties,
) {
self.img_dims = (img_w, img_h);
self.scaled_dims = (scaled_w, scaled_h);
self.current_material = material.clone();
let mut rng = rand::thread_rng();
use rand::Rng;
// 1. 预算与其管理
let max_particles = 12000;
let target_per_click = 4000; // 这里的目标是提升每个大物体的填充感
// 2. 统计 Mask 内容
let active_pixels = mask.iter().filter(|&&v| v > 0).count();
if active_pixels == 0 {
return;
}
// 3. 计算注入概率(确保全域均匀分布)
let spawn_prob = (target_per_click as f32 / active_pixels as f32).min(1.0);
for (idx, &is_selected) in mask.iter().enumerate() {
if is_selected > 0 {
// 随机抽样决定是否在此像点生成
if !rng.gen_bool(spawn_prob as f64) {
continue;
}
let mask_x = (idx as u32 % 256) as f32;
let mask_y = (idx as u32 / 256) as f32;
// 核心修复:基于 4x4 网格的子像素随机抖动,增加边缘平滑和填充感
let jitter_x = rng.gen_range(0.0..4.0);
let jitter_y = rng.gen_range(0.0..4.0);
let x = offset_x + (mask_x * 4.0 + jitter_x) * unit_scale_x;
let y = offset_y + (mask_y * 4.0 + jitter_y) * unit_scale_y;
// 基于色相的颜色扰动
let h_jitter = rng.gen_range(-20.0..20.0);
let final_h = (material.hue + h_jitter + 360.0) % 360.0;
let (r, g, b) =
hsl_to_rgb(final_h, rng.gen_range(0.7..1.0), rng.gen_range(0.4..0.8));
let p = Particle {
x,
y,
vx: rng.gen_range(-4.0..4.0),
vy: rng.gen_range(-4.0..4.0),
life: 1.0,
decay: rng.gen_range(0.001..0.005), // 更持久的物质
mass: rng.gen_range(0.8..1.2),
color: (r, g, b),
};
// 4. 循环缓冲区逻辑:如果满了,覆盖最老的粒子
if self.particles.len() < max_particles {
self.particles.push(p);
} else {
self.particles[self.injection_ptr] = p;
self.injection_ptr = (self.injection_ptr + 1) % max_particles;
}
}
}
}
为确保重度计算不阻塞 UI 渲染,我设计了 AI 推理与物理模拟的双线程架构。
AI 推理线程:
// ai-worker.js: AI 推理专用线程
self.onmessage = async (e) => {
const { type, data } = e.data;
try {
switch (type) {
case "INIT":
await init();
app = new InferenceApp(data.modelData);
self.postMessage({ type: "READY" });
break;
case "SET_IMAGE":
if (app) {
await app.set_image(new Uint8Array(data.imageBytes));
self.postMessage({ type: "IMAGE_READY" });
}
break;
case "INTERACT":
if (app) {
const { x, y, bounds } = data;
const result = await app.get_mask_at(x, y);
// 确保是 Uint8Array 以便进行 Transferable 传输
const mask = new Uint8Array(result.mask);
self.postMessage(
{
type: "MASK_READY",
mask,
material: result.material,
scaled_w: result.scaled_w,
scaled_h: result.scaled_h,
bounds,
},
[mask.buffer],
);
}
break;
}
} catch (err) {
console.error("AIWorker Error:", err);
self.postMessage({ type: "ERROR", error: err.message });
}
};
物理模拟线程:
// phys-worker.js: 物理模拟专用线程
self.onmessage = async (e) => {
const { type, data } = e.data;
try {
switch (type) {
case "INIT":
await init();
const { width, height } = data;
app = new PhysicsApp(width, height);
self.postMessage({ type: "READY" });
break;
case "RESIZE":
if (app) app.resize(data.width, data.height);
break;
case "UPDATE_PARAMS":
if (app) app.update_physics_params(data.viscosity, data.density);
break;
case "INJECT":
if (app) {
const {
mask,
imgW,
imgH,
offset_x,
offset_y,
display_w,
display_h,
scaled_w,
scaled_h,
material,
} = data;
// 极致精度:独立计算 X 和 Y 的缩放倍率
const unitScaleX = display_w / scaled_w;
const unitScaleY = display_h / scaled_h;
app.inject(
new Uint8Array(mask),
imgW,
imgH,
scaled_w,
scaled_h,
offset_x,
offset_y,
unitScaleX,
unitScaleY,
material,
);
}
break;
case "TRIGGER_COLLAPSE":
if (app) app.trigger_collapse(data.avgAudio);
break;
case "RENDER":
if (app) {
const { avgAudio, mouse_x, mouse_y } = data;
const particles = app.render_frame(avgAudio, mouse_x, mouse_y);
self.postMessage({ type: "TICK", particles }, [particles.buffer]);
}
break;
}
} catch (err) {
console.error("PhysWorker Error:", err);
self.postMessage({ type: "ERROR", error: err.message });
}
};
主线程的Worker编排:
// 代码片段来自 main.js 的Worker编排
// 1. 初始化两个 Worker
aiWorker = new Worker("ai-worker.js", { type: "module" });
physWorker = new Worker("phys-worker.js", { type: "module" });
aiWorker.onmessage = (e) => {
const { type, mask, bounds, error } = e.data;
if (type === "READY") {
aiReady = true;
checkReady();
} else if (type === "IMAGE_READY") {
loading.style.display = "none";
const statusText = document.getElementById("loader-text");
if (statusText) statusText.innerText = "SYSTEM READY";
} else if (type === "MASK_READY") {
const { mask, material, bounds, active_pixels } = e.data;
if (!material) {
console.warn("Main: Received MASK_READY but material is undefined.");
return;
}
// 1. 更新材质 UI 和音效
const label = document.getElementById("material-label");
if (label)
label.innerText = `MATERIAL: ${material.label?.toUpperCase() || "UNKNOWN"}`;
if (material.label) playMaterialSound(material, active_pixels);
// 2. 更新 Phys Worker 物理参数
physWorker.postMessage({
type: "UPDATE_PARAMS",
data: {
viscosity: material.viscosity || 0.1,
density: material.density || 1.0,
},
});
// 3. 注入粒子
physWorker.postMessage(
{
type: "INJECT",
data: {
mask,
imgW: currentImageDims.w,
imgH: currentImageDims.h,
offset_x: bounds.left,
offset_y: bounds.top,
display_w: bounds.width,
display_h: bounds.height,
scaled_w: e.data.scaled_w,
scaled_h: e.data.scaled_h,
material,
},
},
[mask.buffer],
);
} else if (type === "ERROR") {
console.error("AIWorker Error:", error);
alert("AI Error: " + error);
}
};
physWorker.onmessage = (e) => {
const { type, particles, error } = e.data;
if (type === "READY") {
physReady = true;
checkReady();
} else if (type === "TICK") {
currentParticles = particles;
isPhysBusy = false;
} else if (type === "ERROR") {
console.error("PhysWorker Error:", error);
}
};
权重命名的陷阱:
在加载模型时,可能会遇到类似 mask_decoder.output_upscaling.2.weight 缺失等报错。
这是由于 MobileSAM 官方实现与 Candle 库层命名规范不一致导致的。需要手动执行权重映射,而非依赖黑盒自动加载。
Web Audio 的时序争战:
linearRampToValueAtTime 极易因 time 略小于 currentTime 抛出异常。
在自动化调度前,务必先调用 cancelScheduledValues 并预留 10-20ms 的 Buffer。
渲染性能上限:
移动端 WebGL 处理 5000+ 粒子会面临压力。
通过在 Fragment Shader 中利用 gl_PointCoord 直接绘制径向发光球体,规避了复杂的几何体构建,大幅提升了渲染效率。
Rust + WASM 正在重塑 Web 应用的边界。它让浏览器从单纯的文档容器,进化为能够承载深度学习与复杂物理模拟的**“高性能边缘计算终端”**。
未来,还可以引入多模态感知:让环境噪音实时驱动粒子的震荡频率,或通过 WebXR 将解构的物质流体投影到真实世界中。
如果你也对 Rust + AI 感兴趣,欢迎关注、点赞、评论、分享!我们下期再见,继续探索 Rust 与智能系统的技术深水区。
项目地址:rust-zhixingshe-examples/wasm/flow-matter-ai