把大涡模拟(LES)用于iOS仪表盘流量计,核心目标不是追求实验室级精度,而是用可接受的计算量换取真实的湍流脉动。全尺度的直接数值模拟在手机上不可能完成,雷诺平均方法又会把瞬态涡抹平,恰好LES空间滤波后保留了大尺度旋涡,小尺度耗散交给模型,这种折中非常适合二维表盘。流量计常见的收缩管、孔板、弯道会产生分离流,这些结构在64×64或128×128网格上已经能够被辨识,配合Core Graphics逐像素写入即可形成动态流场。

移动端实现时,网格尺度不能盲目加大。一方面,Core Graphics绘制每帧都会消耗CPU或GPU资源,较大的数组会导致计算与显示同时卡顿;另一方面,二维LES在低分辨率下容易出现数值耗散过大,涡结构被提前抹掉。因此通常把求解区域限定在表盘圆环或扇形区域,用极坐标或笛卡尔坐标离散,速度数组按行存储,便于后续进行有限差分和颜色映射。
一、LES的基本方程与二维流场初始化
大涡模拟先从Navier-Stokes方程出发,对速度进行低通滤波,得到可分辨速度场。滤波后的动量方程中会多出一项亚格子应力,表示小尺度结构对大尺度运动的反作用。对于不可压缩二维流,方程可以写成涡量-流函数形式,也可以直接求解速度-压力形式。前者在二维问题中更稳定,涡量方程天然去掉了压力项,只需要一个流函数满足连续性方程。
在仪表盘场景中,流体从左侧入口进入,经过孔板或收缩段,在表盘中部形成射流和回流。可以把这一几何抽象为矩形区域,入口给定均匀速度,出口使用对流边界,上下壁面采用无滑移条件。对于二维流,壁面附近需要设置速度为零,同时用流函数表达质量守恒。初始化时可以在入口附近加入少量正弦扰动,以激发不稳定波,否则数值解可能保持过于对称的层流状态。
struct LESGrid {
var width: Int
var height: Int
var u: [Double]
var v: [Double]
var pressure: [Double]
var viscosity: Double
init(width: Int, height: Int, viscosity: Double) {
self.width = width
self.height = height
self.viscosity = viscosity
let count = width * height
self.u = Array(repeating: 0.0, count: count)
self.v = Array(repeating: 0.0, count: count)
self.pressure = Array(repeating: 0.0, count: count)
}
func index(_ x: Int, _ y: Int) -> Int {
return y * width + x
}
}
实际工程中不建议在每帧重新分配数组,内存抖动对仪表盘动画非常不利。可以预先创建所有场变量,并在每一时间步覆盖数值。上面的结构体只是说明组织方式,后续计算中需要加入涡量、流函数和亚格子粘性数组。
二、Smagorinsky亚格子模型如何封闭方程
LES滤波后出现的亚格子应力需要模型化。最简单的常用方案是Smagorinsky模型,它假设小尺度湍流的统计效应类似分子粘性,但粘性系数随着局部应变率变化。应变率张量的幅值越大,说明该处剪切越强,小尺度耗散也越剧烈。因此涡粘系数可以写成局部网格尺度与应变率幅值的乘积。
在二维计算中,只需计算三个速度导数的组合。速度梯度通过中心差分获得,然后代入Smagorinsky常数和网格尺寸。此处有个容易踩坑的地方:如果使用均匀网格,局部网格长度可以用dx和dy的几何平均;如果仪表盘采用非均匀网格,要在每个单元单独计算网格尺寸,否则小单元处的耗散会被严重高估。
func smagorinskyViscosity(grid: LESGrid, dx: Double, dy: Double) -> [Double] {
var nut = [Double](repeating: 0.0, count: grid.width * grid.height)
let cs = 0.1
let filterLength = sqrt(dx * dy)
for j in 1..<(grid.height - 1) {
for i in 1..<(grid.width - 1) {
let idx = grid.index(i, j)
let dudx = (grid.u[grid.index(i + 1, j)] - grid.u[grid.index(i - 1, j)]) / (2.0 * dx)
let dudy = (grid.u[grid.index(i, j + 1)] - grid.u[grid.index(i, j - 1)]) / (2.0 * dy)
let dvdx = (grid.v[grid.index(i + 1, j)] - grid.v[grid.index(i - 1, j)]) / (2.0 * dx)
let dvdy = (grid.v[grid.index(i, j + 1)] - grid.v[grid.index(i, j - 1)]) / (2.0 * dy)
let s11 = dudx
let s12 = 0.5 * (dudy + dvdx)
let s22 = dvdy
let strain = sqrt(2.0 * (s11 * s11 + 2.0 * s12 * s12 + s22 * s22))
nut[idx] = (cs * filterLength) * (cs * filterLength) * strain
}
}
return nut
}
模型参数Cs通常取0.1到0.2之间。仪表盘流体速度量级如果不是很高,可以把有效粘性限制在一个范围内,例如总粘性小于层流粘性的5倍,防止在初始扰动阶段出现负温度或数值振荡。加入限制后,大涡结构更稳定,颜色映射也不会出现跳变。
时间推进可以采用显式欧拉或二阶Runge-Kutta。对于64×64网格,二阶方法每步需要两次右侧计算,但能显著降低耗散,保留小尺度相位。移动端如果只有30帧动画,时间步长可以与帧率解耦,即每帧根据设备计算时间执行若干物理子步,这样显示不会因物理步长过小而失真。
三、把速度场映射到Core Graphics位图
计算得到的u、v数组不能直接显示。一种直观方法是计算涡量,把涡量换算成蓝白红发散色标,这样正负旋涡旋转方向清晰。另一种是使用速度幅值,适合表现流量计主射流和低速回流区。推荐先计算涡量幅值再做对数压缩,否则局部强涡会主导整张图,弱涡结构完全看不出来。
Core Graphics处理逐像素图像时,通常创建RGBA8888缓冲区,然后通过CGDataProvider生成CGImage,再交给上下文绘制。对于仪表盘,可以把湍流图绘制为背景层或半透明叠加层,再在上面用路径画刻度、指针和表盘边框。注意每次创建UIImage对象都会消耗内存,最好复用位图内存,只更新像素内容。
func makeTurbulenceImage(grid: LESGrid, width: Int, height: Int) -> UIImage? {
let bytesPerRow = width * 4
var pixelData = [UInt8](repeating: 0, count: bytesPerRow * height)
let colorSpace = CGColorSpaceCreateDeviceRGB()
for y in 0..<height {
for x in 0..<width {
let idx = grid.index(x, y)
let speed = sqrt(grid.u[idx] * grid.u[idx] + grid.v[idx] * grid.v[idx])
let norm = min(max(speed / 2.5, 0.0), 1.0)
let r = UInt8(norm * 255)
let b = UInt8((1.0 - norm) * 255)
let g = UInt8(120 + 80 * abs(grid.u[idx] / 2.5))
let pixelIndex = bytesPerRow * y + x * 4
pixelData[pixelIndex] = r
pixelData[pixelIndex + 1] = g
pixelData[pixelIndex + 2] = b
pixelData[pixelIndex + 3] = 255
}
}
guard let providerRef = CGDataProvider(data: Data(pixelData) as CFData) else { return nil }
guard let cgImage = CGImage(width: width,
height: height,
bitsPerComponent: 8,
bitsPerPixel: 32,
bytesPerRow: bytesPerRow,
space: colorSpace,
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue),
provider: providerRef,
decode: nil,
shouldInterpolate: false,
intent: .defaultIntent) else { return nil }
return UIImage(cgImage: cgImage)
}
仪表盘背景绘制时,先用暗色底降低对比度干扰,再把湍流图以较低透明度混合,最后叠加刻度线和指针。但如果完全依靠UIKit的draw方法逐帧画全部元素,CPU压力较大。更好的方式是把静态表盘渲染成一张纹理缓存,动态湍流层单独更新,组合时只做一次上下文合成。
func drawGaugeOverlay(in context: CGContext, rect: CGRect) {
context.setStrokeColor(UIColor.white.cgColor)
context.setLineWidth(2.0)
let center = CGPoint(x: rect.midX, y: rect.midY)
let radius = min(rect.width, rect.height) * 0.4
for i in 0..<36 {
let angle = CGFloat(i) * .pi / 18 - .pi
let outer = CGPoint(x: center.x + radius * cos(angle),
y: center.y + radius * sin(angle))
let inner = CGPoint(x: center.x + (radius - 10) * cos(angle),
y: center.y + (radius - 10) * sin(angle))
context.move(to: inner)
context.addLine(to: outer)
}
context.strokePath()
}
四、移动端性能与数值稳定性的平衡
在iPhone上做LES可视化,瓶颈通常不在浮点计算,而在内存分配和位图转换。每帧创建大型数组和CGImage会导致内存峰值升高、帧率抖动。建议将网格控制在96×96以下,同时只对表盘可视区域进行计算。若使用ARC下的Swift,要避免在闭包中捕获大量临时数组,否则自动释放池来不及释放。
数值稳定性方面,显式格式受CFL条件限制。对于速度幅值较大的射流区域,时间步长应当满足dt小于网格尺寸除以最大速度。实际调试时可以从0.0005秒左右起步,然后根据画面是否出现高频网格噪声逐步调整。也可以在入口附近增加一点随机扰动,但不要每帧重新生成随机数,否则引入白噪声会被误认为湍流脉动。
颜色映射同样需要做平滑处理。直接使用速度幅值时,高雷诺数下中心射流可能严重过曝。可以先对速度场做一次3×3均值滤波,再映射到色标。涡结构较丰富的区域可以使用涡量颜色,将正涡量映射为红色,负涡量映射为蓝色,零涡量附近用深灰,这样旋转方向一目了然。最终叠加到仪表盘上时,外层刻度使用高对比白色,内部湍流层保持半透明,既保留机械表盘的读数清晰度,又能看到流动脉动。
Core Graphics大涡模拟亚格子模型修改时间:2026-09-22 00:52:43