run method
Returns linear RGBA for attachment zero. Null discards the fragment.
Implementation
@override
Vector4? run(Float32List v, ShaderBindings bindings, FragmentContext c) {
final scene = bindings.textures['scene_texture'];
if (scene == null) return Vector4(0.0, 0.0, 0.0, 1.0);
final params = bindings.vec4(
'CompositeInfo',
'params',
Vector4(1.0, 0.0, 1.0, 0.0),
);
final look = bindings.vec4(
'CompositeInfo',
'look',
Vector4(1.0, 1.0, 0.0, 0.0),
);
final lookMore = bindings.vec4(
'CompositeInfo',
'look_more',
Vector4(0.0, 0.0, 0.0, 1.0),
);
// `P2`: the lens's own bend, before anything is read, so everything laid
// over the scene bends with it — `Distort` in `composite.frag`. Nought
// leaves the coordinate the very value it was.
final lens = bindings.vec4('CompositeInfo', 'lens', Vector4.zero());
// `P6`: right of a debug view's split the scene holds display values,
// and only the encode touches them. A return here where the GLSL decides
// at its end: this rasteriser has no uniform control flow to keep.
// `A5.22`: lens.y names the sides — one the right, two the left, three
// both.
final rightOfWipe = v[0] >= lens.z;
final showsChannel = switch ((lens.y + 0.5).floor()) {
1 => rightOfWipe,
2 => !rightOfWipe,
>= 3 => true,
_ => false,
};
if (showsChannel) {
final raw = scene.sample(v[0], v[1]);
return Vector4(
toSrgb(raw.x.clamp(0.0, 1.0)),
toSrgb(raw.y.clamp(0.0, 1.0)),
toSrgb(raw.z.clamp(0.0, 1.0)),
raw.w,
);
}
final (u, w) = lens.x != 0.0
? _distort(v[0], v[1], lens.x, lookMore.w)
: (v[0], v[1]);
// Dispersion at sampling, because that is where a lens does it — see the
// note in `composite.frag`, which this mirrors operation for operation.
final sampled = scene.sample(u, w);
var color = Vector3(sampled.x, sampled.y, sampled.z);
if (look.w > 0.0) {
final ox = (u - 0.5) * look.w;
final oy = (w - 0.5) * look.w;
color.x = scene.sample(u + ox, w + oy).x;
color.z = scene.sample(u - ox, w - oy).z;
}
// lens.w: a shadow map in the scene slot, shown as its red channel in
// grey and opaque — `composite.frag` says why the other three cannot be
// trusted to be the same on every device.
final shadowMapView = lens.w > 0.5;
if (shadowMapView) color = Vector3(color.x, color.x, color.x);
final alpha = shadowMapView ? 1.0 : sampled.w;
// Occlusion first, then the glow — the order matters and it is the order
// `composite.frag` uses. Four taps in a 2×2, sized to the artefact rather
// than tuned against it: the occlusion pass rotates its kernel by the
// parity of the pixel, and this averages exactly that pattern away.
//
// Applied to the scene and **not** to the glow, so a lit crack in a corner
// keeps glowing. Adding the bloom first and scaling both — which is what
// the first draft of this function did — dims the one thing in a dark
// corner that should not dim.
final ao = bindings.textures['ao_texture'];
final strength = params.w.clamp(0.0, 1.0);
// Read once: the occlusion, the contact shadow and the display transform
// each take a lane of it, and this runs for every pixel of the frame.
final contactInfo = bindings.vec4(
'CompositeInfo',
'contact',
Vector4.zero(),
);
// `R7`: the local exposure, in stops, before anything is added to the
// scene — `composite.frag` multiplies the scene alone.
if (contactInfo.w > 0.0) {
final stops = bindings.textures['local_exposure_texture'];
if (stops != null) {
color.scale(
math.pow(2.0, stops.sample(u, w).x * contactInfo.w).toDouble(),
);
}
}
var shade = 1.0;
// `L5`: the light the indirect method left in rgb, added below.
Vector3? bounced;
if (ao != null && strength > 0.0) {
final texel = bindings.vec4('CompositeInfo', 'ao_texel', Vector4.zero());
final hx = texel.x * 0.5;
final hy = texel.y * 0.5;
final t0 = ao.sample(u + hx, w + hy);
final t1 = ao.sample(u - hx, w + hy);
final t2 = ao.sample(u + hx, w - hy);
final t3 = ao.sample(u - hx, w - hy);
// The share left open is in a; the occlusion methods write it into
// every channel.
final occlusion = 0.25 * (t0.w + t1.w + t2.w + t3.w);
shade = 1.0 + (occlusion - 1.0) * strength;
final indirect = contactInfo.z;
if (indirect != 0.0) {
final k = 0.25 * indirect * strength;
bounced = Vector3(
(t0.x + t1.x + t2.x + t3.x) * k,
(t0.y + t1.y + t2.y + t3.y) * k,
(t0.z + t1.z + t2.z + t3.z) * k,
);
}
}
// `gfx-76n`, into the same multiplier and with a strength of its own: the
// occlusion says how enclosed a point is, this says whether the sun reaches
// it, and a scene wants them at different amounts. One tap rather than the
// 2×2 above, because the march runs at the frame's own resolution and there
// is no rotated kernel to average away — see `composite.frag`, which this
// mirrors operation for operation.
final contactMap = bindings.textures['contact_shadow_texture'];
final contactStrength = contactInfo.x.clamp(0.0, 1.0);
if (contactMap != null && contactStrength > 0.0) {
final contact = contactMap.sample(u, w).x;
shade *= 1.0 + (contact - 1.0) * contactStrength;
}
// Skipped at exactly one, which is what both settings off comes to: a
// multiply by one is exact, so this is a shortcut rather than a difference,
// and it keeps the frames 96 goldens hold untouched by arithmetic
// they never used to go through.
if (shade != 1.0) color.scale(shade);
// Additive, and unconditional: the engine binds a black texture when bloom
// is off rather than leaving the sampler unbound, so there is no branch to
// make here either.
final bloom = bindings.textures['bloom_texture'];
if (bloom != null) {
final b = bloom.sample(u, w);
color += Vector3(b.x, b.y, b.z) * params.y;
}
if (bounced != null) color += bounced;
color.scale(math.max(params.x, 0.0));
final curve = (params.z + 0.5).floor();
final display = bindings.textures['display_texture'];
color = curve == 6 && display != null
? _sampleDisplay(display, color, math.max(contactInfo.y, 2.0))
: tonemapBy(color, curve);
// Grading after the tone map, then the barrel, then the film. The order is
// the one a camera imposes and it is the order `composite.frag` uses; the
// two are compared by thirty golden images and have to agree.
// A power about mid grey (0.18 in linear light), as `composite.frag`.
if (look.x != 1.0) {
double pivot(double x) =>
0.18 * math.pow(math.max(x, 0.0) / 0.18, look.x).toDouble();
color = Vector3(pivot(color.x), pivot(color.y), pivot(color.z));
}
final luma = 0.2126 * color.x + 0.7152 * color.y + 0.0722 * color.z;
color = Vector3(
luma + (color.x - luma) * look.y,
luma + (color.y - luma) * look.y,
luma + (color.z - luma) * look.y,
);
color.x *= 1.0 + look.z * 0.1;
color.z *= 1.0 - look.z * 0.1;
// `gfx-27n`: lift, then gamma, then gain — the order `composite.frag`
// applies them and the order a grading panel names them.
final lift = bindings.vec4('CompositeInfo', 'lift', Vector4.zero());
final gammaCurve = bindings.vec4(
'CompositeInfo',
'gamma',
Vector4(1.0, 1.0, 1.0, 0.0),
);
final gain = bindings.vec4(
'CompositeInfo',
'gain',
Vector4(1.0, 1.0, 1.0, 0.0),
);
// `c * (1 - lift) + lift`: black rises, white stays.
color = Vector3(
math.max(color.x * (1.0 - lift.x) + lift.x, 0.0),
math.max(color.y * (1.0 - lift.y) + lift.y, 0.0),
math.max(color.z * (1.0 - lift.z) + lift.z, 0.0),
);
if (gammaCurve.x != 1.0 || gammaCurve.y != 1.0 || gammaCurve.z != 1.0) {
color = Vector3(
math.pow(color.x, 1.0 / gammaCurve.x).toDouble(),
math.pow(color.y, 1.0 / gammaCurve.y).toDouble(),
math.pow(color.z, 1.0 / gammaCurve.z).toDouble(),
);
}
color = Vector3(color.x * gain.x, color.y * gain.y, color.z * gain.z);
// White balance and tint, which are the correction rather than the look
// `look.z` above is — see `LookSettings.whiteBalance`.
final encode = bindings.vec4(
'CompositeInfo',
'output_encode',
Vector4.zero(),
);
if (encode.y != 0.0 || encode.z != 0.0) {
color = Vector3(
color.x * (1.0 + encode.y * 0.20) - encode.z * 0.075,
color.y * (1.0 + encode.z * 0.15),
color.z * (1.0 - encode.y * 0.20) - encode.z * 0.075,
);
}
// The colour table, after the grade and before the barrel — the order
// `composite.frag` uses and the order a grading suite does.
final aoTexel = bindings.vec4('CompositeInfo', 'ao_texel', Vector4.zero());
final lut = bindings.textures['lut_texture'];
if (lut != null && aoTexel.z > 0.0) {
// Indexed and answered in sRGB, the space a `.cube` is written in.
final encodedIn = Vector3(
toSrgb(color.x.clamp(0.0, 1.0)),
toSrgb(color.y.clamp(0.0, 1.0)),
toSrgb(color.z.clamp(0.0, 1.0)),
);
final gradedEncoded = _sampleLut(
lut,
encodedIn,
math.max(aoTexel.w, 2.0),
);
final graded = Vector3(
toLinear(gradedEncoded.x),
toLinear(gradedEncoded.y),
toLinear(gradedEncoded.z),
);
final amount = aoTexel.z.clamp(0.0, 1.0);
color = Vector3(
color.x + (graded.x - color.x) * amount,
color.y + (graded.y - color.y) * amount,
color.z + (graded.z - color.z) * amount,
);
}
if (lookMore.x > 0.0) {
final aspect = math.max(lookMore.w, 1e-4);
final fx = (v[0] - 0.5) * (1.0 + (aspect - 1.0) * lookMore.y);
final fy = v[1] - 0.5;
final radius = (math.sqrt(fx * fx + fy * fy) * 1.41421356).clamp(
0.0,
1.0,
);
color.scale(1.0 - lookMore.x * radius);
}
// `gfx-24n`, and the grain below it: both are applied after the encode,
// because banding is an artefact of the 8-bit target and one output step
// is a fixed distance in display space and a wildly varying one in linear
// space.
final outputEncode = bindings.vec4(
'CompositeInfo',
'output_encode',
Vector4.zero(),
);
var encoded = Vector3(
toSrgb(math.max(color.x, 0.0)),
toSrgb(math.max(color.y, 0.0)),
toSrgb(math.max(color.z, 0.0)),
);
if (lookMore.z > 0.0) {
// **Moved out of linear light, where its own comment was wrong.** The
// noise is symmetric either way, but in linear the clamp took the
// negative half and the encode stretched the rest, so black rose. See
// `composite.frag`, which carries the measurement.
//
// **Not bit-identical to the GPU's, and it cannot be.** The hash is a
// sine of a large product, so single and double precision diverge in
// the fraction this keeps. The two golden sets are independent for
// exactly this class of difference; what has to match is the shape of
// the noise, not the bits.
final amount = (_hash(c.coord.x, c.coord.y) - 0.5) * lookMore.z;
encoded = Vector3(
encoded.x + amount,
encoded.y + amount,
encoded.z + amount,
);
}
if (outputEncode.x > 0.0) {
// **Bit-identical to the GPU's, unlike the grain above.** The Bayer cell
// is an integer table and a divide, so there is no precision to lose —
// which is the argument for an ordered matrix over a hash said in
// arithmetic rather than in taste.
// Centred: the cells' mean is -1/32, and the thirty-second undoes it.
final offset =
(_bayerCell(c.coord.x, c.coord.y) + 0.03125) * outputEncode.x;
encoded = Vector3(
encoded.x + offset,
encoded.y + offset,
encoded.z + offset,
);
}
return Vector4(encoded.x, encoded.y, encoded.z, alpha);
}