Files
OneCAD-Simulation/crates/lbm-gpu/src/lbm.wgsl
T
Christian Svensson 74dce59c7d lbm-gpu: sum the Zou-He deviations before adding the weights
The open boundaries summed the tangential and outgoing populations as
deviations from their weights plus the weights (2/3 and 1/6), in single
precision; at low speed the weights round away the deviations' last
digits. A low-speed case through an inlet, an outlet and a slip face
came out 2.26e-5 of the largest speed from the CPU under DXC, against
the 2e-5 the tests allow. The deviations are now summed first and the
total weight, exactly 1, added once: the same Zou-He rule, so results
change only at the round-off level.

Part of #562.
2026-10-17 10:45:09 -04:00

555 lines
21 KiB
WebGPU Shading Language
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The lattice Boltzmann step of fieldcraft_lbm::flow on the GPU: the same D3Q19 two-relaxation-
// time collision with Guo forcing, pull streaming with the per-link rules the CPU prepared
// (bounce-back with Ladd's moving-wall term, specular reflection, open placeholders), the Zou–He
// open boundaries, and the momentum exchange per force group. Populations are stored direction-
// major (f[q n + cell]) in two buffers, A–B: a step collides `f` in place, streams `f` into `g`
// and applies the open boundaries to `g`; the next step binds the buffers the other way round.
//
// Single precision, with every population stored as its deviation from the weight, h_q = f_q − w_q
// (Krüger et al. 2017, §13.3.4: the deviations are small, so f32 keeps their digits instead of
// the weight's): ρ = 1 + Σ h, momentum Σ h c (Σ w c = 0), and every rule below is written for h.
// The CPU scheme (double precision, fixed sums) is the reference; see the crate's tests for the
// tolerances.
//
// With heat, a D3Q7 scalar lattice (the rest direction and the six axes of D3Q19, weights ¼ and
// ⅛) rides on the flow in the buffers t and t2, stored as deviations from w_q T₀ (T₀ the initial
// temperature) so that f32 keeps the digits of the differences: θ = T − T₀ = Σ t. It collides
// (BGK) with the velocity the momentum collision left in `moments`, streams with the links' rules
// read through the group's thermal condition (anti-bounce-back for a held temperature, bounce-
// back for an adiabatic face, a flux added to the bounced population), takes its inlet
// temperatures and outlet neighbours, and gives each group the heat its links exchanged (in
// contrib.w and forces.w). The temperature feeds back as the buoyant acceleration −β g θ.
const Q: u32 = 19u;
const QT: u32 = 7u;
const WG: u32 = 256u;
const KIND_PULL: u32 = 0u;
const KIND_BOUNCE: u32 = 1u;
const KIND_REFLECT: u32 = 2u;
const KIND_OPEN: u32 = 3u;
struct Params {
n: u32,
nx: u32,
ny: u32,
nz: u32,
groups_x: u32,
links: u32,
open: u32,
groups: u32,
omega_plus: f32,
omega_minus: f32,
s_plus: f32,
s_minus: f32,
// The body acceleration (xyz) and whether there is one (w).
acc: vec4<f32>,
// The buoyancy −β g in lattice units (xyz) and whether there is any (w).
buoy: vec4<f32>,
// The scalar lattice: its relaxation rate (x), T₀ (y), whether there is heat (z).
therm: vec4<f32>,
};
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var<storage, read> fluid: array<u32>;
@group(0) @binding(2) var<storage, read_write> f: array<f32>;
@group(0) @binding(3) var<storage, read_write> g: array<f32>;
// Per link: (q | kind << 8 | q2 << 16, from, group, 0) and the moving-wall term.
@group(0) @binding(5) var<storage, read> link_u: array<vec4<u32>>;
@group(0) @binding(6) var<storage, read> link_f: array<f32>;
// Per open cell: (cell, axis, kind: 0 velocity / 1 density, 0) and (target xyz or density in x, inward sign in w).
@group(0) @binding(7) var<storage, read> open_u: array<vec4<u32>>;
@group(0) @binding(8) var<storage, read> open_f: array<vec4<f32>>;
// Per cell: velocity (with the half force) and density, from the moments kernel.
@group(0) @binding(9) var<storage, read_write> moments: array<vec4<f32>>;
// [0]: the largest squared lattice speed of the chunk (as f32 bits); [1]: a density was not positive.
@group(0) @binding(10) var<storage, read_write> stats: array<atomic<u32>>;
// Per link, the momentum it transferred to its group in the last step.
@group(0) @binding(11) var<storage, read_write> contrib: array<vec4<f32>>;
// Link indices ordered by group, and each group's range in that order (groups + 1 entries).
@group(0) @binding(12) var<storage, read> by_group: array<u32>;
@group(0) @binding(13) var<storage, read> group_start: array<u32>;
@group(0) @binding(14) var<storage, read_write> forces: array<vec4<f32>>;
// Per workgroup, the mass of its cells.
@group(0) @binding(15) var<storage, read_write> partials: array<f32>;
// The scalar populations, A–B like f and g (t[q n + cell]; see the notes above).
@group(0) @binding(16) var<storage, read_write> t: array<f32>;
@group(0) @binding(17) var<storage, read_write> t2: array<f32>;
// Per force group: its thermal condition (x: 0 adiabatic, 1 temperature, 2 flux) and the value
// (y: the held temperature − T₀, or the flux as a temperature per step).
@group(0) @binding(18) var<storage, read> group_t: array<vec4<f32>>;
// Per open cell: whether it holds a temperature (x) and that temperature − T₀ (y).
@group(0) @binding(19) var<storage, read> open_t: array<vec4<f32>>;
var<workgroup> red: array<vec4<f32>, 256>;
// The largest squared speed of a workgroup's cells (collide): one atomic per workgroup, not
// per cell, which serialise on a single address.
var<workgroup> speed: array<f32, 256>;
fn c_of(q: u32) -> vec3<i32> {
switch q {
case 0u: { return vec3<i32>(0, 0, 0); }
case 1u: { return vec3<i32>(1, 0, 0); }
case 2u: { return vec3<i32>(-1, 0, 0); }
case 3u: { return vec3<i32>(0, 1, 0); }
case 4u: { return vec3<i32>(0, -1, 0); }
case 5u: { return vec3<i32>(0, 0, 1); }
case 6u: { return vec3<i32>(0, 0, -1); }
case 7u: { return vec3<i32>(1, 1, 0); }
case 8u: { return vec3<i32>(-1, -1, 0); }
case 9u: { return vec3<i32>(1, -1, 0); }
case 10u: { return vec3<i32>(-1, 1, 0); }
case 11u: { return vec3<i32>(1, 0, 1); }
case 12u: { return vec3<i32>(-1, 0, -1); }
case 13u: { return vec3<i32>(1, 0, -1); }
case 14u: { return vec3<i32>(-1, 0, 1); }
case 15u: { return vec3<i32>(0, 1, 1); }
case 16u: { return vec3<i32>(0, -1, -1); }
case 17u: { return vec3<i32>(0, 1, -1); }
default: { return vec3<i32>(0, -1, 1); }
}
}
fn opp(q: u32) -> u32 {
if q == 0u { return 0u; }
if (q & 1u) == 1u { return q + 1u; }
return q - 1u;
}
fn w_of(q: u32) -> f32 {
if q == 0u { return 1.0 / 3.0; }
if q < 7u { return 1.0 / 18.0; }
return 1.0 / 36.0;
}
fn wt_of(q: u32) -> f32 {
if q == 0u { return 0.25; }
return 0.125;
}
fn has_heat() -> bool {
return params.therm.z > 0.5;
}
// The temperature of a cell above T₀: the sum of its scalar populations.
fn theta_of(cell: u32) -> f32 {
var s = 0.0;
for (var q = 0u; q < QT; q = q + 1u) {
s = s + t[q * params.n + cell];
}
return s;
}
// The body acceleration of a cell: the constant one plus the buoyant one.
fn acc_of(cell: u32) -> vec3<f32> {
var a = params.acc.xyz;
if params.buoy.w > 0.5 { a = a + params.buoy.xyz * theta_of(cell); }
return a;
}
fn forced() -> bool {
return params.acc.w > 0.5 || params.buoy.w > 0.5;
}
// The scalar equilibrium w_q T (1 + 4 c_q·u), shifted by w_q T₀ like the populations.
fn eq_t(q: u32, theta: f32, u: vec3<f32>) -> f32 {
let cu = 4.0 * dot(vec3<f32>(c_of(q)), u);
return wt_of(q) * (theta * (1.0 + cu) + params.therm.y * cu);
}
fn cell_of(lid: u32, wg: vec3<u32>) -> u32 {
return (wg.y * params.groups_x + wg.x) * WG + lid;
}
fn is_fluid(cell: u32) -> bool {
return fluid[cell] != 0u;
}
// Two-relaxation-time collision with Guo forcing, in place in f (Ginzburg et al. 2008; Guo et
// al. 2002; Krüger et al. 2017 ch. 10), and the chunk's largest speed.
@compute @workgroup_size(256)
fn collide(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
let cell = cell_of(lid, wg);
speed[lid] = 0.0;
if cell < params.n && is_fluid(cell) {
collide_cell(cell, lid);
}
workgroupBarrier();
for (var k = 128u; k > 0u; k = k >> 1u) {
if lid < k { speed[lid] = max(speed[lid], speed[lid + k]); }
workgroupBarrier();
}
if lid == 0u { atomicMax(&stats[0], bitcast<u32>(speed[0])); }
}
fn collide_cell(cell: u32, lid: u32) {
let n = params.n;
var fq: array<f32, 19>;
var drho = 0.0;
var j = vec3<f32>(0.0);
for (var q = 0u; q < Q; q = q + 1u) {
let v = f[q * n + cell];
fq[q] = v;
drho = drho + v;
j = j + vec3<f32>(c_of(q)) * v;
}
let rho = 1.0 + drho;
if !(rho > 0.0) || rho != rho || abs(rho) > 1e30 {
atomicStore(&stats[1], 1u);
return;
}
let a = acc_of(cell);
let force = rho * a;
let u = (j + 0.5 * force) / rho;
let uu = dot(u, u);
speed[lid] = uu;
if has_heat() { moments[cell] = vec4<f32>(u, rho); }
let uf = dot(u, force);
let forced = forced();
let wp = params.omega_plus;
let wm = params.omega_minus;
let sp = params.s_plus;
let sm = params.s_minus;
// Rest population: symmetric only. Equilibria are shifted by the weight like the populations.
let eq0 = w_of(0u) * (drho - 1.5 * rho * uu);
var out0 = fq[0] - wp * (fq[0] - eq0);
if forced { out0 = out0 + sp * w_of(0u) * (-3.0 * uf); }
f[cell] = out0;
for (var q = 1u; q < Q; q = q + 2u) {
let o = q + 1u;
let cv = vec3<f32>(c_of(q));
let cu = dot(cv, u);
let w = w_of(q);
let eq_plus = w * (drho + rho * (4.5 * cu * cu - 1.5 * uu));
let eq_minus = w * rho * 3.0 * cu;
let plus = 0.5 * (fq[q] + fq[o]);
let minus = 0.5 * (fq[q] - fq[o]);
let relax_plus = wp * (plus - eq_plus);
let relax_minus = wm * (minus - eq_minus);
var s_plus = 0.0;
var s_minus = 0.0;
if forced {
let cf = dot(cv, force);
s_plus = sp * w * (9.0 * cu * cf - 3.0 * uf);
s_minus = sm * w * 3.0 * cf;
}
f[q * n + cell] = fq[q] - relax_plus - relax_minus + s_plus + s_minus;
f[o * n + cell] = fq[o] - relax_plus + relax_minus + s_plus - s_minus;
}
}
// Pull streaming of f into g, first pass: every population from the neighbour at −c_q
// (wrapped; a wall or an open face overrides it in the second pass).
@compute @workgroup_size(256)
fn stream(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
let cell = cell_of(lid, wg);
if cell >= params.n { return; }
if !is_fluid(cell) { return; }
let n = params.n;
let nx = i32(params.nx);
let ny = i32(params.ny);
let nz = i32(params.nz);
let i = i32(cell % params.nx);
let jj = i32((cell / params.nx) % params.ny);
let k = i32(cell / (params.nx * params.ny));
for (var q = 0u; q < Q; q = q + 1u) {
let c = c_of(q);
let si = (i - c.x + nx) % nx;
let sj = (jj - c.y + ny) % ny;
let sk = (k - c.z + nz) % nz;
let src = u32((sk * ny + sj) * nx + si);
g[q * n + cell] = f[q * n + src];
}
}
// Second pass, one thread per link: the populations with a rule (a periodic image, a wall, a
// slip face, an open face). Every (cell, direction) has at most one link, so no two threads
// write the same population.
@compute @workgroup_size(256)
fn stream_links(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
let l = cell_of(lid, wg);
if l >= params.links { return; }
let n = params.n;
let lu = link_u[l];
let q = lu.x & 0xffu;
let kind = (lu.x >> 8u) & 0xffu;
let q2 = (lu.x >> 16u) & 0xffu;
let cell = lu.w;
var v = 0.0;
if kind == KIND_PULL {
v = f[q * n + lu.y];
} else if kind == KIND_BOUNCE {
v = f[opp(q) * n + cell] + link_f[l];
} else if kind == KIND_REFLECT {
// The mirrored direction has the same weight, so h reflects like f.
v = f[q2 * n + lu.y];
} else {
v = f[opp(q) * n + cell];
}
g[q * n + cell] = v;
}
// Zou–He velocity and pressure boundaries on the streamed populations in g (Zou & He 1997;
// Hecht & Harting 2010 for the D3Q19 transverse correction).
@compute @workgroup_size(256)
fn open_boundaries(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
let o = cell_of(lid, wg);
if o >= params.open { return; }
let n = params.n;
let ou = open_u[o];
let ot = open_f[o];
let cell = ou.x;
let axis = ou.y;
let sign = ot.w;
let a_half = 0.5 * acc_of(cell);
// The sums of h over the tangential (c_n = 0) and outgoing (c_n < 0) directions. The sums of
// f add the weights' (2/3 and 1/6 for D3Q19, so tangential + 2 outgoing gains exactly 1): the
// weight is added after the deviations are summed, not before, which in single precision would
// round away the deviations' last digits at low speed.
var tangential = 0.0;
var outgoing = 0.0;
for (var q = 0u; q < Q; q = q + 1u) {
let v = g[q * n + cell];
let cn = f32(c_of(q)[axis]) * sign;
if cn == 0.0 {
tangential = tangential + v;
} else if cn < 0.0 {
outgoing = outgoing + v;
}
}
var rho = 0.0;
var bare = vec3<f32>(0.0);
if ou.z == 0u {
bare = ot.xyz - a_half;
let un = sign * bare[axis];
rho = (1.0 + (tangential + 2.0 * outgoing)) / (1.0 - un);
} else {
rho = ot.x;
let un = ((rho - 1.0) - (tangential + 2.0 * outgoing)) / rho;
// Not `bare[axis] = …`: DX12's shader compiler (FXC) refuses a write through a
// dynamic index into a local vector (X3500).
bare = select(-a_half, vec3<f32>(sign * un), vec3<u32>(0u, 1u, 2u) == vec3<u32>(axis));
}
for (var q = 1u; q < Q; q = q + 1u) {
let c = c_of(q);
if f32(c[axis]) * sign > 0.0 {
let cu = dot(vec3<f32>(c), bare);
g[q * n + cell] = g[opp(q) * n + cell] + 6.0 * w_of(q) * rho * cu;
}
}
for (var t = 0u; t < 3u; t = t + 1u) {
if t == axis { continue; }
var m = -rho * bare[t];
for (var q = 0u; q < Q; q = q + 1u) {
m = m + g[q * n + cell] * f32(c_of(q)[t]);
}
for (var q = 1u; q < Q; q = q + 1u) {
let c = c_of(q);
if f32(c[axis]) * sign > 0.0 && c[t] != 0 {
g[q * n + cell] = g[q * n + cell] - 0.5 * m * f32(c[t]);
}
}
}
}
// The momentum each link transferred to its group in the last step, from the post-collision
// populations in f (Ladd 1994; Mei et al. 2002): c_out f* − c_in f'.
@compute @workgroup_size(256)
fn link_forces(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
let l = cell_of(lid, wg);
if l >= params.links { return; }
let n = params.n;
let lu = link_u[l];
let q = lu.x & 0xffu;
let kind = (lu.x >> 8u) & 0xffu;
let q2 = (lu.x >> 16u) & 0xffu;
let cell = lu.w;
// The deviations only: the weights' share of the exchange (2 w_q c_q per bounced link, which
// cancels between a wall's links in exact arithmetic but is 10⁴ times the shear they carry)
// is added on the host in double precision, so the sum keeps the deviations' digits.
var v = vec3<f32>(0.0);
var heat = 0.0;
if kind == KIND_BOUNCE {
let out = f[opp(q) * n + cell];
let back = out + link_f[l];
v = -vec3<f32>(c_of(q)) * (out + back);
} else if kind == KIND_REFLECT {
let out = f[q2 * n + lu.y];
v = vec3<f32>(c_of(q2)) * out - vec3<f32>(c_of(q)) * out;
}
// The heat the link gave its wall: what left the fluid minus what came back.
if has_heat() && q < QT && (kind == KIND_BOUNCE || kind == KIND_REFLECT) {
heat = t[opp(q) * n + cell] - thermal_rule(lu, q, kind, cell);
}
contrib[l] = vec4<f32>(v, heat);
}
// ---- The scalar lattice ----
// BGK collision of the scalar lattice, in place in t, with the velocity of the momentum
// collision.
@compute @workgroup_size(256)
fn collide_thermal(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
let cell = cell_of(lid, wg);
if cell >= params.n { return; }
if !is_fluid(cell) { return; }
let n = params.n;
var tq: array<f32, 7>;
var theta = 0.0;
for (var q = 0u; q < QT; q = q + 1u) {
let v = t[q * n + cell];
tq[q] = v;
theta = theta + v;
}
let u = moments[cell].xyz;
let omega = params.therm.x;
for (var q = 0u; q < QT; q = q + 1u) {
t[q * n + cell] = tq[q] - omega * (tq[q] - eq_t(q, theta, u));
}
}
// Pull streaming of t into t2, first pass (as `stream`).
@compute @workgroup_size(256)
fn stream_thermal(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
let cell = cell_of(lid, wg);
if cell >= params.n { return; }
if !is_fluid(cell) { return; }
let n = params.n;
let nx = i32(params.nx);
let ny = i32(params.ny);
let nz = i32(params.nz);
let i = i32(cell % params.nx);
let jj = i32((cell / params.nx) % params.ny);
let k = i32(cell / (params.nx * params.ny));
for (var q = 0u; q < QT; q = q + 1u) {
let c = c_of(q);
let si = (i - c.x + nx) % nx;
let sj = (jj - c.y + ny) % ny;
let sk = (k - c.z + nz) % nz;
let src = u32((sk * ny + sj) * nx + si);
t2[q * n + cell] = t[q * n + src];
}
}
// The scalar population a link's rule gives, from t: a pull as the flow's; at a wall or a slip
// face the group's thermal condition on the bounced population; an open placeholder.
fn thermal_rule(lu: vec4<u32>, q: u32, kind: u32, cell: u32) -> f32 {
let n = params.n;
if kind == KIND_PULL { return t[q * n + lu.y]; }
let back = t[opp(q) * n + cell];
if kind == KIND_OPEN { return back; }
let cond = group_t[lu.z];
let k = u32(cond.x);
if k == 1u { return -back + 2.0 * wt_of(q) * cond.y; }
if k == 2u { return back + cond.y; }
return back;
}
// Second pass, one thread per link of the first seven directions.
@compute @workgroup_size(256)
fn stream_links_thermal(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
let l = cell_of(lid, wg);
if l >= params.links { return; }
let lu = link_u[l];
let q = lu.x & 0xffu;
if q >= QT { return; }
let kind = (lu.x >> 8u) & 0xffu;
let cell = lu.w;
t2[q * params.n + cell] = thermal_rule(lu, q, kind, cell);
}
// Inlets hold their temperature (the equilibrium at the cell's velocity); outlets take the
// streamed populations of the neighbour one cell inward.
@compute @workgroup_size(256)
fn open_thermal(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
let o = cell_of(lid, wg);
if o >= params.open { return; }
let n = params.n;
let ou = open_u[o];
let cell = ou.x;
let held = open_t[o];
if held.x > 0.5 {
let u = moments[cell].xyz;
for (var q = 0u; q < QT; q = q + 1u) {
t2[q * n + cell] = eq_t(q, held.y, u);
}
} else {
var step = 1;
if ou.y == 1u { step = i32(params.nx); }
if ou.y == 2u { step = i32(params.nx * params.ny); }
let src = u32(i32(cell) + i32(open_f[o].w) * step);
for (var q = 0u; q < QT; q = q + 1u) {
t2[q * n + cell] = t2[q * n + src];
}
}
}
// Flags a temperature that stopped being a number.
@compute @workgroup_size(256)
fn thermal_check(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
let cell = cell_of(lid, wg);
if cell >= params.n { return; }
if !is_fluid(cell) { return; }
let theta = theta_of(cell);
if theta != theta || abs(theta) > 1e30 { atomicStore(&stats[2], 1u); }
}
// One workgroup per group: the sum of its links' contributions.
@compute @workgroup_size(256)
fn group_forces(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
let group = wg.y * params.groups_x + wg.x;
if group >= params.groups { return; }
let lo = group_start[group];
let hi = group_start[group + 1u];
var s = vec4<f32>(0.0);
for (var i = lo + lid; i < hi; i = i + WG) {
s = s + contrib[by_group[i]];
}
red[lid] = s;
workgroupBarrier();
for (var k = 128u; k > 0u; k = k >> 1u) {
if lid < k { red[lid] = red[lid] + red[lid + k]; }
workgroupBarrier();
}
if lid == 0u { forces[group] = red[0]; }
}
// The density and velocity (with the half force) of every cell from f; zero in solid cells.
@compute @workgroup_size(256)
fn cell_moments(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
let cell = cell_of(lid, wg);
if cell >= params.n { return; }
if !is_fluid(cell) {
moments[cell] = vec4<f32>(0.0);
return;
}
let n = params.n;
var drho = 0.0;
var j = vec3<f32>(0.0);
for (var q = 0u; q < Q; q = q + 1u) {
let v = f[q * n + cell];
drho = drho + v;
j = j + vec3<f32>(c_of(q)) * v;
}
let rho = 1.0 + drho;
let u = (j + 0.5 * rho * acc_of(cell)) / rho;
moments[cell] = vec4<f32>(u, rho);
}
// Per workgroup, the sum of its cells' densities (the mass, since every cell has unit volume).
@compute @workgroup_size(256)
fn mass(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
let group = wg.y * params.groups_x + wg.x;
let cell = group * WG + lid;
var v = 0.0;
if cell < params.n && is_fluid(cell) {
v = moments[cell].w;
}
red[lid] = vec4<f32>(v, 0.0, 0.0, 0.0);
workgroupBarrier();
for (var k = 128u; k > 0u; k = k >> 1u) {
if lid < k { red[lid] = red[lid] + red[lid + k]; }
workgroupBarrier();
}
if lid == 0u { partials[group] = red[0].x; }
}