build method
Fills the buffer for a camera at eye whose axes are right and up,
with the cloud placed in the world by model when it is given.
Both axes are expected orthonormal, which is what a camera's own basis
is. The camera's forward axis is not needed: the order is by distance
from eye, which a turn does not change — see splat_sort.dart.
The quads are rebuilt every call, since their axes follow the camera's
own; the sort, which is most of the cost, runs only when the eye has
moved further than resortFraction allows or model has changed.
With sorted false there is no sort at all and the quads come out in
the cloud's own order — N5's hashed splats, which the depth test
orders. The last sort is kept, so going back to sorting re-sorts only if
the eye has moved since.
lens is the rest of the camera: with it each ellipse is the
perspective one and is widened by kSplatLowPass on the screen, which
is how SplatContributor always builds. Without it the covariance is
projected onto right and up alone and nothing is added — the exact
ellipse on the view axis, and a quad whose size is the splat's own, for
a caller with no projection to hand.
Implementation
void build({
required Vector3 eye,
required Vector3 right,
required Vector3 up,
Matrix4? model,
bool sorted = true,
SplatLens? lens,
}) {
// A tree's cut is chosen where the sort runs, so a hashed build still
// sorts when the cut moves: the order and the cut have to agree.
if ((sorted || lod != null) && _needsSort(eye, model)) {
final lod = this.lod;
if (lod != null) {
// The cut is chosen by distance in the tree's own space, so the eye
// is taken there once rather than every node taken to the world.
_cloud = lod.choose(
model == null ? eye : Matrix4.inverted(model).transformed3(eye),
);
_sortedPageVersion = lod.tree.pageVersion;
_sortedBudget = lod.budget;
}
_sorter.sort(cloud, eye, model: model);
_sorts++;
_sortedEye = eye.clone();
_sortedWithModel = model != null;
if (model != null) _sortedModel.setAll(0, model.storage);
}
final order = sorted ? _sorter.order : null;
final count = cloud.count;
final needed = count * kSplatVerticesPerSplat * kSplatFloatsPerVertex;
if (_vertices.length < needed) _vertices = Float32List(needed);
// The camera's axes as the cloud's own space sees them, when it has one.
// The ellipse is `[r; u] M Σ Mᵀ [r; u]ᵀ` for a cloud placed by `M`, and
// that is the unplaced formula with `Mᵀr` and `Mᵀu` in place of `r` and
// `u` — so the placement costs six dot products once, not a matrix
// product per splat. The quad's own corners stay in world space, built
// from the world `r` and `u` below.
final m = model?.storage;
final rx = m == null
? right.x
: m[0] * right.x + m[1] * right.y + m[2] * right.z;
final ry = m == null
? right.y
: m[4] * right.x + m[5] * right.y + m[6] * right.z;
final rz = m == null
? right.z
: m[8] * right.x + m[9] * right.y + m[10] * right.z;
final ux = m == null ? up.x : m[0] * up.x + m[1] * up.y + m[2] * up.z;
final uy = m == null ? up.y : m[4] * up.x + m[5] * up.y + m[6] * up.z;
final uz = m == null ? up.z : m[8] * up.x + m[9] * up.y + m[10] * up.z;
final wrx = right.x, wry = right.y, wrz = right.z;
final wux = up.x, wuy = up.y, wuz = up.z;
// The view axis, in the world and in the cloud's space as above, when
// there is a lens to lean the rows of `J` along it.
final forward = lens?.forward;
final wfx = forward?.x ?? 0.0;
final wfy = forward?.y ?? 0.0;
final wfz = forward?.z ?? 0.0;
final fx = m == null ? wfx : m[0] * wfx + m[1] * wfy + m[2] * wfz;
final fy = m == null ? wfy : m[4] * wfx + m[5] * wfy + m[6] * wfz;
final fz = m == null ? wfz : m[8] * wfx + m[9] * wfy + m[10] * wfz;
final leanX = lens == null ? 0.0 : kSplatLeanLimit * lens.tanHalfWidth;
final leanY = lens == null ? 0.0 : kSplatLeanLimit * lens.tanHalfHeight;
var at = 0;
for (var n = 0; n < count; n++) {
final i = order == null ? n : order[n];
cloud.covarianceOf(i, _covariance);
final lx = cloud.centres[i * 3];
final ly = cloud.centres[i * 3 + 1];
final lz = cloud.centres[i * 3 + 2];
final cx = m == null ? lx : m[0] * lx + m[4] * ly + m[8] * lz + m[12];
final cy = m == null ? ly : m[1] * lx + m[5] * ly + m[9] * lz + m[13];
final cz = m == null ? lz : m[2] * lx + m[6] * ly + m[10] * lz + m[14];
// Where the splat sits in the camera's frame, and so how far `J`'s rows
// lean: `x/w` and `y/w` along the view axis, bounded as [kSplatLeanLimit]
// says, and nothing for an orthographic lens, whose `w` does not move.
// Its size in pixels is `focal / w` per world unit in the quad's plane.
final double leanR, leanU, pixel;
if (lens == null) {
(leanR, leanU, pixel) = (0.0, 0.0, 0.0);
} else {
final tx = (cx - eye.x) * wrx + (cy - eye.y) * wry + (cz - eye.z) * wrz;
final ty = (cx - eye.x) * wux + (cy - eye.y) * wuy + (cz - eye.z) * wuz;
final tz = (cx - eye.x) * wfx + (cy - eye.y) * wfy + (cz - eye.z) * wfz;
final w = lens.depthWeight * tz + lens.depthOffset;
// Behind the eye nothing is drawn, and the divide means nothing.
final ahead = w > 1e-6;
leanR = ahead
? lens.depthWeight * (tx / w).clamp(-leanX, leanX).toDouble()
: 0.0;
leanU = ahead
? lens.depthWeight * (ty / w).clamp(-leanY, leanY).toDouble()
: 0.0;
pixel = ahead && lens.focal > 0.0 ? w / lens.focal : 0.0;
}
// The rows of `J`, rescaled to the quad's plane: right and up, each
// leaned along the view axis by the splat's offset from it.
final jrx = rx - leanR * fx, jry = ry - leanR * fy, jrz = rz - leanR * fz;
final jux = ux - leanU * fx, juy = uy - leanU * fy, juz = uz - leanU * fz;
// The 3D covariance seen from the camera: `J Σ Jᵀ`, the 2×2 the ellipse
// comes from. Written out rather than multiplied as matrices because
// two of the three rows of the result are never used.
final sxx = _covariance[0], sxy = _covariance[1], sxz = _covariance[2];
final syy = _covariance[3], syz = _covariance[4], szz = _covariance[5];
// Σ·j1 and Σ·j2.
final arx = sxx * jrx + sxy * jry + sxz * jrz;
final ary = sxy * jrx + syy * jry + syz * jrz;
final arz = sxz * jrx + syz * jry + szz * jrz;
final aux = sxx * jux + sxy * juy + sxz * juz;
final auy = sxy * jux + syy * juy + syz * juz;
final auz = sxz * jux + syz * juy + szz * juz;
// With the screen's low-pass filter added to the diagonal, taken from
// pixels squared to the quad's world units squared.
final dilation = kSplatLowPass * pixel * pixel;
final a = jrx * arx + jry * ary + jrz * arz + dilation;
final b = jrx * aux + jry * auy + jrz * auz;
final d = jux * aux + juy * auy + juz * auz + dilation;
// The 2×2's eigenvectors and the standard deviations along them. A
// closed form rather than an iteration: for a symmetric 2×2 the whole
// decomposition is a half-sum, a half-difference and one square root.
final half = 0.5 * (a + d);
final spread = math.sqrt(math.max(0.25 * (a - d) * (a - d) + b * b, 0.0));
final major = math.max(half + spread, 0.0);
final minor = math.max(half - spread, 0.0);
// The major axis, in the camera's own 2D frame. When the off-diagonal is
// nothing the ellipse is already axis-aligned and the general formula
// divides by zero, so that case is taken directly.
final double e1x, e1y;
if (b.abs() < 1e-12) {
e1x = a >= d ? 1.0 : 0.0;
e1y = a >= d ? 0.0 : 1.0;
} else {
final vx = major - d;
final length = math.sqrt(vx * vx + b * b);
e1x = vx / length;
e1y = b / length;
}
final sigma1 = math.sqrt(major) * kSplatReach;
final sigma2 = math.sqrt(minor) * kSplatReach;
// The two quad axes, back in world space.
final axX = (e1x * sigma1) * wrx + (e1y * sigma1) * wux;
final axY = (e1x * sigma1) * wry + (e1y * sigma1) * wuy;
final axZ = (e1x * sigma1) * wrz + (e1y * sigma1) * wuz;
final ayX = (-e1y * sigma2) * wrx + (e1x * sigma2) * wux;
final ayY = (-e1y * sigma2) * wry + (e1x * sigma2) * wuy;
final ayZ = (-e1y * sigma2) * wrz + (e1x * sigma2) * wuz;
final r = cloud.colours[i * 4];
final g = cloud.colours[i * 4 + 1];
final bl = cloud.colours[i * 4 + 2];
final alpha = cloud.colours[i * 4 + 3];
// Two triangles over the four corners. The texture coordinate is the
// corner's position in the Gaussian's own frame, in standard deviations,
// which is what `splat.frag` evaluates its falloff from — so the corners
// carry ±[kSplatReach] rather than the zero-to-one a texture would want.
void corner(double sx, double sy) {
_vertices[at] = cx + axX * sx + ayX * sy;
_vertices[at + 1] = cy + axY * sx + ayY * sy;
_vertices[at + 2] = cz + axZ * sx + ayZ * sy;
_vertices[at + 3] = r;
_vertices[at + 4] = g;
_vertices[at + 5] = bl;
_vertices[at + 6] = alpha;
_vertices[at + 7] = sx * kSplatReach;
_vertices[at + 8] = sy * kSplatReach;
at += kSplatFloatsPerVertex;
}
corner(-1, -1);
corner(1, -1);
corner(1, 1);
corner(-1, -1);
corner(1, 1);
corner(-1, 1);
}
vertexCount = count * kSplatVerticesPerSplat;
}