ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

MiroFish:Canvas 2D 浏览器鱼群模拟与交互镜像

MiroFish:Canvas 2D 浏览器鱼群模拟与交互镜像 第一次把 MiroFish 这个名字写在便利贴上时我脑子里其实只有一个模糊的念头鱼群的运动能不能拿来当一面镜子。传统意义上的电子鱼缸鱼是演给你看的——随机游走、偶尔转向、撞到边缘弹回来看两眼就腻了。MiroFish 想做的恰恰相反它把你在页面上的每一个动作都当成输入源鼠标停在哪里、移动多快、点了什么、甚至当前是白天还是深夜都会被翻译成鱼群的群体语义该散开还是该聚拢该好奇地凑过来还是该受惊一样炸开。说白了MiroFish 是一个跑在浏览器里的轻量鱼群模拟器核心是一套转向行为steering behaviors内核加一层输入到行为的映射层再加一层足够好看但不烧性能的 Canvas 渲染。它适合两类人一类是想找个完整小项目练手前端动画与模拟的开发者另一类是对群集智能、Boids 这类模型好奇、但被论文里的公式劝退的人。整篇内容我会按我实际写代码的顺序来讲包括那些调参调到怀疑人生的时刻。1. MiroFish 到底在做什么把输入翻译成鱼群的运动1.1 从名字里拆出的三个关键词镜像、群体、轻量MiroFish 这个名字如果拆开看其实藏着三个设计约束。Miro我取的是镜像的意思指的是整个项目最核心的那层映射鱼缸不是独立运行的背景动画它反射mirror你的状态。Fish是表现形式但重点不在单条鱼画得多像而在群体呈现出的整体感——一群鱼同时转向时那种像是一个生物的错觉。Fish后面那个没有写出来的词是轻量因为我一开始就给自己定了条死规矩整个项目不引入任何图形引擎、物理引擎、动画库只用原生 Canvas 2D 加一个 requestAnimationFrame 循环搞定。这三个约束互相之间是有张力的。群体感需要鱼与鱼之间互相感知最朴素的做法是两两计算距离300 条鱼就是 44850 次距离计算每帧再乘上三个力分离、对齐、聚合帧率直接跪。镜像感需要把外部的连续输入映射到离散的行为状态上如果映射做得粗糙鱼群就会显得神经质——鼠标动一下全体抽搐那不是反射那是抽搐。轻量则要求每一处性能优化都要靠算法和数据结构而不是靠换个 WebGL 库这种降维打击。所以后面所有的技术选择本质上都是在解这三个约束之间的冲突。1.2 我给它划的三条边界不做什么比做什么更重要在写第一行代码之前我花了大概一个晚上列不做什么事实证明这个习惯救了我。第一条边界不做真实流体与碰撞物理。鱼的转向只受期望速度和最大转向力约束不做刚体碰撞不做鱼身互推。原因很直接一旦引入碰撞求解你就需要空间划分 时间步迭代 位置修正三步走性能开销至少翻三倍而观感提升几乎为零——鱼群本来就不该像台球一样互相弹开。第二条边界不读摄像头、不读麦克风、不读系统敏感权限。把用户的真实动作镜像成鱼群听起来很酷但为了一个氛围应用去要摄像头权限性价比极低用户心理负担也重。我只用三类完全无感的信号指针位置、指针速度、以及本地时间。第三条边界不做持久化账户与云端同步。快照和回放只放在内存里的环形缓冲区刷新即消失。划完这三条边界之后整个项目的模块划分就很自然地浮出来了一个把原始输入洗干净并归一化的输入层一个把归一化信号翻译成行为意图的映射层一个负责邻居查询和转向计算的模拟内核一个负责插值与绘制的渲染层最后加一个负责快照与回放的时间轴模块。模块之间只通过普通的数据对象通信没有任何事件总线或者响应式框架因为一旦上了这些调试时你就很难回答这一帧的鱼为什么会往左拐这种问题。提示小项目最容易死在顺手再加一个功能上。如果你也打算做类似的东西建议在第一版就把不做什么写进 README后面每次手痒就翻出来看一眼。2. 行为内核为什么我最后放弃了纯随机游走2.1 分离、对齐、聚合三个力的权重是怎么调出来的最开始的版本我写了个随机游走 边界反弹每条鱼有自己的速度方向每隔随机时间转一个小角度。代码三十行就跑起来了看起来也确实是鱼在游但问题很快就暴露无论怎么看它都像一堆各自为政的碎片完全没有那种整群突然一起转向的惊艳瞬间。于是换到经典的转向模型每条鱼每帧计算三个期望速度向量加权求和后作为总期望速度再与当前速度求差截断到最大转向力// 每个邻居贡献的三个力先累加再归一化避免邻居数量影响权重 let sepX 0, sepY 0; // 分离远离过近的邻居 let aliX 0, aliY 0; // 对齐朝向邻居的平均方向 let cohX 0, cohY 0; // 聚合朝邻居的平均位置靠拢 let count 0; for (const other of neighbors) { const dx other.x - self.x; const dy other.y - self.y; const d2 dx * dx dy * dy; if (d2 0) continue; aliX other.vx; aliY other.vy; cohX other.x; cohY other.y; count; // 分离力与距离成反比越近推得越狠 if (d2 SEP_RADIUS * SEP_RADIUS) { const inv 1 / d2; sepX - dx * inv; sepY - dy * inv; } }权重这块是整个项目里最耗时间的地方我前后试了大概二十组。最后的结论是分离权重必须显著高于另外两个我最终定在 1.6对齐 0.9聚合 0.7。原因是分离力决定鱼群有多松散一旦它偏小鱼群会坍缩成一个圆点所有人挤在一起抖而聚合权重稍微大一点鱼群又会黏成一条紧绷的线失去那种松散游动的松弛感。对齐权重是群体意志的来源它太小就没有集体转向太大则所有鱼像被一根绳子拴住转向僵硬。另外一个很容易被忽略的细节是三个力的累加结果必须先归一化再乘系数而不是直接乘系数。因为邻居数量是浮动的如果邻居多的鱼算出来的力天然更大就会出现鱼群边缘的鱼反应迟钝、中心区的鱼过度活跃这种诡异现象。这个坑我踩了两天才反应过来当时一直以为是渲染的视觉误差。2.2 邻居查询从 O(n²) 到空间哈希的实测差距转向模型写完之后我拿 120 条鱼测了一下帧率还行于是贪心地把数量拉到 400 条帧率直接从 60 掉到 22。原因就是那个双层循环。解决方案是空间哈希把画布切成边长等于感知半径的方格每条鱼根据坐标落到某个格子查询邻居时只遍历自己所在的 3×3 共 9 个格子里的鱼。const CELL PERCEPTION_RADIUS; const grid new Map(); function cellKey(cx, cy) { // 用位运算拼一个整数 key比字符串拼接快得多 return (cx 16) ^ (cy 0xffff); } function rebuildGrid(fishes) { grid.clear(); for (let i 0; i fishes.length; i) { const f fishes[i]; const cx Math.floor(f.x / CELL); const cy Math.floor(f.y / CELL); const key cellKey(cx, cy); let bucket grid.get(key); if (!bucket) { bucket []; grid.set(key, bucket); } bucket.push(f); } }我的做法是每帧完全重建一次网格而不是增量维护。原因是增量维护需要处理鱼跨格子的边界情况逻辑复杂、容易出 bug而重建本身对 400 条鱼来说只要 0.15 毫秒左右完全在预算内。这里还有个细节格子边长不能随手设成感知半径的一半或两倍。设小了会产生大量空桶遍历 9 个格子还是快但如果你把感知半径设得比格子边长还大就需要遍历 5×5 甚至 7×7哈希的优势立刻消失。我实测下来格子边长严格等于感知半径、查询 3×3 是最稳的配比。优化后的实测对比400 条鱼从 22fps 回到 58fps1200 条鱼还有 41fps。也就是说空间哈希把可承载的鱼群规模提升了差不多一个数量级。代价是代码多了 30 行这个买卖非常划算。2.3 固定步长模拟加插值渲染掉帧不再让鱼群炸开这里有个反直觉的地方模拟的稳定性其实和帧率没关系和时间步长是否一致有关系。我一开始用的是可变时间步——每帧拿两帧之间的真实间隔当 dt 去积分。结果只要页面卡一下比如切标签页回来或者弹出开发者工具dt 突然变成 0.5 秒鱼群就会瞬间加速然后飞出去。更糟的是转向行为对 dt 是非线性的同样一段总时间用一个大步长积分和用六十个小步长积分结果完全不同鱼群会显得忽快忽慢。改成固定步长之后就干净了模拟永远以 1/60 秒为一步推进帧与帧之间累积的时间放进累加器累加器够一步就推一步最多推 5 步防止死亡螺旋卡顿导致积累更多步、更多步导致更卡。渲染的时候用剩余累加器比例做位置插值视觉上就不会有台阶感const STEP 1 / 60; let acc 0; let last performance.now(); function loop(now) { let frameTime (now - last) / 1000; last now; // 关键单帧时间上限防止切回页面时爆炸 if (frameTime 0.25) frameTime 0.25; acc frameTime; let steps 0; while (acc STEP steps 5) { simulate(STEP); acc - STEP; steps; } if (steps 5) acc 0; // 放弃追帧避免螺旋 render(acc / STEP); // alpha 用于插值 requestAnimationFrame(loop); }注意累加器归零那一行看着很粗暴但它比无限追帧安全得多。宁可让模拟时间和真实时间轻微脱节也不要让主线程被拖死。用户对时间精度是没有感知的对卡顿是有的。3. 镜像这一层用户输入如何变成鱼群的语义3.1 输入归一化把指针、速度、时间统一到同一套量纲镜像层最容易做错的地方是把不同来源、不同量纲的信号直接扔给模拟内核。指针位置是像素坐标可能几百到几千指针速度是像素每秒可能几万时间是毫秒或者小时量级完全不在一个维度。如果不过一层归一化调参时你根本不知道是谁在起作用。我的做法是定义一套统一的一维信号协议所有信号都落在 -1 到 1 或者 0 到 1 之间信号名来源归一化方式取值范围pointerX指针横坐标(x / 画布宽) * 2 - 1-1 ~ 1pointerY指针纵坐标(y / 画布高) * 2 - 1-1 ~ 1energy指针移动速度速度 / 2400再做一次软截断0 ~ 1clickness点击事件的衰减值每次点击置 1按 3 每秒衰减0 ~ 1dayness本地时间的小时数用余弦函数映射到昼夜两段0 ~ 1速度的归一化我用了 2400 像素每秒这个参考值大约是快速甩动鼠标的量级超过就截断到 1。软截断的意思是不要用 min 硬切而是用v / (1 v)这类函数让接近上限的部分平滑饱和否则鱼群在高能量区间的反应会显得很死板。点击信号用了指数衰减而不是线性衰减因为指数衰减更符合直觉里惊扰逐渐平复的感觉线性衰减会让鱼群在最后一段时间突然安静下来。时间信号我犹豫了很久要不要加。加了之后深夜打开页面会看到鱼群整体游动变慢、颜色变冷白天则更活跃、更明亮这个细节几乎没人会主动注意到但整体氛围的差别是能感觉到的。这是我个人最满意的一个设计因为它完全不打扰用户却让这个页面是活的这件事变得可信。3.2 恐惧、好奇、聚集三种状态机的切换条件归一化信号之后不能直接把它们喂给转向力那样鱼群会变成指针的提线木偶。中间需要一层状态机把连续信号转成离散的行为意图再让行为意图去改变转向参数。我给每条鱼定义了四种状态巡游、好奇、惊逃、归巢。巡游默认状态使用基础权重感知半径和最大速度都是基准值。好奇当指针附近能量较低但存在点击残留时进入鱼会朝指针方向缓慢靠近但对分离力更敏感保持一个安全的围观距离。惊逃点击瞬间全群触发最大速度提升到 2.2 倍分离权重拉高到 2.4聚合权重降到 0.2持续 1.2 秒后按指数曲线回归。归巢当所有信号都衰减到接近零且持续一段时间后进入鱼群慢慢聚拢到一个默认的舒适区避免页面静止时鱼群散得看不见。这里最关键的技巧是切换条件必须带滞回hysteresis。比如进入好奇的条件是 energy 0.25退出好奇的条件必须是 energy 0.4两个阈值之间留一段缓冲带。如果进出用同一个阈值指针恰好在阈值附近抖动时鱼群会以每帧好几次的频率在两个状态之间来回跳视觉上就是集体痉挛。滞回这个东西在任何状态机里都是刚需我在手势识别、滚动加载、菜单展开这几个场景里都用过同一套思路。另外惊逃状态我没有做成全群同时结束。每条鱼的持续时间会乘一个 0.8 到 1.2 的随机系数这样鱼群的平复过程是逐渐的、有层次的而不是像开关一样所有人同时慢下来。3.3 状态快照与回放把任意一秒的鱼缸镜像出来既然项目叫镜像那把过去某一秒完整还原出来就很自然成了必备能力。实现上我没有存整个对象图而是只存位置的浮点数组。每 100 毫秒打一次快照写进一个 30 秒长度的环形缓冲区每条鱼两个 32 位浮点数x 和 y400 条鱼一次快照就是 3200 字节30 秒总量不到 1MB非常轻。const SNAP_INTERVAL 0.1; const SNAP_COUNT 300; // 30 秒 const ring new Float32Array(400 * 2 * SNAP_COUNT); let snapIndex 0; function takeSnapshot(fishes, t) { const base snapIndex * 400 * 2; for (let i 0; i fishes.length; i) { ring[base i * 2] fishes[i].x; ring[base i * 2 1] fishes[i].y; } snapIndex (snapIndex 1) % SNAP_COUNT; }要回放的时候根据目标时间戳定位到前后两个快照做线性插值即可。不存速度的原因是如果两个快照之间的间隔只有 100 毫秒线性插值足够平滑存速度的收益几乎看不出来反而让内存翻倍。回放状态下模拟循环照常跑只是渲染读取的数据源从实时位置换成了插值出来的历史位置这样暂停模拟但保留氛围动画也能顺手做出来。提示快照数组一定要用预先分配好的类型化数组不要每 100 毫秒 new 一个普通数组。400 条鱼跑十分钟就是 6000 次分配GC 压力会在长时间挂机的页面上暴露得非常明显。4. 渲染与手感为什么 Canvas 2D 就够了4.1 鱼身绘制的批次合并与路径复用先给结论这个项目里 Canvas 2D 完全够用换 WebGL 的收益远小于代价。原因是鱼身本身很简单——一个椭圆加一个三角尾鳍填充一个纯色最多加一层半透明的高光。真正吃性能的不是形状复杂度而是状态切换次数也就是每一次改变填充色和变换矩阵的开销。我的做法是按鱼种分组同一种颜色的鱼放在一起绘制一组内只设置一次 fillStyle然后用 save/translate/rotate/scale/restore 包住每条鱼的路径绘制。这里有个细节值得说一开始我在循环里用ctx.ellipse()直接画测试下来 400 条鱼大约 3 毫秒后来改成预先把鱼身路径用 Path2D 缓存起来按 12 个朝向角度各缓存一份绘制时直接ctx.fill(path)开销降到 1.1 毫秒。原因是 Path2D 的路径构建是在 C 侧完成的避免了每条鱼都重新走一遍 JS 到原生的参数传递。另外几个纯 2D 时代的性能陷阱我都在这个项目里体验了一遍shadowBlur只加了一行阴影帧率掉了 15fps。它是最贵的 2D 特性之一没有任何商量余地直接删掉改用一层半透明的深色椭圆模拟阴影。大量渐变对象createRadialGradient 每帧创建代价很高。改成离屏 canvas 预渲染一次之后当成精灵贴图用。clearRect 全屏清空清空本身不贵但配合拖尾需求时改用fillStyle rgba(10,20,35,0.12)覆盖全屏一次操作既清了屏又留下了残影比先清屏再手动画轨迹的方案便宜得多。4.2 拖尾、涟漪、景深三个几乎不耗性能的氛围技巧拖尾用上面的半透明覆盖就实现了成本几乎为零。它的原理是每一帧没有完全擦掉上一帧的内容旧像素按 12% 的权重残留几次之后就自然淡出。需要注意覆盖层的透明度必须和帧率解耦——如果你的覆盖透明度写死 0.12在 120Hz 屏幕上拖尾会明显短于 60Hz 屏幕。我的处理是把覆盖透明度换算成与 dt 相关的值alpha 1 - Math.pow(1 - 0.12, dt * 60)这样不同刷新率下垂尾长度基本一致。涟漪用来给点击一个视觉反馈。每次点击生成一个圆环对象记录坐标和生成时间渲染时用年龄做半径扩张和透明度衰减同时限制最多 40 个活跃圆环。因为圆环数量有硬上限这部分的开销是完全可预测的不会因为用户疯狂点击而崩溃。景深是让画面看起来有厚度的关键。我把鱼群分成两层远景层缩小到 0.65 倍、透明度 0.55、整体加一点模糊感的偏移近景层正常比例。远景层的鱼不参与转向计算只是把近景层的位置整体缩放后做一点相位延迟成本是零。但视觉上鱼缸立刻从平面贴纸变成了有空间的水体这个性价比高得离谱。4.3 帧率和观感的取舍60fps 不是目标这句话可能有点反直觉但在这个项目里是真的我最终把模拟频率锁在 60但把渲染目标定在了 30 到 45 之间浮动。原因是鱼群的慢本身就是观感的一部分。60fps 的满帧滑行会显得过于顺滑、过于机械反而丢掉了水体的黏滞感。我在渲染插值的时候做了一点额外的平滑让速度变化有一点延迟跟随具体就是插值位置和真实位置之间再做一个 0.12 系数的低通滤波。这个取舍的另一个好处是给低端设备留了余量。我加了一个自适应降级连续 60 帧渲染时间超过 22 毫秒就把鱼的数量从 400 降到 240鱼类粒子从 1200 降到 600同时关掉涟漪和水面光斑层。恢复也有条件——必须连续 300 帧低于 12 毫秒才升回来避免在两个档位之间反复横跳导致画面忽明忽暗。5. 踩坑记录三个让我排查了很久的问题5.1 鱼群贴边抖动边界力与转向力的叠加冲突现象特别迷惑鱼群游到画布边缘附近的时候会有几十条鱼沿着边缘高频抖动看起来像被电击。我第一反应是渲染问题去查了变换矩阵和坐标取整浪费了一个多小时。真正的原因在模拟侧我最初的边界处理是硬反弹碰到左边界就把 vx 取反。但转向力还在持续往左推取反后的速度下一帧又被推回来于是 vx 在正负之间每帧翻转视觉上就是抖动。排查这个问题的正确姿势是打开一个调试开关把每条鱼的速度向量画成短线再给边界区域染个色。一眼就能看出来是速度符号在边界处反复横跳。修复方案是换成软边界在距离边缘 120 像素的范围内施加一个向内的期望速度强度随距离的平方递减然后把这个力和其他三个转向力一起做归一化和截断这样它就自动被 maxForce 约束住了不会出现翻转这种离散事件。const MARGIN 120; let edgeX 0, edgeY 0; if (self.x MARGIN) edgeX (MARGIN - self.x) / MARGIN; if (self.x W - MARGIN) edgeX - (MARGIN - (W - self.x)) / MARGIN; // Y 方向同理 // 和分离、对齐、聚合一起归一化后再乘权重交给统一的 maxForce 截断提示任何取反速度直接赋值位置这类硬约束只要和持续存在的外力共存就一定会产生抖动或者穿透。换成软约束并让它和其他力走同一条归一化通道这类问题基本一次性解决。5.2 标签页切回后鱼群瞬移时间步累积的典型症状这个 bug 的表现是切到别的标签页几分钟再切回来第一帧鱼群会瞬间散成一团乱麻然后慢慢恢复。我一开始以为是内存或者 GC 的问题后来在 rAF 回调里打印时间差才看到真相——切回来那一帧的 frameTime 是 187 秒。我们前面已经用固定步长加累加器把这个问题从根上堵住了但排查过程中的另一个发现值得单独说document.visibilitychange事件也必须处理。因为除了 rAF 暂停之外某些环境下performance.now()的推进和 rAF 的恢复之间还有微小的错位如果不在可见性变化时重置 lastTime 并清空累加器还是会有轻微跳变。我最终在事件回调里做了三件事重置时间基准、清空累加器、把指针的 energy 信号强制归零因为用户不在页面上的时候指针位置是过期的切回来会被当成一次巨大的移动。5.3 长时间运行内存爬升对象池与临时向量最后一个坑是最隐蔽的短时间跑完全没问题挂着两小时后内存从 40MB 涨到 300MB。用内存快照对比之后发现问题出在每帧都在创建临时对象——向量对象、邻居数组、网格的桶数组。400 条鱼每帧创建 3 个临时对象一小时就是 432 万个。修复分三步。第一步位置和速度用两条预分配的 Float32Array 存储不再用对象属性坐标访问变成pos[i * 2]。这一步同时还带来了 30% 以上的性能提升因为类型化数组的内存布局连续缓存友好。第二步邻居查询结果不再返回新数组而是复用一个预分配的索引数组加一个长度计数调用方只读前 count 个元素。第三步空间哈希的桶数组全部预先分配 64 长度的定长数组加一个计数器跨帧复用只清计数不清数组。改完之后挂机六小时内存稳定在 55MB 上下没有明显漂移。这里的心得是任何每帧执行的代码路径里都不应该出现 new、数组字面量、闭包创建、字符串拼接这四样东西。它们单次都很便宜乘上帧率和时间就是灾难。6. 参数速查表与二次开发方向6.1 一份可以直接抄的参数表下面这套参数是我在 400 条鱼、1440×900 画布下跑了很久之后定下来的可以直接拿去当起点再按自己的口味微调。注意所有距离单位都是像素所有速度单位都是像素每秒。参数取值作用与调参方向感知半径70调大更团结但邻居查询变贵调小则鱼群松散分离半径26硬性拥挤阈值超过这个距离才产生分离力分离权重1.6决定鱼群松散程度偏小会坍缩成团对齐权重0.9决定集体转向的整齐度偏大转向僵硬聚合权重0.7决定鱼群聚拢强度偏大会黏成一条线最大速度165巡游基准速度惊逃时乘 2.2最大转向力320转向的灵敏度上限偏大显得急躁惊逃持续1.2 秒带 0.8 到 1.2 的随机系数形成层次感拖尾覆盖透明度0.12数值越小拖尾越长超过 0.3 基本看不出残影快照间隔100 毫秒调小回放更顺滑但内存线性增长边界缓冲带120软边界的作用范围低于鱼身半径会看到贴边调参的顺序我建议是先定感知半径和最大速度这两个尺度参数它们决定了整个鱼群的空间感再调三个权重顺序是分离、聚合、对齐最后才动惊逃参数和视觉参数。反过来的话你会在视觉上反复推翻已经调好的行为参数效率极低。6.2 想扩展时优先动哪几个地方如果你打算在 MiroFish 的基础上做二次开发我建议从下面几个方向切入按投入产出比排序。最高性价比的是多鱼种。只需要在鱼的数据里加一个 kind 字段然后给不同 kind 配不同的感知半径、最大速度、权重表和颜色行为内核一行都不用改。我在测试时加了一个小鱼群感知半径只有 40、速度 1.5 倍、分离权重更高结果它们会自发地在大鱼群外围形成一层快速游动的边缘这个效果完全是从参数里长出来的不是设计出来的。第二个方向是把行为内核抽成独立的模块。现在的位置、速度、权重都是散在几个函数里的如果你把它们收进一个接收 Float32Array 的纯函数模块就可以直接放到 Web Worker 里跑主线程只负责渲染。这个改造的收益在低端设备上非常明显。再往前一步是用 OffscreenCanvas 做渲染转移不过这一步的平台兼容性差异比较大需要做好降级。第三个方向是加导出。既然每一帧的状态都能从环形缓冲区里读出来导出 GIF 或者短视频片段其实只差一个编码器。我的做法是把回放速度和渲染分离开用固定的 30fps 逐帧推进模拟并截图这样导出的片段不受实时帧率影响。最后一个方向是音频驱动但我建议谨慎。用音频信号驱动鱼群确实很酷但实时音频分析对信号的平滑要求很高稍微处理不好鱼群就会跟着节拍抽搐。如果一定要做务必先对频谱做时间上的滑动平均再把低频能量映射到整体活跃度、高频能量映射到分离权重上这样出来的效果才是水随声动而不是鱼在蹦迪。写了这么久我个人最大的体会是这种模拟类小项目真正的分水岭不在渲染技巧也不在数学公式而在你有没有把每帧要做的事情数清楚。我前两版之所以反复推倒重来本质上都是因为每帧的分配数量、状态切换次数、以及那些看不见的隐式转换没有算过账。等你把每帧的预算摊开写在纸上——多少毫秒给邻居查询、多少给转向计算、多少给绘制——剩下的就只是把参数拧到顺眼为止了。
返回列表