简介:这是一份将 Rust 系统编程、WebAssembly 浏览器执行、神经网络与遗传算法结合的完整项目代码包,适合具备一定 Rust 基础、希望在 Web 端实践智能体进化模拟的开发者,也可用于课程设计与技术探索。项目采用 workspace 多模块结构:osmos-core 提供底层核心逻辑,osmos-nn 实现神经网络,osmos-sim 负责遗传算法模拟环境(覆盖选择、交叉、变异等机制),osmos-wasm 生成 WebAssembly 绑定,osmos-web 则是基于 Vite + React 的前端展示层;读者可以借此理解从 Rust 算法实现到浏览器交互的完整链路。压缩包共 46 个文件,以 25 个 Rust 源文件和 5 个 Cargo.toml 为主,辅以 tsx/ts 前端代码、HTML/CSS 页面与 wasm-pack 构建脚本,整体体积仅 389KB,目录结构清晰,便于按模块对照阅读和二次开发。项目内还包含构建脚本与前端页面,能帮助快速启动演示环境,直观观察遗传算法驱动的神经网络在模拟场景中的表现。目前已有 100 人学习下载,对于想同时掌握 Rust/WASM 与进化算法落地的开发者,是一份紧凑而完整的实战参考。
1. 当遗传算法和神经网络在Rust里跑起来,浏览器就成了进化试验场
一个名为 osmos-master 的项目把“细胞吞噬模拟 + 神经网络控制 + 遗传算法进化”这段完整链路用 Rust 重写,再通过 WASM 塞进浏览器。这个组合解决了实际痛点:Python 遗传算法库写起来快,但要在浏览器里实时演示,瓶颈不在逻辑,而在运行时;Rust 把所有状态压进连续内存,编译成 WASM 后,纯计算部分几乎接近原生速度。对想研究 Rust AI 或 WebAssembly 性能的工程师来说,这是一个能完整跑通“环境模拟—推理—进化—前端渲染”的最小闭环。项目没有依赖深度学习框架,矩阵乘法和进化算子全部手写,反而很容易看清每个参数对结果的影响。
2. 拆解 osmos 工程:从 Cargo workspace 到 WASM 边界
2.1 workspace 布局与 crate 职责
打开 osmos-master,第一感觉是目录结构很规整。Cargo workspace 管理了四个 Rust crate,前端是 Vite + TypeScript 工程。各部分的职责如下:
| crate/目录 | 职责 | 关键依赖 |
|---|---|---|
| osmos-core | 基础类型定义:向量、细胞、边界、配置 | 无 |
| osmos-sim | 模拟主循环,处理碰撞、吞噬、物理更新、进化代理 | osmos-core, osmos-nn |
| osmos-nn | 神经网络结构、前向传播、遗传算法算子 | osmos-core |
| osmos-wasm | 用 wasm-bindgen 包装模拟器,导出给前端的接口 | osmos-sim, wasm-bindgen |
| osmos-web | Canvas 渲染、用户交互、UI 控制 | Vite, wasm-bindgen 产物 |
这个拆分逻辑很清晰:核心逻辑和前端边界完全分离。osmos-core 不依赖任何外部库,保证纯逻辑可以在 Rust 侧直接跑单元测试;osmos-sim 在核心之上增加模拟循环和遗传算法调度;osmos-nn 专注 AI。osmos-wasm 只做薄封装,不掺业务逻辑。这样拆的另一个好处是,如果你不想用 WASM,直接写一个二进制入口跑 cargo test 或 cargo bench,逻辑完全一致,不用另起一套测试框架。
2.2 为什么把核心逻辑放在 Rust 而不是前端
进化模拟每秒要跑几十代,每代几十个个体,每个个体每帧可能要做多次前向传播。如果全放在 TypeScript 里,对象属性读写和 GC 会拖慢速度。Rust 这边没有运行时 GC,Vec<f32> 在内存里连续排列,CPU 缓存命中率明显更高。编译成 WASM 后,虽然没有直接操作 DOM 的能力,但纯计算比 JS 快一个量级。
在设计边界时,我一般遵循一个原则:逐帧循环、逐个体推理、逐代进化的代码全部放 Rust;只有 Canvas 绘制和事件交互放 TypeScript。osmos-wasm 只暴露少量函数,比如 new、step、reset、state_ptr,而不是把整个模拟对象暴露给前端,这样能减少 wasm-bindgen 的序列化开销。
2.3 模拟主循环的数据流
从用户点击“开始”到画面更新,数据流是这样的:前端调用 osmos-wasm 导出的 step 函数,传入一个时间步长;osmos-sim 遍历每个细胞,把周围环境信息编码成输入向量,丢给对应的神经网络推理,得到推力方向和大小;然后物理引擎更新位置和速度,检测吞噬;最后把每个细胞的位置、半径、能量写回一块连续的 Float32Array 内存。前端通过 state_ptr 拿到指针,创建 TypedArray 视图来读取,整个过程没有 JSON 传输,只有二进制拷贝。
需要注意,Rust 侧的 Vec 扩容时,wasm 线性内存中这块 buffer 的地址会变化,所以前端每次读取状态前都要重新用 wasm.memory.buffer 创建视图,不能把指针缓存到渲染循环外面。这一点在第四章会详细展开。
2.4 从 Cargo.toml 到 wasm-pack-build.sh
根目录的 wasm-pack-build.sh 是构建入口,内容大致如下:
#!/bin/bash cd osmos-wasm wasm-pack build --target web --out-dir ../osmos-web/src/wasm--target web 会让 wasm-bindgen 生成 ES 模块风格的胶水代码,正好适配 Vite。--out-dir 直接输出到前端 src/wasm 目录,省去手动复制。注意,每次改 Rust 代码后都要重新执行这个脚本,Vite 不会自动重编译 WASM。
对应 osmos-wasm/Cargo.toml 里的关键配置:
[package] name = "osmos-wasm" version = "0.1.0" edition = "2021" [lib] crate-type = ["cdylib", "rlib"] [dependencies] wasm-bindgen = "0.2" osmos-sim = { path = "../osmos-sim" }crate-type 必须包含 cdylib,否则 wasm-bindgen 无法生成动态链接库;同时保留 rlib 是方便 Rust 侧测试。这里没有启用 wasm-bindgen-rayon 这类多线程特性,因为进化模拟在单线程下用串行循环更容易控制,也避免在 WASM 里处理线程池的额外复杂度。当前场景 40 个个体已经够玩,多线程留到个体数量上千时再考虑。
3. 神经网络与遗传算法在 Rust 中的实现
3.1 前馈网络的结构定义
在 osmos-nn 里,网络结构很简单:输入层、一个隐藏层、输出层。细胞感知周围一定范围内其他细胞的距离、速度和类型,编码成固定长度的 f32 向量。隐藏层用 ReLU,输出层用 tanh,让推力分量落在 [-1, 1] 区间。结构体这样定义:
pub struct NeuralNet { weights_ih: Vec<f32>, // 输入层到隐藏层 weights_ho: Vec<f32>, // 隐藏层到输出层 bias_h: Vec<f32>, bias_o: Vec<f32>, input_size: usize, hidden_size: usize, output_size: usize, }weights_ih 的长度是 input_size * hidden_size,weights_ho 是 hidden_size * output_size。为什么不直接用 Vec<Vec<f32>>?因为连续内存访问更快,迭代器可以并行化,而且从基因序列反序列化时直接按块填充,不用嵌套循环分配。相同数据量下,扁平 Vec 的 cache 友好度比嵌套 Vec 高很多,这在每代几十个个体的反复推理中会被放大。
3.2 前向传播实现
前向传播就是两层矩阵乘法叠加激活函数。这里不引入 ndarray,手写循环,便于理解也减少依赖:
impl NeuralNet { pub fn forward(&self, input: &[f32]) -> Vec<f32> { // 计算隐藏层 let mut hidden = vec![0.0f32; self.hidden_size]; for h in 0..self.hidden_size { let mut sum = self.bias_h[h]; for i in 0..self.input_size { sum += input[i] * self.weights_ih[h * self.input_size + i]; } hidden[h] = if sum > 0.0 { sum } else { 0.0 }; // ReLU } // 计算输出层 let mut output = vec![0.0f32; self.output_size]; for o in 0..self.output_size { let mut sum = self.bias_o[o]; for h in 0..self.hidden_size { sum += hidden[h] * self.weights_ho[o * self.hidden_size + h]; } output[o] = sum.tanh(); // 输出限制到 [-1, 1] } output } }这里有个隐蔽的索引问题:weights_ih[h * self.input_size + i] 是按行存储,每行对应一个隐藏节点。我见过有人写成 h + i * hidden_size,结果训练时权重排列和梯度更新不一致,收敛很慢。前向传播先遍历隐藏节点,行优先更符合缓存习惯。tanh 把推力控制在 [-1, 1],避免细胞一个时间步就冲出去。
3.3 遗传算法:选择、交叉与变异
进化循环在 osmos-sim 里,用锦标赛选择法挑父代。锦标赛选择的好处是不需要全局排序,每次随机抽 k 个个体,选适应度最高的,复杂度 O(k),而且能保留一定多样性。核心代码:
fn select_parent(population: &[Individual], fitness: &[f32], k: usize) -> usize { let mut best_idx = rand::random::<usize>() % population.len(); for _ in 0..k { let idx = rand::random::<usize>() % population.len(); if fitness[idx] > fitness[best_idx] { best_idx = idx; } } best_idx }k 直接控制选择压力:k 越小,随机性越大,种群多样性保留得越好;k 越大,高适应度个体越容易被选中,收敛快但容易早熟。一般从 k=2 开始调。接下来是交叉和变异。假设个体是神经网络所有参数拍平后的基因序列:
fn crossover(a: &[f32], b: &[f32], child: &mut [f32], rate: f32) { let cut = (a.len() as f32 * rate) as usize; child[..cut].copy_from_slice(&a[..cut]); child[cut..].copy_from_slice(&b[cut..]); } fn mutate(genes: &mut [f32], rate: f32, strength: f32) { for g in genes.iter_mut() { if rand::random::<f32>() < rate { *g += (rand::random::<f32>() * 2.0 - 1.0) * strength; } } }交叉率 rate 决定基因片段的切点位置,这里用单点交叉,切点前的来自父本,之后来自母本。变异率 rate 每个基因单独判定,通常设 0.05 ~ 0.2;strength 是变异幅度,设 0.5 左右。如果 strength 太大,好的权重会被破坏;太小,探索能力不足。下面是一组推荐起始值:
| 参数 | 起始值 | 说明 |
|---|---|---|
| 种群大小 | 40 | 太小容易早熟,太大每代模拟耗时增加 |
| 锦标赛 k | 2 | 增大选择压力,加快收敛但降低多样性 |
| 交叉率 | 0.7 | 70% 的个体执行交叉,其余保留父本 |
| 变异率 | 0.1 | 小于 0.03 时进化几乎停滞 |
| 变异强度 | 0.5 | 超过 1.0 会破坏累积的基因 |
3.4 适应度函数设计
适应度是整个进化过程的方向标。在 osmos 场景里,我采用“吞噬总量 + 存活时间 - 运动能量消耗”的组合。具体公式:
fitness = eaten_count * 10.0 + lifetime * 0.1 - total_thrust * 0.01这个函数要兼顾两个目标:既要有攻击性(吃别的细胞),也要有生存能力(别一头撞上大细胞)。如果只算 eaten_count,进化出来的个体会横冲直撞;加上运动消耗惩罚后,个体会学会在周围没有食物时静止。你可以把这三个系数暴露到配置里,前端用滑动条实时调整,观察细胞行为变化。
这里顺带说一句,PyTorch 或 Python 里的遗传算法库往往为了通用性牺牲速度,而这里用 Rust 手写,一个 60 秒的模拟可以跑几百代,对验证神经网络权重进化来说完全够用。更重要的是,整个推理过程用不到梯度信息,纯前向传播加随机扰动,这也正是遗传算法对比反向传播在不同场景下的互补之处。
4. 从 Rust 到 WASM:让浏览器跑起进化模拟
4.1 wasm-bindgen 导出接口
osmos-wasm crate 做的事情就是把 osmos-sim 内部结构包装成前端可调用的函数。常见设计是暴露一个 Simulation 结构体,而不是自由函数,这样前端可以持有多个模拟实例,互不干扰。代码示例:
use wasm_bindgen::prelude::*; #[wasm_bindgen] pub struct Simulation { inner: osmos_sim::Simulation, memory: Vec<f32>, } #[wasm_bindgen] impl Simulation { #[wasm_bindgen(constructor)] pub fn new(config: &str) -> Result<Simulation, JsValue> { let cfg = serde_json::from_str(config).map_err(|e| JsValue::from_str(&e.to_string()))?; let inner = osmos_sim::Simulation::new(cfg); let memory = vec![0.0f32; inner.state_size()]; Ok(Simulation { inner, memory }) } pub fn step(&mut self, dt: f32) { self.inner.step(dt); self.inner.write_state(&mut self.memory); } pub fn state_ptr(&self) -> *const f32 { self.memory.as_ptr() } pub fn state_len(&self) -> usize { self.memory.len() } }constructor 用字符串接收 JSON 配置,比传多个整数参数更灵活,前端可以自由增删参数,不需要改 Rust 签名。step 函数更新模拟并写入 memory,前端通过 state_ptr 获取指针,再放进 Float32Array 读取。注意,step 的参数 dt 是秒,前端可以用 requestAnimationFrame 传真实时间差,也可以用 setInterval 传固定 0.016。
4.2 Vite 前端加载 WASM 模块
osmos-web 是 Vite + TypeScript,加载 WASM 的代码通常放在 src/main.ts:
import init, { Simulation } from './wasm/osmos_wasm'; const wasm = await init(); const sim = new Simulation( JSON.stringify({ population: 40, input_size: 12, hidden_size: 8 }) );这里的 init 是 wasm-pack 生成的默认导出函数,调用后 Rust 内存准备就绪。注意 await 必须完成,否则后面 new Simulation 会报“instanceof 错误”或者“wasm function called before ready”。Vite 在 dev 模式下对 .wasm 文件不支持热更新,每次改 Rust 代码后需要重新执行 wasm-pack-build.sh,再刷新页面。
4.3 渲染循环与数据读取
前端每帧调用 sim.step(dt),然后从 sim.state_ptr() 读取状态。由于 Rust 的 Vec<f32> 内存在 wasm 线性内存中,前端需要把指针转成 Float32Array:
const state = new Float32Array( wasm.memory.buffer, sim.state_ptr(), sim.state_len() );然后遍历 state,按预定义的结构解析每个细胞的位置、半径、能量。这里有个最容易踩的坑:wasm.memory.buffer 在 Rust 侧如果发生 Vec 扩容,buffer 地址会变,所以每次读取前都要重新从 wasm.memory.buffer 创建 Float32Array,不能缓存。在我测试时,如果只做一次读取,然后在后续帧里循环清空,会导致数据错乱,画面出现随机漂移。
4.4 性能优化与常见问题
下表列出我在调 WASM 性能时做的几个优化,以及对应的效果:
| 优化手段 | 做法 | 效果 |
|---|---|---|
| 减少 wasm-bindgen 导出函数调用 | 把多次 state_ptr/state_len 合并成一个 export_state 方法 | 少一次边界跳转,数据量大时明显 |
| 前端渲染用 requestAnimationFrame | 让浏览器决定刷新率,模拟时间按实际 dt 走 | 性能抖动减少 |
| 在 Rust 侧用 f32 而不是 f64 | WASM 对 f32 向量运算支持更高效,内存占用减半 | 模拟速度提升约 20% |
| 构建时加 --release | 去掉 debug 断言和溢出检查 | 提升明显,但也掩盖越界问题 |
常见报错里,最多次遇到的是“cannot use wasm-bindgen on a non-exported struct”,通常是把内部类型直接暴露给 #[wasm_bindgen],但没有实现要导出的方法。解决办法是像 Simulation 这样包一层 DTO,或者用公开方法转发内部调用。另外,如果在 Rust 侧用了 panic!,wasm-bindgen 默认会把 panic 信息打印到 console,但需要设置 console_error_panic_hook,否则定位问题只能靠 console.log 一点一点试。
5. 遗传算法调参:早熟、收敛速度与超参数
5.1 早熟现象的成因与对策
用这个项目跑几轮后会发现,前几十代种群多样性迅速消失,所有细胞都变成同一套神经网络的简并版本,这就是遗传算法里经典的早熟现象。原因在于锦标赛选择虽然避免了排序,但 k 如果设得偏大,高适应度个体会被反复选中,加上单点交叉对复杂权重空间的探索能力有限,群体迅速被优势个体占据。
我一般从两个方向调整。第一,把锦标赛 k 从 2 降到 1,或者让变异率在早期阶段保持 0.2 左右,增加随机扰动;第二,每隔 20 代,从种群中随机挑出 10% 的个体,用随机权重重新初始化。这两种做法都能避免种群过早锁死。更精细的办法是监测基因多样性:计算所有个体对应基因位的标准差,当标准差低于某个阈值时强制提高变异率。
5.2 动态调整变异率
一个简单有效的方法是自适应变异率:记录最近 N 代的平均适应度变化,如果改善很小,就临时把变异率乘以 2;如果改善明显,就恢复原值。在 Rust 侧实现如下:
let mut mutation_rate = 0.05; let mut prev_best = f32::MIN; for gen in 0..generations { // 运行一代模拟,得到 best_fitness let delta = best_fitness - prev_best; if delta.abs() < 0.001 { mutation_rate = (mutation_rate * 2.0).min(0.5); } else { mutation_rate = 0.05; } prev_best = best_fitness; }注意,threshold=0.001 要根据适应度量级调整。如果适应度通常有几千,这个阈值太小,永远触发不了自适应逻辑。我一般取最近 10 代平均适应度变化的比例,比如 0.5%。这样既不会频繁触发,也不会错过平台期。
5.3 推荐参数组合
基于 osmos 这个具体场景,我最后给一套能跑的起始参数:
| 参数 | 起始值 | 调节方向 |
|---|---|---|
| 种群大小 | 40 | 太小容易早熟,太大每代模拟耗时增加 |
| 锦标赛 k | 2 | 减小到 1 可提升多样性 |
| 交叉率 | 0.7 | 约 70% 的个体做交叉,其余克隆 |
| 变异率 | 0.1 | 遇到平台期时临时调到 0.2 |
| 变异强度 | 0.5 | 超过 1.0 会破坏累积的基因 |
| 隐藏层神经元 | 8 | 与输入输出维度匹配,6~12 为宜 |
所有超参数都建议做成配置项,通过 JSON 传入 Simulation,不要硬编码在 Rust 里。这样你可以直接在网页端做一个简单的参数面板,用滑动条实时调节变异率、选择压力和适应度权重,观察进化行为的变化。osmos-master 这个工程已经把构建脚本、Vite 前端和各 crate 都配好了,装上 wasm-pack 和 Node 依赖,跑起来就能看到完整效果。
本文还有配套的精品资源,点击获取