Smoke lit by the scene
Open the live demo · Read the source · View on GitHub
A smoke sprite is a picture of smoke lit from wherever the artist's light was. It stays lit from there whichever lamp it drifts past, so a puff between a red light and a blue one is the same grey on both sides.
A six-way sheet is six pictures of the same puff, each lit from one side:
right, left, top, bottom, back and front. The particle stage mixes them by
where each of the scene's lights really is. Which lights those are is the
renderer's answer, through ContributorLights: the lights a mesh of the
particles' bounds would get.
Step 1: A sheet with no asset #
A real sheet comes from a fluid tool's six-way export, repacked by
importSixWay, or from the baker in flutter3d_build. This page writes a
small one itself so it needs no file: a plain soft ball of smoke, 24 pixels
square, one frame.
The light reaching a point from one side is the density summed along its row
to that side's face. Each pixel then marches once from the viewer through its
column and adds up what each of the six lights leaves there. The two images
are laid out the way SixWayMaterial reads them: positive holds right, top,
back and coverage, negative holds left, bottom, front and emission, and a
cell's rows run bottom to top.
/// A six-way sheet of one frame, [n] pixels square, of a soft ball of smoke.
///
/// Single scattering along the six axes: the light reaching a voxel from one
/// side is the density summed along its row to that side's face, and each
/// pixel marches once from the viewer through its column. The layout is the
/// one `SixWayMaterial` reads: positive holds right, top, back and coverage,
/// negative holds left, bottom, front and emission, unpremultiplied, and the
/// rows run bottom to top.
({Uint8List positive, Uint8List negative}) _bakeBall(int n) {
const double extinction = 6.0;
final double step = 2.0 / n;
double at(int i) => -1.0 + (i + 0.5) * step;
int index(int x, int y, int z) => (z * n + y) * n + x;
final Float64List sigma = Float64List(n * n * n);
for (var z = 0; z < n; z++) {
for (var y = 0; y < n; y++) {
for (var x = 0; x < n; x++) {
final double r =
math.sqrt(at(x) * at(x) + at(y) * at(y) + at(z) * at(z)) / 0.8;
sigma[index(x, y, z)] = extinction * ((1.0 - r) * 2.5).clamp(0.0, 1.0);
}
}
}
// How much the smoke takes out between each voxel and the face each light
// comes in through: right, left, top, bottom, back (from -z), front.
final List<Float64List> depth = List<Float64List>.generate(
6,
(_) => Float64List(n * n * n),
);
void sweep(Float64List into, int Function(int a, int b, int s) voxel) {
for (var a = 0; a < n; a++) {
for (var b = 0; b < n; b++) {
var total = 0.0;
for (var s = 0; s < n; s++) {
final int i = voxel(a, b, s);
into[i] = total + sigma[i] * step * 0.5;
total += sigma[i] * step;
}
}
}
}
sweep(depth[0], (int y, int z, int s) => index(n - 1 - s, y, z));
sweep(depth[1], (int y, int z, int s) => index(s, y, z));
sweep(depth[2], (int x, int z, int s) => index(x, n - 1 - s, z));
sweep(depth[3], (int x, int z, int s) => index(x, s, z));
sweep(depth[4], (int x, int y, int s) => index(x, y, s));
sweep(depth[5], (int x, int y, int s) => index(x, y, n - 1 - s));
int byte(double v) => (v.clamp(0.0, 1.0) * 255.0 + 0.5).floor();
final Uint8List positive = Uint8List(n * n * 4);
final Uint8List negative = Uint8List(n * n * 4);
final Float64List lit = Float64List(6);
for (var y = 0; y < n; y++) {
for (var x = 0; x < n; x++) {
lit.fillRange(0, 6, 0.0);
var through = 1.0;
for (var z = n - 1; z >= 0; z--) {
final int i = index(x, y, z);
final double absorbed = 1.0 - math.exp(-sigma[i] * step);
final double weight = through * absorbed;
for (var d = 0; d < 6; d++) {
lit[d] += weight * math.exp(-depth[d][i]);
}
through *= 1.0 - absorbed;
}
final double coverage = 1.0 - through;
final double scale = coverage > 1e-4 ? 1.0 / coverage : 0.0;
final int out = (y * n + x) * 4;
positive
..[out] = byte(lit[0] * scale)
..[out + 1] = byte(lit[2] * scale)
..[out + 2] = byte(lit[4] * scale)
..[out + 3] = byte(coverage);
negative
..[out] = byte(lit[1] * scale)
..[out + 1] = byte(lit[3] * scale)
..[out + 2] = byte(lit[5] * scale)
..[out + 3] = 0;
}
}
return (positive: positive, negative: negative);
}
Step 2: Upload the two textures #
The two images become two textures and one SixWayMaterial. Nothing here
emits, so the emission channel is zero and emission stays at its default.
final ({Uint8List positive, Uint8List negative}) baked = _bakeBall(_cell);
TextureHandle upload(Uint8List bytes) =>
context.device.createTextureFromPixels(
width: _cell,
height: _cell,
format: TextureFormat.r8g8b8a8UNormInt,
pixels: ByteData.sublistView(bytes),
)!;
_sheet = SixWayMaterial(
positive: upload(baked.positive),
negative: upload(baked.negative),
);
Step 3: Three puffs that stand still #
Three particles with no speed and a long life, drawn by a ParticleContributor
given the sheet. A second contributor over the same particles has no sheet,
for comparing later.
_particles = ParticleSystem(capacity: 8);
_burst();
_lit = ParticleContributor(_particles, sixWay: _sheet);
_plain = ParticleContributor(_particles);
context.renderer.addContributor(_lit);
Step 4: A red light and a blue one #
Two point lights with no shadows, and a scene with no ambient light and no default light, so everything on the smoke comes from these two.
LightNode point(Vector3 colour) =>
LightNode(type: LightType.point, intensity: intensity, color: colour)
..castsShadow = false;
_red = point(Vector3(1.0, 0.2, 0.1));
_blue = point(Vector3(0.1, 0.2, 1.0));
final Scene scene = Scene()
..ambientIntensity = 0.0
..defaultLightWhenUnlit = false
..add(_red)
..add(_blue);
Note. A six-way contributor draws nothing outside a renderer's scene pass. That is where the lights are bound, and a stage that declares the light list and gets none would crash on Metal.
Step 5: Move the lights #
Lights around the puffs turns the pair around the puffs in a circle three metres out. At zero the red light is on the left and the blue on the right, and each side of each puff takes the colour of the light on that side. Turn it to 90 degrees and one light is behind the puffs: their thin edges glow with it, which is the "back" picture at work.
SixWayMaterial.ambient is light arriving evenly from every side, read
through the mean of the six pictures. It sits on the material rather than
coming from the scene, so the Ambient slider writes it directly.
final double c = math.cos(angle) * 3.0;
final double s = math.sin(angle) * 3.0;
_red
..setPosition(-c, 0.0, -s)
..intensity = intensity;
_blue
..setPosition(c, 0.0, s)
..intensity = intensity;
_sheet.ambient.setValues(ambient, ambient, ambient);
Step 6: Compare with a plain sprite #
Switch Six-way sheet off and the other contributor draws the same particles as the plain procedural disc. That stage binds no lights, so the puffs turn white and stay white however the lights move.
final ParticleContributor shown = sixWay ? _lit : _plain;
final ParticleContributor hidden = sixWay ? _plain : _lit;
if (context.renderer.removeContributor(hidden)) {
context.renderer.addContributor(shown);
}
Warning. The stage reads right and up as the camera's. A particle with a
rotationturns its picture but not its light, so a puff spun a quarter turn is lit as though its right were its top.
Step 7: What the page checks #
The three puffs are alive, the six-way contributor has something to draw, and the frame drew.
if (_particles.aliveCount != 3 || !_lit.isActive) {
throw StateError('the three puffs are not alive');
}
if (frame.drawCalls < 1) {
throw StateError('the frame drew nothing');
}