engineShaders top-level property

ShaderSources engineShaders
final

Every shader the engine asks for, in GLSL ES 3.00.

Implementation

final ShaderSources engineShaders = ShaderSources(
  <String, String>{
    'MeshVertex': r'''#version 300 es

// The order and types of the `in` variables define the vertex layout:
// flutter_gpu binds a vertex buffer as one blob, with no attribute descriptors.
// The Dart-side vertex struct must match this declaration byte for byte; see
// VertexLayout.standard.
//
// One layout for every model rather than a permutation per attribute set. The
// layout is structural — it is taken from these declarations — so a second one
// would mean a second vertex shader and a second pipeline per lighting model.
in vec3 position;
in vec3 normal;
in vec2 texcoord;

/// xyz is the tangent direction, w is the bitangent sign (glTF convention).
in vec4 tangent;

/// Vertex colour, multiplied into the albedo. Neutral is opaque white.
in vec4 color;

// --- lib/morph.glsl ---
// Morph targets, applied in the vertex stage from a texture of deltas.
//
// ## Why a texture and not attributes
//
// The vertex layout in this engine is **structural**: the `in` declarations of
// `mesh.vert` are the layout, and one layout serves every model so that a
// lighting model needs one pipeline rather than one per attribute set. Morph
// deltas as attributes would mean a second layout, and with it a second vertex
// shader for every lighting model — six of them — and a second pipeline for
// each. A texture read by vertex index costs one sampler and no layout at all.
//
// That the read is possible is measured rather than assumed:
// `checkVertexTextureSampling` in `flutter3d_conformance` draws through a
// vertex stage that samples, on all three backends. It answers yes on each,
// Impeller included, which was the one that could not be settled by reading a
// header.
//
// ## The layout of the texture
//
// `r32g32b32a32Float`, width = the mesh's vertex count, height = one row per
// delta stream per target. Target *t* occupies rows `t * MORPH_ROWS` upwards:
//
//     row + 0   position delta, xyz
//     row + 1   normal delta, xyz     (zero when the file carried none)
//     row + 2   tangent delta, xyz    (zero when the file carried none)
//
// Three rows always, so the arithmetic is a multiply rather than a table: a
// target that morphs only positions costs two rows of zeros, which is memory
// and not branches. `MorphTargetTexture` on the Dart side packs exactly this.
//
// **`texture` at a texel centre, and it should have been `texelFetch`.** There
// is nothing to filter — a vertex has exactly one delta per target — so the
// fetch is the operation this wants: no size arithmetic, no sampler state, no
// half-texel to get wrong.
//
// It is not used because **impellerc crashes on `texelFetch` in a vertex
// stage**: SIGABRT, no diagnostic, exit 134. Bisected — the same call in a
// *fragment* stage compiles, `gl_VertexID` alone compiles, and `texture()`
// in a vertex stage compiles, so it is that one combination. So the coordinate
// is built by hand, `(index + 0.5) / size`, and the sampler is bound nearest
// and clamped: exactly the texel, reached the long way round. The size comes
// down in `morph_params` rather than from `textureSize`, which is one more
// thing that would have to survive the same compiler.

#ifndef MORPH_GLSL_
#define MORPH_GLSL_

/// Rows of the delta texture each target occupies. See the header.
const int kMorphRows = 3;

/// The most targets one draw can blend.
///
/// Eight because glTF's own guidance is that an engine support at least eight
/// active targets, and because a `vec4[2]` is two registers. A model carrying
/// more is not refused — the renderer sends the first eight and says so, which
/// is a face missing an expression rather than a face that will not load.
const int kMorphMax = 8;

uniform sampler2D morph_texture;

layout(std140) uniform MorphInfo {
  /// Weight of target *i* at `morph_weights[i / 4][i % 4]`.
  vec4 morph_weights[2];

  /// x: how many targets are active, as a float.
  /// y: one texel across, `1 / width`. z: one texel down, `1 / height`.
  /// w unused.
  ///
  /// A count rather than a convention that a zero weight means absent: a
  /// target held at exactly nought is a face that is not smiling, and reading
  /// it as "the list ends here" would stop the ones after it.
  vec4 morph_params;
}
morph_info;

/// How many targets this draw blends.
int MorphCount() { return int(morph_info.morph_params.x + 0.5); }

/// The vertex's own column in the delta texture.
///
/// **`gl_VertexID`, spelt the way SPIR-V spells it.** GLSL ES 3.00 calls the
/// same builtin `gl_VertexID`, and the browser backend's translator rewrites
/// the name on its way out — one substitution beside the ones it already makes
/// for `#version` and `layout(std140)`. Written the other way round, impellerc
/// refuses it outright: "undeclared identifier (Did you mean gl_VertexID?)",
/// which is the friendliest error in this repository.
float MorphColumn() {
  return (float(gl_VertexID) + 0.5) * morph_info.morph_params.y;
}

/// Adds target *t*'s deltas onto one vertex, scaled by [weight].
///
/// Split out of [ApplyMorph] so that a stage which gets its weights from
/// somewhere else — `lib/morph_instanced.glsl`, where each instance of a batch
/// wears its own — reads the deltas through the same three lines rather than
/// through a second copy of them.
///
/// [column] and [rowStep] are the caller's, worked out once rather than per
/// target.
///
/// **Splitting this out moved the picture, by 31 pixels of silhouette on
/// Impeller**, and the reference set was re-recorded rather than the split
/// abandoned. The arithmetic is the same arithmetic — it was checked against
/// the software backend, which draws it identically either way — so what moved
/// is what impellerc's optimiser does with a function call it can no longer
/// see through. Hoisting the coordinates was the first guess at the cause and
/// was not it: the same 31 pixels moved with them hoisted. Worth writing down,
/// because the next person to factor a line out of a vertex stage will see a
/// golden fail and reach for the same wrong explanation.
void AddMorphTargetAt(int t, float weight, float column, float rowStep,
                      inout vec3 position, inout vec3 normal,
                      inout vec4 tangent) {
  float row = (float(t * kMorphRows) + 0.5) * rowStep;

  position += texture(morph_texture, vec2(column, row)).xyz * weight;
  normal += texture(morph_texture, vec2(column, row + rowStep)).xyz * weight;
  tangent.xyz +=
      texture(morph_texture, vec2(column, row + rowStep * 2.0)).xyz * weight;
}

/// Adds the blended deltas onto one vertex.
///
/// Called with the attributes as they were read and before anything else
/// touches them — skinning included, which is the order glTF specifies: a
/// skinned morphed mesh morphs in its rest pose and is then posed by the
/// skeleton.
///
/// The tangent is a `vec4` and only its xyz move: w is the bitangent sign, a
/// handedness rather than a direction, and glTF does not morph it.
void ApplyMorph(inout vec3 position, inout vec3 normal, inout vec4 tangent) {
  int count = MorphCount();
  if (count <= 0) return;

  float column = MorphColumn();
  float rowStep = morph_info.morph_params.z;

  for (int i = 0; i < kMorphMax; i++) {
    if (i >= count) break;
    float weight = morph_info.morph_weights[i / 4][i % 4];
    if (weight == 0.0) continue;
    AddMorphTargetAt(i, weight, column, rowStep, position, normal, tangent);
  }
}

#endif  // MORPH_GLSL_


layout(std140) uniform FrameInfo {
  mat4 mvp;
  mat4 model;

  /// Inverse-transpose of the model matrix. Computed on the CPU because doing
  /// it per vertex would waste the ALU, and because mat3(model) is only correct
  /// while the scale stays uniform.
  mat4 normal_matrix;
}
frame_info;

// One varying set shared by every lighting model, matching shaders/lib/color.glsl.
out vec3 v_world_position;
out vec3 v_normal;
out vec2 v_texcoord;
out vec4 v_tangent;
out vec4 v_color;
out vec2 v_lightmap_uv;

void main() {
  // Morphed first and in the mesh's own space, which is the order glTF
  // specifies: a morphed vertex is then transformed, and a morphed *skinned*
  // vertex is morphed in its rest pose before the skeleton poses it.
  vec3 morphed_position = position;
  vec3 morphed_normal = normal;
  vec4 morphed_tangent = tangent;
  ApplyMorph(morphed_position, morphed_normal, morphed_tangent);

  vec4 world = frame_info.model * vec4(morphed_position, 1.0);
  v_world_position = world.xyz;
  v_normal = mat3(frame_info.normal_matrix) * morphed_normal;
  v_texcoord = texcoord;

  // The tangent transforms with the model matrix, not the normal matrix: it
  // lies *in* the surface, so it stretches with the geometry rather than
  // resisting it. Using the inverse transpose here is the classic way to get a
  // TBN that is subtly wrong under non-uniform scale.
  //
  // The sign goes through a mirror too. The fragment stage rebuilds the
  // bitangent as cross(n, t) * w, and a cross product of two transformed
  // vectors comes out multiplied by the matrix's determinant, so under a
  // negative scale it points the opposite way from the transformed bitangent
  // and the green channel of a normal map on a mirrored copy lights from the
  // wrong side. Folding the determinant's sign into w turns it back.
  bool mirrored = determinant(mat3(frame_info.model)) < 0.0;
  v_tangent = vec4(mat3(frame_info.model) * morphed_tangent.xyz,
                   mirrored ? -morphed_tangent.w : morphed_tangent.w);
  v_color = color;
  v_lightmap_uv = vec2(0.0);

  gl_Position = frame_info.mvp * vec4(morphed_position, 1.0);
}

''',
    'DebugLineVertex': r'''#version 300 es

// Vertex stage for the debug line overlay.
//
// A separate vertex shader rather than a reuse of mesh.vert: the debug buffer is
// position + colour with no normal or texcoord, and flutter_gpu takes the vertex
// layout from the order of `in` declarations, so a different layout means a
// different shader. See VertexLayout.positionColor.
in vec3 position;
in vec4 color;

layout(std140) uniform LineInfo {
  mat4 view_projection;
}
line_info;

out vec4 v_line_color;

void main() {
  v_line_color = color;
  gl_Position = line_info.view_projection * vec4(position, 1.0);
}

''',
    'FullscreenVertex': r'''#version 300 es

// Vertex stage for every full-screen pass.
//
// A single oversized triangle, not a quad. A quad has a diagonal seam where the
// two triangles meet, and the GPU rasterizes 2x2 quads of fragments along it
// twice; one triangle that covers the screen has no seam and no duplicated
// work. The extra area outside the viewport is clipped for free.
//
// The three vertices come from a tiny vertex buffer rather than from
// gl_VertexID, because flutter_gpu's draw() renders nothing without an index
// buffer bound, so there is a buffer to bind either way.
in vec2 position;
in vec2 texcoord;

out vec2 v_uv;

void main() {
  v_uv = texcoord;
  gl_Position = vec4(position, 0.0, 1.0);
}

''',
    'MeshSkinnedVertex': r'''#version 300 es

// The skinned vertex stage.
//
// A second vertex shader rather than a branch inside mesh.vert, and the reason
// is structural rather than a performance guess: flutter_gpu takes the vertex
// layout from the `in` declarations, so joints and weights being attributes
// makes this a different layout, and a different layout is a different shader
// whatever the body does. Declaring the joint matrices in the static shader and
// leaving them unread would also be the phantom-uniform trap — reflection would
// report the block while the compiled function bound no buffer.
//
// The fragment side is untouched: skinning moves vertices, and every lighting
// model reads the same varyings either way.
in vec3 position;
in vec3 normal;
in vec2 texcoord;
in vec4 tangent;
in vec4 color;

// --- lib/morph.glsl ---
// Morph targets, applied in the vertex stage from a texture of deltas.
//
// ## Why a texture and not attributes
//
// The vertex layout in this engine is **structural**: the `in` declarations of
// `mesh.vert` are the layout, and one layout serves every model so that a
// lighting model needs one pipeline rather than one per attribute set. Morph
// deltas as attributes would mean a second layout, and with it a second vertex
// shader for every lighting model — six of them — and a second pipeline for
// each. A texture read by vertex index costs one sampler and no layout at all.
//
// That the read is possible is measured rather than assumed:
// `checkVertexTextureSampling` in `flutter3d_conformance` draws through a
// vertex stage that samples, on all three backends. It answers yes on each,
// Impeller included, which was the one that could not be settled by reading a
// header.
//
// ## The layout of the texture
//
// `r32g32b32a32Float`, width = the mesh's vertex count, height = one row per
// delta stream per target. Target *t* occupies rows `t * MORPH_ROWS` upwards:
//
//     row + 0   position delta, xyz
//     row + 1   normal delta, xyz     (zero when the file carried none)
//     row + 2   tangent delta, xyz    (zero when the file carried none)
//
// Three rows always, so the arithmetic is a multiply rather than a table: a
// target that morphs only positions costs two rows of zeros, which is memory
// and not branches. `MorphTargetTexture` on the Dart side packs exactly this.
//
// **`texture` at a texel centre, and it should have been `texelFetch`.** There
// is nothing to filter — a vertex has exactly one delta per target — so the
// fetch is the operation this wants: no size arithmetic, no sampler state, no
// half-texel to get wrong.
//
// It is not used because **impellerc crashes on `texelFetch` in a vertex
// stage**: SIGABRT, no diagnostic, exit 134. Bisected — the same call in a
// *fragment* stage compiles, `gl_VertexID` alone compiles, and `texture()`
// in a vertex stage compiles, so it is that one combination. So the coordinate
// is built by hand, `(index + 0.5) / size`, and the sampler is bound nearest
// and clamped: exactly the texel, reached the long way round. The size comes
// down in `morph_params` rather than from `textureSize`, which is one more
// thing that would have to survive the same compiler.

#ifndef MORPH_GLSL_
#define MORPH_GLSL_

/// Rows of the delta texture each target occupies. See the header.
const int kMorphRows = 3;

/// The most targets one draw can blend.
///
/// Eight because glTF's own guidance is that an engine support at least eight
/// active targets, and because a `vec4[2]` is two registers. A model carrying
/// more is not refused — the renderer sends the first eight and says so, which
/// is a face missing an expression rather than a face that will not load.
const int kMorphMax = 8;

uniform sampler2D morph_texture;

layout(std140) uniform MorphInfo {
  /// Weight of target *i* at `morph_weights[i / 4][i % 4]`.
  vec4 morph_weights[2];

  /// x: how many targets are active, as a float.
  /// y: one texel across, `1 / width`. z: one texel down, `1 / height`.
  /// w unused.
  ///
  /// A count rather than a convention that a zero weight means absent: a
  /// target held at exactly nought is a face that is not smiling, and reading
  /// it as "the list ends here" would stop the ones after it.
  vec4 morph_params;
}
morph_info;

/// How many targets this draw blends.
int MorphCount() { return int(morph_info.morph_params.x + 0.5); }

/// The vertex's own column in the delta texture.
///
/// **`gl_VertexID`, spelt the way SPIR-V spells it.** GLSL ES 3.00 calls the
/// same builtin `gl_VertexID`, and the browser backend's translator rewrites
/// the name on its way out — one substitution beside the ones it already makes
/// for `#version` and `layout(std140)`. Written the other way round, impellerc
/// refuses it outright: "undeclared identifier (Did you mean gl_VertexID?)",
/// which is the friendliest error in this repository.
float MorphColumn() {
  return (float(gl_VertexID) + 0.5) * morph_info.morph_params.y;
}

/// Adds target *t*'s deltas onto one vertex, scaled by [weight].
///
/// Split out of [ApplyMorph] so that a stage which gets its weights from
/// somewhere else — `lib/morph_instanced.glsl`, where each instance of a batch
/// wears its own — reads the deltas through the same three lines rather than
/// through a second copy of them.
///
/// [column] and [rowStep] are the caller's, worked out once rather than per
/// target.
///
/// **Splitting this out moved the picture, by 31 pixels of silhouette on
/// Impeller**, and the reference set was re-recorded rather than the split
/// abandoned. The arithmetic is the same arithmetic — it was checked against
/// the software backend, which draws it identically either way — so what moved
/// is what impellerc's optimiser does with a function call it can no longer
/// see through. Hoisting the coordinates was the first guess at the cause and
/// was not it: the same 31 pixels moved with them hoisted. Worth writing down,
/// because the next person to factor a line out of a vertex stage will see a
/// golden fail and reach for the same wrong explanation.
void AddMorphTargetAt(int t, float weight, float column, float rowStep,
                      inout vec3 position, inout vec3 normal,
                      inout vec4 tangent) {
  float row = (float(t * kMorphRows) + 0.5) * rowStep;

  position += texture(morph_texture, vec2(column, row)).xyz * weight;
  normal += texture(morph_texture, vec2(column, row + rowStep)).xyz * weight;
  tangent.xyz +=
      texture(morph_texture, vec2(column, row + rowStep * 2.0)).xyz * weight;
}

/// Adds the blended deltas onto one vertex.
///
/// Called with the attributes as they were read and before anything else
/// touches them — skinning included, which is the order glTF specifies: a
/// skinned morphed mesh morphs in its rest pose and is then posed by the
/// skeleton.
///
/// The tangent is a `vec4` and only its xyz move: w is the bitangent sign, a
/// handedness rather than a direction, and glTF does not morph it.
void ApplyMorph(inout vec3 position, inout vec3 normal, inout vec4 tangent) {
  int count = MorphCount();
  if (count <= 0) return;

  float column = MorphColumn();
  float rowStep = morph_info.morph_params.z;

  for (int i = 0; i < kMorphMax; i++) {
    if (i >= count) break;
    float weight = morph_info.morph_weights[i / 4][i % 4];
    if (weight == 0.0) continue;
    AddMorphTargetAt(i, weight, column, rowStep, position, normal, tangent);
  }
}

#endif  // MORPH_GLSL_


/// Four joint indices, held as floats. See VertexLayout.joints.
in vec4 joints;

/// Their influences. Normalized here rather than trusted, because an exporter
/// that rounds to a normalized byte leaves sums a little off one, and the error
/// shows up as a mesh that breathes.
in vec4 weights;

layout(std140) uniform FrameInfo {
  mat4 mvp;
  mat4 model;
  mat4 normal_matrix;
}
frame_info;

/// Must match Skeleton.maxJoints on the Dart side.
///
/// A fixed array with the count implied by the data, the same shape the lights
/// use: shaders are compiled ahead of time, so a permutation per joint count is
/// not available even if it were desirable.
#define kMaxJoints 64

layout(std140) uniform SkinInfo {
  mat4 joint_matrices[kMaxJoints];
}
skin_info;

out vec3 v_world_position;
out vec3 v_normal;
out vec2 v_texcoord;
out vec4 v_tangent;
out vec4 v_color;
out vec2 v_lightmap_uv;

/// [joint] as an index into `joint_matrices`, kept inside the array.
///
/// glTF requires every JOINTS_0 value to name a joint of the skin, and the
/// palette past the skin's own joints is padded with identity, but the loader
/// does not police the vertex data. Indexing a uniform array out of range is
/// undefined, and a stray NaN there survives even a zero weight, so an index
/// past the end reads the last (padding) slot instead.
int JointIndex(float joint) {
  return clamp(int(joint), 0, kMaxJoints - 1);
}

/// The blended bone transform for this vertex.
mat4 SkinMatrix() {
  // Renormalizing costs three adds and a divide, and it is what stops a mesh
  // from swelling or shrinking where the authored weights do not quite sum to
  // one. A zero sum means the vertex named no joints at all, and falling back
  // to full influence on the first one leaves it rigid instead of collapsing it
  // to the origin.
  float total = weights.x + weights.y + weights.z + weights.w;
  vec4 w = total > 1e-5 ? weights / total : vec4(1.0, 0.0, 0.0, 0.0);

  return w.x * skin_info.joint_matrices[JointIndex(joints.x)] +
         w.y * skin_info.joint_matrices[JointIndex(joints.y)] +
         w.z * skin_info.joint_matrices[JointIndex(joints.z)] +
         w.w * skin_info.joint_matrices[JointIndex(joints.w)];
}

void main() {
  mat4 skin = SkinMatrix();
  // Skin first, then place: the joint matrices work in the mesh's own space, so
  // the model matrix still has to carry the result into the world.
  // Morphed in the rest pose and skinned afterwards, which is the order glTF
  // specifies and the only one that composes: a face morphs where it was
  // modelled and the skeleton then carries it.
  vec3 morphed_position = position;
  vec3 morphed_normal = normal;
  vec4 morphed_tangent = tangent;
  ApplyMorph(morphed_position, morphed_normal, morphed_tangent);

  mat4 skinnedModel = frame_info.model * skin;

  vec4 world = skinnedModel * vec4(morphed_position, 1.0);
  v_world_position = world.xyz;

  // The joint transform rotates and may scale, so the normal needs the same
  // treatment it gets from the model matrix. mat3(skin) is exact while the
  // joints only rotate and translate, which is the case for every rig in
  // practice; a non-uniformly scaled joint would need the inverse transpose,
  // and computing that per vertex is the trade this deliberately does not make.
  mat3 skinRotation = mat3(skin);
  v_normal =
      mat3(frame_info.normal_matrix) * (skinRotation * morphed_normal);
  // The bitangent sign flips with a mirror (see mesh.vert), and here the
  // tangent has gone through two matrices, either of which may be one.
  bool mirrored = (determinant(mat3(frame_info.model)) < 0.0) !=
                  (determinant(skinRotation) < 0.0);
  v_tangent = vec4(
      mat3(frame_info.model) * (skinRotation * morphed_tangent.xyz),
      mirrored ? -morphed_tangent.w : morphed_tangent.w);

  v_texcoord = texcoord;
  v_color = color;
  v_lightmap_uv = vec2(0.0);

  gl_Position = frame_info.mvp * (skin * vec4(morphed_position, 1.0));
}

''',
    'MeshInstancedVertex': r'''#version 300 es

// Vertex stage for an instanced mesh: one mesh, drawn once per instance, each
// instance with a transform and a colour of its own.
//
// The same varyings as mesh.vert and the same FrameInfo block, and that is the
// whole design: every fragment shader in the bundle and both shadow passes
// take this stage without knowing it is instanced. What differs is slot 1 —
// the placements, stepping once per instance — and that `model` here is the
// node's transform, which the instance transform sits inside. An instance is
// placed relative to its node, so moving the node moves the whole batch and a
// batch of a thousand costs one uniform write.
//
// Twelve floats an instance for the transform, not sixteen: the bottom row of
// an affine matrix is always (0, 0, 0, 1), and a quarter of the buffer would
// be spent saying so. Stored as three rows so the vertex stage assembles a
// mat4 from them in three moves.

in vec3 position;
in vec3 normal;
in vec2 texcoord;
in vec4 tangent;
in vec4 color;

// --- lib/morph_instanced.glsl ---
// Morph weights per instance of a batch, read from a texture by instance id.
//
// ## Why this is a file of its own
//
// One batch of a thousand villagers should be able to wear a thousand
// expressions, and until this existed it wore one: `MorphInfo.morph_weights` is
// a uniform, and a uniform is the same for every instance in the draw by
// definition.
//
// The obvious place to put per-instance weights is the instance record — slot
// one, which already carries a transform and a colour and is rewritten every
// frame. That was rejected: the record's size is part of the instanced vertex
// layout, so eight more floats would be paid by every instanced draw in every
// game, including the overwhelming majority that morph nothing.
//
// So: a texture, one row per instance, read by `gl_InstanceID`. Two texels
// wide, because eight weights are two `vec4`s. It costs one sampler on one
// stage, and the layout is untouched.
//
// **This is included only by `mesh_instanced.vert`.** A sampler declared in
// `lib/morph.glsl` would be declared on all four mesh vertex stages, and every
// one of them would have to bind something to it on every draw for ever. The
// deltas are worth that; a second sampler that three stages can never use is
// not.
//
// ## What it costs to change a weight
//
// A texture in this engine is created with its contents and never written
// again — `GraphicsDevice` has `createTextureFromPixels` and no update, which
// is a decision the HAL makes on purpose. So a batch whose per-instance weights
// change has to build a new texture, and one whose weights are set once pays
// nothing per frame. That is the right way round for what this is for: a crowd
// where each face is *different* rather than a crowd where each face is
// *moving*. `InstancedMeshNode` rebuilds only when a weight actually changed —
// the same skip `MorphBlend` makes, and for the same reason.

#ifndef MORPH_INSTANCED_GLSL_
#define MORPH_INSTANCED_GLSL_

// --- lib/morph.glsl ---
// Morph targets, applied in the vertex stage from a texture of deltas.
//
// ## Why a texture and not attributes
//
// The vertex layout in this engine is **structural**: the `in` declarations of
// `mesh.vert` are the layout, and one layout serves every model so that a
// lighting model needs one pipeline rather than one per attribute set. Morph
// deltas as attributes would mean a second layout, and with it a second vertex
// shader for every lighting model — six of them — and a second pipeline for
// each. A texture read by vertex index costs one sampler and no layout at all.
//
// That the read is possible is measured rather than assumed:
// `checkVertexTextureSampling` in `flutter3d_conformance` draws through a
// vertex stage that samples, on all three backends. It answers yes on each,
// Impeller included, which was the one that could not be settled by reading a
// header.
//
// ## The layout of the texture
//
// `r32g32b32a32Float`, width = the mesh's vertex count, height = one row per
// delta stream per target. Target *t* occupies rows `t * MORPH_ROWS` upwards:
//
//     row + 0   position delta, xyz
//     row + 1   normal delta, xyz     (zero when the file carried none)
//     row + 2   tangent delta, xyz    (zero when the file carried none)
//
// Three rows always, so the arithmetic is a multiply rather than a table: a
// target that morphs only positions costs two rows of zeros, which is memory
// and not branches. `MorphTargetTexture` on the Dart side packs exactly this.
//
// **`texture` at a texel centre, and it should have been `texelFetch`.** There
// is nothing to filter — a vertex has exactly one delta per target — so the
// fetch is the operation this wants: no size arithmetic, no sampler state, no
// half-texel to get wrong.
//
// It is not used because **impellerc crashes on `texelFetch` in a vertex
// stage**: SIGABRT, no diagnostic, exit 134. Bisected — the same call in a
// *fragment* stage compiles, `gl_VertexID` alone compiles, and `texture()`
// in a vertex stage compiles, so it is that one combination. So the coordinate
// is built by hand, `(index + 0.5) / size`, and the sampler is bound nearest
// and clamped: exactly the texel, reached the long way round. The size comes
// down in `morph_params` rather than from `textureSize`, which is one more
// thing that would have to survive the same compiler.

#ifndef MORPH_GLSL_
#define MORPH_GLSL_

/// Rows of the delta texture each target occupies. See the header.
const int kMorphRows = 3;

/// The most targets one draw can blend.
///
/// Eight because glTF's own guidance is that an engine support at least eight
/// active targets, and because a `vec4[2]` is two registers. A model carrying
/// more is not refused — the renderer sends the first eight and says so, which
/// is a face missing an expression rather than a face that will not load.
const int kMorphMax = 8;

uniform sampler2D morph_texture;

layout(std140) uniform MorphInfo {
  /// Weight of target *i* at `morph_weights[i / 4][i % 4]`.
  vec4 morph_weights[2];

  /// x: how many targets are active, as a float.
  /// y: one texel across, `1 / width`. z: one texel down, `1 / height`.
  /// w unused.
  ///
  /// A count rather than a convention that a zero weight means absent: a
  /// target held at exactly nought is a face that is not smiling, and reading
  /// it as "the list ends here" would stop the ones after it.
  vec4 morph_params;
}
morph_info;

/// How many targets this draw blends.
int MorphCount() { return int(morph_info.morph_params.x + 0.5); }

/// The vertex's own column in the delta texture.
///
/// **`gl_VertexID`, spelt the way SPIR-V spells it.** GLSL ES 3.00 calls the
/// same builtin `gl_VertexID`, and the browser backend's translator rewrites
/// the name on its way out — one substitution beside the ones it already makes
/// for `#version` and `layout(std140)`. Written the other way round, impellerc
/// refuses it outright: "undeclared identifier (Did you mean gl_VertexID?)",
/// which is the friendliest error in this repository.
float MorphColumn() {
  return (float(gl_VertexID) + 0.5) * morph_info.morph_params.y;
}

/// Adds target *t*'s deltas onto one vertex, scaled by [weight].
///
/// Split out of [ApplyMorph] so that a stage which gets its weights from
/// somewhere else — `lib/morph_instanced.glsl`, where each instance of a batch
/// wears its own — reads the deltas through the same three lines rather than
/// through a second copy of them.
///
/// [column] and [rowStep] are the caller's, worked out once rather than per
/// target.
///
/// **Splitting this out moved the picture, by 31 pixels of silhouette on
/// Impeller**, and the reference set was re-recorded rather than the split
/// abandoned. The arithmetic is the same arithmetic — it was checked against
/// the software backend, which draws it identically either way — so what moved
/// is what impellerc's optimiser does with a function call it can no longer
/// see through. Hoisting the coordinates was the first guess at the cause and
/// was not it: the same 31 pixels moved with them hoisted. Worth writing down,
/// because the next person to factor a line out of a vertex stage will see a
/// golden fail and reach for the same wrong explanation.
void AddMorphTargetAt(int t, float weight, float column, float rowStep,
                      inout vec3 position, inout vec3 normal,
                      inout vec4 tangent) {
  float row = (float(t * kMorphRows) + 0.5) * rowStep;

  position += texture(morph_texture, vec2(column, row)).xyz * weight;
  normal += texture(morph_texture, vec2(column, row + rowStep)).xyz * weight;
  tangent.xyz +=
      texture(morph_texture, vec2(column, row + rowStep * 2.0)).xyz * weight;
}

/// Adds the blended deltas onto one vertex.
///
/// Called with the attributes as they were read and before anything else
/// touches them — skinning included, which is the order glTF specifies: a
/// skinned morphed mesh morphs in its rest pose and is then posed by the
/// skeleton.
///
/// The tangent is a `vec4` and only its xyz move: w is the bitangent sign, a
/// handedness rather than a direction, and glTF does not morph it.
void ApplyMorph(inout vec3 position, inout vec3 normal, inout vec4 tangent) {
  int count = MorphCount();
  if (count <= 0) return;

  float column = MorphColumn();
  float rowStep = morph_info.morph_params.z;

  for (int i = 0; i < kMorphMax; i++) {
    if (i >= count) break;
    float weight = morph_info.morph_weights[i / 4][i % 4];
    if (weight == 0.0) continue;
    AddMorphTargetAt(i, weight, column, rowStep, position, normal, tangent);
  }
}

#endif  // MORPH_GLSL_


uniform sampler2D morph_instance_weights;

layout(std140) uniform MorphInstanceInfo {
  /// x: 1 when the weights come from the texture, 0 when they come from
  ///    `MorphInfo` and every instance wears the same shape.
  /// y: one texel across, `1 / width`. z: one texel down, `1 / height`.
  /// w unused.
  vec4 instance_params;
}
morph_instance_info;

/// Adds the deltas for [instance]'s own weights onto one vertex.
///
/// Falls through to [ApplyMorph] when the batch has no per-instance weights,
/// which is every batch that does not use this feature: the texture is then a
/// stand-in nobody reads, and the shape comes from the uniform exactly as it
/// does on the three stages that never heard of this file.
void ApplyMorphInstanced(int instance, inout vec3 position, inout vec3 normal,
                         inout vec4 tangent) {
  if (morph_instance_info.instance_params.x < 0.5) {
    ApplyMorph(position, normal, tangent);
    return;
  }

  int count = MorphCount();
  if (count <= 0) return;

  float deltaColumn = MorphColumn();
  float deltaRowStep = morph_info.morph_params.z;

  // The instance's own row, at a texel centre, for the same reason the delta
  // read builds its coordinate by hand: nearest and clamped, and no
  // `textureSize`.
  float row = (float(instance) + 0.5) * morph_instance_info.instance_params.z;

  for (int i = 0; i < kMorphMax; i++) {
    if (i >= count) break;
    // Four weights a texel, so target *i* is in texel `i / 4`, channel `i % 4`
    // — the same arithmetic `morph_weights[i / 4][i % 4]` does over the
    // uniform, which is what makes the two paths agree without either knowing
    // about the other.
    float column =
        (float(i / 4) + 0.5) * morph_instance_info.instance_params.y;
    float weight = texture(morph_instance_weights, vec2(column, row))[i % 4];
    if (weight == 0.0) continue;
    AddMorphTargetAt(i, weight, deltaColumn, deltaRowStep, position, normal,
                     tangent);
  }
}

#endif  // MORPH_INSTANCED_GLSL_


/// Rows of the instance's 3x4 affine transform, in the node's space.
in vec4 i_row0;
in vec4 i_row1;
in vec4 i_row2;
/// Multiplied into the vertex colour, so a batch of one mesh can vary its tint.
in vec4 i_color;

layout(std140) uniform FrameInfo {
  mat4 mvp;
  mat4 model;
  mat4 normal_matrix;
}
frame_info;

out vec3 v_world_position;
out vec3 v_normal;
out vec2 v_texcoord;
out vec4 v_tangent;
out vec4 v_color;
out vec2 v_lightmap_uv;

void main() {
  // Columns from rows: GLSL matrices are column-major, so the constructor is
  // handed the transpose of what the buffer holds.
  mat4 instance = mat4(
      vec4(i_row0.x, i_row1.x, i_row2.x, 0.0),
      vec4(i_row0.y, i_row1.y, i_row2.y, 0.0),
      vec4(i_row0.z, i_row1.z, i_row2.z, 0.0),
      vec4(i_row0.w, i_row1.w, i_row2.w, 1.0));
  // Morphed before the instance transform: the deltas are in the mesh's own
  // space, and every instance of a batch shares the mesh. What they need not
  // share is the *shape* — `ApplyMorphInstanced` reads this instance's own
  // weights out of a texture when the batch has any, and falls through to the
  // batch-wide uniform when it has none. See `lib/morph_instanced.glsl`.
  vec3 morphed_position = position;
  vec3 morphed_normal = normal;
  vec4 morphed_tangent = tangent;
  ApplyMorphInstanced(gl_InstanceID, morphed_position, morphed_normal,
                      morphed_tangent);

  vec4 local = instance * vec4(morphed_position, 1.0);
  vec4 world = frame_info.model * local;
  v_world_position = world.xyz;
  // The instance's rotation and scale applied before the node's normal matrix.
  // Correct for a rotation and a uniform scale, which is what an instance is
  // for; a non-uniform instance scale skews the normal, and that is the
  // documented limit rather than an inverse transpose per vertex.
  mat3 rotation = mat3(instance);
  v_normal =
      mat3(frame_info.normal_matrix) * normalize(rotation * morphed_normal);
  v_texcoord = texcoord;
  // The bitangent sign flips with a mirror (see mesh.vert), and an instance
  // flipped by its own transform is as mirrored as a node flipped by its.
  bool mirrored = (determinant(mat3(frame_info.model)) < 0.0) !=
                  (determinant(rotation) < 0.0);
  v_tangent = vec4(
      mat3(frame_info.model) * (rotation * morphed_tangent.xyz),
      mirrored ? -morphed_tangent.w : morphed_tangent.w);
  v_color = color * i_color;
  v_lightmap_uv = vec2(0.0);
  gl_Position = frame_info.mvp * local;
}

''',
    'MeshLightmappedVertex': r'''#version 300 es

// `mesh.vert` for a level with a baked lightmap.
//
// The same vertex layout as every other model — see mesh.vert for why there
// is one — with one attribute read differently: `color.xy` carries the
// vertex's place in the lightmap rather than a tint. A brush face has no
// vertex colour to lose, and a fourth vertex layout would be a fourth
// pipeline per lighting model on three backends for two floats. So the
// level's geometry writes its second coordinate where the colour goes, and
// this stage hands the fragment an opaque white tint and the coordinate.
in vec3 position;
in vec3 normal;
in vec2 texcoord;
in vec4 tangent;
in vec4 color;

// --- lib/morph.glsl ---
// Morph targets, applied in the vertex stage from a texture of deltas.
//
// ## Why a texture and not attributes
//
// The vertex layout in this engine is **structural**: the `in` declarations of
// `mesh.vert` are the layout, and one layout serves every model so that a
// lighting model needs one pipeline rather than one per attribute set. Morph
// deltas as attributes would mean a second layout, and with it a second vertex
// shader for every lighting model — six of them — and a second pipeline for
// each. A texture read by vertex index costs one sampler and no layout at all.
//
// That the read is possible is measured rather than assumed:
// `checkVertexTextureSampling` in `flutter3d_conformance` draws through a
// vertex stage that samples, on all three backends. It answers yes on each,
// Impeller included, which was the one that could not be settled by reading a
// header.
//
// ## The layout of the texture
//
// `r32g32b32a32Float`, width = the mesh's vertex count, height = one row per
// delta stream per target. Target *t* occupies rows `t * MORPH_ROWS` upwards:
//
//     row + 0   position delta, xyz
//     row + 1   normal delta, xyz     (zero when the file carried none)
//     row + 2   tangent delta, xyz    (zero when the file carried none)
//
// Three rows always, so the arithmetic is a multiply rather than a table: a
// target that morphs only positions costs two rows of zeros, which is memory
// and not branches. `MorphTargetTexture` on the Dart side packs exactly this.
//
// **`texture` at a texel centre, and it should have been `texelFetch`.** There
// is nothing to filter — a vertex has exactly one delta per target — so the
// fetch is the operation this wants: no size arithmetic, no sampler state, no
// half-texel to get wrong.
//
// It is not used because **impellerc crashes on `texelFetch` in a vertex
// stage**: SIGABRT, no diagnostic, exit 134. Bisected — the same call in a
// *fragment* stage compiles, `gl_VertexID` alone compiles, and `texture()`
// in a vertex stage compiles, so it is that one combination. So the coordinate
// is built by hand, `(index + 0.5) / size`, and the sampler is bound nearest
// and clamped: exactly the texel, reached the long way round. The size comes
// down in `morph_params` rather than from `textureSize`, which is one more
// thing that would have to survive the same compiler.

#ifndef MORPH_GLSL_
#define MORPH_GLSL_

/// Rows of the delta texture each target occupies. See the header.
const int kMorphRows = 3;

/// The most targets one draw can blend.
///
/// Eight because glTF's own guidance is that an engine support at least eight
/// active targets, and because a `vec4[2]` is two registers. A model carrying
/// more is not refused — the renderer sends the first eight and says so, which
/// is a face missing an expression rather than a face that will not load.
const int kMorphMax = 8;

uniform sampler2D morph_texture;

layout(std140) uniform MorphInfo {
  /// Weight of target *i* at `morph_weights[i / 4][i % 4]`.
  vec4 morph_weights[2];

  /// x: how many targets are active, as a float.
  /// y: one texel across, `1 / width`. z: one texel down, `1 / height`.
  /// w unused.
  ///
  /// A count rather than a convention that a zero weight means absent: a
  /// target held at exactly nought is a face that is not smiling, and reading
  /// it as "the list ends here" would stop the ones after it.
  vec4 morph_params;
}
morph_info;

/// How many targets this draw blends.
int MorphCount() { return int(morph_info.morph_params.x + 0.5); }

/// The vertex's own column in the delta texture.
///
/// **`gl_VertexID`, spelt the way SPIR-V spells it.** GLSL ES 3.00 calls the
/// same builtin `gl_VertexID`, and the browser backend's translator rewrites
/// the name on its way out — one substitution beside the ones it already makes
/// for `#version` and `layout(std140)`. Written the other way round, impellerc
/// refuses it outright: "undeclared identifier (Did you mean gl_VertexID?)",
/// which is the friendliest error in this repository.
float MorphColumn() {
  return (float(gl_VertexID) + 0.5) * morph_info.morph_params.y;
}

/// Adds target *t*'s deltas onto one vertex, scaled by [weight].
///
/// Split out of [ApplyMorph] so that a stage which gets its weights from
/// somewhere else — `lib/morph_instanced.glsl`, where each instance of a batch
/// wears its own — reads the deltas through the same three lines rather than
/// through a second copy of them.
///
/// [column] and [rowStep] are the caller's, worked out once rather than per
/// target.
///
/// **Splitting this out moved the picture, by 31 pixels of silhouette on
/// Impeller**, and the reference set was re-recorded rather than the split
/// abandoned. The arithmetic is the same arithmetic — it was checked against
/// the software backend, which draws it identically either way — so what moved
/// is what impellerc's optimiser does with a function call it can no longer
/// see through. Hoisting the coordinates was the first guess at the cause and
/// was not it: the same 31 pixels moved with them hoisted. Worth writing down,
/// because the next person to factor a line out of a vertex stage will see a
/// golden fail and reach for the same wrong explanation.
void AddMorphTargetAt(int t, float weight, float column, float rowStep,
                      inout vec3 position, inout vec3 normal,
                      inout vec4 tangent) {
  float row = (float(t * kMorphRows) + 0.5) * rowStep;

  position += texture(morph_texture, vec2(column, row)).xyz * weight;
  normal += texture(morph_texture, vec2(column, row + rowStep)).xyz * weight;
  tangent.xyz +=
      texture(morph_texture, vec2(column, row + rowStep * 2.0)).xyz * weight;
}

/// Adds the blended deltas onto one vertex.
///
/// Called with the attributes as they were read and before anything else
/// touches them — skinning included, which is the order glTF specifies: a
/// skinned morphed mesh morphs in its rest pose and is then posed by the
/// skeleton.
///
/// The tangent is a `vec4` and only its xyz move: w is the bitangent sign, a
/// handedness rather than a direction, and glTF does not morph it.
void ApplyMorph(inout vec3 position, inout vec3 normal, inout vec4 tangent) {
  int count = MorphCount();
  if (count <= 0) return;

  float column = MorphColumn();
  float rowStep = morph_info.morph_params.z;

  for (int i = 0; i < kMorphMax; i++) {
    if (i >= count) break;
    float weight = morph_info.morph_weights[i / 4][i % 4];
    if (weight == 0.0) continue;
    AddMorphTargetAt(i, weight, column, rowStep, position, normal, tangent);
  }
}

#endif  // MORPH_GLSL_


layout(std140) uniform FrameInfo {
  mat4 mvp;
  mat4 model;
  mat4 normal_matrix;
}
frame_info;

out vec3 v_world_position;
out vec3 v_normal;
out vec2 v_texcoord;
out vec4 v_tangent;
out vec4 v_color;
out vec2 v_lightmap_uv;

void main() {
  vec3 morphed_position = position;
  vec3 morphed_normal = normal;
  vec4 morphed_tangent = tangent;
  ApplyMorph(morphed_position, morphed_normal, morphed_tangent);

  vec4 world = frame_info.model * vec4(morphed_position, 1.0);
  v_world_position = world.xyz;
  v_normal = mat3(frame_info.normal_matrix) * morphed_normal;
  v_texcoord = texcoord;
  // The bitangent sign flips with a mirroring model; see mesh.vert.
  bool mirrored = determinant(mat3(frame_info.model)) < 0.0;
  v_tangent = vec4(mat3(frame_info.model) * morphed_tangent.xyz,
                   mirrored ? -morphed_tangent.w : morphed_tangent.w);
  v_color = vec4(1.0);
  v_lightmap_uv = color.xy;

  gl_Position = frame_info.mvp * vec4(morphed_position, 1.0);
}

''',
    'VelocityVertex': r'''#version 300 es

// A moved mesh, drawn into the velocity buffer — `R1`. See
// `lib/velocity.glsl` for the two matrices and why the depth goes through a
// third.
//
// Only the position is declared: the pipeline is built with an explicit
// layout over the standard sixty-four-byte vertex, so the rest of the vertex
// is stepped over rather than read.

in vec3 position;

// --- lib/morph.glsl ---
// Morph targets, applied in the vertex stage from a texture of deltas.
//
// ## Why a texture and not attributes
//
// The vertex layout in this engine is **structural**: the `in` declarations of
// `mesh.vert` are the layout, and one layout serves every model so that a
// lighting model needs one pipeline rather than one per attribute set. Morph
// deltas as attributes would mean a second layout, and with it a second vertex
// shader for every lighting model — six of them — and a second pipeline for
// each. A texture read by vertex index costs one sampler and no layout at all.
//
// That the read is possible is measured rather than assumed:
// `checkVertexTextureSampling` in `flutter3d_conformance` draws through a
// vertex stage that samples, on all three backends. It answers yes on each,
// Impeller included, which was the one that could not be settled by reading a
// header.
//
// ## The layout of the texture
//
// `r32g32b32a32Float`, width = the mesh's vertex count, height = one row per
// delta stream per target. Target *t* occupies rows `t * MORPH_ROWS` upwards:
//
//     row + 0   position delta, xyz
//     row + 1   normal delta, xyz     (zero when the file carried none)
//     row + 2   tangent delta, xyz    (zero when the file carried none)
//
// Three rows always, so the arithmetic is a multiply rather than a table: a
// target that morphs only positions costs two rows of zeros, which is memory
// and not branches. `MorphTargetTexture` on the Dart side packs exactly this.
//
// **`texture` at a texel centre, and it should have been `texelFetch`.** There
// is nothing to filter — a vertex has exactly one delta per target — so the
// fetch is the operation this wants: no size arithmetic, no sampler state, no
// half-texel to get wrong.
//
// It is not used because **impellerc crashes on `texelFetch` in a vertex
// stage**: SIGABRT, no diagnostic, exit 134. Bisected — the same call in a
// *fragment* stage compiles, `gl_VertexID` alone compiles, and `texture()`
// in a vertex stage compiles, so it is that one combination. So the coordinate
// is built by hand, `(index + 0.5) / size`, and the sampler is bound nearest
// and clamped: exactly the texel, reached the long way round. The size comes
// down in `morph_params` rather than from `textureSize`, which is one more
// thing that would have to survive the same compiler.

#ifndef MORPH_GLSL_
#define MORPH_GLSL_

/// Rows of the delta texture each target occupies. See the header.
const int kMorphRows = 3;

/// The most targets one draw can blend.
///
/// Eight because glTF's own guidance is that an engine support at least eight
/// active targets, and because a `vec4[2]` is two registers. A model carrying
/// more is not refused — the renderer sends the first eight and says so, which
/// is a face missing an expression rather than a face that will not load.
const int kMorphMax = 8;

uniform sampler2D morph_texture;

layout(std140) uniform MorphInfo {
  /// Weight of target *i* at `morph_weights[i / 4][i % 4]`.
  vec4 morph_weights[2];

  /// x: how many targets are active, as a float.
  /// y: one texel across, `1 / width`. z: one texel down, `1 / height`.
  /// w unused.
  ///
  /// A count rather than a convention that a zero weight means absent: a
  /// target held at exactly nought is a face that is not smiling, and reading
  /// it as "the list ends here" would stop the ones after it.
  vec4 morph_params;
}
morph_info;

/// How many targets this draw blends.
int MorphCount() { return int(morph_info.morph_params.x + 0.5); }

/// The vertex's own column in the delta texture.
///
/// **`gl_VertexID`, spelt the way SPIR-V spells it.** GLSL ES 3.00 calls the
/// same builtin `gl_VertexID`, and the browser backend's translator rewrites
/// the name on its way out — one substitution beside the ones it already makes
/// for `#version` and `layout(std140)`. Written the other way round, impellerc
/// refuses it outright: "undeclared identifier (Did you mean gl_VertexID?)",
/// which is the friendliest error in this repository.
float MorphColumn() {
  return (float(gl_VertexID) + 0.5) * morph_info.morph_params.y;
}

/// Adds target *t*'s deltas onto one vertex, scaled by [weight].
///
/// Split out of [ApplyMorph] so that a stage which gets its weights from
/// somewhere else — `lib/morph_instanced.glsl`, where each instance of a batch
/// wears its own — reads the deltas through the same three lines rather than
/// through a second copy of them.
///
/// [column] and [rowStep] are the caller's, worked out once rather than per
/// target.
///
/// **Splitting this out moved the picture, by 31 pixels of silhouette on
/// Impeller**, and the reference set was re-recorded rather than the split
/// abandoned. The arithmetic is the same arithmetic — it was checked against
/// the software backend, which draws it identically either way — so what moved
/// is what impellerc's optimiser does with a function call it can no longer
/// see through. Hoisting the coordinates was the first guess at the cause and
/// was not it: the same 31 pixels moved with them hoisted. Worth writing down,
/// because the next person to factor a line out of a vertex stage will see a
/// golden fail and reach for the same wrong explanation.
void AddMorphTargetAt(int t, float weight, float column, float rowStep,
                      inout vec3 position, inout vec3 normal,
                      inout vec4 tangent) {
  float row = (float(t * kMorphRows) + 0.5) * rowStep;

  position += texture(morph_texture, vec2(column, row)).xyz * weight;
  normal += texture(morph_texture, vec2(column, row + rowStep)).xyz * weight;
  tangent.xyz +=
      texture(morph_texture, vec2(column, row + rowStep * 2.0)).xyz * weight;
}

/// Adds the blended deltas onto one vertex.
///
/// Called with the attributes as they were read and before anything else
/// touches them — skinning included, which is the order glTF specifies: a
/// skinned morphed mesh morphs in its rest pose and is then posed by the
/// skeleton.
///
/// The tangent is a `vec4` and only its xyz move: w is the bitangent sign, a
/// handedness rather than a direction, and glTF does not morph it.
void ApplyMorph(inout vec3 position, inout vec3 normal, inout vec4 tangent) {
  int count = MorphCount();
  if (count <= 0) return;

  float column = MorphColumn();
  float rowStep = morph_info.morph_params.z;

  for (int i = 0; i < kMorphMax; i++) {
    if (i >= count) break;
    float weight = morph_info.morph_weights[i / 4][i % 4];
    if (weight == 0.0) continue;
    AddMorphTargetAt(i, weight, column, rowStep, position, normal, tangent);
  }
}

#endif  // MORPH_GLSL_


layout(std140) uniform FrameInfo {
  mat4 mvp;
  mat4 model;
  mat4 normal_matrix;
}
frame_info;

// --- lib/velocity.glsl ---
// What the three velocity vertex stages share — `R1`.
//
// A node that moved is drawn again over the camera's velocity, and each of
// its vertices is carried through two matrices: this frame's and last
// frame's. Both are unjittered and carry the framebuffer origin, the way
// `post/camera_velocity.frag`'s are; the fragment stage turns the two clip
// positions into a difference in UV. The pass's own `gl_Position` goes
// through the jittered `FrameInfo.mvp`, so a fragment lands on the pixel the
// scene drew it on.
//
// **Hidden parts are rejected against the surface buffer, not a depth
// attachment.** Every pass in this engine clears depth on entry, so the
// scene's depth is not there to test against; the surface buffer holds the
// same answer in metres along the camera's axis. Each vertex hands on its
// own distance along that axis, and the fragment stage drops a fragment
// that lies behind what the scene drew there.
//
// Included after `lib/morph.glsl`: the position is morphed twice, by this
// frame's weights and by last frame's, through the one texture of deltas.

#ifndef VELOCITY_GLSL_
#define VELOCITY_GLSL_

layout(std140) uniform PrevFrameInfo {
  /// World to clip for this frame, unjittered, times the model matrix.
  mat4 current_mvp;

  /// The same product as it stood last frame: last frame's camera, last
  /// frame's model matrix.
  mat4 previous_mvp;

  /// Last frame's morph weights, packed as `MorphInfo.morph_weights` is.
  vec4 previous_morph_weights[2];

  /// xyz: where the eye is now.
  vec4 camera;

  /// xyz: the direction the camera looks now — the axis the surface
  /// buffer's depths are measured along.
  vec4 forward;
}
prev_info;

out vec4 v_current;
out vec4 v_previous;

/// This vertex's distance along the camera's axis, in metres.
out float v_depth;

float DepthAlongAxis(vec4 world) {
  return dot(world.xyz - prev_info.camera.xyz, prev_info.forward.xyz);
}

/// [position] moved towards the mesh's targets by [w0] and [w1] — the
/// position half of `ApplyMorph`, with the weights passed in.
vec3 MorphPositionWith(vec3 position, vec4 w0, vec4 w1) {
  int count = MorphCount();
  if (count <= 0) return position;

  float column = MorphColumn();
  float rowStep = morph_info.morph_params.z;
  vec3 normal = vec3(0.0);
  vec4 tangent = vec4(0.0);
  for (int i = 0; i < kMorphMax; i++) {
    if (i >= count) break;
    float weight = i < 4 ? w0[i] : w1[i - 4];
    if (weight == 0.0) continue;
    AddMorphTargetAt(i, weight, column, rowStep, position, normal, tangent);
  }
  return position;
}

#endif  // VELOCITY_GLSL_


void main() {
  vec3 now = MorphPositionWith(position, morph_info.morph_weights[0],
                               morph_info.morph_weights[1]);
  vec3 then = MorphPositionWith(position, prev_info.previous_morph_weights[0],
                                prev_info.previous_morph_weights[1]);
  v_current = prev_info.current_mvp * vec4(now, 1.0);
  v_depth = DepthAlongAxis(frame_info.model * vec4(now, 1.0));
  v_previous = prev_info.previous_mvp * vec4(then, 1.0);
  gl_Position = frame_info.mvp * vec4(now, 1.0);
}

''',
    'VelocitySkinnedVertex': r'''#version 300 es

// A moved skinned mesh, drawn into the velocity buffer — `R1`.
//
// Skinned twice: by this frame's palette and by last frame's, which the
// renderer keeps in its frame history. A character standing still on a
// moving platform and a character running in place both move on screen,
// and only the two palettes together can tell those apart.
//
// **Last frame's palette is a texture**, four texels a joint and one joint a
// row, because a second 4 KB block beside `SkinInfo` is past the uniform
// space impellerc allows one stage. Read at texel centres through a nearest
// sampler, the way `lib/morph.glsl` reads its deltas and for its reason:
// `texelFetch` crashes impellerc in a vertex stage.

in vec3 position;

// --- lib/morph.glsl ---
// Morph targets, applied in the vertex stage from a texture of deltas.
//
// ## Why a texture and not attributes
//
// The vertex layout in this engine is **structural**: the `in` declarations of
// `mesh.vert` are the layout, and one layout serves every model so that a
// lighting model needs one pipeline rather than one per attribute set. Morph
// deltas as attributes would mean a second layout, and with it a second vertex
// shader for every lighting model — six of them — and a second pipeline for
// each. A texture read by vertex index costs one sampler and no layout at all.
//
// That the read is possible is measured rather than assumed:
// `checkVertexTextureSampling` in `flutter3d_conformance` draws through a
// vertex stage that samples, on all three backends. It answers yes on each,
// Impeller included, which was the one that could not be settled by reading a
// header.
//
// ## The layout of the texture
//
// `r32g32b32a32Float`, width = the mesh's vertex count, height = one row per
// delta stream per target. Target *t* occupies rows `t * MORPH_ROWS` upwards:
//
//     row + 0   position delta, xyz
//     row + 1   normal delta, xyz     (zero when the file carried none)
//     row + 2   tangent delta, xyz    (zero when the file carried none)
//
// Three rows always, so the arithmetic is a multiply rather than a table: a
// target that morphs only positions costs two rows of zeros, which is memory
// and not branches. `MorphTargetTexture` on the Dart side packs exactly this.
//
// **`texture` at a texel centre, and it should have been `texelFetch`.** There
// is nothing to filter — a vertex has exactly one delta per target — so the
// fetch is the operation this wants: no size arithmetic, no sampler state, no
// half-texel to get wrong.
//
// It is not used because **impellerc crashes on `texelFetch` in a vertex
// stage**: SIGABRT, no diagnostic, exit 134. Bisected — the same call in a
// *fragment* stage compiles, `gl_VertexID` alone compiles, and `texture()`
// in a vertex stage compiles, so it is that one combination. So the coordinate
// is built by hand, `(index + 0.5) / size`, and the sampler is bound nearest
// and clamped: exactly the texel, reached the long way round. The size comes
// down in `morph_params` rather than from `textureSize`, which is one more
// thing that would have to survive the same compiler.

#ifndef MORPH_GLSL_
#define MORPH_GLSL_

/// Rows of the delta texture each target occupies. See the header.
const int kMorphRows = 3;

/// The most targets one draw can blend.
///
/// Eight because glTF's own guidance is that an engine support at least eight
/// active targets, and because a `vec4[2]` is two registers. A model carrying
/// more is not refused — the renderer sends the first eight and says so, which
/// is a face missing an expression rather than a face that will not load.
const int kMorphMax = 8;

uniform sampler2D morph_texture;

layout(std140) uniform MorphInfo {
  /// Weight of target *i* at `morph_weights[i / 4][i % 4]`.
  vec4 morph_weights[2];

  /// x: how many targets are active, as a float.
  /// y: one texel across, `1 / width`. z: one texel down, `1 / height`.
  /// w unused.
  ///
  /// A count rather than a convention that a zero weight means absent: a
  /// target held at exactly nought is a face that is not smiling, and reading
  /// it as "the list ends here" would stop the ones after it.
  vec4 morph_params;
}
morph_info;

/// How many targets this draw blends.
int MorphCount() { return int(morph_info.morph_params.x + 0.5); }

/// The vertex's own column in the delta texture.
///
/// **`gl_VertexID`, spelt the way SPIR-V spells it.** GLSL ES 3.00 calls the
/// same builtin `gl_VertexID`, and the browser backend's translator rewrites
/// the name on its way out — one substitution beside the ones it already makes
/// for `#version` and `layout(std140)`. Written the other way round, impellerc
/// refuses it outright: "undeclared identifier (Did you mean gl_VertexID?)",
/// which is the friendliest error in this repository.
float MorphColumn() {
  return (float(gl_VertexID) + 0.5) * morph_info.morph_params.y;
}

/// Adds target *t*'s deltas onto one vertex, scaled by [weight].
///
/// Split out of [ApplyMorph] so that a stage which gets its weights from
/// somewhere else — `lib/morph_instanced.glsl`, where each instance of a batch
/// wears its own — reads the deltas through the same three lines rather than
/// through a second copy of them.
///
/// [column] and [rowStep] are the caller's, worked out once rather than per
/// target.
///
/// **Splitting this out moved the picture, by 31 pixels of silhouette on
/// Impeller**, and the reference set was re-recorded rather than the split
/// abandoned. The arithmetic is the same arithmetic — it was checked against
/// the software backend, which draws it identically either way — so what moved
/// is what impellerc's optimiser does with a function call it can no longer
/// see through. Hoisting the coordinates was the first guess at the cause and
/// was not it: the same 31 pixels moved with them hoisted. Worth writing down,
/// because the next person to factor a line out of a vertex stage will see a
/// golden fail and reach for the same wrong explanation.
void AddMorphTargetAt(int t, float weight, float column, float rowStep,
                      inout vec3 position, inout vec3 normal,
                      inout vec4 tangent) {
  float row = (float(t * kMorphRows) + 0.5) * rowStep;

  position += texture(morph_texture, vec2(column, row)).xyz * weight;
  normal += texture(morph_texture, vec2(column, row + rowStep)).xyz * weight;
  tangent.xyz +=
      texture(morph_texture, vec2(column, row + rowStep * 2.0)).xyz * weight;
}

/// Adds the blended deltas onto one vertex.
///
/// Called with the attributes as they were read and before anything else
/// touches them — skinning included, which is the order glTF specifies: a
/// skinned morphed mesh morphs in its rest pose and is then posed by the
/// skeleton.
///
/// The tangent is a `vec4` and only its xyz move: w is the bitangent sign, a
/// handedness rather than a direction, and glTF does not morph it.
void ApplyMorph(inout vec3 position, inout vec3 normal, inout vec4 tangent) {
  int count = MorphCount();
  if (count <= 0) return;

  float column = MorphColumn();
  float rowStep = morph_info.morph_params.z;

  for (int i = 0; i < kMorphMax; i++) {
    if (i >= count) break;
    float weight = morph_info.morph_weights[i / 4][i % 4];
    if (weight == 0.0) continue;
    AddMorphTargetAt(i, weight, column, rowStep, position, normal, tangent);
  }
}

#endif  // MORPH_GLSL_


in vec4 joints;
in vec4 weights;

layout(std140) uniform FrameInfo {
  mat4 mvp;
  mat4 model;
  mat4 normal_matrix;
}
frame_info;

#define kMaxJoints 64

layout(std140) uniform SkinInfo {
  mat4 joint_matrices[kMaxJoints];
}
skin_info;

/// Last frame's `SkinInfo`: joint j's columns at texels (0..3, j) of a 4 ×
/// kMaxJoints float texture.
uniform sampler2D prev_joint_texture;

// --- lib/velocity.glsl ---
// What the three velocity vertex stages share — `R1`.
//
// A node that moved is drawn again over the camera's velocity, and each of
// its vertices is carried through two matrices: this frame's and last
// frame's. Both are unjittered and carry the framebuffer origin, the way
// `post/camera_velocity.frag`'s are; the fragment stage turns the two clip
// positions into a difference in UV. The pass's own `gl_Position` goes
// through the jittered `FrameInfo.mvp`, so a fragment lands on the pixel the
// scene drew it on.
//
// **Hidden parts are rejected against the surface buffer, not a depth
// attachment.** Every pass in this engine clears depth on entry, so the
// scene's depth is not there to test against; the surface buffer holds the
// same answer in metres along the camera's axis. Each vertex hands on its
// own distance along that axis, and the fragment stage drops a fragment
// that lies behind what the scene drew there.
//
// Included after `lib/morph.glsl`: the position is morphed twice, by this
// frame's weights and by last frame's, through the one texture of deltas.

#ifndef VELOCITY_GLSL_
#define VELOCITY_GLSL_

layout(std140) uniform PrevFrameInfo {
  /// World to clip for this frame, unjittered, times the model matrix.
  mat4 current_mvp;

  /// The same product as it stood last frame: last frame's camera, last
  /// frame's model matrix.
  mat4 previous_mvp;

  /// Last frame's morph weights, packed as `MorphInfo.morph_weights` is.
  vec4 previous_morph_weights[2];

  /// xyz: where the eye is now.
  vec4 camera;

  /// xyz: the direction the camera looks now — the axis the surface
  /// buffer's depths are measured along.
  vec4 forward;
}
prev_info;

out vec4 v_current;
out vec4 v_previous;

/// This vertex's distance along the camera's axis, in metres.
out float v_depth;

float DepthAlongAxis(vec4 world) {
  return dot(world.xyz - prev_info.camera.xyz, prev_info.forward.xyz);
}

/// [position] moved towards the mesh's targets by [w0] and [w1] — the
/// position half of `ApplyMorph`, with the weights passed in.
vec3 MorphPositionWith(vec3 position, vec4 w0, vec4 w1) {
  int count = MorphCount();
  if (count <= 0) return position;

  float column = MorphColumn();
  float rowStep = morph_info.morph_params.z;
  vec3 normal = vec3(0.0);
  vec4 tangent = vec4(0.0);
  for (int i = 0; i < kMorphMax; i++) {
    if (i >= count) break;
    float weight = i < 4 ? w0[i] : w1[i - 4];
    if (weight == 0.0) continue;
    AddMorphTargetAt(i, weight, column, rowStep, position, normal, tangent);
  }
  return position;
}

#endif  // VELOCITY_GLSL_


mat4 PrevJoint(float joint) {
  float v = (joint + 0.5) / float(kMaxJoints);
  return mat4(texture(prev_joint_texture, vec2(0.125, v)),
              texture(prev_joint_texture, vec2(0.375, v)),
              texture(prev_joint_texture, vec2(0.625, v)),
              texture(prev_joint_texture, vec2(0.875, v)));
}

/// [joint] kept inside the palette, as `mesh_skinned.vert` does it.
int JointIndex(float joint) {
  return clamp(int(joint), 0, kMaxJoints - 1);
}

vec4 BlendWeights() {
  float total = weights.x + weights.y + weights.z + weights.w;
  return total > 1e-5 ? weights / total : vec4(1.0, 0.0, 0.0, 0.0);
}

void main() {
  vec4 w = BlendWeights();
  ivec4 j = ivec4(JointIndex(joints.x), JointIndex(joints.y),
                  JointIndex(joints.z), JointIndex(joints.w));
  mat4 skin = w.x * skin_info.joint_matrices[j.x] +
              w.y * skin_info.joint_matrices[j.y] +
              w.z * skin_info.joint_matrices[j.z] +
              w.w * skin_info.joint_matrices[j.w];
  mat4 prevSkin = w.x * PrevJoint(float(j.x)) + w.y * PrevJoint(float(j.y)) +
                  w.z * PrevJoint(float(j.z)) + w.w * PrevJoint(float(j.w));

  vec3 now = MorphPositionWith(position, morph_info.morph_weights[0],
                               morph_info.morph_weights[1]);
  vec3 then = MorphPositionWith(position, prev_info.previous_morph_weights[0],
                                prev_info.previous_morph_weights[1]);
  vec4 posed = skin * vec4(now, 1.0);
  v_current = prev_info.current_mvp * posed;
  v_depth = DepthAlongAxis(frame_info.model * posed);
  v_previous = prev_info.previous_mvp * (prevSkin * vec4(then, 1.0));
  gl_Position = frame_info.mvp * posed;
}

''',
    'VelocityInstancedVertex': r'''#version 300 es

// A moved batch, drawn into the velocity buffer — `R1`.
//
// Each instance is placed twice: by the transform it has now (slot 1) and by
// the one it had last frame (slot 2, the frame history's copy of the batch's
// bytes, laid out the same). Per-instance morph weights are not reprojected;
// a batch's morph moves by the batch-wide weights alone.

in vec3 position;

// --- lib/morph.glsl ---
// Morph targets, applied in the vertex stage from a texture of deltas.
//
// ## Why a texture and not attributes
//
// The vertex layout in this engine is **structural**: the `in` declarations of
// `mesh.vert` are the layout, and one layout serves every model so that a
// lighting model needs one pipeline rather than one per attribute set. Morph
// deltas as attributes would mean a second layout, and with it a second vertex
// shader for every lighting model — six of them — and a second pipeline for
// each. A texture read by vertex index costs one sampler and no layout at all.
//
// That the read is possible is measured rather than assumed:
// `checkVertexTextureSampling` in `flutter3d_conformance` draws through a
// vertex stage that samples, on all three backends. It answers yes on each,
// Impeller included, which was the one that could not be settled by reading a
// header.
//
// ## The layout of the texture
//
// `r32g32b32a32Float`, width = the mesh's vertex count, height = one row per
// delta stream per target. Target *t* occupies rows `t * MORPH_ROWS` upwards:
//
//     row + 0   position delta, xyz
//     row + 1   normal delta, xyz     (zero when the file carried none)
//     row + 2   tangent delta, xyz    (zero when the file carried none)
//
// Three rows always, so the arithmetic is a multiply rather than a table: a
// target that morphs only positions costs two rows of zeros, which is memory
// and not branches. `MorphTargetTexture` on the Dart side packs exactly this.
//
// **`texture` at a texel centre, and it should have been `texelFetch`.** There
// is nothing to filter — a vertex has exactly one delta per target — so the
// fetch is the operation this wants: no size arithmetic, no sampler state, no
// half-texel to get wrong.
//
// It is not used because **impellerc crashes on `texelFetch` in a vertex
// stage**: SIGABRT, no diagnostic, exit 134. Bisected — the same call in a
// *fragment* stage compiles, `gl_VertexID` alone compiles, and `texture()`
// in a vertex stage compiles, so it is that one combination. So the coordinate
// is built by hand, `(index + 0.5) / size`, and the sampler is bound nearest
// and clamped: exactly the texel, reached the long way round. The size comes
// down in `morph_params` rather than from `textureSize`, which is one more
// thing that would have to survive the same compiler.

#ifndef MORPH_GLSL_
#define MORPH_GLSL_

/// Rows of the delta texture each target occupies. See the header.
const int kMorphRows = 3;

/// The most targets one draw can blend.
///
/// Eight because glTF's own guidance is that an engine support at least eight
/// active targets, and because a `vec4[2]` is two registers. A model carrying
/// more is not refused — the renderer sends the first eight and says so, which
/// is a face missing an expression rather than a face that will not load.
const int kMorphMax = 8;

uniform sampler2D morph_texture;

layout(std140) uniform MorphInfo {
  /// Weight of target *i* at `morph_weights[i / 4][i % 4]`.
  vec4 morph_weights[2];

  /// x: how many targets are active, as a float.
  /// y: one texel across, `1 / width`. z: one texel down, `1 / height`.
  /// w unused.
  ///
  /// A count rather than a convention that a zero weight means absent: a
  /// target held at exactly nought is a face that is not smiling, and reading
  /// it as "the list ends here" would stop the ones after it.
  vec4 morph_params;
}
morph_info;

/// How many targets this draw blends.
int MorphCount() { return int(morph_info.morph_params.x + 0.5); }

/// The vertex's own column in the delta texture.
///
/// **`gl_VertexID`, spelt the way SPIR-V spells it.** GLSL ES 3.00 calls the
/// same builtin `gl_VertexID`, and the browser backend's translator rewrites
/// the name on its way out — one substitution beside the ones it already makes
/// for `#version` and `layout(std140)`. Written the other way round, impellerc
/// refuses it outright: "undeclared identifier (Did you mean gl_VertexID?)",
/// which is the friendliest error in this repository.
float MorphColumn() {
  return (float(gl_VertexID) + 0.5) * morph_info.morph_params.y;
}

/// Adds target *t*'s deltas onto one vertex, scaled by [weight].
///
/// Split out of [ApplyMorph] so that a stage which gets its weights from
/// somewhere else — `lib/morph_instanced.glsl`, where each instance of a batch
/// wears its own — reads the deltas through the same three lines rather than
/// through a second copy of them.
///
/// [column] and [rowStep] are the caller's, worked out once rather than per
/// target.
///
/// **Splitting this out moved the picture, by 31 pixels of silhouette on
/// Impeller**, and the reference set was re-recorded rather than the split
/// abandoned. The arithmetic is the same arithmetic — it was checked against
/// the software backend, which draws it identically either way — so what moved
/// is what impellerc's optimiser does with a function call it can no longer
/// see through. Hoisting the coordinates was the first guess at the cause and
/// was not it: the same 31 pixels moved with them hoisted. Worth writing down,
/// because the next person to factor a line out of a vertex stage will see a
/// golden fail and reach for the same wrong explanation.
void AddMorphTargetAt(int t, float weight, float column, float rowStep,
                      inout vec3 position, inout vec3 normal,
                      inout vec4 tangent) {
  float row = (float(t * kMorphRows) + 0.5) * rowStep;

  position += texture(morph_texture, vec2(column, row)).xyz * weight;
  normal += texture(morph_texture, vec2(column, row + rowStep)).xyz * weight;
  tangent.xyz +=
      texture(morph_texture, vec2(column, row + rowStep * 2.0)).xyz * weight;
}

/// Adds the blended deltas onto one vertex.
///
/// Called with the attributes as they were read and before anything else
/// touches them — skinning included, which is the order glTF specifies: a
/// skinned morphed mesh morphs in its rest pose and is then posed by the
/// skeleton.
///
/// The tangent is a `vec4` and only its xyz move: w is the bitangent sign, a
/// handedness rather than a direction, and glTF does not morph it.
void ApplyMorph(inout vec3 position, inout vec3 normal, inout vec4 tangent) {
  int count = MorphCount();
  if (count <= 0) return;

  float column = MorphColumn();
  float rowStep = morph_info.morph_params.z;

  for (int i = 0; i < kMorphMax; i++) {
    if (i >= count) break;
    float weight = morph_info.morph_weights[i / 4][i % 4];
    if (weight == 0.0) continue;
    AddMorphTargetAt(i, weight, column, rowStep, position, normal, tangent);
  }
}

#endif  // MORPH_GLSL_


in vec4 i_row0;
in vec4 i_row1;
in vec4 i_row2;

in vec4 i_prev_row0;
in vec4 i_prev_row1;
in vec4 i_prev_row2;

layout(std140) uniform FrameInfo {
  mat4 mvp;
  mat4 model;
  mat4 normal_matrix;
}
frame_info;

// --- lib/velocity.glsl ---
// What the three velocity vertex stages share — `R1`.
//
// A node that moved is drawn again over the camera's velocity, and each of
// its vertices is carried through two matrices: this frame's and last
// frame's. Both are unjittered and carry the framebuffer origin, the way
// `post/camera_velocity.frag`'s are; the fragment stage turns the two clip
// positions into a difference in UV. The pass's own `gl_Position` goes
// through the jittered `FrameInfo.mvp`, so a fragment lands on the pixel the
// scene drew it on.
//
// **Hidden parts are rejected against the surface buffer, not a depth
// attachment.** Every pass in this engine clears depth on entry, so the
// scene's depth is not there to test against; the surface buffer holds the
// same answer in metres along the camera's axis. Each vertex hands on its
// own distance along that axis, and the fragment stage drops a fragment
// that lies behind what the scene drew there.
//
// Included after `lib/morph.glsl`: the position is morphed twice, by this
// frame's weights and by last frame's, through the one texture of deltas.

#ifndef VELOCITY_GLSL_
#define VELOCITY_GLSL_

layout(std140) uniform PrevFrameInfo {
  /// World to clip for this frame, unjittered, times the model matrix.
  mat4 current_mvp;

  /// The same product as it stood last frame: last frame's camera, last
  /// frame's model matrix.
  mat4 previous_mvp;

  /// Last frame's morph weights, packed as `MorphInfo.morph_weights` is.
  vec4 previous_morph_weights[2];

  /// xyz: where the eye is now.
  vec4 camera;

  /// xyz: the direction the camera looks now — the axis the surface
  /// buffer's depths are measured along.
  vec4 forward;
}
prev_info;

out vec4 v_current;
out vec4 v_previous;

/// This vertex's distance along the camera's axis, in metres.
out float v_depth;

float DepthAlongAxis(vec4 world) {
  return dot(world.xyz - prev_info.camera.xyz, prev_info.forward.xyz);
}

/// [position] moved towards the mesh's targets by [w0] and [w1] — the
/// position half of `ApplyMorph`, with the weights passed in.
vec3 MorphPositionWith(vec3 position, vec4 w0, vec4 w1) {
  int count = MorphCount();
  if (count <= 0) return position;

  float column = MorphColumn();
  float rowStep = morph_info.morph_params.z;
  vec3 normal = vec3(0.0);
  vec4 tangent = vec4(0.0);
  for (int i = 0; i < kMorphMax; i++) {
    if (i >= count) break;
    float weight = i < 4 ? w0[i] : w1[i - 4];
    if (weight == 0.0) continue;
    AddMorphTargetAt(i, weight, column, rowStep, position, normal, tangent);
  }
  return position;
}

#endif  // VELOCITY_GLSL_


mat4 Affine(vec4 row0, vec4 row1, vec4 row2) {
  return mat4(vec4(row0.x, row1.x, row2.x, 0.0),
              vec4(row0.y, row1.y, row2.y, 0.0),
              vec4(row0.z, row1.z, row2.z, 0.0),
              vec4(row0.w, row1.w, row2.w, 1.0));
}

void main() {
  vec3 now = MorphPositionWith(position, morph_info.morph_weights[0],
                               morph_info.morph_weights[1]);
  vec3 then = MorphPositionWith(position, prev_info.previous_morph_weights[0],
                                prev_info.previous_morph_weights[1]);
  vec4 local = Affine(i_row0, i_row1, i_row2) * vec4(now, 1.0);
  vec4 before = Affine(i_prev_row0, i_prev_row1, i_prev_row2) * vec4(then, 1.0);
  v_current = prev_info.current_mvp * local;
  v_depth = DepthAlongAxis(frame_info.model * local);
  v_previous = prev_info.previous_mvp * before;
  gl_Position = frame_info.mvp * local;
}

''',
    'PolylineVertex': r'''#version 300 es

// A polyline of constant screen width, widened here rather than on the CPU —
// gfx-86n.
//
// **Why a vertex stage and not a rebuilt buffer.** The width is a number of
// pixels, so where a band's edges go depends on the camera, and working them
// out on the CPU means rebuilding the whole line every time the camera moves.
// For a route of tens of thousands of points that is a rebuild per frame. Here
// the camera is `frame_info.mvp`, which the renderer already sets per draw, so
// the buffer is written once and a camera move costs a uniform.
//
// **The standard vertex layout, repacked.** A material's vertex stage reads the
// same sixteen floats every mesh does (see mesh.vert), because the engine lays
// out one format for every draw. A line needs different things from a vertex,
// and they fit into the same slots:
//
//   position   this point
//   normal     the previous point (this one again at the start of the line)
//   texcoord   x the distance along the line in metres, y 0 on one side and
//              1 on the other — passed through, for a fragment stage that
//              wants dashes or an edge falloff
//   tangent    xyz the next point (this one again at the end), w the half
//              width in pixels, signed for which side of the line this is
//   color      the colour at this point, which is what makes a gradient
//
// Each point is two vertices, one per side.
//
// **The viewport comes from the material's own block**, because nothing the
// engine binds to a vertex stage carries it: FrameInfo is three matrices and is
// shared with every mesh stage. `Material.parameters` is the channel an
// application already had for its own shaders; the renderer now hands it to a
// vertex stage the material brought as well, so a resize is one parameter
// written and not a rebuilt line.
//
// **A joint's own width, not a mitre.** Earlier this offset each point by a
// bisector of its two neighbouring segments, stretched to keep both segments
// full width through the turn — correct on paper, and unstable in practice:
// the stretch depends on the *angle* between two independently projected
// directions, and a projection has no floor on how extreme an angle it will
// report. A route that turns a modest 30 degrees in three dimensions can
// still turn near 180 degrees on screen from the right camera angle — most
// of it looking down the route's own general plane — and at that point the
// stretch a `stroke-miterlimit`-style clamp allows (four half widths) is
// already enough for a handful of neighbouring joints to cover a shape that
// has itself foreshortened to a sliver, which is what a turn's own
// mathematically-correct mitre looked like exploding into unrelated
// triangles as the camera swept past that angle.
//
// The offset here is instead the perpendicular of one direction per point —
// the direction from the point before this one to the point after it, which
// is exactly the *only* direction there is at either end of the line, where
// one of those two is a copy of this point. No angle between two directions
// is ever computed, so there is nothing here for an extreme projection to
// destabilise; the trade is a corner that can pinch inward on a sharp turn
// rather than one that mitres outward to meet both segments exactly — the
// same trade a plain averaged-tangent ribbon makes, for the same reason, and
// the one gfx-86n's original mitre existed to avoid. A pinch is bounded by
// the line's own half width; the mitre it replaced was not
// bounded by anything a viewer could see coming.

in vec3 position;
in vec3 normal;
in vec2 texcoord;
in vec4 tangent;
in vec4 color;

layout(std140) uniform FrameInfo {
  mat4 mvp;
  mat4 model;
  mat4 normal_matrix;
}
frame_info;

layout(std140) uniform MaterialParams {
  /// xy: the render target in pixels. zw unused.
  vec4 viewport;
}
params;

out vec3 v_world_position;
out vec3 v_normal;
out vec2 v_texcoord;
out vec4 v_tangent;
out vec4 v_color;
out vec2 v_lightmap_uv;

// The w below which a point is treated as at the eye. Dividing by a w near zero
// sends a neighbour to infinity, and dividing by a negative one mirrors it
// through the centre of the screen — either turns the band sideways.
const float kNear = 1e-4;

// `from`, moved along the segment towards `to` until it is in front of the eye.
//
// A neighbour behind the camera would otherwise divide into the wrong half of
// the screen, and the direction of the segment — which is all a neighbour is
// used for — would point backwards.
vec4 InFront(vec4 from, vec4 to) {
  if (from.w >= kNear) return from;
  float t = (kNear - from.w) / (to.w - from.w);
  return mix(from, to, clamp(t, 0.0, 1.0));
}

// A clip position in pixels from the centre of the viewport.
vec2 ToPixels(vec4 clip, vec2 viewport) {
  return clip.xy / clip.w * viewport * 0.5;
}

void main() {
  vec2 viewport = params.viewport.xy;
  float halfWidth = abs(tangent.w);
  float side = tangent.w < 0.0 ? -1.0 : 1.0;

  vec4 here = frame_info.mvp * vec4(position, 1.0);
  vec4 before = frame_info.mvp * vec4(normal, 1.0);
  vec4 after = frame_info.mvp * vec4(tangent.xyz, 1.0);

  // A point behind the eye is pulled forward along whichever segment reaches
  // the front, so the visible part of that segment is drawn where it is. Both
  // neighbours behind as well means nothing of this point is visible, and it
  // is left where the clipper will discard it.
  if (here.w < kNear) {
    here = after.w >= kNear ? InFront(here, after) : InFront(here, before);
  }
  before = InFront(before, here);
  after = InFront(after, here);

  // From the point before this one straight to the point after it — skipping
  // `here` itself, so an end of the line (where one neighbour is a copy of
  // `here`) reduces to the one direction that neighbour alone gives, with
  // nothing to fall back from.
  vec2 dir = ToPixels(after, viewport) - ToPixels(before, viewport);
  float dirLength = length(dir);
  // A pair of coincident points on screen — a true zero-length line, not
  // just a foreshortened one — has no direction to be wide across; any
  // perpendicular is as good as any other for the one degenerate pixel it
  // affects.
  vec2 segmentNormal = dirLength > 1e-9
      ? vec2(-dir.y, dir.x) / dirLength
      : vec2(1.0, 0.0);

  vec2 offset = segmentNormal * halfWidth * side;

  // Back from pixels to clip space, at this point's own w, so the offset is the
  // same number of pixels at every depth.
  gl_Position =
      vec4(here.xy + offset / (viewport * 0.5) * here.w, here.zw);

  v_world_position = (frame_info.model * vec4(position, 1.0)).xyz;
  // A band has no normal of its own. Up is what a route drawn over ground
  // faces, and it is a unit vector, which is what ReadSurface normalises —
  // zero would be a NaN in every screen-space effect that reads the buffer.
  v_normal = normalize(mat3(frame_info.normal_matrix) * vec3(0.0, 1.0, 0.0));
  v_texcoord = texcoord;
  v_tangent = vec4(1.0, 0.0, 0.0, 1.0);
  v_color = color;
  v_lightmap_uv = vec2(0.0);
}

''',
    'ParticleVertex': r'''#version 300 es

// Vertex stage for particles.
//
// The quads arrive already facing the camera. Billboarding on the CPU rather
// than here is the cheaper arrangement for this engine: the alternative expands
// a point into a quad in the vertex stage, which needs either a geometry stage
// — flutter_gpu has none — or four vertices carrying the same centre plus a
// corner index, which is the same bandwidth this uses with an extra
// reconstruction on top.
//
// A third layout, and therefore a third vertex shader: flutter_gpu reads the
// layout from the order of these declarations, so position + colour + texcoord
// cannot share a stage with anything else. See VertexLayout.positionColorTexcoord.
in vec3 position;
in vec4 color;
in vec2 texcoord;

layout(std140) uniform ParticleInfo {
  mat4 view_projection;
}
particle_info;

out vec4 v_color;
out vec2 v_uv;

/// Carried so the fragment stage can be fogged. A particle knows where it is
/// only here; the quad's own coordinates say nothing about the world.
out vec3 v_world_position;

void main() {
  v_color = color;
  v_uv = texcoord;
  v_world_position = position;
  gl_Position = particle_info.view_projection * vec4(position, 1.0);
}

''',
    'ParticleMeshVertex': r'''#version 300 es

// Vertex stage for mesh particles: one mesh, drawn once per particle.
//
// The billboard path (particle.vert) expands each particle into a quad on the
// CPU and sends four vertices per particle. That is the right trade for a
// sprite, where the quad *is* the particle and building it costs four writes.
// It is the wrong trade for a mesh: a hundred embers of forty vertices each
// would be four thousand vertices rewritten every frame, when the geometry
// never changes and only the placement does.
//
// So this reads two buffers. The mesh sits in slot 0 and is uploaded once; the
// placements sit in slot 1, are rebuilt each frame, and step once per instance.
// That split is the whole reason `VertexLayoutSpec` exists.
in vec3 position;
in vec3 normal;

/// Where this instance's copy of the mesh goes, in world space.
in vec3 i_position;

/// Linear RGB with alpha as brightness, matching Particle.color. These draw
/// additively, so alpha is not coverage.
in vec4 i_color;

/// Uniform scale. One number rather than three, because a particle's size is
/// one number everywhere else in this engine and a non-uniform scale would need
/// the normal transformed by an inverse transpose to stay a normal.
in float i_scale;

layout(std140) uniform ParticleMeshInfo {
  mat4 view_projection;
}
particle_mesh_info;

out vec4 v_color;
out vec3 v_world_position;
out vec3 v_normal;

void main() {
  // Scale and translate, and no rotation. A rotation per instance is four more
  // floats and a matrix build per vertex; it is worth having and it is not
  // worth guessing at before something asks. What is here is what an ember or a
  // shard needs: a size, a place, and a colour.
  vec3 world = i_position + position * i_scale;

  v_color = i_color;
  v_world_position = world;
  // Uniform scale leaves a normal a normal, which is the second reason the
  // scale is one number.
  v_normal = normal;

  gl_Position = particle_mesh_info.view_projection * vec4(world, 1.0);
}

''',
    'ShadowTileResetVertex': r'''#version 300 es

// Vertex stage for the atlas tile reset. See shadow_tile_reset.frag.
//
// The same oversized triangle every full-screen pass uses, with one difference
// that matters: **z sits on the far plane, not at zero.**
//
// post/fullscreen.vert emits z = 0, which is mid-depth. That is harmless for a
// post pass, where nothing depth-tests afterwards. Here the casters are drawn
// into the same tile immediately after, comparing `less` against a buffer this
// triangle has just covered — and a mid-depth value stamped across the tile
// makes every caster beyond it fail the test and vanish. It showed up as a
// shadow that was present before the tile reset existed and missing after:
// 423 pixels of `cube-shadow`, all inside the one occupied tile.
//
// Depth writes are switched off for this draw as well, so in principle the
// value is never stored. Writing the far plane anyway costs nothing and means
// the pass does not depend on that being true — which is worth more than the
// elegance, given the value written would be invisible right up until it
// silently deleted a shadow.
in vec2 position;
in vec2 texcoord;

out vec2 v_uv;

void main() {
  v_uv = texcoord;
  gl_Position = vec4(position, 1.0, 1.0);
}

''',
    'SkyVertex': r'''#version 300 es

// Vertex stage for the sky: one full-screen triangle, and everything the
// fragment stage needs, carried on the vertices.
//
// **The sky's data travels as attributes because uniforms do not reach this
// pipeline on Impeller.** That is not a guess and not a workaround chosen for
// taste; it is the one channel that was measured to work. What was measured,
// each against a frame recorded from a real Metal device with the golden runner
// (`tool/golden.sh sky`), and each with the picture read back rather than eyed:
//
//  * a uniform block bound to this stage — an identity matrix was bound and the
//    shader read something else, so the picture never changed;
//  * a uniform block bound to the fragment stage — a pure red zenith was bound
//    and the shader saw something that was not red;
//  * a vertex attribute — arrived exactly, to the value bound;
//  * a varying — interpolated across the triangle exactly.
//
// The same two binds work everywhere else in this renderer, in the same pass,
// in the same frame: every mesh takes its matrices this way and every
// post-processing stage takes its settings this way. Why this pipeline is
// different is not known. What is known is which door is open.
//
// The cost is a vertex buffer of three vertices rebuilt each frame, which is
// 348 bytes through the transient allocator — less than one uniform upload.
//
// ---------------------------------------------------------------------------
//
// **The depth.** A post pass writes `gl_Position.z = 0.0`, which is the *near*
// plane; the sky belongs at the far one. This writes 0.999999 — the far plane,
// less a hair. Strictly less than 1.0 so that the ordinary `less` test passes
// against a buffer cleared to 1.0, which is what lets the sky be drawn with the
// pass's own depth state and no `setDepthCompare` at all. With depth writes off
// it never occludes anything, and because it is drawn after the opaque half,
// every pixel already covered by geometry fails the test before the fragment
// stage runs.
//
// **The ray.** One direction per corner, computed on the CPU from the inverse
// view-projection and interpolated across the triangle — which for a
// perspective camera is exact, because the direction is affine in the screen
// position. The renderer builds them; see `Renderer._skyCornerRay`.
precision highp float;

layout(location = 0) in vec2 position;

// The world-space view ray at this corner.
layout(location = 1) in vec3 corner_ray;

// The preset, replicated on all three vertices. Constant across the triangle,
// so any interpolation of it returns exactly what was written.
layout(location = 2) in vec4 zenith;
layout(location = 3) in vec4 horizon;
layout(location = 4) in vec4 nadir;
/// xyz: unit vector pointing at the sun. w: how tight the scattering lobe is.
layout(location = 5) in vec4 sun;
/// rgb: the sun's own colour. a: how bright the lobe is.
layout(location = 6) in vec4 glow;
/// x: cosine of the disc's angular radius. y: cosine of the radius plus its
/// soft edge. z: how bright the disc is. w: unused.
layout(location = 7) in vec4 disc;

out vec3 v_ray;
out vec4 v_zenith;
out vec4 v_horizon;
out vec4 v_nadir;
out vec4 v_sun;
out vec4 v_glow;
out vec4 v_disc;

void main() {
  v_ray = corner_ray;
  v_zenith = zenith;
  v_horizon = horizon;
  v_nadir = nadir;
  v_sun = sun;
  v_glow = glow;
  v_disc = disc;

  gl_Position = vec4(position, 0.999999, 1.0);
}

''',
    'SkyCubeVertex': r'''#version 300 es

// Vertex stage for the cube-map sky: the same full-screen triangle as
// `sky.vert`, with the one value that stage carries instead of a preset.
//
// **Its own stage rather than a shared one**, because the vertex layout is
// derived from these declarations and the two fragment stages want different
// things: the gradient wants six vec4s of preset, this wants a tint. One
// shader serving both would have to declare the union and every draw would
// carry what the other one needed.
//
// Why any of it travels on the vertices at all is written out in `sky.vert`.
precision highp float;

layout(location = 0) in vec2 position;

/// The world-space view ray at this corner.
layout(location = 1) in vec3 corner_ray;

/// rgb: what the sampled cube is multiplied by. a: unused.
layout(location = 2) in vec4 tint;

out vec3 v_ray;
out vec4 v_tint;

void main() {
  v_ray = corner_ray;
  v_tint = tint;
  gl_Position = vec4(position, 0.999999, 1.0);
}

''',
    'VertexTextureProbeVertex': r'''#version 300 es

// **A probe, not a feature.** It answers one question that decides how morph
// targets are drawn: can a *vertex* stage sample a texture on this backend?
//
// Nothing else in this engine samples anything in a vertex stage. The two
// routes for morphing on the GPU are a second vertex layout — and with it a
// second vertex shader per lighting model, because the layout here is
// structural — or the deltas in a texture read by vertex id, which needs
// exactly this. WebGL2 guarantees it by specification (GLES 3.0 requires at
// least sixteen vertex texture units) and the software rasteriser is our own
// code; flutter_gpu is the unknown, and an unknown that a comment cannot
// settle.
//
// So the answer is measured on every backend rather than assumed on two and
// hoped for on the third. `checkVertexTextureSampling` in
// flutter3d_conformance is what reads it: this stage passes what it sampled
// through to the fragment stage unchanged, so a frame read back holds the
// texture's own colour when the sample worked and the clear colour when the
// draw never landed.

in vec3 position;

/// The texture the vertex stage reads. One texel is enough: what is being
/// asked is whether the read happens at all, not whether it filters.
uniform sampler2D probe_texture;

layout(std140) uniform ProbeInfo {
  /// xy: where to sample. zw unused.
  vec4 at;
}
probe_info;

out vec4 v_sampled;

void main() {
  // `textureLod` and not `texture`: a vertex stage has no derivatives, so the
  // level has to be named. Asking for an implicit one is undefined and is the
  // shape of a probe that reports a driver's opinion rather than a fact.
  v_sampled = textureLod(probe_texture, probe_info.at.xy, 0.0);
  gl_Position = vec4(position, 1.0);
}

''',
    'ImpostorVertex': r'''#version 300 es

// An octahedral impostor's card, turned to face the eye here rather than on
// the CPU — C4.
//
// **The standard vertex layout, repacked**, the way polyline.vert repacks it:
//
//   position   a corner of the card as built, upright in the XY plane around
//              the middle of the baked sphere — not where it is drawn, but
//              what the engine measures a node's bounds from, so a card is
//              culled and sized for a level of detail by the sphere it
//              stands in; the middle is this less the corner's offset
//   normal     +Z, the way the card as built faces
//   texcoord   which corner: (0, 0) top left to (1, 1) bottom right
//   tangent    w the sphere's radius in the node's own space; xyz unused
//   color      a the card's opacity, carried to the fragment stage
//
// **Every input is read.** The vertex layout is taken from the declarations
// in order, and an input the compiler drops is a reflection that no longer
// matches the buffer — impellerc refuses the stage outright.
//
// **The eye comes out of the matrix, not out of a uniform.** A card has to
// know where it is looked at from, and FrameInfo is three matrices with no
// camera in them. The eye is the one point every clip row but z sends to
// nought — x, y and w are all zero there — so three rows of the mvp are a
// 3 x 3 system whose answer is the eye in this node's own space, solved below
// by cross products because WGSL has no `inverse`. An orthographic camera has
// no such point: its w row is constant, the system is singular, and the
// direction to the eye is then the one clip depth falls along.
//
// **Varyings, and what each carries** — the lit stage reads the surface
// through them:
//
//   v_normal     the card's facing, in the world: the direction to the eye
//   v_tangent    the card's right-hand axis in the world, w one
//   v_texcoord   the corner, interpolated: where on the card a fragment is
//   v_color      xyz the direction to the eye in the node's own space, which
//                is what picks the baked views; w the vertex alpha

// --- lib/impostor.glsl ---
// The octahedral view grid an impostor is baked on and read from — C4.
//
// Shared by `impostor.vert` and `lighting/impostor.frag`, and mirrored in
// `flutter3d_core`'s `impostor_node.dart` (the bake) and `flutter3d_cpu`'s
// `cpu_shaders_impostor.dart`: the card, the camera a view was baked from and
// the cell it was baked into have to agree to the last sign, or a view is
// read mirrored.

#ifndef IMPOSTOR_GLSL_
#define IMPOSTOR_GLSL_

/// Views along each side of the atlas. Fixed in 0.8: the plan's 8 x 8, and a
/// constant so no block has to carry it.
#define kImpostorGrid 8.0

/// A direction on the sphere as a point of the unit square, with +Y at the
/// centre and -Y at the four corners — the octahedral map with Y as its pole,
/// so the views a tree is mostly seen from (level, and from above) take the
/// middle of the atlas rather than its folded edges.
vec2 ImpostorEncode(vec3 d) {
  vec3 a = abs(d);
  vec2 p = d.xz / max(a.x + a.y + a.z, 1e-8);
  vec2 s = vec2(p.x >= 0.0 ? 1.0 : -1.0, p.y >= 0.0 ? 1.0 : -1.0);
  vec2 folded = (vec2(1.0) - abs(p.yx)) * s;
  return (d.y >= 0.0 ? p : folded) * 0.5 + vec2(0.5);
}

/// The inverse of [ImpostorEncode].
vec3 ImpostorDecode(vec2 uv) {
  vec2 p = uv * 2.0 - vec2(1.0);
  float y = 1.0 - abs(p.x) - abs(p.y);
  vec2 s = vec2(p.x >= 0.0 ? 1.0 : -1.0, p.y >= 0.0 ? 1.0 : -1.0);
  vec2 folded = (vec2(1.0) - abs(p.yx)) * s;
  vec2 xz = y >= 0.0 ? p : folded;
  return normalize(vec3(xz.x, y, xz.y));
}

/// The right-hand axis of a card, or a baked view, facing along [d]: level
/// with the ground, except looking straight up or down, where "level" has no
/// direction and -Z stands in for up.
vec3 ImpostorRight(vec3 d) {
  vec3 up = abs(d.y) > 0.999 ? vec3(0.0, 0.0, -1.0) : vec3(0.0, 1.0, 0.0);
  return normalize(cross(up, d));
}

#endif  // IMPOSTOR_GLSL_


in vec3 position;
in vec3 normal;
in vec2 texcoord;
in vec4 tangent;
in vec4 color;

layout(std140) uniform FrameInfo {
  mat4 mvp;
  mat4 model;
  mat4 normal_matrix;
}
frame_info;

out vec3 v_world_position;
out vec3 v_normal;
out vec2 v_texcoord;
out vec4 v_tangent;
out vec4 v_color;
out vec2 v_lightmap_uv;

vec4 MvpRow(int r) {
  return vec4(frame_info.mvp[0][r], frame_info.mvp[1][r],
              frame_info.mvp[2][r], frame_info.mvp[3][r]);
}

void main() {
  float radius = tangent.w;
  vec3 centre = position - vec3(texcoord.x * 2.0 - 1.0,
                                1.0 - texcoord.y * 2.0, 0.0) * radius;

  vec4 rx = MvpRow(0);
  vec4 ry = MvpRow(1);
  vec4 rz = MvpRow(2);
  vec4 rw = MvpRow(3);
  vec3 yw = cross(ry.xyz, rw.xyz);
  vec3 wx = cross(rw.xyz, rx.xyz);
  vec3 xy = cross(rx.xyz, ry.xyz);
  float det = dot(rx.xyz, yw);
  vec3 eye = -(rx.w * yw + ry.w * wx + rw.w * xy) /
             (abs(det) > 1e-20 ? det : 1.0);
  vec3 toEye = abs(det) > 1e-20 ? eye - centre : -rz.xyz;
  // An eye at the very middle of the sphere sees the card as it was built.
  vec3 d = normalize(dot(toEye, toEye) > 1e-20 ? toEye : normal);

  vec3 right = ImpostorRight(d);
  vec3 up = cross(d, right);
  vec3 corner = centre + (right * (texcoord.x * 2.0 - 1.0) +
                          up * (1.0 - texcoord.y * 2.0)) * radius;

  v_world_position = (frame_info.model * vec4(corner, 1.0)).xyz;
  v_normal = normalize(mat3(frame_info.normal_matrix) * d);
  v_tangent = vec4(normalize(mat3(frame_info.model) * right), 1.0);
  v_texcoord = texcoord;
  v_color = vec4(d, color.a);
  v_lightmap_uv = vec2(0.0);

  gl_Position = frame_info.mvp * vec4(corner, 1.0);
}

''',
  },
  <String, String>{
    'Unlit': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// Albedo only. Useful as a baseline: whatever this shows is purely texture and
// tint, with no lighting term involved.
// This model has no shadow term, and `LightingModel.unlit` says so with
// `usesMaterialMaps: false` — so the engine binds no `PointShadow` block. The
// header must therefore not declare one: a block declared and unbound is a
// dropped draw on WebGL2 and a phantom bind on Impeller. See surface.glsl.
#define F3D_NO_POINT_SHADOW
// The light list too, for the same reason, and that one was not caught before
// 0.7.0 shipped: see surface.glsl.
#define F3D_NO_LIGHT_LIST
// --- lib/surface.glsl ---
// Shared material and lighting interface for the lighting models.
//
// flutter_gpu compiles shaders ahead of time into a bundle: there is no runtime
// compilation, so a node-graph material system assembled at run time is
// impossible. Each lighting model is therefore
// its own pre-built fragment shader, and this header is what keeps them
// interchangeable — one identical uniform block, so the Dart binding code never
// needs to know which model is active.
//
// Keep every declaration below byte-identical across models. A member a model
// does not read may be optimized out of the reflected block, which is why the
// Dart side skips absent members instead of failing.
//
// Only include this from a shader that actually reads FragInfo. Declaring the
// block without using it leaves it visible to reflection while the compiled
// shader binds no buffer for it, and binding that phantom block segfaults
// inside Metal. Shaders needing only colour helpers include lib/color.glsl.

#ifndef SURFACE_GLSL_
#define SURFACE_GLSL_

// --- lib/color.glsl ---
// Colour space helpers and the fragment output interface.
//
// Split out of surface.glsl so a shader that needs no material inputs — the
// normals debug view — can avoid DECLARING the FragInfo uniform block at all.
// That matters more than it looks: reflection metadata reports a block as
// present merely because it was declared, even when the compiled shader binds
// no such buffer, so a declared-but-unused block is indistinguishable from a
// used one until Metal crashes on the bind.

#ifndef COLOR_GLSL_
#define COLOR_GLSL_

precision highp float;

const float kPi = 3.14159265359;

// One varying set shared by every fragment shader, matching mesh.vert.
//
// All five are declared here, including the two the debug models never read: a
// fragment shader whose `in` block disagrees with the vertex shader's `out`
// block fails to link, and there is no partial-match rule to lean on.
in vec3 v_world_position;
in vec3 v_normal;
in vec2 v_texcoord;
in vec4 v_tangent;
in vec4 v_color;

/// Where this fragment is in the level's lightmap. Zero from every vertex
/// stage but `mesh_lightmapped.vert`, and read only by the lit models, which
/// sample a one-texel black there when a material has no map.
in vec2 v_lightmap_uv;

layout(location = 0) out vec4 frag_color;

// The second attachment: what a screen-space effect needs to know about the
// surface it is looking at. World-space normal in rgb, and in a the depth along
// the view axis in world metres — not a window depth; `WriteSurfaceGeometry`
// says at length why not.
//
// Depth travels here rather than in a depth texture because flutter_gpu cannot
// sample one — the same reason the shadow pass writes its depth into a colour
// target. See ARCHITECTURE.md §2.
//
// Guarded, because not every stage that includes this header draws into a
// two-attachment target. The shadow pass draws into one, and a pipeline
// declaring an output its target has no slot for is a mismatch worth avoiding
// rather than discovering.
#ifndef F3D_NO_SURFACE_BUFFER
layout(location = 1) out vec4 frag_surface;

/// The surface's own colour, sRGB-encoded, alpha one where a surface was
/// drawn — `L5`. The third attachment, present only when a pass reads it (the
/// indirect light does) and the device opens three; like the surface buffer,
/// written unconditionally and discarded when absent. Stored in the surface
/// buffer's format rather than eight bits a channel, and `Renderer` says why.
layout(location = 2) out vec4 frag_albedo;
#endif

/// What [frag_albedo] carries: the lit models set it in `ReadSurface`, and a
/// stage that reflects nothing — unlit, the debug views — leaves it black,
/// which is what light bounced onto it would come to.
vec3 g_albedo = vec3(0.0);

/// Octahedral encoding: a unit vector in two channels instead of three.
///
/// Worth the arithmetic because the fourth channel is already spent on depth,
/// and without a free channel there is nowhere to put roughness — which is the
/// difference between a reflection that knows stone from a mirror and one that
/// does not. The error is well under a degree, far below anything a reflection
/// off rough stone would show.
vec2 EncodeOctahedral(vec3 n) {
  n /= abs(n.x) + abs(n.y) + abs(n.z);
  vec2 e = n.xy;
  if (n.z < 0.0) {
    e = (1.0 - abs(n.yx)) * vec2(n.x >= 0.0 ? 1.0 : -1.0,
                                 n.y >= 0.0 ? 1.0 : -1.0);
  }
  return e * 0.5 + 0.5;
}

/// Where a debug pass leaves the picture it wants shown instead of the normal.
///
/// Declared here, in the header every lit shader includes **first**, and
/// written from surface.glsl, which is included after. The alternative was a
/// new member on a shared uniform block; a global costs nothing and moves no
/// offsets. It is read at the moment the surface buffer is written, which
/// happens after the lighting loop has run, so the value is there by then.
vec3 g_debug_surface = vec3(0.0);
bool g_debug_surface_on = false;

/// Whether [WriteSurface] weights the colour by its alpha: set by
/// `ReadSurface` for a material that blends, and false for everything else.
///
/// **The blend takes its source as premultiplied**, so a blended surface has
/// to hand it the colour times the alpha — a pane at a fifth of opaque adds a
/// fifth of its light, not all of it. glTF's blend mode is Porter and Duff's
/// over on straight colour, and this is the one place that turns the lit
/// radiance into what that means. An opaque or masked surface keeps its
/// colour whole: its alpha is not a coverage, and nothing blends it.
/// A global for the reason [g_debug_surface] is one.
bool g_premultiply = false;

// **A stage that needs none of this must be able to declare none of it.** On
// Vulkan both stages' descriptors are merged into one set layout, and two
// bindings with the same number in it is not a layout the specification
// allows. A driver may accept it anyway; a Galaxy A55's refuses the pipeline
// with `ErrorUnknown` and no other word, which is how the shadow pass came to
// build everywhere except there — its only uniform block was this one, and it
// landed on the same binding as the vertex stage's first.
#ifndef F3D_NO_FOG

/// Distance fog, in its own block rather than folded into FragInfo.
///
/// Its own because color.glsl is included before FragInfo is declared, and
/// because appending to a block that half a dozen shaders already share is a
/// way to move offsets nobody expected to move. Three vec4s is a cheap price
/// for not touching any of that.
layout(std140) uniform FogInfo {
  /// rgb: linear fog colour. w: density per metre, zero for no fog.
  vec4 fog;

  /// xyz: camera position in world space. Duplicated from FragInfo so this
  /// block stands alone; a vec3 is cheaper than a coupling.
  vec4 eye;

  /// xyz: the direction the camera looks, as a unit vector in world space.
  /// w: what a transparent draw writes under weighted blended transparency —
  /// `R8`, see `WriteWeightedBlended`. Zero for every other draw.
  ///
  /// Here rather than in a block of its own because it answers the same
  /// question [eye] does — where the camera is and which way it faces — and
  /// this is the block `color.glsl` can see.
  vec4 forward;
}
fog_info;

/// How far this fragment is from the eye, in world metres.
///
/// What the fog fades by. Distance rather than depth, because fog is a
/// property of the air between two points and does not care which way the
/// camera happens to face.
float EyeDistance() { return distance(v_world_position, fog_info.eye.xyz); }

/// How far this fragment is *along the view axis*, in world metres.
///
/// What the surface buffer's alpha holds. Depth rather than distance, and the
/// difference only shows on an orthographic camera — where the rays through
/// the pixels are parallel instead of meeting at the eye, so a distance from
/// the eye names a sphere that the pixel's ray crosses somewhere the reader
/// cannot solve for. A depth along the axis names a plane, which every ray
/// crosses exactly once. See `WorldAtDepth` in `post/ssao.frag` for the
/// reconstruction both projections share.
float ViewDepth() {
  return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz);
}

#else  // F3D_NO_FOG

// The same two questions, answered without the block: a stage that declares no
// fog has no eye position to measure from either. Stubs rather than a guard at
// every call site, so that what includes this file reads the same whichever
// way it was compiled.
float EyeDistance() { return 0.0; }
float ViewDepth() { return 0.0; }

#endif  // F3D_NO_FOG

/// sRGB to linear. Textures are authored in sRGB, but lighting is only correct
/// in linear space; skipping this is what makes naive renderers look muddy.
vec3 SrgbToLinear(vec3 srgb) {
  return mix(
      srgb / 12.92,
      pow((srgb + vec3(0.055)) / 1.055, vec3(2.4)),
      step(vec3(0.04045), srgb));
}

/// Linear to sRGB. The render target is a plain UNorm format rather than an
/// sRGB one, so the encode has to happen here.
vec3 LinearToSrgb(vec3 linear) {
  return mix(
      linear * 12.92,
      1.055 * pow(linear, vec3(1.0 / 2.4)) - vec3(0.055),
      step(vec3(0.0031308), linear));
}

/// Writes scene-referred linear light into the HDR target.
///
/// No tone map and no sRGB encode: those moved into the composite pass, which
/// is the entire point of rendering into `r16g16b16a16Float` first. Applying
/// them here meant every model wrote display-referred colour into an 8-bit
/// buffer, so anything above display white was gone before post-processing
/// could see it — and bloom is a function of exactly that.
///
/// Exposure moved with them, for the same reason: it belongs on the same side
/// of the display transform as the tone map.
/// Records the geometry of this fragment for whatever runs after the scene.
///
/// Called from the same place that writes colour, so a surface cannot be lit
/// into the frame without also describing itself — which is the failure that
/// leaves a screen-space effect reflecting whatever was in the buffer before.
///
/// rg: octahedral normal. b: perceptual roughness. a: **depth along the view
/// axis, in world metres** — see [ViewDepth].
///
/// **Not `gl_FragCoord.z`, and that is a defect this channel carried until it
/// was looked at.** Window depth crowds every distant surface into the top of
/// its range — with a near plane of a tenth of a metre, everything past twenty
/// metres lives in the last half a hundredth of `[0, 1]` — and this attachment
/// is a half float, whose steps up there are about five ten-thousandths. So two
/// surfaces half a metre apart at twenty metres stored the *same* number, and
/// every screen-space pass that compares against this channel decided whole
/// bands of pixels by rounding. The occlusion pass drew them: vertical stripes
/// along the lines of constant depth on any wall receding from the camera, on
/// both GPU backends. The software rasteriser kept the channel at full
/// precision and drew the effect correctly, so it was the one that looked
/// wrong against the other two.
///
/// A depth in metres has none of that: the exponent carries the range and the
/// mantissa carries the same relative precision everywhere, which at twenty
/// metres is a centimetre. Both numbers are measured in
/// `flutter3d/test/surface_depth_test.dart`.
///
/// Zero still means nothing was drawn. The attachment is cleared to zero and
/// nothing is drawn in front of the near plane.
void WriteSurfaceGeometry(float roughness) {
#ifndef F3D_NO_SURFACE_BUFFER
  // `L5`: the surface's colour, whatever the surface buffer ends up holding.
  frag_albedo = vec4(LinearToSrgb(clamp(g_albedo, vec3(0.0), vec3(1.0))), 1.0);
  // A debug pass takes the buffer over rather than getting one of its own.
  // The surface buffer already has an attachment, a viewer and a golden; a
  // second one would need all three built before it could answer anything.
  if (g_debug_surface_on) {
    frag_surface = vec4(g_debug_surface, ViewDepth());
    return;
  }
  // Reversed on a back face, as the lit normal is, so the occlusion and
  // reflection passes see the side of a double-sided surface that faces them.
  vec3 geometric = normalize(v_normal);
  if (!gl_FrontFacing) geometric = -geometric;
  frag_surface = vec4(EncodeOctahedral(geometric),
                      clamp(roughness, 0.0, 1.0), ViewDepth());
#endif
}

/// Fades [color] toward the fog with distance from the eye.
///
/// Exponential rather than linear, because linear fog has a visible plane
/// where it starts and a dungeon corridor is exactly where that shows.
vec3 ApplyFog(vec3 color) {
#ifdef F3D_NO_FOG
  return color;
#else
  float density = fog_info.fog.w;
  if (density <= 0.0) return color;
  float d = EyeDistance();
  return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0));
#endif
}

/// How much a transparent fragment counts for against the others over its
/// pixel — `R8`. McGuire and Bavoil's depth weight (their equation 9): a near
/// layer outweighs a far one, which is all the ordering a weighted average
/// can keep. [alpha] multiplies it, as theirs does, so a faint layer counts
/// faintly. Depth along the view axis, in metres, the surface buffer's.
float WeightedBlendedWeight(float alpha) {
  float z = abs(ViewDepth());
  float near = z / 5.0;
  float far = z / 200.0;
  float far3 = far * far * far;
  return alpha *
         clamp(10.0 / (1e-5 + near * near + far3 * far3), 1e-2, 3e3);
}

/// What a transparent draw writes when the frame composites transparency
/// order-independently — `R8`. `fog_info.forward.w` says which:
///
/// - 0: [frag_color] as it stands, the sorted blend's source. Every opaque
///   draw, and every draw in a frame that sorts.
/// - 1: the accumulation target's share — the colour, which the engine keeps
///   premultiplied, and the alpha, both times the weight. Added.
/// - 2: the revealage target's — the alpha alone, in every channel, which the
///   blend multiplies the target by one minus of.
/// - 3: both at once, the second into attachment one, where the surface
///   buffer would be; the pass that asks has no surface buffer attached.
///
/// Selects rather than returns, because a phi of constants is what
/// SPIRV-Cross refuses. At nought the branch is not taken and [frag_color]
/// is untouched, which is what keeps a sorting frame byte-identical.
void WriteWeightedBlended() {
#ifndef F3D_NO_FOG
  float mode = fog_info.forward.w;
  if (mode > 0.5) {
    float alpha = frag_color.a;
    float weight = WeightedBlendedWeight(alpha);
    vec4 accumulate = vec4(frag_color.rgb * weight, alpha * weight);
    bool revealage = mode > 1.5 && mode < 2.5;
    frag_color = revealage ? vec4(alpha) : accumulate;
#ifndef F3D_NO_SURFACE_BUFFER
    if (mode > 2.5) frag_surface = vec4(alpha);
#endif
  }
#endif
}

/// The fog is mixed in before the weight, so a thin distant pane adds a thin
/// share of the fog too rather than all of it. Times one when nothing blends,
/// which is exact, so an opaque draw writes what it always wrote.
void WriteSurface(vec3 linearColor, float alpha, float roughness) {
  float weight = g_premultiply ? alpha : 1.0;
  frag_color = vec4(ApplyFog(linearColor) * weight, alpha);
  WriteSurfaceGeometry(roughness);
  WriteWeightedBlended();
}

/// For a stage with no material to speak of.
///
/// Fully rough, which is the honest default: a surface that cannot say how
/// polished it is should not be reflected off.
void WriteSurface(vec3 linearColor, float alpha) {
  WriteSurface(linearColor, alpha, 1.0);
}

/// Writes a value that is already display-referred.
///
/// For debug output, where the colour is not a light value at all: a normal
/// encoded as RGB means nothing after a tone curve. Converting to linear here
/// means the composite pass's sRGB encode hands the original back unchanged,
/// provided the view also turns tone mapping and exposure off — which is what
/// `RenderSettings.tonemap` is for.
void WriteDisplayColor(vec3 displayColor, float alpha) {
  frag_color = vec4(SrgbToLinear(displayColor), alpha);
  WriteSurfaceGeometry(1.0);
}

#endif  // COLOR_GLSL_

// --- lib/frag_coord.glsl ---
// Where a fragment sits, counted from the top of its target on every backend.

#ifndef FRAG_COORD_GLSL_
#define FRAG_COORD_GLSL_

/// `gl_FragCoord.xy` with row zero at the top of the picture.
///
/// [rows] is the target's height where the backend's row zero is the bottom
/// of the picture, and zero where it is the top. WebGL2 is the first kind:
/// window coordinates start at the lower left, and the engine draws the
/// picture upright there rather than mirroring every projection. Metal,
/// WebGPU and the software rasteriser are the second.
///
/// **Why a pattern cares and a picture does not.** Every screen-space pattern
/// in the engine — the Bayer dither, the grain, the jitter a ray march starts
/// from, the rotation of a shadow kernel — is a function of the pixel's row.
/// Read from the bottom, the same frame gets the pattern turned upside down,
/// and a four-row Bayer cell lands on different rows unless the height is a
/// multiple of four. The picture underneath is identical; the pattern on top
/// of it is not, and a comparison across backends counts every pixel it
/// moved.
vec2 FragCoordFromTop(float rows) {
  return rows > 0.0 ? vec2(gl_FragCoord.x, rows - gl_FragCoord.y)
                    : gl_FragCoord.xy;
}

#endif  // FRAG_COORD_GLSL_


/// Lights per draw. Must match LightBuffer.maxLights on the Dart side.
///
/// A fixed array with a runtime count, not a shader permutation per light
/// count: turning a light on has to be free, because there is no runtime
/// compilation to fall back on. Verified against the SDK — Impeller keeps
/// `vec4 x[8]` in the compiled Metal struct and reflects the array's base
/// offset, with the std140 stride of 16 bytes.
#define kMaxLights 8

/// How many more lights one draw may be handed — `gfx-74n`.
///
/// **The eight above stay exactly what they were**, which is what keeps this
/// from moving a single recorded frame: a draw with eight lights or fewer runs
/// the loop it has always run, reads the uniform arrays it has always read, and
/// never touches the texture below. The tail is the part that used to be
/// impossible.
///
/// A loop bound rather than a cost. `AccumulateLights` breaks at the draw's own
/// count, so a scene with three lights costs three iterations whatever this
/// says. Twenty-four because the two tables below are `vec4 x[6]` and four
/// lanes fit a `vec4`: two hundred and eight bytes a draw, against the five
/// hundred and twelve the light arrays already cost.
#define kExtraLights 24
#define kTotalLights (kMaxLights + kExtraLights)

// --- lib/light_list.glsl ---
// The frame's light list, and how a fragment finds its tail in it — `gfx-74n`
// and `L6`.
//
// Split out of `surface.glsl` so a stage that is not a surface can read the
// same lights: `N6`'s six-way particles light each fragment by the list the
// lit models read, clusters and all, without declaring `FragInfo`. The text is
// the one that stood in `surface.glsl`, moved rather than copied, so the lit
// models compile to what they compiled to before.

#ifndef LIGHT_LIST_GLSL_
#define LIGHT_LIST_GLSL_
/// Every light in the scene, one per row, four texels across — `gfx-74n`.
///
/// **A texture rather than a wider uniform block, and that is the design.**
/// `FragInfo` is uploaded on every draw, so widening its four `vec4` arrays to
/// hold thirty-two lights would be a two-kilobyte upload per draw in every
/// scene, including every scene with one light. This is built once a frame and
/// only when a scene has more lights than a draw can hold in its slots.
///
/// Row layout, which `renderer_light_list.dart` writes and only this reads:
///
///  * texel 0 — xyz world position, w type (0 directional, 1 point, 2 spot)
///  * texel 1 — rgb linear colour, w intensity
///  * texel 2 — xyz the direction it points, w range
///  * texel 3 — x cos(inner), y cos(outer), zw unused
///
/// The same four vectors the uniform arrays hold, in the same order, so one
/// reader serves both.
///
/// **`F3D_NO_LIGHT_LIST` leaves both out**, for a model that accumulates no
/// lights. Such a model never reaches the reader below, so the compiler drops
/// the block and the sampler from the Metal function while reflection still
/// lists them, with no buffer or texture index assigned. The renderer used to
/// bind them for every draw, Unlit included, and that bind is a crash inside
/// `setFragmentBuffer:offset:atIndex:` on Metal. Vulkan took the same draw
/// without a word, which is how 0.7.0 shipped with it.
#ifndef F3D_NO_LIGHT_LIST
uniform sampler2D light_list_texture;

layout(std140) uniform LightListInfo {
  /// x: how many rows this draw reads, zero when it reads none.
  /// y, z: one over the texture's width and height.
  /// w: unused.
  vec4 list;

  /// Which rows, four to a vector, in the order they are read.
  ///
  /// Indices rather than the light data itself: the data is the same for every
  /// draw in the frame and belongs in the texture; what differs per draw is
  /// *which* of them reach it, and that is what `Renderer._drawLightsFor`
  /// already decides.
  vec4 indices[6];

  /// How much of each of those survives the edge fade, in the same order.
  ///
  /// Per draw and not in the texture, because the row an index points at is
  /// shared by every draw in the frame: a scale written into it would dim that
  /// light for all of them. `gfx-12n`'s fade lives at the end of the list now —
  /// that is where a light stops contributing, and fading the slots against a
  /// water line that no longer marks a cliff would dim a light for no reason
  /// while its rival stayed bright, making the swap more visible rather than
  /// less.
  vec4 scales[6];

  /// `L6`: the view-projection the light clusters were cut with, so this
  /// finds a fragment's cell the way `LightClusters.clusterOf` does.
  mat4 cluster_view_projection;

  /// xyz: tiles across, tiles up, slices deep. w: one when this draw reads
  /// its tail from the cell it is in rather than from `indices`.
  vec4 cluster_grid;

  /// x: where slices begin, in clip w. y: slices per unit of `ln(w / x)`.
  /// z: the texture row the cells' headers start at, four to a row, each
  /// (offset, count). w: the row their entries start at, sixteen to a row.
  vec4 cluster_depth;

  /// Which rows this draw already holds in its eight slots, minus one for
  /// an empty slot. A cell lists every light that reaches it, and one the
  /// slots already carry must not be counted again.
  vec4 slot_rows[2];
}
light_list_info;

/// One lane of a six-vector table, [slot] counting from nought.
float LightListLane(vec4 four, int slot) {
  int lane = slot - (slot / 4) * 4;
  return lane == 0 ? four.x : lane == 1 ? four.y : lane == 2 ? four.z : four.w;
}

/// The row light [slot] of the list reads.
float LightListRow(int slot) {
  return LightListLane(light_list_info.indices[slot / 4], slot);
}

/// How much of light [slot] of the list survives the edge fade.
float LightListScale(int slot) {
  return LightListLane(light_list_info.scales[slot / 4], slot);
}

/// The cell this fragment falls in, as `LightClusters` wrote it: where its
/// entries start and how many there are. Found once, in [LightCount], and
/// read by every [SampleLight] of the loop that follows.
float g_cluster_offset = 0.0;
float g_cluster_count = 0.0;

bool Clustered() { return light_list_info.cluster_grid.w > 0.5; }

/// One texel of the light list texture, [texel] across and [row] down.
vec4 LightListTexel(float texel, float row) {
  return textureLod(light_list_texture,
                    vec2((texel + 0.5) * light_list_info.list.y,
                         (row + 0.5) * light_list_info.list.z),
                    0.0);
}

void FindCluster(vec3 world) {
  vec4 clip = light_list_info.cluster_view_projection * vec4(world, 1.0);
  vec2 ndc = clip.xy / max(clip.w, 1e-6);
  vec3 grid = light_list_info.cluster_grid.xyz;
  float near = light_list_info.cluster_depth.x;
  float tx = clamp(floor((ndc.x * 0.5 + 0.5) * grid.x), 0.0, grid.x - 1.0);
  float ty = clamp(floor((ndc.y * 0.5 + 0.5) * grid.y), 0.0, grid.y - 1.0);
  float tz = clip.w <= near
                 ? 0.0
                 : clamp(floor(log(clip.w / near) *
                               light_list_info.cluster_depth.y),
                         0.0, grid.z - 1.0);
  float cell = tx + ty * grid.x + tz * grid.x * grid.y;
  float row = floor(cell / 4.0);
  vec4 header =
      LightListTexel(cell - row * 4.0, light_list_info.cluster_depth.z + row);
  g_cluster_offset = header.x;
  g_cluster_count = header.y;
}

/// The row entry [slot] of this fragment's cell names.
float ClusterRow(int slot) {
  float entry = g_cluster_offset + float(slot);
  float row = floor(entry / 16.0);
  float within = entry - row * 16.0;
  float texel = floor(within / 4.0);
  vec4 four = LightListTexel(texel, light_list_info.cluster_depth.w + row);
  return LightListLane(four, int(within - texel * 4.0 + 0.5));
}

/// Whether one of the draw's slots already holds light list row [row].
bool InSlots(float row) {
  vec4 a = abs(light_list_info.slot_rows[0] - vec4(row));
  vec4 b = abs(light_list_info.slot_rows[1] - vec4(row));
  return min(min(min(a.x, a.y), min(a.z, a.w)), min(min(b.x, b.y), min(b.z, b.w))) < 0.5;
}
#endif  // F3D_NO_LIGHT_LIST

#endif  // LIGHT_LIST_GLSL_


layout(std140) uniform FragInfo {
  /// xyz: world position (point and spot). w: type, 0 directional 1 point 2 spot.
  vec4 light_position[kMaxLights];

  /// rgb: linear colour. w: intensity.
  vec4 light_color[kMaxLights];

  /// xyz: the direction the light points, its local -Z. w: range, 0 unbounded.
  vec4 light_direction[kMaxLights];

  /// x: cos(inner cone angle). y: cos(outer cone angle).
  vec4 light_cone[kMaxLights];

  /// rgb: albedo tint applied on top of the texture. w: opacity.
  vec4 base_color;

  /// rgb: emissive factor, already linear. w: one when the normal map has
  /// two channels (x, y) and its z is rebuilt — see `ApplyNormalMap`. It sits
  /// here because this was the block's one unspent lane.
  vec4 emissive;

  /// xyz: camera position in world space, needed for every specular term.
  vec4 camera_position;

  /// x: metallic, y: roughness, z: ambient strength, w: specular strength.
  vec4 material;

  /// x: alpha cutoff (negative when the material is not masked: -1 opaque,
  /// -0.5 blended, -2 hashed), y: normal scale, z: occlusion strength,
  /// w: emissive strength.
  vec4 material2;

  /// x: exposure, y: active light count, z: index of the shadow-casting light.
  /// w is reserved so adding a frame-wide parameter does not change the offsets
  /// of anything already here.
  vec4 frame_params;

  /// x: one texel of the shadow map, y: depth bias, z: normal offset,
  /// w: strength, zero when shadows are off.
  vec4 shadow_params;

  /// World space to the shadow camera's clip space. The first cascade.
  mat4 shadow_matrix;

  /// The second and third cascades. Copies of the first when there is one, so
  /// this block's layout never depends on how many there are.
  mat4 shadow_matrix_far;
  mat4 shadow_matrix_farthest;

  /// x, y: where cascades 0 and 1 end, in metres from the camera. z: how many
  /// cascades there are, 1 to 3. w: one texel of a tile, vertically —
  /// shadow_params.x is one texel of the whole atlas, and with more than one
  /// cascade those differ.
  vec4 shadow_cascades;

  /// rgb: what a surface facing straight up receives from the environment.
  /// w: one when the metal-rough models' diffuse is EON rather than Lambert —
  /// `L8`, `RenderSettings.diffuseModel`; a frame-wide switch in a frame-wide
  /// vector, and the block's offsets stay where four backends agree on them.
  ///
  /// Appended after everything else on purpose: std140 lays a block out in
  /// declaration order, so adding here leaves every offset above unchanged and
  /// the three backends do not have to agree about anything they did not
  /// already agree about.
  vec4 ambient_sky;

  /// rgb: what a surface facing straight down receives — bounce off the ground
  /// rather than the ground itself.
  ///
  /// **w is the directional light's apparent size** — `gfx-15n` — which has
  /// nothing to do with ambient and everything to do with this being the last
  /// unspent component in a block six shaders share. `frame_params.w` was the
  /// slot reserved for a frame-wide parameter and the environment's level
  /// count took it; appending to this block moves offsets four backends have
  /// agreed on. See `shadow.glsl`, which reads it.
  ///
  /// Two colours rather than one is the whole of what makes ambient look like
  /// light instead of like a lifted black level. Outdoors the sky is blue and
  /// bright and the ground is warm and dim, and a flat grey for both leaves
  /// every underside as pale as every upward face — which reads as the model
  /// being flat, and gets blamed on the normals.
  vec4 ambient_ground;

  /// x, y, z: the depth bias of each cascade, in that cascade's own normalized
  /// depth. w unused.
  ///
  /// `ShadowSettings.bias` is one number and a cascade's depth range is not:
  /// a near cascade is stretched towards the light when a caster stands
  /// further out than its own volume reaches, and the same bias over a longer
  /// range is a longer distance. The renderer converts it per cascade so it
  /// stays the distance it was tuned as; an unstretched cascade gets the
  /// setting unchanged.
  vec4 shadow_bias;

  /// x: the target's rows when its row zero is the bottom of the picture,
  /// zero when it is the top — see `FragCoordFromTop` in `frag_coord.glsl`,
  /// which the shadow kernel's rotation reads through. y: the mip bias every
  /// material map is read with — `R2`: nought, except while a temporal
  /// resolve reconstructs a picture larger than the scene is drawn at, when
  /// the maps are read as sharp as the output they end up in. z: one when
  /// the metal-rough model puts back the energy single scattering loses —
  /// `L1`, `RenderSettings.energyCompensation`. w: the frame's slice of 32
  /// while a temporal resolve runs, minus one otherwise — `S3`, which steps
  /// the soft shadow's rotation by it.
  vec4 target_origin;
}
frag_info;

/// The bias a material map is read with — see `target_origin.y`.
float MaterialLodBias() { return frag_info.target_origin.y; }

/// The maps a lit material reads, by the index [MapUv] takes — `C8`. The
/// order `LayerInfo.uv_transform` keeps them in, and `MaterialMap`'s on the
/// Dart side.
#define kMapBaseColor 0
#define kMapMetallicRoughness 1
#define kMapNormal 2
#define kMapOcclusion 3
#define kMapEmissive 4

/// Where map [slot] is read — `C8`, `KHR_texture_transform` at the sampler.
///
/// **A macro everywhere but the one stage that has the matrices.** A stage
/// that defines `F3D_TEXTURE_TRANSFORM` supplies [MapUv] and [MapMatrix] from
/// a block of its own; every other stage reads each map at the vertex's own
/// coordinate, and the macro leaves its source exactly what it was, so none of
/// them compiles to anything new.
#ifdef F3D_TEXTURE_TRANSFORM
vec2 MapUv(int slot);

/// The 2×2 part of map [slot]'s transform: x and y its first row, z and w
/// its second.
vec4 MapMatrix(int slot);
#else
#define MapUv(slot) v_texcoord
#endif

uniform sampler2D base_color_texture;

/// Everything about the surface that does not depend on which light is being
/// evaluated, resolved once per fragment.
struct Surface {
  vec3 albedo;      // linear, already tinted
  float alpha;      // opacity after texture, tint and vertex colour
  vec3 n;           // unit normal, perturbed by the normal map when there is one
  vec3 v;           // unit direction to the camera
  float n_dot_v;
  float metallic;
  float roughness;  // perceptual
  float occlusion;  // 1 means unoccluded
  vec3 emissive;    // linear, added after shading
  vec3 ambient;     // hemispheric, already scaled by the scene's strength
  float exposure;
};

/// One light's contribution geometry, recomputed per light per fragment.
struct LightSample {
  vec3 l;           // unit direction to the light
  vec3 h;           // unit half vector
  vec3 radiance;    // colour * intensity * attenuation
  float n_dot_l;
  float n_dot_h;
  float v_dot_h;

  /// One when the specular below is already integrated over the light —
  /// `L7`, a rectangle under a model that defines `F3D_LTC` — and nought
  /// otherwise. Then `ltc.x` is the GGX lobe over the rectangle, `ltc.y` the
  /// fitted norm and `ltc.z` the Fresnel term; see `LtcRectangle`.
  float integrated;
  vec3 ltc;
};

Surface ReadSurface() {
  Surface s;

  vec4 texel = texture(base_color_texture, MapUv(kMapBaseColor), MaterialLodBias());
  // Vertex colour is authored linear per the glTF spec, unlike the base colour
  // texture and the tint, which are sRGB.
  s.albedo = SrgbToLinear(texel.rgb) *
             SrgbToLinear(frag_info.base_color.rgb) *
             v_color.rgb;
  s.alpha = texel.a * frag_info.base_color.a * v_color.a;
  // `L5`: the albedo buffer carries it, for the indirect light.
  g_albedo = s.albedo;

  // Alpha masking, glTF's third alpha mode. A negative cutoff means the
  // material is opaque or blended, and discard would then be wrong rather than
  // merely unnecessary. Doing it before anything else is deliberate: a
  // discarded fragment should not pay for the lighting loop.
  //
  // **A cutoff below -1.5 is the fourth mode: hashed** — `gfx-16n`. The
  // sentinel rides in the same component because the alternative is a second
  // number in a block six shaders share, and -1 already meant "not masked";
  // anything more negative was free. See [MaterialAlphaMode.hashed].
  float cutoff = frag_info.material2.x;
  if (cutoff >= 0.0) {
    if (s.alpha < cutoff) discard;
  } else if (cutoff < -1.5) {
    // **Stochastic instead of a threshold.** A leaf texture at 40% opacity is
    // either entirely there or entirely gone under a fixed cutoff, so a fern
    // comes out as a hard-edged cardboard cut-out; sorting would fix it and
    // costs a sort per frame and a draw per layer. Comparing against noise
    // instead keeps 40% of the *pixels*, which resolves as 40% opacity to
    // anything that averages several of them — a higher-resolution target,
    // a downsample, a person standing back.
    //
    // **Hashed on world position, not on the screen.** Screen-space noise is
    // one line shorter and swims: the pattern stays put while the object
    // moves through it, so a moving branch sparkles. Anchoring it to where
    // the surface *is* means a given speck of leaf keeps its verdict from
    // frame to frame, and the camera moving changes nothing.
    //
    // The scale is a constant and it is the whole tuning: finer than the
    // texture's own detail and the noise disappears into aliasing, coarser
    // and the leaf breaks into blotches. Sixteen per metre is about a
    // centimetre of grain at a metre away.
    vec3 anchored = floor(v_world_position * 16.0);
    float noise = fract(
        sin(dot(anchored, vec3(12.9898, 78.233, 37.719))) * 43758.5453);
    if (s.alpha < noise) discard;
  }
  // **Between -1 and nought is the blend mode**, which `WriteSurface` weights
  // by its alpha: see [g_premultiply]. The engine writes -0.5 for it, -1 for
  // opaque; neither is masked, and only the blend's source is premultiplied.
  g_premultiply = cutoff < 0.0 && cutoff > -0.75;

  s.n = normalize(v_normal);
  // The back of a double-sided surface is lit from its own side: glTF asks
  // for the normal to be reversed there, and without it the underside of a
  // cloth turned to the sun reads n·l below zero and stays unlit. Only a
  // double-sided material ever draws a back face, since everything else has
  // them culled.
  if (!gl_FrontFacing) s.n = -s.n;
  s.v = normalize(frag_info.camera_position.xyz - v_world_position);
  // Clamped away from zero: a grazing view direction otherwise divides by zero
  // in the specular visibility term.
  s.n_dot_v = max(dot(s.n, s.v), 1e-4);

  s.metallic = clamp(frag_info.material.x, 0.0, 1.0);
  s.roughness = clamp(frag_info.material.y, 0.02, 1.0);
  // Hemispheric: the sky above, the ground below, blended by which way this
  // surface faces. `material.z` stays the overall strength, so the two are
  // separable — a scene dims its ambient without changing its colour, which is
  // what the one control used to do on its own.
  //
  // The blend runs on the geometric normal deliberately, before
  // `ApplyMaterialMaps` perturbs it. A normal map describes millimetres of
  // surface relief, and ambient of this kind describes which half of the world
  // a face can see; letting bump detail swing it makes a brick wall's mortar
  // lines pick up sky and reads as noise.
  s.ambient = mix(frag_info.ambient_ground.rgb, frag_info.ambient_sky.rgb,
                  s.n.y * 0.5 + 0.5) *
              frag_info.material.z;
  s.exposure = max(frag_info.frame_params.x, 0.0);

  // Neutral until ApplyMaterialMaps says otherwise, so a model that samples no
  // maps still has a complete surface.
  s.occlusion = 1.0;
  s.emissive = vec3(0.0);

  return s;
}

int LightCount() {
#ifdef F3D_NO_LIGHT_LIST
  return clamp(int(frag_info.frame_params.y + 0.5), 0, kMaxLights);
#else
  // `L6`: the tail is the cell's, when the draw reads one.
  float tail = light_list_info.list.x;
  if (Clustered()) {
    FindCluster(v_world_position);
    tail = g_cluster_count;
  }
  return clamp(int(frag_info.frame_params.y + 0.5), 0, kMaxLights) +
      clamp(int(tail + 0.5), 0, kExtraLights);
#endif
}

/// Whether light [index] carries a shadow — `gfx-74n`.
///
/// Only the first eight do. The cube atlas holds six rows and the slot table is
/// eight entries wide, so a light from the list has no row to read and asking
/// for one would index past the table. That is a real limit and the right one:
/// the eight a draw keeps in its slots are the eight ranked most relevant to
/// it, which is exactly the set worth a shadow map.
bool LightHasShadow(int index) { return index < kMaxLights; }

/// Distance attenuation for a punctual light, following the glTF spec.
///
/// Inverse square with an optional range window. The window is what stops a
/// lamp with a declared range from contributing a faint haze across the whole
/// scene, which matters far more once there are eight of them.
float PunctualAttenuation(float distance, float range) {
  float attenuation = 1.0 / max(distance * distance, 1e-4);
  if (range > 0.0) {
    float ratio = distance / range;
    float window = clamp(1.0 - ratio * ratio * ratio * ratio, 0.0, 1.0);
    attenuation *= window * window;
  }
  return attenuation;
}

/// One edge of Lambert's sum, from [a] to [b], neither of which need be a
/// unit vector: the angle between them times how much their plane leans into
/// [n].
float LambertEdge(vec3 a, vec3 b, vec3 n) {
  // Normalised with a floor rather than `normalize`: a corner exactly at the
  // shading point, or a horizon crossing that lands there, is a zero vector,
  // and `normalize` of that is a NaN that spreads to the whole pixel and then
  // to the bloom. A zero vector here subtends nothing, which is the answer.
  vec3 ua = a / max(length(a), 1e-12);
  vec3 ub = b / max(length(b), 1e-12);
  // Clamped before the `acos`: two nearly parallel edge directions can give a
  // dot a hair past one through rounding alone, and `acos` of that is the same
  // NaN.
  float angle = acos(clamp(dot(ua, ub), -1.0, 1.0));
  vec3 axis = cross(ua, ub);
  float len = length(axis);
  // A degenerate edge — the shading point lies on the line through it —
  // subtends nothing.
  return len > 1e-6 ? angle * dot(axis, n) / len : 0.0;
}

/// How much of [s]'s sky a rectangle covers, weighted by the cosine —
/// `gfx-77n`.
///
/// **Exact, not fitted.** This is Lambert's own form factor for a polygon, from
/// 1760: for each edge, the angle it subtends at the shading point times how
/// much the edge's plane leans into the surface normal. Summed over the edges
/// and halved, it is the integral of `cos θ` over the polygon's projection on
/// the sphere — the quantity a punctual light approximates with a single
/// `n · l`. So there is no table to ship and nothing to fit: the usual
/// linearly-transformed-cosine approach exists to make the *specular* lobe
/// tractable, and buys nothing here.
///
/// **Clipped to the horizon first.** Lambert's sum is signed: a part of the
/// panel below the surface's horizon counts with a negative cosine and cancels
/// light from the part above it, so a panel standing on the horizon read
/// nought where half of it lights the surface. Irradiance wants the clamped
/// cosine, and for a polygon that means cutting away what lies below before
/// summing. A convex quadrilateral cut by a plane leaves one polygon with at
/// most one edge leaving the hemisphere and one entering it, so the cut is the
/// four edges trimmed where they cross plus one edge along the horizon from
/// the exit back to the entry, with no list of vertices to build.
///
/// Returns irradiance over radiance, so a surface facing a rectangle that fills
/// its whole sky gets π, the same as a uniform hemisphere. [corners] are the
/// four vertices in order, relative to the shading point.
///
/// **The rectangle emits along `cross(halfWidth, halfHeight)`**, and with the
/// corners wound as `SampleLight` winds them the sum comes out *negative* on
/// that side, so the negation below is the convention rather than a fix. It was
/// measured rather than derived: the first version returned `+total * 0.5`, and
/// against the reference integration it read nought where the answer was 0.349
/// and 1.02 where the answer was nought — the two failures a flipped winding
/// produces, and between them they name the sign with no room left to argue.
float RectangleFormFactor(vec3 corners[4], vec3 n) {
  float total = 0.0;
  vec3 exit = vec3(0.0);
  vec3 entry = vec3(0.0);
  for (int i = 0; i < 4; i++) {
    vec3 a = corners[i];
    vec3 b = corners[i == 3 ? 0 : i + 1];
    float ha = dot(a, n);
    float hb = dot(b, n);
    // Where the edge meets the horizon; used only when it crosses it, and then
    // the two heights differ in sign, so the division is safe.
    float d = ha - hb;
    vec3 q = a + (b - a) * (abs(d) > 1e-12 ? ha / d : 0.0);
    bool aAbove = ha > 0.0;
    bool bAbove = hb > 0.0;
    total += aAbove || bAbove
                 ? LambertEdge(aAbove ? a : q, bAbove ? b : q, n)
                 : 0.0;
    exit = aAbove && !bAbove ? q : exit;
    entry = !aAbove && bAbove ? q : entry;
  }
  // The horizon edge closing the cut, from where the outline left the
  // hemisphere to where it came back. Nothing when it never crossed: both are
  // still zero and a zero vector subtends nothing.
  total += LambertEdge(exit, entry, n);
  // Clamped: a surface on the panel's dark side sees the outline wound the
  // other way, and the clipped sum comes out negative. `SampleLight` tests the
  // side as well, before any of this is paid for.
  return max(-total * 0.5, 0.0);
}

/// Where on the rectangle the specular lobe is really looking — `gfx-77n`.
///
/// **The representative point, which is an approximation, unlike the diffuse
/// above.** The mirror direction leaves the surface and either hits the panel
/// or misses it; the closest point of the panel to that ray is treated as a
/// punctual light standing in for the whole rectangle. It is the standard
/// cheap answer and its one visible property is the one the row asked for: as
/// the view moves the closest point slides along the panel, so the highlight
/// is a streak with the panel's own shape and orientation rather than a dot.
///
/// What it does not do is widen the lobe by the panel's solid angle, so a
/// rough surface under a large panel is a little darker than a full integration
/// would make it. That is a known error of this method and not a bug in this
/// transcription; the fix is the fitted table this function exists to avoid.
vec3 RectangleClosestPoint(vec3 centre, vec3 halfWidth, vec3 halfHeight,
                           vec3 world, vec3 mirror) {
  vec3 n = cross(halfWidth, halfHeight);
  float nLen = length(n);
  // A panel with no area has no surface to find a point on; its centre is the
  // only answer that is not a division by zero.
  if (nLen < 1e-12) return centre;
  n /= nLen;

  vec3 toPlane = centre - world;
  float denom = dot(mirror, n);
  vec3 onPlane;
  // Parallel to the panel, or pointing away from it: the ray never lands, so
  // the nearest thing to it is the centre projected back, which keeps the
  // highlight on the panel instead of sending it to infinity.
  if (abs(denom) < 1e-5) {
    onPlane = toPlane - n * dot(toPlane, n);
  } else {
    float t = dot(toPlane, n) / denom;
    onPlane = t > 0.0 ? mirror * t : toPlane - n * dot(toPlane, n);
  }

  // Clamped into the rectangle in its own axes. Dividing by the squared length
  // turns a projection into a coordinate in units of the half-extent, so the
  // clamp is against one either way round.
  vec3 offset = onPlane - toPlane;
  float wLen2 = max(dot(halfWidth, halfWidth), 1e-12);
  float hLen2 = max(dot(halfHeight, halfHeight), 1e-12);
  float u = clamp(dot(offset, halfWidth) / wLen2, -1.0, 1.0);
  float v = clamp(dot(offset, halfHeight) / hLen2, -1.0, 1.0);
  return centre + halfWidth * u + halfHeight * v;
}

#ifdef F3D_LTC
// --- lib/ltc.glsl ---
// The GGX lobe over a rectangle light, by linearly transformed cosines — `L7`.
//
// Heitz, Dupuy, Hill and Neubelt, "Real-Time Polygonal-Light Shading with
// Linearly Transformed Cosines", ACM TOG 35(4), 2016. The fitted tables are
// `EngineTables.ltc`; see `tables/ltc.dart` for their layout and licence.
//
// A model that wants it defines `F3D_LTC` before including `surface.glsl`,
// which is what gives its stage the one sampler below. Every other model
// keeps the representative point, and no sampler.

#ifndef LTC_GLSL_
#define LTC_GLSL_

/// Both tables, 64 × 128: the inverse matrices above, the norms, Fresnel
/// terms and sphere form factors below.
uniform sampler2D ltc_texture;

/// Where `(x, y)`, each nought to one, lands in the table starting at
/// [table] (nought the upper, one the lower): on texel centres, so the ends of
/// the range read the first and last entries rather than half of the
/// neighbour.
vec2 LtcUv(float x, float y, float table) {
  vec2 inTable = vec2(x, y) * (63.0 / 64.0) + 0.5 / 64.0;
  return vec2(inTable.x, (inTable.y + table) * 0.5);
}

/// One edge's share of the vector form factor, from [a] to [b], unit
/// directions: the angle between them along the normal of their plane,
/// over 2π. Exact, with the `acos` clamped for the reason
/// `RectangleFormFactor` gives.
vec3 LtcEdge(vec3 a, vec3 b) {
  vec3 axis = cross(a, b);
  float len = length(axis);
  float angle = acos(clamp(dot(a, b), -1.0, 1.0));
  return len > 1e-6 ? axis * (angle / (len * 6.2831853)) : vec3(0.0);
}

/// The GGX lobe of roughness [roughness] seen along [v] from normal [n],
/// integrated over the rectangle with corners [corners] (relative to the
/// shading point, wound as `SampleLight` winds them), with the fitted
/// Fresnel pair for that lobe: x the integral, y the norm, z the Fresnel
/// term. The specular is `x · (f0 · y + (1 − f0) · z)`.
///
/// Clipped to the horizon by the sphere table rather than by cutting the
/// polygon: the vector form factor's length and elevation name a sphere
/// with the same, and the table holds how much of that sphere's clamped
/// cosine lies above the horizon.
///
/// Says nothing about which face of the panel the point is on: the vector
/// form factor points the same way in the world from either side, so this is
/// as bright behind the panel as in front of it. `SampleLight` tests the side
/// and leaves a point behind unlit before this is asked.
vec3 LtcRectangle(vec3 n, vec3 v, float roughness, vec3 corners[4]) {
  vec2 uv = vec2(clamp(roughness, 0.0, 1.0),
                 sqrt(clamp(1.0 - dot(n, v), 0.0, 1.0)));
  vec4 inverse = textureLod(ltc_texture, LtcUv(uv.x, uv.y, 0.0), 0.0);
  vec4 fit = textureLod(ltc_texture, LtcUv(uv.x, uv.y, 1.0), 0.0);

  // The frame the fit was made in: the normal up, the view in the xz plane.
  // A view along the normal has no plane of its own, and any will do.
  vec3 along = v - n * dot(v, n);
  float alongLength = length(along);
  vec3 t1 = alongLength > 1e-5
                ? along / alongLength
                : normalize(cross(n, abs(n.z) < 0.999 ? vec3(0.0, 0.0, 1.0)
                                                      : vec3(1.0, 0.0, 0.0)));
  vec3 t2 = cross(n, t1);
  mat3 minv = mat3(vec3(inverse.x, 0.0, inverse.y), vec3(0.0, 1.0, 0.0),
                   vec3(inverse.z, 0.0, inverse.w));

  vec3 l[4];
  for (int i = 0; i < 4; i++) {
    vec3 p = corners[i];
    l[i] = normalize(minv * vec3(dot(p, t1), dot(p, t2), dot(p, n)));
  }
  // Negated, for `RectangleFormFactor`'s reason: the panel emits along
  // `cross(halfWidth, halfHeight)`, and seen from there these corners run
  // clockwise.
  vec3 f = -(LtcEdge(l[0], l[1]) + LtcEdge(l[1], l[2]) +
             LtcEdge(l[2], l[3]) + LtcEdge(l[3], l[0]));
  float len = length(f);
  float z = len > 1e-9 ? f.z / len : 0.0;
  float sphere =
      textureLod(ltc_texture, LtcUv(z * 0.5 + 0.5, clamp(len, 0.0, 1.0), 1.0),
                 0.0)
          .w;
  return vec3(max(len * sphere, 0.0), fit.x, fit.y);
}

#endif  // LTC_GLSL_


#ifdef F3D_LAYERED
/// The corners of the rectangle [SampleLight] resolved last, relative to the
/// shading point — `M1`. The clear coat integrates its own lobe over the same
/// panel with its own normal and roughness, and those live in `pbr.glsl`,
/// after this file; the loop shades each light straight after sampling it,
/// so this is always the light being shaded.
vec3 g_rect_corners[4];
#endif  // F3D_LAYERED
#endif  // F3D_LTC

/// Resolves light [index] against the surface.
///
/// Returns `n_dot_l == 0` for anything that contributes nothing — behind the
/// surface, out of range, outside the spot cone, the dark face of a panel — so
/// a model can skip it with one test instead of repeating the classification.
LightSample SampleLight(int index, Surface s) {
  LightSample light;
  light.integrated = 0.0;
  light.ltc = vec3(0.0);

  vec4 position;
  vec4 color;
  vec4 direction;
  vec4 cone;
  if (index < kMaxLights) {
    position = frag_info.light_position[index];
    color = frag_info.light_color[index];
    direction = frag_info.light_direction[index];
    cone = frag_info.light_cone[index];
  } else {
#ifdef F3D_NO_LIGHT_LIST
    // Unreachable: `LightCount` stops at the slots without a list.
    position = vec4(0.0);
    color = vec4(0.0);
    direction = vec4(0.0);
    cone = vec4(0.0);
#else
    // A row of the light list — `gfx-74n`. Sampled at texel centres so a
    // driver's rounding cannot land a fetch on a neighbour, and the four texels
    // across the row are the same four vectors the arrays above hold.
    int slot = index - kMaxLights;
    // `L6`: from the cell rather than the draw's own tail, and a light the
    // slots already hold is skipped by its intensity, as a faded one is.
    bool clustered = Clustered();
    float listRow = clustered ? ClusterRow(slot) : LightListRow(slot);
    float v = (listRow + 0.5) * light_list_info.list.z;
    float u = light_list_info.list.y;
    // `textureLod` and not `texture`, for `shadow.glsl`'s own reason: `index`
    // reaches this branch through a function parameter, so a WGSL backend
    // cannot see that every invocation of a draw walks the same light count
    // and refuses the implicit derivative as possibly non-uniform. The atlas
    // has one level, so naming it directly changes no pixel.
    position = textureLod(light_list_texture, vec2(0.5 * u, v), 0.0);
    color = textureLod(light_list_texture, vec2(1.5 * u, v), 0.0);
    direction = textureLod(light_list_texture, vec2(2.5 * u, v), 0.0);
    cone = textureLod(light_list_texture, vec2(3.5 * u, v), 0.0);
    // The intensity and not the colour, for `LightBuffer._pack`'s own reason:
    // the same multiply here, and only one of them is a number nobody authored.
    color.w *= clustered ? (InSlots(listRow) ? 0.0 : 1.0) : LightListScale(slot);
#endif  // F3D_NO_LIGHT_LIST
  }

  float type = position.w;

  // **The rectangle leaves before `aim` is taken — `gfx-77n`.** For every other
  // kind `direction.xyz` is a unit vector saying which way the light points;
  // for this one it is an edge of the panel, with its length carrying half the
  // width, and normalising it here would quietly throw the size away.
  if (type > 2.5) {
    vec3 halfWidth = direction.xyz;
    vec3 halfHeight = cone.xyz;
    vec3 toCentre = position.xyz - v_world_position;

    vec3 corners[4];
    corners[0] = toCentre - halfWidth - halfHeight;
    corners[1] = toCentre + halfWidth - halfHeight;
    corners[2] = toCentre + halfWidth + halfHeight;
    corners[3] = toCentre - halfWidth + halfHeight;

    // **The panel emits from one face only**, and a point on the other side
    // gets nothing: the room above a ceiling panel, the outside of the wall a
    // window is set in. Tested here rather than left to the signs below,
    // because the specular's vector form factor keeps the same orientation
    // from either side of the panel, so a surface behind it facing away read
    // as lit as one in front facing it.
    bool behind = dot(toCentre, cross(halfWidth, halfHeight)) >= 0.0;

    // The cosine-weighted solid angle, which takes the place `n · l` holds for
    // a punctual light: the loop multiplies the shading by `n_dot_l`, so
    // putting the exact integral here makes the diffuse term exact rather than
    // sampled. See [RectangleFormFactor].
    float formFactor = behind ? 0.0 : RectangleFormFactor(corners, s.n);

    // Radiance rather than intensity: `intensity` means the same thing for
    // every kind of light, so a panel's is spread over its own area here.
    // Enlarging a window at a fixed rating then dims it per square metre and
    // leaves the room as bright, which is what the number is supposed to mean.
    float area = length(cross(halfWidth, halfHeight)) * 4.0;
    float radiance = area > 1e-9 ? 1.0 / area : 0.0;

    // The range window only. A punctual light needs the inverse square as
    // well; the form factor already contains it, because a panel twice as far
    // away subtends a quarter of the sky.
    float distance = length(toCentre);
    if (direction.w > 0.0) {
      float ratio = distance / direction.w;
      float window = clamp(1.0 - ratio * ratio * ratio * ratio, 0.0, 1.0);
      radiance *= window * window;
    }

    vec3 mirror = reflect(-s.v, s.n);
    vec3 representative = RectangleClosestPoint(
        position.xyz, halfWidth, halfHeight, v_world_position, mirror);
    vec3 toPoint = representative - v_world_position;
    float pointDistance = length(toPoint);
    light.l = pointDistance > 1e-6 ? toPoint / pointDistance : s.n;

    light.h = normalize(light.l + s.v);
    light.n_dot_l = formFactor;
    light.n_dot_h = max(dot(s.n, light.h), 0.0);
    light.v_dot_h = max(dot(s.v, light.h), 0.0);
    light.radiance = color.rgb * color.w * radiance;
#ifdef F3D_LTC
    // `L7`: the specular over the whole panel rather than at one point of
    // it. The diffuse keeps the exact form factor above.
    light.integrated = 1.0;
    light.ltc = LtcRectangle(s.n, s.v, s.roughness, corners);
#ifdef F3D_LAYERED
    // Kept for the clear coat's own integral; see [g_rect_corners].
    g_rect_corners = corners;
#endif
#endif
    return light;
  }

  vec3 aim = normalize(direction.xyz);
  float attenuation = 1.0;

  if (type < 0.5) {
    // Directional: no position, no falloff. The direction to the light is the
    // reverse of the direction it points.
    light.l = -aim;
  } else {
    vec3 toLight = position.xyz - v_world_position;
    float distance = length(toLight);
    // A light exactly on the surface has no direction; treat it as contributing
    // nothing rather than producing a NaN that spreads through the frame.
    if (distance < 1e-6) {
      light.l = s.n;
      light.h = s.n;
      light.radiance = vec3(0.0);
      light.n_dot_l = 0.0;
      light.n_dot_h = 0.0;
      light.v_dot_h = 0.0;
      return light;
    }
    light.l = toLight / distance;
    attenuation = PunctualAttenuation(distance, direction.w);

    if (type > 1.5) {
      // Spot: a smooth ramp between the two cone cosines. The Dart side already
      // guarantees the denominator is non-zero.
      float cosAngle = dot(aim, -light.l);
      attenuation *= clamp(
          (cosAngle - cone.y) / (cone.x - cone.y), 0.0, 1.0);
    }
  }

  light.h = normalize(light.l + s.v);
  light.n_dot_l = max(dot(s.n, light.l), 0.0);
  light.n_dot_h = max(dot(s.n, light.h), 0.0);
  light.v_dot_h = max(dot(s.v, light.h), 0.0);
  light.radiance = color.rgb * color.w * attenuation;

  return light;
}

/// How much of light [index] reaches this fragment, defined by each fragment
/// shader.
///
/// A prototype rather than a call into shadow.glsl, because the models that
/// sample no shadow map must not declare its sampler — the compiler would drop
/// the slot and leave the engine binding one that is not there. A lit model
/// returns `ShadowFactor(...)`; an unlit one returns 1.
float LightVisibility(Surface s, LightSample light, int index);

/// A model's per-light term, defined by each fragment shader.
///
/// A prototype here and the definition in the model is what lets the loop below
/// be written once. The alternative — repeating the loop in every model — is
/// six copies of the same three lines, and the place a light would go missing.
vec3 ShadeLight(Surface s, LightSample light);

/// Sums every active light's contribution.
///
/// The loop bound is the compile-time maximum with a runtime break, because GLSL
/// wants a constant trip count and the hardware wants the early exit.
// **The point-shadow half of this header, behind a guard.**
//
// A model that never shadows must not *declare* any of this, and the reason is
// the one `unlit.frag` already gives about the shadow sampler — with one
// backend's failure added to the other's. On Impeller the compiler drops what
// nothing reads, and the engine binding a slot that is no longer there is a
// native crash. On WebGL2 nothing is dropped: an active uniform block with no
// buffer under it makes every draw `INVALID_OPERATION`, discarded with nothing
// logged.
//
// That is what `lighting-unlit` was on this backend. Unlit's own metadata says
// `usesPointShadow` is false, so the engine correctly bound no `PointShadow`
// block — and the translated shader declared one anyway, so the sphere was
// never drawn and the frame came back the clear colour.
#ifndef F3D_NO_POINT_SHADOW

/// The cube atlas: three tiles across, two down, each a ninety-degree view
/// from a point light, each storing radial distance normalised by range.
uniform sampler2D point_shadow_texture;

/// The same atlas for the things that never move, rendered once at load.
///
/// Two maps rather than one because a dungeon's walls can be baked and a
/// spinning pickup cannot, and there is no way to draw into part of a texture
/// without redrawing the rest of it. Sampling both and keeping the nearer
/// occluder costs one extra read and saves six views of the level every frame.
uniform sampler2D point_shadow_static_texture;

/// How many lights may have a row of the atlas. Six tiles across each.
// Rows of the cube atlas: six faces across, this many lights down. Must
// match `Renderer.kShadowedLights`, which is where the reasoning lives, and
// `shadowSlots` in the software backend's transcription of this file.
const int kShadowSlots = 6;

layout(std140) uniform PointShadow {
  /// The same view-projections the atlas was rendered with, six per slot.
  ///
  /// Passed rather than reconstructed. Deriving cube face coordinates here
  /// would be a second implementation of a decision the renderer already made,
  /// and the two would disagree about handedness or up vectors on some face
  /// and nowhere else — which shows as one face of every shadow being wrong.
  mat4 faces[6 * kShadowSlots];

  /// Per slot. xyz: the light's world position. w: its range.
  vec4 lights[kShadowSlots];

  /// Per light, in the order the lighting knows them.
  ///
  /// x: the atlas row it owns, or negative when it has none — a fifth torch in
  /// a room lands there. z: the tangent of half the frustum's opening angle,
  /// which is what converts a world width into a fraction of a tile. y and w
  /// are unwritten.
  ///
  /// **z is exactly one for a point light**, because a cube face is a ninety
  /// degree frustum and `tan(45°) == 1`. That is not a convention chosen to be
  /// tidy: it is what lets a narrower frustum share this whole path, since
  /// multiplying by one in IEEE 754 changes no bit of the result. Whatever else
  /// a spot light will need, it does not need a second copy of the filter.
  vec4 slots[kMaxLights];

  /// x: half a texel, in tile-local uv. y: distance bias in metres.
  /// z: strength. w: normal offset, **in texels of the face it lands on**.
  vec4 params;

  /// x: smallest kernel radius in tile-local uv, and the fixed radius used
  /// when contact hardening is off. y: the light's own radius in metres; zero
  /// turns contact hardening off. z: largest kernel radius in tile-local uv.
  /// w: non-zero paints the penumbra estimate into the surface buffer instead
  /// of shading with it.
  vec4 params2;

  /// x: non-zero when this backend stores the atlas bottom-up. y: one over the
  /// edge length of a tile in texels, which is what turns a distance into the
  /// world width of one texel there.
  ///
  /// **Appended after everything else on purpose**, the same way FragInfo's
  /// ambient pair was: std140 lays a block out in declaration order, so adding
  /// here leaves every offset above unchanged and the three backends do not
  /// have to agree about anything they already agreed about. y, z and w are
  /// unwritten.
  vec4 params3;
}
point_shadow;

/// Eight points on a Poisson disk, a common set for filtering cascaded
/// shadows.
///
/// A disk rather than a grid because a grid of taps on a straight shadow edge
/// lands every sample on the same side at once, and the edge steps between
/// kernel widths instead of sliding. Eight rather than sixteen because every
/// tap here reads **two** atlases — the static walls and the movers — so the
/// cost is doubled before it is counted.
vec2 PointShadowDiskTap(int i) {
  if (i == 0) return vec2(-0.94201624, -0.39906216);
  if (i == 1) return vec2(0.94558609, -0.76890725);
  if (i == 2) return vec2(-0.09418410, -0.92938870);
  if (i == 3) return vec2(0.34495938, 0.29387760);
  if (i == 4) return vec2(-0.91588581, 0.45771432);
  if (i == 5) return vec2(-0.81544232, -0.87912464);
  if (i == 6) return vec2(-0.38277543, 0.27676845);
  return vec2(0.97484398, 0.75648379);
}

/// One comparison against the atlas, at [uv] offset within the tile.
///
/// The clamp is applied **after** the offset, not before, and that is the whole
/// reason a kernel can be widened here without touching anything else: each tap
/// is held inside its own tile individually. Clamping the centre and then
/// offsetting would let the outer taps walk straight out of the tile and read a
/// distance measured from a different face, or a different light.
float PointShadowDistance(vec2 uv, vec2 offset, vec2 tile, float range) {
  float inset = point_shadow.params.x;
  vec2 local = clamp(uv + offset, inset, 1.0 - inset);
  vec2 atlas = (local + tile) * vec2(1.0 / 6.0, 1.0 / float(kShadowSlots));
  // **The whole atlas, turned over, where row zero of a render target is at the
  // bottom.** Both halves of the address are wrong there and this is the one
  // place that fixes both: the tile the light owns — a light in slot zero is
  // drawn into the row the shader would call three, because the viewport
  // rectangle is flipped to land it — and the picture inside that tile, which
  // was drawn through a projection built for the other origin.
  //
  // Every check of this atlas missed it for the same reason: the debug view
  // composites the texture through a full-screen pass, which turns it over
  // again and puts the row back. The atlas compared equal on both backends
  // across six scenes while the lit pass, which samples it directly and has no
  // such pass to cancel, read a row that had never been drawn into and found
  // nothing in the way of anything.
  if (point_shadow.params3.x > 0.5) atlas.y = 1.0 - atlas.y;
  // Whichever is nearer occludes: a wall in front of a monster shadows, and so
  // does a monster in front of a wall.
  //
  // **`textureLod` at level zero, because every caller of this function stands
  // behind a branch.** The light loop skips a light the surface faces away
  // from, the blocker search `continue`s past a tap that found nothing, and the
  // slot test returns before any of it — so the invocations of a quad do not
  // arrive here together, and a WGSL backend refuses a sample whose implicit
  // derivative would be read where they disagree. Both atlases are distance
  // render targets with one level, so level zero is the level `texture` was
  // choosing anyway; this names it rather than deriving it, and the picture is
  // the same on every backend.
  return min(textureLod(point_shadow_texture, atlas, 0.0).r,
             textureLod(point_shadow_static_texture, atlas, 0.0).r) * range;
}

float PointShadowTap(vec2 uv, vec2 offset, vec2 tile, float range,
                     float receiver) {
  float stored = PointShadowDistance(uv, offset, tile, range);
  // Nothing was drawn in that direction by either, so nothing is in the way.
  if (stored >= range * 0.999) return 1.0;
  return receiver > stored ? 0.0 : 1.0;
}

/// The disk point for tap [i], rotated by [ca]/[sa] and scaled to [radius].
vec2 PointShadowOffset(int i, float ca, float sa, float radius) {
  vec2 p = PointShadowDiskTap(i);
  return vec2(p.x * ca - p.y * sa, p.x * sa + p.y * ca) * radius;
}

/// How wide the penumbra should be here, in tile-local uv.
///
/// Contact hardening, and the reason a fixed kernel looks wrong: a shadow is
/// sharp where its caster touches the floor and soft a metre away, and one
/// radius for both makes the contact mushy or the distant edge hard.
///
/// The similar-triangles estimate is the standard one — a light of radius `L`
/// with a blocker at `b` and a receiver at `r` throws a penumbra `L * (r - b) /
/// b` wide at the receiver. Converting that to tile uv is exact rather than
/// tuned, because a face is a ninety degree frustum: at distance `r` from the
/// light the face spans `2 * r` in world units across the full `0..1` of uv,
/// so a world width `w` is `w / (2 * r)` of a tile.
///
/// The blocker search runs at the **widest** penumbra allowed, since a blocker
/// outside that circle cannot widen the result anyway, and searching narrower
/// would miss the very blockers that make an edge soft.
///
/// [tanHalf] is where the ninety degrees stop being assumed. The span above is
/// `2 * r` only for a right-angled frustum; in general it is `2 * r * tan(θ/2)`,
/// and for a cube face that factor is one. A narrower frustum covers less world
/// per tile, so the same world width is a *larger* fraction of it — which is
/// why this divides rather than multiplies, and why getting it upside down
/// would make a tight cone's shadows harden instead of soften.
float PointShadowPenumbra(vec2 uv, vec2 tile, float range, float receiver,
                          float ca, float sa, float tanHalf,
                          out float blockerOut) {
  blockerOut = -1.0;
  float lightRadius = point_shadow.params2.y;
  float minRadius = point_shadow.params2.x;
  float maxRadius = point_shadow.params2.z;
  if (lightRadius <= 0.0) {
    // **The debug channel is filled even though the search is skipped**, and
    // leaving it unfilled cost a session. `blockerOut` starts at −1 to mean
    // "nothing was measured"; the debug encoding clamps it into a colour, where
    // −1 becomes zero — the same green as a blocker touching the surface, which
    // reads as the most alarming answer available. A whole theory was built on
    // that zero, and the search it described had never run.
    //
    // The centre tap is what the filter below would use anyway, so this reports
    // a distance the atlas really returned rather than a sentinel.
    blockerOut = PointShadowDistance(uv, vec2(0.0), tile, range);
    return minRadius;
  }


  float sum = 0.0;
  float count = 0.0;
  for (int i = 0; i < 8; i++) {
    float stored =
        PointShadowDistance(uv, PointShadowOffset(i, ca, sa, maxRadius), tile,
                            range);
    if (stored >= range * 0.999) continue;
    if (stored >= receiver) continue;
    sum += stored;
    count += 1.0;
  }
  // Nothing in front of this fragment anywhere in the search: fully lit, and
  // the caller can skip the filter entirely.
  if (count < 0.5) return -1.0;

  float blocker = max(sum / count, 1e-4);
  blockerOut = blocker;
  float world = lightRadius * max(receiver - blocker, 0.0) / blocker;
  return clamp(world / (2.0 * receiver * tanHalf), minRadius, maxRadius);
}

/// How lit [world] is by the point light that owns the cube atlas.
///
/// One, fully lit, when this is not that light or the atlas has nothing to say.
float PointShadowFactor(vec3 world, vec3 normal, int lightIndex) {
  int slot = int(point_shadow.slots[lightIndex].x + 0.5);
  if (point_shadow.slots[lightIndex].x < 0.0) return 1.0;
  float strength = point_shadow.params.z;
  if (strength <= 0.0) return 1.0;

  // Offset along the normal before measuring, and scaled by how steeply the
  // surface leans away from the light.
  //
  // A soft kernel on a tilted surface straddles a depth gradient: the taps at
  // one end of the disk are further from the light than the fragment itself,
  // so a flat offset that clears the surface head-on leaves acne at a grazing
  // angle. The slope term lifts the whole kernel clear instead, and is capped
  // because it runs away as the surface turns edge-on to the light — an
  // uncapped lift detaches the shadow from its caster.
  vec3 toLight = point_shadow.lights[slot].xyz - world;
  float toLightLength = max(length(toLight), 1e-6);
  float nDotL = max(dot(normal, toLight / toLightLength), 0.15);
  float slope = min(sqrt(max(1.0 - nDotL * nDotL, 0.0)) / (nDotL * nDotL), 8.0);

  // **How wide one texel of the face is, out where this fragment is.** The
  // error a normal offset exists to clear is exactly that: a texel of the
  // shadow map covers a patch of surface, the whole patch is recorded at one
  // distance, and a fragment anywhere else in it compares against a distance
  // measured somewhere it is not. That patch grows with range — it is a solid
  // angle, not a length — so an offset fixed in metres is right at one distance
  // and wrong everywhere else.
  //
  // What it was: `params.w` metres, flat. On the golden teapot, at 9.6 m from
  // the lamp, a texel is 3.7 cm and the flat offset was 2 cm, so the floor
  // shadowed itself across everything the light reached — and the acne stopped
  // dead at the *projection of the floor's own edge*, because past it the atlas
  // holds nothing and nothing can occlude. A straight line across a shadow with
  // no straight edge anywhere in the scene.
  float texel =
      2.0 * toLightLength * max(point_shadow.slots[lightIndex].z, 1e-4) *
      point_shadow.params3.y;
  // Both terms are metres. The slope term used to be the kernel radius, which
  // is a fraction of a tile — a unit mismatch carried over from an estimate
  // where a softness radius genuinely was the right quantity. Here it meant
  // widening the kernel also lifted the sample off the surface, by up to ten
  // centimetres at the wider settings, so the softening and the lift
  // cancelled: tripling the kernel moved 184 pixels of the frame,
  // where the kernel alone moves thousands. It is what made contact hardening
  // look inert, and it was hiding in a comparison rather than in the estimate.
  vec3 origin = world + normal * texel * point_shadow.params.w * (1.0 + slope);
  vec3 toFragment = origin - point_shadow.lights[slot].xyz;
  float distance = length(toFragment);
  float range = max(point_shadow.lights[slot].w, 1e-4);
  if (distance >= range) return 1.0;

  // The dominant axis picks the face, in the order the renderer wrote them:
  // +X, -X, +Y, -Y, +Z, -Z, left to right then top to bottom.
  //
  // A spot has one column and no choice to make. Asking the dominant axis
  // anyway would be worse than pointless: a fragment below and to the side of
  // a downlight has −Y dominant, which is column 3, and column 3 of a spot's
  // row is deliberately blank — so the whole cone would read as unshadowed
  // except for the wedge where the aim happens to be the dominant axis.
  int face = 0;
  if (point_shadow.slots[lightIndex].y < 0.5) {
    vec3 a = abs(toFragment);
    if (a.x >= a.y && a.x >= a.z) {
      face = toFragment.x > 0.0 ? 0 : 1;
    } else if (a.y >= a.z) {
      face = toFragment.y > 0.0 ? 2 : 3;
    } else {
      face = toFragment.z > 0.0 ? 4 : 5;
    }
  }

  vec4 clip = point_shadow.faces[slot * 6 + face] * vec4(origin, 1.0);
  if (clip.w <= 0.0) return 1.0;
  vec2 ndc = clip.xy / clip.w;
  if (abs(ndc.x) > 1.0 || abs(ndc.y) > 1.0) return 1.0;

  // v is flipped, the same way the directional map does it: the texture's
  // origin is at the top, where row zero of the render target is. Getting this
  // wrong does not tilt the shadow — it makes the top row of faces read the
  // bottom row, so a whole region compares against an unrelated distance and
  // comes out as a black slab.
  vec2 uv = vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
  // The face across, the light down: six tiles wide, four tall.
  vec2 tile = vec2(float(face), float(slot));

  float receiver = distance - point_shadow.params.y;

  // One rotation, shared by the blocker search and the filter. Per fragment,
  // so eight samples read as a soft edge rather than as eight copies of the
  // silhouette: without it every fragment along an edge tests the same eight
  // directions and the pattern shows.
  //
  // **The three constants are not arbitrary and are not ours.** This is Jorge
  // Jimenez's interleaved gradient noise, from "Next Generation Post
  // Processing in Call of Duty: Advanced Warfare" (SIGGRAPH 2014):
  //
  //   IGN(x, y) = frac(52.9829189 * frac(0.06711056 * x + 0.00583715 * y))
  //
  // The pair inside the dot is a direction whose gradient walks the unit
  // interval as slowly as it can while never repeating over a screen, and the
  // multiplier outside stretches that walk so neighbouring pixels land far
  // apart in the result. What it buys over a hash is the cost: one dot and two
  // fracts, no integer arithmetic, no texture. What a blue-noise texture buys
  // over it is a better spectrum, at a sampler and a fetch — worth it for
  // dithering a whole frame, not for rotating eight taps.
  //
  // Written down because three unexplained decimals read as a magic spell, and
  // the next person to touch this line has no way to tell which of them may be
  // changed. The answer is none of them.
  float noise = fract(52.9829189 * fract(dot(FragCoordFromTop(
                                                frag_info.target_origin.x),
                                            vec2(0.06711056, 0.00583715))));
  float angle = noise * 6.28318530718;
  float ca = cos(angle);
  float sa = sin(angle);

  // Guarded rather than read straight, because a zero here divides by zero and
  // a NaN radius poisons the filter into a black fragment. Zero is what an
  // unwritten channel holds, and "unwritten" is a state this block has been in
  // before: every slot is cleared to −1 each frame.
  float tanHalf = max(point_shadow.slots[lightIndex].z, 1e-4);

  float blocker = -1.0;
  float radius =
      PointShadowPenumbra(uv, tile, range, receiver, ca, sa, tanHalf, blocker);

  // The debug channel, and the reason it exists: two explanations for why the
  // estimate collapses were argued from the finished picture and both were
  // wrong, because the number that decides it never leaves this function.
  //
  // Red is how wide the penumbra came out, against the widest allowed. Green
  // is how far away the blocker was, against the light's range. Blue marks
  // the fragments where the search found nothing at all — which is a different
  // answer from "found something very close", and telling those two apart is
  // most of the question.
  if (point_shadow.params2.w > 0.5) {
    g_debug_surface_on = true;
    g_debug_surface = radius < 0.0
        ? vec3(0.0, 0.0, 1.0)
        : vec3(clamp(radius / max(point_shadow.params2.z, 1e-6), 0.0, 1.0),
               clamp(blocker / range, 0.0, 1.0), 0.0);
  }

  // The search found nothing between here and the light.
  if (radius < 0.0) return 1.0;

  float lit = PointShadowTap(uv, vec2(0.0), tile, range, receiver);
  if (radius > 0.0) {
    for (int i = 0; i < 8; i++) {
      lit += PointShadowTap(uv, PointShadowOffset(i, ca, sa, radius), tile,
                            range, receiver);
    }
    lit *= 1.0 / 9.0;
  }

  // Strength lerps towards fully lit, so the control is "how dark", not "how
  // much of the kernel" — the same convention the directional map uses.
  return mix(1.0, lit, clamp(strength, 0.0, 1.0));
}

#else

/// The stand-in for a model that declares none of the above.
///
/// Fully lit, which is what a model with no shadow term means, and a constant
/// the compiler folds rather than a branch anything pays for.
float PointShadowFactor(vec3 world, vec3 normal, int lightIndex) {
  return 1.0;
}

#endif  // F3D_NO_POINT_SHADOW

vec3 AccumulateLights(Surface s) {
  vec3 total = vec3(0.0);
  int count = LightCount();

  for (int i = 0; i < kTotalLights; i++) {
    if (i >= count) break;
    LightSample light = SampleLight(i, s);
    if (light.n_dot_l <= 0.0) continue;
    // A light from the list has no shadow row to read — see `LightHasShadow`.
    // A branch rather than something folded into the two calls, because both
    // index tables eight entries wide and the ninth light would read past them
    // rather than read a one.
    float visibility = LightHasShadow(i)
        ? LightVisibility(s, light, i) *
              PointShadowFactor(v_world_position, s.n, i)
        : 1.0;
    if (visibility <= 0.0) continue;
    total += ShadeLight(s, light) * light.radiance * light.n_dot_l * visibility;
  }

  return total;
}

#endif  // SURFACE_GLSL_


// Never called — nothing here accumulates lights — but the prototype in
// surface.glsl has to be satisfied, and an unlit surface responding with its
// albedo is the honest answer to "what would this look like lit".
vec3 ShadeLight(Surface s, LightSample light) {
  return s.albedo;
}

// Never called either, and deliberately not routed through shadow.glsl: an
// unlit shader that declared the shadow sampler would lose it to the optimizer
// and leave the engine binding a slot Metal does not have.
float LightVisibility(Surface s, LightSample light, int index) {
  return 1.0;
}

void main() {
  Surface s = ReadSurface();
  // `L5`: an unlit surface shows its colour and reflects no light, so the
  // albedo buffer holds black for it.
  g_albedo = vec3(0.0);
  // The albedo is already linear, and an unlit surface is best
  // understood as emitting exactly it, so it goes into the HDR
  // target as light like everything else.
  WriteSurface(s.albedo, s.alpha);
}

''',
    'Xray': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// `unlit.frag` with its second output taken away: a flat colour that says
// nothing about the surface it covers.
//
// **A shader of its own rather than a blend state, because a blend state does
// not reach here.** The x-ray stage draws every marked node twice inside the
// scene pass, and that pass has two attachments whenever a screen-space effect
// asked for the surface buffer. `BlendState.keepDestination` is what the
// marking draw uses to leave the picture alone, and it protects attachment
// zero only: `setBlend` takes an attachment index that Impeller honours and
// WebGL2 cannot, since per-attachment blending there needs
// `EXT_draw_buffers_indexed`. So the three backends disagreed about what was
// left in attachment one — and the silhouette draw, which blends not at all,
// wrote into it on every one of them.
//
// What that wrote was wrong rather than merely extra. The silhouette's depth
// test is `greater`: it passes exactly where the marked node is BEHIND what
// the depth buffer holds, so `frag_surface` was taking the normal, roughness
// and depth of a monster and stamping them over the wall in front of it. The
// surface buffer's one invariant is that it describes the nearest surface, and
// SSAO and reflections read it as such — a silhouette would have occluded and
// reflected off geometry that is not visible.
//
// Declaring one output into a two-attachment target is the arrangement
// `sky.frag` already ships and `lib/color.glsl` already guards for the shadow
// passes; the reverse — declaring an output the target has no slot for — is
// the one that crashes Metal.
#define F3D_NO_POINT_SHADOW
#define F3D_NO_LIGHT_LIST
#define F3D_NO_SURFACE_BUFFER
// --- lib/surface.glsl ---
// Shared material and lighting interface for the lighting models.
//
// flutter_gpu compiles shaders ahead of time into a bundle: there is no runtime
// compilation, so a node-graph material system assembled at run time is
// impossible. Each lighting model is therefore
// its own pre-built fragment shader, and this header is what keeps them
// interchangeable — one identical uniform block, so the Dart binding code never
// needs to know which model is active.
//
// Keep every declaration below byte-identical across models. A member a model
// does not read may be optimized out of the reflected block, which is why the
// Dart side skips absent members instead of failing.
//
// Only include this from a shader that actually reads FragInfo. Declaring the
// block without using it leaves it visible to reflection while the compiled
// shader binds no buffer for it, and binding that phantom block segfaults
// inside Metal. Shaders needing only colour helpers include lib/color.glsl.

#ifndef SURFACE_GLSL_
#define SURFACE_GLSL_

// --- lib/color.glsl ---
// Colour space helpers and the fragment output interface.
//
// Split out of surface.glsl so a shader that needs no material inputs — the
// normals debug view — can avoid DECLARING the FragInfo uniform block at all.
// That matters more than it looks: reflection metadata reports a block as
// present merely because it was declared, even when the compiled shader binds
// no such buffer, so a declared-but-unused block is indistinguishable from a
// used one until Metal crashes on the bind.

#ifndef COLOR_GLSL_
#define COLOR_GLSL_

precision highp float;

const float kPi = 3.14159265359;

// One varying set shared by every fragment shader, matching mesh.vert.
//
// All five are declared here, including the two the debug models never read: a
// fragment shader whose `in` block disagrees with the vertex shader's `out`
// block fails to link, and there is no partial-match rule to lean on.
in vec3 v_world_position;
in vec3 v_normal;
in vec2 v_texcoord;
in vec4 v_tangent;
in vec4 v_color;

/// Where this fragment is in the level's lightmap. Zero from every vertex
/// stage but `mesh_lightmapped.vert`, and read only by the lit models, which
/// sample a one-texel black there when a material has no map.
in vec2 v_lightmap_uv;

layout(location = 0) out vec4 frag_color;

// The second attachment: what a screen-space effect needs to know about the
// surface it is looking at. World-space normal in rgb, and in a the depth along
// the view axis in world metres — not a window depth; `WriteSurfaceGeometry`
// says at length why not.
//
// Depth travels here rather than in a depth texture because flutter_gpu cannot
// sample one — the same reason the shadow pass writes its depth into a colour
// target. See ARCHITECTURE.md §2.
//
// Guarded, because not every stage that includes this header draws into a
// two-attachment target. The shadow pass draws into one, and a pipeline
// declaring an output its target has no slot for is a mismatch worth avoiding
// rather than discovering.
#ifndef F3D_NO_SURFACE_BUFFER
layout(location = 1) out vec4 frag_surface;

/// The surface's own colour, sRGB-encoded, alpha one where a surface was
/// drawn — `L5`. The third attachment, present only when a pass reads it (the
/// indirect light does) and the device opens three; like the surface buffer,
/// written unconditionally and discarded when absent. Stored in the surface
/// buffer's format rather than eight bits a channel, and `Renderer` says why.
layout(location = 2) out vec4 frag_albedo;
#endif

/// What [frag_albedo] carries: the lit models set it in `ReadSurface`, and a
/// stage that reflects nothing — unlit, the debug views — leaves it black,
/// which is what light bounced onto it would come to.
vec3 g_albedo = vec3(0.0);

/// Octahedral encoding: a unit vector in two channels instead of three.
///
/// Worth the arithmetic because the fourth channel is already spent on depth,
/// and without a free channel there is nowhere to put roughness — which is the
/// difference between a reflection that knows stone from a mirror and one that
/// does not. The error is well under a degree, far below anything a reflection
/// off rough stone would show.
vec2 EncodeOctahedral(vec3 n) {
  n /= abs(n.x) + abs(n.y) + abs(n.z);
  vec2 e = n.xy;
  if (n.z < 0.0) {
    e = (1.0 - abs(n.yx)) * vec2(n.x >= 0.0 ? 1.0 : -1.0,
                                 n.y >= 0.0 ? 1.0 : -1.0);
  }
  return e * 0.5 + 0.5;
}

/// Where a debug pass leaves the picture it wants shown instead of the normal.
///
/// Declared here, in the header every lit shader includes **first**, and
/// written from surface.glsl, which is included after. The alternative was a
/// new member on a shared uniform block; a global costs nothing and moves no
/// offsets. It is read at the moment the surface buffer is written, which
/// happens after the lighting loop has run, so the value is there by then.
vec3 g_debug_surface = vec3(0.0);
bool g_debug_surface_on = false;

/// Whether [WriteSurface] weights the colour by its alpha: set by
/// `ReadSurface` for a material that blends, and false for everything else.
///
/// **The blend takes its source as premultiplied**, so a blended surface has
/// to hand it the colour times the alpha — a pane at a fifth of opaque adds a
/// fifth of its light, not all of it. glTF's blend mode is Porter and Duff's
/// over on straight colour, and this is the one place that turns the lit
/// radiance into what that means. An opaque or masked surface keeps its
/// colour whole: its alpha is not a coverage, and nothing blends it.
/// A global for the reason [g_debug_surface] is one.
bool g_premultiply = false;

// **A stage that needs none of this must be able to declare none of it.** On
// Vulkan both stages' descriptors are merged into one set layout, and two
// bindings with the same number in it is not a layout the specification
// allows. A driver may accept it anyway; a Galaxy A55's refuses the pipeline
// with `ErrorUnknown` and no other word, which is how the shadow pass came to
// build everywhere except there — its only uniform block was this one, and it
// landed on the same binding as the vertex stage's first.
#ifndef F3D_NO_FOG

/// Distance fog, in its own block rather than folded into FragInfo.
///
/// Its own because color.glsl is included before FragInfo is declared, and
/// because appending to a block that half a dozen shaders already share is a
/// way to move offsets nobody expected to move. Three vec4s is a cheap price
/// for not touching any of that.
layout(std140) uniform FogInfo {
  /// rgb: linear fog colour. w: density per metre, zero for no fog.
  vec4 fog;

  /// xyz: camera position in world space. Duplicated from FragInfo so this
  /// block stands alone; a vec3 is cheaper than a coupling.
  vec4 eye;

  /// xyz: the direction the camera looks, as a unit vector in world space.
  /// w: what a transparent draw writes under weighted blended transparency —
  /// `R8`, see `WriteWeightedBlended`. Zero for every other draw.
  ///
  /// Here rather than in a block of its own because it answers the same
  /// question [eye] does — where the camera is and which way it faces — and
  /// this is the block `color.glsl` can see.
  vec4 forward;
}
fog_info;

/// How far this fragment is from the eye, in world metres.
///
/// What the fog fades by. Distance rather than depth, because fog is a
/// property of the air between two points and does not care which way the
/// camera happens to face.
float EyeDistance() { return distance(v_world_position, fog_info.eye.xyz); }

/// How far this fragment is *along the view axis*, in world metres.
///
/// What the surface buffer's alpha holds. Depth rather than distance, and the
/// difference only shows on an orthographic camera — where the rays through
/// the pixels are parallel instead of meeting at the eye, so a distance from
/// the eye names a sphere that the pixel's ray crosses somewhere the reader
/// cannot solve for. A depth along the axis names a plane, which every ray
/// crosses exactly once. See `WorldAtDepth` in `post/ssao.frag` for the
/// reconstruction both projections share.
float ViewDepth() {
  return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz);
}

#else  // F3D_NO_FOG

// The same two questions, answered without the block: a stage that declares no
// fog has no eye position to measure from either. Stubs rather than a guard at
// every call site, so that what includes this file reads the same whichever
// way it was compiled.
float EyeDistance() { return 0.0; }
float ViewDepth() { return 0.0; }

#endif  // F3D_NO_FOG

/// sRGB to linear. Textures are authored in sRGB, but lighting is only correct
/// in linear space; skipping this is what makes naive renderers look muddy.
vec3 SrgbToLinear(vec3 srgb) {
  return mix(
      srgb / 12.92,
      pow((srgb + vec3(0.055)) / 1.055, vec3(2.4)),
      step(vec3(0.04045), srgb));
}

/// Linear to sRGB. The render target is a plain UNorm format rather than an
/// sRGB one, so the encode has to happen here.
vec3 LinearToSrgb(vec3 linear) {
  return mix(
      linear * 12.92,
      1.055 * pow(linear, vec3(1.0 / 2.4)) - vec3(0.055),
      step(vec3(0.0031308), linear));
}

/// Writes scene-referred linear light into the HDR target.
///
/// No tone map and no sRGB encode: those moved into the composite pass, which
/// is the entire point of rendering into `r16g16b16a16Float` first. Applying
/// them here meant every model wrote display-referred colour into an 8-bit
/// buffer, so anything above display white was gone before post-processing
/// could see it — and bloom is a function of exactly that.
///
/// Exposure moved with them, for the same reason: it belongs on the same side
/// of the display transform as the tone map.
/// Records the geometry of this fragment for whatever runs after the scene.
///
/// Called from the same place that writes colour, so a surface cannot be lit
/// into the frame without also describing itself — which is the failure that
/// leaves a screen-space effect reflecting whatever was in the buffer before.
///
/// rg: octahedral normal. b: perceptual roughness. a: **depth along the view
/// axis, in world metres** — see [ViewDepth].
///
/// **Not `gl_FragCoord.z`, and that is a defect this channel carried until it
/// was looked at.** Window depth crowds every distant surface into the top of
/// its range — with a near plane of a tenth of a metre, everything past twenty
/// metres lives in the last half a hundredth of `[0, 1]` — and this attachment
/// is a half float, whose steps up there are about five ten-thousandths. So two
/// surfaces half a metre apart at twenty metres stored the *same* number, and
/// every screen-space pass that compares against this channel decided whole
/// bands of pixels by rounding. The occlusion pass drew them: vertical stripes
/// along the lines of constant depth on any wall receding from the camera, on
/// both GPU backends. The software rasteriser kept the channel at full
/// precision and drew the effect correctly, so it was the one that looked
/// wrong against the other two.
///
/// A depth in metres has none of that: the exponent carries the range and the
/// mantissa carries the same relative precision everywhere, which at twenty
/// metres is a centimetre. Both numbers are measured in
/// `flutter3d/test/surface_depth_test.dart`.
///
/// Zero still means nothing was drawn. The attachment is cleared to zero and
/// nothing is drawn in front of the near plane.
void WriteSurfaceGeometry(float roughness) {
#ifndef F3D_NO_SURFACE_BUFFER
  // `L5`: the surface's colour, whatever the surface buffer ends up holding.
  frag_albedo = vec4(LinearToSrgb(clamp(g_albedo, vec3(0.0), vec3(1.0))), 1.0);
  // A debug pass takes the buffer over rather than getting one of its own.
  // The surface buffer already has an attachment, a viewer and a golden; a
  // second one would need all three built before it could answer anything.
  if (g_debug_surface_on) {
    frag_surface = vec4(g_debug_surface, ViewDepth());
    return;
  }
  // Reversed on a back face, as the lit normal is, so the occlusion and
  // reflection passes see the side of a double-sided surface that faces them.
  vec3 geometric = normalize(v_normal);
  if (!gl_FrontFacing) geometric = -geometric;
  frag_surface = vec4(EncodeOctahedral(geometric),
                      clamp(roughness, 0.0, 1.0), ViewDepth());
#endif
}

/// Fades [color] toward the fog with distance from the eye.
///
/// Exponential rather than linear, because linear fog has a visible plane
/// where it starts and a dungeon corridor is exactly where that shows.
vec3 ApplyFog(vec3 color) {
#ifdef F3D_NO_FOG
  return color;
#else
  float density = fog_info.fog.w;
  if (density <= 0.0) return color;
  float d = EyeDistance();
  return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0));
#endif
}

/// How much a transparent fragment counts for against the others over its
/// pixel — `R8`. McGuire and Bavoil's depth weight (their equation 9): a near
/// layer outweighs a far one, which is all the ordering a weighted average
/// can keep. [alpha] multiplies it, as theirs does, so a faint layer counts
/// faintly. Depth along the view axis, in metres, the surface buffer's.
float WeightedBlendedWeight(float alpha) {
  float z = abs(ViewDepth());
  float near = z / 5.0;
  float far = z / 200.0;
  float far3 = far * far * far;
  return alpha *
         clamp(10.0 / (1e-5 + near * near + far3 * far3), 1e-2, 3e3);
}

/// What a transparent draw writes when the frame composites transparency
/// order-independently — `R8`. `fog_info.forward.w` says which:
///
/// - 0: [frag_color] as it stands, the sorted blend's source. Every opaque
///   draw, and every draw in a frame that sorts.
/// - 1: the accumulation target's share — the colour, which the engine keeps
///   premultiplied, and the alpha, both times the weight. Added.
/// - 2: the revealage target's — the alpha alone, in every channel, which the
///   blend multiplies the target by one minus of.
/// - 3: both at once, the second into attachment one, where the surface
///   buffer would be; the pass that asks has no surface buffer attached.
///
/// Selects rather than returns, because a phi of constants is what
/// SPIRV-Cross refuses. At nought the branch is not taken and [frag_color]
/// is untouched, which is what keeps a sorting frame byte-identical.
void WriteWeightedBlended() {
#ifndef F3D_NO_FOG
  float mode = fog_info.forward.w;
  if (mode > 0.5) {
    float alpha = frag_color.a;
    float weight = WeightedBlendedWeight(alpha);
    vec4 accumulate = vec4(frag_color.rgb * weight, alpha * weight);
    bool revealage = mode > 1.5 && mode < 2.5;
    frag_color = revealage ? vec4(alpha) : accumulate;
#ifndef F3D_NO_SURFACE_BUFFER
    if (mode > 2.5) frag_surface = vec4(alpha);
#endif
  }
#endif
}

/// The fog is mixed in before the weight, so a thin distant pane adds a thin
/// share of the fog too rather than all of it. Times one when nothing blends,
/// which is exact, so an opaque draw writes what it always wrote.
void WriteSurface(vec3 linearColor, float alpha, float roughness) {
  float weight = g_premultiply ? alpha : 1.0;
  frag_color = vec4(ApplyFog(linearColor) * weight, alpha);
  WriteSurfaceGeometry(roughness);
  WriteWeightedBlended();
}

/// For a stage with no material to speak of.
///
/// Fully rough, which is the honest default: a surface that cannot say how
/// polished it is should not be reflected off.
void WriteSurface(vec3 linearColor, float alpha) {
  WriteSurface(linearColor, alpha, 1.0);
}

/// Writes a value that is already display-referred.
///
/// For debug output, where the colour is not a light value at all: a normal
/// encoded as RGB means nothing after a tone curve. Converting to linear here
/// means the composite pass's sRGB encode hands the original back unchanged,
/// provided the view also turns tone mapping and exposure off — which is what
/// `RenderSettings.tonemap` is for.
void WriteDisplayColor(vec3 displayColor, float alpha) {
  frag_color = vec4(SrgbToLinear(displayColor), alpha);
  WriteSurfaceGeometry(1.0);
}

#endif  // COLOR_GLSL_

// --- lib/frag_coord.glsl ---
// Where a fragment sits, counted from the top of its target on every backend.

#ifndef FRAG_COORD_GLSL_
#define FRAG_COORD_GLSL_

/// `gl_FragCoord.xy` with row zero at the top of the picture.
///
/// [rows] is the target's height where the backend's row zero is the bottom
/// of the picture, and zero where it is the top. WebGL2 is the first kind:
/// window coordinates start at the lower left, and the engine draws the
/// picture upright there rather than mirroring every projection. Metal,
/// WebGPU and the software rasteriser are the second.
///
/// **Why a pattern cares and a picture does not.** Every screen-space pattern
/// in the engine — the Bayer dither, the grain, the jitter a ray march starts
/// from, the rotation of a shadow kernel — is a function of the pixel's row.
/// Read from the bottom, the same frame gets the pattern turned upside down,
/// and a four-row Bayer cell lands on different rows unless the height is a
/// multiple of four. The picture underneath is identical; the pattern on top
/// of it is not, and a comparison across backends counts every pixel it
/// moved.
vec2 FragCoordFromTop(float rows) {
  return rows > 0.0 ? vec2(gl_FragCoord.x, rows - gl_FragCoord.y)
                    : gl_FragCoord.xy;
}

#endif  // FRAG_COORD_GLSL_


/// Lights per draw. Must match LightBuffer.maxLights on the Dart side.
///
/// A fixed array with a runtime count, not a shader permutation per light
/// count: turning a light on has to be free, because there is no runtime
/// compilation to fall back on. Verified against the SDK — Impeller keeps
/// `vec4 x[8]` in the compiled Metal struct and reflects the array's base
/// offset, with the std140 stride of 16 bytes.
#define kMaxLights 8

/// How many more lights one draw may be handed — `gfx-74n`.
///
/// **The eight above stay exactly what they were**, which is what keeps this
/// from moving a single recorded frame: a draw with eight lights or fewer runs
/// the loop it has always run, reads the uniform arrays it has always read, and
/// never touches the texture below. The tail is the part that used to be
/// impossible.
///
/// A loop bound rather than a cost. `AccumulateLights` breaks at the draw's own
/// count, so a scene with three lights costs three iterations whatever this
/// says. Twenty-four because the two tables below are `vec4 x[6]` and four
/// lanes fit a `vec4`: two hundred and eight bytes a draw, against the five
/// hundred and twelve the light arrays already cost.
#define kExtraLights 24
#define kTotalLights (kMaxLights + kExtraLights)

// --- lib/light_list.glsl ---
// The frame's light list, and how a fragment finds its tail in it — `gfx-74n`
// and `L6`.
//
// Split out of `surface.glsl` so a stage that is not a surface can read the
// same lights: `N6`'s six-way particles light each fragment by the list the
// lit models read, clusters and all, without declaring `FragInfo`. The text is
// the one that stood in `surface.glsl`, moved rather than copied, so the lit
// models compile to what they compiled to before.

#ifndef LIGHT_LIST_GLSL_
#define LIGHT_LIST_GLSL_
/// Every light in the scene, one per row, four texels across — `gfx-74n`.
///
/// **A texture rather than a wider uniform block, and that is the design.**
/// `FragInfo` is uploaded on every draw, so widening its four `vec4` arrays to
/// hold thirty-two lights would be a two-kilobyte upload per draw in every
/// scene, including every scene with one light. This is built once a frame and
/// only when a scene has more lights than a draw can hold in its slots.
///
/// Row layout, which `renderer_light_list.dart` writes and only this reads:
///
///  * texel 0 — xyz world position, w type (0 directional, 1 point, 2 spot)
///  * texel 1 — rgb linear colour, w intensity
///  * texel 2 — xyz the direction it points, w range
///  * texel 3 — x cos(inner), y cos(outer), zw unused
///
/// The same four vectors the uniform arrays hold, in the same order, so one
/// reader serves both.
///
/// **`F3D_NO_LIGHT_LIST` leaves both out**, for a model that accumulates no
/// lights. Such a model never reaches the reader below, so the compiler drops
/// the block and the sampler from the Metal function while reflection still
/// lists them, with no buffer or texture index assigned. The renderer used to
/// bind them for every draw, Unlit included, and that bind is a crash inside
/// `setFragmentBuffer:offset:atIndex:` on Metal. Vulkan took the same draw
/// without a word, which is how 0.7.0 shipped with it.
#ifndef F3D_NO_LIGHT_LIST
uniform sampler2D light_list_texture;

layout(std140) uniform LightListInfo {
  /// x: how many rows this draw reads, zero when it reads none.
  /// y, z: one over the texture's width and height.
  /// w: unused.
  vec4 list;

  /// Which rows, four to a vector, in the order they are read.
  ///
  /// Indices rather than the light data itself: the data is the same for every
  /// draw in the frame and belongs in the texture; what differs per draw is
  /// *which* of them reach it, and that is what `Renderer._drawLightsFor`
  /// already decides.
  vec4 indices[6];

  /// How much of each of those survives the edge fade, in the same order.
  ///
  /// Per draw and not in the texture, because the row an index points at is
  /// shared by every draw in the frame: a scale written into it would dim that
  /// light for all of them. `gfx-12n`'s fade lives at the end of the list now —
  /// that is where a light stops contributing, and fading the slots against a
  /// water line that no longer marks a cliff would dim a light for no reason
  /// while its rival stayed bright, making the swap more visible rather than
  /// less.
  vec4 scales[6];

  /// `L6`: the view-projection the light clusters were cut with, so this
  /// finds a fragment's cell the way `LightClusters.clusterOf` does.
  mat4 cluster_view_projection;

  /// xyz: tiles across, tiles up, slices deep. w: one when this draw reads
  /// its tail from the cell it is in rather than from `indices`.
  vec4 cluster_grid;

  /// x: where slices begin, in clip w. y: slices per unit of `ln(w / x)`.
  /// z: the texture row the cells' headers start at, four to a row, each
  /// (offset, count). w: the row their entries start at, sixteen to a row.
  vec4 cluster_depth;

  /// Which rows this draw already holds in its eight slots, minus one for
  /// an empty slot. A cell lists every light that reaches it, and one the
  /// slots already carry must not be counted again.
  vec4 slot_rows[2];
}
light_list_info;

/// One lane of a six-vector table, [slot] counting from nought.
float LightListLane(vec4 four, int slot) {
  int lane = slot - (slot / 4) * 4;
  return lane == 0 ? four.x : lane == 1 ? four.y : lane == 2 ? four.z : four.w;
}

/// The row light [slot] of the list reads.
float LightListRow(int slot) {
  return LightListLane(light_list_info.indices[slot / 4], slot);
}

/// How much of light [slot] of the list survives the edge fade.
float LightListScale(int slot) {
  return LightListLane(light_list_info.scales[slot / 4], slot);
}

/// The cell this fragment falls in, as `LightClusters` wrote it: where its
/// entries start and how many there are. Found once, in [LightCount], and
/// read by every [SampleLight] of the loop that follows.
float g_cluster_offset = 0.0;
float g_cluster_count = 0.0;

bool Clustered() { return light_list_info.cluster_grid.w > 0.5; }

/// One texel of the light list texture, [texel] across and [row] down.
vec4 LightListTexel(float texel, float row) {
  return textureLod(light_list_texture,
                    vec2((texel + 0.5) * light_list_info.list.y,
                         (row + 0.5) * light_list_info.list.z),
                    0.0);
}

void FindCluster(vec3 world) {
  vec4 clip = light_list_info.cluster_view_projection * vec4(world, 1.0);
  vec2 ndc = clip.xy / max(clip.w, 1e-6);
  vec3 grid = light_list_info.cluster_grid.xyz;
  float near = light_list_info.cluster_depth.x;
  float tx = clamp(floor((ndc.x * 0.5 + 0.5) * grid.x), 0.0, grid.x - 1.0);
  float ty = clamp(floor((ndc.y * 0.5 + 0.5) * grid.y), 0.0, grid.y - 1.0);
  float tz = clip.w <= near
                 ? 0.0
                 : clamp(floor(log(clip.w / near) *
                               light_list_info.cluster_depth.y),
                         0.0, grid.z - 1.0);
  float cell = tx + ty * grid.x + tz * grid.x * grid.y;
  float row = floor(cell / 4.0);
  vec4 header =
      LightListTexel(cell - row * 4.0, light_list_info.cluster_depth.z + row);
  g_cluster_offset = header.x;
  g_cluster_count = header.y;
}

/// The row entry [slot] of this fragment's cell names.
float ClusterRow(int slot) {
  float entry = g_cluster_offset + float(slot);
  float row = floor(entry / 16.0);
  float within = entry - row * 16.0;
  float texel = floor(within / 4.0);
  vec4 four = LightListTexel(texel, light_list_info.cluster_depth.w + row);
  return LightListLane(four, int(within - texel * 4.0 + 0.5));
}

/// Whether one of the draw's slots already holds light list row [row].
bool InSlots(float row) {
  vec4 a = abs(light_list_info.slot_rows[0] - vec4(row));
  vec4 b = abs(light_list_info.slot_rows[1] - vec4(row));
  return min(min(min(a.x, a.y), min(a.z, a.w)), min(min(b.x, b.y), min(b.z, b.w))) < 0.5;
}
#endif  // F3D_NO_LIGHT_LIST

#endif  // LIGHT_LIST_GLSL_


layout(std140) uniform FragInfo {
  /// xyz: world position (point and spot). w: type, 0 directional 1 point 2 spot.
  vec4 light_position[kMaxLights];

  /// rgb: linear colour. w: intensity.
  vec4 light_color[kMaxLights];

  /// xyz: the direction the light points, its local -Z. w: range, 0 unbounded.
  vec4 light_direction[kMaxLights];

  /// x: cos(inner cone angle). y: cos(outer cone angle).
  vec4 light_cone[kMaxLights];

  /// rgb: albedo tint applied on top of the texture. w: opacity.
  vec4 base_color;

  /// rgb: emissive factor, already linear. w: one when the normal map has
  /// two channels (x, y) and its z is rebuilt — see `ApplyNormalMap`. It sits
  /// here because this was the block's one unspent lane.
  vec4 emissive;

  /// xyz: camera position in world space, needed for every specular term.
  vec4 camera_position;

  /// x: metallic, y: roughness, z: ambient strength, w: specular strength.
  vec4 material;

  /// x: alpha cutoff (negative when the material is not masked: -1 opaque,
  /// -0.5 blended, -2 hashed), y: normal scale, z: occlusion strength,
  /// w: emissive strength.
  vec4 material2;

  /// x: exposure, y: active light count, z: index of the shadow-casting light.
  /// w is reserved so adding a frame-wide parameter does not change the offsets
  /// of anything already here.
  vec4 frame_params;

  /// x: one texel of the shadow map, y: depth bias, z: normal offset,
  /// w: strength, zero when shadows are off.
  vec4 shadow_params;

  /// World space to the shadow camera's clip space. The first cascade.
  mat4 shadow_matrix;

  /// The second and third cascades. Copies of the first when there is one, so
  /// this block's layout never depends on how many there are.
  mat4 shadow_matrix_far;
  mat4 shadow_matrix_farthest;

  /// x, y: where cascades 0 and 1 end, in metres from the camera. z: how many
  /// cascades there are, 1 to 3. w: one texel of a tile, vertically —
  /// shadow_params.x is one texel of the whole atlas, and with more than one
  /// cascade those differ.
  vec4 shadow_cascades;

  /// rgb: what a surface facing straight up receives from the environment.
  /// w: one when the metal-rough models' diffuse is EON rather than Lambert —
  /// `L8`, `RenderSettings.diffuseModel`; a frame-wide switch in a frame-wide
  /// vector, and the block's offsets stay where four backends agree on them.
  ///
  /// Appended after everything else on purpose: std140 lays a block out in
  /// declaration order, so adding here leaves every offset above unchanged and
  /// the three backends do not have to agree about anything they did not
  /// already agree about.
  vec4 ambient_sky;

  /// rgb: what a surface facing straight down receives — bounce off the ground
  /// rather than the ground itself.
  ///
  /// **w is the directional light's apparent size** — `gfx-15n` — which has
  /// nothing to do with ambient and everything to do with this being the last
  /// unspent component in a block six shaders share. `frame_params.w` was the
  /// slot reserved for a frame-wide parameter and the environment's level
  /// count took it; appending to this block moves offsets four backends have
  /// agreed on. See `shadow.glsl`, which reads it.
  ///
  /// Two colours rather than one is the whole of what makes ambient look like
  /// light instead of like a lifted black level. Outdoors the sky is blue and
  /// bright and the ground is warm and dim, and a flat grey for both leaves
  /// every underside as pale as every upward face — which reads as the model
  /// being flat, and gets blamed on the normals.
  vec4 ambient_ground;

  /// x, y, z: the depth bias of each cascade, in that cascade's own normalized
  /// depth. w unused.
  ///
  /// `ShadowSettings.bias` is one number and a cascade's depth range is not:
  /// a near cascade is stretched towards the light when a caster stands
  /// further out than its own volume reaches, and the same bias over a longer
  /// range is a longer distance. The renderer converts it per cascade so it
  /// stays the distance it was tuned as; an unstretched cascade gets the
  /// setting unchanged.
  vec4 shadow_bias;

  /// x: the target's rows when its row zero is the bottom of the picture,
  /// zero when it is the top — see `FragCoordFromTop` in `frag_coord.glsl`,
  /// which the shadow kernel's rotation reads through. y: the mip bias every
  /// material map is read with — `R2`: nought, except while a temporal
  /// resolve reconstructs a picture larger than the scene is drawn at, when
  /// the maps are read as sharp as the output they end up in. z: one when
  /// the metal-rough model puts back the energy single scattering loses —
  /// `L1`, `RenderSettings.energyCompensation`. w: the frame's slice of 32
  /// while a temporal resolve runs, minus one otherwise — `S3`, which steps
  /// the soft shadow's rotation by it.
  vec4 target_origin;
}
frag_info;

/// The bias a material map is read with — see `target_origin.y`.
float MaterialLodBias() { return frag_info.target_origin.y; }

/// The maps a lit material reads, by the index [MapUv] takes — `C8`. The
/// order `LayerInfo.uv_transform` keeps them in, and `MaterialMap`'s on the
/// Dart side.
#define kMapBaseColor 0
#define kMapMetallicRoughness 1
#define kMapNormal 2
#define kMapOcclusion 3
#define kMapEmissive 4

/// Where map [slot] is read — `C8`, `KHR_texture_transform` at the sampler.
///
/// **A macro everywhere but the one stage that has the matrices.** A stage
/// that defines `F3D_TEXTURE_TRANSFORM` supplies [MapUv] and [MapMatrix] from
/// a block of its own; every other stage reads each map at the vertex's own
/// coordinate, and the macro leaves its source exactly what it was, so none of
/// them compiles to anything new.
#ifdef F3D_TEXTURE_TRANSFORM
vec2 MapUv(int slot);

/// The 2×2 part of map [slot]'s transform: x and y its first row, z and w
/// its second.
vec4 MapMatrix(int slot);
#else
#define MapUv(slot) v_texcoord
#endif

uniform sampler2D base_color_texture;

/// Everything about the surface that does not depend on which light is being
/// evaluated, resolved once per fragment.
struct Surface {
  vec3 albedo;      // linear, already tinted
  float alpha;      // opacity after texture, tint and vertex colour
  vec3 n;           // unit normal, perturbed by the normal map when there is one
  vec3 v;           // unit direction to the camera
  float n_dot_v;
  float metallic;
  float roughness;  // perceptual
  float occlusion;  // 1 means unoccluded
  vec3 emissive;    // linear, added after shading
  vec3 ambient;     // hemispheric, already scaled by the scene's strength
  float exposure;
};

/// One light's contribution geometry, recomputed per light per fragment.
struct LightSample {
  vec3 l;           // unit direction to the light
  vec3 h;           // unit half vector
  vec3 radiance;    // colour * intensity * attenuation
  float n_dot_l;
  float n_dot_h;
  float v_dot_h;

  /// One when the specular below is already integrated over the light —
  /// `L7`, a rectangle under a model that defines `F3D_LTC` — and nought
  /// otherwise. Then `ltc.x` is the GGX lobe over the rectangle, `ltc.y` the
  /// fitted norm and `ltc.z` the Fresnel term; see `LtcRectangle`.
  float integrated;
  vec3 ltc;
};

Surface ReadSurface() {
  Surface s;

  vec4 texel = texture(base_color_texture, MapUv(kMapBaseColor), MaterialLodBias());
  // Vertex colour is authored linear per the glTF spec, unlike the base colour
  // texture and the tint, which are sRGB.
  s.albedo = SrgbToLinear(texel.rgb) *
             SrgbToLinear(frag_info.base_color.rgb) *
             v_color.rgb;
  s.alpha = texel.a * frag_info.base_color.a * v_color.a;
  // `L5`: the albedo buffer carries it, for the indirect light.
  g_albedo = s.albedo;

  // Alpha masking, glTF's third alpha mode. A negative cutoff means the
  // material is opaque or blended, and discard would then be wrong rather than
  // merely unnecessary. Doing it before anything else is deliberate: a
  // discarded fragment should not pay for the lighting loop.
  //
  // **A cutoff below -1.5 is the fourth mode: hashed** — `gfx-16n`. The
  // sentinel rides in the same component because the alternative is a second
  // number in a block six shaders share, and -1 already meant "not masked";
  // anything more negative was free. See [MaterialAlphaMode.hashed].
  float cutoff = frag_info.material2.x;
  if (cutoff >= 0.0) {
    if (s.alpha < cutoff) discard;
  } else if (cutoff < -1.5) {
    // **Stochastic instead of a threshold.** A leaf texture at 40% opacity is
    // either entirely there or entirely gone under a fixed cutoff, so a fern
    // comes out as a hard-edged cardboard cut-out; sorting would fix it and
    // costs a sort per frame and a draw per layer. Comparing against noise
    // instead keeps 40% of the *pixels*, which resolves as 40% opacity to
    // anything that averages several of them — a higher-resolution target,
    // a downsample, a person standing back.
    //
    // **Hashed on world position, not on the screen.** Screen-space noise is
    // one line shorter and swims: the pattern stays put while the object
    // moves through it, so a moving branch sparkles. Anchoring it to where
    // the surface *is* means a given speck of leaf keeps its verdict from
    // frame to frame, and the camera moving changes nothing.
    //
    // The scale is a constant and it is the whole tuning: finer than the
    // texture's own detail and the noise disappears into aliasing, coarser
    // and the leaf breaks into blotches. Sixteen per metre is about a
    // centimetre of grain at a metre away.
    vec3 anchored = floor(v_world_position * 16.0);
    float noise = fract(
        sin(dot(anchored, vec3(12.9898, 78.233, 37.719))) * 43758.5453);
    if (s.alpha < noise) discard;
  }
  // **Between -1 and nought is the blend mode**, which `WriteSurface` weights
  // by its alpha: see [g_premultiply]. The engine writes -0.5 for it, -1 for
  // opaque; neither is masked, and only the blend's source is premultiplied.
  g_premultiply = cutoff < 0.0 && cutoff > -0.75;

  s.n = normalize(v_normal);
  // The back of a double-sided surface is lit from its own side: glTF asks
  // for the normal to be reversed there, and without it the underside of a
  // cloth turned to the sun reads n·l below zero and stays unlit. Only a
  // double-sided material ever draws a back face, since everything else has
  // them culled.
  if (!gl_FrontFacing) s.n = -s.n;
  s.v = normalize(frag_info.camera_position.xyz - v_world_position);
  // Clamped away from zero: a grazing view direction otherwise divides by zero
  // in the specular visibility term.
  s.n_dot_v = max(dot(s.n, s.v), 1e-4);

  s.metallic = clamp(frag_info.material.x, 0.0, 1.0);
  s.roughness = clamp(frag_info.material.y, 0.02, 1.0);
  // Hemispheric: the sky above, the ground below, blended by which way this
  // surface faces. `material.z` stays the overall strength, so the two are
  // separable — a scene dims its ambient without changing its colour, which is
  // what the one control used to do on its own.
  //
  // The blend runs on the geometric normal deliberately, before
  // `ApplyMaterialMaps` perturbs it. A normal map describes millimetres of
  // surface relief, and ambient of this kind describes which half of the world
  // a face can see; letting bump detail swing it makes a brick wall's mortar
  // lines pick up sky and reads as noise.
  s.ambient = mix(frag_info.ambient_ground.rgb, frag_info.ambient_sky.rgb,
                  s.n.y * 0.5 + 0.5) *
              frag_info.material.z;
  s.exposure = max(frag_info.frame_params.x, 0.0);

  // Neutral until ApplyMaterialMaps says otherwise, so a model that samples no
  // maps still has a complete surface.
  s.occlusion = 1.0;
  s.emissive = vec3(0.0);

  return s;
}

int LightCount() {
#ifdef F3D_NO_LIGHT_LIST
  return clamp(int(frag_info.frame_params.y + 0.5), 0, kMaxLights);
#else
  // `L6`: the tail is the cell's, when the draw reads one.
  float tail = light_list_info.list.x;
  if (Clustered()) {
    FindCluster(v_world_position);
    tail = g_cluster_count;
  }
  return clamp(int(frag_info.frame_params.y + 0.5), 0, kMaxLights) +
      clamp(int(tail + 0.5), 0, kExtraLights);
#endif
}

/// Whether light [index] carries a shadow — `gfx-74n`.
///
/// Only the first eight do. The cube atlas holds six rows and the slot table is
/// eight entries wide, so a light from the list has no row to read and asking
/// for one would index past the table. That is a real limit and the right one:
/// the eight a draw keeps in its slots are the eight ranked most relevant to
/// it, which is exactly the set worth a shadow map.
bool LightHasShadow(int index) { return index < kMaxLights; }

/// Distance attenuation for a punctual light, following the glTF spec.
///
/// Inverse square with an optional range window. The window is what stops a
/// lamp with a declared range from contributing a faint haze across the whole
/// scene, which matters far more once there are eight of them.
float PunctualAttenuation(float distance, float range) {
  float attenuation = 1.0 / max(distance * distance, 1e-4);
  if (range > 0.0) {
    float ratio = distance / range;
    float window = clamp(1.0 - ratio * ratio * ratio * ratio, 0.0, 1.0);
    attenuation *= window * window;
  }
  return attenuation;
}

/// One edge of Lambert's sum, from [a] to [b], neither of which need be a
/// unit vector: the angle between them times how much their plane leans into
/// [n].
float LambertEdge(vec3 a, vec3 b, vec3 n) {
  // Normalised with a floor rather than `normalize`: a corner exactly at the
  // shading point, or a horizon crossing that lands there, is a zero vector,
  // and `normalize` of that is a NaN that spreads to the whole pixel and then
  // to the bloom. A zero vector here subtends nothing, which is the answer.
  vec3 ua = a / max(length(a), 1e-12);
  vec3 ub = b / max(length(b), 1e-12);
  // Clamped before the `acos`: two nearly parallel edge directions can give a
  // dot a hair past one through rounding alone, and `acos` of that is the same
  // NaN.
  float angle = acos(clamp(dot(ua, ub), -1.0, 1.0));
  vec3 axis = cross(ua, ub);
  float len = length(axis);
  // A degenerate edge — the shading point lies on the line through it —
  // subtends nothing.
  return len > 1e-6 ? angle * dot(axis, n) / len : 0.0;
}

/// How much of [s]'s sky a rectangle covers, weighted by the cosine —
/// `gfx-77n`.
///
/// **Exact, not fitted.** This is Lambert's own form factor for a polygon, from
/// 1760: for each edge, the angle it subtends at the shading point times how
/// much the edge's plane leans into the surface normal. Summed over the edges
/// and halved, it is the integral of `cos θ` over the polygon's projection on
/// the sphere — the quantity a punctual light approximates with a single
/// `n · l`. So there is no table to ship and nothing to fit: the usual
/// linearly-transformed-cosine approach exists to make the *specular* lobe
/// tractable, and buys nothing here.
///
/// **Clipped to the horizon first.** Lambert's sum is signed: a part of the
/// panel below the surface's horizon counts with a negative cosine and cancels
/// light from the part above it, so a panel standing on the horizon read
/// nought where half of it lights the surface. Irradiance wants the clamped
/// cosine, and for a polygon that means cutting away what lies below before
/// summing. A convex quadrilateral cut by a plane leaves one polygon with at
/// most one edge leaving the hemisphere and one entering it, so the cut is the
/// four edges trimmed where they cross plus one edge along the horizon from
/// the exit back to the entry, with no list of vertices to build.
///
/// Returns irradiance over radiance, so a surface facing a rectangle that fills
/// its whole sky gets π, the same as a uniform hemisphere. [corners] are the
/// four vertices in order, relative to the shading point.
///
/// **The rectangle emits along `cross(halfWidth, halfHeight)`**, and with the
/// corners wound as `SampleLight` winds them the sum comes out *negative* on
/// that side, so the negation below is the convention rather than a fix. It was
/// measured rather than derived: the first version returned `+total * 0.5`, and
/// against the reference integration it read nought where the answer was 0.349
/// and 1.02 where the answer was nought — the two failures a flipped winding
/// produces, and between them they name the sign with no room left to argue.
float RectangleFormFactor(vec3 corners[4], vec3 n) {
  float total = 0.0;
  vec3 exit = vec3(0.0);
  vec3 entry = vec3(0.0);
  for (int i = 0; i < 4; i++) {
    vec3 a = corners[i];
    vec3 b = corners[i == 3 ? 0 : i + 1];
    float ha = dot(a, n);
    float hb = dot(b, n);
    // Where the edge meets the horizon; used only when it crosses it, and then
    // the two heights differ in sign, so the division is safe.
    float d = ha - hb;
    vec3 q = a + (b - a) * (abs(d) > 1e-12 ? ha / d : 0.0);
    bool aAbove = ha > 0.0;
    bool bAbove = hb > 0.0;
    total += aAbove || bAbove
                 ? LambertEdge(aAbove ? a : q, bAbove ? b : q, n)
                 : 0.0;
    exit = aAbove && !bAbove ? q : exit;
    entry = !aAbove && bAbove ? q : entry;
  }
  // The horizon edge closing the cut, from where the outline left the
  // hemisphere to where it came back. Nothing when it never crossed: both are
  // still zero and a zero vector subtends nothing.
  total += LambertEdge(exit, entry, n);
  // Clamped: a surface on the panel's dark side sees the outline wound the
  // other way, and the clipped sum comes out negative. `SampleLight` tests the
  // side as well, before any of this is paid for.
  return max(-total * 0.5, 0.0);
}

/// Where on the rectangle the specular lobe is really looking — `gfx-77n`.
///
/// **The representative point, which is an approximation, unlike the diffuse
/// above.** The mirror direction leaves the surface and either hits the panel
/// or misses it; the closest point of the panel to that ray is treated as a
/// punctual light standing in for the whole rectangle. It is the standard
/// cheap answer and its one visible property is the one the row asked for: as
/// the view moves the closest point slides along the panel, so the highlight
/// is a streak with the panel's own shape and orientation rather than a dot.
///
/// What it does not do is widen the lobe by the panel's solid angle, so a
/// rough surface under a large panel is a little darker than a full integration
/// would make it. That is a known error of this method and not a bug in this
/// transcription; the fix is the fitted table this function exists to avoid.
vec3 RectangleClosestPoint(vec3 centre, vec3 halfWidth, vec3 halfHeight,
                           vec3 world, vec3 mirror) {
  vec3 n = cross(halfWidth, halfHeight);
  float nLen = length(n);
  // A panel with no area has no surface to find a point on; its centre is the
  // only answer that is not a division by zero.
  if (nLen < 1e-12) return centre;
  n /= nLen;

  vec3 toPlane = centre - world;
  float denom = dot(mirror, n);
  vec3 onPlane;
  // Parallel to the panel, or pointing away from it: the ray never lands, so
  // the nearest thing to it is the centre projected back, which keeps the
  // highlight on the panel instead of sending it to infinity.
  if (abs(denom) < 1e-5) {
    onPlane = toPlane - n * dot(toPlane, n);
  } else {
    float t = dot(toPlane, n) / denom;
    onPlane = t > 0.0 ? mirror * t : toPlane - n * dot(toPlane, n);
  }

  // Clamped into the rectangle in its own axes. Dividing by the squared length
  // turns a projection into a coordinate in units of the half-extent, so the
  // clamp is against one either way round.
  vec3 offset = onPlane - toPlane;
  float wLen2 = max(dot(halfWidth, halfWidth), 1e-12);
  float hLen2 = max(dot(halfHeight, halfHeight), 1e-12);
  float u = clamp(dot(offset, halfWidth) / wLen2, -1.0, 1.0);
  float v = clamp(dot(offset, halfHeight) / hLen2, -1.0, 1.0);
  return centre + halfWidth * u + halfHeight * v;
}

#ifdef F3D_LTC
// --- lib/ltc.glsl ---
// The GGX lobe over a rectangle light, by linearly transformed cosines — `L7`.
//
// Heitz, Dupuy, Hill and Neubelt, "Real-Time Polygonal-Light Shading with
// Linearly Transformed Cosines", ACM TOG 35(4), 2016. The fitted tables are
// `EngineTables.ltc`; see `tables/ltc.dart` for their layout and licence.
//
// A model that wants it defines `F3D_LTC` before including `surface.glsl`,
// which is what gives its stage the one sampler below. Every other model
// keeps the representative point, and no sampler.

#ifndef LTC_GLSL_
#define LTC_GLSL_

/// Both tables, 64 × 128: the inverse matrices above, the norms, Fresnel
/// terms and sphere form factors below.
uniform sampler2D ltc_texture;

/// Where `(x, y)`, each nought to one, lands in the table starting at
/// [table] (nought the upper, one the lower): on texel centres, so the ends of
/// the range read the first and last entries rather than half of the
/// neighbour.
vec2 LtcUv(float x, float y, float table) {
  vec2 inTable = vec2(x, y) * (63.0 / 64.0) + 0.5 / 64.0;
  return vec2(inTable.x, (inTable.y + table) * 0.5);
}

/// One edge's share of the vector form factor, from [a] to [b], unit
/// directions: the angle between them along the normal of their plane,
/// over 2π. Exact, with the `acos` clamped for the reason
/// `RectangleFormFactor` gives.
vec3 LtcEdge(vec3 a, vec3 b) {
  vec3 axis = cross(a, b);
  float len = length(axis);
  float angle = acos(clamp(dot(a, b), -1.0, 1.0));
  return len > 1e-6 ? axis * (angle / (len * 6.2831853)) : vec3(0.0);
}

/// The GGX lobe of roughness [roughness] seen along [v] from normal [n],
/// integrated over the rectangle with corners [corners] (relative to the
/// shading point, wound as `SampleLight` winds them), with the fitted
/// Fresnel pair for that lobe: x the integral, y the norm, z the Fresnel
/// term. The specular is `x · (f0 · y + (1 − f0) · z)`.
///
/// Clipped to the horizon by the sphere table rather than by cutting the
/// polygon: the vector form factor's length and elevation name a sphere
/// with the same, and the table holds how much of that sphere's clamped
/// cosine lies above the horizon.
///
/// Says nothing about which face of the panel the point is on: the vector
/// form factor points the same way in the world from either side, so this is
/// as bright behind the panel as in front of it. `SampleLight` tests the side
/// and leaves a point behind unlit before this is asked.
vec3 LtcRectangle(vec3 n, vec3 v, float roughness, vec3 corners[4]) {
  vec2 uv = vec2(clamp(roughness, 0.0, 1.0),
                 sqrt(clamp(1.0 - dot(n, v), 0.0, 1.0)));
  vec4 inverse = textureLod(ltc_texture, LtcUv(uv.x, uv.y, 0.0), 0.0);
  vec4 fit = textureLod(ltc_texture, LtcUv(uv.x, uv.y, 1.0), 0.0);

  // The frame the fit was made in: the normal up, the view in the xz plane.
  // A view along the normal has no plane of its own, and any will do.
  vec3 along = v - n * dot(v, n);
  float alongLength = length(along);
  vec3 t1 = alongLength > 1e-5
                ? along / alongLength
                : normalize(cross(n, abs(n.z) < 0.999 ? vec3(0.0, 0.0, 1.0)
                                                      : vec3(1.0, 0.0, 0.0)));
  vec3 t2 = cross(n, t1);
  mat3 minv = mat3(vec3(inverse.x, 0.0, inverse.y), vec3(0.0, 1.0, 0.0),
                   vec3(inverse.z, 0.0, inverse.w));

  vec3 l[4];
  for (int i = 0; i < 4; i++) {
    vec3 p = corners[i];
    l[i] = normalize(minv * vec3(dot(p, t1), dot(p, t2), dot(p, n)));
  }
  // Negated, for `RectangleFormFactor`'s reason: the panel emits along
  // `cross(halfWidth, halfHeight)`, and seen from there these corners run
  // clockwise.
  vec3 f = -(LtcEdge(l[0], l[1]) + LtcEdge(l[1], l[2]) +
             LtcEdge(l[2], l[3]) + LtcEdge(l[3], l[0]));
  float len = length(f);
  float z = len > 1e-9 ? f.z / len : 0.0;
  float sphere =
      textureLod(ltc_texture, LtcUv(z * 0.5 + 0.5, clamp(len, 0.0, 1.0), 1.0),
                 0.0)
          .w;
  return vec3(max(len * sphere, 0.0), fit.x, fit.y);
}

#endif  // LTC_GLSL_


#ifdef F3D_LAYERED
/// The corners of the rectangle [SampleLight] resolved last, relative to the
/// shading point — `M1`. The clear coat integrates its own lobe over the same
/// panel with its own normal and roughness, and those live in `pbr.glsl`,
/// after this file; the loop shades each light straight after sampling it,
/// so this is always the light being shaded.
vec3 g_rect_corners[4];
#endif  // F3D_LAYERED
#endif  // F3D_LTC

/// Resolves light [index] against the surface.
///
/// Returns `n_dot_l == 0` for anything that contributes nothing — behind the
/// surface, out of range, outside the spot cone, the dark face of a panel — so
/// a model can skip it with one test instead of repeating the classification.
LightSample SampleLight(int index, Surface s) {
  LightSample light;
  light.integrated = 0.0;
  light.ltc = vec3(0.0);

  vec4 position;
  vec4 color;
  vec4 direction;
  vec4 cone;
  if (index < kMaxLights) {
    position = frag_info.light_position[index];
    color = frag_info.light_color[index];
    direction = frag_info.light_direction[index];
    cone = frag_info.light_cone[index];
  } else {
#ifdef F3D_NO_LIGHT_LIST
    // Unreachable: `LightCount` stops at the slots without a list.
    position = vec4(0.0);
    color = vec4(0.0);
    direction = vec4(0.0);
    cone = vec4(0.0);
#else
    // A row of the light list — `gfx-74n`. Sampled at texel centres so a
    // driver's rounding cannot land a fetch on a neighbour, and the four texels
    // across the row are the same four vectors the arrays above hold.
    int slot = index - kMaxLights;
    // `L6`: from the cell rather than the draw's own tail, and a light the
    // slots already hold is skipped by its intensity, as a faded one is.
    bool clustered = Clustered();
    float listRow = clustered ? ClusterRow(slot) : LightListRow(slot);
    float v = (listRow + 0.5) * light_list_info.list.z;
    float u = light_list_info.list.y;
    // `textureLod` and not `texture`, for `shadow.glsl`'s own reason: `index`
    // reaches this branch through a function parameter, so a WGSL backend
    // cannot see that every invocation of a draw walks the same light count
    // and refuses the implicit derivative as possibly non-uniform. The atlas
    // has one level, so naming it directly changes no pixel.
    position = textureLod(light_list_texture, vec2(0.5 * u, v), 0.0);
    color = textureLod(light_list_texture, vec2(1.5 * u, v), 0.0);
    direction = textureLod(light_list_texture, vec2(2.5 * u, v), 0.0);
    cone = textureLod(light_list_texture, vec2(3.5 * u, v), 0.0);
    // The intensity and not the colour, for `LightBuffer._pack`'s own reason:
    // the same multiply here, and only one of them is a number nobody authored.
    color.w *= clustered ? (InSlots(listRow) ? 0.0 : 1.0) : LightListScale(slot);
#endif  // F3D_NO_LIGHT_LIST
  }

  float type = position.w;

  // **The rectangle leaves before `aim` is taken — `gfx-77n`.** For every other
  // kind `direction.xyz` is a unit vector saying which way the light points;
  // for this one it is an edge of the panel, with its length carrying half the
  // width, and normalising it here would quietly throw the size away.
  if (type > 2.5) {
    vec3 halfWidth = direction.xyz;
    vec3 halfHeight = cone.xyz;
    vec3 toCentre = position.xyz - v_world_position;

    vec3 corners[4];
    corners[0] = toCentre - halfWidth - halfHeight;
    corners[1] = toCentre + halfWidth - halfHeight;
    corners[2] = toCentre + halfWidth + halfHeight;
    corners[3] = toCentre - halfWidth + halfHeight;

    // **The panel emits from one face only**, and a point on the other side
    // gets nothing: the room above a ceiling panel, the outside of the wall a
    // window is set in. Tested here rather than left to the signs below,
    // because the specular's vector form factor keeps the same orientation
    // from either side of the panel, so a surface behind it facing away read
    // as lit as one in front facing it.
    bool behind = dot(toCentre, cross(halfWidth, halfHeight)) >= 0.0;

    // The cosine-weighted solid angle, which takes the place `n · l` holds for
    // a punctual light: the loop multiplies the shading by `n_dot_l`, so
    // putting the exact integral here makes the diffuse term exact rather than
    // sampled. See [RectangleFormFactor].
    float formFactor = behind ? 0.0 : RectangleFormFactor(corners, s.n);

    // Radiance rather than intensity: `intensity` means the same thing for
    // every kind of light, so a panel's is spread over its own area here.
    // Enlarging a window at a fixed rating then dims it per square metre and
    // leaves the room as bright, which is what the number is supposed to mean.
    float area = length(cross(halfWidth, halfHeight)) * 4.0;
    float radiance = area > 1e-9 ? 1.0 / area : 0.0;

    // The range window only. A punctual light needs the inverse square as
    // well; the form factor already contains it, because a panel twice as far
    // away subtends a quarter of the sky.
    float distance = length(toCentre);
    if (direction.w > 0.0) {
      float ratio = distance / direction.w;
      float window = clamp(1.0 - ratio * ratio * ratio * ratio, 0.0, 1.0);
      radiance *= window * window;
    }

    vec3 mirror = reflect(-s.v, s.n);
    vec3 representative = RectangleClosestPoint(
        position.xyz, halfWidth, halfHeight, v_world_position, mirror);
    vec3 toPoint = representative - v_world_position;
    float pointDistance = length(toPoint);
    light.l = pointDistance > 1e-6 ? toPoint / pointDistance : s.n;

    light.h = normalize(light.l + s.v);
    light.n_dot_l = formFactor;
    light.n_dot_h = max(dot(s.n, light.h), 0.0);
    light.v_dot_h = max(dot(s.v, light.h), 0.0);
    light.radiance = color.rgb * color.w * radiance;
#ifdef F3D_LTC
    // `L7`: the specular over the whole panel rather than at one point of
    // it. The diffuse keeps the exact form factor above.
    light.integrated = 1.0;
    light.ltc = LtcRectangle(s.n, s.v, s.roughness, corners);
#ifdef F3D_LAYERED
    // Kept for the clear coat's own integral; see [g_rect_corners].
    g_rect_corners = corners;
#endif
#endif
    return light;
  }

  vec3 aim = normalize(direction.xyz);
  float attenuation = 1.0;

  if (type < 0.5) {
    // Directional: no position, no falloff. The direction to the light is the
    // reverse of the direction it points.
    light.l = -aim;
  } else {
    vec3 toLight = position.xyz - v_world_position;
    float distance = length(toLight);
    // A light exactly on the surface has no direction; treat it as contributing
    // nothing rather than producing a NaN that spreads through the frame.
    if (distance < 1e-6) {
      light.l = s.n;
      light.h = s.n;
      light.radiance = vec3(0.0);
      light.n_dot_l = 0.0;
      light.n_dot_h = 0.0;
      light.v_dot_h = 0.0;
      return light;
    }
    light.l = toLight / distance;
    attenuation = PunctualAttenuation(distance, direction.w);

    if (type > 1.5) {
      // Spot: a smooth ramp between the two cone cosines. The Dart side already
      // guarantees the denominator is non-zero.
      float cosAngle = dot(aim, -light.l);
      attenuation *= clamp(
          (cosAngle - cone.y) / (cone.x - cone.y), 0.0, 1.0);
    }
  }

  light.h = normalize(light.l + s.v);
  light.n_dot_l = max(dot(s.n, light.l), 0.0);
  light.n_dot_h = max(dot(s.n, light.h), 0.0);
  light.v_dot_h = max(dot(s.v, light.h), 0.0);
  light.radiance = color.rgb * color.w * attenuation;

  return light;
}

/// How much of light [index] reaches this fragment, defined by each fragment
/// shader.
///
/// A prototype rather than a call into shadow.glsl, because the models that
/// sample no shadow map must not declare its sampler — the compiler would drop
/// the slot and leave the engine binding one that is not there. A lit model
/// returns `ShadowFactor(...)`; an unlit one returns 1.
float LightVisibility(Surface s, LightSample light, int index);

/// A model's per-light term, defined by each fragment shader.
///
/// A prototype here and the definition in the model is what lets the loop below
/// be written once. The alternative — repeating the loop in every model — is
/// six copies of the same three lines, and the place a light would go missing.
vec3 ShadeLight(Surface s, LightSample light);

/// Sums every active light's contribution.
///
/// The loop bound is the compile-time maximum with a runtime break, because GLSL
/// wants a constant trip count and the hardware wants the early exit.
// **The point-shadow half of this header, behind a guard.**
//
// A model that never shadows must not *declare* any of this, and the reason is
// the one `unlit.frag` already gives about the shadow sampler — with one
// backend's failure added to the other's. On Impeller the compiler drops what
// nothing reads, and the engine binding a slot that is no longer there is a
// native crash. On WebGL2 nothing is dropped: an active uniform block with no
// buffer under it makes every draw `INVALID_OPERATION`, discarded with nothing
// logged.
//
// That is what `lighting-unlit` was on this backend. Unlit's own metadata says
// `usesPointShadow` is false, so the engine correctly bound no `PointShadow`
// block — and the translated shader declared one anyway, so the sphere was
// never drawn and the frame came back the clear colour.
#ifndef F3D_NO_POINT_SHADOW

/// The cube atlas: three tiles across, two down, each a ninety-degree view
/// from a point light, each storing radial distance normalised by range.
uniform sampler2D point_shadow_texture;

/// The same atlas for the things that never move, rendered once at load.
///
/// Two maps rather than one because a dungeon's walls can be baked and a
/// spinning pickup cannot, and there is no way to draw into part of a texture
/// without redrawing the rest of it. Sampling both and keeping the nearer
/// occluder costs one extra read and saves six views of the level every frame.
uniform sampler2D point_shadow_static_texture;

/// How many lights may have a row of the atlas. Six tiles across each.
// Rows of the cube atlas: six faces across, this many lights down. Must
// match `Renderer.kShadowedLights`, which is where the reasoning lives, and
// `shadowSlots` in the software backend's transcription of this file.
const int kShadowSlots = 6;

layout(std140) uniform PointShadow {
  /// The same view-projections the atlas was rendered with, six per slot.
  ///
  /// Passed rather than reconstructed. Deriving cube face coordinates here
  /// would be a second implementation of a decision the renderer already made,
  /// and the two would disagree about handedness or up vectors on some face
  /// and nowhere else — which shows as one face of every shadow being wrong.
  mat4 faces[6 * kShadowSlots];

  /// Per slot. xyz: the light's world position. w: its range.
  vec4 lights[kShadowSlots];

  /// Per light, in the order the lighting knows them.
  ///
  /// x: the atlas row it owns, or negative when it has none — a fifth torch in
  /// a room lands there. z: the tangent of half the frustum's opening angle,
  /// which is what converts a world width into a fraction of a tile. y and w
  /// are unwritten.
  ///
  /// **z is exactly one for a point light**, because a cube face is a ninety
  /// degree frustum and `tan(45°) == 1`. That is not a convention chosen to be
  /// tidy: it is what lets a narrower frustum share this whole path, since
  /// multiplying by one in IEEE 754 changes no bit of the result. Whatever else
  /// a spot light will need, it does not need a second copy of the filter.
  vec4 slots[kMaxLights];

  /// x: half a texel, in tile-local uv. y: distance bias in metres.
  /// z: strength. w: normal offset, **in texels of the face it lands on**.
  vec4 params;

  /// x: smallest kernel radius in tile-local uv, and the fixed radius used
  /// when contact hardening is off. y: the light's own radius in metres; zero
  /// turns contact hardening off. z: largest kernel radius in tile-local uv.
  /// w: non-zero paints the penumbra estimate into the surface buffer instead
  /// of shading with it.
  vec4 params2;

  /// x: non-zero when this backend stores the atlas bottom-up. y: one over the
  /// edge length of a tile in texels, which is what turns a distance into the
  /// world width of one texel there.
  ///
  /// **Appended after everything else on purpose**, the same way FragInfo's
  /// ambient pair was: std140 lays a block out in declaration order, so adding
  /// here leaves every offset above unchanged and the three backends do not
  /// have to agree about anything they already agreed about. y, z and w are
  /// unwritten.
  vec4 params3;
}
point_shadow;

/// Eight points on a Poisson disk, a common set for filtering cascaded
/// shadows.
///
/// A disk rather than a grid because a grid of taps on a straight shadow edge
/// lands every sample on the same side at once, and the edge steps between
/// kernel widths instead of sliding. Eight rather than sixteen because every
/// tap here reads **two** atlases — the static walls and the movers — so the
/// cost is doubled before it is counted.
vec2 PointShadowDiskTap(int i) {
  if (i == 0) return vec2(-0.94201624, -0.39906216);
  if (i == 1) return vec2(0.94558609, -0.76890725);
  if (i == 2) return vec2(-0.09418410, -0.92938870);
  if (i == 3) return vec2(0.34495938, 0.29387760);
  if (i == 4) return vec2(-0.91588581, 0.45771432);
  if (i == 5) return vec2(-0.81544232, -0.87912464);
  if (i == 6) return vec2(-0.38277543, 0.27676845);
  return vec2(0.97484398, 0.75648379);
}

/// One comparison against the atlas, at [uv] offset within the tile.
///
/// The clamp is applied **after** the offset, not before, and that is the whole
/// reason a kernel can be widened here without touching anything else: each tap
/// is held inside its own tile individually. Clamping the centre and then
/// offsetting would let the outer taps walk straight out of the tile and read a
/// distance measured from a different face, or a different light.
float PointShadowDistance(vec2 uv, vec2 offset, vec2 tile, float range) {
  float inset = point_shadow.params.x;
  vec2 local = clamp(uv + offset, inset, 1.0 - inset);
  vec2 atlas = (local + tile) * vec2(1.0 / 6.0, 1.0 / float(kShadowSlots));
  // **The whole atlas, turned over, where row zero of a render target is at the
  // bottom.** Both halves of the address are wrong there and this is the one
  // place that fixes both: the tile the light owns — a light in slot zero is
  // drawn into the row the shader would call three, because the viewport
  // rectangle is flipped to land it — and the picture inside that tile, which
  // was drawn through a projection built for the other origin.
  //
  // Every check of this atlas missed it for the same reason: the debug view
  // composites the texture through a full-screen pass, which turns it over
  // again and puts the row back. The atlas compared equal on both backends
  // across six scenes while the lit pass, which samples it directly and has no
  // such pass to cancel, read a row that had never been drawn into and found
  // nothing in the way of anything.
  if (point_shadow.params3.x > 0.5) atlas.y = 1.0 - atlas.y;
  // Whichever is nearer occludes: a wall in front of a monster shadows, and so
  // does a monster in front of a wall.
  //
  // **`textureLod` at level zero, because every caller of this function stands
  // behind a branch.** The light loop skips a light the surface faces away
  // from, the blocker search `continue`s past a tap that found nothing, and the
  // slot test returns before any of it — so the invocations of a quad do not
  // arrive here together, and a WGSL backend refuses a sample whose implicit
  // derivative would be read where they disagree. Both atlases are distance
  // render targets with one level, so level zero is the level `texture` was
  // choosing anyway; this names it rather than deriving it, and the picture is
  // the same on every backend.
  return min(textureLod(point_shadow_texture, atlas, 0.0).r,
             textureLod(point_shadow_static_texture, atlas, 0.0).r) * range;
}

float PointShadowTap(vec2 uv, vec2 offset, vec2 tile, float range,
                     float receiver) {
  float stored = PointShadowDistance(uv, offset, tile, range);
  // Nothing was drawn in that direction by either, so nothing is in the way.
  if (stored >= range * 0.999) return 1.0;
  return receiver > stored ? 0.0 : 1.0;
}

/// The disk point for tap [i], rotated by [ca]/[sa] and scaled to [radius].
vec2 PointShadowOffset(int i, float ca, float sa, float radius) {
  vec2 p = PointShadowDiskTap(i);
  return vec2(p.x * ca - p.y * sa, p.x * sa + p.y * ca) * radius;
}

/// How wide the penumbra should be here, in tile-local uv.
///
/// Contact hardening, and the reason a fixed kernel looks wrong: a shadow is
/// sharp where its caster touches the floor and soft a metre away, and one
/// radius for both makes the contact mushy or the distant edge hard.
///
/// The similar-triangles estimate is the standard one — a light of radius `L`
/// with a blocker at `b` and a receiver at `r` throws a penumbra `L * (r - b) /
/// b` wide at the receiver. Converting that to tile uv is exact rather than
/// tuned, because a face is a ninety degree frustum: at distance `r` from the
/// light the face spans `2 * r` in world units across the full `0..1` of uv,
/// so a world width `w` is `w / (2 * r)` of a tile.
///
/// The blocker search runs at the **widest** penumbra allowed, since a blocker
/// outside that circle cannot widen the result anyway, and searching narrower
/// would miss the very blockers that make an edge soft.
///
/// [tanHalf] is where the ninety degrees stop being assumed. The span above is
/// `2 * r` only for a right-angled frustum; in general it is `2 * r * tan(θ/2)`,
/// and for a cube face that factor is one. A narrower frustum covers less world
/// per tile, so the same world width is a *larger* fraction of it — which is
/// why this divides rather than multiplies, and why getting it upside down
/// would make a tight cone's shadows harden instead of soften.
float PointShadowPenumbra(vec2 uv, vec2 tile, float range, float receiver,
                          float ca, float sa, float tanHalf,
                          out float blockerOut) {
  blockerOut = -1.0;
  float lightRadius = point_shadow.params2.y;
  float minRadius = point_shadow.params2.x;
  float maxRadius = point_shadow.params2.z;
  if (lightRadius <= 0.0) {
    // **The debug channel is filled even though the search is skipped**, and
    // leaving it unfilled cost a session. `blockerOut` starts at −1 to mean
    // "nothing was measured"; the debug encoding clamps it into a colour, where
    // −1 becomes zero — the same green as a blocker touching the surface, which
    // reads as the most alarming answer available. A whole theory was built on
    // that zero, and the search it described had never run.
    //
    // The centre tap is what the filter below would use anyway, so this reports
    // a distance the atlas really returned rather than a sentinel.
    blockerOut = PointShadowDistance(uv, vec2(0.0), tile, range);
    return minRadius;
  }


  float sum = 0.0;
  float count = 0.0;
  for (int i = 0; i < 8; i++) {
    float stored =
        PointShadowDistance(uv, PointShadowOffset(i, ca, sa, maxRadius), tile,
                            range);
    if (stored >= range * 0.999) continue;
    if (stored >= receiver) continue;
    sum += stored;
    count += 1.0;
  }
  // Nothing in front of this fragment anywhere in the search: fully lit, and
  // the caller can skip the filter entirely.
  if (count < 0.5) return -1.0;

  float blocker = max(sum / count, 1e-4);
  blockerOut = blocker;
  float world = lightRadius * max(receiver - blocker, 0.0) / blocker;
  return clamp(world / (2.0 * receiver * tanHalf), minRadius, maxRadius);
}

/// How lit [world] is by the point light that owns the cube atlas.
///
/// One, fully lit, when this is not that light or the atlas has nothing to say.
float PointShadowFactor(vec3 world, vec3 normal, int lightIndex) {
  int slot = int(point_shadow.slots[lightIndex].x + 0.5);
  if (point_shadow.slots[lightIndex].x < 0.0) return 1.0;
  float strength = point_shadow.params.z;
  if (strength <= 0.0) return 1.0;

  // Offset along the normal before measuring, and scaled by how steeply the
  // surface leans away from the light.
  //
  // A soft kernel on a tilted surface straddles a depth gradient: the taps at
  // one end of the disk are further from the light than the fragment itself,
  // so a flat offset that clears the surface head-on leaves acne at a grazing
  // angle. The slope term lifts the whole kernel clear instead, and is capped
  // because it runs away as the surface turns edge-on to the light — an
  // uncapped lift detaches the shadow from its caster.
  vec3 toLight = point_shadow.lights[slot].xyz - world;
  float toLightLength = max(length(toLight), 1e-6);
  float nDotL = max(dot(normal, toLight / toLightLength), 0.15);
  float slope = min(sqrt(max(1.0 - nDotL * nDotL, 0.0)) / (nDotL * nDotL), 8.0);

  // **How wide one texel of the face is, out where this fragment is.** The
  // error a normal offset exists to clear is exactly that: a texel of the
  // shadow map covers a patch of surface, the whole patch is recorded at one
  // distance, and a fragment anywhere else in it compares against a distance
  // measured somewhere it is not. That patch grows with range — it is a solid
  // angle, not a length — so an offset fixed in metres is right at one distance
  // and wrong everywhere else.
  //
  // What it was: `params.w` metres, flat. On the golden teapot, at 9.6 m from
  // the lamp, a texel is 3.7 cm and the flat offset was 2 cm, so the floor
  // shadowed itself across everything the light reached — and the acne stopped
  // dead at the *projection of the floor's own edge*, because past it the atlas
  // holds nothing and nothing can occlude. A straight line across a shadow with
  // no straight edge anywhere in the scene.
  float texel =
      2.0 * toLightLength * max(point_shadow.slots[lightIndex].z, 1e-4) *
      point_shadow.params3.y;
  // Both terms are metres. The slope term used to be the kernel radius, which
  // is a fraction of a tile — a unit mismatch carried over from an estimate
  // where a softness radius genuinely was the right quantity. Here it meant
  // widening the kernel also lifted the sample off the surface, by up to ten
  // centimetres at the wider settings, so the softening and the lift
  // cancelled: tripling the kernel moved 184 pixels of the frame,
  // where the kernel alone moves thousands. It is what made contact hardening
  // look inert, and it was hiding in a comparison rather than in the estimate.
  vec3 origin = world + normal * texel * point_shadow.params.w * (1.0 + slope);
  vec3 toFragment = origin - point_shadow.lights[slot].xyz;
  float distance = length(toFragment);
  float range = max(point_shadow.lights[slot].w, 1e-4);
  if (distance >= range) return 1.0;

  // The dominant axis picks the face, in the order the renderer wrote them:
  // +X, -X, +Y, -Y, +Z, -Z, left to right then top to bottom.
  //
  // A spot has one column and no choice to make. Asking the dominant axis
  // anyway would be worse than pointless: a fragment below and to the side of
  // a downlight has −Y dominant, which is column 3, and column 3 of a spot's
  // row is deliberately blank — so the whole cone would read as unshadowed
  // except for the wedge where the aim happens to be the dominant axis.
  int face = 0;
  if (point_shadow.slots[lightIndex].y < 0.5) {
    vec3 a = abs(toFragment);
    if (a.x >= a.y && a.x >= a.z) {
      face = toFragment.x > 0.0 ? 0 : 1;
    } else if (a.y >= a.z) {
      face = toFragment.y > 0.0 ? 2 : 3;
    } else {
      face = toFragment.z > 0.0 ? 4 : 5;
    }
  }

  vec4 clip = point_shadow.faces[slot * 6 + face] * vec4(origin, 1.0);
  if (clip.w <= 0.0) return 1.0;
  vec2 ndc = clip.xy / clip.w;
  if (abs(ndc.x) > 1.0 || abs(ndc.y) > 1.0) return 1.0;

  // v is flipped, the same way the directional map does it: the texture's
  // origin is at the top, where row zero of the render target is. Getting this
  // wrong does not tilt the shadow — it makes the top row of faces read the
  // bottom row, so a whole region compares against an unrelated distance and
  // comes out as a black slab.
  vec2 uv = vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
  // The face across, the light down: six tiles wide, four tall.
  vec2 tile = vec2(float(face), float(slot));

  float receiver = distance - point_shadow.params.y;

  // One rotation, shared by the blocker search and the filter. Per fragment,
  // so eight samples read as a soft edge rather than as eight copies of the
  // silhouette: without it every fragment along an edge tests the same eight
  // directions and the pattern shows.
  //
  // **The three constants are not arbitrary and are not ours.** This is Jorge
  // Jimenez's interleaved gradient noise, from "Next Generation Post
  // Processing in Call of Duty: Advanced Warfare" (SIGGRAPH 2014):
  //
  //   IGN(x, y) = frac(52.9829189 * frac(0.06711056 * x + 0.00583715 * y))
  //
  // The pair inside the dot is a direction whose gradient walks the unit
  // interval as slowly as it can while never repeating over a screen, and the
  // multiplier outside stretches that walk so neighbouring pixels land far
  // apart in the result. What it buys over a hash is the cost: one dot and two
  // fracts, no integer arithmetic, no texture. What a blue-noise texture buys
  // over it is a better spectrum, at a sampler and a fetch — worth it for
  // dithering a whole frame, not for rotating eight taps.
  //
  // Written down because three unexplained decimals read as a magic spell, and
  // the next person to touch this line has no way to tell which of them may be
  // changed. The answer is none of them.
  float noise = fract(52.9829189 * fract(dot(FragCoordFromTop(
                                                frag_info.target_origin.x),
                                            vec2(0.06711056, 0.00583715))));
  float angle = noise * 6.28318530718;
  float ca = cos(angle);
  float sa = sin(angle);

  // Guarded rather than read straight, because a zero here divides by zero and
  // a NaN radius poisons the filter into a black fragment. Zero is what an
  // unwritten channel holds, and "unwritten" is a state this block has been in
  // before: every slot is cleared to −1 each frame.
  float tanHalf = max(point_shadow.slots[lightIndex].z, 1e-4);

  float blocker = -1.0;
  float radius =
      PointShadowPenumbra(uv, tile, range, receiver, ca, sa, tanHalf, blocker);

  // The debug channel, and the reason it exists: two explanations for why the
  // estimate collapses were argued from the finished picture and both were
  // wrong, because the number that decides it never leaves this function.
  //
  // Red is how wide the penumbra came out, against the widest allowed. Green
  // is how far away the blocker was, against the light's range. Blue marks
  // the fragments where the search found nothing at all — which is a different
  // answer from "found something very close", and telling those two apart is
  // most of the question.
  if (point_shadow.params2.w > 0.5) {
    g_debug_surface_on = true;
    g_debug_surface = radius < 0.0
        ? vec3(0.0, 0.0, 1.0)
        : vec3(clamp(radius / max(point_shadow.params2.z, 1e-6), 0.0, 1.0),
               clamp(blocker / range, 0.0, 1.0), 0.0);
  }

  // The search found nothing between here and the light.
  if (radius < 0.0) return 1.0;

  float lit = PointShadowTap(uv, vec2(0.0), tile, range, receiver);
  if (radius > 0.0) {
    for (int i = 0; i < 8; i++) {
      lit += PointShadowTap(uv, PointShadowOffset(i, ca, sa, radius), tile,
                            range, receiver);
    }
    lit *= 1.0 / 9.0;
  }

  // Strength lerps towards fully lit, so the control is "how dark", not "how
  // much of the kernel" — the same convention the directional map uses.
  return mix(1.0, lit, clamp(strength, 0.0, 1.0));
}

#else

/// The stand-in for a model that declares none of the above.
///
/// Fully lit, which is what a model with no shadow term means, and a constant
/// the compiler folds rather than a branch anything pays for.
float PointShadowFactor(vec3 world, vec3 normal, int lightIndex) {
  return 1.0;
}

#endif  // F3D_NO_POINT_SHADOW

vec3 AccumulateLights(Surface s) {
  vec3 total = vec3(0.0);
  int count = LightCount();

  for (int i = 0; i < kTotalLights; i++) {
    if (i >= count) break;
    LightSample light = SampleLight(i, s);
    if (light.n_dot_l <= 0.0) continue;
    // A light from the list has no shadow row to read — see `LightHasShadow`.
    // A branch rather than something folded into the two calls, because both
    // index tables eight entries wide and the ninth light would read past them
    // rather than read a one.
    float visibility = LightHasShadow(i)
        ? LightVisibility(s, light, i) *
              PointShadowFactor(v_world_position, s.n, i)
        : 1.0;
    if (visibility <= 0.0) continue;
    total += ShadeLight(s, light) * light.radiance * light.n_dot_l * visibility;
  }

  return total;
}

#endif  // SURFACE_GLSL_


// Never called, for the reason `unlit.frag` gives: nothing here accumulates
// lights, and the prototypes in surface.glsl still have to be satisfied.
vec3 ShadeLight(Surface s, LightSample light) {
  return s.albedo;
}

float LightVisibility(Surface s, LightSample light, int index) {
  return 1.0;
}

void main() {
  Surface s = ReadSurface();
  // The same call unlit makes, and it behaves the same way but for the guarded
  // half: colour into the HDR target, and `WriteSurfaceGeometry` compiled away
  // to nothing. The stage binds no fog, so `ApplyFog` is the identity here.
  WriteSurface(s.albedo, s.alpha);
}

''',
    'Lambert': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// Pure diffuse. The cheapest model that still reads as three-dimensional, and
// the reference point for judging whether the fancier models are worth their
// cost on a given target.
// --- lib/material_maps.glsl ---
// The texture maps a lit material can carry, beyond base colour.
//
// A separate header from surface.glsl on purpose. Declaring a sampler a shader
// never reads is the same trap as declaring an unused uniform block: the
// compiled function has no such slot, while the Dart side still has metadata
// saying it does. Unlit and the debug models include surface.glsl (or only
// color.glsl) and get none of this; the lit models include both, and
// LightingModel.usesMaterialTextures says which is which.
//
// Every map has a *neutral* fallback texture bound when the material has none,
// so there are no "has this map" flags to keep in sync — a white ORM texture
// multiplies the factors by one, and a flat normal map perturbs nothing. Flags
// would have to be right in two places; a neutral texel is right by
// construction.

#ifndef MATERIAL_MAPS_GLSL_
#define MATERIAL_MAPS_GLSL_

// --- lib/surface.glsl ---
// Shared material and lighting interface for the lighting models.
//
// flutter_gpu compiles shaders ahead of time into a bundle: there is no runtime
// compilation, so a node-graph material system assembled at run time is
// impossible. Each lighting model is therefore
// its own pre-built fragment shader, and this header is what keeps them
// interchangeable — one identical uniform block, so the Dart binding code never
// needs to know which model is active.
//
// Keep every declaration below byte-identical across models. A member a model
// does not read may be optimized out of the reflected block, which is why the
// Dart side skips absent members instead of failing.
//
// Only include this from a shader that actually reads FragInfo. Declaring the
// block without using it leaves it visible to reflection while the compiled
// shader binds no buffer for it, and binding that phantom block segfaults
// inside Metal. Shaders needing only colour helpers include lib/color.glsl.

#ifndef SURFACE_GLSL_
#define SURFACE_GLSL_

// --- lib/color.glsl ---
// Colour space helpers and the fragment output interface.
//
// Split out of surface.glsl so a shader that needs no material inputs — the
// normals debug view — can avoid DECLARING the FragInfo uniform block at all.
// That matters more than it looks: reflection metadata reports a block as
// present merely because it was declared, even when the compiled shader binds
// no such buffer, so a declared-but-unused block is indistinguishable from a
// used one until Metal crashes on the bind.

#ifndef COLOR_GLSL_
#define COLOR_GLSL_

precision highp float;

const float kPi = 3.14159265359;

// One varying set shared by every fragment shader, matching mesh.vert.
//
// All five are declared here, including the two the debug models never read: a
// fragment shader whose `in` block disagrees with the vertex shader's `out`
// block fails to link, and there is no partial-match rule to lean on.
in vec3 v_world_position;
in vec3 v_normal;
in vec2 v_texcoord;
in vec4 v_tangent;
in vec4 v_color;

/// Where this fragment is in the level's lightmap. Zero from every vertex
/// stage but `mesh_lightmapped.vert`, and read only by the lit models, which
/// sample a one-texel black there when a material has no map.
in vec2 v_lightmap_uv;

layout(location = 0) out vec4 frag_color;

// The second attachment: what a screen-space effect needs to know about the
// surface it is looking at. World-space normal in rgb, and in a the depth along
// the view axis in world metres — not a window depth; `WriteSurfaceGeometry`
// says at length why not.
//
// Depth travels here rather than in a depth texture because flutter_gpu cannot
// sample one — the same reason the shadow pass writes its depth into a colour
// target. See ARCHITECTURE.md §2.
//
// Guarded, because not every stage that includes this header draws into a
// two-attachment target. The shadow pass draws into one, and a pipeline
// declaring an output its target has no slot for is a mismatch worth avoiding
// rather than discovering.
#ifndef F3D_NO_SURFACE_BUFFER
layout(location = 1) out vec4 frag_surface;

/// The surface's own colour, sRGB-encoded, alpha one where a surface was
/// drawn — `L5`. The third attachment, present only when a pass reads it (the
/// indirect light does) and the device opens three; like the surface buffer,
/// written unconditionally and discarded when absent. Stored in the surface
/// buffer's format rather than eight bits a channel, and `Renderer` says why.
layout(location = 2) out vec4 frag_albedo;
#endif

/// What [frag_albedo] carries: the lit models set it in `ReadSurface`, and a
/// stage that reflects nothing — unlit, the debug views — leaves it black,
/// which is what light bounced onto it would come to.
vec3 g_albedo = vec3(0.0);

/// Octahedral encoding: a unit vector in two channels instead of three.
///
/// Worth the arithmetic because the fourth channel is already spent on depth,
/// and without a free channel there is nowhere to put roughness — which is the
/// difference between a reflection that knows stone from a mirror and one that
/// does not. The error is well under a degree, far below anything a reflection
/// off rough stone would show.
vec2 EncodeOctahedral(vec3 n) {
  n /= abs(n.x) + abs(n.y) + abs(n.z);
  vec2 e = n.xy;
  if (n.z < 0.0) {
    e = (1.0 - abs(n.yx)) * vec2(n.x >= 0.0 ? 1.0 : -1.0,
                                 n.y >= 0.0 ? 1.0 : -1.0);
  }
  return e * 0.5 + 0.5;
}

/// Where a debug pass leaves the picture it wants shown instead of the normal.
///
/// Declared here, in the header every lit shader includes **first**, and
/// written from surface.glsl, which is included after. The alternative was a
/// new member on a shared uniform block; a global costs nothing and moves no
/// offsets. It is read at the moment the surface buffer is written, which
/// happens after the lighting loop has run, so the value is there by then.
vec3 g_debug_surface = vec3(0.0);
bool g_debug_surface_on = false;

/// Whether [WriteSurface] weights the colour by its alpha: set by
/// `ReadSurface` for a material that blends, and false for everything else.
///
/// **The blend takes its source as premultiplied**, so a blended surface has
/// to hand it the colour times the alpha — a pane at a fifth of opaque adds a
/// fifth of its light, not all of it. glTF's blend mode is Porter and Duff's
/// over on straight colour, and this is the one place that turns the lit
/// radiance into what that means. An opaque or masked surface keeps its
/// colour whole: its alpha is not a coverage, and nothing blends it.
/// A global for the reason [g_debug_surface] is one.
bool g_premultiply = false;

// **A stage that needs none of this must be able to declare none of it.** On
// Vulkan both stages' descriptors are merged into one set layout, and two
// bindings with the same number in it is not a layout the specification
// allows. A driver may accept it anyway; a Galaxy A55's refuses the pipeline
// with `ErrorUnknown` and no other word, which is how the shadow pass came to
// build everywhere except there — its only uniform block was this one, and it
// landed on the same binding as the vertex stage's first.
#ifndef F3D_NO_FOG

/// Distance fog, in its own block rather than folded into FragInfo.
///
/// Its own because color.glsl is included before FragInfo is declared, and
/// because appending to a block that half a dozen shaders already share is a
/// way to move offsets nobody expected to move. Three vec4s is a cheap price
/// for not touching any of that.
layout(std140) uniform FogInfo {
  /// rgb: linear fog colour. w: density per metre, zero for no fog.
  vec4 fog;

  /// xyz: camera position in world space. Duplicated from FragInfo so this
  /// block stands alone; a vec3 is cheaper than a coupling.
  vec4 eye;

  /// xyz: the direction the camera looks, as a unit vector in world space.
  /// w: what a transparent draw writes under weighted blended transparency —
  /// `R8`, see `WriteWeightedBlended`. Zero for every other draw.
  ///
  /// Here rather than in a block of its own because it answers the same
  /// question [eye] does — where the camera is and which way it faces — and
  /// this is the block `color.glsl` can see.
  vec4 forward;
}
fog_info;

/// How far this fragment is from the eye, in world metres.
///
/// What the fog fades by. Distance rather than depth, because fog is a
/// property of the air between two points and does not care which way the
/// camera happens to face.
float EyeDistance() { return distance(v_world_position, fog_info.eye.xyz); }

/// How far this fragment is *along the view axis*, in world metres.
///
/// What the surface buffer's alpha holds. Depth rather than distance, and the
/// difference only shows on an orthographic camera — where the rays through
/// the pixels are parallel instead of meeting at the eye, so a distance from
/// the eye names a sphere that the pixel's ray crosses somewhere the reader
/// cannot solve for. A depth along the axis names a plane, which every ray
/// crosses exactly once. See `WorldAtDepth` in `post/ssao.frag` for the
/// reconstruction both projections share.
float ViewDepth() {
  return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz);
}

#else  // F3D_NO_FOG

// The same two questions, answered without the block: a stage that declares no
// fog has no eye position to measure from either. Stubs rather than a guard at
// every call site, so that what includes this file reads the same whichever
// way it was compiled.
float EyeDistance() { return 0.0; }
float ViewDepth() { return 0.0; }

#endif  // F3D_NO_FOG

/// sRGB to linear. Textures are authored in sRGB, but lighting is only correct
/// in linear space; skipping this is what makes naive renderers look muddy.
vec3 SrgbToLinear(vec3 srgb) {
  return mix(
      srgb / 12.92,
      pow((srgb + vec3(0.055)) / 1.055, vec3(2.4)),
      step(vec3(0.04045), srgb));
}

/// Linear to sRGB. The render target is a plain UNorm format rather than an
/// sRGB one, so the encode has to happen here.
vec3 LinearToSrgb(vec3 linear) {
  return mix(
      linear * 12.92,
      1.055 * pow(linear, vec3(1.0 / 2.4)) - vec3(0.055),
      step(vec3(0.0031308), linear));
}

/// Writes scene-referred linear light into the HDR target.
///
/// No tone map and no sRGB encode: those moved into the composite pass, which
/// is the entire point of rendering into `r16g16b16a16Float` first. Applying
/// them here meant every model wrote display-referred colour into an 8-bit
/// buffer, so anything above display white was gone before post-processing
/// could see it — and bloom is a function of exactly that.
///
/// Exposure moved with them, for the same reason: it belongs on the same side
/// of the display transform as the tone map.
/// Records the geometry of this fragment for whatever runs after the scene.
///
/// Called from the same place that writes colour, so a surface cannot be lit
/// into the frame without also describing itself — which is the failure that
/// leaves a screen-space effect reflecting whatever was in the buffer before.
///
/// rg: octahedral normal. b: perceptual roughness. a: **depth along the view
/// axis, in world metres** — see [ViewDepth].
///
/// **Not `gl_FragCoord.z`, and that is a defect this channel carried until it
/// was looked at.** Window depth crowds every distant surface into the top of
/// its range — with a near plane of a tenth of a metre, everything past twenty
/// metres lives in the last half a hundredth of `[0, 1]` — and this attachment
/// is a half float, whose steps up there are about five ten-thousandths. So two
/// surfaces half a metre apart at twenty metres stored the *same* number, and
/// every screen-space pass that compares against this channel decided whole
/// bands of pixels by rounding. The occlusion pass drew them: vertical stripes
/// along the lines of constant depth on any wall receding from the camera, on
/// both GPU backends. The software rasteriser kept the channel at full
/// precision and drew the effect correctly, so it was the one that looked
/// wrong against the other two.
///
/// A depth in metres has none of that: the exponent carries the range and the
/// mantissa carries the same relative precision everywhere, which at twenty
/// metres is a centimetre. Both numbers are measured in
/// `flutter3d/test/surface_depth_test.dart`.
///
/// Zero still means nothing was drawn. The attachment is cleared to zero and
/// nothing is drawn in front of the near plane.
void WriteSurfaceGeometry(float roughness) {
#ifndef F3D_NO_SURFACE_BUFFER
  // `L5`: the surface's colour, whatever the surface buffer ends up holding.
  frag_albedo = vec4(LinearToSrgb(clamp(g_albedo, vec3(0.0), vec3(1.0))), 1.0);
  // A debug pass takes the buffer over rather than getting one of its own.
  // The surface buffer already has an attachment, a viewer and a golden; a
  // second one would need all three built before it could answer anything.
  if (g_debug_surface_on) {
    frag_surface = vec4(g_debug_surface, ViewDepth());
    return;
  }
  // Reversed on a back face, as the lit normal is, so the occlusion and
  // reflection passes see the side of a double-sided surface that faces them.
  vec3 geometric = normalize(v_normal);
  if (!gl_FrontFacing) geometric = -geometric;
  frag_surface = vec4(EncodeOctahedral(geometric),
                      clamp(roughness, 0.0, 1.0), ViewDepth());
#endif
}

/// Fades [color] toward the fog with distance from the eye.
///
/// Exponential rather than linear, because linear fog has a visible plane
/// where it starts and a dungeon corridor is exactly where that shows.
vec3 ApplyFog(vec3 color) {
#ifdef F3D_NO_FOG
  return color;
#else
  float density = fog_info.fog.w;
  if (density <= 0.0) return color;
  float d = EyeDistance();
  return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0));
#endif
}

/// How much a transparent fragment counts for against the others over its
/// pixel — `R8`. McGuire and Bavoil's depth weight (their equation 9): a near
/// layer outweighs a far one, which is all the ordering a weighted average
/// can keep. [alpha] multiplies it, as theirs does, so a faint layer counts
/// faintly. Depth along the view axis, in metres, the surface buffer's.
float WeightedBlendedWeight(float alpha) {
  float z = abs(ViewDepth());
  float near = z / 5.0;
  float far = z / 200.0;
  float far3 = far * far * far;
  return alpha *
         clamp(10.0 / (1e-5 + near * near + far3 * far3), 1e-2, 3e3);
}

/// What a transparent draw writes when the frame composites transparency
/// order-independently — `R8`. `fog_info.forward.w` says which:
///
/// - 0: [frag_color] as it stands, the sorted blend's source. Every opaque
///   draw, and every draw in a frame that sorts.
/// - 1: the accumulation target's share — the colour, which the engine keeps
///   premultiplied, and the alpha, both times the weight. Added.
/// - 2: the revealage target's — the alpha alone, in every channel, which the
///   blend multiplies the target by one minus of.
/// - 3: both at once, the second into attachment one, where the surface
///   buffer would be; the pass that asks has no surface buffer attached.
///
/// Selects rather than returns, because a phi of constants is what
/// SPIRV-Cross refuses. At nought the branch is not taken and [frag_color]
/// is untouched, which is what keeps a sorting frame byte-identical.
void WriteWeightedBlended() {
#ifndef F3D_NO_FOG
  float mode = fog_info.forward.w;
  if (mode > 0.5) {
    float alpha = frag_color.a;
    float weight = WeightedBlendedWeight(alpha);
    vec4 accumulate = vec4(frag_color.rgb * weight, alpha * weight);
    bool revealage = mode > 1.5 && mode < 2.5;
    frag_color = revealage ? vec4(alpha) : accumulate;
#ifndef F3D_NO_SURFACE_BUFFER
    if (mode > 2.5) frag_surface = vec4(alpha);
#endif
  }
#endif
}

/// The fog is mixed in before the weight, so a thin distant pane adds a thin
/// share of the fog too rather than all of it. Times one when nothing blends,
/// which is exact, so an opaque draw writes what it always wrote.
void WriteSurface(vec3 linearColor, float alpha, float roughness) {
  float weight = g_premultiply ? alpha : 1.0;
  frag_color = vec4(ApplyFog(linearColor) * weight, alpha);
  WriteSurfaceGeometry(roughness);
  WriteWeightedBlended();
}

/// For a stage with no material to speak of.
///
/// Fully rough, which is the honest default: a surface that cannot say how
/// polished it is should not be reflected off.
void WriteSurface(vec3 linearColor, float alpha) {
  WriteSurface(linearColor, alpha, 1.0);
}

/// Writes a value that is already display-referred.
///
/// For debug output, where the colour is not a light value at all: a normal
/// encoded as RGB means nothing after a tone curve. Converting to linear here
/// means the composite pass's sRGB encode hands the original back unchanged,
/// provided the view also turns tone mapping and exposure off — which is what
/// `RenderSettings.tonemap` is for.
void WriteDisplayColor(vec3 displayColor, float alpha) {
  frag_color = vec4(SrgbToLinear(displayColor), alpha);
  WriteSurfaceGeometry(1.0);
}

#endif  // COLOR_GLSL_

// --- lib/frag_coord.glsl ---
// Where a fragment sits, counted from the top of its target on every backend.

#ifndef FRAG_COORD_GLSL_
#define FRAG_COORD_GLSL_

/// `gl_FragCoord.xy` with row zero at the top of the picture.
///
/// [rows] is the target's height where the backend's row zero is the bottom
/// of the picture, and zero where it is the top. WebGL2 is the first kind:
/// window coordinates start at the lower left, and the engine draws the
/// picture upright there rather than mirroring every projection. Metal,
/// WebGPU and the software rasteriser are the second.
///
/// **Why a pattern cares and a picture does not.** Every screen-space pattern
/// in the engine — the Bayer dither, the grain, the jitter a ray march starts
/// from, the rotation of a shadow kernel — is a function of the pixel's row.
/// Read from the bottom, the same frame gets the pattern turned upside down,
/// and a four-row Bayer cell lands on different rows unless the height is a
/// multiple of four. The picture underneath is identical; the pattern on top
/// of it is not, and a comparison across backends counts every pixel it
/// moved.
vec2 FragCoordFromTop(float rows) {
  return rows > 0.0 ? vec2(gl_FragCoord.x, rows - gl_FragCoord.y)
                    : gl_FragCoord.xy;
}

#endif  // FRAG_COORD_GLSL_


/// Lights per draw. Must match LightBuffer.maxLights on the Dart side.
///
/// A fixed array with a runtime count, not a shader permutation per light
/// count: turning a light on has to be free, because there is no runtime
/// compilation to fall back on. Verified against the SDK — Impeller keeps
/// `vec4 x[8]` in the compiled Metal struct and reflects the array's base
/// offset, with the std140 stride of 16 bytes.
#define kMaxLights 8

/// How many more lights one draw may be handed — `gfx-74n`.
///
/// **The eight above stay exactly what they were**, which is what keeps this
/// from moving a single recorded frame: a draw with eight lights or fewer runs
/// the loop it has always run, reads the uniform arrays it has always read, and
/// never touches the texture below. The tail is the part that used to be
/// impossible.
///
/// A loop bound rather than a cost. `AccumulateLights` breaks at the draw's own
/// count, so a scene with three lights costs three iterations whatever this
/// says. Twenty-four because the two tables below are `vec4 x[6]` and four
/// lanes fit a `vec4`: two hundred and eight bytes a draw, against the five
/// hundred and twelve the light arrays already cost.
#define kExtraLights 24
#define kTotalLights (kMaxLights + kExtraLights)

// --- lib/light_list.glsl ---
// The frame's light list, and how a fragment finds its tail in it — `gfx-74n`
// and `L6`.
//
// Split out of `surface.glsl` so a stage that is not a surface can read the
// same lights: `N6`'s six-way particles light each fragment by the list the
// lit models read, clusters and all, without declaring `FragInfo`. The text is
// the one that stood in `surface.glsl`, moved rather than copied, so the lit
// models compile to what they compiled to before.

#ifndef LIGHT_LIST_GLSL_
#define LIGHT_LIST_GLSL_
/// Every light in the scene, one per row, four texels across — `gfx-74n`.
///
/// **A texture rather than a wider uniform block, and that is the design.**
/// `FragInfo` is uploaded on every draw, so widening its four `vec4` arrays to
/// hold thirty-two lights would be a two-kilobyte upload per draw in every
/// scene, including every scene with one light. This is built once a frame and
/// only when a scene has more lights than a draw can hold in its slots.
///
/// Row layout, which `renderer_light_list.dart` writes and only this reads:
///
///  * texel 0 — xyz world position, w type (0 directional, 1 point, 2 spot)
///  * texel 1 — rgb linear colour, w intensity
///  * texel 2 — xyz the direction it points, w range
///  * texel 3 — x cos(inner), y cos(outer), zw unused
///
/// The same four vectors the uniform arrays hold, in the same order, so one
/// reader serves both.
///
/// **`F3D_NO_LIGHT_LIST` leaves both out**, for a model that accumulates no
/// lights. Such a model never reaches the reader below, so the compiler drops
/// the block and the sampler from the Metal function while reflection still
/// lists them, with no buffer or texture index assigned. The renderer used to
/// bind them for every draw, Unlit included, and that bind is a crash inside
/// `setFragmentBuffer:offset:atIndex:` on Metal. Vulkan took the same draw
/// without a word, which is how 0.7.0 shipped with it.
#ifndef F3D_NO_LIGHT_LIST
uniform sampler2D light_list_texture;

layout(std140) uniform LightListInfo {
  /// x: how many rows this draw reads, zero when it reads none.
  /// y, z: one over the texture's width and height.
  /// w: unused.
  vec4 list;

  /// Which rows, four to a vector, in the order they are read.
  ///
  /// Indices rather than the light data itself: the data is the same for every
  /// draw in the frame and belongs in the texture; what differs per draw is
  /// *which* of them reach it, and that is what `Renderer._drawLightsFor`
  /// already decides.
  vec4 indices[6];

  /// How much of each of those survives the edge fade, in the same order.
  ///
  /// Per draw and not in the texture, because the row an index points at is
  /// shared by every draw in the frame: a scale written into it would dim that
  /// light for all of them. `gfx-12n`'s fade lives at the end of the list now —
  /// that is where a light stops contributing, and fading the slots against a
  /// water line that no longer marks a cliff would dim a light for no reason
  /// while its rival stayed bright, making the swap more visible rather than
  /// less.
  vec4 scales[6];

  /// `L6`: the view-projection the light clusters were cut with, so this
  /// finds a fragment's cell the way `LightClusters.clusterOf` does.
  mat4 cluster_view_projection;

  /// xyz: tiles across, tiles up, slices deep. w: one when this draw reads
  /// its tail from the cell it is in rather than from `indices`.
  vec4 cluster_grid;

  /// x: where slices begin, in clip w. y: slices per unit of `ln(w / x)`.
  /// z: the texture row the cells' headers start at, four to a row, each
  /// (offset, count). w: the row their entries start at, sixteen to a row.
  vec4 cluster_depth;

  /// Which rows this draw already holds in its eight slots, minus one for
  /// an empty slot. A cell lists every light that reaches it, and one the
  /// slots already carry must not be counted again.
  vec4 slot_rows[2];
}
light_list_info;

/// One lane of a six-vector table, [slot] counting from nought.
float LightListLane(vec4 four, int slot) {
  int lane = slot - (slot / 4) * 4;
  return lane == 0 ? four.x : lane == 1 ? four.y : lane == 2 ? four.z : four.w;
}

/// The row light [slot] of the list reads.
float LightListRow(int slot) {
  return LightListLane(light_list_info.indices[slot / 4], slot);
}

/// How much of light [slot] of the list survives the edge fade.
float LightListScale(int slot) {
  return LightListLane(light_list_info.scales[slot / 4], slot);
}

/// The cell this fragment falls in, as `LightClusters` wrote it: where its
/// entries start and how many there are. Found once, in [LightCount], and
/// read by every [SampleLight] of the loop that follows.
float g_cluster_offset = 0.0;
float g_cluster_count = 0.0;

bool Clustered() { return light_list_info.cluster_grid.w > 0.5; }

/// One texel of the light list texture, [texel] across and [row] down.
vec4 LightListTexel(float texel, float row) {
  return textureLod(light_list_texture,
                    vec2((texel + 0.5) * light_list_info.list.y,
                         (row + 0.5) * light_list_info.list.z),
                    0.0);
}

void FindCluster(vec3 world) {
  vec4 clip = light_list_info.cluster_view_projection * vec4(world, 1.0);
  vec2 ndc = clip.xy / max(clip.w, 1e-6);
  vec3 grid = light_list_info.cluster_grid.xyz;
  float near = light_list_info.cluster_depth.x;
  float tx = clamp(floor((ndc.x * 0.5 + 0.5) * grid.x), 0.0, grid.x - 1.0);
  float ty = clamp(floor((ndc.y * 0.5 + 0.5) * grid.y), 0.0, grid.y - 1.0);
  float tz = clip.w <= near
                 ? 0.0
                 : clamp(floor(log(clip.w / near) *
                               light_list_info.cluster_depth.y),
                         0.0, grid.z - 1.0);
  float cell = tx + ty * grid.x + tz * grid.x * grid.y;
  float row = floor(cell / 4.0);
  vec4 header =
      LightListTexel(cell - row * 4.0, light_list_info.cluster_depth.z + row);
  g_cluster_offset = header.x;
  g_cluster_count = header.y;
}

/// The row entry [slot] of this fragment's cell names.
float ClusterRow(int slot) {
  float entry = g_cluster_offset + float(slot);
  float row = floor(entry / 16.0);
  float within = entry - row * 16.0;
  float texel = floor(within / 4.0);
  vec4 four = LightListTexel(texel, light_list_info.cluster_depth.w + row);
  return LightListLane(four, int(within - texel * 4.0 + 0.5));
}

/// Whether one of the draw's slots already holds light list row [row].
bool InSlots(float row) {
  vec4 a = abs(light_list_info.slot_rows[0] - vec4(row));
  vec4 b = abs(light_list_info.slot_rows[1] - vec4(row));
  return min(min(min(a.x, a.y), min(a.z, a.w)), min(min(b.x, b.y), min(b.z, b.w))) < 0.5;
}
#endif  // F3D_NO_LIGHT_LIST

#endif  // LIGHT_LIST_GLSL_


layout(std140) uniform FragInfo {
  /// xyz: world position (point and spot). w: type, 0 directional 1 point 2 spot.
  vec4 light_position[kMaxLights];

  /// rgb: linear colour. w: intensity.
  vec4 light_color[kMaxLights];

  /// xyz: the direction the light points, its local -Z. w: range, 0 unbounded.
  vec4 light_direction[kMaxLights];

  /// x: cos(inner cone angle). y: cos(outer cone angle).
  vec4 light_cone[kMaxLights];

  /// rgb: albedo tint applied on top of the texture. w: opacity.
  vec4 base_color;

  /// rgb: emissive factor, already linear. w: one when the normal map has
  /// two channels (x, y) and its z is rebuilt — see `ApplyNormalMap`. It sits
  /// here because this was the block's one unspent lane.
  vec4 emissive;

  /// xyz: camera position in world space, needed for every specular term.
  vec4 camera_position;

  /// x: metallic, y: roughness, z: ambient strength, w: specular strength.
  vec4 material;

  /// x: alpha cutoff (negative when the material is not masked: -1 opaque,
  /// -0.5 blended, -2 hashed), y: normal scale, z: occlusion strength,
  /// w: emissive strength.
  vec4 material2;

  /// x: exposure, y: active light count, z: index of the shadow-casting light.
  /// w is reserved so adding a frame-wide parameter does not change the offsets
  /// of anything already here.
  vec4 frame_params;

  /// x: one texel of the shadow map, y: depth bias, z: normal offset,
  /// w: strength, zero when shadows are off.
  vec4 shadow_params;

  /// World space to the shadow camera's clip space. The first cascade.
  mat4 shadow_matrix;

  /// The second and third cascades. Copies of the first when there is one, so
  /// this block's layout never depends on how many there are.
  mat4 shadow_matrix_far;
  mat4 shadow_matrix_farthest;

  /// x, y: where cascades 0 and 1 end, in metres from the camera. z: how many
  /// cascades there are, 1 to 3. w: one texel of a tile, vertically —
  /// shadow_params.x is one texel of the whole atlas, and with more than one
  /// cascade those differ.
  vec4 shadow_cascades;

  /// rgb: what a surface facing straight up receives from the environment.
  /// w: one when the metal-rough models' diffuse is EON rather than Lambert —
  /// `L8`, `RenderSettings.diffuseModel`; a frame-wide switch in a frame-wide
  /// vector, and the block's offsets stay where four backends agree on them.
  ///
  /// Appended after everything else on purpose: std140 lays a block out in
  /// declaration order, so adding here leaves every offset above unchanged and
  /// the three backends do not have to agree about anything they did not
  /// already agree about.
  vec4 ambient_sky;

  /// rgb: what a surface facing straight down receives — bounce off the ground
  /// rather than the ground itself.
  ///
  /// **w is the directional light's apparent size** — `gfx-15n` — which has
  /// nothing to do with ambient and everything to do with this being the last
  /// unspent component in a block six shaders share. `frame_params.w` was the
  /// slot reserved for a frame-wide parameter and the environment's level
  /// count took it; appending to this block moves offsets four backends have
  /// agreed on. See `shadow.glsl`, which reads it.
  ///
  /// Two colours rather than one is the whole of what makes ambient look like
  /// light instead of like a lifted black level. Outdoors the sky is blue and
  /// bright and the ground is warm and dim, and a flat grey for both leaves
  /// every underside as pale as every upward face — which reads as the model
  /// being flat, and gets blamed on the normals.
  vec4 ambient_ground;

  /// x, y, z: the depth bias of each cascade, in that cascade's own normalized
  /// depth. w unused.
  ///
  /// `ShadowSettings.bias` is one number and a cascade's depth range is not:
  /// a near cascade is stretched towards the light when a caster stands
  /// further out than its own volume reaches, and the same bias over a longer
  /// range is a longer distance. The renderer converts it per cascade so it
  /// stays the distance it was tuned as; an unstretched cascade gets the
  /// setting unchanged.
  vec4 shadow_bias;

  /// x: the target's rows when its row zero is the bottom of the picture,
  /// zero when it is the top — see `FragCoordFromTop` in `frag_coord.glsl`,
  /// which the shadow kernel's rotation reads through. y: the mip bias every
  /// material map is read with — `R2`: nought, except while a temporal
  /// resolve reconstructs a picture larger than the scene is drawn at, when
  /// the maps are read as sharp as the output they end up in. z: one when
  /// the metal-rough model puts back the energy single scattering loses —
  /// `L1`, `RenderSettings.energyCompensation`. w: the frame's slice of 32
  /// while a temporal resolve runs, minus one otherwise — `S3`, which steps
  /// the soft shadow's rotation by it.
  vec4 target_origin;
}
frag_info;

/// The bias a material map is read with — see `target_origin.y`.
float MaterialLodBias() { return frag_info.target_origin.y; }

/// The maps a lit material reads, by the index [MapUv] takes — `C8`. The
/// order `LayerInfo.uv_transform` keeps them in, and `MaterialMap`'s on the
/// Dart side.
#define kMapBaseColor 0
#define kMapMetallicRoughness 1
#define kMapNormal 2
#define kMapOcclusion 3
#define kMapEmissive 4

/// Where map [slot] is read — `C8`, `KHR_texture_transform` at the sampler.
///
/// **A macro everywhere but the one stage that has the matrices.** A stage
/// that defines `F3D_TEXTURE_TRANSFORM` supplies [MapUv] and [MapMatrix] from
/// a block of its own; every other stage reads each map at the vertex's own
/// coordinate, and the macro leaves its source exactly what it was, so none of
/// them compiles to anything new.
#ifdef F3D_TEXTURE_TRANSFORM
vec2 MapUv(int slot);

/// The 2×2 part of map [slot]'s transform: x and y its first row, z and w
/// its second.
vec4 MapMatrix(int slot);
#else
#define MapUv(slot) v_texcoord
#endif

uniform sampler2D base_color_texture;

/// Everything about the surface that does not depend on which light is being
/// evaluated, resolved once per fragment.
struct Surface {
  vec3 albedo;      // linear, already tinted
  float alpha;      // opacity after texture, tint and vertex colour
  vec3 n;           // unit normal, perturbed by the normal map when there is one
  vec3 v;           // unit direction to the camera
  float n_dot_v;
  float metallic;
  float roughness;  // perceptual
  float occlusion;  // 1 means unoccluded
  vec3 emissive;    // linear, added after shading
  vec3 ambient;     // hemispheric, already scaled by the scene's strength
  float exposure;
};

/// One light's contribution geometry, recomputed per light per fragment.
struct LightSample {
  vec3 l;           // unit direction to the light
  vec3 h;           // unit half vector
  vec3 radiance;    // colour * intensity * attenuation
  float n_dot_l;
  float n_dot_h;
  float v_dot_h;

  /// One when the specular below is already integrated over the light —
  /// `L7`, a rectangle under a model that defines `F3D_LTC` — and nought
  /// otherwise. Then `ltc.x` is the GGX lobe over the rectangle, `ltc.y` the
  /// fitted norm and `ltc.z` the Fresnel term; see `LtcRectangle`.
  float integrated;
  vec3 ltc;
};

Surface ReadSurface() {
  Surface s;

  vec4 texel = texture(base_color_texture, MapUv(kMapBaseColor), MaterialLodBias());
  // Vertex colour is authored linear per the glTF spec, unlike the base colour
  // texture and the tint, which are sRGB.
  s.albedo = SrgbToLinear(texel.rgb) *
             SrgbToLinear(frag_info.base_color.rgb) *
             v_color.rgb;
  s.alpha = texel.a * frag_info.base_color.a * v_color.a;
  // `L5`: the albedo buffer carries it, for the indirect light.
  g_albedo = s.albedo;

  // Alpha masking, glTF's third alpha mode. A negative cutoff means the
  // material is opaque or blended, and discard would then be wrong rather than
  // merely unnecessary. Doing it before anything else is deliberate: a
  // discarded fragment should not pay for the lighting loop.
  //
  // **A cutoff below -1.5 is the fourth mode: hashed** — `gfx-16n`. The
  // sentinel rides in the same component because the alternative is a second
  // number in a block six shaders share, and -1 already meant "not masked";
  // anything more negative was free. See [MaterialAlphaMode.hashed].
  float cutoff = frag_info.material2.x;
  if (cutoff >= 0.0) {
    if (s.alpha < cutoff) discard;
  } else if (cutoff < -1.5) {
    // **Stochastic instead of a threshold.** A leaf texture at 40% opacity is
    // either entirely there or entirely gone under a fixed cutoff, so a fern
    // comes out as a hard-edged cardboard cut-out; sorting would fix it and
    // costs a sort per frame and a draw per layer. Comparing against noise
    // instead keeps 40% of the *pixels*, which resolves as 40% opacity to
    // anything that averages several of them — a higher-resolution target,
    // a downsample, a person standing back.
    //
    // **Hashed on world position, not on the screen.** Screen-space noise is
    // one line shorter and swims: the pattern stays put while the object
    // moves through it, so a moving branch sparkles. Anchoring it to where
    // the surface *is* means a given speck of leaf keeps its verdict from
    // frame to frame, and the camera moving changes nothing.
    //
    // The scale is a constant and it is the whole tuning: finer than the
    // texture's own detail and the noise disappears into aliasing, coarser
    // and the leaf breaks into blotches. Sixteen per metre is about a
    // centimetre of grain at a metre away.
    vec3 anchored = floor(v_world_position * 16.0);
    float noise = fract(
        sin(dot(anchored, vec3(12.9898, 78.233, 37.719))) * 43758.5453);
    if (s.alpha < noise) discard;
  }
  // **Between -1 and nought is the blend mode**, which `WriteSurface` weights
  // by its alpha: see [g_premultiply]. The engine writes -0.5 for it, -1 for
  // opaque; neither is masked, and only the blend's source is premultiplied.
  g_premultiply = cutoff < 0.0 && cutoff > -0.75;

  s.n = normalize(v_normal);
  // The back of a double-sided surface is lit from its own side: glTF asks
  // for the normal to be reversed there, and without it the underside of a
  // cloth turned to the sun reads n·l below zero and stays unlit. Only a
  // double-sided material ever draws a back face, since everything else has
  // them culled.
  if (!gl_FrontFacing) s.n = -s.n;
  s.v = normalize(frag_info.camera_position.xyz - v_world_position);
  // Clamped away from zero: a grazing view direction otherwise divides by zero
  // in the specular visibility term.
  s.n_dot_v = max(dot(s.n, s.v), 1e-4);

  s.metallic = clamp(frag_info.material.x, 0.0, 1.0);
  s.roughness = clamp(frag_info.material.y, 0.02, 1.0);
  // Hemispheric: the sky above, the ground below, blended by which way this
  // surface faces. `material.z` stays the overall strength, so the two are
  // separable — a scene dims its ambient without changing its colour, which is
  // what the one control used to do on its own.
  //
  // The blend runs on the geometric normal deliberately, before
  // `ApplyMaterialMaps` perturbs it. A normal map describes millimetres of
  // surface relief, and ambient of this kind describes which half of the world
  // a face can see; letting bump detail swing it makes a brick wall's mortar
  // lines pick up sky and reads as noise.
  s.ambient = mix(frag_info.ambient_ground.rgb, frag_info.ambient_sky.rgb,
                  s.n.y * 0.5 + 0.5) *
              frag_info.material.z;
  s.exposure = max(frag_info.frame_params.x, 0.0);

  // Neutral until ApplyMaterialMaps says otherwise, so a model that samples no
  // maps still has a complete surface.
  s.occlusion = 1.0;
  s.emissive = vec3(0.0);

  return s;
}

int LightCount() {
#ifdef F3D_NO_LIGHT_LIST
  return clamp(int(frag_info.frame_params.y + 0.5), 0, kMaxLights);
#else
  // `L6`: the tail is the cell's, when the draw reads one.
  float tail = light_list_info.list.x;
  if (Clustered()) {
    FindCluster(v_world_position);
    tail = g_cluster_count;
  }
  return clamp(int(frag_info.frame_params.y + 0.5), 0, kMaxLights) +
      clamp(int(tail + 0.5), 0, kExtraLights);
#endif
}

/// Whether light [index] carries a shadow — `gfx-74n`.
///
/// Only the first eight do. The cube atlas holds six rows and the slot table is
/// eight entries wide, so a light from the list has no row to read and asking
/// for one would index past the table. That is a real limit and the right one:
/// the eight a draw keeps in its slots are the eight ranked most relevant to
/// it, which is exactly the set worth a shadow map.
bool LightHasShadow(int index) { return index < kMaxLights; }

/// Distance attenuation for a punctual light, following the glTF spec.
///
/// Inverse square with an optional range window. The window is what stops a
/// lamp with a declared range from contributing a faint haze across the whole
/// scene, which matters far more once there are eight of them.
float PunctualAttenuation(float distance, float range) {
  float attenuation = 1.0 / max(distance * distance, 1e-4);
  if (range > 0.0) {
    float ratio = distance / range;
    float window = clamp(1.0 - ratio * ratio * ratio * ratio, 0.0, 1.0);
    attenuation *= window * window;
  }
  return attenuation;
}

/// One edge of Lambert's sum, from [a] to [b], neither of which need be a
/// unit vector: the angle between them times how much their plane leans into
/// [n].
float LambertEdge(vec3 a, vec3 b, vec3 n) {
  // Normalised with a floor rather than `normalize`: a corner exactly at the
  // shading point, or a horizon crossing that lands there, is a zero vector,
  // and `normalize` of that is a NaN that spreads to the whole pixel and then
  // to the bloom. A zero vector here subtends nothing, which is the answer.
  vec3 ua = a / max(length(a), 1e-12);
  vec3 ub = b / max(length(b), 1e-12);
  // Clamped before the `acos`: two nearly parallel edge directions can give a
  // dot a hair past one through rounding alone, and `acos` of that is the same
  // NaN.
  float angle = acos(clamp(dot(ua, ub), -1.0, 1.0));
  vec3 axis = cross(ua, ub);
  float len = length(axis);
  // A degenerate edge — the shading point lies on the line through it —
  // subtends nothing.
  return len > 1e-6 ? angle * dot(axis, n) / len : 0.0;
}

/// How much of [s]'s sky a rectangle covers, weighted by the cosine —
/// `gfx-77n`.
///
/// **Exact, not fitted.** This is Lambert's own form factor for a polygon, from
/// 1760: for each edge, the angle it subtends at the shading point times how
/// much the edge's plane leans into the surface normal. Summed over the edges
/// and halved, it is the integral of `cos θ` over the polygon's projection on
/// the sphere — the quantity a punctual light approximates with a single
/// `n · l`. So there is no table to ship and nothing to fit: the usual
/// linearly-transformed-cosine approach exists to make the *specular* lobe
/// tractable, and buys nothing here.
///
/// **Clipped to the horizon first.** Lambert's sum is signed: a part of the
/// panel below the surface's horizon counts with a negative cosine and cancels
/// light from the part above it, so a panel standing on the horizon read
/// nought where half of it lights the surface. Irradiance wants the clamped
/// cosine, and for a polygon that means cutting away what lies below before
/// summing. A convex quadrilateral cut by a plane leaves one polygon with at
/// most one edge leaving the hemisphere and one entering it, so the cut is the
/// four edges trimmed where they cross plus one edge along the horizon from
/// the exit back to the entry, with no list of vertices to build.
///
/// Returns irradiance over radiance, so a surface facing a rectangle that fills
/// its whole sky gets π, the same as a uniform hemisphere. [corners] are the
/// four vertices in order, relative to the shading point.
///
/// **The rectangle emits along `cross(halfWidth, halfHeight)`**, and with the
/// corners wound as `SampleLight` winds them the sum comes out *negative* on
/// that side, so the negation below is the convention rather than a fix. It was
/// measured rather than derived: the first version returned `+total * 0.5`, and
/// against the reference integration it read nought where the answer was 0.349
/// and 1.02 where the answer was nought — the two failures a flipped winding
/// produces, and between them they name the sign with no room left to argue.
float RectangleFormFactor(vec3 corners[4], vec3 n) {
  float total = 0.0;
  vec3 exit = vec3(0.0);
  vec3 entry = vec3(0.0);
  for (int i = 0; i < 4; i++) {
    vec3 a = corners[i];
    vec3 b = corners[i == 3 ? 0 : i + 1];
    float ha = dot(a, n);
    float hb = dot(b, n);
    // Where the edge meets the horizon; used only when it crosses it, and then
    // the two heights differ in sign, so the division is safe.
    float d = ha - hb;
    vec3 q = a + (b - a) * (abs(d) > 1e-12 ? ha / d : 0.0);
    bool aAbove = ha > 0.0;
    bool bAbove = hb > 0.0;
    total += aAbove || bAbove
                 ? LambertEdge(aAbove ? a : q, bAbove ? b : q, n)
                 : 0.0;
    exit = aAbove && !bAbove ? q : exit;
    entry = !aAbove && bAbove ? q : entry;
  }
  // The horizon edge closing the cut, from where the outline left the
  // hemisphere to where it came back. Nothing when it never crossed: both are
  // still zero and a zero vector subtends nothing.
  total += LambertEdge(exit, entry, n);
  // Clamped: a surface on the panel's dark side sees the outline wound the
  // other way, and the clipped sum comes out negative. `SampleLight` tests the
  // side as well, before any of this is paid for.
  return max(-total * 0.5, 0.0);
}

/// Where on the rectangle the specular lobe is really looking — `gfx-77n`.
///
/// **The representative point, which is an approximation, unlike the diffuse
/// above.** The mirror direction leaves the surface and either hits the panel
/// or misses it; the closest point of the panel to that ray is treated as a
/// punctual light standing in for the whole rectangle. It is the standard
/// cheap answer and its one visible property is the one the row asked for: as
/// the view moves the closest point slides along the panel, so the highlight
/// is a streak with the panel's own shape and orientation rather than a dot.
///
/// What it does not do is widen the lobe by the panel's solid angle, so a
/// rough surface under a large panel is a little darker than a full integration
/// would make it. That is a known error of this method and not a bug in this
/// transcription; the fix is the fitted table this function exists to avoid.
vec3 RectangleClosestPoint(vec3 centre, vec3 halfWidth, vec3 halfHeight,
                           vec3 world, vec3 mirror) {
  vec3 n = cross(halfWidth, halfHeight);
  float nLen = length(n);
  // A panel with no area has no surface to find a point on; its centre is the
  // only answer that is not a division by zero.
  if (nLen < 1e-12) return centre;
  n /= nLen;

  vec3 toPlane = centre - world;
  float denom = dot(mirror, n);
  vec3 onPlane;
  // Parallel to the panel, or pointing away from it: the ray never lands, so
  // the nearest thing to it is the centre projected back, which keeps the
  // highlight on the panel instead of sending it to infinity.
  if (abs(denom) < 1e-5) {
    onPlane = toPlane - n * dot(toPlane, n);
  } else {
    float t = dot(toPlane, n) / denom;
    onPlane = t > 0.0 ? mirror * t : toPlane - n * dot(toPlane, n);
  }

  // Clamped into the rectangle in its own axes. Dividing by the squared length
  // turns a projection into a coordinate in units of the half-extent, so the
  // clamp is against one either way round.
  vec3 offset = onPlane - toPlane;
  float wLen2 = max(dot(halfWidth, halfWidth), 1e-12);
  float hLen2 = max(dot(halfHeight, halfHeight), 1e-12);
  float u = clamp(dot(offset, halfWidth) / wLen2, -1.0, 1.0);
  float v = clamp(dot(offset, halfHeight) / hLen2, -1.0, 1.0);
  return centre + halfWidth * u + halfHeight * v;
}

#ifdef F3D_LTC
// --- lib/ltc.glsl ---
// The GGX lobe over a rectangle light, by linearly transformed cosines — `L7`.
//
// Heitz, Dupuy, Hill and Neubelt, "Real-Time Polygonal-Light Shading with
// Linearly Transformed Cosines", ACM TOG 35(4), 2016. The fitted tables are
// `EngineTables.ltc`; see `tables/ltc.dart` for their layout and licence.
//
// A model that wants it defines `F3D_LTC` before including `surface.glsl`,
// which is what gives its stage the one sampler below. Every other model
// keeps the representative point, and no sampler.

#ifndef LTC_GLSL_
#define LTC_GLSL_

/// Both tables, 64 × 128: the inverse matrices above, the norms, Fresnel
/// terms and sphere form factors below.
uniform sampler2D ltc_texture;

/// Where `(x, y)`, each nought to one, lands in the table starting at
/// [table] (nought the upper, one the lower): on texel centres, so the ends of
/// the range read the first and last entries rather than half of the
/// neighbour.
vec2 LtcUv(float x, float y, float table) {
  vec2 inTable = vec2(x, y) * (63.0 / 64.0) + 0.5 / 64.0;
  return vec2(inTable.x, (inTable.y + table) * 0.5);
}

/// One edge's share of the vector form factor, from [a] to [b], unit
/// directions: the angle between them along the normal of their plane,
/// over 2π. Exact, with the `acos` clamped for the reason
/// `RectangleFormFactor` gives.
vec3 LtcEdge(vec3 a, vec3 b) {
  vec3 axis = cross(a, b);
  float len = length(axis);
  float angle = acos(clamp(dot(a, b), -1.0, 1.0));
  return len > 1e-6 ? axis * (angle / (len * 6.2831853)) : vec3(0.0);
}

/// The GGX lobe of roughness [roughness] seen along [v] from normal [n],
/// integrated over the rectangle with corners [corners] (relative to the
/// shading point, wound as `SampleLight` winds them), with the fitted
/// Fresnel pair for that lobe: x the integral, y the norm, z the Fresnel
/// term. The specular is `x · (f0 · y + (1 − f0) · z)`.
///
/// Clipped to the horizon by the sphere table rather than by cutting the
/// polygon: the vector form factor's length and elevation name a sphere
/// with the same, and the table holds how much of that sphere's clamped
/// cosine lies above the horizon.
///
/// Says nothing about which face of the panel the point is on: the vector
/// form factor points the same way in the world from either side, so this is
/// as bright behind the panel as in front of it. `SampleLight` tests the side
/// and leaves a point behind unlit before this is asked.
vec3 LtcRectangle(vec3 n, vec3 v, float roughness, vec3 corners[4]) {
  vec2 uv = vec2(clamp(roughness, 0.0, 1.0),
                 sqrt(clamp(1.0 - dot(n, v), 0.0, 1.0)));
  vec4 inverse = textureLod(ltc_texture, LtcUv(uv.x, uv.y, 0.0), 0.0);
  vec4 fit = textureLod(ltc_texture, LtcUv(uv.x, uv.y, 1.0), 0.0);

  // The frame the fit was made in: the normal up, the view in the xz plane.
  // A view along the normal has no plane of its own, and any will do.
  vec3 along = v - n * dot(v, n);
  float alongLength = length(along);
  vec3 t1 = alongLength > 1e-5
                ? along / alongLength
                : normalize(cross(n, abs(n.z) < 0.999 ? vec3(0.0, 0.0, 1.0)
                                                      : vec3(1.0, 0.0, 0.0)));
  vec3 t2 = cross(n, t1);
  mat3 minv = mat3(vec3(inverse.x, 0.0, inverse.y), vec3(0.0, 1.0, 0.0),
                   vec3(inverse.z, 0.0, inverse.w));

  vec3 l[4];
  for (int i = 0; i < 4; i++) {
    vec3 p = corners[i];
    l[i] = normalize(minv * vec3(dot(p, t1), dot(p, t2), dot(p, n)));
  }
  // Negated, for `RectangleFormFactor`'s reason: the panel emits along
  // `cross(halfWidth, halfHeight)`, and seen from there these corners run
  // clockwise.
  vec3 f = -(LtcEdge(l[0], l[1]) + LtcEdge(l[1], l[2]) +
             LtcEdge(l[2], l[3]) + LtcEdge(l[3], l[0]));
  float len = length(f);
  float z = len > 1e-9 ? f.z / len : 0.0;
  float sphere =
      textureLod(ltc_texture, LtcUv(z * 0.5 + 0.5, clamp(len, 0.0, 1.0), 1.0),
                 0.0)
          .w;
  return vec3(max(len * sphere, 0.0), fit.x, fit.y);
}

#endif  // LTC_GLSL_


#ifdef F3D_LAYERED
/// The corners of the rectangle [SampleLight] resolved last, relative to the
/// shading point — `M1`. The clear coat integrates its own lobe over the same
/// panel with its own normal and roughness, and those live in `pbr.glsl`,
/// after this file; the loop shades each light straight after sampling it,
/// so this is always the light being shaded.
vec3 g_rect_corners[4];
#endif  // F3D_LAYERED
#endif  // F3D_LTC

/// Resolves light [index] against the surface.
///
/// Returns `n_dot_l == 0` for anything that contributes nothing — behind the
/// surface, out of range, outside the spot cone, the dark face of a panel — so
/// a model can skip it with one test instead of repeating the classification.
LightSample SampleLight(int index, Surface s) {
  LightSample light;
  light.integrated = 0.0;
  light.ltc = vec3(0.0);

  vec4 position;
  vec4 color;
  vec4 direction;
  vec4 cone;
  if (index < kMaxLights) {
    position = frag_info.light_position[index];
    color = frag_info.light_color[index];
    direction = frag_info.light_direction[index];
    cone = frag_info.light_cone[index];
  } else {
#ifdef F3D_NO_LIGHT_LIST
    // Unreachable: `LightCount` stops at the slots without a list.
    position = vec4(0.0);
    color = vec4(0.0);
    direction = vec4(0.0);
    cone = vec4(0.0);
#else
    // A row of the light list — `gfx-74n`. Sampled at texel centres so a
    // driver's rounding cannot land a fetch on a neighbour, and the four texels
    // across the row are the same four vectors the arrays above hold.
    int slot = index - kMaxLights;
    // `L6`: from the cell rather than the draw's own tail, and a light the
    // slots already hold is skipped by its intensity, as a faded one is.
    bool clustered = Clustered();
    float listRow = clustered ? ClusterRow(slot) : LightListRow(slot);
    float v = (listRow + 0.5) * light_list_info.list.z;
    float u = light_list_info.list.y;
    // `textureLod` and not `texture`, for `shadow.glsl`'s own reason: `index`
    // reaches this branch through a function parameter, so a WGSL backend
    // cannot see that every invocation of a draw walks the same light count
    // and refuses the implicit derivative as possibly non-uniform. The atlas
    // has one level, so naming it directly changes no pixel.
    position = textureLod(light_list_texture, vec2(0.5 * u, v), 0.0);
    color = textureLod(light_list_texture, vec2(1.5 * u, v), 0.0);
    direction = textureLod(light_list_texture, vec2(2.5 * u, v), 0.0);
    cone = textureLod(light_list_texture, vec2(3.5 * u, v), 0.0);
    // The intensity and not the colour, for `LightBuffer._pack`'s own reason:
    // the same multiply here, and only one of them is a number nobody authored.
    color.w *= clustered ? (InSlots(listRow) ? 0.0 : 1.0) : LightListScale(slot);
#endif  // F3D_NO_LIGHT_LIST
  }

  float type = position.w;

  // **The rectangle leaves before `aim` is taken — `gfx-77n`.** For every other
  // kind `direction.xyz` is a unit vector saying which way the light points;
  // for this one it is an edge of the panel, with its length carrying half the
  // width, and normalising it here would quietly throw the size away.
  if (type > 2.5) {
    vec3 halfWidth = direction.xyz;
    vec3 halfHeight = cone.xyz;
    vec3 toCentre = position.xyz - v_world_position;

    vec3 corners[4];
    corners[0] = toCentre - halfWidth - halfHeight;
    corners[1] = toCentre + halfWidth - halfHeight;
    corners[2] = toCentre + halfWidth + halfHeight;
    corners[3] = toCentre - halfWidth + halfHeight;

    // **The panel emits from one face only**, and a point on the other side
    // gets nothing: the room above a ceiling panel, the outside of the wall a
    // window is set in. Tested here rather than left to the signs below,
    // because the specular's vector form factor keeps the same orientation
    // from either side of the panel, so a surface behind it facing away read
    // as lit as one in front facing it.
    bool behind = dot(toCentre, cross(halfWidth, halfHeight)) >= 0.0;

    // The cosine-weighted solid angle, which takes the place `n · l` holds for
    // a punctual light: the loop multiplies the shading by `n_dot_l`, so
    // putting the exact integral here makes the diffuse term exact rather than
    // sampled. See [RectangleFormFactor].
    float formFactor = behind ? 0.0 : RectangleFormFactor(corners, s.n);

    // Radiance rather than intensity: `intensity` means the same thing for
    // every kind of light, so a panel's is spread over its own area here.
    // Enlarging a window at a fixed rating then dims it per square metre and
    // leaves the room as bright, which is what the number is supposed to mean.
    float area = length(cross(halfWidth, halfHeight)) * 4.0;
    float radiance = area > 1e-9 ? 1.0 / area : 0.0;

    // The range window only. A punctual light needs the inverse square as
    // well; the form factor already contains it, because a panel twice as far
    // away subtends a quarter of the sky.
    float distance = length(toCentre);
    if (direction.w > 0.0) {
      float ratio = distance / direction.w;
      float window = clamp(1.0 - ratio * ratio * ratio * ratio, 0.0, 1.0);
      radiance *= window * window;
    }

    vec3 mirror = reflect(-s.v, s.n);
    vec3 representative = RectangleClosestPoint(
        position.xyz, halfWidth, halfHeight, v_world_position, mirror);
    vec3 toPoint = representative - v_world_position;
    float pointDistance = length(toPoint);
    light.l = pointDistance > 1e-6 ? toPoint / pointDistance : s.n;

    light.h = normalize(light.l + s.v);
    light.n_dot_l = formFactor;
    light.n_dot_h = max(dot(s.n, light.h), 0.0);
    light.v_dot_h = max(dot(s.v, light.h), 0.0);
    light.radiance = color.rgb * color.w * radiance;
#ifdef F3D_LTC
    // `L7`: the specular over the whole panel rather than at one point of
    // it. The diffuse keeps the exact form factor above.
    light.integrated = 1.0;
    light.ltc = LtcRectangle(s.n, s.v, s.roughness, corners);
#ifdef F3D_LAYERED
    // Kept for the clear coat's own integral; see [g_rect_corners].
    g_rect_corners = corners;
#endif
#endif
    return light;
  }

  vec3 aim = normalize(direction.xyz);
  float attenuation = 1.0;

  if (type < 0.5) {
    // Directional: no position, no falloff. The direction to the light is the
    // reverse of the direction it points.
    light.l = -aim;
  } else {
    vec3 toLight = position.xyz - v_world_position;
    float distance = length(toLight);
    // A light exactly on the surface has no direction; treat it as contributing
    // nothing rather than producing a NaN that spreads through the frame.
    if (distance < 1e-6) {
      light.l = s.n;
      light.h = s.n;
      light.radiance = vec3(0.0);
      light.n_dot_l = 0.0;
      light.n_dot_h = 0.0;
      light.v_dot_h = 0.0;
      return light;
    }
    light.l = toLight / distance;
    attenuation = PunctualAttenuation(distance, direction.w);

    if (type > 1.5) {
      // Spot: a smooth ramp between the two cone cosines. The Dart side already
      // guarantees the denominator is non-zero.
      float cosAngle = dot(aim, -light.l);
      attenuation *= clamp(
          (cosAngle - cone.y) / (cone.x - cone.y), 0.0, 1.0);
    }
  }

  light.h = normalize(light.l + s.v);
  light.n_dot_l = max(dot(s.n, light.l), 0.0);
  light.n_dot_h = max(dot(s.n, light.h), 0.0);
  light.v_dot_h = max(dot(s.v, light.h), 0.0);
  light.radiance = color.rgb * color.w * attenuation;

  return light;
}

/// How much of light [index] reaches this fragment, defined by each fragment
/// shader.
///
/// A prototype rather than a call into shadow.glsl, because the models that
/// sample no shadow map must not declare its sampler — the compiler would drop
/// the slot and leave the engine binding one that is not there. A lit model
/// returns `ShadowFactor(...)`; an unlit one returns 1.
float LightVisibility(Surface s, LightSample light, int index);

/// A model's per-light term, defined by each fragment shader.
///
/// A prototype here and the definition in the model is what lets the loop below
/// be written once. The alternative — repeating the loop in every model — is
/// six copies of the same three lines, and the place a light would go missing.
vec3 ShadeLight(Surface s, LightSample light);

/// Sums every active light's contribution.
///
/// The loop bound is the compile-time maximum with a runtime break, because GLSL
/// wants a constant trip count and the hardware wants the early exit.
// **The point-shadow half of this header, behind a guard.**
//
// A model that never shadows must not *declare* any of this, and the reason is
// the one `unlit.frag` already gives about the shadow sampler — with one
// backend's failure added to the other's. On Impeller the compiler drops what
// nothing reads, and the engine binding a slot that is no longer there is a
// native crash. On WebGL2 nothing is dropped: an active uniform block with no
// buffer under it makes every draw `INVALID_OPERATION`, discarded with nothing
// logged.
//
// That is what `lighting-unlit` was on this backend. Unlit's own metadata says
// `usesPointShadow` is false, so the engine correctly bound no `PointShadow`
// block — and the translated shader declared one anyway, so the sphere was
// never drawn and the frame came back the clear colour.
#ifndef F3D_NO_POINT_SHADOW

/// The cube atlas: three tiles across, two down, each a ninety-degree view
/// from a point light, each storing radial distance normalised by range.
uniform sampler2D point_shadow_texture;

/// The same atlas for the things that never move, rendered once at load.
///
/// Two maps rather than one because a dungeon's walls can be baked and a
/// spinning pickup cannot, and there is no way to draw into part of a texture
/// without redrawing the rest of it. Sampling both and keeping the nearer
/// occluder costs one extra read and saves six views of the level every frame.
uniform sampler2D point_shadow_static_texture;

/// How many lights may have a row of the atlas. Six tiles across each.
// Rows of the cube atlas: six faces across, this many lights down. Must
// match `Renderer.kShadowedLights`, which is where the reasoning lives, and
// `shadowSlots` in the software backend's transcription of this file.
const int kShadowSlots = 6;

layout(std140) uniform PointShadow {
  /// The same view-projections the atlas was rendered with, six per slot.
  ///
  /// Passed rather than reconstructed. Deriving cube face coordinates here
  /// would be a second implementation of a decision the renderer already made,
  /// and the two would disagree about handedness or up vectors on some face
  /// and nowhere else — which shows as one face of every shadow being wrong.
  mat4 faces[6 * kShadowSlots];

  /// Per slot. xyz: the light's world position. w: its range.
  vec4 lights[kShadowSlots];

  /// Per light, in the order the lighting knows them.
  ///
  /// x: the atlas row it owns, or negative when it has none — a fifth torch in
  /// a room lands there. z: the tangent of half the frustum's opening angle,
  /// which is what converts a world width into a fraction of a tile. y and w
  /// are unwritten.
  ///
  /// **z is exactly one for a point light**, because a cube face is a ninety
  /// degree frustum and `tan(45°) == 1`. That is not a convention chosen to be
  /// tidy: it is what lets a narrower frustum share this whole path, since
  /// multiplying by one in IEEE 754 changes no bit of the result. Whatever else
  /// a spot light will need, it does not need a second copy of the filter.
  vec4 slots[kMaxLights];

  /// x: half a texel, in tile-local uv. y: distance bias in metres.
  /// z: strength. w: normal offset, **in texels of the face it lands on**.
  vec4 params;

  /// x: smallest kernel radius in tile-local uv, and the fixed radius used
  /// when contact hardening is off. y: the light's own radius in metres; zero
  /// turns contact hardening off. z: largest kernel radius in tile-local uv.
  /// w: non-zero paints the penumbra estimate into the surface buffer instead
  /// of shading with it.
  vec4 params2;

  /// x: non-zero when this backend stores the atlas bottom-up. y: one over the
  /// edge length of a tile in texels, which is what turns a distance into the
  /// world width of one texel there.
  ///
  /// **Appended after everything else on purpose**, the same way FragInfo's
  /// ambient pair was: std140 lays a block out in declaration order, so adding
  /// here leaves every offset above unchanged and the three backends do not
  /// have to agree about anything they already agreed about. y, z and w are
  /// unwritten.
  vec4 params3;
}
point_shadow;

/// Eight points on a Poisson disk, a common set for filtering cascaded
/// shadows.
///
/// A disk rather than a grid because a grid of taps on a straight shadow edge
/// lands every sample on the same side at once, and the edge steps between
/// kernel widths instead of sliding. Eight rather than sixteen because every
/// tap here reads **two** atlases — the static walls and the movers — so the
/// cost is doubled before it is counted.
vec2 PointShadowDiskTap(int i) {
  if (i == 0) return vec2(-0.94201624, -0.39906216);
  if (i == 1) return vec2(0.94558609, -0.76890725);
  if (i == 2) return vec2(-0.09418410, -0.92938870);
  if (i == 3) return vec2(0.34495938, 0.29387760);
  if (i == 4) return vec2(-0.91588581, 0.45771432);
  if (i == 5) return vec2(-0.81544232, -0.87912464);
  if (i == 6) return vec2(-0.38277543, 0.27676845);
  return vec2(0.97484398, 0.75648379);
}

/// One comparison against the atlas, at [uv] offset within the tile.
///
/// The clamp is applied **after** the offset, not before, and that is the whole
/// reason a kernel can be widened here without touching anything else: each tap
/// is held inside its own tile individually. Clamping the centre and then
/// offsetting would let the outer taps walk straight out of the tile and read a
/// distance measured from a different face, or a different light.
float PointShadowDistance(vec2 uv, vec2 offset, vec2 tile, float range) {
  float inset = point_shadow.params.x;
  vec2 local = clamp(uv + offset, inset, 1.0 - inset);
  vec2 atlas = (local + tile) * vec2(1.0 / 6.0, 1.0 / float(kShadowSlots));
  // **The whole atlas, turned over, where row zero of a render target is at the
  // bottom.** Both halves of the address are wrong there and this is the one
  // place that fixes both: the tile the light owns — a light in slot zero is
  // drawn into the row the shader would call three, because the viewport
  // rectangle is flipped to land it — and the picture inside that tile, which
  // was drawn through a projection built for the other origin.
  //
  // Every check of this atlas missed it for the same reason: the debug view
  // composites the texture through a full-screen pass, which turns it over
  // again and puts the row back. The atlas compared equal on both backends
  // across six scenes while the lit pass, which samples it directly and has no
  // such pass to cancel, read a row that had never been drawn into and found
  // nothing in the way of anything.
  if (point_shadow.params3.x > 0.5) atlas.y = 1.0 - atlas.y;
  // Whichever is nearer occludes: a wall in front of a monster shadows, and so
  // does a monster in front of a wall.
  //
  // **`textureLod` at level zero, because every caller of this function stands
  // behind a branch.** The light loop skips a light the surface faces away
  // from, the blocker search `continue`s past a tap that found nothing, and the
  // slot test returns before any of it — so the invocations of a quad do not
  // arrive here together, and a WGSL backend refuses a sample whose implicit
  // derivative would be read where they disagree. Both atlases are distance
  // render targets with one level, so level zero is the level `texture` was
  // choosing anyway; this names it rather than deriving it, and the picture is
  // the same on every backend.
  return min(textureLod(point_shadow_texture, atlas, 0.0).r,
             textureLod(point_shadow_static_texture, atlas, 0.0).r) * range;
}

float PointShadowTap(vec2 uv, vec2 offset, vec2 tile, float range,
                     float receiver) {
  float stored = PointShadowDistance(uv, offset, tile, range);
  // Nothing was drawn in that direction by either, so nothing is in the way.
  if (stored >= range * 0.999) return 1.0;
  return receiver > stored ? 0.0 : 1.0;
}

/// The disk point for tap [i], rotated by [ca]/[sa] and scaled to [radius].
vec2 PointShadowOffset(int i, float ca, float sa, float radius) {
  vec2 p = PointShadowDiskTap(i);
  return vec2(p.x * ca - p.y * sa, p.x * sa + p.y * ca) * radius;
}

/// How wide the penumbra should be here, in tile-local uv.
///
/// Contact hardening, and the reason a fixed kernel looks wrong: a shadow is
/// sharp where its caster touches the floor and soft a metre away, and one
/// radius for both makes the contact mushy or the distant edge hard.
///
/// The similar-triangles estimate is the standard one — a light of radius `L`
/// with a blocker at `b` and a receiver at `r` throws a penumbra `L * (r - b) /
/// b` wide at the receiver. Converting that to tile uv is exact rather than
/// tuned, because a face is a ninety degree frustum: at distance `r` from the
/// light the face spans `2 * r` in world units across the full `0..1` of uv,
/// so a world width `w` is `w / (2 * r)` of a tile.
///
/// The blocker search runs at the **widest** penumbra allowed, since a blocker
/// outside that circle cannot widen the result anyway, and searching narrower
/// would miss the very blockers that make an edge soft.
///
/// [tanHalf] is where the ninety degrees stop being assumed. The span above is
/// `2 * r` only for a right-angled frustum; in general it is `2 * r * tan(θ/2)`,
/// and for a cube face that factor is one. A narrower frustum covers less world
/// per tile, so the same world width is a *larger* fraction of it — which is
/// why this divides rather than multiplies, and why getting it upside down
/// would make a tight cone's shadows harden instead of soften.
float PointShadowPenumbra(vec2 uv, vec2 tile, float range, float receiver,
                          float ca, float sa, float tanHalf,
                          out float blockerOut) {
  blockerOut = -1.0;
  float lightRadius = point_shadow.params2.y;
  float minRadius = point_shadow.params2.x;
  float maxRadius = point_shadow.params2.z;
  if (lightRadius <= 0.0) {
    // **The debug channel is filled even though the search is skipped**, and
    // leaving it unfilled cost a session. `blockerOut` starts at −1 to mean
    // "nothing was measured"; the debug encoding clamps it into a colour, where
    // −1 becomes zero — the same green as a blocker touching the surface, which
    // reads as the most alarming answer available. A whole theory was built on
    // that zero, and the search it described had never run.
    //
    // The centre tap is what the filter below would use anyway, so this reports
    // a distance the atlas really returned rather than a sentinel.
    blockerOut = PointShadowDistance(uv, vec2(0.0), tile, range);
    return minRadius;
  }


  float sum = 0.0;
  float count = 0.0;
  for (int i = 0; i < 8; i++) {
    float stored =
        PointShadowDistance(uv, PointShadowOffset(i, ca, sa, maxRadius), tile,
                            range);
    if (stored >= range * 0.999) continue;
    if (stored >= receiver) continue;
    sum += stored;
    count += 1.0;
  }
  // Nothing in front of this fragment anywhere in the search: fully lit, and
  // the caller can skip the filter entirely.
  if (count < 0.5) return -1.0;

  float blocker = max(sum / count, 1e-4);
  blockerOut = blocker;
  float world = lightRadius * max(receiver - blocker, 0.0) / blocker;
  return clamp(world / (2.0 * receiver * tanHalf), minRadius, maxRadius);
}

/// How lit [world] is by the point light that owns the cube atlas.
///
/// One, fully lit, when this is not that light or the atlas has nothing to say.
float PointShadowFactor(vec3 world, vec3 normal, int lightIndex) {
  int slot = int(point_shadow.slots[lightIndex].x + 0.5);
  if (point_shadow.slots[lightIndex].x < 0.0) return 1.0;
  float strength = point_shadow.params.z;
  if (strength <= 0.0) return 1.0;

  // Offset along the normal before measuring, and scaled by how steeply the
  // surface leans away from the light.
  //
  // A soft kernel on a tilted surface straddles a depth gradient: the taps at
  // one end of the disk are further from the light than the fragment itself,
  // so a flat offset that clears the surface head-on leaves acne at a grazing
  // angle. The slope term lifts the whole kernel clear instead, and is capped
  // because it runs away as the surface turns edge-on to the light — an
  // uncapped lift detaches the shadow from its caster.
  vec3 toLight = point_shadow.lights[slot].xyz - world;
  float toLightLength = max(length(toLight), 1e-6);
  float nDotL = max(dot(normal, toLight / toLightLength), 0.15);
  float slope = min(sqrt(max(1.0 - nDotL * nDotL, 0.0)) / (nDotL * nDotL), 8.0);

  // **How wide one texel of the face is, out where this fragment is.** The
  // error a normal offset exists to clear is exactly that: a texel of the
  // shadow map covers a patch of surface, the whole patch is recorded at one
  // distance, and a fragment anywhere else in it compares against a distance
  // measured somewhere it is not. That patch grows with range — it is a solid
  // angle, not a length — so an offset fixed in metres is right at one distance
  // and wrong everywhere else.
  //
  // What it was: `params.w` metres, flat. On the golden teapot, at 9.6 m from
  // the lamp, a texel is 3.7 cm and the flat offset was 2 cm, so the floor
  // shadowed itself across everything the light reached — and the acne stopped
  // dead at the *projection of the floor's own edge*, because past it the atlas
  // holds nothing and nothing can occlude. A straight line across a shadow with
  // no straight edge anywhere in the scene.
  float texel =
      2.0 * toLightLength * max(point_shadow.slots[lightIndex].z, 1e-4) *
      point_shadow.params3.y;
  // Both terms are metres. The slope term used to be the kernel radius, which
  // is a fraction of a tile — a unit mismatch carried over from an estimate
  // where a softness radius genuinely was the right quantity. Here it meant
  // widening the kernel also lifted the sample off the surface, by up to ten
  // centimetres at the wider settings, so the softening and the lift
  // cancelled: tripling the kernel moved 184 pixels of the frame,
  // where the kernel alone moves thousands. It is what made contact hardening
  // look inert, and it was hiding in a comparison rather than in the estimate.
  vec3 origin = world + normal * texel * point_shadow.params.w * (1.0 + slope);
  vec3 toFragment = origin - point_shadow.lights[slot].xyz;
  float distance = length(toFragment);
  float range = max(point_shadow.lights[slot].w, 1e-4);
  if (distance >= range) return 1.0;

  // The dominant axis picks the face, in the order the renderer wrote them:
  // +X, -X, +Y, -Y, +Z, -Z, left to right then top to bottom.
  //
  // A spot has one column and no choice to make. Asking the dominant axis
  // anyway would be worse than pointless: a fragment below and to the side of
  // a downlight has −Y dominant, which is column 3, and column 3 of a spot's
  // row is deliberately blank — so the whole cone would read as unshadowed
  // except for the wedge where the aim happens to be the dominant axis.
  int face = 0;
  if (point_shadow.slots[lightIndex].y < 0.5) {
    vec3 a = abs(toFragment);
    if (a.x >= a.y && a.x >= a.z) {
      face = toFragment.x > 0.0 ? 0 : 1;
    } else if (a.y >= a.z) {
      face = toFragment.y > 0.0 ? 2 : 3;
    } else {
      face = toFragment.z > 0.0 ? 4 : 5;
    }
  }

  vec4 clip = point_shadow.faces[slot * 6 + face] * vec4(origin, 1.0);
  if (clip.w <= 0.0) return 1.0;
  vec2 ndc = clip.xy / clip.w;
  if (abs(ndc.x) > 1.0 || abs(ndc.y) > 1.0) return 1.0;

  // v is flipped, the same way the directional map does it: the texture's
  // origin is at the top, where row zero of the render target is. Getting this
  // wrong does not tilt the shadow — it makes the top row of faces read the
  // bottom row, so a whole region compares against an unrelated distance and
  // comes out as a black slab.
  vec2 uv = vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
  // The face across, the light down: six tiles wide, four tall.
  vec2 tile = vec2(float(face), float(slot));

  float receiver = distance - point_shadow.params.y;

  // One rotation, shared by the blocker search and the filter. Per fragment,
  // so eight samples read as a soft edge rather than as eight copies of the
  // silhouette: without it every fragment along an edge tests the same eight
  // directions and the pattern shows.
  //
  // **The three constants are not arbitrary and are not ours.** This is Jorge
  // Jimenez's interleaved gradient noise, from "Next Generation Post
  // Processing in Call of Duty: Advanced Warfare" (SIGGRAPH 2014):
  //
  //   IGN(x, y) = frac(52.9829189 * frac(0.06711056 * x + 0.00583715 * y))
  //
  // The pair inside the dot is a direction whose gradient walks the unit
  // interval as slowly as it can while never repeating over a screen, and the
  // multiplier outside stretches that walk so neighbouring pixels land far
  // apart in the result. What it buys over a hash is the cost: one dot and two
  // fracts, no integer arithmetic, no texture. What a blue-noise texture buys
  // over it is a better spectrum, at a sampler and a fetch — worth it for
  // dithering a whole frame, not for rotating eight taps.
  //
  // Written down because three unexplained decimals read as a magic spell, and
  // the next person to touch this line has no way to tell which of them may be
  // changed. The answer is none of them.
  float noise = fract(52.9829189 * fract(dot(FragCoordFromTop(
                                                frag_info.target_origin.x),
                                            vec2(0.06711056, 0.00583715))));
  float angle = noise * 6.28318530718;
  float ca = cos(angle);
  float sa = sin(angle);

  // Guarded rather than read straight, because a zero here divides by zero and
  // a NaN radius poisons the filter into a black fragment. Zero is what an
  // unwritten channel holds, and "unwritten" is a state this block has been in
  // before: every slot is cleared to −1 each frame.
  float tanHalf = max(point_shadow.slots[lightIndex].z, 1e-4);

  float blocker = -1.0;
  float radius =
      PointShadowPenumbra(uv, tile, range, receiver, ca, sa, tanHalf, blocker);

  // The debug channel, and the reason it exists: two explanations for why the
  // estimate collapses were argued from the finished picture and both were
  // wrong, because the number that decides it never leaves this function.
  //
  // Red is how wide the penumbra came out, against the widest allowed. Green
  // is how far away the blocker was, against the light's range. Blue marks
  // the fragments where the search found nothing at all — which is a different
  // answer from "found something very close", and telling those two apart is
  // most of the question.
  if (point_shadow.params2.w > 0.5) {
    g_debug_surface_on = true;
    g_debug_surface = radius < 0.0
        ? vec3(0.0, 0.0, 1.0)
        : vec3(clamp(radius / max(point_shadow.params2.z, 1e-6), 0.0, 1.0),
               clamp(blocker / range, 0.0, 1.0), 0.0);
  }

  // The search found nothing between here and the light.
  if (radius < 0.0) return 1.0;

  float lit = PointShadowTap(uv, vec2(0.0), tile, range, receiver);
  if (radius > 0.0) {
    for (int i = 0; i < 8; i++) {
      lit += PointShadowTap(uv, PointShadowOffset(i, ca, sa, radius), tile,
                            range, receiver);
    }
    lit *= 1.0 / 9.0;
  }

  // Strength lerps towards fully lit, so the control is "how dark", not "how
  // much of the kernel" — the same convention the directional map uses.
  return mix(1.0, lit, clamp(strength, 0.0, 1.0));
}

#else

/// The stand-in for a model that declares none of the above.
///
/// Fully lit, which is what a model with no shadow term means, and a constant
/// the compiler folds rather than a branch anything pays for.
float PointShadowFactor(vec3 world, vec3 normal, int lightIndex) {
  return 1.0;
}

#endif  // F3D_NO_POINT_SHADOW

vec3 AccumulateLights(Surface s) {
  vec3 total = vec3(0.0);
  int count = LightCount();

  for (int i = 0; i < kTotalLights; i++) {
    if (i >= count) break;
    LightSample light = SampleLight(i, s);
    if (light.n_dot_l <= 0.0) continue;
    // A light from the list has no shadow row to read — see `LightHasShadow`.
    // A branch rather than something folded into the two calls, because both
    // index tables eight entries wide and the ninth light would read past them
    // rather than read a one.
    float visibility = LightHasShadow(i)
        ? LightVisibility(s, light, i) *
              PointShadowFactor(v_world_position, s.n, i)
        : 1.0;
    if (visibility <= 0.0) continue;
    total += ShadeLight(s, light) * light.radiance * light.n_dot_l * visibility;
  }

  return total;
}

#endif  // SURFACE_GLSL_

// --- lib/irradiance.glsl ---
// The irradiance field, read per pixel — `L3`.
//
// **Per pixel where it was per object.** The field used to be sampled once
// per draw at the node's centre, twice (up and down), and handed to the shader
// as the hemisphere ambient. A floor that runs from a red wall to a blue one
// then took one colour, whichever its middle saw. Read here, at each point,
// the red bleeds onto the floor near the red wall and fades across it.
//
// The field arrives as one float texture: every probe's irradiance tile (rgb,
// with the probe's "active" flag in alpha) in a grid of `columns` × `rows`
// tiles at the top, and every probe's depth-moment tile (mean and mean
// square) in the same grid below. Each tile carries a one-texel gutter, so a
// bilinear read inside it never needs to know where the tile ends. The read
// is done here, four nearest taps at a time, rather than by a filtered
// sampler: a filtered float texture is a capability three backends answer
// differently, and four taps are the same on all of them.
//
// Weights per probe, as `IrradianceField.sample` on the host: trilinear by
// the point's place in its cell, the square of a half-cosine towards the
// probe, and Chebyshev's bound from the depth moments, the last two floored
// and crushed so no active probe's weight reaches nought. The point is moved
// off its surface along the normal and towards the eye first, so a surface
// does not read the probe's own view of it as a wall.
//
// Included by the lit models only, through `material_maps.glsl`.

#ifndef IRRADIANCE_GLSL_
#define IRRADIANCE_GLSL_

uniform sampler2D irradiance_texture;

layout(std140) uniform IrradianceInfo {
  /// xyz: where probe (0, 0, 0) stands. w: one when the field is read,
  /// nought when the hemisphere ambient stands.
  vec4 origin;

  /// xyz: the spacing between probes per axis. w: how far the point is
  /// moved along the normal, in metres.
  vec4 spacing;

  /// xyz: probes per axis. w: how far the point is moved towards the eye.
  vec4 counts;

  /// x: an irradiance tile's interior, y: a moment tile's, in texels.
  /// z: tiles per row of the atlas. w: the row the moment tiles start at.
  vec4 tiles;

  /// xy: one over the atlas's size. zw unused.
  vec4 atlas;
}
irradiance_info;

bool IrradianceEnabled() { return irradiance_info.origin.w > 0.5; }

/// `encodeOctahedral` in `irradiance_field.dart`.
vec2 ProbeOctahedral(vec3 direction) {
  float sum = abs(direction.x) + abs(direction.y) + abs(direction.z);
  if (sum <= 0.0) return vec2(0.5);
  vec3 n = direction / sum;
  vec2 xy = n.xy;
  if (n.z < 0.0) {
    xy = vec2((1.0 - abs(n.y)) * (n.x >= 0.0 ? 1.0 : -1.0),
              (1.0 - abs(n.x)) * (n.y >= 0.0 ? 1.0 : -1.0));
  }
  return xy * 0.5 + 0.5;
}

vec4 AtlasTexel(vec2 texel) {
  return textureLod(irradiance_texture, (texel + 0.5) * irradiance_info.atlas.xy,
                    0.0);
}

/// A bilinear read of the tile whose top-left stored texel is [corner],
/// [interior] wide, at the octahedral [uv].
vec4 TileBilinear(vec2 corner, float interior, vec2 uv) {
  vec2 at = 1.0 + uv * interior - 0.5;
  vec2 low = floor(at);
  vec2 f = at - low;
  vec4 a = AtlasTexel(corner + low);
  vec4 b = AtlasTexel(corner + low + vec2(1.0, 0.0));
  vec4 c = AtlasTexel(corner + low + vec2(0.0, 1.0));
  vec4 d = AtlasTexel(corner + low + vec2(1.0, 1.0));
  return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}

/// The irradiance arriving at [world] on a surface facing [normal], seen
/// from the direction [view] (a unit vector towards the eye).
vec3 SampleIrradiance(vec3 world, vec3 normal, vec3 view) {
  vec3 origin = irradiance_info.origin.xyz;
  vec3 spacing = irradiance_info.spacing.xyz;
  vec3 counts = irradiance_info.counts.xyz;
  float irradianceTile = irradiance_info.tiles.x;
  float depthTile = irradiance_info.tiles.y;
  float columns = irradiance_info.tiles.z;
  float momentsTop = irradiance_info.tiles.w;
  vec3 unit = normalize(normal);

  vec3 biased = world + unit * irradiance_info.spacing.w +
                view * irradiance_info.counts.w;
  vec3 grid = (biased - origin) / spacing;
  vec3 base = clamp(floor(grid), vec3(0.0), counts - 2.0);
  vec3 f = clamp(grid - base, vec3(0.0), vec3(1.0));

  vec3 total = vec3(0.0);
  float weights = 0.0;
  for (int corner = 0; corner < 8; corner++) {
    vec3 offset = vec3(float(corner & 1), float((corner >> 1) & 1),
                       float((corner >> 2) & 1));
    vec3 cell = base + offset;
    float probe = (cell.z * counts.y + cell.y) * counts.x + cell.x;
    vec2 tile = vec2(mod(probe, columns), floor(probe / columns));

    vec2 irradianceCorner = tile * (irradianceTile + 2.0);
    vec2 momentCorner = vec2(tile.x * (depthTile + 2.0),
                             momentsTop + tile.y * (depthTile + 2.0));

    // The probe's own flag, on the tile's first interior texel.
    if (AtlasTexel(irradianceCorner + 1.0).a < 0.5) continue;

    vec3 trilinear = mix(vec3(1.0) - f, f, offset);
    float weight = max(trilinear.x * trilinear.y * trilinear.z, 0.001);

    vec3 probePosition = origin + spacing * cell;
    vec3 toProbe = probePosition - biased;
    float distance = length(toProbe);
    if (distance > 1e-6) {
      vec3 direction = toProbe / distance;
      // Facing and visibility are floored, then crushed, rather than let
      // fall to nought (Majercik et al. 2019): a probe behind the surface or
      // past a wall counts for almost nothing but never for nothing, so a
      // point every probe of its cell is cut off from still reads a blend of
      // them rather than black.
      float facing = dot(unit, normalize(probePosition - world)) * 0.5 + 0.5;
      float probeWeight = facing * facing + 0.2;

      vec2 moments = TileBilinear(momentCorner, depthTile,
                                  ProbeOctahedral(-direction)).xy;
      float chebyshev = 1.0;
      if (distance > moments.x) {
        float variance = max(moments.y - moments.x * moments.x, 1e-6);
        float difference = distance - moments.x;
        chebyshev = variance / (variance + difference * difference);
        chebyshev = chebyshev * chebyshev * chebyshev;
      }
      probeWeight = max(probeWeight * max(chebyshev, 0.05), 1e-6);
      if (probeWeight < 0.2) probeWeight *= probeWeight * probeWeight * 25.0;
      weight *= probeWeight;
    }

    total += TileBilinear(irradianceCorner, irradianceTile,
                          ProbeOctahedral(unit)).rgb *
             weight;
    weights += weight;
  }
  return weights > 0.0 ? total / weights : vec3(0.0);
}

#endif  // IRRADIANCE_GLSL_


/// Tangent-space normal map. Neutral is (0.5, 0.5, 1.0).
uniform sampler2D normal_texture;

/// glTF's ORM packing: g is roughness, b is metallic. Neutral is white.
uniform sampler2D metallic_roughness_texture;

/// Ambient occlusion in r. Neutral is white.
uniform sampler2D occlusion_texture;

/// Emitted colour, multiplied by the emissive factor. Neutral is white, and the
/// factor defaults to black, so a material with neither emits nothing.
uniform sampler2D emissive_texture;

/// The level's baked lightmap, RGBM: colour over a shared multiplier, decoded
/// as `rgb × a × 8`. Sampled at the second coordinate, which every vertex
/// stage but the lightmapped one leaves at the atlas corner; neutral is
/// black, so a material without a map adds nothing.
uniform sampler2D lightmap_texture;

/// The irradiance the lightmap holds at this fragment, in the units a light's
/// `colour × intensity × attenuation × cos` arrives in.
vec3 SampleLightmap() {
  vec4 texel = texture(lightmap_texture, v_lightmap_uv);
  return texel.rgb * texel.a * 8.0;
}

/// One function per map, rather than one that applies all four.
///
/// Not a style choice. The compiler drops a sampler whose result never reaches
/// the output, so a model that samples the ORM map and then ignores metallic and
/// roughness — Lambert does exactly that — ends up with no
/// `metallic_roughness_texture` in its compiled signature at all, while the Dart
/// side still thinks there is one to bind. That is the phantom-binding trap
/// again, and binding a slot Metal does not have is a native crash.
///
/// Splitting them means a model calls only what it genuinely uses, so the
/// compiled signature matches the source, and `LightingModel` can declare the
/// same set truthfully. `tool/build_shaders.sh` prints the compiled slots so
/// the two cannot drift apart unnoticed.

/// glTF's ORM packing: roughness in g, metallic in b, both multiplying the
/// material factors.
void ApplyMetallicRoughnessMap(inout Surface s) {
  vec3 orm = texture(metallic_roughness_texture, MapUv(kMapMetallicRoughness), MaterialLodBias()).rgb;
  s.metallic = clamp(s.metallic * orm.b, 0.0, 1.0);
  s.roughness = clamp(s.roughness * orm.g, 0.02, 1.0);
}

void ApplyOcclusionMap(inout Surface s) {
  float occlusion = texture(occlusion_texture, MapUv(kMapOcclusion), MaterialLodBias()).r;
  // glTF's occlusionStrength lerps between "ignore the map" and "apply it in
  // full", which is why it is a mix and not a multiply.
  s.occlusion = mix(1.0, occlusion, clamp(frag_info.material2.z, 0.0, 1.0));
}

void ApplyEmissiveMap(inout Surface s) {
  vec3 emissive = SrgbToLinear(texture(emissive_texture, MapUv(kMapEmissive), MaterialLodBias()).rgb);
  s.emissive = emissive * frag_info.emissive.rgb * frag_info.material2.w;
}

/// Perturbs the surface normal by the tangent-space normal map.
void ApplyNormalMap(inout Surface s) {
  // **Sampled before the frame is tested, and that order is load-bearing.**
  // The test below is a branch on interpolated data, so the four invocations of
  // a quad can take different sides of it; a WGSL backend then refuses a
  // `texture` call underneath, because the mip level it derives is only defined
  // where the whole quad agrees. Unlike the shadow atlases, this map really is
  // mipped — a normal map read at full resolution on a surface turned away from
  // the camera is the aliasing that made this the widest disagreement between
  // backends — so pinning a level here would be a picture change, and hoisting
  // the sample is the cure that is not. A degenerate tangent is rare enough
  // that paying for its unused texel is nothing, and the texel it reads is the
  // same one the branch would have read.
  vec4 sampledTexel = texture(normal_texture, MapUv(kMapNormal), MaterialLodBias());

  // The tangent is re-orthogonalized against the normal because interpolating
  // both across a triangle does not preserve the right angle between them.
  vec3 t = v_tangent.xyz;
  t = t - s.n * dot(s.n, t);
  if (dot(t, t) < 1e-12) return;  // no usable frame; keep the vertex normal
  t = normalize(t);

  // The bitangent sign is what encodes a mirrored UV island. Dropping it makes
  // every mirrored half of a symmetric model light from the wrong side, which
  // is exactly what NormalTangentTest is built to show.
  vec3 b = cross(s.n, t) * v_tangent.w;
#ifdef F3D_TEXTURE_TRANSFORM
  // `C8`: a map turned or mirrored by its transform is read along axes the
  // vertex tangent no longer names, so the frame turns with it — the rule
  // `withTextureTransform` applies to a baked mesh, here at the sampler. The
  // new tangent is where the map's own `u` increases: the first column of the
  // matrix's inverse, times its determinant, whose sign a mirror flips and the
  // bitangent's sign with it. Measured on the front face's frame, which is
  // the frame the transform was authored on. A plain scale leaves the frame
  // as it was, bit for bit, which is why the test is on the matrix. That
  // column is `m11 dP/du - m10 dP/dv`, and dP/dv is **minus** the bitangent:
  // `v` runs down the texture, a normal map's green up it.
  vec4 m = MapMatrix(kMapNormal);
  float det = m.x * m.w - m.y * m.z;
  float flip = det < 0.0 ? -1.0 : 1.0;
  vec3 front = gl_FrontFacing ? b : -b;
  vec3 turned = (t * m.w + front * m.z) * flip;
  bool turns = (m.y != 0.0 || m.z != 0.0 || m.x < 0.0 || m.w < 0.0) &&
               dot(turned, turned) > 1e-12;
  t = turns ? normalize(turned) : t;
  b = turns ? cross(s.n, t) * v_tangent.w * flip : b;
#endif
  // On a back face `ReadSurface` has already turned the normal round, and
  // the bitangent above turned with it. The tangent has to follow, or the
  // frame is half-mirrored and relief along u lights from the wrong side —
  // glTF turns the whole frame, not the normal alone.
  if (!gl_FrontFacing) t = -t;

  vec3 sampled = sampledTexel.xyz * 2.0 - 1.0;
  // A two-channel map (BC5, RG8) stores only x and y and samples as
  // (x, y, 0, 1); read as it stands, blue 0 is z = -1 and the normal points
  // into the surface. z is rebuilt from the unit length instead, before the
  // scale, which glTF applies to the stored normal. `emissive.w` is the flag.
  if (frag_info.emissive.w > 0.5) {
    sampled.z = sqrt(max(1.0 - dot(sampled.xy, sampled.xy), 0.0));
  }
  // normalScale attenuates the tangent-space xy, per the glTF spec.
  sampled.xy *= frag_info.material2.y;

  s.n = normalize(t * sampled.x + b * sampled.y + s.n * sampled.z);
  s.n_dot_v = max(dot(s.n, s.v), 1e-4);
}

/// The three maps every lit model uses. Metal-rough is separate because only
/// the models that actually respond to metallic or roughness may sample it.
void ApplyCommonMaps(inout Surface s) {
  // `L3`: the field in place of the hemisphere, read before the normal map
  // for the reason the hemisphere is — which half of the room a face sees is
  // not a question about millimetres of relief. At the same strength the
  // hemisphere was.
  if (IrradianceEnabled()) {
    s.ambient = SampleIrradiance(v_world_position, s.n, s.v) *
                frag_info.material.z;
  }
  ApplyNormalMap(s);
  ApplyOcclusionMap(s);
  ApplyEmissiveMap(s);
}

#endif  // MATERIAL_MAPS_GLSL_

// --- lib/shadow.glsl ---
// Sampling the directional light's shadow map.
//
// A separate header for the same reason material_maps.glsl is one: the sampler
// must only be declared by shaders that actually read it, or the compiler drops
// the slot while the engine still tries to bind it.

#ifndef SHADOW_GLSL_
#define SHADOW_GLSL_

// --- lib/evsm.glsl ---
// Exponential variance shadow maps — `S2`.
//
// Shared by the pass that turns the directional depth atlas into moments
// (`evsm_filter.frag`) and by `ShadowFactor`, which reads them back: the two
// halves must warp depth with the same two exponents, or every comparison is
// between numbers on different scales.
//
// A header of its own rather than a section of `shadow.glsl`, because that
// one declares the lit stages' shadow sampler and the filter pass has no
// business declaring it.

#ifndef EVSM_GLSL_
#define EVSM_GLSL_

precision highp float;

// The two exponents depth is warped by. **Forty and five, and the ceiling is
// the format.** The moments are stored squared, so the positive side reaches
// e^80 at the far plane, about 5.5e34 — inside a 32-bit float with three
// orders of magnitude to spare, and far outside a half float, which is why
// the moments live in an rgba32f atlas and the depth atlas does not. The
// negative side only has to catch what the positive side lets through at a
// receiver just behind a caster, and five is the usual answer.
const float kEvsmPositive = 40.0;
const float kEvsmNegative = 5.0;

/// [depth], in [0, 1], warped onto both exponentials: x positive, y negative.
///
/// Depth is first spread to [-1, 1] so the two sides share the range evenly
/// rather than the negative one flattening to nothing at the far end.
vec2 EvsmWarp(float depth) {
  float d = 2.0 * clamp(depth, 0.0, 1.0) - 1.0;
  return vec2(exp(kEvsmPositive * d), -exp(-kEvsmNegative * d));
}

/// What one texel of the depth atlas stores in the moments atlas: each warp
/// and its square, which a blur then averages into a mean and a variance.
vec4 EvsmMoments(float depth) {
  vec2 warped = EvsmWarp(depth);
  return vec4(warped.x, warped.x * warped.x, warped.y, warped.y * warped.y);
}

/// Chebyshev's upper bound on the share of [moments]'s distribution at or
/// beyond [t], with the light-bleeding cut [bleed] taken off the bottom.
///
/// A select at the end rather than an early return of one, because a phi of
/// constants is what SPIRV-Cross refuses when it writes the WGSL.
float EvsmChebyshev(vec2 moments, float t, float minVariance, float bleed) {
  float variance = max(moments.y - moments.x * moments.x, minVariance);
  float d = t - moments.x;
  float pMax = variance / (variance + d * d);
  // Light bleeding: where two casters overlap, the bound admits light the
  // nearer one should block. Everything under [bleed] is called shadow and
  // the rest stretched back over [0, 1].
  float reduced = clamp((pMax - bleed) / max(1.0 - bleed, 1e-4), 0.0, 1.0);
  return t <= moments.x ? 1.0 : reduced;
}

/// How much light reaches a receiver at [depth] past filtered [moments].
///
/// The smaller of the two bounds: each exponential lets through a different
/// kind of error, and neither lets through what the other stops.
float EvsmVisibility(vec4 moments, float depth, float bleed) {
  vec2 warped = EvsmWarp(depth);
  // A floor on the variance proportional to the warped depth's own slope,
  // so a flat receiver compared against its own texel does not divide
  // nought by nought — the variance of one depth is zero.
  vec2 scale = 0.0001 * vec2(kEvsmPositive, kEvsmNegative) * warped;
  float positive = EvsmChebyshev(moments.xy, warped.x, scale.x * scale.x, bleed);
  float negative = EvsmChebyshev(moments.zw, warped.y, scale.y * scale.y, bleed);
  return min(positive, negative);
}

#endif  // EVSM_GLSL_


/// Linear depth from the light's point of view, in the red channel — or,
/// with the `evsm` filter (`S2`), the blurred moments `evsm_filter.frag`
/// made of it, bound to the same slot so the lit stages spend no sampler on
/// the choice.
uniform sampler2D shadow_texture;

/// Point [i] of [n] on a Vogel disc turned by [turn] radians — `S3`: the
/// golden angle between neighbours, so any prefix of the points covers the
/// disc evenly, and a radius growing with the square root, so they cover it
/// at an even density.
vec2 VogelDisc(int i, int n, float turn) {
  float r = sqrt((float(i) + 0.5) / float(n));
  float theta = float(i) * 2.3999632 + turn;
  return r * vec2(cos(theta), sin(theta));
}

/// Interleaved gradient noise at this pixel, in [0, 1), stepped on by the
/// frame's slice while a temporal resolve runs (`target_origin.w`) so the
/// history averages the rotations. The pattern needs no texture, which keeps
/// the lit stages at the samplers they have. Rows are counted from the top
/// (`target_origin.x`), as the point shadow's rotation counts them, so WebGL2
/// turns the kernel on the same pixels as every other backend.
float ShadowNoise() {
  vec2 at = FragCoordFromTop(frag_info.target_origin.x) +
            5.588238 * max(frag_info.target_origin.w, 0.0);
  return fract(52.9829189 * fract(dot(at, vec2(0.06711056, 0.00583715))));
}

/// How much of the light survives at this fragment, from 0 to 1.
///
/// Returns 1 when shadows are off, when the fragment falls outside the map, or
/// when the light in question is not the caster — a fragment beyond the shadow
/// volume is unshadowed, not black, and getting that wrong puts a hard edge
/// across the scene at the edge of the map.
float ShadowFactor(Surface s, LightSample light, int lightIndex) {
  float strength = frag_info.shadow_params.w;
  if (strength <= 0.0) return 1.0;
  if (lightIndex != int(frag_info.frame_params.z + 0.5)) return 1.0;

  // Normal offset: move the sample point along the surface normal before
  // projecting it. It costs nothing and fixes the shadow acne that a depth bias
  // alone cannot, because the error is proportional to the surface's slope
  // relative to the light rather than to depth.
  //
  // **A flat distance plus what the kernel's reach needs, and no more.** The
  // flat part alone was tuned for surfaces the map never recorded: with the
  // default `casterFaces: back` a closed mesh writes only the faces turned
  // away from the sun, so a lit face compares against its own far side. A
  // double-sided material writes its lit faces too, and then the offset has
  // to lift the point clear of its own plane as far out as the 3×3 kernel
  // reads: a tap one texel over lands in a texel whose centre is up to a
  // texel and a half away, where the plane is 1.5·texel·tanθ nearer the
  // light. A step d along the normal clears the plane by d / cosθ along the
  // ray, so d = 1.5·texel·sinθ is exactly enough, taken per axis of the map
  // because a slope running diagonally across it reaches further in texels.
  // Nothing at normal incidence, a texel and a half at grazing. The depth
  // bias covers the rest. Every metre more than this moves the shadow away
  // from its caster, and in the far cascade a texel is decimetres. Measured
  // per cascade in the loop below, since each has a texel of its own.

  // Which cascade covers this fragment.
  //
  // Chosen by distance from the camera and then *checked*, because the volumes
  // are spheres on the line of sight rather than fitted frusta: a fragment at
  // the edge of the view can be past the end of the cascade its distance
  // suggests. Falling through to the next one costs a branch and removes a
  // whole class of missing-shadow bug, and the last cascade is fitted to the
  // entire scene, so the fall-through always terminates somewhere real.
  int cascadeCount = int(frag_info.shadow_cascades.z + 0.5);
  float viewDistance = length(v_world_position - frag_info.camera_position.xyz);
  int cascade = 0;
  if (cascadeCount > 1 && viewDistance > frag_info.shadow_cascades.x) cascade = 1;
  if (cascadeCount > 2 && viewDistance > frag_info.shadow_cascades.y) cascade = 2;

  vec2 uv = vec2(0.0);
  vec3 projected = vec3(0.0);
  bool found = false;
  // `S3`: what the soft path needs of the cascade it lands in — metres per
  // texel across, and metres per unit of stored depth along the light.
  float cascadeTexel = 1.0;
  float cascadeDepth = 1.0;
  for (int attempt = 0; attempt < 3; attempt++) {
    int which = cascade + attempt;
    if (which >= cascadeCount) break;

    mat4 matrix = which == 0
        ? frag_info.shadow_matrix
        : (which == 1 ? frag_info.shadow_matrix_far
                      : frag_info.shadow_matrix_farthest);
    // One texel of this cascade in metres. The projection is orthographic,
    // so its first row is 2 / width, and a tile texel is `shadow_cascades.w`
    // of the width. The rows are also the map's axes in the world, which is
    // what the normal is measured along: its share across each axis is the
    // sine of the slope in that direction.
    vec3 axisX = vec3(matrix[0][0], matrix[1][0], matrix[2][0]);
    vec3 axisY = vec3(matrix[0][1], matrix[1][1], matrix[2][1]);
    float rowX = max(length(axisX), 1e-6);
    float rowY = max(length(axisY), 1e-6);
    float texelMetres = 2.0 * frag_info.shadow_cascades.w / rowX;
    float reach = 1.5 * 2.0 * frag_info.shadow_cascades.w *
        (abs(dot(s.n, axisX)) / (rowX * rowX) +
         abs(dot(s.n, axisY)) / (rowY * rowY));
    vec3 origin = v_world_position + s.n * (frag_info.shadow_params.z + reach);
    vec4 lightSpace = matrix * vec4(origin, 1.0);
    if (lightSpace.w <= 0.0) continue;
    vec3 candidate = lightSpace.xyz / lightSpace.w;

    // Clip space x and y are in [-1, 1]; a tile is in [0, 1] with the origin at
    // the top, matching where the render target's row zero is.
    vec2 inTile = vec2(candidate.x * 0.5 + 0.5, 0.5 - candidate.y * 0.5);
    if (inTile.x < 0.0 || inTile.x > 1.0 || inTile.y < 0.0 || inTile.y > 1.0) {
      continue;
    }
    // Depth is already in [0, 1] here, as every projection in this engine
    // produces. **Past the far plane is behind every caster, not outside the
    // map.** The last cascade's depth is fitted to the casters alone, so a
    // floor that runs on past them — the tip of a long evening shadow — sits
    // beyond it. Skipping that point called it lit and cut the shadow off
    // along the line where the far plane meets the floor. A nearer cascade
    // may still be missing casters and hands the point on; the last one
    // clamps, and 1.0 compares lit only against a texel nothing was drawn in.
    if (candidate.z > 1.0) {
      if (which < cascadeCount - 1) continue;
      candidate.z = 1.0;
    }

    // Into the atlas: the cascades sit side by side in one texture.
    uv = vec2((inTile.x + float(which)) / float(cascadeCount), inTile.y);
    projected = candidate;
    cascade = which;
    cascadeTexel = texelMetres;
    cascadeDepth =
        1.0 / max(length(vec3(matrix[0][2], matrix[1][2], matrix[2][2])), 1e-6);
    found = true;
    break;
  }
  if (!found) return 1.0;

  float bias = cascade == 0
      ? frag_info.shadow_bias.x
      : (cascade == 1 ? frag_info.shadow_bias.y : frag_info.shadow_bias.z);
  // Horizontally a texel of the atlas, vertically a texel of a tile. With one
  // cascade they are the same number and this is the kernel it has always been.
  vec2 texel = vec2(frag_info.shadow_params.x, frag_info.shadow_cascades.w);

  // **Every tap is held inside its own cascade's tile**, half a texel in from
  // the edge, and after the offset rather than before: the cube atlas learned
  // this first (`PointShadowDistance`). The cascades sit side by side, so a
  // tap that stepped past a seam read the neighbouring cascade's depth,
  // measured through another projection, and a fragment at the edge of the
  // near tile took its shadow partly from the far one. With one cascade the
  // tile is the whole texture and the clamp is the sampler's own edge.
  vec2 tileLo = vec2(float(cascade) / float(cascadeCount), 0.0) + 0.5 * texel;
  vec2 tileHi =
      vec2(float(cascade + 1) / float(cascadeCount), 1.0) - 0.5 * texel;

  // **`textureLod` and not `texture`, and the level asked for is the only one
  // there is.** Everything above this loop is a reason not to be here — the
  // cascade search returns early when no cascade contains the fragment, and the
  // light loop that calls it skips a light facing away — so a WGSL backend sees
  // a sample taken where the four invocations of a quad need not agree, and
  // refuses it: the implicit derivative `texture` asks for is only defined
  // where they all arrive. The cascade atlas is a depth render target with a
  // single level, so the derivative was never doing anything but selecting
  // level zero, and naming that level directly costs nothing and changes no
  // pixel on any backend.
  //
  // **The softness, where it rides, and what zero means.**
  //
  // `ambient_ground.w` is the directional light's apparent size. It has
  // nothing to do with ambient light and everything to do with this being the
  // one component left unspent in a block six shaders share: `frame_params.w`
  // was the slot reserved for exactly this and the environment's level count
  // took it, and appending to the block moves offsets four backends have
  // agreed on. The alternative was a second uniform block bound per draw for
  // one float. Named here because a reader arriving at `ambient_ground` has
  // every right to be surprised.
  //
  // Zero is the 3×3 kernel this has always had, which is what keeps every
  // recorded golden where it is. Above zero the edge widens with the distance
  // between the occluder and what it falls on — what a real light does, and
  // what no fixed kernel can.
  //
  // **Below zero is the `evsm` filter** (`S2`), and the texture bound here is
  // then the moments atlas rather than depth: one filtered tap replaces the
  // kernel, and how far under minus one the value sits is the light-bleeding
  // cut. A sign rather than another uniform, for the reason the softness
  // itself rides here.
  float softness = frag_info.ambient_ground.w;
  float lit = 0.0;
  if (softness < 0.0) {
    // The blur already happened, once for the whole atlas, so the one tap
    // is the filter: the sampler's own bilinear step is all it adds.
    vec4 moments = textureLod(shadow_texture, clamp(uv, tileLo, tileHi), 0.0);
    lit = EvsmVisibility(moments, projected.z - bias,
                         clamp(-softness - 1.0, 0.0, 0.95));
  } else if (softness <= 0.0) {
    // PCF 3x3. Four samples would band visibly at this map size and nine is
    // the smallest kernel that reads as a soft edge rather than as stair
    // steps.
    for (int y = -1; y <= 1; y++) {
      for (int x = -1; x <= 1; x++) {
        float occluder = textureLod(
            shadow_texture,
            clamp(uv + vec2(float(x), float(y)) * texel, tileLo, tileHi),
            0.0).r;
        lit += projected.z - bias > occluder ? 0.0 : 1.0;
      }
    }
    lit *= 1.0 / 9.0;
  } else {
    // **Find what is casting before deciding how wide to blur**, then blur by
    // what a light of this size would leave — `S3`. Sixteen taps each way on
    // a Vogel disc turned per pixel, where there were five fixed ones: the
    // turn trades the five's regular pattern for noise the eye reads as
    // grain, and a temporal resolve averages away.
    //
    // **In metres, per cascade.** The gap between the blocker and this
    // fragment is measured in the cascade's stored depth, whose unit is a
    // different length in each cascade; converted to metres, the penumbra is
    // the gap times the light's apparent diameter, and in texels it is that
    // over the cascade's own texel. A shadow keeps its softness crossing
    // from one cascade into the next.
    //
    // **A radius, so half that width.** A disc of radius R swept across an
    // edge ramps from dark to lit over 2R, so the kernel is the gap times
    // the tangent of the light's angular *radius*: the penumbra comes out the
    // full `2·tan(α)·gap` the settings promise, not twice it. The search is
    // the same cone, `tan(α)` of the way back to the light; a wider one only
    // pulls in blockers that cannot reach this fragment.
    float spread = tan(min(softness, 0.5));
    float turn = ShadowNoise() * 6.2831853;

    // As wide as the widest penumbra could be at this depth, and no wider:
    // the whole of the distance back to the light is the largest gap there
    // is.
    float searchRadius =
        clamp(spread * projected.z * cascadeDepth / cascadeTexel, 1.0, 16.0);
    float blockerSum = 0.0;
    float blockerCount = 0.0;
    for (int i = 0; i < 16; i++) {
      float occluder = textureLod(
          shadow_texture,
          clamp(uv + VogelDisc(i, 16, turn) * texel * searchRadius, tileLo,
                tileHi),
          0.0).r;
      if (projected.z - bias > occluder) {
        blockerSum += occluder;
        blockerCount += 1.0;
      }
    }
    // Nothing between this fragment and the light: lit, and no second loop.
    if (blockerCount <= 0.0) return 1.0;

    float gap = max(projected.z - blockerSum / blockerCount, 0.0) * cascadeDepth;
    // One texel at the tightest, so a contact edge stays an edge; the cap
    // keeps a distant occluder from reaching across a whole cascade.
    float radius = clamp(spread * gap / cascadeTexel, 1.0, 16.0);

    for (int i = 0; i < 16; i++) {
      float occluder = textureLod(
          shadow_texture,
          clamp(uv + VogelDisc(i, 16, turn + 1.0) * texel * radius, tileLo,
                tileHi),
          0.0).r;
      lit += projected.z - bias > occluder ? 0.0 : 1.0;
    }
    lit *= 1.0 / 16.0;
  }

  // Strength lerps towards fully lit, so the control is "how dark", not "how
  // much of the kernel".
  return mix(1.0, lit, clamp(strength, 0.0, 1.0));
}

#endif  // SHADOW_GLSL_


float LightVisibility(Surface s, LightSample light, int index) {
  return ShadowFactor(s, light, index);
}

vec3 ShadeLight(Surface s, LightSample light) {
  // The radiance and the N.L factor are applied by AccumulateLights, so the
  // model itself only says how the surface responds.
  return s.albedo;
}

void main() {
  Surface s = ReadSurface();
  // No ORM map: a purely diffuse model has no response to metallic or
  // roughness, so sampling it would leave a slot the compiler then drops.
  ApplyCommonMaps(s);
  vec3 ambient = s.albedo * (s.ambient + SampleLightmap()) * s.occlusion;
  WriteSurface(
      AccumulateLights(s) * s.occlusion + ambient + s.emissive,
      s.alpha,
      s.roughness);
}

''',
    'BlinnPhong': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// Blinn-Phong: diffuse plus a half-vector specular lobe.
//
// Not energy conserving and not physically based, but it is what most older
// engines shipped, and it stays useful for stylised looks where a controllable
// highlight matters more than correctness.
// --- lib/material_maps.glsl ---
// The texture maps a lit material can carry, beyond base colour.
//
// A separate header from surface.glsl on purpose. Declaring a sampler a shader
// never reads is the same trap as declaring an unused uniform block: the
// compiled function has no such slot, while the Dart side still has metadata
// saying it does. Unlit and the debug models include surface.glsl (or only
// color.glsl) and get none of this; the lit models include both, and
// LightingModel.usesMaterialTextures says which is which.
//
// Every map has a *neutral* fallback texture bound when the material has none,
// so there are no "has this map" flags to keep in sync — a white ORM texture
// multiplies the factors by one, and a flat normal map perturbs nothing. Flags
// would have to be right in two places; a neutral texel is right by
// construction.

#ifndef MATERIAL_MAPS_GLSL_
#define MATERIAL_MAPS_GLSL_

// --- lib/surface.glsl ---
// Shared material and lighting interface for the lighting models.
//
// flutter_gpu compiles shaders ahead of time into a bundle: there is no runtime
// compilation, so a node-graph material system assembled at run time is
// impossible. Each lighting model is therefore
// its own pre-built fragment shader, and this header is what keeps them
// interchangeable — one identical uniform block, so the Dart binding code never
// needs to know which model is active.
//
// Keep every declaration below byte-identical across models. A member a model
// does not read may be optimized out of the reflected block, which is why the
// Dart side skips absent members instead of failing.
//
// Only include this from a shader that actually reads FragInfo. Declaring the
// block without using it leaves it visible to reflection while the compiled
// shader binds no buffer for it, and binding that phantom block segfaults
// inside Metal. Shaders needing only colour helpers include lib/color.glsl.

#ifndef SURFACE_GLSL_
#define SURFACE_GLSL_

// --- lib/color.glsl ---
// Colour space helpers and the fragment output interface.
//
// Split out of surface.glsl so a shader that needs no material inputs — the
// normals debug view — can avoid DECLARING the FragInfo uniform block at all.
// That matters more than it looks: reflection metadata reports a block as
// present merely because it was declared, even when the compiled shader binds
// no such buffer, so a declared-but-unused block is indistinguishable from a
// used one until Metal crashes on the bind.

#ifndef COLOR_GLSL_
#define COLOR_GLSL_

precision highp float;

const float kPi = 3.14159265359;

// One varying set shared by every fragment shader, matching mesh.vert.
//
// All five are declared here, including the two the debug models never read: a
// fragment shader whose `in` block disagrees with the vertex shader's `out`
// block fails to link, and there is no partial-match rule to lean on.
in vec3 v_world_position;
in vec3 v_normal;
in vec2 v_texcoord;
in vec4 v_tangent;
in vec4 v_color;

/// Where this fragment is in the level's lightmap. Zero from every vertex
/// stage but `mesh_lightmapped.vert`, and read only by the lit models, which
/// sample a one-texel black there when a material has no map.
in vec2 v_lightmap_uv;

layout(location = 0) out vec4 frag_color;

// The second attachment: what a screen-space effect needs to know about the
// surface it is looking at. World-space normal in rgb, and in a the depth along
// the view axis in world metres — not a window depth; `WriteSurfaceGeometry`
// says at length why not.
//
// Depth travels here rather than in a depth texture because flutter_gpu cannot
// sample one — the same reason the shadow pass writes its depth into a colour
// target. See ARCHITECTURE.md §2.
//
// Guarded, because not every stage that includes this header draws into a
// two-attachment target. The shadow pass draws into one, and a pipeline
// declaring an output its target has no slot for is a mismatch worth avoiding
// rather than discovering.
#ifndef F3D_NO_SURFACE_BUFFER
layout(location = 1) out vec4 frag_surface;

/// The surface's own colour, sRGB-encoded, alpha one where a surface was
/// drawn — `L5`. The third attachment, present only when a pass reads it (the
/// indirect light does) and the device opens three; like the surface buffer,
/// written unconditionally and discarded when absent. Stored in the surface
/// buffer's format rather than eight bits a channel, and `Renderer` says why.
layout(location = 2) out vec4 frag_albedo;
#endif

/// What [frag_albedo] carries: the lit models set it in `ReadSurface`, and a
/// stage that reflects nothing — unlit, the debug views — leaves it black,
/// which is what light bounced onto it would come to.
vec3 g_albedo = vec3(0.0);

/// Octahedral encoding: a unit vector in two channels instead of three.
///
/// Worth the arithmetic because the fourth channel is already spent on depth,
/// and without a free channel there is nowhere to put roughness — which is the
/// difference between a reflection that knows stone from a mirror and one that
/// does not. The error is well under a degree, far below anything a reflection
/// off rough stone would show.
vec2 EncodeOctahedral(vec3 n) {
  n /= abs(n.x) + abs(n.y) + abs(n.z);
  vec2 e = n.xy;
  if (n.z < 0.0) {
    e = (1.0 - abs(n.yx)) * vec2(n.x >= 0.0 ? 1.0 : -1.0,
                                 n.y >= 0.0 ? 1.0 : -1.0);
  }
  return e * 0.5 + 0.5;
}

/// Where a debug pass leaves the picture it wants shown instead of the normal.
///
/// Declared here, in the header every lit shader includes **first**, and
/// written from surface.glsl, which is included after. The alternative was a
/// new member on a shared uniform block; a global costs nothing and moves no
/// offsets. It is read at the moment the surface buffer is written, which
/// happens after the lighting loop has run, so the value is there by then.
vec3 g_debug_surface = vec3(0.0);
bool g_debug_surface_on = false;

/// Whether [WriteSurface] weights the colour by its alpha: set by
/// `ReadSurface` for a material that blends, and false for everything else.
///
/// **The blend takes its source as premultiplied**, so a blended surface has
/// to hand it the colour times the alpha — a pane at a fifth of opaque adds a
/// fifth of its light, not all of it. glTF's blend mode is Porter and Duff's
/// over on straight colour, and this is the one place that turns the lit
/// radiance into what that means. An opaque or masked surface keeps its
/// colour whole: its alpha is not a coverage, and nothing blends it.
/// A global for the reason [g_debug_surface] is one.
bool g_premultiply = false;

// **A stage that needs none of this must be able to declare none of it.** On
// Vulkan both stages' descriptors are merged into one set layout, and two
// bindings with the same number in it is not a layout the specification
// allows. A driver may accept it anyway; a Galaxy A55's refuses the pipeline
// with `ErrorUnknown` and no other word, which is how the shadow pass came to
// build everywhere except there — its only uniform block was this one, and it
// landed on the same binding as the vertex stage's first.
#ifndef F3D_NO_FOG

/// Distance fog, in its own block rather than folded into FragInfo.
///
/// Its own because color.glsl is included before FragInfo is declared, and
/// because appending to a block that half a dozen shaders already share is a
/// way to move offsets nobody expected to move. Three vec4s is a cheap price
/// for not touching any of that.
layout(std140) uniform FogInfo {
  /// rgb: linear fog colour. w: density per metre, zero for no fog.
  vec4 fog;

  /// xyz: camera position in world space. Duplicated from FragInfo so this
  /// block stands alone; a vec3 is cheaper than a coupling.
  vec4 eye;

  /// xyz: the direction the camera looks, as a unit vector in world space.
  /// w: what a transparent draw writes under weighted blended transparency —
  /// `R8`, see `WriteWeightedBlended`. Zero for every other draw.
  ///
  /// Here rather than in a block of its own because it answers the same
  /// question [eye] does — where the camera is and which way it faces — and
  /// this is the block `color.glsl` can see.
  vec4 forward;
}
fog_info;

/// How far this fragment is from the eye, in world metres.
///
/// What the fog fades by. Distance rather than depth, because fog is a
/// property of the air between two points and does not care which way the
/// camera happens to face.
float EyeDistance() { return distance(v_world_position, fog_info.eye.xyz); }

/// How far this fragment is *along the view axis*, in world metres.
///
/// What the surface buffer's alpha holds. Depth rather than distance, and the
/// difference only shows on an orthographic camera — where the rays through
/// the pixels are parallel instead of meeting at the eye, so a distance from
/// the eye names a sphere that the pixel's ray crosses somewhere the reader
/// cannot solve for. A depth along the axis names a plane, which every ray
/// crosses exactly once. See `WorldAtDepth` in `post/ssao.frag` for the
/// reconstruction both projections share.
float ViewDepth() {
  return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz);
}

#else  // F3D_NO_FOG

// The same two questions, answered without the block: a stage that declares no
// fog has no eye position to measure from either. Stubs rather than a guard at
// every call site, so that what includes this file reads the same whichever
// way it was compiled.
float EyeDistance() { return 0.0; }
float ViewDepth() { return 0.0; }

#endif  // F3D_NO_FOG

/// sRGB to linear. Textures are authored in sRGB, but lighting is only correct
/// in linear space; skipping this is what makes naive renderers look muddy.
vec3 SrgbToLinear(vec3 srgb) {
  return mix(
      srgb / 12.92,
      pow((srgb + vec3(0.055)) / 1.055, vec3(2.4)),
      step(vec3(0.04045), srgb));
}

/// Linear to sRGB. The render target is a plain UNorm format rather than an
/// sRGB one, so the encode has to happen here.
vec3 LinearToSrgb(vec3 linear) {
  return mix(
      linear * 12.92,
      1.055 * pow(linear, vec3(1.0 / 2.4)) - vec3(0.055),
      step(vec3(0.0031308), linear));
}

/// Writes scene-referred linear light into the HDR target.
///
/// No tone map and no sRGB encode: those moved into the composite pass, which
/// is the entire point of rendering into `r16g16b16a16Float` first. Applying
/// them here meant every model wrote display-referred colour into an 8-bit
/// buffer, so anything above display white was gone before post-processing
/// could see it — and bloom is a function of exactly that.
///
/// Exposure moved with them, for the same reason: it belongs on the same side
/// of the display transform as the tone map.
/// Records the geometry of this fragment for whatever runs after the scene.
///
/// Called from the same place that writes colour, so a surface cannot be lit
/// into the frame without also describing itself — which is the failure that
/// leaves a screen-space effect reflecting whatever was in the buffer before.
///
/// rg: octahedral normal. b: perceptual roughness. a: **depth along the view
/// axis, in world metres** — see [ViewDepth].
///
/// **Not `gl_FragCoord.z`, and that is a defect this channel carried until it
/// was looked at.** Window depth crowds every distant surface into the top of
/// its range — with a near plane of a tenth of a metre, everything past twenty
/// metres lives in the last half a hundredth of `[0, 1]` — and this attachment
/// is a half float, whose steps up there are about five ten-thousandths. So two
/// surfaces half a metre apart at twenty metres stored the *same* number, and
/// every screen-space pass that compares against this channel decided whole
/// bands of pixels by rounding. The occlusion pass drew them: vertical stripes
/// along the lines of constant depth on any wall receding from the camera, on
/// both GPU backends. The software rasteriser kept the channel at full
/// precision and drew the effect correctly, so it was the one that looked
/// wrong against the other two.
///
/// A depth in metres has none of that: the exponent carries the range and the
/// mantissa carries the same relative precision everywhere, which at twenty
/// metres is a centimetre. Both numbers are measured in
/// `flutter3d/test/surface_depth_test.dart`.
///
/// Zero still means nothing was drawn. The attachment is cleared to zero and
/// nothing is drawn in front of the near plane.
void WriteSurfaceGeometry(float roughness) {
#ifndef F3D_NO_SURFACE_BUFFER
  // `L5`: the surface's colour, whatever the surface buffer ends up holding.
  frag_albedo = vec4(LinearToSrgb(clamp(g_albedo, vec3(0.0), vec3(1.0))), 1.0);
  // A debug pass takes the buffer over rather than getting one of its own.
  // The surface buffer already has an attachment, a viewer and a golden; a
  // second one would need all three built before it could answer anything.
  if (g_debug_surface_on) {
    frag_surface = vec4(g_debug_surface, ViewDepth());
    return;
  }
  // Reversed on a back face, as the lit normal is, so the occlusion and
  // reflection passes see the side of a double-sided surface that faces them.
  vec3 geometric = normalize(v_normal);
  if (!gl_FrontFacing) geometric = -geometric;
  frag_surface = vec4(EncodeOctahedral(geometric),
                      clamp(roughness, 0.0, 1.0), ViewDepth());
#endif
}

/// Fades [color] toward the fog with distance from the eye.
///
/// Exponential rather than linear, because linear fog has a visible plane
/// where it starts and a dungeon corridor is exactly where that shows.
vec3 ApplyFog(vec3 color) {
#ifdef F3D_NO_FOG
  return color;
#else
  float density = fog_info.fog.w;
  if (density <= 0.0) return color;
  float d = EyeDistance();
  return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0));
#endif
}

/// How much a transparent fragment counts for against the others over its
/// pixel — `R8`. McGuire and Bavoil's depth weight (their equation 9): a near
/// layer outweighs a far one, which is all the ordering a weighted average
/// can keep. [alpha] multiplies it, as theirs does, so a faint layer counts
/// faintly. Depth along the view axis, in metres, the surface buffer's.
float WeightedBlendedWeight(float alpha) {
  float z = abs(ViewDepth());
  float near = z / 5.0;
  float far = z / 200.0;
  float far3 = far * far * far;
  return alpha *
         clamp(10.0 / (1e-5 + near * near + far3 * far3), 1e-2, 3e3);
}

/// What a transparent draw writes when the frame composites transparency
/// order-independently — `R8`. `fog_info.forward.w` says which:
///
/// - 0: [frag_color] as it stands, the sorted blend's source. Every opaque
///   draw, and every draw in a frame that sorts.
/// - 1: the accumulation target's share — the colour, which the engine keeps
///   premultiplied, and the alpha, both times the weight. Added.
/// - 2: the revealage target's — the alpha alone, in every channel, which the
///   blend multiplies the target by one minus of.
/// - 3: both at once, the second into attachment one, where the surface
///   buffer would be; the pass that asks has no surface buffer attached.
///
/// Selects rather than returns, because a phi of constants is what
/// SPIRV-Cross refuses. At nought the branch is not taken and [frag_color]
/// is untouched, which is what keeps a sorting frame byte-identical.
void WriteWeightedBlended() {
#ifndef F3D_NO_FOG
  float mode = fog_info.forward.w;
  if (mode > 0.5) {
    float alpha = frag_color.a;
    float weight = WeightedBlendedWeight(alpha);
    vec4 accumulate = vec4(frag_color.rgb * weight, alpha * weight);
    bool revealage = mode > 1.5 && mode < 2.5;
    frag_color = revealage ? vec4(alpha) : accumulate;
#ifndef F3D_NO_SURFACE_BUFFER
    if (mode > 2.5) frag_surface = vec4(alpha);
#endif
  }
#endif
}

/// The fog is mixed in before the weight, so a thin distant pane adds a thin
/// share of the fog too rather than all of it. Times one when nothing blends,
/// which is exact, so an opaque draw writes what it always wrote.
void WriteSurface(vec3 linearColor, float alpha, float roughness) {
  float weight = g_premultiply ? alpha : 1.0;
  frag_color = vec4(ApplyFog(linearColor) * weight, alpha);
  WriteSurfaceGeometry(roughness);
  WriteWeightedBlended();
}

/// For a stage with no material to speak of.
///
/// Fully rough, which is the honest default: a surface that cannot say how
/// polished it is should not be reflected off.
void WriteSurface(vec3 linearColor, float alpha) {
  WriteSurface(linearColor, alpha, 1.0);
}

/// Writes a value that is already display-referred.
///
/// For debug output, where the colour is not a light value at all: a normal
/// encoded as RGB means nothing after a tone curve. Converting to linear here
/// means the composite pass's sRGB encode hands the original back unchanged,
/// provided the view also turns tone mapping and exposure off — which is what
/// `RenderSettings.tonemap` is for.
void WriteDisplayColor(vec3 displayColor, float alpha) {
  frag_color = vec4(SrgbToLinear(displayColor), alpha);
  WriteSurfaceGeometry(1.0);
}

#endif  // COLOR_GLSL_

// --- lib/frag_coord.glsl ---
// Where a fragment sits, counted from the top of its target on every backend.

#ifndef FRAG_COORD_GLSL_
#define FRAG_COORD_GLSL_

/// `gl_FragCoord.xy` with row zero at the top of the picture.
///
/// [rows] is the target's height where the backend's row zero is the bottom
/// of the picture, and zero where it is the top. WebGL2 is the first kind:
/// window coordinates start at the lower left, and the engine draws the
/// picture upright there rather than mirroring every projection. Metal,
/// WebGPU and the software rasteriser are the second.
///
/// **Why a pattern cares and a picture does not.** Every screen-space pattern
/// in the engine — the Bayer dither, the grain, the jitter a ray march starts
/// from, the rotation of a shadow kernel — is a function of the pixel's row.
/// Read from the bottom, the same frame gets the pattern turned upside down,
/// and a four-row Bayer cell lands on different rows unless the height is a
/// multiple of four. The picture underneath is identical; the pattern on top
/// of it is not, and a comparison across backends counts every pixel it
/// moved.
vec2 FragCoordFromTop(float rows) {
  return rows > 0.0 ? vec2(gl_FragCoord.x, rows - gl_FragCoord.y)
                    : gl_FragCoord.xy;
}

#endif  // FRAG_COORD_GLSL_


/// Lights per draw. Must match LightBuffer.maxLights on the Dart side.
///
/// A fixed array with a runtime count, not a shader permutation per light
/// count: turning a light on has to be free, because there is no runtime
/// compilation to fall back on. Verified against the SDK — Impeller keeps
/// `vec4 x[8]` in the compiled Metal struct and reflects the array's base
/// offset, with the std140 stride of 16 bytes.
#define kMaxLights 8

/// How many more lights one draw may be handed — `gfx-74n`.
///
/// **The eight above stay exactly what they were**, which is what keeps this
/// from moving a single recorded frame: a draw with eight lights or fewer runs
/// the loop it has always run, reads the uniform arrays it has always read, and
/// never touches the texture below. The tail is the part that used to be
/// impossible.
///
/// A loop bound rather than a cost. `AccumulateLights` breaks at the draw's own
/// count, so a scene with three lights costs three iterations whatever this
/// says. Twenty-four because the two tables below are `vec4 x[6]` and four
/// lanes fit a `vec4`: two hundred and eight bytes a draw, against the five
/// hundred and twelve the light arrays already cost.
#define kExtraLights 24
#define kTotalLights (kMaxLights + kExtraLights)

// --- lib/light_list.glsl ---
// The frame's light list, and how a fragment finds its tail in it — `gfx-74n`
// and `L6`.
//
// Split out of `surface.glsl` so a stage that is not a surface can read the
// same lights: `N6`'s six-way particles light each fragment by the list the
// lit models read, clusters and all, without declaring `FragInfo`. The text is
// the one that stood in `surface.glsl`, moved rather than copied, so the lit
// models compile to what they compiled to before.

#ifndef LIGHT_LIST_GLSL_
#define LIGHT_LIST_GLSL_
/// Every light in the scene, one per row, four texels across — `gfx-74n`.
///
/// **A texture rather than a wider uniform block, and that is the design.**
/// `FragInfo` is uploaded on every draw, so widening its four `vec4` arrays to
/// hold thirty-two lights would be a two-kilobyte upload per draw in every
/// scene, including every scene with one light. This is built once a frame and
/// only when a scene has more lights than a draw can hold in its slots.
///
/// Row layout, which `renderer_light_list.dart` writes and only this reads:
///
///  * texel 0 — xyz world position, w type (0 directional, 1 point, 2 spot)
///  * texel 1 — rgb linear colour, w intensity
///  * texel 2 — xyz the direction it points, w range
///  * texel 3 — x cos(inner), y cos(outer), zw unused
///
/// The same four vectors the uniform arrays hold, in the same order, so one
/// reader serves both.
///
/// **`F3D_NO_LIGHT_LIST` leaves both out**, for a model that accumulates no
/// lights. Such a model never reaches the reader below, so the compiler drops
/// the block and the sampler from the Metal function while reflection still
/// lists them, with no buffer or texture index assigned. The renderer used to
/// bind them for every draw, Unlit included, and that bind is a crash inside
/// `setFragmentBuffer:offset:atIndex:` on Metal. Vulkan took the same draw
/// without a word, which is how 0.7.0 shipped with it.
#ifndef F3D_NO_LIGHT_LIST
uniform sampler2D light_list_texture;

layout(std140) uniform LightListInfo {
  /// x: how many rows this draw reads, zero when it reads none.
  /// y, z: one over the texture's width and height.
  /// w: unused.
  vec4 list;

  /// Which rows, four to a vector, in the order they are read.
  ///
  /// Indices rather than the light data itself: the data is the same for every
  /// draw in the frame and belongs in the texture; what differs per draw is
  /// *which* of them reach it, and that is what `Renderer._drawLightsFor`
  /// already decides.
  vec4 indices[6];

  /// How much of each of those survives the edge fade, in the same order.
  ///
  /// Per draw and not in the texture, because the row an index points at is
  /// shared by every draw in the frame: a scale written into it would dim that
  /// light for all of them. `gfx-12n`'s fade lives at the end of the list now —
  /// that is where a light stops contributing, and fading the slots against a
  /// water line that no longer marks a cliff would dim a light for no reason
  /// while its rival stayed bright, making the swap more visible rather than
  /// less.
  vec4 scales[6];

  /// `L6`: the view-projection the light clusters were cut with, so this
  /// finds a fragment's cell the way `LightClusters.clusterOf` does.
  mat4 cluster_view_projection;

  /// xyz: tiles across, tiles up, slices deep. w: one when this draw reads
  /// its tail from the cell it is in rather than from `indices`.
  vec4 cluster_grid;

  /// x: where slices begin, in clip w. y: slices per unit of `ln(w / x)`.
  /// z: the texture row the cells' headers start at, four to a row, each
  /// (offset, count). w: the row their entries start at, sixteen to a row.
  vec4 cluster_depth;

  /// Which rows this draw already holds in its eight slots, minus one for
  /// an empty slot. A cell lists every light that reaches it, and one the
  /// slots already carry must not be counted again.
  vec4 slot_rows[2];
}
light_list_info;

/// One lane of a six-vector table, [slot] counting from nought.
float LightListLane(vec4 four, int slot) {
  int lane = slot - (slot / 4) * 4;
  return lane == 0 ? four.x : lane == 1 ? four.y : lane == 2 ? four.z : four.w;
}

/// The row light [slot] of the list reads.
float LightListRow(int slot) {
  return LightListLane(light_list_info.indices[slot / 4], slot);
}

/// How much of light [slot] of the list survives the edge fade.
float LightListScale(int slot) {
  return LightListLane(light_list_info.scales[slot / 4], slot);
}

/// The cell this fragment falls in, as `LightClusters` wrote it: where its
/// entries start and how many there are. Found once, in [LightCount], and
/// read by every [SampleLight] of the loop that follows.
float g_cluster_offset = 0.0;
float g_cluster_count = 0.0;

bool Clustered() { return light_list_info.cluster_grid.w > 0.5; }

/// One texel of the light list texture, [texel] across and [row] down.
vec4 LightListTexel(float texel, float row) {
  return textureLod(light_list_texture,
                    vec2((texel + 0.5) * light_list_info.list.y,
                         (row + 0.5) * light_list_info.list.z),
                    0.0);
}

void FindCluster(vec3 world) {
  vec4 clip = light_list_info.cluster_view_projection * vec4(world, 1.0);
  vec2 ndc = clip.xy / max(clip.w, 1e-6);
  vec3 grid = light_list_info.cluster_grid.xyz;
  float near = light_list_info.cluster_depth.x;
  float tx = clamp(floor((ndc.x * 0.5 + 0.5) * grid.x), 0.0, grid.x - 1.0);
  float ty = clamp(floor((ndc.y * 0.5 + 0.5) * grid.y), 0.0, grid.y - 1.0);
  float tz = clip.w <= near
                 ? 0.0
                 : clamp(floor(log(clip.w / near) *
                               light_list_info.cluster_depth.y),
                         0.0, grid.z - 1.0);
  float cell = tx + ty * grid.x + tz * grid.x * grid.y;
  float row = floor(cell / 4.0);
  vec4 header =
      LightListTexel(cell - row * 4.0, light_list_info.cluster_depth.z + row);
  g_cluster_offset = header.x;
  g_cluster_count = header.y;
}

/// The row entry [slot] of this fragment's cell names.
float ClusterRow(int slot) {
  float entry = g_cluster_offset + float(slot);
  float row = floor(entry / 16.0);
  float within = entry - row * 16.0;
  float texel = floor(within / 4.0);
  vec4 four = LightListTexel(texel, light_list_info.cluster_depth.w + row);
  return LightListLane(four, int(within - texel * 4.0 + 0.5));
}

/// Whether one of the draw's slots already holds light list row [row].
bool InSlots(float row) {
  vec4 a = abs(light_list_info.slot_rows[0] - vec4(row));
  vec4 b = abs(light_list_info.slot_rows[1] - vec4(row));
  return min(min(min(a.x, a.y), min(a.z, a.w)), min(min(b.x, b.y), min(b.z, b.w))) < 0.5;
}
#endif  // F3D_NO_LIGHT_LIST

#endif  // LIGHT_LIST_GLSL_


layout(std140) uniform FragInfo {
  /// xyz: world position (point and spot). w: type, 0 directional 1 point 2 spot.
  vec4 light_position[kMaxLights];

  /// rgb: linear colour. w: intensity.
  vec4 light_color[kMaxLights];

  /// xyz: the direction the light points, its local -Z. w: range, 0 unbounded.
  vec4 light_direction[kMaxLights];

  /// x: cos(inner cone angle). y: cos(outer cone angle).
  vec4 light_cone[kMaxLights];

  /// rgb: albedo tint applied on top of the texture. w: opacity.
  vec4 base_color;

  /// rgb: emissive factor, already linear. w: one when the normal map has
  /// two channels (x, y) and its z is rebuilt — see `ApplyNormalMap`. It sits
  /// here because this was the block's one unspent lane.
  vec4 emissive;

  /// xyz: camera position in world space, needed for every specular term.
  vec4 camera_position;

  /// x: metallic, y: roughness, z: ambient strength, w: specular strength.
  vec4 material;

  /// x: alpha cutoff (negative when the material is not masked: -1 opaque,
  /// -0.5 blended, -2 hashed), y: normal scale, z: occlusion strength,
  /// w: emissive strength.
  vec4 material2;

  /// x: exposure, y: active light count, z: index of the shadow-casting light.
  /// w is reserved so adding a frame-wide parameter does not change the offsets
  /// of anything already here.
  vec4 frame_params;

  /// x: one texel of the shadow map, y: depth bias, z: normal offset,
  /// w: strength, zero when shadows are off.
  vec4 shadow_params;

  /// World space to the shadow camera's clip space. The first cascade.
  mat4 shadow_matrix;

  /// The second and third cascades. Copies of the first when there is one, so
  /// this block's layout never depends on how many there are.
  mat4 shadow_matrix_far;
  mat4 shadow_matrix_farthest;

  /// x, y: where cascades 0 and 1 end, in metres from the camera. z: how many
  /// cascades there are, 1 to 3. w: one texel of a tile, vertically —
  /// shadow_params.x is one texel of the whole atlas, and with more than one
  /// cascade those differ.
  vec4 shadow_cascades;

  /// rgb: what a surface facing straight up receives from the environment.
  /// w: one when the metal-rough models' diffuse is EON rather than Lambert —
  /// `L8`, `RenderSettings.diffuseModel`; a frame-wide switch in a frame-wide
  /// vector, and the block's offsets stay where four backends agree on them.
  ///
  /// Appended after everything else on purpose: std140 lays a block out in
  /// declaration order, so adding here leaves every offset above unchanged and
  /// the three backends do not have to agree about anything they did not
  /// already agree about.
  vec4 ambient_sky;

  /// rgb: what a surface facing straight down receives — bounce off the ground
  /// rather than the ground itself.
  ///
  /// **w is the directional light's apparent size** — `gfx-15n` — which has
  /// nothing to do with ambient and everything to do with this being the last
  /// unspent component in a block six shaders share. `frame_params.w` was the
  /// slot reserved for a frame-wide parameter and the environment's level
  /// count took it; appending to this block moves offsets four backends have
  /// agreed on. See `shadow.glsl`, which reads it.
  ///
  /// Two colours rather than one is the whole of what makes ambient look like
  /// light instead of like a lifted black level. Outdoors the sky is blue and
  /// bright and the ground is warm and dim, and a flat grey for both leaves
  /// every underside as pale as every upward face — which reads as the model
  /// being flat, and gets blamed on the normals.
  vec4 ambient_ground;

  /// x, y, z: the depth bias of each cascade, in that cascade's own normalized
  /// depth. w unused.
  ///
  /// `ShadowSettings.bias` is one number and a cascade's depth range is not:
  /// a near cascade is stretched towards the light when a caster stands
  /// further out than its own volume reaches, and the same bias over a longer
  /// range is a longer distance. The renderer converts it per cascade so it
  /// stays the distance it was tuned as; an unstretched cascade gets the
  /// setting unchanged.
  vec4 shadow_bias;

  /// x: the target's rows when its row zero is the bottom of the picture,
  /// zero when it is the top — see `FragCoordFromTop` in `frag_coord.glsl`,
  /// which the shadow kernel's rotation reads through. y: the mip bias every
  /// material map is read with — `R2`: nought, except while a temporal
  /// resolve reconstructs a picture larger than the scene is drawn at, when
  /// the maps are read as sharp as the output they end up in. z: one when
  /// the metal-rough model puts back the energy single scattering loses —
  /// `L1`, `RenderSettings.energyCompensation`. w: the frame's slice of 32
  /// while a temporal resolve runs, minus one otherwise — `S3`, which steps
  /// the soft shadow's rotation by it.
  vec4 target_origin;
}
frag_info;

/// The bias a material map is read with — see `target_origin.y`.
float MaterialLodBias() { return frag_info.target_origin.y; }

/// The maps a lit material reads, by the index [MapUv] takes — `C8`. The
/// order `LayerInfo.uv_transform` keeps them in, and `MaterialMap`'s on the
/// Dart side.
#define kMapBaseColor 0
#define kMapMetallicRoughness 1
#define kMapNormal 2
#define kMapOcclusion 3
#define kMapEmissive 4

/// Where map [slot] is read — `C8`, `KHR_texture_transform` at the sampler.
///
/// **A macro everywhere but the one stage that has the matrices.** A stage
/// that defines `F3D_TEXTURE_TRANSFORM` supplies [MapUv] and [MapMatrix] from
/// a block of its own; every other stage reads each map at the vertex's own
/// coordinate, and the macro leaves its source exactly what it was, so none of
/// them compiles to anything new.
#ifdef F3D_TEXTURE_TRANSFORM
vec2 MapUv(int slot);

/// The 2×2 part of map [slot]'s transform: x and y its first row, z and w
/// its second.
vec4 MapMatrix(int slot);
#else
#define MapUv(slot) v_texcoord
#endif

uniform sampler2D base_color_texture;

/// Everything about the surface that does not depend on which light is being
/// evaluated, resolved once per fragment.
struct Surface {
  vec3 albedo;      // linear, already tinted
  float alpha;      // opacity after texture, tint and vertex colour
  vec3 n;           // unit normal, perturbed by the normal map when there is one
  vec3 v;           // unit direction to the camera
  float n_dot_v;
  float metallic;
  float roughness;  // perceptual
  float occlusion;  // 1 means unoccluded
  vec3 emissive;    // linear, added after shading
  vec3 ambient;     // hemispheric, already scaled by the scene's strength
  float exposure;
};

/// One light's contribution geometry, recomputed per light per fragment.
struct LightSample {
  vec3 l;           // unit direction to the light
  vec3 h;           // unit half vector
  vec3 radiance;    // colour * intensity * attenuation
  float n_dot_l;
  float n_dot_h;
  float v_dot_h;

  /// One when the specular below is already integrated over the light —
  /// `L7`, a rectangle under a model that defines `F3D_LTC` — and nought
  /// otherwise. Then `ltc.x` is the GGX lobe over the rectangle, `ltc.y` the
  /// fitted norm and `ltc.z` the Fresnel term; see `LtcRectangle`.
  float integrated;
  vec3 ltc;
};

Surface ReadSurface() {
  Surface s;

  vec4 texel = texture(base_color_texture, MapUv(kMapBaseColor), MaterialLodBias());
  // Vertex colour is authored linear per the glTF spec, unlike the base colour
  // texture and the tint, which are sRGB.
  s.albedo = SrgbToLinear(texel.rgb) *
             SrgbToLinear(frag_info.base_color.rgb) *
             v_color.rgb;
  s.alpha = texel.a * frag_info.base_color.a * v_color.a;
  // `L5`: the albedo buffer carries it, for the indirect light.
  g_albedo = s.albedo;

  // Alpha masking, glTF's third alpha mode. A negative cutoff means the
  // material is opaque or blended, and discard would then be wrong rather than
  // merely unnecessary. Doing it before anything else is deliberate: a
  // discarded fragment should not pay for the lighting loop.
  //
  // **A cutoff below -1.5 is the fourth mode: hashed** — `gfx-16n`. The
  // sentinel rides in the same component because the alternative is a second
  // number in a block six shaders share, and -1 already meant "not masked";
  // anything more negative was free. See [MaterialAlphaMode.hashed].
  float cutoff = frag_info.material2.x;
  if (cutoff >= 0.0) {
    if (s.alpha < cutoff) discard;
  } else if (cutoff < -1.5) {
    // **Stochastic instead of a threshold.** A leaf texture at 40% opacity is
    // either entirely there or entirely gone under a fixed cutoff, so a fern
    // comes out as a hard-edged cardboard cut-out; sorting would fix it and
    // costs a sort per frame and a draw per layer. Comparing against noise
    // instead keeps 40% of the *pixels*, which resolves as 40% opacity to
    // anything that averages several of them — a higher-resolution target,
    // a downsample, a person standing back.
    //
    // **Hashed on world position, not on the screen.** Screen-space noise is
    // one line shorter and swims: the pattern stays put while the object
    // moves through it, so a moving branch sparkles. Anchoring it to where
    // the surface *is* means a given speck of leaf keeps its verdict from
    // frame to frame, and the camera moving changes nothing.
    //
    // The scale is a constant and it is the whole tuning: finer than the
    // texture's own detail and the noise disappears into aliasing, coarser
    // and the leaf breaks into blotches. Sixteen per metre is about a
    // centimetre of grain at a metre away.
    vec3 anchored = floor(v_world_position * 16.0);
    float noise = fract(
        sin(dot(anchored, vec3(12.9898, 78.233, 37.719))) * 43758.5453);
    if (s.alpha < noise) discard;
  }
  // **Between -1 and nought is the blend mode**, which `WriteSurface` weights
  // by its alpha: see [g_premultiply]. The engine writes -0.5 for it, -1 for
  // opaque; neither is masked, and only the blend's source is premultiplied.
  g_premultiply = cutoff < 0.0 && cutoff > -0.75;

  s.n = normalize(v_normal);
  // The back of a double-sided surface is lit from its own side: glTF asks
  // for the normal to be reversed there, and without it the underside of a
  // cloth turned to the sun reads n·l below zero and stays unlit. Only a
  // double-sided material ever draws a back face, since everything else has
  // them culled.
  if (!gl_FrontFacing) s.n = -s.n;
  s.v = normalize(frag_info.camera_position.xyz - v_world_position);
  // Clamped away from zero: a grazing view direction otherwise divides by zero
  // in the specular visibility term.
  s.n_dot_v = max(dot(s.n, s.v), 1e-4);

  s.metallic = clamp(frag_info.material.x, 0.0, 1.0);
  s.roughness = clamp(frag_info.material.y, 0.02, 1.0);
  // Hemispheric: the sky above, the ground below, blended by which way this
  // surface faces. `material.z` stays the overall strength, so the two are
  // separable — a scene dims its ambient without changing its colour, which is
  // what the one control used to do on its own.
  //
  // The blend runs on the geometric normal deliberately, before
  // `ApplyMaterialMaps` perturbs it. A normal map describes millimetres of
  // surface relief, and ambient of this kind describes which half of the world
  // a face can see; letting bump detail swing it makes a brick wall's mortar
  // lines pick up sky and reads as noise.
  s.ambient = mix(frag_info.ambient_ground.rgb, frag_info.ambient_sky.rgb,
                  s.n.y * 0.5 + 0.5) *
              frag_info.material.z;
  s.exposure = max(frag_info.frame_params.x, 0.0);

  // Neutral until ApplyMaterialMaps says otherwise, so a model that samples no
  // maps still has a complete surface.
  s.occlusion = 1.0;
  s.emissive = vec3(0.0);

  return s;
}

int LightCount() {
#ifdef F3D_NO_LIGHT_LIST
  return clamp(int(frag_info.frame_params.y + 0.5), 0, kMaxLights);
#else
  // `L6`: the tail is the cell's, when the draw reads one.
  float tail = light_list_info.list.x;
  if (Clustered()) {
    FindCluster(v_world_position);
    tail = g_cluster_count;
  }
  return clamp(int(frag_info.frame_params.y + 0.5), 0, kMaxLights) +
      clamp(int(tail + 0.5), 0, kExtraLights);
#endif
}

/// Whether light [index] carries a shadow — `gfx-74n`.
///
/// Only the first eight do. The cube atlas holds six rows and the slot table is
/// eight entries wide, so a light from the list has no row to read and asking
/// for one would index past the table. That is a real limit and the right one:
/// the eight a draw keeps in its slots are the eight ranked most relevant to
/// it, which is exactly the set worth a shadow map.
bool LightHasShadow(int index) { return index < kMaxLights; }

/// Distance attenuation for a punctual light, following the glTF spec.
///
/// Inverse square with an optional range window. The window is what stops a
/// lamp with a declared range from contributing a faint haze across the whole
/// scene, which matters far more once there are eight of them.
float PunctualAttenuation(float distance, float range) {
  float attenuation = 1.0 / max(distance * distance, 1e-4);
  if (range > 0.0) {
    float ratio = distance / range;
    float window = clamp(1.0 - ratio * ratio * ratio * ratio, 0.0, 1.0);
    attenuation *= window * window;
  }
  return attenuation;
}

/// One edge of Lambert's sum, from [a] to [b], neither of which need be a
/// unit vector: the angle between them times how much their plane leans into
/// [n].
float LambertEdge(vec3 a, vec3 b, vec3 n) {
  // Normalised with a floor rather than `normalize`: a corner exactly at the
  // shading point, or a horizon crossing that lands there, is a zero vector,
  // and `normalize` of that is a NaN that spreads to the whole pixel and then
  // to the bloom. A zero vector here subtends nothing, which is the answer.
  vec3 ua = a / max(length(a), 1e-12);
  vec3 ub = b / max(length(b), 1e-12);
  // Clamped before the `acos`: two nearly parallel edge directions can give a
  // dot a hair past one through rounding alone, and `acos` of that is the same
  // NaN.
  float angle = acos(clamp(dot(ua, ub), -1.0, 1.0));
  vec3 axis = cross(ua, ub);
  float len = length(axis);
  // A degenerate edge — the shading point lies on the line through it —
  // subtends nothing.
  return len > 1e-6 ? angle * dot(axis, n) / len : 0.0;
}

/// How much of [s]'s sky a rectangle covers, weighted by the cosine —
/// `gfx-77n`.
///
/// **Exact, not fitted.** This is Lambert's own form factor for a polygon, from
/// 1760: for each edge, the angle it subtends at the shading point times how
/// much the edge's plane leans into the surface normal. Summed over the edges
/// and halved, it is the integral of `cos θ` over the polygon's projection on
/// the sphere — the quantity a punctual light approximates with a single
/// `n · l`. So there is no table to ship and nothing to fit: the usual
/// linearly-transformed-cosine approach exists to make the *specular* lobe
/// tractable, and buys nothing here.
///
/// **Clipped to the horizon first.** Lambert's sum is signed: a part of the
/// panel below the surface's horizon counts with a negative cosine and cancels
/// light from the part above it, so a panel standing on the horizon read
/// nought where half of it lights the surface. Irradiance wants the clamped
/// cosine, and for a polygon that means cutting away what lies below before
/// summing. A convex quadrilateral cut by a plane leaves one polygon with at
/// most one edge leaving the hemisphere and one entering it, so the cut is the
/// four edges trimmed where they cross plus one edge along the horizon from
/// the exit back to the entry, with no list of vertices to build.
///
/// Returns irradiance over radiance, so a surface facing a rectangle that fills
/// its whole sky gets π, the same as a uniform hemisphere. [corners] are the
/// four vertices in order, relative to the shading point.
///
/// **The rectangle emits along `cross(halfWidth, halfHeight)`**, and with the
/// corners wound as `SampleLight` winds them the sum comes out *negative* on
/// that side, so the negation below is the convention rather than a fix. It was
/// measured rather than derived: the first version returned `+total * 0.5`, and
/// against the reference integration it read nought where the answer was 0.349
/// and 1.02 where the answer was nought — the two failures a flipped winding
/// produces, and between them they name the sign with no room left to argue.
float RectangleFormFactor(vec3 corners[4], vec3 n) {
  float total = 0.0;
  vec3 exit = vec3(0.0);
  vec3 entry = vec3(0.0);
  for (int i = 0; i < 4; i++) {
    vec3 a = corners[i];
    vec3 b = corners[i == 3 ? 0 : i + 1];
    float ha = dot(a, n);
    float hb = dot(b, n);
    // Where the edge meets the horizon; used only when it crosses it, and then
    // the two heights differ in sign, so the division is safe.
    float d = ha - hb;
    vec3 q = a + (b - a) * (abs(d) > 1e-12 ? ha / d : 0.0);
    bool aAbove = ha > 0.0;
    bool bAbove = hb > 0.0;
    total += aAbove || bAbove
                 ? LambertEdge(aAbove ? a : q, bAbove ? b : q, n)
                 : 0.0;
    exit = aAbove && !bAbove ? q : exit;
    entry = !aAbove && bAbove ? q : entry;
  }
  // The horizon edge closing the cut, from where the outline left the
  // hemisphere to where it came back. Nothing when it never crossed: both are
  // still zero and a zero vector subtends nothing.
  total += LambertEdge(exit, entry, n);
  // Clamped: a surface on the panel's dark side sees the outline wound the
  // other way, and the clipped sum comes out negative. `SampleLight` tests the
  // side as well, before any of this is paid for.
  return max(-total * 0.5, 0.0);
}

/// Where on the rectangle the specular lobe is really looking — `gfx-77n`.
///
/// **The representative point, which is an approximation, unlike the diffuse
/// above.** The mirror direction leaves the surface and either hits the panel
/// or misses it; the closest point of the panel to that ray is treated as a
/// punctual light standing in for the whole rectangle. It is the standard
/// cheap answer and its one visible property is the one the row asked for: as
/// the view moves the closest point slides along the panel, so the highlight
/// is a streak with the panel's own shape and orientation rather than a dot.
///
/// What it does not do is widen the lobe by the panel's solid angle, so a
/// rough surface under a large panel is a little darker than a full integration
/// would make it. That is a known error of this method and not a bug in this
/// transcription; the fix is the fitted table this function exists to avoid.
vec3 RectangleClosestPoint(vec3 centre, vec3 halfWidth, vec3 halfHeight,
                           vec3 world, vec3 mirror) {
  vec3 n = cross(halfWidth, halfHeight);
  float nLen = length(n);
  // A panel with no area has no surface to find a point on; its centre is the
  // only answer that is not a division by zero.
  if (nLen < 1e-12) return centre;
  n /= nLen;

  vec3 toPlane = centre - world;
  float denom = dot(mirror, n);
  vec3 onPlane;
  // Parallel to the panel, or pointing away from it: the ray never lands, so
  // the nearest thing to it is the centre projected back, which keeps the
  // highlight on the panel instead of sending it to infinity.
  if (abs(denom) < 1e-5) {
    onPlane = toPlane - n * dot(toPlane, n);
  } else {
    float t = dot(toPlane, n) / denom;
    onPlane = t > 0.0 ? mirror * t : toPlane - n * dot(toPlane, n);
  }

  // Clamped into the rectangle in its own axes. Dividing by the squared length
  // turns a projection into a coordinate in units of the half-extent, so the
  // clamp is against one either way round.
  vec3 offset = onPlane - toPlane;
  float wLen2 = max(dot(halfWidth, halfWidth), 1e-12);
  float hLen2 = max(dot(halfHeight, halfHeight), 1e-12);
  float u = clamp(dot(offset, halfWidth) / wLen2, -1.0, 1.0);
  float v = clamp(dot(offset, halfHeight) / hLen2, -1.0, 1.0);
  return centre + halfWidth * u + halfHeight * v;
}

#ifdef F3D_LTC
// --- lib/ltc.glsl ---
// The GGX lobe over a rectangle light, by linearly transformed cosines — `L7`.
//
// Heitz, Dupuy, Hill and Neubelt, "Real-Time Polygonal-Light Shading with
// Linearly Transformed Cosines", ACM TOG 35(4), 2016. The fitted tables are
// `EngineTables.ltc`; see `tables/ltc.dart` for their layout and licence.
//
// A model that wants it defines `F3D_LTC` before including `surface.glsl`,
// which is what gives its stage the one sampler below. Every other model
// keeps the representative point, and no sampler.

#ifndef LTC_GLSL_
#define LTC_GLSL_

/// Both tables, 64 × 128: the inverse matrices above, the norms, Fresnel
/// terms and sphere form factors below.
uniform sampler2D ltc_texture;

/// Where `(x, y)`, each nought to one, lands in the table starting at
/// [table] (nought the upper, one the lower): on texel centres, so the ends of
/// the range read the first and last entries rather than half of the
/// neighbour.
vec2 LtcUv(float x, float y, float table) {
  vec2 inTable = vec2(x, y) * (63.0 / 64.0) + 0.5 / 64.0;
  return vec2(inTable.x, (inTable.y + table) * 0.5);
}

/// One edge's share of the vector form factor, from [a] to [b], unit
/// directions: the angle between them along the normal of their plane,
/// over 2π. Exact, with the `acos` clamped for the reason
/// `RectangleFormFactor` gives.
vec3 LtcEdge(vec3 a, vec3 b) {
  vec3 axis = cross(a, b);
  float len = length(axis);
  float angle = acos(clamp(dot(a, b), -1.0, 1.0));
  return len > 1e-6 ? axis * (angle / (len * 6.2831853)) : vec3(0.0);
}

/// The GGX lobe of roughness [roughness] seen along [v] from normal [n],
/// integrated over the rectangle with corners [corners] (relative to the
/// shading point, wound as `SampleLight` winds them), with the fitted
/// Fresnel pair for that lobe: x the integral, y the norm, z the Fresnel
/// term. The specular is `x · (f0 · y + (1 − f0) · z)`.
///
/// Clipped to the horizon by the sphere table rather than by cutting the
/// polygon: the vector form factor's length and elevation name a sphere
/// with the same, and the table holds how much of that sphere's clamped
/// cosine lies above the horizon.
///
/// Says nothing about which face of the panel the point is on: the vector
/// form factor points the same way in the world from either side, so this is
/// as bright behind the panel as in front of it. `SampleLight` tests the side
/// and leaves a point behind unlit before this is asked.
vec3 LtcRectangle(vec3 n, vec3 v, float roughness, vec3 corners[4]) {
  vec2 uv = vec2(clamp(roughness, 0.0, 1.0),
                 sqrt(clamp(1.0 - dot(n, v), 0.0, 1.0)));
  vec4 inverse = textureLod(ltc_texture, LtcUv(uv.x, uv.y, 0.0), 0.0);
  vec4 fit = textureLod(ltc_texture, LtcUv(uv.x, uv.y, 1.0), 0.0);

  // The frame the fit was made in: the normal up, the view in the xz plane.
  // A view along the normal has no plane of its own, and any will do.
  vec3 along = v - n * dot(v, n);
  float alongLength = length(along);
  vec3 t1 = alongLength > 1e-5
                ? along / alongLength
                : normalize(cross(n, abs(n.z) < 0.999 ? vec3(0.0, 0.0, 1.0)
                                                      : vec3(1.0, 0.0, 0.0)));
  vec3 t2 = cross(n, t1);
  mat3 minv = mat3(vec3(inverse.x, 0.0, inverse.y), vec3(0.0, 1.0, 0.0),
                   vec3(inverse.z, 0.0, inverse.w));

  vec3 l[4];
  for (int i = 0; i < 4; i++) {
    vec3 p = corners[i];
    l[i] = normalize(minv * vec3(dot(p, t1), dot(p, t2), dot(p, n)));
  }
  // Negated, for `RectangleFormFactor`'s reason: the panel emits along
  // `cross(halfWidth, halfHeight)`, and seen from there these corners run
  // clockwise.
  vec3 f = -(LtcEdge(l[0], l[1]) + LtcEdge(l[1], l[2]) +
             LtcEdge(l[2], l[3]) + LtcEdge(l[3], l[0]));
  float len = length(f);
  float z = len > 1e-9 ? f.z / len : 0.0;
  float sphere =
      textureLod(ltc_texture, LtcUv(z * 0.5 + 0.5, clamp(len, 0.0, 1.0), 1.0),
                 0.0)
          .w;
  return vec3(max(len * sphere, 0.0), fit.x, fit.y);
}

#endif  // LTC_GLSL_


#ifdef F3D_LAYERED
/// The corners of the rectangle [SampleLight] resolved last, relative to the
/// shading point — `M1`. The clear coat integrates its own lobe over the same
/// panel with its own normal and roughness, and those live in `pbr.glsl`,
/// after this file; the loop shades each light straight after sampling it,
/// so this is always the light being shaded.
vec3 g_rect_corners[4];
#endif  // F3D_LAYERED
#endif  // F3D_LTC

/// Resolves light [index] against the surface.
///
/// Returns `n_dot_l == 0` for anything that contributes nothing — behind the
/// surface, out of range, outside the spot cone, the dark face of a panel — so
/// a model can skip it with one test instead of repeating the classification.
LightSample SampleLight(int index, Surface s) {
  LightSample light;
  light.integrated = 0.0;
  light.ltc = vec3(0.0);

  vec4 position;
  vec4 color;
  vec4 direction;
  vec4 cone;
  if (index < kMaxLights) {
    position = frag_info.light_position[index];
    color = frag_info.light_color[index];
    direction = frag_info.light_direction[index];
    cone = frag_info.light_cone[index];
  } else {
#ifdef F3D_NO_LIGHT_LIST
    // Unreachable: `LightCount` stops at the slots without a list.
    position = vec4(0.0);
    color = vec4(0.0);
    direction = vec4(0.0);
    cone = vec4(0.0);
#else
    // A row of the light list — `gfx-74n`. Sampled at texel centres so a
    // driver's rounding cannot land a fetch on a neighbour, and the four texels
    // across the row are the same four vectors the arrays above hold.
    int slot = index - kMaxLights;
    // `L6`: from the cell rather than the draw's own tail, and a light the
    // slots already hold is skipped by its intensity, as a faded one is.
    bool clustered = Clustered();
    float listRow = clustered ? ClusterRow(slot) : LightListRow(slot);
    float v = (listRow + 0.5) * light_list_info.list.z;
    float u = light_list_info.list.y;
    // `textureLod` and not `texture`, for `shadow.glsl`'s own reason: `index`
    // reaches this branch through a function parameter, so a WGSL backend
    // cannot see that every invocation of a draw walks the same light count
    // and refuses the implicit derivative as possibly non-uniform. The atlas
    // has one level, so naming it directly changes no pixel.
    position = textureLod(light_list_texture, vec2(0.5 * u, v), 0.0);
    color = textureLod(light_list_texture, vec2(1.5 * u, v), 0.0);
    direction = textureLod(light_list_texture, vec2(2.5 * u, v), 0.0);
    cone = textureLod(light_list_texture, vec2(3.5 * u, v), 0.0);
    // The intensity and not the colour, for `LightBuffer._pack`'s own reason:
    // the same multiply here, and only one of them is a number nobody authored.
    color.w *= clustered ? (InSlots(listRow) ? 0.0 : 1.0) : LightListScale(slot);
#endif  // F3D_NO_LIGHT_LIST
  }

  float type = position.w;

  // **The rectangle leaves before `aim` is taken — `gfx-77n`.** For every other
  // kind `direction.xyz` is a unit vector saying which way the light points;
  // for this one it is an edge of the panel, with its length carrying half the
  // width, and normalising it here would quietly throw the size away.
  if (type > 2.5) {
    vec3 halfWidth = direction.xyz;
    vec3 halfHeight = cone.xyz;
    vec3 toCentre = position.xyz - v_world_position;

    vec3 corners[4];
    corners[0] = toCentre - halfWidth - halfHeight;
    corners[1] = toCentre + halfWidth - halfHeight;
    corners[2] = toCentre + halfWidth + halfHeight;
    corners[3] = toCentre - halfWidth + halfHeight;

    // **The panel emits from one face only**, and a point on the other side
    // gets nothing: the room above a ceiling panel, the outside of the wall a
    // window is set in. Tested here rather than left to the signs below,
    // because the specular's vector form factor keeps the same orientation
    // from either side of the panel, so a surface behind it facing away read
    // as lit as one in front facing it.
    bool behind = dot(toCentre, cross(halfWidth, halfHeight)) >= 0.0;

    // The cosine-weighted solid angle, which takes the place `n · l` holds for
    // a punctual light: the loop multiplies the shading by `n_dot_l`, so
    // putting the exact integral here makes the diffuse term exact rather than
    // sampled. See [RectangleFormFactor].
    float formFactor = behind ? 0.0 : RectangleFormFactor(corners, s.n);

    // Radiance rather than intensity: `intensity` means the same thing for
    // every kind of light, so a panel's is spread over its own area here.
    // Enlarging a window at a fixed rating then dims it per square metre and
    // leaves the room as bright, which is what the number is supposed to mean.
    float area = length(cross(halfWidth, halfHeight)) * 4.0;
    float radiance = area > 1e-9 ? 1.0 / area : 0.0;

    // The range window only. A punctual light needs the inverse square as
    // well; the form factor already contains it, because a panel twice as far
    // away subtends a quarter of the sky.
    float distance = length(toCentre);
    if (direction.w > 0.0) {
      float ratio = distance / direction.w;
      float window = clamp(1.0 - ratio * ratio * ratio * ratio, 0.0, 1.0);
      radiance *= window * window;
    }

    vec3 mirror = reflect(-s.v, s.n);
    vec3 representative = RectangleClosestPoint(
        position.xyz, halfWidth, halfHeight, v_world_position, mirror);
    vec3 toPoint = representative - v_world_position;
    float pointDistance = length(toPoint);
    light.l = pointDistance > 1e-6 ? toPoint / pointDistance : s.n;

    light.h = normalize(light.l + s.v);
    light.n_dot_l = formFactor;
    light.n_dot_h = max(dot(s.n, light.h), 0.0);
    light.v_dot_h = max(dot(s.v, light.h), 0.0);
    light.radiance = color.rgb * color.w * radiance;
#ifdef F3D_LTC
    // `L7`: the specular over the whole panel rather than at one point of
    // it. The diffuse keeps the exact form factor above.
    light.integrated = 1.0;
    light.ltc = LtcRectangle(s.n, s.v, s.roughness, corners);
#ifdef F3D_LAYERED
    // Kept for the clear coat's own integral; see [g_rect_corners].
    g_rect_corners = corners;
#endif
#endif
    return light;
  }

  vec3 aim = normalize(direction.xyz);
  float attenuation = 1.0;

  if (type < 0.5) {
    // Directional: no position, no falloff. The direction to the light is the
    // reverse of the direction it points.
    light.l = -aim;
  } else {
    vec3 toLight = position.xyz - v_world_position;
    float distance = length(toLight);
    // A light exactly on the surface has no direction; treat it as contributing
    // nothing rather than producing a NaN that spreads through the frame.
    if (distance < 1e-6) {
      light.l = s.n;
      light.h = s.n;
      light.radiance = vec3(0.0);
      light.n_dot_l = 0.0;
      light.n_dot_h = 0.0;
      light.v_dot_h = 0.0;
      return light;
    }
    light.l = toLight / distance;
    attenuation = PunctualAttenuation(distance, direction.w);

    if (type > 1.5) {
      // Spot: a smooth ramp between the two cone cosines. The Dart side already
      // guarantees the denominator is non-zero.
      float cosAngle = dot(aim, -light.l);
      attenuation *= clamp(
          (cosAngle - cone.y) / (cone.x - cone.y), 0.0, 1.0);
    }
  }

  light.h = normalize(light.l + s.v);
  light.n_dot_l = max(dot(s.n, light.l), 0.0);
  light.n_dot_h = max(dot(s.n, light.h), 0.0);
  light.v_dot_h = max(dot(s.v, light.h), 0.0);
  light.radiance = color.rgb * color.w * attenuation;

  return light;
}

/// How much of light [index] reaches this fragment, defined by each fragment
/// shader.
///
/// A prototype rather than a call into shadow.glsl, because the models that
/// sample no shadow map must not declare its sampler — the compiler would drop
/// the slot and leave the engine binding one that is not there. A lit model
/// returns `ShadowFactor(...)`; an unlit one returns 1.
float LightVisibility(Surface s, LightSample light, int index);

/// A model's per-light term, defined by each fragment shader.
///
/// A prototype here and the definition in the model is what lets the loop below
/// be written once. The alternative — repeating the loop in every model — is
/// six copies of the same three lines, and the place a light would go missing.
vec3 ShadeLight(Surface s, LightSample light);

/// Sums every active light's contribution.
///
/// The loop bound is the compile-time maximum with a runtime break, because GLSL
/// wants a constant trip count and the hardware wants the early exit.
// **The point-shadow half of this header, behind a guard.**
//
// A model that never shadows must not *declare* any of this, and the reason is
// the one `unlit.frag` already gives about the shadow sampler — with one
// backend's failure added to the other's. On Impeller the compiler drops what
// nothing reads, and the engine binding a slot that is no longer there is a
// native crash. On WebGL2 nothing is dropped: an active uniform block with no
// buffer under it makes every draw `INVALID_OPERATION`, discarded with nothing
// logged.
//
// That is what `lighting-unlit` was on this backend. Unlit's own metadata says
// `usesPointShadow` is false, so the engine correctly bound no `PointShadow`
// block — and the translated shader declared one anyway, so the sphere was
// never drawn and the frame came back the clear colour.
#ifndef F3D_NO_POINT_SHADOW

/// The cube atlas: three tiles across, two down, each a ninety-degree view
/// from a point light, each storing radial distance normalised by range.
uniform sampler2D point_shadow_texture;

/// The same atlas for the things that never move, rendered once at load.
///
/// Two maps rather than one because a dungeon's walls can be baked and a
/// spinning pickup cannot, and there is no way to draw into part of a texture
/// without redrawing the rest of it. Sampling both and keeping the nearer
/// occluder costs one extra read and saves six views of the level every frame.
uniform sampler2D point_shadow_static_texture;

/// How many lights may have a row of the atlas. Six tiles across each.
// Rows of the cube atlas: six faces across, this many lights down. Must
// match `Renderer.kShadowedLights`, which is where the reasoning lives, and
// `shadowSlots` in the software backend's transcription of this file.
const int kShadowSlots = 6;

layout(std140) uniform PointShadow {
  /// The same view-projections the atlas was rendered with, six per slot.
  ///
  /// Passed rather than reconstructed. Deriving cube face coordinates here
  /// would be a second implementation of a decision the renderer already made,
  /// and the two would disagree about handedness or up vectors on some face
  /// and nowhere else — which shows as one face of every shadow being wrong.
  mat4 faces[6 * kShadowSlots];

  /// Per slot. xyz: the light's world position. w: its range.
  vec4 lights[kShadowSlots];

  /// Per light, in the order the lighting knows them.
  ///
  /// x: the atlas row it owns, or negative when it has none — a fifth torch in
  /// a room lands there. z: the tangent of half the frustum's opening angle,
  /// which is what converts a world width into a fraction of a tile. y and w
  /// are unwritten.
  ///
  /// **z is exactly one for a point light**, because a cube face is a ninety
  /// degree frustum and `tan(45°) == 1`. That is not a convention chosen to be
  /// tidy: it is what lets a narrower frustum share this whole path, since
  /// multiplying by one in IEEE 754 changes no bit of the result. Whatever else
  /// a spot light will need, it does not need a second copy of the filter.
  vec4 slots[kMaxLights];

  /// x: half a texel, in tile-local uv. y: distance bias in metres.
  /// z: strength. w: normal offset, **in texels of the face it lands on**.
  vec4 params;

  /// x: smallest kernel radius in tile-local uv, and the fixed radius used
  /// when contact hardening is off. y: the light's own radius in metres; zero
  /// turns contact hardening off. z: largest kernel radius in tile-local uv.
  /// w: non-zero paints the penumbra estimate into the surface buffer instead
  /// of shading with it.
  vec4 params2;

  /// x: non-zero when this backend stores the atlas bottom-up. y: one over the
  /// edge length of a tile in texels, which is what turns a distance into the
  /// world width of one texel there.
  ///
  /// **Appended after everything else on purpose**, the same way FragInfo's
  /// ambient pair was: std140 lays a block out in declaration order, so adding
  /// here leaves every offset above unchanged and the three backends do not
  /// have to agree about anything they already agreed about. y, z and w are
  /// unwritten.
  vec4 params3;
}
point_shadow;

/// Eight points on a Poisson disk, a common set for filtering cascaded
/// shadows.
///
/// A disk rather than a grid because a grid of taps on a straight shadow edge
/// lands every sample on the same side at once, and the edge steps between
/// kernel widths instead of sliding. Eight rather than sixteen because every
/// tap here reads **two** atlases — the static walls and the movers — so the
/// cost is doubled before it is counted.
vec2 PointShadowDiskTap(int i) {
  if (i == 0) return vec2(-0.94201624, -0.39906216);
  if (i == 1) return vec2(0.94558609, -0.76890725);
  if (i == 2) return vec2(-0.09418410, -0.92938870);
  if (i == 3) return vec2(0.34495938, 0.29387760);
  if (i == 4) return vec2(-0.91588581, 0.45771432);
  if (i == 5) return vec2(-0.81544232, -0.87912464);
  if (i == 6) return vec2(-0.38277543, 0.27676845);
  return vec2(0.97484398, 0.75648379);
}

/// One comparison against the atlas, at [uv] offset within the tile.
///
/// The clamp is applied **after** the offset, not before, and that is the whole
/// reason a kernel can be widened here without touching anything else: each tap
/// is held inside its own tile individually. Clamping the centre and then
/// offsetting would let the outer taps walk straight out of the tile and read a
/// distance measured from a different face, or a different light.
float PointShadowDistance(vec2 uv, vec2 offset, vec2 tile, float range) {
  float inset = point_shadow.params.x;
  vec2 local = clamp(uv + offset, inset, 1.0 - inset);
  vec2 atlas = (local + tile) * vec2(1.0 / 6.0, 1.0 / float(kShadowSlots));
  // **The whole atlas, turned over, where row zero of a render target is at the
  // bottom.** Both halves of the address are wrong there and this is the one
  // place that fixes both: the tile the light owns — a light in slot zero is
  // drawn into the row the shader would call three, because the viewport
  // rectangle is flipped to land it — and the picture inside that tile, which
  // was drawn through a projection built for the other origin.
  //
  // Every check of this atlas missed it for the same reason: the debug view
  // composites the texture through a full-screen pass, which turns it over
  // again and puts the row back. The atlas compared equal on both backends
  // across six scenes while the lit pass, which samples it directly and has no
  // such pass to cancel, read a row that had never been drawn into and found
  // nothing in the way of anything.
  if (point_shadow.params3.x > 0.5) atlas.y = 1.0 - atlas.y;
  // Whichever is nearer occludes: a wall in front of a monster shadows, and so
  // does a monster in front of a wall.
  //
  // **`textureLod` at level zero, because every caller of this function stands
  // behind a branch.** The light loop skips a light the surface faces away
  // from, the blocker search `continue`s past a tap that found nothing, and the
  // slot test returns before any of it — so the invocations of a quad do not
  // arrive here together, and a WGSL backend refuses a sample whose implicit
  // derivative would be read where they disagree. Both atlases are distance
  // render targets with one level, so level zero is the level `texture` was
  // choosing anyway; this names it rather than deriving it, and the picture is
  // the same on every backend.
  return min(textureLod(point_shadow_texture, atlas, 0.0).r,
             textureLod(point_shadow_static_texture, atlas, 0.0).r) * range;
}

float PointShadowTap(vec2 uv, vec2 offset, vec2 tile, float range,
                     float receiver) {
  float stored = PointShadowDistance(uv, offset, tile, range);
  // Nothing was drawn in that direction by either, so nothing is in the way.
  if (stored >= range * 0.999) return 1.0;
  return receiver > stored ? 0.0 : 1.0;
}

/// The disk point for tap [i], rotated by [ca]/[sa] and scaled to [radius].
vec2 PointShadowOffset(int i, float ca, float sa, float radius) {
  vec2 p = PointShadowDiskTap(i);
  return vec2(p.x * ca - p.y * sa, p.x * sa + p.y * ca) * radius;
}

/// How wide the penumbra should be here, in tile-local uv.
///
/// Contact hardening, and the reason a fixed kernel looks wrong: a shadow is
/// sharp where its caster touches the floor and soft a metre away, and one
/// radius for both makes the contact mushy or the distant edge hard.
///
/// The similar-triangles estimate is the standard one — a light of radius `L`
/// with a blocker at `b` and a receiver at `r` throws a penumbra `L * (r - b) /
/// b` wide at the receiver. Converting that to tile uv is exact rather than
/// tuned, because a face is a ninety degree frustum: at distance `r` from the
/// light the face spans `2 * r` in world units across the full `0..1` of uv,
/// so a world width `w` is `w / (2 * r)` of a tile.
///
/// The blocker search runs at the **widest** penumbra allowed, since a blocker
/// outside that circle cannot widen the result anyway, and searching narrower
/// would miss the very blockers that make an edge soft.
///
/// [tanHalf] is where the ninety degrees stop being assumed. The span above is
/// `2 * r` only for a right-angled frustum; in general it is `2 * r * tan(θ/2)`,
/// and for a cube face that factor is one. A narrower frustum covers less world
/// per tile, so the same world width is a *larger* fraction of it — which is
/// why this divides rather than multiplies, and why getting it upside down
/// would make a tight cone's shadows harden instead of soften.
float PointShadowPenumbra(vec2 uv, vec2 tile, float range, float receiver,
                          float ca, float sa, float tanHalf,
                          out float blockerOut) {
  blockerOut = -1.0;
  float lightRadius = point_shadow.params2.y;
  float minRadius = point_shadow.params2.x;
  float maxRadius = point_shadow.params2.z;
  if (lightRadius <= 0.0) {
    // **The debug channel is filled even though the search is skipped**, and
    // leaving it unfilled cost a session. `blockerOut` starts at −1 to mean
    // "nothing was measured"; the debug encoding clamps it into a colour, where
    // −1 becomes zero — the same green as a blocker touching the surface, which
    // reads as the most alarming answer available. A whole theory was built on
    // that zero, and the search it described had never run.
    //
    // The centre tap is what the filter below would use anyway, so this reports
    // a distance the atlas really returned rather than a sentinel.
    blockerOut = PointShadowDistance(uv, vec2(0.0), tile, range);
    return minRadius;
  }


  float sum = 0.0;
  float count = 0.0;
  for (int i = 0; i < 8; i++) {
    float stored =
        PointShadowDistance(uv, PointShadowOffset(i, ca, sa, maxRadius), tile,
                            range);
    if (stored >= range * 0.999) continue;
    if (stored >= receiver) continue;
    sum += stored;
    count += 1.0;
  }
  // Nothing in front of this fragment anywhere in the search: fully lit, and
  // the caller can skip the filter entirely.
  if (count < 0.5) return -1.0;

  float blocker = max(sum / count, 1e-4);
  blockerOut = blocker;
  float world = lightRadius * max(receiver - blocker, 0.0) / blocker;
  return clamp(world / (2.0 * receiver * tanHalf), minRadius, maxRadius);
}

/// How lit [world] is by the point light that owns the cube atlas.
///
/// One, fully lit, when this is not that light or the atlas has nothing to say.
float PointShadowFactor(vec3 world, vec3 normal, int lightIndex) {
  int slot = int(point_shadow.slots[lightIndex].x + 0.5);
  if (point_shadow.slots[lightIndex].x < 0.0) return 1.0;
  float strength = point_shadow.params.z;
  if (strength <= 0.0) return 1.0;

  // Offset along the normal before measuring, and scaled by how steeply the
  // surface leans away from the light.
  //
  // A soft kernel on a tilted surface straddles a depth gradient: the taps at
  // one end of the disk are further from the light than the fragment itself,
  // so a flat offset that clears the surface head-on leaves acne at a grazing
  // angle. The slope term lifts the whole kernel clear instead, and is capped
  // because it runs away as the surface turns edge-on to the light — an
  // uncapped lift detaches the shadow from its caster.
  vec3 toLight = point_shadow.lights[slot].xyz - world;
  float toLightLength = max(length(toLight), 1e-6);
  float nDotL = max(dot(normal, toLight / toLightLength), 0.15);
  float slope = min(sqrt(max(1.0 - nDotL * nDotL, 0.0)) / (nDotL * nDotL), 8.0);

  // **How wide one texel of the face is, out where this fragment is.** The
  // error a normal offset exists to clear is exactly that: a texel of the
  // shadow map covers a patch of surface, the whole patch is recorded at one
  // distance, and a fragment anywhere else in it compares against a distance
  // measured somewhere it is not. That patch grows with range — it is a solid
  // angle, not a length — so an offset fixed in metres is right at one distance
  // and wrong everywhere else.
  //
  // What it was: `params.w` metres, flat. On the golden teapot, at 9.6 m from
  // the lamp, a texel is 3.7 cm and the flat offset was 2 cm, so the floor
  // shadowed itself across everything the light reached — and the acne stopped
  // dead at the *projection of the floor's own edge*, because past it the atlas
  // holds nothing and nothing can occlude. A straight line across a shadow with
  // no straight edge anywhere in the scene.
  float texel =
      2.0 * toLightLength * max(point_shadow.slots[lightIndex].z, 1e-4) *
      point_shadow.params3.y;
  // Both terms are metres. The slope term used to be the kernel radius, which
  // is a fraction of a tile — a unit mismatch carried over from an estimate
  // where a softness radius genuinely was the right quantity. Here it meant
  // widening the kernel also lifted the sample off the surface, by up to ten
  // centimetres at the wider settings, so the softening and the lift
  // cancelled: tripling the kernel moved 184 pixels of the frame,
  // where the kernel alone moves thousands. It is what made contact hardening
  // look inert, and it was hiding in a comparison rather than in the estimate.
  vec3 origin = world + normal * texel * point_shadow.params.w * (1.0 + slope);
  vec3 toFragment = origin - point_shadow.lights[slot].xyz;
  float distance = length(toFragment);
  float range = max(point_shadow.lights[slot].w, 1e-4);
  if (distance >= range) return 1.0;

  // The dominant axis picks the face, in the order the renderer wrote them:
  // +X, -X, +Y, -Y, +Z, -Z, left to right then top to bottom.
  //
  // A spot has one column and no choice to make. Asking the dominant axis
  // anyway would be worse than pointless: a fragment below and to the side of
  // a downlight has −Y dominant, which is column 3, and column 3 of a spot's
  // row is deliberately blank — so the whole cone would read as unshadowed
  // except for the wedge where the aim happens to be the dominant axis.
  int face = 0;
  if (point_shadow.slots[lightIndex].y < 0.5) {
    vec3 a = abs(toFragment);
    if (a.x >= a.y && a.x >= a.z) {
      face = toFragment.x > 0.0 ? 0 : 1;
    } else if (a.y >= a.z) {
      face = toFragment.y > 0.0 ? 2 : 3;
    } else {
      face = toFragment.z > 0.0 ? 4 : 5;
    }
  }

  vec4 clip = point_shadow.faces[slot * 6 + face] * vec4(origin, 1.0);
  if (clip.w <= 0.0) return 1.0;
  vec2 ndc = clip.xy / clip.w;
  if (abs(ndc.x) > 1.0 || abs(ndc.y) > 1.0) return 1.0;

  // v is flipped, the same way the directional map does it: the texture's
  // origin is at the top, where row zero of the render target is. Getting this
  // wrong does not tilt the shadow — it makes the top row of faces read the
  // bottom row, so a whole region compares against an unrelated distance and
  // comes out as a black slab.
  vec2 uv = vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
  // The face across, the light down: six tiles wide, four tall.
  vec2 tile = vec2(float(face), float(slot));

  float receiver = distance - point_shadow.params.y;

  // One rotation, shared by the blocker search and the filter. Per fragment,
  // so eight samples read as a soft edge rather than as eight copies of the
  // silhouette: without it every fragment along an edge tests the same eight
  // directions and the pattern shows.
  //
  // **The three constants are not arbitrary and are not ours.** This is Jorge
  // Jimenez's interleaved gradient noise, from "Next Generation Post
  // Processing in Call of Duty: Advanced Warfare" (SIGGRAPH 2014):
  //
  //   IGN(x, y) = frac(52.9829189 * frac(0.06711056 * x + 0.00583715 * y))
  //
  // The pair inside the dot is a direction whose gradient walks the unit
  // interval as slowly as it can while never repeating over a screen, and the
  // multiplier outside stretches that walk so neighbouring pixels land far
  // apart in the result. What it buys over a hash is the cost: one dot and two
  // fracts, no integer arithmetic, no texture. What a blue-noise texture buys
  // over it is a better spectrum, at a sampler and a fetch — worth it for
  // dithering a whole frame, not for rotating eight taps.
  //
  // Written down because three unexplained decimals read as a magic spell, and
  // the next person to touch this line has no way to tell which of them may be
  // changed. The answer is none of them.
  float noise = fract(52.9829189 * fract(dot(FragCoordFromTop(
                                                frag_info.target_origin.x),
                                            vec2(0.06711056, 0.00583715))));
  float angle = noise * 6.28318530718;
  float ca = cos(angle);
  float sa = sin(angle);

  // Guarded rather than read straight, because a zero here divides by zero and
  // a NaN radius poisons the filter into a black fragment. Zero is what an
  // unwritten channel holds, and "unwritten" is a state this block has been in
  // before: every slot is cleared to −1 each frame.
  float tanHalf = max(point_shadow.slots[lightIndex].z, 1e-4);

  float blocker = -1.0;
  float radius =
      PointShadowPenumbra(uv, tile, range, receiver, ca, sa, tanHalf, blocker);

  // The debug channel, and the reason it exists: two explanations for why the
  // estimate collapses were argued from the finished picture and both were
  // wrong, because the number that decides it never leaves this function.
  //
  // Red is how wide the penumbra came out, against the widest allowed. Green
  // is how far away the blocker was, against the light's range. Blue marks
  // the fragments where the search found nothing at all — which is a different
  // answer from "found something very close", and telling those two apart is
  // most of the question.
  if (point_shadow.params2.w > 0.5) {
    g_debug_surface_on = true;
    g_debug_surface = radius < 0.0
        ? vec3(0.0, 0.0, 1.0)
        : vec3(clamp(radius / max(point_shadow.params2.z, 1e-6), 0.0, 1.0),
               clamp(blocker / range, 0.0, 1.0), 0.0);
  }

  // The search found nothing between here and the light.
  if (radius < 0.0) return 1.0;

  float lit = PointShadowTap(uv, vec2(0.0), tile, range, receiver);
  if (radius > 0.0) {
    for (int i = 0; i < 8; i++) {
      lit += PointShadowTap(uv, PointShadowOffset(i, ca, sa, radius), tile,
                            range, receiver);
    }
    lit *= 1.0 / 9.0;
  }

  // Strength lerps towards fully lit, so the control is "how dark", not "how
  // much of the kernel" — the same convention the directional map uses.
  return mix(1.0, lit, clamp(strength, 0.0, 1.0));
}

#else

/// The stand-in for a model that declares none of the above.
///
/// Fully lit, which is what a model with no shadow term means, and a constant
/// the compiler folds rather than a branch anything pays for.
float PointShadowFactor(vec3 world, vec3 normal, int lightIndex) {
  return 1.0;
}

#endif  // F3D_NO_POINT_SHADOW

vec3 AccumulateLights(Surface s) {
  vec3 total = vec3(0.0);
  int count = LightCount();

  for (int i = 0; i < kTotalLights; i++) {
    if (i >= count) break;
    LightSample light = SampleLight(i, s);
    if (light.n_dot_l <= 0.0) continue;
    // A light from the list has no shadow row to read — see `LightHasShadow`.
    // A branch rather than something folded into the two calls, because both
    // index tables eight entries wide and the ninth light would read past them
    // rather than read a one.
    float visibility = LightHasShadow(i)
        ? LightVisibility(s, light, i) *
              PointShadowFactor(v_world_position, s.n, i)
        : 1.0;
    if (visibility <= 0.0) continue;
    total += ShadeLight(s, light) * light.radiance * light.n_dot_l * visibility;
  }

  return total;
}

#endif  // SURFACE_GLSL_

// --- lib/irradiance.glsl ---
// The irradiance field, read per pixel — `L3`.
//
// **Per pixel where it was per object.** The field used to be sampled once
// per draw at the node's centre, twice (up and down), and handed to the shader
// as the hemisphere ambient. A floor that runs from a red wall to a blue one
// then took one colour, whichever its middle saw. Read here, at each point,
// the red bleeds onto the floor near the red wall and fades across it.
//
// The field arrives as one float texture: every probe's irradiance tile (rgb,
// with the probe's "active" flag in alpha) in a grid of `columns` × `rows`
// tiles at the top, and every probe's depth-moment tile (mean and mean
// square) in the same grid below. Each tile carries a one-texel gutter, so a
// bilinear read inside it never needs to know where the tile ends. The read
// is done here, four nearest taps at a time, rather than by a filtered
// sampler: a filtered float texture is a capability three backends answer
// differently, and four taps are the same on all of them.
//
// Weights per probe, as `IrradianceField.sample` on the host: trilinear by
// the point's place in its cell, the square of a half-cosine towards the
// probe, and Chebyshev's bound from the depth moments, the last two floored
// and crushed so no active probe's weight reaches nought. The point is moved
// off its surface along the normal and towards the eye first, so a surface
// does not read the probe's own view of it as a wall.
//
// Included by the lit models only, through `material_maps.glsl`.

#ifndef IRRADIANCE_GLSL_
#define IRRADIANCE_GLSL_

uniform sampler2D irradiance_texture;

layout(std140) uniform IrradianceInfo {
  /// xyz: where probe (0, 0, 0) stands. w: one when the field is read,
  /// nought when the hemisphere ambient stands.
  vec4 origin;

  /// xyz: the spacing between probes per axis. w: how far the point is
  /// moved along the normal, in metres.
  vec4 spacing;

  /// xyz: probes per axis. w: how far the point is moved towards the eye.
  vec4 counts;

  /// x: an irradiance tile's interior, y: a moment tile's, in texels.
  /// z: tiles per row of the atlas. w: the row the moment tiles start at.
  vec4 tiles;

  /// xy: one over the atlas's size. zw unused.
  vec4 atlas;
}
irradiance_info;

bool IrradianceEnabled() { return irradiance_info.origin.w > 0.5; }

/// `encodeOctahedral` in `irradiance_field.dart`.
vec2 ProbeOctahedral(vec3 direction) {
  float sum = abs(direction.x) + abs(direction.y) + abs(direction.z);
  if (sum <= 0.0) return vec2(0.5);
  vec3 n = direction / sum;
  vec2 xy = n.xy;
  if (n.z < 0.0) {
    xy = vec2((1.0 - abs(n.y)) * (n.x >= 0.0 ? 1.0 : -1.0),
              (1.0 - abs(n.x)) * (n.y >= 0.0 ? 1.0 : -1.0));
  }
  return xy * 0.5 + 0.5;
}

vec4 AtlasTexel(vec2 texel) {
  return textureLod(irradiance_texture, (texel + 0.5) * irradiance_info.atlas.xy,
                    0.0);
}

/// A bilinear read of the tile whose top-left stored texel is [corner],
/// [interior] wide, at the octahedral [uv].
vec4 TileBilinear(vec2 corner, float interior, vec2 uv) {
  vec2 at = 1.0 + uv * interior - 0.5;
  vec2 low = floor(at);
  vec2 f = at - low;
  vec4 a = AtlasTexel(corner + low);
  vec4 b = AtlasTexel(corner + low + vec2(1.0, 0.0));
  vec4 c = AtlasTexel(corner + low + vec2(0.0, 1.0));
  vec4 d = AtlasTexel(corner + low + vec2(1.0, 1.0));
  return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}

/// The irradiance arriving at [world] on a surface facing [normal], seen
/// from the direction [view] (a unit vector towards the eye).
vec3 SampleIrradiance(vec3 world, vec3 normal, vec3 view) {
  vec3 origin = irradiance_info.origin.xyz;
  vec3 spacing = irradiance_info.spacing.xyz;
  vec3 counts = irradiance_info.counts.xyz;
  float irradianceTile = irradiance_info.tiles.x;
  float depthTile = irradiance_info.tiles.y;
  float columns = irradiance_info.tiles.z;
  float momentsTop = irradiance_info.tiles.w;
  vec3 unit = normalize(normal);

  vec3 biased = world + unit * irradiance_info.spacing.w +
                view * irradiance_info.counts.w;
  vec3 grid = (biased - origin) / spacing;
  vec3 base = clamp(floor(grid), vec3(0.0), counts - 2.0);
  vec3 f = clamp(grid - base, vec3(0.0), vec3(1.0));

  vec3 total = vec3(0.0);
  float weights = 0.0;
  for (int corner = 0; corner < 8; corner++) {
    vec3 offset = vec3(float(corner & 1), float((corner >> 1) & 1),
                       float((corner >> 2) & 1));
    vec3 cell = base + offset;
    float probe = (cell.z * counts.y + cell.y) * counts.x + cell.x;
    vec2 tile = vec2(mod(probe, columns), floor(probe / columns));

    vec2 irradianceCorner = tile * (irradianceTile + 2.0);
    vec2 momentCorner = vec2(tile.x * (depthTile + 2.0),
                             momentsTop + tile.y * (depthTile + 2.0));

    // The probe's own flag, on the tile's first interior texel.
    if (AtlasTexel(irradianceCorner + 1.0).a < 0.5) continue;

    vec3 trilinear = mix(vec3(1.0) - f, f, offset);
    float weight = max(trilinear.x * trilinear.y * trilinear.z, 0.001);

    vec3 probePosition = origin + spacing * cell;
    vec3 toProbe = probePosition - biased;
    float distance = length(toProbe);
    if (distance > 1e-6) {
      vec3 direction = toProbe / distance;
      // Facing and visibility are floored, then crushed, rather than let
      // fall to nought (Majercik et al. 2019): a probe behind the surface or
      // past a wall counts for almost nothing but never for nothing, so a
      // point every probe of its cell is cut off from still reads a blend of
      // them rather than black.
      float facing = dot(unit, normalize(probePosition - world)) * 0.5 + 0.5;
      float probeWeight = facing * facing + 0.2;

      vec2 moments = TileBilinear(momentCorner, depthTile,
                                  ProbeOctahedral(-direction)).xy;
      float chebyshev = 1.0;
      if (distance > moments.x) {
        float variance = max(moments.y - moments.x * moments.x, 1e-6);
        float difference = distance - moments.x;
        chebyshev = variance / (variance + difference * difference);
        chebyshev = chebyshev * chebyshev * chebyshev;
      }
      probeWeight = max(probeWeight * max(chebyshev, 0.05), 1e-6);
      if (probeWeight < 0.2) probeWeight *= probeWeight * probeWeight * 25.0;
      weight *= probeWeight;
    }

    total += TileBilinear(irradianceCorner, irradianceTile,
                          ProbeOctahedral(unit)).rgb *
             weight;
    weights += weight;
  }
  return weights > 0.0 ? total / weights : vec3(0.0);
}

#endif  // IRRADIANCE_GLSL_


/// Tangent-space normal map. Neutral is (0.5, 0.5, 1.0).
uniform sampler2D normal_texture;

/// glTF's ORM packing: g is roughness, b is metallic. Neutral is white.
uniform sampler2D metallic_roughness_texture;

/// Ambient occlusion in r. Neutral is white.
uniform sampler2D occlusion_texture;

/// Emitted colour, multiplied by the emissive factor. Neutral is white, and the
/// factor defaults to black, so a material with neither emits nothing.
uniform sampler2D emissive_texture;

/// The level's baked lightmap, RGBM: colour over a shared multiplier, decoded
/// as `rgb × a × 8`. Sampled at the second coordinate, which every vertex
/// stage but the lightmapped one leaves at the atlas corner; neutral is
/// black, so a material without a map adds nothing.
uniform sampler2D lightmap_texture;

/// The irradiance the lightmap holds at this fragment, in the units a light's
/// `colour × intensity × attenuation × cos` arrives in.
vec3 SampleLightmap() {
  vec4 texel = texture(lightmap_texture, v_lightmap_uv);
  return texel.rgb * texel.a * 8.0;
}

/// One function per map, rather than one that applies all four.
///
/// Not a style choice. The compiler drops a sampler whose result never reaches
/// the output, so a model that samples the ORM map and then ignores metallic and
/// roughness — Lambert does exactly that — ends up with no
/// `metallic_roughness_texture` in its compiled signature at all, while the Dart
/// side still thinks there is one to bind. That is the phantom-binding trap
/// again, and binding a slot Metal does not have is a native crash.
///
/// Splitting them means a model calls only what it genuinely uses, so the
/// compiled signature matches the source, and `LightingModel` can declare the
/// same set truthfully. `tool/build_shaders.sh` prints the compiled slots so
/// the two cannot drift apart unnoticed.

/// glTF's ORM packing: roughness in g, metallic in b, both multiplying the
/// material factors.
void ApplyMetallicRoughnessMap(inout Surface s) {
  vec3 orm = texture(metallic_roughness_texture, MapUv(kMapMetallicRoughness), MaterialLodBias()).rgb;
  s.metallic = clamp(s.metallic * orm.b, 0.0, 1.0);
  s.roughness = clamp(s.roughness * orm.g, 0.02, 1.0);
}

void ApplyOcclusionMap(inout Surface s) {
  float occlusion = texture(occlusion_texture, MapUv(kMapOcclusion), MaterialLodBias()).r;
  // glTF's occlusionStrength lerps between "ignore the map" and "apply it in
  // full", which is why it is a mix and not a multiply.
  s.occlusion = mix(1.0, occlusion, clamp(frag_info.material2.z, 0.0, 1.0));
}

void ApplyEmissiveMap(inout Surface s) {
  vec3 emissive = SrgbToLinear(texture(emissive_texture, MapUv(kMapEmissive), MaterialLodBias()).rgb);
  s.emissive = emissive * frag_info.emissive.rgb * frag_info.material2.w;
}

/// Perturbs the surface normal by the tangent-space normal map.
void ApplyNormalMap(inout Surface s) {
  // **Sampled before the frame is tested, and that order is load-bearing.**
  // The test below is a branch on interpolated data, so the four invocations of
  // a quad can take different sides of it; a WGSL backend then refuses a
  // `texture` call underneath, because the mip level it derives is only defined
  // where the whole quad agrees. Unlike the shadow atlases, this map really is
  // mipped — a normal map read at full resolution on a surface turned away from
  // the camera is the aliasing that made this the widest disagreement between
  // backends — so pinning a level here would be a picture change, and hoisting
  // the sample is the cure that is not. A degenerate tangent is rare enough
  // that paying for its unused texel is nothing, and the texel it reads is the
  // same one the branch would have read.
  vec4 sampledTexel = texture(normal_texture, MapUv(kMapNormal), MaterialLodBias());

  // The tangent is re-orthogonalized against the normal because interpolating
  // both across a triangle does not preserve the right angle between them.
  vec3 t = v_tangent.xyz;
  t = t - s.n * dot(s.n, t);
  if (dot(t, t) < 1e-12) return;  // no usable frame; keep the vertex normal
  t = normalize(t);

  // The bitangent sign is what encodes a mirrored UV island. Dropping it makes
  // every mirrored half of a symmetric model light from the wrong side, which
  // is exactly what NormalTangentTest is built to show.
  vec3 b = cross(s.n, t) * v_tangent.w;
#ifdef F3D_TEXTURE_TRANSFORM
  // `C8`: a map turned or mirrored by its transform is read along axes the
  // vertex tangent no longer names, so the frame turns with it — the rule
  // `withTextureTransform` applies to a baked mesh, here at the sampler. The
  // new tangent is where the map's own `u` increases: the first column of the
  // matrix's inverse, times its determinant, whose sign a mirror flips and the
  // bitangent's sign with it. Measured on the front face's frame, which is
  // the frame the transform was authored on. A plain scale leaves the frame
  // as it was, bit for bit, which is why the test is on the matrix. That
  // column is `m11 dP/du - m10 dP/dv`, and dP/dv is **minus** the bitangent:
  // `v` runs down the texture, a normal map's green up it.
  vec4 m = MapMatrix(kMapNormal);
  float det = m.x * m.w - m.y * m.z;
  float flip = det < 0.0 ? -1.0 : 1.0;
  vec3 front = gl_FrontFacing ? b : -b;
  vec3 turned = (t * m.w + front * m.z) * flip;
  bool turns = (m.y != 0.0 || m.z != 0.0 || m.x < 0.0 || m.w < 0.0) &&
               dot(turned, turned) > 1e-12;
  t = turns ? normalize(turned) : t;
  b = turns ? cross(s.n, t) * v_tangent.w * flip : b;
#endif
  // On a back face `ReadSurface` has already turned the normal round, and
  // the bitangent above turned with it. The tangent has to follow, or the
  // frame is half-mirrored and relief along u lights from the wrong side —
  // glTF turns the whole frame, not the normal alone.
  if (!gl_FrontFacing) t = -t;

  vec3 sampled = sampledTexel.xyz * 2.0 - 1.0;
  // A two-channel map (BC5, RG8) stores only x and y and samples as
  // (x, y, 0, 1); read as it stands, blue 0 is z = -1 and the normal points
  // into the surface. z is rebuilt from the unit length instead, before the
  // scale, which glTF applies to the stored normal. `emissive.w` is the flag.
  if (frag_info.emissive.w > 0.5) {
    sampled.z = sqrt(max(1.0 - dot(sampled.xy, sampled.xy), 0.0));
  }
  // normalScale attenuates the tangent-space xy, per the glTF spec.
  sampled.xy *= frag_info.material2.y;

  s.n = normalize(t * sampled.x + b * sampled.y + s.n * sampled.z);
  s.n_dot_v = max(dot(s.n, s.v), 1e-4);
}

/// The three maps every lit model uses. Metal-rough is separate because only
/// the models that actually respond to metallic or roughness may sample it.
void ApplyCommonMaps(inout Surface s) {
  // `L3`: the field in place of the hemisphere, read before the normal map
  // for the reason the hemisphere is — which half of the room a face sees is
  // not a question about millimetres of relief. At the same strength the
  // hemisphere was.
  if (IrradianceEnabled()) {
    s.ambient = SampleIrradiance(v_world_position, s.n, s.v) *
                frag_info.material.z;
  }
  ApplyNormalMap(s);
  ApplyOcclusionMap(s);
  ApplyEmissiveMap(s);
}

#endif  // MATERIAL_MAPS_GLSL_

// --- lib/shadow.glsl ---
// Sampling the directional light's shadow map.
//
// A separate header for the same reason material_maps.glsl is one: the sampler
// must only be declared by shaders that actually read it, or the compiler drops
// the slot while the engine still tries to bind it.

#ifndef SHADOW_GLSL_
#define SHADOW_GLSL_

// --- lib/evsm.glsl ---
// Exponential variance shadow maps — `S2`.
//
// Shared by the pass that turns the directional depth atlas into moments
// (`evsm_filter.frag`) and by `ShadowFactor`, which reads them back: the two
// halves must warp depth with the same two exponents, or every comparison is
// between numbers on different scales.
//
// A header of its own rather than a section of `shadow.glsl`, because that
// one declares the lit stages' shadow sampler and the filter pass has no
// business declaring it.

#ifndef EVSM_GLSL_
#define EVSM_GLSL_

precision highp float;

// The two exponents depth is warped by. **Forty and five, and the ceiling is
// the format.** The moments are stored squared, so the positive side reaches
// e^80 at the far plane, about 5.5e34 — inside a 32-bit float with three
// orders of magnitude to spare, and far outside a half float, which is why
// the moments live in an rgba32f atlas and the depth atlas does not. The
// negative side only has to catch what the positive side lets through at a
// receiver just behind a caster, and five is the usual answer.
const float kEvsmPositive = 40.0;
const float kEvsmNegative = 5.0;

/// [depth], in [0, 1], warped onto both exponentials: x positive, y negative.
///
/// Depth is first spread to [-1, 1] so the two sides share the range evenly
/// rather than the negative one flattening to nothing at the far end.
vec2 EvsmWarp(float depth) {
  float d = 2.0 * clamp(depth, 0.0, 1.0) - 1.0;
  return vec2(exp(kEvsmPositive * d), -exp(-kEvsmNegative * d));
}

/// What one texel of the depth atlas stores in the moments atlas: each warp
/// and its square, which a blur then averages into a mean and a variance.
vec4 EvsmMoments(float depth) {
  vec2 warped = EvsmWarp(depth);
  return vec4(warped.x, warped.x * warped.x, warped.y, warped.y * warped.y);
}

/// Chebyshev's upper bound on the share of [moments]'s distribution at or
/// beyond [t], with the light-bleeding cut [bleed] taken off the bottom.
///
/// A select at the end rather than an early return of one, because a phi of
/// constants is what SPIRV-Cross refuses when it writes the WGSL.
float EvsmChebyshev(vec2 moments, float t, float minVariance, float bleed) {
  float variance = max(moments.y - moments.x * moments.x, minVariance);
  float d = t - moments.x;
  float pMax = variance / (variance + d * d);
  // Light bleeding: where two casters overlap, the bound admits light the
  // nearer one should block. Everything under [bleed] is called shadow and
  // the rest stretched back over [0, 1].
  float reduced = clamp((pMax - bleed) / max(1.0 - bleed, 1e-4), 0.0, 1.0);
  return t <= moments.x ? 1.0 : reduced;
}

/// How much light reaches a receiver at [depth] past filtered [moments].
///
/// The smaller of the two bounds: each exponential lets through a different
/// kind of error, and neither lets through what the other stops.
float EvsmVisibility(vec4 moments, float depth, float bleed) {
  vec2 warped = EvsmWarp(depth);
  // A floor on the variance proportional to the warped depth's own slope,
  // so a flat receiver compared against its own texel does not divide
  // nought by nought — the variance of one depth is zero.
  vec2 scale = 0.0001 * vec2(kEvsmPositive, kEvsmNegative) * warped;
  float positive = EvsmChebyshev(moments.xy, warped.x, scale.x * scale.x, bleed);
  float negative = EvsmChebyshev(moments.zw, warped.y, scale.y * scale.y, bleed);
  return min(positive, negative);
}

#endif  // EVSM_GLSL_


/// Linear depth from the light's point of view, in the red channel — or,
/// with the `evsm` filter (`S2`), the blurred moments `evsm_filter.frag`
/// made of it, bound to the same slot so the lit stages spend no sampler on
/// the choice.
uniform sampler2D shadow_texture;

/// Point [i] of [n] on a Vogel disc turned by [turn] radians — `S3`: the
/// golden angle between neighbours, so any prefix of the points covers the
/// disc evenly, and a radius growing with the square root, so they cover it
/// at an even density.
vec2 VogelDisc(int i, int n, float turn) {
  float r = sqrt((float(i) + 0.5) / float(n));
  float theta = float(i) * 2.3999632 + turn;
  return r * vec2(cos(theta), sin(theta));
}

/// Interleaved gradient noise at this pixel, in [0, 1), stepped on by the
/// frame's slice while a temporal resolve runs (`target_origin.w`) so the
/// history averages the rotations. The pattern needs no texture, which keeps
/// the lit stages at the samplers they have. Rows are counted from the top
/// (`target_origin.x`), as the point shadow's rotation counts them, so WebGL2
/// turns the kernel on the same pixels as every other backend.
float ShadowNoise() {
  vec2 at = FragCoordFromTop(frag_info.target_origin.x) +
            5.588238 * max(frag_info.target_origin.w, 0.0);
  return fract(52.9829189 * fract(dot(at, vec2(0.06711056, 0.00583715))));
}

/// How much of the light survives at this fragment, from 0 to 1.
///
/// Returns 1 when shadows are off, when the fragment falls outside the map, or
/// when the light in question is not the caster — a fragment beyond the shadow
/// volume is unshadowed, not black, and getting that wrong puts a hard edge
/// across the scene at the edge of the map.
float ShadowFactor(Surface s, LightSample light, int lightIndex) {
  float strength = frag_info.shadow_params.w;
  if (strength <= 0.0) return 1.0;
  if (lightIndex != int(frag_info.frame_params.z + 0.5)) return 1.0;

  // Normal offset: move the sample point along the surface normal before
  // projecting it. It costs nothing and fixes the shadow acne that a depth bias
  // alone cannot, because the error is proportional to the surface's slope
  // relative to the light rather than to depth.
  //
  // **A flat distance plus what the kernel's reach needs, and no more.** The
  // flat part alone was tuned for surfaces the map never recorded: with the
  // default `casterFaces: back` a closed mesh writes only the faces turned
  // away from the sun, so a lit face compares against its own far side. A
  // double-sided material writes its lit faces too, and then the offset has
  // to lift the point clear of its own plane as far out as the 3×3 kernel
  // reads: a tap one texel over lands in a texel whose centre is up to a
  // texel and a half away, where the plane is 1.5·texel·tanθ nearer the
  // light. A step d along the normal clears the plane by d / cosθ along the
  // ray, so d = 1.5·texel·sinθ is exactly enough, taken per axis of the map
  // because a slope running diagonally across it reaches further in texels.
  // Nothing at normal incidence, a texel and a half at grazing. The depth
  // bias covers the rest. Every metre more than this moves the shadow away
  // from its caster, and in the far cascade a texel is decimetres. Measured
  // per cascade in the loop below, since each has a texel of its own.

  // Which cascade covers this fragment.
  //
  // Chosen by distance from the camera and then *checked*, because the volumes
  // are spheres on the line of sight rather than fitted frusta: a fragment at
  // the edge of the view can be past the end of the cascade its distance
  // suggests. Falling through to the next one costs a branch and removes a
  // whole class of missing-shadow bug, and the last cascade is fitted to the
  // entire scene, so the fall-through always terminates somewhere real.
  int cascadeCount = int(frag_info.shadow_cascades.z + 0.5);
  float viewDistance = length(v_world_position - frag_info.camera_position.xyz);
  int cascade = 0;
  if (cascadeCount > 1 && viewDistance > frag_info.shadow_cascades.x) cascade = 1;
  if (cascadeCount > 2 && viewDistance > frag_info.shadow_cascades.y) cascade = 2;

  vec2 uv = vec2(0.0);
  vec3 projected = vec3(0.0);
  bool found = false;
  // `S3`: what the soft path needs of the cascade it lands in — metres per
  // texel across, and metres per unit of stored depth along the light.
  float cascadeTexel = 1.0;
  float cascadeDepth = 1.0;
  for (int attempt = 0; attempt < 3; attempt++) {
    int which = cascade + attempt;
    if (which >= cascadeCount) break;

    mat4 matrix = which == 0
        ? frag_info.shadow_matrix
        : (which == 1 ? frag_info.shadow_matrix_far
                      : frag_info.shadow_matrix_farthest);
    // One texel of this cascade in metres. The projection is orthographic,
    // so its first row is 2 / width, and a tile texel is `shadow_cascades.w`
    // of the width. The rows are also the map's axes in the world, which is
    // what the normal is measured along: its share across each axis is the
    // sine of the slope in that direction.
    vec3 axisX = vec3(matrix[0][0], matrix[1][0], matrix[2][0]);
    vec3 axisY = vec3(matrix[0][1], matrix[1][1], matrix[2][1]);
    float rowX = max(length(axisX), 1e-6);
    float rowY = max(length(axisY), 1e-6);
    float texelMetres = 2.0 * frag_info.shadow_cascades.w / rowX;
    float reach = 1.5 * 2.0 * frag_info.shadow_cascades.w *
        (abs(dot(s.n, axisX)) / (rowX * rowX) +
         abs(dot(s.n, axisY)) / (rowY * rowY));
    vec3 origin = v_world_position + s.n * (frag_info.shadow_params.z + reach);
    vec4 lightSpace = matrix * vec4(origin, 1.0);
    if (lightSpace.w <= 0.0) continue;
    vec3 candidate = lightSpace.xyz / lightSpace.w;

    // Clip space x and y are in [-1, 1]; a tile is in [0, 1] with the origin at
    // the top, matching where the render target's row zero is.
    vec2 inTile = vec2(candidate.x * 0.5 + 0.5, 0.5 - candidate.y * 0.5);
    if (inTile.x < 0.0 || inTile.x > 1.0 || inTile.y < 0.0 || inTile.y > 1.0) {
      continue;
    }
    // Depth is already in [0, 1] here, as every projection in this engine
    // produces. **Past the far plane is behind every caster, not outside the
    // map.** The last cascade's depth is fitted to the casters alone, so a
    // floor that runs on past them — the tip of a long evening shadow — sits
    // beyond it. Skipping that point called it lit and cut the shadow off
    // along the line where the far plane meets the floor. A nearer cascade
    // may still be missing casters and hands the point on; the last one
    // clamps, and 1.0 compares lit only against a texel nothing was drawn in.
    if (candidate.z > 1.0) {
      if (which < cascadeCount - 1) continue;
      candidate.z = 1.0;
    }

    // Into the atlas: the cascades sit side by side in one texture.
    uv = vec2((inTile.x + float(which)) / float(cascadeCount), inTile.y);
    projected = candidate;
    cascade = which;
    cascadeTexel = texelMetres;
    cascadeDepth =
        1.0 / max(length(vec3(matrix[0][2], matrix[1][2], matrix[2][2])), 1e-6);
    found = true;
    break;
  }
  if (!found) return 1.0;

  float bias = cascade == 0
      ? frag_info.shadow_bias.x
      : (cascade == 1 ? frag_info.shadow_bias.y : frag_info.shadow_bias.z);
  // Horizontally a texel of the atlas, vertically a texel of a tile. With one
  // cascade they are the same number and this is the kernel it has always been.
  vec2 texel = vec2(frag_info.shadow_params.x, frag_info.shadow_cascades.w);

  // **Every tap is held inside its own cascade's tile**, half a texel in from
  // the edge, and after the offset rather than before: the cube atlas learned
  // this first (`PointShadowDistance`). The cascades sit side by side, so a
  // tap that stepped past a seam read the neighbouring cascade's depth,
  // measured through another projection, and a fragment at the edge of the
  // near tile took its shadow partly from the far one. With one cascade the
  // tile is the whole texture and the clamp is the sampler's own edge.
  vec2 tileLo = vec2(float(cascade) / float(cascadeCount), 0.0) + 0.5 * texel;
  vec2 tileHi =
      vec2(float(cascade + 1) / float(cascadeCount), 1.0) - 0.5 * texel;

  // **`textureLod` and not `texture`, and the level asked for is the only one
  // there is.** Everything above this loop is a reason not to be here — the
  // cascade search returns early when no cascade contains the fragment, and the
  // light loop that calls it skips a light facing away — so a WGSL backend sees
  // a sample taken where the four invocations of a quad need not agree, and
  // refuses it: the implicit derivative `texture` asks for is only defined
  // where they all arrive. The cascade atlas is a depth render target with a
  // single level, so the derivative was never doing anything but selecting
  // level zero, and naming that level directly costs nothing and changes no
  // pixel on any backend.
  //
  // **The softness, where it rides, and what zero means.**
  //
  // `ambient_ground.w` is the directional light's apparent size. It has
  // nothing to do with ambient light and everything to do with this being the
  // one component left unspent in a block six shaders share: `frame_params.w`
  // was the slot reserved for exactly this and the environment's level count
  // took it, and appending to the block moves offsets four backends have
  // agreed on. The alternative was a second uniform block bound per draw for
  // one float. Named here because a reader arriving at `ambient_ground` has
  // every right to be surprised.
  //
  // Zero is the 3×3 kernel this has always had, which is what keeps every
  // recorded golden where it is. Above zero the edge widens with the distance
  // between the occluder and what it falls on — what a real light does, and
  // what no fixed kernel can.
  //
  // **Below zero is the `evsm` filter** (`S2`), and the texture bound here is
  // then the moments atlas rather than depth: one filtered tap replaces the
  // kernel, and how far under minus one the value sits is the light-bleeding
  // cut. A sign rather than another uniform, for the reason the softness
  // itself rides here.
  float softness = frag_info.ambient_ground.w;
  float lit = 0.0;
  if (softness < 0.0) {
    // The blur already happened, once for the whole atlas, so the one tap
    // is the filter: the sampler's own bilinear step is all it adds.
    vec4 moments = textureLod(shadow_texture, clamp(uv, tileLo, tileHi), 0.0);
    lit = EvsmVisibility(moments, projected.z - bias,
                         clamp(-softness - 1.0, 0.0, 0.95));
  } else if (softness <= 0.0) {
    // PCF 3x3. Four samples would band visibly at this map size and nine is
    // the smallest kernel that reads as a soft edge rather than as stair
    // steps.
    for (int y = -1; y <= 1; y++) {
      for (int x = -1; x <= 1; x++) {
        float occluder = textureLod(
            shadow_texture,
            clamp(uv + vec2(float(x), float(y)) * texel, tileLo, tileHi),
            0.0).r;
        lit += projected.z - bias > occluder ? 0.0 : 1.0;
      }
    }
    lit *= 1.0 / 9.0;
  } else {
    // **Find what is casting before deciding how wide to blur**, then blur by
    // what a light of this size would leave — `S3`. Sixteen taps each way on
    // a Vogel disc turned per pixel, where there were five fixed ones: the
    // turn trades the five's regular pattern for noise the eye reads as
    // grain, and a temporal resolve averages away.
    //
    // **In metres, per cascade.** The gap between the blocker and this
    // fragment is measured in the cascade's stored depth, whose unit is a
    // different length in each cascade; converted to metres, the penumbra is
    // the gap times the light's apparent diameter, and in texels it is that
    // over the cascade's own texel. A shadow keeps its softness crossing
    // from one cascade into the next.
    //
    // **A radius, so half that width.** A disc of radius R swept across an
    // edge ramps from dark to lit over 2R, so the kernel is the gap times
    // the tangent of the light's angular *radius*: the penumbra comes out the
    // full `2·tan(α)·gap` the settings promise, not twice it. The search is
    // the same cone, `tan(α)` of the way back to the light; a wider one only
    // pulls in blockers that cannot reach this fragment.
    float spread = tan(min(softness, 0.5));
    float turn = ShadowNoise() * 6.2831853;

    // As wide as the widest penumbra could be at this depth, and no wider:
    // the whole of the distance back to the light is the largest gap there
    // is.
    float searchRadius =
        clamp(spread * projected.z * cascadeDepth / cascadeTexel, 1.0, 16.0);
    float blockerSum = 0.0;
    float blockerCount = 0.0;
    for (int i = 0; i < 16; i++) {
      float occluder = textureLod(
          shadow_texture,
          clamp(uv + VogelDisc(i, 16, turn) * texel * searchRadius, tileLo,
                tileHi),
          0.0).r;
      if (projected.z - bias > occluder) {
        blockerSum += occluder;
        blockerCount += 1.0;
      }
    }
    // Nothing between this fragment and the light: lit, and no second loop.
    if (blockerCount <= 0.0) return 1.0;

    float gap = max(projected.z - blockerSum / blockerCount, 0.0) * cascadeDepth;
    // One texel at the tightest, so a contact edge stays an edge; the cap
    // keeps a distant occluder from reaching across a whole cascade.
    float radius = clamp(spread * gap / cascadeTexel, 1.0, 16.0);

    for (int i = 0; i < 16; i++) {
      float occluder = textureLod(
          shadow_texture,
          clamp(uv + VogelDisc(i, 16, turn + 1.0) * texel * radius, tileLo,
                tileHi),
          0.0).r;
      lit += projected.z - bias > occluder ? 0.0 : 1.0;
    }
    lit *= 1.0 / 16.0;
  }

  // Strength lerps towards fully lit, so the control is "how dark", not "how
  // much of the kernel".
  return mix(1.0, lit, clamp(strength, 0.0, 1.0));
}

#endif  // SHADOW_GLSL_


float LightVisibility(Surface s, LightSample light, int index) {
  return ShadowFactor(s, light, index);
}

vec3 ShadeLight(Surface s, LightSample light) {
  // Map perceptual roughness onto a Phong exponent. The mapping is arbitrary;
  // it just has to feel monotonic as the roughness slider moves.
  float shininess = mix(256.0, 4.0, s.roughness);
  float specular = pow(light.n_dot_h, shininess) * frag_info.material.w;

  // The caller already dropped lights with N.L at zero, so no separate gate is
  // needed to keep the highlight off facing-away geometry.
  return s.albedo + vec3(specular);
}

void main() {
  Surface s = ReadSurface();
  ApplyCommonMaps(s);
  // Roughness drives the Phong exponent, so the ORM map does reach the
  // output here.
  ApplyMetallicRoughnessMap(s);
  vec3 ambient = s.albedo * (s.ambient + SampleLightmap()) * s.occlusion;
  WriteSurface(
      AccumulateLights(s) * s.occlusion + ambient + s.emissive,
      s.alpha,
      s.roughness);
}

''',
    'Pbr': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// Metal-rough, as `lib/pbr.glsl` describes it. The body lives there so the
// layered stage beside this one is the same code with its layers switched on.
// --- lib/pbr.glsl ---
// Metal-rough physically based shading: Cook-Torrance specular with the GGX
// distribution, height-correlated Smith visibility and a Schlick Fresnel.
//
// **The body of two stages.** `lighting/pbr.frag` is this and nothing else;
// `lighting/pbr_layered.frag` defines `F3D_LAYERED` first and gets the glTF
// layers on top — `M1`. Everything under `#ifdef F3D_LAYERED` is the layered
// stage's alone, and everything under its `#else` is what plain metal-rough
// always was, kept as it was so that stage compiles to what it compiled to.
// Formulations follow Filament, which is also what the glTF spec describes, so
// imported glTF materials will land on the same look.
//
// Image-based lighting is here when a scene supplies an environment, and the
// flat hemispheric ambient stands in when it does not. `frame_params.w` carries
// the number of levels in the environment cube and is zero when there is none —
// the slot that block reserved for exactly this kind of frame-wide parameter.
//
// **The environment sampler is always bound**, to a one-texel cube when a scene
// has no environment. A sampler a shader declares and nobody binds is a native
// crash on Metal rather than a black texture; the same rule keeps the sky's
// cube out of `sky.frag` and a white texel under the composite's occlusion.
// `L7`: rectangle lights integrate the GGX lobe; see `lib/ltc.glsl`.
#define F3D_LTC
#ifndef PBR_GLSL_
#define PBR_GLSL_

// `C8`: the layered stage reads each map through its own transform — see
// `MapUv` in `lib/surface.glsl`, and the definitions under `LayerInfo` below.
#ifdef F3D_LAYERED
#define F3D_TEXTURE_TRANSFORM
#endif

// --- lib/material_maps.glsl ---
// The texture maps a lit material can carry, beyond base colour.
//
// A separate header from surface.glsl on purpose. Declaring a sampler a shader
// never reads is the same trap as declaring an unused uniform block: the
// compiled function has no such slot, while the Dart side still has metadata
// saying it does. Unlit and the debug models include surface.glsl (or only
// color.glsl) and get none of this; the lit models include both, and
// LightingModel.usesMaterialTextures says which is which.
//
// Every map has a *neutral* fallback texture bound when the material has none,
// so there are no "has this map" flags to keep in sync — a white ORM texture
// multiplies the factors by one, and a flat normal map perturbs nothing. Flags
// would have to be right in two places; a neutral texel is right by
// construction.

#ifndef MATERIAL_MAPS_GLSL_
#define MATERIAL_MAPS_GLSL_

// --- lib/surface.glsl ---
// Shared material and lighting interface for the lighting models.
//
// flutter_gpu compiles shaders ahead of time into a bundle: there is no runtime
// compilation, so a node-graph material system assembled at run time is
// impossible. Each lighting model is therefore
// its own pre-built fragment shader, and this header is what keeps them
// interchangeable — one identical uniform block, so the Dart binding code never
// needs to know which model is active.
//
// Keep every declaration below byte-identical across models. A member a model
// does not read may be optimized out of the reflected block, which is why the
// Dart side skips absent members instead of failing.
//
// Only include this from a shader that actually reads FragInfo. Declaring the
// block without using it leaves it visible to reflection while the compiled
// shader binds no buffer for it, and binding that phantom block segfaults
// inside Metal. Shaders needing only colour helpers include lib/color.glsl.

#ifndef SURFACE_GLSL_
#define SURFACE_GLSL_

// --- lib/color.glsl ---
// Colour space helpers and the fragment output interface.
//
// Split out of surface.glsl so a shader that needs no material inputs — the
// normals debug view — can avoid DECLARING the FragInfo uniform block at all.
// That matters more than it looks: reflection metadata reports a block as
// present merely because it was declared, even when the compiled shader binds
// no such buffer, so a declared-but-unused block is indistinguishable from a
// used one until Metal crashes on the bind.

#ifndef COLOR_GLSL_
#define COLOR_GLSL_

precision highp float;

const float kPi = 3.14159265359;

// One varying set shared by every fragment shader, matching mesh.vert.
//
// All five are declared here, including the two the debug models never read: a
// fragment shader whose `in` block disagrees with the vertex shader's `out`
// block fails to link, and there is no partial-match rule to lean on.
in vec3 v_world_position;
in vec3 v_normal;
in vec2 v_texcoord;
in vec4 v_tangent;
in vec4 v_color;

/// Where this fragment is in the level's lightmap. Zero from every vertex
/// stage but `mesh_lightmapped.vert`, and read only by the lit models, which
/// sample a one-texel black there when a material has no map.
in vec2 v_lightmap_uv;

layout(location = 0) out vec4 frag_color;

// The second attachment: what a screen-space effect needs to know about the
// surface it is looking at. World-space normal in rgb, and in a the depth along
// the view axis in world metres — not a window depth; `WriteSurfaceGeometry`
// says at length why not.
//
// Depth travels here rather than in a depth texture because flutter_gpu cannot
// sample one — the same reason the shadow pass writes its depth into a colour
// target. See ARCHITECTURE.md §2.
//
// Guarded, because not every stage that includes this header draws into a
// two-attachment target. The shadow pass draws into one, and a pipeline
// declaring an output its target has no slot for is a mismatch worth avoiding
// rather than discovering.
#ifndef F3D_NO_SURFACE_BUFFER
layout(location = 1) out vec4 frag_surface;

/// The surface's own colour, sRGB-encoded, alpha one where a surface was
/// drawn — `L5`. The third attachment, present only when a pass reads it (the
/// indirect light does) and the device opens three; like the surface buffer,
/// written unconditionally and discarded when absent. Stored in the surface
/// buffer's format rather than eight bits a channel, and `Renderer` says why.
layout(location = 2) out vec4 frag_albedo;
#endif

/// What [frag_albedo] carries: the lit models set it in `ReadSurface`, and a
/// stage that reflects nothing — unlit, the debug views — leaves it black,
/// which is what light bounced onto it would come to.
vec3 g_albedo = vec3(0.0);

/// Octahedral encoding: a unit vector in two channels instead of three.
///
/// Worth the arithmetic because the fourth channel is already spent on depth,
/// and without a free channel there is nowhere to put roughness — which is the
/// difference between a reflection that knows stone from a mirror and one that
/// does not. The error is well under a degree, far below anything a reflection
/// off rough stone would show.
vec2 EncodeOctahedral(vec3 n) {
  n /= abs(n.x) + abs(n.y) + abs(n.z);
  vec2 e = n.xy;
  if (n.z < 0.0) {
    e = (1.0 - abs(n.yx)) * vec2(n.x >= 0.0 ? 1.0 : -1.0,
                                 n.y >= 0.0 ? 1.0 : -1.0);
  }
  return e * 0.5 + 0.5;
}

/// Where a debug pass leaves the picture it wants shown instead of the normal.
///
/// Declared here, in the header every lit shader includes **first**, and
/// written from surface.glsl, which is included after. The alternative was a
/// new member on a shared uniform block; a global costs nothing and moves no
/// offsets. It is read at the moment the surface buffer is written, which
/// happens after the lighting loop has run, so the value is there by then.
vec3 g_debug_surface = vec3(0.0);
bool g_debug_surface_on = false;

/// Whether [WriteSurface] weights the colour by its alpha: set by
/// `ReadSurface` for a material that blends, and false for everything else.
///
/// **The blend takes its source as premultiplied**, so a blended surface has
/// to hand it the colour times the alpha — a pane at a fifth of opaque adds a
/// fifth of its light, not all of it. glTF's blend mode is Porter and Duff's
/// over on straight colour, and this is the one place that turns the lit
/// radiance into what that means. An opaque or masked surface keeps its
/// colour whole: its alpha is not a coverage, and nothing blends it.
/// A global for the reason [g_debug_surface] is one.
bool g_premultiply = false;

// **A stage that needs none of this must be able to declare none of it.** On
// Vulkan both stages' descriptors are merged into one set layout, and two
// bindings with the same number in it is not a layout the specification
// allows. A driver may accept it anyway; a Galaxy A55's refuses the pipeline
// with `ErrorUnknown` and no other word, which is how the shadow pass came to
// build everywhere except there — its only uniform block was this one, and it
// landed on the same binding as the vertex stage's first.
#ifndef F3D_NO_FOG

/// Distance fog, in its own block rather than folded into FragInfo.
///
/// Its own because color.glsl is included before FragInfo is declared, and
/// because appending to a block that half a dozen shaders already share is a
/// way to move offsets nobody expected to move. Three vec4s is a cheap price
/// for not touching any of that.
layout(std140) uniform FogInfo {
  /// rgb: linear fog colour. w: density per metre, zero for no fog.
  vec4 fog;

  /// xyz: camera position in world space. Duplicated from FragInfo so this
  /// block stands alone; a vec3 is cheaper than a coupling.
  vec4 eye;

  /// xyz: the direction the camera looks, as a unit vector in world space.
  /// w: what a transparent draw writes under weighted blended transparency —
  /// `R8`, see `WriteWeightedBlended`. Zero for every other draw.
  ///
  /// Here rather than in a block of its own because it answers the same
  /// question [eye] does — where the camera is and which way it faces — and
  /// this is the block `color.glsl` can see.
  vec4 forward;
}
fog_info;

/// How far this fragment is from the eye, in world metres.
///
/// What the fog fades by. Distance rather than depth, because fog is a
/// property of the air between two points and does not care which way the
/// camera happens to face.
float EyeDistance() { return distance(v_world_position, fog_info.eye.xyz); }

/// How far this fragment is *along the view axis*, in world metres.
///
/// What the surface buffer's alpha holds. Depth rather than distance, and the
/// difference only shows on an orthographic camera — where the rays through
/// the pixels are parallel instead of meeting at the eye, so a distance from
/// the eye names a sphere that the pixel's ray crosses somewhere the reader
/// cannot solve for. A depth along the axis names a plane, which every ray
/// crosses exactly once. See `WorldAtDepth` in `post/ssao.frag` for the
/// reconstruction both projections share.
float ViewDepth() {
  return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz);
}

#else  // F3D_NO_FOG

// The same two questions, answered without the block: a stage that declares no
// fog has no eye position to measure from either. Stubs rather than a guard at
// every call site, so that what includes this file reads the same whichever
// way it was compiled.
float EyeDistance() { return 0.0; }
float ViewDepth() { return 0.0; }

#endif  // F3D_NO_FOG

/// sRGB to linear. Textures are authored in sRGB, but lighting is only correct
/// in linear space; skipping this is what makes naive renderers look muddy.
vec3 SrgbToLinear(vec3 srgb) {
  return mix(
      srgb / 12.92,
      pow((srgb + vec3(0.055)) / 1.055, vec3(2.4)),
      step(vec3(0.04045), srgb));
}

/// Linear to sRGB. The render target is a plain UNorm format rather than an
/// sRGB one, so the encode has to happen here.
vec3 LinearToSrgb(vec3 linear) {
  return mix(
      linear * 12.92,
      1.055 * pow(linear, vec3(1.0 / 2.4)) - vec3(0.055),
      step(vec3(0.0031308), linear));
}

/// Writes scene-referred linear light into the HDR target.
///
/// No tone map and no sRGB encode: those moved into the composite pass, which
/// is the entire point of rendering into `r16g16b16a16Float` first. Applying
/// them here meant every model wrote display-referred colour into an 8-bit
/// buffer, so anything above display white was gone before post-processing
/// could see it — and bloom is a function of exactly that.
///
/// Exposure moved with them, for the same reason: it belongs on the same side
/// of the display transform as the tone map.
/// Records the geometry of this fragment for whatever runs after the scene.
///
/// Called from the same place that writes colour, so a surface cannot be lit
/// into the frame without also describing itself — which is the failure that
/// leaves a screen-space effect reflecting whatever was in the buffer before.
///
/// rg: octahedral normal. b: perceptual roughness. a: **depth along the view
/// axis, in world metres** — see [ViewDepth].
///
/// **Not `gl_FragCoord.z`, and that is a defect this channel carried until it
/// was looked at.** Window depth crowds every distant surface into the top of
/// its range — with a near plane of a tenth of a metre, everything past twenty
/// metres lives in the last half a hundredth of `[0, 1]` — and this attachment
/// is a half float, whose steps up there are about five ten-thousandths. So two
/// surfaces half a metre apart at twenty metres stored the *same* number, and
/// every screen-space pass that compares against this channel decided whole
/// bands of pixels by rounding. The occlusion pass drew them: vertical stripes
/// along the lines of constant depth on any wall receding from the camera, on
/// both GPU backends. The software rasteriser kept the channel at full
/// precision and drew the effect correctly, so it was the one that looked
/// wrong against the other two.
///
/// A depth in metres has none of that: the exponent carries the range and the
/// mantissa carries the same relative precision everywhere, which at twenty
/// metres is a centimetre. Both numbers are measured in
/// `flutter3d/test/surface_depth_test.dart`.
///
/// Zero still means nothing was drawn. The attachment is cleared to zero and
/// nothing is drawn in front of the near plane.
void WriteSurfaceGeometry(float roughness) {
#ifndef F3D_NO_SURFACE_BUFFER
  // `L5`: the surface's colour, whatever the surface buffer ends up holding.
  frag_albedo = vec4(LinearToSrgb(clamp(g_albedo, vec3(0.0), vec3(1.0))), 1.0);
  // A debug pass takes the buffer over rather than getting one of its own.
  // The surface buffer already has an attachment, a viewer and a golden; a
  // second one would need all three built before it could answer anything.
  if (g_debug_surface_on) {
    frag_surface = vec4(g_debug_surface, ViewDepth());
    return;
  }
  // Reversed on a back face, as the lit normal is, so the occlusion and
  // reflection passes see the side of a double-sided surface that faces them.
  vec3 geometric = normalize(v_normal);
  if (!gl_FrontFacing) geometric = -geometric;
  frag_surface = vec4(EncodeOctahedral(geometric),
                      clamp(roughness, 0.0, 1.0), ViewDepth());
#endif
}

/// Fades [color] toward the fog with distance from the eye.
///
/// Exponential rather than linear, because linear fog has a visible plane
/// where it starts and a dungeon corridor is exactly where that shows.
vec3 ApplyFog(vec3 color) {
#ifdef F3D_NO_FOG
  return color;
#else
  float density = fog_info.fog.w;
  if (density <= 0.0) return color;
  float d = EyeDistance();
  return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0));
#endif
}

/// How much a transparent fragment counts for against the others over its
/// pixel — `R8`. McGuire and Bavoil's depth weight (their equation 9): a near
/// layer outweighs a far one, which is all the ordering a weighted average
/// can keep. [alpha] multiplies it, as theirs does, so a faint layer counts
/// faintly. Depth along the view axis, in metres, the surface buffer's.
float WeightedBlendedWeight(float alpha) {
  float z = abs(ViewDepth());
  float near = z / 5.0;
  float far = z / 200.0;
  float far3 = far * far * far;
  return alpha *
         clamp(10.0 / (1e-5 + near * near + far3 * far3), 1e-2, 3e3);
}

/// What a transparent draw writes when the frame composites transparency
/// order-independently — `R8`. `fog_info.forward.w` says which:
///
/// - 0: [frag_color] as it stands, the sorted blend's source. Every opaque
///   draw, and every draw in a frame that sorts.
/// - 1: the accumulation target's share — the colour, which the engine keeps
///   premultiplied, and the alpha, both times the weight. Added.
/// - 2: the revealage target's — the alpha alone, in every channel, which the
///   blend multiplies the target by one minus of.
/// - 3: both at once, the second into attachment one, where the surface
///   buffer would be; the pass that asks has no surface buffer attached.
///
/// Selects rather than returns, because a phi of constants is what
/// SPIRV-Cross refuses. At nought the branch is not taken and [frag_color]
/// is untouched, which is what keeps a sorting frame byte-identical.
void WriteWeightedBlended() {
#ifndef F3D_NO_FOG
  float mode = fog_info.forward.w;
  if (mode > 0.5) {
    float alpha = frag_color.a;
    float weight = WeightedBlendedWeight(alpha);
    vec4 accumulate = vec4(frag_color.rgb * weight, alpha * weight);
    bool revealage = mode > 1.5 && mode < 2.5;
    frag_color = revealage ? vec4(alpha) : accumulate;
#ifndef F3D_NO_SURFACE_BUFFER
    if (mode > 2.5) frag_surface = vec4(alpha);
#endif
  }
#endif
}

/// The fog is mixed in before the weight, so a thin distant pane adds a thin
/// share of the fog too rather than all of it. Times one when nothing blends,
/// which is exact, so an opaque draw writes what it always wrote.
void WriteSurface(vec3 linearColor, float alpha, float roughness) {
  float weight = g_premultiply ? alpha : 1.0;
  frag_color = vec4(ApplyFog(linearColor) * weight, alpha);
  WriteSurfaceGeometry(roughness);
  WriteWeightedBlended();
}

/// For a stage with no material to speak of.
///
/// Fully rough, which is the honest default: a surface that cannot say how
/// polished it is should not be reflected off.
void WriteSurface(vec3 linearColor, float alpha) {
  WriteSurface(linearColor, alpha, 1.0);
}

/// Writes a value that is already display-referred.
///
/// For debug output, where the colour is not a light value at all: a normal
/// encoded as RGB means nothing after a tone curve. Converting to linear here
/// means the composite pass's sRGB encode hands the original back unchanged,
/// provided the view also turns tone mapping and exposure off — which is what
/// `RenderSettings.tonemap` is for.
void WriteDisplayColor(vec3 displayColor, float alpha) {
  frag_color = vec4(SrgbToLinear(displayColor), alpha);
  WriteSurfaceGeometry(1.0);
}

#endif  // COLOR_GLSL_

// --- lib/frag_coord.glsl ---
// Where a fragment sits, counted from the top of its target on every backend.

#ifndef FRAG_COORD_GLSL_
#define FRAG_COORD_GLSL_

/// `gl_FragCoord.xy` with row zero at the top of the picture.
///
/// [rows] is the target's height where the backend's row zero is the bottom
/// of the picture, and zero where it is the top. WebGL2 is the first kind:
/// window coordinates start at the lower left, and the engine draws the
/// picture upright there rather than mirroring every projection. Metal,
/// WebGPU and the software rasteriser are the second.
///
/// **Why a pattern cares and a picture does not.** Every screen-space pattern
/// in the engine — the Bayer dither, the grain, the jitter a ray march starts
/// from, the rotation of a shadow kernel — is a function of the pixel's row.
/// Read from the bottom, the same frame gets the pattern turned upside down,
/// and a four-row Bayer cell lands on different rows unless the height is a
/// multiple of four. The picture underneath is identical; the pattern on top
/// of it is not, and a comparison across backends counts every pixel it
/// moved.
vec2 FragCoordFromTop(float rows) {
  return rows > 0.0 ? vec2(gl_FragCoord.x, rows - gl_FragCoord.y)
                    : gl_FragCoord.xy;
}

#endif  // FRAG_COORD_GLSL_


/// Lights per draw. Must match LightBuffer.maxLights on the Dart side.
///
/// A fixed array with a runtime count, not a shader permutation per light
/// count: turning a light on has to be free, because there is no runtime
/// compilation to fall back on. Verified against the SDK — Impeller keeps
/// `vec4 x[8]` in the compiled Metal struct and reflects the array's base
/// offset, with the std140 stride of 16 bytes.
#define kMaxLights 8

/// How many more lights one draw may be handed — `gfx-74n`.
///
/// **The eight above stay exactly what they were**, which is what keeps this
/// from moving a single recorded frame: a draw with eight lights or fewer runs
/// the loop it has always run, reads the uniform arrays it has always read, and
/// never touches the texture below. The tail is the part that used to be
/// impossible.
///
/// A loop bound rather than a cost. `AccumulateLights` breaks at the draw's own
/// count, so a scene with three lights costs three iterations whatever this
/// says. Twenty-four because the two tables below are `vec4 x[6]` and four
/// lanes fit a `vec4`: two hundred and eight bytes a draw, against the five
/// hundred and twelve the light arrays already cost.
#define kExtraLights 24
#define kTotalLights (kMaxLights + kExtraLights)

// --- lib/light_list.glsl ---
// The frame's light list, and how a fragment finds its tail in it — `gfx-74n`
// and `L6`.
//
// Split out of `surface.glsl` so a stage that is not a surface can read the
// same lights: `N6`'s six-way particles light each fragment by the list the
// lit models read, clusters and all, without declaring `FragInfo`. The text is
// the one that stood in `surface.glsl`, moved rather than copied, so the lit
// models compile to what they compiled to before.

#ifndef LIGHT_LIST_GLSL_
#define LIGHT_LIST_GLSL_
/// Every light in the scene, one per row, four texels across — `gfx-74n`.
///
/// **A texture rather than a wider uniform block, and that is the design.**
/// `FragInfo` is uploaded on every draw, so widening its four `vec4` arrays to
/// hold thirty-two lights would be a two-kilobyte upload per draw in every
/// scene, including every scene with one light. This is built once a frame and
/// only when a scene has more lights than a draw can hold in its slots.
///
/// Row layout, which `renderer_light_list.dart` writes and only this reads:
///
///  * texel 0 — xyz world position, w type (0 directional, 1 point, 2 spot)
///  * texel 1 — rgb linear colour, w intensity
///  * texel 2 — xyz the direction it points, w range
///  * texel 3 — x cos(inner), y cos(outer), zw unused
///
/// The same four vectors the uniform arrays hold, in the same order, so one
/// reader serves both.
///
/// **`F3D_NO_LIGHT_LIST` leaves both out**, for a model that accumulates no
/// lights. Such a model never reaches the reader below, so the compiler drops
/// the block and the sampler from the Metal function while reflection still
/// lists them, with no buffer or texture index assigned. The renderer used to
/// bind them for every draw, Unlit included, and that bind is a crash inside
/// `setFragmentBuffer:offset:atIndex:` on Metal. Vulkan took the same draw
/// without a word, which is how 0.7.0 shipped with it.
#ifndef F3D_NO_LIGHT_LIST
uniform sampler2D light_list_texture;

layout(std140) uniform LightListInfo {
  /// x: how many rows this draw reads, zero when it reads none.
  /// y, z: one over the texture's width and height.
  /// w: unused.
  vec4 list;

  /// Which rows, four to a vector, in the order they are read.
  ///
  /// Indices rather than the light data itself: the data is the same for every
  /// draw in the frame and belongs in the texture; what differs per draw is
  /// *which* of them reach it, and that is what `Renderer._drawLightsFor`
  /// already decides.
  vec4 indices[6];

  /// How much of each of those survives the edge fade, in the same order.
  ///
  /// Per draw and not in the texture, because the row an index points at is
  /// shared by every draw in the frame: a scale written into it would dim that
  /// light for all of them. `gfx-12n`'s fade lives at the end of the list now —
  /// that is where a light stops contributing, and fading the slots against a
  /// water line that no longer marks a cliff would dim a light for no reason
  /// while its rival stayed bright, making the swap more visible rather than
  /// less.
  vec4 scales[6];

  /// `L6`: the view-projection the light clusters were cut with, so this
  /// finds a fragment's cell the way `LightClusters.clusterOf` does.
  mat4 cluster_view_projection;

  /// xyz: tiles across, tiles up, slices deep. w: one when this draw reads
  /// its tail from the cell it is in rather than from `indices`.
  vec4 cluster_grid;

  /// x: where slices begin, in clip w. y: slices per unit of `ln(w / x)`.
  /// z: the texture row the cells' headers start at, four to a row, each
  /// (offset, count). w: the row their entries start at, sixteen to a row.
  vec4 cluster_depth;

  /// Which rows this draw already holds in its eight slots, minus one for
  /// an empty slot. A cell lists every light that reaches it, and one the
  /// slots already carry must not be counted again.
  vec4 slot_rows[2];
}
light_list_info;

/// One lane of a six-vector table, [slot] counting from nought.
float LightListLane(vec4 four, int slot) {
  int lane = slot - (slot / 4) * 4;
  return lane == 0 ? four.x : lane == 1 ? four.y : lane == 2 ? four.z : four.w;
}

/// The row light [slot] of the list reads.
float LightListRow(int slot) {
  return LightListLane(light_list_info.indices[slot / 4], slot);
}

/// How much of light [slot] of the list survives the edge fade.
float LightListScale(int slot) {
  return LightListLane(light_list_info.scales[slot / 4], slot);
}

/// The cell this fragment falls in, as `LightClusters` wrote it: where its
/// entries start and how many there are. Found once, in [LightCount], and
/// read by every [SampleLight] of the loop that follows.
float g_cluster_offset = 0.0;
float g_cluster_count = 0.0;

bool Clustered() { return light_list_info.cluster_grid.w > 0.5; }

/// One texel of the light list texture, [texel] across and [row] down.
vec4 LightListTexel(float texel, float row) {
  return textureLod(light_list_texture,
                    vec2((texel + 0.5) * light_list_info.list.y,
                         (row + 0.5) * light_list_info.list.z),
                    0.0);
}

void FindCluster(vec3 world) {
  vec4 clip = light_list_info.cluster_view_projection * vec4(world, 1.0);
  vec2 ndc = clip.xy / max(clip.w, 1e-6);
  vec3 grid = light_list_info.cluster_grid.xyz;
  float near = light_list_info.cluster_depth.x;
  float tx = clamp(floor((ndc.x * 0.5 + 0.5) * grid.x), 0.0, grid.x - 1.0);
  float ty = clamp(floor((ndc.y * 0.5 + 0.5) * grid.y), 0.0, grid.y - 1.0);
  float tz = clip.w <= near
                 ? 0.0
                 : clamp(floor(log(clip.w / near) *
                               light_list_info.cluster_depth.y),
                         0.0, grid.z - 1.0);
  float cell = tx + ty * grid.x + tz * grid.x * grid.y;
  float row = floor(cell / 4.0);
  vec4 header =
      LightListTexel(cell - row * 4.0, light_list_info.cluster_depth.z + row);
  g_cluster_offset = header.x;
  g_cluster_count = header.y;
}

/// The row entry [slot] of this fragment's cell names.
float ClusterRow(int slot) {
  float entry = g_cluster_offset + float(slot);
  float row = floor(entry / 16.0);
  float within = entry - row * 16.0;
  float texel = floor(within / 4.0);
  vec4 four = LightListTexel(texel, light_list_info.cluster_depth.w + row);
  return LightListLane(four, int(within - texel * 4.0 + 0.5));
}

/// Whether one of the draw's slots already holds light list row [row].
bool InSlots(float row) {
  vec4 a = abs(light_list_info.slot_rows[0] - vec4(row));
  vec4 b = abs(light_list_info.slot_rows[1] - vec4(row));
  return min(min(min(a.x, a.y), min(a.z, a.w)), min(min(b.x, b.y), min(b.z, b.w))) < 0.5;
}
#endif  // F3D_NO_LIGHT_LIST

#endif  // LIGHT_LIST_GLSL_


layout(std140) uniform FragInfo {
  /// xyz: world position (point and spot). w: type, 0 directional 1 point 2 spot.
  vec4 light_position[kMaxLights];

  /// rgb: linear colour. w: intensity.
  vec4 light_color[kMaxLights];

  /// xyz: the direction the light points, its local -Z. w: range, 0 unbounded.
  vec4 light_direction[kMaxLights];

  /// x: cos(inner cone angle). y: cos(outer cone angle).
  vec4 light_cone[kMaxLights];

  /// rgb: albedo tint applied on top of the texture. w: opacity.
  vec4 base_color;

  /// rgb: emissive factor, already linear. w: one when the normal map has
  /// two channels (x, y) and its z is rebuilt — see `ApplyNormalMap`. It sits
  /// here because this was the block's one unspent lane.
  vec4 emissive;

  /// xyz: camera position in world space, needed for every specular term.
  vec4 camera_position;

  /// x: metallic, y: roughness, z: ambient strength, w: specular strength.
  vec4 material;

  /// x: alpha cutoff (negative when the material is not masked: -1 opaque,
  /// -0.5 blended, -2 hashed), y: normal scale, z: occlusion strength,
  /// w: emissive strength.
  vec4 material2;

  /// x: exposure, y: active light count, z: index of the shadow-casting light.
  /// w is reserved so adding a frame-wide parameter does not change the offsets
  /// of anything already here.
  vec4 frame_params;

  /// x: one texel of the shadow map, y: depth bias, z: normal offset,
  /// w: strength, zero when shadows are off.
  vec4 shadow_params;

  /// World space to the shadow camera's clip space. The first cascade.
  mat4 shadow_matrix;

  /// The second and third cascades. Copies of the first when there is one, so
  /// this block's layout never depends on how many there are.
  mat4 shadow_matrix_far;
  mat4 shadow_matrix_farthest;

  /// x, y: where cascades 0 and 1 end, in metres from the camera. z: how many
  /// cascades there are, 1 to 3. w: one texel of a tile, vertically —
  /// shadow_params.x is one texel of the whole atlas, and with more than one
  /// cascade those differ.
  vec4 shadow_cascades;

  /// rgb: what a surface facing straight up receives from the environment.
  /// w: one when the metal-rough models' diffuse is EON rather than Lambert —
  /// `L8`, `RenderSettings.diffuseModel`; a frame-wide switch in a frame-wide
  /// vector, and the block's offsets stay where four backends agree on them.
  ///
  /// Appended after everything else on purpose: std140 lays a block out in
  /// declaration order, so adding here leaves every offset above unchanged and
  /// the three backends do not have to agree about anything they did not
  /// already agree about.
  vec4 ambient_sky;

  /// rgb: what a surface facing straight down receives — bounce off the ground
  /// rather than the ground itself.
  ///
  /// **w is the directional light's apparent size** — `gfx-15n` — which has
  /// nothing to do with ambient and everything to do with this being the last
  /// unspent component in a block six shaders share. `frame_params.w` was the
  /// slot reserved for a frame-wide parameter and the environment's level
  /// count took it; appending to this block moves offsets four backends have
  /// agreed on. See `shadow.glsl`, which reads it.
  ///
  /// Two colours rather than one is the whole of what makes ambient look like
  /// light instead of like a lifted black level. Outdoors the sky is blue and
  /// bright and the ground is warm and dim, and a flat grey for both leaves
  /// every underside as pale as every upward face — which reads as the model
  /// being flat, and gets blamed on the normals.
  vec4 ambient_ground;

  /// x, y, z: the depth bias of each cascade, in that cascade's own normalized
  /// depth. w unused.
  ///
  /// `ShadowSettings.bias` is one number and a cascade's depth range is not:
  /// a near cascade is stretched towards the light when a caster stands
  /// further out than its own volume reaches, and the same bias over a longer
  /// range is a longer distance. The renderer converts it per cascade so it
  /// stays the distance it was tuned as; an unstretched cascade gets the
  /// setting unchanged.
  vec4 shadow_bias;

  /// x: the target's rows when its row zero is the bottom of the picture,
  /// zero when it is the top — see `FragCoordFromTop` in `frag_coord.glsl`,
  /// which the shadow kernel's rotation reads through. y: the mip bias every
  /// material map is read with — `R2`: nought, except while a temporal
  /// resolve reconstructs a picture larger than the scene is drawn at, when
  /// the maps are read as sharp as the output they end up in. z: one when
  /// the metal-rough model puts back the energy single scattering loses —
  /// `L1`, `RenderSettings.energyCompensation`. w: the frame's slice of 32
  /// while a temporal resolve runs, minus one otherwise — `S3`, which steps
  /// the soft shadow's rotation by it.
  vec4 target_origin;
}
frag_info;

/// The bias a material map is read with — see `target_origin.y`.
float MaterialLodBias() { return frag_info.target_origin.y; }

/// The maps a lit material reads, by the index [MapUv] takes — `C8`. The
/// order `LayerInfo.uv_transform` keeps them in, and `MaterialMap`'s on the
/// Dart side.
#define kMapBaseColor 0
#define kMapMetallicRoughness 1
#define kMapNormal 2
#define kMapOcclusion 3
#define kMapEmissive 4

/// Where map [slot] is read — `C8`, `KHR_texture_transform` at the sampler.
///
/// **A macro everywhere but the one stage that has the matrices.** A stage
/// that defines `F3D_TEXTURE_TRANSFORM` supplies [MapUv] and [MapMatrix] from
/// a block of its own; every other stage reads each map at the vertex's own
/// coordinate, and the macro leaves its source exactly what it was, so none of
/// them compiles to anything new.
#ifdef F3D_TEXTURE_TRANSFORM
vec2 MapUv(int slot);

/// The 2×2 part of map [slot]'s transform: x and y its first row, z and w
/// its second.
vec4 MapMatrix(int slot);
#else
#define MapUv(slot) v_texcoord
#endif

uniform sampler2D base_color_texture;

/// Everything about the surface that does not depend on which light is being
/// evaluated, resolved once per fragment.
struct Surface {
  vec3 albedo;      // linear, already tinted
  float alpha;      // opacity after texture, tint and vertex colour
  vec3 n;           // unit normal, perturbed by the normal map when there is one
  vec3 v;           // unit direction to the camera
  float n_dot_v;
  float metallic;
  float roughness;  // perceptual
  float occlusion;  // 1 means unoccluded
  vec3 emissive;    // linear, added after shading
  vec3 ambient;     // hemispheric, already scaled by the scene's strength
  float exposure;
};

/// One light's contribution geometry, recomputed per light per fragment.
struct LightSample {
  vec3 l;           // unit direction to the light
  vec3 h;           // unit half vector
  vec3 radiance;    // colour * intensity * attenuation
  float n_dot_l;
  float n_dot_h;
  float v_dot_h;

  /// One when the specular below is already integrated over the light —
  /// `L7`, a rectangle under a model that defines `F3D_LTC` — and nought
  /// otherwise. Then `ltc.x` is the GGX lobe over the rectangle, `ltc.y` the
  /// fitted norm and `ltc.z` the Fresnel term; see `LtcRectangle`.
  float integrated;
  vec3 ltc;
};

Surface ReadSurface() {
  Surface s;

  vec4 texel = texture(base_color_texture, MapUv(kMapBaseColor), MaterialLodBias());
  // Vertex colour is authored linear per the glTF spec, unlike the base colour
  // texture and the tint, which are sRGB.
  s.albedo = SrgbToLinear(texel.rgb) *
             SrgbToLinear(frag_info.base_color.rgb) *
             v_color.rgb;
  s.alpha = texel.a * frag_info.base_color.a * v_color.a;
  // `L5`: the albedo buffer carries it, for the indirect light.
  g_albedo = s.albedo;

  // Alpha masking, glTF's third alpha mode. A negative cutoff means the
  // material is opaque or blended, and discard would then be wrong rather than
  // merely unnecessary. Doing it before anything else is deliberate: a
  // discarded fragment should not pay for the lighting loop.
  //
  // **A cutoff below -1.5 is the fourth mode: hashed** — `gfx-16n`. The
  // sentinel rides in the same component because the alternative is a second
  // number in a block six shaders share, and -1 already meant "not masked";
  // anything more negative was free. See [MaterialAlphaMode.hashed].
  float cutoff = frag_info.material2.x;
  if (cutoff >= 0.0) {
    if (s.alpha < cutoff) discard;
  } else if (cutoff < -1.5) {
    // **Stochastic instead of a threshold.** A leaf texture at 40% opacity is
    // either entirely there or entirely gone under a fixed cutoff, so a fern
    // comes out as a hard-edged cardboard cut-out; sorting would fix it and
    // costs a sort per frame and a draw per layer. Comparing against noise
    // instead keeps 40% of the *pixels*, which resolves as 40% opacity to
    // anything that averages several of them — a higher-resolution target,
    // a downsample, a person standing back.
    //
    // **Hashed on world position, not on the screen.** Screen-space noise is
    // one line shorter and swims: the pattern stays put while the object
    // moves through it, so a moving branch sparkles. Anchoring it to where
    // the surface *is* means a given speck of leaf keeps its verdict from
    // frame to frame, and the camera moving changes nothing.
    //
    // The scale is a constant and it is the whole tuning: finer than the
    // texture's own detail and the noise disappears into aliasing, coarser
    // and the leaf breaks into blotches. Sixteen per metre is about a
    // centimetre of grain at a metre away.
    vec3 anchored = floor(v_world_position * 16.0);
    float noise = fract(
        sin(dot(anchored, vec3(12.9898, 78.233, 37.719))) * 43758.5453);
    if (s.alpha < noise) discard;
  }
  // **Between -1 and nought is the blend mode**, which `WriteSurface` weights
  // by its alpha: see [g_premultiply]. The engine writes -0.5 for it, -1 for
  // opaque; neither is masked, and only the blend's source is premultiplied.
  g_premultiply = cutoff < 0.0 && cutoff > -0.75;

  s.n = normalize(v_normal);
  // The back of a double-sided surface is lit from its own side: glTF asks
  // for the normal to be reversed there, and without it the underside of a
  // cloth turned to the sun reads n·l below zero and stays unlit. Only a
  // double-sided material ever draws a back face, since everything else has
  // them culled.
  if (!gl_FrontFacing) s.n = -s.n;
  s.v = normalize(frag_info.camera_position.xyz - v_world_position);
  // Clamped away from zero: a grazing view direction otherwise divides by zero
  // in the specular visibility term.
  s.n_dot_v = max(dot(s.n, s.v), 1e-4);

  s.metallic = clamp(frag_info.material.x, 0.0, 1.0);
  s.roughness = clamp(frag_info.material.y, 0.02, 1.0);
  // Hemispheric: the sky above, the ground below, blended by which way this
  // surface faces. `material.z` stays the overall strength, so the two are
  // separable — a scene dims its ambient without changing its colour, which is
  // what the one control used to do on its own.
  //
  // The blend runs on the geometric normal deliberately, before
  // `ApplyMaterialMaps` perturbs it. A normal map describes millimetres of
  // surface relief, and ambient of this kind describes which half of the world
  // a face can see; letting bump detail swing it makes a brick wall's mortar
  // lines pick up sky and reads as noise.
  s.ambient = mix(frag_info.ambient_ground.rgb, frag_info.ambient_sky.rgb,
                  s.n.y * 0.5 + 0.5) *
              frag_info.material.z;
  s.exposure = max(frag_info.frame_params.x, 0.0);

  // Neutral until ApplyMaterialMaps says otherwise, so a model that samples no
  // maps still has a complete surface.
  s.occlusion = 1.0;
  s.emissive = vec3(0.0);

  return s;
}

int LightCount() {
#ifdef F3D_NO_LIGHT_LIST
  return clamp(int(frag_info.frame_params.y + 0.5), 0, kMaxLights);
#else
  // `L6`: the tail is the cell's, when the draw reads one.
  float tail = light_list_info.list.x;
  if (Clustered()) {
    FindCluster(v_world_position);
    tail = g_cluster_count;
  }
  return clamp(int(frag_info.frame_params.y + 0.5), 0, kMaxLights) +
      clamp(int(tail + 0.5), 0, kExtraLights);
#endif
}

/// Whether light [index] carries a shadow — `gfx-74n`.
///
/// Only the first eight do. The cube atlas holds six rows and the slot table is
/// eight entries wide, so a light from the list has no row to read and asking
/// for one would index past the table. That is a real limit and the right one:
/// the eight a draw keeps in its slots are the eight ranked most relevant to
/// it, which is exactly the set worth a shadow map.
bool LightHasShadow(int index) { return index < kMaxLights; }

/// Distance attenuation for a punctual light, following the glTF spec.
///
/// Inverse square with an optional range window. The window is what stops a
/// lamp with a declared range from contributing a faint haze across the whole
/// scene, which matters far more once there are eight of them.
float PunctualAttenuation(float distance, float range) {
  float attenuation = 1.0 / max(distance * distance, 1e-4);
  if (range > 0.0) {
    float ratio = distance / range;
    float window = clamp(1.0 - ratio * ratio * ratio * ratio, 0.0, 1.0);
    attenuation *= window * window;
  }
  return attenuation;
}

/// One edge of Lambert's sum, from [a] to [b], neither of which need be a
/// unit vector: the angle between them times how much their plane leans into
/// [n].
float LambertEdge(vec3 a, vec3 b, vec3 n) {
  // Normalised with a floor rather than `normalize`: a corner exactly at the
  // shading point, or a horizon crossing that lands there, is a zero vector,
  // and `normalize` of that is a NaN that spreads to the whole pixel and then
  // to the bloom. A zero vector here subtends nothing, which is the answer.
  vec3 ua = a / max(length(a), 1e-12);
  vec3 ub = b / max(length(b), 1e-12);
  // Clamped before the `acos`: two nearly parallel edge directions can give a
  // dot a hair past one through rounding alone, and `acos` of that is the same
  // NaN.
  float angle = acos(clamp(dot(ua, ub), -1.0, 1.0));
  vec3 axis = cross(ua, ub);
  float len = length(axis);
  // A degenerate edge — the shading point lies on the line through it —
  // subtends nothing.
  return len > 1e-6 ? angle * dot(axis, n) / len : 0.0;
}

/// How much of [s]'s sky a rectangle covers, weighted by the cosine —
/// `gfx-77n`.
///
/// **Exact, not fitted.** This is Lambert's own form factor for a polygon, from
/// 1760: for each edge, the angle it subtends at the shading point times how
/// much the edge's plane leans into the surface normal. Summed over the edges
/// and halved, it is the integral of `cos θ` over the polygon's projection on
/// the sphere — the quantity a punctual light approximates with a single
/// `n · l`. So there is no table to ship and nothing to fit: the usual
/// linearly-transformed-cosine approach exists to make the *specular* lobe
/// tractable, and buys nothing here.
///
/// **Clipped to the horizon first.** Lambert's sum is signed: a part of the
/// panel below the surface's horizon counts with a negative cosine and cancels
/// light from the part above it, so a panel standing on the horizon read
/// nought where half of it lights the surface. Irradiance wants the clamped
/// cosine, and for a polygon that means cutting away what lies below before
/// summing. A convex quadrilateral cut by a plane leaves one polygon with at
/// most one edge leaving the hemisphere and one entering it, so the cut is the
/// four edges trimmed where they cross plus one edge along the horizon from
/// the exit back to the entry, with no list of vertices to build.
///
/// Returns irradiance over radiance, so a surface facing a rectangle that fills
/// its whole sky gets π, the same as a uniform hemisphere. [corners] are the
/// four vertices in order, relative to the shading point.
///
/// **The rectangle emits along `cross(halfWidth, halfHeight)`**, and with the
/// corners wound as `SampleLight` winds them the sum comes out *negative* on
/// that side, so the negation below is the convention rather than a fix. It was
/// measured rather than derived: the first version returned `+total * 0.5`, and
/// against the reference integration it read nought where the answer was 0.349
/// and 1.02 where the answer was nought — the two failures a flipped winding
/// produces, and between them they name the sign with no room left to argue.
float RectangleFormFactor(vec3 corners[4], vec3 n) {
  float total = 0.0;
  vec3 exit = vec3(0.0);
  vec3 entry = vec3(0.0);
  for (int i = 0; i < 4; i++) {
    vec3 a = corners[i];
    vec3 b = corners[i == 3 ? 0 : i + 1];
    float ha = dot(a, n);
    float hb = dot(b, n);
    // Where the edge meets the horizon; used only when it crosses it, and then
    // the two heights differ in sign, so the division is safe.
    float d = ha - hb;
    vec3 q = a + (b - a) * (abs(d) > 1e-12 ? ha / d : 0.0);
    bool aAbove = ha > 0.0;
    bool bAbove = hb > 0.0;
    total += aAbove || bAbove
                 ? LambertEdge(aAbove ? a : q, bAbove ? b : q, n)
                 : 0.0;
    exit = aAbove && !bAbove ? q : exit;
    entry = !aAbove && bAbove ? q : entry;
  }
  // The horizon edge closing the cut, from where the outline left the
  // hemisphere to where it came back. Nothing when it never crossed: both are
  // still zero and a zero vector subtends nothing.
  total += LambertEdge(exit, entry, n);
  // Clamped: a surface on the panel's dark side sees the outline wound the
  // other way, and the clipped sum comes out negative. `SampleLight` tests the
  // side as well, before any of this is paid for.
  return max(-total * 0.5, 0.0);
}

/// Where on the rectangle the specular lobe is really looking — `gfx-77n`.
///
/// **The representative point, which is an approximation, unlike the diffuse
/// above.** The mirror direction leaves the surface and either hits the panel
/// or misses it; the closest point of the panel to that ray is treated as a
/// punctual light standing in for the whole rectangle. It is the standard
/// cheap answer and its one visible property is the one the row asked for: as
/// the view moves the closest point slides along the panel, so the highlight
/// is a streak with the panel's own shape and orientation rather than a dot.
///
/// What it does not do is widen the lobe by the panel's solid angle, so a
/// rough surface under a large panel is a little darker than a full integration
/// would make it. That is a known error of this method and not a bug in this
/// transcription; the fix is the fitted table this function exists to avoid.
vec3 RectangleClosestPoint(vec3 centre, vec3 halfWidth, vec3 halfHeight,
                           vec3 world, vec3 mirror) {
  vec3 n = cross(halfWidth, halfHeight);
  float nLen = length(n);
  // A panel with no area has no surface to find a point on; its centre is the
  // only answer that is not a division by zero.
  if (nLen < 1e-12) return centre;
  n /= nLen;

  vec3 toPlane = centre - world;
  float denom = dot(mirror, n);
  vec3 onPlane;
  // Parallel to the panel, or pointing away from it: the ray never lands, so
  // the nearest thing to it is the centre projected back, which keeps the
  // highlight on the panel instead of sending it to infinity.
  if (abs(denom) < 1e-5) {
    onPlane = toPlane - n * dot(toPlane, n);
  } else {
    float t = dot(toPlane, n) / denom;
    onPlane = t > 0.0 ? mirror * t : toPlane - n * dot(toPlane, n);
  }

  // Clamped into the rectangle in its own axes. Dividing by the squared length
  // turns a projection into a coordinate in units of the half-extent, so the
  // clamp is against one either way round.
  vec3 offset = onPlane - toPlane;
  float wLen2 = max(dot(halfWidth, halfWidth), 1e-12);
  float hLen2 = max(dot(halfHeight, halfHeight), 1e-12);
  float u = clamp(dot(offset, halfWidth) / wLen2, -1.0, 1.0);
  float v = clamp(dot(offset, halfHeight) / hLen2, -1.0, 1.0);
  return centre + halfWidth * u + halfHeight * v;
}

#ifdef F3D_LTC
// --- lib/ltc.glsl ---
// The GGX lobe over a rectangle light, by linearly transformed cosines — `L7`.
//
// Heitz, Dupuy, Hill and Neubelt, "Real-Time Polygonal-Light Shading with
// Linearly Transformed Cosines", ACM TOG 35(4), 2016. The fitted tables are
// `EngineTables.ltc`; see `tables/ltc.dart` for their layout and licence.
//
// A model that wants it defines `F3D_LTC` before including `surface.glsl`,
// which is what gives its stage the one sampler below. Every other model
// keeps the representative point, and no sampler.

#ifndef LTC_GLSL_
#define LTC_GLSL_

/// Both tables, 64 × 128: the inverse matrices above, the norms, Fresnel
/// terms and sphere form factors below.
uniform sampler2D ltc_texture;

/// Where `(x, y)`, each nought to one, lands in the table starting at
/// [table] (nought the upper, one the lower): on texel centres, so the ends of
/// the range read the first and last entries rather than half of the
/// neighbour.
vec2 LtcUv(float x, float y, float table) {
  vec2 inTable = vec2(x, y) * (63.0 / 64.0) + 0.5 / 64.0;
  return vec2(inTable.x, (inTable.y + table) * 0.5);
}

/// One edge's share of the vector form factor, from [a] to [b], unit
/// directions: the angle between them along the normal of their plane,
/// over 2π. Exact, with the `acos` clamped for the reason
/// `RectangleFormFactor` gives.
vec3 LtcEdge(vec3 a, vec3 b) {
  vec3 axis = cross(a, b);
  float len = length(axis);
  float angle = acos(clamp(dot(a, b), -1.0, 1.0));
  return len > 1e-6 ? axis * (angle / (len * 6.2831853)) : vec3(0.0);
}

/// The GGX lobe of roughness [roughness] seen along [v] from normal [n],
/// integrated over the rectangle with corners [corners] (relative to the
/// shading point, wound as `SampleLight` winds them), with the fitted
/// Fresnel pair for that lobe: x the integral, y the norm, z the Fresnel
/// term. The specular is `x · (f0 · y + (1 − f0) · z)`.
///
/// Clipped to the horizon by the sphere table rather than by cutting the
/// polygon: the vector form factor's length and elevation name a sphere
/// with the same, and the table holds how much of that sphere's clamped
/// cosine lies above the horizon.
///
/// Says nothing about which face of the panel the point is on: the vector
/// form factor points the same way in the world from either side, so this is
/// as bright behind the panel as in front of it. `SampleLight` tests the side
/// and leaves a point behind unlit before this is asked.
vec3 LtcRectangle(vec3 n, vec3 v, float roughness, vec3 corners[4]) {
  vec2 uv = vec2(clamp(roughness, 0.0, 1.0),
                 sqrt(clamp(1.0 - dot(n, v), 0.0, 1.0)));
  vec4 inverse = textureLod(ltc_texture, LtcUv(uv.x, uv.y, 0.0), 0.0);
  vec4 fit = textureLod(ltc_texture, LtcUv(uv.x, uv.y, 1.0), 0.0);

  // The frame the fit was made in: the normal up, the view in the xz plane.
  // A view along the normal has no plane of its own, and any will do.
  vec3 along = v - n * dot(v, n);
  float alongLength = length(along);
  vec3 t1 = alongLength > 1e-5
                ? along / alongLength
                : normalize(cross(n, abs(n.z) < 0.999 ? vec3(0.0, 0.0, 1.0)
                                                      : vec3(1.0, 0.0, 0.0)));
  vec3 t2 = cross(n, t1);
  mat3 minv = mat3(vec3(inverse.x, 0.0, inverse.y), vec3(0.0, 1.0, 0.0),
                   vec3(inverse.z, 0.0, inverse.w));

  vec3 l[4];
  for (int i = 0; i < 4; i++) {
    vec3 p = corners[i];
    l[i] = normalize(minv * vec3(dot(p, t1), dot(p, t2), dot(p, n)));
  }
  // Negated, for `RectangleFormFactor`'s reason: the panel emits along
  // `cross(halfWidth, halfHeight)`, and seen from there these corners run
  // clockwise.
  vec3 f = -(LtcEdge(l[0], l[1]) + LtcEdge(l[1], l[2]) +
             LtcEdge(l[2], l[3]) + LtcEdge(l[3], l[0]));
  float len = length(f);
  float z = len > 1e-9 ? f.z / len : 0.0;
  float sphere =
      textureLod(ltc_texture, LtcUv(z * 0.5 + 0.5, clamp(len, 0.0, 1.0), 1.0),
                 0.0)
          .w;
  return vec3(max(len * sphere, 0.0), fit.x, fit.y);
}

#endif  // LTC_GLSL_


#ifdef F3D_LAYERED
/// The corners of the rectangle [SampleLight] resolved last, relative to the
/// shading point — `M1`. The clear coat integrates its own lobe over the same
/// panel with its own normal and roughness, and those live in `pbr.glsl`,
/// after this file; the loop shades each light straight after sampling it,
/// so this is always the light being shaded.
vec3 g_rect_corners[4];
#endif  // F3D_LAYERED
#endif  // F3D_LTC

/// Resolves light [index] against the surface.
///
/// Returns `n_dot_l == 0` for anything that contributes nothing — behind the
/// surface, out of range, outside the spot cone, the dark face of a panel — so
/// a model can skip it with one test instead of repeating the classification.
LightSample SampleLight(int index, Surface s) {
  LightSample light;
  light.integrated = 0.0;
  light.ltc = vec3(0.0);

  vec4 position;
  vec4 color;
  vec4 direction;
  vec4 cone;
  if (index < kMaxLights) {
    position = frag_info.light_position[index];
    color = frag_info.light_color[index];
    direction = frag_info.light_direction[index];
    cone = frag_info.light_cone[index];
  } else {
#ifdef F3D_NO_LIGHT_LIST
    // Unreachable: `LightCount` stops at the slots without a list.
    position = vec4(0.0);
    color = vec4(0.0);
    direction = vec4(0.0);
    cone = vec4(0.0);
#else
    // A row of the light list — `gfx-74n`. Sampled at texel centres so a
    // driver's rounding cannot land a fetch on a neighbour, and the four texels
    // across the row are the same four vectors the arrays above hold.
    int slot = index - kMaxLights;
    // `L6`: from the cell rather than the draw's own tail, and a light the
    // slots already hold is skipped by its intensity, as a faded one is.
    bool clustered = Clustered();
    float listRow = clustered ? ClusterRow(slot) : LightListRow(slot);
    float v = (listRow + 0.5) * light_list_info.list.z;
    float u = light_list_info.list.y;
    // `textureLod` and not `texture`, for `shadow.glsl`'s own reason: `index`
    // reaches this branch through a function parameter, so a WGSL backend
    // cannot see that every invocation of a draw walks the same light count
    // and refuses the implicit derivative as possibly non-uniform. The atlas
    // has one level, so naming it directly changes no pixel.
    position = textureLod(light_list_texture, vec2(0.5 * u, v), 0.0);
    color = textureLod(light_list_texture, vec2(1.5 * u, v), 0.0);
    direction = textureLod(light_list_texture, vec2(2.5 * u, v), 0.0);
    cone = textureLod(light_list_texture, vec2(3.5 * u, v), 0.0);
    // The intensity and not the colour, for `LightBuffer._pack`'s own reason:
    // the same multiply here, and only one of them is a number nobody authored.
    color.w *= clustered ? (InSlots(listRow) ? 0.0 : 1.0) : LightListScale(slot);
#endif  // F3D_NO_LIGHT_LIST
  }

  float type = position.w;

  // **The rectangle leaves before `aim` is taken — `gfx-77n`.** For every other
  // kind `direction.xyz` is a unit vector saying which way the light points;
  // for this one it is an edge of the panel, with its length carrying half the
  // width, and normalising it here would quietly throw the size away.
  if (type > 2.5) {
    vec3 halfWidth = direction.xyz;
    vec3 halfHeight = cone.xyz;
    vec3 toCentre = position.xyz - v_world_position;

    vec3 corners[4];
    corners[0] = toCentre - halfWidth - halfHeight;
    corners[1] = toCentre + halfWidth - halfHeight;
    corners[2] = toCentre + halfWidth + halfHeight;
    corners[3] = toCentre - halfWidth + halfHeight;

    // **The panel emits from one face only**, and a point on the other side
    // gets nothing: the room above a ceiling panel, the outside of the wall a
    // window is set in. Tested here rather than left to the signs below,
    // because the specular's vector form factor keeps the same orientation
    // from either side of the panel, so a surface behind it facing away read
    // as lit as one in front facing it.
    bool behind = dot(toCentre, cross(halfWidth, halfHeight)) >= 0.0;

    // The cosine-weighted solid angle, which takes the place `n · l` holds for
    // a punctual light: the loop multiplies the shading by `n_dot_l`, so
    // putting the exact integral here makes the diffuse term exact rather than
    // sampled. See [RectangleFormFactor].
    float formFactor = behind ? 0.0 : RectangleFormFactor(corners, s.n);

    // Radiance rather than intensity: `intensity` means the same thing for
    // every kind of light, so a panel's is spread over its own area here.
    // Enlarging a window at a fixed rating then dims it per square metre and
    // leaves the room as bright, which is what the number is supposed to mean.
    float area = length(cross(halfWidth, halfHeight)) * 4.0;
    float radiance = area > 1e-9 ? 1.0 / area : 0.0;

    // The range window only. A punctual light needs the inverse square as
    // well; the form factor already contains it, because a panel twice as far
    // away subtends a quarter of the sky.
    float distance = length(toCentre);
    if (direction.w > 0.0) {
      float ratio = distance / direction.w;
      float window = clamp(1.0 - ratio * ratio * ratio * ratio, 0.0, 1.0);
      radiance *= window * window;
    }

    vec3 mirror = reflect(-s.v, s.n);
    vec3 representative = RectangleClosestPoint(
        position.xyz, halfWidth, halfHeight, v_world_position, mirror);
    vec3 toPoint = representative - v_world_position;
    float pointDistance = length(toPoint);
    light.l = pointDistance > 1e-6 ? toPoint / pointDistance : s.n;

    light.h = normalize(light.l + s.v);
    light.n_dot_l = formFactor;
    light.n_dot_h = max(dot(s.n, light.h), 0.0);
    light.v_dot_h = max(dot(s.v, light.h), 0.0);
    light.radiance = color.rgb * color.w * radiance;
#ifdef F3D_LTC
    // `L7`: the specular over the whole panel rather than at one point of
    // it. The diffuse keeps the exact form factor above.
    light.integrated = 1.0;
    light.ltc = LtcRectangle(s.n, s.v, s.roughness, corners);
#ifdef F3D_LAYERED
    // Kept for the clear coat's own integral; see [g_rect_corners].
    g_rect_corners = corners;
#endif
#endif
    return light;
  }

  vec3 aim = normalize(direction.xyz);
  float attenuation = 1.0;

  if (type < 0.5) {
    // Directional: no position, no falloff. The direction to the light is the
    // reverse of the direction it points.
    light.l = -aim;
  } else {
    vec3 toLight = position.xyz - v_world_position;
    float distance = length(toLight);
    // A light exactly on the surface has no direction; treat it as contributing
    // nothing rather than producing a NaN that spreads through the frame.
    if (distance < 1e-6) {
      light.l = s.n;
      light.h = s.n;
      light.radiance = vec3(0.0);
      light.n_dot_l = 0.0;
      light.n_dot_h = 0.0;
      light.v_dot_h = 0.0;
      return light;
    }
    light.l = toLight / distance;
    attenuation = PunctualAttenuation(distance, direction.w);

    if (type > 1.5) {
      // Spot: a smooth ramp between the two cone cosines. The Dart side already
      // guarantees the denominator is non-zero.
      float cosAngle = dot(aim, -light.l);
      attenuation *= clamp(
          (cosAngle - cone.y) / (cone.x - cone.y), 0.0, 1.0);
    }
  }

  light.h = normalize(light.l + s.v);
  light.n_dot_l = max(dot(s.n, light.l), 0.0);
  light.n_dot_h = max(dot(s.n, light.h), 0.0);
  light.v_dot_h = max(dot(s.v, light.h), 0.0);
  light.radiance = color.rgb * color.w * attenuation;

  return light;
}

/// How much of light [index] reaches this fragment, defined by each fragment
/// shader.
///
/// A prototype rather than a call into shadow.glsl, because the models that
/// sample no shadow map must not declare its sampler — the compiler would drop
/// the slot and leave the engine binding one that is not there. A lit model
/// returns `ShadowFactor(...)`; an unlit one returns 1.
float LightVisibility(Surface s, LightSample light, int index);

/// A model's per-light term, defined by each fragment shader.
///
/// A prototype here and the definition in the model is what lets the loop below
/// be written once. The alternative — repeating the loop in every model — is
/// six copies of the same three lines, and the place a light would go missing.
vec3 ShadeLight(Surface s, LightSample light);

/// Sums every active light's contribution.
///
/// The loop bound is the compile-time maximum with a runtime break, because GLSL
/// wants a constant trip count and the hardware wants the early exit.
// **The point-shadow half of this header, behind a guard.**
//
// A model that never shadows must not *declare* any of this, and the reason is
// the one `unlit.frag` already gives about the shadow sampler — with one
// backend's failure added to the other's. On Impeller the compiler drops what
// nothing reads, and the engine binding a slot that is no longer there is a
// native crash. On WebGL2 nothing is dropped: an active uniform block with no
// buffer under it makes every draw `INVALID_OPERATION`, discarded with nothing
// logged.
//
// That is what `lighting-unlit` was on this backend. Unlit's own metadata says
// `usesPointShadow` is false, so the engine correctly bound no `PointShadow`
// block — and the translated shader declared one anyway, so the sphere was
// never drawn and the frame came back the clear colour.
#ifndef F3D_NO_POINT_SHADOW

/// The cube atlas: three tiles across, two down, each a ninety-degree view
/// from a point light, each storing radial distance normalised by range.
uniform sampler2D point_shadow_texture;

/// The same atlas for the things that never move, rendered once at load.
///
/// Two maps rather than one because a dungeon's walls can be baked and a
/// spinning pickup cannot, and there is no way to draw into part of a texture
/// without redrawing the rest of it. Sampling both and keeping the nearer
/// occluder costs one extra read and saves six views of the level every frame.
uniform sampler2D point_shadow_static_texture;

/// How many lights may have a row of the atlas. Six tiles across each.
// Rows of the cube atlas: six faces across, this many lights down. Must
// match `Renderer.kShadowedLights`, which is where the reasoning lives, and
// `shadowSlots` in the software backend's transcription of this file.
const int kShadowSlots = 6;

layout(std140) uniform PointShadow {
  /// The same view-projections the atlas was rendered with, six per slot.
  ///
  /// Passed rather than reconstructed. Deriving cube face coordinates here
  /// would be a second implementation of a decision the renderer already made,
  /// and the two would disagree about handedness or up vectors on some face
  /// and nowhere else — which shows as one face of every shadow being wrong.
  mat4 faces[6 * kShadowSlots];

  /// Per slot. xyz: the light's world position. w: its range.
  vec4 lights[kShadowSlots];

  /// Per light, in the order the lighting knows them.
  ///
  /// x: the atlas row it owns, or negative when it has none — a fifth torch in
  /// a room lands there. z: the tangent of half the frustum's opening angle,
  /// which is what converts a world width into a fraction of a tile. y and w
  /// are unwritten.
  ///
  /// **z is exactly one for a point light**, because a cube face is a ninety
  /// degree frustum and `tan(45°) == 1`. That is not a convention chosen to be
  /// tidy: it is what lets a narrower frustum share this whole path, since
  /// multiplying by one in IEEE 754 changes no bit of the result. Whatever else
  /// a spot light will need, it does not need a second copy of the filter.
  vec4 slots[kMaxLights];

  /// x: half a texel, in tile-local uv. y: distance bias in metres.
  /// z: strength. w: normal offset, **in texels of the face it lands on**.
  vec4 params;

  /// x: smallest kernel radius in tile-local uv, and the fixed radius used
  /// when contact hardening is off. y: the light's own radius in metres; zero
  /// turns contact hardening off. z: largest kernel radius in tile-local uv.
  /// w: non-zero paints the penumbra estimate into the surface buffer instead
  /// of shading with it.
  vec4 params2;

  /// x: non-zero when this backend stores the atlas bottom-up. y: one over the
  /// edge length of a tile in texels, which is what turns a distance into the
  /// world width of one texel there.
  ///
  /// **Appended after everything else on purpose**, the same way FragInfo's
  /// ambient pair was: std140 lays a block out in declaration order, so adding
  /// here leaves every offset above unchanged and the three backends do not
  /// have to agree about anything they already agreed about. y, z and w are
  /// unwritten.
  vec4 params3;
}
point_shadow;

/// Eight points on a Poisson disk, a common set for filtering cascaded
/// shadows.
///
/// A disk rather than a grid because a grid of taps on a straight shadow edge
/// lands every sample on the same side at once, and the edge steps between
/// kernel widths instead of sliding. Eight rather than sixteen because every
/// tap here reads **two** atlases — the static walls and the movers — so the
/// cost is doubled before it is counted.
vec2 PointShadowDiskTap(int i) {
  if (i == 0) return vec2(-0.94201624, -0.39906216);
  if (i == 1) return vec2(0.94558609, -0.76890725);
  if (i == 2) return vec2(-0.09418410, -0.92938870);
  if (i == 3) return vec2(0.34495938, 0.29387760);
  if (i == 4) return vec2(-0.91588581, 0.45771432);
  if (i == 5) return vec2(-0.81544232, -0.87912464);
  if (i == 6) return vec2(-0.38277543, 0.27676845);
  return vec2(0.97484398, 0.75648379);
}

/// One comparison against the atlas, at [uv] offset within the tile.
///
/// The clamp is applied **after** the offset, not before, and that is the whole
/// reason a kernel can be widened here without touching anything else: each tap
/// is held inside its own tile individually. Clamping the centre and then
/// offsetting would let the outer taps walk straight out of the tile and read a
/// distance measured from a different face, or a different light.
float PointShadowDistance(vec2 uv, vec2 offset, vec2 tile, float range) {
  float inset = point_shadow.params.x;
  vec2 local = clamp(uv + offset, inset, 1.0 - inset);
  vec2 atlas = (local + tile) * vec2(1.0 / 6.0, 1.0 / float(kShadowSlots));
  // **The whole atlas, turned over, where row zero of a render target is at the
  // bottom.** Both halves of the address are wrong there and this is the one
  // place that fixes both: the tile the light owns — a light in slot zero is
  // drawn into the row the shader would call three, because the viewport
  // rectangle is flipped to land it — and the picture inside that tile, which
  // was drawn through a projection built for the other origin.
  //
  // Every check of this atlas missed it for the same reason: the debug view
  // composites the texture through a full-screen pass, which turns it over
  // again and puts the row back. The atlas compared equal on both backends
  // across six scenes while the lit pass, which samples it directly and has no
  // such pass to cancel, read a row that had never been drawn into and found
  // nothing in the way of anything.
  if (point_shadow.params3.x > 0.5) atlas.y = 1.0 - atlas.y;
  // Whichever is nearer occludes: a wall in front of a monster shadows, and so
  // does a monster in front of a wall.
  //
  // **`textureLod` at level zero, because every caller of this function stands
  // behind a branch.** The light loop skips a light the surface faces away
  // from, the blocker search `continue`s past a tap that found nothing, and the
  // slot test returns before any of it — so the invocations of a quad do not
  // arrive here together, and a WGSL backend refuses a sample whose implicit
  // derivative would be read where they disagree. Both atlases are distance
  // render targets with one level, so level zero is the level `texture` was
  // choosing anyway; this names it rather than deriving it, and the picture is
  // the same on every backend.
  return min(textureLod(point_shadow_texture, atlas, 0.0).r,
             textureLod(point_shadow_static_texture, atlas, 0.0).r) * range;
}

float PointShadowTap(vec2 uv, vec2 offset, vec2 tile, float range,
                     float receiver) {
  float stored = PointShadowDistance(uv, offset, tile, range);
  // Nothing was drawn in that direction by either, so nothing is in the way.
  if (stored >= range * 0.999) return 1.0;
  return receiver > stored ? 0.0 : 1.0;
}

/// The disk point for tap [i], rotated by [ca]/[sa] and scaled to [radius].
vec2 PointShadowOffset(int i, float ca, float sa, float radius) {
  vec2 p = PointShadowDiskTap(i);
  return vec2(p.x * ca - p.y * sa, p.x * sa + p.y * ca) * radius;
}

/// How wide the penumbra should be here, in tile-local uv.
///
/// Contact hardening, and the reason a fixed kernel looks wrong: a shadow is
/// sharp where its caster touches the floor and soft a metre away, and one
/// radius for both makes the contact mushy or the distant edge hard.
///
/// The similar-triangles estimate is the standard one — a light of radius `L`
/// with a blocker at `b` and a receiver at `r` throws a penumbra `L * (r - b) /
/// b` wide at the receiver. Converting that to tile uv is exact rather than
/// tuned, because a face is a ninety degree frustum: at distance `r` from the
/// light the face spans `2 * r` in world units across the full `0..1` of uv,
/// so a world width `w` is `w / (2 * r)` of a tile.
///
/// The blocker search runs at the **widest** penumbra allowed, since a blocker
/// outside that circle cannot widen the result anyway, and searching narrower
/// would miss the very blockers that make an edge soft.
///
/// [tanHalf] is where the ninety degrees stop being assumed. The span above is
/// `2 * r` only for a right-angled frustum; in general it is `2 * r * tan(θ/2)`,
/// and for a cube face that factor is one. A narrower frustum covers less world
/// per tile, so the same world width is a *larger* fraction of it — which is
/// why this divides rather than multiplies, and why getting it upside down
/// would make a tight cone's shadows harden instead of soften.
float PointShadowPenumbra(vec2 uv, vec2 tile, float range, float receiver,
                          float ca, float sa, float tanHalf,
                          out float blockerOut) {
  blockerOut = -1.0;
  float lightRadius = point_shadow.params2.y;
  float minRadius = point_shadow.params2.x;
  float maxRadius = point_shadow.params2.z;
  if (lightRadius <= 0.0) {
    // **The debug channel is filled even though the search is skipped**, and
    // leaving it unfilled cost a session. `blockerOut` starts at −1 to mean
    // "nothing was measured"; the debug encoding clamps it into a colour, where
    // −1 becomes zero — the same green as a blocker touching the surface, which
    // reads as the most alarming answer available. A whole theory was built on
    // that zero, and the search it described had never run.
    //
    // The centre tap is what the filter below would use anyway, so this reports
    // a distance the atlas really returned rather than a sentinel.
    blockerOut = PointShadowDistance(uv, vec2(0.0), tile, range);
    return minRadius;
  }


  float sum = 0.0;
  float count = 0.0;
  for (int i = 0; i < 8; i++) {
    float stored =
        PointShadowDistance(uv, PointShadowOffset(i, ca, sa, maxRadius), tile,
                            range);
    if (stored >= range * 0.999) continue;
    if (stored >= receiver) continue;
    sum += stored;
    count += 1.0;
  }
  // Nothing in front of this fragment anywhere in the search: fully lit, and
  // the caller can skip the filter entirely.
  if (count < 0.5) return -1.0;

  float blocker = max(sum / count, 1e-4);
  blockerOut = blocker;
  float world = lightRadius * max(receiver - blocker, 0.0) / blocker;
  return clamp(world / (2.0 * receiver * tanHalf), minRadius, maxRadius);
}

/// How lit [world] is by the point light that owns the cube atlas.
///
/// One, fully lit, when this is not that light or the atlas has nothing to say.
float PointShadowFactor(vec3 world, vec3 normal, int lightIndex) {
  int slot = int(point_shadow.slots[lightIndex].x + 0.5);
  if (point_shadow.slots[lightIndex].x < 0.0) return 1.0;
  float strength = point_shadow.params.z;
  if (strength <= 0.0) return 1.0;

  // Offset along the normal before measuring, and scaled by how steeply the
  // surface leans away from the light.
  //
  // A soft kernel on a tilted surface straddles a depth gradient: the taps at
  // one end of the disk are further from the light than the fragment itself,
  // so a flat offset that clears the surface head-on leaves acne at a grazing
  // angle. The slope term lifts the whole kernel clear instead, and is capped
  // because it runs away as the surface turns edge-on to the light — an
  // uncapped lift detaches the shadow from its caster.
  vec3 toLight = point_shadow.lights[slot].xyz - world;
  float toLightLength = max(length(toLight), 1e-6);
  float nDotL = max(dot(normal, toLight / toLightLength), 0.15);
  float slope = min(sqrt(max(1.0 - nDotL * nDotL, 0.0)) / (nDotL * nDotL), 8.0);

  // **How wide one texel of the face is, out where this fragment is.** The
  // error a normal offset exists to clear is exactly that: a texel of the
  // shadow map covers a patch of surface, the whole patch is recorded at one
  // distance, and a fragment anywhere else in it compares against a distance
  // measured somewhere it is not. That patch grows with range — it is a solid
  // angle, not a length — so an offset fixed in metres is right at one distance
  // and wrong everywhere else.
  //
  // What it was: `params.w` metres, flat. On the golden teapot, at 9.6 m from
  // the lamp, a texel is 3.7 cm and the flat offset was 2 cm, so the floor
  // shadowed itself across everything the light reached — and the acne stopped
  // dead at the *projection of the floor's own edge*, because past it the atlas
  // holds nothing and nothing can occlude. A straight line across a shadow with
  // no straight edge anywhere in the scene.
  float texel =
      2.0 * toLightLength * max(point_shadow.slots[lightIndex].z, 1e-4) *
      point_shadow.params3.y;
  // Both terms are metres. The slope term used to be the kernel radius, which
  // is a fraction of a tile — a unit mismatch carried over from an estimate
  // where a softness radius genuinely was the right quantity. Here it meant
  // widening the kernel also lifted the sample off the surface, by up to ten
  // centimetres at the wider settings, so the softening and the lift
  // cancelled: tripling the kernel moved 184 pixels of the frame,
  // where the kernel alone moves thousands. It is what made contact hardening
  // look inert, and it was hiding in a comparison rather than in the estimate.
  vec3 origin = world + normal * texel * point_shadow.params.w * (1.0 + slope);
  vec3 toFragment = origin - point_shadow.lights[slot].xyz;
  float distance = length(toFragment);
  float range = max(point_shadow.lights[slot].w, 1e-4);
  if (distance >= range) return 1.0;

  // The dominant axis picks the face, in the order the renderer wrote them:
  // +X, -X, +Y, -Y, +Z, -Z, left to right then top to bottom.
  //
  // A spot has one column and no choice to make. Asking the dominant axis
  // anyway would be worse than pointless: a fragment below and to the side of
  // a downlight has −Y dominant, which is column 3, and column 3 of a spot's
  // row is deliberately blank — so the whole cone would read as unshadowed
  // except for the wedge where the aim happens to be the dominant axis.
  int face = 0;
  if (point_shadow.slots[lightIndex].y < 0.5) {
    vec3 a = abs(toFragment);
    if (a.x >= a.y && a.x >= a.z) {
      face = toFragment.x > 0.0 ? 0 : 1;
    } else if (a.y >= a.z) {
      face = toFragment.y > 0.0 ? 2 : 3;
    } else {
      face = toFragment.z > 0.0 ? 4 : 5;
    }
  }

  vec4 clip = point_shadow.faces[slot * 6 + face] * vec4(origin, 1.0);
  if (clip.w <= 0.0) return 1.0;
  vec2 ndc = clip.xy / clip.w;
  if (abs(ndc.x) > 1.0 || abs(ndc.y) > 1.0) return 1.0;

  // v is flipped, the same way the directional map does it: the texture's
  // origin is at the top, where row zero of the render target is. Getting this
  // wrong does not tilt the shadow — it makes the top row of faces read the
  // bottom row, so a whole region compares against an unrelated distance and
  // comes out as a black slab.
  vec2 uv = vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
  // The face across, the light down: six tiles wide, four tall.
  vec2 tile = vec2(float(face), float(slot));

  float receiver = distance - point_shadow.params.y;

  // One rotation, shared by the blocker search and the filter. Per fragment,
  // so eight samples read as a soft edge rather than as eight copies of the
  // silhouette: without it every fragment along an edge tests the same eight
  // directions and the pattern shows.
  //
  // **The three constants are not arbitrary and are not ours.** This is Jorge
  // Jimenez's interleaved gradient noise, from "Next Generation Post
  // Processing in Call of Duty: Advanced Warfare" (SIGGRAPH 2014):
  //
  //   IGN(x, y) = frac(52.9829189 * frac(0.06711056 * x + 0.00583715 * y))
  //
  // The pair inside the dot is a direction whose gradient walks the unit
  // interval as slowly as it can while never repeating over a screen, and the
  // multiplier outside stretches that walk so neighbouring pixels land far
  // apart in the result. What it buys over a hash is the cost: one dot and two
  // fracts, no integer arithmetic, no texture. What a blue-noise texture buys
  // over it is a better spectrum, at a sampler and a fetch — worth it for
  // dithering a whole frame, not for rotating eight taps.
  //
  // Written down because three unexplained decimals read as a magic spell, and
  // the next person to touch this line has no way to tell which of them may be
  // changed. The answer is none of them.
  float noise = fract(52.9829189 * fract(dot(FragCoordFromTop(
                                                frag_info.target_origin.x),
                                            vec2(0.06711056, 0.00583715))));
  float angle = noise * 6.28318530718;
  float ca = cos(angle);
  float sa = sin(angle);

  // Guarded rather than read straight, because a zero here divides by zero and
  // a NaN radius poisons the filter into a black fragment. Zero is what an
  // unwritten channel holds, and "unwritten" is a state this block has been in
  // before: every slot is cleared to −1 each frame.
  float tanHalf = max(point_shadow.slots[lightIndex].z, 1e-4);

  float blocker = -1.0;
  float radius =
      PointShadowPenumbra(uv, tile, range, receiver, ca, sa, tanHalf, blocker);

  // The debug channel, and the reason it exists: two explanations for why the
  // estimate collapses were argued from the finished picture and both were
  // wrong, because the number that decides it never leaves this function.
  //
  // Red is how wide the penumbra came out, against the widest allowed. Green
  // is how far away the blocker was, against the light's range. Blue marks
  // the fragments where the search found nothing at all — which is a different
  // answer from "found something very close", and telling those two apart is
  // most of the question.
  if (point_shadow.params2.w > 0.5) {
    g_debug_surface_on = true;
    g_debug_surface = radius < 0.0
        ? vec3(0.0, 0.0, 1.0)
        : vec3(clamp(radius / max(point_shadow.params2.z, 1e-6), 0.0, 1.0),
               clamp(blocker / range, 0.0, 1.0), 0.0);
  }

  // The search found nothing between here and the light.
  if (radius < 0.0) return 1.0;

  float lit = PointShadowTap(uv, vec2(0.0), tile, range, receiver);
  if (radius > 0.0) {
    for (int i = 0; i < 8; i++) {
      lit += PointShadowTap(uv, PointShadowOffset(i, ca, sa, radius), tile,
                            range, receiver);
    }
    lit *= 1.0 / 9.0;
  }

  // Strength lerps towards fully lit, so the control is "how dark", not "how
  // much of the kernel" — the same convention the directional map uses.
  return mix(1.0, lit, clamp(strength, 0.0, 1.0));
}

#else

/// The stand-in for a model that declares none of the above.
///
/// Fully lit, which is what a model with no shadow term means, and a constant
/// the compiler folds rather than a branch anything pays for.
float PointShadowFactor(vec3 world, vec3 normal, int lightIndex) {
  return 1.0;
}

#endif  // F3D_NO_POINT_SHADOW

vec3 AccumulateLights(Surface s) {
  vec3 total = vec3(0.0);
  int count = LightCount();

  for (int i = 0; i < kTotalLights; i++) {
    if (i >= count) break;
    LightSample light = SampleLight(i, s);
    if (light.n_dot_l <= 0.0) continue;
    // A light from the list has no shadow row to read — see `LightHasShadow`.
    // A branch rather than something folded into the two calls, because both
    // index tables eight entries wide and the ninth light would read past them
    // rather than read a one.
    float visibility = LightHasShadow(i)
        ? LightVisibility(s, light, i) *
              PointShadowFactor(v_world_position, s.n, i)
        : 1.0;
    if (visibility <= 0.0) continue;
    total += ShadeLight(s, light) * light.radiance * light.n_dot_l * visibility;
  }

  return total;
}

#endif  // SURFACE_GLSL_

// --- lib/irradiance.glsl ---
// The irradiance field, read per pixel — `L3`.
//
// **Per pixel where it was per object.** The field used to be sampled once
// per draw at the node's centre, twice (up and down), and handed to the shader
// as the hemisphere ambient. A floor that runs from a red wall to a blue one
// then took one colour, whichever its middle saw. Read here, at each point,
// the red bleeds onto the floor near the red wall and fades across it.
//
// The field arrives as one float texture: every probe's irradiance tile (rgb,
// with the probe's "active" flag in alpha) in a grid of `columns` × `rows`
// tiles at the top, and every probe's depth-moment tile (mean and mean
// square) in the same grid below. Each tile carries a one-texel gutter, so a
// bilinear read inside it never needs to know where the tile ends. The read
// is done here, four nearest taps at a time, rather than by a filtered
// sampler: a filtered float texture is a capability three backends answer
// differently, and four taps are the same on all of them.
//
// Weights per probe, as `IrradianceField.sample` on the host: trilinear by
// the point's place in its cell, the square of a half-cosine towards the
// probe, and Chebyshev's bound from the depth moments, the last two floored
// and crushed so no active probe's weight reaches nought. The point is moved
// off its surface along the normal and towards the eye first, so a surface
// does not read the probe's own view of it as a wall.
//
// Included by the lit models only, through `material_maps.glsl`.

#ifndef IRRADIANCE_GLSL_
#define IRRADIANCE_GLSL_

uniform sampler2D irradiance_texture;

layout(std140) uniform IrradianceInfo {
  /// xyz: where probe (0, 0, 0) stands. w: one when the field is read,
  /// nought when the hemisphere ambient stands.
  vec4 origin;

  /// xyz: the spacing between probes per axis. w: how far the point is
  /// moved along the normal, in metres.
  vec4 spacing;

  /// xyz: probes per axis. w: how far the point is moved towards the eye.
  vec4 counts;

  /// x: an irradiance tile's interior, y: a moment tile's, in texels.
  /// z: tiles per row of the atlas. w: the row the moment tiles start at.
  vec4 tiles;

  /// xy: one over the atlas's size. zw unused.
  vec4 atlas;
}
irradiance_info;

bool IrradianceEnabled() { return irradiance_info.origin.w > 0.5; }

/// `encodeOctahedral` in `irradiance_field.dart`.
vec2 ProbeOctahedral(vec3 direction) {
  float sum = abs(direction.x) + abs(direction.y) + abs(direction.z);
  if (sum <= 0.0) return vec2(0.5);
  vec3 n = direction / sum;
  vec2 xy = n.xy;
  if (n.z < 0.0) {
    xy = vec2((1.0 - abs(n.y)) * (n.x >= 0.0 ? 1.0 : -1.0),
              (1.0 - abs(n.x)) * (n.y >= 0.0 ? 1.0 : -1.0));
  }
  return xy * 0.5 + 0.5;
}

vec4 AtlasTexel(vec2 texel) {
  return textureLod(irradiance_texture, (texel + 0.5) * irradiance_info.atlas.xy,
                    0.0);
}

/// A bilinear read of the tile whose top-left stored texel is [corner],
/// [interior] wide, at the octahedral [uv].
vec4 TileBilinear(vec2 corner, float interior, vec2 uv) {
  vec2 at = 1.0 + uv * interior - 0.5;
  vec2 low = floor(at);
  vec2 f = at - low;
  vec4 a = AtlasTexel(corner + low);
  vec4 b = AtlasTexel(corner + low + vec2(1.0, 0.0));
  vec4 c = AtlasTexel(corner + low + vec2(0.0, 1.0));
  vec4 d = AtlasTexel(corner + low + vec2(1.0, 1.0));
  return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}

/// The irradiance arriving at [world] on a surface facing [normal], seen
/// from the direction [view] (a unit vector towards the eye).
vec3 SampleIrradiance(vec3 world, vec3 normal, vec3 view) {
  vec3 origin = irradiance_info.origin.xyz;
  vec3 spacing = irradiance_info.spacing.xyz;
  vec3 counts = irradiance_info.counts.xyz;
  float irradianceTile = irradiance_info.tiles.x;
  float depthTile = irradiance_info.tiles.y;
  float columns = irradiance_info.tiles.z;
  float momentsTop = irradiance_info.tiles.w;
  vec3 unit = normalize(normal);

  vec3 biased = world + unit * irradiance_info.spacing.w +
                view * irradiance_info.counts.w;
  vec3 grid = (biased - origin) / spacing;
  vec3 base = clamp(floor(grid), vec3(0.0), counts - 2.0);
  vec3 f = clamp(grid - base, vec3(0.0), vec3(1.0));

  vec3 total = vec3(0.0);
  float weights = 0.0;
  for (int corner = 0; corner < 8; corner++) {
    vec3 offset = vec3(float(corner & 1), float((corner >> 1) & 1),
                       float((corner >> 2) & 1));
    vec3 cell = base + offset;
    float probe = (cell.z * counts.y + cell.y) * counts.x + cell.x;
    vec2 tile = vec2(mod(probe, columns), floor(probe / columns));

    vec2 irradianceCorner = tile * (irradianceTile + 2.0);
    vec2 momentCorner = vec2(tile.x * (depthTile + 2.0),
                             momentsTop + tile.y * (depthTile + 2.0));

    // The probe's own flag, on the tile's first interior texel.
    if (AtlasTexel(irradianceCorner + 1.0).a < 0.5) continue;

    vec3 trilinear = mix(vec3(1.0) - f, f, offset);
    float weight = max(trilinear.x * trilinear.y * trilinear.z, 0.001);

    vec3 probePosition = origin + spacing * cell;
    vec3 toProbe = probePosition - biased;
    float distance = length(toProbe);
    if (distance > 1e-6) {
      vec3 direction = toProbe / distance;
      // Facing and visibility are floored, then crushed, rather than let
      // fall to nought (Majercik et al. 2019): a probe behind the surface or
      // past a wall counts for almost nothing but never for nothing, so a
      // point every probe of its cell is cut off from still reads a blend of
      // them rather than black.
      float facing = dot(unit, normalize(probePosition - world)) * 0.5 + 0.5;
      float probeWeight = facing * facing + 0.2;

      vec2 moments = TileBilinear(momentCorner, depthTile,
                                  ProbeOctahedral(-direction)).xy;
      float chebyshev = 1.0;
      if (distance > moments.x) {
        float variance = max(moments.y - moments.x * moments.x, 1e-6);
        float difference = distance - moments.x;
        chebyshev = variance / (variance + difference * difference);
        chebyshev = chebyshev * chebyshev * chebyshev;
      }
      probeWeight = max(probeWeight * max(chebyshev, 0.05), 1e-6);
      if (probeWeight < 0.2) probeWeight *= probeWeight * probeWeight * 25.0;
      weight *= probeWeight;
    }

    total += TileBilinear(irradianceCorner, irradianceTile,
                          ProbeOctahedral(unit)).rgb *
             weight;
    weights += weight;
  }
  return weights > 0.0 ? total / weights : vec3(0.0);
}

#endif  // IRRADIANCE_GLSL_


/// Tangent-space normal map. Neutral is (0.5, 0.5, 1.0).
uniform sampler2D normal_texture;

/// glTF's ORM packing: g is roughness, b is metallic. Neutral is white.
uniform sampler2D metallic_roughness_texture;

/// Ambient occlusion in r. Neutral is white.
uniform sampler2D occlusion_texture;

/// Emitted colour, multiplied by the emissive factor. Neutral is white, and the
/// factor defaults to black, so a material with neither emits nothing.
uniform sampler2D emissive_texture;

/// The level's baked lightmap, RGBM: colour over a shared multiplier, decoded
/// as `rgb × a × 8`. Sampled at the second coordinate, which every vertex
/// stage but the lightmapped one leaves at the atlas corner; neutral is
/// black, so a material without a map adds nothing.
uniform sampler2D lightmap_texture;

/// The irradiance the lightmap holds at this fragment, in the units a light's
/// `colour × intensity × attenuation × cos` arrives in.
vec3 SampleLightmap() {
  vec4 texel = texture(lightmap_texture, v_lightmap_uv);
  return texel.rgb * texel.a * 8.0;
}

/// One function per map, rather than one that applies all four.
///
/// Not a style choice. The compiler drops a sampler whose result never reaches
/// the output, so a model that samples the ORM map and then ignores metallic and
/// roughness — Lambert does exactly that — ends up with no
/// `metallic_roughness_texture` in its compiled signature at all, while the Dart
/// side still thinks there is one to bind. That is the phantom-binding trap
/// again, and binding a slot Metal does not have is a native crash.
///
/// Splitting them means a model calls only what it genuinely uses, so the
/// compiled signature matches the source, and `LightingModel` can declare the
/// same set truthfully. `tool/build_shaders.sh` prints the compiled slots so
/// the two cannot drift apart unnoticed.

/// glTF's ORM packing: roughness in g, metallic in b, both multiplying the
/// material factors.
void ApplyMetallicRoughnessMap(inout Surface s) {
  vec3 orm = texture(metallic_roughness_texture, MapUv(kMapMetallicRoughness), MaterialLodBias()).rgb;
  s.metallic = clamp(s.metallic * orm.b, 0.0, 1.0);
  s.roughness = clamp(s.roughness * orm.g, 0.02, 1.0);
}

void ApplyOcclusionMap(inout Surface s) {
  float occlusion = texture(occlusion_texture, MapUv(kMapOcclusion), MaterialLodBias()).r;
  // glTF's occlusionStrength lerps between "ignore the map" and "apply it in
  // full", which is why it is a mix and not a multiply.
  s.occlusion = mix(1.0, occlusion, clamp(frag_info.material2.z, 0.0, 1.0));
}

void ApplyEmissiveMap(inout Surface s) {
  vec3 emissive = SrgbToLinear(texture(emissive_texture, MapUv(kMapEmissive), MaterialLodBias()).rgb);
  s.emissive = emissive * frag_info.emissive.rgb * frag_info.material2.w;
}

/// Perturbs the surface normal by the tangent-space normal map.
void ApplyNormalMap(inout Surface s) {
  // **Sampled before the frame is tested, and that order is load-bearing.**
  // The test below is a branch on interpolated data, so the four invocations of
  // a quad can take different sides of it; a WGSL backend then refuses a
  // `texture` call underneath, because the mip level it derives is only defined
  // where the whole quad agrees. Unlike the shadow atlases, this map really is
  // mipped — a normal map read at full resolution on a surface turned away from
  // the camera is the aliasing that made this the widest disagreement between
  // backends — so pinning a level here would be a picture change, and hoisting
  // the sample is the cure that is not. A degenerate tangent is rare enough
  // that paying for its unused texel is nothing, and the texel it reads is the
  // same one the branch would have read.
  vec4 sampledTexel = texture(normal_texture, MapUv(kMapNormal), MaterialLodBias());

  // The tangent is re-orthogonalized against the normal because interpolating
  // both across a triangle does not preserve the right angle between them.
  vec3 t = v_tangent.xyz;
  t = t - s.n * dot(s.n, t);
  if (dot(t, t) < 1e-12) return;  // no usable frame; keep the vertex normal
  t = normalize(t);

  // The bitangent sign is what encodes a mirrored UV island. Dropping it makes
  // every mirrored half of a symmetric model light from the wrong side, which
  // is exactly what NormalTangentTest is built to show.
  vec3 b = cross(s.n, t) * v_tangent.w;
#ifdef F3D_TEXTURE_TRANSFORM
  // `C8`: a map turned or mirrored by its transform is read along axes the
  // vertex tangent no longer names, so the frame turns with it — the rule
  // `withTextureTransform` applies to a baked mesh, here at the sampler. The
  // new tangent is where the map's own `u` increases: the first column of the
  // matrix's inverse, times its determinant, whose sign a mirror flips and the
  // bitangent's sign with it. Measured on the front face's frame, which is
  // the frame the transform was authored on. A plain scale leaves the frame
  // as it was, bit for bit, which is why the test is on the matrix. That
  // column is `m11 dP/du - m10 dP/dv`, and dP/dv is **minus** the bitangent:
  // `v` runs down the texture, a normal map's green up it.
  vec4 m = MapMatrix(kMapNormal);
  float det = m.x * m.w - m.y * m.z;
  float flip = det < 0.0 ? -1.0 : 1.0;
  vec3 front = gl_FrontFacing ? b : -b;
  vec3 turned = (t * m.w + front * m.z) * flip;
  bool turns = (m.y != 0.0 || m.z != 0.0 || m.x < 0.0 || m.w < 0.0) &&
               dot(turned, turned) > 1e-12;
  t = turns ? normalize(turned) : t;
  b = turns ? cross(s.n, t) * v_tangent.w * flip : b;
#endif
  // On a back face `ReadSurface` has already turned the normal round, and
  // the bitangent above turned with it. The tangent has to follow, or the
  // frame is half-mirrored and relief along u lights from the wrong side —
  // glTF turns the whole frame, not the normal alone.
  if (!gl_FrontFacing) t = -t;

  vec3 sampled = sampledTexel.xyz * 2.0 - 1.0;
  // A two-channel map (BC5, RG8) stores only x and y and samples as
  // (x, y, 0, 1); read as it stands, blue 0 is z = -1 and the normal points
  // into the surface. z is rebuilt from the unit length instead, before the
  // scale, which glTF applies to the stored normal. `emissive.w` is the flag.
  if (frag_info.emissive.w > 0.5) {
    sampled.z = sqrt(max(1.0 - dot(sampled.xy, sampled.xy), 0.0));
  }
  // normalScale attenuates the tangent-space xy, per the glTF spec.
  sampled.xy *= frag_info.material2.y;

  s.n = normalize(t * sampled.x + b * sampled.y + s.n * sampled.z);
  s.n_dot_v = max(dot(s.n, s.v), 1e-4);
}

/// The three maps every lit model uses. Metal-rough is separate because only
/// the models that actually respond to metallic or roughness may sample it.
void ApplyCommonMaps(inout Surface s) {
  // `L3`: the field in place of the hemisphere, read before the normal map
  // for the reason the hemisphere is — which half of the room a face sees is
  // not a question about millimetres of relief. At the same strength the
  // hemisphere was.
  if (IrradianceEnabled()) {
    s.ambient = SampleIrradiance(v_world_position, s.n, s.v) *
                frag_info.material.z;
  }
  ApplyNormalMap(s);
  ApplyOcclusionMap(s);
  ApplyEmissiveMap(s);
}

#endif  // MATERIAL_MAPS_GLSL_

// --- lib/shadow.glsl ---
// Sampling the directional light's shadow map.
//
// A separate header for the same reason material_maps.glsl is one: the sampler
// must only be declared by shaders that actually read it, or the compiler drops
// the slot while the engine still tries to bind it.

#ifndef SHADOW_GLSL_
#define SHADOW_GLSL_

// --- lib/evsm.glsl ---
// Exponential variance shadow maps — `S2`.
//
// Shared by the pass that turns the directional depth atlas into moments
// (`evsm_filter.frag`) and by `ShadowFactor`, which reads them back: the two
// halves must warp depth with the same two exponents, or every comparison is
// between numbers on different scales.
//
// A header of its own rather than a section of `shadow.glsl`, because that
// one declares the lit stages' shadow sampler and the filter pass has no
// business declaring it.

#ifndef EVSM_GLSL_
#define EVSM_GLSL_

precision highp float;

// The two exponents depth is warped by. **Forty and five, and the ceiling is
// the format.** The moments are stored squared, so the positive side reaches
// e^80 at the far plane, about 5.5e34 — inside a 32-bit float with three
// orders of magnitude to spare, and far outside a half float, which is why
// the moments live in an rgba32f atlas and the depth atlas does not. The
// negative side only has to catch what the positive side lets through at a
// receiver just behind a caster, and five is the usual answer.
const float kEvsmPositive = 40.0;
const float kEvsmNegative = 5.0;

/// [depth], in [0, 1], warped onto both exponentials: x positive, y negative.
///
/// Depth is first spread to [-1, 1] so the two sides share the range evenly
/// rather than the negative one flattening to nothing at the far end.
vec2 EvsmWarp(float depth) {
  float d = 2.0 * clamp(depth, 0.0, 1.0) - 1.0;
  return vec2(exp(kEvsmPositive * d), -exp(-kEvsmNegative * d));
}

/// What one texel of the depth atlas stores in the moments atlas: each warp
/// and its square, which a blur then averages into a mean and a variance.
vec4 EvsmMoments(float depth) {
  vec2 warped = EvsmWarp(depth);
  return vec4(warped.x, warped.x * warped.x, warped.y, warped.y * warped.y);
}

/// Chebyshev's upper bound on the share of [moments]'s distribution at or
/// beyond [t], with the light-bleeding cut [bleed] taken off the bottom.
///
/// A select at the end rather than an early return of one, because a phi of
/// constants is what SPIRV-Cross refuses when it writes the WGSL.
float EvsmChebyshev(vec2 moments, float t, float minVariance, float bleed) {
  float variance = max(moments.y - moments.x * moments.x, minVariance);
  float d = t - moments.x;
  float pMax = variance / (variance + d * d);
  // Light bleeding: where two casters overlap, the bound admits light the
  // nearer one should block. Everything under [bleed] is called shadow and
  // the rest stretched back over [0, 1].
  float reduced = clamp((pMax - bleed) / max(1.0 - bleed, 1e-4), 0.0, 1.0);
  return t <= moments.x ? 1.0 : reduced;
}

/// How much light reaches a receiver at [depth] past filtered [moments].
///
/// The smaller of the two bounds: each exponential lets through a different
/// kind of error, and neither lets through what the other stops.
float EvsmVisibility(vec4 moments, float depth, float bleed) {
  vec2 warped = EvsmWarp(depth);
  // A floor on the variance proportional to the warped depth's own slope,
  // so a flat receiver compared against its own texel does not divide
  // nought by nought — the variance of one depth is zero.
  vec2 scale = 0.0001 * vec2(kEvsmPositive, kEvsmNegative) * warped;
  float positive = EvsmChebyshev(moments.xy, warped.x, scale.x * scale.x, bleed);
  float negative = EvsmChebyshev(moments.zw, warped.y, scale.y * scale.y, bleed);
  return min(positive, negative);
}

#endif  // EVSM_GLSL_


/// Linear depth from the light's point of view, in the red channel — or,
/// with the `evsm` filter (`S2`), the blurred moments `evsm_filter.frag`
/// made of it, bound to the same slot so the lit stages spend no sampler on
/// the choice.
uniform sampler2D shadow_texture;

/// Point [i] of [n] on a Vogel disc turned by [turn] radians — `S3`: the
/// golden angle between neighbours, so any prefix of the points covers the
/// disc evenly, and a radius growing with the square root, so they cover it
/// at an even density.
vec2 VogelDisc(int i, int n, float turn) {
  float r = sqrt((float(i) + 0.5) / float(n));
  float theta = float(i) * 2.3999632 + turn;
  return r * vec2(cos(theta), sin(theta));
}

/// Interleaved gradient noise at this pixel, in [0, 1), stepped on by the
/// frame's slice while a temporal resolve runs (`target_origin.w`) so the
/// history averages the rotations. The pattern needs no texture, which keeps
/// the lit stages at the samplers they have. Rows are counted from the top
/// (`target_origin.x`), as the point shadow's rotation counts them, so WebGL2
/// turns the kernel on the same pixels as every other backend.
float ShadowNoise() {
  vec2 at = FragCoordFromTop(frag_info.target_origin.x) +
            5.588238 * max(frag_info.target_origin.w, 0.0);
  return fract(52.9829189 * fract(dot(at, vec2(0.06711056, 0.00583715))));
}

/// How much of the light survives at this fragment, from 0 to 1.
///
/// Returns 1 when shadows are off, when the fragment falls outside the map, or
/// when the light in question is not the caster — a fragment beyond the shadow
/// volume is unshadowed, not black, and getting that wrong puts a hard edge
/// across the scene at the edge of the map.
float ShadowFactor(Surface s, LightSample light, int lightIndex) {
  float strength = frag_info.shadow_params.w;
  if (strength <= 0.0) return 1.0;
  if (lightIndex != int(frag_info.frame_params.z + 0.5)) return 1.0;

  // Normal offset: move the sample point along the surface normal before
  // projecting it. It costs nothing and fixes the shadow acne that a depth bias
  // alone cannot, because the error is proportional to the surface's slope
  // relative to the light rather than to depth.
  //
  // **A flat distance plus what the kernel's reach needs, and no more.** The
  // flat part alone was tuned for surfaces the map never recorded: with the
  // default `casterFaces: back` a closed mesh writes only the faces turned
  // away from the sun, so a lit face compares against its own far side. A
  // double-sided material writes its lit faces too, and then the offset has
  // to lift the point clear of its own plane as far out as the 3×3 kernel
  // reads: a tap one texel over lands in a texel whose centre is up to a
  // texel and a half away, where the plane is 1.5·texel·tanθ nearer the
  // light. A step d along the normal clears the plane by d / cosθ along the
  // ray, so d = 1.5·texel·sinθ is exactly enough, taken per axis of the map
  // because a slope running diagonally across it reaches further in texels.
  // Nothing at normal incidence, a texel and a half at grazing. The depth
  // bias covers the rest. Every metre more than this moves the shadow away
  // from its caster, and in the far cascade a texel is decimetres. Measured
  // per cascade in the loop below, since each has a texel of its own.

  // Which cascade covers this fragment.
  //
  // Chosen by distance from the camera and then *checked*, because the volumes
  // are spheres on the line of sight rather than fitted frusta: a fragment at
  // the edge of the view can be past the end of the cascade its distance
  // suggests. Falling through to the next one costs a branch and removes a
  // whole class of missing-shadow bug, and the last cascade is fitted to the
  // entire scene, so the fall-through always terminates somewhere real.
  int cascadeCount = int(frag_info.shadow_cascades.z + 0.5);
  float viewDistance = length(v_world_position - frag_info.camera_position.xyz);
  int cascade = 0;
  if (cascadeCount > 1 && viewDistance > frag_info.shadow_cascades.x) cascade = 1;
  if (cascadeCount > 2 && viewDistance > frag_info.shadow_cascades.y) cascade = 2;

  vec2 uv = vec2(0.0);
  vec3 projected = vec3(0.0);
  bool found = false;
  // `S3`: what the soft path needs of the cascade it lands in — metres per
  // texel across, and metres per unit of stored depth along the light.
  float cascadeTexel = 1.0;
  float cascadeDepth = 1.0;
  for (int attempt = 0; attempt < 3; attempt++) {
    int which = cascade + attempt;
    if (which >= cascadeCount) break;

    mat4 matrix = which == 0
        ? frag_info.shadow_matrix
        : (which == 1 ? frag_info.shadow_matrix_far
                      : frag_info.shadow_matrix_farthest);
    // One texel of this cascade in metres. The projection is orthographic,
    // so its first row is 2 / width, and a tile texel is `shadow_cascades.w`
    // of the width. The rows are also the map's axes in the world, which is
    // what the normal is measured along: its share across each axis is the
    // sine of the slope in that direction.
    vec3 axisX = vec3(matrix[0][0], matrix[1][0], matrix[2][0]);
    vec3 axisY = vec3(matrix[0][1], matrix[1][1], matrix[2][1]);
    float rowX = max(length(axisX), 1e-6);
    float rowY = max(length(axisY), 1e-6);
    float texelMetres = 2.0 * frag_info.shadow_cascades.w / rowX;
    float reach = 1.5 * 2.0 * frag_info.shadow_cascades.w *
        (abs(dot(s.n, axisX)) / (rowX * rowX) +
         abs(dot(s.n, axisY)) / (rowY * rowY));
    vec3 origin = v_world_position + s.n * (frag_info.shadow_params.z + reach);
    vec4 lightSpace = matrix * vec4(origin, 1.0);
    if (lightSpace.w <= 0.0) continue;
    vec3 candidate = lightSpace.xyz / lightSpace.w;

    // Clip space x and y are in [-1, 1]; a tile is in [0, 1] with the origin at
    // the top, matching where the render target's row zero is.
    vec2 inTile = vec2(candidate.x * 0.5 + 0.5, 0.5 - candidate.y * 0.5);
    if (inTile.x < 0.0 || inTile.x > 1.0 || inTile.y < 0.0 || inTile.y > 1.0) {
      continue;
    }
    // Depth is already in [0, 1] here, as every projection in this engine
    // produces. **Past the far plane is behind every caster, not outside the
    // map.** The last cascade's depth is fitted to the casters alone, so a
    // floor that runs on past them — the tip of a long evening shadow — sits
    // beyond it. Skipping that point called it lit and cut the shadow off
    // along the line where the far plane meets the floor. A nearer cascade
    // may still be missing casters and hands the point on; the last one
    // clamps, and 1.0 compares lit only against a texel nothing was drawn in.
    if (candidate.z > 1.0) {
      if (which < cascadeCount - 1) continue;
      candidate.z = 1.0;
    }

    // Into the atlas: the cascades sit side by side in one texture.
    uv = vec2((inTile.x + float(which)) / float(cascadeCount), inTile.y);
    projected = candidate;
    cascade = which;
    cascadeTexel = texelMetres;
    cascadeDepth =
        1.0 / max(length(vec3(matrix[0][2], matrix[1][2], matrix[2][2])), 1e-6);
    found = true;
    break;
  }
  if (!found) return 1.0;

  float bias = cascade == 0
      ? frag_info.shadow_bias.x
      : (cascade == 1 ? frag_info.shadow_bias.y : frag_info.shadow_bias.z);
  // Horizontally a texel of the atlas, vertically a texel of a tile. With one
  // cascade they are the same number and this is the kernel it has always been.
  vec2 texel = vec2(frag_info.shadow_params.x, frag_info.shadow_cascades.w);

  // **Every tap is held inside its own cascade's tile**, half a texel in from
  // the edge, and after the offset rather than before: the cube atlas learned
  // this first (`PointShadowDistance`). The cascades sit side by side, so a
  // tap that stepped past a seam read the neighbouring cascade's depth,
  // measured through another projection, and a fragment at the edge of the
  // near tile took its shadow partly from the far one. With one cascade the
  // tile is the whole texture and the clamp is the sampler's own edge.
  vec2 tileLo = vec2(float(cascade) / float(cascadeCount), 0.0) + 0.5 * texel;
  vec2 tileHi =
      vec2(float(cascade + 1) / float(cascadeCount), 1.0) - 0.5 * texel;

  // **`textureLod` and not `texture`, and the level asked for is the only one
  // there is.** Everything above this loop is a reason not to be here — the
  // cascade search returns early when no cascade contains the fragment, and the
  // light loop that calls it skips a light facing away — so a WGSL backend sees
  // a sample taken where the four invocations of a quad need not agree, and
  // refuses it: the implicit derivative `texture` asks for is only defined
  // where they all arrive. The cascade atlas is a depth render target with a
  // single level, so the derivative was never doing anything but selecting
  // level zero, and naming that level directly costs nothing and changes no
  // pixel on any backend.
  //
  // **The softness, where it rides, and what zero means.**
  //
  // `ambient_ground.w` is the directional light's apparent size. It has
  // nothing to do with ambient light and everything to do with this being the
  // one component left unspent in a block six shaders share: `frame_params.w`
  // was the slot reserved for exactly this and the environment's level count
  // took it, and appending to the block moves offsets four backends have
  // agreed on. The alternative was a second uniform block bound per draw for
  // one float. Named here because a reader arriving at `ambient_ground` has
  // every right to be surprised.
  //
  // Zero is the 3×3 kernel this has always had, which is what keeps every
  // recorded golden where it is. Above zero the edge widens with the distance
  // between the occluder and what it falls on — what a real light does, and
  // what no fixed kernel can.
  //
  // **Below zero is the `evsm` filter** (`S2`), and the texture bound here is
  // then the moments atlas rather than depth: one filtered tap replaces the
  // kernel, and how far under minus one the value sits is the light-bleeding
  // cut. A sign rather than another uniform, for the reason the softness
  // itself rides here.
  float softness = frag_info.ambient_ground.w;
  float lit = 0.0;
  if (softness < 0.0) {
    // The blur already happened, once for the whole atlas, so the one tap
    // is the filter: the sampler's own bilinear step is all it adds.
    vec4 moments = textureLod(shadow_texture, clamp(uv, tileLo, tileHi), 0.0);
    lit = EvsmVisibility(moments, projected.z - bias,
                         clamp(-softness - 1.0, 0.0, 0.95));
  } else if (softness <= 0.0) {
    // PCF 3x3. Four samples would band visibly at this map size and nine is
    // the smallest kernel that reads as a soft edge rather than as stair
    // steps.
    for (int y = -1; y <= 1; y++) {
      for (int x = -1; x <= 1; x++) {
        float occluder = textureLod(
            shadow_texture,
            clamp(uv + vec2(float(x), float(y)) * texel, tileLo, tileHi),
            0.0).r;
        lit += projected.z - bias > occluder ? 0.0 : 1.0;
      }
    }
    lit *= 1.0 / 9.0;
  } else {
    // **Find what is casting before deciding how wide to blur**, then blur by
    // what a light of this size would leave — `S3`. Sixteen taps each way on
    // a Vogel disc turned per pixel, where there were five fixed ones: the
    // turn trades the five's regular pattern for noise the eye reads as
    // grain, and a temporal resolve averages away.
    //
    // **In metres, per cascade.** The gap between the blocker and this
    // fragment is measured in the cascade's stored depth, whose unit is a
    // different length in each cascade; converted to metres, the penumbra is
    // the gap times the light's apparent diameter, and in texels it is that
    // over the cascade's own texel. A shadow keeps its softness crossing
    // from one cascade into the next.
    //
    // **A radius, so half that width.** A disc of radius R swept across an
    // edge ramps from dark to lit over 2R, so the kernel is the gap times
    // the tangent of the light's angular *radius*: the penumbra comes out the
    // full `2·tan(α)·gap` the settings promise, not twice it. The search is
    // the same cone, `tan(α)` of the way back to the light; a wider one only
    // pulls in blockers that cannot reach this fragment.
    float spread = tan(min(softness, 0.5));
    float turn = ShadowNoise() * 6.2831853;

    // As wide as the widest penumbra could be at this depth, and no wider:
    // the whole of the distance back to the light is the largest gap there
    // is.
    float searchRadius =
        clamp(spread * projected.z * cascadeDepth / cascadeTexel, 1.0, 16.0);
    float blockerSum = 0.0;
    float blockerCount = 0.0;
    for (int i = 0; i < 16; i++) {
      float occluder = textureLod(
          shadow_texture,
          clamp(uv + VogelDisc(i, 16, turn) * texel * searchRadius, tileLo,
                tileHi),
          0.0).r;
      if (projected.z - bias > occluder) {
        blockerSum += occluder;
        blockerCount += 1.0;
      }
    }
    // Nothing between this fragment and the light: lit, and no second loop.
    if (blockerCount <= 0.0) return 1.0;

    float gap = max(projected.z - blockerSum / blockerCount, 0.0) * cascadeDepth;
    // One texel at the tightest, so a contact edge stays an edge; the cap
    // keeps a distant occluder from reaching across a whole cascade.
    float radius = clamp(spread * gap / cascadeTexel, 1.0, 16.0);

    for (int i = 0; i < 16; i++) {
      float occluder = textureLod(
          shadow_texture,
          clamp(uv + VogelDisc(i, 16, turn + 1.0) * texel * radius, tileLo,
                tileHi),
          0.0).r;
      lit += projected.z - bias > occluder ? 0.0 : 1.0;
    }
    lit *= 1.0 / 16.0;
  }

  // Strength lerps towards fully lit, so the control is "how dark", not "how
  // much of the kernel".
  return mix(1.0, lit, clamp(strength, 0.0, 1.0));
}

#endif  // SHADOW_GLSL_


#ifdef F3D_LAYERED
/// What a layered material adds to metal-rough — `M1`. A block of its own
/// rather than members appended to `FragInfo`: six stages share that block and
/// none of the other five reads a layer.
layout(std140) uniform LayerInfo {
  /// rgb: `KHR_materials_specular`'s colour, linear. w: its strength.
  vec4 specular;

  /// x: the clear coat, y: its perceptual roughness, z: the index of
  /// refraction, w: unused.
  vec4 coat;

  /// rgb: `KHR_materials_sheen`'s colour, linear. w: its roughness — `M2`.
  vec4 sheen;

  /// x: `KHR_materials_anisotropy`'s strength, y and z: the cosine and sine
  /// of its rotation from the tangent. w: unused.
  vec4 anisotropy;

  /// x: `KHR_materials_transmission`, y: the volume's thickness, z: its
  /// attenuation distance, nought for a medium that takes nothing away, w:
  /// `KHR_materials_dispersion` — `M3`.
  vec4 transmission;

  /// rgb: the volume's attenuation colour, linear. w: unused.
  vec4 attenuation;

  /// x: `KHR_materials_iridescence`, y: the film's index of refraction, z:
  /// its thickness in nanometres. w: unused.
  vec4 iridescence;

  /// `KHR_texture_transform` per map — `C8`: two rows of a 2×3 matrix each,
  /// the map's coordinate being `(dot(row0.xyz, uvw), dot(row1.xyz, uvw))`
  /// with `uvw = (u, v, 1)`, in the order `kMapBaseColor` and the rest count.
  /// The identity for a map that names none, which reads the coordinate
  /// unchanged to the bit: one times `u`, plus nought twice.
  ///
  /// **Here and not baked into the vertices**, which is what an atlas export
  /// gets: one set of coordinates can carry one transform, and this is the
  /// material whose maps disagree, or whose offset a clip moves.
  vec4 uv_transform[10];

  /// The copy of the scene behind the transmissive draws — `M3`. x: how many
  /// levels the copy has, its base included, and nought outside the pass
  /// that draws them, where the environment stands in as it always did. y,
  /// z: one over the copy's width and height in texels. w: unused.
  vec4 scene_colour;

  /// Where this view sits in the copy's base level, in its texture
  /// coordinates: xy the corner, zw the size.
  vec4 scene_viewport;

  /// Each level's rectangle in the copy, in its texture coordinates: xy the
  /// corner, zw the size. The levels share one texture side by side — see
  /// `SceneColourChain`.
  vec4 scene_levels[6];

  /// The view-projection the draw was made with, turned to the rows of the
  /// framebuffer as every screen-space pass turns it.
  mat4 scene_view_projection;
}
layer_info;

vec2 MapUv(int slot) {
  vec3 uvw = vec3(v_texcoord, 1.0);
  return vec2(dot(layer_info.uv_transform[slot * 2].xyz, uvw),
              dot(layer_info.uv_transform[slot * 2 + 1].xyz, uvw));
}

vec4 MapMatrix(int slot) {
  vec4 u = layer_info.uv_transform[slot * 2];
  vec4 v = layer_info.uv_transform[slot * 2 + 1];
  return vec4(u.x, u.y, v.x, v.y);
}

/// The coat map: r the clear coat, g its roughness, b the transmission and a
/// the thickness, each multiplying its factor — `M3` reads b and a. White when a
/// material has none. One texture where glTF gives up to four, because the
/// lit stages have two samplers left under WebGL2's sixteen.
uniform sampler2D coat_texture;

/// The sheen map — `M2`: rgb the sheen colour, sRGB as authored, and a its
/// roughness, each multiplying its factor. White when a material has none.
uniform sampler2D sheen_texture;

/// The scene as it stood before the transmissive draws, every level of it in
/// one texture — `M3`. Black, and never read, outside the pass that draws
/// them. The stage's sixteenth sampler, and the last WebGL2 promises.
uniform sampler2D scene_colour_texture;

/// The layers at this fragment, resolved once by [ReadLayers] and read by
/// every light: the dielectric's reflectance head-on and at grazing, and the
/// coat — how much, how rough, which way it faces, and what it leaves of what
/// is under it.
vec3 g_f0_dielectric = vec3(0.04);
float g_f90 = 1.0;
float g_coat = 0.0;
float g_coat_roughness = 0.02;
vec3 g_coat_n = vec3(0.0, 0.0, 1.0);
float g_coat_n_dot_v = 1.0;
float g_coat_through = 1.0;

/// The sheen — `M2`: its colour and roughness, and what its albedo leaves of
/// the layer beneath. And the anisotropy: how strong, and the frame the
/// highlight stretches along, on the normal the maps leave.
vec3 g_sheen = vec3(0.0);
float g_sheen_roughness = 0.07;
float g_sheen_albedo = 0.0;
float g_sheen_scale = 1.0;
float g_aniso = 0.0;
vec3 g_aniso_t = vec3(1.0, 0.0, 0.0);
vec3 g_aniso_b = vec3(0.0, 1.0, 0.0);

/// The transmission — `M3`: how much passes through, how thick the medium
/// is, and what of each colour survives that thickness. And the thin film:
/// how much, and the Fresnel its interference gives at this view.
float g_transmission = 0.0;
float g_thickness = 0.0;
vec3 g_transmittance = vec3(1.0);
float g_iridescence = 0.0;
vec3 g_irid_fresnel = vec3(0.04);

/// Whether the index is `KHR_materials_ior`'s nought: the value its
/// specular-glossiness migration writes, which means an index of infinity —
/// a Fresnel of one at every angle, and no dispersion.
bool IorInfinite() { return layer_info.coat.z == 0.0; }

/// The index the refraction bends by. Infinity is stood in for by an index
/// so large that the ray leaves along the normal, which is where an infinite
/// one sends it; anything else below one is held at one.
float RefractionIor() {
  return IorInfinite() ? 1.0e4 : max(layer_info.coat.z, 1.0);
}

/// How far the dispersion spreads the index over red and blue; nothing at an
/// infinite index, which the extension says dispersion leaves alone.
float DispersionSpread(float ior) {
  return IorInfinite() ? 0.0 : (ior - 1.0) * 0.025 * layer_info.transmission.w;
}

/// Fills the globals above from the block and the coat map.
///
/// Called before the normal map bends `s.n`, because the coat is lit on the
/// geometric normal: a lacquer over a bumpy base is smooth, and that is what
/// makes car paint read as car paint.
void ReadLayers(Surface s) {
  vec4 coatTexel = texture(coat_texture, v_texcoord, MaterialLodBias());
  vec4 sheenTexel = texture(sheen_texture, v_texcoord, MaterialLodBias());
  g_sheen = layer_info.sheen.rgb * SrgbToLinear(sheenTexel.rgb);
  // Floored where the Charlie lobe's exponent would outgrow a half float,
  // which is also where `tool/make_tables.dart` floors its albedo.
  g_sheen_roughness = clamp(layer_info.sheen.w * sheenTexel.a, 0.07, 1.0);
  // `KHR_materials_ior` and `KHR_materials_specular`: the reflectance a
  // dielectric of this index has head-on, tinted and scaled, and the
  // strength alone at grazing. 1.5, white and one give 0.04 and 1 — plain
  // metal-rough. An index of nought is infinity, whose reflectance is one
  // head-on as at grazing, so the tint and the strength are all that is left.
  float ior = max(layer_info.coat.z, 1.0);
  float r = IorInfinite() ? 1.0 : (ior - 1.0) / (ior + 1.0);
  g_f0_dielectric =
      min(vec3(r * r) * layer_info.specular.rgb, vec3(1.0)) *
      layer_info.specular.w;
  g_f90 = layer_info.specular.w;
  g_coat = clamp(layer_info.coat.x * coatTexel.r, 0.0, 1.0);
  g_coat_roughness = clamp(layer_info.coat.y * coatTexel.g, 0.02, 1.0);
  // `M3`: the coat map's other two lanes.
  g_transmission = clamp(layer_info.transmission.x * coatTexel.b, 0.0, 1.0);
  g_thickness = max(layer_info.transmission.y * coatTexel.a, 0.0);
  // Beer's law over the thickness: what is left of each colour after the
  // attenuation distance is the attenuation colour.
  float distance = layer_info.transmission.z;
  g_transmittance =
      distance > 0.0
          ? pow(max(layer_info.attenuation.rgb, vec3(1e-4)),
                vec3(g_thickness / distance))
          : vec3(1.0);
  g_iridescence = clamp(layer_info.iridescence.x, 0.0, 1.0);
  g_coat_n = s.n;
  g_coat_n_dot_v = max(dot(s.n, s.v), 1e-4);
  // The coat is a dielectric of index 1.5, and what it reflects towards the
  // eye does not reach the layer under it: everything beneath is scaled by
  // what its Fresnel lets through.
  float fc = 0.04 + 0.96 * pow(1.0 - g_coat_n_dot_v, 5.0);
  g_coat_through = 1.0 - g_coat * fc;
}

/// The half of the layers that depends on the normal the maps leave: the
/// sheen's albedo at this view, and the anisotropy's frame. Called after
/// the maps, before any light.
void ReadLayersOnMaps(Surface s) {
  // `M2`: the sheen's directional albedo, from the LTC table's spare lane,
  // and what it leaves of everything under the sheen.
  g_sheen_albedo =
      textureLod(ltc_texture,
                 LtcUv(g_sheen_roughness,
                       sqrt(clamp(1.0 - s.n_dot_v, 0.0, 1.0)), 1.0),
                 0.0)
          .z;
  g_sheen_scale =
      1.0 - max(max(g_sheen.r, g_sheen.g), g_sheen.b) * g_sheen_albedo;

  // The tangent frame `ApplyNormalMap` builds, on the normal it left, turned
  // by the rotation. A surface without a usable tangent stays isotropic.
  vec3 t = v_tangent.xyz - s.n * dot(s.n, v_tangent.xyz);
  bool usable = dot(t, t) > 1e-12;
  t = usable ? normalize(t) : vec3(1.0, 0.0, 0.0);
  vec3 b = cross(s.n, t) * v_tangent.w;
  if (!gl_FrontFacing) t = -t;
  vec2 turn = layer_info.anisotropy.yz;
  vec3 along = t * turn.x + b * turn.y;
  g_aniso = usable && dot(along, along) > 1e-12
                ? clamp(layer_info.anisotropy.x, 0.0, 1.0)
                : 0.0;
  g_aniso_t = g_aniso > 0.0 ? normalize(along) : t;
  g_aniso_b = cross(s.n, g_aniso_t);
}
#endif  // F3D_LAYERED

/// The environment, convolved by roughness: level zero is a mirror and the last
/// is rough enough to stand in for irradiance. Built by `EnvironmentMap`.
uniform samplerCube environment_texture;

/// The split-sum BRDF: the scale and bias to apply to F0, whose sum is the
/// lobe's directional albedo `Ess`.
///
/// **Read from the LTC table, not fitted.** Its second half already holds,
/// in x and y, the GGX lobe with height-correlated Smith — the lobe
/// [ShadeLight] evaluates — integrated over the hemisphere at this
/// roughness and view, plain and weighted by Schlick's `(1 − v·h)⁵`: the
/// scale is their difference and the bias the second. An analytic fit stood
/// here before, made against another BRDF; it was a sixth dark head-on at
/// mid roughness and turned the falloff of a rough metal upside down, and
/// the energy compensation that divides by its sum inherited both.
vec2 EnvBrdf(float roughness, float n_dot_v) {
  vec2 dfg = textureLod(ltc_texture,
                        LtcUv(clamp(roughness, 0.0, 1.0),
                              sqrt(clamp(1.0 - n_dot_v, 0.0, 1.0)), 1.0),
                        0.0)
                 .xy;
  return vec2(dfg.x - dfg.y, dfg.y);
}

/// The least perceptual roughness the GGX lobe is evaluated at, above the
/// surface's own floor. At 0.045 alpha² is 4·10⁻⁶, which keeps the peak of
/// [D_GGX] representable and its denominator, which is never below alpha²,
/// clear of the guard that stops a division by nought; below it the guard
/// cut the peak and a mirror's highlight lost most of its light.
const float kMinGgxRoughness = 0.045;

float D_GGX(float n_dot_h, float alpha) {
  float a = n_dot_h * alpha;
  float k = alpha / max(1.0 - n_dot_h * n_dot_h + a * a, 1e-7);
  return k * k * (1.0 / kPi);
}

float V_SmithGGXCorrelated(float n_dot_v, float n_dot_l, float alpha) {
  float a2 = alpha * alpha;
  float lambda_v = n_dot_l * sqrt(n_dot_v * n_dot_v * (1.0 - a2) + a2);
  float lambda_l = n_dot_v * sqrt(n_dot_l * n_dot_l * (1.0 - a2) + a2);
  return 0.5 / max(lambda_v + lambda_l, 1e-5);
}

vec3 F_Schlick(vec3 f0, float v_dot_h) {
  float f = pow(1.0 - v_dot_h, 5.0);
  return f0 + (vec3(1.0) - f0) * f;
}

#ifdef F3D_LAYERED
/// [F_Schlick] towards [f90] rather than towards one at grazing — what
/// `KHR_materials_specular`'s strength scales.
vec3 F_SchlickF90(vec3 f0, vec3 f90, float v_dot_h) {
  float f = pow(1.0 - v_dot_h, 5.0);
  return f0 + (f90 - f0) * f;
}

/// The clear coat's own GGX lobe for [light], on the coat's normal, with the
/// Fresnel of a dielectric of index 1.5. Scaled so that the loop's `n_dot_l`,
/// which is the base's, becomes the coat's: the coat faces the geometric
/// normal and the base may face the normal map's.
///
/// **A rectangle's coat is integrated over the panel, as the base's is** —
/// `L7`. Evaluated at the representative point instead, the lobe's peak
/// multiplied the panel's whole form factor: wherever the mirror ray lands on
/// the panel the half vector is the normal, and a coat as smooth as a
/// varnish has a peak in the tens of thousands, so the panel's reflection
/// came out thousands of times brighter than the few per cent a coat
/// reflects. The same tables at the coat's roughness, on the coat's normal,
/// over the corners `SampleLight` kept; over `n_dot_l` for the base's reason.
float CoatLobe(Surface s, LightSample light) {
  if (light.integrated > 0.5) {
    vec3 ltc = LtcRectangle(g_coat_n, s.v, g_coat_roughness, g_rect_corners);
    return ltc.x * (0.04 * ltc.y + 0.96 * ltc.z) * frag_info.material.w /
           max(light.n_dot_l, 1e-6);
  }
  float lobe = max(g_coat_roughness, kMinGgxRoughness);
  float alpha = lobe * lobe;
  float n_dot_l = max(dot(g_coat_n, light.l), 0.0);
  float n_dot_h = max(dot(g_coat_n, light.h), 0.0);
  float d = D_GGX(n_dot_h, alpha);
  float vis = V_SmithGGXCorrelated(g_coat_n_dot_v, n_dot_l, alpha);
  float f = 0.04 + 0.96 * pow(1.0 - light.v_dot_h, 5.0);
  return d * vis * f * frag_info.material.w * n_dot_l /
         max(light.n_dot_l, 1e-6);
}

/// The Charlie sheen distribution, Estevez and Kulla's, with Filament's
/// floor on `sin²θ` so the power stays inside a half float.
float D_Charlie(float roughness, float n_dot_h) {
  float inv_alpha = 1.0 / (roughness * roughness);
  float sin2h = max(1.0 - n_dot_h * n_dot_h, 0.0078125);
  return (2.0 + inv_alpha) * pow(sin2h, inv_alpha * 0.5) / (2.0 * kPi);
}

/// What a thin film's interference does to the colours it reflects, at an
/// optical path difference [opd] in nanometres and a phase [shift]: the
/// spectral sensitivity of the eye, as Gaussians in XYZ, taken to linear
/// Rec. 709. Belcour and Barla, "A Practical Extension to Microfacet Theory
/// for the Modeling of Varying Iridescence", 2017, with the constants the
/// glTF sample viewer uses.
vec3 IridescenceSensitivity(float opd, vec3 shift) {
  float phase = 2.0 * kPi * opd * 1.0e-9;
  vec3 val = vec3(5.4856e-13, 4.4201e-13, 5.2481e-13);
  vec3 pos = vec3(1.6810e+06, 1.7953e+06, 2.2084e+06);
  vec3 variance = vec3(4.3278e+09, 9.3046e+09, 6.6121e+09);
  vec3 xyz = val * sqrt(2.0 * kPi * variance) * cos(pos * phase + shift) *
             exp(-(phase * phase) * variance);
  xyz.x += 9.7470e-14 * sqrt(2.0 * kPi * 4.5282e+09) *
           cos(2.2399e+06 * phase + shift.x) *
           exp(-4.5282e+09 * phase * phase);
  xyz /= 1.0685e-7;
  return mat3(3.2404542, -0.9692660, 0.0556434, -1.5371385, 1.8760108,
              -0.2040259, -0.4985314, 0.0415560, 1.0572252) *
         xyz;
}

/// The reflectance of a film of index [film] and [thickness] nanometres over
/// a base of reflectance [base], seen at [cos1] — the two-bounce Airy sum of
/// Belcour and Barla. Total internal reflection inside the film reflects
/// everything, chosen at the end rather than returned early.
vec3 FresnelIridescence(float film, float cos1, float thickness, vec3 base) {
  // A film thinning to nothing fades to the base, not to a step.
  float eta2 = mix(1.0, film, smoothstep(0.0, 0.03, thickness));
  float sin2Sq = (1.0 - cos1 * cos1) / (eta2 * eta2);
  float cos2Sq = 1.0 - sin2Sq;
  float cos2 = sqrt(max(cos2Sq, 0.0));

  float r0 = (eta2 - 1.0) / (eta2 + 1.0);
  float r12 = r0 * r0 + (1.0 - r0 * r0) * pow(1.0 - cos1, 5.0);
  float t121 = 1.0 - r12;
  float phi12 = eta2 < 1.0 ? kPi : 0.0;
  float phi21 = kPi - phi12;

  vec3 sqrtBase = sqrt(clamp(base, vec3(0.0), vec3(0.9999)));
  vec3 baseIor = (vec3(1.0) + sqrtBase) / (vec3(1.0) - sqrtBase);
  vec3 r1 = (baseIor - vec3(eta2)) / (baseIor + vec3(eta2));
  r1 *= r1;
  vec3 r23 = r1 + (vec3(1.0) - r1) * pow(1.0 - cos2, 5.0);
  vec3 phi23 = vec3(baseIor.x < eta2 ? kPi : 0.0, baseIor.y < eta2 ? kPi : 0.0,
                    baseIor.z < eta2 ? kPi : 0.0);

  float opd = 2.0 * eta2 * thickness * cos2;
  vec3 phi = vec3(phi21) + phi23;
  vec3 r123 = clamp(r12 * r23, vec3(1e-5), vec3(0.9999));
  vec3 rootR123 = sqrt(r123);
  vec3 rs = t121 * t121 * r23 / (vec3(1.0) - r123);
  vec3 total = vec3(r12) + rs;
  vec3 cm = rs - vec3(t121);
  for (int m = 1; m <= 2; m++) {
    cm *= rootR123;
    total += cm * 2.0 * IridescenceSensitivity(float(m) * opd, float(m) * phi);
  }
  return cos2Sq < 0.0 ? vec3(1.0) : max(total, vec3(0.0));
}

/// Fills the thin film's Fresnel for this fragment, on the base reflectance
/// the maps left. Called after `ReadLayersOnMaps`.
void ReadIridescence(Surface s) {
  vec3 f0 = mix(g_f0_dielectric, s.albedo, clamp(s.metallic, 0.0, 1.0));
  g_irid_fresnel = FresnelIridescence(layer_info.iridescence.y, s.n_dot_v,
                                      layer_info.iridescence.z, f0);
}

/// The environment seen through the surface — `M3`.
///
/// **The environment, where there is no scene to read.** What passes through
/// glass is read from the cube the reflections read, bent by the index when
/// the material has a volume and straight through when it is thin-walled, as
/// the volume extension distinguishes them. The objects behind the glass are
/// not in that cube; [SceneBehind] reads them instead wherever the frame made
/// a copy of the scene, and this is what a draw outside that pass — a probe's
/// capture, the view model — still sees. Dispersion spreads the index over
/// red, green and blue and reads each on its own ray.
vec3 TransmittedRadiance(Surface s, float levels) {
  float ior = RefractionIor();
  float spread = DispersionSpread(ior);
  // A rough glass blurs what is behind it more the denser it is.
  float lod = s.roughness * clamp(ior * 2.0 - 2.0, 0.0, 1.0) * levels;
  bool thin = g_thickness <= 0.0;
  vec3 red = thin ? -s.v : refract(-s.v, s.n, 1.0 / max(ior - spread, 1.0));
  vec3 green = thin ? -s.v : refract(-s.v, s.n, 1.0 / ior);
  vec3 blue = thin ? -s.v : refract(-s.v, s.n, 1.0 / (ior + spread));
  return vec3(textureLod(environment_texture, red, lod).r,
              textureLod(environment_texture, green, lod).g,
              textureLod(environment_texture, blue, lod).b);
}

/// Whether this draw has the copy of the scene to read — `M3`.
bool SceneColourBound() { return layer_info.scene_colour.x > 0.0; }

/// Level [level] of the copy at [uv], a coordinate of the picture as a whole.
/// Held half a texel inside the level's rectangle, so a bilinear tap never
/// reaches the level beside it in the same texture.
vec3 SceneColourLevel(vec2 uv, int level) {
  vec4 rect = layer_info.scene_levels[level];
  vec2 inset = 0.5 * layer_info.scene_colour.yz;
  vec2 at = rect.xy + clamp(uv * rect.zw, inset, max(rect.zw - inset, inset));
  return textureLod(scene_colour_texture, at, 0.0).rgb;
}

/// The copy where [world] lands on the screen, blurred to level [lod] and
/// blended between the two levels either side of it, as a trilinear sampler
/// would. Held inside this view, so a ray bent past its edge reads the edge
/// rather than the view beside it.
vec3 SceneColourAt(vec3 world, float lod) {
  vec4 clip = layer_info.scene_view_projection * vec4(world, 1.0);
  vec2 ndc = clip.xy / max(clip.w, 1e-6);
  vec2 view = clamp(vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5), vec2(0.0),
                    vec2(1.0));
  vec2 uv = layer_info.scene_viewport.xy + view * layer_info.scene_viewport.zw;
  float top = layer_info.scene_colour.x - 1.0;
  float level = clamp(lod, 0.0, top);
  float lower = floor(level);
  vec3 near = SceneColourLevel(uv, int(lower));
  vec3 far = SceneColourLevel(uv, int(min(lower + 1.0, top)));
  return mix(near, far, level - lower);
}

/// The scene seen through the surface — `M3`: where the ray the index bends
/// leaves the far side of the volume, as the copy made before this pass holds
/// it, at a level chosen by the roughness. A thin wall bends nothing and
/// reads what lies straight behind it.
///
/// **The level is log2 of the base width times the roughness**, the way the
/// glTF sample renderer reads its transmission target, and not the roughness
/// times the chain's own length. Each level is a box average of the scene,
/// as a mip level is, so level k is a blur of 2^k texels however long the
/// chain is, and the halvings a width has are what take roughness 1 to a
/// single texel. Scaled by the chain instead, a 0.5-rough glass at an index
/// of 1.5 read level 2.5 where it should read 5, a blur of about six texels
/// against thirty-two, and frosted glass looked nearly clear. The chain
/// still ends at `SceneColourChain.maxLevels`, where [SceneColourAt] clamps,
/// so at a thousand texels wide anything rougher than about half reads its
/// last level.
///
/// The width is the base level's, in texels: its rectangle's width over one
/// texel of the texture, which is the size of the scene the chain was copied
/// from.
///
/// **The thickness is in world units as authored.** glTF measures it in the
/// mesh's own space; a node scaled up or down refracts as if it were not,
/// because the stage has no model matrix to scale it by.
vec3 SceneBehind(Surface s) {
  float ior = RefractionIor();
  float spread = DispersionSpread(ior);
  float width =
      layer_info.scene_levels[0].z / max(layer_info.scene_colour.y, 1e-9);
  float lod = log2(max(width, 1.0)) * s.roughness *
              clamp(ior * 2.0 - 2.0, 0.0, 1.0);
  bool thin = g_thickness <= 0.0;
  vec3 red = thin ? -s.v : refract(-s.v, s.n, 1.0 / max(ior - spread, 1.0));
  vec3 green = thin ? -s.v : refract(-s.v, s.n, 1.0 / ior);
  vec3 blue = thin ? -s.v : refract(-s.v, s.n, 1.0 / (ior + spread));
  return vec3(SceneColourAt(v_world_position + red * g_thickness, lod).r,
              SceneColourAt(v_world_position + green * g_thickness, lod).g,
              SceneColourAt(v_world_position + blue * g_thickness, lod).b);
}

/// Neubelt and Pettineo's visibility for cloth.
float V_Neubelt(float n_dot_v, float n_dot_l) {
  return 1.0 / (4.0 * (n_dot_l + n_dot_v - n_dot_l * n_dot_v));
}

/// GGX stretched along [t] — `KHR_materials_anisotropy`, the form its
/// specification gives: [at] the roughness along the tangent, [ab] across.
float D_GGXAnisotropic(float n_dot_h, float t_dot_h, float b_dot_h, float at,
                       float ab) {
  float a2 = at * ab;
  vec3 f = vec3(ab * t_dot_h, at * b_dot_h, a2 * n_dot_h);
  float w2 = a2 / max(dot(f, f), 1e-12);
  return a2 * w2 * w2 / kPi;
}

float V_GGXAnisotropic(float n_dot_l, float n_dot_v, float b_dot_v,
                       float t_dot_v, float t_dot_l, float b_dot_l, float at,
                       float ab) {
  float ggx_v = n_dot_l * length(vec3(at * t_dot_v, ab * b_dot_v, n_dot_v));
  float ggx_l = n_dot_v * length(vec3(at * t_dot_l, ab * b_dot_l, n_dot_l));
  return clamp(0.5 / max(ggx_v + ggx_l, 1e-5), 0.0, 1.0);
}
#endif  // F3D_LAYERED

float LightVisibility(Surface s, LightSample light, int index) {
  return ShadowFactor(s, light, index);
}

/// Whether the energy lost to single scattering is put back — `L1`,
/// `RenderSettings.energyCompensation`, in `FragInfo.target_origin.z`.
bool EnergyCompensation() { return frag_info.target_origin.z > 0.5; }

/// The light GGX loses on a rough metal, returned as the factor its single
/// scattering has to be multiplied by: one plus f0 times the share of the
/// hemisphere the single-scattering albedo misses. Fdez-Agüera's term, with
/// the albedo the split sum already reads — the albedo of the very lobe it
/// scales, at the roughness [ShadeLight] evaluates it at, or the white
/// furnace would not come back white.
vec3 MultiscatterScale(vec3 f0, Surface s) {
  vec2 ab = EnvBrdf(max(s.roughness, kMinGgxRoughness), s.n_dot_v);
  float ess = max(ab.x + ab.y, 1e-4);
  return vec3(1.0) + f0 * (1.0 / ess - 1.0);
}

/// Whether the diffuse lobe is EON rather than Lambert — `L8`,
/// `RenderSettings.diffuseModel`, in `FragInfo.ambient_sky.w`.
bool EonDiffuse() { return frag_info.ambient_sky.w > 0.5; }

/// The two constants of the Fujii Oren–Nayar lobe EON is built on:
/// `1/2 − 2/(3π)`, which normalises its A term, and `2/3 − 28/(15π)`, which
/// with it gives the lobe's albedo averaged over the hemisphere.
const float kFonA = 0.5 - 2.0 / (3.0 * kPi);
const float kFonAverage = 2.0 / 3.0 - 28.0 / (15.0 * kPi);

/// The Fujii Oren–Nayar lobe's directional albedo at a cosine [mu] and
/// roughness [r]: Portsmouth, Kutz and Hill's quartic fit of the exact
/// integral, which trades an `acos` and a division by [mu] for four
/// multiply-adds.
float FonAlbedo(float mu, float r) {
  float m = 1.0 - mu;
  float g = m * (0.0571085289 +
                 m * (0.491881867 + m * (-0.332181442 + m * 0.0714429953)));
  return (1.0 + r * g) / (1.0 + kFonA * r);
}

/// The single-scattering lobe's albedo averaged over the hemisphere.
float FonAverage(float r) {
  return (1.0 + kFonAverage * r) / (1.0 + kFonA * r);
}

/// The albedo the light bouncing between the facets comes back with: one
/// more factor of [rho] per bounce, summed. This is what saturates a rough
/// colour, and what makes a white surface keep every bit of the light.
vec3 EonMultiAlbedo(vec3 rho, float average) {
  return rho * rho * average / (vec3(1.0) - rho * (1.0 - average));
}

/// EON, "An energy-preserving Oren–Nayar model", Portsmouth, Kutz and Hill,
/// 2024: the Fujii Oren–Nayar lobe for one bounce off the facets, plus a
/// lobe shaped by what that one misses at each end for the rest. [rho] the
/// diffuse colour, [r] the roughness, [mu_i] and [mu_o] the cosines to the
/// light and to the eye, [l_dot_v] the cosine between them. Divided by π,
/// as `diffuseColor / kPi` is, so it stands in for it.
vec3 EonLobe(vec3 rho, float r, float mu_i, float mu_o, float l_dot_v) {
  // Oren–Nayar's `s / t`: how far the light and the eye stand on the same
  // side of the normal, which is where the facets turned to both are seen.
  float s = l_dot_v - mu_i * mu_o;
  float s_over_t = s > 0.0 ? s / max(mu_i, mu_o) : s;
  float af = 1.0 / (1.0 + kFonA * r);
  vec3 single = rho * (af * (1.0 + r * s_over_t));
  float average = FonAverage(r);
  vec3 multi = EonMultiAlbedo(rho, average) *
               (max(1.0 - FonAlbedo(mu_o, r), 1e-7) *
                max(1.0 - FonAlbedo(mu_i, r), 1e-7) /
                max(1.0 - average, 1e-7));
  return (single + multi) / kPi;
}

/// [EonLobe] integrated over the hemisphere of light at a cosine [mu] to the
/// eye: what it reflects of light that comes from everywhere alike, as an
/// ambient, an environment's irradiance and a lightmap are taken to.
vec3 EonAlbedo(vec3 rho, float r, float mu) {
  float e = FonAlbedo(mu, r);
  return rho * e + EonMultiAlbedo(rho, FonAverage(r)) * (1.0 - e);
}

vec3 ShadeLight(Surface s, LightSample light) {
  // Perceptual roughness is squared to get the GGX alpha; this is what makes
  // the roughness slider feel linear. Held at the lobe's own floor, which
  // sits above the surface's.
  float lobe = max(s.roughness, kMinGgxRoughness);
  float alpha = lobe * lobe;

  // Dielectrics reflect ~4% at normal incidence; metals tint the reflection
  // with their own albedo and have no diffuse response.
#ifdef F3D_LAYERED
  // The dielectric's own reflectance, from its index and specular layer.
  vec3 f0 = mix(g_f0_dielectric, s.albedo, s.metallic);
  vec3 f90 = vec3(mix(g_f90, 1.0, s.metallic));
#else
  vec3 f0 = mix(vec3(0.04), s.albedo, s.metallic);
#endif
  vec3 diffuseColor = s.albedo * (1.0 - s.metallic);

  float d = D_GGX(light.n_dot_h, alpha);
  float vis = V_SmithGGXCorrelated(s.n_dot_v, light.n_dot_l, alpha);
#ifdef F3D_LAYERED
  if (g_aniso > 0.0) {
    // `M2`: the lobe stretched along the tangent, as far as the strength
    // says; across it, the roughness as it was.
    float at = mix(alpha, 1.0, g_aniso * g_aniso);
    float ab = max(alpha, 1e-3);
    d = D_GGXAnisotropic(light.n_dot_h, dot(g_aniso_t, light.h),
                         dot(g_aniso_b, light.h), at, ab);
    vis = V_GGXAnisotropic(light.n_dot_l, s.n_dot_v, dot(g_aniso_b, s.v),
                           dot(g_aniso_t, s.v), dot(g_aniso_t, light.l),
                           dot(g_aniso_b, light.l), at, ab);
  }
  vec3 f = F_SchlickF90(f0, f90, light.v_dot_h);
  // `M3`: the thin film's colours in place of the plain Fresnel.
  f = mix(f, g_irid_fresnel, g_iridescence);
#else
  vec3 f = F_Schlick(f0, light.v_dot_h);
#endif

  vec3 specular = d * vis * f * frag_info.material.w;
  if (light.integrated > 0.5) {
    // `L7`: the lobe already integrated over the rectangle, with the fit's
    // own Fresnel. Divided by `n_dot_l` because the loop multiplies by it,
    // and that is the diffuse form factor, not a term of this; the `kPi` the
    // return applies is the same calibration the diffuse gets.
#ifdef F3D_LAYERED
    specular = light.ltc.x * (f0 * light.ltc.y + (f90 - f0) * light.ltc.z) *
               frag_info.material.w / max(light.n_dot_l, 1e-6);
#else
    specular = light.ltc.x * (f0 * light.ltc.y + (1.0 - f0) * light.ltc.z) *
               frag_info.material.w / max(light.n_dot_l, 1e-6);
#endif
  }
  if (EnergyCompensation()) specular *= MultiscatterScale(f0, s);
  // Energy left over after reflection is what scatters diffusely.
  vec3 diffuse = diffuseColor * (vec3(1.0) - f) / kPi;
  if (EonDiffuse()) {
    // `L8`: on the direction to the light rather than `n_dot_l`, which for
    // a rectangle is a form factor and not a cosine.
    diffuse = EonLobe(diffuseColor, s.roughness,
                      clamp(dot(s.n, light.l), 1e-4, 1.0), s.n_dot_v,
                      dot(light.l, s.v)) *
              (vec3(1.0) - f);
  }
#ifdef F3D_LAYERED
  // `M3`: what passes through is not scattered back; a light on the viewer's
  // side reaches the eye through transmission only by the environment.
  diffuse *= 1.0 - g_transmission;
#endif

  // The pi puts the result back on the scale the tone mapper and the exposure
  // default were calibrated against.
#ifdef F3D_LAYERED
  // Under the sheen, what its albedo leaves; under the coat, what its
  // Fresnel lets through; on top, the coat's own lobe. The sheen's
  // visibility takes a cosine, which `n_dot_l` is not under a rectangle.
  vec3 sheen = g_sheen * D_Charlie(g_sheen_roughness, light.n_dot_h) *
               V_Neubelt(s.n_dot_v, clamp(dot(s.n, light.l), 0.0, 1.0));
  return (((diffuse + specular) * g_sheen_scale + sheen) * g_coat_through +
          vec3(g_coat * CoatLobe(s, light))) *
         kPi;
#else
  return (diffuse + specular) * kPi;
#endif
}

void main() {
  Surface s = ReadSurface();
#ifdef F3D_LAYERED
  ReadLayers(s);
#endif
  ApplyCommonMaps(s);
  ApplyMetallicRoughnessMap(s);
#ifdef F3D_LAYERED
  ReadLayersOnMaps(s);
  ReadIridescence(s);
#endif

  float metallic = clamp(s.metallic, 0.0, 1.0);
  vec3 diffuseColor = s.albedo * (1.0 - metallic);
  // `L8`: light that arrives from everywhere alike — the flat ambient, the
  // environment's irradiance, a lightmap, and under glass what passes
  // through — is reflected by the EON lobe's albedo at this view rather than
  // by the colour itself.
  if (EonDiffuse()) {
    diffuseColor = EonAlbedo(diffuseColor, s.roughness, s.n_dot_v);
  }

  // Ambient occlusion darkens indirect light. It is applied to the direct term
  // too, which is not physical, but with no environment the flat ambient is far
  // too weak for an occlusion map to be visible otherwise.
  vec3 ambient = diffuseColor * s.ambient * s.occlusion;
#ifdef F3D_LAYERED
  // `M3`: without an environment the light passing through is the flat
  // ambient too, less what the medium takes — unless the scene behind is
  // there to be read, when that share is the scene instead (below).
  ambient *= mix(vec3(1.0), SceneColourBound() ? vec3(0.0) : g_transmittance,
                 g_transmission);
#endif

  float levels = frag_info.frame_params.w;
#ifdef F3D_LAYERED
  // What the coat reflects of the environment; nothing without one, since
  // the flat ambient has no specular part for it to have. The sheen's
  // incoming light, which without an environment is the flat ambient.
  vec3 coatAmbient = vec3(0.0);
  vec3 sheenIncoming = s.ambient;
#endif
  if (levels > 0.0) {
    // **This is the term that made metal black.** A metal has no diffuse
    // response at all, so with nothing to reflect it was lit by direct light
    // alone and read as very nearly unlit — which is why the games reached for
    // dark dielectrics wherever they wanted gunmetal.
#ifdef F3D_LAYERED
    vec3 f0 = mix(g_f0_dielectric, s.albedo, metallic);
    float f90 = mix(g_f90, 1.0, metallic);
    // `M2`: an anisotropic surface reflects along a normal bent towards the
    // stretch, the specification's own approximation.
    vec3 bent = s.n;
    if (g_aniso > 0.0) {
      vec3 across = cross(g_aniso_t, s.v);
      vec3 anisoN = cross(across, g_aniso_t);
      float bend = 1.0 - g_aniso * (1.0 - s.roughness);
      float bend4 = bend * bend * bend * bend;
      bent = normalize(mix(anisoN, s.n, bend4));
    }
    vec3 reflected = reflect(-s.v, bent);
#else
    vec3 f0 = mix(vec3(0.04), s.albedo, metallic);
    vec3 reflected = reflect(-s.v, s.n);
#endif

    // The roughest level stands in for irradiance. Not a true Lambert
    // convolution — see `EnvironmentMap.diffuseLevel`, which says the same
    // thing from the other side and states what it costs.
    vec3 irradiance = textureLod(environment_texture, s.n, levels).rgb;
    vec3 prefiltered =
        textureLod(environment_texture, reflected, s.roughness * levels).rgb;
    vec2 ab = EnvBrdf(s.roughness, s.n_dot_v);

    // Scaled by the strength in the slot the flat term above reads, which is
    // why the two are interchangeable rather than additive: whichever term
    // runs, it runs at `material.z`. **What that number is depends on what is
    // bound.** A scene's own environment is scaled by `Scene.ambientIntensity`,
    // the same knob the flat term uses, so a scene that dials its indirect
    // light down dials both; a reflection probe brings its own
    // `ReflectionProbeNode.intensity` instead, because a probe is the room's
    // light already measured. The renderer decides which — see `_encodeNode`
    // in renderer_mesh_encode.dart — and this stage cannot tell them apart.
    // The surface's single-scatter albedo, thin film included: the specular
    // term and the multiscatter term below both read this one value, so an
    // iridescent surface tints the light it scatters twice as it tints the
    // light it scatters once.
#ifdef F3D_LAYERED
    vec3 single = mix(f0, g_irid_fresnel, g_iridescence) * ab.x + f90 * ab.y;
#else
    vec3 single = f0 * ab.x + ab.y;
#endif
    vec3 specular = prefiltered * single;
    if (EnergyCompensation()) {
      // Fdez-Agüera: the single-scattered part as it was, and the multiple
      // scattering it misses added from the irradiance, tinted by the average
      // Fresnel — `L1`.
      float missed = 1.0 - (ab.x + ab.y);
      vec3 average = f0 + (vec3(1.0) - f0) / 21.0;
      vec3 multiple = single * average / (vec3(1.0) - missed * average);
      specular += multiple * missed * irradiance;
    }
    ambient = (diffuseColor * irradiance + specular) * frag_info.material.z *
              s.occlusion;
#ifdef F3D_LAYERED
    // `M3`: the transmitted share of the diffuse light is the environment
    // behind the surface instead, less what the dielectric reflects and what
    // the medium takes, tinted by the base colour as glTF tints it.
    // With the scene behind to read, the environment's share is taken away
    // and the scene's added below.
    vec3 reflects =
        mix(g_f0_dielectric, g_irid_fresnel, g_iridescence) * ab.x + g_f90 * ab.y;
    vec3 through = SceneColourBound()
                       ? vec3(0.0)
                       : TransmittedRadiance(s, levels) * g_transmittance *
                             (vec3(1.0) - min(reflects, vec3(1.0)));
    ambient += diffuseColor * (through - irradiance) * g_transmission *
               frag_info.material.z * s.occlusion;
    // The coat reflects the environment too, on its own normal and at its
    // own roughness, over what it lets through of the layer beneath.
    vec3 coatPrefiltered = textureLod(environment_texture,
                                      reflect(-s.v, g_coat_n),
                                      g_coat_roughness * levels)
                               .rgb;
    vec2 coatAb = EnvBrdf(g_coat_roughness, g_coat_n_dot_v);
    coatAmbient = coatPrefiltered * (0.04 * coatAb.x + coatAb.y) * g_coat *
                  frag_info.material.z * s.occlusion;
    sheenIncoming =
        textureLod(environment_texture, s.n, g_sheen_roughness * levels).rgb *
        frag_info.material.z;
#endif
  }
#ifdef F3D_LAYERED
  // `M3`: the transmitted share is the scene behind, where the pass has a
  // copy of it — less what the dielectric reflects and what the medium
  // takes, tinted by the base colour. Light already, so neither the ambient
  // strength nor the occlusion scales it.
  if (SceneColourBound()) {
    vec2 sceneAb = EnvBrdf(s.roughness, s.n_dot_v);
    vec3 sceneReflects =
        mix(g_f0_dielectric, g_irid_fresnel, g_iridescence) * sceneAb.x +
        g_f90 * sceneAb.y;
    ambient += diffuseColor * SceneBehind(s) * g_transmittance *
               (vec3(1.0) - min(sceneReflects, vec3(1.0))) * g_transmission;
  }
#endif
  // The light the level's walls throw on each other, baked: diffuse only,
  // since a lightmap holds irradiance and a metal has no diffuse response.
  // Zero from the one-texel black a material without a map is bound to.
  //
  // **Added rather than chosen between, and the choosing happens above this
  // shader.** A lightmap and an environment's roughest level are two answers
  // to the same question — how much indirect light reaches this point — so a
  // draw that had both would count it twice. There is no flag here to branch
  // on: a material without a map is bound the neutral black by design (see
  // material_maps.glsl), which is what makes this a plain add. The renderer
  // keeps the two apart instead, by handing no reflection probe to a
  // lightmapped draw; see `_encodeNode` in renderer_mesh_encode.dart. A sky
  // environment over a lightmapped level still adds, and should: sky light
  // is not what the bake measured.
  ambient += diffuseColor * SampleLightmap() * s.occlusion;

#ifdef F3D_LAYERED
  // What shines from under the coat is dimmed by it on the way out, the
  // emission included — glTF's own layering. The direct light was scaled in
  // `ShadeLight`.
  vec3 sheenAmbient = g_sheen * g_sheen_albedo * sheenIncoming * s.occlusion;
  WriteSurface(
      AccumulateLights(s) * s.occlusion +
          (ambient * g_sheen_scale + sheenAmbient + s.emissive) *
              g_coat_through +
          coatAmbient,
      s.alpha,
      s.roughness);
#else
  WriteSurface(
      AccumulateLights(s) * s.occlusion + ambient + s.emissive,
      s.alpha,
      s.roughness);
#endif
}

#endif  // PBR_GLSL_


''',
    'PbrLayered': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// Metal-rough with glTF's layers on top — `M1`–`M3`: the index of refraction,
// the specular strength and tint, a clear coat lit on the geometric normal, a
// sheen, anisotropy, transmission through a volume, dispersion and a thin
// film. `lib/pbr.glsl` compiled with `F3D_LAYERED`, so a plain metal-rough
// surface keeps the cost and the samplers it had. See
// `LightingModel.pbrLayered`.
#define F3D_LAYERED
// --- lib/pbr.glsl ---
// Metal-rough physically based shading: Cook-Torrance specular with the GGX
// distribution, height-correlated Smith visibility and a Schlick Fresnel.
//
// **The body of two stages.** `lighting/pbr.frag` is this and nothing else;
// `lighting/pbr_layered.frag` defines `F3D_LAYERED` first and gets the glTF
// layers on top — `M1`. Everything under `#ifdef F3D_LAYERED` is the layered
// stage's alone, and everything under its `#else` is what plain metal-rough
// always was, kept as it was so that stage compiles to what it compiled to.
// Formulations follow Filament, which is also what the glTF spec describes, so
// imported glTF materials will land on the same look.
//
// Image-based lighting is here when a scene supplies an environment, and the
// flat hemispheric ambient stands in when it does not. `frame_params.w` carries
// the number of levels in the environment cube and is zero when there is none —
// the slot that block reserved for exactly this kind of frame-wide parameter.
//
// **The environment sampler is always bound**, to a one-texel cube when a scene
// has no environment. A sampler a shader declares and nobody binds is a native
// crash on Metal rather than a black texture; the same rule keeps the sky's
// cube out of `sky.frag` and a white texel under the composite's occlusion.
// `L7`: rectangle lights integrate the GGX lobe; see `lib/ltc.glsl`.
#define F3D_LTC
#ifndef PBR_GLSL_
#define PBR_GLSL_

// `C8`: the layered stage reads each map through its own transform — see
// `MapUv` in `lib/surface.glsl`, and the definitions under `LayerInfo` below.
#ifdef F3D_LAYERED
#define F3D_TEXTURE_TRANSFORM
#endif

// --- lib/material_maps.glsl ---
// The texture maps a lit material can carry, beyond base colour.
//
// A separate header from surface.glsl on purpose. Declaring a sampler a shader
// never reads is the same trap as declaring an unused uniform block: the
// compiled function has no such slot, while the Dart side still has metadata
// saying it does. Unlit and the debug models include surface.glsl (or only
// color.glsl) and get none of this; the lit models include both, and
// LightingModel.usesMaterialTextures says which is which.
//
// Every map has a *neutral* fallback texture bound when the material has none,
// so there are no "has this map" flags to keep in sync — a white ORM texture
// multiplies the factors by one, and a flat normal map perturbs nothing. Flags
// would have to be right in two places; a neutral texel is right by
// construction.

#ifndef MATERIAL_MAPS_GLSL_
#define MATERIAL_MAPS_GLSL_

// --- lib/surface.glsl ---
// Shared material and lighting interface for the lighting models.
//
// flutter_gpu compiles shaders ahead of time into a bundle: there is no runtime
// compilation, so a node-graph material system assembled at run time is
// impossible. Each lighting model is therefore
// its own pre-built fragment shader, and this header is what keeps them
// interchangeable — one identical uniform block, so the Dart binding code never
// needs to know which model is active.
//
// Keep every declaration below byte-identical across models. A member a model
// does not read may be optimized out of the reflected block, which is why the
// Dart side skips absent members instead of failing.
//
// Only include this from a shader that actually reads FragInfo. Declaring the
// block without using it leaves it visible to reflection while the compiled
// shader binds no buffer for it, and binding that phantom block segfaults
// inside Metal. Shaders needing only colour helpers include lib/color.glsl.

#ifndef SURFACE_GLSL_
#define SURFACE_GLSL_

// --- lib/color.glsl ---
// Colour space helpers and the fragment output interface.
//
// Split out of surface.glsl so a shader that needs no material inputs — the
// normals debug view — can avoid DECLARING the FragInfo uniform block at all.
// That matters more than it looks: reflection metadata reports a block as
// present merely because it was declared, even when the compiled shader binds
// no such buffer, so a declared-but-unused block is indistinguishable from a
// used one until Metal crashes on the bind.

#ifndef COLOR_GLSL_
#define COLOR_GLSL_

precision highp float;

const float kPi = 3.14159265359;

// One varying set shared by every fragment shader, matching mesh.vert.
//
// All five are declared here, including the two the debug models never read: a
// fragment shader whose `in` block disagrees with the vertex shader's `out`
// block fails to link, and there is no partial-match rule to lean on.
in vec3 v_world_position;
in vec3 v_normal;
in vec2 v_texcoord;
in vec4 v_tangent;
in vec4 v_color;

/// Where this fragment is in the level's lightmap. Zero from every vertex
/// stage but `mesh_lightmapped.vert`, and read only by the lit models, which
/// sample a one-texel black there when a material has no map.
in vec2 v_lightmap_uv;

layout(location = 0) out vec4 frag_color;

// The second attachment: what a screen-space effect needs to know about the
// surface it is looking at. World-space normal in rgb, and in a the depth along
// the view axis in world metres — not a window depth; `WriteSurfaceGeometry`
// says at length why not.
//
// Depth travels here rather than in a depth texture because flutter_gpu cannot
// sample one — the same reason the shadow pass writes its depth into a colour
// target. See ARCHITECTURE.md §2.
//
// Guarded, because not every stage that includes this header draws into a
// two-attachment target. The shadow pass draws into one, and a pipeline
// declaring an output its target has no slot for is a mismatch worth avoiding
// rather than discovering.
#ifndef F3D_NO_SURFACE_BUFFER
layout(location = 1) out vec4 frag_surface;

/// The surface's own colour, sRGB-encoded, alpha one where a surface was
/// drawn — `L5`. The third attachment, present only when a pass reads it (the
/// indirect light does) and the device opens three; like the surface buffer,
/// written unconditionally and discarded when absent. Stored in the surface
/// buffer's format rather than eight bits a channel, and `Renderer` says why.
layout(location = 2) out vec4 frag_albedo;
#endif

/// What [frag_albedo] carries: the lit models set it in `ReadSurface`, and a
/// stage that reflects nothing — unlit, the debug views — leaves it black,
/// which is what light bounced onto it would come to.
vec3 g_albedo = vec3(0.0);

/// Octahedral encoding: a unit vector in two channels instead of three.
///
/// Worth the arithmetic because the fourth channel is already spent on depth,
/// and without a free channel there is nowhere to put roughness — which is the
/// difference between a reflection that knows stone from a mirror and one that
/// does not. The error is well under a degree, far below anything a reflection
/// off rough stone would show.
vec2 EncodeOctahedral(vec3 n) {
  n /= abs(n.x) + abs(n.y) + abs(n.z);
  vec2 e = n.xy;
  if (n.z < 0.0) {
    e = (1.0 - abs(n.yx)) * vec2(n.x >= 0.0 ? 1.0 : -1.0,
                                 n.y >= 0.0 ? 1.0 : -1.0);
  }
  return e * 0.5 + 0.5;
}

/// Where a debug pass leaves the picture it wants shown instead of the normal.
///
/// Declared here, in the header every lit shader includes **first**, and
/// written from surface.glsl, which is included after. The alternative was a
/// new member on a shared uniform block; a global costs nothing and moves no
/// offsets. It is read at the moment the surface buffer is written, which
/// happens after the lighting loop has run, so the value is there by then.
vec3 g_debug_surface = vec3(0.0);
bool g_debug_surface_on = false;

/// Whether [WriteSurface] weights the colour by its alpha: set by
/// `ReadSurface` for a material that blends, and false for everything else.
///
/// **The blend takes its source as premultiplied**, so a blended surface has
/// to hand it the colour times the alpha — a pane at a fifth of opaque adds a
/// fifth of its light, not all of it. glTF's blend mode is Porter and Duff's
/// over on straight colour, and this is the one place that turns the lit
/// radiance into what that means. An opaque or masked surface keeps its
/// colour whole: its alpha is not a coverage, and nothing blends it.
/// A global for the reason [g_debug_surface] is one.
bool g_premultiply = false;

// **A stage that needs none of this must be able to declare none of it.** On
// Vulkan both stages' descriptors are merged into one set layout, and two
// bindings with the same number in it is not a layout the specification
// allows. A driver may accept it anyway; a Galaxy A55's refuses the pipeline
// with `ErrorUnknown` and no other word, which is how the shadow pass came to
// build everywhere except there — its only uniform block was this one, and it
// landed on the same binding as the vertex stage's first.
#ifndef F3D_NO_FOG

/// Distance fog, in its own block rather than folded into FragInfo.
///
/// Its own because color.glsl is included before FragInfo is declared, and
/// because appending to a block that half a dozen shaders already share is a
/// way to move offsets nobody expected to move. Three vec4s is a cheap price
/// for not touching any of that.
layout(std140) uniform FogInfo {
  /// rgb: linear fog colour. w: density per metre, zero for no fog.
  vec4 fog;

  /// xyz: camera position in world space. Duplicated from FragInfo so this
  /// block stands alone; a vec3 is cheaper than a coupling.
  vec4 eye;

  /// xyz: the direction the camera looks, as a unit vector in world space.
  /// w: what a transparent draw writes under weighted blended transparency —
  /// `R8`, see `WriteWeightedBlended`. Zero for every other draw.
  ///
  /// Here rather than in a block of its own because it answers the same
  /// question [eye] does — where the camera is and which way it faces — and
  /// this is the block `color.glsl` can see.
  vec4 forward;
}
fog_info;

/// How far this fragment is from the eye, in world metres.
///
/// What the fog fades by. Distance rather than depth, because fog is a
/// property of the air between two points and does not care which way the
/// camera happens to face.
float EyeDistance() { return distance(v_world_position, fog_info.eye.xyz); }

/// How far this fragment is *along the view axis*, in world metres.
///
/// What the surface buffer's alpha holds. Depth rather than distance, and the
/// difference only shows on an orthographic camera — where the rays through
/// the pixels are parallel instead of meeting at the eye, so a distance from
/// the eye names a sphere that the pixel's ray crosses somewhere the reader
/// cannot solve for. A depth along the axis names a plane, which every ray
/// crosses exactly once. See `WorldAtDepth` in `post/ssao.frag` for the
/// reconstruction both projections share.
float ViewDepth() {
  return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz);
}

#else  // F3D_NO_FOG

// The same two questions, answered without the block: a stage that declares no
// fog has no eye position to measure from either. Stubs rather than a guard at
// every call site, so that what includes this file reads the same whichever
// way it was compiled.
float EyeDistance() { return 0.0; }
float ViewDepth() { return 0.0; }

#endif  // F3D_NO_FOG

/// sRGB to linear. Textures are authored in sRGB, but lighting is only correct
/// in linear space; skipping this is what makes naive renderers look muddy.
vec3 SrgbToLinear(vec3 srgb) {
  return mix(
      srgb / 12.92,
      pow((srgb + vec3(0.055)) / 1.055, vec3(2.4)),
      step(vec3(0.04045), srgb));
}

/// Linear to sRGB. The render target is a plain UNorm format rather than an
/// sRGB one, so the encode has to happen here.
vec3 LinearToSrgb(vec3 linear) {
  return mix(
      linear * 12.92,
      1.055 * pow(linear, vec3(1.0 / 2.4)) - vec3(0.055),
      step(vec3(0.0031308), linear));
}

/// Writes scene-referred linear light into the HDR target.
///
/// No tone map and no sRGB encode: those moved into the composite pass, which
/// is the entire point of rendering into `r16g16b16a16Float` first. Applying
/// them here meant every model wrote display-referred colour into an 8-bit
/// buffer, so anything above display white was gone before post-processing
/// could see it — and bloom is a function of exactly that.
///
/// Exposure moved with them, for the same reason: it belongs on the same side
/// of the display transform as the tone map.
/// Records the geometry of this fragment for whatever runs after the scene.
///
/// Called from the same place that writes colour, so a surface cannot be lit
/// into the frame without also describing itself — which is the failure that
/// leaves a screen-space effect reflecting whatever was in the buffer before.
///
/// rg: octahedral normal. b: perceptual roughness. a: **depth along the view
/// axis, in world metres** — see [ViewDepth].
///
/// **Not `gl_FragCoord.z`, and that is a defect this channel carried until it
/// was looked at.** Window depth crowds every distant surface into the top of
/// its range — with a near plane of a tenth of a metre, everything past twenty
/// metres lives in the last half a hundredth of `[0, 1]` — and this attachment
/// is a half float, whose steps up there are about five ten-thousandths. So two
/// surfaces half a metre apart at twenty metres stored the *same* number, and
/// every screen-space pass that compares against this channel decided whole
/// bands of pixels by rounding. The occlusion pass drew them: vertical stripes
/// along the lines of constant depth on any wall receding from the camera, on
/// both GPU backends. The software rasteriser kept the channel at full
/// precision and drew the effect correctly, so it was the one that looked
/// wrong against the other two.
///
/// A depth in metres has none of that: the exponent carries the range and the
/// mantissa carries the same relative precision everywhere, which at twenty
/// metres is a centimetre. Both numbers are measured in
/// `flutter3d/test/surface_depth_test.dart`.
///
/// Zero still means nothing was drawn. The attachment is cleared to zero and
/// nothing is drawn in front of the near plane.
void WriteSurfaceGeometry(float roughness) {
#ifndef F3D_NO_SURFACE_BUFFER
  // `L5`: the surface's colour, whatever the surface buffer ends up holding.
  frag_albedo = vec4(LinearToSrgb(clamp(g_albedo, vec3(0.0), vec3(1.0))), 1.0);
  // A debug pass takes the buffer over rather than getting one of its own.
  // The surface buffer already has an attachment, a viewer and a golden; a
  // second one would need all three built before it could answer anything.
  if (g_debug_surface_on) {
    frag_surface = vec4(g_debug_surface, ViewDepth());
    return;
  }
  // Reversed on a back face, as the lit normal is, so the occlusion and
  // reflection passes see the side of a double-sided surface that faces them.
  vec3 geometric = normalize(v_normal);
  if (!gl_FrontFacing) geometric = -geometric;
  frag_surface = vec4(EncodeOctahedral(geometric),
                      clamp(roughness, 0.0, 1.0), ViewDepth());
#endif
}

/// Fades [color] toward the fog with distance from the eye.
///
/// Exponential rather than linear, because linear fog has a visible plane
/// where it starts and a dungeon corridor is exactly where that shows.
vec3 ApplyFog(vec3 color) {
#ifdef F3D_NO_FOG
  return color;
#else
  float density = fog_info.fog.w;
  if (density <= 0.0) return color;
  float d = EyeDistance();
  return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0));
#endif
}

/// How much a transparent fragment counts for against the others over its
/// pixel — `R8`. McGuire and Bavoil's depth weight (their equation 9): a near
/// layer outweighs a far one, which is all the ordering a weighted average
/// can keep. [alpha] multiplies it, as theirs does, so a faint layer counts
/// faintly. Depth along the view axis, in metres, the surface buffer's.
float WeightedBlendedWeight(float alpha) {
  float z = abs(ViewDepth());
  float near = z / 5.0;
  float far = z / 200.0;
  float far3 = far * far * far;
  return alpha *
         clamp(10.0 / (1e-5 + near * near + far3 * far3), 1e-2, 3e3);
}

/// What a transparent draw writes when the frame composites transparency
/// order-independently — `R8`. `fog_info.forward.w` says which:
///
/// - 0: [frag_color] as it stands, the sorted blend's source. Every opaque
///   draw, and every draw in a frame that sorts.
/// - 1: the accumulation target's share — the colour, which the engine keeps
///   premultiplied, and the alpha, both times the weight. Added.
/// - 2: the revealage target's — the alpha alone, in every channel, which the
///   blend multiplies the target by one minus of.
/// - 3: both at once, the second into attachment one, where the surface
///   buffer would be; the pass that asks has no surface buffer attached.
///
/// Selects rather than returns, because a phi of constants is what
/// SPIRV-Cross refuses. At nought the branch is not taken and [frag_color]
/// is untouched, which is what keeps a sorting frame byte-identical.
void WriteWeightedBlended() {
#ifndef F3D_NO_FOG
  float mode = fog_info.forward.w;
  if (mode > 0.5) {
    float alpha = frag_color.a;
    float weight = WeightedBlendedWeight(alpha);
    vec4 accumulate = vec4(frag_color.rgb * weight, alpha * weight);
    bool revealage = mode > 1.5 && mode < 2.5;
    frag_color = revealage ? vec4(alpha) : accumulate;
#ifndef F3D_NO_SURFACE_BUFFER
    if (mode > 2.5) frag_surface = vec4(alpha);
#endif
  }
#endif
}

/// The fog is mixed in before the weight, so a thin distant pane adds a thin
/// share of the fog too rather than all of it. Times one when nothing blends,
/// which is exact, so an opaque draw writes what it always wrote.
void WriteSurface(vec3 linearColor, float alpha, float roughness) {
  float weight = g_premultiply ? alpha : 1.0;
  frag_color = vec4(ApplyFog(linearColor) * weight, alpha);
  WriteSurfaceGeometry(roughness);
  WriteWeightedBlended();
}

/// For a stage with no material to speak of.
///
/// Fully rough, which is the honest default: a surface that cannot say how
/// polished it is should not be reflected off.
void WriteSurface(vec3 linearColor, float alpha) {
  WriteSurface(linearColor, alpha, 1.0);
}

/// Writes a value that is already display-referred.
///
/// For debug output, where the colour is not a light value at all: a normal
/// encoded as RGB means nothing after a tone curve. Converting to linear here
/// means the composite pass's sRGB encode hands the original back unchanged,
/// provided the view also turns tone mapping and exposure off — which is what
/// `RenderSettings.tonemap` is for.
void WriteDisplayColor(vec3 displayColor, float alpha) {
  frag_color = vec4(SrgbToLinear(displayColor), alpha);
  WriteSurfaceGeometry(1.0);
}

#endif  // COLOR_GLSL_

// --- lib/frag_coord.glsl ---
// Where a fragment sits, counted from the top of its target on every backend.

#ifndef FRAG_COORD_GLSL_
#define FRAG_COORD_GLSL_

/// `gl_FragCoord.xy` with row zero at the top of the picture.
///
/// [rows] is the target's height where the backend's row zero is the bottom
/// of the picture, and zero where it is the top. WebGL2 is the first kind:
/// window coordinates start at the lower left, and the engine draws the
/// picture upright there rather than mirroring every projection. Metal,
/// WebGPU and the software rasteriser are the second.
///
/// **Why a pattern cares and a picture does not.** Every screen-space pattern
/// in the engine — the Bayer dither, the grain, the jitter a ray march starts
/// from, the rotation of a shadow kernel — is a function of the pixel's row.
/// Read from the bottom, the same frame gets the pattern turned upside down,
/// and a four-row Bayer cell lands on different rows unless the height is a
/// multiple of four. The picture underneath is identical; the pattern on top
/// of it is not, and a comparison across backends counts every pixel it
/// moved.
vec2 FragCoordFromTop(float rows) {
  return rows > 0.0 ? vec2(gl_FragCoord.x, rows - gl_FragCoord.y)
                    : gl_FragCoord.xy;
}

#endif  // FRAG_COORD_GLSL_


/// Lights per draw. Must match LightBuffer.maxLights on the Dart side.
///
/// A fixed array with a runtime count, not a shader permutation per light
/// count: turning a light on has to be free, because there is no runtime
/// compilation to fall back on. Verified against the SDK — Impeller keeps
/// `vec4 x[8]` in the compiled Metal struct and reflects the array's base
/// offset, with the std140 stride of 16 bytes.
#define kMaxLights 8

/// How many more lights one draw may be handed — `gfx-74n`.
///
/// **The eight above stay exactly what they were**, which is what keeps this
/// from moving a single recorded frame: a draw with eight lights or fewer runs
/// the loop it has always run, reads the uniform arrays it has always read, and
/// never touches the texture below. The tail is the part that used to be
/// impossible.
///
/// A loop bound rather than a cost. `AccumulateLights` breaks at the draw's own
/// count, so a scene with three lights costs three iterations whatever this
/// says. Twenty-four because the two tables below are `vec4 x[6]` and four
/// lanes fit a `vec4`: two hundred and eight bytes a draw, against the five
/// hundred and twelve the light arrays already cost.
#define kExtraLights 24
#define kTotalLights (kMaxLights + kExtraLights)

// --- lib/light_list.glsl ---
// The frame's light list, and how a fragment finds its tail in it — `gfx-74n`
// and `L6`.
//
// Split out of `surface.glsl` so a stage that is not a surface can read the
// same lights: `N6`'s six-way particles light each fragment by the list the
// lit models read, clusters and all, without declaring `FragInfo`. The text is
// the one that stood in `surface.glsl`, moved rather than copied, so the lit
// models compile to what they compiled to before.

#ifndef LIGHT_LIST_GLSL_
#define LIGHT_LIST_GLSL_
/// Every light in the scene, one per row, four texels across — `gfx-74n`.
///
/// **A texture rather than a wider uniform block, and that is the design.**
/// `FragInfo` is uploaded on every draw, so widening its four `vec4` arrays to
/// hold thirty-two lights would be a two-kilobyte upload per draw in every
/// scene, including every scene with one light. This is built once a frame and
/// only when a scene has more lights than a draw can hold in its slots.
///
/// Row layout, which `renderer_light_list.dart` writes and only this reads:
///
///  * texel 0 — xyz world position, w type (0 directional, 1 point, 2 spot)
///  * texel 1 — rgb linear colour, w intensity
///  * texel 2 — xyz the direction it points, w range
///  * texel 3 — x cos(inner), y cos(outer), zw unused
///
/// The same four vectors the uniform arrays hold, in the same order, so one
/// reader serves both.
///
/// **`F3D_NO_LIGHT_LIST` leaves both out**, for a model that accumulates no
/// lights. Such a model never reaches the reader below, so the compiler drops
/// the block and the sampler from the Metal function while reflection still
/// lists them, with no buffer or texture index assigned. The renderer used to
/// bind them for every draw, Unlit included, and that bind is a crash inside
/// `setFragmentBuffer:offset:atIndex:` on Metal. Vulkan took the same draw
/// without a word, which is how 0.7.0 shipped with it.
#ifndef F3D_NO_LIGHT_LIST
uniform sampler2D light_list_texture;

layout(std140) uniform LightListInfo {
  /// x: how many rows this draw reads, zero when it reads none.
  /// y, z: one over the texture's width and height.
  /// w: unused.
  vec4 list;

  /// Which rows, four to a vector, in the order they are read.
  ///
  /// Indices rather than the light data itself: the data is the same for every
  /// draw in the frame and belongs in the texture; what differs per draw is
  /// *which* of them reach it, and that is what `Renderer._drawLightsFor`
  /// already decides.
  vec4 indices[6];

  /// How much of each of those survives the edge fade, in the same order.
  ///
  /// Per draw and not in the texture, because the row an index points at is
  /// shared by every draw in the frame: a scale written into it would dim that
  /// light for all of them. `gfx-12n`'s fade lives at the end of the list now —
  /// that is where a light stops contributing, and fading the slots against a
  /// water line that no longer marks a cliff would dim a light for no reason
  /// while its rival stayed bright, making the swap more visible rather than
  /// less.
  vec4 scales[6];

  /// `L6`: the view-projection the light clusters were cut with, so this
  /// finds a fragment's cell the way `LightClusters.clusterOf` does.
  mat4 cluster_view_projection;

  /// xyz: tiles across, tiles up, slices deep. w: one when this draw reads
  /// its tail from the cell it is in rather than from `indices`.
  vec4 cluster_grid;

  /// x: where slices begin, in clip w. y: slices per unit of `ln(w / x)`.
  /// z: the texture row the cells' headers start at, four to a row, each
  /// (offset, count). w: the row their entries start at, sixteen to a row.
  vec4 cluster_depth;

  /// Which rows this draw already holds in its eight slots, minus one for
  /// an empty slot. A cell lists every light that reaches it, and one the
  /// slots already carry must not be counted again.
  vec4 slot_rows[2];
}
light_list_info;

/// One lane of a six-vector table, [slot] counting from nought.
float LightListLane(vec4 four, int slot) {
  int lane = slot - (slot / 4) * 4;
  return lane == 0 ? four.x : lane == 1 ? four.y : lane == 2 ? four.z : four.w;
}

/// The row light [slot] of the list reads.
float LightListRow(int slot) {
  return LightListLane(light_list_info.indices[slot / 4], slot);
}

/// How much of light [slot] of the list survives the edge fade.
float LightListScale(int slot) {
  return LightListLane(light_list_info.scales[slot / 4], slot);
}

/// The cell this fragment falls in, as `LightClusters` wrote it: where its
/// entries start and how many there are. Found once, in [LightCount], and
/// read by every [SampleLight] of the loop that follows.
float g_cluster_offset = 0.0;
float g_cluster_count = 0.0;

bool Clustered() { return light_list_info.cluster_grid.w > 0.5; }

/// One texel of the light list texture, [texel] across and [row] down.
vec4 LightListTexel(float texel, float row) {
  return textureLod(light_list_texture,
                    vec2((texel + 0.5) * light_list_info.list.y,
                         (row + 0.5) * light_list_info.list.z),
                    0.0);
}

void FindCluster(vec3 world) {
  vec4 clip = light_list_info.cluster_view_projection * vec4(world, 1.0);
  vec2 ndc = clip.xy / max(clip.w, 1e-6);
  vec3 grid = light_list_info.cluster_grid.xyz;
  float near = light_list_info.cluster_depth.x;
  float tx = clamp(floor((ndc.x * 0.5 + 0.5) * grid.x), 0.0, grid.x - 1.0);
  float ty = clamp(floor((ndc.y * 0.5 + 0.5) * grid.y), 0.0, grid.y - 1.0);
  float tz = clip.w <= near
                 ? 0.0
                 : clamp(floor(log(clip.w / near) *
                               light_list_info.cluster_depth.y),
                         0.0, grid.z - 1.0);
  float cell = tx + ty * grid.x + tz * grid.x * grid.y;
  float row = floor(cell / 4.0);
  vec4 header =
      LightListTexel(cell - row * 4.0, light_list_info.cluster_depth.z + row);
  g_cluster_offset = header.x;
  g_cluster_count = header.y;
}

/// The row entry [slot] of this fragment's cell names.
float ClusterRow(int slot) {
  float entry = g_cluster_offset + float(slot);
  float row = floor(entry / 16.0);
  float within = entry - row * 16.0;
  float texel = floor(within / 4.0);
  vec4 four = LightListTexel(texel, light_list_info.cluster_depth.w + row);
  return LightListLane(four, int(within - texel * 4.0 + 0.5));
}

/// Whether one of the draw's slots already holds light list row [row].
bool InSlots(float row) {
  vec4 a = abs(light_list_info.slot_rows[0] - vec4(row));
  vec4 b = abs(light_list_info.slot_rows[1] - vec4(row));
  return min(min(min(a.x, a.y), min(a.z, a.w)), min(min(b.x, b.y), min(b.z, b.w))) < 0.5;
}
#endif  // F3D_NO_LIGHT_LIST

#endif  // LIGHT_LIST_GLSL_


layout(std140) uniform FragInfo {
  /// xyz: world position (point and spot). w: type, 0 directional 1 point 2 spot.
  vec4 light_position[kMaxLights];

  /// rgb: linear colour. w: intensity.
  vec4 light_color[kMaxLights];

  /// xyz: the direction the light points, its local -Z. w: range, 0 unbounded.
  vec4 light_direction[kMaxLights];

  /// x: cos(inner cone angle). y: cos(outer cone angle).
  vec4 light_cone[kMaxLights];

  /// rgb: albedo tint applied on top of the texture. w: opacity.
  vec4 base_color;

  /// rgb: emissive factor, already linear. w: one when the normal map has
  /// two channels (x, y) and its z is rebuilt — see `ApplyNormalMap`. It sits
  /// here because this was the block's one unspent lane.
  vec4 emissive;

  /// xyz: camera position in world space, needed for every specular term.
  vec4 camera_position;

  /// x: metallic, y: roughness, z: ambient strength, w: specular strength.
  vec4 material;

  /// x: alpha cutoff (negative when the material is not masked: -1 opaque,
  /// -0.5 blended, -2 hashed), y: normal scale, z: occlusion strength,
  /// w: emissive strength.
  vec4 material2;

  /// x: exposure, y: active light count, z: index of the shadow-casting light.
  /// w is reserved so adding a frame-wide parameter does not change the offsets
  /// of anything already here.
  vec4 frame_params;

  /// x: one texel of the shadow map, y: depth bias, z: normal offset,
  /// w: strength, zero when shadows are off.
  vec4 shadow_params;

  /// World space to the shadow camera's clip space. The first cascade.
  mat4 shadow_matrix;

  /// The second and third cascades. Copies of the first when there is one, so
  /// this block's layout never depends on how many there are.
  mat4 shadow_matrix_far;
  mat4 shadow_matrix_farthest;

  /// x, y: where cascades 0 and 1 end, in metres from the camera. z: how many
  /// cascades there are, 1 to 3. w: one texel of a tile, vertically —
  /// shadow_params.x is one texel of the whole atlas, and with more than one
  /// cascade those differ.
  vec4 shadow_cascades;

  /// rgb: what a surface facing straight up receives from the environment.
  /// w: one when the metal-rough models' diffuse is EON rather than Lambert —
  /// `L8`, `RenderSettings.diffuseModel`; a frame-wide switch in a frame-wide
  /// vector, and the block's offsets stay where four backends agree on them.
  ///
  /// Appended after everything else on purpose: std140 lays a block out in
  /// declaration order, so adding here leaves every offset above unchanged and
  /// the three backends do not have to agree about anything they did not
  /// already agree about.
  vec4 ambient_sky;

  /// rgb: what a surface facing straight down receives — bounce off the ground
  /// rather than the ground itself.
  ///
  /// **w is the directional light's apparent size** — `gfx-15n` — which has
  /// nothing to do with ambient and everything to do with this being the last
  /// unspent component in a block six shaders share. `frame_params.w` was the
  /// slot reserved for a frame-wide parameter and the environment's level
  /// count took it; appending to this block moves offsets four backends have
  /// agreed on. See `shadow.glsl`, which reads it.
  ///
  /// Two colours rather than one is the whole of what makes ambient look like
  /// light instead of like a lifted black level. Outdoors the sky is blue and
  /// bright and the ground is warm and dim, and a flat grey for both leaves
  /// every underside as pale as every upward face — which reads as the model
  /// being flat, and gets blamed on the normals.
  vec4 ambient_ground;

  /// x, y, z: the depth bias of each cascade, in that cascade's own normalized
  /// depth. w unused.
  ///
  /// `ShadowSettings.bias` is one number and a cascade's depth range is not:
  /// a near cascade is stretched towards the light when a caster stands
  /// further out than its own volume reaches, and the same bias over a longer
  /// range is a longer distance. The renderer converts it per cascade so it
  /// stays the distance it was tuned as; an unstretched cascade gets the
  /// setting unchanged.
  vec4 shadow_bias;

  /// x: the target's rows when its row zero is the bottom of the picture,
  /// zero when it is the top — see `FragCoordFromTop` in `frag_coord.glsl`,
  /// which the shadow kernel's rotation reads through. y: the mip bias every
  /// material map is read with — `R2`: nought, except while a temporal
  /// resolve reconstructs a picture larger than the scene is drawn at, when
  /// the maps are read as sharp as the output they end up in. z: one when
  /// the metal-rough model puts back the energy single scattering loses —
  /// `L1`, `RenderSettings.energyCompensation`. w: the frame's slice of 32
  /// while a temporal resolve runs, minus one otherwise — `S3`, which steps
  /// the soft shadow's rotation by it.
  vec4 target_origin;
}
frag_info;

/// The bias a material map is read with — see `target_origin.y`.
float MaterialLodBias() { return frag_info.target_origin.y; }

/// The maps a lit material reads, by the index [MapUv] takes — `C8`. The
/// order `LayerInfo.uv_transform` keeps them in, and `MaterialMap`'s on the
/// Dart side.
#define kMapBaseColor 0
#define kMapMetallicRoughness 1
#define kMapNormal 2
#define kMapOcclusion 3
#define kMapEmissive 4

/// Where map [slot] is read — `C8`, `KHR_texture_transform` at the sampler.
///
/// **A macro everywhere but the one stage that has the matrices.** A stage
/// that defines `F3D_TEXTURE_TRANSFORM` supplies [MapUv] and [MapMatrix] from
/// a block of its own; every other stage reads each map at the vertex's own
/// coordinate, and the macro leaves its source exactly what it was, so none of
/// them compiles to anything new.
#ifdef F3D_TEXTURE_TRANSFORM
vec2 MapUv(int slot);

/// The 2×2 part of map [slot]'s transform: x and y its first row, z and w
/// its second.
vec4 MapMatrix(int slot);
#else
#define MapUv(slot) v_texcoord
#endif

uniform sampler2D base_color_texture;

/// Everything about the surface that does not depend on which light is being
/// evaluated, resolved once per fragment.
struct Surface {
  vec3 albedo;      // linear, already tinted
  float alpha;      // opacity after texture, tint and vertex colour
  vec3 n;           // unit normal, perturbed by the normal map when there is one
  vec3 v;           // unit direction to the camera
  float n_dot_v;
  float metallic;
  float roughness;  // perceptual
  float occlusion;  // 1 means unoccluded
  vec3 emissive;    // linear, added after shading
  vec3 ambient;     // hemispheric, already scaled by the scene's strength
  float exposure;
};

/// One light's contribution geometry, recomputed per light per fragment.
struct LightSample {
  vec3 l;           // unit direction to the light
  vec3 h;           // unit half vector
  vec3 radiance;    // colour * intensity * attenuation
  float n_dot_l;
  float n_dot_h;
  float v_dot_h;

  /// One when the specular below is already integrated over the light —
  /// `L7`, a rectangle under a model that defines `F3D_LTC` — and nought
  /// otherwise. Then `ltc.x` is the GGX lobe over the rectangle, `ltc.y` the
  /// fitted norm and `ltc.z` the Fresnel term; see `LtcRectangle`.
  float integrated;
  vec3 ltc;
};

Surface ReadSurface() {
  Surface s;

  vec4 texel = texture(base_color_texture, MapUv(kMapBaseColor), MaterialLodBias());
  // Vertex colour is authored linear per the glTF spec, unlike the base colour
  // texture and the tint, which are sRGB.
  s.albedo = SrgbToLinear(texel.rgb) *
             SrgbToLinear(frag_info.base_color.rgb) *
             v_color.rgb;
  s.alpha = texel.a * frag_info.base_color.a * v_color.a;
  // `L5`: the albedo buffer carries it, for the indirect light.
  g_albedo = s.albedo;

  // Alpha masking, glTF's third alpha mode. A negative cutoff means the
  // material is opaque or blended, and discard would then be wrong rather than
  // merely unnecessary. Doing it before anything else is deliberate: a
  // discarded fragment should not pay for the lighting loop.
  //
  // **A cutoff below -1.5 is the fourth mode: hashed** — `gfx-16n`. The
  // sentinel rides in the same component because the alternative is a second
  // number in a block six shaders share, and -1 already meant "not masked";
  // anything more negative was free. See [MaterialAlphaMode.hashed].
  float cutoff = frag_info.material2.x;
  if (cutoff >= 0.0) {
    if (s.alpha < cutoff) discard;
  } else if (cutoff < -1.5) {
    // **Stochastic instead of a threshold.** A leaf texture at 40% opacity is
    // either entirely there or entirely gone under a fixed cutoff, so a fern
    // comes out as a hard-edged cardboard cut-out; sorting would fix it and
    // costs a sort per frame and a draw per layer. Comparing against noise
    // instead keeps 40% of the *pixels*, which resolves as 40% opacity to
    // anything that averages several of them — a higher-resolution target,
    // a downsample, a person standing back.
    //
    // **Hashed on world position, not on the screen.** Screen-space noise is
    // one line shorter and swims: the pattern stays put while the object
    // moves through it, so a moving branch sparkles. Anchoring it to where
    // the surface *is* means a given speck of leaf keeps its verdict from
    // frame to frame, and the camera moving changes nothing.
    //
    // The scale is a constant and it is the whole tuning: finer than the
    // texture's own detail and the noise disappears into aliasing, coarser
    // and the leaf breaks into blotches. Sixteen per metre is about a
    // centimetre of grain at a metre away.
    vec3 anchored = floor(v_world_position * 16.0);
    float noise = fract(
        sin(dot(anchored, vec3(12.9898, 78.233, 37.719))) * 43758.5453);
    if (s.alpha < noise) discard;
  }
  // **Between -1 and nought is the blend mode**, which `WriteSurface` weights
  // by its alpha: see [g_premultiply]. The engine writes -0.5 for it, -1 for
  // opaque; neither is masked, and only the blend's source is premultiplied.
  g_premultiply = cutoff < 0.0 && cutoff > -0.75;

  s.n = normalize(v_normal);
  // The back of a double-sided surface is lit from its own side: glTF asks
  // for the normal to be reversed there, and without it the underside of a
  // cloth turned to the sun reads n·l below zero and stays unlit. Only a
  // double-sided material ever draws a back face, since everything else has
  // them culled.
  if (!gl_FrontFacing) s.n = -s.n;
  s.v = normalize(frag_info.camera_position.xyz - v_world_position);
  // Clamped away from zero: a grazing view direction otherwise divides by zero
  // in the specular visibility term.
  s.n_dot_v = max(dot(s.n, s.v), 1e-4);

  s.metallic = clamp(frag_info.material.x, 0.0, 1.0);
  s.roughness = clamp(frag_info.material.y, 0.02, 1.0);
  // Hemispheric: the sky above, the ground below, blended by which way this
  // surface faces. `material.z` stays the overall strength, so the two are
  // separable — a scene dims its ambient without changing its colour, which is
  // what the one control used to do on its own.
  //
  // The blend runs on the geometric normal deliberately, before
  // `ApplyMaterialMaps` perturbs it. A normal map describes millimetres of
  // surface relief, and ambient of this kind describes which half of the world
  // a face can see; letting bump detail swing it makes a brick wall's mortar
  // lines pick up sky and reads as noise.
  s.ambient = mix(frag_info.ambient_ground.rgb, frag_info.ambient_sky.rgb,
                  s.n.y * 0.5 + 0.5) *
              frag_info.material.z;
  s.exposure = max(frag_info.frame_params.x, 0.0);

  // Neutral until ApplyMaterialMaps says otherwise, so a model that samples no
  // maps still has a complete surface.
  s.occlusion = 1.0;
  s.emissive = vec3(0.0);

  return s;
}

int LightCount() {
#ifdef F3D_NO_LIGHT_LIST
  return clamp(int(frag_info.frame_params.y + 0.5), 0, kMaxLights);
#else
  // `L6`: the tail is the cell's, when the draw reads one.
  float tail = light_list_info.list.x;
  if (Clustered()) {
    FindCluster(v_world_position);
    tail = g_cluster_count;
  }
  return clamp(int(frag_info.frame_params.y + 0.5), 0, kMaxLights) +
      clamp(int(tail + 0.5), 0, kExtraLights);
#endif
}

/// Whether light [index] carries a shadow — `gfx-74n`.
///
/// Only the first eight do. The cube atlas holds six rows and the slot table is
/// eight entries wide, so a light from the list has no row to read and asking
/// for one would index past the table. That is a real limit and the right one:
/// the eight a draw keeps in its slots are the eight ranked most relevant to
/// it, which is exactly the set worth a shadow map.
bool LightHasShadow(int index) { return index < kMaxLights; }

/// Distance attenuation for a punctual light, following the glTF spec.
///
/// Inverse square with an optional range window. The window is what stops a
/// lamp with a declared range from contributing a faint haze across the whole
/// scene, which matters far more once there are eight of them.
float PunctualAttenuation(float distance, float range) {
  float attenuation = 1.0 / max(distance * distance, 1e-4);
  if (range > 0.0) {
    float ratio = distance / range;
    float window = clamp(1.0 - ratio * ratio * ratio * ratio, 0.0, 1.0);
    attenuation *= window * window;
  }
  return attenuation;
}

/// One edge of Lambert's sum, from [a] to [b], neither of which need be a
/// unit vector: the angle between them times how much their plane leans into
/// [n].
float LambertEdge(vec3 a, vec3 b, vec3 n) {
  // Normalised with a floor rather than `normalize`: a corner exactly at the
  // shading point, or a horizon crossing that lands there, is a zero vector,
  // and `normalize` of that is a NaN that spreads to the whole pixel and then
  // to the bloom. A zero vector here subtends nothing, which is the answer.
  vec3 ua = a / max(length(a), 1e-12);
  vec3 ub = b / max(length(b), 1e-12);
  // Clamped before the `acos`: two nearly parallel edge directions can give a
  // dot a hair past one through rounding alone, and `acos` of that is the same
  // NaN.
  float angle = acos(clamp(dot(ua, ub), -1.0, 1.0));
  vec3 axis = cross(ua, ub);
  float len = length(axis);
  // A degenerate edge — the shading point lies on the line through it —
  // subtends nothing.
  return len > 1e-6 ? angle * dot(axis, n) / len : 0.0;
}

/// How much of [s]'s sky a rectangle covers, weighted by the cosine —
/// `gfx-77n`.
///
/// **Exact, not fitted.** This is Lambert's own form factor for a polygon, from
/// 1760: for each edge, the angle it subtends at the shading point times how
/// much the edge's plane leans into the surface normal. Summed over the edges
/// and halved, it is the integral of `cos θ` over the polygon's projection on
/// the sphere — the quantity a punctual light approximates with a single
/// `n · l`. So there is no table to ship and nothing to fit: the usual
/// linearly-transformed-cosine approach exists to make the *specular* lobe
/// tractable, and buys nothing here.
///
/// **Clipped to the horizon first.** Lambert's sum is signed: a part of the
/// panel below the surface's horizon counts with a negative cosine and cancels
/// light from the part above it, so a panel standing on the horizon read
/// nought where half of it lights the surface. Irradiance wants the clamped
/// cosine, and for a polygon that means cutting away what lies below before
/// summing. A convex quadrilateral cut by a plane leaves one polygon with at
/// most one edge leaving the hemisphere and one entering it, so the cut is the
/// four edges trimmed where they cross plus one edge along the horizon from
/// the exit back to the entry, with no list of vertices to build.
///
/// Returns irradiance over radiance, so a surface facing a rectangle that fills
/// its whole sky gets π, the same as a uniform hemisphere. [corners] are the
/// four vertices in order, relative to the shading point.
///
/// **The rectangle emits along `cross(halfWidth, halfHeight)`**, and with the
/// corners wound as `SampleLight` winds them the sum comes out *negative* on
/// that side, so the negation below is the convention rather than a fix. It was
/// measured rather than derived: the first version returned `+total * 0.5`, and
/// against the reference integration it read nought where the answer was 0.349
/// and 1.02 where the answer was nought — the two failures a flipped winding
/// produces, and between them they name the sign with no room left to argue.
float RectangleFormFactor(vec3 corners[4], vec3 n) {
  float total = 0.0;
  vec3 exit = vec3(0.0);
  vec3 entry = vec3(0.0);
  for (int i = 0; i < 4; i++) {
    vec3 a = corners[i];
    vec3 b = corners[i == 3 ? 0 : i + 1];
    float ha = dot(a, n);
    float hb = dot(b, n);
    // Where the edge meets the horizon; used only when it crosses it, and then
    // the two heights differ in sign, so the division is safe.
    float d = ha - hb;
    vec3 q = a + (b - a) * (abs(d) > 1e-12 ? ha / d : 0.0);
    bool aAbove = ha > 0.0;
    bool bAbove = hb > 0.0;
    total += aAbove || bAbove
                 ? LambertEdge(aAbove ? a : q, bAbove ? b : q, n)
                 : 0.0;
    exit = aAbove && !bAbove ? q : exit;
    entry = !aAbove && bAbove ? q : entry;
  }
  // The horizon edge closing the cut, from where the outline left the
  // hemisphere to where it came back. Nothing when it never crossed: both are
  // still zero and a zero vector subtends nothing.
  total += LambertEdge(exit, entry, n);
  // Clamped: a surface on the panel's dark side sees the outline wound the
  // other way, and the clipped sum comes out negative. `SampleLight` tests the
  // side as well, before any of this is paid for.
  return max(-total * 0.5, 0.0);
}

/// Where on the rectangle the specular lobe is really looking — `gfx-77n`.
///
/// **The representative point, which is an approximation, unlike the diffuse
/// above.** The mirror direction leaves the surface and either hits the panel
/// or misses it; the closest point of the panel to that ray is treated as a
/// punctual light standing in for the whole rectangle. It is the standard
/// cheap answer and its one visible property is the one the row asked for: as
/// the view moves the closest point slides along the panel, so the highlight
/// is a streak with the panel's own shape and orientation rather than a dot.
///
/// What it does not do is widen the lobe by the panel's solid angle, so a
/// rough surface under a large panel is a little darker than a full integration
/// would make it. That is a known error of this method and not a bug in this
/// transcription; the fix is the fitted table this function exists to avoid.
vec3 RectangleClosestPoint(vec3 centre, vec3 halfWidth, vec3 halfHeight,
                           vec3 world, vec3 mirror) {
  vec3 n = cross(halfWidth, halfHeight);
  float nLen = length(n);
  // A panel with no area has no surface to find a point on; its centre is the
  // only answer that is not a division by zero.
  if (nLen < 1e-12) return centre;
  n /= nLen;

  vec3 toPlane = centre - world;
  float denom = dot(mirror, n);
  vec3 onPlane;
  // Parallel to the panel, or pointing away from it: the ray never lands, so
  // the nearest thing to it is the centre projected back, which keeps the
  // highlight on the panel instead of sending it to infinity.
  if (abs(denom) < 1e-5) {
    onPlane = toPlane - n * dot(toPlane, n);
  } else {
    float t = dot(toPlane, n) / denom;
    onPlane = t > 0.0 ? mirror * t : toPlane - n * dot(toPlane, n);
  }

  // Clamped into the rectangle in its own axes. Dividing by the squared length
  // turns a projection into a coordinate in units of the half-extent, so the
  // clamp is against one either way round.
  vec3 offset = onPlane - toPlane;
  float wLen2 = max(dot(halfWidth, halfWidth), 1e-12);
  float hLen2 = max(dot(halfHeight, halfHeight), 1e-12);
  float u = clamp(dot(offset, halfWidth) / wLen2, -1.0, 1.0);
  float v = clamp(dot(offset, halfHeight) / hLen2, -1.0, 1.0);
  return centre + halfWidth * u + halfHeight * v;
}

#ifdef F3D_LTC
// --- lib/ltc.glsl ---
// The GGX lobe over a rectangle light, by linearly transformed cosines — `L7`.
//
// Heitz, Dupuy, Hill and Neubelt, "Real-Time Polygonal-Light Shading with
// Linearly Transformed Cosines", ACM TOG 35(4), 2016. The fitted tables are
// `EngineTables.ltc`; see `tables/ltc.dart` for their layout and licence.
//
// A model that wants it defines `F3D_LTC` before including `surface.glsl`,
// which is what gives its stage the one sampler below. Every other model
// keeps the representative point, and no sampler.

#ifndef LTC_GLSL_
#define LTC_GLSL_

/// Both tables, 64 × 128: the inverse matrices above, the norms, Fresnel
/// terms and sphere form factors below.
uniform sampler2D ltc_texture;

/// Where `(x, y)`, each nought to one, lands in the table starting at
/// [table] (nought the upper, one the lower): on texel centres, so the ends of
/// the range read the first and last entries rather than half of the
/// neighbour.
vec2 LtcUv(float x, float y, float table) {
  vec2 inTable = vec2(x, y) * (63.0 / 64.0) + 0.5 / 64.0;
  return vec2(inTable.x, (inTable.y + table) * 0.5);
}

/// One edge's share of the vector form factor, from [a] to [b], unit
/// directions: the angle between them along the normal of their plane,
/// over 2π. Exact, with the `acos` clamped for the reason
/// `RectangleFormFactor` gives.
vec3 LtcEdge(vec3 a, vec3 b) {
  vec3 axis = cross(a, b);
  float len = length(axis);
  float angle = acos(clamp(dot(a, b), -1.0, 1.0));
  return len > 1e-6 ? axis * (angle / (len * 6.2831853)) : vec3(0.0);
}

/// The GGX lobe of roughness [roughness] seen along [v] from normal [n],
/// integrated over the rectangle with corners [corners] (relative to the
/// shading point, wound as `SampleLight` winds them), with the fitted
/// Fresnel pair for that lobe: x the integral, y the norm, z the Fresnel
/// term. The specular is `x · (f0 · y + (1 − f0) · z)`.
///
/// Clipped to the horizon by the sphere table rather than by cutting the
/// polygon: the vector form factor's length and elevation name a sphere
/// with the same, and the table holds how much of that sphere's clamped
/// cosine lies above the horizon.
///
/// Says nothing about which face of the panel the point is on: the vector
/// form factor points the same way in the world from either side, so this is
/// as bright behind the panel as in front of it. `SampleLight` tests the side
/// and leaves a point behind unlit before this is asked.
vec3 LtcRectangle(vec3 n, vec3 v, float roughness, vec3 corners[4]) {
  vec2 uv = vec2(clamp(roughness, 0.0, 1.0),
                 sqrt(clamp(1.0 - dot(n, v), 0.0, 1.0)));
  vec4 inverse = textureLod(ltc_texture, LtcUv(uv.x, uv.y, 0.0), 0.0);
  vec4 fit = textureLod(ltc_texture, LtcUv(uv.x, uv.y, 1.0), 0.0);

  // The frame the fit was made in: the normal up, the view in the xz plane.
  // A view along the normal has no plane of its own, and any will do.
  vec3 along = v - n * dot(v, n);
  float alongLength = length(along);
  vec3 t1 = alongLength > 1e-5
                ? along / alongLength
                : normalize(cross(n, abs(n.z) < 0.999 ? vec3(0.0, 0.0, 1.0)
                                                      : vec3(1.0, 0.0, 0.0)));
  vec3 t2 = cross(n, t1);
  mat3 minv = mat3(vec3(inverse.x, 0.0, inverse.y), vec3(0.0, 1.0, 0.0),
                   vec3(inverse.z, 0.0, inverse.w));

  vec3 l[4];
  for (int i = 0; i < 4; i++) {
    vec3 p = corners[i];
    l[i] = normalize(minv * vec3(dot(p, t1), dot(p, t2), dot(p, n)));
  }
  // Negated, for `RectangleFormFactor`'s reason: the panel emits along
  // `cross(halfWidth, halfHeight)`, and seen from there these corners run
  // clockwise.
  vec3 f = -(LtcEdge(l[0], l[1]) + LtcEdge(l[1], l[2]) +
             LtcEdge(l[2], l[3]) + LtcEdge(l[3], l[0]));
  float len = length(f);
  float z = len > 1e-9 ? f.z / len : 0.0;
  float sphere =
      textureLod(ltc_texture, LtcUv(z * 0.5 + 0.5, clamp(len, 0.0, 1.0), 1.0),
                 0.0)
          .w;
  return vec3(max(len * sphere, 0.0), fit.x, fit.y);
}

#endif  // LTC_GLSL_


#ifdef F3D_LAYERED
/// The corners of the rectangle [SampleLight] resolved last, relative to the
/// shading point — `M1`. The clear coat integrates its own lobe over the same
/// panel with its own normal and roughness, and those live in `pbr.glsl`,
/// after this file; the loop shades each light straight after sampling it,
/// so this is always the light being shaded.
vec3 g_rect_corners[4];
#endif  // F3D_LAYERED
#endif  // F3D_LTC

/// Resolves light [index] against the surface.
///
/// Returns `n_dot_l == 0` for anything that contributes nothing — behind the
/// surface, out of range, outside the spot cone, the dark face of a panel — so
/// a model can skip it with one test instead of repeating the classification.
LightSample SampleLight(int index, Surface s) {
  LightSample light;
  light.integrated = 0.0;
  light.ltc = vec3(0.0);

  vec4 position;
  vec4 color;
  vec4 direction;
  vec4 cone;
  if (index < kMaxLights) {
    position = frag_info.light_position[index];
    color = frag_info.light_color[index];
    direction = frag_info.light_direction[index];
    cone = frag_info.light_cone[index];
  } else {
#ifdef F3D_NO_LIGHT_LIST
    // Unreachable: `LightCount` stops at the slots without a list.
    position = vec4(0.0);
    color = vec4(0.0);
    direction = vec4(0.0);
    cone = vec4(0.0);
#else
    // A row of the light list — `gfx-74n`. Sampled at texel centres so a
    // driver's rounding cannot land a fetch on a neighbour, and the four texels
    // across the row are the same four vectors the arrays above hold.
    int slot = index - kMaxLights;
    // `L6`: from the cell rather than the draw's own tail, and a light the
    // slots already hold is skipped by its intensity, as a faded one is.
    bool clustered = Clustered();
    float listRow = clustered ? ClusterRow(slot) : LightListRow(slot);
    float v = (listRow + 0.5) * light_list_info.list.z;
    float u = light_list_info.list.y;
    // `textureLod` and not `texture`, for `shadow.glsl`'s own reason: `index`
    // reaches this branch through a function parameter, so a WGSL backend
    // cannot see that every invocation of a draw walks the same light count
    // and refuses the implicit derivative as possibly non-uniform. The atlas
    // has one level, so naming it directly changes no pixel.
    position = textureLod(light_list_texture, vec2(0.5 * u, v), 0.0);
    color = textureLod(light_list_texture, vec2(1.5 * u, v), 0.0);
    direction = textureLod(light_list_texture, vec2(2.5 * u, v), 0.0);
    cone = textureLod(light_list_texture, vec2(3.5 * u, v), 0.0);
    // The intensity and not the colour, for `LightBuffer._pack`'s own reason:
    // the same multiply here, and only one of them is a number nobody authored.
    color.w *= clustered ? (InSlots(listRow) ? 0.0 : 1.0) : LightListScale(slot);
#endif  // F3D_NO_LIGHT_LIST
  }

  float type = position.w;

  // **The rectangle leaves before `aim` is taken — `gfx-77n`.** For every other
  // kind `direction.xyz` is a unit vector saying which way the light points;
  // for this one it is an edge of the panel, with its length carrying half the
  // width, and normalising it here would quietly throw the size away.
  if (type > 2.5) {
    vec3 halfWidth = direction.xyz;
    vec3 halfHeight = cone.xyz;
    vec3 toCentre = position.xyz - v_world_position;

    vec3 corners[4];
    corners[0] = toCentre - halfWidth - halfHeight;
    corners[1] = toCentre + halfWidth - halfHeight;
    corners[2] = toCentre + halfWidth + halfHeight;
    corners[3] = toCentre - halfWidth + halfHeight;

    // **The panel emits from one face only**, and a point on the other side
    // gets nothing: the room above a ceiling panel, the outside of the wall a
    // window is set in. Tested here rather than left to the signs below,
    // because the specular's vector form factor keeps the same orientation
    // from either side of the panel, so a surface behind it facing away read
    // as lit as one in front facing it.
    bool behind = dot(toCentre, cross(halfWidth, halfHeight)) >= 0.0;

    // The cosine-weighted solid angle, which takes the place `n · l` holds for
    // a punctual light: the loop multiplies the shading by `n_dot_l`, so
    // putting the exact integral here makes the diffuse term exact rather than
    // sampled. See [RectangleFormFactor].
    float formFactor = behind ? 0.0 : RectangleFormFactor(corners, s.n);

    // Radiance rather than intensity: `intensity` means the same thing for
    // every kind of light, so a panel's is spread over its own area here.
    // Enlarging a window at a fixed rating then dims it per square metre and
    // leaves the room as bright, which is what the number is supposed to mean.
    float area = length(cross(halfWidth, halfHeight)) * 4.0;
    float radiance = area > 1e-9 ? 1.0 / area : 0.0;

    // The range window only. A punctual light needs the inverse square as
    // well; the form factor already contains it, because a panel twice as far
    // away subtends a quarter of the sky.
    float distance = length(toCentre);
    if (direction.w > 0.0) {
      float ratio = distance / direction.w;
      float window = clamp(1.0 - ratio * ratio * ratio * ratio, 0.0, 1.0);
      radiance *= window * window;
    }

    vec3 mirror = reflect(-s.v, s.n);
    vec3 representative = RectangleClosestPoint(
        position.xyz, halfWidth, halfHeight, v_world_position, mirror);
    vec3 toPoint = representative - v_world_position;
    float pointDistance = length(toPoint);
    light.l = pointDistance > 1e-6 ? toPoint / pointDistance : s.n;

    light.h = normalize(light.l + s.v);
    light.n_dot_l = formFactor;
    light.n_dot_h = max(dot(s.n, light.h), 0.0);
    light.v_dot_h = max(dot(s.v, light.h), 0.0);
    light.radiance = color.rgb * color.w * radiance;
#ifdef F3D_LTC
    // `L7`: the specular over the whole panel rather than at one point of
    // it. The diffuse keeps the exact form factor above.
    light.integrated = 1.0;
    light.ltc = LtcRectangle(s.n, s.v, s.roughness, corners);
#ifdef F3D_LAYERED
    // Kept for the clear coat's own integral; see [g_rect_corners].
    g_rect_corners = corners;
#endif
#endif
    return light;
  }

  vec3 aim = normalize(direction.xyz);
  float attenuation = 1.0;

  if (type < 0.5) {
    // Directional: no position, no falloff. The direction to the light is the
    // reverse of the direction it points.
    light.l = -aim;
  } else {
    vec3 toLight = position.xyz - v_world_position;
    float distance = length(toLight);
    // A light exactly on the surface has no direction; treat it as contributing
    // nothing rather than producing a NaN that spreads through the frame.
    if (distance < 1e-6) {
      light.l = s.n;
      light.h = s.n;
      light.radiance = vec3(0.0);
      light.n_dot_l = 0.0;
      light.n_dot_h = 0.0;
      light.v_dot_h = 0.0;
      return light;
    }
    light.l = toLight / distance;
    attenuation = PunctualAttenuation(distance, direction.w);

    if (type > 1.5) {
      // Spot: a smooth ramp between the two cone cosines. The Dart side already
      // guarantees the denominator is non-zero.
      float cosAngle = dot(aim, -light.l);
      attenuation *= clamp(
          (cosAngle - cone.y) / (cone.x - cone.y), 0.0, 1.0);
    }
  }

  light.h = normalize(light.l + s.v);
  light.n_dot_l = max(dot(s.n, light.l), 0.0);
  light.n_dot_h = max(dot(s.n, light.h), 0.0);
  light.v_dot_h = max(dot(s.v, light.h), 0.0);
  light.radiance = color.rgb * color.w * attenuation;

  return light;
}

/// How much of light [index] reaches this fragment, defined by each fragment
/// shader.
///
/// A prototype rather than a call into shadow.glsl, because the models that
/// sample no shadow map must not declare its sampler — the compiler would drop
/// the slot and leave the engine binding one that is not there. A lit model
/// returns `ShadowFactor(...)`; an unlit one returns 1.
float LightVisibility(Surface s, LightSample light, int index);

/// A model's per-light term, defined by each fragment shader.
///
/// A prototype here and the definition in the model is what lets the loop below
/// be written once. The alternative — repeating the loop in every model — is
/// six copies of the same three lines, and the place a light would go missing.
vec3 ShadeLight(Surface s, LightSample light);

/// Sums every active light's contribution.
///
/// The loop bound is the compile-time maximum with a runtime break, because GLSL
/// wants a constant trip count and the hardware wants the early exit.
// **The point-shadow half of this header, behind a guard.**
//
// A model that never shadows must not *declare* any of this, and the reason is
// the one `unlit.frag` already gives about the shadow sampler — with one
// backend's failure added to the other's. On Impeller the compiler drops what
// nothing reads, and the engine binding a slot that is no longer there is a
// native crash. On WebGL2 nothing is dropped: an active uniform block with no
// buffer under it makes every draw `INVALID_OPERATION`, discarded with nothing
// logged.
//
// That is what `lighting-unlit` was on this backend. Unlit's own metadata says
// `usesPointShadow` is false, so the engine correctly bound no `PointShadow`
// block — and the translated shader declared one anyway, so the sphere was
// never drawn and the frame came back the clear colour.
#ifndef F3D_NO_POINT_SHADOW

/// The cube atlas: three tiles across, two down, each a ninety-degree view
/// from a point light, each storing radial distance normalised by range.
uniform sampler2D point_shadow_texture;

/// The same atlas for the things that never move, rendered once at load.
///
/// Two maps rather than one because a dungeon's walls can be baked and a
/// spinning pickup cannot, and there is no way to draw into part of a texture
/// without redrawing the rest of it. Sampling both and keeping the nearer
/// occluder costs one extra read and saves six views of the level every frame.
uniform sampler2D point_shadow_static_texture;

/// How many lights may have a row of the atlas. Six tiles across each.
// Rows of the cube atlas: six faces across, this many lights down. Must
// match `Renderer.kShadowedLights`, which is where the reasoning lives, and
// `shadowSlots` in the software backend's transcription of this file.
const int kShadowSlots = 6;

layout(std140) uniform PointShadow {
  /// The same view-projections the atlas was rendered with, six per slot.
  ///
  /// Passed rather than reconstructed. Deriving cube face coordinates here
  /// would be a second implementation of a decision the renderer already made,
  /// and the two would disagree about handedness or up vectors on some face
  /// and nowhere else — which shows as one face of every shadow being wrong.
  mat4 faces[6 * kShadowSlots];

  /// Per slot. xyz: the light's world position. w: its range.
  vec4 lights[kShadowSlots];

  /// Per light, in the order the lighting knows them.
  ///
  /// x: the atlas row it owns, or negative when it has none — a fifth torch in
  /// a room lands there. z: the tangent of half the frustum's opening angle,
  /// which is what converts a world width into a fraction of a tile. y and w
  /// are unwritten.
  ///
  /// **z is exactly one for a point light**, because a cube face is a ninety
  /// degree frustum and `tan(45°) == 1`. That is not a convention chosen to be
  /// tidy: it is what lets a narrower frustum share this whole path, since
  /// multiplying by one in IEEE 754 changes no bit of the result. Whatever else
  /// a spot light will need, it does not need a second copy of the filter.
  vec4 slots[kMaxLights];

  /// x: half a texel, in tile-local uv. y: distance bias in metres.
  /// z: strength. w: normal offset, **in texels of the face it lands on**.
  vec4 params;

  /// x: smallest kernel radius in tile-local uv, and the fixed radius used
  /// when contact hardening is off. y: the light's own radius in metres; zero
  /// turns contact hardening off. z: largest kernel radius in tile-local uv.
  /// w: non-zero paints the penumbra estimate into the surface buffer instead
  /// of shading with it.
  vec4 params2;

  /// x: non-zero when this backend stores the atlas bottom-up. y: one over the
  /// edge length of a tile in texels, which is what turns a distance into the
  /// world width of one texel there.
  ///
  /// **Appended after everything else on purpose**, the same way FragInfo's
  /// ambient pair was: std140 lays a block out in declaration order, so adding
  /// here leaves every offset above unchanged and the three backends do not
  /// have to agree about anything they already agreed about. y, z and w are
  /// unwritten.
  vec4 params3;
}
point_shadow;

/// Eight points on a Poisson disk, a common set for filtering cascaded
/// shadows.
///
/// A disk rather than a grid because a grid of taps on a straight shadow edge
/// lands every sample on the same side at once, and the edge steps between
/// kernel widths instead of sliding. Eight rather than sixteen because every
/// tap here reads **two** atlases — the static walls and the movers — so the
/// cost is doubled before it is counted.
vec2 PointShadowDiskTap(int i) {
  if (i == 0) return vec2(-0.94201624, -0.39906216);
  if (i == 1) return vec2(0.94558609, -0.76890725);
  if (i == 2) return vec2(-0.09418410, -0.92938870);
  if (i == 3) return vec2(0.34495938, 0.29387760);
  if (i == 4) return vec2(-0.91588581, 0.45771432);
  if (i == 5) return vec2(-0.81544232, -0.87912464);
  if (i == 6) return vec2(-0.38277543, 0.27676845);
  return vec2(0.97484398, 0.75648379);
}

/// One comparison against the atlas, at [uv] offset within the tile.
///
/// The clamp is applied **after** the offset, not before, and that is the whole
/// reason a kernel can be widened here without touching anything else: each tap
/// is held inside its own tile individually. Clamping the centre and then
/// offsetting would let the outer taps walk straight out of the tile and read a
/// distance measured from a different face, or a different light.
float PointShadowDistance(vec2 uv, vec2 offset, vec2 tile, float range) {
  float inset = point_shadow.params.x;
  vec2 local = clamp(uv + offset, inset, 1.0 - inset);
  vec2 atlas = (local + tile) * vec2(1.0 / 6.0, 1.0 / float(kShadowSlots));
  // **The whole atlas, turned over, where row zero of a render target is at the
  // bottom.** Both halves of the address are wrong there and this is the one
  // place that fixes both: the tile the light owns — a light in slot zero is
  // drawn into the row the shader would call three, because the viewport
  // rectangle is flipped to land it — and the picture inside that tile, which
  // was drawn through a projection built for the other origin.
  //
  // Every check of this atlas missed it for the same reason: the debug view
  // composites the texture through a full-screen pass, which turns it over
  // again and puts the row back. The atlas compared equal on both backends
  // across six scenes while the lit pass, which samples it directly and has no
  // such pass to cancel, read a row that had never been drawn into and found
  // nothing in the way of anything.
  if (point_shadow.params3.x > 0.5) atlas.y = 1.0 - atlas.y;
  // Whichever is nearer occludes: a wall in front of a monster shadows, and so
  // does a monster in front of a wall.
  //
  // **`textureLod` at level zero, because every caller of this function stands
  // behind a branch.** The light loop skips a light the surface faces away
  // from, the blocker search `continue`s past a tap that found nothing, and the
  // slot test returns before any of it — so the invocations of a quad do not
  // arrive here together, and a WGSL backend refuses a sample whose implicit
  // derivative would be read where they disagree. Both atlases are distance
  // render targets with one level, so level zero is the level `texture` was
  // choosing anyway; this names it rather than deriving it, and the picture is
  // the same on every backend.
  return min(textureLod(point_shadow_texture, atlas, 0.0).r,
             textureLod(point_shadow_static_texture, atlas, 0.0).r) * range;
}

float PointShadowTap(vec2 uv, vec2 offset, vec2 tile, float range,
                     float receiver) {
  float stored = PointShadowDistance(uv, offset, tile, range);
  // Nothing was drawn in that direction by either, so nothing is in the way.
  if (stored >= range * 0.999) return 1.0;
  return receiver > stored ? 0.0 : 1.0;
}

/// The disk point for tap [i], rotated by [ca]/[sa] and scaled to [radius].
vec2 PointShadowOffset(int i, float ca, float sa, float radius) {
  vec2 p = PointShadowDiskTap(i);
  return vec2(p.x * ca - p.y * sa, p.x * sa + p.y * ca) * radius;
}

/// How wide the penumbra should be here, in tile-local uv.
///
/// Contact hardening, and the reason a fixed kernel looks wrong: a shadow is
/// sharp where its caster touches the floor and soft a metre away, and one
/// radius for both makes the contact mushy or the distant edge hard.
///
/// The similar-triangles estimate is the standard one — a light of radius `L`
/// with a blocker at `b` and a receiver at `r` throws a penumbra `L * (r - b) /
/// b` wide at the receiver. Converting that to tile uv is exact rather than
/// tuned, because a face is a ninety degree frustum: at distance `r` from the
/// light the face spans `2 * r` in world units across the full `0..1` of uv,
/// so a world width `w` is `w / (2 * r)` of a tile.
///
/// The blocker search runs at the **widest** penumbra allowed, since a blocker
/// outside that circle cannot widen the result anyway, and searching narrower
/// would miss the very blockers that make an edge soft.
///
/// [tanHalf] is where the ninety degrees stop being assumed. The span above is
/// `2 * r` only for a right-angled frustum; in general it is `2 * r * tan(θ/2)`,
/// and for a cube face that factor is one. A narrower frustum covers less world
/// per tile, so the same world width is a *larger* fraction of it — which is
/// why this divides rather than multiplies, and why getting it upside down
/// would make a tight cone's shadows harden instead of soften.
float PointShadowPenumbra(vec2 uv, vec2 tile, float range, float receiver,
                          float ca, float sa, float tanHalf,
                          out float blockerOut) {
  blockerOut = -1.0;
  float lightRadius = point_shadow.params2.y;
  float minRadius = point_shadow.params2.x;
  float maxRadius = point_shadow.params2.z;
  if (lightRadius <= 0.0) {
    // **The debug channel is filled even though the search is skipped**, and
    // leaving it unfilled cost a session. `blockerOut` starts at −1 to mean
    // "nothing was measured"; the debug encoding clamps it into a colour, where
    // −1 becomes zero — the same green as a blocker touching the surface, which
    // reads as the most alarming answer available. A whole theory was built on
    // that zero, and the search it described had never run.
    //
    // The centre tap is what the filter below would use anyway, so this reports
    // a distance the atlas really returned rather than a sentinel.
    blockerOut = PointShadowDistance(uv, vec2(0.0), tile, range);
    return minRadius;
  }


  float sum = 0.0;
  float count = 0.0;
  for (int i = 0; i < 8; i++) {
    float stored =
        PointShadowDistance(uv, PointShadowOffset(i, ca, sa, maxRadius), tile,
                            range);
    if (stored >= range * 0.999) continue;
    if (stored >= receiver) continue;
    sum += stored;
    count += 1.0;
  }
  // Nothing in front of this fragment anywhere in the search: fully lit, and
  // the caller can skip the filter entirely.
  if (count < 0.5) return -1.0;

  float blocker = max(sum / count, 1e-4);
  blockerOut = blocker;
  float world = lightRadius * max(receiver - blocker, 0.0) / blocker;
  return clamp(world / (2.0 * receiver * tanHalf), minRadius, maxRadius);
}

/// How lit [world] is by the point light that owns the cube atlas.
///
/// One, fully lit, when this is not that light or the atlas has nothing to say.
float PointShadowFactor(vec3 world, vec3 normal, int lightIndex) {
  int slot = int(point_shadow.slots[lightIndex].x + 0.5);
  if (point_shadow.slots[lightIndex].x < 0.0) return 1.0;
  float strength = point_shadow.params.z;
  if (strength <= 0.0) return 1.0;

  // Offset along the normal before measuring, and scaled by how steeply the
  // surface leans away from the light.
  //
  // A soft kernel on a tilted surface straddles a depth gradient: the taps at
  // one end of the disk are further from the light than the fragment itself,
  // so a flat offset that clears the surface head-on leaves acne at a grazing
  // angle. The slope term lifts the whole kernel clear instead, and is capped
  // because it runs away as the surface turns edge-on to the light — an
  // uncapped lift detaches the shadow from its caster.
  vec3 toLight = point_shadow.lights[slot].xyz - world;
  float toLightLength = max(length(toLight), 1e-6);
  float nDotL = max(dot(normal, toLight / toLightLength), 0.15);
  float slope = min(sqrt(max(1.0 - nDotL * nDotL, 0.0)) / (nDotL * nDotL), 8.0);

  // **How wide one texel of the face is, out where this fragment is.** The
  // error a normal offset exists to clear is exactly that: a texel of the
  // shadow map covers a patch of surface, the whole patch is recorded at one
  // distance, and a fragment anywhere else in it compares against a distance
  // measured somewhere it is not. That patch grows with range — it is a solid
  // angle, not a length — so an offset fixed in metres is right at one distance
  // and wrong everywhere else.
  //
  // What it was: `params.w` metres, flat. On the golden teapot, at 9.6 m from
  // the lamp, a texel is 3.7 cm and the flat offset was 2 cm, so the floor
  // shadowed itself across everything the light reached — and the acne stopped
  // dead at the *projection of the floor's own edge*, because past it the atlas
  // holds nothing and nothing can occlude. A straight line across a shadow with
  // no straight edge anywhere in the scene.
  float texel =
      2.0 * toLightLength * max(point_shadow.slots[lightIndex].z, 1e-4) *
      point_shadow.params3.y;
  // Both terms are metres. The slope term used to be the kernel radius, which
  // is a fraction of a tile — a unit mismatch carried over from an estimate
  // where a softness radius genuinely was the right quantity. Here it meant
  // widening the kernel also lifted the sample off the surface, by up to ten
  // centimetres at the wider settings, so the softening and the lift
  // cancelled: tripling the kernel moved 184 pixels of the frame,
  // where the kernel alone moves thousands. It is what made contact hardening
  // look inert, and it was hiding in a comparison rather than in the estimate.
  vec3 origin = world + normal * texel * point_shadow.params.w * (1.0 + slope);
  vec3 toFragment = origin - point_shadow.lights[slot].xyz;
  float distance = length(toFragment);
  float range = max(point_shadow.lights[slot].w, 1e-4);
  if (distance >= range) return 1.0;

  // The dominant axis picks the face, in the order the renderer wrote them:
  // +X, -X, +Y, -Y, +Z, -Z, left to right then top to bottom.
  //
  // A spot has one column and no choice to make. Asking the dominant axis
  // anyway would be worse than pointless: a fragment below and to the side of
  // a downlight has −Y dominant, which is column 3, and column 3 of a spot's
  // row is deliberately blank — so the whole cone would read as unshadowed
  // except for the wedge where the aim happens to be the dominant axis.
  int face = 0;
  if (point_shadow.slots[lightIndex].y < 0.5) {
    vec3 a = abs(toFragment);
    if (a.x >= a.y && a.x >= a.z) {
      face = toFragment.x > 0.0 ? 0 : 1;
    } else if (a.y >= a.z) {
      face = toFragment.y > 0.0 ? 2 : 3;
    } else {
      face = toFragment.z > 0.0 ? 4 : 5;
    }
  }

  vec4 clip = point_shadow.faces[slot * 6 + face] * vec4(origin, 1.0);
  if (clip.w <= 0.0) return 1.0;
  vec2 ndc = clip.xy / clip.w;
  if (abs(ndc.x) > 1.0 || abs(ndc.y) > 1.0) return 1.0;

  // v is flipped, the same way the directional map does it: the texture's
  // origin is at the top, where row zero of the render target is. Getting this
  // wrong does not tilt the shadow — it makes the top row of faces read the
  // bottom row, so a whole region compares against an unrelated distance and
  // comes out as a black slab.
  vec2 uv = vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
  // The face across, the light down: six tiles wide, four tall.
  vec2 tile = vec2(float(face), float(slot));

  float receiver = distance - point_shadow.params.y;

  // One rotation, shared by the blocker search and the filter. Per fragment,
  // so eight samples read as a soft edge rather than as eight copies of the
  // silhouette: without it every fragment along an edge tests the same eight
  // directions and the pattern shows.
  //
  // **The three constants are not arbitrary and are not ours.** This is Jorge
  // Jimenez's interleaved gradient noise, from "Next Generation Post
  // Processing in Call of Duty: Advanced Warfare" (SIGGRAPH 2014):
  //
  //   IGN(x, y) = frac(52.9829189 * frac(0.06711056 * x + 0.00583715 * y))
  //
  // The pair inside the dot is a direction whose gradient walks the unit
  // interval as slowly as it can while never repeating over a screen, and the
  // multiplier outside stretches that walk so neighbouring pixels land far
  // apart in the result. What it buys over a hash is the cost: one dot and two
  // fracts, no integer arithmetic, no texture. What a blue-noise texture buys
  // over it is a better spectrum, at a sampler and a fetch — worth it for
  // dithering a whole frame, not for rotating eight taps.
  //
  // Written down because three unexplained decimals read as a magic spell, and
  // the next person to touch this line has no way to tell which of them may be
  // changed. The answer is none of them.
  float noise = fract(52.9829189 * fract(dot(FragCoordFromTop(
                                                frag_info.target_origin.x),
                                            vec2(0.06711056, 0.00583715))));
  float angle = noise * 6.28318530718;
  float ca = cos(angle);
  float sa = sin(angle);

  // Guarded rather than read straight, because a zero here divides by zero and
  // a NaN radius poisons the filter into a black fragment. Zero is what an
  // unwritten channel holds, and "unwritten" is a state this block has been in
  // before: every slot is cleared to −1 each frame.
  float tanHalf = max(point_shadow.slots[lightIndex].z, 1e-4);

  float blocker = -1.0;
  float radius =
      PointShadowPenumbra(uv, tile, range, receiver, ca, sa, tanHalf, blocker);

  // The debug channel, and the reason it exists: two explanations for why the
  // estimate collapses were argued from the finished picture and both were
  // wrong, because the number that decides it never leaves this function.
  //
  // Red is how wide the penumbra came out, against the widest allowed. Green
  // is how far away the blocker was, against the light's range. Blue marks
  // the fragments where the search found nothing at all — which is a different
  // answer from "found something very close", and telling those two apart is
  // most of the question.
  if (point_shadow.params2.w > 0.5) {
    g_debug_surface_on = true;
    g_debug_surface = radius < 0.0
        ? vec3(0.0, 0.0, 1.0)
        : vec3(clamp(radius / max(point_shadow.params2.z, 1e-6), 0.0, 1.0),
               clamp(blocker / range, 0.0, 1.0), 0.0);
  }

  // The search found nothing between here and the light.
  if (radius < 0.0) return 1.0;

  float lit = PointShadowTap(uv, vec2(0.0), tile, range, receiver);
  if (radius > 0.0) {
    for (int i = 0; i < 8; i++) {
      lit += PointShadowTap(uv, PointShadowOffset(i, ca, sa, radius), tile,
                            range, receiver);
    }
    lit *= 1.0 / 9.0;
  }

  // Strength lerps towards fully lit, so the control is "how dark", not "how
  // much of the kernel" — the same convention the directional map uses.
  return mix(1.0, lit, clamp(strength, 0.0, 1.0));
}

#else

/// The stand-in for a model that declares none of the above.
///
/// Fully lit, which is what a model with no shadow term means, and a constant
/// the compiler folds rather than a branch anything pays for.
float PointShadowFactor(vec3 world, vec3 normal, int lightIndex) {
  return 1.0;
}

#endif  // F3D_NO_POINT_SHADOW

vec3 AccumulateLights(Surface s) {
  vec3 total = vec3(0.0);
  int count = LightCount();

  for (int i = 0; i < kTotalLights; i++) {
    if (i >= count) break;
    LightSample light = SampleLight(i, s);
    if (light.n_dot_l <= 0.0) continue;
    // A light from the list has no shadow row to read — see `LightHasShadow`.
    // A branch rather than something folded into the two calls, because both
    // index tables eight entries wide and the ninth light would read past them
    // rather than read a one.
    float visibility = LightHasShadow(i)
        ? LightVisibility(s, light, i) *
              PointShadowFactor(v_world_position, s.n, i)
        : 1.0;
    if (visibility <= 0.0) continue;
    total += ShadeLight(s, light) * light.radiance * light.n_dot_l * visibility;
  }

  return total;
}

#endif  // SURFACE_GLSL_

// --- lib/irradiance.glsl ---
// The irradiance field, read per pixel — `L3`.
//
// **Per pixel where it was per object.** The field used to be sampled once
// per draw at the node's centre, twice (up and down), and handed to the shader
// as the hemisphere ambient. A floor that runs from a red wall to a blue one
// then took one colour, whichever its middle saw. Read here, at each point,
// the red bleeds onto the floor near the red wall and fades across it.
//
// The field arrives as one float texture: every probe's irradiance tile (rgb,
// with the probe's "active" flag in alpha) in a grid of `columns` × `rows`
// tiles at the top, and every probe's depth-moment tile (mean and mean
// square) in the same grid below. Each tile carries a one-texel gutter, so a
// bilinear read inside it never needs to know where the tile ends. The read
// is done here, four nearest taps at a time, rather than by a filtered
// sampler: a filtered float texture is a capability three backends answer
// differently, and four taps are the same on all of them.
//
// Weights per probe, as `IrradianceField.sample` on the host: trilinear by
// the point's place in its cell, the square of a half-cosine towards the
// probe, and Chebyshev's bound from the depth moments, the last two floored
// and crushed so no active probe's weight reaches nought. The point is moved
// off its surface along the normal and towards the eye first, so a surface
// does not read the probe's own view of it as a wall.
//
// Included by the lit models only, through `material_maps.glsl`.

#ifndef IRRADIANCE_GLSL_
#define IRRADIANCE_GLSL_

uniform sampler2D irradiance_texture;

layout(std140) uniform IrradianceInfo {
  /// xyz: where probe (0, 0, 0) stands. w: one when the field is read,
  /// nought when the hemisphere ambient stands.
  vec4 origin;

  /// xyz: the spacing between probes per axis. w: how far the point is
  /// moved along the normal, in metres.
  vec4 spacing;

  /// xyz: probes per axis. w: how far the point is moved towards the eye.
  vec4 counts;

  /// x: an irradiance tile's interior, y: a moment tile's, in texels.
  /// z: tiles per row of the atlas. w: the row the moment tiles start at.
  vec4 tiles;

  /// xy: one over the atlas's size. zw unused.
  vec4 atlas;
}
irradiance_info;

bool IrradianceEnabled() { return irradiance_info.origin.w > 0.5; }

/// `encodeOctahedral` in `irradiance_field.dart`.
vec2 ProbeOctahedral(vec3 direction) {
  float sum = abs(direction.x) + abs(direction.y) + abs(direction.z);
  if (sum <= 0.0) return vec2(0.5);
  vec3 n = direction / sum;
  vec2 xy = n.xy;
  if (n.z < 0.0) {
    xy = vec2((1.0 - abs(n.y)) * (n.x >= 0.0 ? 1.0 : -1.0),
              (1.0 - abs(n.x)) * (n.y >= 0.0 ? 1.0 : -1.0));
  }
  return xy * 0.5 + 0.5;
}

vec4 AtlasTexel(vec2 texel) {
  return textureLod(irradiance_texture, (texel + 0.5) * irradiance_info.atlas.xy,
                    0.0);
}

/// A bilinear read of the tile whose top-left stored texel is [corner],
/// [interior] wide, at the octahedral [uv].
vec4 TileBilinear(vec2 corner, float interior, vec2 uv) {
  vec2 at = 1.0 + uv * interior - 0.5;
  vec2 low = floor(at);
  vec2 f = at - low;
  vec4 a = AtlasTexel(corner + low);
  vec4 b = AtlasTexel(corner + low + vec2(1.0, 0.0));
  vec4 c = AtlasTexel(corner + low + vec2(0.0, 1.0));
  vec4 d = AtlasTexel(corner + low + vec2(1.0, 1.0));
  return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}

/// The irradiance arriving at [world] on a surface facing [normal], seen
/// from the direction [view] (a unit vector towards the eye).
vec3 SampleIrradiance(vec3 world, vec3 normal, vec3 view) {
  vec3 origin = irradiance_info.origin.xyz;
  vec3 spacing = irradiance_info.spacing.xyz;
  vec3 counts = irradiance_info.counts.xyz;
  float irradianceTile = irradiance_info.tiles.x;
  float depthTile = irradiance_info.tiles.y;
  float columns = irradiance_info.tiles.z;
  float momentsTop = irradiance_info.tiles.w;
  vec3 unit = normalize(normal);

  vec3 biased = world + unit * irradiance_info.spacing.w +
                view * irradiance_info.counts.w;
  vec3 grid = (biased - origin) / spacing;
  vec3 base = clamp(floor(grid), vec3(0.0), counts - 2.0);
  vec3 f = clamp(grid - base, vec3(0.0), vec3(1.0));

  vec3 total = vec3(0.0);
  float weights = 0.0;
  for (int corner = 0; corner < 8; corner++) {
    vec3 offset = vec3(float(corner & 1), float((corner >> 1) & 1),
                       float((corner >> 2) & 1));
    vec3 cell = base + offset;
    float probe = (cell.z * counts.y + cell.y) * counts.x + cell.x;
    vec2 tile = vec2(mod(probe, columns), floor(probe / columns));

    vec2 irradianceCorner = tile * (irradianceTile + 2.0);
    vec2 momentCorner = vec2(tile.x * (depthTile + 2.0),
                             momentsTop + tile.y * (depthTile + 2.0));

    // The probe's own flag, on the tile's first interior texel.
    if (AtlasTexel(irradianceCorner + 1.0).a < 0.5) continue;

    vec3 trilinear = mix(vec3(1.0) - f, f, offset);
    float weight = max(trilinear.x * trilinear.y * trilinear.z, 0.001);

    vec3 probePosition = origin + spacing * cell;
    vec3 toProbe = probePosition - biased;
    float distance = length(toProbe);
    if (distance > 1e-6) {
      vec3 direction = toProbe / distance;
      // Facing and visibility are floored, then crushed, rather than let
      // fall to nought (Majercik et al. 2019): a probe behind the surface or
      // past a wall counts for almost nothing but never for nothing, so a
      // point every probe of its cell is cut off from still reads a blend of
      // them rather than black.
      float facing = dot(unit, normalize(probePosition - world)) * 0.5 + 0.5;
      float probeWeight = facing * facing + 0.2;

      vec2 moments = TileBilinear(momentCorner, depthTile,
                                  ProbeOctahedral(-direction)).xy;
      float chebyshev = 1.0;
      if (distance > moments.x) {
        float variance = max(moments.y - moments.x * moments.x, 1e-6);
        float difference = distance - moments.x;
        chebyshev = variance / (variance + difference * difference);
        chebyshev = chebyshev * chebyshev * chebyshev;
      }
      probeWeight = max(probeWeight * max(chebyshev, 0.05), 1e-6);
      if (probeWeight < 0.2) probeWeight *= probeWeight * probeWeight * 25.0;
      weight *= probeWeight;
    }

    total += TileBilinear(irradianceCorner, irradianceTile,
                          ProbeOctahedral(unit)).rgb *
             weight;
    weights += weight;
  }
  return weights > 0.0 ? total / weights : vec3(0.0);
}

#endif  // IRRADIANCE_GLSL_


/// Tangent-space normal map. Neutral is (0.5, 0.5, 1.0).
uniform sampler2D normal_texture;

/// glTF's ORM packing: g is roughness, b is metallic. Neutral is white.
uniform sampler2D metallic_roughness_texture;

/// Ambient occlusion in r. Neutral is white.
uniform sampler2D occlusion_texture;

/// Emitted colour, multiplied by the emissive factor. Neutral is white, and the
/// factor defaults to black, so a material with neither emits nothing.
uniform sampler2D emissive_texture;

/// The level's baked lightmap, RGBM: colour over a shared multiplier, decoded
/// as `rgb × a × 8`. Sampled at the second coordinate, which every vertex
/// stage but the lightmapped one leaves at the atlas corner; neutral is
/// black, so a material without a map adds nothing.
uniform sampler2D lightmap_texture;

/// The irradiance the lightmap holds at this fragment, in the units a light's
/// `colour × intensity × attenuation × cos` arrives in.
vec3 SampleLightmap() {
  vec4 texel = texture(lightmap_texture, v_lightmap_uv);
  return texel.rgb * texel.a * 8.0;
}

/// One function per map, rather than one that applies all four.
///
/// Not a style choice. The compiler drops a sampler whose result never reaches
/// the output, so a model that samples the ORM map and then ignores metallic and
/// roughness — Lambert does exactly that — ends up with no
/// `metallic_roughness_texture` in its compiled signature at all, while the Dart
/// side still thinks there is one to bind. That is the phantom-binding trap
/// again, and binding a slot Metal does not have is a native crash.
///
/// Splitting them means a model calls only what it genuinely uses, so the
/// compiled signature matches the source, and `LightingModel` can declare the
/// same set truthfully. `tool/build_shaders.sh` prints the compiled slots so
/// the two cannot drift apart unnoticed.

/// glTF's ORM packing: roughness in g, metallic in b, both multiplying the
/// material factors.
void ApplyMetallicRoughnessMap(inout Surface s) {
  vec3 orm = texture(metallic_roughness_texture, MapUv(kMapMetallicRoughness), MaterialLodBias()).rgb;
  s.metallic = clamp(s.metallic * orm.b, 0.0, 1.0);
  s.roughness = clamp(s.roughness * orm.g, 0.02, 1.0);
}

void ApplyOcclusionMap(inout Surface s) {
  float occlusion = texture(occlusion_texture, MapUv(kMapOcclusion), MaterialLodBias()).r;
  // glTF's occlusionStrength lerps between "ignore the map" and "apply it in
  // full", which is why it is a mix and not a multiply.
  s.occlusion = mix(1.0, occlusion, clamp(frag_info.material2.z, 0.0, 1.0));
}

void ApplyEmissiveMap(inout Surface s) {
  vec3 emissive = SrgbToLinear(texture(emissive_texture, MapUv(kMapEmissive), MaterialLodBias()).rgb);
  s.emissive = emissive * frag_info.emissive.rgb * frag_info.material2.w;
}

/// Perturbs the surface normal by the tangent-space normal map.
void ApplyNormalMap(inout Surface s) {
  // **Sampled before the frame is tested, and that order is load-bearing.**
  // The test below is a branch on interpolated data, so the four invocations of
  // a quad can take different sides of it; a WGSL backend then refuses a
  // `texture` call underneath, because the mip level it derives is only defined
  // where the whole quad agrees. Unlike the shadow atlases, this map really is
  // mipped — a normal map read at full resolution on a surface turned away from
  // the camera is the aliasing that made this the widest disagreement between
  // backends — so pinning a level here would be a picture change, and hoisting
  // the sample is the cure that is not. A degenerate tangent is rare enough
  // that paying for its unused texel is nothing, and the texel it reads is the
  // same one the branch would have read.
  vec4 sampledTexel = texture(normal_texture, MapUv(kMapNormal), MaterialLodBias());

  // The tangent is re-orthogonalized against the normal because interpolating
  // both across a triangle does not preserve the right angle between them.
  vec3 t = v_tangent.xyz;
  t = t - s.n * dot(s.n, t);
  if (dot(t, t) < 1e-12) return;  // no usable frame; keep the vertex normal
  t = normalize(t);

  // The bitangent sign is what encodes a mirrored UV island. Dropping it makes
  // every mirrored half of a symmetric model light from the wrong side, which
  // is exactly what NormalTangentTest is built to show.
  vec3 b = cross(s.n, t) * v_tangent.w;
#ifdef F3D_TEXTURE_TRANSFORM
  // `C8`: a map turned or mirrored by its transform is read along axes the
  // vertex tangent no longer names, so the frame turns with it — the rule
  // `withTextureTransform` applies to a baked mesh, here at the sampler. The
  // new tangent is where the map's own `u` increases: the first column of the
  // matrix's inverse, times its determinant, whose sign a mirror flips and the
  // bitangent's sign with it. Measured on the front face's frame, which is
  // the frame the transform was authored on. A plain scale leaves the frame
  // as it was, bit for bit, which is why the test is on the matrix. That
  // column is `m11 dP/du - m10 dP/dv`, and dP/dv is **minus** the bitangent:
  // `v` runs down the texture, a normal map's green up it.
  vec4 m = MapMatrix(kMapNormal);
  float det = m.x * m.w - m.y * m.z;
  float flip = det < 0.0 ? -1.0 : 1.0;
  vec3 front = gl_FrontFacing ? b : -b;
  vec3 turned = (t * m.w + front * m.z) * flip;
  bool turns = (m.y != 0.0 || m.z != 0.0 || m.x < 0.0 || m.w < 0.0) &&
               dot(turned, turned) > 1e-12;
  t = turns ? normalize(turned) : t;
  b = turns ? cross(s.n, t) * v_tangent.w * flip : b;
#endif
  // On a back face `ReadSurface` has already turned the normal round, and
  // the bitangent above turned with it. The tangent has to follow, or the
  // frame is half-mirrored and relief along u lights from the wrong side —
  // glTF turns the whole frame, not the normal alone.
  if (!gl_FrontFacing) t = -t;

  vec3 sampled = sampledTexel.xyz * 2.0 - 1.0;
  // A two-channel map (BC5, RG8) stores only x and y and samples as
  // (x, y, 0, 1); read as it stands, blue 0 is z = -1 and the normal points
  // into the surface. z is rebuilt from the unit length instead, before the
  // scale, which glTF applies to the stored normal. `emissive.w` is the flag.
  if (frag_info.emissive.w > 0.5) {
    sampled.z = sqrt(max(1.0 - dot(sampled.xy, sampled.xy), 0.0));
  }
  // normalScale attenuates the tangent-space xy, per the glTF spec.
  sampled.xy *= frag_info.material2.y;

  s.n = normalize(t * sampled.x + b * sampled.y + s.n * sampled.z);
  s.n_dot_v = max(dot(s.n, s.v), 1e-4);
}

/// The three maps every lit model uses. Metal-rough is separate because only
/// the models that actually respond to metallic or roughness may sample it.
void ApplyCommonMaps(inout Surface s) {
  // `L3`: the field in place of the hemisphere, read before the normal map
  // for the reason the hemisphere is — which half of the room a face sees is
  // not a question about millimetres of relief. At the same strength the
  // hemisphere was.
  if (IrradianceEnabled()) {
    s.ambient = SampleIrradiance(v_world_position, s.n, s.v) *
                frag_info.material.z;
  }
  ApplyNormalMap(s);
  ApplyOcclusionMap(s);
  ApplyEmissiveMap(s);
}

#endif  // MATERIAL_MAPS_GLSL_

// --- lib/shadow.glsl ---
// Sampling the directional light's shadow map.
//
// A separate header for the same reason material_maps.glsl is one: the sampler
// must only be declared by shaders that actually read it, or the compiler drops
// the slot while the engine still tries to bind it.

#ifndef SHADOW_GLSL_
#define SHADOW_GLSL_

// --- lib/evsm.glsl ---
// Exponential variance shadow maps — `S2`.
//
// Shared by the pass that turns the directional depth atlas into moments
// (`evsm_filter.frag`) and by `ShadowFactor`, which reads them back: the two
// halves must warp depth with the same two exponents, or every comparison is
// between numbers on different scales.
//
// A header of its own rather than a section of `shadow.glsl`, because that
// one declares the lit stages' shadow sampler and the filter pass has no
// business declaring it.

#ifndef EVSM_GLSL_
#define EVSM_GLSL_

precision highp float;

// The two exponents depth is warped by. **Forty and five, and the ceiling is
// the format.** The moments are stored squared, so the positive side reaches
// e^80 at the far plane, about 5.5e34 — inside a 32-bit float with three
// orders of magnitude to spare, and far outside a half float, which is why
// the moments live in an rgba32f atlas and the depth atlas does not. The
// negative side only has to catch what the positive side lets through at a
// receiver just behind a caster, and five is the usual answer.
const float kEvsmPositive = 40.0;
const float kEvsmNegative = 5.0;

/// [depth], in [0, 1], warped onto both exponentials: x positive, y negative.
///
/// Depth is first spread to [-1, 1] so the two sides share the range evenly
/// rather than the negative one flattening to nothing at the far end.
vec2 EvsmWarp(float depth) {
  float d = 2.0 * clamp(depth, 0.0, 1.0) - 1.0;
  return vec2(exp(kEvsmPositive * d), -exp(-kEvsmNegative * d));
}

/// What one texel of the depth atlas stores in the moments atlas: each warp
/// and its square, which a blur then averages into a mean and a variance.
vec4 EvsmMoments(float depth) {
  vec2 warped = EvsmWarp(depth);
  return vec4(warped.x, warped.x * warped.x, warped.y, warped.y * warped.y);
}

/// Chebyshev's upper bound on the share of [moments]'s distribution at or
/// beyond [t], with the light-bleeding cut [bleed] taken off the bottom.
///
/// A select at the end rather than an early return of one, because a phi of
/// constants is what SPIRV-Cross refuses when it writes the WGSL.
float EvsmChebyshev(vec2 moments, float t, float minVariance, float bleed) {
  float variance = max(moments.y - moments.x * moments.x, minVariance);
  float d = t - moments.x;
  float pMax = variance / (variance + d * d);
  // Light bleeding: where two casters overlap, the bound admits light the
  // nearer one should block. Everything under [bleed] is called shadow and
  // the rest stretched back over [0, 1].
  float reduced = clamp((pMax - bleed) / max(1.0 - bleed, 1e-4), 0.0, 1.0);
  return t <= moments.x ? 1.0 : reduced;
}

/// How much light reaches a receiver at [depth] past filtered [moments].
///
/// The smaller of the two bounds: each exponential lets through a different
/// kind of error, and neither lets through what the other stops.
float EvsmVisibility(vec4 moments, float depth, float bleed) {
  vec2 warped = EvsmWarp(depth);
  // A floor on the variance proportional to the warped depth's own slope,
  // so a flat receiver compared against its own texel does not divide
  // nought by nought — the variance of one depth is zero.
  vec2 scale = 0.0001 * vec2(kEvsmPositive, kEvsmNegative) * warped;
  float positive = EvsmChebyshev(moments.xy, warped.x, scale.x * scale.x, bleed);
  float negative = EvsmChebyshev(moments.zw, warped.y, scale.y * scale.y, bleed);
  return min(positive, negative);
}

#endif  // EVSM_GLSL_


/// Linear depth from the light's point of view, in the red channel — or,
/// with the `evsm` filter (`S2`), the blurred moments `evsm_filter.frag`
/// made of it, bound to the same slot so the lit stages spend no sampler on
/// the choice.
uniform sampler2D shadow_texture;

/// Point [i] of [n] on a Vogel disc turned by [turn] radians — `S3`: the
/// golden angle between neighbours, so any prefix of the points covers the
/// disc evenly, and a radius growing with the square root, so they cover it
/// at an even density.
vec2 VogelDisc(int i, int n, float turn) {
  float r = sqrt((float(i) + 0.5) / float(n));
  float theta = float(i) * 2.3999632 + turn;
  return r * vec2(cos(theta), sin(theta));
}

/// Interleaved gradient noise at this pixel, in [0, 1), stepped on by the
/// frame's slice while a temporal resolve runs (`target_origin.w`) so the
/// history averages the rotations. The pattern needs no texture, which keeps
/// the lit stages at the samplers they have. Rows are counted from the top
/// (`target_origin.x`), as the point shadow's rotation counts them, so WebGL2
/// turns the kernel on the same pixels as every other backend.
float ShadowNoise() {
  vec2 at = FragCoordFromTop(frag_info.target_origin.x) +
            5.588238 * max(frag_info.target_origin.w, 0.0);
  return fract(52.9829189 * fract(dot(at, vec2(0.06711056, 0.00583715))));
}

/// How much of the light survives at this fragment, from 0 to 1.
///
/// Returns 1 when shadows are off, when the fragment falls outside the map, or
/// when the light in question is not the caster — a fragment beyond the shadow
/// volume is unshadowed, not black, and getting that wrong puts a hard edge
/// across the scene at the edge of the map.
float ShadowFactor(Surface s, LightSample light, int lightIndex) {
  float strength = frag_info.shadow_params.w;
  if (strength <= 0.0) return 1.0;
  if (lightIndex != int(frag_info.frame_params.z + 0.5)) return 1.0;

  // Normal offset: move the sample point along the surface normal before
  // projecting it. It costs nothing and fixes the shadow acne that a depth bias
  // alone cannot, because the error is proportional to the surface's slope
  // relative to the light rather than to depth.
  //
  // **A flat distance plus what the kernel's reach needs, and no more.** The
  // flat part alone was tuned for surfaces the map never recorded: with the
  // default `casterFaces: back` a closed mesh writes only the faces turned
  // away from the sun, so a lit face compares against its own far side. A
  // double-sided material writes its lit faces too, and then the offset has
  // to lift the point clear of its own plane as far out as the 3×3 kernel
  // reads: a tap one texel over lands in a texel whose centre is up to a
  // texel and a half away, where the plane is 1.5·texel·tanθ nearer the
  // light. A step d along the normal clears the plane by d / cosθ along the
  // ray, so d = 1.5·texel·sinθ is exactly enough, taken per axis of the map
  // because a slope running diagonally across it reaches further in texels.
  // Nothing at normal incidence, a texel and a half at grazing. The depth
  // bias covers the rest. Every metre more than this moves the shadow away
  // from its caster, and in the far cascade a texel is decimetres. Measured
  // per cascade in the loop below, since each has a texel of its own.

  // Which cascade covers this fragment.
  //
  // Chosen by distance from the camera and then *checked*, because the volumes
  // are spheres on the line of sight rather than fitted frusta: a fragment at
  // the edge of the view can be past the end of the cascade its distance
  // suggests. Falling through to the next one costs a branch and removes a
  // whole class of missing-shadow bug, and the last cascade is fitted to the
  // entire scene, so the fall-through always terminates somewhere real.
  int cascadeCount = int(frag_info.shadow_cascades.z + 0.5);
  float viewDistance = length(v_world_position - frag_info.camera_position.xyz);
  int cascade = 0;
  if (cascadeCount > 1 && viewDistance > frag_info.shadow_cascades.x) cascade = 1;
  if (cascadeCount > 2 && viewDistance > frag_info.shadow_cascades.y) cascade = 2;

  vec2 uv = vec2(0.0);
  vec3 projected = vec3(0.0);
  bool found = false;
  // `S3`: what the soft path needs of the cascade it lands in — metres per
  // texel across, and metres per unit of stored depth along the light.
  float cascadeTexel = 1.0;
  float cascadeDepth = 1.0;
  for (int attempt = 0; attempt < 3; attempt++) {
    int which = cascade + attempt;
    if (which >= cascadeCount) break;

    mat4 matrix = which == 0
        ? frag_info.shadow_matrix
        : (which == 1 ? frag_info.shadow_matrix_far
                      : frag_info.shadow_matrix_farthest);
    // One texel of this cascade in metres. The projection is orthographic,
    // so its first row is 2 / width, and a tile texel is `shadow_cascades.w`
    // of the width. The rows are also the map's axes in the world, which is
    // what the normal is measured along: its share across each axis is the
    // sine of the slope in that direction.
    vec3 axisX = vec3(matrix[0][0], matrix[1][0], matrix[2][0]);
    vec3 axisY = vec3(matrix[0][1], matrix[1][1], matrix[2][1]);
    float rowX = max(length(axisX), 1e-6);
    float rowY = max(length(axisY), 1e-6);
    float texelMetres = 2.0 * frag_info.shadow_cascades.w / rowX;
    float reach = 1.5 * 2.0 * frag_info.shadow_cascades.w *
        (abs(dot(s.n, axisX)) / (rowX * rowX) +
         abs(dot(s.n, axisY)) / (rowY * rowY));
    vec3 origin = v_world_position + s.n * (frag_info.shadow_params.z + reach);
    vec4 lightSpace = matrix * vec4(origin, 1.0);
    if (lightSpace.w <= 0.0) continue;
    vec3 candidate = lightSpace.xyz / lightSpace.w;

    // Clip space x and y are in [-1, 1]; a tile is in [0, 1] with the origin at
    // the top, matching where the render target's row zero is.
    vec2 inTile = vec2(candidate.x * 0.5 + 0.5, 0.5 - candidate.y * 0.5);
    if (inTile.x < 0.0 || inTile.x > 1.0 || inTile.y < 0.0 || inTile.y > 1.0) {
      continue;
    }
    // Depth is already in [0, 1] here, as every projection in this engine
    // produces. **Past the far plane is behind every caster, not outside the
    // map.** The last cascade's depth is fitted to the casters alone, so a
    // floor that runs on past them — the tip of a long evening shadow — sits
    // beyond it. Skipping that point called it lit and cut the shadow off
    // along the line where the far plane meets the floor. A nearer cascade
    // may still be missing casters and hands the point on; the last one
    // clamps, and 1.0 compares lit only against a texel nothing was drawn in.
    if (candidate.z > 1.0) {
      if (which < cascadeCount - 1) continue;
      candidate.z = 1.0;
    }

    // Into the atlas: the cascades sit side by side in one texture.
    uv = vec2((inTile.x + float(which)) / float(cascadeCount), inTile.y);
    projected = candidate;
    cascade = which;
    cascadeTexel = texelMetres;
    cascadeDepth =
        1.0 / max(length(vec3(matrix[0][2], matrix[1][2], matrix[2][2])), 1e-6);
    found = true;
    break;
  }
  if (!found) return 1.0;

  float bias = cascade == 0
      ? frag_info.shadow_bias.x
      : (cascade == 1 ? frag_info.shadow_bias.y : frag_info.shadow_bias.z);
  // Horizontally a texel of the atlas, vertically a texel of a tile. With one
  // cascade they are the same number and this is the kernel it has always been.
  vec2 texel = vec2(frag_info.shadow_params.x, frag_info.shadow_cascades.w);

  // **Every tap is held inside its own cascade's tile**, half a texel in from
  // the edge, and after the offset rather than before: the cube atlas learned
  // this first (`PointShadowDistance`). The cascades sit side by side, so a
  // tap that stepped past a seam read the neighbouring cascade's depth,
  // measured through another projection, and a fragment at the edge of the
  // near tile took its shadow partly from the far one. With one cascade the
  // tile is the whole texture and the clamp is the sampler's own edge.
  vec2 tileLo = vec2(float(cascade) / float(cascadeCount), 0.0) + 0.5 * texel;
  vec2 tileHi =
      vec2(float(cascade + 1) / float(cascadeCount), 1.0) - 0.5 * texel;

  // **`textureLod` and not `texture`, and the level asked for is the only one
  // there is.** Everything above this loop is a reason not to be here — the
  // cascade search returns early when no cascade contains the fragment, and the
  // light loop that calls it skips a light facing away — so a WGSL backend sees
  // a sample taken where the four invocations of a quad need not agree, and
  // refuses it: the implicit derivative `texture` asks for is only defined
  // where they all arrive. The cascade atlas is a depth render target with a
  // single level, so the derivative was never doing anything but selecting
  // level zero, and naming that level directly costs nothing and changes no
  // pixel on any backend.
  //
  // **The softness, where it rides, and what zero means.**
  //
  // `ambient_ground.w` is the directional light's apparent size. It has
  // nothing to do with ambient light and everything to do with this being the
  // one component left unspent in a block six shaders share: `frame_params.w`
  // was the slot reserved for exactly this and the environment's level count
  // took it, and appending to the block moves offsets four backends have
  // agreed on. The alternative was a second uniform block bound per draw for
  // one float. Named here because a reader arriving at `ambient_ground` has
  // every right to be surprised.
  //
  // Zero is the 3×3 kernel this has always had, which is what keeps every
  // recorded golden where it is. Above zero the edge widens with the distance
  // between the occluder and what it falls on — what a real light does, and
  // what no fixed kernel can.
  //
  // **Below zero is the `evsm` filter** (`S2`), and the texture bound here is
  // then the moments atlas rather than depth: one filtered tap replaces the
  // kernel, and how far under minus one the value sits is the light-bleeding
  // cut. A sign rather than another uniform, for the reason the softness
  // itself rides here.
  float softness = frag_info.ambient_ground.w;
  float lit = 0.0;
  if (softness < 0.0) {
    // The blur already happened, once for the whole atlas, so the one tap
    // is the filter: the sampler's own bilinear step is all it adds.
    vec4 moments = textureLod(shadow_texture, clamp(uv, tileLo, tileHi), 0.0);
    lit = EvsmVisibility(moments, projected.z - bias,
                         clamp(-softness - 1.0, 0.0, 0.95));
  } else if (softness <= 0.0) {
    // PCF 3x3. Four samples would band visibly at this map size and nine is
    // the smallest kernel that reads as a soft edge rather than as stair
    // steps.
    for (int y = -1; y <= 1; y++) {
      for (int x = -1; x <= 1; x++) {
        float occluder = textureLod(
            shadow_texture,
            clamp(uv + vec2(float(x), float(y)) * texel, tileLo, tileHi),
            0.0).r;
        lit += projected.z - bias > occluder ? 0.0 : 1.0;
      }
    }
    lit *= 1.0 / 9.0;
  } else {
    // **Find what is casting before deciding how wide to blur**, then blur by
    // what a light of this size would leave — `S3`. Sixteen taps each way on
    // a Vogel disc turned per pixel, where there were five fixed ones: the
    // turn trades the five's regular pattern for noise the eye reads as
    // grain, and a temporal resolve averages away.
    //
    // **In metres, per cascade.** The gap between the blocker and this
    // fragment is measured in the cascade's stored depth, whose unit is a
    // different length in each cascade; converted to metres, the penumbra is
    // the gap times the light's apparent diameter, and in texels it is that
    // over the cascade's own texel. A shadow keeps its softness crossing
    // from one cascade into the next.
    //
    // **A radius, so half that width.** A disc of radius R swept across an
    // edge ramps from dark to lit over 2R, so the kernel is the gap times
    // the tangent of the light's angular *radius*: the penumbra comes out the
    // full `2·tan(α)·gap` the settings promise, not twice it. The search is
    // the same cone, `tan(α)` of the way back to the light; a wider one only
    // pulls in blockers that cannot reach this fragment.
    float spread = tan(min(softness, 0.5));
    float turn = ShadowNoise() * 6.2831853;

    // As wide as the widest penumbra could be at this depth, and no wider:
    // the whole of the distance back to the light is the largest gap there
    // is.
    float searchRadius =
        clamp(spread * projected.z * cascadeDepth / cascadeTexel, 1.0, 16.0);
    float blockerSum = 0.0;
    float blockerCount = 0.0;
    for (int i = 0; i < 16; i++) {
      float occluder = textureLod(
          shadow_texture,
          clamp(uv + VogelDisc(i, 16, turn) * texel * searchRadius, tileLo,
                tileHi),
          0.0).r;
      if (projected.z - bias > occluder) {
        blockerSum += occluder;
        blockerCount += 1.0;
      }
    }
    // Nothing between this fragment and the light: lit, and no second loop.
    if (blockerCount <= 0.0) return 1.0;

    float gap = max(projected.z - blockerSum / blockerCount, 0.0) * cascadeDepth;
    // One texel at the tightest, so a contact edge stays an edge; the cap
    // keeps a distant occluder from reaching across a whole cascade.
    float radius = clamp(spread * gap / cascadeTexel, 1.0, 16.0);

    for (int i = 0; i < 16; i++) {
      float occluder = textureLod(
          shadow_texture,
          clamp(uv + VogelDisc(i, 16, turn + 1.0) * texel * radius, tileLo,
                tileHi),
          0.0).r;
      lit += projected.z - bias > occluder ? 0.0 : 1.0;
    }
    lit *= 1.0 / 16.0;
  }

  // Strength lerps towards fully lit, so the control is "how dark", not "how
  // much of the kernel".
  return mix(1.0, lit, clamp(strength, 0.0, 1.0));
}

#endif  // SHADOW_GLSL_


#ifdef F3D_LAYERED
/// What a layered material adds to metal-rough — `M1`. A block of its own
/// rather than members appended to `FragInfo`: six stages share that block and
/// none of the other five reads a layer.
layout(std140) uniform LayerInfo {
  /// rgb: `KHR_materials_specular`'s colour, linear. w: its strength.
  vec4 specular;

  /// x: the clear coat, y: its perceptual roughness, z: the index of
  /// refraction, w: unused.
  vec4 coat;

  /// rgb: `KHR_materials_sheen`'s colour, linear. w: its roughness — `M2`.
  vec4 sheen;

  /// x: `KHR_materials_anisotropy`'s strength, y and z: the cosine and sine
  /// of its rotation from the tangent. w: unused.
  vec4 anisotropy;

  /// x: `KHR_materials_transmission`, y: the volume's thickness, z: its
  /// attenuation distance, nought for a medium that takes nothing away, w:
  /// `KHR_materials_dispersion` — `M3`.
  vec4 transmission;

  /// rgb: the volume's attenuation colour, linear. w: unused.
  vec4 attenuation;

  /// x: `KHR_materials_iridescence`, y: the film's index of refraction, z:
  /// its thickness in nanometres. w: unused.
  vec4 iridescence;

  /// `KHR_texture_transform` per map — `C8`: two rows of a 2×3 matrix each,
  /// the map's coordinate being `(dot(row0.xyz, uvw), dot(row1.xyz, uvw))`
  /// with `uvw = (u, v, 1)`, in the order `kMapBaseColor` and the rest count.
  /// The identity for a map that names none, which reads the coordinate
  /// unchanged to the bit: one times `u`, plus nought twice.
  ///
  /// **Here and not baked into the vertices**, which is what an atlas export
  /// gets: one set of coordinates can carry one transform, and this is the
  /// material whose maps disagree, or whose offset a clip moves.
  vec4 uv_transform[10];

  /// The copy of the scene behind the transmissive draws — `M3`. x: how many
  /// levels the copy has, its base included, and nought outside the pass
  /// that draws them, where the environment stands in as it always did. y,
  /// z: one over the copy's width and height in texels. w: unused.
  vec4 scene_colour;

  /// Where this view sits in the copy's base level, in its texture
  /// coordinates: xy the corner, zw the size.
  vec4 scene_viewport;

  /// Each level's rectangle in the copy, in its texture coordinates: xy the
  /// corner, zw the size. The levels share one texture side by side — see
  /// `SceneColourChain`.
  vec4 scene_levels[6];

  /// The view-projection the draw was made with, turned to the rows of the
  /// framebuffer as every screen-space pass turns it.
  mat4 scene_view_projection;
}
layer_info;

vec2 MapUv(int slot) {
  vec3 uvw = vec3(v_texcoord, 1.0);
  return vec2(dot(layer_info.uv_transform[slot * 2].xyz, uvw),
              dot(layer_info.uv_transform[slot * 2 + 1].xyz, uvw));
}

vec4 MapMatrix(int slot) {
  vec4 u = layer_info.uv_transform[slot * 2];
  vec4 v = layer_info.uv_transform[slot * 2 + 1];
  return vec4(u.x, u.y, v.x, v.y);
}

/// The coat map: r the clear coat, g its roughness, b the transmission and a
/// the thickness, each multiplying its factor — `M3` reads b and a. White when a
/// material has none. One texture where glTF gives up to four, because the
/// lit stages have two samplers left under WebGL2's sixteen.
uniform sampler2D coat_texture;

/// The sheen map — `M2`: rgb the sheen colour, sRGB as authored, and a its
/// roughness, each multiplying its factor. White when a material has none.
uniform sampler2D sheen_texture;

/// The scene as it stood before the transmissive draws, every level of it in
/// one texture — `M3`. Black, and never read, outside the pass that draws
/// them. The stage's sixteenth sampler, and the last WebGL2 promises.
uniform sampler2D scene_colour_texture;

/// The layers at this fragment, resolved once by [ReadLayers] and read by
/// every light: the dielectric's reflectance head-on and at grazing, and the
/// coat — how much, how rough, which way it faces, and what it leaves of what
/// is under it.
vec3 g_f0_dielectric = vec3(0.04);
float g_f90 = 1.0;
float g_coat = 0.0;
float g_coat_roughness = 0.02;
vec3 g_coat_n = vec3(0.0, 0.0, 1.0);
float g_coat_n_dot_v = 1.0;
float g_coat_through = 1.0;

/// The sheen — `M2`: its colour and roughness, and what its albedo leaves of
/// the layer beneath. And the anisotropy: how strong, and the frame the
/// highlight stretches along, on the normal the maps leave.
vec3 g_sheen = vec3(0.0);
float g_sheen_roughness = 0.07;
float g_sheen_albedo = 0.0;
float g_sheen_scale = 1.0;
float g_aniso = 0.0;
vec3 g_aniso_t = vec3(1.0, 0.0, 0.0);
vec3 g_aniso_b = vec3(0.0, 1.0, 0.0);

/// The transmission — `M3`: how much passes through, how thick the medium
/// is, and what of each colour survives that thickness. And the thin film:
/// how much, and the Fresnel its interference gives at this view.
float g_transmission = 0.0;
float g_thickness = 0.0;
vec3 g_transmittance = vec3(1.0);
float g_iridescence = 0.0;
vec3 g_irid_fresnel = vec3(0.04);

/// Whether the index is `KHR_materials_ior`'s nought: the value its
/// specular-glossiness migration writes, which means an index of infinity —
/// a Fresnel of one at every angle, and no dispersion.
bool IorInfinite() { return layer_info.coat.z == 0.0; }

/// The index the refraction bends by. Infinity is stood in for by an index
/// so large that the ray leaves along the normal, which is where an infinite
/// one sends it; anything else below one is held at one.
float RefractionIor() {
  return IorInfinite() ? 1.0e4 : max(layer_info.coat.z, 1.0);
}

/// How far the dispersion spreads the index over red and blue; nothing at an
/// infinite index, which the extension says dispersion leaves alone.
float DispersionSpread(float ior) {
  return IorInfinite() ? 0.0 : (ior - 1.0) * 0.025 * layer_info.transmission.w;
}

/// Fills the globals above from the block and the coat map.
///
/// Called before the normal map bends `s.n`, because the coat is lit on the
/// geometric normal: a lacquer over a bumpy base is smooth, and that is what
/// makes car paint read as car paint.
void ReadLayers(Surface s) {
  vec4 coatTexel = texture(coat_texture, v_texcoord, MaterialLodBias());
  vec4 sheenTexel = texture(sheen_texture, v_texcoord, MaterialLodBias());
  g_sheen = layer_info.sheen.rgb * SrgbToLinear(sheenTexel.rgb);
  // Floored where the Charlie lobe's exponent would outgrow a half float,
  // which is also where `tool/make_tables.dart` floors its albedo.
  g_sheen_roughness = clamp(layer_info.sheen.w * sheenTexel.a, 0.07, 1.0);
  // `KHR_materials_ior` and `KHR_materials_specular`: the reflectance a
  // dielectric of this index has head-on, tinted and scaled, and the
  // strength alone at grazing. 1.5, white and one give 0.04 and 1 — plain
  // metal-rough. An index of nought is infinity, whose reflectance is one
  // head-on as at grazing, so the tint and the strength are all that is left.
  float ior = max(layer_info.coat.z, 1.0);
  float r = IorInfinite() ? 1.0 : (ior - 1.0) / (ior + 1.0);
  g_f0_dielectric =
      min(vec3(r * r) * layer_info.specular.rgb, vec3(1.0)) *
      layer_info.specular.w;
  g_f90 = layer_info.specular.w;
  g_coat = clamp(layer_info.coat.x * coatTexel.r, 0.0, 1.0);
  g_coat_roughness = clamp(layer_info.coat.y * coatTexel.g, 0.02, 1.0);
  // `M3`: the coat map's other two lanes.
  g_transmission = clamp(layer_info.transmission.x * coatTexel.b, 0.0, 1.0);
  g_thickness = max(layer_info.transmission.y * coatTexel.a, 0.0);
  // Beer's law over the thickness: what is left of each colour after the
  // attenuation distance is the attenuation colour.
  float distance = layer_info.transmission.z;
  g_transmittance =
      distance > 0.0
          ? pow(max(layer_info.attenuation.rgb, vec3(1e-4)),
                vec3(g_thickness / distance))
          : vec3(1.0);
  g_iridescence = clamp(layer_info.iridescence.x, 0.0, 1.0);
  g_coat_n = s.n;
  g_coat_n_dot_v = max(dot(s.n, s.v), 1e-4);
  // The coat is a dielectric of index 1.5, and what it reflects towards the
  // eye does not reach the layer under it: everything beneath is scaled by
  // what its Fresnel lets through.
  float fc = 0.04 + 0.96 * pow(1.0 - g_coat_n_dot_v, 5.0);
  g_coat_through = 1.0 - g_coat * fc;
}

/// The half of the layers that depends on the normal the maps leave: the
/// sheen's albedo at this view, and the anisotropy's frame. Called after
/// the maps, before any light.
void ReadLayersOnMaps(Surface s) {
  // `M2`: the sheen's directional albedo, from the LTC table's spare lane,
  // and what it leaves of everything under the sheen.
  g_sheen_albedo =
      textureLod(ltc_texture,
                 LtcUv(g_sheen_roughness,
                       sqrt(clamp(1.0 - s.n_dot_v, 0.0, 1.0)), 1.0),
                 0.0)
          .z;
  g_sheen_scale =
      1.0 - max(max(g_sheen.r, g_sheen.g), g_sheen.b) * g_sheen_albedo;

  // The tangent frame `ApplyNormalMap` builds, on the normal it left, turned
  // by the rotation. A surface without a usable tangent stays isotropic.
  vec3 t = v_tangent.xyz - s.n * dot(s.n, v_tangent.xyz);
  bool usable = dot(t, t) > 1e-12;
  t = usable ? normalize(t) : vec3(1.0, 0.0, 0.0);
  vec3 b = cross(s.n, t) * v_tangent.w;
  if (!gl_FrontFacing) t = -t;
  vec2 turn = layer_info.anisotropy.yz;
  vec3 along = t * turn.x + b * turn.y;
  g_aniso = usable && dot(along, along) > 1e-12
                ? clamp(layer_info.anisotropy.x, 0.0, 1.0)
                : 0.0;
  g_aniso_t = g_aniso > 0.0 ? normalize(along) : t;
  g_aniso_b = cross(s.n, g_aniso_t);
}
#endif  // F3D_LAYERED

/// The environment, convolved by roughness: level zero is a mirror and the last
/// is rough enough to stand in for irradiance. Built by `EnvironmentMap`.
uniform samplerCube environment_texture;

/// The split-sum BRDF: the scale and bias to apply to F0, whose sum is the
/// lobe's directional albedo `Ess`.
///
/// **Read from the LTC table, not fitted.** Its second half already holds,
/// in x and y, the GGX lobe with height-correlated Smith — the lobe
/// [ShadeLight] evaluates — integrated over the hemisphere at this
/// roughness and view, plain and weighted by Schlick's `(1 − v·h)⁵`: the
/// scale is their difference and the bias the second. An analytic fit stood
/// here before, made against another BRDF; it was a sixth dark head-on at
/// mid roughness and turned the falloff of a rough metal upside down, and
/// the energy compensation that divides by its sum inherited both.
vec2 EnvBrdf(float roughness, float n_dot_v) {
  vec2 dfg = textureLod(ltc_texture,
                        LtcUv(clamp(roughness, 0.0, 1.0),
                              sqrt(clamp(1.0 - n_dot_v, 0.0, 1.0)), 1.0),
                        0.0)
                 .xy;
  return vec2(dfg.x - dfg.y, dfg.y);
}

/// The least perceptual roughness the GGX lobe is evaluated at, above the
/// surface's own floor. At 0.045 alpha² is 4·10⁻⁶, which keeps the peak of
/// [D_GGX] representable and its denominator, which is never below alpha²,
/// clear of the guard that stops a division by nought; below it the guard
/// cut the peak and a mirror's highlight lost most of its light.
const float kMinGgxRoughness = 0.045;

float D_GGX(float n_dot_h, float alpha) {
  float a = n_dot_h * alpha;
  float k = alpha / max(1.0 - n_dot_h * n_dot_h + a * a, 1e-7);
  return k * k * (1.0 / kPi);
}

float V_SmithGGXCorrelated(float n_dot_v, float n_dot_l, float alpha) {
  float a2 = alpha * alpha;
  float lambda_v = n_dot_l * sqrt(n_dot_v * n_dot_v * (1.0 - a2) + a2);
  float lambda_l = n_dot_v * sqrt(n_dot_l * n_dot_l * (1.0 - a2) + a2);
  return 0.5 / max(lambda_v + lambda_l, 1e-5);
}

vec3 F_Schlick(vec3 f0, float v_dot_h) {
  float f = pow(1.0 - v_dot_h, 5.0);
  return f0 + (vec3(1.0) - f0) * f;
}

#ifdef F3D_LAYERED
/// [F_Schlick] towards [f90] rather than towards one at grazing — what
/// `KHR_materials_specular`'s strength scales.
vec3 F_SchlickF90(vec3 f0, vec3 f90, float v_dot_h) {
  float f = pow(1.0 - v_dot_h, 5.0);
  return f0 + (f90 - f0) * f;
}

/// The clear coat's own GGX lobe for [light], on the coat's normal, with the
/// Fresnel of a dielectric of index 1.5. Scaled so that the loop's `n_dot_l`,
/// which is the base's, becomes the coat's: the coat faces the geometric
/// normal and the base may face the normal map's.
///
/// **A rectangle's coat is integrated over the panel, as the base's is** —
/// `L7`. Evaluated at the representative point instead, the lobe's peak
/// multiplied the panel's whole form factor: wherever the mirror ray lands on
/// the panel the half vector is the normal, and a coat as smooth as a
/// varnish has a peak in the tens of thousands, so the panel's reflection
/// came out thousands of times brighter than the few per cent a coat
/// reflects. The same tables at the coat's roughness, on the coat's normal,
/// over the corners `SampleLight` kept; over `n_dot_l` for the base's reason.
float CoatLobe(Surface s, LightSample light) {
  if (light.integrated > 0.5) {
    vec3 ltc = LtcRectangle(g_coat_n, s.v, g_coat_roughness, g_rect_corners);
    return ltc.x * (0.04 * ltc.y + 0.96 * ltc.z) * frag_info.material.w /
           max(light.n_dot_l, 1e-6);
  }
  float lobe = max(g_coat_roughness, kMinGgxRoughness);
  float alpha = lobe * lobe;
  float n_dot_l = max(dot(g_coat_n, light.l), 0.0);
  float n_dot_h = max(dot(g_coat_n, light.h), 0.0);
  float d = D_GGX(n_dot_h, alpha);
  float vis = V_SmithGGXCorrelated(g_coat_n_dot_v, n_dot_l, alpha);
  float f = 0.04 + 0.96 * pow(1.0 - light.v_dot_h, 5.0);
  return d * vis * f * frag_info.material.w * n_dot_l /
         max(light.n_dot_l, 1e-6);
}

/// The Charlie sheen distribution, Estevez and Kulla's, with Filament's
/// floor on `sin²θ` so the power stays inside a half float.
float D_Charlie(float roughness, float n_dot_h) {
  float inv_alpha = 1.0 / (roughness * roughness);
  float sin2h = max(1.0 - n_dot_h * n_dot_h, 0.0078125);
  return (2.0 + inv_alpha) * pow(sin2h, inv_alpha * 0.5) / (2.0 * kPi);
}

/// What a thin film's interference does to the colours it reflects, at an
/// optical path difference [opd] in nanometres and a phase [shift]: the
/// spectral sensitivity of the eye, as Gaussians in XYZ, taken to linear
/// Rec. 709. Belcour and Barla, "A Practical Extension to Microfacet Theory
/// for the Modeling of Varying Iridescence", 2017, with the constants the
/// glTF sample viewer uses.
vec3 IridescenceSensitivity(float opd, vec3 shift) {
  float phase = 2.0 * kPi * opd * 1.0e-9;
  vec3 val = vec3(5.4856e-13, 4.4201e-13, 5.2481e-13);
  vec3 pos = vec3(1.6810e+06, 1.7953e+06, 2.2084e+06);
  vec3 variance = vec3(4.3278e+09, 9.3046e+09, 6.6121e+09);
  vec3 xyz = val * sqrt(2.0 * kPi * variance) * cos(pos * phase + shift) *
             exp(-(phase * phase) * variance);
  xyz.x += 9.7470e-14 * sqrt(2.0 * kPi * 4.5282e+09) *
           cos(2.2399e+06 * phase + shift.x) *
           exp(-4.5282e+09 * phase * phase);
  xyz /= 1.0685e-7;
  return mat3(3.2404542, -0.9692660, 0.0556434, -1.5371385, 1.8760108,
              -0.2040259, -0.4985314, 0.0415560, 1.0572252) *
         xyz;
}

/// The reflectance of a film of index [film] and [thickness] nanometres over
/// a base of reflectance [base], seen at [cos1] — the two-bounce Airy sum of
/// Belcour and Barla. Total internal reflection inside the film reflects
/// everything, chosen at the end rather than returned early.
vec3 FresnelIridescence(float film, float cos1, float thickness, vec3 base) {
  // A film thinning to nothing fades to the base, not to a step.
  float eta2 = mix(1.0, film, smoothstep(0.0, 0.03, thickness));
  float sin2Sq = (1.0 - cos1 * cos1) / (eta2 * eta2);
  float cos2Sq = 1.0 - sin2Sq;
  float cos2 = sqrt(max(cos2Sq, 0.0));

  float r0 = (eta2 - 1.0) / (eta2 + 1.0);
  float r12 = r0 * r0 + (1.0 - r0 * r0) * pow(1.0 - cos1, 5.0);
  float t121 = 1.0 - r12;
  float phi12 = eta2 < 1.0 ? kPi : 0.0;
  float phi21 = kPi - phi12;

  vec3 sqrtBase = sqrt(clamp(base, vec3(0.0), vec3(0.9999)));
  vec3 baseIor = (vec3(1.0) + sqrtBase) / (vec3(1.0) - sqrtBase);
  vec3 r1 = (baseIor - vec3(eta2)) / (baseIor + vec3(eta2));
  r1 *= r1;
  vec3 r23 = r1 + (vec3(1.0) - r1) * pow(1.0 - cos2, 5.0);
  vec3 phi23 = vec3(baseIor.x < eta2 ? kPi : 0.0, baseIor.y < eta2 ? kPi : 0.0,
                    baseIor.z < eta2 ? kPi : 0.0);

  float opd = 2.0 * eta2 * thickness * cos2;
  vec3 phi = vec3(phi21) + phi23;
  vec3 r123 = clamp(r12 * r23, vec3(1e-5), vec3(0.9999));
  vec3 rootR123 = sqrt(r123);
  vec3 rs = t121 * t121 * r23 / (vec3(1.0) - r123);
  vec3 total = vec3(r12) + rs;
  vec3 cm = rs - vec3(t121);
  for (int m = 1; m <= 2; m++) {
    cm *= rootR123;
    total += cm * 2.0 * IridescenceSensitivity(float(m) * opd, float(m) * phi);
  }
  return cos2Sq < 0.0 ? vec3(1.0) : max(total, vec3(0.0));
}

/// Fills the thin film's Fresnel for this fragment, on the base reflectance
/// the maps left. Called after `ReadLayersOnMaps`.
void ReadIridescence(Surface s) {
  vec3 f0 = mix(g_f0_dielectric, s.albedo, clamp(s.metallic, 0.0, 1.0));
  g_irid_fresnel = FresnelIridescence(layer_info.iridescence.y, s.n_dot_v,
                                      layer_info.iridescence.z, f0);
}

/// The environment seen through the surface — `M3`.
///
/// **The environment, where there is no scene to read.** What passes through
/// glass is read from the cube the reflections read, bent by the index when
/// the material has a volume and straight through when it is thin-walled, as
/// the volume extension distinguishes them. The objects behind the glass are
/// not in that cube; [SceneBehind] reads them instead wherever the frame made
/// a copy of the scene, and this is what a draw outside that pass — a probe's
/// capture, the view model — still sees. Dispersion spreads the index over
/// red, green and blue and reads each on its own ray.
vec3 TransmittedRadiance(Surface s, float levels) {
  float ior = RefractionIor();
  float spread = DispersionSpread(ior);
  // A rough glass blurs what is behind it more the denser it is.
  float lod = s.roughness * clamp(ior * 2.0 - 2.0, 0.0, 1.0) * levels;
  bool thin = g_thickness <= 0.0;
  vec3 red = thin ? -s.v : refract(-s.v, s.n, 1.0 / max(ior - spread, 1.0));
  vec3 green = thin ? -s.v : refract(-s.v, s.n, 1.0 / ior);
  vec3 blue = thin ? -s.v : refract(-s.v, s.n, 1.0 / (ior + spread));
  return vec3(textureLod(environment_texture, red, lod).r,
              textureLod(environment_texture, green, lod).g,
              textureLod(environment_texture, blue, lod).b);
}

/// Whether this draw has the copy of the scene to read — `M3`.
bool SceneColourBound() { return layer_info.scene_colour.x > 0.0; }

/// Level [level] of the copy at [uv], a coordinate of the picture as a whole.
/// Held half a texel inside the level's rectangle, so a bilinear tap never
/// reaches the level beside it in the same texture.
vec3 SceneColourLevel(vec2 uv, int level) {
  vec4 rect = layer_info.scene_levels[level];
  vec2 inset = 0.5 * layer_info.scene_colour.yz;
  vec2 at = rect.xy + clamp(uv * rect.zw, inset, max(rect.zw - inset, inset));
  return textureLod(scene_colour_texture, at, 0.0).rgb;
}

/// The copy where [world] lands on the screen, blurred to level [lod] and
/// blended between the two levels either side of it, as a trilinear sampler
/// would. Held inside this view, so a ray bent past its edge reads the edge
/// rather than the view beside it.
vec3 SceneColourAt(vec3 world, float lod) {
  vec4 clip = layer_info.scene_view_projection * vec4(world, 1.0);
  vec2 ndc = clip.xy / max(clip.w, 1e-6);
  vec2 view = clamp(vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5), vec2(0.0),
                    vec2(1.0));
  vec2 uv = layer_info.scene_viewport.xy + view * layer_info.scene_viewport.zw;
  float top = layer_info.scene_colour.x - 1.0;
  float level = clamp(lod, 0.0, top);
  float lower = floor(level);
  vec3 near = SceneColourLevel(uv, int(lower));
  vec3 far = SceneColourLevel(uv, int(min(lower + 1.0, top)));
  return mix(near, far, level - lower);
}

/// The scene seen through the surface — `M3`: where the ray the index bends
/// leaves the far side of the volume, as the copy made before this pass holds
/// it, at a level chosen by the roughness. A thin wall bends nothing and
/// reads what lies straight behind it.
///
/// **The level is log2 of the base width times the roughness**, the way the
/// glTF sample renderer reads its transmission target, and not the roughness
/// times the chain's own length. Each level is a box average of the scene,
/// as a mip level is, so level k is a blur of 2^k texels however long the
/// chain is, and the halvings a width has are what take roughness 1 to a
/// single texel. Scaled by the chain instead, a 0.5-rough glass at an index
/// of 1.5 read level 2.5 where it should read 5, a blur of about six texels
/// against thirty-two, and frosted glass looked nearly clear. The chain
/// still ends at `SceneColourChain.maxLevels`, where [SceneColourAt] clamps,
/// so at a thousand texels wide anything rougher than about half reads its
/// last level.
///
/// The width is the base level's, in texels: its rectangle's width over one
/// texel of the texture, which is the size of the scene the chain was copied
/// from.
///
/// **The thickness is in world units as authored.** glTF measures it in the
/// mesh's own space; a node scaled up or down refracts as if it were not,
/// because the stage has no model matrix to scale it by.
vec3 SceneBehind(Surface s) {
  float ior = RefractionIor();
  float spread = DispersionSpread(ior);
  float width =
      layer_info.scene_levels[0].z / max(layer_info.scene_colour.y, 1e-9);
  float lod = log2(max(width, 1.0)) * s.roughness *
              clamp(ior * 2.0 - 2.0, 0.0, 1.0);
  bool thin = g_thickness <= 0.0;
  vec3 red = thin ? -s.v : refract(-s.v, s.n, 1.0 / max(ior - spread, 1.0));
  vec3 green = thin ? -s.v : refract(-s.v, s.n, 1.0 / ior);
  vec3 blue = thin ? -s.v : refract(-s.v, s.n, 1.0 / (ior + spread));
  return vec3(SceneColourAt(v_world_position + red * g_thickness, lod).r,
              SceneColourAt(v_world_position + green * g_thickness, lod).g,
              SceneColourAt(v_world_position + blue * g_thickness, lod).b);
}

/// Neubelt and Pettineo's visibility for cloth.
float V_Neubelt(float n_dot_v, float n_dot_l) {
  return 1.0 / (4.0 * (n_dot_l + n_dot_v - n_dot_l * n_dot_v));
}

/// GGX stretched along [t] — `KHR_materials_anisotropy`, the form its
/// specification gives: [at] the roughness along the tangent, [ab] across.
float D_GGXAnisotropic(float n_dot_h, float t_dot_h, float b_dot_h, float at,
                       float ab) {
  float a2 = at * ab;
  vec3 f = vec3(ab * t_dot_h, at * b_dot_h, a2 * n_dot_h);
  float w2 = a2 / max(dot(f, f), 1e-12);
  return a2 * w2 * w2 / kPi;
}

float V_GGXAnisotropic(float n_dot_l, float n_dot_v, float b_dot_v,
                       float t_dot_v, float t_dot_l, float b_dot_l, float at,
                       float ab) {
  float ggx_v = n_dot_l * length(vec3(at * t_dot_v, ab * b_dot_v, n_dot_v));
  float ggx_l = n_dot_v * length(vec3(at * t_dot_l, ab * b_dot_l, n_dot_l));
  return clamp(0.5 / max(ggx_v + ggx_l, 1e-5), 0.0, 1.0);
}
#endif  // F3D_LAYERED

float LightVisibility(Surface s, LightSample light, int index) {
  return ShadowFactor(s, light, index);
}

/// Whether the energy lost to single scattering is put back — `L1`,
/// `RenderSettings.energyCompensation`, in `FragInfo.target_origin.z`.
bool EnergyCompensation() { return frag_info.target_origin.z > 0.5; }

/// The light GGX loses on a rough metal, returned as the factor its single
/// scattering has to be multiplied by: one plus f0 times the share of the
/// hemisphere the single-scattering albedo misses. Fdez-Agüera's term, with
/// the albedo the split sum already reads — the albedo of the very lobe it
/// scales, at the roughness [ShadeLight] evaluates it at, or the white
/// furnace would not come back white.
vec3 MultiscatterScale(vec3 f0, Surface s) {
  vec2 ab = EnvBrdf(max(s.roughness, kMinGgxRoughness), s.n_dot_v);
  float ess = max(ab.x + ab.y, 1e-4);
  return vec3(1.0) + f0 * (1.0 / ess - 1.0);
}

/// Whether the diffuse lobe is EON rather than Lambert — `L8`,
/// `RenderSettings.diffuseModel`, in `FragInfo.ambient_sky.w`.
bool EonDiffuse() { return frag_info.ambient_sky.w > 0.5; }

/// The two constants of the Fujii Oren–Nayar lobe EON is built on:
/// `1/2 − 2/(3π)`, which normalises its A term, and `2/3 − 28/(15π)`, which
/// with it gives the lobe's albedo averaged over the hemisphere.
const float kFonA = 0.5 - 2.0 / (3.0 * kPi);
const float kFonAverage = 2.0 / 3.0 - 28.0 / (15.0 * kPi);

/// The Fujii Oren–Nayar lobe's directional albedo at a cosine [mu] and
/// roughness [r]: Portsmouth, Kutz and Hill's quartic fit of the exact
/// integral, which trades an `acos` and a division by [mu] for four
/// multiply-adds.
float FonAlbedo(float mu, float r) {
  float m = 1.0 - mu;
  float g = m * (0.0571085289 +
                 m * (0.491881867 + m * (-0.332181442 + m * 0.0714429953)));
  return (1.0 + r * g) / (1.0 + kFonA * r);
}

/// The single-scattering lobe's albedo averaged over the hemisphere.
float FonAverage(float r) {
  return (1.0 + kFonAverage * r) / (1.0 + kFonA * r);
}

/// The albedo the light bouncing between the facets comes back with: one
/// more factor of [rho] per bounce, summed. This is what saturates a rough
/// colour, and what makes a white surface keep every bit of the light.
vec3 EonMultiAlbedo(vec3 rho, float average) {
  return rho * rho * average / (vec3(1.0) - rho * (1.0 - average));
}

/// EON, "An energy-preserving Oren–Nayar model", Portsmouth, Kutz and Hill,
/// 2024: the Fujii Oren–Nayar lobe for one bounce off the facets, plus a
/// lobe shaped by what that one misses at each end for the rest. [rho] the
/// diffuse colour, [r] the roughness, [mu_i] and [mu_o] the cosines to the
/// light and to the eye, [l_dot_v] the cosine between them. Divided by π,
/// as `diffuseColor / kPi` is, so it stands in for it.
vec3 EonLobe(vec3 rho, float r, float mu_i, float mu_o, float l_dot_v) {
  // Oren–Nayar's `s / t`: how far the light and the eye stand on the same
  // side of the normal, which is where the facets turned to both are seen.
  float s = l_dot_v - mu_i * mu_o;
  float s_over_t = s > 0.0 ? s / max(mu_i, mu_o) : s;
  float af = 1.0 / (1.0 + kFonA * r);
  vec3 single = rho * (af * (1.0 + r * s_over_t));
  float average = FonAverage(r);
  vec3 multi = EonMultiAlbedo(rho, average) *
               (max(1.0 - FonAlbedo(mu_o, r), 1e-7) *
                max(1.0 - FonAlbedo(mu_i, r), 1e-7) /
                max(1.0 - average, 1e-7));
  return (single + multi) / kPi;
}

/// [EonLobe] integrated over the hemisphere of light at a cosine [mu] to the
/// eye: what it reflects of light that comes from everywhere alike, as an
/// ambient, an environment's irradiance and a lightmap are taken to.
vec3 EonAlbedo(vec3 rho, float r, float mu) {
  float e = FonAlbedo(mu, r);
  return rho * e + EonMultiAlbedo(rho, FonAverage(r)) * (1.0 - e);
}

vec3 ShadeLight(Surface s, LightSample light) {
  // Perceptual roughness is squared to get the GGX alpha; this is what makes
  // the roughness slider feel linear. Held at the lobe's own floor, which
  // sits above the surface's.
  float lobe = max(s.roughness, kMinGgxRoughness);
  float alpha = lobe * lobe;

  // Dielectrics reflect ~4% at normal incidence; metals tint the reflection
  // with their own albedo and have no diffuse response.
#ifdef F3D_LAYERED
  // The dielectric's own reflectance, from its index and specular layer.
  vec3 f0 = mix(g_f0_dielectric, s.albedo, s.metallic);
  vec3 f90 = vec3(mix(g_f90, 1.0, s.metallic));
#else
  vec3 f0 = mix(vec3(0.04), s.albedo, s.metallic);
#endif
  vec3 diffuseColor = s.albedo * (1.0 - s.metallic);

  float d = D_GGX(light.n_dot_h, alpha);
  float vis = V_SmithGGXCorrelated(s.n_dot_v, light.n_dot_l, alpha);
#ifdef F3D_LAYERED
  if (g_aniso > 0.0) {
    // `M2`: the lobe stretched along the tangent, as far as the strength
    // says; across it, the roughness as it was.
    float at = mix(alpha, 1.0, g_aniso * g_aniso);
    float ab = max(alpha, 1e-3);
    d = D_GGXAnisotropic(light.n_dot_h, dot(g_aniso_t, light.h),
                         dot(g_aniso_b, light.h), at, ab);
    vis = V_GGXAnisotropic(light.n_dot_l, s.n_dot_v, dot(g_aniso_b, s.v),
                           dot(g_aniso_t, s.v), dot(g_aniso_t, light.l),
                           dot(g_aniso_b, light.l), at, ab);
  }
  vec3 f = F_SchlickF90(f0, f90, light.v_dot_h);
  // `M3`: the thin film's colours in place of the plain Fresnel.
  f = mix(f, g_irid_fresnel, g_iridescence);
#else
  vec3 f = F_Schlick(f0, light.v_dot_h);
#endif

  vec3 specular = d * vis * f * frag_info.material.w;
  if (light.integrated > 0.5) {
    // `L7`: the lobe already integrated over the rectangle, with the fit's
    // own Fresnel. Divided by `n_dot_l` because the loop multiplies by it,
    // and that is the diffuse form factor, not a term of this; the `kPi` the
    // return applies is the same calibration the diffuse gets.
#ifdef F3D_LAYERED
    specular = light.ltc.x * (f0 * light.ltc.y + (f90 - f0) * light.ltc.z) *
               frag_info.material.w / max(light.n_dot_l, 1e-6);
#else
    specular = light.ltc.x * (f0 * light.ltc.y + (1.0 - f0) * light.ltc.z) *
               frag_info.material.w / max(light.n_dot_l, 1e-6);
#endif
  }
  if (EnergyCompensation()) specular *= MultiscatterScale(f0, s);
  // Energy left over after reflection is what scatters diffusely.
  vec3 diffuse = diffuseColor * (vec3(1.0) - f) / kPi;
  if (EonDiffuse()) {
    // `L8`: on the direction to the light rather than `n_dot_l`, which for
    // a rectangle is a form factor and not a cosine.
    diffuse = EonLobe(diffuseColor, s.roughness,
                      clamp(dot(s.n, light.l), 1e-4, 1.0), s.n_dot_v,
                      dot(light.l, s.v)) *
              (vec3(1.0) - f);
  }
#ifdef F3D_LAYERED
  // `M3`: what passes through is not scattered back; a light on the viewer's
  // side reaches the eye through transmission only by the environment.
  diffuse *= 1.0 - g_transmission;
#endif

  // The pi puts the result back on the scale the tone mapper and the exposure
  // default were calibrated against.
#ifdef F3D_LAYERED
  // Under the sheen, what its albedo leaves; under the coat, what its
  // Fresnel lets through; on top, the coat's own lobe. The sheen's
  // visibility takes a cosine, which `n_dot_l` is not under a rectangle.
  vec3 sheen = g_sheen * D_Charlie(g_sheen_roughness, light.n_dot_h) *
               V_Neubelt(s.n_dot_v, clamp(dot(s.n, light.l), 0.0, 1.0));
  return (((diffuse + specular) * g_sheen_scale + sheen) * g_coat_through +
          vec3(g_coat * CoatLobe(s, light))) *
         kPi;
#else
  return (diffuse + specular) * kPi;
#endif
}

void main() {
  Surface s = ReadSurface();
#ifdef F3D_LAYERED
  ReadLayers(s);
#endif
  ApplyCommonMaps(s);
  ApplyMetallicRoughnessMap(s);
#ifdef F3D_LAYERED
  ReadLayersOnMaps(s);
  ReadIridescence(s);
#endif

  float metallic = clamp(s.metallic, 0.0, 1.0);
  vec3 diffuseColor = s.albedo * (1.0 - metallic);
  // `L8`: light that arrives from everywhere alike — the flat ambient, the
  // environment's irradiance, a lightmap, and under glass what passes
  // through — is reflected by the EON lobe's albedo at this view rather than
  // by the colour itself.
  if (EonDiffuse()) {
    diffuseColor = EonAlbedo(diffuseColor, s.roughness, s.n_dot_v);
  }

  // Ambient occlusion darkens indirect light. It is applied to the direct term
  // too, which is not physical, but with no environment the flat ambient is far
  // too weak for an occlusion map to be visible otherwise.
  vec3 ambient = diffuseColor * s.ambient * s.occlusion;
#ifdef F3D_LAYERED
  // `M3`: without an environment the light passing through is the flat
  // ambient too, less what the medium takes — unless the scene behind is
  // there to be read, when that share is the scene instead (below).
  ambient *= mix(vec3(1.0), SceneColourBound() ? vec3(0.0) : g_transmittance,
                 g_transmission);
#endif

  float levels = frag_info.frame_params.w;
#ifdef F3D_LAYERED
  // What the coat reflects of the environment; nothing without one, since
  // the flat ambient has no specular part for it to have. The sheen's
  // incoming light, which without an environment is the flat ambient.
  vec3 coatAmbient = vec3(0.0);
  vec3 sheenIncoming = s.ambient;
#endif
  if (levels > 0.0) {
    // **This is the term that made metal black.** A metal has no diffuse
    // response at all, so with nothing to reflect it was lit by direct light
    // alone and read as very nearly unlit — which is why the games reached for
    // dark dielectrics wherever they wanted gunmetal.
#ifdef F3D_LAYERED
    vec3 f0 = mix(g_f0_dielectric, s.albedo, metallic);
    float f90 = mix(g_f90, 1.0, metallic);
    // `M2`: an anisotropic surface reflects along a normal bent towards the
    // stretch, the specification's own approximation.
    vec3 bent = s.n;
    if (g_aniso > 0.0) {
      vec3 across = cross(g_aniso_t, s.v);
      vec3 anisoN = cross(across, g_aniso_t);
      float bend = 1.0 - g_aniso * (1.0 - s.roughness);
      float bend4 = bend * bend * bend * bend;
      bent = normalize(mix(anisoN, s.n, bend4));
    }
    vec3 reflected = reflect(-s.v, bent);
#else
    vec3 f0 = mix(vec3(0.04), s.albedo, metallic);
    vec3 reflected = reflect(-s.v, s.n);
#endif

    // The roughest level stands in for irradiance. Not a true Lambert
    // convolution — see `EnvironmentMap.diffuseLevel`, which says the same
    // thing from the other side and states what it costs.
    vec3 irradiance = textureLod(environment_texture, s.n, levels).rgb;
    vec3 prefiltered =
        textureLod(environment_texture, reflected, s.roughness * levels).rgb;
    vec2 ab = EnvBrdf(s.roughness, s.n_dot_v);

    // Scaled by the strength in the slot the flat term above reads, which is
    // why the two are interchangeable rather than additive: whichever term
    // runs, it runs at `material.z`. **What that number is depends on what is
    // bound.** A scene's own environment is scaled by `Scene.ambientIntensity`,
    // the same knob the flat term uses, so a scene that dials its indirect
    // light down dials both; a reflection probe brings its own
    // `ReflectionProbeNode.intensity` instead, because a probe is the room's
    // light already measured. The renderer decides which — see `_encodeNode`
    // in renderer_mesh_encode.dart — and this stage cannot tell them apart.
    // The surface's single-scatter albedo, thin film included: the specular
    // term and the multiscatter term below both read this one value, so an
    // iridescent surface tints the light it scatters twice as it tints the
    // light it scatters once.
#ifdef F3D_LAYERED
    vec3 single = mix(f0, g_irid_fresnel, g_iridescence) * ab.x + f90 * ab.y;
#else
    vec3 single = f0 * ab.x + ab.y;
#endif
    vec3 specular = prefiltered * single;
    if (EnergyCompensation()) {
      // Fdez-Agüera: the single-scattered part as it was, and the multiple
      // scattering it misses added from the irradiance, tinted by the average
      // Fresnel — `L1`.
      float missed = 1.0 - (ab.x + ab.y);
      vec3 average = f0 + (vec3(1.0) - f0) / 21.0;
      vec3 multiple = single * average / (vec3(1.0) - missed * average);
      specular += multiple * missed * irradiance;
    }
    ambient = (diffuseColor * irradiance + specular) * frag_info.material.z *
              s.occlusion;
#ifdef F3D_LAYERED
    // `M3`: the transmitted share of the diffuse light is the environment
    // behind the surface instead, less what the dielectric reflects and what
    // the medium takes, tinted by the base colour as glTF tints it.
    // With the scene behind to read, the environment's share is taken away
    // and the scene's added below.
    vec3 reflects =
        mix(g_f0_dielectric, g_irid_fresnel, g_iridescence) * ab.x + g_f90 * ab.y;
    vec3 through = SceneColourBound()
                       ? vec3(0.0)
                       : TransmittedRadiance(s, levels) * g_transmittance *
                             (vec3(1.0) - min(reflects, vec3(1.0)));
    ambient += diffuseColor * (through - irradiance) * g_transmission *
               frag_info.material.z * s.occlusion;
    // The coat reflects the environment too, on its own normal and at its
    // own roughness, over what it lets through of the layer beneath.
    vec3 coatPrefiltered = textureLod(environment_texture,
                                      reflect(-s.v, g_coat_n),
                                      g_coat_roughness * levels)
                               .rgb;
    vec2 coatAb = EnvBrdf(g_coat_roughness, g_coat_n_dot_v);
    coatAmbient = coatPrefiltered * (0.04 * coatAb.x + coatAb.y) * g_coat *
                  frag_info.material.z * s.occlusion;
    sheenIncoming =
        textureLod(environment_texture, s.n, g_sheen_roughness * levels).rgb *
        frag_info.material.z;
#endif
  }
#ifdef F3D_LAYERED
  // `M3`: the transmitted share is the scene behind, where the pass has a
  // copy of it — less what the dielectric reflects and what the medium
  // takes, tinted by the base colour. Light already, so neither the ambient
  // strength nor the occlusion scales it.
  if (SceneColourBound()) {
    vec2 sceneAb = EnvBrdf(s.roughness, s.n_dot_v);
    vec3 sceneReflects =
        mix(g_f0_dielectric, g_irid_fresnel, g_iridescence) * sceneAb.x +
        g_f90 * sceneAb.y;
    ambient += diffuseColor * SceneBehind(s) * g_transmittance *
               (vec3(1.0) - min(sceneReflects, vec3(1.0))) * g_transmission;
  }
#endif
  // The light the level's walls throw on each other, baked: diffuse only,
  // since a lightmap holds irradiance and a metal has no diffuse response.
  // Zero from the one-texel black a material without a map is bound to.
  //
  // **Added rather than chosen between, and the choosing happens above this
  // shader.** A lightmap and an environment's roughest level are two answers
  // to the same question — how much indirect light reaches this point — so a
  // draw that had both would count it twice. There is no flag here to branch
  // on: a material without a map is bound the neutral black by design (see
  // material_maps.glsl), which is what makes this a plain add. The renderer
  // keeps the two apart instead, by handing no reflection probe to a
  // lightmapped draw; see `_encodeNode` in renderer_mesh_encode.dart. A sky
  // environment over a lightmapped level still adds, and should: sky light
  // is not what the bake measured.
  ambient += diffuseColor * SampleLightmap() * s.occlusion;

#ifdef F3D_LAYERED
  // What shines from under the coat is dimmed by it on the way out, the
  // emission included — glTF's own layering. The direct light was scaled in
  // `ShadeLight`.
  vec3 sheenAmbient = g_sheen * g_sheen_albedo * sheenIncoming * s.occlusion;
  WriteSurface(
      AccumulateLights(s) * s.occlusion +
          (ambient * g_sheen_scale + sheenAmbient + s.emissive) *
              g_coat_through +
          coatAmbient,
      s.alpha,
      s.roughness);
#else
  WriteSurface(
      AccumulateLights(s) * s.occlusion + ambient + s.emissive,
      s.alpha,
      s.roughness);
#endif
}

#endif  // PBR_GLSL_


''',
    'Toon': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// Cel shading: the diffuse response is quantized into bands and a rim term
// fakes a backlight.
//
// Included because a stylised model stresses the permutation design differently
// from the physical ones — it needs no camera-dependent specular but does need
// the view vector for the rim, so it proves the shared surface interface is
// genuinely model-agnostic.
// --- lib/material_maps.glsl ---
// The texture maps a lit material can carry, beyond base colour.
//
// A separate header from surface.glsl on purpose. Declaring a sampler a shader
// never reads is the same trap as declaring an unused uniform block: the
// compiled function has no such slot, while the Dart side still has metadata
// saying it does. Unlit and the debug models include surface.glsl (or only
// color.glsl) and get none of this; the lit models include both, and
// LightingModel.usesMaterialTextures says which is which.
//
// Every map has a *neutral* fallback texture bound when the material has none,
// so there are no "has this map" flags to keep in sync — a white ORM texture
// multiplies the factors by one, and a flat normal map perturbs nothing. Flags
// would have to be right in two places; a neutral texel is right by
// construction.

#ifndef MATERIAL_MAPS_GLSL_
#define MATERIAL_MAPS_GLSL_

// --- lib/surface.glsl ---
// Shared material and lighting interface for the lighting models.
//
// flutter_gpu compiles shaders ahead of time into a bundle: there is no runtime
// compilation, so a node-graph material system assembled at run time is
// impossible. Each lighting model is therefore
// its own pre-built fragment shader, and this header is what keeps them
// interchangeable — one identical uniform block, so the Dart binding code never
// needs to know which model is active.
//
// Keep every declaration below byte-identical across models. A member a model
// does not read may be optimized out of the reflected block, which is why the
// Dart side skips absent members instead of failing.
//
// Only include this from a shader that actually reads FragInfo. Declaring the
// block without using it leaves it visible to reflection while the compiled
// shader binds no buffer for it, and binding that phantom block segfaults
// inside Metal. Shaders needing only colour helpers include lib/color.glsl.

#ifndef SURFACE_GLSL_
#define SURFACE_GLSL_

// --- lib/color.glsl ---
// Colour space helpers and the fragment output interface.
//
// Split out of surface.glsl so a shader that needs no material inputs — the
// normals debug view — can avoid DECLARING the FragInfo uniform block at all.
// That matters more than it looks: reflection metadata reports a block as
// present merely because it was declared, even when the compiled shader binds
// no such buffer, so a declared-but-unused block is indistinguishable from a
// used one until Metal crashes on the bind.

#ifndef COLOR_GLSL_
#define COLOR_GLSL_

precision highp float;

const float kPi = 3.14159265359;

// One varying set shared by every fragment shader, matching mesh.vert.
//
// All five are declared here, including the two the debug models never read: a
// fragment shader whose `in` block disagrees with the vertex shader's `out`
// block fails to link, and there is no partial-match rule to lean on.
in vec3 v_world_position;
in vec3 v_normal;
in vec2 v_texcoord;
in vec4 v_tangent;
in vec4 v_color;

/// Where this fragment is in the level's lightmap. Zero from every vertex
/// stage but `mesh_lightmapped.vert`, and read only by the lit models, which
/// sample a one-texel black there when a material has no map.
in vec2 v_lightmap_uv;

layout(location = 0) out vec4 frag_color;

// The second attachment: what a screen-space effect needs to know about the
// surface it is looking at. World-space normal in rgb, and in a the depth along
// the view axis in world metres — not a window depth; `WriteSurfaceGeometry`
// says at length why not.
//
// Depth travels here rather than in a depth texture because flutter_gpu cannot
// sample one — the same reason the shadow pass writes its depth into a colour
// target. See ARCHITECTURE.md §2.
//
// Guarded, because not every stage that includes this header draws into a
// two-attachment target. The shadow pass draws into one, and a pipeline
// declaring an output its target has no slot for is a mismatch worth avoiding
// rather than discovering.
#ifndef F3D_NO_SURFACE_BUFFER
layout(location = 1) out vec4 frag_surface;

/// The surface's own colour, sRGB-encoded, alpha one where a surface was
/// drawn — `L5`. The third attachment, present only when a pass reads it (the
/// indirect light does) and the device opens three; like the surface buffer,
/// written unconditionally and discarded when absent. Stored in the surface
/// buffer's format rather than eight bits a channel, and `Renderer` says why.
layout(location = 2) out vec4 frag_albedo;
#endif

/// What [frag_albedo] carries: the lit models set it in `ReadSurface`, and a
/// stage that reflects nothing — unlit, the debug views — leaves it black,
/// which is what light bounced onto it would come to.
vec3 g_albedo = vec3(0.0);

/// Octahedral encoding: a unit vector in two channels instead of three.
///
/// Worth the arithmetic because the fourth channel is already spent on depth,
/// and without a free channel there is nowhere to put roughness — which is the
/// difference between a reflection that knows stone from a mirror and one that
/// does not. The error is well under a degree, far below anything a reflection
/// off rough stone would show.
vec2 EncodeOctahedral(vec3 n) {
  n /= abs(n.x) + abs(n.y) + abs(n.z);
  vec2 e = n.xy;
  if (n.z < 0.0) {
    e = (1.0 - abs(n.yx)) * vec2(n.x >= 0.0 ? 1.0 : -1.0,
                                 n.y >= 0.0 ? 1.0 : -1.0);
  }
  return e * 0.5 + 0.5;
}

/// Where a debug pass leaves the picture it wants shown instead of the normal.
///
/// Declared here, in the header every lit shader includes **first**, and
/// written from surface.glsl, which is included after. The alternative was a
/// new member on a shared uniform block; a global costs nothing and moves no
/// offsets. It is read at the moment the surface buffer is written, which
/// happens after the lighting loop has run, so the value is there by then.
vec3 g_debug_surface = vec3(0.0);
bool g_debug_surface_on = false;

/// Whether [WriteSurface] weights the colour by its alpha: set by
/// `ReadSurface` for a material that blends, and false for everything else.
///
/// **The blend takes its source as premultiplied**, so a blended surface has
/// to hand it the colour times the alpha — a pane at a fifth of opaque adds a
/// fifth of its light, not all of it. glTF's blend mode is Porter and Duff's
/// over on straight colour, and this is the one place that turns the lit
/// radiance into what that means. An opaque or masked surface keeps its
/// colour whole: its alpha is not a coverage, and nothing blends it.
/// A global for the reason [g_debug_surface] is one.
bool g_premultiply = false;

// **A stage that needs none of this must be able to declare none of it.** On
// Vulkan both stages' descriptors are merged into one set layout, and two
// bindings with the same number in it is not a layout the specification
// allows. A driver may accept it anyway; a Galaxy A55's refuses the pipeline
// with `ErrorUnknown` and no other word, which is how the shadow pass came to
// build everywhere except there — its only uniform block was this one, and it
// landed on the same binding as the vertex stage's first.
#ifndef F3D_NO_FOG

/// Distance fog, in its own block rather than folded into FragInfo.
///
/// Its own because color.glsl is included before FragInfo is declared, and
/// because appending to a block that half a dozen shaders already share is a
/// way to move offsets nobody expected to move. Three vec4s is a cheap price
/// for not touching any of that.
layout(std140) uniform FogInfo {
  /// rgb: linear fog colour. w: density per metre, zero for no fog.
  vec4 fog;

  /// xyz: camera position in world space. Duplicated from FragInfo so this
  /// block stands alone; a vec3 is cheaper than a coupling.
  vec4 eye;

  /// xyz: the direction the camera looks, as a unit vector in world space.
  /// w: what a transparent draw writes under weighted blended transparency —
  /// `R8`, see `WriteWeightedBlended`. Zero for every other draw.
  ///
  /// Here rather than in a block of its own because it answers the same
  /// question [eye] does — where the camera is and which way it faces — and
  /// this is the block `color.glsl` can see.
  vec4 forward;
}
fog_info;

/// How far this fragment is from the eye, in world metres.
///
/// What the fog fades by. Distance rather than depth, because fog is a
/// property of the air between two points and does not care which way the
/// camera happens to face.
float EyeDistance() { return distance(v_world_position, fog_info.eye.xyz); }

/// How far this fragment is *along the view axis*, in world metres.
///
/// What the surface buffer's alpha holds. Depth rather than distance, and the
/// difference only shows on an orthographic camera — where the rays through
/// the pixels are parallel instead of meeting at the eye, so a distance from
/// the eye names a sphere that the pixel's ray crosses somewhere the reader
/// cannot solve for. A depth along the axis names a plane, which every ray
/// crosses exactly once. See `WorldAtDepth` in `post/ssao.frag` for the
/// reconstruction both projections share.
float ViewDepth() {
  return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz);
}

#else  // F3D_NO_FOG

// The same two questions, answered without the block: a stage that declares no
// fog has no eye position to measure from either. Stubs rather than a guard at
// every call site, so that what includes this file reads the same whichever
// way it was compiled.
float EyeDistance() { return 0.0; }
float ViewDepth() { return 0.0; }

#endif  // F3D_NO_FOG

/// sRGB to linear. Textures are authored in sRGB, but lighting is only correct
/// in linear space; skipping this is what makes naive renderers look muddy.
vec3 SrgbToLinear(vec3 srgb) {
  return mix(
      srgb / 12.92,
      pow((srgb + vec3(0.055)) / 1.055, vec3(2.4)),
      step(vec3(0.04045), srgb));
}

/// Linear to sRGB. The render target is a plain UNorm format rather than an
/// sRGB one, so the encode has to happen here.
vec3 LinearToSrgb(vec3 linear) {
  return mix(
      linear * 12.92,
      1.055 * pow(linear, vec3(1.0 / 2.4)) - vec3(0.055),
      step(vec3(0.0031308), linear));
}

/// Writes scene-referred linear light into the HDR target.
///
/// No tone map and no sRGB encode: those moved into the composite pass, which
/// is the entire point of rendering into `r16g16b16a16Float` first. Applying
/// them here meant every model wrote display-referred colour into an 8-bit
/// buffer, so anything above display white was gone before post-processing
/// could see it — and bloom is a function of exactly that.
///
/// Exposure moved with them, for the same reason: it belongs on the same side
/// of the display transform as the tone map.
/// Records the geometry of this fragment for whatever runs after the scene.
///
/// Called from the same place that writes colour, so a surface cannot be lit
/// into the frame without also describing itself — which is the failure that
/// leaves a screen-space effect reflecting whatever was in the buffer before.
///
/// rg: octahedral normal. b: perceptual roughness. a: **depth along the view
/// axis, in world metres** — see [ViewDepth].
///
/// **Not `gl_FragCoord.z`, and that is a defect this channel carried until it
/// was looked at.** Window depth crowds every distant surface into the top of
/// its range — with a near plane of a tenth of a metre, everything past twenty
/// metres lives in the last half a hundredth of `[0, 1]` — and this attachment
/// is a half float, whose steps up there are about five ten-thousandths. So two
/// surfaces half a metre apart at twenty metres stored the *same* number, and
/// every screen-space pass that compares against this channel decided whole
/// bands of pixels by rounding. The occlusion pass drew them: vertical stripes
/// along the lines of constant depth on any wall receding from the camera, on
/// both GPU backends. The software rasteriser kept the channel at full
/// precision and drew the effect correctly, so it was the one that looked
/// wrong against the other two.
///
/// A depth in metres has none of that: the exponent carries the range and the
/// mantissa carries the same relative precision everywhere, which at twenty
/// metres is a centimetre. Both numbers are measured in
/// `flutter3d/test/surface_depth_test.dart`.
///
/// Zero still means nothing was drawn. The attachment is cleared to zero and
/// nothing is drawn in front of the near plane.
void WriteSurfaceGeometry(float roughness) {
#ifndef F3D_NO_SURFACE_BUFFER
  // `L5`: the surface's colour, whatever the surface buffer ends up holding.
  frag_albedo = vec4(LinearToSrgb(clamp(g_albedo, vec3(0.0), vec3(1.0))), 1.0);
  // A debug pass takes the buffer over rather than getting one of its own.
  // The surface buffer already has an attachment, a viewer and a golden; a
  // second one would need all three built before it could answer anything.
  if (g_debug_surface_on) {
    frag_surface = vec4(g_debug_surface, ViewDepth());
    return;
  }
  // Reversed on a back face, as the lit normal is, so the occlusion and
  // reflection passes see the side of a double-sided surface that faces them.
  vec3 geometric = normalize(v_normal);
  if (!gl_FrontFacing) geometric = -geometric;
  frag_surface = vec4(EncodeOctahedral(geometric),
                      clamp(roughness, 0.0, 1.0), ViewDepth());
#endif
}

/// Fades [color] toward the fog with distance from the eye.
///
/// Exponential rather than linear, because linear fog has a visible plane
/// where it starts and a dungeon corridor is exactly where that shows.
vec3 ApplyFog(vec3 color) {
#ifdef F3D_NO_FOG
  return color;
#else
  float density = fog_info.fog.w;
  if (density <= 0.0) return color;
  float d = EyeDistance();
  return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0));
#endif
}

/// How much a transparent fragment counts for against the others over its
/// pixel — `R8`. McGuire and Bavoil's depth weight (their equation 9): a near
/// layer outweighs a far one, which is all the ordering a weighted average
/// can keep. [alpha] multiplies it, as theirs does, so a faint layer counts
/// faintly. Depth along the view axis, in metres, the surface buffer's.
float WeightedBlendedWeight(float alpha) {
  float z = abs(ViewDepth());
  float near = z / 5.0;
  float far = z / 200.0;
  float far3 = far * far * far;
  return alpha *
         clamp(10.0 / (1e-5 + near * near + far3 * far3), 1e-2, 3e3);
}

/// What a transparent draw writes when the frame composites transparency
/// order-independently — `R8`. `fog_info.forward.w` says which:
///
/// - 0: [frag_color] as it stands, the sorted blend's source. Every opaque
///   draw, and every draw in a frame that sorts.
/// - 1: the accumulation target's share — the colour, which the engine keeps
///   premultiplied, and the alpha, both times the weight. Added.
/// - 2: the revealage target's — the alpha alone, in every channel, which the
///   blend multiplies the target by one minus of.
/// - 3: both at once, the second into attachment one, where the surface
///   buffer would be; the pass that asks has no surface buffer attached.
///
/// Selects rather than returns, because a phi of constants is what
/// SPIRV-Cross refuses. At nought the branch is not taken and [frag_color]
/// is untouched, which is what keeps a sorting frame byte-identical.
void WriteWeightedBlended() {
#ifndef F3D_NO_FOG
  float mode = fog_info.forward.w;
  if (mode > 0.5) {
    float alpha = frag_color.a;
    float weight = WeightedBlendedWeight(alpha);
    vec4 accumulate = vec4(frag_color.rgb * weight, alpha * weight);
    bool revealage = mode > 1.5 && mode < 2.5;
    frag_color = revealage ? vec4(alpha) : accumulate;
#ifndef F3D_NO_SURFACE_BUFFER
    if (mode > 2.5) frag_surface = vec4(alpha);
#endif
  }
#endif
}

/// The fog is mixed in before the weight, so a thin distant pane adds a thin
/// share of the fog too rather than all of it. Times one when nothing blends,
/// which is exact, so an opaque draw writes what it always wrote.
void WriteSurface(vec3 linearColor, float alpha, float roughness) {
  float weight = g_premultiply ? alpha : 1.0;
  frag_color = vec4(ApplyFog(linearColor) * weight, alpha);
  WriteSurfaceGeometry(roughness);
  WriteWeightedBlended();
}

/// For a stage with no material to speak of.
///
/// Fully rough, which is the honest default: a surface that cannot say how
/// polished it is should not be reflected off.
void WriteSurface(vec3 linearColor, float alpha) {
  WriteSurface(linearColor, alpha, 1.0);
}

/// Writes a value that is already display-referred.
///
/// For debug output, where the colour is not a light value at all: a normal
/// encoded as RGB means nothing after a tone curve. Converting to linear here
/// means the composite pass's sRGB encode hands the original back unchanged,
/// provided the view also turns tone mapping and exposure off — which is what
/// `RenderSettings.tonemap` is for.
void WriteDisplayColor(vec3 displayColor, float alpha) {
  frag_color = vec4(SrgbToLinear(displayColor), alpha);
  WriteSurfaceGeometry(1.0);
}

#endif  // COLOR_GLSL_

// --- lib/frag_coord.glsl ---
// Where a fragment sits, counted from the top of its target on every backend.

#ifndef FRAG_COORD_GLSL_
#define FRAG_COORD_GLSL_

/// `gl_FragCoord.xy` with row zero at the top of the picture.
///
/// [rows] is the target's height where the backend's row zero is the bottom
/// of the picture, and zero where it is the top. WebGL2 is the first kind:
/// window coordinates start at the lower left, and the engine draws the
/// picture upright there rather than mirroring every projection. Metal,
/// WebGPU and the software rasteriser are the second.
///
/// **Why a pattern cares and a picture does not.** Every screen-space pattern
/// in the engine — the Bayer dither, the grain, the jitter a ray march starts
/// from, the rotation of a shadow kernel — is a function of the pixel's row.
/// Read from the bottom, the same frame gets the pattern turned upside down,
/// and a four-row Bayer cell lands on different rows unless the height is a
/// multiple of four. The picture underneath is identical; the pattern on top
/// of it is not, and a comparison across backends counts every pixel it
/// moved.
vec2 FragCoordFromTop(float rows) {
  return rows > 0.0 ? vec2(gl_FragCoord.x, rows - gl_FragCoord.y)
                    : gl_FragCoord.xy;
}

#endif  // FRAG_COORD_GLSL_


/// Lights per draw. Must match LightBuffer.maxLights on the Dart side.
///
/// A fixed array with a runtime count, not a shader permutation per light
/// count: turning a light on has to be free, because there is no runtime
/// compilation to fall back on. Verified against the SDK — Impeller keeps
/// `vec4 x[8]` in the compiled Metal struct and reflects the array's base
/// offset, with the std140 stride of 16 bytes.
#define kMaxLights 8

/// How many more lights one draw may be handed — `gfx-74n`.
///
/// **The eight above stay exactly what they were**, which is what keeps this
/// from moving a single recorded frame: a draw with eight lights or fewer runs
/// the loop it has always run, reads the uniform arrays it has always read, and
/// never touches the texture below. The tail is the part that used to be
/// impossible.
///
/// A loop bound rather than a cost. `AccumulateLights` breaks at the draw's own
/// count, so a scene with three lights costs three iterations whatever this
/// says. Twenty-four because the two tables below are `vec4 x[6]` and four
/// lanes fit a `vec4`: two hundred and eight bytes a draw, against the five
/// hundred and twelve the light arrays already cost.
#define kExtraLights 24
#define kTotalLights (kMaxLights + kExtraLights)

// --- lib/light_list.glsl ---
// The frame's light list, and how a fragment finds its tail in it — `gfx-74n`
// and `L6`.
//
// Split out of `surface.glsl` so a stage that is not a surface can read the
// same lights: `N6`'s six-way particles light each fragment by the list the
// lit models read, clusters and all, without declaring `FragInfo`. The text is
// the one that stood in `surface.glsl`, moved rather than copied, so the lit
// models compile to what they compiled to before.

#ifndef LIGHT_LIST_GLSL_
#define LIGHT_LIST_GLSL_
/// Every light in the scene, one per row, four texels across — `gfx-74n`.
///
/// **A texture rather than a wider uniform block, and that is the design.**
/// `FragInfo` is uploaded on every draw, so widening its four `vec4` arrays to
/// hold thirty-two lights would be a two-kilobyte upload per draw in every
/// scene, including every scene with one light. This is built once a frame and
/// only when a scene has more lights than a draw can hold in its slots.
///
/// Row layout, which `renderer_light_list.dart` writes and only this reads:
///
///  * texel 0 — xyz world position, w type (0 directional, 1 point, 2 spot)
///  * texel 1 — rgb linear colour, w intensity
///  * texel 2 — xyz the direction it points, w range
///  * texel 3 — x cos(inner), y cos(outer), zw unused
///
/// The same four vectors the uniform arrays hold, in the same order, so one
/// reader serves both.
///
/// **`F3D_NO_LIGHT_LIST` leaves both out**, for a model that accumulates no
/// lights. Such a model never reaches the reader below, so the compiler drops
/// the block and the sampler from the Metal function while reflection still
/// lists them, with no buffer or texture index assigned. The renderer used to
/// bind them for every draw, Unlit included, and that bind is a crash inside
/// `setFragmentBuffer:offset:atIndex:` on Metal. Vulkan took the same draw
/// without a word, which is how 0.7.0 shipped with it.
#ifndef F3D_NO_LIGHT_LIST
uniform sampler2D light_list_texture;

layout(std140) uniform LightListInfo {
  /// x: how many rows this draw reads, zero when it reads none.
  /// y, z: one over the texture's width and height.
  /// w: unused.
  vec4 list;

  /// Which rows, four to a vector, in the order they are read.
  ///
  /// Indices rather than the light data itself: the data is the same for every
  /// draw in the frame and belongs in the texture; what differs per draw is
  /// *which* of them reach it, and that is what `Renderer._drawLightsFor`
  /// already decides.
  vec4 indices[6];

  /// How much of each of those survives the edge fade, in the same order.
  ///
  /// Per draw and not in the texture, because the row an index points at is
  /// shared by every draw in the frame: a scale written into it would dim that
  /// light for all of them. `gfx-12n`'s fade lives at the end of the list now —
  /// that is where a light stops contributing, and fading the slots against a
  /// water line that no longer marks a cliff would dim a light for no reason
  /// while its rival stayed bright, making the swap more visible rather than
  /// less.
  vec4 scales[6];

  /// `L6`: the view-projection the light clusters were cut with, so this
  /// finds a fragment's cell the way `LightClusters.clusterOf` does.
  mat4 cluster_view_projection;

  /// xyz: tiles across, tiles up, slices deep. w: one when this draw reads
  /// its tail from the cell it is in rather than from `indices`.
  vec4 cluster_grid;

  /// x: where slices begin, in clip w. y: slices per unit of `ln(w / x)`.
  /// z: the texture row the cells' headers start at, four to a row, each
  /// (offset, count). w: the row their entries start at, sixteen to a row.
  vec4 cluster_depth;

  /// Which rows this draw already holds in its eight slots, minus one for
  /// an empty slot. A cell lists every light that reaches it, and one the
  /// slots already carry must not be counted again.
  vec4 slot_rows[2];
}
light_list_info;

/// One lane of a six-vector table, [slot] counting from nought.
float LightListLane(vec4 four, int slot) {
  int lane = slot - (slot / 4) * 4;
  return lane == 0 ? four.x : lane == 1 ? four.y : lane == 2 ? four.z : four.w;
}

/// The row light [slot] of the list reads.
float LightListRow(int slot) {
  return LightListLane(light_list_info.indices[slot / 4], slot);
}

/// How much of light [slot] of the list survives the edge fade.
float LightListScale(int slot) {
  return LightListLane(light_list_info.scales[slot / 4], slot);
}

/// The cell this fragment falls in, as `LightClusters` wrote it: where its
/// entries start and how many there are. Found once, in [LightCount], and
/// read by every [SampleLight] of the loop that follows.
float g_cluster_offset = 0.0;
float g_cluster_count = 0.0;

bool Clustered() { return light_list_info.cluster_grid.w > 0.5; }

/// One texel of the light list texture, [texel] across and [row] down.
vec4 LightListTexel(float texel, float row) {
  return textureLod(light_list_texture,
                    vec2((texel + 0.5) * light_list_info.list.y,
                         (row + 0.5) * light_list_info.list.z),
                    0.0);
}

void FindCluster(vec3 world) {
  vec4 clip = light_list_info.cluster_view_projection * vec4(world, 1.0);
  vec2 ndc = clip.xy / max(clip.w, 1e-6);
  vec3 grid = light_list_info.cluster_grid.xyz;
  float near = light_list_info.cluster_depth.x;
  float tx = clamp(floor((ndc.x * 0.5 + 0.5) * grid.x), 0.0, grid.x - 1.0);
  float ty = clamp(floor((ndc.y * 0.5 + 0.5) * grid.y), 0.0, grid.y - 1.0);
  float tz = clip.w <= near
                 ? 0.0
                 : clamp(floor(log(clip.w / near) *
                               light_list_info.cluster_depth.y),
                         0.0, grid.z - 1.0);
  float cell = tx + ty * grid.x + tz * grid.x * grid.y;
  float row = floor(cell / 4.0);
  vec4 header =
      LightListTexel(cell - row * 4.0, light_list_info.cluster_depth.z + row);
  g_cluster_offset = header.x;
  g_cluster_count = header.y;
}

/// The row entry [slot] of this fragment's cell names.
float ClusterRow(int slot) {
  float entry = g_cluster_offset + float(slot);
  float row = floor(entry / 16.0);
  float within = entry - row * 16.0;
  float texel = floor(within / 4.0);
  vec4 four = LightListTexel(texel, light_list_info.cluster_depth.w + row);
  return LightListLane(four, int(within - texel * 4.0 + 0.5));
}

/// Whether one of the draw's slots already holds light list row [row].
bool InSlots(float row) {
  vec4 a = abs(light_list_info.slot_rows[0] - vec4(row));
  vec4 b = abs(light_list_info.slot_rows[1] - vec4(row));
  return min(min(min(a.x, a.y), min(a.z, a.w)), min(min(b.x, b.y), min(b.z, b.w))) < 0.5;
}
#endif  // F3D_NO_LIGHT_LIST

#endif  // LIGHT_LIST_GLSL_


layout(std140) uniform FragInfo {
  /// xyz: world position (point and spot). w: type, 0 directional 1 point 2 spot.
  vec4 light_position[kMaxLights];

  /// rgb: linear colour. w: intensity.
  vec4 light_color[kMaxLights];

  /// xyz: the direction the light points, its local -Z. w: range, 0 unbounded.
  vec4 light_direction[kMaxLights];

  /// x: cos(inner cone angle). y: cos(outer cone angle).
  vec4 light_cone[kMaxLights];

  /// rgb: albedo tint applied on top of the texture. w: opacity.
  vec4 base_color;

  /// rgb: emissive factor, already linear. w: one when the normal map has
  /// two channels (x, y) and its z is rebuilt — see `ApplyNormalMap`. It sits
  /// here because this was the block's one unspent lane.
  vec4 emissive;

  /// xyz: camera position in world space, needed for every specular term.
  vec4 camera_position;

  /// x: metallic, y: roughness, z: ambient strength, w: specular strength.
  vec4 material;

  /// x: alpha cutoff (negative when the material is not masked: -1 opaque,
  /// -0.5 blended, -2 hashed), y: normal scale, z: occlusion strength,
  /// w: emissive strength.
  vec4 material2;

  /// x: exposure, y: active light count, z: index of the shadow-casting light.
  /// w is reserved so adding a frame-wide parameter does not change the offsets
  /// of anything already here.
  vec4 frame_params;

  /// x: one texel of the shadow map, y: depth bias, z: normal offset,
  /// w: strength, zero when shadows are off.
  vec4 shadow_params;

  /// World space to the shadow camera's clip space. The first cascade.
  mat4 shadow_matrix;

  /// The second and third cascades. Copies of the first when there is one, so
  /// this block's layout never depends on how many there are.
  mat4 shadow_matrix_far;
  mat4 shadow_matrix_farthest;

  /// x, y: where cascades 0 and 1 end, in metres from the camera. z: how many
  /// cascades there are, 1 to 3. w: one texel of a tile, vertically —
  /// shadow_params.x is one texel of the whole atlas, and with more than one
  /// cascade those differ.
  vec4 shadow_cascades;

  /// rgb: what a surface facing straight up receives from the environment.
  /// w: one when the metal-rough models' diffuse is EON rather than Lambert —
  /// `L8`, `RenderSettings.diffuseModel`; a frame-wide switch in a frame-wide
  /// vector, and the block's offsets stay where four backends agree on them.
  ///
  /// Appended after everything else on purpose: std140 lays a block out in
  /// declaration order, so adding here leaves every offset above unchanged and
  /// the three backends do not have to agree about anything they did not
  /// already agree about.
  vec4 ambient_sky;

  /// rgb: what a surface facing straight down receives — bounce off the ground
  /// rather than the ground itself.
  ///
  /// **w is the directional light's apparent size** — `gfx-15n` — which has
  /// nothing to do with ambient and everything to do with this being the last
  /// unspent component in a block six shaders share. `frame_params.w` was the
  /// slot reserved for a frame-wide parameter and the environment's level
  /// count took it; appending to this block moves offsets four backends have
  /// agreed on. See `shadow.glsl`, which reads it.
  ///
  /// Two colours rather than one is the whole of what makes ambient look like
  /// light instead of like a lifted black level. Outdoors the sky is blue and
  /// bright and the ground is warm and dim, and a flat grey for both leaves
  /// every underside as pale as every upward face — which reads as the model
  /// being flat, and gets blamed on the normals.
  vec4 ambient_ground;

  /// x, y, z: the depth bias of each cascade, in that cascade's own normalized
  /// depth. w unused.
  ///
  /// `ShadowSettings.bias` is one number and a cascade's depth range is not:
  /// a near cascade is stretched towards the light when a caster stands
  /// further out than its own volume reaches, and the same bias over a longer
  /// range is a longer distance. The renderer converts it per cascade so it
  /// stays the distance it was tuned as; an unstretched cascade gets the
  /// setting unchanged.
  vec4 shadow_bias;

  /// x: the target's rows when its row zero is the bottom of the picture,
  /// zero when it is the top — see `FragCoordFromTop` in `frag_coord.glsl`,
  /// which the shadow kernel's rotation reads through. y: the mip bias every
  /// material map is read with — `R2`: nought, except while a temporal
  /// resolve reconstructs a picture larger than the scene is drawn at, when
  /// the maps are read as sharp as the output they end up in. z: one when
  /// the metal-rough model puts back the energy single scattering loses —
  /// `L1`, `RenderSettings.energyCompensation`. w: the frame's slice of 32
  /// while a temporal resolve runs, minus one otherwise — `S3`, which steps
  /// the soft shadow's rotation by it.
  vec4 target_origin;
}
frag_info;

/// The bias a material map is read with — see `target_origin.y`.
float MaterialLodBias() { return frag_info.target_origin.y; }

/// The maps a lit material reads, by the index [MapUv] takes — `C8`. The
/// order `LayerInfo.uv_transform` keeps them in, and `MaterialMap`'s on the
/// Dart side.
#define kMapBaseColor 0
#define kMapMetallicRoughness 1
#define kMapNormal 2
#define kMapOcclusion 3
#define kMapEmissive 4

/// Where map [slot] is read — `C8`, `KHR_texture_transform` at the sampler.
///
/// **A macro everywhere but the one stage that has the matrices.** A stage
/// that defines `F3D_TEXTURE_TRANSFORM` supplies [MapUv] and [MapMatrix] from
/// a block of its own; every other stage reads each map at the vertex's own
/// coordinate, and the macro leaves its source exactly what it was, so none of
/// them compiles to anything new.
#ifdef F3D_TEXTURE_TRANSFORM
vec2 MapUv(int slot);

/// The 2×2 part of map [slot]'s transform: x and y its first row, z and w
/// its second.
vec4 MapMatrix(int slot);
#else
#define MapUv(slot) v_texcoord
#endif

uniform sampler2D base_color_texture;

/// Everything about the surface that does not depend on which light is being
/// evaluated, resolved once per fragment.
struct Surface {
  vec3 albedo;      // linear, already tinted
  float alpha;      // opacity after texture, tint and vertex colour
  vec3 n;           // unit normal, perturbed by the normal map when there is one
  vec3 v;           // unit direction to the camera
  float n_dot_v;
  float metallic;
  float roughness;  // perceptual
  float occlusion;  // 1 means unoccluded
  vec3 emissive;    // linear, added after shading
  vec3 ambient;     // hemispheric, already scaled by the scene's strength
  float exposure;
};

/// One light's contribution geometry, recomputed per light per fragment.
struct LightSample {
  vec3 l;           // unit direction to the light
  vec3 h;           // unit half vector
  vec3 radiance;    // colour * intensity * attenuation
  float n_dot_l;
  float n_dot_h;
  float v_dot_h;

  /// One when the specular below is already integrated over the light —
  /// `L7`, a rectangle under a model that defines `F3D_LTC` — and nought
  /// otherwise. Then `ltc.x` is the GGX lobe over the rectangle, `ltc.y` the
  /// fitted norm and `ltc.z` the Fresnel term; see `LtcRectangle`.
  float integrated;
  vec3 ltc;
};

Surface ReadSurface() {
  Surface s;

  vec4 texel = texture(base_color_texture, MapUv(kMapBaseColor), MaterialLodBias());
  // Vertex colour is authored linear per the glTF spec, unlike the base colour
  // texture and the tint, which are sRGB.
  s.albedo = SrgbToLinear(texel.rgb) *
             SrgbToLinear(frag_info.base_color.rgb) *
             v_color.rgb;
  s.alpha = texel.a * frag_info.base_color.a * v_color.a;
  // `L5`: the albedo buffer carries it, for the indirect light.
  g_albedo = s.albedo;

  // Alpha masking, glTF's third alpha mode. A negative cutoff means the
  // material is opaque or blended, and discard would then be wrong rather than
  // merely unnecessary. Doing it before anything else is deliberate: a
  // discarded fragment should not pay for the lighting loop.
  //
  // **A cutoff below -1.5 is the fourth mode: hashed** — `gfx-16n`. The
  // sentinel rides in the same component because the alternative is a second
  // number in a block six shaders share, and -1 already meant "not masked";
  // anything more negative was free. See [MaterialAlphaMode.hashed].
  float cutoff = frag_info.material2.x;
  if (cutoff >= 0.0) {
    if (s.alpha < cutoff) discard;
  } else if (cutoff < -1.5) {
    // **Stochastic instead of a threshold.** A leaf texture at 40% opacity is
    // either entirely there or entirely gone under a fixed cutoff, so a fern
    // comes out as a hard-edged cardboard cut-out; sorting would fix it and
    // costs a sort per frame and a draw per layer. Comparing against noise
    // instead keeps 40% of the *pixels*, which resolves as 40% opacity to
    // anything that averages several of them — a higher-resolution target,
    // a downsample, a person standing back.
    //
    // **Hashed on world position, not on the screen.** Screen-space noise is
    // one line shorter and swims: the pattern stays put while the object
    // moves through it, so a moving branch sparkles. Anchoring it to where
    // the surface *is* means a given speck of leaf keeps its verdict from
    // frame to frame, and the camera moving changes nothing.
    //
    // The scale is a constant and it is the whole tuning: finer than the
    // texture's own detail and the noise disappears into aliasing, coarser
    // and the leaf breaks into blotches. Sixteen per metre is about a
    // centimetre of grain at a metre away.
    vec3 anchored = floor(v_world_position * 16.0);
    float noise = fract(
        sin(dot(anchored, vec3(12.9898, 78.233, 37.719))) * 43758.5453);
    if (s.alpha < noise) discard;
  }
  // **Between -1 and nought is the blend mode**, which `WriteSurface` weights
  // by its alpha: see [g_premultiply]. The engine writes -0.5 for it, -1 for
  // opaque; neither is masked, and only the blend's source is premultiplied.
  g_premultiply = cutoff < 0.0 && cutoff > -0.75;

  s.n = normalize(v_normal);
  // The back of a double-sided surface is lit from its own side: glTF asks
  // for the normal to be reversed there, and without it the underside of a
  // cloth turned to the sun reads n·l below zero and stays unlit. Only a
  // double-sided material ever draws a back face, since everything else has
  // them culled.
  if (!gl_FrontFacing) s.n = -s.n;
  s.v = normalize(frag_info.camera_position.xyz - v_world_position);
  // Clamped away from zero: a grazing view direction otherwise divides by zero
  // in the specular visibility term.
  s.n_dot_v = max(dot(s.n, s.v), 1e-4);

  s.metallic = clamp(frag_info.material.x, 0.0, 1.0);
  s.roughness = clamp(frag_info.material.y, 0.02, 1.0);
  // Hemispheric: the sky above, the ground below, blended by which way this
  // surface faces. `material.z` stays the overall strength, so the two are
  // separable — a scene dims its ambient without changing its colour, which is
  // what the one control used to do on its own.
  //
  // The blend runs on the geometric normal deliberately, before
  // `ApplyMaterialMaps` perturbs it. A normal map describes millimetres of
  // surface relief, and ambient of this kind describes which half of the world
  // a face can see; letting bump detail swing it makes a brick wall's mortar
  // lines pick up sky and reads as noise.
  s.ambient = mix(frag_info.ambient_ground.rgb, frag_info.ambient_sky.rgb,
                  s.n.y * 0.5 + 0.5) *
              frag_info.material.z;
  s.exposure = max(frag_info.frame_params.x, 0.0);

  // Neutral until ApplyMaterialMaps says otherwise, so a model that samples no
  // maps still has a complete surface.
  s.occlusion = 1.0;
  s.emissive = vec3(0.0);

  return s;
}

int LightCount() {
#ifdef F3D_NO_LIGHT_LIST
  return clamp(int(frag_info.frame_params.y + 0.5), 0, kMaxLights);
#else
  // `L6`: the tail is the cell's, when the draw reads one.
  float tail = light_list_info.list.x;
  if (Clustered()) {
    FindCluster(v_world_position);
    tail = g_cluster_count;
  }
  return clamp(int(frag_info.frame_params.y + 0.5), 0, kMaxLights) +
      clamp(int(tail + 0.5), 0, kExtraLights);
#endif
}

/// Whether light [index] carries a shadow — `gfx-74n`.
///
/// Only the first eight do. The cube atlas holds six rows and the slot table is
/// eight entries wide, so a light from the list has no row to read and asking
/// for one would index past the table. That is a real limit and the right one:
/// the eight a draw keeps in its slots are the eight ranked most relevant to
/// it, which is exactly the set worth a shadow map.
bool LightHasShadow(int index) { return index < kMaxLights; }

/// Distance attenuation for a punctual light, following the glTF spec.
///
/// Inverse square with an optional range window. The window is what stops a
/// lamp with a declared range from contributing a faint haze across the whole
/// scene, which matters far more once there are eight of them.
float PunctualAttenuation(float distance, float range) {
  float attenuation = 1.0 / max(distance * distance, 1e-4);
  if (range > 0.0) {
    float ratio = distance / range;
    float window = clamp(1.0 - ratio * ratio * ratio * ratio, 0.0, 1.0);
    attenuation *= window * window;
  }
  return attenuation;
}

/// One edge of Lambert's sum, from [a] to [b], neither of which need be a
/// unit vector: the angle between them times how much their plane leans into
/// [n].
float LambertEdge(vec3 a, vec3 b, vec3 n) {
  // Normalised with a floor rather than `normalize`: a corner exactly at the
  // shading point, or a horizon crossing that lands there, is a zero vector,
  // and `normalize` of that is a NaN that spreads to the whole pixel and then
  // to the bloom. A zero vector here subtends nothing, which is the answer.
  vec3 ua = a / max(length(a), 1e-12);
  vec3 ub = b / max(length(b), 1e-12);
  // Clamped before the `acos`: two nearly parallel edge directions can give a
  // dot a hair past one through rounding alone, and `acos` of that is the same
  // NaN.
  float angle = acos(clamp(dot(ua, ub), -1.0, 1.0));
  vec3 axis = cross(ua, ub);
  float len = length(axis);
  // A degenerate edge — the shading point lies on the line through it —
  // subtends nothing.
  return len > 1e-6 ? angle * dot(axis, n) / len : 0.0;
}

/// How much of [s]'s sky a rectangle covers, weighted by the cosine —
/// `gfx-77n`.
///
/// **Exact, not fitted.** This is Lambert's own form factor for a polygon, from
/// 1760: for each edge, the angle it subtends at the shading point times how
/// much the edge's plane leans into the surface normal. Summed over the edges
/// and halved, it is the integral of `cos θ` over the polygon's projection on
/// the sphere — the quantity a punctual light approximates with a single
/// `n · l`. So there is no table to ship and nothing to fit: the usual
/// linearly-transformed-cosine approach exists to make the *specular* lobe
/// tractable, and buys nothing here.
///
/// **Clipped to the horizon first.** Lambert's sum is signed: a part of the
/// panel below the surface's horizon counts with a negative cosine and cancels
/// light from the part above it, so a panel standing on the horizon read
/// nought where half of it lights the surface. Irradiance wants the clamped
/// cosine, and for a polygon that means cutting away what lies below before
/// summing. A convex quadrilateral cut by a plane leaves one polygon with at
/// most one edge leaving the hemisphere and one entering it, so the cut is the
/// four edges trimmed where they cross plus one edge along the horizon from
/// the exit back to the entry, with no list of vertices to build.
///
/// Returns irradiance over radiance, so a surface facing a rectangle that fills
/// its whole sky gets π, the same as a uniform hemisphere. [corners] are the
/// four vertices in order, relative to the shading point.
///
/// **The rectangle emits along `cross(halfWidth, halfHeight)`**, and with the
/// corners wound as `SampleLight` winds them the sum comes out *negative* on
/// that side, so the negation below is the convention rather than a fix. It was
/// measured rather than derived: the first version returned `+total * 0.5`, and
/// against the reference integration it read nought where the answer was 0.349
/// and 1.02 where the answer was nought — the two failures a flipped winding
/// produces, and between them they name the sign with no room left to argue.
float RectangleFormFactor(vec3 corners[4], vec3 n) {
  float total = 0.0;
  vec3 exit = vec3(0.0);
  vec3 entry = vec3(0.0);
  for (int i = 0; i < 4; i++) {
    vec3 a = corners[i];
    vec3 b = corners[i == 3 ? 0 : i + 1];
    float ha = dot(a, n);
    float hb = dot(b, n);
    // Where the edge meets the horizon; used only when it crosses it, and then
    // the two heights differ in sign, so the division is safe.
    float d = ha - hb;
    vec3 q = a + (b - a) * (abs(d) > 1e-12 ? ha / d : 0.0);
    bool aAbove = ha > 0.0;
    bool bAbove = hb > 0.0;
    total += aAbove || bAbove
                 ? LambertEdge(aAbove ? a : q, bAbove ? b : q, n)
                 : 0.0;
    exit = aAbove && !bAbove ? q : exit;
    entry = !aAbove && bAbove ? q : entry;
  }
  // The horizon edge closing the cut, from where the outline left the
  // hemisphere to where it came back. Nothing when it never crossed: both are
  // still zero and a zero vector subtends nothing.
  total += LambertEdge(exit, entry, n);
  // Clamped: a surface on the panel's dark side sees the outline wound the
  // other way, and the clipped sum comes out negative. `SampleLight` tests the
  // side as well, before any of this is paid for.
  return max(-total * 0.5, 0.0);
}

/// Where on the rectangle the specular lobe is really looking — `gfx-77n`.
///
/// **The representative point, which is an approximation, unlike the diffuse
/// above.** The mirror direction leaves the surface and either hits the panel
/// or misses it; the closest point of the panel to that ray is treated as a
/// punctual light standing in for the whole rectangle. It is the standard
/// cheap answer and its one visible property is the one the row asked for: as
/// the view moves the closest point slides along the panel, so the highlight
/// is a streak with the panel's own shape and orientation rather than a dot.
///
/// What it does not do is widen the lobe by the panel's solid angle, so a
/// rough surface under a large panel is a little darker than a full integration
/// would make it. That is a known error of this method and not a bug in this
/// transcription; the fix is the fitted table this function exists to avoid.
vec3 RectangleClosestPoint(vec3 centre, vec3 halfWidth, vec3 halfHeight,
                           vec3 world, vec3 mirror) {
  vec3 n = cross(halfWidth, halfHeight);
  float nLen = length(n);
  // A panel with no area has no surface to find a point on; its centre is the
  // only answer that is not a division by zero.
  if (nLen < 1e-12) return centre;
  n /= nLen;

  vec3 toPlane = centre - world;
  float denom = dot(mirror, n);
  vec3 onPlane;
  // Parallel to the panel, or pointing away from it: the ray never lands, so
  // the nearest thing to it is the centre projected back, which keeps the
  // highlight on the panel instead of sending it to infinity.
  if (abs(denom) < 1e-5) {
    onPlane = toPlane - n * dot(toPlane, n);
  } else {
    float t = dot(toPlane, n) / denom;
    onPlane = t > 0.0 ? mirror * t : toPlane - n * dot(toPlane, n);
  }

  // Clamped into the rectangle in its own axes. Dividing by the squared length
  // turns a projection into a coordinate in units of the half-extent, so the
  // clamp is against one either way round.
  vec3 offset = onPlane - toPlane;
  float wLen2 = max(dot(halfWidth, halfWidth), 1e-12);
  float hLen2 = max(dot(halfHeight, halfHeight), 1e-12);
  float u = clamp(dot(offset, halfWidth) / wLen2, -1.0, 1.0);
  float v = clamp(dot(offset, halfHeight) / hLen2, -1.0, 1.0);
  return centre + halfWidth * u + halfHeight * v;
}

#ifdef F3D_LTC
// --- lib/ltc.glsl ---
// The GGX lobe over a rectangle light, by linearly transformed cosines — `L7`.
//
// Heitz, Dupuy, Hill and Neubelt, "Real-Time Polygonal-Light Shading with
// Linearly Transformed Cosines", ACM TOG 35(4), 2016. The fitted tables are
// `EngineTables.ltc`; see `tables/ltc.dart` for their layout and licence.
//
// A model that wants it defines `F3D_LTC` before including `surface.glsl`,
// which is what gives its stage the one sampler below. Every other model
// keeps the representative point, and no sampler.

#ifndef LTC_GLSL_
#define LTC_GLSL_

/// Both tables, 64 × 128: the inverse matrices above, the norms, Fresnel
/// terms and sphere form factors below.
uniform sampler2D ltc_texture;

/// Where `(x, y)`, each nought to one, lands in the table starting at
/// [table] (nought the upper, one the lower): on texel centres, so the ends of
/// the range read the first and last entries rather than half of the
/// neighbour.
vec2 LtcUv(float x, float y, float table) {
  vec2 inTable = vec2(x, y) * (63.0 / 64.0) + 0.5 / 64.0;
  return vec2(inTable.x, (inTable.y + table) * 0.5);
}

/// One edge's share of the vector form factor, from [a] to [b], unit
/// directions: the angle between them along the normal of their plane,
/// over 2π. Exact, with the `acos` clamped for the reason
/// `RectangleFormFactor` gives.
vec3 LtcEdge(vec3 a, vec3 b) {
  vec3 axis = cross(a, b);
  float len = length(axis);
  float angle = acos(clamp(dot(a, b), -1.0, 1.0));
  return len > 1e-6 ? axis * (angle / (len * 6.2831853)) : vec3(0.0);
}

/// The GGX lobe of roughness [roughness] seen along [v] from normal [n],
/// integrated over the rectangle with corners [corners] (relative to the
/// shading point, wound as `SampleLight` winds them), with the fitted
/// Fresnel pair for that lobe: x the integral, y the norm, z the Fresnel
/// term. The specular is `x · (f0 · y + (1 − f0) · z)`.
///
/// Clipped to the horizon by the sphere table rather than by cutting the
/// polygon: the vector form factor's length and elevation name a sphere
/// with the same, and the table holds how much of that sphere's clamped
/// cosine lies above the horizon.
///
/// Says nothing about which face of the panel the point is on: the vector
/// form factor points the same way in the world from either side, so this is
/// as bright behind the panel as in front of it. `SampleLight` tests the side
/// and leaves a point behind unlit before this is asked.
vec3 LtcRectangle(vec3 n, vec3 v, float roughness, vec3 corners[4]) {
  vec2 uv = vec2(clamp(roughness, 0.0, 1.0),
                 sqrt(clamp(1.0 - dot(n, v), 0.0, 1.0)));
  vec4 inverse = textureLod(ltc_texture, LtcUv(uv.x, uv.y, 0.0), 0.0);
  vec4 fit = textureLod(ltc_texture, LtcUv(uv.x, uv.y, 1.0), 0.0);

  // The frame the fit was made in: the normal up, the view in the xz plane.
  // A view along the normal has no plane of its own, and any will do.
  vec3 along = v - n * dot(v, n);
  float alongLength = length(along);
  vec3 t1 = alongLength > 1e-5
                ? along / alongLength
                : normalize(cross(n, abs(n.z) < 0.999 ? vec3(0.0, 0.0, 1.0)
                                                      : vec3(1.0, 0.0, 0.0)));
  vec3 t2 = cross(n, t1);
  mat3 minv = mat3(vec3(inverse.x, 0.0, inverse.y), vec3(0.0, 1.0, 0.0),
                   vec3(inverse.z, 0.0, inverse.w));

  vec3 l[4];
  for (int i = 0; i < 4; i++) {
    vec3 p = corners[i];
    l[i] = normalize(minv * vec3(dot(p, t1), dot(p, t2), dot(p, n)));
  }
  // Negated, for `RectangleFormFactor`'s reason: the panel emits along
  // `cross(halfWidth, halfHeight)`, and seen from there these corners run
  // clockwise.
  vec3 f = -(LtcEdge(l[0], l[1]) + LtcEdge(l[1], l[2]) +
             LtcEdge(l[2], l[3]) + LtcEdge(l[3], l[0]));
  float len = length(f);
  float z = len > 1e-9 ? f.z / len : 0.0;
  float sphere =
      textureLod(ltc_texture, LtcUv(z * 0.5 + 0.5, clamp(len, 0.0, 1.0), 1.0),
                 0.0)
          .w;
  return vec3(max(len * sphere, 0.0), fit.x, fit.y);
}

#endif  // LTC_GLSL_


#ifdef F3D_LAYERED
/// The corners of the rectangle [SampleLight] resolved last, relative to the
/// shading point — `M1`. The clear coat integrates its own lobe over the same
/// panel with its own normal and roughness, and those live in `pbr.glsl`,
/// after this file; the loop shades each light straight after sampling it,
/// so this is always the light being shaded.
vec3 g_rect_corners[4];
#endif  // F3D_LAYERED
#endif  // F3D_LTC

/// Resolves light [index] against the surface.
///
/// Returns `n_dot_l == 0` for anything that contributes nothing — behind the
/// surface, out of range, outside the spot cone, the dark face of a panel — so
/// a model can skip it with one test instead of repeating the classification.
LightSample SampleLight(int index, Surface s) {
  LightSample light;
  light.integrated = 0.0;
  light.ltc = vec3(0.0);

  vec4 position;
  vec4 color;
  vec4 direction;
  vec4 cone;
  if (index < kMaxLights) {
    position = frag_info.light_position[index];
    color = frag_info.light_color[index];
    direction = frag_info.light_direction[index];
    cone = frag_info.light_cone[index];
  } else {
#ifdef F3D_NO_LIGHT_LIST
    // Unreachable: `LightCount` stops at the slots without a list.
    position = vec4(0.0);
    color = vec4(0.0);
    direction = vec4(0.0);
    cone = vec4(0.0);
#else
    // A row of the light list — `gfx-74n`. Sampled at texel centres so a
    // driver's rounding cannot land a fetch on a neighbour, and the four texels
    // across the row are the same four vectors the arrays above hold.
    int slot = index - kMaxLights;
    // `L6`: from the cell rather than the draw's own tail, and a light the
    // slots already hold is skipped by its intensity, as a faded one is.
    bool clustered = Clustered();
    float listRow = clustered ? ClusterRow(slot) : LightListRow(slot);
    float v = (listRow + 0.5) * light_list_info.list.z;
    float u = light_list_info.list.y;
    // `textureLod` and not `texture`, for `shadow.glsl`'s own reason: `index`
    // reaches this branch through a function parameter, so a WGSL backend
    // cannot see that every invocation of a draw walks the same light count
    // and refuses the implicit derivative as possibly non-uniform. The atlas
    // has one level, so naming it directly changes no pixel.
    position = textureLod(light_list_texture, vec2(0.5 * u, v), 0.0);
    color = textureLod(light_list_texture, vec2(1.5 * u, v), 0.0);
    direction = textureLod(light_list_texture, vec2(2.5 * u, v), 0.0);
    cone = textureLod(light_list_texture, vec2(3.5 * u, v), 0.0);
    // The intensity and not the colour, for `LightBuffer._pack`'s own reason:
    // the same multiply here, and only one of them is a number nobody authored.
    color.w *= clustered ? (InSlots(listRow) ? 0.0 : 1.0) : LightListScale(slot);
#endif  // F3D_NO_LIGHT_LIST
  }

  float type = position.w;

  // **The rectangle leaves before `aim` is taken — `gfx-77n`.** For every other
  // kind `direction.xyz` is a unit vector saying which way the light points;
  // for this one it is an edge of the panel, with its length carrying half the
  // width, and normalising it here would quietly throw the size away.
  if (type > 2.5) {
    vec3 halfWidth = direction.xyz;
    vec3 halfHeight = cone.xyz;
    vec3 toCentre = position.xyz - v_world_position;

    vec3 corners[4];
    corners[0] = toCentre - halfWidth - halfHeight;
    corners[1] = toCentre + halfWidth - halfHeight;
    corners[2] = toCentre + halfWidth + halfHeight;
    corners[3] = toCentre - halfWidth + halfHeight;

    // **The panel emits from one face only**, and a point on the other side
    // gets nothing: the room above a ceiling panel, the outside of the wall a
    // window is set in. Tested here rather than left to the signs below,
    // because the specular's vector form factor keeps the same orientation
    // from either side of the panel, so a surface behind it facing away read
    // as lit as one in front facing it.
    bool behind = dot(toCentre, cross(halfWidth, halfHeight)) >= 0.0;

    // The cosine-weighted solid angle, which takes the place `n · l` holds for
    // a punctual light: the loop multiplies the shading by `n_dot_l`, so
    // putting the exact integral here makes the diffuse term exact rather than
    // sampled. See [RectangleFormFactor].
    float formFactor = behind ? 0.0 : RectangleFormFactor(corners, s.n);

    // Radiance rather than intensity: `intensity` means the same thing for
    // every kind of light, so a panel's is spread over its own area here.
    // Enlarging a window at a fixed rating then dims it per square metre and
    // leaves the room as bright, which is what the number is supposed to mean.
    float area = length(cross(halfWidth, halfHeight)) * 4.0;
    float radiance = area > 1e-9 ? 1.0 / area : 0.0;

    // The range window only. A punctual light needs the inverse square as
    // well; the form factor already contains it, because a panel twice as far
    // away subtends a quarter of the sky.
    float distance = length(toCentre);
    if (direction.w > 0.0) {
      float ratio = distance / direction.w;
      float window = clamp(1.0 - ratio * ratio * ratio * ratio, 0.0, 1.0);
      radiance *= window * window;
    }

    vec3 mirror = reflect(-s.v, s.n);
    vec3 representative = RectangleClosestPoint(
        position.xyz, halfWidth, halfHeight, v_world_position, mirror);
    vec3 toPoint = representative - v_world_position;
    float pointDistance = length(toPoint);
    light.l = pointDistance > 1e-6 ? toPoint / pointDistance : s.n;

    light.h = normalize(light.l + s.v);
    light.n_dot_l = formFactor;
    light.n_dot_h = max(dot(s.n, light.h), 0.0);
    light.v_dot_h = max(dot(s.v, light.h), 0.0);
    light.radiance = color.rgb * color.w * radiance;
#ifdef F3D_LTC
    // `L7`: the specular over the whole panel rather than at one point of
    // it. The diffuse keeps the exact form factor above.
    light.integrated = 1.0;
    light.ltc = LtcRectangle(s.n, s.v, s.roughness, corners);
#ifdef F3D_LAYERED
    // Kept for the clear coat's own integral; see [g_rect_corners].
    g_rect_corners = corners;
#endif
#endif
    return light;
  }

  vec3 aim = normalize(direction.xyz);
  float attenuation = 1.0;

  if (type < 0.5) {
    // Directional: no position, no falloff. The direction to the light is the
    // reverse of the direction it points.
    light.l = -aim;
  } else {
    vec3 toLight = position.xyz - v_world_position;
    float distance = length(toLight);
    // A light exactly on the surface has no direction; treat it as contributing
    // nothing rather than producing a NaN that spreads through the frame.
    if (distance < 1e-6) {
      light.l = s.n;
      light.h = s.n;
      light.radiance = vec3(0.0);
      light.n_dot_l = 0.0;
      light.n_dot_h = 0.0;
      light.v_dot_h = 0.0;
      return light;
    }
    light.l = toLight / distance;
    attenuation = PunctualAttenuation(distance, direction.w);

    if (type > 1.5) {
      // Spot: a smooth ramp between the two cone cosines. The Dart side already
      // guarantees the denominator is non-zero.
      float cosAngle = dot(aim, -light.l);
      attenuation *= clamp(
          (cosAngle - cone.y) / (cone.x - cone.y), 0.0, 1.0);
    }
  }

  light.h = normalize(light.l + s.v);
  light.n_dot_l = max(dot(s.n, light.l), 0.0);
  light.n_dot_h = max(dot(s.n, light.h), 0.0);
  light.v_dot_h = max(dot(s.v, light.h), 0.0);
  light.radiance = color.rgb * color.w * attenuation;

  return light;
}

/// How much of light [index] reaches this fragment, defined by each fragment
/// shader.
///
/// A prototype rather than a call into shadow.glsl, because the models that
/// sample no shadow map must not declare its sampler — the compiler would drop
/// the slot and leave the engine binding one that is not there. A lit model
/// returns `ShadowFactor(...)`; an unlit one returns 1.
float LightVisibility(Surface s, LightSample light, int index);

/// A model's per-light term, defined by each fragment shader.
///
/// A prototype here and the definition in the model is what lets the loop below
/// be written once. The alternative — repeating the loop in every model — is
/// six copies of the same three lines, and the place a light would go missing.
vec3 ShadeLight(Surface s, LightSample light);

/// Sums every active light's contribution.
///
/// The loop bound is the compile-time maximum with a runtime break, because GLSL
/// wants a constant trip count and the hardware wants the early exit.
// **The point-shadow half of this header, behind a guard.**
//
// A model that never shadows must not *declare* any of this, and the reason is
// the one `unlit.frag` already gives about the shadow sampler — with one
// backend's failure added to the other's. On Impeller the compiler drops what
// nothing reads, and the engine binding a slot that is no longer there is a
// native crash. On WebGL2 nothing is dropped: an active uniform block with no
// buffer under it makes every draw `INVALID_OPERATION`, discarded with nothing
// logged.
//
// That is what `lighting-unlit` was on this backend. Unlit's own metadata says
// `usesPointShadow` is false, so the engine correctly bound no `PointShadow`
// block — and the translated shader declared one anyway, so the sphere was
// never drawn and the frame came back the clear colour.
#ifndef F3D_NO_POINT_SHADOW

/// The cube atlas: three tiles across, two down, each a ninety-degree view
/// from a point light, each storing radial distance normalised by range.
uniform sampler2D point_shadow_texture;

/// The same atlas for the things that never move, rendered once at load.
///
/// Two maps rather than one because a dungeon's walls can be baked and a
/// spinning pickup cannot, and there is no way to draw into part of a texture
/// without redrawing the rest of it. Sampling both and keeping the nearer
/// occluder costs one extra read and saves six views of the level every frame.
uniform sampler2D point_shadow_static_texture;

/// How many lights may have a row of the atlas. Six tiles across each.
// Rows of the cube atlas: six faces across, this many lights down. Must
// match `Renderer.kShadowedLights`, which is where the reasoning lives, and
// `shadowSlots` in the software backend's transcription of this file.
const int kShadowSlots = 6;

layout(std140) uniform PointShadow {
  /// The same view-projections the atlas was rendered with, six per slot.
  ///
  /// Passed rather than reconstructed. Deriving cube face coordinates here
  /// would be a second implementation of a decision the renderer already made,
  /// and the two would disagree about handedness or up vectors on some face
  /// and nowhere else — which shows as one face of every shadow being wrong.
  mat4 faces[6 * kShadowSlots];

  /// Per slot. xyz: the light's world position. w: its range.
  vec4 lights[kShadowSlots];

  /// Per light, in the order the lighting knows them.
  ///
  /// x: the atlas row it owns, or negative when it has none — a fifth torch in
  /// a room lands there. z: the tangent of half the frustum's opening angle,
  /// which is what converts a world width into a fraction of a tile. y and w
  /// are unwritten.
  ///
  /// **z is exactly one for a point light**, because a cube face is a ninety
  /// degree frustum and `tan(45°) == 1`. That is not a convention chosen to be
  /// tidy: it is what lets a narrower frustum share this whole path, since
  /// multiplying by one in IEEE 754 changes no bit of the result. Whatever else
  /// a spot light will need, it does not need a second copy of the filter.
  vec4 slots[kMaxLights];

  /// x: half a texel, in tile-local uv. y: distance bias in metres.
  /// z: strength. w: normal offset, **in texels of the face it lands on**.
  vec4 params;

  /// x: smallest kernel radius in tile-local uv, and the fixed radius used
  /// when contact hardening is off. y: the light's own radius in metres; zero
  /// turns contact hardening off. z: largest kernel radius in tile-local uv.
  /// w: non-zero paints the penumbra estimate into the surface buffer instead
  /// of shading with it.
  vec4 params2;

  /// x: non-zero when this backend stores the atlas bottom-up. y: one over the
  /// edge length of a tile in texels, which is what turns a distance into the
  /// world width of one texel there.
  ///
  /// **Appended after everything else on purpose**, the same way FragInfo's
  /// ambient pair was: std140 lays a block out in declaration order, so adding
  /// here leaves every offset above unchanged and the three backends do not
  /// have to agree about anything they already agreed about. y, z and w are
  /// unwritten.
  vec4 params3;
}
point_shadow;

/// Eight points on a Poisson disk, a common set for filtering cascaded
/// shadows.
///
/// A disk rather than a grid because a grid of taps on a straight shadow edge
/// lands every sample on the same side at once, and the edge steps between
/// kernel widths instead of sliding. Eight rather than sixteen because every
/// tap here reads **two** atlases — the static walls and the movers — so the
/// cost is doubled before it is counted.
vec2 PointShadowDiskTap(int i) {
  if (i == 0) return vec2(-0.94201624, -0.39906216);
  if (i == 1) return vec2(0.94558609, -0.76890725);
  if (i == 2) return vec2(-0.09418410, -0.92938870);
  if (i == 3) return vec2(0.34495938, 0.29387760);
  if (i == 4) return vec2(-0.91588581, 0.45771432);
  if (i == 5) return vec2(-0.81544232, -0.87912464);
  if (i == 6) return vec2(-0.38277543, 0.27676845);
  return vec2(0.97484398, 0.75648379);
}

/// One comparison against the atlas, at [uv] offset within the tile.
///
/// The clamp is applied **after** the offset, not before, and that is the whole
/// reason a kernel can be widened here without touching anything else: each tap
/// is held inside its own tile individually. Clamping the centre and then
/// offsetting would let the outer taps walk straight out of the tile and read a
/// distance measured from a different face, or a different light.
float PointShadowDistance(vec2 uv, vec2 offset, vec2 tile, float range) {
  float inset = point_shadow.params.x;
  vec2 local = clamp(uv + offset, inset, 1.0 - inset);
  vec2 atlas = (local + tile) * vec2(1.0 / 6.0, 1.0 / float(kShadowSlots));
  // **The whole atlas, turned over, where row zero of a render target is at the
  // bottom.** Both halves of the address are wrong there and this is the one
  // place that fixes both: the tile the light owns — a light in slot zero is
  // drawn into the row the shader would call three, because the viewport
  // rectangle is flipped to land it — and the picture inside that tile, which
  // was drawn through a projection built for the other origin.
  //
  // Every check of this atlas missed it for the same reason: the debug view
  // composites the texture through a full-screen pass, which turns it over
  // again and puts the row back. The atlas compared equal on both backends
  // across six scenes while the lit pass, which samples it directly and has no
  // such pass to cancel, read a row that had never been drawn into and found
  // nothing in the way of anything.
  if (point_shadow.params3.x > 0.5) atlas.y = 1.0 - atlas.y;
  // Whichever is nearer occludes: a wall in front of a monster shadows, and so
  // does a monster in front of a wall.
  //
  // **`textureLod` at level zero, because every caller of this function stands
  // behind a branch.** The light loop skips a light the surface faces away
  // from, the blocker search `continue`s past a tap that found nothing, and the
  // slot test returns before any of it — so the invocations of a quad do not
  // arrive here together, and a WGSL backend refuses a sample whose implicit
  // derivative would be read where they disagree. Both atlases are distance
  // render targets with one level, so level zero is the level `texture` was
  // choosing anyway; this names it rather than deriving it, and the picture is
  // the same on every backend.
  return min(textureLod(point_shadow_texture, atlas, 0.0).r,
             textureLod(point_shadow_static_texture, atlas, 0.0).r) * range;
}

float PointShadowTap(vec2 uv, vec2 offset, vec2 tile, float range,
                     float receiver) {
  float stored = PointShadowDistance(uv, offset, tile, range);
  // Nothing was drawn in that direction by either, so nothing is in the way.
  if (stored >= range * 0.999) return 1.0;
  return receiver > stored ? 0.0 : 1.0;
}

/// The disk point for tap [i], rotated by [ca]/[sa] and scaled to [radius].
vec2 PointShadowOffset(int i, float ca, float sa, float radius) {
  vec2 p = PointShadowDiskTap(i);
  return vec2(p.x * ca - p.y * sa, p.x * sa + p.y * ca) * radius;
}

/// How wide the penumbra should be here, in tile-local uv.
///
/// Contact hardening, and the reason a fixed kernel looks wrong: a shadow is
/// sharp where its caster touches the floor and soft a metre away, and one
/// radius for both makes the contact mushy or the distant edge hard.
///
/// The similar-triangles estimate is the standard one — a light of radius `L`
/// with a blocker at `b` and a receiver at `r` throws a penumbra `L * (r - b) /
/// b` wide at the receiver. Converting that to tile uv is exact rather than
/// tuned, because a face is a ninety degree frustum: at distance `r` from the
/// light the face spans `2 * r` in world units across the full `0..1` of uv,
/// so a world width `w` is `w / (2 * r)` of a tile.
///
/// The blocker search runs at the **widest** penumbra allowed, since a blocker
/// outside that circle cannot widen the result anyway, and searching narrower
/// would miss the very blockers that make an edge soft.
///
/// [tanHalf] is where the ninety degrees stop being assumed. The span above is
/// `2 * r` only for a right-angled frustum; in general it is `2 * r * tan(θ/2)`,
/// and for a cube face that factor is one. A narrower frustum covers less world
/// per tile, so the same world width is a *larger* fraction of it — which is
/// why this divides rather than multiplies, and why getting it upside down
/// would make a tight cone's shadows harden instead of soften.
float PointShadowPenumbra(vec2 uv, vec2 tile, float range, float receiver,
                          float ca, float sa, float tanHalf,
                          out float blockerOut) {
  blockerOut = -1.0;
  float lightRadius = point_shadow.params2.y;
  float minRadius = point_shadow.params2.x;
  float maxRadius = point_shadow.params2.z;
  if (lightRadius <= 0.0) {
    // **The debug channel is filled even though the search is skipped**, and
    // leaving it unfilled cost a session. `blockerOut` starts at −1 to mean
    // "nothing was measured"; the debug encoding clamps it into a colour, where
    // −1 becomes zero — the same green as a blocker touching the surface, which
    // reads as the most alarming answer available. A whole theory was built on
    // that zero, and the search it described had never run.
    //
    // The centre tap is what the filter below would use anyway, so this reports
    // a distance the atlas really returned rather than a sentinel.
    blockerOut = PointShadowDistance(uv, vec2(0.0), tile, range);
    return minRadius;
  }


  float sum = 0.0;
  float count = 0.0;
  for (int i = 0; i < 8; i++) {
    float stored =
        PointShadowDistance(uv, PointShadowOffset(i, ca, sa, maxRadius), tile,
                            range);
    if (stored >= range * 0.999) continue;
    if (stored >= receiver) continue;
    sum += stored;
    count += 1.0;
  }
  // Nothing in front of this fragment anywhere in the search: fully lit, and
  // the caller can skip the filter entirely.
  if (count < 0.5) return -1.0;

  float blocker = max(sum / count, 1e-4);
  blockerOut = blocker;
  float world = lightRadius * max(receiver - blocker, 0.0) / blocker;
  return clamp(world / (2.0 * receiver * tanHalf), minRadius, maxRadius);
}

/// How lit [world] is by the point light that owns the cube atlas.
///
/// One, fully lit, when this is not that light or the atlas has nothing to say.
float PointShadowFactor(vec3 world, vec3 normal, int lightIndex) {
  int slot = int(point_shadow.slots[lightIndex].x + 0.5);
  if (point_shadow.slots[lightIndex].x < 0.0) return 1.0;
  float strength = point_shadow.params.z;
  if (strength <= 0.0) return 1.0;

  // Offset along the normal before measuring, and scaled by how steeply the
  // surface leans away from the light.
  //
  // A soft kernel on a tilted surface straddles a depth gradient: the taps at
  // one end of the disk are further from the light than the fragment itself,
  // so a flat offset that clears the surface head-on leaves acne at a grazing
  // angle. The slope term lifts the whole kernel clear instead, and is capped
  // because it runs away as the surface turns edge-on to the light — an
  // uncapped lift detaches the shadow from its caster.
  vec3 toLight = point_shadow.lights[slot].xyz - world;
  float toLightLength = max(length(toLight), 1e-6);
  float nDotL = max(dot(normal, toLight / toLightLength), 0.15);
  float slope = min(sqrt(max(1.0 - nDotL * nDotL, 0.0)) / (nDotL * nDotL), 8.0);

  // **How wide one texel of the face is, out where this fragment is.** The
  // error a normal offset exists to clear is exactly that: a texel of the
  // shadow map covers a patch of surface, the whole patch is recorded at one
  // distance, and a fragment anywhere else in it compares against a distance
  // measured somewhere it is not. That patch grows with range — it is a solid
  // angle, not a length — so an offset fixed in metres is right at one distance
  // and wrong everywhere else.
  //
  // What it was: `params.w` metres, flat. On the golden teapot, at 9.6 m from
  // the lamp, a texel is 3.7 cm and the flat offset was 2 cm, so the floor
  // shadowed itself across everything the light reached — and the acne stopped
  // dead at the *projection of the floor's own edge*, because past it the atlas
  // holds nothing and nothing can occlude. A straight line across a shadow with
  // no straight edge anywhere in the scene.
  float texel =
      2.0 * toLightLength * max(point_shadow.slots[lightIndex].z, 1e-4) *
      point_shadow.params3.y;
  // Both terms are metres. The slope term used to be the kernel radius, which
  // is a fraction of a tile — a unit mismatch carried over from an estimate
  // where a softness radius genuinely was the right quantity. Here it meant
  // widening the kernel also lifted the sample off the surface, by up to ten
  // centimetres at the wider settings, so the softening and the lift
  // cancelled: tripling the kernel moved 184 pixels of the frame,
  // where the kernel alone moves thousands. It is what made contact hardening
  // look inert, and it was hiding in a comparison rather than in the estimate.
  vec3 origin = world + normal * texel * point_shadow.params.w * (1.0 + slope);
  vec3 toFragment = origin - point_shadow.lights[slot].xyz;
  float distance = length(toFragment);
  float range = max(point_shadow.lights[slot].w, 1e-4);
  if (distance >= range) return 1.0;

  // The dominant axis picks the face, in the order the renderer wrote them:
  // +X, -X, +Y, -Y, +Z, -Z, left to right then top to bottom.
  //
  // A spot has one column and no choice to make. Asking the dominant axis
  // anyway would be worse than pointless: a fragment below and to the side of
  // a downlight has −Y dominant, which is column 3, and column 3 of a spot's
  // row is deliberately blank — so the whole cone would read as unshadowed
  // except for the wedge where the aim happens to be the dominant axis.
  int face = 0;
  if (point_shadow.slots[lightIndex].y < 0.5) {
    vec3 a = abs(toFragment);
    if (a.x >= a.y && a.x >= a.z) {
      face = toFragment.x > 0.0 ? 0 : 1;
    } else if (a.y >= a.z) {
      face = toFragment.y > 0.0 ? 2 : 3;
    } else {
      face = toFragment.z > 0.0 ? 4 : 5;
    }
  }

  vec4 clip = point_shadow.faces[slot * 6 + face] * vec4(origin, 1.0);
  if (clip.w <= 0.0) return 1.0;
  vec2 ndc = clip.xy / clip.w;
  if (abs(ndc.x) > 1.0 || abs(ndc.y) > 1.0) return 1.0;

  // v is flipped, the same way the directional map does it: the texture's
  // origin is at the top, where row zero of the render target is. Getting this
  // wrong does not tilt the shadow — it makes the top row of faces read the
  // bottom row, so a whole region compares against an unrelated distance and
  // comes out as a black slab.
  vec2 uv = vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
  // The face across, the light down: six tiles wide, four tall.
  vec2 tile = vec2(float(face), float(slot));

  float receiver = distance - point_shadow.params.y;

  // One rotation, shared by the blocker search and the filter. Per fragment,
  // so eight samples read as a soft edge rather than as eight copies of the
  // silhouette: without it every fragment along an edge tests the same eight
  // directions and the pattern shows.
  //
  // **The three constants are not arbitrary and are not ours.** This is Jorge
  // Jimenez's interleaved gradient noise, from "Next Generation Post
  // Processing in Call of Duty: Advanced Warfare" (SIGGRAPH 2014):
  //
  //   IGN(x, y) = frac(52.9829189 * frac(0.06711056 * x + 0.00583715 * y))
  //
  // The pair inside the dot is a direction whose gradient walks the unit
  // interval as slowly as it can while never repeating over a screen, and the
  // multiplier outside stretches that walk so neighbouring pixels land far
  // apart in the result. What it buys over a hash is the cost: one dot and two
  // fracts, no integer arithmetic, no texture. What a blue-noise texture buys
  // over it is a better spectrum, at a sampler and a fetch — worth it for
  // dithering a whole frame, not for rotating eight taps.
  //
  // Written down because three unexplained decimals read as a magic spell, and
  // the next person to touch this line has no way to tell which of them may be
  // changed. The answer is none of them.
  float noise = fract(52.9829189 * fract(dot(FragCoordFromTop(
                                                frag_info.target_origin.x),
                                            vec2(0.06711056, 0.00583715))));
  float angle = noise * 6.28318530718;
  float ca = cos(angle);
  float sa = sin(angle);

  // Guarded rather than read straight, because a zero here divides by zero and
  // a NaN radius poisons the filter into a black fragment. Zero is what an
  // unwritten channel holds, and "unwritten" is a state this block has been in
  // before: every slot is cleared to −1 each frame.
  float tanHalf = max(point_shadow.slots[lightIndex].z, 1e-4);

  float blocker = -1.0;
  float radius =
      PointShadowPenumbra(uv, tile, range, receiver, ca, sa, tanHalf, blocker);

  // The debug channel, and the reason it exists: two explanations for why the
  // estimate collapses were argued from the finished picture and both were
  // wrong, because the number that decides it never leaves this function.
  //
  // Red is how wide the penumbra came out, against the widest allowed. Green
  // is how far away the blocker was, against the light's range. Blue marks
  // the fragments where the search found nothing at all — which is a different
  // answer from "found something very close", and telling those two apart is
  // most of the question.
  if (point_shadow.params2.w > 0.5) {
    g_debug_surface_on = true;
    g_debug_surface = radius < 0.0
        ? vec3(0.0, 0.0, 1.0)
        : vec3(clamp(radius / max(point_shadow.params2.z, 1e-6), 0.0, 1.0),
               clamp(blocker / range, 0.0, 1.0), 0.0);
  }

  // The search found nothing between here and the light.
  if (radius < 0.0) return 1.0;

  float lit = PointShadowTap(uv, vec2(0.0), tile, range, receiver);
  if (radius > 0.0) {
    for (int i = 0; i < 8; i++) {
      lit += PointShadowTap(uv, PointShadowOffset(i, ca, sa, radius), tile,
                            range, receiver);
    }
    lit *= 1.0 / 9.0;
  }

  // Strength lerps towards fully lit, so the control is "how dark", not "how
  // much of the kernel" — the same convention the directional map uses.
  return mix(1.0, lit, clamp(strength, 0.0, 1.0));
}

#else

/// The stand-in for a model that declares none of the above.
///
/// Fully lit, which is what a model with no shadow term means, and a constant
/// the compiler folds rather than a branch anything pays for.
float PointShadowFactor(vec3 world, vec3 normal, int lightIndex) {
  return 1.0;
}

#endif  // F3D_NO_POINT_SHADOW

vec3 AccumulateLights(Surface s) {
  vec3 total = vec3(0.0);
  int count = LightCount();

  for (int i = 0; i < kTotalLights; i++) {
    if (i >= count) break;
    LightSample light = SampleLight(i, s);
    if (light.n_dot_l <= 0.0) continue;
    // A light from the list has no shadow row to read — see `LightHasShadow`.
    // A branch rather than something folded into the two calls, because both
    // index tables eight entries wide and the ninth light would read past them
    // rather than read a one.
    float visibility = LightHasShadow(i)
        ? LightVisibility(s, light, i) *
              PointShadowFactor(v_world_position, s.n, i)
        : 1.0;
    if (visibility <= 0.0) continue;
    total += ShadeLight(s, light) * light.radiance * light.n_dot_l * visibility;
  }

  return total;
}

#endif  // SURFACE_GLSL_

// --- lib/irradiance.glsl ---
// The irradiance field, read per pixel — `L3`.
//
// **Per pixel where it was per object.** The field used to be sampled once
// per draw at the node's centre, twice (up and down), and handed to the shader
// as the hemisphere ambient. A floor that runs from a red wall to a blue one
// then took one colour, whichever its middle saw. Read here, at each point,
// the red bleeds onto the floor near the red wall and fades across it.
//
// The field arrives as one float texture: every probe's irradiance tile (rgb,
// with the probe's "active" flag in alpha) in a grid of `columns` × `rows`
// tiles at the top, and every probe's depth-moment tile (mean and mean
// square) in the same grid below. Each tile carries a one-texel gutter, so a
// bilinear read inside it never needs to know where the tile ends. The read
// is done here, four nearest taps at a time, rather than by a filtered
// sampler: a filtered float texture is a capability three backends answer
// differently, and four taps are the same on all of them.
//
// Weights per probe, as `IrradianceField.sample` on the host: trilinear by
// the point's place in its cell, the square of a half-cosine towards the
// probe, and Chebyshev's bound from the depth moments, the last two floored
// and crushed so no active probe's weight reaches nought. The point is moved
// off its surface along the normal and towards the eye first, so a surface
// does not read the probe's own view of it as a wall.
//
// Included by the lit models only, through `material_maps.glsl`.

#ifndef IRRADIANCE_GLSL_
#define IRRADIANCE_GLSL_

uniform sampler2D irradiance_texture;

layout(std140) uniform IrradianceInfo {
  /// xyz: where probe (0, 0, 0) stands. w: one when the field is read,
  /// nought when the hemisphere ambient stands.
  vec4 origin;

  /// xyz: the spacing between probes per axis. w: how far the point is
  /// moved along the normal, in metres.
  vec4 spacing;

  /// xyz: probes per axis. w: how far the point is moved towards the eye.
  vec4 counts;

  /// x: an irradiance tile's interior, y: a moment tile's, in texels.
  /// z: tiles per row of the atlas. w: the row the moment tiles start at.
  vec4 tiles;

  /// xy: one over the atlas's size. zw unused.
  vec4 atlas;
}
irradiance_info;

bool IrradianceEnabled() { return irradiance_info.origin.w > 0.5; }

/// `encodeOctahedral` in `irradiance_field.dart`.
vec2 ProbeOctahedral(vec3 direction) {
  float sum = abs(direction.x) + abs(direction.y) + abs(direction.z);
  if (sum <= 0.0) return vec2(0.5);
  vec3 n = direction / sum;
  vec2 xy = n.xy;
  if (n.z < 0.0) {
    xy = vec2((1.0 - abs(n.y)) * (n.x >= 0.0 ? 1.0 : -1.0),
              (1.0 - abs(n.x)) * (n.y >= 0.0 ? 1.0 : -1.0));
  }
  return xy * 0.5 + 0.5;
}

vec4 AtlasTexel(vec2 texel) {
  return textureLod(irradiance_texture, (texel + 0.5) * irradiance_info.atlas.xy,
                    0.0);
}

/// A bilinear read of the tile whose top-left stored texel is [corner],
/// [interior] wide, at the octahedral [uv].
vec4 TileBilinear(vec2 corner, float interior, vec2 uv) {
  vec2 at = 1.0 + uv * interior - 0.5;
  vec2 low = floor(at);
  vec2 f = at - low;
  vec4 a = AtlasTexel(corner + low);
  vec4 b = AtlasTexel(corner + low + vec2(1.0, 0.0));
  vec4 c = AtlasTexel(corner + low + vec2(0.0, 1.0));
  vec4 d = AtlasTexel(corner + low + vec2(1.0, 1.0));
  return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}

/// The irradiance arriving at [world] on a surface facing [normal], seen
/// from the direction [view] (a unit vector towards the eye).
vec3 SampleIrradiance(vec3 world, vec3 normal, vec3 view) {
  vec3 origin = irradiance_info.origin.xyz;
  vec3 spacing = irradiance_info.spacing.xyz;
  vec3 counts = irradiance_info.counts.xyz;
  float irradianceTile = irradiance_info.tiles.x;
  float depthTile = irradiance_info.tiles.y;
  float columns = irradiance_info.tiles.z;
  float momentsTop = irradiance_info.tiles.w;
  vec3 unit = normalize(normal);

  vec3 biased = world + unit * irradiance_info.spacing.w +
                view * irradiance_info.counts.w;
  vec3 grid = (biased - origin) / spacing;
  vec3 base = clamp(floor(grid), vec3(0.0), counts - 2.0);
  vec3 f = clamp(grid - base, vec3(0.0), vec3(1.0));

  vec3 total = vec3(0.0);
  float weights = 0.0;
  for (int corner = 0; corner < 8; corner++) {
    vec3 offset = vec3(float(corner & 1), float((corner >> 1) & 1),
                       float((corner >> 2) & 1));
    vec3 cell = base + offset;
    float probe = (cell.z * counts.y + cell.y) * counts.x + cell.x;
    vec2 tile = vec2(mod(probe, columns), floor(probe / columns));

    vec2 irradianceCorner = tile * (irradianceTile + 2.0);
    vec2 momentCorner = vec2(tile.x * (depthTile + 2.0),
                             momentsTop + tile.y * (depthTile + 2.0));

    // The probe's own flag, on the tile's first interior texel.
    if (AtlasTexel(irradianceCorner + 1.0).a < 0.5) continue;

    vec3 trilinear = mix(vec3(1.0) - f, f, offset);
    float weight = max(trilinear.x * trilinear.y * trilinear.z, 0.001);

    vec3 probePosition = origin + spacing * cell;
    vec3 toProbe = probePosition - biased;
    float distance = length(toProbe);
    if (distance > 1e-6) {
      vec3 direction = toProbe / distance;
      // Facing and visibility are floored, then crushed, rather than let
      // fall to nought (Majercik et al. 2019): a probe behind the surface or
      // past a wall counts for almost nothing but never for nothing, so a
      // point every probe of its cell is cut off from still reads a blend of
      // them rather than black.
      float facing = dot(unit, normalize(probePosition - world)) * 0.5 + 0.5;
      float probeWeight = facing * facing + 0.2;

      vec2 moments = TileBilinear(momentCorner, depthTile,
                                  ProbeOctahedral(-direction)).xy;
      float chebyshev = 1.0;
      if (distance > moments.x) {
        float variance = max(moments.y - moments.x * moments.x, 1e-6);
        float difference = distance - moments.x;
        chebyshev = variance / (variance + difference * difference);
        chebyshev = chebyshev * chebyshev * chebyshev;
      }
      probeWeight = max(probeWeight * max(chebyshev, 0.05), 1e-6);
      if (probeWeight < 0.2) probeWeight *= probeWeight * probeWeight * 25.0;
      weight *= probeWeight;
    }

    total += TileBilinear(irradianceCorner, irradianceTile,
                          ProbeOctahedral(unit)).rgb *
             weight;
    weights += weight;
  }
  return weights > 0.0 ? total / weights : vec3(0.0);
}

#endif  // IRRADIANCE_GLSL_


/// Tangent-space normal map. Neutral is (0.5, 0.5, 1.0).
uniform sampler2D normal_texture;

/// glTF's ORM packing: g is roughness, b is metallic. Neutral is white.
uniform sampler2D metallic_roughness_texture;

/// Ambient occlusion in r. Neutral is white.
uniform sampler2D occlusion_texture;

/// Emitted colour, multiplied by the emissive factor. Neutral is white, and the
/// factor defaults to black, so a material with neither emits nothing.
uniform sampler2D emissive_texture;

/// The level's baked lightmap, RGBM: colour over a shared multiplier, decoded
/// as `rgb × a × 8`. Sampled at the second coordinate, which every vertex
/// stage but the lightmapped one leaves at the atlas corner; neutral is
/// black, so a material without a map adds nothing.
uniform sampler2D lightmap_texture;

/// The irradiance the lightmap holds at this fragment, in the units a light's
/// `colour × intensity × attenuation × cos` arrives in.
vec3 SampleLightmap() {
  vec4 texel = texture(lightmap_texture, v_lightmap_uv);
  return texel.rgb * texel.a * 8.0;
}

/// One function per map, rather than one that applies all four.
///
/// Not a style choice. The compiler drops a sampler whose result never reaches
/// the output, so a model that samples the ORM map and then ignores metallic and
/// roughness — Lambert does exactly that — ends up with no
/// `metallic_roughness_texture` in its compiled signature at all, while the Dart
/// side still thinks there is one to bind. That is the phantom-binding trap
/// again, and binding a slot Metal does not have is a native crash.
///
/// Splitting them means a model calls only what it genuinely uses, so the
/// compiled signature matches the source, and `LightingModel` can declare the
/// same set truthfully. `tool/build_shaders.sh` prints the compiled slots so
/// the two cannot drift apart unnoticed.

/// glTF's ORM packing: roughness in g, metallic in b, both multiplying the
/// material factors.
void ApplyMetallicRoughnessMap(inout Surface s) {
  vec3 orm = texture(metallic_roughness_texture, MapUv(kMapMetallicRoughness), MaterialLodBias()).rgb;
  s.metallic = clamp(s.metallic * orm.b, 0.0, 1.0);
  s.roughness = clamp(s.roughness * orm.g, 0.02, 1.0);
}

void ApplyOcclusionMap(inout Surface s) {
  float occlusion = texture(occlusion_texture, MapUv(kMapOcclusion), MaterialLodBias()).r;
  // glTF's occlusionStrength lerps between "ignore the map" and "apply it in
  // full", which is why it is a mix and not a multiply.
  s.occlusion = mix(1.0, occlusion, clamp(frag_info.material2.z, 0.0, 1.0));
}

void ApplyEmissiveMap(inout Surface s) {
  vec3 emissive = SrgbToLinear(texture(emissive_texture, MapUv(kMapEmissive), MaterialLodBias()).rgb);
  s.emissive = emissive * frag_info.emissive.rgb * frag_info.material2.w;
}

/// Perturbs the surface normal by the tangent-space normal map.
void ApplyNormalMap(inout Surface s) {
  // **Sampled before the frame is tested, and that order is load-bearing.**
  // The test below is a branch on interpolated data, so the four invocations of
  // a quad can take different sides of it; a WGSL backend then refuses a
  // `texture` call underneath, because the mip level it derives is only defined
  // where the whole quad agrees. Unlike the shadow atlases, this map really is
  // mipped — a normal map read at full resolution on a surface turned away from
  // the camera is the aliasing that made this the widest disagreement between
  // backends — so pinning a level here would be a picture change, and hoisting
  // the sample is the cure that is not. A degenerate tangent is rare enough
  // that paying for its unused texel is nothing, and the texel it reads is the
  // same one the branch would have read.
  vec4 sampledTexel = texture(normal_texture, MapUv(kMapNormal), MaterialLodBias());

  // The tangent is re-orthogonalized against the normal because interpolating
  // both across a triangle does not preserve the right angle between them.
  vec3 t = v_tangent.xyz;
  t = t - s.n * dot(s.n, t);
  if (dot(t, t) < 1e-12) return;  // no usable frame; keep the vertex normal
  t = normalize(t);

  // The bitangent sign is what encodes a mirrored UV island. Dropping it makes
  // every mirrored half of a symmetric model light from the wrong side, which
  // is exactly what NormalTangentTest is built to show.
  vec3 b = cross(s.n, t) * v_tangent.w;
#ifdef F3D_TEXTURE_TRANSFORM
  // `C8`: a map turned or mirrored by its transform is read along axes the
  // vertex tangent no longer names, so the frame turns with it — the rule
  // `withTextureTransform` applies to a baked mesh, here at the sampler. The
  // new tangent is where the map's own `u` increases: the first column of the
  // matrix's inverse, times its determinant, whose sign a mirror flips and the
  // bitangent's sign with it. Measured on the front face's frame, which is
  // the frame the transform was authored on. A plain scale leaves the frame
  // as it was, bit for bit, which is why the test is on the matrix. That
  // column is `m11 dP/du - m10 dP/dv`, and dP/dv is **minus** the bitangent:
  // `v` runs down the texture, a normal map's green up it.
  vec4 m = MapMatrix(kMapNormal);
  float det = m.x * m.w - m.y * m.z;
  float flip = det < 0.0 ? -1.0 : 1.0;
  vec3 front = gl_FrontFacing ? b : -b;
  vec3 turned = (t * m.w + front * m.z) * flip;
  bool turns = (m.y != 0.0 || m.z != 0.0 || m.x < 0.0 || m.w < 0.0) &&
               dot(turned, turned) > 1e-12;
  t = turns ? normalize(turned) : t;
  b = turns ? cross(s.n, t) * v_tangent.w * flip : b;
#endif
  // On a back face `ReadSurface` has already turned the normal round, and
  // the bitangent above turned with it. The tangent has to follow, or the
  // frame is half-mirrored and relief along u lights from the wrong side —
  // glTF turns the whole frame, not the normal alone.
  if (!gl_FrontFacing) t = -t;

  vec3 sampled = sampledTexel.xyz * 2.0 - 1.0;
  // A two-channel map (BC5, RG8) stores only x and y and samples as
  // (x, y, 0, 1); read as it stands, blue 0 is z = -1 and the normal points
  // into the surface. z is rebuilt from the unit length instead, before the
  // scale, which glTF applies to the stored normal. `emissive.w` is the flag.
  if (frag_info.emissive.w > 0.5) {
    sampled.z = sqrt(max(1.0 - dot(sampled.xy, sampled.xy), 0.0));
  }
  // normalScale attenuates the tangent-space xy, per the glTF spec.
  sampled.xy *= frag_info.material2.y;

  s.n = normalize(t * sampled.x + b * sampled.y + s.n * sampled.z);
  s.n_dot_v = max(dot(s.n, s.v), 1e-4);
}

/// The three maps every lit model uses. Metal-rough is separate because only
/// the models that actually respond to metallic or roughness may sample it.
void ApplyCommonMaps(inout Surface s) {
  // `L3`: the field in place of the hemisphere, read before the normal map
  // for the reason the hemisphere is — which half of the room a face sees is
  // not a question about millimetres of relief. At the same strength the
  // hemisphere was.
  if (IrradianceEnabled()) {
    s.ambient = SampleIrradiance(v_world_position, s.n, s.v) *
                frag_info.material.z;
  }
  ApplyNormalMap(s);
  ApplyOcclusionMap(s);
  ApplyEmissiveMap(s);
}

#endif  // MATERIAL_MAPS_GLSL_

// --- lib/shadow.glsl ---
// Sampling the directional light's shadow map.
//
// A separate header for the same reason material_maps.glsl is one: the sampler
// must only be declared by shaders that actually read it, or the compiler drops
// the slot while the engine still tries to bind it.

#ifndef SHADOW_GLSL_
#define SHADOW_GLSL_

// --- lib/evsm.glsl ---
// Exponential variance shadow maps — `S2`.
//
// Shared by the pass that turns the directional depth atlas into moments
// (`evsm_filter.frag`) and by `ShadowFactor`, which reads them back: the two
// halves must warp depth with the same two exponents, or every comparison is
// between numbers on different scales.
//
// A header of its own rather than a section of `shadow.glsl`, because that
// one declares the lit stages' shadow sampler and the filter pass has no
// business declaring it.

#ifndef EVSM_GLSL_
#define EVSM_GLSL_

precision highp float;

// The two exponents depth is warped by. **Forty and five, and the ceiling is
// the format.** The moments are stored squared, so the positive side reaches
// e^80 at the far plane, about 5.5e34 — inside a 32-bit float with three
// orders of magnitude to spare, and far outside a half float, which is why
// the moments live in an rgba32f atlas and the depth atlas does not. The
// negative side only has to catch what the positive side lets through at a
// receiver just behind a caster, and five is the usual answer.
const float kEvsmPositive = 40.0;
const float kEvsmNegative = 5.0;

/// [depth], in [0, 1], warped onto both exponentials: x positive, y negative.
///
/// Depth is first spread to [-1, 1] so the two sides share the range evenly
/// rather than the negative one flattening to nothing at the far end.
vec2 EvsmWarp(float depth) {
  float d = 2.0 * clamp(depth, 0.0, 1.0) - 1.0;
  return vec2(exp(kEvsmPositive * d), -exp(-kEvsmNegative * d));
}

/// What one texel of the depth atlas stores in the moments atlas: each warp
/// and its square, which a blur then averages into a mean and a variance.
vec4 EvsmMoments(float depth) {
  vec2 warped = EvsmWarp(depth);
  return vec4(warped.x, warped.x * warped.x, warped.y, warped.y * warped.y);
}

/// Chebyshev's upper bound on the share of [moments]'s distribution at or
/// beyond [t], with the light-bleeding cut [bleed] taken off the bottom.
///
/// A select at the end rather than an early return of one, because a phi of
/// constants is what SPIRV-Cross refuses when it writes the WGSL.
float EvsmChebyshev(vec2 moments, float t, float minVariance, float bleed) {
  float variance = max(moments.y - moments.x * moments.x, minVariance);
  float d = t - moments.x;
  float pMax = variance / (variance + d * d);
  // Light bleeding: where two casters overlap, the bound admits light the
  // nearer one should block. Everything under [bleed] is called shadow and
  // the rest stretched back over [0, 1].
  float reduced = clamp((pMax - bleed) / max(1.0 - bleed, 1e-4), 0.0, 1.0);
  return t <= moments.x ? 1.0 : reduced;
}

/// How much light reaches a receiver at [depth] past filtered [moments].
///
/// The smaller of the two bounds: each exponential lets through a different
/// kind of error, and neither lets through what the other stops.
float EvsmVisibility(vec4 moments, float depth, float bleed) {
  vec2 warped = EvsmWarp(depth);
  // A floor on the variance proportional to the warped depth's own slope,
  // so a flat receiver compared against its own texel does not divide
  // nought by nought — the variance of one depth is zero.
  vec2 scale = 0.0001 * vec2(kEvsmPositive, kEvsmNegative) * warped;
  float positive = EvsmChebyshev(moments.xy, warped.x, scale.x * scale.x, bleed);
  float negative = EvsmChebyshev(moments.zw, warped.y, scale.y * scale.y, bleed);
  return min(positive, negative);
}

#endif  // EVSM_GLSL_


/// Linear depth from the light's point of view, in the red channel — or,
/// with the `evsm` filter (`S2`), the blurred moments `evsm_filter.frag`
/// made of it, bound to the same slot so the lit stages spend no sampler on
/// the choice.
uniform sampler2D shadow_texture;

/// Point [i] of [n] on a Vogel disc turned by [turn] radians — `S3`: the
/// golden angle between neighbours, so any prefix of the points covers the
/// disc evenly, and a radius growing with the square root, so they cover it
/// at an even density.
vec2 VogelDisc(int i, int n, float turn) {
  float r = sqrt((float(i) + 0.5) / float(n));
  float theta = float(i) * 2.3999632 + turn;
  return r * vec2(cos(theta), sin(theta));
}

/// Interleaved gradient noise at this pixel, in [0, 1), stepped on by the
/// frame's slice while a temporal resolve runs (`target_origin.w`) so the
/// history averages the rotations. The pattern needs no texture, which keeps
/// the lit stages at the samplers they have. Rows are counted from the top
/// (`target_origin.x`), as the point shadow's rotation counts them, so WebGL2
/// turns the kernel on the same pixels as every other backend.
float ShadowNoise() {
  vec2 at = FragCoordFromTop(frag_info.target_origin.x) +
            5.588238 * max(frag_info.target_origin.w, 0.0);
  return fract(52.9829189 * fract(dot(at, vec2(0.06711056, 0.00583715))));
}

/// How much of the light survives at this fragment, from 0 to 1.
///
/// Returns 1 when shadows are off, when the fragment falls outside the map, or
/// when the light in question is not the caster — a fragment beyond the shadow
/// volume is unshadowed, not black, and getting that wrong puts a hard edge
/// across the scene at the edge of the map.
float ShadowFactor(Surface s, LightSample light, int lightIndex) {
  float strength = frag_info.shadow_params.w;
  if (strength <= 0.0) return 1.0;
  if (lightIndex != int(frag_info.frame_params.z + 0.5)) return 1.0;

  // Normal offset: move the sample point along the surface normal before
  // projecting it. It costs nothing and fixes the shadow acne that a depth bias
  // alone cannot, because the error is proportional to the surface's slope
  // relative to the light rather than to depth.
  //
  // **A flat distance plus what the kernel's reach needs, and no more.** The
  // flat part alone was tuned for surfaces the map never recorded: with the
  // default `casterFaces: back` a closed mesh writes only the faces turned
  // away from the sun, so a lit face compares against its own far side. A
  // double-sided material writes its lit faces too, and then the offset has
  // to lift the point clear of its own plane as far out as the 3×3 kernel
  // reads: a tap one texel over lands in a texel whose centre is up to a
  // texel and a half away, where the plane is 1.5·texel·tanθ nearer the
  // light. A step d along the normal clears the plane by d / cosθ along the
  // ray, so d = 1.5·texel·sinθ is exactly enough, taken per axis of the map
  // because a slope running diagonally across it reaches further in texels.
  // Nothing at normal incidence, a texel and a half at grazing. The depth
  // bias covers the rest. Every metre more than this moves the shadow away
  // from its caster, and in the far cascade a texel is decimetres. Measured
  // per cascade in the loop below, since each has a texel of its own.

  // Which cascade covers this fragment.
  //
  // Chosen by distance from the camera and then *checked*, because the volumes
  // are spheres on the line of sight rather than fitted frusta: a fragment at
  // the edge of the view can be past the end of the cascade its distance
  // suggests. Falling through to the next one costs a branch and removes a
  // whole class of missing-shadow bug, and the last cascade is fitted to the
  // entire scene, so the fall-through always terminates somewhere real.
  int cascadeCount = int(frag_info.shadow_cascades.z + 0.5);
  float viewDistance = length(v_world_position - frag_info.camera_position.xyz);
  int cascade = 0;
  if (cascadeCount > 1 && viewDistance > frag_info.shadow_cascades.x) cascade = 1;
  if (cascadeCount > 2 && viewDistance > frag_info.shadow_cascades.y) cascade = 2;

  vec2 uv = vec2(0.0);
  vec3 projected = vec3(0.0);
  bool found = false;
  // `S3`: what the soft path needs of the cascade it lands in — metres per
  // texel across, and metres per unit of stored depth along the light.
  float cascadeTexel = 1.0;
  float cascadeDepth = 1.0;
  for (int attempt = 0; attempt < 3; attempt++) {
    int which = cascade + attempt;
    if (which >= cascadeCount) break;

    mat4 matrix = which == 0
        ? frag_info.shadow_matrix
        : (which == 1 ? frag_info.shadow_matrix_far
                      : frag_info.shadow_matrix_farthest);
    // One texel of this cascade in metres. The projection is orthographic,
    // so its first row is 2 / width, and a tile texel is `shadow_cascades.w`
    // of the width. The rows are also the map's axes in the world, which is
    // what the normal is measured along: its share across each axis is the
    // sine of the slope in that direction.
    vec3 axisX = vec3(matrix[0][0], matrix[1][0], matrix[2][0]);
    vec3 axisY = vec3(matrix[0][1], matrix[1][1], matrix[2][1]);
    float rowX = max(length(axisX), 1e-6);
    float rowY = max(length(axisY), 1e-6);
    float texelMetres = 2.0 * frag_info.shadow_cascades.w / rowX;
    float reach = 1.5 * 2.0 * frag_info.shadow_cascades.w *
        (abs(dot(s.n, axisX)) / (rowX * rowX) +
         abs(dot(s.n, axisY)) / (rowY * rowY));
    vec3 origin = v_world_position + s.n * (frag_info.shadow_params.z + reach);
    vec4 lightSpace = matrix * vec4(origin, 1.0);
    if (lightSpace.w <= 0.0) continue;
    vec3 candidate = lightSpace.xyz / lightSpace.w;

    // Clip space x and y are in [-1, 1]; a tile is in [0, 1] with the origin at
    // the top, matching where the render target's row zero is.
    vec2 inTile = vec2(candidate.x * 0.5 + 0.5, 0.5 - candidate.y * 0.5);
    if (inTile.x < 0.0 || inTile.x > 1.0 || inTile.y < 0.0 || inTile.y > 1.0) {
      continue;
    }
    // Depth is already in [0, 1] here, as every projection in this engine
    // produces. **Past the far plane is behind every caster, not outside the
    // map.** The last cascade's depth is fitted to the casters alone, so a
    // floor that runs on past them — the tip of a long evening shadow — sits
    // beyond it. Skipping that point called it lit and cut the shadow off
    // along the line where the far plane meets the floor. A nearer cascade
    // may still be missing casters and hands the point on; the last one
    // clamps, and 1.0 compares lit only against a texel nothing was drawn in.
    if (candidate.z > 1.0) {
      if (which < cascadeCount - 1) continue;
      candidate.z = 1.0;
    }

    // Into the atlas: the cascades sit side by side in one texture.
    uv = vec2((inTile.x + float(which)) / float(cascadeCount), inTile.y);
    projected = candidate;
    cascade = which;
    cascadeTexel = texelMetres;
    cascadeDepth =
        1.0 / max(length(vec3(matrix[0][2], matrix[1][2], matrix[2][2])), 1e-6);
    found = true;
    break;
  }
  if (!found) return 1.0;

  float bias = cascade == 0
      ? frag_info.shadow_bias.x
      : (cascade == 1 ? frag_info.shadow_bias.y : frag_info.shadow_bias.z);
  // Horizontally a texel of the atlas, vertically a texel of a tile. With one
  // cascade they are the same number and this is the kernel it has always been.
  vec2 texel = vec2(frag_info.shadow_params.x, frag_info.shadow_cascades.w);

  // **Every tap is held inside its own cascade's tile**, half a texel in from
  // the edge, and after the offset rather than before: the cube atlas learned
  // this first (`PointShadowDistance`). The cascades sit side by side, so a
  // tap that stepped past a seam read the neighbouring cascade's depth,
  // measured through another projection, and a fragment at the edge of the
  // near tile took its shadow partly from the far one. With one cascade the
  // tile is the whole texture and the clamp is the sampler's own edge.
  vec2 tileLo = vec2(float(cascade) / float(cascadeCount), 0.0) + 0.5 * texel;
  vec2 tileHi =
      vec2(float(cascade + 1) / float(cascadeCount), 1.0) - 0.5 * texel;

  // **`textureLod` and not `texture`, and the level asked for is the only one
  // there is.** Everything above this loop is a reason not to be here — the
  // cascade search returns early when no cascade contains the fragment, and the
  // light loop that calls it skips a light facing away — so a WGSL backend sees
  // a sample taken where the four invocations of a quad need not agree, and
  // refuses it: the implicit derivative `texture` asks for is only defined
  // where they all arrive. The cascade atlas is a depth render target with a
  // single level, so the derivative was never doing anything but selecting
  // level zero, and naming that level directly costs nothing and changes no
  // pixel on any backend.
  //
  // **The softness, where it rides, and what zero means.**
  //
  // `ambient_ground.w` is the directional light's apparent size. It has
  // nothing to do with ambient light and everything to do with this being the
  // one component left unspent in a block six shaders share: `frame_params.w`
  // was the slot reserved for exactly this and the environment's level count
  // took it, and appending to the block moves offsets four backends have
  // agreed on. The alternative was a second uniform block bound per draw for
  // one float. Named here because a reader arriving at `ambient_ground` has
  // every right to be surprised.
  //
  // Zero is the 3×3 kernel this has always had, which is what keeps every
  // recorded golden where it is. Above zero the edge widens with the distance
  // between the occluder and what it falls on — what a real light does, and
  // what no fixed kernel can.
  //
  // **Below zero is the `evsm` filter** (`S2`), and the texture bound here is
  // then the moments atlas rather than depth: one filtered tap replaces the
  // kernel, and how far under minus one the value sits is the light-bleeding
  // cut. A sign rather than another uniform, for the reason the softness
  // itself rides here.
  float softness = frag_info.ambient_ground.w;
  float lit = 0.0;
  if (softness < 0.0) {
    // The blur already happened, once for the whole atlas, so the one tap
    // is the filter: the sampler's own bilinear step is all it adds.
    vec4 moments = textureLod(shadow_texture, clamp(uv, tileLo, tileHi), 0.0);
    lit = EvsmVisibility(moments, projected.z - bias,
                         clamp(-softness - 1.0, 0.0, 0.95));
  } else if (softness <= 0.0) {
    // PCF 3x3. Four samples would band visibly at this map size and nine is
    // the smallest kernel that reads as a soft edge rather than as stair
    // steps.
    for (int y = -1; y <= 1; y++) {
      for (int x = -1; x <= 1; x++) {
        float occluder = textureLod(
            shadow_texture,
            clamp(uv + vec2(float(x), float(y)) * texel, tileLo, tileHi),
            0.0).r;
        lit += projected.z - bias > occluder ? 0.0 : 1.0;
      }
    }
    lit *= 1.0 / 9.0;
  } else {
    // **Find what is casting before deciding how wide to blur**, then blur by
    // what a light of this size would leave — `S3`. Sixteen taps each way on
    // a Vogel disc turned per pixel, where there were five fixed ones: the
    // turn trades the five's regular pattern for noise the eye reads as
    // grain, and a temporal resolve averages away.
    //
    // **In metres, per cascade.** The gap between the blocker and this
    // fragment is measured in the cascade's stored depth, whose unit is a
    // different length in each cascade; converted to metres, the penumbra is
    // the gap times the light's apparent diameter, and in texels it is that
    // over the cascade's own texel. A shadow keeps its softness crossing
    // from one cascade into the next.
    //
    // **A radius, so half that width.** A disc of radius R swept across an
    // edge ramps from dark to lit over 2R, so the kernel is the gap times
    // the tangent of the light's angular *radius*: the penumbra comes out the
    // full `2·tan(α)·gap` the settings promise, not twice it. The search is
    // the same cone, `tan(α)` of the way back to the light; a wider one only
    // pulls in blockers that cannot reach this fragment.
    float spread = tan(min(softness, 0.5));
    float turn = ShadowNoise() * 6.2831853;

    // As wide as the widest penumbra could be at this depth, and no wider:
    // the whole of the distance back to the light is the largest gap there
    // is.
    float searchRadius =
        clamp(spread * projected.z * cascadeDepth / cascadeTexel, 1.0, 16.0);
    float blockerSum = 0.0;
    float blockerCount = 0.0;
    for (int i = 0; i < 16; i++) {
      float occluder = textureLod(
          shadow_texture,
          clamp(uv + VogelDisc(i, 16, turn) * texel * searchRadius, tileLo,
                tileHi),
          0.0).r;
      if (projected.z - bias > occluder) {
        blockerSum += occluder;
        blockerCount += 1.0;
      }
    }
    // Nothing between this fragment and the light: lit, and no second loop.
    if (blockerCount <= 0.0) return 1.0;

    float gap = max(projected.z - blockerSum / blockerCount, 0.0) * cascadeDepth;
    // One texel at the tightest, so a contact edge stays an edge; the cap
    // keeps a distant occluder from reaching across a whole cascade.
    float radius = clamp(spread * gap / cascadeTexel, 1.0, 16.0);

    for (int i = 0; i < 16; i++) {
      float occluder = textureLod(
          shadow_texture,
          clamp(uv + VogelDisc(i, 16, turn + 1.0) * texel * radius, tileLo,
                tileHi),
          0.0).r;
      lit += projected.z - bias > occluder ? 0.0 : 1.0;
    }
    lit *= 1.0 / 16.0;
  }

  // Strength lerps towards fully lit, so the control is "how dark", not "how
  // much of the kernel".
  return mix(1.0, lit, clamp(strength, 0.0, 1.0));
}

#endif  // SHADOW_GLSL_


float LightVisibility(Surface s, LightSample light, int index) {
  return ShadowFactor(s, light, index);
}

vec3 ShadeLight(Surface s, LightSample light) {
  // Fewer bands as roughness rises, so the slider still does something here.
  float bands = mix(5.0, 2.0, s.roughness);
  // smoothstep on the band edge keeps the step from aliasing into jagged
  // terminator lines.
  float quantized = floor(light.n_dot_l * bands) / bands;
  float fraction = fract(light.n_dot_l * bands);
  quantized += smoothstep(0.85, 1.0, fraction) / bands;

  // AccumulateLights multiplies by N.L, which is exactly what banding is meant
  // to replace, so divide it back out and keep the quantized ramp instead.
  float ramp = quantized / max(light.n_dot_l, 1e-3);

  return s.albedo * ramp;
}

void main() {
  Surface s = ReadSurface();
  ApplyCommonMaps(s);
  // Roughness sets the band count, so the ORM map matters here too.
  ApplyMetallicRoughnessMap(s);

  // The rim is a property of the view, not of any one light, so it belongs
  // outside the loop — adding it per light would make it brighten with the
  // number of lamps in the scene.
  float rim = pow(1.0 - s.n_dot_v, 3.0) * frag_info.material.w;
  vec3 ambient = s.albedo * (s.ambient + SampleLightmap()) * s.occlusion;

  WriteSurface(
      AccumulateLights(s) * s.occlusion + ambient + vec3(rim * 0.35) +
          s.emissive,
      s.alpha,
      s.roughness);
}

''',
    'Normals': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// Debug view: world-space normal mapped into RGB.
//
// The fastest way to tell a geometry bug from a lighting bug. Hard edges show as
// flat colour blocks, smooth ones as gradients, and inverted winding shows as
// the complement of the expected colour.
//
// Includes lib/color.glsl rather than lib/surface.glsl on purpose: this shader
// reads no material inputs, and merely DECLARING the FragInfo block would leave
// it visible to reflection while the compiled shader binds no buffer for it.
// Binding that phantom block segfaults inside Metal's
// setFragmentBuffer:offset:atIndex:. LightingModel.usesFragInfo encodes the same
// fact on the Dart side, because reflection alone cannot be trusted here.
// --- lib/color.glsl ---
// Colour space helpers and the fragment output interface.
//
// Split out of surface.glsl so a shader that needs no material inputs — the
// normals debug view — can avoid DECLARING the FragInfo uniform block at all.
// That matters more than it looks: reflection metadata reports a block as
// present merely because it was declared, even when the compiled shader binds
// no such buffer, so a declared-but-unused block is indistinguishable from a
// used one until Metal crashes on the bind.

#ifndef COLOR_GLSL_
#define COLOR_GLSL_

precision highp float;

const float kPi = 3.14159265359;

// One varying set shared by every fragment shader, matching mesh.vert.
//
// All five are declared here, including the two the debug models never read: a
// fragment shader whose `in` block disagrees with the vertex shader's `out`
// block fails to link, and there is no partial-match rule to lean on.
in vec3 v_world_position;
in vec3 v_normal;
in vec2 v_texcoord;
in vec4 v_tangent;
in vec4 v_color;

/// Where this fragment is in the level's lightmap. Zero from every vertex
/// stage but `mesh_lightmapped.vert`, and read only by the lit models, which
/// sample a one-texel black there when a material has no map.
in vec2 v_lightmap_uv;

layout(location = 0) out vec4 frag_color;

// The second attachment: what a screen-space effect needs to know about the
// surface it is looking at. World-space normal in rgb, and in a the depth along
// the view axis in world metres — not a window depth; `WriteSurfaceGeometry`
// says at length why not.
//
// Depth travels here rather than in a depth texture because flutter_gpu cannot
// sample one — the same reason the shadow pass writes its depth into a colour
// target. See ARCHITECTURE.md §2.
//
// Guarded, because not every stage that includes this header draws into a
// two-attachment target. The shadow pass draws into one, and a pipeline
// declaring an output its target has no slot for is a mismatch worth avoiding
// rather than discovering.
#ifndef F3D_NO_SURFACE_BUFFER
layout(location = 1) out vec4 frag_surface;

/// The surface's own colour, sRGB-encoded, alpha one where a surface was
/// drawn — `L5`. The third attachment, present only when a pass reads it (the
/// indirect light does) and the device opens three; like the surface buffer,
/// written unconditionally and discarded when absent. Stored in the surface
/// buffer's format rather than eight bits a channel, and `Renderer` says why.
layout(location = 2) out vec4 frag_albedo;
#endif

/// What [frag_albedo] carries: the lit models set it in `ReadSurface`, and a
/// stage that reflects nothing — unlit, the debug views — leaves it black,
/// which is what light bounced onto it would come to.
vec3 g_albedo = vec3(0.0);

/// Octahedral encoding: a unit vector in two channels instead of three.
///
/// Worth the arithmetic because the fourth channel is already spent on depth,
/// and without a free channel there is nowhere to put roughness — which is the
/// difference between a reflection that knows stone from a mirror and one that
/// does not. The error is well under a degree, far below anything a reflection
/// off rough stone would show.
vec2 EncodeOctahedral(vec3 n) {
  n /= abs(n.x) + abs(n.y) + abs(n.z);
  vec2 e = n.xy;
  if (n.z < 0.0) {
    e = (1.0 - abs(n.yx)) * vec2(n.x >= 0.0 ? 1.0 : -1.0,
                                 n.y >= 0.0 ? 1.0 : -1.0);
  }
  return e * 0.5 + 0.5;
}

/// Where a debug pass leaves the picture it wants shown instead of the normal.
///
/// Declared here, in the header every lit shader includes **first**, and
/// written from surface.glsl, which is included after. The alternative was a
/// new member on a shared uniform block; a global costs nothing and moves no
/// offsets. It is read at the moment the surface buffer is written, which
/// happens after the lighting loop has run, so the value is there by then.
vec3 g_debug_surface = vec3(0.0);
bool g_debug_surface_on = false;

/// Whether [WriteSurface] weights the colour by its alpha: set by
/// `ReadSurface` for a material that blends, and false for everything else.
///
/// **The blend takes its source as premultiplied**, so a blended surface has
/// to hand it the colour times the alpha — a pane at a fifth of opaque adds a
/// fifth of its light, not all of it. glTF's blend mode is Porter and Duff's
/// over on straight colour, and this is the one place that turns the lit
/// radiance into what that means. An opaque or masked surface keeps its
/// colour whole: its alpha is not a coverage, and nothing blends it.
/// A global for the reason [g_debug_surface] is one.
bool g_premultiply = false;

// **A stage that needs none of this must be able to declare none of it.** On
// Vulkan both stages' descriptors are merged into one set layout, and two
// bindings with the same number in it is not a layout the specification
// allows. A driver may accept it anyway; a Galaxy A55's refuses the pipeline
// with `ErrorUnknown` and no other word, which is how the shadow pass came to
// build everywhere except there — its only uniform block was this one, and it
// landed on the same binding as the vertex stage's first.
#ifndef F3D_NO_FOG

/// Distance fog, in its own block rather than folded into FragInfo.
///
/// Its own because color.glsl is included before FragInfo is declared, and
/// because appending to a block that half a dozen shaders already share is a
/// way to move offsets nobody expected to move. Three vec4s is a cheap price
/// for not touching any of that.
layout(std140) uniform FogInfo {
  /// rgb: linear fog colour. w: density per metre, zero for no fog.
  vec4 fog;

  /// xyz: camera position in world space. Duplicated from FragInfo so this
  /// block stands alone; a vec3 is cheaper than a coupling.
  vec4 eye;

  /// xyz: the direction the camera looks, as a unit vector in world space.
  /// w: what a transparent draw writes under weighted blended transparency —
  /// `R8`, see `WriteWeightedBlended`. Zero for every other draw.
  ///
  /// Here rather than in a block of its own because it answers the same
  /// question [eye] does — where the camera is and which way it faces — and
  /// this is the block `color.glsl` can see.
  vec4 forward;
}
fog_info;

/// How far this fragment is from the eye, in world metres.
///
/// What the fog fades by. Distance rather than depth, because fog is a
/// property of the air between two points and does not care which way the
/// camera happens to face.
float EyeDistance() { return distance(v_world_position, fog_info.eye.xyz); }

/// How far this fragment is *along the view axis*, in world metres.
///
/// What the surface buffer's alpha holds. Depth rather than distance, and the
/// difference only shows on an orthographic camera — where the rays through
/// the pixels are parallel instead of meeting at the eye, so a distance from
/// the eye names a sphere that the pixel's ray crosses somewhere the reader
/// cannot solve for. A depth along the axis names a plane, which every ray
/// crosses exactly once. See `WorldAtDepth` in `post/ssao.frag` for the
/// reconstruction both projections share.
float ViewDepth() {
  return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz);
}

#else  // F3D_NO_FOG

// The same two questions, answered without the block: a stage that declares no
// fog has no eye position to measure from either. Stubs rather than a guard at
// every call site, so that what includes this file reads the same whichever
// way it was compiled.
float EyeDistance() { return 0.0; }
float ViewDepth() { return 0.0; }

#endif  // F3D_NO_FOG

/// sRGB to linear. Textures are authored in sRGB, but lighting is only correct
/// in linear space; skipping this is what makes naive renderers look muddy.
vec3 SrgbToLinear(vec3 srgb) {
  return mix(
      srgb / 12.92,
      pow((srgb + vec3(0.055)) / 1.055, vec3(2.4)),
      step(vec3(0.04045), srgb));
}

/// Linear to sRGB. The render target is a plain UNorm format rather than an
/// sRGB one, so the encode has to happen here.
vec3 LinearToSrgb(vec3 linear) {
  return mix(
      linear * 12.92,
      1.055 * pow(linear, vec3(1.0 / 2.4)) - vec3(0.055),
      step(vec3(0.0031308), linear));
}

/// Writes scene-referred linear light into the HDR target.
///
/// No tone map and no sRGB encode: those moved into the composite pass, which
/// is the entire point of rendering into `r16g16b16a16Float` first. Applying
/// them here meant every model wrote display-referred colour into an 8-bit
/// buffer, so anything above display white was gone before post-processing
/// could see it — and bloom is a function of exactly that.
///
/// Exposure moved with them, for the same reason: it belongs on the same side
/// of the display transform as the tone map.
/// Records the geometry of this fragment for whatever runs after the scene.
///
/// Called from the same place that writes colour, so a surface cannot be lit
/// into the frame without also describing itself — which is the failure that
/// leaves a screen-space effect reflecting whatever was in the buffer before.
///
/// rg: octahedral normal. b: perceptual roughness. a: **depth along the view
/// axis, in world metres** — see [ViewDepth].
///
/// **Not `gl_FragCoord.z`, and that is a defect this channel carried until it
/// was looked at.** Window depth crowds every distant surface into the top of
/// its range — with a near plane of a tenth of a metre, everything past twenty
/// metres lives in the last half a hundredth of `[0, 1]` — and this attachment
/// is a half float, whose steps up there are about five ten-thousandths. So two
/// surfaces half a metre apart at twenty metres stored the *same* number, and
/// every screen-space pass that compares against this channel decided whole
/// bands of pixels by rounding. The occlusion pass drew them: vertical stripes
/// along the lines of constant depth on any wall receding from the camera, on
/// both GPU backends. The software rasteriser kept the channel at full
/// precision and drew the effect correctly, so it was the one that looked
/// wrong against the other two.
///
/// A depth in metres has none of that: the exponent carries the range and the
/// mantissa carries the same relative precision everywhere, which at twenty
/// metres is a centimetre. Both numbers are measured in
/// `flutter3d/test/surface_depth_test.dart`.
///
/// Zero still means nothing was drawn. The attachment is cleared to zero and
/// nothing is drawn in front of the near plane.
void WriteSurfaceGeometry(float roughness) {
#ifndef F3D_NO_SURFACE_BUFFER
  // `L5`: the surface's colour, whatever the surface buffer ends up holding.
  frag_albedo = vec4(LinearToSrgb(clamp(g_albedo, vec3(0.0), vec3(1.0))), 1.0);
  // A debug pass takes the buffer over rather than getting one of its own.
  // The surface buffer already has an attachment, a viewer and a golden; a
  // second one would need all three built before it could answer anything.
  if (g_debug_surface_on) {
    frag_surface = vec4(g_debug_surface, ViewDepth());
    return;
  }
  // Reversed on a back face, as the lit normal is, so the occlusion and
  // reflection passes see the side of a double-sided surface that faces them.
  vec3 geometric = normalize(v_normal);
  if (!gl_FrontFacing) geometric = -geometric;
  frag_surface = vec4(EncodeOctahedral(geometric),
                      clamp(roughness, 0.0, 1.0), ViewDepth());
#endif
}

/// Fades [color] toward the fog with distance from the eye.
///
/// Exponential rather than linear, because linear fog has a visible plane
/// where it starts and a dungeon corridor is exactly where that shows.
vec3 ApplyFog(vec3 color) {
#ifdef F3D_NO_FOG
  return color;
#else
  float density = fog_info.fog.w;
  if (density <= 0.0) return color;
  float d = EyeDistance();
  return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0));
#endif
}

/// How much a transparent fragment counts for against the others over its
/// pixel — `R8`. McGuire and Bavoil's depth weight (their equation 9): a near
/// layer outweighs a far one, which is all the ordering a weighted average
/// can keep. [alpha] multiplies it, as theirs does, so a faint layer counts
/// faintly. Depth along the view axis, in metres, the surface buffer's.
float WeightedBlendedWeight(float alpha) {
  float z = abs(ViewDepth());
  float near = z / 5.0;
  float far = z / 200.0;
  float far3 = far * far * far;
  return alpha *
         clamp(10.0 / (1e-5 + near * near + far3 * far3), 1e-2, 3e3);
}

/// What a transparent draw writes when the frame composites transparency
/// order-independently — `R8`. `fog_info.forward.w` says which:
///
/// - 0: [frag_color] as it stands, the sorted blend's source. Every opaque
///   draw, and every draw in a frame that sorts.
/// - 1: the accumulation target's share — the colour, which the engine keeps
///   premultiplied, and the alpha, both times the weight. Added.
/// - 2: the revealage target's — the alpha alone, in every channel, which the
///   blend multiplies the target by one minus of.
/// - 3: both at once, the second into attachment one, where the surface
///   buffer would be; the pass that asks has no surface buffer attached.
///
/// Selects rather than returns, because a phi of constants is what
/// SPIRV-Cross refuses. At nought the branch is not taken and [frag_color]
/// is untouched, which is what keeps a sorting frame byte-identical.
void WriteWeightedBlended() {
#ifndef F3D_NO_FOG
  float mode = fog_info.forward.w;
  if (mode > 0.5) {
    float alpha = frag_color.a;
    float weight = WeightedBlendedWeight(alpha);
    vec4 accumulate = vec4(frag_color.rgb * weight, alpha * weight);
    bool revealage = mode > 1.5 && mode < 2.5;
    frag_color = revealage ? vec4(alpha) : accumulate;
#ifndef F3D_NO_SURFACE_BUFFER
    if (mode > 2.5) frag_surface = vec4(alpha);
#endif
  }
#endif
}

/// The fog is mixed in before the weight, so a thin distant pane adds a thin
/// share of the fog too rather than all of it. Times one when nothing blends,
/// which is exact, so an opaque draw writes what it always wrote.
void WriteSurface(vec3 linearColor, float alpha, float roughness) {
  float weight = g_premultiply ? alpha : 1.0;
  frag_color = vec4(ApplyFog(linearColor) * weight, alpha);
  WriteSurfaceGeometry(roughness);
  WriteWeightedBlended();
}

/// For a stage with no material to speak of.
///
/// Fully rough, which is the honest default: a surface that cannot say how
/// polished it is should not be reflected off.
void WriteSurface(vec3 linearColor, float alpha) {
  WriteSurface(linearColor, alpha, 1.0);
}

/// Writes a value that is already display-referred.
///
/// For debug output, where the colour is not a light value at all: a normal
/// encoded as RGB means nothing after a tone curve. Converting to linear here
/// means the composite pass's sRGB encode hands the original back unchanged,
/// provided the view also turns tone mapping and exposure off — which is what
/// `RenderSettings.tonemap` is for.
void WriteDisplayColor(vec3 displayColor, float alpha) {
  frag_color = vec4(SrgbToLinear(displayColor), alpha);
  WriteSurfaceGeometry(1.0);
}

#endif  // COLOR_GLSL_


void main() {
  vec3 n = normalize(v_normal);
  WriteDisplayColor(n * 0.5 + vec3(0.5), 1.0);
}

''',
    'DebugLine': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// Fragment stage for the debug line overlay: the vertex colour, unchanged.
//
// No tone mapping and no sRGB encode. Overlay colours are chosen to be read on
// screen, not to be light values, so pushing them through the display transform
// would only make them differ from what the Dart side asked for.
//
// It includes nothing from shaders/lib on purpose: those headers declare the
// mesh varyings and the FragInfo block, and a shader that declares a uniform
// block it never reads is exactly the phantom-binding trap documented in
// ARCHITECTURE.md §2.
precision highp float;

in vec4 v_line_color;

layout(location = 0) out vec4 frag_color;

void main() {
  frag_color = v_line_color;
}

''',
    'BloomThreshold': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// First step of the bloom chain: keep what is brighter than the threshold, at
// half resolution.
//
// The downsample and the threshold are one pass because the threshold has to
// happen *before* the blur — thresholding blurred pixels would spread the
// dimmer parts of a highlight into the bloom as well — and doing it while
// already reading four texels costs nothing extra.
precision highp float;

in vec2 v_uv;

layout(location = 0) out vec4 frag_color;

uniform sampler2D source_texture;

layout(std140) uniform BloomInfo {
  /// x: 1/width, y: 1/height of the SOURCE texture. z: threshold. w: knee.
  vec4 params;
}
bloom_info;

/// Rec. 709 luma, which is what "how bright does this look" means.
float Luminance(vec3 color) {
  return dot(color, vec3(0.2126, 0.7152, 0.0722));
}

void main() {
  vec2 texel = bloom_info.params.xy;

  // A four-tap box at the corners of the source pixel quad: a plain single tap
  // would alias a one-pixel specular highlight in and out of existence as the
  // camera moves, which reads as flickering rather than as bloom.
  //
  // **Weighted by Karis's `1 / (1 + luma)`**, as Jimenez's Call of Duty chain
  // does on its first step down and nowhere after. A plain average lets one
  // texel of a glossy floor's highlight, a hundred times brighter than its
  // neighbours, own the whole quad, and it flickers as it crosses texels —
  // "fireflies". The weight takes the energy of a lone outlier down to about
  // its neighbours' and leaves a uniformly bright quad an exact average.
  vec3 s0 = texture(source_texture, v_uv + texel * vec2(-0.5, -0.5)).rgb;
  vec3 s1 = texture(source_texture, v_uv + texel * vec2(0.5, -0.5)).rgb;
  vec3 s2 = texture(source_texture, v_uv + texel * vec2(-0.5, 0.5)).rgb;
  vec3 s3 = texture(source_texture, v_uv + texel * vec2(0.5, 0.5)).rgb;
  float w0 = 1.0 / (1.0 + Luminance(s0));
  float w1 = 1.0 / (1.0 + Luminance(s1));
  float w2 = 1.0 / (1.0 + Luminance(s2));
  float w3 = 1.0 / (1.0 + Luminance(s3));
  vec3 color = (s0 * w0 + s1 * w1 + s2 * w2 + s3 * w3) / (w0 + w1 + w2 + w3);

  float threshold = bloom_info.params.z;
  float knee = max(bloom_info.params.w, 1e-4);

  // A soft knee rather than a hard step: a hard cut makes the bloom appear and
  // disappear along a visible contour as a highlight brightens through the
  // threshold.
  float brightness = Luminance(color);
  float soft = clamp(brightness - threshold + knee, 0.0, 2.0 * knee);
  soft = soft * soft / (4.0 * knee);
  float contribution =
      max(soft, brightness - threshold) / max(brightness, 1e-4);

  frag_color = vec4(color * contribution, 1.0);
}

''',
    'BloomDownsample': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// Halves the resolution with a 13-tap filter.
//
// The kernel is the one from Jimenez's "Next Generation Post Processing in Call
// of Duty: Advanced Warfare": four 2x2 boxes at the corners plus one at the
// centre, weighted so the result is stable. It exists because the obvious
// bilinear halving pulses badly when a bright pixel crosses a texel boundary,
// and a bloom that pulses is worse than no bloom.
//
// Halving repeatedly is how the wide blur is built. flutter_gpu has no mip
// levels at all — no `mipCount` on `Texture`, no render-to-mip-level — so a
// mip pyramid is not available and the chain is a series of separate textures
// instead. That is the whole reason this file exists rather than a
// `textureLod` call.
precision highp float;

in vec2 v_uv;

layout(location = 0) out vec4 frag_color;

uniform sampler2D source_texture;

layout(std140) uniform BloomInfo {
  /// x: 1/width, y: 1/height of the SOURCE texture. z and w unused here.
  vec4 params;
}
bloom_info;

void main() {
  vec2 t = bloom_info.params.xy;

  vec3 a = texture(source_texture, v_uv + vec2(-2.0, 2.0) * t).rgb;
  vec3 b = texture(source_texture, v_uv + vec2(0.0, 2.0) * t).rgb;
  vec3 c = texture(source_texture, v_uv + vec2(2.0, 2.0) * t).rgb;
  vec3 d = texture(source_texture, v_uv + vec2(-2.0, 0.0) * t).rgb;
  vec3 e = texture(source_texture, v_uv).rgb;
  vec3 f = texture(source_texture, v_uv + vec2(2.0, 0.0) * t).rgb;
  vec3 g = texture(source_texture, v_uv + vec2(-2.0, -2.0) * t).rgb;
  vec3 h = texture(source_texture, v_uv + vec2(0.0, -2.0) * t).rgb;
  vec3 i = texture(source_texture, v_uv + vec2(2.0, -2.0) * t).rgb;

  vec3 j = texture(source_texture, v_uv + vec2(-1.0, 1.0) * t).rgb;
  vec3 k = texture(source_texture, v_uv + vec2(1.0, 1.0) * t).rgb;
  vec3 l = texture(source_texture, v_uv + vec2(-1.0, -1.0) * t).rgb;
  vec3 m = texture(source_texture, v_uv + vec2(1.0, -1.0) * t).rgb;

  // The inner four boxes carry half the weight between them; the five outer
  // ones share the rest.
  vec3 result = (j + k + l + m) * 0.5 * 0.25;
  result += (a + b + d + e) * 0.125 * 0.25;
  result += (b + c + e + f) * 0.125 * 0.25;
  result += (d + e + g + h) * 0.125 * 0.25;
  result += (e + f + h + i) * 0.125 * 0.25;

  frag_color = vec4(result, 1.0);
}

''',
    'BloomUpsample': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// Doubles the resolution with a 3x3 tent filter, for the way back up the chain.
//
// The tent is what turns a stack of box-filtered halvings into something that
// looks like a Gaussian: each level is upsampled and added to the one above, so
// the widest level contributes the broad glow and the narrowest the tight core.
// A plain bilinear upsample instead leaves visible blocky steps where the
// levels meet.
precision highp float;

in vec2 v_uv;

layout(location = 0) out vec4 frag_color;

uniform sampler2D source_texture;

layout(std140) uniform BloomInfo {
  /// x: 1/width, y: 1/height of the SOURCE texture. z: filter radius in source
  /// texels. w: unused.
  vec4 params;
  /// rgb: what this step multiplies the level it carries up by — the ratio of
  /// this level's weight and warmth to the one above's. w: unused.
  vec4 tint;
}
bloom_info;

void main() {
  vec2 t = bloom_info.params.xy * max(bloom_info.params.z, 0.0);

  vec3 a = texture(source_texture, v_uv + vec2(-1.0, 1.0) * t).rgb;
  vec3 b = texture(source_texture, v_uv + vec2(0.0, 1.0) * t).rgb;
  vec3 c = texture(source_texture, v_uv + vec2(1.0, 1.0) * t).rgb;
  vec3 d = texture(source_texture, v_uv + vec2(-1.0, 0.0) * t).rgb;
  vec3 e = texture(source_texture, v_uv).rgb;
  vec3 f = texture(source_texture, v_uv + vec2(1.0, 0.0) * t).rgb;
  vec3 g = texture(source_texture, v_uv + vec2(-1.0, -1.0) * t).rgb;
  vec3 h = texture(source_texture, v_uv + vec2(0.0, -1.0) * t).rgb;
  vec3 i = texture(source_texture, v_uv + vec2(1.0, -1.0) * t).rgb;

  // 1 2 1 / 2 4 2 / 1 2 1, over sixteen.
  vec3 result = e * 4.0 + (b + d + f + h) * 2.0 + (a + c + g + i);
  result *= (1.0 / 16.0);

  // **Halation: the wide part of the glow goes red — `gfx-30n`.** On film the
  // halo around a highlight is warm, because light that made it through the
  // emulsion scatters off the backing and comes back, and the red layer sits
  // deepest so it catches the most of it. The same asymmetry is what stops a
  // digital bloom reading as a grey smear.
  //
  // Applied here rather than in the composite because here is where the
  // *levels* are: the caller hands each level its own amount, so the tight
  // core stays neutral and only the broad skirt warms. The composite sees one
  // glow and could not tell them apart.
  //
  // **A ratio, not the warmth itself.** On the way up this level already
  // holds every level below it, so multiplying it by its own warmth warmed
  // the narrower levels again at every step and the factors compounded. The
  // caller hands the ratio between this level's weight and the one above's,
  // and the product down the chain is each level's own, once. With no
  // halation and a scatter of one it is one on every channel, which keeps
  // every recorded frame where it is.
  result *= bloom_info.tint.rgb;

  frag_color = vec4(result, 1.0);
}

''',
    'Composite': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// The last pass: add the bloom, tone map, encode to sRGB.
//
// Tone mapping lives here rather than in each lighting model, which is the
// point of having an HDR target at all. Applying it per model meant every
// shader wrote display-referred colour into an 8-bit buffer, so anything above
// display white was gone before post-processing could see it — and bloom is
// entirely a function of what is above display white.
precision highp float;

// --- lib/frag_coord_info.glsl ---
// The target's orientation, for a full-screen pass.
//
// Its own block rather than a member of each pass's, so the renderer binds it
// in one place, `drawFullscreen`, for every stage that declares it — the
// contract answers false for a stage that does not, and a pass that adds a
// screen-space pattern later gets the right rows by including this file.

#ifndef FRAG_COORD_INFO_GLSL_
#define FRAG_COORD_INFO_GLSL_

// --- lib/frag_coord.glsl ---
// Where a fragment sits, counted from the top of its target on every backend.

#ifndef FRAG_COORD_GLSL_
#define FRAG_COORD_GLSL_

/// `gl_FragCoord.xy` with row zero at the top of the picture.
///
/// [rows] is the target's height where the backend's row zero is the bottom
/// of the picture, and zero where it is the top. WebGL2 is the first kind:
/// window coordinates start at the lower left, and the engine draws the
/// picture upright there rather than mirroring every projection. Metal,
/// WebGPU and the software rasteriser are the second.
///
/// **Why a pattern cares and a picture does not.** Every screen-space pattern
/// in the engine — the Bayer dither, the grain, the jitter a ray march starts
/// from, the rotation of a shadow kernel — is a function of the pixel's row.
/// Read from the bottom, the same frame gets the pattern turned upside down,
/// and a four-row Bayer cell lands on different rows unless the height is a
/// multiple of four. The picture underneath is identical; the pattern on top
/// of it is not, and a comparison across backends counts every pixel it
/// moved.
vec2 FragCoordFromTop(float rows) {
  return rows > 0.0 ? vec2(gl_FragCoord.x, rows - gl_FragCoord.y)
                    : gl_FragCoord.xy;
}

#endif  // FRAG_COORD_GLSL_


layout(std140) uniform FragCoordInfo {
  /// x: the target's rows when its row zero is the bottom of the picture,
  /// zero when it is the top — see [FragCoordFromTop]. yzw unused.
  vec4 origin;
}
frag_coord_info;

/// This fragment's position with row zero at the top of the target.
vec2 TargetFragCoord() {
  return FragCoordFromTop(frag_coord_info.origin.x);
}

#endif  // FRAG_COORD_INFO_GLSL_


in vec2 v_uv;

layout(location = 0) out vec4 frag_color;

/// The scene, linear and unbounded.
uniform sampler2D scene_texture;

/// The bloom chain's top level, or a black texture when bloom is off.
uniform sampler2D bloom_texture;

/// Ambient occlusion at half resolution, or a white texture when it is off.
///
/// White rather than absent, because a sampler a shader declares and nobody
/// binds is a native crash on Metal rather than a black texture — the same rule
/// that kept the sky's cube map out of `sky.frag`. One white texel costs
/// nothing and removes the branch.
uniform sampler2D ao_texture;

/// The contact shadow's own factor — `gfx-76n`. Bound on every frame with a
/// white stand-in when the pass did not run, the same rule `ao_texture` above
/// follows and for the same reason: a declared sampler nobody binds is a native
/// crash on Metal rather than a black texture.
uniform sampler2D contact_shadow_texture;

/// The local exposure, in stops, at an eighth of the frame — `R7`. A black
/// stand-in when it is off, which is nought stops, and `contact.w` is nought
/// beside it.
uniform sampler2D local_exposure_texture;

/// The colour table, as a strip: N slices of N×N laid out left to right, so
/// the image is N² wide and N tall. Bound to whatever the engine has when no
/// table is set — the strength is zero then and nothing samples it, but a
/// sampler this shader declares and nobody binds is a native crash on Metal
/// rather than a black texture, which is the same rule `ao_texture` above
/// already follows.
uniform sampler2D lut_texture;

/// The display transform, as a strip in the colour table's shape but float
/// and indexed through a log2 shaper — `L2`. Read **instead of** a tone curve
/// when `params.z` is 6, bound to a stand-in otherwise, for the rule every
/// sampler here follows.
uniform sampler2D display_texture;

layout(std140) uniform CompositeInfo {
  /// x: exposure, y: bloom intensity, z: which tone curve, w: how much of the
  /// occlusion to apply, 0 for none.
  ///
  /// **z is a curve number, not a flag, and 1 is still the old flag's
  /// meaning.** 0 leaves the colour alone, 1 is Khronos PBR Neutral — what
  /// every golden in this repository was recorded with — 2 is ACES, 3 is AgX
  /// and 4 is Reinhard. Numbering the default 1 is what lets a `> 0.5` read
  /// of the old flag and an `int()` read of the new number agree about every
  /// scene already recorded.
  vec4 params;

  /// x, y: one texel of the ao texture. z: how much of the colour table to
  /// apply, 0 for none. w: how many slices the table has, its N.
  vec4 ao_texel;

  /// The look, half of it. x: contrast, y: saturation, z: temperature,
  /// w: chromatic aberration.
  ///
  /// **Neutral is (1, 1, 0, 0) and has to stay exactly that.** Every golden in
  /// the repository composites with this block; a default that only nearly
  /// cancels moves thirty reference images by a bit each.
  vec4 look;

  /// The look, the rest. x: vignette, y: vignette roundness, z: grain,
  /// w: the target's aspect, width over height.
  vec4 look_more;

  /// x: dither amount, in display units — 1/255 is one 8-bit step, and 0 is
  /// off exactly. y: white balance, warm above zero. z: tint, green against
  /// magenta. w: unclaimed.
  ///
  /// **A fifth block rather than a spare component of a fourth**, because the
  /// other four are full and because a number that means "one output step"
  /// does not belong beside three that mean "a look". Neutral is
  /// (0, 0, 0, 0) and must stay exactly that: every golden in the repository
  /// goes through this block.
  vec4 output_encode;

  /// Lift, in xyz — what is added, so it moves the shadows and leaves white
  /// where it was. w: unclaimed. Neutral is (0, 0, 0, 0).
  vec4 lift;

  /// Gamma, in xyz — the exponent, so it moves the midtones and leaves both
  /// ends. w: unclaimed. Neutral is (1, 1, 1, 0).
  vec4 gamma;

  /// Gain, in xyz — what is multiplied, so it moves the highlights and leaves
  /// black where it was. w: unclaimed. Neutral is (1, 1, 1, 0).
  vec4 gain;

  /// x: how much of the contact shadow reaches the picture, nought to one —
  /// `gfx-76n`. y: the display transform's entries per axis, its N — `L2`;
  /// read only when the curve is 6. z: one when the occlusion buffer carries
  /// indirect light in rgb as well — `L5`'s SSIL — nought otherwise.
  /// w: how much of the local exposure applies, nought to one — `R7`.
  ///
  /// Appended after everything else, the way this block has grown before: a
  /// std140 block is laid out in declaration order, so adding here leaves every
  /// offset above unchanged and the four backends do not have to agree about
  /// anything they had not already agreed on.
  vec4 contact;
}
composite_info;

/// Rec. 709 luma, which is what the sRGB primaries weight to.
float Luma(vec3 color) { return dot(color, vec3(0.2126, 0.7152, 0.0722)); }

/// A value in [0, 1) from a screen position, with no state and no frame count.
///
/// Static by construction: a shader that read a frame counter would produce a
/// different golden on every run, so the grain is fixed to the pixel. See
/// `LookSettings.grain`, which says the same thing from the other side.
float Hash(vec2 at) {
  return fract(sin(dot(at, vec2(12.9898, 78.233))) * 43758.5453);
}

/// One cell of a 4x4 Bayer matrix, as a value in [-0.5, 0.5).
///
/// **Ordered rather than random, and that is the whole choice.** The grain
/// above already uses a hash, and a hash here would work — but blue-ish noise
/// on a flat gradient reads as noise, where an ordered matrix reads as a
/// gradient. The pattern repeats every four pixels and is fixed to screen
/// position, so it is as golden-stable as the grain is and for the same
/// reason: nothing here is a function of time.
///
/// The matrix is the standard recursive one, written out because computing it
/// costs more than reading it.
float BayerCell(vec2 at) {
  int x = int(mod(at.x, 4.0));
  int y = int(mod(at.y, 4.0));
  int index = y * 4 + x;
  // 0, 8, 2, 10 / 12, 4, 14, 6 / 3, 11, 1, 9 / 15, 7, 13, 5
  float value = 0.0;
  if (index == 0) value = 0.0;
  else if (index == 1) value = 8.0;
  else if (index == 2) value = 2.0;
  else if (index == 3) value = 10.0;
  else if (index == 4) value = 12.0;
  else if (index == 5) value = 4.0;
  else if (index == 6) value = 14.0;
  else if (index == 7) value = 6.0;
  else if (index == 8) value = 3.0;
  else if (index == 9) value = 11.0;
  else if (index == 10) value = 1.0;
  else if (index == 11) value = 9.0;
  else if (index == 12) value = 15.0;
  else if (index == 13) value = 7.0;
  else if (index == 14) value = 13.0;
  else value = 5.0;
  return value / 16.0 - 0.5;
}

vec3 LinearToSrgb(vec3 linear) {
  return mix(
      linear * 12.92,
      1.055 * pow(max(linear, vec3(0.0)), vec3(1.0 / 2.4)) - vec3(0.055),
      step(vec3(0.0031308), linear));
}

/// The inverse of [LinearToSrgb], for the colour a LUT hands back.
vec3 SrgbToLinear(vec3 encoded) {
  return mix(
      encoded / 12.92,
      pow((max(encoded, vec3(0.0)) + vec3(0.055)) / 1.055, vec3(2.4)),
      step(vec3(0.04045), encoded));
}

/// Khronos PBR Neutral tone mapper.
///
/// The mapper the glTF ecosystem settled on, which matters because the renderer
/// targets glTF materials — the same asset should not look different here than
/// in a reference viewer. It leaves everything below the compression threshold
/// untouched, so midtones keep their values and only highlights roll off. That
/// is the property a filmic curve like ACES lacks: ACES would darken the whole
/// image to tame one highlight.
vec3 TonemapNeutral(vec3 color) {
  const float kStartCompression = 0.8 - 0.04;
  const float kDesaturation = 0.15;

  float minChannel = min(color.r, min(color.g, color.b));
  float offset =
      minChannel < 0.08 ? minChannel - 6.25 * minChannel * minChannel : 0.04;
  color -= offset;

  float peak = max(color.r, max(color.g, color.b));
  if (peak < kStartCompression) return color;

  const float d = 1.0 - kStartCompression;
  float newPeak = 1.0 - d * d / (peak + d - kStartCompression);
  color *= newPeak / peak;

  float desaturate = 1.0 - 1.0 / (kDesaturation * (peak - newPeak) + 1.0);
  return mix(color, vec3(newPeak), desaturate);
}

/// ACES, the Narkowicz fit.
///
/// **It lifts the midtones, and that is why it is offered rather than
/// imposed.** Measured rather than recited: 18% grey comes out at 0.267 where
/// the neutral curve leaves it at 0.140, because this fit carries roughly a
/// stop of exposure inside it — the RRT and ODT it approximates were never
/// meant to be fed display-referred values. The result is the brighter,
/// contrastier image people recognise from film, and it is also exactly why
/// it is wrong as a default here: a glTF asset is authored against a
/// reference viewer, and a curve that moves the midtones moves the asset away
/// from how its author saw it.
///
/// The three-term rational fit rather than the full RRT/ODT: the matrices in
/// front and behind cost more than the curve is worth at this end of the
/// pipeline. Its own ceiling arrives early — everything above about 7.2 comes
/// out at exactly one, so two different highlights an artist can tell apart
/// become one flat patch. [TonemapAgx] is the answer to that.
vec3 TonemapAces(vec3 color) {
  const float a = 2.51;
  const float b = 0.03;
  const float c = 2.43;
  const float d = 0.59;
  const float e = 0.14;
  return clamp((color * (a * color + b)) / (color * (c * color + d) + e),
               vec3(0.0), vec3(1.0));
}

/// AgX's log-encoded sigmoid, one channel at a time.
///
/// Its output is display-encoded (roughly a 2.2 gamma), not linear. That is
/// the fact the first version of this curve missed: it handed the sigmoid's
/// value straight to the sRGB encode at the end of `main`, so every AgX frame
/// was encoded twice — 18% grey arrived at 187/255 instead of 128/255 and a
/// saturated red came out pastel. [TonemapAgx] linearises it again.
vec3 AgxSigmoid(vec3 color) {
  const float kMinEv = -12.47393;
  const float kMaxEv = 4.026069;

  vec3 v = clamp(log2(max(color, vec3(1e-10))), vec3(kMinEv), vec3(kMaxEv));
  v = (v - vec3(kMinEv)) / (kMaxEv - kMinEv);

  // A sixth-order fit of AgX's own sigmoid, which is the part that does the
  // work: gentle through the middle, long shoulders at both ends.
  vec3 v2 = v * v;
  vec3 v4 = v2 * v2;
  v = 15.5 * v4 * v2 - 40.14 * v4 * v + 31.96 * v4 - 6.868 * v2 * v +
      0.4298 * v2 + 0.1191 * v - 0.00232;
  return clamp(v, vec3(0.0), vec3(1.0));
}

/// AgX: inset, sigmoid, outset, linearise — Wrensch's "Minimal AgX".
///
/// **What it is for: bright saturated light that does not turn into a flat
/// disc of colour.** The inset matrix mixes a little of each channel into the
/// others before the curve, so no channel is compressed alone and a hue that
/// is over-bright walks towards white rather than through another primary;
/// the outset matrix takes the mixing back out afterwards.
///
/// **Linear in, linear out**, like every other curve here, so the sRGB encode
/// at the end of `main` is the only encode. The `pow(2.2)` is the reference
/// implementation's own last step ("we're linearizing the output here"); the
/// default look is the identity, so there is no extra desaturation — the
/// `mix(luma, v, 0.84)` this curve used to end with was not AgX's and took a
/// sixth of the saturation off the whole frame.
///
/// The matrices are the published AgX ones, written out rather than derived,
/// and they are inverses to about six decimal places — checked as arithmetic
/// in `tonemap_curve_test.dart` rather than trusted, because a transposed row
/// here would look like a subtle grade rather than like a bug.
vec3 TonemapAgx(vec3 color) {
  // The published pair, written in the same layout and used in the same order
  // as the reference implementation — `M * v`, with the literals as that
  // implementation lists them. Taken as a matched pair on purpose: an inset
  // from one variant beside an outset from another is a matrix product that is
  // *nearly* the identity, which reads as a grade nobody asked for rather than
  // as a mistake.
  const mat3 kInset = mat3(
      0.842479062253094, 0.0423282422610123, 0.0423756549057051,
      0.0784335999999992, 0.878468636469772, 0.0784336,
      0.0792237451477643, 0.0791661274605434, 0.879142973793104);
  const mat3 kOutset = mat3(
      1.19687900512017, -0.0528968517574562, -0.0529716355144438,
      -0.0980208811401368, 1.15190312990417, -0.0980434501171241,
      -0.0990297440797205, -0.0989611768448433, 1.15107367264116);

  vec3 v = AgxSigmoid(kInset * max(color, vec3(0.0)));
  // Out of the wider gamut, then back to linear. The outset can push a
  // channel a little below zero on a colour that was already at the edge,
  // which the `max` keeps out of `pow`; anything above display white is
  // display white.
  v = kOutset * v;
  return clamp(pow(max(v, vec3(0.0)), vec3(2.2)), vec3(0.0), vec3(1.0));
}

/// The same transform as [TonemapAgx], kept for code 5 — `gfx-26n`.
///
/// It was added as the rotated variant when [TonemapAgx] was the bare
/// sigmoid. Now that [TonemapAgx] is the whole of AgX, the two codes draw
/// the same picture; the code stays so a setting that names it keeps working.
vec3 TonemapAgxFull(vec3 color) {
  return TonemapAgx(color);
}

/// Reinhard, extended so that white maps to white.
///
/// The plain `c / (1 + c)` never reaches one, so a sky that should clip to
/// paper white comes out grey; the extension takes the value that *should*
/// become white and normalises to it. Here that value is 4 — two stops over
/// display white — which is the point past which this engine's bloom has
/// taken over anyway.
///
/// **Clamped, because the extension keeps climbing past its own white
/// point.** The curve maps 4 to exactly one and 40 to 3.4, which is not a
/// tone mapper's job: anything above white is white. Without the clamp the
/// only thing bounding the output is the sRGB encode at the very end, and a
/// curve whose contract is "this fits on a display" should be the thing that
/// makes it fit.
///
/// Kept because it is the plainest of the four and the one everything else
/// gets compared against.
vec3 TonemapReinhard(vec3 color) {
  const float kWhite = 4.0;
  vec3 numerator = color * (vec3(1.0) + color / vec3(kWhite * kWhite));
  return clamp(numerator / (vec3(1.0) + color), vec3(0.0), vec3(1.0));
}

/// [color] through whichever curve [curve] names.
///
/// A chain of comparisons rather than a `switch`: the number is a uniform,
/// so every backend takes the same branch for a whole frame, and `switch` on
/// a non-constant is the construct that has needed a workaround on one
/// backend or another every time it has been used here.
/// [color] looked up in the colour table, which holds `size` slices.
///
/// **A strip, not a 3D texture**, because three of the four backends this
/// engine runs on either have no 3D sampler or have one that costs a
/// capability check — and a strip is an ordinary 2D image an artist can open,
/// which is how every grading tool exports one anyway.
///
/// The blue axis picks a pair of neighbouring slices and mixes between them;
/// red and green come out of the sampler's own bilinear filtering inside a
/// slice. The half-texel inset on red is what keeps the first and last
/// entries reachable: without it the ends of the ramp are never sampled and a
/// table that should be an identity darkens white.
vec3 SampleLut(vec3 color, float size) {
  vec3 c = clamp(color, vec3(0.0), vec3(1.0));

  float sliceWidth = 1.0 / size;
  float texel = 1.0 / (size * size);
  float innerWidth = texel * (size - 1.0);

  float u = texel * 0.5 + c.r * innerWidth;
  float v = (0.5 / size) + c.g * ((size - 1.0) / size);

  float slice = c.b * (size - 1.0);
  float lower = floor(slice);
  float upper = min(lower + 1.0, size - 1.0);

  vec3 a = texture(lut_texture, vec2(lower * sliceWidth + u, v)).rgb;
  vec3 b = texture(lut_texture, vec2(upper * sliceWidth + u, v)).rgb;
  return mix(a, b, slice - lower);
}

/// [color] through the display transform — `L2`: shaped to log2 stops about
/// 0.18 over −10…+10, then looked up in the strip exactly as [SampleLut]
/// looks up the grade. Scene-linear in, display-linear out, which is what a
/// tone curve returns. Ten stops over grey is 184, past the 128 where the
/// SDR tonescale reaches the display's peak: a range that stopped at +6
/// clamped every highlight to 0.92 of white.
vec3 SampleDisplay(vec3 color) {
  float size = max(composite_info.contact.y, 2.0);
  vec3 c = clamp((log2(max(color, vec3(1e-10)) / 0.18) + 10.0) / 20.0,
                 vec3(0.0), vec3(1.0));

  float sliceWidth = 1.0 / size;
  float texel = 1.0 / (size * size);
  float innerWidth = texel * (size - 1.0);

  float u = texel * 0.5 + c.r * innerWidth;
  float v = (0.5 / size) + c.g * ((size - 1.0) / size);

  float slice = c.b * (size - 1.0);
  float lower = floor(slice);
  float upper = min(lower + 1.0, size - 1.0);

  vec3 a = texture(display_texture, vec2(lower * sliceWidth + u, v)).rgb;
  vec3 b = texture(display_texture, vec2(upper * sliceWidth + u, v)).rgb;
  return mix(a, b, slice - lower);
}

vec3 TonemapBy(vec3 color, int curve) {
  if (curve == 6) return SampleDisplay(color);
  if (curve == 1) return TonemapNeutral(color);
  if (curve == 2) return TonemapAces(color);
  if (curve == 3) return TonemapAgx(color);
  if (curve == 4) return TonemapReinhard(color);
  if (curve == 5) return TonemapAgxFull(color);
  return color;
}

void main() {
  // **Dispersion happens at the lens, so it happens at sampling.** Sampling the
  // scene three times at radially offset coordinates is the whole effect; doing
  // it after the tone map would smear an already-compressed image and could not
  // separate the channels of a highlight that had already clipped together.
  //
  // The offset grows from the centre outwards, which is what a real lens does:
  // a ray through the middle of the glass is not dispersed at all.
  float dispersion = composite_info.look.w;
  vec4 scene;
  if (dispersion > 0.0) {
    vec2 fromCentre = v_uv - vec2(0.5);
    vec2 step_uv = fromCentre * dispersion;
    scene = texture(scene_texture, v_uv);
    scene.r = texture(scene_texture, v_uv + step_uv).r;
    scene.b = texture(scene_texture, v_uv - step_uv).b;
  } else {
    scene = texture(scene_texture, v_uv);
  }
  vec3 bloom = texture(bloom_texture, v_uv).rgb;

  // Four taps in a 2×2, which is not a general-purpose blur: the occlusion pass
  // rotates its kernel by the parity of the pixel, leaving a 2×2 pattern, and
  // this averages exactly that away. The size is derived from the artefact
  // rather than tuned against it, so the two have to move together — widening
  // one without the other either leaves the pattern or smears the contact
  // shadows this whole pass exists to draw.
  vec2 half_texel = composite_info.ao_texel.xy * 0.5;
  vec4 occlusion = 0.25 * (texture(ao_texture, v_uv + vec2(half_texel.x, half_texel.y)) +
                           texture(ao_texture, v_uv + vec2(-half_texel.x, half_texel.y)) +
                           texture(ao_texture, v_uv + vec2(half_texel.x, -half_texel.y)) +
                           texture(ao_texture, v_uv + vec2(-half_texel.x, -half_texel.y)));
  // The share left open is in a; the occlusion methods write it into every
  // channel, and the indirect one keeps its light in rgb.
  float ao = occlusion.a;
  // Lerped towards one by the strength, so "off" is exactly one and multiplies
  // nothing — every golden in the repository depends on that being exact rather
  // than nearly so.
  ao = mix(1.0, ao, clamp(composite_info.params.w, 0.0, 1.0));

  // **The contact shadow, folded into the same multiplier — `gfx-76n`.** Its
  // own strength, because it answers a different question from the occlusion:
  // one is how enclosed a point is and the other is whether the sun reaches
  // it, and a scene wants them at different amounts. Its own `mix` for the
  // reason the line above has one — "off" has to be a multiplier of exactly
  // one, which every golden in this repository depends on.
  //
  // Full resolution rather than the occlusion's half, so no four-tap average:
  // the whole point of a contact shadow is the first few centimetres at the
  // join, and a half-resolution one is the seam it exists to draw, blurred
  // away.
  float contact = texture(contact_shadow_texture, v_uv).r;
  ao *= mix(1.0, contact, clamp(composite_info.contact.x, 0.0, 1.0));

  // Applied to the scene and **not** to the bloom, which is the whole reason
  // this lives in the composite rather than in a pass that reads and rewrites
  // the HDR colour. Multiplying before bloom would take the glow out of a lit
  // crack along with the ambient, and a crack that stops glowing is a worse
  // error than a crack that stays bright.
  //
  // The cost, stated rather than left to be discovered: this multiplies the
  // *sum* of the light, not the indirect part of it alone. Separating them
  // would mean a third attachment and rewriting all six lit stages. So an
  // emissive strip in a corner dims, which is physically wrong — the same
  // compromise `pbr.frag` already makes with the occlusion map from a glTF.
  //
  // **Except under fog** (`S4`): the fog's in-scatter is in this colour by
  // now, and a crease behind the air must not darken the air. So the fog's
  // upsample lays both multipliers on the surface before the air, and they
  // arrive here at a strength of nought. The glow then reads the occluded
  // scene, which is the price of the air being right.
  // `R7`: each place of the scene at the exposure that shows it best, before
  // the glow is added and the curve applied. Bilinear from an eighth of the
  // frame: the stops were blurred wide, so there is no edge in them to keep.
  float localStops = texture(local_exposure_texture, v_uv).r;
  vec3 exposed = scene.rgb * exp2(localStops * composite_info.contact.w);
  vec3 color = exposed * ao + bloom * composite_info.params.y;

  // `L5`: the light that bounced onto the point off what it sees, by the same
  // strength as the occlusion beside it, so a strength of nought is no light
  // added as it is no darkening.
  color += occlusion.rgb * composite_info.contact.z *
           clamp(composite_info.params.w, 0.0, 1.0);

  // Exposure before the tone map, so it behaves like a camera stop — it moves
  // which part of the scene's range lands in the mapper's shoulder instead of
  // stretching an already-compressed image.
  color *= max(composite_info.params.x, 0.0);

  color = TonemapBy(color, int(composite_info.params.z + 0.5));

  // **After the tone map, and that is the point.** Grading is a decision about
  // an image somebody can see; applied to unbounded scene-referred colour it
  // would be pulling on values the display will never show anyway.
  float contrast = composite_info.look.x;
  float saturation = composite_info.look.y;
  float temperature = composite_info.look.z;

  // Pivoted about mid grey, so contrast does not double as an exposure knob —
  // and mid grey here is 0.18, not 0.5: this is linear light, where 0.5 is a
  // bright highlight, and pivoting on it darkened a 1.2 contrast by about a
  // stop. A power about 0.18 rather than a line through it, so black stays
  // black and grey stays exactly where it was. One is the identity.
  if (contrast != 1.0) {
    color = vec3(0.18) * pow(max(color, vec3(0.0)) / 0.18, vec3(contrast));
  }
  color = mix(vec3(Luma(color)), color, saturation);
  // A gain on the ends against the middle. Not a white-balance conversion —
  // a scene lit at the wrong temperature is fixed at the light, not here.
  // `white_balance` below is the conversion, and the two are deliberately
  // separate: this one is a look, that one is a correction.
  color *= vec3(1.0 + temperature * 0.1, 1.0, 1.0 - temperature * 0.1);

  // **Lift, gamma, gain — `gfx-27n`, and the three ranges a colourist
  // actually reaches for.** Contrast and saturation move the whole picture at
  // once; these move one end of it. Lift raises black towards itself and
  // leaves white where it was — `c * (1 - lift) + lift`, the classic form;
  // it used to be a plain add, which moved white to one plus the lift and
  // clipped it. Gain multiplies, so it moves the highlights and
  // leaves black alone. Gamma is the exponent between them, so it moves the
  // midtones and leaves both ends. Applied in that order, which is the order
  // they are named in and the order a grading panel applies them.
  //
  // Each is a vec3, not a scalar: the whole reason to have them is a warm
  // highlight over a cool shadow, which one number per stage cannot say.
  vec3 lift = composite_info.lift.xyz;
  vec3 gammaCurve = composite_info.gamma.xyz;
  vec3 gain = composite_info.gain.xyz;
  color = color * (vec3(1.0) - lift) + lift;
  // Guarded, because a channel at zero under a fractional exponent is a
  // divide by zero on some drivers and a black pixel on others, and the
  // defaults have to be an exact identity rather than nearly one.
  color = max(color, vec3(0.0));
  if (gammaCurve != vec3(1.0)) color = pow(color, vec3(1.0) / gammaCurve);
  color *= gain;

  // **White balance, which the temperature above is not.** A gain on red
  // against blue is a look; this is the correction — a shift along the
  // warm-to-cool axis with a green-magenta tint across it, the pair every
  // camera and every grading panel offers together. Approximated in the
  // display space rather than converted through a chromatic adaptation
  // matrix: the exact transform wants the scene's own white point, and this
  // pass has the picture rather than the light that made it.
  float balance = composite_info.output_encode.y;
  float tint = composite_info.output_encode.z;
  if (balance != 0.0 || tint != 0.0) {
    color *= vec3(
        1.0 + balance * 0.20,
        1.0 + tint * 0.15,
        1.0 - balance * 0.20);
    // The tint takes its green out of the other two rather than adding light,
    // so a tint alone changes the hue and not the level.
    color.r -= tint * 0.075;
    color.b -= tint * 0.075;
  }

  // **The table goes after the grade and before the barrel**, which is where
  // a grading suite puts it: a LUT is somebody's finished look, so it should
  // see the contrast and saturation decisions rather than have them applied
  // on top of it — and it should not see the vignette or the grain, which
  // belong to the lens and the film rather than to the colour.
  //
  // Branched on the strength rather than mixed by it, so a frame with no
  // table does not sample one. The branch is on a uniform, so the whole draw
  // takes the same side of it.
  //
  // **Indexed and answered in sRGB**, which is the space a grading tool's
  // `.cube` is written in: Resolve and Photoshop export a table that takes a
  // display-encoded colour and gives one back. Looked up with linear values it
  // shifted every tone, and put nearly everything below a linear 0.03 into
  // the first of 33 slices.
  float lutStrength = composite_info.ao_texel.z;
  if (lutStrength > 0.0) {
    vec3 encodedIn = LinearToSrgb(clamp(color, vec3(0.0), vec3(1.0)));
    vec3 graded = SrgbToLinear(
        SampleLut(encodedIn, max(composite_info.ao_texel.w, 2.0)));
    color = mix(color, graded, clamp(lutStrength, 0.0, 1.0));
  }

  // The barrel and the film, last, and in that order: a vignette darkens what
  // the grain then lands on, which is the way round a camera does it.
  float vignette = composite_info.look_more.x;
  if (vignette > 0.0) {
    vec2 fromCentre = v_uv - vec2(0.5);
    // **The aspect has to be in the uniform for this to mean anything.** UV
    // space is square and the frame is not, so a falloff computed on UV alone
    // is an ellipse on screen. Roundness 1 undoes that and keeps the vignette
    // circular; 0 lets it follow the frame and reach the short edges first.
    float aspect = max(composite_info.look_more.w, 1e-4);
    fromCentre.x *= mix(1.0, aspect, composite_info.look_more.y);
    float radius = length(fromCentre) * 1.41421356;
    color *= mix(1.0, 1.0 - vignette, clamp(radius, 0.0, 1.0));
  }

  // **Grain and dither are both after the encode, and for the same reason.**
  // Banding is a quantisation artefact of the 8-bit target, so the noise that
  // breaks it up has to be the size of one output step: a fixed distance in
  // display space and a wildly varying one in linear space, where a step near
  // black is a thousandth of a step near white.
  vec3 encoded = LinearToSrgb(max(color, vec3(0.0)));

  // **This used to be added in linear light, and the comment on it was
  // wrong.** It said the noise was centred on zero so grain neither lifts nor
  // lowers the average level. Symmetric in linear it was; symmetric by the
  // time anybody saw it, it was not. `max(color, 0.0)` clipped the negative
  // half, and the sRGB encode then stretched what was left: at a grain of
  // 0.08 on black, the surviving half ran up to a linear 0.04, which encodes
  // to 56 of 255. Measured over the `look` parity fixture, the darkest cell
  // sat at 14 instead of 0, across a picture that is 188 cells of black.
  //
  // In display space the two halves are the same size, nothing is clipped
  // before the average is taken, and the sentence above is finally true. What
  // it costs is that the amount means something different: 0.08 is now eight
  // percent of the output range rather than of the light, which is what a
  // film grain control has always meant on every other tool.
  float grain = composite_info.look_more.z;
  if (grain > 0.0) encoded += vec3((Hash(TargetFragCoord()) - 0.5) * grain);

  // Dither last, because it is the one aimed at the quantiser itself.
  // **Centred exactly.** The cells run from -1/2 to 7/16, whose mean is
  // -1/32; the thirty-second puts it at nought, so with dithering on by
  // default (0.7.4) a flat colour stays the colour it was and only where a
  // gradient's bands fall changes.
  float dither = composite_info.output_encode.x;
  if (dither > 0.0) {
    encoded += vec3((BayerCell(TargetFragCoord()) + 0.03125) * dither);
  }

  frag_color = vec4(encoded, scene.a);
}

''',
    'Easu': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// Edge-adaptive spatial upscale for the path without a temporal resolve —
// `R5`.
//
// When `renderScale` is below one and nothing reconstructs the frame over
// time, the scene is drawn small and this brings the finished, tone-mapped
// picture up to the size that was asked for. Twelve taps around the output
// pixel's position in the source: the luma gradient over the four nearest
// says which way an edge runs and how sharply, and each tap is weighted by an
// approximation of Lanczos-2 stretched along that edge and narrowed across
// it, so an edge stays an edge where a bilinear upscale would blur it. The
// result is held between the four nearest taps, which is what keeps the
// lobes from ringing.
//
// After the tone map, never before: in HDR a highlight is so far above its
// neighbours that the negative lobe rings around it however it is clamped.
// The grain the composite would have added comes here instead, so it lands
// on output pixels rather than being stretched with the picture.

// --- lib/frag_coord_info.glsl ---
// The target's orientation, for a full-screen pass.
//
// Its own block rather than a member of each pass's, so the renderer binds it
// in one place, `drawFullscreen`, for every stage that declares it — the
// contract answers false for a stage that does not, and a pass that adds a
// screen-space pattern later gets the right rows by including this file.

#ifndef FRAG_COORD_INFO_GLSL_
#define FRAG_COORD_INFO_GLSL_

// --- lib/frag_coord.glsl ---
// Where a fragment sits, counted from the top of its target on every backend.

#ifndef FRAG_COORD_GLSL_
#define FRAG_COORD_GLSL_

/// `gl_FragCoord.xy` with row zero at the top of the picture.
///
/// [rows] is the target's height where the backend's row zero is the bottom
/// of the picture, and zero where it is the top. WebGL2 is the first kind:
/// window coordinates start at the lower left, and the engine draws the
/// picture upright there rather than mirroring every projection. Metal,
/// WebGPU and the software rasteriser are the second.
///
/// **Why a pattern cares and a picture does not.** Every screen-space pattern
/// in the engine — the Bayer dither, the grain, the jitter a ray march starts
/// from, the rotation of a shadow kernel — is a function of the pixel's row.
/// Read from the bottom, the same frame gets the pattern turned upside down,
/// and a four-row Bayer cell lands on different rows unless the height is a
/// multiple of four. The picture underneath is identical; the pattern on top
/// of it is not, and a comparison across backends counts every pixel it
/// moved.
vec2 FragCoordFromTop(float rows) {
  return rows > 0.0 ? vec2(gl_FragCoord.x, rows - gl_FragCoord.y)
                    : gl_FragCoord.xy;
}

#endif  // FRAG_COORD_GLSL_


layout(std140) uniform FragCoordInfo {
  /// x: the target's rows when its row zero is the bottom of the picture,
  /// zero when it is the top — see [FragCoordFromTop]. yzw unused.
  vec4 origin;
}
frag_coord_info;

/// This fragment's position with row zero at the top of the target.
vec2 TargetFragCoord() {
  return FragCoordFromTop(frag_coord_info.origin.x);
}

#endif  // FRAG_COORD_INFO_GLSL_


in vec2 v_uv;

layout(location = 0) out vec4 frag_color;

uniform sampler2D source_texture;

layout(std140) uniform EasuInfo {
  /// xy: the source's size in pixels. zw: one over it.
  vec4 source;
  /// x: the grain the composite left out, as `LookSettings.grain`. yzw unused.
  vec4 params;
}
easu_info;

float Hash(vec2 at) {
  return fract(sin(dot(at, vec2(12.9898, 78.233))) * 43758.5453);
}

vec3 Tap(vec2 pixel) {
  return textureLod(source_texture, (pixel + 0.5) * easu_info.source.zw, 0.0).rgb;
}

/// Luma as the filter weighs it: green twice, red and blue once.
float EasuLuma(vec3 c) { return c.g + 0.5 * (c.r + c.b); }

/// One of the four nearest taps' share of the edge's direction and length,
/// weighted by [w], its bilinear weight. [a] is above the centre [c], [b]
/// left of it, [d] right and [e] below.
void EdgeAt(inout vec2 dir, inout float len, float w, float a, float b, float c,
            float d, float e) {
  float lenX = max(abs(d - c), abs(c - b));
  float dirX = d - b;
  float stretchX = clamp(abs(dirX) / max(lenX, 1e-6), 0.0, 1.0);
  float lenY = max(abs(e - c), abs(c - a));
  float dirY = e - a;
  float stretchY = clamp(abs(dirY) / max(lenY, 1e-6), 0.0, 1.0);
  dir += vec2(dirX, dirY) * w;
  len += (stretchX * stretchX + stretchY * stretchY) * w;
}

/// One tap's weight at offset [off] from the sample position.
float TapWeight(vec2 off, vec2 dir, vec2 len2, float lob, float clp) {
  vec2 v = vec2(off.x * dir.x + off.y * dir.y, off.x * -dir.y + off.y * dir.x);
  v *= len2;
  float d2 = min(dot(v, v), clp);
  float wB = 0.4 * d2 - 1.0;
  float wA = lob * d2 - 1.0;
  wB *= wB;
  wA *= wA;
  wB = 1.5625 * wB - 0.5625;
  return wB * wA;
}

void main() {
  vec2 pp = v_uv * easu_info.source.xy - 0.5;
  vec2 fp = floor(pp);
  pp -= fp;

  vec3 b = Tap(fp + vec2(0.0, -1.0));
  vec3 c = Tap(fp + vec2(1.0, -1.0));
  vec3 e = Tap(fp + vec2(-1.0, 0.0));
  vec3 f = Tap(fp);
  vec3 g = Tap(fp + vec2(1.0, 0.0));
  vec3 h = Tap(fp + vec2(2.0, 0.0));
  vec3 i = Tap(fp + vec2(-1.0, 1.0));
  vec3 j = Tap(fp + vec2(0.0, 1.0));
  vec3 k = Tap(fp + vec2(1.0, 1.0));
  vec3 l = Tap(fp + vec2(2.0, 1.0));
  vec3 n = Tap(fp + vec2(0.0, 2.0));
  vec3 o = Tap(fp + vec2(1.0, 2.0));

  float bL = EasuLuma(b);
  float cL = EasuLuma(c);
  float eL = EasuLuma(e);
  float fL = EasuLuma(f);
  float gL = EasuLuma(g);
  float hL = EasuLuma(h);
  float iL = EasuLuma(i);
  float jL = EasuLuma(j);
  float kL = EasuLuma(k);
  float lL = EasuLuma(l);
  float nL = EasuLuma(n);
  float oL = EasuLuma(o);

  vec2 dir = vec2(0.0);
  float len = 0.0;
  EdgeAt(dir, len, (1.0 - pp.x) * (1.0 - pp.y), bL, eL, fL, gL, jL);
  EdgeAt(dir, len, pp.x * (1.0 - pp.y), cL, fL, gL, hL, kL);
  EdgeAt(dir, len, (1.0 - pp.x) * pp.y, fL, iL, jL, kL, nL);
  EdgeAt(dir, len, pp.x * pp.y, gL, jL, kL, lL, oL);

  // A flat patch has no direction; any will do, and x is as good as any.
  float dirR = dot(dir, dir);
  bool featureless = dirR < 1.0 / 32768.0;
  dir = featureless ? vec2(1.0, 0.0) : dir * inversesqrt(max(dirR, 1e-12));

  len *= 0.5;
  len *= len;
  float stretch = dot(dir, dir) / max(abs(dir.x), abs(dir.y));
  vec2 len2 = vec2(1.0 + (stretch - 1.0) * len, 1.0 - 0.5 * len);
  float lob = 0.5 - 0.29 * len;
  float clp = 1.0 / lob;

  vec3 sum = vec3(0.0);
  float weight = 0.0;
  float w;
  w = TapWeight(vec2(0.0, -1.0) - pp, dir, len2, lob, clp); sum += b * w; weight += w;
  w = TapWeight(vec2(1.0, -1.0) - pp, dir, len2, lob, clp); sum += c * w; weight += w;
  w = TapWeight(vec2(-1.0, 1.0) - pp, dir, len2, lob, clp); sum += i * w; weight += w;
  w = TapWeight(vec2(0.0, 1.0) - pp, dir, len2, lob, clp); sum += j * w; weight += w;
  w = TapWeight(vec2(0.0, 0.0) - pp, dir, len2, lob, clp); sum += f * w; weight += w;
  w = TapWeight(vec2(-1.0, 0.0) - pp, dir, len2, lob, clp); sum += e * w; weight += w;
  w = TapWeight(vec2(1.0, 1.0) - pp, dir, len2, lob, clp); sum += k * w; weight += w;
  w = TapWeight(vec2(2.0, 1.0) - pp, dir, len2, lob, clp); sum += l * w; weight += w;
  w = TapWeight(vec2(2.0, 0.0) - pp, dir, len2, lob, clp); sum += h * w; weight += w;
  w = TapWeight(vec2(1.0, 0.0) - pp, dir, len2, lob, clp); sum += g * w; weight += w;
  w = TapWeight(vec2(1.0, 2.0) - pp, dir, len2, lob, clp); sum += o * w; weight += w;
  w = TapWeight(vec2(0.0, 2.0) - pp, dir, len2, lob, clp); sum += n * w; weight += w;

  vec3 lo = min(min(f, g), min(j, k));
  vec3 hi = max(max(f, g), max(j, k));
  vec3 color = clamp(sum / max(weight, 1e-6), lo, hi);

  float grain = easu_info.params.x;
  color += vec3((Hash(TargetFragCoord()) - 0.5) * grain);
  frag_color = vec4(color, 1.0);
}

''',
    'LocalExposure': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// How well exposed each part of the frame would be at three exposures —
// `R7`, the first step of local exposure.
//
// Exposure fusion: a picture taken three times, the shadows pushed up, as
// shot, and the highlights pulled down, and at each place whichever of them
// shows it best. "Best" is how near mid-grey the place comes out, which is
// the well-exposedness weight of exposure fusion. Here it is measured at an
// eighth of the frame's size, from the scene before the tone map; the blur
// that follows turns the three weights into one exposure per place, and the
// composite applies it before the curve. A dark room keeps its bright window
// and a window keeps its dark room, where one global exposure has to choose.

in vec2 v_uv;

layout(location = 0) out vec4 frag_color;

uniform sampler2D scene_texture;

layout(std140) uniform LocalExposureInfo {
  /// x: how many stops the shadow exposure lifts by. y: how many the
  /// highlight exposure pulls down by. zw: one texel of the scene.
  vec4 stops;

  /// x: the frame's own exposure, the one the composite multiplies by after
  /// this (auto exposure's answer when it is on). yzw unused.
  vec4 camera;
}
local_exposure_info;

float Luma(vec3 c) { return dot(c, vec3(0.2126, 0.7152, 0.0722)); }

/// How near mid-grey a scene luminance [y] comes out, from nought to one.
float WellExposed(float y) {
  float display = pow(y / (1.0 + y), 1.0 / 2.2);
  float off = display - 0.5;
  return exp(-off * off / 0.08);
}

void main() {
  // Four taps across the eighth's block, so a small bright thing counts.
  vec2 t = local_exposure_info.stops.zw * 2.0;
  float y = 0.25 * (Luma(texture(scene_texture, v_uv + vec2(-t.x, -t.y)).rgb) +
                    Luma(texture(scene_texture, v_uv + vec2(t.x, -t.y)).rgb) +
                    Luma(texture(scene_texture, v_uv + vec2(-t.x, t.y)).rgb) +
                    Luma(texture(scene_texture, v_uv + vec2(t.x, t.y)).rgb));
  // **As shot means as the camera exposed it.** The scene buffer is not
  // pre-exposed: the composite multiplies by the frame's exposure after the
  // local stops. Judged at exposure one, a dark room the meter has already
  // lifted three stops still looked underexposed and was lifted again, and a
  // scene authored in physical units looked blown everywhere. Exposure
  // fusion weighs the exposures a camera would actually have taken.
  y = max(y, 0.0) * max(local_exposure_info.camera.x, 0.0);
  float shadow = exp2(local_exposure_info.stops.x);
  float highlight = exp2(-local_exposure_info.stops.y);
  frag_color = vec4(WellExposed(y * shadow) + 1e-4, WellExposed(y) + 1e-4,
                    WellExposed(y * highlight) + 1e-4, 1.0);
}

''',
    'LocalExposureBlur': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// The three well-exposedness weights, blurred wide along one axis — `R7`.
//
// Run twice, across and then down. Wide, because an exposure that changed
// from one texel to the next would be a halo round every edge: the weights
// are what fusion blends through its pyramid, and a blur this wide at an
// eighth of the frame stands in for the coarse levels of it. The second run
// turns the weights into the exposure itself, in stops: each exposure's
// shift weighted by how well it shows the place.

in vec2 v_uv;

layout(location = 0) out vec4 frag_color;

uniform sampler2D weight_texture;

layout(std140) uniform LocalExposureBlurInfo {
  /// xy: one step of the blur, in texture coordinates. z: one on the second
  /// run, which writes the exposure rather than the weights. w unused.
  vec4 step;
  /// x: the shadow exposure's lift in stops, y: the highlight's pull. zw
  /// unused.
  vec4 stops;
}
blur_info;

void main() {
  vec3 sum = vec3(0.0);
  float total = 0.0;
  for (int i = -6; i <= 6; i++) {
    float w = exp(-float(i * i) / 18.0);
    sum += texture(weight_texture, v_uv + blur_info.step.xy * float(i)).rgb * w;
    total += w;
  }
  vec3 weights = sum / total;
  float stops = (weights.x * blur_info.stops.x - weights.z * blur_info.stops.y) /
                max(weights.x + weights.y + weights.z, 1e-6);
  frag_color = blur_info.step.z > 0.5 ? vec4(stops, stops, stops, 1.0)
                                      : vec4(weights, 1.0);
}

''',
    'Fxaa': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// Edges smoothed after the fact, on the finished picture.
//
// **This exists to end a choice nobody should have to make.** The scene pass
// turns MSAA off whenever anything consumes the surface buffer, because a
// multisampled attachment and a buffer something else reads back are the same
// decision made two ways — so switching ambient occlusion on cost every edge
// in the frame its smoothing. A game got shadows in its corners or clean
// silhouettes, and not both.
//
// Working on the composited image rather than on the scene is what makes that
// possible, and it is also what makes this cheap: one pass, one texture, no
// depth, no second attachment, no knowledge of geometry at all. What it
// cannot do is help an edge the rasteriser never saw — a thin wire that fell
// between two pixel centres is gone before this reads it, which MSAA would
// have caught. Named here because it is the honest limit of the technique
// rather than a defect in this implementation.
//
// The algorithm is FXAA 3.11 Quality at preset 12: the early exit on local
// contrast, the direction from the 3x3 second differences, the search along
// the edge for both of its ends, and the sub-pixel term, the larger of the
// two offsets winning. The search is what smooths a long, shallow staircase:
// without it a pixel only knows its neighbours and moves the same whether it
// sits at the start of a step or at its end.
precision highp float;

in vec2 v_uv;

layout(location = 0) out vec4 frag_color;

/// The composited frame, already tone mapped and sRGB encoded.
uniform sampler2D source_texture;

layout(std140) uniform FxaaInfo {
  /// x, y: one texel. z: the contrast a pixel needs before it is worth
  /// touching, as a fraction of the local maximum. w: the sub-pixel amount,
  /// FXAA's `subpix`: the most a pixel moves on local contrast alone, in
  /// texels.
  vec4 params;

  /// x: contrast-adaptive sharpening, 0 for none. y: one for the robust
  /// kernel a temporal resolve is followed by (`R2`), nought for the one
  /// below. z, w: unclaimed.
  ///
  /// **A second block member rather than a fifth component**, because
  /// `params` is full — and because sharpening is not an anti-aliasing
  /// parameter. It rides in this pass for one reason: the four taps it needs
  /// are the four this pass already fetches.
  vec4 sharpen;
}
fxaa_info;

/// The smallest of the three, which GLSL has no builtin for.
float MinChannel(vec3 v) { return min(v.x, min(v.y, v.z)); }

/// The largest of the three.
float MaxChannel(vec3 v) { return max(v.x, max(v.y, v.z)); }

/// Robust contrast-adaptive sharpening, after a temporal resolve — `R2`.
///
/// The cross-shaped kernel FSR 1 publishes: the negative lobe each neighbour
/// gets is the largest that keeps every channel of the result inside the
/// neighbourhood's own range, so it cannot ring past what was there. Limited
/// to three sixteenths, where the kernel stops being a sharpen and starts
/// being noise — which also keeps the denominator at a quarter or more, the
/// hole the kernel below fell into at a quarter exactly.
vec3 SharpenRobust(vec3 centre, vec3 n, vec3 s, vec3 w, vec3 e, float amount) {
  vec3 lowest = min(min(n, s), min(w, e));
  vec3 highest = max(max(n, s), max(w, e));
  vec3 hitMin = lowest / max(4.0 * highest, vec3(1e-5));
  vec3 hitMax = (vec3(1.0) - highest) / min(4.0 * lowest - 4.0, vec3(-1e-5));
  vec3 lobeRgb = max(-hitMin, hitMax);
  float lobe = max(-0.1875, min(MaxChannel(lobeRgb), 0.0)) * amount;
  return (lobe * (n + s + w + e) + centre) / (4.0 * lobe + 1.0);
}

/// Contrast-adaptive sharpening over the cross this pass already sampled —
/// `gfx-29n`.
///
/// **Why it is here and not in a pass of its own.** Output sharpening is what
/// normally follows an FXAA-softened image, and the neighbourhood it needs is
/// the four taps the smoothing already fetched. A pass of its own would be a
/// second full-screen draw and four more texture reads for the same answer.
///
/// **Adaptive, which is the whole of the name.** A plain unsharp mask
/// overshoots wherever the neighbourhood is already near an extreme — the
/// bright halo along a hard edge that reads as a cheap filter. The amplitude
/// here is taken from how much headroom the darkest and lightest neighbours
/// leave, so a pixel with room is sharpened and a pixel already against the
/// ceiling is not.
///
/// **Pushed away from the neighbourhood average, with no denominator.** The
/// first version here used the normalised kernel a sharpener usually has —
/// `(c + w*(n+s+w+e)) / (1 + 4w)` — and that form has a hole in it with four
/// neighbours: the denominator is zero at `w = -0.25`, which is exactly where
/// full strength on a pixel with full headroom lands. A flat grey frame came
/// back white. Measured, not reasoned about: 96 went to 255 on every pixel.
///
/// This form has no denominator to vanish. A flat neighbourhood has the
/// centre equal to its own average, so the difference is zero and the pixel
/// is returned untouched — an identity by construction rather than by
/// algebra that happens to cancel.
vec3 Sharpen(vec3 centre, vec3 n, vec3 s, vec3 w, vec3 e) {
  float strength = fxaa_info.sharpen.x;
  if (strength <= 0.0) return centre;
  if (fxaa_info.sharpen.y > 0.5) {
    return SharpenRobust(centre, n, s, w, e, strength);
  }

  vec3 lowest = min(centre, min(min(n, s), min(w, e)));
  vec3 highest = max(centre, max(max(n, s), max(w, e)));
  // How much room is left at the nearer end. Per channel, because a red edge
  // against white has headroom in two channels and none in the third.
  vec3 room = min(lowest, vec3(1.0) - highest) / max(highest, vec3(1e-5));
  float amount = clamp(sqrt(clamp(MinChannel(room), 0.0, 1.0)), 0.0, 1.0);

  vec3 average = (n + s + w + e) * 0.25;
  return centre + (centre - average) * amount * strength;
}

/// Perceptual weight, on the encoded image.
///
/// Green-weighted rather than Rec. 709 luma: this runs *after* the sRGB
/// encode, so the values are not linear light and a photometric weighting
/// would be measuring the wrong space. What the algorithm needs is a number
/// that moves when a person would see an edge, and green carries most of
/// that.
float Weight(vec3 color) { return dot(color, vec3(0.299, 0.587, 0.114)); }

/// The edge search's steps, in texels, after the first one texel —
/// FXAA 3.11's quality preset 12 (`FXAA_QUALITY__P1` to `P4`). Selects
/// rather than a table: the OpenGL ES target has no constant arrays worth
/// indexing, and a chain of ternaries is one select per step.
float SearchStep(int i) {
  return i == 1 ? 1.5 : (i == 2 ? 2.0 : (i == 3 ? 4.0 : 12.0));
}

/// Steps the edge search takes, the first included.
const int kSearchSteps = 5;

void main() {
  vec2 texel = fxaa_info.params.xy;

  // `textureLod` throughout this pass, for `shadow.glsl`'s own reason: every
  // tap after the early return below, the edge search's above all, sits in
  // control flow that differs per fragment, so a WGSL backend refuses the
  // implicit derivative as possibly non-uniform. The composited frame is read
  // at its native size with no mipmap of its own, so naming level zero
  // directly changes no pixel.
  vec3 middle = textureLod(source_texture, v_uv, 0.0).rgb;
  float mid = Weight(middle);

  // The four edge neighbours, colours kept: the sharpening at the end needs
  // the neighbourhood itself, and these are the same four taps either way.
  vec3 northRgb = textureLod(source_texture, v_uv + vec2(0.0, -texel.y), 0.0).rgb;
  vec3 southRgb = textureLod(source_texture, v_uv + vec2(0.0, texel.y), 0.0).rgb;
  vec3 westRgb = textureLod(source_texture, v_uv + vec2(-texel.x, 0.0), 0.0).rgb;
  vec3 eastRgb = textureLod(source_texture, v_uv + vec2(texel.x, 0.0), 0.0).rgb;
  float north = Weight(northRgb);
  float south = Weight(southRgb);
  float west = Weight(westRgb);
  float east = Weight(eastRgb);

  float lowest = min(mid, min(min(north, south), min(west, east)));
  float highest = max(mid, max(max(north, south), max(west, east)));
  float contrast = highest - lowest;

  // **Relative to the local brightness, not absolute.** A step of 0.02 across
  // a dark surface is an edge somebody can see; the same step across a white
  // wall is dithering. A fixed threshold either scrubs the dark parts of the
  // frame or leaves the bright parts crawling, and this frame has both.
  if (contrast < max(0.0312, highest * fxaa_info.params.z)) {
    frag_color = vec4(
        Sharpen(middle, northRgb, southRgb, westRgb, eastRgb), 1.0);
    return;
  }

  // FXAA 3.11 Quality from here on, step for step. The diagonals only for
  // pixels past the early exit: they sharpen the direction estimate at a
  // corner and weigh into the sub-pixel average.
  float northWest = Weight(textureLod(source_texture, v_uv - texel, 0.0).rgb);
  float southEast = Weight(textureLod(source_texture, v_uv + texel, 0.0).rgb);
  float northEast = Weight(
      textureLod(source_texture, v_uv + vec2(texel.x, -texel.y), 0.0).rgb);
  float southWest = Weight(
      textureLod(source_texture, v_uv + vec2(-texel.x, texel.y), 0.0).rgb);

  // Which way the edge runs: the second differences across it, the middle
  // row counted twice. A horizontal edge changes most from north to south.
  float edgeHorizontal =
      abs(northWest + southWest - 2.0 * west) +
      2.0 * abs(north + south - 2.0 * mid) +
      abs(northEast + southEast - 2.0 * east);
  float edgeVertical =
      abs(northWest + northEast - 2.0 * north) +
      2.0 * abs(west + east - 2.0 * mid) +
      abs(southWest + southEast - 2.0 * south);
  bool horizontalSpan = edgeHorizontal >= edgeVertical;

  // The sub-pixel term: how far the middle sits from the 3x3 low-pass (the
  // cross twice, the corners once, over twelve), against the local range,
  // through a smoothstep and squared.
  float lowPass = (2.0 * (north + south + west + east) +
                   northWest + northEast + southWest + southEast) / 12.0;
  float subpixC = clamp(abs(lowPass - mid) / contrast, 0.0, 1.0);
  float subpixF = (3.0 - 2.0 * subpixC) * subpixC * subpixC;
  float subpixH = subpixF * subpixF * fxaa_info.params.w;

  // The two neighbours across the edge, and the steeper side. On a tie the
  // north (or west) one, as FXAA's `pairN` has it.
  float lumaN = horizontalSpan ? north : west;
  float lumaS = horizontalSpan ? south : east;
  float gradientN = lumaN - mid;
  float gradientS = lumaS - mid;
  bool pairN = abs(gradientN) >= abs(gradientS);
  float gradient = max(abs(gradientN), abs(gradientS));
  float lengthSign = horizontalSpan ? texel.y : texel.x;
  if (pairN) lengthSign = -lengthSign;
  float pairAverage = 0.5 * (pairN ? lumaN + mid : lumaS + mid);

  // **The edge search.** Half a texel onto the steeper side, so a bilinear
  // tap straddles the edge, then outwards both ways along it until the
  // straddled average leaves the pair's average by a quarter of the
  // gradient: that is where the edge ends. Knowing both ends is what lets a
  // pixel on a long shallow staircase know where on its step it sits, which
  // the local neighbourhood alone cannot say.
  vec2 along = horizontalSpan ? vec2(texel.x, 0.0) : vec2(0.0, texel.y);
  vec2 start = v_uv + (horizontalSpan ? vec2(0.0, lengthSign * 0.5)
                                      : vec2(lengthSign * 0.5, 0.0));
  float gradientScaled = gradient * 0.25;
  vec2 posN = start - along;
  vec2 posP = start + along;
  float endN = Weight(textureLod(source_texture, posN, 0.0).rgb) - pairAverage;
  float endP = Weight(textureLod(source_texture, posP, 0.0).rgb) - pairAverage;
  bool doneN = abs(endN) >= gradientScaled;
  bool doneP = abs(endP) >= gradientScaled;
  for (int i = 1; i < kSearchSteps; i++) {
    if (doneN && doneP) break;
    float stride = SearchStep(i);
    if (!doneN) {
      posN -= along * stride;
      endN = Weight(textureLod(source_texture, posN, 0.0).rgb) - pairAverage;
      doneN = abs(endN) >= gradientScaled;
    }
    if (!doneP) {
      posP += along * stride;
      endP = Weight(textureLod(source_texture, posP, 0.0).rgb) - pairAverage;
      doneP = abs(endP) >= gradientScaled;
    }
  }

  // The nearer end decides. Its luma has to have gone the other way from the
  // middle's, or this pixel is on the far side of that end's step and is
  // not moved by the edge at all; otherwise it moves by how near that end
  // it is, half a texel at the end itself and nothing at the span's middle.
  float distanceN = horizontalSpan ? v_uv.x - posN.x : v_uv.y - posN.y;
  float distanceP = horizontalSpan ? posP.x - v_uv.x : posP.y - v_uv.y;
  bool middleBelow = mid - pairAverage < 0.0;
  bool nearerN = distanceN < distanceP;
  bool goodSpan = nearerN ? (endN < 0.0) != middleBelow
                          : (endP < 0.0) != middleBelow;
  float nearest = min(distanceN, distanceP);
  float pixelOffset = 0.5 - nearest / (distanceN + distanceP);
  float offset = max(goodSpan ? pixelOffset : 0.0, subpixH);

  vec2 at = v_uv + (horizontalSpan ? vec2(0.0, offset * lengthSign)
                                   : vec2(offset * lengthSign, 0.0));
  vec3 smoothed = textureLod(source_texture, at, 0.0).rgb;
  frag_color =
      vec4(Sharpen(smoothed, northRgb, southRgb, westRgb, eastRgb), 1.0);
}

''',
    'MrtProbe': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// PROBE, not part of the pipeline: does Impeller honour more than one colour
// attachment?
//
// `RenderTarget.colorAttachments` is a list and `setColorBlendEnable` takes an
// attachment index, so MRT is there structurally — but structure in the Dart
// bindings has already proved to be a poor predictor of runtime behaviour
// twice in this project. Writing two distinct constants and reading both
// targets back settles it.
//
// Enabled with `--dart-define=FLUTTER3D_MRT_PROBE=true`; the entry stays in the
// bundle because the answer is tied to a Flutter version and the next SDK bump
// should re-run the check rather than re-derive it.
precision highp float;

in vec2 v_uv;

layout(location = 0) out vec4 out_first;
layout(location = 1) out vec4 out_second;

void main() {
  // Two values nothing else in the engine produces, so reading them back is
  // unambiguous evidence that each attachment received its own output.
  out_first = vec4(0.25, 0.5, 0.75, 1.0);
  out_second = vec4(0.75, 0.5, 0.25, 1.0);
}

''',
    'ShadowDepth': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// The shadow pass: write depth, nothing else.
//
// Depth goes into a **colour** target rather than being read back out of the
// depth buffer. flutter_gpu gives no way to sample a depth texture — the format
// enum has depth formats, but a `DepthStencilAttachment` texture is not
// something `bindTexture` will take — so the workaround is chosen up front
// rather than discovered: render linear depth into `r16g16b16a16Float`, which
// is a format sampling is known to work for.
//
// `gl_FragCoord.z` is exactly what is wanted here *because* the shadow camera is
// orthographic. Under a perspective projection that value is hyperbolic and
// would concentrate all its precision near the near plane; an orthographic one
// is linear in view space, so the stored value is a distance and comparing two
// of them is meaningful.
//
// It includes lib/color.glsl for the varying declarations and the output, not
// for the colour helpers: a fragment shader whose inputs disagree with the
// vertex shader's outputs does not link, and mesh.vert emits all five.
// One attachment, not two: this pass writes a shadow map, and the surface
// buffer belongs to the scene pass.
// And no fog block either: this shader reads neither, and a uniform block it
// declares without using is a descriptor that collides with the vertex stage's
// on Vulkan. See the guard in lib/color.glsl.
#define F3D_NO_SURFACE_BUFFER
#define F3D_NO_FOG
// --- lib/color.glsl ---
// Colour space helpers and the fragment output interface.
//
// Split out of surface.glsl so a shader that needs no material inputs — the
// normals debug view — can avoid DECLARING the FragInfo uniform block at all.
// That matters more than it looks: reflection metadata reports a block as
// present merely because it was declared, even when the compiled shader binds
// no such buffer, so a declared-but-unused block is indistinguishable from a
// used one until Metal crashes on the bind.

#ifndef COLOR_GLSL_
#define COLOR_GLSL_

precision highp float;

const float kPi = 3.14159265359;

// One varying set shared by every fragment shader, matching mesh.vert.
//
// All five are declared here, including the two the debug models never read: a
// fragment shader whose `in` block disagrees with the vertex shader's `out`
// block fails to link, and there is no partial-match rule to lean on.
in vec3 v_world_position;
in vec3 v_normal;
in vec2 v_texcoord;
in vec4 v_tangent;
in vec4 v_color;

/// Where this fragment is in the level's lightmap. Zero from every vertex
/// stage but `mesh_lightmapped.vert`, and read only by the lit models, which
/// sample a one-texel black there when a material has no map.
in vec2 v_lightmap_uv;

layout(location = 0) out vec4 frag_color;

// The second attachment: what a screen-space effect needs to know about the
// surface it is looking at. World-space normal in rgb, and in a the depth along
// the view axis in world metres — not a window depth; `WriteSurfaceGeometry`
// says at length why not.
//
// Depth travels here rather than in a depth texture because flutter_gpu cannot
// sample one — the same reason the shadow pass writes its depth into a colour
// target. See ARCHITECTURE.md §2.
//
// Guarded, because not every stage that includes this header draws into a
// two-attachment target. The shadow pass draws into one, and a pipeline
// declaring an output its target has no slot for is a mismatch worth avoiding
// rather than discovering.
#ifndef F3D_NO_SURFACE_BUFFER
layout(location = 1) out vec4 frag_surface;

/// The surface's own colour, sRGB-encoded, alpha one where a surface was
/// drawn — `L5`. The third attachment, present only when a pass reads it (the
/// indirect light does) and the device opens three; like the surface buffer,
/// written unconditionally and discarded when absent. Stored in the surface
/// buffer's format rather than eight bits a channel, and `Renderer` says why.
layout(location = 2) out vec4 frag_albedo;
#endif

/// What [frag_albedo] carries: the lit models set it in `ReadSurface`, and a
/// stage that reflects nothing — unlit, the debug views — leaves it black,
/// which is what light bounced onto it would come to.
vec3 g_albedo = vec3(0.0);

/// Octahedral encoding: a unit vector in two channels instead of three.
///
/// Worth the arithmetic because the fourth channel is already spent on depth,
/// and without a free channel there is nowhere to put roughness — which is the
/// difference between a reflection that knows stone from a mirror and one that
/// does not. The error is well under a degree, far below anything a reflection
/// off rough stone would show.
vec2 EncodeOctahedral(vec3 n) {
  n /= abs(n.x) + abs(n.y) + abs(n.z);
  vec2 e = n.xy;
  if (n.z < 0.0) {
    e = (1.0 - abs(n.yx)) * vec2(n.x >= 0.0 ? 1.0 : -1.0,
                                 n.y >= 0.0 ? 1.0 : -1.0);
  }
  return e * 0.5 + 0.5;
}

/// Where a debug pass leaves the picture it wants shown instead of the normal.
///
/// Declared here, in the header every lit shader includes **first**, and
/// written from surface.glsl, which is included after. The alternative was a
/// new member on a shared uniform block; a global costs nothing and moves no
/// offsets. It is read at the moment the surface buffer is written, which
/// happens after the lighting loop has run, so the value is there by then.
vec3 g_debug_surface = vec3(0.0);
bool g_debug_surface_on = false;

/// Whether [WriteSurface] weights the colour by its alpha: set by
/// `ReadSurface` for a material that blends, and false for everything else.
///
/// **The blend takes its source as premultiplied**, so a blended surface has
/// to hand it the colour times the alpha — a pane at a fifth of opaque adds a
/// fifth of its light, not all of it. glTF's blend mode is Porter and Duff's
/// over on straight colour, and this is the one place that turns the lit
/// radiance into what that means. An opaque or masked surface keeps its
/// colour whole: its alpha is not a coverage, and nothing blends it.
/// A global for the reason [g_debug_surface] is one.
bool g_premultiply = false;

// **A stage that needs none of this must be able to declare none of it.** On
// Vulkan both stages' descriptors are merged into one set layout, and two
// bindings with the same number in it is not a layout the specification
// allows. A driver may accept it anyway; a Galaxy A55's refuses the pipeline
// with `ErrorUnknown` and no other word, which is how the shadow pass came to
// build everywhere except there — its only uniform block was this one, and it
// landed on the same binding as the vertex stage's first.
#ifndef F3D_NO_FOG

/// Distance fog, in its own block rather than folded into FragInfo.
///
/// Its own because color.glsl is included before FragInfo is declared, and
/// because appending to a block that half a dozen shaders already share is a
/// way to move offsets nobody expected to move. Three vec4s is a cheap price
/// for not touching any of that.
layout(std140) uniform FogInfo {
  /// rgb: linear fog colour. w: density per metre, zero for no fog.
  vec4 fog;

  /// xyz: camera position in world space. Duplicated from FragInfo so this
  /// block stands alone; a vec3 is cheaper than a coupling.
  vec4 eye;

  /// xyz: the direction the camera looks, as a unit vector in world space.
  /// w: what a transparent draw writes under weighted blended transparency —
  /// `R8`, see `WriteWeightedBlended`. Zero for every other draw.
  ///
  /// Here rather than in a block of its own because it answers the same
  /// question [eye] does — where the camera is and which way it faces — and
  /// this is the block `color.glsl` can see.
  vec4 forward;
}
fog_info;

/// How far this fragment is from the eye, in world metres.
///
/// What the fog fades by. Distance rather than depth, because fog is a
/// property of the air between two points and does not care which way the
/// camera happens to face.
float EyeDistance() { return distance(v_world_position, fog_info.eye.xyz); }

/// How far this fragment is *along the view axis*, in world metres.
///
/// What the surface buffer's alpha holds. Depth rather than distance, and the
/// difference only shows on an orthographic camera — where the rays through
/// the pixels are parallel instead of meeting at the eye, so a distance from
/// the eye names a sphere that the pixel's ray crosses somewhere the reader
/// cannot solve for. A depth along the axis names a plane, which every ray
/// crosses exactly once. See `WorldAtDepth` in `post/ssao.frag` for the
/// reconstruction both projections share.
float ViewDepth() {
  return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz);
}

#else  // F3D_NO_FOG

// The same two questions, answered without the block: a stage that declares no
// fog has no eye position to measure from either. Stubs rather than a guard at
// every call site, so that what includes this file reads the same whichever
// way it was compiled.
float EyeDistance() { return 0.0; }
float ViewDepth() { return 0.0; }

#endif  // F3D_NO_FOG

/// sRGB to linear. Textures are authored in sRGB, but lighting is only correct
/// in linear space; skipping this is what makes naive renderers look muddy.
vec3 SrgbToLinear(vec3 srgb) {
  return mix(
      srgb / 12.92,
      pow((srgb + vec3(0.055)) / 1.055, vec3(2.4)),
      step(vec3(0.04045), srgb));
}

/// Linear to sRGB. The render target is a plain UNorm format rather than an
/// sRGB one, so the encode has to happen here.
vec3 LinearToSrgb(vec3 linear) {
  return mix(
      linear * 12.92,
      1.055 * pow(linear, vec3(1.0 / 2.4)) - vec3(0.055),
      step(vec3(0.0031308), linear));
}

/// Writes scene-referred linear light into the HDR target.
///
/// No tone map and no sRGB encode: those moved into the composite pass, which
/// is the entire point of rendering into `r16g16b16a16Float` first. Applying
/// them here meant every model wrote display-referred colour into an 8-bit
/// buffer, so anything above display white was gone before post-processing
/// could see it — and bloom is a function of exactly that.
///
/// Exposure moved with them, for the same reason: it belongs on the same side
/// of the display transform as the tone map.
/// Records the geometry of this fragment for whatever runs after the scene.
///
/// Called from the same place that writes colour, so a surface cannot be lit
/// into the frame without also describing itself — which is the failure that
/// leaves a screen-space effect reflecting whatever was in the buffer before.
///
/// rg: octahedral normal. b: perceptual roughness. a: **depth along the view
/// axis, in world metres** — see [ViewDepth].
///
/// **Not `gl_FragCoord.z`, and that is a defect this channel carried until it
/// was looked at.** Window depth crowds every distant surface into the top of
/// its range — with a near plane of a tenth of a metre, everything past twenty
/// metres lives in the last half a hundredth of `[0, 1]` — and this attachment
/// is a half float, whose steps up there are about five ten-thousandths. So two
/// surfaces half a metre apart at twenty metres stored the *same* number, and
/// every screen-space pass that compares against this channel decided whole
/// bands of pixels by rounding. The occlusion pass drew them: vertical stripes
/// along the lines of constant depth on any wall receding from the camera, on
/// both GPU backends. The software rasteriser kept the channel at full
/// precision and drew the effect correctly, so it was the one that looked
/// wrong against the other two.
///
/// A depth in metres has none of that: the exponent carries the range and the
/// mantissa carries the same relative precision everywhere, which at twenty
/// metres is a centimetre. Both numbers are measured in
/// `flutter3d/test/surface_depth_test.dart`.
///
/// Zero still means nothing was drawn. The attachment is cleared to zero and
/// nothing is drawn in front of the near plane.
void WriteSurfaceGeometry(float roughness) {
#ifndef F3D_NO_SURFACE_BUFFER
  // `L5`: the surface's colour, whatever the surface buffer ends up holding.
  frag_albedo = vec4(LinearToSrgb(clamp(g_albedo, vec3(0.0), vec3(1.0))), 1.0);
  // A debug pass takes the buffer over rather than getting one of its own.
  // The surface buffer already has an attachment, a viewer and a golden; a
  // second one would need all three built before it could answer anything.
  if (g_debug_surface_on) {
    frag_surface = vec4(g_debug_surface, ViewDepth());
    return;
  }
  // Reversed on a back face, as the lit normal is, so the occlusion and
  // reflection passes see the side of a double-sided surface that faces them.
  vec3 geometric = normalize(v_normal);
  if (!gl_FrontFacing) geometric = -geometric;
  frag_surface = vec4(EncodeOctahedral(geometric),
                      clamp(roughness, 0.0, 1.0), ViewDepth());
#endif
}

/// Fades [color] toward the fog with distance from the eye.
///
/// Exponential rather than linear, because linear fog has a visible plane
/// where it starts and a dungeon corridor is exactly where that shows.
vec3 ApplyFog(vec3 color) {
#ifdef F3D_NO_FOG
  return color;
#else
  float density = fog_info.fog.w;
  if (density <= 0.0) return color;
  float d = EyeDistance();
  return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0));
#endif
}

/// How much a transparent fragment counts for against the others over its
/// pixel — `R8`. McGuire and Bavoil's depth weight (their equation 9): a near
/// layer outweighs a far one, which is all the ordering a weighted average
/// can keep. [alpha] multiplies it, as theirs does, so a faint layer counts
/// faintly. Depth along the view axis, in metres, the surface buffer's.
float WeightedBlendedWeight(float alpha) {
  float z = abs(ViewDepth());
  float near = z / 5.0;
  float far = z / 200.0;
  float far3 = far * far * far;
  return alpha *
         clamp(10.0 / (1e-5 + near * near + far3 * far3), 1e-2, 3e3);
}

/// What a transparent draw writes when the frame composites transparency
/// order-independently — `R8`. `fog_info.forward.w` says which:
///
/// - 0: [frag_color] as it stands, the sorted blend's source. Every opaque
///   draw, and every draw in a frame that sorts.
/// - 1: the accumulation target's share — the colour, which the engine keeps
///   premultiplied, and the alpha, both times the weight. Added.
/// - 2: the revealage target's — the alpha alone, in every channel, which the
///   blend multiplies the target by one minus of.
/// - 3: both at once, the second into attachment one, where the surface
///   buffer would be; the pass that asks has no surface buffer attached.
///
/// Selects rather than returns, because a phi of constants is what
/// SPIRV-Cross refuses. At nought the branch is not taken and [frag_color]
/// is untouched, which is what keeps a sorting frame byte-identical.
void WriteWeightedBlended() {
#ifndef F3D_NO_FOG
  float mode = fog_info.forward.w;
  if (mode > 0.5) {
    float alpha = frag_color.a;
    float weight = WeightedBlendedWeight(alpha);
    vec4 accumulate = vec4(frag_color.rgb * weight, alpha * weight);
    bool revealage = mode > 1.5 && mode < 2.5;
    frag_color = revealage ? vec4(alpha) : accumulate;
#ifndef F3D_NO_SURFACE_BUFFER
    if (mode > 2.5) frag_surface = vec4(alpha);
#endif
  }
#endif
}

/// The fog is mixed in before the weight, so a thin distant pane adds a thin
/// share of the fog too rather than all of it. Times one when nothing blends,
/// which is exact, so an opaque draw writes what it always wrote.
void WriteSurface(vec3 linearColor, float alpha, float roughness) {
  float weight = g_premultiply ? alpha : 1.0;
  frag_color = vec4(ApplyFog(linearColor) * weight, alpha);
  WriteSurfaceGeometry(roughness);
  WriteWeightedBlended();
}

/// For a stage with no material to speak of.
///
/// Fully rough, which is the honest default: a surface that cannot say how
/// polished it is should not be reflected off.
void WriteSurface(vec3 linearColor, float alpha) {
  WriteSurface(linearColor, alpha, 1.0);
}

/// Writes a value that is already display-referred.
///
/// For debug output, where the colour is not a light value at all: a normal
/// encoded as RGB means nothing after a tone curve. Converting to linear here
/// means the composite pass's sRGB encode hands the original back unchanged,
/// provided the view also turns tone mapping and exposure off — which is what
/// `RenderSettings.tonemap` is for.
void WriteDisplayColor(vec3 displayColor, float alpha) {
  frag_color = vec4(SrgbToLinear(displayColor), alpha);
  WriteSurfaceGeometry(1.0);
}

#endif  // COLOR_GLSL_


void main() {
  frag_color = vec4(gl_FragCoord.z, 0.0, 0.0, 1.0);
}

''',
    'Particle': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// Particles, as a procedural round sprite. The body, and why it has no
// sampler, is `lib/particle.glsl`; `particle_soft.frag` is the same body
// faded against the scene's depth.

// --- lib/particle.glsl ---
// Particles, as a procedural round sprite: the body of `lighting/particle.frag`
// and, with `F3D_SOFT_PARTICLE`, of `lighting/particle_soft.frag`.
//
// No texture, and that is a decision rather than a placeholder. A sampler here
// would be one more slot to bind correctly, and this engine's most expensive
// recurring bug is binding a texture a compiled shader has no room for — the
// crash is native and carries no Dart stack. A smooth falloff computed from the
// quad's own coordinates costs a length and a smoothstep, needs no asset, and
// scales to any resolution without a mip chain, which this channel cannot
// produce anyway.
//
// The alpha is folded into the colour instead of being blended with it. These
// are drawn additively, where the destination is only ever added to: a spark
// brightens what is behind it and a faded spark adds nothing. That is also why
// they need no sorting — addition does not care about order, which is the whole
// reason additive is the right mode for fire and sparks and the wrong one for
// smoke.
//
// The soft variant is the one exception to "no sampler", and it is a stage of
// its own for the reason above: it reads the scene's depth, and only a
// contributor that was handed one picks it.

// --- lib/particle_soft.glsl ---
// Soft particles: a sprite that fades as it nears the opaque scene behind it.
//
// **What this removes is a seam.** A particle is a flat quad, depth-tested and
// never depth-written, so where it passes through a floor or a wall the test
// cuts it along the line the two planes meet — a hard straight edge across a
// puff of smoke that has no edges anywhere else. Lorach's fix ("Soft
// Particles", 2007) scales the particle by how far the scene lies behind it:
//
//   fade = saturate((sceneDepth - particleDepth) / softness)
//
// so a fragment a softness or more in front of the scene is untouched, one
// touching it is gone, and the line becomes a ramp.
//
// Included by every particle stage, and empty unless the stage defines
// `F3D_SOFT_PARTICLE`: the soft stages are stages of their own, picked only by
// a contributor that was handed the scene's depth, so the ones every recorded
// frame goes through declare nothing new — no sampler to leave unbound, which
// on Metal is a native crash.

#ifndef PARTICLE_SOFT_GLSL_
#define PARTICLE_SOFT_GLSL_

#ifdef F3D_SOFT_PARTICLE

// --- lib/frag_coord.glsl ---
// Where a fragment sits, counted from the top of its target on every backend.

#ifndef FRAG_COORD_GLSL_
#define FRAG_COORD_GLSL_

/// `gl_FragCoord.xy` with row zero at the top of the picture.
///
/// [rows] is the target's height where the backend's row zero is the bottom
/// of the picture, and zero where it is the top. WebGL2 is the first kind:
/// window coordinates start at the lower left, and the engine draws the
/// picture upright there rather than mirroring every projection. Metal,
/// WebGPU and the software rasteriser are the second.
///
/// **Why a pattern cares and a picture does not.** Every screen-space pattern
/// in the engine — the Bayer dither, the grain, the jitter a ray march starts
/// from, the rotation of a shadow kernel — is a function of the pixel's row.
/// Read from the bottom, the same frame gets the pattern turned upside down,
/// and a four-row Bayer cell lands on different rows unless the height is a
/// multiple of four. The picture underneath is identical; the pattern on top
/// of it is not, and a comparison across backends counts every pixel it
/// moved.
vec2 FragCoordFromTop(float rows) {
  return rows > 0.0 ? vec2(gl_FragCoord.x, rows - gl_FragCoord.y)
                    : gl_FragCoord.xy;
}

#endif  // FRAG_COORD_GLSL_


/// The surface buffer: in `a`, the opaque scene's depth along the view axis,
/// in metres, and zero where nothing was drawn. Read with nearest filtering.
uniform sampler2D scene_depth_texture;

layout(std140) uniform SoftParticleInfo {
  /// xy: one over the target's size. z: the target's height where its row
  /// zero is the bottom, nought where it is the top — see `FragCoordFromTop`.
  /// w: one over the softness, in metres.
  vec4 target;

  /// xyz: the camera position in world space.
  vec4 eye;

  /// xyz: the direction the camera looks, a unit vector — the axis the
  /// surface buffer measures its depths along.
  vec4 forward;
}
soft_particle_info;

/// How much of a particle fragment at [world] is left once it nears the
/// scene: one a softness in front of it or further, nought at it.
///
/// One where nothing was drawn behind — the sky is infinitely far — and a
/// select rather than an early return, which a phi of constants would make
/// SPIRV-Cross refuse. `textureLod` for WGSL, which will not take an implicit
/// level where the caller's control flow may not be uniform.
float SoftParticleFade(vec3 world) {
  vec2 uv = FragCoordFromTop(soft_particle_info.target.z) *
            soft_particle_info.target.xy;
  float stored = textureLod(scene_depth_texture, uv, 0.0).a;
  float depth = dot(world - soft_particle_info.eye.xyz,
                    soft_particle_info.forward.xyz);
  float fade =
      clamp((stored - depth) * soft_particle_info.target.w, 0.0, 1.0);
  return stored > 0.0 ? fade : 1.0;
}

#endif  // F3D_SOFT_PARTICLE

#endif  // PARTICLE_SOFT_GLSL_


in vec4 v_color;
in vec2 v_uv;
in vec3 v_world_position;

layout(location = 0) out vec4 frag_color;

/// The lit shaders' block, declared again because this shader shares none of
/// their headers — it has a different vertex layout and none of their varyings.
///
/// **The first two members of it, not all three.** `color.glsl` carries a
/// `forward` beside these, for the view axis the surface buffer measures its
/// depths along; a particle writes no surface buffer, so it neither declares
/// that member nor is bound one. The two blocks share a name and not a shape,
/// which is fine — they belong to different programs — and a check that binds
/// this one has to bind what it declares.
layout(std140) uniform FogInfo {
  /// rgb: linear fog colour. w: density per metre, zero for no fog.
  vec4 fog;

  /// xyz: camera position in world space.
  vec4 eye;
}
fog_info;

void main() {
  // Distance from the middle of the quad, where the corners sit at 1.
  vec2 centred = v_uv * 2.0 - 1.0;
  float radius = length(centred);

  // Soft edge, and a brighter core: a flat disc reads as a paper cut-out, and
  // the falloff is what makes a cluster of these look like light rather than
  // like confetti.
  float falloff = 1.0 - smoothstep(0.0, 1.0, radius);
  float intensity = falloff * falloff;

  // Fog on an additive particle is attenuation, not a mix. Blending toward
  // the fog colour would make a distant flame *add* fog to the wall behind it
  // and come out brighter than the wall it is supposed to be fading into;
  // multiplying toward zero is what "further away contributes less" means when
  // the destination is only ever added to.
  float fogged = 1.0;
  if (fog_info.fog.w > 0.0) {
    fogged = clamp(
        exp(-fog_info.fog.w * distance(v_world_position, fog_info.eye.xyz)),
        0.0,
        1.0);
  }

#ifdef F3D_SOFT_PARTICLE
  intensity *= SoftParticleFade(v_world_position);
#endif

  frag_color = vec4(v_color.rgb * v_color.a * intensity * fogged, 1.0);
}


''',
    'ParticleTextured': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// Particles with a texture. The body is `lib/particle_textured.glsl`;
// `particle_textured_soft.frag` is the same body faded against the scene's
// depth.

// --- lib/particle_textured.glsl ---
// Particles with a texture, beside the procedural one rather than replacing it:
// the body of `lighting/particle_textured.frag` and, with `F3D_SOFT_PARTICLE`,
// of `lighting/particle_textured_soft.frag`.
//
// `lighting/particle.frag` computes a round falloff from the quad's own
// coordinates and has no sampler at all. Its comment says why, and the reason
// has not expired: "this engine's most expensive recurring bug is binding a
// texture a compiled shader has no room for — the crash is native and carries
// no Dart stack". A stage with a sampler and a stage without are two stages,
// and a contributor picks between them by whether it was given a texture.
//
// What the procedural one cannot do is be a *shape*: smoke needs an edge that
// is not a circle, a flipbook needs frames, and an ember needs to look like
// something burnt rather than like a dot. That is what this is for.
//
// The same vertex stage feeds both — `particle.vert` already carries `v_uv`
// across, which the procedural stage uses for its radius and this one uses as a
// texture coordinate.

// --- lib/particle_soft.glsl ---
// Soft particles: a sprite that fades as it nears the opaque scene behind it.
//
// **What this removes is a seam.** A particle is a flat quad, depth-tested and
// never depth-written, so where it passes through a floor or a wall the test
// cuts it along the line the two planes meet — a hard straight edge across a
// puff of smoke that has no edges anywhere else. Lorach's fix ("Soft
// Particles", 2007) scales the particle by how far the scene lies behind it:
//
//   fade = saturate((sceneDepth - particleDepth) / softness)
//
// so a fragment a softness or more in front of the scene is untouched, one
// touching it is gone, and the line becomes a ramp.
//
// Included by every particle stage, and empty unless the stage defines
// `F3D_SOFT_PARTICLE`: the soft stages are stages of their own, picked only by
// a contributor that was handed the scene's depth, so the ones every recorded
// frame goes through declare nothing new — no sampler to leave unbound, which
// on Metal is a native crash.

#ifndef PARTICLE_SOFT_GLSL_
#define PARTICLE_SOFT_GLSL_

#ifdef F3D_SOFT_PARTICLE

// --- lib/frag_coord.glsl ---
// Where a fragment sits, counted from the top of its target on every backend.

#ifndef FRAG_COORD_GLSL_
#define FRAG_COORD_GLSL_

/// `gl_FragCoord.xy` with row zero at the top of the picture.
///
/// [rows] is the target's height where the backend's row zero is the bottom
/// of the picture, and zero where it is the top. WebGL2 is the first kind:
/// window coordinates start at the lower left, and the engine draws the
/// picture upright there rather than mirroring every projection. Metal,
/// WebGPU and the software rasteriser are the second.
///
/// **Why a pattern cares and a picture does not.** Every screen-space pattern
/// in the engine — the Bayer dither, the grain, the jitter a ray march starts
/// from, the rotation of a shadow kernel — is a function of the pixel's row.
/// Read from the bottom, the same frame gets the pattern turned upside down,
/// and a four-row Bayer cell lands on different rows unless the height is a
/// multiple of four. The picture underneath is identical; the pattern on top
/// of it is not, and a comparison across backends counts every pixel it
/// moved.
vec2 FragCoordFromTop(float rows) {
  return rows > 0.0 ? vec2(gl_FragCoord.x, rows - gl_FragCoord.y)
                    : gl_FragCoord.xy;
}

#endif  // FRAG_COORD_GLSL_


/// The surface buffer: in `a`, the opaque scene's depth along the view axis,
/// in metres, and zero where nothing was drawn. Read with nearest filtering.
uniform sampler2D scene_depth_texture;

layout(std140) uniform SoftParticleInfo {
  /// xy: one over the target's size. z: the target's height where its row
  /// zero is the bottom, nought where it is the top — see `FragCoordFromTop`.
  /// w: one over the softness, in metres.
  vec4 target;

  /// xyz: the camera position in world space.
  vec4 eye;

  /// xyz: the direction the camera looks, a unit vector — the axis the
  /// surface buffer measures its depths along.
  vec4 forward;
}
soft_particle_info;

/// How much of a particle fragment at [world] is left once it nears the
/// scene: one a softness in front of it or further, nought at it.
///
/// One where nothing was drawn behind — the sky is infinitely far — and a
/// select rather than an early return, which a phi of constants would make
/// SPIRV-Cross refuse. `textureLod` for WGSL, which will not take an implicit
/// level where the caller's control flow may not be uniform.
float SoftParticleFade(vec3 world) {
  vec2 uv = FragCoordFromTop(soft_particle_info.target.z) *
            soft_particle_info.target.xy;
  float stored = textureLod(scene_depth_texture, uv, 0.0).a;
  float depth = dot(world - soft_particle_info.eye.xyz,
                    soft_particle_info.forward.xyz);
  float fade =
      clamp((stored - depth) * soft_particle_info.target.w, 0.0, 1.0);
  return stored > 0.0 ? fade : 1.0;
}

#endif  // F3D_SOFT_PARTICLE

#endif  // PARTICLE_SOFT_GLSL_


in vec4 v_color;
in vec2 v_uv;
in vec3 v_world_position;

layout(location = 0) out vec4 frag_color;

uniform sampler2D particle_texture;

/// Declared again for the same reason the other particle stages declare it:
/// this shader shares none of the lit path's headers.
layout(std140) uniform FogInfo {
  /// rgb: linear fog colour. w: density per metre, zero for no fog.
  vec4 fog;

  /// xyz: camera position in world space.
  vec4 eye;
}
fog_info;

void main() {
  // `texture`, not `textureLod`. The level is chosen from the derivative the
  // hardware computes for this fragment, which is the whole point of building
  // the chain — and it is the one place the software backend cannot follow
  // exactly, since it has no neighbouring fragments to difference. See
  // `BoundTexture.sample`.
  vec4 texel = texture(particle_texture, v_uv);

  // Attenuation rather than a mix. Blending an additive particle toward the
  // fog colour makes a distant one *add* fog to the wall behind it — the same
  // note as the other two particle stages, kept because each is read alone.
  float fogged = 1.0;
  if (fog_info.fog.w > 0.0) {
    fogged = clamp(
        exp(-fog_info.fog.w * distance(v_world_position, fog_info.eye.xyz)),
        0.0,
        1.0);
  }

  // The texture's alpha is coverage and the particle's is brightness, so the
  // two multiply rather than one replacing the other: a faded spark of a
  // half-transparent sprite contributes a quarter, which is what additive
  // blending means by both of those at once.
  float scale = v_color.a * texel.a * fogged;
#ifdef F3D_SOFT_PARTICLE
  scale *= SoftParticleFade(v_world_position);
#endif
  frag_color = vec4(v_color.rgb * texel.rgb * scale, 1.0);
}


''',
    'ParticleSixWay': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// Particles lit from six directions — `N6`. The body is
// `lib/particle_six_way.glsl`; `particle_six_way_soft.frag` is the same body
// faded against the scene's depth.

// --- lib/particle_six_way.glsl ---
// Particles lit from six directions — `N6`: the body of
// `lighting/particle_six_way.frag` and, with `F3D_SOFT_PARTICLE`, of
// `lighting/particle_six_way_soft.frag`.
//
// Smoke is the one effect additive blending cannot draw: it is dark where it
// is thick and lit where a light reaches into it, and addition can only ever
// brighten. So this stage blends over what is behind it, and lights each
// fragment by the scene's lights through six pictures of the same puff, each
// rendered with a light from one side. Mixing those by where a light really is
// gives the self-shadowing a volume would have, at the cost of two texture
// reads: a light low on the right brightens the lower right rim and leaves the
// far side in the puff's own shade.
//
// **Its own stage beside `particle_textured.frag`, not a branch inside it.**
// The textured stage has one sampler and one block, and every recorded frame
// with a sprite in it goes through it; a six-way branch there would declare
// three samplers and two blocks more that every sprite then has to be bound.
// `ParticleContributor` picks this one when it is handed a six-way material,
// the way it already picks between the sprite and the procedural disc.
//
// ## The layout
//
// Two textures, the channels as the engine's baker writes them and the
// EmberGen and Houdini six-way exports lay them out:
//
//  * `six_way_positive` — r: lit from the right, g: from the top, b: from the
//    back, a: coverage.
//  * `six_way_negative` — r: lit from the left, g: from the bottom, b: from
//    the front, a: emission.
//
// "Back" is the far side of the puff from the viewer, so a light behind smoke
// shows through its thin edges; "front" is the viewer's side. Right and top are
// the quad's own: top is the way a cell's texture coordinate rises, which
// `ParticleSystem.writeQuads` points along the camera's up.
//
// The responses are unpremultiplied — light as it would read at full coverage
// — and the blend is premultiplied, so this multiplies by the coverage once.

// --- lib/contributor_lights.glsl ---
// The scene's lights, for a stage a contributor draws rather than a surface —
// `N6`.
//
// A surface reads its lights out of `FragInfo`, a block that also carries a
// material, three shadow cascades and an environment: close to six kilobytes a
// stage cannot afford to declare for the sake of four arrays. This is those
// four arrays alone, in the order `FragInfo` holds them, plus the light list
// and its clusters from `lib/light_list.glsl`, which is the same one the lit
// models read. `ContributorLights.bind` on the Dart side writes both, from the
// same selection a mesh of the same bounds would be given.
//
// **No shadows, and no rectangle integral.** A particle is a translucent
// sprite: sampling a shadow map at a point inside a cloud of smoke answers a
// question about an opaque surface that is not there. A rectangular light is
// read as a point at its centre with the inverse square, which is the right
// answer at the distances a puff of smoke is from a window and the wrong one
// only close enough to touch it.

#ifndef CONTRIBUTOR_LIGHTS_GLSL_
#define CONTRIBUTOR_LIGHTS_GLSL_

// --- lib/light_list.glsl ---
// The frame's light list, and how a fragment finds its tail in it — `gfx-74n`
// and `L6`.
//
// Split out of `surface.glsl` so a stage that is not a surface can read the
// same lights: `N6`'s six-way particles light each fragment by the list the
// lit models read, clusters and all, without declaring `FragInfo`. The text is
// the one that stood in `surface.glsl`, moved rather than copied, so the lit
// models compile to what they compiled to before.

#ifndef LIGHT_LIST_GLSL_
#define LIGHT_LIST_GLSL_
/// Every light in the scene, one per row, four texels across — `gfx-74n`.
///
/// **A texture rather than a wider uniform block, and that is the design.**
/// `FragInfo` is uploaded on every draw, so widening its four `vec4` arrays to
/// hold thirty-two lights would be a two-kilobyte upload per draw in every
/// scene, including every scene with one light. This is built once a frame and
/// only when a scene has more lights than a draw can hold in its slots.
///
/// Row layout, which `renderer_light_list.dart` writes and only this reads:
///
///  * texel 0 — xyz world position, w type (0 directional, 1 point, 2 spot)
///  * texel 1 — rgb linear colour, w intensity
///  * texel 2 — xyz the direction it points, w range
///  * texel 3 — x cos(inner), y cos(outer), zw unused
///
/// The same four vectors the uniform arrays hold, in the same order, so one
/// reader serves both.
///
/// **`F3D_NO_LIGHT_LIST` leaves both out**, for a model that accumulates no
/// lights. Such a model never reaches the reader below, so the compiler drops
/// the block and the sampler from the Metal function while reflection still
/// lists them, with no buffer or texture index assigned. The renderer used to
/// bind them for every draw, Unlit included, and that bind is a crash inside
/// `setFragmentBuffer:offset:atIndex:` on Metal. Vulkan took the same draw
/// without a word, which is how 0.7.0 shipped with it.
#ifndef F3D_NO_LIGHT_LIST
uniform sampler2D light_list_texture;

layout(std140) uniform LightListInfo {
  /// x: how many rows this draw reads, zero when it reads none.
  /// y, z: one over the texture's width and height.
  /// w: unused.
  vec4 list;

  /// Which rows, four to a vector, in the order they are read.
  ///
  /// Indices rather than the light data itself: the data is the same for every
  /// draw in the frame and belongs in the texture; what differs per draw is
  /// *which* of them reach it, and that is what `Renderer._drawLightsFor`
  /// already decides.
  vec4 indices[6];

  /// How much of each of those survives the edge fade, in the same order.
  ///
  /// Per draw and not in the texture, because the row an index points at is
  /// shared by every draw in the frame: a scale written into it would dim that
  /// light for all of them. `gfx-12n`'s fade lives at the end of the list now —
  /// that is where a light stops contributing, and fading the slots against a
  /// water line that no longer marks a cliff would dim a light for no reason
  /// while its rival stayed bright, making the swap more visible rather than
  /// less.
  vec4 scales[6];

  /// `L6`: the view-projection the light clusters were cut with, so this
  /// finds a fragment's cell the way `LightClusters.clusterOf` does.
  mat4 cluster_view_projection;

  /// xyz: tiles across, tiles up, slices deep. w: one when this draw reads
  /// its tail from the cell it is in rather than from `indices`.
  vec4 cluster_grid;

  /// x: where slices begin, in clip w. y: slices per unit of `ln(w / x)`.
  /// z: the texture row the cells' headers start at, four to a row, each
  /// (offset, count). w: the row their entries start at, sixteen to a row.
  vec4 cluster_depth;

  /// Which rows this draw already holds in its eight slots, minus one for
  /// an empty slot. A cell lists every light that reaches it, and one the
  /// slots already carry must not be counted again.
  vec4 slot_rows[2];
}
light_list_info;

/// One lane of a six-vector table, [slot] counting from nought.
float LightListLane(vec4 four, int slot) {
  int lane = slot - (slot / 4) * 4;
  return lane == 0 ? four.x : lane == 1 ? four.y : lane == 2 ? four.z : four.w;
}

/// The row light [slot] of the list reads.
float LightListRow(int slot) {
  return LightListLane(light_list_info.indices[slot / 4], slot);
}

/// How much of light [slot] of the list survives the edge fade.
float LightListScale(int slot) {
  return LightListLane(light_list_info.scales[slot / 4], slot);
}

/// The cell this fragment falls in, as `LightClusters` wrote it: where its
/// entries start and how many there are. Found once, in [LightCount], and
/// read by every [SampleLight] of the loop that follows.
float g_cluster_offset = 0.0;
float g_cluster_count = 0.0;

bool Clustered() { return light_list_info.cluster_grid.w > 0.5; }

/// One texel of the light list texture, [texel] across and [row] down.
vec4 LightListTexel(float texel, float row) {
  return textureLod(light_list_texture,
                    vec2((texel + 0.5) * light_list_info.list.y,
                         (row + 0.5) * light_list_info.list.z),
                    0.0);
}

void FindCluster(vec3 world) {
  vec4 clip = light_list_info.cluster_view_projection * vec4(world, 1.0);
  vec2 ndc = clip.xy / max(clip.w, 1e-6);
  vec3 grid = light_list_info.cluster_grid.xyz;
  float near = light_list_info.cluster_depth.x;
  float tx = clamp(floor((ndc.x * 0.5 + 0.5) * grid.x), 0.0, grid.x - 1.0);
  float ty = clamp(floor((ndc.y * 0.5 + 0.5) * grid.y), 0.0, grid.y - 1.0);
  float tz = clip.w <= near
                 ? 0.0
                 : clamp(floor(log(clip.w / near) *
                               light_list_info.cluster_depth.y),
                         0.0, grid.z - 1.0);
  float cell = tx + ty * grid.x + tz * grid.x * grid.y;
  float row = floor(cell / 4.0);
  vec4 header =
      LightListTexel(cell - row * 4.0, light_list_info.cluster_depth.z + row);
  g_cluster_offset = header.x;
  g_cluster_count = header.y;
}

/// The row entry [slot] of this fragment's cell names.
float ClusterRow(int slot) {
  float entry = g_cluster_offset + float(slot);
  float row = floor(entry / 16.0);
  float within = entry - row * 16.0;
  float texel = floor(within / 4.0);
  vec4 four = LightListTexel(texel, light_list_info.cluster_depth.w + row);
  return LightListLane(four, int(within - texel * 4.0 + 0.5));
}

/// Whether one of the draw's slots already holds light list row [row].
bool InSlots(float row) {
  vec4 a = abs(light_list_info.slot_rows[0] - vec4(row));
  vec4 b = abs(light_list_info.slot_rows[1] - vec4(row));
  return min(min(min(a.x, a.y), min(a.z, a.w)), min(min(b.x, b.y), min(b.z, b.w))) < 0.5;
}
#endif  // F3D_NO_LIGHT_LIST

#endif  // LIGHT_LIST_GLSL_


/// The slots a draw is handed, and the tail it may read past them. The same
/// eight and twenty-four as `kMaxLights` and `kExtraLights` in `surface.glsl`,
/// named apart so a stage may include both headers.
#define kContributorSlots 8
#define kContributorTail 24
#define kContributorLights (kContributorSlots + kContributorTail)

layout(std140) uniform ContributorLightInfo {
  /// xyz: world position. w: type, 0 directional 1 point 2 spot 3 rectangle.
  vec4 light_position[kContributorSlots];

  /// rgb: linear colour. w: intensity.
  vec4 light_color[kContributorSlots];

  /// xyz: the direction the light points. w: range, 0 unbounded.
  vec4 light_direction[kContributorSlots];

  /// x: cos(inner cone angle). y: cos(outer cone angle).
  vec4 light_cone[kContributorSlots];

  /// x: how many of the slots hold a light. yzw unused.
  vec4 slots;
}
contributor_light_info;

/// How many lights reach [world]: the draw's slots and its tail, or the
/// cell's tail when the view is clustered.
int ContributorLightCount(vec3 world) {
  float tail = light_list_info.list.x;
  if (Clustered()) {
    FindCluster(world);
    tail = g_cluster_count;
  }
  return clamp(int(contributor_light_info.slots.x + 0.5), 0,
               kContributorSlots) +
         clamp(int(tail + 0.5), 0, kContributorTail);
}

/// Light [index] as [world] receives it: [toLight] the unit direction towards
/// it, and [radiance] what arrives, zero for a light that does not reach.
///
/// Selects rather than early returns, for SPIR-V Cross's sake: a function that
/// returns a constant from two branches becomes a phi of constants it refuses.
void ContributorLight(int index, vec3 world, out vec3 toLight,
                      out vec3 radiance) {
  vec4 position;
  vec4 color;
  vec4 direction;
  vec4 cone;
  if (index < kContributorSlots) {
    position = contributor_light_info.light_position[index];
    color = contributor_light_info.light_color[index];
    direction = contributor_light_info.light_direction[index];
    cone = contributor_light_info.light_cone[index];
  } else {
    // The list's row, read the way `SampleLight` reads it: from the cell when
    // the view is clustered, skipping a light the slots already hold.
    int slot = index - kContributorSlots;
    bool clustered = Clustered();
    float listRow = clustered ? ClusterRow(slot) : LightListRow(slot);
    float v = (listRow + 0.5) * light_list_info.list.z;
    float u = light_list_info.list.y;
    position = textureLod(light_list_texture, vec2(0.5 * u, v), 0.0);
    color = textureLod(light_list_texture, vec2(1.5 * u, v), 0.0);
    direction = textureLod(light_list_texture, vec2(2.5 * u, v), 0.0);
    cone = textureLod(light_list_texture, vec2(3.5 * u, v), 0.0);
    color.w *= clustered ? (InSlots(listRow) ? 0.0 : 1.0)
                         : LightListScale(slot);
  }

  float type = position.w;
  bool directional = type < 0.5;
  vec3 offset = position.xyz - world;
  float distance = length(offset);
  vec3 aim = normalize(direction.xyz);

  // A light exactly at the point has no direction; it contributes nothing
  // rather than a NaN that spreads through the blend.
  bool degenerate = !directional && distance < 1e-6;
  toLight = directional ? -aim : offset / max(distance, 1e-6);

  // The glTF window, as `PunctualAttenuation` has it.
  float ratio = direction.w > 0.0 ? distance / direction.w : 0.0;
  float window = clamp(1.0 - ratio * ratio * ratio * ratio, 0.0, 1.0);
  float falloff = window * window / max(distance * distance, 1e-4);

  // A spot's ramp between its two cone cosines. Only a spot's direction is an
  // aim; a rectangle's is the edge of its panel.
  bool spot = type > 1.5 && type < 2.5;
  float ramp = spot ? clamp((dot(aim, -toLight) - cone.y) /
                                max(cone.x - cone.y, 1e-4),
                            0.0, 1.0)
                    : 1.0;

  float attenuation = directional ? 1.0 : (degenerate ? 0.0 : falloff * ramp);
  radiance = color.rgb * color.w * attenuation;
}

#endif  // CONTRIBUTOR_LIGHTS_GLSL_

// --- lib/particle_soft.glsl ---
// Soft particles: a sprite that fades as it nears the opaque scene behind it.
//
// **What this removes is a seam.** A particle is a flat quad, depth-tested and
// never depth-written, so where it passes through a floor or a wall the test
// cuts it along the line the two planes meet — a hard straight edge across a
// puff of smoke that has no edges anywhere else. Lorach's fix ("Soft
// Particles", 2007) scales the particle by how far the scene lies behind it:
//
//   fade = saturate((sceneDepth - particleDepth) / softness)
//
// so a fragment a softness or more in front of the scene is untouched, one
// touching it is gone, and the line becomes a ramp.
//
// Included by every particle stage, and empty unless the stage defines
// `F3D_SOFT_PARTICLE`: the soft stages are stages of their own, picked only by
// a contributor that was handed the scene's depth, so the ones every recorded
// frame goes through declare nothing new — no sampler to leave unbound, which
// on Metal is a native crash.

#ifndef PARTICLE_SOFT_GLSL_
#define PARTICLE_SOFT_GLSL_

#ifdef F3D_SOFT_PARTICLE

// --- lib/frag_coord.glsl ---
// Where a fragment sits, counted from the top of its target on every backend.

#ifndef FRAG_COORD_GLSL_
#define FRAG_COORD_GLSL_

/// `gl_FragCoord.xy` with row zero at the top of the picture.
///
/// [rows] is the target's height where the backend's row zero is the bottom
/// of the picture, and zero where it is the top. WebGL2 is the first kind:
/// window coordinates start at the lower left, and the engine draws the
/// picture upright there rather than mirroring every projection. Metal,
/// WebGPU and the software rasteriser are the second.
///
/// **Why a pattern cares and a picture does not.** Every screen-space pattern
/// in the engine — the Bayer dither, the grain, the jitter a ray march starts
/// from, the rotation of a shadow kernel — is a function of the pixel's row.
/// Read from the bottom, the same frame gets the pattern turned upside down,
/// and a four-row Bayer cell lands on different rows unless the height is a
/// multiple of four. The picture underneath is identical; the pattern on top
/// of it is not, and a comparison across backends counts every pixel it
/// moved.
vec2 FragCoordFromTop(float rows) {
  return rows > 0.0 ? vec2(gl_FragCoord.x, rows - gl_FragCoord.y)
                    : gl_FragCoord.xy;
}

#endif  // FRAG_COORD_GLSL_


/// The surface buffer: in `a`, the opaque scene's depth along the view axis,
/// in metres, and zero where nothing was drawn. Read with nearest filtering.
uniform sampler2D scene_depth_texture;

layout(std140) uniform SoftParticleInfo {
  /// xy: one over the target's size. z: the target's height where its row
  /// zero is the bottom, nought where it is the top — see `FragCoordFromTop`.
  /// w: one over the softness, in metres.
  vec4 target;

  /// xyz: the camera position in world space.
  vec4 eye;

  /// xyz: the direction the camera looks, a unit vector — the axis the
  /// surface buffer measures its depths along.
  vec4 forward;
}
soft_particle_info;

/// How much of a particle fragment at [world] is left once it nears the
/// scene: one a softness in front of it or further, nought at it.
///
/// One where nothing was drawn behind — the sky is infinitely far — and a
/// select rather than an early return, which a phi of constants would make
/// SPIRV-Cross refuse. `textureLod` for WGSL, which will not take an implicit
/// level where the caller's control flow may not be uniform.
float SoftParticleFade(vec3 world) {
  vec2 uv = FragCoordFromTop(soft_particle_info.target.z) *
            soft_particle_info.target.xy;
  float stored = textureLod(scene_depth_texture, uv, 0.0).a;
  float depth = dot(world - soft_particle_info.eye.xyz,
                    soft_particle_info.forward.xyz);
  float fade =
      clamp((stored - depth) * soft_particle_info.target.w, 0.0, 1.0);
  return stored > 0.0 ? fade : 1.0;
}

#endif  // F3D_SOFT_PARTICLE

#endif  // PARTICLE_SOFT_GLSL_


in vec4 v_color;
in vec2 v_uv;
in vec3 v_world_position;

layout(location = 0) out vec4 frag_color;

uniform sampler2D six_way_positive;
uniform sampler2D six_way_negative;

/// Declared again, as in the other particle stages, since this shares none of
/// the lit path's headers.
layout(std140) uniform FogInfo {
  /// rgb: linear fog colour. w: density per metre, zero for no fog.
  vec4 fog;

  /// xyz: camera position in world space.
  vec4 eye;
}
fog_info;

layout(std140) uniform SixWayInfo {
  /// xyz: the quad's right, which is the camera's, in world space.
  vec4 right;

  /// xyz: the quad's up, the way a cell's v rises.
  vec4 up;

  /// xyz: away from the viewer, which is where "back" is.
  vec4 forward;

  /// rgb: what the emission channel's full value emits, linear. w unused.
  vec4 emission;

  /// rgb: light arriving evenly from every side. w unused.
  vec4 ambient;
}
six_way_info;

/// How much of a light arriving from [l] the puff sends to the viewer.
///
/// The squared components of a unit direction sum to one, so they are weights:
/// a light straight to the right reads the right picture alone, and one up and
/// to the right reads half of each. The sign picks which of a pair.
float SixWayResponse(vec3 l, vec3 positive, vec3 negative) {
  float x = dot(l, six_way_info.right.xyz);
  float y = dot(l, six_way_info.up.xyz);
  float z = dot(l, six_way_info.forward.xyz);
  return x * x * (x > 0.0 ? positive.r : negative.r) +
         y * y * (y > 0.0 ? positive.g : negative.g) +
         z * z * (z > 0.0 ? positive.b : negative.b);
}

void main() {
  // `texture`, as the sprite stage has it: the level comes from the
  // footprint, and a receding puff wants its chain.
  vec4 positive = texture(six_way_positive, v_uv);
  vec4 negative = texture(six_way_negative, v_uv);

  vec3 lit = vec3(0.0);
  int count = ContributorLightCount(v_world_position);
  for (int i = 0; i < kContributorLights; i++) {
    if (i >= count) break;
    vec3 l;
    vec3 radiance;
    ContributorLight(i, v_world_position, l, radiance);
    lit += radiance * SixWayResponse(l, positive.rgb, negative.rgb);
  }

  // Light from every side at once reads each picture for a sixth of the
  // sphere, so the ambient term is their mean.
  float mean = (positive.r + positive.g + positive.b + negative.r +
                negative.g + negative.b) /
               6.0;
  vec3 color = v_color.rgb * (lit + six_way_info.ambient.rgb * mean) +
               six_way_info.emission.rgb * negative.a;

  // A mix toward the fog, unlike the additive stages: this one covers what is
  // behind it, and covered smoke far away should read as the fog does.
  float fogged = 1.0;
  if (fog_info.fog.w > 0.0) {
    fogged = clamp(
        exp(-fog_info.fog.w * distance(v_world_position, fog_info.eye.xyz)),
        0.0,
        1.0);
  }
  color = mix(fog_info.fog.rgb, color, fogged);

  float coverage = clamp(v_color.a * positive.a, 0.0, 1.0);
#ifdef F3D_SOFT_PARTICLE
  // Coverage, not colour: the blend is premultiplied, so a puff fading into
  // the floor has to let the floor through as well as stop covering it.
  coverage *= SoftParticleFade(v_world_position);
#endif
  frag_color = vec4(color * coverage, coverage);
}


''',
    'ParticleSoft': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// `particle.frag`, faded where it nears the opaque scene — soft particles.
// See `lib/particle_soft.glsl`.

#define F3D_SOFT_PARTICLE
// --- lib/particle.glsl ---
// Particles, as a procedural round sprite: the body of `lighting/particle.frag`
// and, with `F3D_SOFT_PARTICLE`, of `lighting/particle_soft.frag`.
//
// No texture, and that is a decision rather than a placeholder. A sampler here
// would be one more slot to bind correctly, and this engine's most expensive
// recurring bug is binding a texture a compiled shader has no room for — the
// crash is native and carries no Dart stack. A smooth falloff computed from the
// quad's own coordinates costs a length and a smoothstep, needs no asset, and
// scales to any resolution without a mip chain, which this channel cannot
// produce anyway.
//
// The alpha is folded into the colour instead of being blended with it. These
// are drawn additively, where the destination is only ever added to: a spark
// brightens what is behind it and a faded spark adds nothing. That is also why
// they need no sorting — addition does not care about order, which is the whole
// reason additive is the right mode for fire and sparks and the wrong one for
// smoke.
//
// The soft variant is the one exception to "no sampler", and it is a stage of
// its own for the reason above: it reads the scene's depth, and only a
// contributor that was handed one picks it.

// --- lib/particle_soft.glsl ---
// Soft particles: a sprite that fades as it nears the opaque scene behind it.
//
// **What this removes is a seam.** A particle is a flat quad, depth-tested and
// never depth-written, so where it passes through a floor or a wall the test
// cuts it along the line the two planes meet — a hard straight edge across a
// puff of smoke that has no edges anywhere else. Lorach's fix ("Soft
// Particles", 2007) scales the particle by how far the scene lies behind it:
//
//   fade = saturate((sceneDepth - particleDepth) / softness)
//
// so a fragment a softness or more in front of the scene is untouched, one
// touching it is gone, and the line becomes a ramp.
//
// Included by every particle stage, and empty unless the stage defines
// `F3D_SOFT_PARTICLE`: the soft stages are stages of their own, picked only by
// a contributor that was handed the scene's depth, so the ones every recorded
// frame goes through declare nothing new — no sampler to leave unbound, which
// on Metal is a native crash.

#ifndef PARTICLE_SOFT_GLSL_
#define PARTICLE_SOFT_GLSL_

#ifdef F3D_SOFT_PARTICLE

// --- lib/frag_coord.glsl ---
// Where a fragment sits, counted from the top of its target on every backend.

#ifndef FRAG_COORD_GLSL_
#define FRAG_COORD_GLSL_

/// `gl_FragCoord.xy` with row zero at the top of the picture.
///
/// [rows] is the target's height where the backend's row zero is the bottom
/// of the picture, and zero where it is the top. WebGL2 is the first kind:
/// window coordinates start at the lower left, and the engine draws the
/// picture upright there rather than mirroring every projection. Metal,
/// WebGPU and the software rasteriser are the second.
///
/// **Why a pattern cares and a picture does not.** Every screen-space pattern
/// in the engine — the Bayer dither, the grain, the jitter a ray march starts
/// from, the rotation of a shadow kernel — is a function of the pixel's row.
/// Read from the bottom, the same frame gets the pattern turned upside down,
/// and a four-row Bayer cell lands on different rows unless the height is a
/// multiple of four. The picture underneath is identical; the pattern on top
/// of it is not, and a comparison across backends counts every pixel it
/// moved.
vec2 FragCoordFromTop(float rows) {
  return rows > 0.0 ? vec2(gl_FragCoord.x, rows - gl_FragCoord.y)
                    : gl_FragCoord.xy;
}

#endif  // FRAG_COORD_GLSL_


/// The surface buffer: in `a`, the opaque scene's depth along the view axis,
/// in metres, and zero where nothing was drawn. Read with nearest filtering.
uniform sampler2D scene_depth_texture;

layout(std140) uniform SoftParticleInfo {
  /// xy: one over the target's size. z: the target's height where its row
  /// zero is the bottom, nought where it is the top — see `FragCoordFromTop`.
  /// w: one over the softness, in metres.
  vec4 target;

  /// xyz: the camera position in world space.
  vec4 eye;

  /// xyz: the direction the camera looks, a unit vector — the axis the
  /// surface buffer measures its depths along.
  vec4 forward;
}
soft_particle_info;

/// How much of a particle fragment at [world] is left once it nears the
/// scene: one a softness in front of it or further, nought at it.
///
/// One where nothing was drawn behind — the sky is infinitely far — and a
/// select rather than an early return, which a phi of constants would make
/// SPIRV-Cross refuse. `textureLod` for WGSL, which will not take an implicit
/// level where the caller's control flow may not be uniform.
float SoftParticleFade(vec3 world) {
  vec2 uv = FragCoordFromTop(soft_particle_info.target.z) *
            soft_particle_info.target.xy;
  float stored = textureLod(scene_depth_texture, uv, 0.0).a;
  float depth = dot(world - soft_particle_info.eye.xyz,
                    soft_particle_info.forward.xyz);
  float fade =
      clamp((stored - depth) * soft_particle_info.target.w, 0.0, 1.0);
  return stored > 0.0 ? fade : 1.0;
}

#endif  // F3D_SOFT_PARTICLE

#endif  // PARTICLE_SOFT_GLSL_


in vec4 v_color;
in vec2 v_uv;
in vec3 v_world_position;

layout(location = 0) out vec4 frag_color;

/// The lit shaders' block, declared again because this shader shares none of
/// their headers — it has a different vertex layout and none of their varyings.
///
/// **The first two members of it, not all three.** `color.glsl` carries a
/// `forward` beside these, for the view axis the surface buffer measures its
/// depths along; a particle writes no surface buffer, so it neither declares
/// that member nor is bound one. The two blocks share a name and not a shape,
/// which is fine — they belong to different programs — and a check that binds
/// this one has to bind what it declares.
layout(std140) uniform FogInfo {
  /// rgb: linear fog colour. w: density per metre, zero for no fog.
  vec4 fog;

  /// xyz: camera position in world space.
  vec4 eye;
}
fog_info;

void main() {
  // Distance from the middle of the quad, where the corners sit at 1.
  vec2 centred = v_uv * 2.0 - 1.0;
  float radius = length(centred);

  // Soft edge, and a brighter core: a flat disc reads as a paper cut-out, and
  // the falloff is what makes a cluster of these look like light rather than
  // like confetti.
  float falloff = 1.0 - smoothstep(0.0, 1.0, radius);
  float intensity = falloff * falloff;

  // Fog on an additive particle is attenuation, not a mix. Blending toward
  // the fog colour would make a distant flame *add* fog to the wall behind it
  // and come out brighter than the wall it is supposed to be fading into;
  // multiplying toward zero is what "further away contributes less" means when
  // the destination is only ever added to.
  float fogged = 1.0;
  if (fog_info.fog.w > 0.0) {
    fogged = clamp(
        exp(-fog_info.fog.w * distance(v_world_position, fog_info.eye.xyz)),
        0.0,
        1.0);
  }

#ifdef F3D_SOFT_PARTICLE
  intensity *= SoftParticleFade(v_world_position);
#endif

  frag_color = vec4(v_color.rgb * v_color.a * intensity * fogged, 1.0);
}


''',
    'ParticleTexturedSoft': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// `particle_textured.frag`, faded where it nears the opaque scene — soft
// particles. See `lib/particle_soft.glsl`.

#define F3D_SOFT_PARTICLE
// --- lib/particle_textured.glsl ---
// Particles with a texture, beside the procedural one rather than replacing it:
// the body of `lighting/particle_textured.frag` and, with `F3D_SOFT_PARTICLE`,
// of `lighting/particle_textured_soft.frag`.
//
// `lighting/particle.frag` computes a round falloff from the quad's own
// coordinates and has no sampler at all. Its comment says why, and the reason
// has not expired: "this engine's most expensive recurring bug is binding a
// texture a compiled shader has no room for — the crash is native and carries
// no Dart stack". A stage with a sampler and a stage without are two stages,
// and a contributor picks between them by whether it was given a texture.
//
// What the procedural one cannot do is be a *shape*: smoke needs an edge that
// is not a circle, a flipbook needs frames, and an ember needs to look like
// something burnt rather than like a dot. That is what this is for.
//
// The same vertex stage feeds both — `particle.vert` already carries `v_uv`
// across, which the procedural stage uses for its radius and this one uses as a
// texture coordinate.

// --- lib/particle_soft.glsl ---
// Soft particles: a sprite that fades as it nears the opaque scene behind it.
//
// **What this removes is a seam.** A particle is a flat quad, depth-tested and
// never depth-written, so where it passes through a floor or a wall the test
// cuts it along the line the two planes meet — a hard straight edge across a
// puff of smoke that has no edges anywhere else. Lorach's fix ("Soft
// Particles", 2007) scales the particle by how far the scene lies behind it:
//
//   fade = saturate((sceneDepth - particleDepth) / softness)
//
// so a fragment a softness or more in front of the scene is untouched, one
// touching it is gone, and the line becomes a ramp.
//
// Included by every particle stage, and empty unless the stage defines
// `F3D_SOFT_PARTICLE`: the soft stages are stages of their own, picked only by
// a contributor that was handed the scene's depth, so the ones every recorded
// frame goes through declare nothing new — no sampler to leave unbound, which
// on Metal is a native crash.

#ifndef PARTICLE_SOFT_GLSL_
#define PARTICLE_SOFT_GLSL_

#ifdef F3D_SOFT_PARTICLE

// --- lib/frag_coord.glsl ---
// Where a fragment sits, counted from the top of its target on every backend.

#ifndef FRAG_COORD_GLSL_
#define FRAG_COORD_GLSL_

/// `gl_FragCoord.xy` with row zero at the top of the picture.
///
/// [rows] is the target's height where the backend's row zero is the bottom
/// of the picture, and zero where it is the top. WebGL2 is the first kind:
/// window coordinates start at the lower left, and the engine draws the
/// picture upright there rather than mirroring every projection. Metal,
/// WebGPU and the software rasteriser are the second.
///
/// **Why a pattern cares and a picture does not.** Every screen-space pattern
/// in the engine — the Bayer dither, the grain, the jitter a ray march starts
/// from, the rotation of a shadow kernel — is a function of the pixel's row.
/// Read from the bottom, the same frame gets the pattern turned upside down,
/// and a four-row Bayer cell lands on different rows unless the height is a
/// multiple of four. The picture underneath is identical; the pattern on top
/// of it is not, and a comparison across backends counts every pixel it
/// moved.
vec2 FragCoordFromTop(float rows) {
  return rows > 0.0 ? vec2(gl_FragCoord.x, rows - gl_FragCoord.y)
                    : gl_FragCoord.xy;
}

#endif  // FRAG_COORD_GLSL_


/// The surface buffer: in `a`, the opaque scene's depth along the view axis,
/// in metres, and zero where nothing was drawn. Read with nearest filtering.
uniform sampler2D scene_depth_texture;

layout(std140) uniform SoftParticleInfo {
  /// xy: one over the target's size. z: the target's height where its row
  /// zero is the bottom, nought where it is the top — see `FragCoordFromTop`.
  /// w: one over the softness, in metres.
  vec4 target;

  /// xyz: the camera position in world space.
  vec4 eye;

  /// xyz: the direction the camera looks, a unit vector — the axis the
  /// surface buffer measures its depths along.
  vec4 forward;
}
soft_particle_info;

/// How much of a particle fragment at [world] is left once it nears the
/// scene: one a softness in front of it or further, nought at it.
///
/// One where nothing was drawn behind — the sky is infinitely far — and a
/// select rather than an early return, which a phi of constants would make
/// SPIRV-Cross refuse. `textureLod` for WGSL, which will not take an implicit
/// level where the caller's control flow may not be uniform.
float SoftParticleFade(vec3 world) {
  vec2 uv = FragCoordFromTop(soft_particle_info.target.z) *
            soft_particle_info.target.xy;
  float stored = textureLod(scene_depth_texture, uv, 0.0).a;
  float depth = dot(world - soft_particle_info.eye.xyz,
                    soft_particle_info.forward.xyz);
  float fade =
      clamp((stored - depth) * soft_particle_info.target.w, 0.0, 1.0);
  return stored > 0.0 ? fade : 1.0;
}

#endif  // F3D_SOFT_PARTICLE

#endif  // PARTICLE_SOFT_GLSL_


in vec4 v_color;
in vec2 v_uv;
in vec3 v_world_position;

layout(location = 0) out vec4 frag_color;

uniform sampler2D particle_texture;

/// Declared again for the same reason the other particle stages declare it:
/// this shader shares none of the lit path's headers.
layout(std140) uniform FogInfo {
  /// rgb: linear fog colour. w: density per metre, zero for no fog.
  vec4 fog;

  /// xyz: camera position in world space.
  vec4 eye;
}
fog_info;

void main() {
  // `texture`, not `textureLod`. The level is chosen from the derivative the
  // hardware computes for this fragment, which is the whole point of building
  // the chain — and it is the one place the software backend cannot follow
  // exactly, since it has no neighbouring fragments to difference. See
  // `BoundTexture.sample`.
  vec4 texel = texture(particle_texture, v_uv);

  // Attenuation rather than a mix. Blending an additive particle toward the
  // fog colour makes a distant one *add* fog to the wall behind it — the same
  // note as the other two particle stages, kept because each is read alone.
  float fogged = 1.0;
  if (fog_info.fog.w > 0.0) {
    fogged = clamp(
        exp(-fog_info.fog.w * distance(v_world_position, fog_info.eye.xyz)),
        0.0,
        1.0);
  }

  // The texture's alpha is coverage and the particle's is brightness, so the
  // two multiply rather than one replacing the other: a faded spark of a
  // half-transparent sprite contributes a quarter, which is what additive
  // blending means by both of those at once.
  float scale = v_color.a * texel.a * fogged;
#ifdef F3D_SOFT_PARTICLE
  scale *= SoftParticleFade(v_world_position);
#endif
  frag_color = vec4(v_color.rgb * texel.rgb * scale, 1.0);
}


''',
    'ParticleSixWaySoft': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// `particle_six_way.frag`, faded where it nears the opaque scene — soft
// particles, and the case they matter most for: smoke resting on the ground.
// See `lib/particle_soft.glsl`.

#define F3D_SOFT_PARTICLE
// --- lib/particle_six_way.glsl ---
// Particles lit from six directions — `N6`: the body of
// `lighting/particle_six_way.frag` and, with `F3D_SOFT_PARTICLE`, of
// `lighting/particle_six_way_soft.frag`.
//
// Smoke is the one effect additive blending cannot draw: it is dark where it
// is thick and lit where a light reaches into it, and addition can only ever
// brighten. So this stage blends over what is behind it, and lights each
// fragment by the scene's lights through six pictures of the same puff, each
// rendered with a light from one side. Mixing those by where a light really is
// gives the self-shadowing a volume would have, at the cost of two texture
// reads: a light low on the right brightens the lower right rim and leaves the
// far side in the puff's own shade.
//
// **Its own stage beside `particle_textured.frag`, not a branch inside it.**
// The textured stage has one sampler and one block, and every recorded frame
// with a sprite in it goes through it; a six-way branch there would declare
// three samplers and two blocks more that every sprite then has to be bound.
// `ParticleContributor` picks this one when it is handed a six-way material,
// the way it already picks between the sprite and the procedural disc.
//
// ## The layout
//
// Two textures, the channels as the engine's baker writes them and the
// EmberGen and Houdini six-way exports lay them out:
//
//  * `six_way_positive` — r: lit from the right, g: from the top, b: from the
//    back, a: coverage.
//  * `six_way_negative` — r: lit from the left, g: from the bottom, b: from
//    the front, a: emission.
//
// "Back" is the far side of the puff from the viewer, so a light behind smoke
// shows through its thin edges; "front" is the viewer's side. Right and top are
// the quad's own: top is the way a cell's texture coordinate rises, which
// `ParticleSystem.writeQuads` points along the camera's up.
//
// The responses are unpremultiplied — light as it would read at full coverage
// — and the blend is premultiplied, so this multiplies by the coverage once.

// --- lib/contributor_lights.glsl ---
// The scene's lights, for a stage a contributor draws rather than a surface —
// `N6`.
//
// A surface reads its lights out of `FragInfo`, a block that also carries a
// material, three shadow cascades and an environment: close to six kilobytes a
// stage cannot afford to declare for the sake of four arrays. This is those
// four arrays alone, in the order `FragInfo` holds them, plus the light list
// and its clusters from `lib/light_list.glsl`, which is the same one the lit
// models read. `ContributorLights.bind` on the Dart side writes both, from the
// same selection a mesh of the same bounds would be given.
//
// **No shadows, and no rectangle integral.** A particle is a translucent
// sprite: sampling a shadow map at a point inside a cloud of smoke answers a
// question about an opaque surface that is not there. A rectangular light is
// read as a point at its centre with the inverse square, which is the right
// answer at the distances a puff of smoke is from a window and the wrong one
// only close enough to touch it.

#ifndef CONTRIBUTOR_LIGHTS_GLSL_
#define CONTRIBUTOR_LIGHTS_GLSL_

// --- lib/light_list.glsl ---
// The frame's light list, and how a fragment finds its tail in it — `gfx-74n`
// and `L6`.
//
// Split out of `surface.glsl` so a stage that is not a surface can read the
// same lights: `N6`'s six-way particles light each fragment by the list the
// lit models read, clusters and all, without declaring `FragInfo`. The text is
// the one that stood in `surface.glsl`, moved rather than copied, so the lit
// models compile to what they compiled to before.

#ifndef LIGHT_LIST_GLSL_
#define LIGHT_LIST_GLSL_
/// Every light in the scene, one per row, four texels across — `gfx-74n`.
///
/// **A texture rather than a wider uniform block, and that is the design.**
/// `FragInfo` is uploaded on every draw, so widening its four `vec4` arrays to
/// hold thirty-two lights would be a two-kilobyte upload per draw in every
/// scene, including every scene with one light. This is built once a frame and
/// only when a scene has more lights than a draw can hold in its slots.
///
/// Row layout, which `renderer_light_list.dart` writes and only this reads:
///
///  * texel 0 — xyz world position, w type (0 directional, 1 point, 2 spot)
///  * texel 1 — rgb linear colour, w intensity
///  * texel 2 — xyz the direction it points, w range
///  * texel 3 — x cos(inner), y cos(outer), zw unused
///
/// The same four vectors the uniform arrays hold, in the same order, so one
/// reader serves both.
///
/// **`F3D_NO_LIGHT_LIST` leaves both out**, for a model that accumulates no
/// lights. Such a model never reaches the reader below, so the compiler drops
/// the block and the sampler from the Metal function while reflection still
/// lists them, with no buffer or texture index assigned. The renderer used to
/// bind them for every draw, Unlit included, and that bind is a crash inside
/// `setFragmentBuffer:offset:atIndex:` on Metal. Vulkan took the same draw
/// without a word, which is how 0.7.0 shipped with it.
#ifndef F3D_NO_LIGHT_LIST
uniform sampler2D light_list_texture;

layout(std140) uniform LightListInfo {
  /// x: how many rows this draw reads, zero when it reads none.
  /// y, z: one over the texture's width and height.
  /// w: unused.
  vec4 list;

  /// Which rows, four to a vector, in the order they are read.
  ///
  /// Indices rather than the light data itself: the data is the same for every
  /// draw in the frame and belongs in the texture; what differs per draw is
  /// *which* of them reach it, and that is what `Renderer._drawLightsFor`
  /// already decides.
  vec4 indices[6];

  /// How much of each of those survives the edge fade, in the same order.
  ///
  /// Per draw and not in the texture, because the row an index points at is
  /// shared by every draw in the frame: a scale written into it would dim that
  /// light for all of them. `gfx-12n`'s fade lives at the end of the list now —
  /// that is where a light stops contributing, and fading the slots against a
  /// water line that no longer marks a cliff would dim a light for no reason
  /// while its rival stayed bright, making the swap more visible rather than
  /// less.
  vec4 scales[6];

  /// `L6`: the view-projection the light clusters were cut with, so this
  /// finds a fragment's cell the way `LightClusters.clusterOf` does.
  mat4 cluster_view_projection;

  /// xyz: tiles across, tiles up, slices deep. w: one when this draw reads
  /// its tail from the cell it is in rather than from `indices`.
  vec4 cluster_grid;

  /// x: where slices begin, in clip w. y: slices per unit of `ln(w / x)`.
  /// z: the texture row the cells' headers start at, four to a row, each
  /// (offset, count). w: the row their entries start at, sixteen to a row.
  vec4 cluster_depth;

  /// Which rows this draw already holds in its eight slots, minus one for
  /// an empty slot. A cell lists every light that reaches it, and one the
  /// slots already carry must not be counted again.
  vec4 slot_rows[2];
}
light_list_info;

/// One lane of a six-vector table, [slot] counting from nought.
float LightListLane(vec4 four, int slot) {
  int lane = slot - (slot / 4) * 4;
  return lane == 0 ? four.x : lane == 1 ? four.y : lane == 2 ? four.z : four.w;
}

/// The row light [slot] of the list reads.
float LightListRow(int slot) {
  return LightListLane(light_list_info.indices[slot / 4], slot);
}

/// How much of light [slot] of the list survives the edge fade.
float LightListScale(int slot) {
  return LightListLane(light_list_info.scales[slot / 4], slot);
}

/// The cell this fragment falls in, as `LightClusters` wrote it: where its
/// entries start and how many there are. Found once, in [LightCount], and
/// read by every [SampleLight] of the loop that follows.
float g_cluster_offset = 0.0;
float g_cluster_count = 0.0;

bool Clustered() { return light_list_info.cluster_grid.w > 0.5; }

/// One texel of the light list texture, [texel] across and [row] down.
vec4 LightListTexel(float texel, float row) {
  return textureLod(light_list_texture,
                    vec2((texel + 0.5) * light_list_info.list.y,
                         (row + 0.5) * light_list_info.list.z),
                    0.0);
}

void FindCluster(vec3 world) {
  vec4 clip = light_list_info.cluster_view_projection * vec4(world, 1.0);
  vec2 ndc = clip.xy / max(clip.w, 1e-6);
  vec3 grid = light_list_info.cluster_grid.xyz;
  float near = light_list_info.cluster_depth.x;
  float tx = clamp(floor((ndc.x * 0.5 + 0.5) * grid.x), 0.0, grid.x - 1.0);
  float ty = clamp(floor((ndc.y * 0.5 + 0.5) * grid.y), 0.0, grid.y - 1.0);
  float tz = clip.w <= near
                 ? 0.0
                 : clamp(floor(log(clip.w / near) *
                               light_list_info.cluster_depth.y),
                         0.0, grid.z - 1.0);
  float cell = tx + ty * grid.x + tz * grid.x * grid.y;
  float row = floor(cell / 4.0);
  vec4 header =
      LightListTexel(cell - row * 4.0, light_list_info.cluster_depth.z + row);
  g_cluster_offset = header.x;
  g_cluster_count = header.y;
}

/// The row entry [slot] of this fragment's cell names.
float ClusterRow(int slot) {
  float entry = g_cluster_offset + float(slot);
  float row = floor(entry / 16.0);
  float within = entry - row * 16.0;
  float texel = floor(within / 4.0);
  vec4 four = LightListTexel(texel, light_list_info.cluster_depth.w + row);
  return LightListLane(four, int(within - texel * 4.0 + 0.5));
}

/// Whether one of the draw's slots already holds light list row [row].
bool InSlots(float row) {
  vec4 a = abs(light_list_info.slot_rows[0] - vec4(row));
  vec4 b = abs(light_list_info.slot_rows[1] - vec4(row));
  return min(min(min(a.x, a.y), min(a.z, a.w)), min(min(b.x, b.y), min(b.z, b.w))) < 0.5;
}
#endif  // F3D_NO_LIGHT_LIST

#endif  // LIGHT_LIST_GLSL_


/// The slots a draw is handed, and the tail it may read past them. The same
/// eight and twenty-four as `kMaxLights` and `kExtraLights` in `surface.glsl`,
/// named apart so a stage may include both headers.
#define kContributorSlots 8
#define kContributorTail 24
#define kContributorLights (kContributorSlots + kContributorTail)

layout(std140) uniform ContributorLightInfo {
  /// xyz: world position. w: type, 0 directional 1 point 2 spot 3 rectangle.
  vec4 light_position[kContributorSlots];

  /// rgb: linear colour. w: intensity.
  vec4 light_color[kContributorSlots];

  /// xyz: the direction the light points. w: range, 0 unbounded.
  vec4 light_direction[kContributorSlots];

  /// x: cos(inner cone angle). y: cos(outer cone angle).
  vec4 light_cone[kContributorSlots];

  /// x: how many of the slots hold a light. yzw unused.
  vec4 slots;
}
contributor_light_info;

/// How many lights reach [world]: the draw's slots and its tail, or the
/// cell's tail when the view is clustered.
int ContributorLightCount(vec3 world) {
  float tail = light_list_info.list.x;
  if (Clustered()) {
    FindCluster(world);
    tail = g_cluster_count;
  }
  return clamp(int(contributor_light_info.slots.x + 0.5), 0,
               kContributorSlots) +
         clamp(int(tail + 0.5), 0, kContributorTail);
}

/// Light [index] as [world] receives it: [toLight] the unit direction towards
/// it, and [radiance] what arrives, zero for a light that does not reach.
///
/// Selects rather than early returns, for SPIR-V Cross's sake: a function that
/// returns a constant from two branches becomes a phi of constants it refuses.
void ContributorLight(int index, vec3 world, out vec3 toLight,
                      out vec3 radiance) {
  vec4 position;
  vec4 color;
  vec4 direction;
  vec4 cone;
  if (index < kContributorSlots) {
    position = contributor_light_info.light_position[index];
    color = contributor_light_info.light_color[index];
    direction = contributor_light_info.light_direction[index];
    cone = contributor_light_info.light_cone[index];
  } else {
    // The list's row, read the way `SampleLight` reads it: from the cell when
    // the view is clustered, skipping a light the slots already hold.
    int slot = index - kContributorSlots;
    bool clustered = Clustered();
    float listRow = clustered ? ClusterRow(slot) : LightListRow(slot);
    float v = (listRow + 0.5) * light_list_info.list.z;
    float u = light_list_info.list.y;
    position = textureLod(light_list_texture, vec2(0.5 * u, v), 0.0);
    color = textureLod(light_list_texture, vec2(1.5 * u, v), 0.0);
    direction = textureLod(light_list_texture, vec2(2.5 * u, v), 0.0);
    cone = textureLod(light_list_texture, vec2(3.5 * u, v), 0.0);
    color.w *= clustered ? (InSlots(listRow) ? 0.0 : 1.0)
                         : LightListScale(slot);
  }

  float type = position.w;
  bool directional = type < 0.5;
  vec3 offset = position.xyz - world;
  float distance = length(offset);
  vec3 aim = normalize(direction.xyz);

  // A light exactly at the point has no direction; it contributes nothing
  // rather than a NaN that spreads through the blend.
  bool degenerate = !directional && distance < 1e-6;
  toLight = directional ? -aim : offset / max(distance, 1e-6);

  // The glTF window, as `PunctualAttenuation` has it.
  float ratio = direction.w > 0.0 ? distance / direction.w : 0.0;
  float window = clamp(1.0 - ratio * ratio * ratio * ratio, 0.0, 1.0);
  float falloff = window * window / max(distance * distance, 1e-4);

  // A spot's ramp between its two cone cosines. Only a spot's direction is an
  // aim; a rectangle's is the edge of its panel.
  bool spot = type > 1.5 && type < 2.5;
  float ramp = spot ? clamp((dot(aim, -toLight) - cone.y) /
                                max(cone.x - cone.y, 1e-4),
                            0.0, 1.0)
                    : 1.0;

  float attenuation = directional ? 1.0 : (degenerate ? 0.0 : falloff * ramp);
  radiance = color.rgb * color.w * attenuation;
}

#endif  // CONTRIBUTOR_LIGHTS_GLSL_

// --- lib/particle_soft.glsl ---
// Soft particles: a sprite that fades as it nears the opaque scene behind it.
//
// **What this removes is a seam.** A particle is a flat quad, depth-tested and
// never depth-written, so where it passes through a floor or a wall the test
// cuts it along the line the two planes meet — a hard straight edge across a
// puff of smoke that has no edges anywhere else. Lorach's fix ("Soft
// Particles", 2007) scales the particle by how far the scene lies behind it:
//
//   fade = saturate((sceneDepth - particleDepth) / softness)
//
// so a fragment a softness or more in front of the scene is untouched, one
// touching it is gone, and the line becomes a ramp.
//
// Included by every particle stage, and empty unless the stage defines
// `F3D_SOFT_PARTICLE`: the soft stages are stages of their own, picked only by
// a contributor that was handed the scene's depth, so the ones every recorded
// frame goes through declare nothing new — no sampler to leave unbound, which
// on Metal is a native crash.

#ifndef PARTICLE_SOFT_GLSL_
#define PARTICLE_SOFT_GLSL_

#ifdef F3D_SOFT_PARTICLE

// --- lib/frag_coord.glsl ---
// Where a fragment sits, counted from the top of its target on every backend.

#ifndef FRAG_COORD_GLSL_
#define FRAG_COORD_GLSL_

/// `gl_FragCoord.xy` with row zero at the top of the picture.
///
/// [rows] is the target's height where the backend's row zero is the bottom
/// of the picture, and zero where it is the top. WebGL2 is the first kind:
/// window coordinates start at the lower left, and the engine draws the
/// picture upright there rather than mirroring every projection. Metal,
/// WebGPU and the software rasteriser are the second.
///
/// **Why a pattern cares and a picture does not.** Every screen-space pattern
/// in the engine — the Bayer dither, the grain, the jitter a ray march starts
/// from, the rotation of a shadow kernel — is a function of the pixel's row.
/// Read from the bottom, the same frame gets the pattern turned upside down,
/// and a four-row Bayer cell lands on different rows unless the height is a
/// multiple of four. The picture underneath is identical; the pattern on top
/// of it is not, and a comparison across backends counts every pixel it
/// moved.
vec2 FragCoordFromTop(float rows) {
  return rows > 0.0 ? vec2(gl_FragCoord.x, rows - gl_FragCoord.y)
                    : gl_FragCoord.xy;
}

#endif  // FRAG_COORD_GLSL_


/// The surface buffer: in `a`, the opaque scene's depth along the view axis,
/// in metres, and zero where nothing was drawn. Read with nearest filtering.
uniform sampler2D scene_depth_texture;

layout(std140) uniform SoftParticleInfo {
  /// xy: one over the target's size. z: the target's height where its row
  /// zero is the bottom, nought where it is the top — see `FragCoordFromTop`.
  /// w: one over the softness, in metres.
  vec4 target;

  /// xyz: the camera position in world space.
  vec4 eye;

  /// xyz: the direction the camera looks, a unit vector — the axis the
  /// surface buffer measures its depths along.
  vec4 forward;
}
soft_particle_info;

/// How much of a particle fragment at [world] is left once it nears the
/// scene: one a softness in front of it or further, nought at it.
///
/// One where nothing was drawn behind — the sky is infinitely far — and a
/// select rather than an early return, which a phi of constants would make
/// SPIRV-Cross refuse. `textureLod` for WGSL, which will not take an implicit
/// level where the caller's control flow may not be uniform.
float SoftParticleFade(vec3 world) {
  vec2 uv = FragCoordFromTop(soft_particle_info.target.z) *
            soft_particle_info.target.xy;
  float stored = textureLod(scene_depth_texture, uv, 0.0).a;
  float depth = dot(world - soft_particle_info.eye.xyz,
                    soft_particle_info.forward.xyz);
  float fade =
      clamp((stored - depth) * soft_particle_info.target.w, 0.0, 1.0);
  return stored > 0.0 ? fade : 1.0;
}

#endif  // F3D_SOFT_PARTICLE

#endif  // PARTICLE_SOFT_GLSL_


in vec4 v_color;
in vec2 v_uv;
in vec3 v_world_position;

layout(location = 0) out vec4 frag_color;

uniform sampler2D six_way_positive;
uniform sampler2D six_way_negative;

/// Declared again, as in the other particle stages, since this shares none of
/// the lit path's headers.
layout(std140) uniform FogInfo {
  /// rgb: linear fog colour. w: density per metre, zero for no fog.
  vec4 fog;

  /// xyz: camera position in world space.
  vec4 eye;
}
fog_info;

layout(std140) uniform SixWayInfo {
  /// xyz: the quad's right, which is the camera's, in world space.
  vec4 right;

  /// xyz: the quad's up, the way a cell's v rises.
  vec4 up;

  /// xyz: away from the viewer, which is where "back" is.
  vec4 forward;

  /// rgb: what the emission channel's full value emits, linear. w unused.
  vec4 emission;

  /// rgb: light arriving evenly from every side. w unused.
  vec4 ambient;
}
six_way_info;

/// How much of a light arriving from [l] the puff sends to the viewer.
///
/// The squared components of a unit direction sum to one, so they are weights:
/// a light straight to the right reads the right picture alone, and one up and
/// to the right reads half of each. The sign picks which of a pair.
float SixWayResponse(vec3 l, vec3 positive, vec3 negative) {
  float x = dot(l, six_way_info.right.xyz);
  float y = dot(l, six_way_info.up.xyz);
  float z = dot(l, six_way_info.forward.xyz);
  return x * x * (x > 0.0 ? positive.r : negative.r) +
         y * y * (y > 0.0 ? positive.g : negative.g) +
         z * z * (z > 0.0 ? positive.b : negative.b);
}

void main() {
  // `texture`, as the sprite stage has it: the level comes from the
  // footprint, and a receding puff wants its chain.
  vec4 positive = texture(six_way_positive, v_uv);
  vec4 negative = texture(six_way_negative, v_uv);

  vec3 lit = vec3(0.0);
  int count = ContributorLightCount(v_world_position);
  for (int i = 0; i < kContributorLights; i++) {
    if (i >= count) break;
    vec3 l;
    vec3 radiance;
    ContributorLight(i, v_world_position, l, radiance);
    lit += radiance * SixWayResponse(l, positive.rgb, negative.rgb);
  }

  // Light from every side at once reads each picture for a sixth of the
  // sphere, so the ambient term is their mean.
  float mean = (positive.r + positive.g + positive.b + negative.r +
                negative.g + negative.b) /
               6.0;
  vec3 color = v_color.rgb * (lit + six_way_info.ambient.rgb * mean) +
               six_way_info.emission.rgb * negative.a;

  // A mix toward the fog, unlike the additive stages: this one covers what is
  // behind it, and covered smoke far away should read as the fog does.
  float fogged = 1.0;
  if (fog_info.fog.w > 0.0) {
    fogged = clamp(
        exp(-fog_info.fog.w * distance(v_world_position, fog_info.eye.xyz)),
        0.0,
        1.0);
  }
  color = mix(fog_info.fog.rgb, color, fogged);

  float coverage = clamp(v_color.a * positive.a, 0.0, 1.0);
#ifdef F3D_SOFT_PARTICLE
  // Coverage, not colour: the blend is premultiplied, so a puff fading into
  // the floor has to let the floor through as well as stop covering it.
  coverage *= SoftParticleFade(v_world_position);
#endif
  frag_color = vec4(color * coverage, coverage);
}


''',
    'Splat': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// A Gaussian splat, as the falloff inside a quad — `gfx-80n`.
//
// **Why this is a fragment stage and nothing else.** A splat is an ellipsoid of
// fading opacity, and what reaches the screen is that ellipsoid's projection: an
// ellipse whose brightness falls off as a Gaussian from its middle. Two ways to
// draw one. Expand a point into an ellipse here, which needs a geometry stage
// flutter_gpu does not have; or hand the stage a quad already shaped and
// oriented, and evaluate the falloff across it. This engine already made that
// choice once, for particles, and `particle.vert`'s own comment says why — so
// splats reuse that vertex stage exactly rather than adding a second one that
// would read the same three attributes.
//
// The quad's `texcoord` is therefore not a texture coordinate. It is the
// fragment's position in the *Gaussian's own* space, in units of its standard
// deviation, centred at zero: the CPU builds the quad from the projected
// ellipse's axes, so `v_uv` arrives already in the frame where the falloff is
// round and the arithmetic here is one dot product.
//
// **Alpha blended, back to front, and that is the whole difference from a
// particle.** Particles are additive, which is why they need no sorting —
// addition does not care about order. A splat is translucent: two overlapping
// ones give a different colour depending on which was drawn first, so the cloud
// is sorted every time the camera moves and this stage writes straight alpha.

in vec4 v_color;
in vec2 v_uv;
in vec3 v_world_position;

layout(location = 0) out vec4 frag_color;

/// The same block the particle stage declares, for the same reason it declares
/// it: a different vertex layout and none of the lit shaders' varyings, so none
/// of their headers apply. Two members, not three — nothing here writes a
/// surface buffer, so there is no view axis to measure a depth along.
layout(std140) uniform FogInfo {
  /// rgb: linear fog colour. w: density per metre, zero for no fog.
  vec4 fog;

  /// xyz: camera position in world space.
  vec4 eye;
}
fog_info;

void main() {
  // `exp(-½ dᵀd)` with d already in standard deviations, which is what the
  // quad's own coordinates are. No matrix here: the inverse covariance was
  // applied when the corners were placed, because it is one ellipse per splat
  // and four corners, not one per fragment.
  float power = -0.5 * dot(v_uv, v_uv);

  // **Cut off rather than trailed to nothing.** A Gaussian never reaches zero,
  // so a quad sized to hold all of it would be infinite; the corners sit at
  // three standard deviations, where the falloff is under a hundredth, and
  // anything past that is discarded so the quad's own square edge can never
  // show. Without this the cloud reads as a field of faint rectangles.
  if (power < -4.5) discard;

  float alpha = v_color.a * exp(power);
  if (alpha < 1.0 / 255.0) discard;

  // Fog as a mix rather than as attenuation, which is the opposite of the
  // particle stage above and for the opposite reason: this is blended, so the
  // destination is *replaced* in proportion to alpha, and a distant splat that
  // faded toward black would put black into the wall behind it instead of
  // fading into the air.
  vec3 colour = v_color.rgb;
  if (fog_info.fog.w > 0.0) {
    float visibility = clamp(
        exp(-fog_info.fog.w * distance(v_world_position, fog_info.eye.xyz)),
        0.0,
        1.0);
    colour = mix(fog_info.fog.rgb, colour, visibility);
  }

  // Premultiplied, because that is what the blend state this is drawn under
  // expects: `one, one_minus_src_alpha` composites a stack of translucent
  // layers correctly in one pass, and straight alpha under the same state
  // double-counts the colour of everything in front.
  frag_color = vec4(colour * alpha, alpha);
}

''',
    'SplatHashed': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// A Gaussian splat kept or dropped whole per pixel, instead of blended — `N5`.
//
// **Why a second stage rather than a switch in `splat.frag`.** Blending needs
// the cloud sorted back to front, and the sort is most of what a cloud costs a
// camera move. With a temporal resolve running there is another way: treat the
// splat's opacity at a pixel as a *probability* of covering it, keep or discard
// the fragment against noise, and write it opaque with its depth. One frame of
// that is speckle; the resolve averages sixteen, and the expected colour of a
// pixel is exactly what the sorted blend would have put there — the nearest
// kept splat wins the depth test, and it is kept with its own opacity times the
// chance that everything nearer was dropped. No order is needed because the
// depth buffer does the ordering. `splat.frag` stays as it was, byte for byte,
// so a frame without the resolve draws what it always drew.
//
// **The noise is the pixel's, the splat's and the frame's.** The engine's
// `hashed` material mode anchors its noise to world position so a moving leaf
// keeps its verdict; that is the wrong choice here, because the resolve has to
// *see* the verdict change from frame to frame to average it. So:
//
//   * the engine's blue noise (`R3`), a new slice each frame: across one
//     splat, the pixels it keeps are spread evenly rather than clumped, so
//     every three-by-three neighbourhood the resolve clips its history
//     against holds close to its share of them. White noise clumps, and a
//     neighbourhood that happens to hold none clips the history to the
//     background — the picture comes out darker than the sorted one, and
//     stays so however many frames are averaged;
//   * read at an offset that is the splat's own, so two splats over one pixel
//     read unrelated texels and draw independent verdicts. With the same
//     number for both, a splat behind a half-opaque one would only ever
//     survive where the front one also did, and the far layer would vanish
//     instead of showing through. The fragment does not know its splat's
//     index — the quads share `particle.vert`'s layout, which has no room for
//     one — so the offset is hashed from what does tell two splats apart at
//     one pixel and is the same at every pixel of one: its colour, and its
//     distance along the view axis, which is constant across a quad because
//     every quad lies in the camera's own plane.
//
// **Both are counted the same way on every backend.** The pixel is read with
// row zero at the top (`FragCoordFromTop`), so WebGL2 does not turn the tile
// upside down. The offset is hashed in whole numbers under 2^24, where a
// 32-bit float is exact, instead of through `sin`, whose argument ran to
// hundreds of thousands and whose last bits no GPU promises: a GPU and the
// software rasteriser now give one splat the same offset.

// --- lib/frag_coord.glsl ---
// Where a fragment sits, counted from the top of its target on every backend.

#ifndef FRAG_COORD_GLSL_
#define FRAG_COORD_GLSL_

/// `gl_FragCoord.xy` with row zero at the top of the picture.
///
/// [rows] is the target's height where the backend's row zero is the bottom
/// of the picture, and zero where it is the top. WebGL2 is the first kind:
/// window coordinates start at the lower left, and the engine draws the
/// picture upright there rather than mirroring every projection. Metal,
/// WebGPU and the software rasteriser are the second.
///
/// **Why a pattern cares and a picture does not.** Every screen-space pattern
/// in the engine — the Bayer dither, the grain, the jitter a ray march starts
/// from, the rotation of a shadow kernel — is a function of the pixel's row.
/// Read from the bottom, the same frame gets the pattern turned upside down,
/// and a four-row Bayer cell lands on different rows unless the height is a
/// multiple of four. The picture underneath is identical; the pattern on top
/// of it is not, and a comparison across backends counts every pixel it
/// moved.
vec2 FragCoordFromTop(float rows) {
  return rows > 0.0 ? vec2(gl_FragCoord.x, rows - gl_FragCoord.y)
                    : gl_FragCoord.xy;
}

#endif  // FRAG_COORD_GLSL_


in vec4 v_color;
in vec2 v_uv;
in vec3 v_world_position;

layout(location = 0) out vec4 frag_color;

/// The block `splat.frag` declares, for the fog mix it makes.
layout(std140) uniform FogInfo {
  /// rgb: linear fog colour. w: density per metre, zero for no fog.
  vec4 fog;

  /// xyz: camera position in world space.
  vec4 eye;
}
fog_info;

/// `EngineTables.blueNoise`: 32 slices of 64 × 64 in an 8 × 4 atlas.
uniform sampler2D blue_noise_texture;

layout(std140) uniform SplatHashInfo {
  /// x: the frame's slice of the blue noise, `frameIndex % 32`. y: the
  /// target's rows where its row zero is the bottom, nought where it is the
  /// top — see `FragCoordFromTop`. zw unused.
  vec4 frame;

  /// xyz: the camera's position in world space.
  vec4 eye;

  /// xyz: the camera's forward axis, unit length.
  vec4 forward;
}
splat_hash_info;

void main() {
  // The same falloff and the same cut-offs as `splat.frag`: what is kept here
  // is exactly what would have been blended there.
  float power = -0.5 * dot(v_uv, v_uv);
  if (power < -4.5) discard;

  float alpha = v_color.a * exp(power);
  if (alpha < 1.0 / 255.0) discard;

  // The splat's identity, as a whole number: millimetres along the view axis
  // and the colour in 8-bit steps. Whole numbers so that the rounding which
  // makes one fragment's interpolated colour differ from its neighbour's in
  // the last bit changes nothing, except on the rare pixel that straddles a
  // step.
  float along = dot(v_world_position - splat_hash_info.eye.xyz,
                    splat_hash_info.forward.xyz);
  float identity =
      floor(along * 1000.0) +
      floor(dot(v_color, vec4(255.0, 255.0 * 7.0, 255.0 * 31.0, 255.0 * 127.0)));
  identity = mod(identity, 4096.0);

  // One of the 4096 cells of the tile per identity, and a different one for
  // each: an affine step and `2a² + a`, each a permutation of the whole
  // numbers modulo 4096, then the high six bits stirred into the low six so
  // the column depends on all of them. No product reaches 2^24 and every
  // divisor is a power of two, so each step is exact in a 32-bit float.
  float mixed = mod(identity * 1597.0 + 2531.0, 4096.0);
  mixed = mod(mixed * mod(2.0 * mixed + 1.0, 4096.0), 4096.0);
  float row = floor(mixed / 64.0);
  vec2 offset = vec2(mod(mixed + row * 37.0, 64.0), row);

  // This frame's slice of the blue noise at the pixel, moved by the splat's
  // offset. The same arithmetic as `BlueNoise` in `lib/blue_noise.glsl`,
  // which this does not include because it brings the `NoiseInfo` block the
  // post passes share and a slice this stage already has.
  float slice = splat_hash_info.frame.x;
  vec2 cell = mod(floor(FragCoordFromTop(splat_hash_info.frame.y)) + offset,
                  64.0);
  vec2 corner = vec2(mod(slice, 8.0), floor(slice / 8.0)) * 64.0;
  float threshold =
      textureLod(blue_noise_texture,
                 (corner + cell + 0.5) / vec2(512.0, 256.0),
                 0.0).r *
      (255.0 / 256.0);

  // Kept with probability `alpha`, which is what the sorted blend would have
  // given this splat's colour at this pixel had nothing been in front of it.
  if (alpha <= threshold) discard;

  vec3 colour = v_color.rgb;
  if (fog_info.fog.w > 0.0) {
    float visibility = clamp(
        exp(-fog_info.fog.w * distance(v_world_position, fog_info.eye.xyz)),
        0.0,
        1.0);
    colour = mix(fog_info.fog.rgb, colour, visibility);
  }

  // Opaque: whether the splat is here was decided above, and how much of it
  // shows is what the resolve's average says.
  frag_color = vec4(colour, 1.0);
}

''',
    'ParticleMesh': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// Mesh particles: additive, fogged, and shaded by which way each face points.
//
// ## Why there is a facing term at all
//
// The billboard path needs none: its quad is a procedural disc, so the falloff
// from the middle to the edge is what gives a sprite its form. A mesh has no
// such coordinate, and additive blending flattens everything it touches — every
// face adds the same colour, so a tumbling shard comes back as a solid
// silhouette of its own outline. It reads as a hole in the world rather than as
// an object.
//
// One term fixes it: how squarely a face points at the eye. A face turned away
// contributes less, so the shape's own geometry separates itself, and a shard
// spinning through a torch's light flickers because its faces do.
//
// **This is not lighting.** It reads no light in the scene, casts nothing, and
// receives nothing; the same shape is equally bright in a dark corridor. That
// is deliberate: an additive particle is *emissive by definition* — it adds to
// what is behind it — and shading one by the room's lights would mean binding
// the whole lit path's uniform set to something that has no business being lit.

in vec4 v_color;
in vec3 v_world_position;
in vec3 v_normal;

layout(location = 0) out vec4 frag_color;

/// The same block the other particle stage declares, for the same reason: this
/// shader shares none of the lit shaders' headers.
layout(std140) uniform FogInfo {
  /// rgb: linear fog colour. w: density per metre, zero for no fog.
  vec4 fog;

  /// xyz: camera position in world space.
  vec4 eye;
}
fog_info;

void main() {
  vec3 to_eye = fog_info.eye.xyz - v_world_position;
  float distance_to_eye = length(to_eye);

  // `abs`, not `max(dot, 0)`. Nothing here is culled — a particle mesh is seen
  // from every side as it tumbles — so a back face is as visible as a front
  // one, and clamping would make half of every shard go black rather than dim.
  vec3 n = normalize(v_normal);
  float facing = distance_to_eye > 0.0
      ? abs(dot(n, to_eye / distance_to_eye))
      : 1.0;

  // Never all the way to zero. A silhouette edge is exactly perpendicular to
  // the eye, and a face that vanished there would carve a dark seam across the
  // shape at precisely the place the eye is best at noticing one.
  float intensity = mix(0.35, 1.0, facing);

  // Attenuation rather than a mix, for the reason spelled out in
  // lighting/particle.frag: blending toward the fog colour makes a distant
  // additive particle *add* fog to the wall behind it.
  float fogged = 1.0;
  if (fog_info.fog.w > 0.0) {
    fogged = clamp(exp(-fog_info.fog.w * distance_to_eye), 0.0, 1.0);
  }

  frag_color = vec4(v_color.rgb * v_color.a * intensity * fogged, 1.0);
}

''',
    'Reflections': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// Screen-space reflections.
//
// Reflects what is already on screen, and nothing else. That is the whole
// bargain: a torch behind the camera does not appear in the floor, and a
// surface at a grazing angle reflects a stretched smear of whatever the ray
// happened to hit. It is bought cheaply — one texture read per march step, no
// second pass over the geometry, no cube maps and so no mip levels, which this
// channel does not have.
//
// The surface buffer is what makes it possible at all: a forward renderer
// throws its normals away inside the fragment shader, and there is nothing to
// reflect against without them. rg is the world-space normal, octahedrally
// encoded; b is perceptual roughness; a is the depth along the view axis in
// world metres — depth is here rather than in a depth texture because
// flutter_gpu cannot sample one, and it is in metres rather than a window depth
// because a half float cannot hold the second one usefully past a few metres.
//
// Roughness is why the normal is squeezed into two channels. Without it the
// shader reflects off rough stone as readily as off a wet floor, which is what
// the first version did: the walls of the crypt lit up and the floor did not.
precision highp float;

// --- lib/frag_coord_info.glsl ---
// The target's orientation, for a full-screen pass.
//
// Its own block rather than a member of each pass's, so the renderer binds it
// in one place, `drawFullscreen`, for every stage that declares it — the
// contract answers false for a stage that does not, and a pass that adds a
// screen-space pattern later gets the right rows by including this file.

#ifndef FRAG_COORD_INFO_GLSL_
#define FRAG_COORD_INFO_GLSL_

// --- lib/frag_coord.glsl ---
// Where a fragment sits, counted from the top of its target on every backend.

#ifndef FRAG_COORD_GLSL_
#define FRAG_COORD_GLSL_

/// `gl_FragCoord.xy` with row zero at the top of the picture.
///
/// [rows] is the target's height where the backend's row zero is the bottom
/// of the picture, and zero where it is the top. WebGL2 is the first kind:
/// window coordinates start at the lower left, and the engine draws the
/// picture upright there rather than mirroring every projection. Metal,
/// WebGPU and the software rasteriser are the second.
///
/// **Why a pattern cares and a picture does not.** Every screen-space pattern
/// in the engine — the Bayer dither, the grain, the jitter a ray march starts
/// from, the rotation of a shadow kernel — is a function of the pixel's row.
/// Read from the bottom, the same frame gets the pattern turned upside down,
/// and a four-row Bayer cell lands on different rows unless the height is a
/// multiple of four. The picture underneath is identical; the pattern on top
/// of it is not, and a comparison across backends counts every pixel it
/// moved.
vec2 FragCoordFromTop(float rows) {
  return rows > 0.0 ? vec2(gl_FragCoord.x, rows - gl_FragCoord.y)
                    : gl_FragCoord.xy;
}

#endif  // FRAG_COORD_GLSL_


layout(std140) uniform FragCoordInfo {
  /// x: the target's rows when its row zero is the bottom of the picture,
  /// zero when it is the top — see [FragCoordFromTop]. yzw unused.
  vec4 origin;
}
frag_coord_info;

/// This fragment's position with row zero at the top of the target.
vec2 TargetFragCoord() {
  return FragCoordFromTop(frag_coord_info.origin.x);
}

#endif  // FRAG_COORD_INFO_GLSL_

// --- lib/blue_noise.glsl ---
// A per-pixel offset for a march or a kernel rotation — `R3`.
//
// **The engine's blue noise while a temporal resolve runs, the fixed 4 × 4
// pattern otherwise.** A march jittered by a pattern that never changes puts
// the same dither on every frame, and the eye finds it; with the resolve on,
// each frame reads the next of 32 slices of blue noise and the history
// averages them into a smooth answer. Off, the pattern is exactly what the
// passes read before, so a frame without the resolve is the frame it was.
//
// The table is `EngineTables.blueNoise`: 32 slices of 64 × 64 in an 8 × 4
// atlas, one byte a texel. Read at texel centres through a nearest sampler.
//
// Include after `lib/frag_coord_info.glsl` or anything else that gives the
// pixel from the top.

#ifndef BLUE_NOISE_GLSL_
#define BLUE_NOISE_GLSL_

uniform sampler2D blue_noise_texture;

layout(std140) uniform NoiseInfo {
  /// x: one to read the blue noise, nought for the pattern. y: this frame's
  /// slice, the frame index modulo 32. zw unused.
  vec4 noise;
}
noise_info;

/// One cell of a 4 × 4 Bayer matrix, in [0, 1).
float BayerCell(vec2 at) {
  int x = int(mod(at.x, 4.0));
  int y = int(mod(at.y, 4.0));
  int index = y * 4 + x;
  float value = 0.0;
  if (index == 0) value = 0.0;
  else if (index == 1) value = 8.0;
  else if (index == 2) value = 2.0;
  else if (index == 3) value = 10.0;
  else if (index == 4) value = 12.0;
  else if (index == 5) value = 4.0;
  else if (index == 6) value = 14.0;
  else if (index == 7) value = 6.0;
  else if (index == 8) value = 3.0;
  else if (index == 9) value = 11.0;
  else if (index == 10) value = 1.0;
  else if (index == 11) value = 9.0;
  else if (index == 12) value = 15.0;
  else if (index == 13) value = 7.0;
  else if (index == 14) value = 13.0;
  else value = 5.0;
  return value / 16.0;
}

/// This frame's blue noise at the pixel [at], in [0, 1).
float BlueNoise(vec2 at) {
  float slice = noise_info.noise.y;
  vec2 cell = mod(floor(at), 64.0);
  vec2 corner = vec2(mod(slice, 8.0), floor(slice / 8.0)) * 64.0;
  vec2 uv = (corner + cell + 0.5) / vec2(512.0, 256.0);
  return textureLod(blue_noise_texture, uv, 0.0).r * (255.0 / 256.0);
}

/// The offset for the pixel [at]: blue noise or the pattern, per `noise.x`.
float PixelNoise(vec2 at) {
  return noise_info.noise.x > 0.5 ? BlueNoise(at) : BayerCell(at);
}

#endif  // BLUE_NOISE_GLSL_


in vec2 v_uv;

layout(location = 0) out vec4 frag_color;

uniform sampler2D scene_texture;
uniform sampler2D surface_texture;
/// The scene's environment, which the lit pass has already reflected. Bound
/// always, to a one-texel cube when there is none, for the reason
/// `lib/pbr.glsl` gives: a declared sampler nobody binds is a crash on Metal.
uniform samplerCube environment_texture;

layout(std140) uniform ReflectionInfo {
  /// World to clip, and back. Both carry the framebuffer origin — see
  /// [UvFromNdc] — so neither is the camera's own matrix on every backend.
  mat4 view_projection;
  mat4 inverse_view_projection;
  /// xyz: camera position. w: unused.
  vec4 camera;
  /// xyz: the direction the camera looks, a unit vector in world space.
  /// w: unused. With [camera] it names the planes the buffer's depths measure
  /// against; see [WorldAt].
  vec4 forward;
  /// x: steps. y: stride in world metres. z: thickness in world metres.
  /// w: intensity.
  vec4 params;
  /// x: 1/width, y: 1/height, z: unused, w: 1 to show only what the march
  /// found, which is the only way to see whether it found anything.
  vec4 screen;
  /// The environment the lit pass reflected, so that a hit can take its place
  /// — see the end of [main]. x: its levels, nought when the lit pass read
  /// none. y: the strength it was read at, `Scene.ambientIntensity`. zw:
  /// unused.
  vec4 environment;
}
reflection_info;

vec3 DecodeOctahedral(vec2 e) {
  e = e * 2.0 - 1.0;
  vec3 n = vec3(e.xy, 1.0 - abs(e.x) - abs(e.y));
  float t = max(-n.z, 0.0);
  n.x += n.x >= 0.0 ? -t : t;
  n.y += n.y >= 0.0 ? -t : t;
  return normalize(n);
}

/// Where a point at clip-space [ndc] lands in the textures this pass reads.
///
/// **v runs the other way from y, and the matrix is what makes that true on
/// both backends.** Row zero of a rendered texture is its top on Impeller and
/// its bottom in WebGL, so the conversion cannot be written once for both in
/// GLSL — `toFramebufferOrigin` negates y in the matrix handed down here for
/// the backend that needs it, exactly as it does for the shadow lookup in
/// `lib/surface.glsl`, which has used this convention all along.
///
/// This pass used the opposite one — `ndc * 0.5 + 0.5`, with an unadjusted
/// matrix — and was therefore right in a browser and mirrored top to bottom
/// everywhere else: the march read the surface buffer at the pixel reflected
/// about the middle of the frame, found nothing there that had anything to do
/// with the ray, and drew a reflection of whatever happened to be in the way.
vec2 UvFromNdc(vec2 ndc) {
  return vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
}

/// World position of the pixel at [uv], [depth] metres along the view axis.
///
/// **A ray crossing a plane**, because that is what the buffer holds now — see
/// `WriteSurfaceGeometry`. The inverse matrix gives both ends of the pixel's
/// ray; the stored depth names the plane the surface sits on, and every ray
/// crosses that plane once. A window depth in a half float could not name it at
/// range: past twenty metres its steps are wider than the differences this
/// march turns on.
///
/// Both ends rather than the camera and one end, so that an orthographic camera
/// — whose rays are parallel and meet nowhere — reconstructs correctly too.
/// `post/ssao.frag` says the same at more length.
///
/// y undoes [UvFromNdc], so that reconstructing the point a march projected
/// hands back the point it started from.
void PixelRay(vec2 uv, out vec3 origin, out vec3 along) {
  vec2 xy = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
  vec4 nearH = reflection_info.inverse_view_projection * vec4(xy, 0.0, 1.0);
  vec4 farH = reflection_info.inverse_view_projection * vec4(xy, 1.0, 1.0);
  origin = nearH.xyz / nearH.w;
  along = normalize(farH.xyz / farH.w - origin);
}

vec3 WorldAt(vec2 uv, float depth) {
  vec3 origin, along;
  PixelRay(uv, origin, along);
  vec3 axis = reflection_info.forward.xyz;
  return origin +
         along * ((depth - dot(origin - reflection_info.camera.xyz, axis)) /
                  dot(along, axis));
}

/// How deep [at] is, in the metres the buffer holds.
float DepthOf(vec3 at) {
  return dot(at - reflection_info.camera.xyz, reflection_info.forward.xyz);
}

/// Where [at] lands in the textures this pass reads, or a negative x when it
/// is behind the camera.
vec2 UvOf(vec3 at) {
  vec4 clip = reflection_info.view_projection * vec4(at, 1.0);
  if (clip.w <= 0.0) return vec2(-1.0);
  return UvFromNdc(clip.xy / clip.w);
}

void main() {
  vec4 surface = texture(surface_texture, v_uv);
  vec3 scene = texture(scene_texture, v_uv).rgb;

  // Nothing was drawn here: the buffer is cleared to zero and a zero alpha is
  // the sky, not a surface at the near plane.
  bool debugOnly = reflection_info.screen.w > 0.5;
  vec3 background = debugOnly ? vec3(0.0) : scene;

  if (surface.a <= 0.0) {
    frag_color = vec4(background, 1.0);
    return;
  }

  vec3 normal = DecodeOctahedral(surface.rg);
  float roughness = surface.b;

  // Rough surfaces scatter: a sharp screen-space reflection off one is a lie,
  // and the honest thing is to stop rather than to blur something that was
  // never sampled widely enough to blur. **Gone by 0.25, not by 0.45.** At a
  // perceptual roughness of 0.3 the GGX lobe is several degrees wide — tens of
  // centimetres of blur three metres out — and this pass has no blur: the old
  // window left such a floor a sharp mirror at 58% weight, which is where the
  // ghostly copies of objects standing on it came from.
  float polish = 1.0 - smoothstep(0.05, 0.25, roughness);
  if (polish <= 0.0) {
    frag_color = vec4(background, 1.0);
    return;
  }
  vec3 position = WorldAt(v_uv, surface.a);

  // Back along the ray this pixel looks down, rather than towards the camera
  // position. The two are the same thing under a perspective camera and are
  // not under an orthographic one, whose rays are parallel: there the vector to
  // the camera *position* leans further off the view axis the nearer a pixel is
  // to the edge of the frame, and every reflection in an isometric scene would
  // be angled by where it happened to sit on screen.
  vec3 rayOrigin, viewRay;
  PixelRay(v_uv, rayOrigin, viewRay);
  vec3 toEye = -viewRay;

  // Facing away, or so nearly edge-on that the march would crawl along the
  // surface it started from.
  float facing = dot(normal, toEye);
  if (facing <= 0.05) {
    frag_color = vec4(background, 1.0);
    return;
  }

  vec3 ray = reflect(-toEye, normal);

  int steps = int(reflection_info.params.x);
  float stride = reflection_info.params.y;
  float thickness = reflection_info.params.z;
  float intensity = reflection_info.params.w;

  // **Started a jittered fraction of a stride out** — McGuire and Mara's
  // answer to the banding a fixed world stride leaves: neighbouring pixels
  // otherwise cross an object on the same step with the same overshoot, and
  // the reflection comes back as a stack of shifted copies of it. Half a
  // stride at least, off a centimetre of normal bias, so the first sample does
  // not land on the pixel it came from.
  float jitter = 0.5 + PixelNoise(TargetFragCoord());
  float travelled = stride * jitter;
  vec3 march = position + normal * 0.01 + ray * travelled;
  float reach = stride * (float(steps) + 0.5);
  vec3 hitColor = vec3(0.0);
  float hit = 0.0;

  for (int i = 0; i < 64; i++) {
    if (i >= steps) break;

    vec2 uv = UvOf(march);
    if (uv.x < -0.5) break;

    // Off screen is where this technique ends. Fading rather than cutting,
    // because a hard edge at the border of the frame is more distracting than
    // a missing reflection.
    if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) break;

    // **`textureLod` at level zero for every read inside this march.** The loop
    // breaks the moment a ray leaves the frustum or the frame, so no two
    // invocations of a quad are guaranteed to be on the same step, and a WGSL
    // backend will not derive a mip level under a branch like that. Both
    // textures are full-screen render targets with a single level and are read
    // at one texel per pixel, so level zero is what the derivative was
    // selecting; asking for it by name is the same picture.
    float sceneDepth = textureLod(surface_texture, uv, 0.0).a;
    // The march's own depth, in the same metres the buffer holds — so the two
    // are comparable without a projection between them.
    float marchDepth = DepthOf(march);
    // Behind whatever was drawn at this pixel, in metres both sides of the
    // comparison are already in.
    if (sceneDepth > 0.0 && marchDepth > sceneDepth) {
      // *How far* behind, in metres, and that is the whole fix. This used to
      // read the window-depth difference as a thickness, and a window-depth
      // difference is a different number of metres at every range: near the
      // camera 0.006 was a few centimetres and one stride stepped clean over
      // every surface in the frame, while at twenty metres it was several metres
      // and every ray passing in front of a distant wall "hit" it. That is why
      // the effect was off in every scene and looked wrong the moment it was
      // switched on. `ssao.frag` splits the same two questions the same way.
      //
      // The gap between the two points rather than between their depths: they
      // sit on one ray from the eye, and along a ray running away from the
      // camera a depth difference is shorter than the distance it stands for.
      // Thickness is a size in the world, so it is compared against one.
      vec3 seen = WorldAt(uv, sceneDepth);
      float behind = distance(march, seen);
      // A surface turned away from the ray is the back of something: the ray
      // would have met its front first, so it is not what this pixel sees.
      vec3 seenNormal = DecodeOctahedral(textureLod(surface_texture, uv, 0.0).rg);
      if (behind < thickness && dot(seenNormal, ray) < 0.0) {
        // **Refined before it is read.** The step that crossed the surface
        // overshot it by up to a stride; halving the last stride five times
        // lands within a thirty-second of it, so the colour is read where the
        // ray met the surface rather than where the step happened to stop.
        //
        // The bracket starts no further back than the march has come. The
        // first step travels only the jittered fraction of a stride, and a
        // full stride back from it can sit under the reflecting surface, where
        // the depth test also reads "behind"; a bracket with two behind ends
        // lets the halvings settle on the floor itself, and a contact
        // reflection reads the floor's own colour back.
        vec3 lo = march - ray * min(stride, travelled);
        vec3 hi = march;
        for (int j = 0; j < 5; j++) {
          vec3 mid = 0.5 * (lo + hi);
          vec2 at = UvOf(mid);
          float d = at.x < -0.5 ? 0.0 : textureLod(surface_texture, at, 0.0).a;
          if (d > 0.0 && DepthOf(mid) > d) {
            hi = mid;
          } else {
            lo = mid;
          }
        }
        vec2 hitUv = UvOf(hi);
        if (hitUv.x < -0.5) hitUv = uv;
        hitColor = textureLod(scene_texture, hitUv, 0.0).rgb;
        // Fade at the edges of the frame, and with the length of the ray: a
        // hit at the far end of the march weighs nothing, so the reflection
        // thins out instead of stopping where the march does (three.js's
        // `(1 - d / max)^2`).
        vec2 edge = abs(hitUv * 2.0 - 1.0);
        float border = 1.0 - max(edge.x, edge.y);
        float along = clamp(1.0 - travelled / reach, 0.0, 1.0);
        hit = smoothstep(0.0, 0.15, border) * along * along;
        break;
      }
    }

    march += ray * stride;
    travelled += stride;
  }

  // Schlick's Fresnel for a dielectric, F0 = 0.04: four percent head-on, all
  // of it at grazing. The buffer carries no metalness to tint it with. This
  // used to floor at fifteen percent with a fourth power, which put nearly
  // four times the reflection on a floor seen from above.
  float fresnel = 0.04 + 0.96 * pow(1.0 - facing, 5.0);
  vec3 reflection = hitColor * hit * intensity * polish * fresnel;
  // The share of the hit that is used, [intensity] included: a reflection
  // dialled down to seventy percent takes the sky's place in the same
  // seventy percent, or it would take the sky out and put less back.
  float confidence = hit * intensity * polish;
  // **A hit replaces the environment's reflection rather than adding to it.**
  // The lit colour already holds the environment's specular wherever the
  // scene has one: the metal-rough stage reflects the cube along this same
  // ray, prefiltered to this roughness. Adding the hit on top made every
  // reflected object a second reflection laid over the sky's, brighter than
  // the light there is and see-through where it should hide the sky behind
  // it. So the cube is read here the way the lit pass read it and taken
  // away in the share the hit is trusted, which leaves the environment
  // wherever the march found nothing. Weighted by this pass's Fresnel for
  // both, so the swap stays a swap; the lit pass's own weight differs a
  // little, and the difference is what stays of the sky.
  //
  // What is taken away is an estimate of what was added, read from the
  // scene's own cube: this pass cannot tell which pixels a probe lit instead,
  // so the renderer sends no levels while the scene has probes, and a surface
  // shaded by a model with no environment term (Lambert, Phong, toon) loses a
  // share it never had. Hence the clamp at nought, applied only when
  // something is taken, so a scene with no environment is the sum it was.
  float levels = reflection_info.environment.x;
  vec3 environment =
      levels > 0.0
          ? textureLod(environment_texture, ray, roughness * levels).rgb *
                reflection_info.environment.y
          : vec3(0.0);
  vec3 replaced = environment * confidence * fresnel;
  vec3 composed = levels > 0.0 ? max(scene + reflection - replaced, vec3(0.0))
                               : scene + reflection;
  frag_color = vec4(debugOnly ? reflection : composed, 1.0);
}

''',
    'Ssao': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// Screen-space ambient occlusion, read out of the surface buffer.
//
// What it measures is how much of the sky a point can see. That is the same
// question the hemispheric ambient in `lib/surface.glsl` answers by looking at
// the normal alone, and the reason the two belong together: ambient without
// occlusion lifts the inside of a corner exactly as much as the outside of one,
// and no amount of colour makes that read as light.
//
// **Nothing here needs a depth texture, and that is not a preference.**
// flutter_gpu cannot sample a depth attachment at all, so the engine's depth
// lives in the alpha channel of the surface buffer — as metres along the view
// axis, which is not what a depth buffer holds and is deliberate; see
// `WriteSurfaceGeometry` in `lib/color.glsl`. Every screen-space effect in this
// renderer is built on that one decision, and this stage inherits it rather
// than working around it.
//
// The cost that must be stated rather than discovered: reading the surface
// buffer turns MSAA off for the whole scene pass, because the average of two
// octahedral normals is not the encoding of any normal. Switching this on
// therefore changes the antialiasing of the entire frame, not just the shading
// in its corners.
precision highp float;

in vec2 v_uv;

layout(location = 0) out vec4 frag_color;

uniform sampler2D surface_texture;

layout(std140) uniform SsaoInfo {
  /// Screen to world, for turning a stored depth back into a point.
  ///
  /// Carries the framebuffer origin, as its partner below does — see
  /// [UvFromNdc].
  mat4 inverse_view_projection;

  /// World to screen, for finding where a sampled point lands.
  mat4 view_projection;

  /// x: radius in world metres. y: how many samples. w: bias in metres, which
  /// keeps a flat surface from occluding itself.
  ///
  /// z: one when `albedo_texture` holds the albedo buffer (`L5`); otherwise
  /// the strength's old slot, unused: the strength belongs to the composite, which is the pass that
  /// has to make "off" mean a multiplier of exactly one. It is left in place
  /// rather than removed so the block's layout does not depend on that staying
  /// true.
  vec4 params;

  /// x: 1/width, y: 1/height of *this* target, which is half the scene's.
  /// z: the method — nought the kernel below, one ground-truth horizon
  /// search, two the horizon search with indirect light (`L5`). w: the
  /// thickness the indirect method gives each sample, in metres.
  vec4 screen;

  /// xyz: where the eye is. w unused.
  ///
  /// Needed because the surface buffer holds a *depth along the view axis in
  /// metres* rather than a window depth — see `WriteSurfaceGeometry`. Turning
  /// one back into a point takes a ray and a plane rather than a matrix
  /// multiply, and this is where the ray starts.
  vec4 camera;

  /// xyz: the direction the camera looks, a unit vector in world space.
  /// w unused. The normal of the planes the stored depth measures against.
  vec4 forward;
}
ssao_info;

// --- lib/blue_noise.glsl ---
// A per-pixel offset for a march or a kernel rotation — `R3`.
//
// **The engine's blue noise while a temporal resolve runs, the fixed 4 × 4
// pattern otherwise.** A march jittered by a pattern that never changes puts
// the same dither on every frame, and the eye finds it; with the resolve on,
// each frame reads the next of 32 slices of blue noise and the history
// averages them into a smooth answer. Off, the pattern is exactly what the
// passes read before, so a frame without the resolve is the frame it was.
//
// The table is `EngineTables.blueNoise`: 32 slices of 64 × 64 in an 8 × 4
// atlas, one byte a texel. Read at texel centres through a nearest sampler.
//
// Include after `lib/frag_coord_info.glsl` or anything else that gives the
// pixel from the top.

#ifndef BLUE_NOISE_GLSL_
#define BLUE_NOISE_GLSL_

uniform sampler2D blue_noise_texture;

layout(std140) uniform NoiseInfo {
  /// x: one to read the blue noise, nought for the pattern. y: this frame's
  /// slice, the frame index modulo 32. zw unused.
  vec4 noise;
}
noise_info;

/// One cell of a 4 × 4 Bayer matrix, in [0, 1).
float BayerCell(vec2 at) {
  int x = int(mod(at.x, 4.0));
  int y = int(mod(at.y, 4.0));
  int index = y * 4 + x;
  float value = 0.0;
  if (index == 0) value = 0.0;
  else if (index == 1) value = 8.0;
  else if (index == 2) value = 2.0;
  else if (index == 3) value = 10.0;
  else if (index == 4) value = 12.0;
  else if (index == 5) value = 4.0;
  else if (index == 6) value = 14.0;
  else if (index == 7) value = 6.0;
  else if (index == 8) value = 3.0;
  else if (index == 9) value = 11.0;
  else if (index == 10) value = 1.0;
  else if (index == 11) value = 9.0;
  else if (index == 12) value = 15.0;
  else if (index == 13) value = 7.0;
  else if (index == 14) value = 13.0;
  else value = 5.0;
  return value / 16.0;
}

/// This frame's blue noise at the pixel [at], in [0, 1).
float BlueNoise(vec2 at) {
  float slice = noise_info.noise.y;
  vec2 cell = mod(floor(at), 64.0);
  vec2 corner = vec2(mod(slice, 8.0), floor(slice / 8.0)) * 64.0;
  vec2 uv = (corner + cell + 0.5) / vec2(512.0, 256.0);
  return textureLod(blue_noise_texture, uv, 0.0).r * (255.0 / 256.0);
}

/// The offset for the pixel [at]: blue noise or the pattern, per `noise.x`.
float PixelNoise(vec2 at) {
  return noise_info.noise.x > 0.5 ? BlueNoise(at) : BayerCell(at);
}

#endif  // BLUE_NOISE_GLSL_


vec3 DecodeOctahedral(vec2 e) {
  e = e * 2.0 - 1.0;
  vec3 n = vec3(e.xy, 1.0 - abs(e.x) - abs(e.y));
  float t = max(-n.z, 0.0);
  n.x += n.x >= 0.0 ? -t : t;
  n.y += n.y >= 0.0 ? -t : t;
  return normalize(n);
}

/// Where a point at clip-space [ndc] lands in the surface buffer.
///
/// **v runs the other way from y, and the matrix is what makes that true on
/// both backends** — the convention `lib/surface.glsl` reads shadow maps with,
/// and the one this pass should have had. `toFramebufferOrigin` negates y in
/// the matrices below for the backend whose row zero is at the bottom.
///
/// Written the other way round — `ndc * 0.5 + 0.5`, with unadjusted matrices —
/// this pass reconstructed the point at the pixel mirrored about the middle of
/// the frame and took its taps around that, on every backend but the browser.
vec2 UvFromNdc(vec2 ndc) {
  return vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
}

/// Where the depth stored for [uv] is, in the world.
///
/// **A ray crossing a plane**, rather than a matrix multiply. The buffer stores
/// metres along the view axis rather than a window depth, so the inverse matrix
/// is used to find the pixel's ray — both ends of it — and the stored depth
/// picks the point on that ray lying [depth] metres in front of the eye. What
/// that buys is precision: a window depth in a half float cannot tell twenty
/// metres from twenty and a half, which is what used to draw bands across every
/// wall.
///
/// **Both ends, rather than one and the camera**, and that is what makes it
/// true of an orthographic camera as well. Its rays do not meet at the eye;
/// they are parallel, and a reconstruction that starts every ray at the camera
/// position puts an isometric scene's geometry somewhere it is not. Two
/// unprojections cost one extra matrix multiply and are right either way.
///
/// y undoes [UvFromNdc]: a point projected and then reconstructed has to come
/// back where it started.
///
/// [PixelRay] is the ray on its own: where it starts and which way it goes.
/// Reversed it is also the way to the eye, for either camera — see [EyeWard].
void PixelRay(vec2 uv, out vec3 origin, out vec3 along) {
  vec2 xy = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
  vec4 nearH = ssao_info.inverse_view_projection * vec4(xy, 0.0, 1.0);
  vec4 farH = ssao_info.inverse_view_projection * vec4(xy, 1.0, 1.0);
  origin = nearH.xyz / nearH.w;
  along = normalize(farH.xyz / farH.w - origin);
}

vec3 WorldAtDepth(vec2 uv, float depth) {
  vec3 origin;
  vec3 along;
  PixelRay(uv, origin, along);
  vec3 axis = ssao_info.forward.xyz;
  return origin +
         along * ((depth - dot(origin - ssao_info.camera.xyz, axis)) /
                  dot(along, axis));
}

/// The way to the eye from what [uv] shows: the pixel's ray, reversed — the
/// zenith the horizon slices below are measured from.
///
/// **The ray rather than the camera position.** Under a perspective camera
/// the two agree, since every ray starts at the eye. An orthographic camera's
/// rays are parallel, so the samples along a screen line lie in the plane of
/// that line and the view axis; a zenith pointed at the camera position tilts
/// out of that plane towards the frame's edges, and the occlusion measured
/// against it drifted with the distance from the centre.
vec3 EyeWard(vec2 uv) {
  vec3 origin;
  vec3 along;
  PixelRay(uv, origin, along);
  return -along;
}

/// How deep [at] is, in the metres the buffer holds.
float DepthOf(vec3 at) {
  return dot(at - ssao_info.camera.xyz, ssao_info.forward.xyz);
}

/// Twelve directions on a hemisphere, as a fixed table.
///
/// A table rather than a hash of the fragment coordinate, and the reason is the
/// conformance suite rather than taste: the cross-backend budgets in this
/// repository are measured in hundredths of a per cent, and a float hash agrees
/// between a GPU and a software rasteriser nowhere. A table is the same twelve
/// numbers everywhere.
///
/// Lengths vary deliberately, packing more samples near the origin: occlusion
/// falls off with distance, so uniform spacing spends most of its taps where
/// they matter least.
vec3 KernelTap(int i) {
  if (i == 0) return vec3(0.5381, 0.1856, 0.4319);
  if (i == 1) return vec3(0.1379, 0.2486, 0.4430);
  if (i == 2) return vec3(0.3371, 0.5679, 0.0057);
  if (i == 3) return vec3(-0.6999, -0.0451, 0.0019);
  if (i == 4) return vec3(0.0689, -0.1598, -0.8547);
  if (i == 5) return vec3(0.0560, 0.0069, -0.1843);
  if (i == 6) return vec3(-0.0146, 0.1402, 0.0762);
  if (i == 7) return vec3(0.0100, -0.1924, -0.0344);
  if (i == 8) return vec3(-0.3577, -0.5301, -0.4358);
  if (i == 9) return vec3(-0.3169, 0.1063, 0.0158);
  if (i == 10) return vec3(0.0103, -0.5869, 0.0046);
  return vec3(-0.0897, -0.4940, 0.3287);
}

/// One of four rotations, chosen by the parity of the pixel.
///
/// Four constants rather than a random angle, for the same reason the kernel is
/// a table. It leaves a 2×2 pattern in the result, which is exactly what the
/// composite's 2×2 average cancels — the blur is sized to the artefact rather
/// than guessed at, and the two have to change together or neither works.
///
/// **An angle from the blue noise while a temporal resolve runs** — `R3`:
/// a different one each frame, which the occlusion's own history averages,
/// so the pattern the composite's blur was sized for is not there to cancel.
vec2 Rotation(vec2 uv) {
  vec2 pixel = floor(uv / ssao_info.screen.xy);
  if (noise_info.noise.x > 0.5) {
    float angle = 6.2831853 * BlueNoise(pixel);
    return vec2(cos(angle), sin(angle));
  }
  bool oddX = mod(pixel.x, 2.0) >= 1.0;
  bool oddY = mod(pixel.y, 2.0) >= 1.0;
  if (oddX && oddY) return vec2(-0.7071, -0.7071);
  if (oddX) return vec2(0.7071, -0.7071);
  if (oddY) return vec2(-0.7071, 0.7071);
  return vec2(1.0, 0.0);
}

/// How many pixels of this target [radius] metres span at [point] — the
/// reach of the horizon searches below.
///
/// **In pixels, and stepped in pixels**, because a pixel is the one unit
/// the projection keeps square. A uv unit is the target's width one way and
/// its height the other, so a radius measured across the screen and stepped
/// as uv reached only height/width of it up the screen — a little over half
/// on a landscape frame, and nearly twice too far on a phone held upright —
/// and slices spread evenly in uv were not spread evenly in angle.
///
/// Measured across the view, along a horizontal line through [point]; the
/// world up is swapped for x when the eye looks nearly straight along it.
float PixelRadius(vec3 point, vec3 view, float radius) {
  vec3 across = normalize(
      cross(view, abs(view.y) < 0.99 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0)));
  vec4 here = ssao_info.view_projection * vec4(point, 1.0);
  vec4 there = ssao_info.view_projection * vec4(point + across * radius, 1.0);
  return length((UvFromNdc(there.xy / there.w) - UvFromNdc(here.xy / here.w)) /
                ssao_info.screen.xy);
}

/// Ground-truth ambient occlusion (Jimenez et al. 2016) — `L5`.
///
/// Two slices through the point, turned by the pixel's noise; along each,
/// the highest horizon on either side within the radius, as the cosine of
/// its angle from the eye; and the cosine-weighted visibility between the
/// two horizons integrated in closed form against the normal projected into
/// the slice. What the kernel above estimates by twelve taps into a
/// hemisphere this answers per slice exactly, which is why its corners are
/// the right darkness rather than a matter of tuning.
///
/// The steps are the sample count spread over the two sides of two slices,
/// and falloff towards the radius is a smooth fade of each horizon back to
/// the eye's own, so a wall just past the radius does not snap in.
float GtaoVisibility(vec2 uv, vec3 point, vec3 normal) {
  vec3 view = EyeWard(uv);
  float radius = max(ssao_info.params.x, 1e-4);
  int steps = clamp(int(ssao_info.params.y + 0.5) / 4, 1, 4);
  float pixelRadius = PixelRadius(point, view, radius);

  vec2 pixel = floor(uv / ssao_info.screen.xy);
  float noise = PixelNoise(pixel);
  float depth = textureLod(surface_texture, uv, 0.0).a;

  float visibility = 0.0;
  float slices = 0.0;
  for (int slice = 0; slice < 2; slice++) {
    float phi = (float(slice) + noise) * 1.5707963;
    // One pixel along the slice, in uv: the angle is an angle on the screen.
    vec2 direction = vec2(cos(phi), sin(phi)) * ssao_info.screen.xy;

    // The slice's direction in the world: the same screen step taken at this
    // point's depth, with the eye's component removed.
    vec3 along = WorldAtDepth(uv + direction, depth) - point;
    vec3 tangent = along - view * dot(along, view);
    float tangentLength = length(tangent);
    if (tangentLength < 1e-6) continue;
    tangent /= tangentLength;
    vec3 axis = normalize(cross(tangent, view));
    vec3 projected = normal - axis * dot(normal, axis);
    float projectedLength = length(projected);
    if (projectedLength < 1e-4) continue;
    // The normal's angle from the eye, kept within a quarter turn of it: a
    // stored normal turned away from the eye — an interpolated one at a
    // smooth mesh's silhouette — would otherwise put a horizon on the wrong
    // side of the zenith, and the arc below would take visibility away.
    float n = sign(dot(projected, tangent)) *
              acos(clamp(dot(projected / projectedLength, view), 0.0, 1.0));

    float horizons[2];
    for (int side = 0; side < 2; side++) {
      float s = side == 0 ? -1.0 : 1.0;
      float best = -1.0;
      for (int i = 0; i < 4; i++) {
        if (i >= steps) break;
        float t = (float(i) + 0.5 + 0.5 * noise) / float(steps);
        vec2 at = uv + s * direction * pixelRadius * t;
        if (at.x < 0.0 || at.x > 1.0 || at.y < 0.0 || at.y > 1.0) continue;
        float d = textureLod(surface_texture, at, 0.0).a;
        if (d <= 0.0) continue;
        vec3 toSample = WorldAtDepth(at, d) - point;
        float distance = length(toSample);
        if (distance < 1e-5) continue;
        float cosine = dot(toSample / distance, view);
        float fade = clamp(1.0 - (distance * distance) / (radius * radius), 0.0,
                           1.0);
        best = max(best, mix(-1.0, cosine, fade));
      }
      horizons[side] = s * acos(clamp(best, -1.0, 1.0));
    }
    // Both horizons within the quarter turns either side of the normal, bound
    // from above and below alike.
    float h1 = n + clamp(horizons[0] - n, -1.5707963, 1.5707963);
    float h2 = n + clamp(horizons[1] - n, -1.5707963, 1.5707963);
    visibility += projectedLength * 0.25 *
                  ((-cos(2.0 * h1 - n) + cos(n) + 2.0 * h1 * sin(n)) +
                   (-cos(2.0 * h2 - n) + cos(n) + 2.0 * h2 * sin(n)));
    slices += 1.0;
  }
  return slices > 0.0 ? clamp(visibility / slices, 0.0, 1.0) : 1.0;
}

/// The lit scene, for the light the indirect method bounces — `L5`. Bound
/// to the scene's colour on every draw; read only by that method.
uniform sampler2D scene_texture;

/// The albedo buffer — `L5`: the receiving surface's own colour. A stand-in
/// when the device has none, which `params.z` says: then the indirect method
/// takes a neutral grey and the horizon method no bounces.
uniform sampler2D albedo_texture;

vec3 SrgbToLinearAlbedo(vec3 srgb) {
  return mix(srgb / 12.92, pow((srgb + vec3(0.055)) / 1.055, vec3(2.4)),
             step(vec3(0.04045), srgb));
}

/// The sectors a run from [low] to [high] covers, each in nought to one
/// across the slice's half circle: sixteen of them, four to a vector, one
/// where the run takes in a sector's centre and nought where it does not.
///
/// Floats rather than the bits of a `uint`: the OpenGL ES target impellerc
/// compiles for has no unsigned integers, and aborts on the shifts.
void SectorRun(float low, float high, out vec4 m0, out vec4 m1, out vec4 m2,
               out vec4 m3) {
  const vec4 base = vec4(0.5, 1.5, 2.5, 3.5) / 16.0;
  m0 = step(vec4(low), base) * step(base, vec4(high));
  m1 = step(vec4(low), base + 0.25) * step(base + 0.25, vec4(high));
  m2 = step(vec4(low), base + 0.5) * step(base + 0.5, vec4(high));
  m3 = step(vec4(low), base + 0.75) * step(base + 0.75, vec4(high));
}

/// Visibility with the light the surroundings pass back — `L5`: the fit of
/// Jimenez et al. 2016 to many bounces between surfaces of this [albedo],
/// taken per channel and brought to one number by Rec. 709 luma, since the
/// composite multiplies by one. A dark room stays as dark as the horizon
/// says; a white one gives back much of what the crease took.
float MultiBounce(float visible, vec3 albedo) {
  vec3 a = 2.0404 * albedo - 0.3324;
  vec3 b = -4.7951 * albedo + 0.6417;
  vec3 c = 2.7552 * albedo + 0.6903;
  vec3 v = vec3(visible);
  vec3 bounced = max(v, ((v * a + b) * v + c) * v);
  return dot(bounced, vec3(0.2126, 0.7152, 0.0722));
}

float SectorCount(vec4 m0, vec4 m1, vec4 m2, vec4 m3) {
  return dot(m0 + m1 + m2 + m3, vec4(1.0));
}

/// Screen-space indirect light with a visibility bitmask (Therrien et al.
/// 2023) — `L5`. rgb: the light that bounces onto this point off what it
/// sees, times its own albedo; a: the share of the hemisphere left open.
///
/// The slices and steps of [GtaoVisibility], but each sample is a slab of
/// the given thickness rather than a height field: its front and back
/// angles cover a run of 16 sectors across the slice, and only sectors no
/// nearer sample covered yet let its light through. So a thin pole shades
/// what is behind it and lets the light past it on either side, where a
/// horizon would have hidden everything behind the pole.
vec4 SsilLight(vec2 uv, vec3 point, vec3 normal) {
  vec3 view = EyeWard(uv);
  float radius = max(ssao_info.params.x, 1e-4);
  float thickness = max(ssao_info.screen.w, 1e-3);
  int steps = clamp(int(ssao_info.params.y + 0.5) / 4, 1, 4);
  float pixelRadius = PixelRadius(point, view, radius);

  vec2 pixel = floor(uv / ssao_info.screen.xy);
  float noise = PixelNoise(pixel);
  float depth = textureLod(surface_texture, uv, 0.0).a;

  vec3 light = vec3(0.0);
  float open = 0.0;
  float slices = 0.0;
  for (int slice = 0; slice < 2; slice++) {
    float phi = (float(slice) + noise) * 1.5707963;
    vec2 direction = vec2(cos(phi), sin(phi)) * ssao_info.screen.xy;
    vec3 along = WorldAtDepth(uv + direction, depth) - point;
    vec3 tangent = along - view * dot(along, view);
    float tangentLength = length(tangent);
    if (tangentLength < 1e-6) continue;
    tangent /= tangentLength;
    vec3 axis = normalize(cross(tangent, view));
    vec3 projected = normal - axis * dot(normal, axis);
    float projectedLength = length(projected);
    if (projectedLength < 1e-4) continue;
    float n = sign(dot(projected, tangent)) *
              acos(clamp(dot(projected / projectedLength, view), 0.0, 1.0));

    vec4 c0 = vec4(0.0);
    vec4 c1 = vec4(0.0);
    vec4 c2 = vec4(0.0);
    vec4 c3 = vec4(0.0);
    for (int side = 0; side < 2; side++) {
      float s = side == 0 ? -1.0 : 1.0;
      for (int i = 0; i < 4; i++) {
        if (i >= steps) break;
        float t = (float(i) + 0.5 + 0.5 * noise) / float(steps);
        vec2 at = uv + s * direction * pixelRadius * t;
        if (at.x < 0.0 || at.x > 1.0 || at.y < 0.0 || at.y > 1.0) continue;
        vec4 sampled = textureLod(surface_texture, at, 0.0);
        if (sampled.a <= 0.0) continue;
        vec3 front = WorldAtDepth(at, sampled.a) - point;
        if (length(front) > radius) continue;
        vec3 toward = normalize(front);
        vec3 back = front - view * thickness;
        // Angles from the eye, signed by the side, over the half circle
        // centred on the normal: nought at one end, one at the other.
        float a = s * acos(clamp(dot(toward, view), -1.0, 1.0));
        float b = s * acos(clamp(dot(normalize(back), view), -1.0, 1.0));
        float lowAngle = (min(a, b) - n + 1.5707963) / 3.1415927;
        float highAngle = (max(a, b) - n + 1.5707963) / 3.1415927;
        vec4 m0;
        vec4 m1;
        vec4 m2;
        vec4 m3;
        SectorRun(lowAngle, highAngle, m0, m1, m2, m3);
        float fresh = SectorCount(m0 * (1.0 - c0), m1 * (1.0 - c1),
                                  m2 * (1.0 - c2), m3 * (1.0 - c3));
        vec3 radiance = textureLod(scene_texture, at, 0.0).rgb;
        // Both ends' cosines: the receiver's to the sample, and the sample's
        // back to the receiver. Without the second, the lit top of a table
        // bled onto the floor it faces away from.
        float cosine = max(dot(normal, toward), 0.0) *
                       max(dot(DecodeOctahedral(sampled.rg), -toward), 0.0);
        light += radiance * cosine * fresh / 16.0;
        c0 = max(c0, m0);
        c1 = max(c1, m1);
        c2 = max(c2, m2);
        c3 = max(c3, m3);
      }
    }
    open += 1.0 - SectorCount(c0, c1, c2, c3) / 16.0;
    slices += 1.0;
  }
  float count = max(slices, 1.0);
  vec3 albedo = ssao_info.params.z > 0.5
                     ? SrgbToLinearAlbedo(textureLod(albedo_texture, uv, 0.0).rgb)
                     : vec3(0.5);
  // With no slice to measure, open and unlit — as a select: impellerc's
  // SPIR-V to Metal step aborts on a phi of constants.
  return slices > 0.0 ? vec4(light / count * albedo, open / count)
                      : vec4(0.0, 0.0, 0.0, 1.0);
}

void main() {
  vec4 surface = textureLod(surface_texture, v_uv, 0.0);

  // Nothing was drawn here. The buffer is cleared to zero and a zero alpha is
  // the sky, not a surface sitting on the near plane — the same test
  // `reflections.frag` makes, and for the same reason.
  if (surface.a <= 0.0) {
    // Open sky: nothing occludes it, and with the indirect method nothing
    // bounces onto it either.
    frag_color = ssao_info.screen.z > 1.5 ? vec4(0.0, 0.0, 0.0, 1.0)
                                           : vec4(1.0);
    return;
  }

  vec3 normal = DecodeOctahedral(surface.rg);

  if (ssao_info.screen.z > 1.5) {
    frag_color = SsilLight(v_uv, WorldAtDepth(v_uv, surface.a), normal);
    return;
  }
  if (ssao_info.screen.z > 0.5) {
    float visible =
        GtaoVisibility(v_uv, WorldAtDepth(v_uv, surface.a), normal);
    // With the albedo buffer, the bounces too; without it, the horizon alone.
    float shaded = ssao_info.params.z > 0.5
                       ? MultiBounce(visible, SrgbToLinearAlbedo(
                                                  textureLod(albedo_texture, v_uv, 0.0).rgb))
                       : visible;
    frag_color = vec4(shaded);
    return;
  }

  float radius = max(ssao_info.params.x, 1e-4);
  int samples = clamp(int(ssao_info.params.y + 0.5), 1, 12);

  // Lifted off the surface along its own normal, and this is where the bias
  // goes rather than into the depth comparison below. A bias in window depth is
  // a different number of millimetres at every distance from the camera —
  // that is what a projection matrix does — so a value tuned on a near wall
  // leaves acne on a far one. A metre is a metre anywhere.
  vec3 origin =
      WorldAtDepth(v_uv, surface.a) + normal * ssao_info.params.w;

  vec2 rot = Rotation(v_uv);
  float occluded = 0.0;

  for (int i = 0; i < 12; i++) {
    if (i >= samples) break;

    vec3 tap = KernelTap(i);
    // Rotated about the vertical axis of the kernel's own space, before it is
    // oriented to the surface: rotating afterwards would turn the hemisphere
    // off the normal and let taps fall behind the surface.
    vec3 spun =
        vec3(tap.x * rot.x - tap.y * rot.y, tap.x * rot.y + tap.y * rot.x, tap.z);

    // Flipped into the hemisphere the surface faces, rather than built from a
    // tangent frame. A frame needs a tangent, this pass has none, and any it
    // invented would rotate along a silhouette and shimmer.
    if (dot(spun, normal) < 0.0) spun = -spun;

    vec3 at = origin + spun * radius;

    vec4 clip = ssao_info.view_projection * vec4(at, 1.0);
    if (clip.w <= 0.0) continue;
    vec3 ndc = clip.xyz / clip.w;
    if (abs(ndc.x) > 1.0 || abs(ndc.y) > 1.0) continue;

    vec2 uv = UvFromNdc(ndc.xy);
    // **`textureLod` at level zero, for the same reason the march in
    // `reflections.frag` uses it.** Two `continue`s stand above this line, so
    // the invocations of a quad are not all here, and a WGSL backend refuses to
    // derive a mip level where they are not. The surface buffer is a
    // full-screen render target with one level, and this pass binds it
    // unfiltered besides, so level zero is the only level there has ever been
    // to read.
    vec4 there = textureLod(surface_texture, uv, 0.0);
    // The sky occludes nothing: a sample that lands on it is a sample looking
    // out of the scene, which is the opposite of being enclosed.
    if (there.a <= 0.0) continue;

    // Nearer to the eye than the point we sampled towards means something
    // stands between them. **Compared in metres**, which is what the buffer
    // holds: the same test in window depth is a comparison whose resolution
    // collapses with range, and at twenty metres a half float cannot separate
    // two surfaces half a metre apart. `reflections.frag` reached this
    // conclusion first and says so at more length.
    if (there.a >= DepthOf(at)) continue;

    // The range check, and the reason a version without one draws haloes: a
    // wall four metres behind a railing is nearer to the camera than every
    // sample taken around the railing, and would occlude all of them. Distance
    // measured in the world, because "four metres behind" is a world fact and
    // the depth buffer's answer to it depends on where the camera is.
    vec3 seen = WorldAtDepth(uv, there.a);
    occluded +=
        smoothstep(0.0, 1.0, radius / max(distance(seen, origin), 1e-4));
  }

  // Raw, with no strength applied. The strength lives in the composite, and it
  // lives in exactly one place on purpose: applied here as well it would be
  // squared, and — more to the point — "off" has to mean a multiplier of
  // exactly one, which is a property of the composite's `mix` rather than of
  // any arithmetic done here.
  frag_color = vec4(clamp(1.0 - occluded / float(samples), 0.0, 1.0));
}

''',
    'SsaoBlur': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// A depth-aware blur over the occlusion buffer — `gfx-32n`.
//
// **Why the occlusion needs one and the composite cannot give it.** The
// composite already averages a 2x2, and that is not a blur: the occlusion
// pass rotates its kernel by the parity of the pixel and the 2x2 averages
// exactly that pattern away. Widening it would smear the contact shadows the
// pass exists to draw, and the two are sized to each other on purpose. So the
// quality has to come from somewhere else, and that somewhere is a pass of
// its own over the occlusion buffer, before the composite reads it.
//
// **Depth-aware, because occlusion is the one signal a blur must not spread
// across a silhouette.** A plain blur pulls the dark of a corner out past the
// object that made it, which reads as a halo around every shape — the
// artefact that makes people switch ambient occlusion off. Each tap is
// weighted by how close its depth is to the centre's, so a tap on the other
// side of an edge contributes nothing.
//
// Depth comes from the surface buffer's alpha, which carries view-axis
// distance in metres — the same channel `ssao.frag` reconstructs positions
// from, and the reason this pass needs no depth attachment of its own.
//
// All four channels, since `L5`: the occlusion methods write their one number
// four times over, and the indirect one puts its light in rgb and what is left
// open in a, so a blur of the whole texel smooths both at once.

in vec2 v_uv;

layout(location = 0) out vec4 frag_color;

uniform sampler2D ao_texture;
uniform sampler2D surface_texture;

layout(std140) uniform SsaoBlurInfo {
  // x, y: one texel of the occlusion buffer. z: how many taps to each side,
  // 0 for none. w: how much difference in depth, in metres, halves a tap's
  // weight.
  vec4 params;
}
blur_info;

void main() {
  float taps = blur_info.params.z;
  vec4 centre = texture(ao_texture, v_uv);
  if (taps < 1.0) {
    frag_color = centre;
    return;
  }

  float centreDepth = texture(surface_texture, v_uv).a;
  float falloff = max(blur_info.params.w, 1e-4);

  vec4 total = centre;
  float weightSum = 1.0;
  // Bounded at eight to each side whatever the uniform says, the same rule
  // `ssao.frag`'s own sample loop keeps: a loop a uniform can lengthen
  // without limit is a hang rather than a slow frame.
  for (int i = 1; i <= 8; i++) {
    if (float(i) > taps) break;
    float offset = float(i);
    vec2 steps[4];
    steps[0] = vec2(blur_info.params.x * offset, 0.0);
    steps[1] = vec2(-blur_info.params.x * offset, 0.0);
    steps[2] = vec2(0.0, blur_info.params.y * offset);
    steps[3] = vec2(0.0, -blur_info.params.y * offset);

    for (int s = 0; s < 4; s++) {
      vec2 at = v_uv + steps[s];
      float depth = texture(surface_texture, at).a;
      // A tap across a silhouette is a tap from another surface, and the
      // whole reason this is depth-aware is that it must not count. The
      // weight falls off with the difference rather than cutting at a
      // threshold, so a curved surface does not band where the cut would be.
      // Relative to the centre's own depth, as XeGTAO's denoiser: a
      // difference that is a silhouette a metre away is one pixel's worth of
      // a floor at twenty.
      float closeness =
          exp(-abs(depth - centreDepth) / (falloff * max(centreDepth, 1e-3)));
      // And further taps count for less, which is what makes this a blur
      // rather than a box.
      float weight = closeness / offset;
      total += texture(ao_texture, at) * weight;
      weightSum += weight;
    }
  }

  frag_color = total / weightSum;
}

''',
    'ContactShadow': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// Contact shadows: a short march toward the light, in screen space —
// `gfx-76n`.
//
// **What a shadow map cannot do at any resolution.** A box resting on a plane
// meets it along a line, and the shadow that belongs at that line is a texel
// wide or less. Raise the map's resolution and the line moves closer to right
// without arriving; raise the bias enough to stop the acne a tight contact
// produces and the shadow detaches from the object entirely — which is the
// familiar look of a prop floating a centimetre above the floor. The gap is
// structural, and the answer everywhere is to stop asking the map about the
// first few centimetres and march the depth buffer instead.
//
// **Not contact *hardening*, which this repository already has.** `shadow.glsl`
// searches for blockers and sizes its penumbra from what it finds, so a shadow
// is sharp where its caster is close. That is a different thing with a
// confusingly similar name, and it is the grep hit that made a survey record
// this row as already done.
//
// **The march is the same arithmetic `ssao.frag` does**, with one direction
// instead of a hemisphere: reconstruct the surface point from the buffer's
// depth, step toward the light in world metres, project each step back into the
// buffer, and compare in metres. Everything about why — the reconstruction as a
// ray crossing a plane, the comparison in metres rather than window depth,
// `textureLod` at level zero — is written out there and holds here unchanged.
//
// The strength is not applied here. It lives in the composite, for the reason
// the occlusion's does: "off" has to mean a multiplier of exactly one, and that
// is a property of a `mix` in one place rather than of arithmetic in two.

// A fullscreen stage declares its own varying and its own output, the way
// every other pass in this directory does: `lib/color.glsl` is the mesh
// fragment's preamble and brings a surface this pass does not have.
// --- lib/frag_coord_info.glsl ---
// The target's orientation, for a full-screen pass.
//
// Its own block rather than a member of each pass's, so the renderer binds it
// in one place, `drawFullscreen`, for every stage that declares it — the
// contract answers false for a stage that does not, and a pass that adds a
// screen-space pattern later gets the right rows by including this file.

#ifndef FRAG_COORD_INFO_GLSL_
#define FRAG_COORD_INFO_GLSL_

// --- lib/frag_coord.glsl ---
// Where a fragment sits, counted from the top of its target on every backend.

#ifndef FRAG_COORD_GLSL_
#define FRAG_COORD_GLSL_

/// `gl_FragCoord.xy` with row zero at the top of the picture.
///
/// [rows] is the target's height where the backend's row zero is the bottom
/// of the picture, and zero where it is the top. WebGL2 is the first kind:
/// window coordinates start at the lower left, and the engine draws the
/// picture upright there rather than mirroring every projection. Metal,
/// WebGPU and the software rasteriser are the second.
///
/// **Why a pattern cares and a picture does not.** Every screen-space pattern
/// in the engine — the Bayer dither, the grain, the jitter a ray march starts
/// from, the rotation of a shadow kernel — is a function of the pixel's row.
/// Read from the bottom, the same frame gets the pattern turned upside down,
/// and a four-row Bayer cell lands on different rows unless the height is a
/// multiple of four. The picture underneath is identical; the pattern on top
/// of it is not, and a comparison across backends counts every pixel it
/// moved.
vec2 FragCoordFromTop(float rows) {
  return rows > 0.0 ? vec2(gl_FragCoord.x, rows - gl_FragCoord.y)
                    : gl_FragCoord.xy;
}

#endif  // FRAG_COORD_GLSL_


layout(std140) uniform FragCoordInfo {
  /// x: the target's rows when its row zero is the bottom of the picture,
  /// zero when it is the top — see [FragCoordFromTop]. yzw unused.
  vec4 origin;
}
frag_coord_info;

/// This fragment's position with row zero at the top of the target.
vec2 TargetFragCoord() {
  return FragCoordFromTop(frag_coord_info.origin.x);
}

#endif  // FRAG_COORD_INFO_GLSL_

// --- lib/blue_noise.glsl ---
// A per-pixel offset for a march or a kernel rotation — `R3`.
//
// **The engine's blue noise while a temporal resolve runs, the fixed 4 × 4
// pattern otherwise.** A march jittered by a pattern that never changes puts
// the same dither on every frame, and the eye finds it; with the resolve on,
// each frame reads the next of 32 slices of blue noise and the history
// averages them into a smooth answer. Off, the pattern is exactly what the
// passes read before, so a frame without the resolve is the frame it was.
//
// The table is `EngineTables.blueNoise`: 32 slices of 64 × 64 in an 8 × 4
// atlas, one byte a texel. Read at texel centres through a nearest sampler.
//
// Include after `lib/frag_coord_info.glsl` or anything else that gives the
// pixel from the top.

#ifndef BLUE_NOISE_GLSL_
#define BLUE_NOISE_GLSL_

uniform sampler2D blue_noise_texture;

layout(std140) uniform NoiseInfo {
  /// x: one to read the blue noise, nought for the pattern. y: this frame's
  /// slice, the frame index modulo 32. zw unused.
  vec4 noise;
}
noise_info;

/// One cell of a 4 × 4 Bayer matrix, in [0, 1).
float BayerCell(vec2 at) {
  int x = int(mod(at.x, 4.0));
  int y = int(mod(at.y, 4.0));
  int index = y * 4 + x;
  float value = 0.0;
  if (index == 0) value = 0.0;
  else if (index == 1) value = 8.0;
  else if (index == 2) value = 2.0;
  else if (index == 3) value = 10.0;
  else if (index == 4) value = 12.0;
  else if (index == 5) value = 4.0;
  else if (index == 6) value = 14.0;
  else if (index == 7) value = 6.0;
  else if (index == 8) value = 3.0;
  else if (index == 9) value = 11.0;
  else if (index == 10) value = 1.0;
  else if (index == 11) value = 9.0;
  else if (index == 12) value = 15.0;
  else if (index == 13) value = 7.0;
  else if (index == 14) value = 13.0;
  else value = 5.0;
  return value / 16.0;
}

/// This frame's blue noise at the pixel [at], in [0, 1).
float BlueNoise(vec2 at) {
  float slice = noise_info.noise.y;
  vec2 cell = mod(floor(at), 64.0);
  vec2 corner = vec2(mod(slice, 8.0), floor(slice / 8.0)) * 64.0;
  vec2 uv = (corner + cell + 0.5) / vec2(512.0, 256.0);
  return textureLod(blue_noise_texture, uv, 0.0).r * (255.0 / 256.0);
}

/// The offset for the pixel [at]: blue noise or the pattern, per `noise.x`.
float PixelNoise(vec2 at) {
  return noise_info.noise.x > 0.5 ? BlueNoise(at) : BayerCell(at);
}

#endif  // BLUE_NOISE_GLSL_


in vec2 v_uv;

layout(location = 0) out vec4 frag_color;

uniform sampler2D surface_texture;

layout(std140) uniform ContactShadowInfo {
  /// Screen to world, for turning a stored depth back into a point.
  mat4 inverse_view_projection;

  /// World to screen, for finding where a marched point lands.
  mat4 view_projection;

  /// x: how far to march, in world metres. y: how many steps.
  /// z: how thick an occluder is assumed to be, in metres — a surface nearer
  /// than the ray by more than this is something else in front rather than the
  /// thing casting. w: bias in metres, which lifts the ray off its own surface.
  vec4 params;

  /// xyz: where the eye is. w unused.
  vec4 camera;

  /// xyz: the direction the camera looks, a unit vector. The normal of the
  /// planes the stored depth measures against.
  vec4 forward;

  /// xyz: the direction *to* the light, a unit vector in world space — the
  /// reverse of the direction a directional light points. w unused.
  ///
  /// One light and not eight. A march is a march per light, and eight of them
  /// per pixel is a different pass with a different budget; the sun is the one
  /// whose contact is missing from a shadow map that has to cover a level.
  vec4 to_light;
}
contact_info;

vec3 DecodeOctahedral(vec2 e) {
  e = e * 2.0 - 1.0;
  vec3 n = vec3(e.xy, 1.0 - abs(e.x) - abs(e.y));
  float t = max(-n.z, 0.0);
  n.x += n.x >= 0.0 ? -t : t;
  n.y += n.y >= 0.0 ? -t : t;
  return normalize(n);
}

/// Where a point at clip-space [ndc] lands in the surface buffer.
///
/// v runs the other way from y, and the matrices carry the framebuffer origin
/// — `ssao.frag` says at length what going the other way costs.
vec2 UvFromNdc(vec2 ndc) {
  return vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
}

/// Where the depth stored for [uv] is, in the world.
vec3 WorldAtDepth(vec2 uv, float depth) {
  vec2 xy = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
  vec4 nearH = contact_info.inverse_view_projection * vec4(xy, 0.0, 1.0);
  vec4 farH = contact_info.inverse_view_projection * vec4(xy, 1.0, 1.0);
  vec3 origin = nearH.xyz / nearH.w;
  vec3 along = normalize(farH.xyz / farH.w - origin);
  vec3 axis = contact_info.forward.xyz;
  return origin +
         along * ((depth - dot(origin - contact_info.camera.xyz, axis)) /
                  dot(along, axis));
}

/// How deep [at] is, in the metres the buffer holds.
float DepthOf(vec3 at) {
  return dot(at - contact_info.camera.xyz, contact_info.forward.xyz);
}

void main() {
  vec4 surface = texture(surface_texture, v_uv);

  // Nothing was drawn here. The buffer is cleared to zero and a zero alpha is
  // the sky, not a surface sitting on the near plane.
  if (surface.a <= 0.0) {
    frag_color = vec4(1.0);
    return;
  }

  vec3 normal = DecodeOctahedral(surface.rg);
  vec3 toLight = normalize(contact_info.to_light.xyz);

  // A surface already facing away from the light is unlit by the light term
  // itself, and marching from it would find its own far side. Returning one
  // leaves it to the lighting, which is the half that knows about the normal.
  if (dot(normal, toLight) <= 0.0) {
    frag_color = vec4(1.0);
    return;
  }

  float reach = max(contact_info.params.x, 1e-4);
  int steps = clamp(int(contact_info.params.y + 0.5), 1, 16);
  float thickness = max(contact_info.params.z, 1e-4);

  // Lifted along the normal, in metres, for `ssao.frag`'s reason: a bias in
  // window depth is a different number of millimetres at every distance.
  vec3 origin = WorldAtDepth(v_uv, surface.a) + normal * contact_info.params.w;
  float stride = reach / float(steps);

  // **Jittered by a Bayer cell**, as Unreal's march is by its dither and
  // Bend's by its offsets: eight fixed steps otherwise quantise the fade
  // below into eight flat levels, a staircase across every penumbra. Each
  // sample lands somewhere in its own step rather than at its end. A pattern
  // rather than a hash so the software backend matches bit for bit.
  float jitter = PixelNoise(TargetFragCoord());

  // **A tolerance at least twice what one step moves in depth**, as Unreal's
  // `CompareTolerance`: a ray running steeply away from the camera crosses
  // more depth per step than a fixed thickness, and a thin blocker passed
  // between two samples was never found.
  float stepDepth = abs(DepthOf(origin + toLight * stride) - DepthOf(origin));
  float tolerance = max(thickness, 2.0 * stepDepth);

  for (int i = 0; i < 16; i++) {
    if (i >= steps) break;

    float along = float(i) + 1.0 - jitter;
    vec3 at = origin + toLight * (stride * along);
    vec4 clip = contact_info.view_projection * vec4(at, 1.0);
    // Behind the eye: the march has left the frame, and a division by a
    // negative w would fold it back into view somewhere it is not.
    if (clip.w <= 0.0) break;
    vec2 uv = UvFromNdc(clip.xy / clip.w);
    // Off the edge of the buffer. Nothing is known out there, and guessing
    // would put a dark rim around every frame.
    if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) break;

    vec4 there = textureLod(surface_texture, uv, 0.0);
    if (there.a <= 0.0) continue;

    float marched = DepthOf(at);
    float gap = marched - there.a;
    // In front of the ray, and not so far in front that it is a different
    // object seen past the one casting: without the thickness test a wall four
    // metres nearer than the floor shadows everything the ray crosses, which
    // is the same halo `ssao.frag`'s range check exists to stop.
    if (gap > 0.0 && gap < tolerance) {
      // **Darker the nearer the blocker, which is what makes this a contact
      // shadow rather than a stencil.** A hit on the first step is a surface
      // touching this one and gets nothing; a hit at the far end of the march
      // is most of a metre away and barely counts. Without the fade the pass
      // writes zero or one and the march's own reach becomes a visible edge on
      // the floor — a hard band that ends where the loop does, which is a
      // number in a settings object rather than anything in the scene.
      frag_color = vec4(max(along - 1.0, 0.0) / float(steps));
      return;
    }
  }

  frag_color = vec4(1.0);
}

''',
    'CameraVelocity': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// How far each pixel moved on screen since the last frame, because the
// camera moved — `R1`.
//
// **Reconstructed rather than drawn.** Everything that stood still in the
// world moved on screen only because the camera did, and for those pixels
// the answer is arithmetic: take the point the surface buffer stored, carry
// it through last frame's view-projection, and the difference between where
// it lands and where it is now is its motion. The object pass draws over
// this only where a node itself moved.
//
// **Both matrices are the unjittered ones.** The resolve wants the motion of
// the picture, and the jitter is a deliberate wobble of the sampling grid
// that the history averages out; counting it as motion would make a still
// scene reproject by a fraction of a pixel every frame and never settle.
//
// The answer is in UV units, now minus then: the resolve reads history at
// `v_uv - velocity`. Red and green carry it; blue is zero and alpha one, so
// the target reads back as a picture.

in vec2 v_uv;

layout(location = 0) out vec4 frag_color;

uniform sampler2D surface_texture;

layout(std140) uniform CameraVelocityInfo {
  /// This frame's screen to world, for turning a stored depth into a point.
  /// Carries the framebuffer origin, as `ContactShadowInfo`'s does.
  mat4 inverse_view_projection;

  /// Last frame's world to screen, with the same origin.
  mat4 previous_view_projection;

  /// xyz: where the eye is now.
  vec4 camera;

  /// xyz: the direction the camera looks now, a unit vector.
  vec4 forward;
}
velocity_info;

vec2 UvFromNdc(vec2 ndc) {
  return vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
}

void main() {
  vec4 surface = texture(surface_texture, v_uv);

  vec2 xy = vec2(v_uv.x * 2.0 - 1.0, 1.0 - v_uv.y * 2.0);
  vec4 nearH = velocity_info.inverse_view_projection * vec4(xy, 0.0, 1.0);
  vec4 farH = velocity_info.inverse_view_projection * vec4(xy, 1.0, 1.0);
  vec3 origin = nearH.xyz / nearH.w;
  vec3 along = normalize(farH.xyz / farH.w - origin);

  // The sky is at infinity, so only the camera's turning moves it: a
  // direction carried through last frame's matrix with w zero, which drops
  // the translation exactly.
  vec4 then;
  if (surface.a <= 0.0) {
    then = velocity_info.previous_view_projection * vec4(along, 0.0);
  } else {
    vec3 axis = velocity_info.forward.xyz;
    vec3 world =
        origin +
        along * ((surface.a - dot(origin - velocity_info.camera.xyz, axis)) /
                 dot(along, axis));
    then = velocity_info.previous_view_projection * vec4(world, 1.0);
  }

  // Behind last frame's eye: nothing on screen then to reproject from, and
  // no motion is the answer that leaves the resolve to reject the history
  // by depth instead.
  if (then.w <= 0.0) {
    frag_color = vec4(0.0, 0.0, 0.0, 1.0);
    return;
  }
  frag_color = vec4(v_uv - UvFromNdc(then.xy / then.w), 0.0, 1.0);
}

''',
    'Velocity': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// A moved node's velocity, from the two clip positions its vertex stage
// wrote — `R1`. Now minus then, in UV, as `camera_velocity.frag` writes it.
//
// Divided per fragment rather than per vertex: the perspective divide does
// not interpolate linearly across a triangle, and a velocity divided at the
// corners would bend across a large polygon seen at a slant.
//
// A fragment behind what the scene drew at this pixel is dropped, which is
// the depth test done against the surface buffer — see `lib/velocity.glsl`.

// --- lib/frag_coord.glsl ---
// Where a fragment sits, counted from the top of its target on every backend.

#ifndef FRAG_COORD_GLSL_
#define FRAG_COORD_GLSL_

/// `gl_FragCoord.xy` with row zero at the top of the picture.
///
/// [rows] is the target's height where the backend's row zero is the bottom
/// of the picture, and zero where it is the top. WebGL2 is the first kind:
/// window coordinates start at the lower left, and the engine draws the
/// picture upright there rather than mirroring every projection. Metal,
/// WebGPU and the software rasteriser are the second.
///
/// **Why a pattern cares and a picture does not.** Every screen-space pattern
/// in the engine — the Bayer dither, the grain, the jitter a ray march starts
/// from, the rotation of a shadow kernel — is a function of the pixel's row.
/// Read from the bottom, the same frame gets the pattern turned upside down,
/// and a four-row Bayer cell lands on different rows unless the height is a
/// multiple of four. The picture underneath is identical; the pattern on top
/// of it is not, and a comparison across backends counts every pixel it
/// moved.
vec2 FragCoordFromTop(float rows) {
  return rows > 0.0 ? vec2(gl_FragCoord.x, rows - gl_FragCoord.y)
                    : gl_FragCoord.xy;
}

#endif  // FRAG_COORD_GLSL_


in vec4 v_current;
in vec4 v_previous;
in float v_depth;

layout(location = 0) out vec4 frag_color;

uniform sampler2D surface_texture;

layout(std140) uniform VelocityInfo {
  /// xy: one over the target's size in pixels. z: the target's rows when
  /// its row zero is the bottom, zero when it is the top — see
  /// `FragCoordFromTop`. w: how far behind the stored depth a fragment may
  /// lie and still count as the surface, as a fraction of that depth.
  vec4 target;
}
velocity_info;

vec2 UvFromClip(vec4 clip) {
  vec2 ndc = clip.xy / clip.w;
  return vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
}

void main() {
  vec2 uv = FragCoordFromTop(velocity_info.target.z) * velocity_info.target.xy;
  float stored = textureLod(surface_texture, uv, 0.0).a;
  // Sky, or something nearer: this fragment is not what the pixel shows.
  // The tolerance is relative, for the reason every comparison against this
  // buffer is: a fixed one is a different share of a pixel at every range.
  if (stored <= 0.0 ||
      v_depth > stored * (1.0 + velocity_info.target.w) + 1e-3) {
    discard;
  }
  if (v_previous.w <= 0.0) {
    frag_color = vec4(0.0, 0.0, 0.0, 1.0);
    return;
  }
  frag_color = vec4(UvFromClip(v_current) - UvFromClip(v_previous), 0.0, 1.0);
}

''',
    'Reactive': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// A blended surface, marked as reactive in the velocity target's blue — `R4`.
//
// **What the mark is for.** The temporal resolve keeps most of each pixel
// from the frames before, and that is right for anything the velocity can
// follow. Glass cannot be followed: it wrote no depth, so the velocity under
// it is the motion of what is behind it, and whatever moves across the glass
// itself (a reflection, the light it tints) is remembered for a dozen frames
// after it went. Where this is drawn the resolve keeps less of the past, in
// proportion to the value written here.
//
// Drawn through the three velocity vertex stages, so a skinned, morphed or
// batched surface lands where the scene drew it; of what they hand on only
// the depth along the camera's axis is read. The other two are declared
// because a stage's inputs are matched to the vertex stage's outputs by
// position on some targets, and a stage that declared the depth alone would
// read `v_current` in its place.
//
// Added into blue under an additive blend that finds red, green and alpha
// the velocity passes left and adds nought to each, which leaves them exactly
// as they were: flutter_gpu has no colour write mask, and adding zero is the
// one blend that is a mask.

// --- lib/frag_coord.glsl ---
// Where a fragment sits, counted from the top of its target on every backend.

#ifndef FRAG_COORD_GLSL_
#define FRAG_COORD_GLSL_

/// `gl_FragCoord.xy` with row zero at the top of the picture.
///
/// [rows] is the target's height where the backend's row zero is the bottom
/// of the picture, and zero where it is the top. WebGL2 is the first kind:
/// window coordinates start at the lower left, and the engine draws the
/// picture upright there rather than mirroring every projection. Metal,
/// WebGPU and the software rasteriser are the second.
///
/// **Why a pattern cares and a picture does not.** Every screen-space pattern
/// in the engine — the Bayer dither, the grain, the jitter a ray march starts
/// from, the rotation of a shadow kernel — is a function of the pixel's row.
/// Read from the bottom, the same frame gets the pattern turned upside down,
/// and a four-row Bayer cell lands on different rows unless the height is a
/// multiple of four. The picture underneath is identical; the pattern on top
/// of it is not, and a comparison across backends counts every pixel it
/// moved.
vec2 FragCoordFromTop(float rows) {
  return rows > 0.0 ? vec2(gl_FragCoord.x, rows - gl_FragCoord.y)
                    : gl_FragCoord.xy;
}

#endif  // FRAG_COORD_GLSL_


in vec4 v_current;
in vec4 v_previous;
in float v_depth;

layout(location = 0) out vec4 frag_color;

uniform sampler2D surface_texture;

layout(std140) uniform ReactiveInfo {
  /// xy: one over the target's size in pixels. z: the target's rows when
  /// its row zero is the bottom, zero when it is the top. w: how far behind
  /// the stored depth a fragment may lie and still count as in front of it,
  /// as a fraction of that depth.
  vec4 target;

  /// xyz: where the eye is. Read by the sprite stage, which has no depth of
  /// its own handed on.
  vec4 eye;

  /// xyz: the direction the camera looks, the surface buffer's axis.
  vec4 forward;

  /// x: how reactive full coverage is, nought to one. y: the sprite stage's
  /// shape — nought a disc, one a Gaussian, two the sprite's own alpha.
  /// z: this draw's coverage, for a surface: its material's alpha.
  vec4 params;
}
reactive_info;

void main() {
  vec2 uv = FragCoordFromTop(reactive_info.target.z) * reactive_info.target.xy;
  float stored = textureLod(surface_texture, uv, 0.0).a;
  // Behind what the opaque scene drew here: the glass is hidden and so is
  // whatever it would have smeared. Over the sky, or over a pixel the blend
  // left no depth in, it is in front of everything there is.
  bool hidden = stored > 0.0 &&
                v_depth > stored * (1.0 + reactive_info.target.w) + 1e-3;
  float coverage = reactive_info.params.x * reactive_info.params.z;
  if (hidden || coverage <= 0.0) discard;
  frag_color = vec4(0.0, 0.0, coverage, 0.0);
}

''',
    'ReactiveSprite': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// A particle or a splat, marked as reactive in the velocity target's blue —
// `R4`. See `reactive.frag` for what the mark is for; this is the same mark
// for what `particle.vert` draws.
//
// **Particles have no motion the resolve can see.** They write no depth and
// no velocity, so an ember that flew across a wall is reprojected as the
// wall, the history there is the wall, and the neighbourhood clip lets most
// of that wall through: the ember shows at a tenth of its brightness and
// trails. Marked here, the resolve takes that pixel mostly from this frame.
//
// **How much of a pixel a sprite covers**, which is what is written, comes
// from the same falloff its own stage draws with, so a spark's soft edge is
// only a little reactive and its core is fully so:
//
//   * a disc — `particle.frag`'s squared smoothstep, times the particle's
//     alpha;
//   * a Gaussian — `splat.frag`'s falloff, cut off at the same three
//     standard deviations, times the splat's alpha;
//   * a sprite — the texture's alpha, times the particle's.
//
// All three are worked out and one is chosen, rather than branching into
// one: the texture read has to sit in uniform control flow for WGSL, and the
// sprite stage is bound a white texel when the draw has none.

// --- lib/frag_coord.glsl ---
// Where a fragment sits, counted from the top of its target on every backend.

#ifndef FRAG_COORD_GLSL_
#define FRAG_COORD_GLSL_

/// `gl_FragCoord.xy` with row zero at the top of the picture.
///
/// [rows] is the target's height where the backend's row zero is the bottom
/// of the picture, and zero where it is the top. WebGL2 is the first kind:
/// window coordinates start at the lower left, and the engine draws the
/// picture upright there rather than mirroring every projection. Metal,
/// WebGPU and the software rasteriser are the second.
///
/// **Why a pattern cares and a picture does not.** Every screen-space pattern
/// in the engine — the Bayer dither, the grain, the jitter a ray march starts
/// from, the rotation of a shadow kernel — is a function of the pixel's row.
/// Read from the bottom, the same frame gets the pattern turned upside down,
/// and a four-row Bayer cell lands on different rows unless the height is a
/// multiple of four. The picture underneath is identical; the pattern on top
/// of it is not, and a comparison across backends counts every pixel it
/// moved.
vec2 FragCoordFromTop(float rows) {
  return rows > 0.0 ? vec2(gl_FragCoord.x, rows - gl_FragCoord.y)
                    : gl_FragCoord.xy;
}

#endif  // FRAG_COORD_GLSL_


in vec4 v_color;
in vec2 v_uv;
in vec3 v_world_position;

layout(location = 0) out vec4 frag_color;

uniform sampler2D surface_texture;
uniform sampler2D sprite_texture;

/// The same block `reactive.frag` declares, member for member.
layout(std140) uniform ReactiveInfo {
  vec4 target;
  vec4 eye;
  vec4 forward;
  vec4 params;
}
reactive_info;

void main() {
  float spriteAlpha = texture(sprite_texture, v_uv).a;

  vec2 centred = v_uv * 2.0 - 1.0;
  float falloff = 1.0 - smoothstep(0.0, 1.0, length(centred));
  float disc = falloff * falloff;

  float power = -0.5 * dot(v_uv, v_uv);
  float gaussian = power < -4.5 ? 0.0 : exp(power);

  float shape = reactive_info.params.y;
  float coverage =
      v_color.a *
      (shape < 0.5 ? disc : (shape < 1.5 ? gaussian : spriteAlpha));

  vec2 uv = FragCoordFromTop(reactive_info.target.z) * reactive_info.target.xy;
  float stored = textureLod(surface_texture, uv, 0.0).a;
  float depth = dot(v_world_position - reactive_info.eye.xyz,
                    reactive_info.forward.xyz);
  bool hidden = stored > 0.0 &&
                depth > stored * (1.0 + reactive_info.target.w) + 1e-3;
  float reactive = reactive_info.params.x * coverage;
  if (hidden || reactive <= 0.0) discard;
  frag_color = vec4(0.0, 0.0, reactive, 0.0);
}

''',
    'TemporalResolve': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// The temporal resolve: this frame's jittered scene blended into the history
// of the frames before it, at the output's size — `R2`.
//
// Per output pixel:
//
//   * **This frame's colour** is read where the jitter put the pixel's centre,
//     so a still picture's samples land on sixteen different sub-pixel
//     positions over sixteen frames and the history averages them.
//   * **The motion** is the velocity of the nearest surface in the 3 × 3
//     scene texels around it. The nearest rather than the centre's, so an
//     edge follows the object in front and does not smear the background
//     over it.
//   * **Last frame's colour** is read from the history where the motion says
//     the pixel was, through a Catmull-Rom filter, which keeps a moving
//     picture from going soft the way a bilinear read of a bilinear read
//     does.
//   * **The history is clipped** to the colours this frame's neighbourhood
//     spans, as a box of mean ± 1.25 σ in YCoCg, so something that was there
//     and is not any more is not remembered. Clipped in a weighted space —
//     each colour divided by one plus its exposed luminance — so a single
//     bright texel does not stretch the box for everything around it.
//     With `TemporalClip` set to a k-DOP (`N4`), the box gives way to k/2
//     slabs along optimised axes that hug the nine colours, and the history
//     moves along the line to this frame's colour until it is inside every
//     slab: a colour of the right brightness and the wrong hue, which sits
//     inside the box, is outside the k-DOP.
//   * **The history is dropped** where the nearest surface there last frame
//     was at a different depth: a pixel that was the floor and is now a crate
//     has no past worth blending. The history's alpha is that depth, and
//     each of the four texels around the reprojected point is tested on its
//     own rather than their blend, which on a silhouette is neither depth.
//   * **The blend** weighs each side by one over one plus its exposed
//     luminance, so a flickering highlight does not dominate its neighbours.
//   * **A reactive pixel keeps less history** — `R4`. Particles, splats and
//     blended surfaces write how much of the pixel they cover into the
//     velocity's blue, and the history's share is lowered by that fraction:
//     what moves without a velocity of its own is taken from this frame.
//     Blue is nought wherever nothing reactive was drawn, and the share is
//     then exactly what it was.
//
// The history is linear scene light, like the scene: the exposure is only a
// weight here, and the composite applies it as it always did.

in vec2 v_uv;

layout(location = 0) out vec4 frag_color;

uniform sampler2D scene_texture;
uniform sampler2D history_texture;
uniform sampler2D velocity_texture;
uniform sampler2D surface_texture;

layout(std140) uniform TemporalInfo {
  /// xy: one over the scene's size in pixels. zw: the scene's size.
  vec4 scene_texel;

  /// xy: this frame's jitter as an offset in UV. z: how much of each pixel
  /// is history, nought to one. w: one when there is a history to blend,
  /// nought on the first frame and after a cut.
  vec4 jitter;

  /// x: the exposure, for the weights. y: how far apart two depths may be,
  /// as a fraction of the nearer, and still be one surface. zw: the output's
  /// size in pixels, which the history has.
  vec4 params;

  /// x: how many of [clip_axes] bound the neighbourhood — `N4`. Nought is
  /// the box, the resolve's clip before them.
  vec4 clip;

  /// The k-DOP's axes in weighted YCoCg, xyz; the first `clip.x` are used.
  vec4 clip_axes[16];
}
temporal_info;

float Luma(vec3 c) { return dot(c, vec3(0.2126, 0.7152, 0.0722)); }

vec3 Weigh(vec3 c) {
  return c / (1.0 + Luma(c) * temporal_info.params.x);
}

vec3 Unweigh(vec3 c) {
  return c / max(1.0 - Luma(c) * temporal_info.params.x, 1e-4);
}

vec3 RgbToYCoCg(vec3 c) {
  return vec3(0.25 * c.r + 0.5 * c.g + 0.25 * c.b,
              0.5 * c.r - 0.5 * c.b,
              -0.25 * c.r + 0.5 * c.g - 0.25 * c.b);
}

vec3 YCoCgToRgb(vec3 c) {
  return vec3(c.x + c.y - c.z, c.x + c.z, c.x - c.y - c.z);
}

/// [q] pulled towards the box's centre until it lies inside it.
vec3 ClipToBox(vec3 lo, vec3 hi, vec3 q) {
  vec3 centre = 0.5 * (hi + lo);
  vec3 extent = 0.5 * (hi - lo) + vec3(1e-5);
  vec3 v = q - centre;
  vec3 units = abs(v / extent);
  float most = max(units.x, max(units.y, units.z));
  return most > 1.0 ? centre + v / most : q;
}

/// [history] moved along the line to [current] until it lies inside every
/// slab the neighbourhood [around] spans along the first `clip.x` axes.
///
/// Each slab is tightened the way the box is, to the projections' mean
/// ± 1.25σ inside their min–max, so that along the box's own three axes the
/// k-DOP is never looser than the box and one bright texel stretches
/// neither. Then it is widened to take in [current] itself, which is read
/// between texels and can sit outside the nine: the line then always starts
/// inside, and the answer is how far along it the first slab is left.
vec3 ClipToDop(vec3 current, vec3 history, vec3 around[9]) {
  vec3 toward = history - current;
  float reach = 1.0;
  for (int a = 0; a < 16; a++) {
    if (float(a) < temporal_info.clip.x) {
      vec3 axis = temporal_info.clip_axes[a].xyz;
      float at = dot(current, axis);
      float lowest = 1e30;
      float highest = -1e30;
      float sum = 0.0;
      float sumSquares = 0.0;
      for (int n = 0; n < 9; n++) {
        float p = dot(around[n], axis);
        lowest = min(lowest, p);
        highest = max(highest, p);
        sum += p;
        sumSquares += p * p;
      }
      float mean = sum / 9.0;
      float sigma = sqrt(max(sumSquares / 9.0 - mean * mean, 0.0));
      float lo = min(max(lowest, mean - 1.25 * sigma), at);
      float hi = max(min(highest, mean + 1.25 * sigma), at);
      float along = dot(toward, axis);
      // Selects rather than branches returning constants: SPIRV-Cross will
      // not take a phi of them.
      float leave = along > 1e-8 ? (hi - at) / along
                  : (along < -1e-8 ? (lo - at) / along : 1.0);
      reach = min(reach, leave);
    }
  }
  return current + toward * max(reach, 0.0);
}

/// One when a depth last frame [then] is the surface at [depth] — both sky,
/// or both surfaces within `params.y` of the nearer — and nought when not.
float SameSurface(float depth, float then) {
  bool sky = depth <= 0.0;
  bool bothSky = sky && then <= 0.0;
  bool close = !sky && then > 0.0 &&
      abs(then - depth) <= temporal_info.params.y * min(depth, then);
  return (bothSky || close) ? 1.0 : 0.0;
}

/// How much of the history at [uv] is the surface at [depth]: each of the
/// four texels a bilinear read there would blend is tested on its own, and
/// those that pass count by their bilinear weight. The depths themselves are
/// never blended — across a silhouette that is a depth belonging to neither
/// side, and it would drop the history of both. The four are read at texel
/// centres, where the colour's filtered sampler returns a texel as it is.
float DepthTrust(float depth, vec2 uv) {
  vec2 size = temporal_info.params.zw;
  vec2 position = uv * size - 0.5;
  vec2 corner = floor(position);
  vec2 f = position - corner;
  vec2 at0 = (corner + 0.5) / size;
  vec2 at1 = (corner + 1.5) / size;
  float d00 = textureLod(history_texture, at0, 0.0).a;
  float d10 = textureLod(history_texture, vec2(at1.x, at0.y), 0.0).a;
  float d01 = textureLod(history_texture, vec2(at0.x, at1.y), 0.0).a;
  float d11 = textureLod(history_texture, at1, 0.0).a;
  return SameSurface(depth, d00) * (1.0 - f.x) * (1.0 - f.y) +
      SameSurface(depth, d10) * f.x * (1.0 - f.y) +
      SameSurface(depth, d01) * (1.0 - f.x) * f.y +
      SameSurface(depth, d11) * f.x * f.y;
}

/// Catmull-Rom over the history, in nine bilinear taps.
vec3 HistoryAt(vec2 uv) {
  vec2 size = temporal_info.params.zw;
  vec2 position = uv * size;
  vec2 centre1 = floor(position - 0.5) + 0.5;
  vec2 f = position - centre1;
  vec2 w0 = f * (-0.5 + f * (1.0 - 0.5 * f));
  vec2 w1 = 1.0 + f * f * (-2.5 + 1.5 * f);
  vec2 w2 = f * (0.5 + f * (2.0 - 1.5 * f));
  vec2 w3 = f * f * (-0.5 + 0.5 * f);
  vec2 w12 = w1 + w2;
  vec2 at0 = (centre1 - 1.0) / size;
  vec2 at3 = (centre1 + 2.0) / size;
  vec2 at12 = (centre1 + w2 / w12) / size;

  vec3 sum = vec3(0.0);
  sum += textureLod(history_texture, vec2(at0.x, at0.y), 0.0).rgb * w0.x * w0.y;
  sum += textureLod(history_texture, vec2(at12.x, at0.y), 0.0).rgb * w12.x * w0.y;
  sum += textureLod(history_texture, vec2(at3.x, at0.y), 0.0).rgb * w3.x * w0.y;
  sum += textureLod(history_texture, vec2(at0.x, at12.y), 0.0).rgb * w0.x * w12.y;
  sum += textureLod(history_texture, vec2(at12.x, at12.y), 0.0).rgb * w12.x * w12.y;
  sum += textureLod(history_texture, vec2(at3.x, at12.y), 0.0).rgb * w3.x * w12.y;
  sum += textureLod(history_texture, vec2(at0.x, at3.y), 0.0).rgb * w0.x * w3.y;
  sum += textureLod(history_texture, vec2(at12.x, at3.y), 0.0).rgb * w12.x * w3.y;
  sum += textureLod(history_texture, vec2(at3.x, at3.y), 0.0).rgb * w3.x * w3.y;
  // The negative lobes can take a sharp edge below zero.
  return max(sum, vec3(0.0));
}

void main() {
  vec2 texel = temporal_info.scene_texel.xy;
  vec2 sceneUv = v_uv + temporal_info.jitter.xy;
  vec2 centre = (floor(sceneUv * temporal_info.scene_texel.zw) + 0.5) * texel;

  vec3 sum = vec3(0.0);
  vec3 sumSquares = vec3(0.0);
  vec3 lowest = vec3(1e30);
  vec3 highest = vec3(-1e30);
  float nearest = 1e30;
  vec2 nearestUv = centre;
  vec3 around[9];
  for (int dy = -1; dy <= 1; dy++) {
    for (int dx = -1; dx <= 1; dx++) {
      vec2 at = centre + vec2(float(dx), float(dy)) * texel;
      vec3 c = RgbToYCoCg(Weigh(textureLod(scene_texture, at, 0.0).rgb));
      around[(dy + 1) * 3 + dx + 1] = c;
      sum += c;
      sumSquares += c * c;
      lowest = min(lowest, c);
      highest = max(highest, c);
      float depth = textureLod(surface_texture, at, 0.0).a;
      if (depth > 0.0 && depth < nearest) {
        nearest = depth;
        nearestUv = at;
      }
    }
  }

  vec3 current = textureLod(scene_texture, sceneUv, 0.0).rgb;
  // The nearest depth around the pixel rather than the depth at it: on a
  // silhouette the jitter moves the centre on and off the object every
  // frame, and a history compared against that would be thrown away every
  // frame. The nearest surface in the neighbourhood stays put.
  float depth = nearest < 1e30 ? nearest : 0.0;
  vec2 then = v_uv - textureLod(velocity_texture, nearestUv, 0.0).xy;

  if (temporal_info.jitter.w < 0.5 || then.x < 0.0 || then.x > 1.0 ||
      then.y < 0.0 || then.y > 1.0) {
    frag_color = vec4(current, depth);
    return;
  }

  float trust = DepthTrust(depth, then);

  vec3 mean = sum / 9.0;
  vec3 sigma = sqrt(max(sumSquares / 9.0 - mean * mean, vec3(0.0)));
  vec3 lo = max(lowest, mean - 1.25 * sigma);
  vec3 hi = min(highest, mean + 1.25 * sigma);
  vec3 remembered = RgbToYCoCg(Weigh(HistoryAt(then)));
  vec3 clipped = temporal_info.clip.x > 0.5
      ? ClipToDop(RgbToYCoCg(Weigh(current)), remembered, around)
      : ClipToBox(lo, hi, remembered);
  vec3 history = Unweigh(YCoCgToRgb(clipped));

  // Read at the pixel itself rather than at the nearest surface: a particle
  // writes no depth, so the nearest surface around it is whatever it flew
  // over, and the mark belongs to where the particle is.
  float reactive =
      clamp(textureLod(velocity_texture, centre, 0.0).b, 0.0, 1.0);
  float keep = temporal_info.jitter.z * trust * (1.0 - reactive);
  float exposure = temporal_info.params.x;
  float wCurrent = (1.0 - keep) / (1.0 + Luma(current) * exposure);
  float wHistory = keep / (1.0 + Luma(history) * exposure);
  vec3 resolved =
      (current * wCurrent + history * wHistory) / max(wCurrent + wHistory, 1e-6);
  frag_color = vec4(resolved, depth);
}

''',
    'TemporalAccumulate': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// A noisy effect blended into its own history — `R3`.
//
// The occlusion and the contact shadow are drawn with fewer samples while a
// temporal resolve runs, each frame rotated or offset by the next slice of
// blue noise. This pass carries last frame's answer to where each pixel is
// now, through the velocity the resolve uses, clamps it to what this frame
// found around the pixel so a moved edge cannot drag a stale shadow along,
// and blends. What comes out is many frames' worth of samples.
//
// One pass for both, at whatever size the effect is drawn: the velocity is
// read by UV, and a half-size occlusion reads it at its own coarser UV.

in vec2 v_uv;

layout(location = 0) out vec4 frag_color;

uniform sampler2D current_texture;
uniform sampler2D history_texture;
uniform sampler2D velocity_texture;

layout(std140) uniform AccumulateInfo {
  /// x: how much of each pixel is history, nought to one. y: one when there
  /// is a history to read, nought on the first frame and after a cut.
  /// zw: one texel of the effect.
  vec4 params;
}
accumulate_info;

void main() {
  vec4 now = textureLod(current_texture, v_uv, 0.0);
  vec2 then = v_uv - textureLod(velocity_texture, v_uv, 0.0).xy;
  if (accumulate_info.params.y < 0.5 || then.x < 0.0 || then.x > 1.0 ||
      then.y < 0.0 || then.y > 1.0) {
    frag_color = now;
    return;
  }

  vec2 texel = accumulate_info.params.zw;
  vec4 lowest = now;
  vec4 highest = now;
  for (int dy = -1; dy <= 1; dy++) {
    for (int dx = -1; dx <= 1; dx++) {
      vec4 around =
          textureLod(current_texture, v_uv + vec2(float(dx), float(dy)) * texel, 0.0);
      lowest = min(lowest, around);
      highest = max(highest, around);
    }
  }
  vec4 past = clamp(textureLod(history_texture, then, 0.0), lowest, highest);
  frag_color = mix(now, past, accumulate_info.params.x);
}

''',
    'LightShafts': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// Volumetric light shafts, marched through the directional shadow map —
// `gfx-33n`.
//
// **What it draws, and why it is not a screen-brightness trick.** The
// familiar cheap version takes the bright pixels of the frame and smears them
// radially from the sun's position on screen. That needs the sun to be *in*
// the frame, it brightens anything else that happens to be bright, and it has
// no idea what is casting. This marches the view ray instead and asks the
// shadow map, at each step, whether that point in the air is lit. What comes
// out is a shaft where the light actually reaches and none where something is
// in the way — so a beam through a doorway is the doorway's shape, and
// turning the caster's shadow off leaves nothing at all.
//
// **Additive, and it reads the scene only for where to stop.** The in-scatter
// is added to the lit colour; the surface buffer's depth says how far along
// the ray there is still air to march. Past that the ray is inside geometry
// and anything accumulated would be light inside a wall.
//
// The dithered start is what makes sixteen steps look like a beam rather than
// sixteen bands. Each pixel starts a fraction of a step further along, from a
// 4x4 Bayer cell — ordered rather than random for the reason the dither in
// `composite.frag` is: it is a function of screen position and of nothing
// else, so a golden recorded with shafts on stays recorded.

// --- lib/frag_coord_info.glsl ---
// The target's orientation, for a full-screen pass.
//
// Its own block rather than a member of each pass's, so the renderer binds it
// in one place, `drawFullscreen`, for every stage that declares it — the
// contract answers false for a stage that does not, and a pass that adds a
// screen-space pattern later gets the right rows by including this file.

#ifndef FRAG_COORD_INFO_GLSL_
#define FRAG_COORD_INFO_GLSL_

// --- lib/frag_coord.glsl ---
// Where a fragment sits, counted from the top of its target on every backend.

#ifndef FRAG_COORD_GLSL_
#define FRAG_COORD_GLSL_

/// `gl_FragCoord.xy` with row zero at the top of the picture.
///
/// [rows] is the target's height where the backend's row zero is the bottom
/// of the picture, and zero where it is the top. WebGL2 is the first kind:
/// window coordinates start at the lower left, and the engine draws the
/// picture upright there rather than mirroring every projection. Metal,
/// WebGPU and the software rasteriser are the second.
///
/// **Why a pattern cares and a picture does not.** Every screen-space pattern
/// in the engine — the Bayer dither, the grain, the jitter a ray march starts
/// from, the rotation of a shadow kernel — is a function of the pixel's row.
/// Read from the bottom, the same frame gets the pattern turned upside down,
/// and a four-row Bayer cell lands on different rows unless the height is a
/// multiple of four. The picture underneath is identical; the pattern on top
/// of it is not, and a comparison across backends counts every pixel it
/// moved.
vec2 FragCoordFromTop(float rows) {
  return rows > 0.0 ? vec2(gl_FragCoord.x, rows - gl_FragCoord.y)
                    : gl_FragCoord.xy;
}

#endif  // FRAG_COORD_GLSL_


layout(std140) uniform FragCoordInfo {
  /// x: the target's rows when its row zero is the bottom of the picture,
  /// zero when it is the top — see [FragCoordFromTop]. yzw unused.
  vec4 origin;
}
frag_coord_info;

/// This fragment's position with row zero at the top of the target.
vec2 TargetFragCoord() {
  return FragCoordFromTop(frag_coord_info.origin.x);
}

#endif  // FRAG_COORD_INFO_GLSL_

// --- lib/blue_noise.glsl ---
// A per-pixel offset for a march or a kernel rotation — `R3`.
//
// **The engine's blue noise while a temporal resolve runs, the fixed 4 × 4
// pattern otherwise.** A march jittered by a pattern that never changes puts
// the same dither on every frame, and the eye finds it; with the resolve on,
// each frame reads the next of 32 slices of blue noise and the history
// averages them into a smooth answer. Off, the pattern is exactly what the
// passes read before, so a frame without the resolve is the frame it was.
//
// The table is `EngineTables.blueNoise`: 32 slices of 64 × 64 in an 8 × 4
// atlas, one byte a texel. Read at texel centres through a nearest sampler.
//
// Include after `lib/frag_coord_info.glsl` or anything else that gives the
// pixel from the top.

#ifndef BLUE_NOISE_GLSL_
#define BLUE_NOISE_GLSL_

uniform sampler2D blue_noise_texture;

layout(std140) uniform NoiseInfo {
  /// x: one to read the blue noise, nought for the pattern. y: this frame's
  /// slice, the frame index modulo 32. zw unused.
  vec4 noise;
}
noise_info;

/// One cell of a 4 × 4 Bayer matrix, in [0, 1).
float BayerCell(vec2 at) {
  int x = int(mod(at.x, 4.0));
  int y = int(mod(at.y, 4.0));
  int index = y * 4 + x;
  float value = 0.0;
  if (index == 0) value = 0.0;
  else if (index == 1) value = 8.0;
  else if (index == 2) value = 2.0;
  else if (index == 3) value = 10.0;
  else if (index == 4) value = 12.0;
  else if (index == 5) value = 4.0;
  else if (index == 6) value = 14.0;
  else if (index == 7) value = 6.0;
  else if (index == 8) value = 3.0;
  else if (index == 9) value = 11.0;
  else if (index == 10) value = 1.0;
  else if (index == 11) value = 9.0;
  else if (index == 12) value = 15.0;
  else if (index == 13) value = 7.0;
  else if (index == 14) value = 13.0;
  else value = 5.0;
  return value / 16.0;
}

/// This frame's blue noise at the pixel [at], in [0, 1).
float BlueNoise(vec2 at) {
  float slice = noise_info.noise.y;
  vec2 cell = mod(floor(at), 64.0);
  vec2 corner = vec2(mod(slice, 8.0), floor(slice / 8.0)) * 64.0;
  vec2 uv = (corner + cell + 0.5) / vec2(512.0, 256.0);
  return textureLod(blue_noise_texture, uv, 0.0).r * (255.0 / 256.0);
}

/// The offset for the pixel [at]: blue noise or the pattern, per `noise.x`.
float PixelNoise(vec2 at) {
  return noise_info.noise.x > 0.5 ? BlueNoise(at) : BayerCell(at);
}

#endif  // BLUE_NOISE_GLSL_


in vec2 v_uv;

layout(location = 0) out vec4 frag_color;

uniform sampler2D scene_texture;
uniform sampler2D surface_texture;
uniform sampler2D shadow_texture;

layout(std140) uniform ShaftInfo {
  // Screen to world, for turning a stored depth back into a point.
  mat4 inverse_view_projection;

  // The three cascade matrices, world to light clip. Unused ones are the
  // identity and are never reached, because `cascades.z` says how many there
  // are.
  mat4 shadow_matrix;
  mat4 shadow_matrix_far;
  mat4 shadow_matrix_farthest;

  // xyz: where the eye is. w: how far to march, in world metres.
  vec4 camera;

  // xyz: the direction the camera looks. w: how many steps.
  vec4 forward;

  // xyz: what a lit point sends towards the eye before phase and path — the
  // sun's colour times its intensity, tinted by the air's albedo. w: the
  // air's density σ, per metre.
  vec4 scatter;

  // x, y: the two cascade split distances. z: how many cascades. w unused.
  vec4 cascades;

  // xyz: towards the sun, a unit vector. w: Henyey–Greenstein's g.
  vec4 sun;

  // x, y, z: each cascade's depth bias, in the units its part of the map
  // holds — `FragInfo.shadow_bias`, for the reason given there. w unused.
  vec4 bias;
}
shaft_info;

// How much of the light a point in the air sends along [cosine] from the
// sun's direction — Henyey–Greenstein, normalised over the sphere.
float HenyeyGreenstein(float cosine, float g) {
  float g2 = g * g;
  float denominator = max(1.0 + g2 - 2.0 * g * cosine, 1e-4);
  return (1.0 - g2) / (12.566371 * denominator * sqrt(denominator));
}

// Whether [world] is lit by the caster: 1 in the light, 0 in shadow.
//
// The cascade walk `lib/shadow.glsl` does, without the surface it needs. A
// point in the air has no normal, so there is no normal offset here and no
// soft kernel either — one tap, because sixteen of them per pixel is already
// the cost of this pass.
float LitAt(vec3 world, float viewDistance) {
  int cascadeCount = int(shaft_info.cascades.z + 0.5);
  int cascade = 0;
  if (cascadeCount > 1 && viewDistance > shaft_info.cascades.x) cascade = 1;
  if (cascadeCount > 2 && viewDistance > shaft_info.cascades.y) cascade = 2;

  for (int attempt = 0; attempt < 3; attempt++) {
    int which = cascade + attempt;
    if (which >= cascadeCount) break;

    mat4 matrix = which == 0
        ? shaft_info.shadow_matrix
        : (which == 1 ? shaft_info.shadow_matrix_far
                      : shaft_info.shadow_matrix_farthest);
    vec4 lightSpace = matrix * vec4(world, 1.0);
    if (lightSpace.w <= 0.0) continue;
    vec3 candidate = lightSpace.xyz / lightSpace.w;

    vec2 inTile = vec2(candidate.x * 0.5 + 0.5, 0.5 - candidate.y * 0.5);
    if (inTile.x < 0.0 || inTile.x > 1.0 || inTile.y < 0.0 || inTile.y > 1.0) {
      continue;
    }
    // Past the far plane is behind every caster, as in `shadow.glsl`: the
    // last cascade clamps rather than letting the air behind a caster glow.
    if (candidate.z > 1.0) {
      if (which < cascadeCount - 1) continue;
      candidate.z = 1.0;
    }

    vec2 uv = vec2((inTile.x + float(which)) / float(cascadeCount), inTile.y);
    // `textureLod`, for `shadow.glsl`'s own reason: the cascade search above
    // continues and breaks on values computed per fragment, so a WGSL backend
    // refuses the implicit derivative here as possibly non-uniform. One level,
    // so naming it directly changes no pixel.
    float stored = textureLod(shadow_texture, uv, 0.0).r;
    // Outside the map is lit rather than dark: a point beyond the shadow
    // volume has nothing recorded about it, and calling that shadow would
    // put a wall of darkness across the far half of every shaft.
    float bias = which == 0
        ? shaft_info.bias.x
        : (which == 1 ? shaft_info.bias.y : shaft_info.bias.z);
    return candidate.z - bias > stored ? 0.0 : 1.0;
  }
  return 1.0;
}

void main() {
  vec4 scene = texture(scene_texture, v_uv);
  int steps = int(shaft_info.forward.w + 0.5);
  if (steps < 1) {
    frag_color = scene;
    return;
  }

  // Where the ray starts and which way it goes.
  vec2 xy = vec2(v_uv.x * 2.0 - 1.0, 1.0 - v_uv.y * 2.0);
  vec4 nearH = shaft_info.inverse_view_projection * vec4(xy, 0.0, 1.0);
  vec4 farH = shaft_info.inverse_view_projection * vec4(xy, 1.0, 1.0);
  vec3 origin = nearH.xyz / nearH.w;
  vec3 along = normalize(farH.xyz / farH.w - origin);

  // How far there is air. The surface buffer holds depth along the view axis
  // in metres, so the distance along *this* ray is that over the cosine
  // between the two — a ray at the corner of the frame travels further than
  // the axis does to reach the same plane.
  float surfaceDepth = texture(surface_texture, v_uv).a;
  float cosine = max(dot(along, shaft_info.forward.xyz), 1e-4);
  float toSurface = surfaceDepth > 0.0 ? surfaceDepth / cosine : 1e9;
  float distance = min(shaft_info.camera.w, toSurface);
  if (distance <= 0.0) {
    frag_color = scene;
    return;
  }

  float stride = distance / float(steps);
  // The dithered start: a fraction of a step, so the banding sixteen samples
  // would otherwise draw is broken into a pattern the eye integrates.
  float offset = PixelNoise(TargetFragCoord()) * stride;

  // **Single scattering with transmittance.** Each step in-scatters the
  // share of the light its own length of air catches, `1 − e^(−σ·stride)`,
  // times what is left of the path to the eye. Summed, a fully lit ray comes
  // to `1 − e^(−σd)`: brighter with more air, and never past one — where the
  // old average of lit samples gave a wall two metres off the same shaft as
  // forty metres of sky. The step count still changes the quality and not
  // the brightness, because the sum converges to the same integral.
  float sigma = max(shaft_info.scatter.w, 0.0);
  float stepTransmittance = exp(-sigma * stride);
  float transmittance = exp(-sigma * offset);
  vec3 eye = shaft_info.camera.xyz;
  float inscatter = 0.0;
  for (int i = 0; i < 64; i++) {
    if (i >= steps) break;
    float travelled = offset + float(i) * stride;
    vec3 at = origin + along * travelled;
    // The cascade chosen by distance from the eye, the metric `shadow.glsl`
    // picks a surface's cascade by, so a shaft and the ground under it agree.
    float lit = LitAt(at, length(at - eye));
    inscatter += transmittance * (1.0 - stepTransmittance) * lit;
    transmittance *= stepTransmittance;
  }

  // Towards the sun the air glows; away from it, with forward scattering,
  // almost not at all — which is what keeps a frame with the sun behind the
  // camera clear. `along` runs from the eye, so looking into the sun is
  // `dot(along, toSun)` near one.
  float phase = HenyeyGreenstein(dot(along, shaft_info.sun.xyz), shaft_info.sun.w);
  vec3 shaft = shaft_info.scatter.rgb * (phase * inscatter);
  frag_color = vec4(scene.rgb + shaft, scene.a);
}

''',
    'VolumetricFog': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// Volumetric fog, marched at half resolution — `S4`.
//
// **The light shafts' march, given a medium.** `light_shafts.frag` asks the
// shadow map at each step whether a point in the air is lit and adds the sun
// it scatters. This asks the same question and three more: how thick the air
// is at that height, which of the view's clustered lights reach that point,
// and how much of what lies behind it is still seen through what the ray has
// crossed. What comes out is not a colour to add but a pair — the light the
// air sends towards the eye, and the share of the scene that survives it —
// which `volumetric_fog_upsample.frag` lays over the full-resolution picture.
//
// **Half resolution, and that is what the upsample is for.** Fog varies
// slowly across the screen except where the depth jumps, so a quarter of the
// rays carry nearly all of the picture; the upsample weighs each of the four
// nearest by how close its depth is to the pixel's own, which keeps a torch's
// halo from bleeding over the edge of the wall in front of it.
//
// **The start is offset by the engine's noise.** The fixed 4 × 4 pattern
// without a temporal resolve, R3's blue noise with one — the history then
// averages the next slice every frame into the integral the steps sample.

// --- lib/frag_coord_info.glsl ---
// The target's orientation, for a full-screen pass.
//
// Its own block rather than a member of each pass's, so the renderer binds it
// in one place, `drawFullscreen`, for every stage that declares it — the
// contract answers false for a stage that does not, and a pass that adds a
// screen-space pattern later gets the right rows by including this file.

#ifndef FRAG_COORD_INFO_GLSL_
#define FRAG_COORD_INFO_GLSL_

// --- lib/frag_coord.glsl ---
// Where a fragment sits, counted from the top of its target on every backend.

#ifndef FRAG_COORD_GLSL_
#define FRAG_COORD_GLSL_

/// `gl_FragCoord.xy` with row zero at the top of the picture.
///
/// [rows] is the target's height where the backend's row zero is the bottom
/// of the picture, and zero where it is the top. WebGL2 is the first kind:
/// window coordinates start at the lower left, and the engine draws the
/// picture upright there rather than mirroring every projection. Metal,
/// WebGPU and the software rasteriser are the second.
///
/// **Why a pattern cares and a picture does not.** Every screen-space pattern
/// in the engine — the Bayer dither, the grain, the jitter a ray march starts
/// from, the rotation of a shadow kernel — is a function of the pixel's row.
/// Read from the bottom, the same frame gets the pattern turned upside down,
/// and a four-row Bayer cell lands on different rows unless the height is a
/// multiple of four. The picture underneath is identical; the pattern on top
/// of it is not, and a comparison across backends counts every pixel it
/// moved.
vec2 FragCoordFromTop(float rows) {
  return rows > 0.0 ? vec2(gl_FragCoord.x, rows - gl_FragCoord.y)
                    : gl_FragCoord.xy;
}

#endif  // FRAG_COORD_GLSL_


layout(std140) uniform FragCoordInfo {
  /// x: the target's rows when its row zero is the bottom of the picture,
  /// zero when it is the top — see [FragCoordFromTop]. yzw unused.
  vec4 origin;
}
frag_coord_info;

/// This fragment's position with row zero at the top of the target.
vec2 TargetFragCoord() {
  return FragCoordFromTop(frag_coord_info.origin.x);
}

#endif  // FRAG_COORD_INFO_GLSL_

// --- lib/blue_noise.glsl ---
// A per-pixel offset for a march or a kernel rotation — `R3`.
//
// **The engine's blue noise while a temporal resolve runs, the fixed 4 × 4
// pattern otherwise.** A march jittered by a pattern that never changes puts
// the same dither on every frame, and the eye finds it; with the resolve on,
// each frame reads the next of 32 slices of blue noise and the history
// averages them into a smooth answer. Off, the pattern is exactly what the
// passes read before, so a frame without the resolve is the frame it was.
//
// The table is `EngineTables.blueNoise`: 32 slices of 64 × 64 in an 8 × 4
// atlas, one byte a texel. Read at texel centres through a nearest sampler.
//
// Include after `lib/frag_coord_info.glsl` or anything else that gives the
// pixel from the top.

#ifndef BLUE_NOISE_GLSL_
#define BLUE_NOISE_GLSL_

uniform sampler2D blue_noise_texture;

layout(std140) uniform NoiseInfo {
  /// x: one to read the blue noise, nought for the pattern. y: this frame's
  /// slice, the frame index modulo 32. zw unused.
  vec4 noise;
}
noise_info;

/// One cell of a 4 × 4 Bayer matrix, in [0, 1).
float BayerCell(vec2 at) {
  int x = int(mod(at.x, 4.0));
  int y = int(mod(at.y, 4.0));
  int index = y * 4 + x;
  float value = 0.0;
  if (index == 0) value = 0.0;
  else if (index == 1) value = 8.0;
  else if (index == 2) value = 2.0;
  else if (index == 3) value = 10.0;
  else if (index == 4) value = 12.0;
  else if (index == 5) value = 4.0;
  else if (index == 6) value = 14.0;
  else if (index == 7) value = 6.0;
  else if (index == 8) value = 3.0;
  else if (index == 9) value = 11.0;
  else if (index == 10) value = 1.0;
  else if (index == 11) value = 9.0;
  else if (index == 12) value = 15.0;
  else if (index == 13) value = 7.0;
  else if (index == 14) value = 13.0;
  else value = 5.0;
  return value / 16.0;
}

/// This frame's blue noise at the pixel [at], in [0, 1).
float BlueNoise(vec2 at) {
  float slice = noise_info.noise.y;
  vec2 cell = mod(floor(at), 64.0);
  vec2 corner = vec2(mod(slice, 8.0), floor(slice / 8.0)) * 64.0;
  vec2 uv = (corner + cell + 0.5) / vec2(512.0, 256.0);
  return textureLod(blue_noise_texture, uv, 0.0).r * (255.0 / 256.0);
}

/// The offset for the pixel [at]: blue noise or the pattern, per `noise.x`.
float PixelNoise(vec2 at) {
  return noise_info.noise.x > 0.5 ? BlueNoise(at) : BayerCell(at);
}

#endif  // BLUE_NOISE_GLSL_


in vec2 v_uv;

layout(location = 0) out vec4 frag_color;

uniform sampler2D surface_texture;
uniform sampler2D shadow_texture;
uniform sampler2D light_list_texture;

// The cube atlas the lit draws read, and the block they read it through —
// `PointShadow` as `surface.glsl` declares it, member for member, because a
// block is one layout whichever stage names it. The march reads the atlas
// rows, the face matrices and the frame's numbers; the slot table is a draw's
// and goes unread here, since a light's row rides in its list row.
uniform sampler2D point_shadow_texture;
uniform sampler2D point_shadow_static_texture;

// `kShadowSlots` and `kMaxLights` from `surface.glsl`.
#define kFogShadowSlots 6
#define kFogSlotLights 8

layout(std140) uniform PointShadow {
  mat4 faces[6 * kFogShadowSlots];
  vec4 lights[kFogShadowSlots];
  vec4 slots[kFogSlotLights];
  vec4 params;
  vec4 params2;
  vec4 params3;
}
point_shadow;

// The most lights one step of the march reads from its cell. A cell lists
// every light whose range reaches into it, and a loop a scene can lengthen
// without limit is a hang rather than a slow frame.
#define kFogCellLights 16

layout(std140) uniform VolumeFogInfo {
  // Screen to world, for turning a stored depth back into a point.
  mat4 inverse_view_projection;

  // The three cascade matrices, world to light clip, as `ShaftInfo` has them.
  mat4 shadow_matrix;
  mat4 shadow_matrix_far;
  mat4 shadow_matrix_farthest;

  // `L6`: the view-projection the light clusters were cut with, so a step
  // finds its cell the way `LightClusters.clusterOf` does.
  mat4 cluster_view_projection;

  // xyz: where the eye is. w: how far to march, in world metres.
  vec4 camera;

  // xyz: the direction the camera looks. w: how many steps.
  vec4 forward;

  // xyz: towards the sun, a unit vector. w: Henyey–Greenstein's g.
  vec4 sun;

  // rgb: the sun's colour times its intensity, times the air's albedo;
  // nought with no directional light. w unused.
  vec4 sun_radiance;

  // x, y: the two cascade split distances. z: how many cascades, nought when
  // there is no shadow map and every point is lit. w unused.
  vec4 cascades;

  // x, y, z: each cascade's depth bias. w unused.
  vec4 bias;

  // x: the air's extinction σ at the base height, per metre. y: how fast it
  // thins with height, per metre. z: the base height. w unused.
  vec4 medium;

  // rgb: the air's albedo, which tints what the clustered lights scatter.
  // w: one when the cells are there to read, nought otherwise.
  vec4 albedo;

  // rgb: light reaching the air from every direction, times the albedo —
  // what keeps fog in shadow from reading as a black wall. w unused.
  vec4 ambient;

  // xyz: tiles across, tiles up, slices deep. w unused.
  vec4 cluster_grid;

  // x: where slices begin, in clip w. y: slices per unit of `ln(w / x)`.
  // z: the row the cells' headers start at. w: the row their entries start at.
  vec4 cluster_depth;

  // x, y: one over the light list texture's width and height. zw unused.
  vec4 list;
}
fog_info;

// How much of the light a point in the air sends along [cosine] from the
// light's direction — Henyey–Greenstein, normalised over the sphere, as in
// `light_shafts.frag`.
float HenyeyGreenstein(float cosine, float g) {
  float g2 = g * g;
  float denominator = max(1.0 + g2 - 2.0 * g * cosine, 1e-4);
  return (1.0 - g2) / (12.566371 * denominator * sqrt(denominator));
}

// The air's extinction at height [y]: σ at the base, thinning exponentially
// above it and thickening below. The exponent is clamped so a camera far
// below the base height reads very thick fog rather than an infinity.
float Density(float y) {
  float exponent = clamp(-fog_info.medium.y * (y - fog_info.medium.z),
                         -30.0, 30.0);
  return max(fog_info.medium.x, 0.0) * exp(exponent);
}

// Whether [world] is lit by the sun: `LitAt` from `light_shafts.frag`, one
// tap per cascade walk. With no map (`cascades.z` nought) everything is lit.
float LitAt(vec3 world, float viewDistance) {
  int cascadeCount = int(fog_info.cascades.z + 0.5);
  int cascade = 0;
  if (cascadeCount > 1 && viewDistance > fog_info.cascades.x) cascade = 1;
  if (cascadeCount > 2 && viewDistance > fog_info.cascades.y) cascade = 2;

  for (int attempt = 0; attempt < 3; attempt++) {
    int which = cascade + attempt;
    if (which >= cascadeCount) break;

    mat4 matrix = which == 0
        ? fog_info.shadow_matrix
        : (which == 1 ? fog_info.shadow_matrix_far
                      : fog_info.shadow_matrix_farthest);
    vec4 lightSpace = matrix * vec4(world, 1.0);
    if (lightSpace.w <= 0.0) continue;
    vec3 candidate = lightSpace.xyz / lightSpace.w;

    vec2 inTile = vec2(candidate.x * 0.5 + 0.5, 0.5 - candidate.y * 0.5);
    if (inTile.x < 0.0 || inTile.x > 1.0 || inTile.y < 0.0 || inTile.y > 1.0) {
      continue;
    }
    if (candidate.z > 1.0) {
      if (which < cascadeCount - 1) continue;
      candidate.z = 1.0;
    }

    vec2 uv = vec2((inTile.x + float(which)) / float(cascadeCount), inTile.y);
    float stored = textureLod(shadow_texture, uv, 0.0).r;
    float bias = which == 0
        ? fog_info.bias.x
        : (which == 1 ? fog_info.bias.y : fog_info.bias.z);
    return candidate.z - bias > stored ? 0.0 : 1.0;
  }
  return 1.0;
}

// How lit [world] is by the light whose list row ends in [cone]: one tap of
// its atlas row, without a normal to offset along — a point in the air has
// none — and so without the filter the surfaces use either. A soft edge in
// the air comes from the march's jitter, and nine taps a step per light would
// be the whole budget of the pass.
//
// `cone.z` is the atlas row plus one, nought for a light that holds none;
// `cone.w` is one for a spot's single tile. The face, the flip and the pair of
// atlases are `PointShadowFactor`'s and `PointShadowDistance`'s.
float LocalLitAt(vec3 world, vec4 cone) {
  float strength = point_shadow.params.z;
  if (cone.z < 0.5 || strength <= 0.0) return 1.0;
  int slot = int(cone.z - 0.5);
  vec3 toFragment = world - point_shadow.lights[slot].xyz;
  float distance = length(toFragment);
  float range = max(point_shadow.lights[slot].w, 1e-4);
  if (distance >= range) return 1.0;

  int face = 0;
  if (cone.w < 0.5) {
    vec3 a = abs(toFragment);
    if (a.x >= a.y && a.x >= a.z) {
      face = toFragment.x > 0.0 ? 0 : 1;
    } else if (a.y >= a.z) {
      face = toFragment.y > 0.0 ? 2 : 3;
    } else {
      face = toFragment.z > 0.0 ? 4 : 5;
    }
  }

  vec4 clip = point_shadow.faces[slot * 6 + face] * vec4(world, 1.0);
  if (clip.w <= 0.0) return 1.0;
  vec2 ndc = clip.xy / clip.w;
  if (abs(ndc.x) > 1.0 || abs(ndc.y) > 1.0) return 1.0;
  vec2 uv = vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
  float inset = point_shadow.params.x;
  vec2 local = clamp(uv, inset, 1.0 - inset);
  vec2 atlas = (local + vec2(float(face), float(slot))) *
               vec2(1.0 / 6.0, 1.0 / float(kFogShadowSlots));
  if (point_shadow.params3.x > 0.5) atlas.y = 1.0 - atlas.y;
  // Level zero by name: this stands behind the slot test and the light loop's
  // own branches, where WGSL takes no implicit derivative.
  float stored = min(textureLod(point_shadow_texture, atlas, 0.0).r,
                     textureLod(point_shadow_static_texture, atlas, 0.0).r) *
                 range;
  // Nothing was drawn in that direction by either, so nothing is in the way.
  if (stored >= range * 0.999) return 1.0;
  float lit = distance - point_shadow.params.y > stored ? 0.0 : 1.0;
  return mix(1.0, lit, clamp(strength, 0.0, 1.0));
}

// One texel of the light list texture, [texel] across and [row] down.
vec4 ListTexel(float texel, float row) {
  return textureLod(light_list_texture,
                    vec2((texel + 0.5) * fog_info.list.x,
                         (row + 0.5) * fog_info.list.y),
                    0.0);
}

// One lane of a four-vector.
float Lane(vec4 four, float lane) {
  return lane < 0.5 ? four.x
                    : (lane < 1.5 ? four.y : (lane < 2.5 ? four.z : four.w));
}

// What the clustered lights whose cell holds [world] send towards the eye
// along [along], before the albedo and the path.
//
// `FindCluster` and `ClusterRow` from `surface.glsl`, with nothing of a draw
// in them: a cell lists every light that reaches it, and the air has no
// slots holding some of them already. A light that owns a row of the cube
// atlas is shadowed through it, so a torch behind a wall lights no air on
// this side of the wall. Points and spots only — a rectangle's
// intensity is spread over its area in a way a point in the air has no
// normal to integrate against, and a directional light is the sun's job.
vec3 ClusterLight(vec3 world, vec3 along, float g) {
  vec4 clip = fog_info.cluster_view_projection * vec4(world, 1.0);
  vec2 ndc = clip.xy / max(clip.w, 1e-6);
  vec3 grid = fog_info.cluster_grid.xyz;
  float near = fog_info.cluster_depth.x;
  float tx = clamp(floor((ndc.x * 0.5 + 0.5) * grid.x), 0.0, grid.x - 1.0);
  float ty = clamp(floor((ndc.y * 0.5 + 0.5) * grid.y), 0.0, grid.y - 1.0);
  float tz = clip.w <= near
                 ? 0.0
                 : clamp(floor(log(clip.w / near) * fog_info.cluster_depth.y),
                         0.0, grid.z - 1.0);
  float cell = tx + ty * grid.x + tz * grid.x * grid.y;
  float headerRow = floor(cell / 4.0);
  vec4 header =
      ListTexel(cell - headerRow * 4.0, fog_info.cluster_depth.z + headerRow);
  int count = int(header.y + 0.5);

  vec3 total = vec3(0.0);
  for (int i = 0; i < kFogCellLights; i++) {
    if (i >= count) break;
    float entry = header.x + float(i);
    float entryRow = floor(entry / 16.0);
    float within = entry - entryRow * 16.0;
    float texel = floor(within / 4.0);
    vec4 four = ListTexel(texel, fog_info.cluster_depth.w + entryRow);
    float row = Lane(four, within - texel * 4.0);

    vec4 position = ListTexel(0.0, row);
    vec4 color = ListTexel(1.0, row);
    vec4 direction = ListTexel(2.0, row);
    vec4 cone = ListTexel(3.0, row);
    float type = position.w;
    vec3 toLight = position.xyz - world;
    float distance = length(toLight);
    // Points and spots, at a distance with a direction.
    float usable = (type > 0.5 && type < 2.5 && distance > 1e-4) ? 1.0 : 0.0;
    vec3 l = toLight / max(distance, 1e-4);

    // `PunctualAttenuation` from `surface.glsl`.
    float attenuation = 1.0 / max(distance * distance, 1e-4);
    if (direction.w > 0.0) {
      float ratio = distance / direction.w;
      float window = clamp(1.0 - ratio * ratio * ratio * ratio, 0.0, 1.0);
      attenuation *= window * window;
    }
    // Spots only: a rectangle's `cone` holds an edge, not two cosines, and
    // the ramp over it could divide by nought into a NaN the `usable`
    // multiply would not clear.
    if (type > 1.5 && type < 2.5) {
      float cosAngle = dot(normalize(direction.xyz), -l);
      attenuation *= clamp((cosAngle - cone.y) / (cone.x - cone.y), 0.0, 1.0);
    }
    // Asked only of a light that reaches here, since it costs two reads.
    float visibility = attenuation * usable > 0.0 ? LocalLitAt(world, cone) : 1.0;
    total += color.rgb * (color.w * attenuation * usable * visibility *
                          HenyeyGreenstein(dot(along, l), g));
  }
  return total;
}

void main() {
  int steps = int(fog_info.forward.w + 0.5);

  // Where the ray starts and which way it goes.
  vec2 xy = vec2(v_uv.x * 2.0 - 1.0, 1.0 - v_uv.y * 2.0);
  vec4 nearH = fog_info.inverse_view_projection * vec4(xy, 0.0, 1.0);
  vec4 farH = fog_info.inverse_view_projection * vec4(xy, 1.0, 1.0);
  vec3 nearPoint = nearH.xyz / nearH.w;
  vec3 along = normalize(farH.xyz / farH.w - nearPoint);
  float cosine = max(dot(along, fog_info.forward.xyz), 1e-4);

  // **From the eye's plane, not the near plane.** The surface buffer's depth
  // is measured from the eye, so a march starting at the near plane and
  // running that depth ends the near distance behind the surface — inside
  // the wall, where a torch on its far side still reaches, and the one step
  // there glowed through the stone. Stepped back along the ray to where the
  // depth is nought: the eye itself for a perspective view.
  vec3 origin = nearPoint -
                along * (dot(nearPoint - fog_info.camera.xyz,
                             fog_info.forward.xyz) /
                         cosine);

  // How far there is air, as `light_shafts.frag` measures it: the surface
  // buffer's depth along the view axis over the cosine to this ray.
  float surfaceDepth = texture(surface_texture, v_uv).a;
  float toSurface = surfaceDepth > 0.0 ? surfaceDepth / cosine : 1e9;
  float distance = min(fog_info.camera.w, toSurface);
  if (steps < 1 || distance <= 0.0) {
    frag_color = vec4(0.0, 0.0, 0.0, 1.0);
    return;
  }

  float stride = distance / float(steps);
  float offset = PixelNoise(TargetFragCoord()) * stride;

  // **Single scattering with transmittance, per step.** A step of length
  // `stride` at extinction σ catches `1 − e^(−σ·stride)` of the light that
  // reaches it and passes on `e^(−σ·stride)` of what is behind it; the light
  // it catches is weighted by what is left of the path to the eye. Each
  // sample stands for one stride of the ray, placed at the offset within it,
  // so the strides add up to the distance exactly and a wall seen through
  // uniform air keeps `e^(−σd)` of itself whatever the noise says.
  float g = fog_info.sun.w;
  float sunPhase = HenyeyGreenstein(dot(along, fog_info.sun.xyz), g);
  bool clustered = fog_info.albedo.w > 0.5;
  vec3 eye = fog_info.camera.xyz;
  float transmittance = 1.0;
  vec3 inscatter = vec3(0.0);
  for (int i = 0; i < 64; i++) {
    if (i >= steps) break;
    float travelled = offset + float(i) * stride;
    vec3 at = origin + along * travelled;
    float stepTransmittance = exp(-Density(at.y) * stride);

    vec3 light = fog_info.sun_radiance.rgb *
                     (sunPhase * LitAt(at, length(at - eye))) +
                 fog_info.ambient.rgb * 0.07957747;
    if (clustered) {
      light += fog_info.albedo.rgb * ClusterLight(at, along, g);
    }
    inscatter += light * (transmittance * (1.0 - stepTransmittance));
    transmittance *= stepTransmittance;
  }

  frag_color = vec4(inscatter, transmittance);
}

''',
    'VolumetricFogUpsample': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// The half-resolution fog laid over the full-resolution scene — `S4`.
//
// **Depth-aware, which is the whole reason this is its own pass.** A plain
// bilinear stretch of a half-resolution fog mixes, at every silhouette, a ray
// that stopped at the near wall with one that ran on to the far one: the
// torch's halo behind a pillar bleeds a pixel over the pillar's edge, and the
// pillar's edge brings its clear air into the halo. Here each of the four
// nearest fog texels is weighted by its bilinear share *and* by how close the
// depth its ray stopped at is to this pixel's own, so the texels on the other
// side of an edge all but drop out and the edge stays where the scene has it.
//
// The fog texel's depth is read from the full-resolution surface buffer at
// that texel's centre, which is the very texel the march read — nearest on
// both — so the depth compared is the one the ray was actually cut at.
//
// **Composited before the tone map**: the scene behind keeps the share the
// air lets through and the in-scatter is added, both in linear light.
//
// **The occlusion lands on the surface here, before the air, and not in the
// composite.** Ambient occlusion and the contact shadow say how much light
// reaches the surface a ray stopped at; they have nothing to say about the
// air in front of it. The composite multiplies them into the whole colour,
// which, once the fog is in that colour, draws the creases of a far wall on
// the air in front of it: dark lines that thicker fog should wash out and
// instead makes plainer. So this pass takes them over, the same 2×2 average
// and the same strengths the composite uses, and the renderer zeroes the
// composite's strengths on a frame where it did. Zero strengths here are a
// multiplier of exactly one, so fog without occlusion is what it was.

in vec2 v_uv;

layout(location = 0) out vec4 frag_color;

uniform sampler2D scene_texture;
uniform sampler2D fog_texture;
uniform sampler2D surface_texture;
uniform sampler2D ao_texture;
uniform sampler2D contact_shadow_texture;

layout(std140) uniform FogUpsampleInfo {
  // xy: the fog texture's size in texels. zw: one texel of the occlusion
  // buffer, for the composite's 2×2 average.
  vec4 size;
  // x: the occlusion's strength, y: the contact shadow's, z: one when the
  // occlusion buffer holds bounced light in rgb (`L5`). All nought when the
  // composite keeps them. w unused.
  vec4 occlusion;
}
upsample_info;

// A depth to compare: the surface buffer's, with the cleared sky pushed far
// away so a sky pixel matches sky texels and not the nearest wall.
float DepthAt(vec2 uv) {
  float depth = textureLod(surface_texture, uv, 0.0).a;
  return depth > 0.0 ? depth : 1e6;
}

// Adds fog texel [cell], at bilinear share [share], weighted against the
// pixel's own depth [here].
void Tap(vec2 cell, float share, float here, inout vec4 sum,
         inout float weight) {
  vec2 size = upsample_info.size.xy;
  vec2 uv = (clamp(cell, vec2(0.0), size - 1.0) + 0.5) / size;
  float difference = abs(DepthAt(uv) - here) / max(here, 1e-3);
  float w = share / (0.01 + difference);
  sum += textureLod(fog_texture, uv, 0.0) * w;
  weight += w;
}

void main() {
  vec4 scene = texture(scene_texture, v_uv);
  vec2 size = max(upsample_info.size.xy, vec2(1.0));
  vec2 at = v_uv * size - 0.5;
  vec2 base = floor(at);
  vec2 f = at - base;
  float here = DepthAt(v_uv);

  vec4 sum = vec4(0.0);
  float weight = 0.0;
  Tap(base, (1.0 - f.x) * (1.0 - f.y), here, sum, weight);
  Tap(base + vec2(1.0, 0.0), f.x * (1.0 - f.y), here, sum, weight);
  Tap(base + vec2(0.0, 1.0), (1.0 - f.x) * f.y, here, sum, weight);
  Tap(base + vec2(1.0, 1.0), f.x * f.y, here, sum, weight);
  vec4 fog = weight > 1e-6 ? sum / weight : vec4(0.0, 0.0, 0.0, 1.0);

  // What `composite.frag` does to the scene, operation for operation.
  vec2 half_texel = upsample_info.size.zw * 0.5;
  vec4 occlusion =
      0.25 * (texture(ao_texture, v_uv + vec2(half_texel.x, half_texel.y)) +
              texture(ao_texture, v_uv + vec2(-half_texel.x, half_texel.y)) +
              texture(ao_texture, v_uv + vec2(half_texel.x, -half_texel.y)) +
              texture(ao_texture, v_uv + vec2(-half_texel.x, -half_texel.y)));
  float strength = clamp(upsample_info.occlusion.x, 0.0, 1.0);
  float ao = mix(1.0, occlusion.a, strength);
  float contact = texture(contact_shadow_texture, v_uv).r;
  ao *= mix(1.0, contact, clamp(upsample_info.occlusion.y, 0.0, 1.0));
  vec3 surfaceLight =
      scene.rgb * ao + occlusion.rgb * upsample_info.occlusion.z * strength;

  frag_color = vec4(surfaceLight * fog.a + fog.rgb, scene.a);
}

''',
    'DepthOfField': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// Depth of field: a thin lens, a circle of confusion, and a gather — `gfx-34n`.
//
// **The arithmetic is a lens rather than a curve somebody liked.** A point at
// distance `d` images to a disc whose diameter is
//
//     |d - focus| / d  *  f^2 / (N * (focus - f))
//
// where `f` is the focal length and `N` the f-number. That is the thin-lens
// formula, and the reason to use it rather than a hand-drawn ramp is that
// every number in it is one a photographer already knows: open the aperture
// and the background goes softer by an amount somebody can predict.
//
// **The depth is the surface buffer's alpha**, which carries view-axis
// distance in metres — so the circle is computed from a real distance rather
// than from a window depth, where the same blur would mean different things
// near and far. That channel is also why no depth attachment is needed:
// flutter_gpu cannot sample one.
//
// **A gather, not a scatter**, reaching as far as the largest circle nearby.
// Each output pixel reads the neighbourhood and asks which of those samples
// would have landed on it. How far it reads is the largest circle in its
// tile and the eight around it (`DofTileMax`, then the motion blur's column
// and neighbourhood passes), not its own: a sharp pixel beside a blurred
// foreground has a circle of nought, and a gather that stopped at its own
// circle never saw the foreground whose disc covers it, which left every
// out-of-focus foreground with a hard outline against a sharp background.
// Samples nearer than this pixel and more blurred than it are a layer of
// their own, laid over the rest by how much of this pixel their discs cover.
//
// Sampled on a spiral rather than a grid: a square kernel makes a square
// bokeh, and the shape of an out-of-focus highlight is the one thing anybody
// looks at in this effect.

// --- lib/circle_of_confusion.glsl ---
// The thin lens's circle of confusion — `gfx-34n`.
//
// One function for the two stages that need it, the depth of field's gather
// and the tile search in front of it: the tile's largest circle has to be the
// largest of the circles the gather will compute, to the bit.

#ifndef CIRCLE_OF_CONFUSION_GLSL_
#define CIRCLE_OF_CONFUSION_GLSL_

// The circle of confusion at [depth], as a radius in texels.
//
// [lens] x: focus distance in metres. y: focal length in metres. z: f-number.
// [params] z: the largest circle, in texels. w: texels per metre across the
// sensor.
float CircleOfConfusion(float depth, vec4 lens, vec4 params) {
  float focus = max(lens.x, 1e-3);
  float focal = max(lens.y, 1e-4);
  float fnumber = max(lens.z, 1e-3);

  // The thin-lens diameter, in metres on the sensor. Nothing drawn — the sky,
  // the cleared background — is infinitely far, where `|d - s| / d` tends to
  // one and the circle to its largest: a lens focused on a face blurs the
  // horizon behind it. This used to answer zero there and kept the sky sharp.
  float denominator = max(fnumber * (focus - focal), 1e-6);
  float ratio = depth <= 0.0 ? 1.0 : abs(depth - focus) / depth;
  float diameter = ratio * (focal * focal) / denominator;

  // Metres on the sensor into texels on the screen, and a diameter into a
  // radius. The conversion needs a sensor size, which is what makes a
  // millimetre of focal length mean something; the frame's width supplies the
  // other half of it. **Derived rather than a constant**, because a constant
  // would mean a lens whose blur changed with the resolution — the same scene
  // rendered twice as wide would be a different photograph rather than a
  // larger one.
  return min(diameter * 0.5 * params.w, max(params.z, 0.0));
}

#endif  // CIRCLE_OF_CONFUSION_GLSL_

// --- lib/frag_coord_info.glsl ---
// The target's orientation, for a full-screen pass.
//
// Its own block rather than a member of each pass's, so the renderer binds it
// in one place, `drawFullscreen`, for every stage that declares it — the
// contract answers false for a stage that does not, and a pass that adds a
// screen-space pattern later gets the right rows by including this file.

#ifndef FRAG_COORD_INFO_GLSL_
#define FRAG_COORD_INFO_GLSL_

// --- lib/frag_coord.glsl ---
// Where a fragment sits, counted from the top of its target on every backend.

#ifndef FRAG_COORD_GLSL_
#define FRAG_COORD_GLSL_

/// `gl_FragCoord.xy` with row zero at the top of the picture.
///
/// [rows] is the target's height where the backend's row zero is the bottom
/// of the picture, and zero where it is the top. WebGL2 is the first kind:
/// window coordinates start at the lower left, and the engine draws the
/// picture upright there rather than mirroring every projection. Metal,
/// WebGPU and the software rasteriser are the second.
///
/// **Why a pattern cares and a picture does not.** Every screen-space pattern
/// in the engine — the Bayer dither, the grain, the jitter a ray march starts
/// from, the rotation of a shadow kernel — is a function of the pixel's row.
/// Read from the bottom, the same frame gets the pattern turned upside down,
/// and a four-row Bayer cell lands on different rows unless the height is a
/// multiple of four. The picture underneath is identical; the pattern on top
/// of it is not, and a comparison across backends counts every pixel it
/// moved.
vec2 FragCoordFromTop(float rows) {
  return rows > 0.0 ? vec2(gl_FragCoord.x, rows - gl_FragCoord.y)
                    : gl_FragCoord.xy;
}

#endif  // FRAG_COORD_GLSL_


layout(std140) uniform FragCoordInfo {
  /// x: the target's rows when its row zero is the bottom of the picture,
  /// zero when it is the top — see [FragCoordFromTop]. yzw unused.
  vec4 origin;
}
frag_coord_info;

/// This fragment's position with row zero at the top of the target.
vec2 TargetFragCoord() {
  return FragCoordFromTop(frag_coord_info.origin.x);
}

#endif  // FRAG_COORD_INFO_GLSL_


in vec2 v_uv;

layout(location = 0) out vec4 frag_color;

uniform sampler2D scene_texture;
uniform sampler2D surface_texture;

// The largest circle within a tile of here, in red, one texel a tile.
uniform sampler2D coc_tile_texture;

layout(std140) uniform DofInfo {
  // x: focus distance in metres. y: focal length in metres. z: f-number.
  // w: how many samples in the gather.
  vec4 lens;

  // x, y: one texel. z: the largest circle, in texels, whatever the lens
  // arithmetic says — a bound on the gather rather than on the optics.
  // w: texels per metre across the sensor, which is the frame's width in
  // texels over the sensor's width in metres.
  vec4 params;
}
dof_info;

// One cell of a 4x4 Bayer matrix, in [0, 1). The same table
// `reflections.frag` and `light_shafts.frag` keep.
float BayerCell(vec2 at) {
  int x = int(mod(at.x, 4.0));
  int y = int(mod(at.y, 4.0));
  int index = y * 4 + x;
  float value = 0.0;
  if (index == 0) value = 0.0;
  else if (index == 1) value = 8.0;
  else if (index == 2) value = 2.0;
  else if (index == 3) value = 10.0;
  else if (index == 4) value = 12.0;
  else if (index == 5) value = 4.0;
  else if (index == 6) value = 14.0;
  else if (index == 7) value = 6.0;
  else if (index == 8) value = 3.0;
  else if (index == 9) value = 11.0;
  else if (index == 10) value = 1.0;
  else if (index == 11) value = 9.0;
  else if (index == 12) value = 15.0;
  else if (index == 13) value = 7.0;
  else if (index == 14) value = 13.0;
  else value = 5.0;
  return value / 16.0;
}

// The circle of confusion at [depth], as a radius in texels.
float CircleAt(float depth) {
  return CircleOfConfusion(depth, dof_info.lens, dof_info.params);
}

void main() {
  // `textureLod` throughout this pass, for `shadow.glsl`'s own reason: the
  // gather below sits behind two early returns keyed on a per-fragment circle
  // of confusion, so a WGSL backend refuses the implicit derivative as
  // possibly non-uniform. All three textures are read at native size with no
  // mipmap of their own, so naming level zero directly changes no pixel.
  vec4 centre = textureLod(scene_texture, v_uv, 0.0);
  int samples = int(dof_info.lens.w + 0.5);
  if (samples < 1) {
    frag_color = centre;
    return;
  }

  float centreDepth = textureLod(surface_texture, v_uv, 0.0).a;
  float radius = CircleAt(centreDepth);
  // As far as anything nearby could spread, and never less than this
  // pixel's own circle.
  float gather = max(textureLod(coc_tile_texture, v_uv, 0.0).r, radius);
  if (gather < 0.5) {
    // Inside half a texel there is nothing to gather: no disc near here is
    // larger than the pixel it lands on, which is what "in focus" means.
    frag_color = centre;
    return;
  }

  vec3 total = centre.rgb;
  float weight = 1.0;
  // The nearer, more blurred layer: its colour, and how much of this pixel
  // its discs cover.
  vec3 nearTotal = vec3(0.0);
  float nearWeight = 0.0;
  float nearCover = 0.0;

  // Nothing drawn is infinitely far, for the comparison below as for the
  // circle above.
  float centreFar = centreDepth <= 0.0 ? 1e9 : centreDepth;

  // The spiral turned by a different angle at each pixel of a 4x4 block, so
  // twenty-odd taps across a wide disc read as grain rather than as twenty-odd
  // copies of a bright highlight. A Bayer cell rather than Jimenez's
  // interleaved gradient noise, which is a `fract` of a large product and
  // would not land on the same angle in the software backend's doubles.
  float turn = 6.2831853 * BayerCell(TargetFragCoord());

  // The golden angle, so consecutive samples never line up into a spoke.
  const float kGolden = 2.39996323;
  for (int i = 1; i <= 64; i++) {
    if (i > samples) break;
    // The middle of each ring's share of the area rather than its outer edge.
    float t = (float(i) - 0.5) / float(samples);
    // sqrt so the samples spread evenly over the disc's *area* rather than
    // bunching at the middle, which would leave the rim of a bokeh thin.
    float r = sqrt(t) * gather;
    float angle = float(i) * kGolden + turn;
    vec2 at = v_uv + vec2(cos(angle), sin(angle)) * r * dof_info.params.xy;

    vec4 tap = textureLod(scene_texture, at, 0.0);
    float tapDepth = textureLod(surface_texture, at, 0.0).a;
    float tapRadius = CircleAt(tapDepth);
    float tapFar = tapDepth <= 0.0 ? 1e9 : tapDepth;

    // Would this sample's own disc have reached here? A sharp pixel in front
    // of a blurred background says no — its disc is smaller than the
    // distance to here — and letting it in anyway is the bleed that makes a
    // sharp object glow into the blur behind it. A sample *behind* this pixel
    // reaches no further than this pixel's own disc either. Half a texel of
    // soft edge, so the reach does not step.
    float tapReach = tapFar > centreFar ? min(tapRadius, radius) : tapRadius;
    float reach = clamp(tapReach - r + 0.5, 0.0, 1.0);

    if (tapFar < centreFar && tapRadius > radius) {
      // In front and more blurred: a disc spread over this pixel. Each
      // sample stands for an equal share of the gather's area, and a disc
      // of radius c puts 1 / (pi c^2) of its light on each unit of it, so
      // the share it covers is reach * (gather / c)^2 / samples.
      float spread = gather / max(tapRadius, 0.5);
      nearTotal += tap.rgb * reach;
      nearWeight += reach;
      nearCover += reach * spread * spread;
    } else {
      total += tap.rgb * reach;
      weight += reach;
    }
  }

  vec3 far = total / weight;
  vec3 near = nearTotal / max(nearWeight, 1e-5);
  float cover = clamp(nearCover / float(samples), 0.0, 1.0);
  frag_color = vec4(mix(far, near, cover), centre.a);
}

''',
    'DofTileMax': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// The largest circle of confusion along one row of a tile — `gfx-34n`.
//
// The first step of the depth of field's neighbourhood, and the only one of
// its own: the frame is cut into square tiles as wide as the largest circle,
// and this walks each tile's rows, turning depth into a circle as it goes.
// The columns and the three-by-three neighbourhood that follow are the motion
// blur's own passes (`VelocityTileMax`, `VelocityNeighborMax`) reading a
// circle in red and nought in green, which is a motion whose length is the
// circle.
//
// **Why the gather needs it.** A pixel gathers from as far as the largest
// circle that could reach it, not from as far as its own: a sharp pixel
// beside a blurred foreground has a circle of nought and still lies under the
// foreground's disc.

// --- lib/circle_of_confusion.glsl ---
// The thin lens's circle of confusion — `gfx-34n`.
//
// One function for the two stages that need it, the depth of field's gather
// and the tile search in front of it: the tile's largest circle has to be the
// largest of the circles the gather will compute, to the bit.

#ifndef CIRCLE_OF_CONFUSION_GLSL_
#define CIRCLE_OF_CONFUSION_GLSL_

// The circle of confusion at [depth], as a radius in texels.
//
// [lens] x: focus distance in metres. y: focal length in metres. z: f-number.
// [params] z: the largest circle, in texels. w: texels per metre across the
// sensor.
float CircleOfConfusion(float depth, vec4 lens, vec4 params) {
  float focus = max(lens.x, 1e-3);
  float focal = max(lens.y, 1e-4);
  float fnumber = max(lens.z, 1e-3);

  // The thin-lens diameter, in metres on the sensor. Nothing drawn — the sky,
  // the cleared background — is infinitely far, where `|d - s| / d` tends to
  // one and the circle to its largest: a lens focused on a face blurs the
  // horizon behind it. This used to answer zero there and kept the sky sharp.
  float denominator = max(fnumber * (focus - focal), 1e-6);
  float ratio = depth <= 0.0 ? 1.0 : abs(depth - focus) / depth;
  float diameter = ratio * (focal * focal) / denominator;

  // Metres on the sensor into texels on the screen, and a diameter into a
  // radius. The conversion needs a sensor size, which is what makes a
  // millimetre of focal length mean something; the frame's width supplies the
  // other half of it. **Derived rather than a constant**, because a constant
  // would mean a lens whose blur changed with the resolution — the same scene
  // rendered twice as wide would be a different photograph rather than a
  // larger one.
  return min(diameter * 0.5 * params.w, max(params.z, 0.0));
}

#endif  // CIRCLE_OF_CONFUSION_GLSL_


in vec2 v_uv;

layout(location = 0) out vec4 frag_color;

uniform sampler2D surface_texture;

layout(std140) uniform DofTileInfo {
  // As `DofInfo.lens`: focus distance, focal length, f-number. w unused.
  vec4 lens;

  // As `DofInfo.params`: xy unused, z the largest circle in texels, w texels
  // per metre across the sensor.
  vec4 params;

  // xy: one texel of the scene, the grid the gather samples on. z: texels
  // per tile. w unused.
  vec4 source;

  // xy: this target's size in texels. zw unused.
  vec4 target;
}
dof_tile_info;

void main() {
  // `textureLod`, for `velocity_tile_max.frag`'s reason: a loop whose exit
  // is per fragment.
  int taps = int(dof_tile_info.source.z + 0.5);
  vec2 texel = floor(v_uv * dof_tile_info.target.xy);
  float row = (texel.y + 0.5) * dof_tile_info.source.y;
  float first = texel.x * float(taps);

  float largest = 0.0;
  for (int i = 0; i < 64; i++) {
    if (i >= taps) break;
    vec2 at = vec2((first + float(i) + 0.5) * dof_tile_info.source.x, row);
    float depth = textureLod(surface_texture, at, 0.0).a;
    largest = max(largest,
                  CircleOfConfusion(depth, dof_tile_info.lens,
                                    dof_tile_info.params));
  }
  frag_color = vec4(largest, 0.0, 0.0, 1.0);
}

''',
    'VelocityTileMax': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// The longest motion in a tile, one axis at a time — `R6`.
//
// The first half of the motion blur's neighbourhood: the frame is cut into
// square tiles as wide as the longest blur, and each tile learns the longest
// motion anywhere in it. Two passes rather than one, each over one axis of
// the tile — a tile of twenty pixels is forty reads that way instead of four
// hundred — and the same stage draws both: the step between taps says which
// axis it is walking.
//
// **The first pass also turns the velocity into what the blur spreads**:
// the velocity buffer holds a whole frame's motion in UV units, and the blur
// wants half the exposed part of it in pixels, clamped to the largest radius.
// The second pass reads what the first wrote and scales by one.

in vec2 v_uv;

layout(location = 0) out vec4 frag_color;

uniform sampler2D velocity_texture;

layout(std140) uniform TileMaxInfo {
  // xy: one texel of the source. zw: the step between taps, in texels —
  // (1, 0) walks a row, (0, 1) a column.
  vec4 source;

  // xy: what a source value is multiplied by to be a half-motion in pixels.
  // z: the longest a half-motion may be, in pixels. w: taps per tile.
  vec4 params;

  // xy: this target's size in texels. zw unused.
  vec4 target;
}
tile_info;

// [motion] scaled into pixels and no longer than the largest radius.
vec2 HalfMotion(vec2 motion) {
  vec2 pixels = motion * tile_info.params.xy;
  float span = length(pixels);
  float most = max(tile_info.params.z, 0.0);
  return span > most ? pixels * (most / span) : pixels;
}

void main() {
  // `textureLod` for `depth_of_field.frag`'s reason: the reads sit in a loop
  // whose exit is per fragment, and WGSL refuses an implicit derivative
  // there.
  vec2 walk = tile_info.source.zw;
  vec2 tile = floor(v_uv * tile_info.target.xy);
  int taps = int(tile_info.params.w + 0.5);
  // A tile's first texel: a whole tile along the axis walked, the tile's own
  // row or column across it.
  vec2 start = tile * mix(vec2(1.0), vec2(float(taps)), walk);

  vec2 longest = vec2(0.0);
  float longestSpan = 0.0;
  for (int i = 0; i < 64; i++) {
    if (i >= taps) break;
    vec2 at = (start + walk * float(i) + 0.5) * tile_info.source.xy;
    vec2 motion = HalfMotion(textureLod(velocity_texture, at, 0.0).rg);
    float span = dot(motion, motion);
    if (span > longestSpan) {
      longest = motion;
      longestSpan = span;
    }
  }
  frag_color = vec4(longest, 0.0, 1.0);
}

''',
    'VelocityNeighborMax': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// The longest motion in a tile and the eight around it — `R6`.
//
// A pixel is blurred by what moves near it, and "near" reaches as far as the
// longest blur, which is a tile. A pixel at the edge of its own tile can be
// crossed by something moving in the next one, so the blur asks this
// neighbourhood rather than its own tile: the dominant motion within one
// radius of any pixel in the tile.

in vec2 v_uv;

layout(location = 0) out vec4 frag_color;

uniform sampler2D tile_texture;

layout(std140) uniform NeighborMaxInfo {
  // xy: one tile texel. zw unused.
  vec4 texel;
}
neighbor_info;

void main() {
  vec2 longest = vec2(0.0);
  float longestSpan = 0.0;
  for (int dy = -1; dy <= 1; dy++) {
    for (int dx = -1; dx <= 1; dx++) {
      vec2 at = v_uv + vec2(float(dx), float(dy)) * neighbor_info.texel.xy;
      vec2 motion = textureLod(tile_texture, at, 0.0).rg;
      float span = dot(motion, motion);
      if (span > longestSpan) {
        longest = motion;
        longestSpan = span;
      }
    }
  }
  frag_color = vec4(longest, 0.0, 1.0);
}

''',
    'MotionBlur': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// Motion blur: a gather along the motion that dominates each neighbourhood
// — `R6`, after McGuire, Hennessy, Bukowski and Osman's reconstruction
// filter (2012), composited the way the exposure is.
//
// **Along the neighbourhood's motion, not the pixel's own.** A still pixel
// beside a moving object is still crossed by it for part of the exposure, so
// sampling only along each pixel's own velocity would leave the moving
// object's blur with a hard edge wherever it passes over the background.
// Each pixel therefore walks the longest motion within one tile of it — the
// `VelocityNeighborMax` pass's answer — and asks of every sample whether its
// colour could have reached here.
//
// **For how long, rather than whether.** A sample stands for one stride of
// the line, and a stride of surface sweeping a streak `2 × span` pixels long
// sits over any one pixel of it for `stride / (2 × span)` of the exposure.
// That is the sample's share of the time, and the shares say how much of the
// exposure something covered this pixel; what they leave over is time the
// pixel showed whatever was behind. The 2012 filter normalised its weights
// instead, and a still background has no weight there once a sample is half
// a pixel away, so where a spoke swept over the background the spoke was all
// that was left to normalise: it came out nearly opaque across its whole fan
// where the exposure shows it for the share of the time it was there.
//
// **Three layers, by depth against this pixel's.** Samples in front are
// occluders, over everything for their share. Samples level with it are
// this pixel's own surface, the pixel itself among them, over what is behind
// for their share. Samples behind are what shows where this pixel's surface
// is not: the pixel cannot see what its surface hid, so it borrows the
// nearest of what the line saw behind it, weighted by the inverse square of
// the distance. The depth is the surface buffer's alpha, view distance in
// metres, and "in front" is soft over a few centimetres so a surface does
// not occlude itself. The shares are summed in linear light, before the
// tone curve, which is where a camera integrates.
//
// Fifteen samples, offset along the line by the per-pixel noise so the
// steps between them are grain rather than fifteen copies of the object.
//
// The motion is half the exposed part of a frame's movement, either side of
// the pixel: a shutter open for half the frame blurs a quarter of the
// motion forward and a quarter back.

// --- lib/frag_coord_info.glsl ---
// The target's orientation, for a full-screen pass.
//
// Its own block rather than a member of each pass's, so the renderer binds it
// in one place, `drawFullscreen`, for every stage that declares it — the
// contract answers false for a stage that does not, and a pass that adds a
// screen-space pattern later gets the right rows by including this file.

#ifndef FRAG_COORD_INFO_GLSL_
#define FRAG_COORD_INFO_GLSL_

// --- lib/frag_coord.glsl ---
// Where a fragment sits, counted from the top of its target on every backend.

#ifndef FRAG_COORD_GLSL_
#define FRAG_COORD_GLSL_

/// `gl_FragCoord.xy` with row zero at the top of the picture.
///
/// [rows] is the target's height where the backend's row zero is the bottom
/// of the picture, and zero where it is the top. WebGL2 is the first kind:
/// window coordinates start at the lower left, and the engine draws the
/// picture upright there rather than mirroring every projection. Metal,
/// WebGPU and the software rasteriser are the second.
///
/// **Why a pattern cares and a picture does not.** Every screen-space pattern
/// in the engine — the Bayer dither, the grain, the jitter a ray march starts
/// from, the rotation of a shadow kernel — is a function of the pixel's row.
/// Read from the bottom, the same frame gets the pattern turned upside down,
/// and a four-row Bayer cell lands on different rows unless the height is a
/// multiple of four. The picture underneath is identical; the pattern on top
/// of it is not, and a comparison across backends counts every pixel it
/// moved.
vec2 FragCoordFromTop(float rows) {
  return rows > 0.0 ? vec2(gl_FragCoord.x, rows - gl_FragCoord.y)
                    : gl_FragCoord.xy;
}

#endif  // FRAG_COORD_GLSL_


layout(std140) uniform FragCoordInfo {
  /// x: the target's rows when its row zero is the bottom of the picture,
  /// zero when it is the top — see [FragCoordFromTop]. yzw unused.
  vec4 origin;
}
frag_coord_info;

/// This fragment's position with row zero at the top of the target.
vec2 TargetFragCoord() {
  return FragCoordFromTop(frag_coord_info.origin.x);
}

#endif  // FRAG_COORD_INFO_GLSL_

// --- lib/blue_noise.glsl ---
// A per-pixel offset for a march or a kernel rotation — `R3`.
//
// **The engine's blue noise while a temporal resolve runs, the fixed 4 × 4
// pattern otherwise.** A march jittered by a pattern that never changes puts
// the same dither on every frame, and the eye finds it; with the resolve on,
// each frame reads the next of 32 slices of blue noise and the history
// averages them into a smooth answer. Off, the pattern is exactly what the
// passes read before, so a frame without the resolve is the frame it was.
//
// The table is `EngineTables.blueNoise`: 32 slices of 64 × 64 in an 8 × 4
// atlas, one byte a texel. Read at texel centres through a nearest sampler.
//
// Include after `lib/frag_coord_info.glsl` or anything else that gives the
// pixel from the top.

#ifndef BLUE_NOISE_GLSL_
#define BLUE_NOISE_GLSL_

uniform sampler2D blue_noise_texture;

layout(std140) uniform NoiseInfo {
  /// x: one to read the blue noise, nought for the pattern. y: this frame's
  /// slice, the frame index modulo 32. zw unused.
  vec4 noise;
}
noise_info;

/// One cell of a 4 × 4 Bayer matrix, in [0, 1).
float BayerCell(vec2 at) {
  int x = int(mod(at.x, 4.0));
  int y = int(mod(at.y, 4.0));
  int index = y * 4 + x;
  float value = 0.0;
  if (index == 0) value = 0.0;
  else if (index == 1) value = 8.0;
  else if (index == 2) value = 2.0;
  else if (index == 3) value = 10.0;
  else if (index == 4) value = 12.0;
  else if (index == 5) value = 4.0;
  else if (index == 6) value = 14.0;
  else if (index == 7) value = 6.0;
  else if (index == 8) value = 3.0;
  else if (index == 9) value = 11.0;
  else if (index == 10) value = 1.0;
  else if (index == 11) value = 9.0;
  else if (index == 12) value = 15.0;
  else if (index == 13) value = 7.0;
  else if (index == 14) value = 13.0;
  else value = 5.0;
  return value / 16.0;
}

/// This frame's blue noise at the pixel [at], in [0, 1).
float BlueNoise(vec2 at) {
  float slice = noise_info.noise.y;
  vec2 cell = mod(floor(at), 64.0);
  vec2 corner = vec2(mod(slice, 8.0), floor(slice / 8.0)) * 64.0;
  vec2 uv = (corner + cell + 0.5) / vec2(512.0, 256.0);
  return textureLod(blue_noise_texture, uv, 0.0).r * (255.0 / 256.0);
}

/// The offset for the pixel [at]: blue noise or the pattern, per `noise.x`.
float PixelNoise(vec2 at) {
  return noise_info.noise.x > 0.5 ? BlueNoise(at) : BayerCell(at);
}

#endif  // BLUE_NOISE_GLSL_


in vec2 v_uv;

layout(location = 0) out vec4 frag_color;

uniform sampler2D scene_texture;
uniform sampler2D velocity_texture;
uniform sampler2D surface_texture;
uniform sampler2D neighbor_texture;

layout(std140) uniform MotionBlurInfo {
  // xy: one texel of the scene. zw: its size in texels.
  vec4 scene;

  // xy: what a velocity is multiplied by to be a half-motion in pixels.
  // z: the longest a half-motion may be, in pixels. w: samples.
  vec4 params;

  // xy: the neighbourhood texture's size in tiles. z: a tile's width in
  // pixels. w: how far apart in metres two depths are before one is in
  // front of the other.
  vec4 tiles;
}
blur_info;

// [motion] scaled into pixels and no longer than the largest radius — the
// same arithmetic `velocity_tile_max.frag` applies on its first pass.
vec2 HalfMotion(vec2 motion) {
  vec2 pixels = motion * blur_info.params.xy;
  float span = length(pixels);
  float most = max(blur_info.params.z, 0.0);
  return span > most ? pixels * (most / span) : pixels;
}

// Nothing drawn is infinitely far.
float Far(float depth) {
  return depth <= 0.0 ? 1e9 : depth;
}

// Whether a point [gap] away is inside a streak [span] pixels long, with a
// pixel's width of soft edge.
float Reaches(float gap, float span) {
  return 1.0 - smoothstep(span - 0.5, span + 0.5, gap);
}

void main() {
  // `textureLod` throughout, for `depth_of_field.frag`'s reason: the gather
  // sits behind an early return keyed on the neighbourhood's motion.
  vec4 centre = textureLod(scene_texture, v_uv, 0.0);
  vec2 here = v_uv * blur_info.scene.zw;
  vec2 tile = floor(here / max(blur_info.tiles.z, 1.0));
  vec2 dominant =
      textureLod(neighbor_texture, (tile + 0.5) / blur_info.tiles.xy, 0.0).rg;
  float reach = length(dominant);
  int samples = int(blur_info.params.w + 0.5);
  // Under half a pixel of motion anywhere near: nothing here would move a
  // sample off this pixel.
  if (reach <= 0.5 || samples < 1) {
    frag_color = centre;
    return;
  }

  // Half a pixel at least, so a still pixel's own streak is the pixel it is
  // in, and the division below has something to divide by.
  float ownSpan =
      max(length(HalfMotion(textureLod(velocity_texture, v_uv, 0.0).rg)), 0.5);
  float ownDepth = Far(textureLod(surface_texture, v_uv, 0.0).a);
  float extent = max(blur_info.tiles.w, 1e-4);
  // The length of line each sample stands for.
  float stride = 2.0 * reach / float(samples + 1);

  // The pixel itself is level with itself, and covers itself for its share:
  // all of the exposure when it is still, little of it when it is fast. It
  // stands for a pixel at least, so a short line whose samples crowd closer
  // than a pixel does not leave a still pixel partly see-through.
  float ownShare = min(max(stride, 1.0) / (2.0 * ownSpan), 1.0);
  vec3 front = vec3(0.0);
  float frontCover = 0.0;
  vec3 level = centre.rgb * ownShare;
  float levelCover = ownShare;
  vec3 back = vec3(0.0);
  float backWeight = 0.0;

  float jitter = PixelNoise(TargetFragCoord()) - 0.5;
  int middle = (samples - 1) / 2;
  for (int i = 0; i < 64; i++) {
    if (i >= samples) break;
    // The middle sample is the pixel itself, counted above.
    if (i == middle) continue;
    float t = mix(-1.0, 1.0, (float(i) + jitter + 1.0) / float(samples + 1));
    vec2 there = floor(here + dominant * t) + 0.5;
    vec2 at = there * blur_info.scene.xy;
    float gap = length(there - here);

    vec3 tap = textureLod(scene_texture, at, 0.0).rgb;
    float tapSpan =
        max(length(HalfMotion(textureLod(velocity_texture, at, 0.0).rg)), 0.5);
    float tapDepth = Far(textureLod(surface_texture, at, 0.0).a);

    // How far the sample is in front of this pixel, and how far behind:
    // each is one past the soft extent, and neither is level.
    float nearer = clamp((ownDepth - tapDepth) / extent, 0.0, 1.0);
    float behind = clamp((tapDepth - ownDepth) / extent, 0.0, 1.0);
    // The share of the exposure the sample's stride spends over this pixel.
    float share = Reaches(gap, tapSpan) * min(stride / (2.0 * tapSpan), 1.0);
    front += tap * (nearer * share);
    frontCover += nearer * share;
    level += tap * ((1.0 - nearer - behind) * share);
    levelCover += (1.0 - nearer - behind) * share;
    // A sample on this very pixel is no nearer than one a pixel away.
    float nearness = behind / max(gap * gap, 1.0);
    back += tap * nearness;
    backWeight += nearness;
  }

  // Behind, under this pixel's surface, under what passed in front — each
  // layer for the part of the exposure the one above it left over, and the
  // average of a layer standing for it when it covers more than the whole.
  vec3 own = level / levelCover;
  vec3 behindColor = backWeight > 0.0 ? back / backWeight : own;
  vec3 under = mix(behindColor, own, min(levelCover, 1.0));
  vec3 over = frontCover > 0.0 ? front / frontCover : under;
  frag_color = vec4(mix(under, over, min(frontCover, 1.0)), centre.a);
}

''',
    'ViewportShade': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// Viewport shading, read out of the surface buffer — `gfx-43n`, `gfx-44n` and
// `gfx-45n`, three branches of one stage.
//
// **This deletes a bug class rather than adding a look.** The modeller's
// normals view works by walking the subject and swapping every material for a
// debug one, remembering the old one to put back; its own docstring documents
// what happens when the remembering fails. Nothing here touches a material.
// The scene pass already wrote a world normal, a roughness and a view-axis
// depth into the second attachment, and every mode below is arithmetic on
// those — so the subject is never modified, there is nothing to restore, and
// a mode is a uniform rather than a traversal.
//
// **The cost is real and is stated where a caller can see it.** Declaring a
// read of the surface buffer attaches the second colour attachment, and
// attachments in one target must agree on sample count, so a frame with any
// of these modes on is a frame the scene pass did not multisample.
// `FrameResult.antiAliasing.msaaDeclined` says so; `anchor_identity_test.dart`
// is where that trade is pinned.
//
// One stage with branches rather than three stages, because all three read the
// same two channels and the difference between them is a handful of lines. A
// branch on a uniform is coherent across the whole draw — every fragment takes
// the same one — so it costs a compare and not a divergence.

in vec2 v_uv;

layout(location = 0) out vec4 frag_color;

uniform sampler2D scene_texture;
uniform sampler2D surface_texture;

layout(std140) uniform ShadeInfo {
  // x: which mode. 0 leaves the picture alone, 1 normals as colour, 2 clay,
  // 3 outline, 4 curvature. z and w below mean different things per mode,
  // which is what keeps this to one block.
  // y: how much of the shaded result to mix over the lit picture, 0 to 1.
  // z: mode 2 — how much ambient sits under the studio light. mode 3 — the
  //    depth difference, in metres, an edge starts at. mode 4 — the gain on
  //    the curvature estimate.
  // w: mode 3 — how far apart in angle two normals must be to count as an
  //    edge, in cosine. mode 4 — how much of the cavity is darkened rather
  //    than lit.
  vec4 params;

  // x, y: one texel. z: the outline's width in texels. w: unused.
  vec4 screen;

  // xyz: which way the studio light points, for mode 2. w: unused.
  vec4 light;
}
shade_info;

// The octahedral decode every reader of this buffer keeps — see `ssao.frag`,
// which carries the argument for the encoding.
vec3 DecodeOctahedral(vec2 e) {
  e = e * 2.0 - 1.0;
  vec3 n = vec3(e.xy, 1.0 - abs(e.x) - abs(e.y));
  float t = max(-n.z, 0.0);
  n.x += n.x >= 0.0 ? -t : t;
  n.y += n.y >= 0.0 ? -t : t;
  return normalize(n);
}

void main() {
  // `textureLod` throughout this pass, for `shadow.glsl`'s own reason: modes 3
  // and 4 read the surface buffer again behind the early return below, which
  // is keyed on a per-fragment depth rather than on the uniform `mode` the
  // docstring above talks about — so a WGSL backend refuses the implicit
  // derivative there as possibly non-uniform. Both textures are read at native
  // size with no mipmap of their own, so naming level zero directly changes no
  // pixel.
  vec4 scene = textureLod(scene_texture, v_uv, 0.0);
  int mode = int(shade_info.params.x + 0.5);
  float mix_amount = clamp(shade_info.params.y, 0.0, 1.0);
  if (mode < 1 || mix_amount <= 0.0) {
    frag_color = scene;
    return;
  }

  vec4 surface = textureLod(surface_texture, v_uv, 0.0);
  float depth = surface.a;
  // Nothing was drawn here: the buffer is cleared to zero and a normal
  // decoded from that is a direction pointing nowhere. The background keeps
  // whatever the scene left, which is what makes every mode below a shading
  // of the *subject* rather than a wash over the frame.
  if (depth <= 0.0) {
    frag_color = scene;
    return;
  }

  vec3 normal = DecodeOctahedral(surface.rg);
  vec3 shaded = scene.rgb;

  if (mode == 1) {
    // **Normals as colour**, the mode the material swap existed for. The
    // usual half-and-half mapping, so a surface facing the camera is the
    // pale blue everybody recognises from every other modeller.
    shaded = normal * 0.5 + 0.5;
  } else if (mode == 2) {
    // **Clay**: one studio light and an ambient floor, no texture, no
    // material. What it is for is shape — a form with its albedo taken away,
    // which is the whole reason a sculptor turns it on.
    float ambient = clamp(shade_info.params.z, 0.0, 1.0);
    float lambert = max(dot(normal, normalize(shade_info.light.xyz)), 0.0);
    shaded = vec3(ambient + (1.0 - ambient) * lambert);
  } else if (mode == 3) {
    // **Outline**, from depth *and* normal, because either alone misses half
    // the edges a modeller is looking for. A depth step finds a silhouette
    // and misses a crease in a flat wall; a normal step finds the crease and
    // misses two surfaces at the same angle one behind the other. Both, and
    // an edge is either.
    vec2 texel = shade_info.screen.xy * max(shade_info.screen.z, 1.0);
    float depthEdge = 0.0;
    float normalEdge = 0.0;
    // Four neighbours rather than eight: a Sobel would weight diagonals it
    // then has to normalise, and the answer here is a threshold rather than a
    // gradient direction.
    vec2 offsets[4] = vec2[4](
        vec2(texel.x, 0.0), vec2(-texel.x, 0.0),
        vec2(0.0, texel.y), vec2(0.0, -texel.y));
    for (int i = 0; i < 4; i++) {
      vec4 tap = textureLod(surface_texture, v_uv + offsets[i], 0.0);
      if (tap.a <= 0.0) {
        // Against the background: that is a silhouette, and the strongest
        // edge there is.
        depthEdge = 1.0;
        continue;
      }
      depthEdge = max(depthEdge, abs(tap.a - depth));
      normalEdge =
          max(normalEdge, 1.0 - dot(DecodeOctahedral(tap.rg), normal));
    }

    float depthHit = step(max(shade_info.params.z, 1e-4), depthEdge);
    float normalHit = step(max(shade_info.params.w, 1e-4), normalEdge);
    float edge = max(depthHit, normalHit);
    // The line is drawn *dark over the picture* rather than as its own
    // colour: an outline that replaced the pixel would hide the shading it is
    // meant to clarify.
    shaded = scene.rgb * (1.0 - edge);
  } else if (mode == 4) {
    // **Curvature and cavity**, from how fast the normal field turns. The
    // divergence of the normals across a pixel: a convex ridge turns one way,
    // a concave crease the other, and a flat face does not turn at all.
    //
    // Read from the normal buffer rather than from depth, deliberately: a
    // depth-based curvature is dominated by how far away the surface is, so a
    // model twice as far reads as half as detailed. A normal field is the same
    // at any distance.
    vec2 texel = shade_info.screen.xy;
    vec3 right = DecodeOctahedral(
        textureLod(surface_texture, v_uv + vec2(texel.x, 0.0), 0.0).rg);
    vec3 left = DecodeOctahedral(
        textureLod(surface_texture, v_uv - vec2(texel.x, 0.0), 0.0).rg);
    vec3 down = DecodeOctahedral(
        textureLod(surface_texture, v_uv + vec2(0.0, texel.y), 0.0).rg);
    vec3 up = DecodeOctahedral(
        textureLod(surface_texture, v_uv - vec2(0.0, texel.y), 0.0).rg);

    // The x component of the horizontal change plus the y of the vertical:
    // the screen-space divergence, which is positive on a ridge and negative
    // in a groove.
    float curvature = ((right.x - left.x) + (down.y - up.y)) *
                      max(shade_info.params.z, 0.0);
    float cavity = clamp(-curvature, 0.0, 1.0) *
                   clamp(shade_info.params.w, 0.0, 1.0);
    float ridge = clamp(curvature, 0.0, 1.0);
    // Grey, lit on the ridges and darkened in the cavities, which is what a
    // cavity map is read for: the creases a sculpt has, seen without its
    // colour.
    shaded = vec3(clamp(0.5 + ridge * 0.5 - cavity, 0.0, 1.0));
  }

  frag_color = vec4(mix(scene.rgb, shaded, mix_amount), scene.a);
}

''',
    'ProbePrefilter': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// One face of one level of a reflection probe: the captured cube, convolved
// by roughness, written where a lit shader's `textureLod` will read it.
//
// The device-side twin of `EnvironmentMap.prefilter`, and deliberately the
// same convolution: the same fixed spiral of taps, the same GGX lobe for the
// roughness, the same weighting, so the software rasteriser that the three
// golden sets are measured by reads this file aloud. Where the two differ —
// bilinear taps here against nearest ones there — the difference is noise
// well under the cross-backend budgets.
//
// **Not yet filtered importance sampling.** Every tap reads the capture's base
// level, because the capture has no mips to read a wider one from; a rough
// level's sixty-four taps are therefore sixty-four points, and a small bright
// thing in the room can show as a faint pattern rather than a smooth glow.
//
// **Where this writes decides the direction, not the vertex stage.** The
// full-screen triangle hands over a uv with v = 0 at row zero of the level on
// every backend, and a cube face maps (s, t) to a direction by the table every
// graphics API agrees on: row zero of the +X face looks up +Y, column zero
// looks along +Z. `FaceDirection` is that table inverted, face by face, and
// it has to agree with `BoundTexture.sampleCube` on the software side and the
// hardware sampler on the other two, which the conformance check that clears
// one face and reads it back through this stage is for.
//
// The capture is read through the sampler, not through a table of its own:
// the renderer drew each face so that a hardware lookup returns the picture
// in that direction — see `probeFaceViewProjection` for what that took.
precision highp float;

in vec2 v_uv;

layout(location = 0) out vec4 frag_color;

/// The six views the probe captured, base level only.
uniform samplerCube capture_texture;

layout(std140) uniform ProbeInfo {
  /// x: which face is being written, 0..5 in +X, −X, +Y, −Y, +Z, −Z order.
  /// y: the roughness of this level, 0 for the mirror.
  /// z: the level of the capture to read, 0 for a capture with one.
  /// w: how many taps; 1 copies the capture along the axis and nothing else.
  vec4 params;
}
probe_info;

/// The direction the texel at [st] of [face] looks along, with t measured
/// down the face from row zero.
vec3 FaceDirection(int face, vec2 st) {
  float u = st.x * 2.0 - 1.0;
  float v = st.y * 2.0 - 1.0;
  if (face == 0) return normalize(vec3(1.0, -v, -u));
  if (face == 1) return normalize(vec3(-1.0, -v, u));
  if (face == 2) return normalize(vec3(u, 1.0, v));
  if (face == 3) return normalize(vec3(u, -1.0, -v));
  if (face == 4) return normalize(vec3(u, -v, 1.0));
  return normalize(vec3(-u, -v, -1.0));
}

void main() {
  int face = int(probe_info.params.x + 0.5);
  float roughness = probe_info.params.y;
  float lod = probe_info.params.z;
  int samples = clamp(int(probe_info.params.w + 0.5), 1, 128);

  vec3 axis = FaceDirection(face, v_uv);

  // One tap is a copy: the mirror level, and the conformance check's way of
  // reading one texel of one face at one level back out.
  if (samples == 1) {
    frag_color = vec4(textureLod(capture_texture, axis, lod).rgb, 1.0);
    return;
  }

  // The GGX width, squared first because roughness is authored perceptually —
  // the alpha the lit shaders' own specular term uses, so the lobe a level
  // was built for is the lobe a surface of that roughness actually has.
  float alpha = max(roughness * roughness, 1e-3);
  float alpha2 = alpha * alpha;

  // A frame about the axis, so one tap set serves every texel.
  vec3 up = abs(axis.z) < 0.999 ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0);
  vec3 right = normalize(cross(up, axis));
  vec3 ahead = cross(axis, right);

  // A fixed spiral rather than a hash of the fragment: the same texel has to
  // come out the same on every backend, or the golden means nothing.
  const float golden = 3.14159265 * (3.0 - sqrt(5.0));
  vec3 sum = vec3(0.0);
  float weight = 0.0;
  for (int i = 0; i < 128; i++) {
    if (i >= samples) break;
    float e = (float(i) + 0.5) / float(samples);
    float theta = golden * float(i);
    // A half vector drawn from GGX about the axis, which stands for the
    // normal, the view and the reflection at once, and the tap is the view
    // reflected about it. The inverse CDF places the half vector's cosine;
    // its sine is what is left of the unit length, and nothing else may
    // shrink it, or the lobe collapses onto the axis.
    float cosH = sqrt((1.0 - e) / (1.0 + (alpha2 - 1.0) * e));
    float sinH = sqrt(max(1.0 - cosH * cosH, 0.0));
    vec3 tap = vec3(2.0 * cosH * sinH * cos(theta),
                    2.0 * cosH * sinH * sin(theta),
                    2.0 * cosH * cosH - 1.0);
    vec3 dir = right * tap.x + ahead * tap.y + axis * tap.z;
    // Weighted by the cosine to the axis, the n·l the split sum leaves in
    // the integral; a tap reflected below the horizon carries nothing.
    float cosine = dot(dir, axis);
    if (cosine <= 0.0) continue;
    sum += textureLod(capture_texture, dir, lod).rgb * cosine;
    weight += cosine;
  }

  frag_color = vec4(weight > 0.0 ? sum / weight : vec3(0.0), 1.0);
}

''',
    'ShadowDistance': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// The depth pass for a point light: radial distance rather than clip depth.
//
// A cube shadow compares how far a fragment is from the light against how far
// the nearest caster in that direction was. Clip depth cannot answer that: it
// is measured along one face's axis, so the same distance reads differently
// depending on which face a direction lands on, and every face boundary would
// show a seam. Distance from the light is the same number whichever face
// recorded it.
//
// Normalised by the light's range so it fits an 8-bit-ish target and so the
// comparison is a plain fraction. Beyond the range there is no light, so the
// shadow there is nobody's business.
//
// One attachment: this writes a shadow atlas, and the surface buffer belongs
// to the scene pass.
#define F3D_NO_SURFACE_BUFFER
// No fog: shadow_depth.frag gives the reason.
#define F3D_NO_FOG
// --- lib/color.glsl ---
// Colour space helpers and the fragment output interface.
//
// Split out of surface.glsl so a shader that needs no material inputs — the
// normals debug view — can avoid DECLARING the FragInfo uniform block at all.
// That matters more than it looks: reflection metadata reports a block as
// present merely because it was declared, even when the compiled shader binds
// no such buffer, so a declared-but-unused block is indistinguishable from a
// used one until Metal crashes on the bind.

#ifndef COLOR_GLSL_
#define COLOR_GLSL_

precision highp float;

const float kPi = 3.14159265359;

// One varying set shared by every fragment shader, matching mesh.vert.
//
// All five are declared here, including the two the debug models never read: a
// fragment shader whose `in` block disagrees with the vertex shader's `out`
// block fails to link, and there is no partial-match rule to lean on.
in vec3 v_world_position;
in vec3 v_normal;
in vec2 v_texcoord;
in vec4 v_tangent;
in vec4 v_color;

/// Where this fragment is in the level's lightmap. Zero from every vertex
/// stage but `mesh_lightmapped.vert`, and read only by the lit models, which
/// sample a one-texel black there when a material has no map.
in vec2 v_lightmap_uv;

layout(location = 0) out vec4 frag_color;

// The second attachment: what a screen-space effect needs to know about the
// surface it is looking at. World-space normal in rgb, and in a the depth along
// the view axis in world metres — not a window depth; `WriteSurfaceGeometry`
// says at length why not.
//
// Depth travels here rather than in a depth texture because flutter_gpu cannot
// sample one — the same reason the shadow pass writes its depth into a colour
// target. See ARCHITECTURE.md §2.
//
// Guarded, because not every stage that includes this header draws into a
// two-attachment target. The shadow pass draws into one, and a pipeline
// declaring an output its target has no slot for is a mismatch worth avoiding
// rather than discovering.
#ifndef F3D_NO_SURFACE_BUFFER
layout(location = 1) out vec4 frag_surface;

/// The surface's own colour, sRGB-encoded, alpha one where a surface was
/// drawn — `L5`. The third attachment, present only when a pass reads it (the
/// indirect light does) and the device opens three; like the surface buffer,
/// written unconditionally and discarded when absent. Stored in the surface
/// buffer's format rather than eight bits a channel, and `Renderer` says why.
layout(location = 2) out vec4 frag_albedo;
#endif

/// What [frag_albedo] carries: the lit models set it in `ReadSurface`, and a
/// stage that reflects nothing — unlit, the debug views — leaves it black,
/// which is what light bounced onto it would come to.
vec3 g_albedo = vec3(0.0);

/// Octahedral encoding: a unit vector in two channels instead of three.
///
/// Worth the arithmetic because the fourth channel is already spent on depth,
/// and without a free channel there is nowhere to put roughness — which is the
/// difference between a reflection that knows stone from a mirror and one that
/// does not. The error is well under a degree, far below anything a reflection
/// off rough stone would show.
vec2 EncodeOctahedral(vec3 n) {
  n /= abs(n.x) + abs(n.y) + abs(n.z);
  vec2 e = n.xy;
  if (n.z < 0.0) {
    e = (1.0 - abs(n.yx)) * vec2(n.x >= 0.0 ? 1.0 : -1.0,
                                 n.y >= 0.0 ? 1.0 : -1.0);
  }
  return e * 0.5 + 0.5;
}

/// Where a debug pass leaves the picture it wants shown instead of the normal.
///
/// Declared here, in the header every lit shader includes **first**, and
/// written from surface.glsl, which is included after. The alternative was a
/// new member on a shared uniform block; a global costs nothing and moves no
/// offsets. It is read at the moment the surface buffer is written, which
/// happens after the lighting loop has run, so the value is there by then.
vec3 g_debug_surface = vec3(0.0);
bool g_debug_surface_on = false;

/// Whether [WriteSurface] weights the colour by its alpha: set by
/// `ReadSurface` for a material that blends, and false for everything else.
///
/// **The blend takes its source as premultiplied**, so a blended surface has
/// to hand it the colour times the alpha — a pane at a fifth of opaque adds a
/// fifth of its light, not all of it. glTF's blend mode is Porter and Duff's
/// over on straight colour, and this is the one place that turns the lit
/// radiance into what that means. An opaque or masked surface keeps its
/// colour whole: its alpha is not a coverage, and nothing blends it.
/// A global for the reason [g_debug_surface] is one.
bool g_premultiply = false;

// **A stage that needs none of this must be able to declare none of it.** On
// Vulkan both stages' descriptors are merged into one set layout, and two
// bindings with the same number in it is not a layout the specification
// allows. A driver may accept it anyway; a Galaxy A55's refuses the pipeline
// with `ErrorUnknown` and no other word, which is how the shadow pass came to
// build everywhere except there — its only uniform block was this one, and it
// landed on the same binding as the vertex stage's first.
#ifndef F3D_NO_FOG

/// Distance fog, in its own block rather than folded into FragInfo.
///
/// Its own because color.glsl is included before FragInfo is declared, and
/// because appending to a block that half a dozen shaders already share is a
/// way to move offsets nobody expected to move. Three vec4s is a cheap price
/// for not touching any of that.
layout(std140) uniform FogInfo {
  /// rgb: linear fog colour. w: density per metre, zero for no fog.
  vec4 fog;

  /// xyz: camera position in world space. Duplicated from FragInfo so this
  /// block stands alone; a vec3 is cheaper than a coupling.
  vec4 eye;

  /// xyz: the direction the camera looks, as a unit vector in world space.
  /// w: what a transparent draw writes under weighted blended transparency —
  /// `R8`, see `WriteWeightedBlended`. Zero for every other draw.
  ///
  /// Here rather than in a block of its own because it answers the same
  /// question [eye] does — where the camera is and which way it faces — and
  /// this is the block `color.glsl` can see.
  vec4 forward;
}
fog_info;

/// How far this fragment is from the eye, in world metres.
///
/// What the fog fades by. Distance rather than depth, because fog is a
/// property of the air between two points and does not care which way the
/// camera happens to face.
float EyeDistance() { return distance(v_world_position, fog_info.eye.xyz); }

/// How far this fragment is *along the view axis*, in world metres.
///
/// What the surface buffer's alpha holds. Depth rather than distance, and the
/// difference only shows on an orthographic camera — where the rays through
/// the pixels are parallel instead of meeting at the eye, so a distance from
/// the eye names a sphere that the pixel's ray crosses somewhere the reader
/// cannot solve for. A depth along the axis names a plane, which every ray
/// crosses exactly once. See `WorldAtDepth` in `post/ssao.frag` for the
/// reconstruction both projections share.
float ViewDepth() {
  return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz);
}

#else  // F3D_NO_FOG

// The same two questions, answered without the block: a stage that declares no
// fog has no eye position to measure from either. Stubs rather than a guard at
// every call site, so that what includes this file reads the same whichever
// way it was compiled.
float EyeDistance() { return 0.0; }
float ViewDepth() { return 0.0; }

#endif  // F3D_NO_FOG

/// sRGB to linear. Textures are authored in sRGB, but lighting is only correct
/// in linear space; skipping this is what makes naive renderers look muddy.
vec3 SrgbToLinear(vec3 srgb) {
  return mix(
      srgb / 12.92,
      pow((srgb + vec3(0.055)) / 1.055, vec3(2.4)),
      step(vec3(0.04045), srgb));
}

/// Linear to sRGB. The render target is a plain UNorm format rather than an
/// sRGB one, so the encode has to happen here.
vec3 LinearToSrgb(vec3 linear) {
  return mix(
      linear * 12.92,
      1.055 * pow(linear, vec3(1.0 / 2.4)) - vec3(0.055),
      step(vec3(0.0031308), linear));
}

/// Writes scene-referred linear light into the HDR target.
///
/// No tone map and no sRGB encode: those moved into the composite pass, which
/// is the entire point of rendering into `r16g16b16a16Float` first. Applying
/// them here meant every model wrote display-referred colour into an 8-bit
/// buffer, so anything above display white was gone before post-processing
/// could see it — and bloom is a function of exactly that.
///
/// Exposure moved with them, for the same reason: it belongs on the same side
/// of the display transform as the tone map.
/// Records the geometry of this fragment for whatever runs after the scene.
///
/// Called from the same place that writes colour, so a surface cannot be lit
/// into the frame without also describing itself — which is the failure that
/// leaves a screen-space effect reflecting whatever was in the buffer before.
///
/// rg: octahedral normal. b: perceptual roughness. a: **depth along the view
/// axis, in world metres** — see [ViewDepth].
///
/// **Not `gl_FragCoord.z`, and that is a defect this channel carried until it
/// was looked at.** Window depth crowds every distant surface into the top of
/// its range — with a near plane of a tenth of a metre, everything past twenty
/// metres lives in the last half a hundredth of `[0, 1]` — and this attachment
/// is a half float, whose steps up there are about five ten-thousandths. So two
/// surfaces half a metre apart at twenty metres stored the *same* number, and
/// every screen-space pass that compares against this channel decided whole
/// bands of pixels by rounding. The occlusion pass drew them: vertical stripes
/// along the lines of constant depth on any wall receding from the camera, on
/// both GPU backends. The software rasteriser kept the channel at full
/// precision and drew the effect correctly, so it was the one that looked
/// wrong against the other two.
///
/// A depth in metres has none of that: the exponent carries the range and the
/// mantissa carries the same relative precision everywhere, which at twenty
/// metres is a centimetre. Both numbers are measured in
/// `flutter3d/test/surface_depth_test.dart`.
///
/// Zero still means nothing was drawn. The attachment is cleared to zero and
/// nothing is drawn in front of the near plane.
void WriteSurfaceGeometry(float roughness) {
#ifndef F3D_NO_SURFACE_BUFFER
  // `L5`: the surface's colour, whatever the surface buffer ends up holding.
  frag_albedo = vec4(LinearToSrgb(clamp(g_albedo, vec3(0.0), vec3(1.0))), 1.0);
  // A debug pass takes the buffer over rather than getting one of its own.
  // The surface buffer already has an attachment, a viewer and a golden; a
  // second one would need all three built before it could answer anything.
  if (g_debug_surface_on) {
    frag_surface = vec4(g_debug_surface, ViewDepth());
    return;
  }
  // Reversed on a back face, as the lit normal is, so the occlusion and
  // reflection passes see the side of a double-sided surface that faces them.
  vec3 geometric = normalize(v_normal);
  if (!gl_FrontFacing) geometric = -geometric;
  frag_surface = vec4(EncodeOctahedral(geometric),
                      clamp(roughness, 0.0, 1.0), ViewDepth());
#endif
}

/// Fades [color] toward the fog with distance from the eye.
///
/// Exponential rather than linear, because linear fog has a visible plane
/// where it starts and a dungeon corridor is exactly where that shows.
vec3 ApplyFog(vec3 color) {
#ifdef F3D_NO_FOG
  return color;
#else
  float density = fog_info.fog.w;
  if (density <= 0.0) return color;
  float d = EyeDistance();
  return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0));
#endif
}

/// How much a transparent fragment counts for against the others over its
/// pixel — `R8`. McGuire and Bavoil's depth weight (their equation 9): a near
/// layer outweighs a far one, which is all the ordering a weighted average
/// can keep. [alpha] multiplies it, as theirs does, so a faint layer counts
/// faintly. Depth along the view axis, in metres, the surface buffer's.
float WeightedBlendedWeight(float alpha) {
  float z = abs(ViewDepth());
  float near = z / 5.0;
  float far = z / 200.0;
  float far3 = far * far * far;
  return alpha *
         clamp(10.0 / (1e-5 + near * near + far3 * far3), 1e-2, 3e3);
}

/// What a transparent draw writes when the frame composites transparency
/// order-independently — `R8`. `fog_info.forward.w` says which:
///
/// - 0: [frag_color] as it stands, the sorted blend's source. Every opaque
///   draw, and every draw in a frame that sorts.
/// - 1: the accumulation target's share — the colour, which the engine keeps
///   premultiplied, and the alpha, both times the weight. Added.
/// - 2: the revealage target's — the alpha alone, in every channel, which the
///   blend multiplies the target by one minus of.
/// - 3: both at once, the second into attachment one, where the surface
///   buffer would be; the pass that asks has no surface buffer attached.
///
/// Selects rather than returns, because a phi of constants is what
/// SPIRV-Cross refuses. At nought the branch is not taken and [frag_color]
/// is untouched, which is what keeps a sorting frame byte-identical.
void WriteWeightedBlended() {
#ifndef F3D_NO_FOG
  float mode = fog_info.forward.w;
  if (mode > 0.5) {
    float alpha = frag_color.a;
    float weight = WeightedBlendedWeight(alpha);
    vec4 accumulate = vec4(frag_color.rgb * weight, alpha * weight);
    bool revealage = mode > 1.5 && mode < 2.5;
    frag_color = revealage ? vec4(alpha) : accumulate;
#ifndef F3D_NO_SURFACE_BUFFER
    if (mode > 2.5) frag_surface = vec4(alpha);
#endif
  }
#endif
}

/// The fog is mixed in before the weight, so a thin distant pane adds a thin
/// share of the fog too rather than all of it. Times one when nothing blends,
/// which is exact, so an opaque draw writes what it always wrote.
void WriteSurface(vec3 linearColor, float alpha, float roughness) {
  float weight = g_premultiply ? alpha : 1.0;
  frag_color = vec4(ApplyFog(linearColor) * weight, alpha);
  WriteSurfaceGeometry(roughness);
  WriteWeightedBlended();
}

/// For a stage with no material to speak of.
///
/// Fully rough, which is the honest default: a surface that cannot say how
/// polished it is should not be reflected off.
void WriteSurface(vec3 linearColor, float alpha) {
  WriteSurface(linearColor, alpha, 1.0);
}

/// Writes a value that is already display-referred.
///
/// For debug output, where the colour is not a light value at all: a normal
/// encoded as RGB means nothing after a tone curve. Converting to linear here
/// means the composite pass's sRGB encode hands the original back unchanged,
/// provided the view also turns tone mapping and exposure off — which is what
/// `RenderSettings.tonemap` is for.
void WriteDisplayColor(vec3 displayColor, float alpha) {
  frag_color = vec4(SrgbToLinear(displayColor), alpha);
  WriteSurfaceGeometry(1.0);
}

#endif  // COLOR_GLSL_


layout(std140) uniform ShadowLight {
  /// xyz: the light's world position. w: its range in metres.
  vec4 light;
}
shadow_light;

void main() {
  float range = max(shadow_light.light.w, 1e-4);
  float distance = length(v_world_position - shadow_light.light.xyz);
  frag_color = vec4(clamp(distance / range, 0.0, 1.0), 0.0, 0.0, 1.0);
}

''',
    'ShadowDepthMasked': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// The shadow pass for a cut-out caster: write depth, or write nothing —
// `gfx-60n`.
//
// **A separate stage rather than a branch in `shadow_depth.frag`.** Every
// caster that is not cut out keeps the stage it has always had, which is a
// pipeline with no sampler in it and no texture bound per draw. That is worth
// a second entry point twice over: the common path pays nothing, and the
// forty-four golden frames recorded against the old stage cannot move, because
// the old stage is still the one they go through — the same split other
// engines draw here, for the same reason.
//
// **Why the shadow pass has to know about alpha at all.** A leaf card is a
// quad with a texture that is transparent almost everywhere. Depth-only, that
// quad is opaque, so a tree casts the shadow of its bounding rectangles — a
// stack of dark slabs where the eye expects dappled light. Nothing about the
// lit pass can repair it, because by then the shadow map already says the
// ground is in shadow.
//
// The threshold is the material's own `alphaCutoff` and the comparison is the
// same one glTF's MASK mode specifies: alpha below the cutoff is not drawn,
// alpha at or above it is fully drawn. There is no partial coverage here on
// purpose; a shadow map holds one depth per texel, so a half-transparent
// fragment either records or does not.

#define F3D_NO_SURFACE_BUFFER
#define F3D_NO_FOG
// --- lib/color.glsl ---
// Colour space helpers and the fragment output interface.
//
// Split out of surface.glsl so a shader that needs no material inputs — the
// normals debug view — can avoid DECLARING the FragInfo uniform block at all.
// That matters more than it looks: reflection metadata reports a block as
// present merely because it was declared, even when the compiled shader binds
// no such buffer, so a declared-but-unused block is indistinguishable from a
// used one until Metal crashes on the bind.

#ifndef COLOR_GLSL_
#define COLOR_GLSL_

precision highp float;

const float kPi = 3.14159265359;

// One varying set shared by every fragment shader, matching mesh.vert.
//
// All five are declared here, including the two the debug models never read: a
// fragment shader whose `in` block disagrees with the vertex shader's `out`
// block fails to link, and there is no partial-match rule to lean on.
in vec3 v_world_position;
in vec3 v_normal;
in vec2 v_texcoord;
in vec4 v_tangent;
in vec4 v_color;

/// Where this fragment is in the level's lightmap. Zero from every vertex
/// stage but `mesh_lightmapped.vert`, and read only by the lit models, which
/// sample a one-texel black there when a material has no map.
in vec2 v_lightmap_uv;

layout(location = 0) out vec4 frag_color;

// The second attachment: what a screen-space effect needs to know about the
// surface it is looking at. World-space normal in rgb, and in a the depth along
// the view axis in world metres — not a window depth; `WriteSurfaceGeometry`
// says at length why not.
//
// Depth travels here rather than in a depth texture because flutter_gpu cannot
// sample one — the same reason the shadow pass writes its depth into a colour
// target. See ARCHITECTURE.md §2.
//
// Guarded, because not every stage that includes this header draws into a
// two-attachment target. The shadow pass draws into one, and a pipeline
// declaring an output its target has no slot for is a mismatch worth avoiding
// rather than discovering.
#ifndef F3D_NO_SURFACE_BUFFER
layout(location = 1) out vec4 frag_surface;

/// The surface's own colour, sRGB-encoded, alpha one where a surface was
/// drawn — `L5`. The third attachment, present only when a pass reads it (the
/// indirect light does) and the device opens three; like the surface buffer,
/// written unconditionally and discarded when absent. Stored in the surface
/// buffer's format rather than eight bits a channel, and `Renderer` says why.
layout(location = 2) out vec4 frag_albedo;
#endif

/// What [frag_albedo] carries: the lit models set it in `ReadSurface`, and a
/// stage that reflects nothing — unlit, the debug views — leaves it black,
/// which is what light bounced onto it would come to.
vec3 g_albedo = vec3(0.0);

/// Octahedral encoding: a unit vector in two channels instead of three.
///
/// Worth the arithmetic because the fourth channel is already spent on depth,
/// and without a free channel there is nowhere to put roughness — which is the
/// difference between a reflection that knows stone from a mirror and one that
/// does not. The error is well under a degree, far below anything a reflection
/// off rough stone would show.
vec2 EncodeOctahedral(vec3 n) {
  n /= abs(n.x) + abs(n.y) + abs(n.z);
  vec2 e = n.xy;
  if (n.z < 0.0) {
    e = (1.0 - abs(n.yx)) * vec2(n.x >= 0.0 ? 1.0 : -1.0,
                                 n.y >= 0.0 ? 1.0 : -1.0);
  }
  return e * 0.5 + 0.5;
}

/// Where a debug pass leaves the picture it wants shown instead of the normal.
///
/// Declared here, in the header every lit shader includes **first**, and
/// written from surface.glsl, which is included after. The alternative was a
/// new member on a shared uniform block; a global costs nothing and moves no
/// offsets. It is read at the moment the surface buffer is written, which
/// happens after the lighting loop has run, so the value is there by then.
vec3 g_debug_surface = vec3(0.0);
bool g_debug_surface_on = false;

/// Whether [WriteSurface] weights the colour by its alpha: set by
/// `ReadSurface` for a material that blends, and false for everything else.
///
/// **The blend takes its source as premultiplied**, so a blended surface has
/// to hand it the colour times the alpha — a pane at a fifth of opaque adds a
/// fifth of its light, not all of it. glTF's blend mode is Porter and Duff's
/// over on straight colour, and this is the one place that turns the lit
/// radiance into what that means. An opaque or masked surface keeps its
/// colour whole: its alpha is not a coverage, and nothing blends it.
/// A global for the reason [g_debug_surface] is one.
bool g_premultiply = false;

// **A stage that needs none of this must be able to declare none of it.** On
// Vulkan both stages' descriptors are merged into one set layout, and two
// bindings with the same number in it is not a layout the specification
// allows. A driver may accept it anyway; a Galaxy A55's refuses the pipeline
// with `ErrorUnknown` and no other word, which is how the shadow pass came to
// build everywhere except there — its only uniform block was this one, and it
// landed on the same binding as the vertex stage's first.
#ifndef F3D_NO_FOG

/// Distance fog, in its own block rather than folded into FragInfo.
///
/// Its own because color.glsl is included before FragInfo is declared, and
/// because appending to a block that half a dozen shaders already share is a
/// way to move offsets nobody expected to move. Three vec4s is a cheap price
/// for not touching any of that.
layout(std140) uniform FogInfo {
  /// rgb: linear fog colour. w: density per metre, zero for no fog.
  vec4 fog;

  /// xyz: camera position in world space. Duplicated from FragInfo so this
  /// block stands alone; a vec3 is cheaper than a coupling.
  vec4 eye;

  /// xyz: the direction the camera looks, as a unit vector in world space.
  /// w: what a transparent draw writes under weighted blended transparency —
  /// `R8`, see `WriteWeightedBlended`. Zero for every other draw.
  ///
  /// Here rather than in a block of its own because it answers the same
  /// question [eye] does — where the camera is and which way it faces — and
  /// this is the block `color.glsl` can see.
  vec4 forward;
}
fog_info;

/// How far this fragment is from the eye, in world metres.
///
/// What the fog fades by. Distance rather than depth, because fog is a
/// property of the air between two points and does not care which way the
/// camera happens to face.
float EyeDistance() { return distance(v_world_position, fog_info.eye.xyz); }

/// How far this fragment is *along the view axis*, in world metres.
///
/// What the surface buffer's alpha holds. Depth rather than distance, and the
/// difference only shows on an orthographic camera — where the rays through
/// the pixels are parallel instead of meeting at the eye, so a distance from
/// the eye names a sphere that the pixel's ray crosses somewhere the reader
/// cannot solve for. A depth along the axis names a plane, which every ray
/// crosses exactly once. See `WorldAtDepth` in `post/ssao.frag` for the
/// reconstruction both projections share.
float ViewDepth() {
  return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz);
}

#else  // F3D_NO_FOG

// The same two questions, answered without the block: a stage that declares no
// fog has no eye position to measure from either. Stubs rather than a guard at
// every call site, so that what includes this file reads the same whichever
// way it was compiled.
float EyeDistance() { return 0.0; }
float ViewDepth() { return 0.0; }

#endif  // F3D_NO_FOG

/// sRGB to linear. Textures are authored in sRGB, but lighting is only correct
/// in linear space; skipping this is what makes naive renderers look muddy.
vec3 SrgbToLinear(vec3 srgb) {
  return mix(
      srgb / 12.92,
      pow((srgb + vec3(0.055)) / 1.055, vec3(2.4)),
      step(vec3(0.04045), srgb));
}

/// Linear to sRGB. The render target is a plain UNorm format rather than an
/// sRGB one, so the encode has to happen here.
vec3 LinearToSrgb(vec3 linear) {
  return mix(
      linear * 12.92,
      1.055 * pow(linear, vec3(1.0 / 2.4)) - vec3(0.055),
      step(vec3(0.0031308), linear));
}

/// Writes scene-referred linear light into the HDR target.
///
/// No tone map and no sRGB encode: those moved into the composite pass, which
/// is the entire point of rendering into `r16g16b16a16Float` first. Applying
/// them here meant every model wrote display-referred colour into an 8-bit
/// buffer, so anything above display white was gone before post-processing
/// could see it — and bloom is a function of exactly that.
///
/// Exposure moved with them, for the same reason: it belongs on the same side
/// of the display transform as the tone map.
/// Records the geometry of this fragment for whatever runs after the scene.
///
/// Called from the same place that writes colour, so a surface cannot be lit
/// into the frame without also describing itself — which is the failure that
/// leaves a screen-space effect reflecting whatever was in the buffer before.
///
/// rg: octahedral normal. b: perceptual roughness. a: **depth along the view
/// axis, in world metres** — see [ViewDepth].
///
/// **Not `gl_FragCoord.z`, and that is a defect this channel carried until it
/// was looked at.** Window depth crowds every distant surface into the top of
/// its range — with a near plane of a tenth of a metre, everything past twenty
/// metres lives in the last half a hundredth of `[0, 1]` — and this attachment
/// is a half float, whose steps up there are about five ten-thousandths. So two
/// surfaces half a metre apart at twenty metres stored the *same* number, and
/// every screen-space pass that compares against this channel decided whole
/// bands of pixels by rounding. The occlusion pass drew them: vertical stripes
/// along the lines of constant depth on any wall receding from the camera, on
/// both GPU backends. The software rasteriser kept the channel at full
/// precision and drew the effect correctly, so it was the one that looked
/// wrong against the other two.
///
/// A depth in metres has none of that: the exponent carries the range and the
/// mantissa carries the same relative precision everywhere, which at twenty
/// metres is a centimetre. Both numbers are measured in
/// `flutter3d/test/surface_depth_test.dart`.
///
/// Zero still means nothing was drawn. The attachment is cleared to zero and
/// nothing is drawn in front of the near plane.
void WriteSurfaceGeometry(float roughness) {
#ifndef F3D_NO_SURFACE_BUFFER
  // `L5`: the surface's colour, whatever the surface buffer ends up holding.
  frag_albedo = vec4(LinearToSrgb(clamp(g_albedo, vec3(0.0), vec3(1.0))), 1.0);
  // A debug pass takes the buffer over rather than getting one of its own.
  // The surface buffer already has an attachment, a viewer and a golden; a
  // second one would need all three built before it could answer anything.
  if (g_debug_surface_on) {
    frag_surface = vec4(g_debug_surface, ViewDepth());
    return;
  }
  // Reversed on a back face, as the lit normal is, so the occlusion and
  // reflection passes see the side of a double-sided surface that faces them.
  vec3 geometric = normalize(v_normal);
  if (!gl_FrontFacing) geometric = -geometric;
  frag_surface = vec4(EncodeOctahedral(geometric),
                      clamp(roughness, 0.0, 1.0), ViewDepth());
#endif
}

/// Fades [color] toward the fog with distance from the eye.
///
/// Exponential rather than linear, because linear fog has a visible plane
/// where it starts and a dungeon corridor is exactly where that shows.
vec3 ApplyFog(vec3 color) {
#ifdef F3D_NO_FOG
  return color;
#else
  float density = fog_info.fog.w;
  if (density <= 0.0) return color;
  float d = EyeDistance();
  return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0));
#endif
}

/// How much a transparent fragment counts for against the others over its
/// pixel — `R8`. McGuire and Bavoil's depth weight (their equation 9): a near
/// layer outweighs a far one, which is all the ordering a weighted average
/// can keep. [alpha] multiplies it, as theirs does, so a faint layer counts
/// faintly. Depth along the view axis, in metres, the surface buffer's.
float WeightedBlendedWeight(float alpha) {
  float z = abs(ViewDepth());
  float near = z / 5.0;
  float far = z / 200.0;
  float far3 = far * far * far;
  return alpha *
         clamp(10.0 / (1e-5 + near * near + far3 * far3), 1e-2, 3e3);
}

/// What a transparent draw writes when the frame composites transparency
/// order-independently — `R8`. `fog_info.forward.w` says which:
///
/// - 0: [frag_color] as it stands, the sorted blend's source. Every opaque
///   draw, and every draw in a frame that sorts.
/// - 1: the accumulation target's share — the colour, which the engine keeps
///   premultiplied, and the alpha, both times the weight. Added.
/// - 2: the revealage target's — the alpha alone, in every channel, which the
///   blend multiplies the target by one minus of.
/// - 3: both at once, the second into attachment one, where the surface
///   buffer would be; the pass that asks has no surface buffer attached.
///
/// Selects rather than returns, because a phi of constants is what
/// SPIRV-Cross refuses. At nought the branch is not taken and [frag_color]
/// is untouched, which is what keeps a sorting frame byte-identical.
void WriteWeightedBlended() {
#ifndef F3D_NO_FOG
  float mode = fog_info.forward.w;
  if (mode > 0.5) {
    float alpha = frag_color.a;
    float weight = WeightedBlendedWeight(alpha);
    vec4 accumulate = vec4(frag_color.rgb * weight, alpha * weight);
    bool revealage = mode > 1.5 && mode < 2.5;
    frag_color = revealage ? vec4(alpha) : accumulate;
#ifndef F3D_NO_SURFACE_BUFFER
    if (mode > 2.5) frag_surface = vec4(alpha);
#endif
  }
#endif
}

/// The fog is mixed in before the weight, so a thin distant pane adds a thin
/// share of the fog too rather than all of it. Times one when nothing blends,
/// which is exact, so an opaque draw writes what it always wrote.
void WriteSurface(vec3 linearColor, float alpha, float roughness) {
  float weight = g_premultiply ? alpha : 1.0;
  frag_color = vec4(ApplyFog(linearColor) * weight, alpha);
  WriteSurfaceGeometry(roughness);
  WriteWeightedBlended();
}

/// For a stage with no material to speak of.
///
/// Fully rough, which is the honest default: a surface that cannot say how
/// polished it is should not be reflected off.
void WriteSurface(vec3 linearColor, float alpha) {
  WriteSurface(linearColor, alpha, 1.0);
}

/// Writes a value that is already display-referred.
///
/// For debug output, where the colour is not a light value at all: a normal
/// encoded as RGB means nothing after a tone curve. Converting to linear here
/// means the composite pass's sRGB encode hands the original back unchanged,
/// provided the view also turns tone mapping and exposure off — which is what
/// `RenderSettings.tonemap` is for.
void WriteDisplayColor(vec3 displayColor, float alpha) {
  frag_color = vec4(SrgbToLinear(displayColor), alpha);
  WriteSurfaceGeometry(1.0);
}

#endif  // COLOR_GLSL_


/// The base colour map, whose alpha is the mask. The same texture and the same
/// slot name the lit stages bind, so a caller that has one has it already.
uniform sampler2D base_color_texture;

layout(std140) uniform MaskInfo {
  // x: the cutoff, from `Material.alphaCutoff`. y: the base colour's own
  // alpha, which glTF multiplies the texture's by, so a material faded to
  // nothing casts nothing. z, w: unused.
  vec4 mask;
}
mask_info;

void main() {
  float alpha = texture(base_color_texture, v_texcoord).a * mask_info.mask.y;
  if (alpha < mask_info.mask.x) discard;
  frag_color = vec4(gl_FragCoord.z, 0.0, 0.0, 1.0);
}

''',
    'ShadowDistanceMasked': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// The point-light depth pass for a cut-out caster — `gfx-60n`.
//
// `shadow_distance.frag` with the same mask test `shadow_depth_masked.frag`
// carries, and a separate stage for the same reason: a caster that is not cut
// out keeps a pipeline with no sampler in it.
//
// Both stages exist because both shadow paths record a caster, and a leaf card
// lit by a torch is exactly as wrong as one lit by the sun. Point lights are
// where it shows worst, in fact: a cube face is a ninety-degree frustum with a
// caster close to it, so the slab of a foliage quad fills much more of the
// tile than it would in a cascade.

#define F3D_NO_SURFACE_BUFFER
// No fog: shadow_depth.frag gives the reason.
#define F3D_NO_FOG
// --- lib/color.glsl ---
// Colour space helpers and the fragment output interface.
//
// Split out of surface.glsl so a shader that needs no material inputs — the
// normals debug view — can avoid DECLARING the FragInfo uniform block at all.
// That matters more than it looks: reflection metadata reports a block as
// present merely because it was declared, even when the compiled shader binds
// no such buffer, so a declared-but-unused block is indistinguishable from a
// used one until Metal crashes on the bind.

#ifndef COLOR_GLSL_
#define COLOR_GLSL_

precision highp float;

const float kPi = 3.14159265359;

// One varying set shared by every fragment shader, matching mesh.vert.
//
// All five are declared here, including the two the debug models never read: a
// fragment shader whose `in` block disagrees with the vertex shader's `out`
// block fails to link, and there is no partial-match rule to lean on.
in vec3 v_world_position;
in vec3 v_normal;
in vec2 v_texcoord;
in vec4 v_tangent;
in vec4 v_color;

/// Where this fragment is in the level's lightmap. Zero from every vertex
/// stage but `mesh_lightmapped.vert`, and read only by the lit models, which
/// sample a one-texel black there when a material has no map.
in vec2 v_lightmap_uv;

layout(location = 0) out vec4 frag_color;

// The second attachment: what a screen-space effect needs to know about the
// surface it is looking at. World-space normal in rgb, and in a the depth along
// the view axis in world metres — not a window depth; `WriteSurfaceGeometry`
// says at length why not.
//
// Depth travels here rather than in a depth texture because flutter_gpu cannot
// sample one — the same reason the shadow pass writes its depth into a colour
// target. See ARCHITECTURE.md §2.
//
// Guarded, because not every stage that includes this header draws into a
// two-attachment target. The shadow pass draws into one, and a pipeline
// declaring an output its target has no slot for is a mismatch worth avoiding
// rather than discovering.
#ifndef F3D_NO_SURFACE_BUFFER
layout(location = 1) out vec4 frag_surface;

/// The surface's own colour, sRGB-encoded, alpha one where a surface was
/// drawn — `L5`. The third attachment, present only when a pass reads it (the
/// indirect light does) and the device opens three; like the surface buffer,
/// written unconditionally and discarded when absent. Stored in the surface
/// buffer's format rather than eight bits a channel, and `Renderer` says why.
layout(location = 2) out vec4 frag_albedo;
#endif

/// What [frag_albedo] carries: the lit models set it in `ReadSurface`, and a
/// stage that reflects nothing — unlit, the debug views — leaves it black,
/// which is what light bounced onto it would come to.
vec3 g_albedo = vec3(0.0);

/// Octahedral encoding: a unit vector in two channels instead of three.
///
/// Worth the arithmetic because the fourth channel is already spent on depth,
/// and without a free channel there is nowhere to put roughness — which is the
/// difference between a reflection that knows stone from a mirror and one that
/// does not. The error is well under a degree, far below anything a reflection
/// off rough stone would show.
vec2 EncodeOctahedral(vec3 n) {
  n /= abs(n.x) + abs(n.y) + abs(n.z);
  vec2 e = n.xy;
  if (n.z < 0.0) {
    e = (1.0 - abs(n.yx)) * vec2(n.x >= 0.0 ? 1.0 : -1.0,
                                 n.y >= 0.0 ? 1.0 : -1.0);
  }
  return e * 0.5 + 0.5;
}

/// Where a debug pass leaves the picture it wants shown instead of the normal.
///
/// Declared here, in the header every lit shader includes **first**, and
/// written from surface.glsl, which is included after. The alternative was a
/// new member on a shared uniform block; a global costs nothing and moves no
/// offsets. It is read at the moment the surface buffer is written, which
/// happens after the lighting loop has run, so the value is there by then.
vec3 g_debug_surface = vec3(0.0);
bool g_debug_surface_on = false;

/// Whether [WriteSurface] weights the colour by its alpha: set by
/// `ReadSurface` for a material that blends, and false for everything else.
///
/// **The blend takes its source as premultiplied**, so a blended surface has
/// to hand it the colour times the alpha — a pane at a fifth of opaque adds a
/// fifth of its light, not all of it. glTF's blend mode is Porter and Duff's
/// over on straight colour, and this is the one place that turns the lit
/// radiance into what that means. An opaque or masked surface keeps its
/// colour whole: its alpha is not a coverage, and nothing blends it.
/// A global for the reason [g_debug_surface] is one.
bool g_premultiply = false;

// **A stage that needs none of this must be able to declare none of it.** On
// Vulkan both stages' descriptors are merged into one set layout, and two
// bindings with the same number in it is not a layout the specification
// allows. A driver may accept it anyway; a Galaxy A55's refuses the pipeline
// with `ErrorUnknown` and no other word, which is how the shadow pass came to
// build everywhere except there — its only uniform block was this one, and it
// landed on the same binding as the vertex stage's first.
#ifndef F3D_NO_FOG

/// Distance fog, in its own block rather than folded into FragInfo.
///
/// Its own because color.glsl is included before FragInfo is declared, and
/// because appending to a block that half a dozen shaders already share is a
/// way to move offsets nobody expected to move. Three vec4s is a cheap price
/// for not touching any of that.
layout(std140) uniform FogInfo {
  /// rgb: linear fog colour. w: density per metre, zero for no fog.
  vec4 fog;

  /// xyz: camera position in world space. Duplicated from FragInfo so this
  /// block stands alone; a vec3 is cheaper than a coupling.
  vec4 eye;

  /// xyz: the direction the camera looks, as a unit vector in world space.
  /// w: what a transparent draw writes under weighted blended transparency —
  /// `R8`, see `WriteWeightedBlended`. Zero for every other draw.
  ///
  /// Here rather than in a block of its own because it answers the same
  /// question [eye] does — where the camera is and which way it faces — and
  /// this is the block `color.glsl` can see.
  vec4 forward;
}
fog_info;

/// How far this fragment is from the eye, in world metres.
///
/// What the fog fades by. Distance rather than depth, because fog is a
/// property of the air between two points and does not care which way the
/// camera happens to face.
float EyeDistance() { return distance(v_world_position, fog_info.eye.xyz); }

/// How far this fragment is *along the view axis*, in world metres.
///
/// What the surface buffer's alpha holds. Depth rather than distance, and the
/// difference only shows on an orthographic camera — where the rays through
/// the pixels are parallel instead of meeting at the eye, so a distance from
/// the eye names a sphere that the pixel's ray crosses somewhere the reader
/// cannot solve for. A depth along the axis names a plane, which every ray
/// crosses exactly once. See `WorldAtDepth` in `post/ssao.frag` for the
/// reconstruction both projections share.
float ViewDepth() {
  return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz);
}

#else  // F3D_NO_FOG

// The same two questions, answered without the block: a stage that declares no
// fog has no eye position to measure from either. Stubs rather than a guard at
// every call site, so that what includes this file reads the same whichever
// way it was compiled.
float EyeDistance() { return 0.0; }
float ViewDepth() { return 0.0; }

#endif  // F3D_NO_FOG

/// sRGB to linear. Textures are authored in sRGB, but lighting is only correct
/// in linear space; skipping this is what makes naive renderers look muddy.
vec3 SrgbToLinear(vec3 srgb) {
  return mix(
      srgb / 12.92,
      pow((srgb + vec3(0.055)) / 1.055, vec3(2.4)),
      step(vec3(0.04045), srgb));
}

/// Linear to sRGB. The render target is a plain UNorm format rather than an
/// sRGB one, so the encode has to happen here.
vec3 LinearToSrgb(vec3 linear) {
  return mix(
      linear * 12.92,
      1.055 * pow(linear, vec3(1.0 / 2.4)) - vec3(0.055),
      step(vec3(0.0031308), linear));
}

/// Writes scene-referred linear light into the HDR target.
///
/// No tone map and no sRGB encode: those moved into the composite pass, which
/// is the entire point of rendering into `r16g16b16a16Float` first. Applying
/// them here meant every model wrote display-referred colour into an 8-bit
/// buffer, so anything above display white was gone before post-processing
/// could see it — and bloom is a function of exactly that.
///
/// Exposure moved with them, for the same reason: it belongs on the same side
/// of the display transform as the tone map.
/// Records the geometry of this fragment for whatever runs after the scene.
///
/// Called from the same place that writes colour, so a surface cannot be lit
/// into the frame without also describing itself — which is the failure that
/// leaves a screen-space effect reflecting whatever was in the buffer before.
///
/// rg: octahedral normal. b: perceptual roughness. a: **depth along the view
/// axis, in world metres** — see [ViewDepth].
///
/// **Not `gl_FragCoord.z`, and that is a defect this channel carried until it
/// was looked at.** Window depth crowds every distant surface into the top of
/// its range — with a near plane of a tenth of a metre, everything past twenty
/// metres lives in the last half a hundredth of `[0, 1]` — and this attachment
/// is a half float, whose steps up there are about five ten-thousandths. So two
/// surfaces half a metre apart at twenty metres stored the *same* number, and
/// every screen-space pass that compares against this channel decided whole
/// bands of pixels by rounding. The occlusion pass drew them: vertical stripes
/// along the lines of constant depth on any wall receding from the camera, on
/// both GPU backends. The software rasteriser kept the channel at full
/// precision and drew the effect correctly, so it was the one that looked
/// wrong against the other two.
///
/// A depth in metres has none of that: the exponent carries the range and the
/// mantissa carries the same relative precision everywhere, which at twenty
/// metres is a centimetre. Both numbers are measured in
/// `flutter3d/test/surface_depth_test.dart`.
///
/// Zero still means nothing was drawn. The attachment is cleared to zero and
/// nothing is drawn in front of the near plane.
void WriteSurfaceGeometry(float roughness) {
#ifndef F3D_NO_SURFACE_BUFFER
  // `L5`: the surface's colour, whatever the surface buffer ends up holding.
  frag_albedo = vec4(LinearToSrgb(clamp(g_albedo, vec3(0.0), vec3(1.0))), 1.0);
  // A debug pass takes the buffer over rather than getting one of its own.
  // The surface buffer already has an attachment, a viewer and a golden; a
  // second one would need all three built before it could answer anything.
  if (g_debug_surface_on) {
    frag_surface = vec4(g_debug_surface, ViewDepth());
    return;
  }
  // Reversed on a back face, as the lit normal is, so the occlusion and
  // reflection passes see the side of a double-sided surface that faces them.
  vec3 geometric = normalize(v_normal);
  if (!gl_FrontFacing) geometric = -geometric;
  frag_surface = vec4(EncodeOctahedral(geometric),
                      clamp(roughness, 0.0, 1.0), ViewDepth());
#endif
}

/// Fades [color] toward the fog with distance from the eye.
///
/// Exponential rather than linear, because linear fog has a visible plane
/// where it starts and a dungeon corridor is exactly where that shows.
vec3 ApplyFog(vec3 color) {
#ifdef F3D_NO_FOG
  return color;
#else
  float density = fog_info.fog.w;
  if (density <= 0.0) return color;
  float d = EyeDistance();
  return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0));
#endif
}

/// How much a transparent fragment counts for against the others over its
/// pixel — `R8`. McGuire and Bavoil's depth weight (their equation 9): a near
/// layer outweighs a far one, which is all the ordering a weighted average
/// can keep. [alpha] multiplies it, as theirs does, so a faint layer counts
/// faintly. Depth along the view axis, in metres, the surface buffer's.
float WeightedBlendedWeight(float alpha) {
  float z = abs(ViewDepth());
  float near = z / 5.0;
  float far = z / 200.0;
  float far3 = far * far * far;
  return alpha *
         clamp(10.0 / (1e-5 + near * near + far3 * far3), 1e-2, 3e3);
}

/// What a transparent draw writes when the frame composites transparency
/// order-independently — `R8`. `fog_info.forward.w` says which:
///
/// - 0: [frag_color] as it stands, the sorted blend's source. Every opaque
///   draw, and every draw in a frame that sorts.
/// - 1: the accumulation target's share — the colour, which the engine keeps
///   premultiplied, and the alpha, both times the weight. Added.
/// - 2: the revealage target's — the alpha alone, in every channel, which the
///   blend multiplies the target by one minus of.
/// - 3: both at once, the second into attachment one, where the surface
///   buffer would be; the pass that asks has no surface buffer attached.
///
/// Selects rather than returns, because a phi of constants is what
/// SPIRV-Cross refuses. At nought the branch is not taken and [frag_color]
/// is untouched, which is what keeps a sorting frame byte-identical.
void WriteWeightedBlended() {
#ifndef F3D_NO_FOG
  float mode = fog_info.forward.w;
  if (mode > 0.5) {
    float alpha = frag_color.a;
    float weight = WeightedBlendedWeight(alpha);
    vec4 accumulate = vec4(frag_color.rgb * weight, alpha * weight);
    bool revealage = mode > 1.5 && mode < 2.5;
    frag_color = revealage ? vec4(alpha) : accumulate;
#ifndef F3D_NO_SURFACE_BUFFER
    if (mode > 2.5) frag_surface = vec4(alpha);
#endif
  }
#endif
}

/// The fog is mixed in before the weight, so a thin distant pane adds a thin
/// share of the fog too rather than all of it. Times one when nothing blends,
/// which is exact, so an opaque draw writes what it always wrote.
void WriteSurface(vec3 linearColor, float alpha, float roughness) {
  float weight = g_premultiply ? alpha : 1.0;
  frag_color = vec4(ApplyFog(linearColor) * weight, alpha);
  WriteSurfaceGeometry(roughness);
  WriteWeightedBlended();
}

/// For a stage with no material to speak of.
///
/// Fully rough, which is the honest default: a surface that cannot say how
/// polished it is should not be reflected off.
void WriteSurface(vec3 linearColor, float alpha) {
  WriteSurface(linearColor, alpha, 1.0);
}

/// Writes a value that is already display-referred.
///
/// For debug output, where the colour is not a light value at all: a normal
/// encoded as RGB means nothing after a tone curve. Converting to linear here
/// means the composite pass's sRGB encode hands the original back unchanged,
/// provided the view also turns tone mapping and exposure off — which is what
/// `RenderSettings.tonemap` is for.
void WriteDisplayColor(vec3 displayColor, float alpha) {
  frag_color = vec4(SrgbToLinear(displayColor), alpha);
  WriteSurfaceGeometry(1.0);
}

#endif  // COLOR_GLSL_


layout(std140) uniform ShadowLight {
  /// xyz: the light's world position. w: its range in metres.
  vec4 light;
}
shadow_light;

/// The base colour map, whose alpha is the mask.
uniform sampler2D base_color_texture;

layout(std140) uniform MaskInfo {
  // x: the cutoff, from `Material.alphaCutoff`. y: the base colour's own
  // alpha. z, w: unused.
  vec4 mask;
}
mask_info;

void main() {
  float alpha = texture(base_color_texture, v_texcoord).a * mask_info.mask.y;
  if (alpha < mask_info.mask.x) discard;

  float range = max(shadow_light.light.w, 1e-4);
  float distance = length(v_world_position - shadow_light.light.xyz);
  frag_color = vec4(clamp(distance / range, 0.0, 1.0), 0.0, 0.0, 1.0);
}

''',
    'ShadowCopy': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// Copies one cascade's tile of the static shadow atlas into the frame's own
// — `S1`.
//
// The directional atlas is split the way the cube atlases are: static casters
// in one atlas drawn when they change, and a per-frame atlas that starts each
// redrawn tile from the static one and draws the dynamic casters on top. The
// copy has to carry depth as well as colour, or a dynamic caster behind a
// static wall would pass the depth test against a cleared buffer and write
// its farther depth over the wall's. So this writes the stored depth, which
// is window depth by construction (see `shadow_depth.frag`), to both.
//
// Drawn with the fullscreen triangle inside the tile's viewport, with the
// depth test off and depth writes on.
precision highp float;

in vec2 v_uv;

layout(location = 0) out vec4 frag_color;

uniform sampler2D static_shadow_texture;

layout(std140) uniform ShadowCopyInfo {
  /// xy: where this tile starts in the atlas, zw: its size, both in the
  /// atlas's own texture coordinates.
  vec4 tile;

  /// A scroll — `S1`: xy how far back, in the tile's own coordinates, the
  /// texel this one shows was held; z what the move added to every stored
  /// depth. Nought for a plain copy. A texel whose source lies outside the
  /// tile is the strip that scrolled in, and reads as nothing there, for
  /// the casters drawn into it next.
  vec4 shift;
}
copy_info;

void main() {
  vec2 from = v_uv - copy_info.shift.xy;
  bool inside = all(greaterThanEqual(from, vec2(0.0))) &&
                all(lessThanEqual(from, vec2(1.0)));
  float stored =
      textureLod(static_shadow_texture,
                 copy_info.tile.xy + clamp(from, 0.0, 1.0) * copy_info.tile.zw,
                 0.0)
          .r;
  // Nothing stays nothing: the far end is not a depth the move shifts.
  float depth = inside && stored < 1.0
                    ? clamp(stored + copy_info.shift.z, 0.0, 1.0)
                    : 1.0;
  frag_color = vec4(depth, 0.0, 0.0, 1.0);
  gl_FragDepth = depth;
}

''',
    'EvsmFilter': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// The directional atlas as exponential variance moments, blurred — `S2`.
//
// Drawn twice over the whole atlas: first across, reading the depth atlas
// and warping each tap into moments before it is averaged, then down,
// reading what the first pass wrote. A separable Gaussian, so a radius of r
// texels costs 2r + 1 taps a pass rather than (2r + 1)² in one.
//
// **After the static and dynamic casters are combined, not instead of
// them.** `S1` puts the two halves together by drawing dynamic casters over
// a copy of the static tile, which works because depth combines by keeping
// the nearer. Moments do not combine that way — the average of two
// distributions is not the nearer of them — so the depth atlas stays as it
// is and this pass is the step after it.
//
// **Every tap stays inside its own cascade's tile**, clamped half a texel in
// from the edge: the cascades sit side by side, and a blur that crossed a
// seam would average in depths measured through another projection.

in vec2 v_uv;

layout(location = 0) out vec4 frag_color;

// --- lib/evsm.glsl ---
// Exponential variance shadow maps — `S2`.
//
// Shared by the pass that turns the directional depth atlas into moments
// (`evsm_filter.frag`) and by `ShadowFactor`, which reads them back: the two
// halves must warp depth with the same two exponents, or every comparison is
// between numbers on different scales.
//
// A header of its own rather than a section of `shadow.glsl`, because that
// one declares the lit stages' shadow sampler and the filter pass has no
// business declaring it.

#ifndef EVSM_GLSL_
#define EVSM_GLSL_

precision highp float;

// The two exponents depth is warped by. **Forty and five, and the ceiling is
// the format.** The moments are stored squared, so the positive side reaches
// e^80 at the far plane, about 5.5e34 — inside a 32-bit float with three
// orders of magnitude to spare, and far outside a half float, which is why
// the moments live in an rgba32f atlas and the depth atlas does not. The
// negative side only has to catch what the positive side lets through at a
// receiver just behind a caster, and five is the usual answer.
const float kEvsmPositive = 40.0;
const float kEvsmNegative = 5.0;

/// [depth], in [0, 1], warped onto both exponentials: x positive, y negative.
///
/// Depth is first spread to [-1, 1] so the two sides share the range evenly
/// rather than the negative one flattening to nothing at the far end.
vec2 EvsmWarp(float depth) {
  float d = 2.0 * clamp(depth, 0.0, 1.0) - 1.0;
  return vec2(exp(kEvsmPositive * d), -exp(-kEvsmNegative * d));
}

/// What one texel of the depth atlas stores in the moments atlas: each warp
/// and its square, which a blur then averages into a mean and a variance.
vec4 EvsmMoments(float depth) {
  vec2 warped = EvsmWarp(depth);
  return vec4(warped.x, warped.x * warped.x, warped.y, warped.y * warped.y);
}

/// Chebyshev's upper bound on the share of [moments]'s distribution at or
/// beyond [t], with the light-bleeding cut [bleed] taken off the bottom.
///
/// A select at the end rather than an early return of one, because a phi of
/// constants is what SPIRV-Cross refuses when it writes the WGSL.
float EvsmChebyshev(vec2 moments, float t, float minVariance, float bleed) {
  float variance = max(moments.y - moments.x * moments.x, minVariance);
  float d = t - moments.x;
  float pMax = variance / (variance + d * d);
  // Light bleeding: where two casters overlap, the bound admits light the
  // nearer one should block. Everything under [bleed] is called shadow and
  // the rest stretched back over [0, 1].
  float reduced = clamp((pMax - bleed) / max(1.0 - bleed, 1e-4), 0.0, 1.0);
  return t <= moments.x ? 1.0 : reduced;
}

/// How much light reaches a receiver at [depth] past filtered [moments].
///
/// The smaller of the two bounds: each exponential lets through a different
/// kind of error, and neither lets through what the other stops.
float EvsmVisibility(vec4 moments, float depth, float bleed) {
  vec2 warped = EvsmWarp(depth);
  // A floor on the variance proportional to the warped depth's own slope,
  // so a flat receiver compared against its own texel does not divide
  // nought by nought — the variance of one depth is zero.
  vec2 scale = 0.0001 * vec2(kEvsmPositive, kEvsmNegative) * warped;
  float positive = EvsmChebyshev(moments.xy, warped.x, scale.x * scale.x, bleed);
  float negative = EvsmChebyshev(moments.zw, warped.y, scale.y * scale.y, bleed);
  return min(positive, negative);
}

#endif  // EVSM_GLSL_


uniform sampler2D evsm_source;

layout(std140) uniform EvsmFilterInfo {
  /// xy: one texel of the atlas along the axis this pass blurs, nought on
  /// the other. z: taps to each side, nought to eight. w: 1 when the source
  /// is the depth atlas and each tap is warped first, 0 when it already
  /// holds moments.
  vec4 axis;

  /// x: how many cascades share the atlas across. y, z: half a texel of the
  /// atlas, across and down, which is how far in from a tile's edge a tap is
  /// held.
  vec4 tile;
}
evsm_info;

void main() {
  float count = max(evsm_info.tile.x, 1.0);
  float which = min(floor(v_uv.x * count), count - 1.0);
  vec2 lo = vec2(which / count + evsm_info.tile.y, evsm_info.tile.z);
  vec2 hi = vec2((which + 1.0) / count - evsm_info.tile.y,
                 1.0 - evsm_info.tile.z);

  float taps = clamp(evsm_info.axis.z, 0.0, 8.0);
  // A Gaussian whose tail is two deviations out at the last tap, which is
  // where its weight has fallen to an eighth and a tap still earns its read.
  float sigma = max(taps * 0.5, 0.5);
  bool warp = evsm_info.axis.w > 0.5;

  vec4 total = vec4(0.0);
  float weightSum = 0.0;
  // Bounded at eight to each side whatever the uniform says, the rule every
  // blur here keeps: a loop a uniform can lengthen is a hang, not a slow frame.
  for (int i = -8; i <= 8; i++) {
    float offset = float(i);
    if (abs(offset) > taps) continue;
    vec2 at = clamp(v_uv + evsm_info.axis.xy * offset, lo, hi);
    vec4 texel = textureLod(evsm_source, at, 0.0);
    vec4 value = warp ? EvsmMoments(texel.r) : texel;
    float weight = exp(-(offset * offset) / (2.0 * sigma * sigma));
    total += value * weight;
    weightSum += weight;
  }
  frag_color = total / weightSum;
}

''',
    'ShadowTileReset': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// Clearing one tile of the shadow atlas, by drawing over it.
//
// A render pass clears its whole colour attachment: viewport and scissor bound
// where the rasteriser may write, and neither bounds the load action. That is
// fine while every tile is redrawn every frame, and fatal the moment they are
// not — refreshing one light's face would erase every other face in the atlas.
//
// So the atlas pass loads its previous contents instead of clearing them, and
// a tile that *is* being refreshed is reset by drawing this over it first,
// inside that tile's viewport. A draw is bounded by the viewport where a clear
// is not, which is the whole reason this shader exists.
//
// One, the far end of the range: a texel no caster covers means "nothing
// between the light and its range", which is the right answer for a direction
// with nothing in it. The same value the pass used to clear to.
precision highp float;

in vec2 v_uv;

layout(location = 0) out vec4 frag_color;

void main() {
  frag_color = vec4(1.0);
}

''',
    'Sky': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// The sky, evaluated per pixel from the view ray.
//
// What this buys over a painted dome, which is the thing it replaces: a sun
// disc. A disc is about half a degree across — two, if you are being generous
// about glare — and a dome fine enough to resolve one would need rings a
// fraction of a degree apart. Here the shape is analytic and its size is a
// number, so it costs the same at any angular radius.
//
// It also escapes the fog. `ApplyFog` lives inside `WriteSurface` in
// `lib/color.glsl` and every lit model goes through it, so a dome ten metres
// across is fogged by ten metres of air whether that makes sense or not. This
// stage includes none of that.
//
// **Deliberately not including `lib/color.glsl`.** That header declares the
// five varyings `mesh.vert` emits, and a fragment shader whose inputs disagree
// with its vertex stage's outputs does not link — there is no partial-match
// rule. `shadow_depth.frag` includes it *because* it runs off `mesh.vert`; this
// runs off `sky.vert`, whose varyings are its own.
//
// **The preset arrives on the varyings rather than in a uniform block**, and
// `sky.vert` sets out at length what was measured to make that the design: on
// Impeller a uniform block bound to this pipeline never arrives, in either
// stage, while an attribute does.
precision highp float;

in vec3 v_ray;
in vec4 v_zenith;
in vec4 v_horizon;
in vec4 v_nadir;
in vec4 v_sun;
in vec4 v_glow;
in vec4 v_disc;

layout(location = 0) out vec4 frag_color;

// **The surface buffer is deliberately not written here, and the sentence this
// replaces cost a working sky.**
//
// It used to say: "Writing it costs nothing and does not depend on that. When
// the scene draws into one attachment rather than two, the extra output is
// discarded; the renderer decides whether anyone is listening." Every clause of
// that is wrong on Impeller. Measured, both ways round, in the `sky` golden
// scene:
//
//  * one attachment (the usual path — no screen-space effect asked for the
//    surface buffer, so the pass multisamples instead) and this shader
//    declaring `frag_surface`: **the process dies**, inside Metal, at
//    `-[AGXG15XFamilyRenderContext setFragmentBuffer:offset:atIndex:]` with a
//    bad address. When it survives long enough to draw, `SkyInfo` and `SkyRay`
//    arrive as rubbish, which is a flat maroon sky over a racing circuit.
//  * two attachments (`surfaceBuffer: true`), same shader: draws correctly.
//
// `lib/color.glsl` already knew — "a pipeline declaring an output its target has
// no slot for is a mismatch worth avoiding rather than discovering" — and
// guards its own second output behind `F3D_NO_SURFACE_BUFFER` for the shadow
// pass. This file was the one place that declared it anyway.
//
// Nothing is lost by leaving it out. The attachment is cleared to zero and zero
// alpha is exactly what `reflections.frag` reads as "nothing was drawn here" —
// the same answer this shader was writing by hand.

// **No uniform block, and `sky.vert` says at length why.** Its members reach
// this stage as varyings, written on all three vertices of the full-screen
// triangle: the only channel measured to arrive on this pipeline.

void main() {
  vec3 direction = normalize(v_ray);

  // The gradient, smoothstepped in height rather than linear: the first fifteen
  // degrees above the horizon are most of what anybody looks at, and a straight
  // ramp spends its range on the part they do not.
  float height = clamp(direction.y, -1.0, 1.0);
  vec3 far = height >= 0.0 ? v_zenith.rgb : v_nadir.rgb;
  float t = abs(height);
  t = t * t * (3.0 - 2.0 * t);
  vec3 colour = mix(v_horizon.rgb, far, t);

  float towards = dot(direction, v_sun.xyz);

  // The wide scattering lobe. Guarded, because `pow` of a negative base is
  // undefined and a NaN here is a pixel that is black on one backend and white
  // on another.
  if (towards > 0.0) {
    colour += v_glow.rgb * (v_glow.a * pow(towards, v_sun.w));
  }

  // And the disc itself, added on top of the lobe rather than replacing it: the
  // sun is a bright thing seen through the glow around it, not instead of it.
  // Its brightness is free to sit above one — this target is HDR, and a sun
  // that cannot blow out is a sun bloom has nothing to find.
  //
  // Guarded, and the guard is not defensive programming. `smoothstep` is
  // undefined when its two edges are equal — GLSL says so, and Metal computes
  // `(x - e0) / (e1 - e0)`, which is 0/0 and therefore NaN. The two edges here
  // are cosines of angles a third of a degree apart, so they are equal for any
  // caller who leaves the disc at its default size, and a single NaN channel
  // poisons the whole pixel through the tone map. A sky is exactly where that
  // is least visible as a NaN and most visible as "the gradient went away".
  //
  // **The other branch was `0.0`, and that turned the sun off.** A soft edge of
  // nothing is a sun with a hard edge — which is a thing to ask for — and the
  // answer to it is a step, not an absence. `SkySettings.sample`, which is the
  // same model written in Dart, has always drawn one; this drew nothing, so a
  // hard-edged sun existed in the fog colour and not in the sky.
  float disc = v_disc.y > 0.0
      ? smoothstep(v_disc.x - v_disc.y, v_disc.x, towards)
      : step(v_disc.x, towards);
  colour += v_glow.rgb * (disc * v_disc.z);

  frag_color = vec4(colour, 1.0);

}

''',
    'SkyCube': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// A textured sky: the same view ray, sampled out of a cube map.
//
// A second fragment stage rather than a branch inside `sky.frag`, and rather
// than a new vertex stage. The ray `sky.vert` emits is already a world-space
// direction, which is exactly what a cube sampler takes — so the whole of the
// difference between a procedural sky and a photographed one is this file.
//
// A branch would have been the wrong shape twice over. The uniform block would
// have had to carry both descriptions whichever was in use, and every pixel
// would have paid for a sampler bind that half the callers never fill; and a
// shader that declares a sampler nobody binds is a native crash on Metal rather
// than a black texture. Two entry points, one manifest, one pipeline each.
//
// **The faces are +X, −X, +Y, −Y, +Z, −Z.** That order is documented once, on
// `GraphicsDevice.createCubeTextureFromPixels`, and it is what every backend
// here uploads in — Impeller by slice index, WebGL by consecutive face target,
// the software rasteriser by the table in `BoundTexture.sampleCube`. Nothing in
// a picture says whether two of them are transposed, which is why the
// conformance suite draws six known directions against six known colours.
precision highp float;

in vec3 v_ray;
in vec4 v_tint;

layout(location = 0) out vec4 frag_color;

// No second output — see `sky.frag`.

uniform samplerCube sky_texture;

// **No uniform block, and `sky.vert` says at length why.** The tint arrives as
// a varying, written on all three vertices of the full-screen triangle.

void main() {
  // Decoded from sRGB, because a cube map is an image and an image is authored
  // in display space — the same rule `lib/surface.glsl` applies to a base
  // colour texture, and the same rule a vertex colour is exempt from. Without
  // this a photographed sky arrives with its midtones lifted, which reads as
  // haze rather than as a colour-space mistake.
  vec3 texel = texture(sky_texture, normalize(v_ray)).rgb;
  vec3 linear = mix(texel / 12.92,
                    pow((texel + 0.055) / 1.055, vec3(2.4)),
                    step(vec3(0.04045), texel));

  frag_color = vec4(linear * v_tint.rgb, 1.0);
}

''',
    'Luminance': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// The scene's brightness at low resolution, for the exposure meter to read
// back.
//
// Drawn into a small target — 64×64 — so a readback of it is a few kilobytes
// rather than a frame, and encoded as **log luminance in eight bits**: linear
// values would spend most of the byte on the top stop and nothing on the
// shadows, and an exposure is a stops question. Each texel averages sixteen
// taps across its own footprint of the scene, so the estimate is the mean of
// what it covers rather than one point in it.
//
// What comes out is a picture only in the sense that a histogram is: nothing
// reads it as an image, so which way up it lands is nobody's concern — the
// meter counts texels. See `ExposureMeter` for the other end of the encoding.
precision highp float;

in vec2 v_uv;

layout(location = 0) out vec4 frag_color;

/// The lit scene, linear and unbounded.
uniform sampler2D scene_texture;

layout(std140) uniform LuminanceInfo {
  /// x, y: one texel of *this* target, in uv — the footprint each texel
  /// averages over. z: the stop the encoding starts at. w: one over how many
  /// stops the byte spans.
  vec4 params;
}
luminance_info;

/// Rec. 709 luma, the same weights the composite uses.
float Luma(vec3 color) { return dot(color, vec3(0.2126, 0.7152, 0.0722)); }

void main() {
  vec2 footprint = luminance_info.params.xy;
  float sum = 0.0;
  for (int j = 0; j < 4; j++) {
    for (int i = 0; i < 4; i++) {
      // Four by four, centred: from three eighths of a texel before the
      // middle to three eighths after it.
      vec2 offset = ((vec2(float(i), float(j)) + 0.5) / 4.0 - 0.5) * footprint;
      sum += Luma(texture(scene_texture, v_uv + offset).rgb);
    }
  }
  float mean = sum / 16.0;
  // A floor well below anything a scene lights, so black encodes as the first
  // stop rather than as minus infinity.
  float stops = log2(max(mean, 1e-6));
  float encoded =
      clamp((stops - luminance_info.params.z) * luminance_info.params.w, 0.0, 1.0);
  frag_color = vec4(encoded, encoded, encoded, 1.0);
}

''',
    'DepthPyramid': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// The surface buffer's depth, reduced to a small grid for the CPU to read
// back — `C3`, `HiZOcclusion` on the other end.
//
// Each texel of this target covers a block of the surface buffer and keeps
// the **farthest** view depth in it, because what the reading is for is
// saying "everything behind this is hidden", and only the farthest surface in
// a block is in front of all of it. A block with a single empty pixel — sky
// through a gap, the edge of the world — is no occluder at all and is written
// with alpha zero.
//
// Alpha also carries the block's **nearest** depth, as a fraction of the
// farthest: 128 + 127 × nearest / farthest, rounded down, so a block is
// never read as flatter than it is. A post in front of a doorway is one such
// block: the reading moves it as one plane at the doorway's depth, and the
// CPU needs the post's depth to know how far the two part when the camera
// moves — `HiZOcclusion.prepare` drops the block once they part by more
// than a fraction of a cell. Alpha below one half still means "not drawn".
//
// Written into eight bits a channel, because that is what `readback` hands
// back on every backend: the depth as a 24-bit fraction of the far plane in
// red, green and blue, most significant first, rounded *up* so the reading is
// never nearer than the surface. Arithmetic rather than bit operations, which
// the OpenGL ES target does not have.
precision highp float;

in vec2 v_uv;

layout(location = 0) out vec4 frag_color;

/// The surface buffer: view depth in metres in alpha, zero where nothing was
/// drawn.
uniform sampler2D surface_texture;

layout(std140) uniform DepthPyramidInfo {
  /// x, y: one texel of this target, in uv — the block each texel reduces.
  /// z, w: how many surface texels that block spans, across and down.
  vec4 block;
  /// x: one over the far plane, which the depth is written as a fraction of.
  /// y, z, w: unused.
  vec4 range;
}
pyramid_info;

void main() {
  vec2 blockUv = pyramid_info.block.xy;
  // One tap a source texel up to thirty-two across, then spread: a bound a
  // uniform cannot lengthen, for the reason `ssao_blur.frag` keeps one. Not
  // sixteen: a phone held upright is over 2 048 pixels tall, a block of it
  // is then more than sixteen rows, and a row no tap lands on is a gap of
  // sky or a far wall the reading would cover over.
  float tapsX = clamp(ceil(pyramid_info.block.z - 1e-3), 1.0, 32.0);
  float tapsY = clamp(ceil(pyramid_info.block.w - 1e-3), 1.0, 32.0);
  vec2 corner = v_uv - 0.5 * blockUv;
  vec2 stepUv = blockUv / vec2(tapsX, tapsY);

  float farthest = 0.0;
  float nearest = 3.0e38;
  float empty = 0.0;
  for (int j = 0; j < 32; j++) {
    if (float(j) >= tapsY) break;
    for (int i = 0; i < 32; i++) {
      if (float(i) >= tapsX) break;
      vec2 at = corner + (vec2(float(i), float(j)) + 0.5) * stepUv;
      float depth = textureLod(surface_texture, at, 0.0).a;
      empty = depth > 0.0 ? empty : 1.0;
      farthest = max(farthest, depth);
      nearest = min(nearest, depth);
    }
  }

  float steps = 16777215.0;
  float scaled = ceil(clamp(farthest * pyramid_info.range.x, 0.0, 1.0) * steps);
  float high = floor(scaled / 65536.0);
  float rest = scaled - high * 65536.0;
  float middle = floor(rest / 256.0);
  float low = rest - middle * 256.0;
  // A thousandth of a step up before the floor, so a block at one depth is
  // 255 on a GPU whose division lands an ulp short of one.
  float ratio = farthest > 0.0 ? clamp(nearest / farthest, 0.0, 1.0) : 0.0;
  float flatness = 128.0 + floor(ratio * 127.0 + 1e-3);
  frag_color = vec4(high / 255.0, middle / 255.0, low / 255.0,
                    empty > 0.0 ? 0.0 : flatness / 255.0);
}

''',
    'ObjectId': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// The picking pass: the id the renderer gave this node, and nothing else.
//
// A `Normals`-shaped stage — every mesh drawn again through the same vertex
// stages, with a fragment stage that writes a constant instead of a colour —
// into an RGBA8 target one pixel of which is then read back. The id arrives
// as three bytes in [0, 1] so an eight-bit store hands it back exactly; the
// renderer decodes `r + g·256 + b·65536`, and zero is the clear colour, which
// is what "nothing here" reads as.
//
// One attachment, not two: the target is the id texture and there is no
// surface buffer beside it, so the second output is left undeclared the way the
// shadow passes leave it — see `shadow_depth.frag`.
//
// Includes lib/color.glsl rather than lib/surface.glsl for the reason
// `normals.frag` gives: merely declaring FragInfo would leave it visible to
// reflection while the compiled shader binds no buffer for it, and binding
// that phantom block is a native crash on Metal. This stage declares a block of
// its own and reads that.
#define F3D_NO_SURFACE_BUFFER
// No fog: shadow_depth.frag gives the reason.
#define F3D_NO_FOG
// --- lib/color.glsl ---
// Colour space helpers and the fragment output interface.
//
// Split out of surface.glsl so a shader that needs no material inputs — the
// normals debug view — can avoid DECLARING the FragInfo uniform block at all.
// That matters more than it looks: reflection metadata reports a block as
// present merely because it was declared, even when the compiled shader binds
// no such buffer, so a declared-but-unused block is indistinguishable from a
// used one until Metal crashes on the bind.

#ifndef COLOR_GLSL_
#define COLOR_GLSL_

precision highp float;

const float kPi = 3.14159265359;

// One varying set shared by every fragment shader, matching mesh.vert.
//
// All five are declared here, including the two the debug models never read: a
// fragment shader whose `in` block disagrees with the vertex shader's `out`
// block fails to link, and there is no partial-match rule to lean on.
in vec3 v_world_position;
in vec3 v_normal;
in vec2 v_texcoord;
in vec4 v_tangent;
in vec4 v_color;

/// Where this fragment is in the level's lightmap. Zero from every vertex
/// stage but `mesh_lightmapped.vert`, and read only by the lit models, which
/// sample a one-texel black there when a material has no map.
in vec2 v_lightmap_uv;

layout(location = 0) out vec4 frag_color;

// The second attachment: what a screen-space effect needs to know about the
// surface it is looking at. World-space normal in rgb, and in a the depth along
// the view axis in world metres — not a window depth; `WriteSurfaceGeometry`
// says at length why not.
//
// Depth travels here rather than in a depth texture because flutter_gpu cannot
// sample one — the same reason the shadow pass writes its depth into a colour
// target. See ARCHITECTURE.md §2.
//
// Guarded, because not every stage that includes this header draws into a
// two-attachment target. The shadow pass draws into one, and a pipeline
// declaring an output its target has no slot for is a mismatch worth avoiding
// rather than discovering.
#ifndef F3D_NO_SURFACE_BUFFER
layout(location = 1) out vec4 frag_surface;

/// The surface's own colour, sRGB-encoded, alpha one where a surface was
/// drawn — `L5`. The third attachment, present only when a pass reads it (the
/// indirect light does) and the device opens three; like the surface buffer,
/// written unconditionally and discarded when absent. Stored in the surface
/// buffer's format rather than eight bits a channel, and `Renderer` says why.
layout(location = 2) out vec4 frag_albedo;
#endif

/// What [frag_albedo] carries: the lit models set it in `ReadSurface`, and a
/// stage that reflects nothing — unlit, the debug views — leaves it black,
/// which is what light bounced onto it would come to.
vec3 g_albedo = vec3(0.0);

/// Octahedral encoding: a unit vector in two channels instead of three.
///
/// Worth the arithmetic because the fourth channel is already spent on depth,
/// and without a free channel there is nowhere to put roughness — which is the
/// difference between a reflection that knows stone from a mirror and one that
/// does not. The error is well under a degree, far below anything a reflection
/// off rough stone would show.
vec2 EncodeOctahedral(vec3 n) {
  n /= abs(n.x) + abs(n.y) + abs(n.z);
  vec2 e = n.xy;
  if (n.z < 0.0) {
    e = (1.0 - abs(n.yx)) * vec2(n.x >= 0.0 ? 1.0 : -1.0,
                                 n.y >= 0.0 ? 1.0 : -1.0);
  }
  return e * 0.5 + 0.5;
}

/// Where a debug pass leaves the picture it wants shown instead of the normal.
///
/// Declared here, in the header every lit shader includes **first**, and
/// written from surface.glsl, which is included after. The alternative was a
/// new member on a shared uniform block; a global costs nothing and moves no
/// offsets. It is read at the moment the surface buffer is written, which
/// happens after the lighting loop has run, so the value is there by then.
vec3 g_debug_surface = vec3(0.0);
bool g_debug_surface_on = false;

/// Whether [WriteSurface] weights the colour by its alpha: set by
/// `ReadSurface` for a material that blends, and false for everything else.
///
/// **The blend takes its source as premultiplied**, so a blended surface has
/// to hand it the colour times the alpha — a pane at a fifth of opaque adds a
/// fifth of its light, not all of it. glTF's blend mode is Porter and Duff's
/// over on straight colour, and this is the one place that turns the lit
/// radiance into what that means. An opaque or masked surface keeps its
/// colour whole: its alpha is not a coverage, and nothing blends it.
/// A global for the reason [g_debug_surface] is one.
bool g_premultiply = false;

// **A stage that needs none of this must be able to declare none of it.** On
// Vulkan both stages' descriptors are merged into one set layout, and two
// bindings with the same number in it is not a layout the specification
// allows. A driver may accept it anyway; a Galaxy A55's refuses the pipeline
// with `ErrorUnknown` and no other word, which is how the shadow pass came to
// build everywhere except there — its only uniform block was this one, and it
// landed on the same binding as the vertex stage's first.
#ifndef F3D_NO_FOG

/// Distance fog, in its own block rather than folded into FragInfo.
///
/// Its own because color.glsl is included before FragInfo is declared, and
/// because appending to a block that half a dozen shaders already share is a
/// way to move offsets nobody expected to move. Three vec4s is a cheap price
/// for not touching any of that.
layout(std140) uniform FogInfo {
  /// rgb: linear fog colour. w: density per metre, zero for no fog.
  vec4 fog;

  /// xyz: camera position in world space. Duplicated from FragInfo so this
  /// block stands alone; a vec3 is cheaper than a coupling.
  vec4 eye;

  /// xyz: the direction the camera looks, as a unit vector in world space.
  /// w: what a transparent draw writes under weighted blended transparency —
  /// `R8`, see `WriteWeightedBlended`. Zero for every other draw.
  ///
  /// Here rather than in a block of its own because it answers the same
  /// question [eye] does — where the camera is and which way it faces — and
  /// this is the block `color.glsl` can see.
  vec4 forward;
}
fog_info;

/// How far this fragment is from the eye, in world metres.
///
/// What the fog fades by. Distance rather than depth, because fog is a
/// property of the air between two points and does not care which way the
/// camera happens to face.
float EyeDistance() { return distance(v_world_position, fog_info.eye.xyz); }

/// How far this fragment is *along the view axis*, in world metres.
///
/// What the surface buffer's alpha holds. Depth rather than distance, and the
/// difference only shows on an orthographic camera — where the rays through
/// the pixels are parallel instead of meeting at the eye, so a distance from
/// the eye names a sphere that the pixel's ray crosses somewhere the reader
/// cannot solve for. A depth along the axis names a plane, which every ray
/// crosses exactly once. See `WorldAtDepth` in `post/ssao.frag` for the
/// reconstruction both projections share.
float ViewDepth() {
  return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz);
}

#else  // F3D_NO_FOG

// The same two questions, answered without the block: a stage that declares no
// fog has no eye position to measure from either. Stubs rather than a guard at
// every call site, so that what includes this file reads the same whichever
// way it was compiled.
float EyeDistance() { return 0.0; }
float ViewDepth() { return 0.0; }

#endif  // F3D_NO_FOG

/// sRGB to linear. Textures are authored in sRGB, but lighting is only correct
/// in linear space; skipping this is what makes naive renderers look muddy.
vec3 SrgbToLinear(vec3 srgb) {
  return mix(
      srgb / 12.92,
      pow((srgb + vec3(0.055)) / 1.055, vec3(2.4)),
      step(vec3(0.04045), srgb));
}

/// Linear to sRGB. The render target is a plain UNorm format rather than an
/// sRGB one, so the encode has to happen here.
vec3 LinearToSrgb(vec3 linear) {
  return mix(
      linear * 12.92,
      1.055 * pow(linear, vec3(1.0 / 2.4)) - vec3(0.055),
      step(vec3(0.0031308), linear));
}

/// Writes scene-referred linear light into the HDR target.
///
/// No tone map and no sRGB encode: those moved into the composite pass, which
/// is the entire point of rendering into `r16g16b16a16Float` first. Applying
/// them here meant every model wrote display-referred colour into an 8-bit
/// buffer, so anything above display white was gone before post-processing
/// could see it — and bloom is a function of exactly that.
///
/// Exposure moved with them, for the same reason: it belongs on the same side
/// of the display transform as the tone map.
/// Records the geometry of this fragment for whatever runs after the scene.
///
/// Called from the same place that writes colour, so a surface cannot be lit
/// into the frame without also describing itself — which is the failure that
/// leaves a screen-space effect reflecting whatever was in the buffer before.
///
/// rg: octahedral normal. b: perceptual roughness. a: **depth along the view
/// axis, in world metres** — see [ViewDepth].
///
/// **Not `gl_FragCoord.z`, and that is a defect this channel carried until it
/// was looked at.** Window depth crowds every distant surface into the top of
/// its range — with a near plane of a tenth of a metre, everything past twenty
/// metres lives in the last half a hundredth of `[0, 1]` — and this attachment
/// is a half float, whose steps up there are about five ten-thousandths. So two
/// surfaces half a metre apart at twenty metres stored the *same* number, and
/// every screen-space pass that compares against this channel decided whole
/// bands of pixels by rounding. The occlusion pass drew them: vertical stripes
/// along the lines of constant depth on any wall receding from the camera, on
/// both GPU backends. The software rasteriser kept the channel at full
/// precision and drew the effect correctly, so it was the one that looked
/// wrong against the other two.
///
/// A depth in metres has none of that: the exponent carries the range and the
/// mantissa carries the same relative precision everywhere, which at twenty
/// metres is a centimetre. Both numbers are measured in
/// `flutter3d/test/surface_depth_test.dart`.
///
/// Zero still means nothing was drawn. The attachment is cleared to zero and
/// nothing is drawn in front of the near plane.
void WriteSurfaceGeometry(float roughness) {
#ifndef F3D_NO_SURFACE_BUFFER
  // `L5`: the surface's colour, whatever the surface buffer ends up holding.
  frag_albedo = vec4(LinearToSrgb(clamp(g_albedo, vec3(0.0), vec3(1.0))), 1.0);
  // A debug pass takes the buffer over rather than getting one of its own.
  // The surface buffer already has an attachment, a viewer and a golden; a
  // second one would need all three built before it could answer anything.
  if (g_debug_surface_on) {
    frag_surface = vec4(g_debug_surface, ViewDepth());
    return;
  }
  // Reversed on a back face, as the lit normal is, so the occlusion and
  // reflection passes see the side of a double-sided surface that faces them.
  vec3 geometric = normalize(v_normal);
  if (!gl_FrontFacing) geometric = -geometric;
  frag_surface = vec4(EncodeOctahedral(geometric),
                      clamp(roughness, 0.0, 1.0), ViewDepth());
#endif
}

/// Fades [color] toward the fog with distance from the eye.
///
/// Exponential rather than linear, because linear fog has a visible plane
/// where it starts and a dungeon corridor is exactly where that shows.
vec3 ApplyFog(vec3 color) {
#ifdef F3D_NO_FOG
  return color;
#else
  float density = fog_info.fog.w;
  if (density <= 0.0) return color;
  float d = EyeDistance();
  return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0));
#endif
}

/// How much a transparent fragment counts for against the others over its
/// pixel — `R8`. McGuire and Bavoil's depth weight (their equation 9): a near
/// layer outweighs a far one, which is all the ordering a weighted average
/// can keep. [alpha] multiplies it, as theirs does, so a faint layer counts
/// faintly. Depth along the view axis, in metres, the surface buffer's.
float WeightedBlendedWeight(float alpha) {
  float z = abs(ViewDepth());
  float near = z / 5.0;
  float far = z / 200.0;
  float far3 = far * far * far;
  return alpha *
         clamp(10.0 / (1e-5 + near * near + far3 * far3), 1e-2, 3e3);
}

/// What a transparent draw writes when the frame composites transparency
/// order-independently — `R8`. `fog_info.forward.w` says which:
///
/// - 0: [frag_color] as it stands, the sorted blend's source. Every opaque
///   draw, and every draw in a frame that sorts.
/// - 1: the accumulation target's share — the colour, which the engine keeps
///   premultiplied, and the alpha, both times the weight. Added.
/// - 2: the revealage target's — the alpha alone, in every channel, which the
///   blend multiplies the target by one minus of.
/// - 3: both at once, the second into attachment one, where the surface
///   buffer would be; the pass that asks has no surface buffer attached.
///
/// Selects rather than returns, because a phi of constants is what
/// SPIRV-Cross refuses. At nought the branch is not taken and [frag_color]
/// is untouched, which is what keeps a sorting frame byte-identical.
void WriteWeightedBlended() {
#ifndef F3D_NO_FOG
  float mode = fog_info.forward.w;
  if (mode > 0.5) {
    float alpha = frag_color.a;
    float weight = WeightedBlendedWeight(alpha);
    vec4 accumulate = vec4(frag_color.rgb * weight, alpha * weight);
    bool revealage = mode > 1.5 && mode < 2.5;
    frag_color = revealage ? vec4(alpha) : accumulate;
#ifndef F3D_NO_SURFACE_BUFFER
    if (mode > 2.5) frag_surface = vec4(alpha);
#endif
  }
#endif
}

/// The fog is mixed in before the weight, so a thin distant pane adds a thin
/// share of the fog too rather than all of it. Times one when nothing blends,
/// which is exact, so an opaque draw writes what it always wrote.
void WriteSurface(vec3 linearColor, float alpha, float roughness) {
  float weight = g_premultiply ? alpha : 1.0;
  frag_color = vec4(ApplyFog(linearColor) * weight, alpha);
  WriteSurfaceGeometry(roughness);
  WriteWeightedBlended();
}

/// For a stage with no material to speak of.
///
/// Fully rough, which is the honest default: a surface that cannot say how
/// polished it is should not be reflected off.
void WriteSurface(vec3 linearColor, float alpha) {
  WriteSurface(linearColor, alpha, 1.0);
}

/// Writes a value that is already display-referred.
///
/// For debug output, where the colour is not a light value at all: a normal
/// encoded as RGB means nothing after a tone curve. Converting to linear here
/// means the composite pass's sRGB encode hands the original back unchanged,
/// provided the view also turns tone mapping and exposure off — which is what
/// `RenderSettings.tonemap` is for.
void WriteDisplayColor(vec3 displayColor, float alpha) {
  frag_color = vec4(SrgbToLinear(displayColor), alpha);
  WriteSurfaceGeometry(1.0);
}

#endif  // COLOR_GLSL_


layout(std140) uniform IdInfo {
  /// xyz: the id, low byte first, each as a fraction of 255. w: unused.
  vec4 id;

  /// x: the material's alpha cutoff, negative when it is not masked — the
  /// encoding `FragInfo.material2.x` uses. y: the tint's alpha, the
  /// `base_color.a` the scene pass multiplies the texel by. zw: unused.
  vec4 mask;
}
id_info;

/// The same texture the scene pass reads, for the one thing it reads it for
/// here: where a masked material's alpha falls under its cutoff is a hole, and
/// a hole is where the thing behind it is on the screen.
uniform sampler2D base_color_texture;

void main() {
  // What the scene pass threw away, thrown away here too, before the write: a
  // click through a fence's hole has to answer with what is seen through it.
  // The alpha is the one `ReadSurface` computes — texel, tint, vertex colour —
  // or the two stages would disagree about where the hole is.
  float cutoff = id_info.mask.x;
  if (cutoff >= 0.0) {
    float alpha = texture(base_color_texture, v_texcoord).a *
                  id_info.mask.y * v_color.a;
    if (alpha < cutoff) discard;
  }
  frag_color = vec4(id_info.id.xyz, 1.0);
}

''',
    'VertexTextureProbe': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// The fragment half of the vertex-texture probe. See `vertex_texture.vert`.
//
// It writes what the *vertex* stage sampled, and that is the whole design: a
// probe whose fragment stage did its own sampling would come back green on a
// backend where the vertex stage read nothing, which is the answer it exists to
// distinguish.
precision highp float;

in vec4 v_sampled;

layout(location = 0) out vec4 frag_color;

void main() {
  frag_color = v_sampled;
}

''',
    'FieldDecay': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// One step of a decaying field — `H5`'s probe kernel.
//
// The smallest thing `FieldPass` can be held to: every texel becomes itself
// times a factor plus a constant, so after n steps from a known start the
// answer is written down in closed form, and a backend that cannot render
// into a float target, or read one back through a vertex stage, misses it
// by more than rounding.

precision highp float;

uniform sampler2D field_texture;

layout(std140) uniform FieldDecayInfo {
  /// x: the factor each step multiplies by. y: the constant each step adds.
  /// zw unused.
  vec4 params;
}
field_decay;

in vec2 v_uv;

layout(location = 0) out vec4 frag_color;

void main() {
  // `textureLod` at level zero: a field is a render target with one level,
  // and a sample the compiler cannot prove uniform is refused by WGSL when it
  // asks for an implicit derivative.
  frag_color = textureLod(field_texture, v_uv, 0.0) * field_decay.params.x +
               vec4(field_decay.params.y);
}

''',
    'IrradianceConvolve': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// One probe of the irradiance field, updated from a capture — `L4`.
//
// A `FieldPass` kernel over the whole atlas `lib/irradiance.glsl` reads: every
// texel that is not one of this probe's two tiles is copied through, and
// every texel that is gets the capture convolved into it and blended with
// what it held, by the field's hysteresis. Each step updates one probe; the
// renderer schedules a few a frame, round robin, so the field follows a
// changing room over a second or two rather than all at once.
//
// **The same arithmetic as `gatherProbe` on the host**, over the six cube
// faces the probe's capture drew instead of over rays: a cosine-weighted
// mean of the radiance for irradiance, and a mean and mean square of the
// distance under a cosine to the sixth for the moments. The capture's
// second attachment is the surface buffer, whose alpha is the depth along
// the face's own axis; the distance along a direction is that over the
// direction's component on the axis.
//
// **Every texel of the capture, each by the solid angle it covers**, rather
// than a fixed set of directions through it. A fixed set gave every update
// of a still room the same estimate, so the hysteresis settled on that
// estimate's error rather than averaging it away: a lamp or a sunlit patch a
// few texels wide was missed by one probe and counted twice by the next. At
// sixteen texels a side the whole cube is 1536 taps, cheap for a kernel that
// runs over two small tiles.
//
// Gutters are filled here too, from the interior texel `fillGutters` would
// copy, so a probe's tiles stay continuous without a second pass.
//
// Mode 1 is a plain copy from `seed_texture`, which is how the atlas the
// host baked becomes the one the GPU keeps updating.

in vec2 v_uv;

layout(location = 0) out vec4 frag_color;

uniform sampler2D field_texture;
uniform sampler2D seed_texture;
uniform samplerCube radiance_texture;
uniform samplerCube surface_texture;

layout(std140) uniform ConvolveInfo {
  /// x: the probe being updated. y: nought to update, one to seed. z: the
  /// hysteresis, the share of the old value kept. w: tiles per row.
  vec4 probe;

  /// x: an irradiance tile's interior, y: a moment tile's, in texels.
  /// z: the row the moment tiles start at. w: the distance a direction that
  /// saw only sky is given.
  vec4 tiles;

  /// xy: the atlas's size in texels. z: the capture's side in texels.
  /// w unused.
  vec4 atlas;
}
convolve_info;

/// Towards the centre of a texel of cube face [face] (+X, −X, +Y, −Y, +Z,
/// −Z), [a] and [b] across it in −1..1: unnormalised, the face's axis at one.
/// Which of the other two axes each coordinate names does not matter — the
/// texel centres are symmetric under either — only that every texel is
/// reached once.
vec3 CubeTexel(int face, float a, float b) {
  float side = (face & 1) == 0 ? 1.0 : -1.0;
  int axis = face >> 1;
  if (axis == 0) return vec3(side, a, b);
  if (axis == 1) return vec3(a, side, b);
  return vec3(a, b, side);
}

/// `decodeOctahedral` in `irradiance_field.dart`.
vec3 DecodeProbeOctahedral(vec2 uv) {
  vec2 xy = uv * 2.0 - 1.0;
  float z = 1.0 - abs(xy.x) - abs(xy.y);
  float t = max(-z, 0.0);
  vec3 n = vec3(xy.x + (xy.x >= 0.0 ? -t : t), xy.y + (xy.y >= 0.0 ? -t : t),
                z);
  return normalize(n);
}

/// The interior texel the stored texel [local] of a tile [interior] wide
/// stands for — itself inside, the one `fillGutters` copies in the gutter.
vec2 InteriorOf(vec2 local, float interior) {
  float last = interior - 1.0;
  vec2 i = local - 1.0;
  bool left = local.x < 0.5;
  bool right = local.x > interior + 0.5;
  bool top = local.y < 0.5;
  bool bottom = local.y > interior + 0.5;
  if ((left || right) && (top || bottom)) {
    return vec2(left ? last : 0.0, top ? last : 0.0);
  }
  if (top) return vec2(last - i.x, 0.0);
  if (bottom) return vec2(last - i.x, last);
  if (left) return vec2(0.0, last - i.y);
  if (right) return vec2(last, last - i.y);
  return i;
}

float DistanceAlong(vec3 direction) {
  float depth = textureLod(surface_texture, direction, 0.0).a;
  if (depth <= 0.0) return convolve_info.tiles.w;
  float axis = max(abs(direction.x), max(abs(direction.y), abs(direction.z)));
  return depth / max(axis, 1e-4);
}

void main() {
  vec2 size = convolve_info.atlas.xy;
  vec2 pixel = floor(v_uv * size);
  vec4 old = textureLod(field_texture, (pixel + 0.5) / size, 0.0);

  if (convolve_info.probe.y > 0.5) {
    frag_color = textureLod(seed_texture, (pixel + 0.5) / size, 0.0);
    return;
  }

  float columns = convolve_info.probe.w;
  float target = convolve_info.probe.x;
  float irradianceTile = convolve_info.tiles.x;
  float depthTile = convolve_info.tiles.y;
  float momentsTop = convolve_info.tiles.z;
  bool moments = pixel.y >= momentsTop;
  float stride = (moments ? depthTile : irradianceTile) + 2.0;
  vec2 local = moments ? vec2(pixel.x, pixel.y - momentsTop) : pixel;
  vec2 tile = floor(local / stride);
  if (tile.y * columns + tile.x != target || tile.x >= columns) {
    frag_color = old;
    return;
  }

  float interior = moments ? depthTile : irradianceTile;
  vec2 texel = InteriorOf(local - tile * stride, interior);
  vec3 normal = DecodeProbeOctahedral((texel + 0.5) / interior);

  int captureSide = max(int(convolve_info.atlas.z + 0.5), 1);
  float span = 2.0 / float(captureSide);
  vec3 light = vec3(0.0);
  float mean = 0.0;
  float square = 0.0;
  float weight = 0.0;
  for (int face = 0; face < 6; face++) {
    for (int row = 0; row < captureSide; row++) {
      for (int column = 0; column < captureSide; column++) {
        float a = (float(column) + 0.5) * span - 1.0;
        float b = (float(row) + 0.5) * span - 1.0;
        // A texel's solid angle goes as one over its distance from the
        // centre cubed: the square for the distance, one more for the slant.
        float inverse = inversesqrt(1.0 + a * a + b * b);
        vec3 direction = CubeTexel(face, a, b) * inverse;
        float solidAngle = inverse * inverse * inverse;
        float cosine = dot(normal, direction);
        if (cosine <= 0.0) continue;
        if (moments) {
          float c2 = cosine * cosine;
          float w = c2 * c2 * c2 * solidAngle;
          float distance = DistanceAlong(direction);
          mean += distance * w;
          square += distance * distance * w;
          weight += w;
        } else {
          float w = cosine * solidAngle;
          light += textureLod(radiance_texture, direction, 0.0).rgb * w;
          weight += w;
        }
      }
    }
  }
  float keep = convolve_info.probe.z;
  if (moments) {
    vec2 fresh = weight > 0.0 ? vec2(mean, square) / weight : old.xy;
    frag_color = vec4(mix(fresh, old.xy, keep), 0.0, 1.0);
  } else {
    vec3 fresh = weight > 0.0 ? light / weight : old.rgb;
    frag_color = vec4(mix(fresh, old.rgb, keep), old.a);
  }
}

''',
    'WboitResolve': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// Weighted blended transparency's resolve — `R8`.
//
// The transparent draws went into two targets instead of the picture: the
// accumulation target holds the sum of every layer's premultiplied colour and
// alpha, each times its weight, and the revealage target the product of one
// minus every layer's alpha — how much of what is behind still shows. This
// turns the pair into one layer over the lit scene: the weighted average
// colour, covering as much of the pixel as the layers together do. Drawn with
// the engine's premultiplied source-over, so a pixel no layer touched —
// revealage one — writes nothing and leaves the scene exactly as it was.
//
// Addition and multiplication do not care about order, which is the point:
// the transparent list needs no sort, and two intersecting panes composite
// the same whichever was drawn first.
precision highp float;

in vec2 v_uv;

layout(location = 0) out vec4 frag_color;

uniform sampler2D accumulation_texture;
uniform sampler2D revealage_texture;

void main() {
  vec4 accumulation = texture(accumulation_texture, v_uv);
  float coverage = 1.0 - texture(revealage_texture, v_uv).r;
  // Sums past half float's range come back infinite; clamped, their ratio is
  // still a colour rather than a NaN.
  vec3 average = min(accumulation.rgb, vec3(65504.0)) /
                 clamp(accumulation.a, 1e-5, 65504.0);
  frag_color = vec4(average * coverage, coverage);
}

''',
    'SceneColourCopy': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// One level of the copy of the scene that transmissive draws read — `M3`.
//
// Drawn once per level into its own rectangle of one texture: the base at
// the scene's size, and each level after it half the one before, side by
// side — see `SceneColourChain`. Every level is taken from the scene itself
// rather than from the level above it, because a pass cannot read the
// texture it draws into, and one texture is what the lit stage has a
// sampler left for.
//
// A texel of level k is the mean of the 2^k by 2^k block of the scene under
// it: (2^(k-1))² bilinear taps, each on the corner between four texels and so
// the mean of those four. Level zero takes one tap on a texel's centre, which
// is the texel. The work is about a quarter of the scene's texels a level,
// whatever the level.
precision highp float;

in vec2 v_uv;

layout(location = 0) out vec4 frag_color;

uniform sampler2D source_texture;

layout(std140) uniform SceneCopyInfo {
  /// x: the taps along each side, a power of two up to sixteen. y, z: one
  /// over the scene's width and height. w: unused.
  vec4 params;
}
copy_info;

void main() {
  float taps = copy_info.params.x;
  vec2 texel = copy_info.params.yz;
  // Offsets of 1 - n, 3 - n, … n - 1 texels: every corner inside the block.
  float first = 1.0 - taps;
  vec3 sum = vec3(0.0);
  for (int j = 0; j < 16; j++) {
    if (float(j) >= taps) break;
    for (int i = 0; i < 16; i++) {
      if (float(i) >= taps) break;
      vec2 offset =
          vec2(first + 2.0 * float(i), first + 2.0 * float(j)) * texel;
      sum += textureLod(source_texture, v_uv + offset, 0.0).rgb;
    }
  }
  frag_color = vec4(sum / (taps * taps), 1.0);
}

''',
    'Impostor': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;

// An octahedral impostor, lit — C4. The card `impostor.vert` turned to the
// eye, showing the three baked views nearest the direction it is seen from.
//
// **Lambert's lighting over a surface read from two atlases.** The albedo
// atlas takes the base colour slot and the normal-depth atlas the normal map
// slot, so the stage asks for no sampler a lit model does not already have —
// the lit stages sit near the sixteen a stage may hold. Diffuse only, because
// the atlases carry no roughness or metal: at the distance a tree becomes a
// card its highlights are below a pixel anyway.
//
// **Three views, blended by where the eye falls between them.** The eye's
// direction lands inside one triangle of the octahedral grid, and its
// barycentric weights say how much of each corner's view to take. Each view is
// read where this fragment's point on the card falls in *that* view's own
// picture — the card turns with the eye, the views do not — so the three agree
// about where a branch is rather than smearing three copies of it.
//
// **Normals were baked in the node's own space** and are turned into the world
// through the card's own frame: its right-hand axis, up and facing are known in
// both spaces (v_tangent and v_normal in the world, and rebuilt here from the
// eye's direction in the node's own), and the rotation that maps one frame
// onto the other is the node's.
//
// The depth in the normal atlas's alpha is baked but not read yet: it is what
// a later stage writes as the fragment's depth so a card intersects the ground
// where the tree does.

// --- lib/impostor.glsl ---
// The octahedral view grid an impostor is baked on and read from — C4.
//
// Shared by `impostor.vert` and `lighting/impostor.frag`, and mirrored in
// `flutter3d_core`'s `impostor_node.dart` (the bake) and `flutter3d_cpu`'s
// `cpu_shaders_impostor.dart`: the card, the camera a view was baked from and
// the cell it was baked into have to agree to the last sign, or a view is
// read mirrored.

#ifndef IMPOSTOR_GLSL_
#define IMPOSTOR_GLSL_

/// Views along each side of the atlas. Fixed in 0.8: the plan's 8 x 8, and a
/// constant so no block has to carry it.
#define kImpostorGrid 8.0

/// A direction on the sphere as a point of the unit square, with +Y at the
/// centre and -Y at the four corners — the octahedral map with Y as its pole,
/// so the views a tree is mostly seen from (level, and from above) take the
/// middle of the atlas rather than its folded edges.
vec2 ImpostorEncode(vec3 d) {
  vec3 a = abs(d);
  vec2 p = d.xz / max(a.x + a.y + a.z, 1e-8);
  vec2 s = vec2(p.x >= 0.0 ? 1.0 : -1.0, p.y >= 0.0 ? 1.0 : -1.0);
  vec2 folded = (vec2(1.0) - abs(p.yx)) * s;
  return (d.y >= 0.0 ? p : folded) * 0.5 + vec2(0.5);
}

/// The inverse of [ImpostorEncode].
vec3 ImpostorDecode(vec2 uv) {
  vec2 p = uv * 2.0 - vec2(1.0);
  float y = 1.0 - abs(p.x) - abs(p.y);
  vec2 s = vec2(p.x >= 0.0 ? 1.0 : -1.0, p.y >= 0.0 ? 1.0 : -1.0);
  vec2 folded = (vec2(1.0) - abs(p.yx)) * s;
  vec2 xz = y >= 0.0 ? p : folded;
  return normalize(vec3(xz.x, y, xz.y));
}

/// The right-hand axis of a card, or a baked view, facing along [d]: level
/// with the ground, except looking straight up or down, where "level" has no
/// direction and -Z stands in for up.
vec3 ImpostorRight(vec3 d) {
  vec3 up = abs(d.y) > 0.999 ? vec3(0.0, 0.0, -1.0) : vec3(0.0, 1.0, 0.0);
  return normalize(cross(up, d));
}

#endif  // IMPOSTOR_GLSL_

// --- lib/shadow.glsl ---
// Sampling the directional light's shadow map.
//
// A separate header for the same reason material_maps.glsl is one: the sampler
// must only be declared by shaders that actually read it, or the compiler drops
// the slot while the engine still tries to bind it.

#ifndef SHADOW_GLSL_
#define SHADOW_GLSL_

// --- lib/evsm.glsl ---
// Exponential variance shadow maps — `S2`.
//
// Shared by the pass that turns the directional depth atlas into moments
// (`evsm_filter.frag`) and by `ShadowFactor`, which reads them back: the two
// halves must warp depth with the same two exponents, or every comparison is
// between numbers on different scales.
//
// A header of its own rather than a section of `shadow.glsl`, because that
// one declares the lit stages' shadow sampler and the filter pass has no
// business declaring it.

#ifndef EVSM_GLSL_
#define EVSM_GLSL_

precision highp float;

// The two exponents depth is warped by. **Forty and five, and the ceiling is
// the format.** The moments are stored squared, so the positive side reaches
// e^80 at the far plane, about 5.5e34 — inside a 32-bit float with three
// orders of magnitude to spare, and far outside a half float, which is why
// the moments live in an rgba32f atlas and the depth atlas does not. The
// negative side only has to catch what the positive side lets through at a
// receiver just behind a caster, and five is the usual answer.
const float kEvsmPositive = 40.0;
const float kEvsmNegative = 5.0;

/// [depth], in [0, 1], warped onto both exponentials: x positive, y negative.
///
/// Depth is first spread to [-1, 1] so the two sides share the range evenly
/// rather than the negative one flattening to nothing at the far end.
vec2 EvsmWarp(float depth) {
  float d = 2.0 * clamp(depth, 0.0, 1.0) - 1.0;
  return vec2(exp(kEvsmPositive * d), -exp(-kEvsmNegative * d));
}

/// What one texel of the depth atlas stores in the moments atlas: each warp
/// and its square, which a blur then averages into a mean and a variance.
vec4 EvsmMoments(float depth) {
  vec2 warped = EvsmWarp(depth);
  return vec4(warped.x, warped.x * warped.x, warped.y, warped.y * warped.y);
}

/// Chebyshev's upper bound on the share of [moments]'s distribution at or
/// beyond [t], with the light-bleeding cut [bleed] taken off the bottom.
///
/// A select at the end rather than an early return of one, because a phi of
/// constants is what SPIRV-Cross refuses when it writes the WGSL.
float EvsmChebyshev(vec2 moments, float t, float minVariance, float bleed) {
  float variance = max(moments.y - moments.x * moments.x, minVariance);
  float d = t - moments.x;
  float pMax = variance / (variance + d * d);
  // Light bleeding: where two casters overlap, the bound admits light the
  // nearer one should block. Everything under [bleed] is called shadow and
  // the rest stretched back over [0, 1].
  float reduced = clamp((pMax - bleed) / max(1.0 - bleed, 1e-4), 0.0, 1.0);
  return t <= moments.x ? 1.0 : reduced;
}

/// How much light reaches a receiver at [depth] past filtered [moments].
///
/// The smaller of the two bounds: each exponential lets through a different
/// kind of error, and neither lets through what the other stops.
float EvsmVisibility(vec4 moments, float depth, float bleed) {
  vec2 warped = EvsmWarp(depth);
  // A floor on the variance proportional to the warped depth's own slope,
  // so a flat receiver compared against its own texel does not divide
  // nought by nought — the variance of one depth is zero.
  vec2 scale = 0.0001 * vec2(kEvsmPositive, kEvsmNegative) * warped;
  float positive = EvsmChebyshev(moments.xy, warped.x, scale.x * scale.x, bleed);
  float negative = EvsmChebyshev(moments.zw, warped.y, scale.y * scale.y, bleed);
  return min(positive, negative);
}

#endif  // EVSM_GLSL_

// --- lib/surface.glsl ---
// Shared material and lighting interface for the lighting models.
//
// flutter_gpu compiles shaders ahead of time into a bundle: there is no runtime
// compilation, so a node-graph material system assembled at run time is
// impossible. Each lighting model is therefore
// its own pre-built fragment shader, and this header is what keeps them
// interchangeable — one identical uniform block, so the Dart binding code never
// needs to know which model is active.
//
// Keep every declaration below byte-identical across models. A member a model
// does not read may be optimized out of the reflected block, which is why the
// Dart side skips absent members instead of failing.
//
// Only include this from a shader that actually reads FragInfo. Declaring the
// block without using it leaves it visible to reflection while the compiled
// shader binds no buffer for it, and binding that phantom block segfaults
// inside Metal. Shaders needing only colour helpers include lib/color.glsl.

#ifndef SURFACE_GLSL_
#define SURFACE_GLSL_

// --- lib/color.glsl ---
// Colour space helpers and the fragment output interface.
//
// Split out of surface.glsl so a shader that needs no material inputs — the
// normals debug view — can avoid DECLARING the FragInfo uniform block at all.
// That matters more than it looks: reflection metadata reports a block as
// present merely because it was declared, even when the compiled shader binds
// no such buffer, so a declared-but-unused block is indistinguishable from a
// used one until Metal crashes on the bind.

#ifndef COLOR_GLSL_
#define COLOR_GLSL_

precision highp float;

const float kPi = 3.14159265359;

// One varying set shared by every fragment shader, matching mesh.vert.
//
// All five are declared here, including the two the debug models never read: a
// fragment shader whose `in` block disagrees with the vertex shader's `out`
// block fails to link, and there is no partial-match rule to lean on.
in vec3 v_world_position;
in vec3 v_normal;
in vec2 v_texcoord;
in vec4 v_tangent;
in vec4 v_color;

/// Where this fragment is in the level's lightmap. Zero from every vertex
/// stage but `mesh_lightmapped.vert`, and read only by the lit models, which
/// sample a one-texel black there when a material has no map.
in vec2 v_lightmap_uv;

layout(location = 0) out vec4 frag_color;

// The second attachment: what a screen-space effect needs to know about the
// surface it is looking at. World-space normal in rgb, and in a the depth along
// the view axis in world metres — not a window depth; `WriteSurfaceGeometry`
// says at length why not.
//
// Depth travels here rather than in a depth texture because flutter_gpu cannot
// sample one — the same reason the shadow pass writes its depth into a colour
// target. See ARCHITECTURE.md §2.
//
// Guarded, because not every stage that includes this header draws into a
// two-attachment target. The shadow pass draws into one, and a pipeline
// declaring an output its target has no slot for is a mismatch worth avoiding
// rather than discovering.
#ifndef F3D_NO_SURFACE_BUFFER
layout(location = 1) out vec4 frag_surface;

/// The surface's own colour, sRGB-encoded, alpha one where a surface was
/// drawn — `L5`. The third attachment, present only when a pass reads it (the
/// indirect light does) and the device opens three; like the surface buffer,
/// written unconditionally and discarded when absent. Stored in the surface
/// buffer's format rather than eight bits a channel, and `Renderer` says why.
layout(location = 2) out vec4 frag_albedo;
#endif

/// What [frag_albedo] carries: the lit models set it in `ReadSurface`, and a
/// stage that reflects nothing — unlit, the debug views — leaves it black,
/// which is what light bounced onto it would come to.
vec3 g_albedo = vec3(0.0);

/// Octahedral encoding: a unit vector in two channels instead of three.
///
/// Worth the arithmetic because the fourth channel is already spent on depth,
/// and without a free channel there is nowhere to put roughness — which is the
/// difference between a reflection that knows stone from a mirror and one that
/// does not. The error is well under a degree, far below anything a reflection
/// off rough stone would show.
vec2 EncodeOctahedral(vec3 n) {
  n /= abs(n.x) + abs(n.y) + abs(n.z);
  vec2 e = n.xy;
  if (n.z < 0.0) {
    e = (1.0 - abs(n.yx)) * vec2(n.x >= 0.0 ? 1.0 : -1.0,
                                 n.y >= 0.0 ? 1.0 : -1.0);
  }
  return e * 0.5 + 0.5;
}

/// Where a debug pass leaves the picture it wants shown instead of the normal.
///
/// Declared here, in the header every lit shader includes **first**, and
/// written from surface.glsl, which is included after. The alternative was a
/// new member on a shared uniform block; a global costs nothing and moves no
/// offsets. It is read at the moment the surface buffer is written, which
/// happens after the lighting loop has run, so the value is there by then.
vec3 g_debug_surface = vec3(0.0);
bool g_debug_surface_on = false;

/// Whether [WriteSurface] weights the colour by its alpha: set by
/// `ReadSurface` for a material that blends, and false for everything else.
///
/// **The blend takes its source as premultiplied**, so a blended surface has
/// to hand it the colour times the alpha — a pane at a fifth of opaque adds a
/// fifth of its light, not all of it. glTF's blend mode is Porter and Duff's
/// over on straight colour, and this is the one place that turns the lit
/// radiance into what that means. An opaque or masked surface keeps its
/// colour whole: its alpha is not a coverage, and nothing blends it.
/// A global for the reason [g_debug_surface] is one.
bool g_premultiply = false;

// **A stage that needs none of this must be able to declare none of it.** On
// Vulkan both stages' descriptors are merged into one set layout, and two
// bindings with the same number in it is not a layout the specification
// allows. A driver may accept it anyway; a Galaxy A55's refuses the pipeline
// with `ErrorUnknown` and no other word, which is how the shadow pass came to
// build everywhere except there — its only uniform block was this one, and it
// landed on the same binding as the vertex stage's first.
#ifndef F3D_NO_FOG

/// Distance fog, in its own block rather than folded into FragInfo.
///
/// Its own because color.glsl is included before FragInfo is declared, and
/// because appending to a block that half a dozen shaders already share is a
/// way to move offsets nobody expected to move. Three vec4s is a cheap price
/// for not touching any of that.
layout(std140) uniform FogInfo {
  /// rgb: linear fog colour. w: density per metre, zero for no fog.
  vec4 fog;

  /// xyz: camera position in world space. Duplicated from FragInfo so this
  /// block stands alone; a vec3 is cheaper than a coupling.
  vec4 eye;

  /// xyz: the direction the camera looks, as a unit vector in world space.
  /// w: what a transparent draw writes under weighted blended transparency —
  /// `R8`, see `WriteWeightedBlended`. Zero for every other draw.
  ///
  /// Here rather than in a block of its own because it answers the same
  /// question [eye] does — where the camera is and which way it faces — and
  /// this is the block `color.glsl` can see.
  vec4 forward;
}
fog_info;

/// How far this fragment is from the eye, in world metres.
///
/// What the fog fades by. Distance rather than depth, because fog is a
/// property of the air between two points and does not care which way the
/// camera happens to face.
float EyeDistance() { return distance(v_world_position, fog_info.eye.xyz); }

/// How far this fragment is *along the view axis*, in world metres.
///
/// What the surface buffer's alpha holds. Depth rather than distance, and the
/// difference only shows on an orthographic camera — where the rays through
/// the pixels are parallel instead of meeting at the eye, so a distance from
/// the eye names a sphere that the pixel's ray crosses somewhere the reader
/// cannot solve for. A depth along the axis names a plane, which every ray
/// crosses exactly once. See `WorldAtDepth` in `post/ssao.frag` for the
/// reconstruction both projections share.
float ViewDepth() {
  return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz);
}

#else  // F3D_NO_FOG

// The same two questions, answered without the block: a stage that declares no
// fog has no eye position to measure from either. Stubs rather than a guard at
// every call site, so that what includes this file reads the same whichever
// way it was compiled.
float EyeDistance() { return 0.0; }
float ViewDepth() { return 0.0; }

#endif  // F3D_NO_FOG

/// sRGB to linear. Textures are authored in sRGB, but lighting is only correct
/// in linear space; skipping this is what makes naive renderers look muddy.
vec3 SrgbToLinear(vec3 srgb) {
  return mix(
      srgb / 12.92,
      pow((srgb + vec3(0.055)) / 1.055, vec3(2.4)),
      step(vec3(0.04045), srgb));
}

/// Linear to sRGB. The render target is a plain UNorm format rather than an
/// sRGB one, so the encode has to happen here.
vec3 LinearToSrgb(vec3 linear) {
  return mix(
      linear * 12.92,
      1.055 * pow(linear, vec3(1.0 / 2.4)) - vec3(0.055),
      step(vec3(0.0031308), linear));
}

/// Writes scene-referred linear light into the HDR target.
///
/// No tone map and no sRGB encode: those moved into the composite pass, which
/// is the entire point of rendering into `r16g16b16a16Float` first. Applying
/// them here meant every model wrote display-referred colour into an 8-bit
/// buffer, so anything above display white was gone before post-processing
/// could see it — and bloom is a function of exactly that.
///
/// Exposure moved with them, for the same reason: it belongs on the same side
/// of the display transform as the tone map.
/// Records the geometry of this fragment for whatever runs after the scene.
///
/// Called from the same place that writes colour, so a surface cannot be lit
/// into the frame without also describing itself — which is the failure that
/// leaves a screen-space effect reflecting whatever was in the buffer before.
///
/// rg: octahedral normal. b: perceptual roughness. a: **depth along the view
/// axis, in world metres** — see [ViewDepth].
///
/// **Not `gl_FragCoord.z`, and that is a defect this channel carried until it
/// was looked at.** Window depth crowds every distant surface into the top of
/// its range — with a near plane of a tenth of a metre, everything past twenty
/// metres lives in the last half a hundredth of `[0, 1]` — and this attachment
/// is a half float, whose steps up there are about five ten-thousandths. So two
/// surfaces half a metre apart at twenty metres stored the *same* number, and
/// every screen-space pass that compares against this channel decided whole
/// bands of pixels by rounding. The occlusion pass drew them: vertical stripes
/// along the lines of constant depth on any wall receding from the camera, on
/// both GPU backends. The software rasteriser kept the channel at full
/// precision and drew the effect correctly, so it was the one that looked
/// wrong against the other two.
///
/// A depth in metres has none of that: the exponent carries the range and the
/// mantissa carries the same relative precision everywhere, which at twenty
/// metres is a centimetre. Both numbers are measured in
/// `flutter3d/test/surface_depth_test.dart`.
///
/// Zero still means nothing was drawn. The attachment is cleared to zero and
/// nothing is drawn in front of the near plane.
void WriteSurfaceGeometry(float roughness) {
#ifndef F3D_NO_SURFACE_BUFFER
  // `L5`: the surface's colour, whatever the surface buffer ends up holding.
  frag_albedo = vec4(LinearToSrgb(clamp(g_albedo, vec3(0.0), vec3(1.0))), 1.0);
  // A debug pass takes the buffer over rather than getting one of its own.
  // The surface buffer already has an attachment, a viewer and a golden; a
  // second one would need all three built before it could answer anything.
  if (g_debug_surface_on) {
    frag_surface = vec4(g_debug_surface, ViewDepth());
    return;
  }
  // Reversed on a back face, as the lit normal is, so the occlusion and
  // reflection passes see the side of a double-sided surface that faces them.
  vec3 geometric = normalize(v_normal);
  if (!gl_FrontFacing) geometric = -geometric;
  frag_surface = vec4(EncodeOctahedral(geometric),
                      clamp(roughness, 0.0, 1.0), ViewDepth());
#endif
}

/// Fades [color] toward the fog with distance from the eye.
///
/// Exponential rather than linear, because linear fog has a visible plane
/// where it starts and a dungeon corridor is exactly where that shows.
vec3 ApplyFog(vec3 color) {
#ifdef F3D_NO_FOG
  return color;
#else
  float density = fog_info.fog.w;
  if (density <= 0.0) return color;
  float d = EyeDistance();
  return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0));
#endif
}

/// How much a transparent fragment counts for against the others over its
/// pixel — `R8`. McGuire and Bavoil's depth weight (their equation 9): a near
/// layer outweighs a far one, which is all the ordering a weighted average
/// can keep. [alpha] multiplies it, as theirs does, so a faint layer counts
/// faintly. Depth along the view axis, in metres, the surface buffer's.
float WeightedBlendedWeight(float alpha) {
  float z = abs(ViewDepth());
  float near = z / 5.0;
  float far = z / 200.0;
  float far3 = far * far * far;
  return alpha *
         clamp(10.0 / (1e-5 + near * near + far3 * far3), 1e-2, 3e3);
}

/// What a transparent draw writes when the frame composites transparency
/// order-independently — `R8`. `fog_info.forward.w` says which:
///
/// - 0: [frag_color] as it stands, the sorted blend's source. Every opaque
///   draw, and every draw in a frame that sorts.
/// - 1: the accumulation target's share — the colour, which the engine keeps
///   premultiplied, and the alpha, both times the weight. Added.
/// - 2: the revealage target's — the alpha alone, in every channel, which the
///   blend multiplies the target by one minus of.
/// - 3: both at once, the second into attachment one, where the surface
///   buffer would be; the pass that asks has no surface buffer attached.
///
/// Selects rather than returns, because a phi of constants is what
/// SPIRV-Cross refuses. At nought the branch is not taken and [frag_color]
/// is untouched, which is what keeps a sorting frame byte-identical.
void WriteWeightedBlended() {
#ifndef F3D_NO_FOG
  float mode = fog_info.forward.w;
  if (mode > 0.5) {
    float alpha = frag_color.a;
    float weight = WeightedBlendedWeight(alpha);
    vec4 accumulate = vec4(frag_color.rgb * weight, alpha * weight);
    bool revealage = mode > 1.5 && mode < 2.5;
    frag_color = revealage ? vec4(alpha) : accumulate;
#ifndef F3D_NO_SURFACE_BUFFER
    if (mode > 2.5) frag_surface = vec4(alpha);
#endif
  }
#endif
}

/// The fog is mixed in before the weight, so a thin distant pane adds a thin
/// share of the fog too rather than all of it. Times one when nothing blends,
/// which is exact, so an opaque draw writes what it always wrote.
void WriteSurface(vec3 linearColor, float alpha, float roughness) {
  float weight = g_premultiply ? alpha : 1.0;
  frag_color = vec4(ApplyFog(linearColor) * weight, alpha);
  WriteSurfaceGeometry(roughness);
  WriteWeightedBlended();
}

/// For a stage with no material to speak of.
///
/// Fully rough, which is the honest default: a surface that cannot say how
/// polished it is should not be reflected off.
void WriteSurface(vec3 linearColor, float alpha) {
  WriteSurface(linearColor, alpha, 1.0);
}

/// Writes a value that is already display-referred.
///
/// For debug output, where the colour is not a light value at all: a normal
/// encoded as RGB means nothing after a tone curve. Converting to linear here
/// means the composite pass's sRGB encode hands the original back unchanged,
/// provided the view also turns tone mapping and exposure off — which is what
/// `RenderSettings.tonemap` is for.
void WriteDisplayColor(vec3 displayColor, float alpha) {
  frag_color = vec4(SrgbToLinear(displayColor), alpha);
  WriteSurfaceGeometry(1.0);
}

#endif  // COLOR_GLSL_

// --- lib/frag_coord.glsl ---
// Where a fragment sits, counted from the top of its target on every backend.

#ifndef FRAG_COORD_GLSL_
#define FRAG_COORD_GLSL_

/// `gl_FragCoord.xy` with row zero at the top of the picture.
///
/// [rows] is the target's height where the backend's row zero is the bottom
/// of the picture, and zero where it is the top. WebGL2 is the first kind:
/// window coordinates start at the lower left, and the engine draws the
/// picture upright there rather than mirroring every projection. Metal,
/// WebGPU and the software rasteriser are the second.
///
/// **Why a pattern cares and a picture does not.** Every screen-space pattern
/// in the engine — the Bayer dither, the grain, the jitter a ray march starts
/// from, the rotation of a shadow kernel — is a function of the pixel's row.
/// Read from the bottom, the same frame gets the pattern turned upside down,
/// and a four-row Bayer cell lands on different rows unless the height is a
/// multiple of four. The picture underneath is identical; the pattern on top
/// of it is not, and a comparison across backends counts every pixel it
/// moved.
vec2 FragCoordFromTop(float rows) {
  return rows > 0.0 ? vec2(gl_FragCoord.x, rows - gl_FragCoord.y)
                    : gl_FragCoord.xy;
}

#endif  // FRAG_COORD_GLSL_


/// Lights per draw. Must match LightBuffer.maxLights on the Dart side.
///
/// A fixed array with a runtime count, not a shader permutation per light
/// count: turning a light on has to be free, because there is no runtime
/// compilation to fall back on. Verified against the SDK — Impeller keeps
/// `vec4 x[8]` in the compiled Metal struct and reflects the array's base
/// offset, with the std140 stride of 16 bytes.
#define kMaxLights 8

/// How many more lights one draw may be handed — `gfx-74n`.
///
/// **The eight above stay exactly what they were**, which is what keeps this
/// from moving a single recorded frame: a draw with eight lights or fewer runs
/// the loop it has always run, reads the uniform arrays it has always read, and
/// never touches the texture below. The tail is the part that used to be
/// impossible.
///
/// A loop bound rather than a cost. `AccumulateLights` breaks at the draw's own
/// count, so a scene with three lights costs three iterations whatever this
/// says. Twenty-four because the two tables below are `vec4 x[6]` and four
/// lanes fit a `vec4`: two hundred and eight bytes a draw, against the five
/// hundred and twelve the light arrays already cost.
#define kExtraLights 24
#define kTotalLights (kMaxLights + kExtraLights)

// --- lib/light_list.glsl ---
// The frame's light list, and how a fragment finds its tail in it — `gfx-74n`
// and `L6`.
//
// Split out of `surface.glsl` so a stage that is not a surface can read the
// same lights: `N6`'s six-way particles light each fragment by the list the
// lit models read, clusters and all, without declaring `FragInfo`. The text is
// the one that stood in `surface.glsl`, moved rather than copied, so the lit
// models compile to what they compiled to before.

#ifndef LIGHT_LIST_GLSL_
#define LIGHT_LIST_GLSL_
/// Every light in the scene, one per row, four texels across — `gfx-74n`.
///
/// **A texture rather than a wider uniform block, and that is the design.**
/// `FragInfo` is uploaded on every draw, so widening its four `vec4` arrays to
/// hold thirty-two lights would be a two-kilobyte upload per draw in every
/// scene, including every scene with one light. This is built once a frame and
/// only when a scene has more lights than a draw can hold in its slots.
///
/// Row layout, which `renderer_light_list.dart` writes and only this reads:
///
///  * texel 0 — xyz world position, w type (0 directional, 1 point, 2 spot)
///  * texel 1 — rgb linear colour, w intensity
///  * texel 2 — xyz the direction it points, w range
///  * texel 3 — x cos(inner), y cos(outer), zw unused
///
/// The same four vectors the uniform arrays hold, in the same order, so one
/// reader serves both.
///
/// **`F3D_NO_LIGHT_LIST` leaves both out**, for a model that accumulates no
/// lights. Such a model never reaches the reader below, so the compiler drops
/// the block and the sampler from the Metal function while reflection still
/// lists them, with no buffer or texture index assigned. The renderer used to
/// bind them for every draw, Unlit included, and that bind is a crash inside
/// `setFragmentBuffer:offset:atIndex:` on Metal. Vulkan took the same draw
/// without a word, which is how 0.7.0 shipped with it.
#ifndef F3D_NO_LIGHT_LIST
uniform sampler2D light_list_texture;

layout(std140) uniform LightListInfo {
  /// x: how many rows this draw reads, zero when it reads none.
  /// y, z: one over the texture's width and height.
  /// w: unused.
  vec4 list;

  /// Which rows, four to a vector, in the order they are read.
  ///
  /// Indices rather than the light data itself: the data is the same for every
  /// draw in the frame and belongs in the texture; what differs per draw is
  /// *which* of them reach it, and that is what `Renderer._drawLightsFor`
  /// already decides.
  vec4 indices[6];

  /// How much of each of those survives the edge fade, in the same order.
  ///
  /// Per draw and not in the texture, because the row an index points at is
  /// shared by every draw in the frame: a scale written into it would dim that
  /// light for all of them. `gfx-12n`'s fade lives at the end of the list now —
  /// that is where a light stops contributing, and fading the slots against a
  /// water line that no longer marks a cliff would dim a light for no reason
  /// while its rival stayed bright, making the swap more visible rather than
  /// less.
  vec4 scales[6];

  /// `L6`: the view-projection the light clusters were cut with, so this
  /// finds a fragment's cell the way `LightClusters.clusterOf` does.
  mat4 cluster_view_projection;

  /// xyz: tiles across, tiles up, slices deep. w: one when this draw reads
  /// its tail from the cell it is in rather than from `indices`.
  vec4 cluster_grid;

  /// x: where slices begin, in clip w. y: slices per unit of `ln(w / x)`.
  /// z: the texture row the cells' headers start at, four to a row, each
  /// (offset, count). w: the row their entries start at, sixteen to a row.
  vec4 cluster_depth;

  /// Which rows this draw already holds in its eight slots, minus one for
  /// an empty slot. A cell lists every light that reaches it, and one the
  /// slots already carry must not be counted again.
  vec4 slot_rows[2];
}
light_list_info;

/// One lane of a six-vector table, [slot] counting from nought.
float LightListLane(vec4 four, int slot) {
  int lane = slot - (slot / 4) * 4;
  return lane == 0 ? four.x : lane == 1 ? four.y : lane == 2 ? four.z : four.w;
}

/// The row light [slot] of the list reads.
float LightListRow(int slot) {
  return LightListLane(light_list_info.indices[slot / 4], slot);
}

/// How much of light [slot] of the list survives the edge fade.
float LightListScale(int slot) {
  return LightListLane(light_list_info.scales[slot / 4], slot);
}

/// The cell this fragment falls in, as `LightClusters` wrote it: where its
/// entries start and how many there are. Found once, in [LightCount], and
/// read by every [SampleLight] of the loop that follows.
float g_cluster_offset = 0.0;
float g_cluster_count = 0.0;

bool Clustered() { return light_list_info.cluster_grid.w > 0.5; }

/// One texel of the light list texture, [texel] across and [row] down.
vec4 LightListTexel(float texel, float row) {
  return textureLod(light_list_texture,
                    vec2((texel + 0.5) * light_list_info.list.y,
                         (row + 0.5) * light_list_info.list.z),
                    0.0);
}

void FindCluster(vec3 world) {
  vec4 clip = light_list_info.cluster_view_projection * vec4(world, 1.0);
  vec2 ndc = clip.xy / max(clip.w, 1e-6);
  vec3 grid = light_list_info.cluster_grid.xyz;
  float near = light_list_info.cluster_depth.x;
  float tx = clamp(floor((ndc.x * 0.5 + 0.5) * grid.x), 0.0, grid.x - 1.0);
  float ty = clamp(floor((ndc.y * 0.5 + 0.5) * grid.y), 0.0, grid.y - 1.0);
  float tz = clip.w <= near
                 ? 0.0
                 : clamp(floor(log(clip.w / near) *
                               light_list_info.cluster_depth.y),
                         0.0, grid.z - 1.0);
  float cell = tx + ty * grid.x + tz * grid.x * grid.y;
  float row = floor(cell / 4.0);
  vec4 header =
      LightListTexel(cell - row * 4.0, light_list_info.cluster_depth.z + row);
  g_cluster_offset = header.x;
  g_cluster_count = header.y;
}

/// The row entry [slot] of this fragment's cell names.
float ClusterRow(int slot) {
  float entry = g_cluster_offset + float(slot);
  float row = floor(entry / 16.0);
  float within = entry - row * 16.0;
  float texel = floor(within / 4.0);
  vec4 four = LightListTexel(texel, light_list_info.cluster_depth.w + row);
  return LightListLane(four, int(within - texel * 4.0 + 0.5));
}

/// Whether one of the draw's slots already holds light list row [row].
bool InSlots(float row) {
  vec4 a = abs(light_list_info.slot_rows[0] - vec4(row));
  vec4 b = abs(light_list_info.slot_rows[1] - vec4(row));
  return min(min(min(a.x, a.y), min(a.z, a.w)), min(min(b.x, b.y), min(b.z, b.w))) < 0.5;
}
#endif  // F3D_NO_LIGHT_LIST

#endif  // LIGHT_LIST_GLSL_


layout(std140) uniform FragInfo {
  /// xyz: world position (point and spot). w: type, 0 directional 1 point 2 spot.
  vec4 light_position[kMaxLights];

  /// rgb: linear colour. w: intensity.
  vec4 light_color[kMaxLights];

  /// xyz: the direction the light points, its local -Z. w: range, 0 unbounded.
  vec4 light_direction[kMaxLights];

  /// x: cos(inner cone angle). y: cos(outer cone angle).
  vec4 light_cone[kMaxLights];

  /// rgb: albedo tint applied on top of the texture. w: opacity.
  vec4 base_color;

  /// rgb: emissive factor, already linear. w: one when the normal map has
  /// two channels (x, y) and its z is rebuilt — see `ApplyNormalMap`. It sits
  /// here because this was the block's one unspent lane.
  vec4 emissive;

  /// xyz: camera position in world space, needed for every specular term.
  vec4 camera_position;

  /// x: metallic, y: roughness, z: ambient strength, w: specular strength.
  vec4 material;

  /// x: alpha cutoff (negative when the material is not masked: -1 opaque,
  /// -0.5 blended, -2 hashed), y: normal scale, z: occlusion strength,
  /// w: emissive strength.
  vec4 material2;

  /// x: exposure, y: active light count, z: index of the shadow-casting light.
  /// w is reserved so adding a frame-wide parameter does not change the offsets
  /// of anything already here.
  vec4 frame_params;

  /// x: one texel of the shadow map, y: depth bias, z: normal offset,
  /// w: strength, zero when shadows are off.
  vec4 shadow_params;

  /// World space to the shadow camera's clip space. The first cascade.
  mat4 shadow_matrix;

  /// The second and third cascades. Copies of the first when there is one, so
  /// this block's layout never depends on how many there are.
  mat4 shadow_matrix_far;
  mat4 shadow_matrix_farthest;

  /// x, y: where cascades 0 and 1 end, in metres from the camera. z: how many
  /// cascades there are, 1 to 3. w: one texel of a tile, vertically —
  /// shadow_params.x is one texel of the whole atlas, and with more than one
  /// cascade those differ.
  vec4 shadow_cascades;

  /// rgb: what a surface facing straight up receives from the environment.
  /// w: one when the metal-rough models' diffuse is EON rather than Lambert —
  /// `L8`, `RenderSettings.diffuseModel`; a frame-wide switch in a frame-wide
  /// vector, and the block's offsets stay where four backends agree on them.
  ///
  /// Appended after everything else on purpose: std140 lays a block out in
  /// declaration order, so adding here leaves every offset above unchanged and
  /// the three backends do not have to agree about anything they did not
  /// already agree about.
  vec4 ambient_sky;

  /// rgb: what a surface facing straight down receives — bounce off the ground
  /// rather than the ground itself.
  ///
  /// **w is the directional light's apparent size** — `gfx-15n` — which has
  /// nothing to do with ambient and everything to do with this being the last
  /// unspent component in a block six shaders share. `frame_params.w` was the
  /// slot reserved for a frame-wide parameter and the environment's level
  /// count took it; appending to this block moves offsets four backends have
  /// agreed on. See `shadow.glsl`, which reads it.
  ///
  /// Two colours rather than one is the whole of what makes ambient look like
  /// light instead of like a lifted black level. Outdoors the sky is blue and
  /// bright and the ground is warm and dim, and a flat grey for both leaves
  /// every underside as pale as every upward face — which reads as the model
  /// being flat, and gets blamed on the normals.
  vec4 ambient_ground;

  /// x, y, z: the depth bias of each cascade, in that cascade's own normalized
  /// depth. w unused.
  ///
  /// `ShadowSettings.bias` is one number and a cascade's depth range is not:
  /// a near cascade is stretched towards the light when a caster stands
  /// further out than its own volume reaches, and the same bias over a longer
  /// range is a longer distance. The renderer converts it per cascade so it
  /// stays the distance it was tuned as; an unstretched cascade gets the
  /// setting unchanged.
  vec4 shadow_bias;

  /// x: the target's rows when its row zero is the bottom of the picture,
  /// zero when it is the top — see `FragCoordFromTop` in `frag_coord.glsl`,
  /// which the shadow kernel's rotation reads through. y: the mip bias every
  /// material map is read with — `R2`: nought, except while a temporal
  /// resolve reconstructs a picture larger than the scene is drawn at, when
  /// the maps are read as sharp as the output they end up in. z: one when
  /// the metal-rough model puts back the energy single scattering loses —
  /// `L1`, `RenderSettings.energyCompensation`. w: the frame's slice of 32
  /// while a temporal resolve runs, minus one otherwise — `S3`, which steps
  /// the soft shadow's rotation by it.
  vec4 target_origin;
}
frag_info;

/// The bias a material map is read with — see `target_origin.y`.
float MaterialLodBias() { return frag_info.target_origin.y; }

/// The maps a lit material reads, by the index [MapUv] takes — `C8`. The
/// order `LayerInfo.uv_transform` keeps them in, and `MaterialMap`'s on the
/// Dart side.
#define kMapBaseColor 0
#define kMapMetallicRoughness 1
#define kMapNormal 2
#define kMapOcclusion 3
#define kMapEmissive 4

/// Where map [slot] is read — `C8`, `KHR_texture_transform` at the sampler.
///
/// **A macro everywhere but the one stage that has the matrices.** A stage
/// that defines `F3D_TEXTURE_TRANSFORM` supplies [MapUv] and [MapMatrix] from
/// a block of its own; every other stage reads each map at the vertex's own
/// coordinate, and the macro leaves its source exactly what it was, so none of
/// them compiles to anything new.
#ifdef F3D_TEXTURE_TRANSFORM
vec2 MapUv(int slot);

/// The 2×2 part of map [slot]'s transform: x and y its first row, z and w
/// its second.
vec4 MapMatrix(int slot);
#else
#define MapUv(slot) v_texcoord
#endif

uniform sampler2D base_color_texture;

/// Everything about the surface that does not depend on which light is being
/// evaluated, resolved once per fragment.
struct Surface {
  vec3 albedo;      // linear, already tinted
  float alpha;      // opacity after texture, tint and vertex colour
  vec3 n;           // unit normal, perturbed by the normal map when there is one
  vec3 v;           // unit direction to the camera
  float n_dot_v;
  float metallic;
  float roughness;  // perceptual
  float occlusion;  // 1 means unoccluded
  vec3 emissive;    // linear, added after shading
  vec3 ambient;     // hemispheric, already scaled by the scene's strength
  float exposure;
};

/// One light's contribution geometry, recomputed per light per fragment.
struct LightSample {
  vec3 l;           // unit direction to the light
  vec3 h;           // unit half vector
  vec3 radiance;    // colour * intensity * attenuation
  float n_dot_l;
  float n_dot_h;
  float v_dot_h;

  /// One when the specular below is already integrated over the light —
  /// `L7`, a rectangle under a model that defines `F3D_LTC` — and nought
  /// otherwise. Then `ltc.x` is the GGX lobe over the rectangle, `ltc.y` the
  /// fitted norm and `ltc.z` the Fresnel term; see `LtcRectangle`.
  float integrated;
  vec3 ltc;
};

Surface ReadSurface() {
  Surface s;

  vec4 texel = texture(base_color_texture, MapUv(kMapBaseColor), MaterialLodBias());
  // Vertex colour is authored linear per the glTF spec, unlike the base colour
  // texture and the tint, which are sRGB.
  s.albedo = SrgbToLinear(texel.rgb) *
             SrgbToLinear(frag_info.base_color.rgb) *
             v_color.rgb;
  s.alpha = texel.a * frag_info.base_color.a * v_color.a;
  // `L5`: the albedo buffer carries it, for the indirect light.
  g_albedo = s.albedo;

  // Alpha masking, glTF's third alpha mode. A negative cutoff means the
  // material is opaque or blended, and discard would then be wrong rather than
  // merely unnecessary. Doing it before anything else is deliberate: a
  // discarded fragment should not pay for the lighting loop.
  //
  // **A cutoff below -1.5 is the fourth mode: hashed** — `gfx-16n`. The
  // sentinel rides in the same component because the alternative is a second
  // number in a block six shaders share, and -1 already meant "not masked";
  // anything more negative was free. See [MaterialAlphaMode.hashed].
  float cutoff = frag_info.material2.x;
  if (cutoff >= 0.0) {
    if (s.alpha < cutoff) discard;
  } else if (cutoff < -1.5) {
    // **Stochastic instead of a threshold.** A leaf texture at 40% opacity is
    // either entirely there or entirely gone under a fixed cutoff, so a fern
    // comes out as a hard-edged cardboard cut-out; sorting would fix it and
    // costs a sort per frame and a draw per layer. Comparing against noise
    // instead keeps 40% of the *pixels*, which resolves as 40% opacity to
    // anything that averages several of them — a higher-resolution target,
    // a downsample, a person standing back.
    //
    // **Hashed on world position, not on the screen.** Screen-space noise is
    // one line shorter and swims: the pattern stays put while the object
    // moves through it, so a moving branch sparkles. Anchoring it to where
    // the surface *is* means a given speck of leaf keeps its verdict from
    // frame to frame, and the camera moving changes nothing.
    //
    // The scale is a constant and it is the whole tuning: finer than the
    // texture's own detail and the noise disappears into aliasing, coarser
    // and the leaf breaks into blotches. Sixteen per metre is about a
    // centimetre of grain at a metre away.
    vec3 anchored = floor(v_world_position * 16.0);
    float noise = fract(
        sin(dot(anchored, vec3(12.9898, 78.233, 37.719))) * 43758.5453);
    if (s.alpha < noise) discard;
  }
  // **Between -1 and nought is the blend mode**, which `WriteSurface` weights
  // by its alpha: see [g_premultiply]. The engine writes -0.5 for it, -1 for
  // opaque; neither is masked, and only the blend's source is premultiplied.
  g_premultiply = cutoff < 0.0 && cutoff > -0.75;

  s.n = normalize(v_normal);
  // The back of a double-sided surface is lit from its own side: glTF asks
  // for the normal to be reversed there, and without it the underside of a
  // cloth turned to the sun reads n·l below zero and stays unlit. Only a
  // double-sided material ever draws a back face, since everything else has
  // them culled.
  if (!gl_FrontFacing) s.n = -s.n;
  s.v = normalize(frag_info.camera_position.xyz - v_world_position);
  // Clamped away from zero: a grazing view direction otherwise divides by zero
  // in the specular visibility term.
  s.n_dot_v = max(dot(s.n, s.v), 1e-4);

  s.metallic = clamp(frag_info.material.x, 0.0, 1.0);
  s.roughness = clamp(frag_info.material.y, 0.02, 1.0);
  // Hemispheric: the sky above, the ground below, blended by which way this
  // surface faces. `material.z` stays the overall strength, so the two are
  // separable — a scene dims its ambient without changing its colour, which is
  // what the one control used to do on its own.
  //
  // The blend runs on the geometric normal deliberately, before
  // `ApplyMaterialMaps` perturbs it. A normal map describes millimetres of
  // surface relief, and ambient of this kind describes which half of the world
  // a face can see; letting bump detail swing it makes a brick wall's mortar
  // lines pick up sky and reads as noise.
  s.ambient = mix(frag_info.ambient_ground.rgb, frag_info.ambient_sky.rgb,
                  s.n.y * 0.5 + 0.5) *
              frag_info.material.z;
  s.exposure = max(frag_info.frame_params.x, 0.0);

  // Neutral until ApplyMaterialMaps says otherwise, so a model that samples no
  // maps still has a complete surface.
  s.occlusion = 1.0;
  s.emissive = vec3(0.0);

  return s;
}

int LightCount() {
#ifdef F3D_NO_LIGHT_LIST
  return clamp(int(frag_info.frame_params.y + 0.5), 0, kMaxLights);
#else
  // `L6`: the tail is the cell's, when the draw reads one.
  float tail = light_list_info.list.x;
  if (Clustered()) {
    FindCluster(v_world_position);
    tail = g_cluster_count;
  }
  return clamp(int(frag_info.frame_params.y + 0.5), 0, kMaxLights) +
      clamp(int(tail + 0.5), 0, kExtraLights);
#endif
}

/// Whether light [index] carries a shadow — `gfx-74n`.
///
/// Only the first eight do. The cube atlas holds six rows and the slot table is
/// eight entries wide, so a light from the list has no row to read and asking
/// for one would index past the table. That is a real limit and the right one:
/// the eight a draw keeps in its slots are the eight ranked most relevant to
/// it, which is exactly the set worth a shadow map.
bool LightHasShadow(int index) { return index < kMaxLights; }

/// Distance attenuation for a punctual light, following the glTF spec.
///
/// Inverse square with an optional range window. The window is what stops a
/// lamp with a declared range from contributing a faint haze across the whole
/// scene, which matters far more once there are eight of them.
float PunctualAttenuation(float distance, float range) {
  float attenuation = 1.0 / max(distance * distance, 1e-4);
  if (range > 0.0) {
    float ratio = distance / range;
    float window = clamp(1.0 - ratio * ratio * ratio * ratio, 0.0, 1.0);
    attenuation *= window * window;
  }
  return attenuation;
}

/// One edge of Lambert's sum, from [a] to [b], neither of which need be a
/// unit vector: the angle between them times how much their plane leans into
/// [n].
float LambertEdge(vec3 a, vec3 b, vec3 n) {
  // Normalised with a floor rather than `normalize`: a corner exactly at the
  // shading point, or a horizon crossing that lands there, is a zero vector,
  // and `normalize` of that is a NaN that spreads to the whole pixel and then
  // to the bloom. A zero vector here subtends nothing, which is the answer.
  vec3 ua = a / max(length(a), 1e-12);
  vec3 ub = b / max(length(b), 1e-12);
  // Clamped before the `acos`: two nearly parallel edge directions can give a
  // dot a hair past one through rounding alone, and `acos` of that is the same
  // NaN.
  float angle = acos(clamp(dot(ua, ub), -1.0, 1.0));
  vec3 axis = cross(ua, ub);
  float len = length(axis);
  // A degenerate edge — the shading point lies on the line through it —
  // subtends nothing.
  return len > 1e-6 ? angle * dot(axis, n) / len : 0.0;
}

/// How much of [s]'s sky a rectangle covers, weighted by the cosine —
/// `gfx-77n`.
///
/// **Exact, not fitted.** This is Lambert's own form factor for a polygon, from
/// 1760: for each edge, the angle it subtends at the shading point times how
/// much the edge's plane leans into the surface normal. Summed over the edges
/// and halved, it is the integral of `cos θ` over the polygon's projection on
/// the sphere — the quantity a punctual light approximates with a single
/// `n · l`. So there is no table to ship and nothing to fit: the usual
/// linearly-transformed-cosine approach exists to make the *specular* lobe
/// tractable, and buys nothing here.
///
/// **Clipped to the horizon first.** Lambert's sum is signed: a part of the
/// panel below the surface's horizon counts with a negative cosine and cancels
/// light from the part above it, so a panel standing on the horizon read
/// nought where half of it lights the surface. Irradiance wants the clamped
/// cosine, and for a polygon that means cutting away what lies below before
/// summing. A convex quadrilateral cut by a plane leaves one polygon with at
/// most one edge leaving the hemisphere and one entering it, so the cut is the
/// four edges trimmed where they cross plus one edge along the horizon from
/// the exit back to the entry, with no list of vertices to build.
///
/// Returns irradiance over radiance, so a surface facing a rectangle that fills
/// its whole sky gets π, the same as a uniform hemisphere. [corners] are the
/// four vertices in order, relative to the shading point.
///
/// **The rectangle emits along `cross(halfWidth, halfHeight)`**, and with the
/// corners wound as `SampleLight` winds them the sum comes out *negative* on
/// that side, so the negation below is the convention rather than a fix. It was
/// measured rather than derived: the first version returned `+total * 0.5`, and
/// against the reference integration it read nought where the answer was 0.349
/// and 1.02 where the answer was nought — the two failures a flipped winding
/// produces, and between them they name the sign with no room left to argue.
float RectangleFormFactor(vec3 corners[4], vec3 n) {
  float total = 0.0;
  vec3 exit = vec3(0.0);
  vec3 entry = vec3(0.0);
  for (int i = 0; i < 4; i++) {
    vec3 a = corners[i];
    vec3 b = corners[i == 3 ? 0 : i + 1];
    float ha = dot(a, n);
    float hb = dot(b, n);
    // Where the edge meets the horizon; used only when it crosses it, and then
    // the two heights differ in sign, so the division is safe.
    float d = ha - hb;
    vec3 q = a + (b - a) * (abs(d) > 1e-12 ? ha / d : 0.0);
    bool aAbove = ha > 0.0;
    bool bAbove = hb > 0.0;
    total += aAbove || bAbove
                 ? LambertEdge(aAbove ? a : q, bAbove ? b : q, n)
                 : 0.0;
    exit = aAbove && !bAbove ? q : exit;
    entry = !aAbove && bAbove ? q : entry;
  }
  // The horizon edge closing the cut, from where the outline left the
  // hemisphere to where it came back. Nothing when it never crossed: both are
  // still zero and a zero vector subtends nothing.
  total += LambertEdge(exit, entry, n);
  // Clamped: a surface on the panel's dark side sees the outline wound the
  // other way, and the clipped sum comes out negative. `SampleLight` tests the
  // side as well, before any of this is paid for.
  return max(-total * 0.5, 0.0);
}

/// Where on the rectangle the specular lobe is really looking — `gfx-77n`.
///
/// **The representative point, which is an approximation, unlike the diffuse
/// above.** The mirror direction leaves the surface and either hits the panel
/// or misses it; the closest point of the panel to that ray is treated as a
/// punctual light standing in for the whole rectangle. It is the standard
/// cheap answer and its one visible property is the one the row asked for: as
/// the view moves the closest point slides along the panel, so the highlight
/// is a streak with the panel's own shape and orientation rather than a dot.
///
/// What it does not do is widen the lobe by the panel's solid angle, so a
/// rough surface under a large panel is a little darker than a full integration
/// would make it. That is a known error of this method and not a bug in this
/// transcription; the fix is the fitted table this function exists to avoid.
vec3 RectangleClosestPoint(vec3 centre, vec3 halfWidth, vec3 halfHeight,
                           vec3 world, vec3 mirror) {
  vec3 n = cross(halfWidth, halfHeight);
  float nLen = length(n);
  // A panel with no area has no surface to find a point on; its centre is the
  // only answer that is not a division by zero.
  if (nLen < 1e-12) return centre;
  n /= nLen;

  vec3 toPlane = centre - world;
  float denom = dot(mirror, n);
  vec3 onPlane;
  // Parallel to the panel, or pointing away from it: the ray never lands, so
  // the nearest thing to it is the centre projected back, which keeps the
  // highlight on the panel instead of sending it to infinity.
  if (abs(denom) < 1e-5) {
    onPlane = toPlane - n * dot(toPlane, n);
  } else {
    float t = dot(toPlane, n) / denom;
    onPlane = t > 0.0 ? mirror * t : toPlane - n * dot(toPlane, n);
  }

  // Clamped into the rectangle in its own axes. Dividing by the squared length
  // turns a projection into a coordinate in units of the half-extent, so the
  // clamp is against one either way round.
  vec3 offset = onPlane - toPlane;
  float wLen2 = max(dot(halfWidth, halfWidth), 1e-12);
  float hLen2 = max(dot(halfHeight, halfHeight), 1e-12);
  float u = clamp(dot(offset, halfWidth) / wLen2, -1.0, 1.0);
  float v = clamp(dot(offset, halfHeight) / hLen2, -1.0, 1.0);
  return centre + halfWidth * u + halfHeight * v;
}

#ifdef F3D_LTC
// --- lib/ltc.glsl ---
// The GGX lobe over a rectangle light, by linearly transformed cosines — `L7`.
//
// Heitz, Dupuy, Hill and Neubelt, "Real-Time Polygonal-Light Shading with
// Linearly Transformed Cosines", ACM TOG 35(4), 2016. The fitted tables are
// `EngineTables.ltc`; see `tables/ltc.dart` for their layout and licence.
//
// A model that wants it defines `F3D_LTC` before including `surface.glsl`,
// which is what gives its stage the one sampler below. Every other model
// keeps the representative point, and no sampler.

#ifndef LTC_GLSL_
#define LTC_GLSL_

/// Both tables, 64 × 128: the inverse matrices above, the norms, Fresnel
/// terms and sphere form factors below.
uniform sampler2D ltc_texture;

/// Where `(x, y)`, each nought to one, lands in the table starting at
/// [table] (nought the upper, one the lower): on texel centres, so the ends of
/// the range read the first and last entries rather than half of the
/// neighbour.
vec2 LtcUv(float x, float y, float table) {
  vec2 inTable = vec2(x, y) * (63.0 / 64.0) + 0.5 / 64.0;
  return vec2(inTable.x, (inTable.y + table) * 0.5);
}

/// One edge's share of the vector form factor, from [a] to [b], unit
/// directions: the angle between them along the normal of their plane,
/// over 2π. Exact, with the `acos` clamped for the reason
/// `RectangleFormFactor` gives.
vec3 LtcEdge(vec3 a, vec3 b) {
  vec3 axis = cross(a, b);
  float len = length(axis);
  float angle = acos(clamp(dot(a, b), -1.0, 1.0));
  return len > 1e-6 ? axis * (angle / (len * 6.2831853)) : vec3(0.0);
}

/// The GGX lobe of roughness [roughness] seen along [v] from normal [n],
/// integrated over the rectangle with corners [corners] (relative to the
/// shading point, wound as `SampleLight` winds them), with the fitted
/// Fresnel pair for that lobe: x the integral, y the norm, z the Fresnel
/// term. The specular is `x · (f0 · y + (1 − f0) · z)`.
///
/// Clipped to the horizon by the sphere table rather than by cutting the
/// polygon: the vector form factor's length and elevation name a sphere
/// with the same, and the table holds how much of that sphere's clamped
/// cosine lies above the horizon.
///
/// Says nothing about which face of the panel the point is on: the vector
/// form factor points the same way in the world from either side, so this is
/// as bright behind the panel as in front of it. `SampleLight` tests the side
/// and leaves a point behind unlit before this is asked.
vec3 LtcRectangle(vec3 n, vec3 v, float roughness, vec3 corners[4]) {
  vec2 uv = vec2(clamp(roughness, 0.0, 1.0),
                 sqrt(clamp(1.0 - dot(n, v), 0.0, 1.0)));
  vec4 inverse = textureLod(ltc_texture, LtcUv(uv.x, uv.y, 0.0), 0.0);
  vec4 fit = textureLod(ltc_texture, LtcUv(uv.x, uv.y, 1.0), 0.0);

  // The frame the fit was made in: the normal up, the view in the xz plane.
  // A view along the normal has no plane of its own, and any will do.
  vec3 along = v - n * dot(v, n);
  float alongLength = length(along);
  vec3 t1 = alongLength > 1e-5
                ? along / alongLength
                : normalize(cross(n, abs(n.z) < 0.999 ? vec3(0.0, 0.0, 1.0)
                                                      : vec3(1.0, 0.0, 0.0)));
  vec3 t2 = cross(n, t1);
  mat3 minv = mat3(vec3(inverse.x, 0.0, inverse.y), vec3(0.0, 1.0, 0.0),
                   vec3(inverse.z, 0.0, inverse.w));

  vec3 l[4];
  for (int i = 0; i < 4; i++) {
    vec3 p = corners[i];
    l[i] = normalize(minv * vec3(dot(p, t1), dot(p, t2), dot(p, n)));
  }
  // Negated, for `RectangleFormFactor`'s reason: the panel emits along
  // `cross(halfWidth, halfHeight)`, and seen from there these corners run
  // clockwise.
  vec3 f = -(LtcEdge(l[0], l[1]) + LtcEdge(l[1], l[2]) +
             LtcEdge(l[2], l[3]) + LtcEdge(l[3], l[0]));
  float len = length(f);
  float z = len > 1e-9 ? f.z / len : 0.0;
  float sphere =
      textureLod(ltc_texture, LtcUv(z * 0.5 + 0.5, clamp(len, 0.0, 1.0), 1.0),
                 0.0)
          .w;
  return vec3(max(len * sphere, 0.0), fit.x, fit.y);
}

#endif  // LTC_GLSL_


#ifdef F3D_LAYERED
/// The corners of the rectangle [SampleLight] resolved last, relative to the
/// shading point — `M1`. The clear coat integrates its own lobe over the same
/// panel with its own normal and roughness, and those live in `pbr.glsl`,
/// after this file; the loop shades each light straight after sampling it,
/// so this is always the light being shaded.
vec3 g_rect_corners[4];
#endif  // F3D_LAYERED
#endif  // F3D_LTC

/// Resolves light [index] against the surface.
///
/// Returns `n_dot_l == 0` for anything that contributes nothing — behind the
/// surface, out of range, outside the spot cone, the dark face of a panel — so
/// a model can skip it with one test instead of repeating the classification.
LightSample SampleLight(int index, Surface s) {
  LightSample light;
  light.integrated = 0.0;
  light.ltc = vec3(0.0);

  vec4 position;
  vec4 color;
  vec4 direction;
  vec4 cone;
  if (index < kMaxLights) {
    position = frag_info.light_position[index];
    color = frag_info.light_color[index];
    direction = frag_info.light_direction[index];
    cone = frag_info.light_cone[index];
  } else {
#ifdef F3D_NO_LIGHT_LIST
    // Unreachable: `LightCount` stops at the slots without a list.
    position = vec4(0.0);
    color = vec4(0.0);
    direction = vec4(0.0);
    cone = vec4(0.0);
#else
    // A row of the light list — `gfx-74n`. Sampled at texel centres so a
    // driver's rounding cannot land a fetch on a neighbour, and the four texels
    // across the row are the same four vectors the arrays above hold.
    int slot = index - kMaxLights;
    // `L6`: from the cell rather than the draw's own tail, and a light the
    // slots already hold is skipped by its intensity, as a faded one is.
    bool clustered = Clustered();
    float listRow = clustered ? ClusterRow(slot) : LightListRow(slot);
    float v = (listRow + 0.5) * light_list_info.list.z;
    float u = light_list_info.list.y;
    // `textureLod` and not `texture`, for `shadow.glsl`'s own reason: `index`
    // reaches this branch through a function parameter, so a WGSL backend
    // cannot see that every invocation of a draw walks the same light count
    // and refuses the implicit derivative as possibly non-uniform. The atlas
    // has one level, so naming it directly changes no pixel.
    position = textureLod(light_list_texture, vec2(0.5 * u, v), 0.0);
    color = textureLod(light_list_texture, vec2(1.5 * u, v), 0.0);
    direction = textureLod(light_list_texture, vec2(2.5 * u, v), 0.0);
    cone = textureLod(light_list_texture, vec2(3.5 * u, v), 0.0);
    // The intensity and not the colour, for `LightBuffer._pack`'s own reason:
    // the same multiply here, and only one of them is a number nobody authored.
    color.w *= clustered ? (InSlots(listRow) ? 0.0 : 1.0) : LightListScale(slot);
#endif  // F3D_NO_LIGHT_LIST
  }

  float type = position.w;

  // **The rectangle leaves before `aim` is taken — `gfx-77n`.** For every other
  // kind `direction.xyz` is a unit vector saying which way the light points;
  // for this one it is an edge of the panel, with its length carrying half the
  // width, and normalising it here would quietly throw the size away.
  if (type > 2.5) {
    vec3 halfWidth = direction.xyz;
    vec3 halfHeight = cone.xyz;
    vec3 toCentre = position.xyz - v_world_position;

    vec3 corners[4];
    corners[0] = toCentre - halfWidth - halfHeight;
    corners[1] = toCentre + halfWidth - halfHeight;
    corners[2] = toCentre + halfWidth + halfHeight;
    corners[3] = toCentre - halfWidth + halfHeight;

    // **The panel emits from one face only**, and a point on the other side
    // gets nothing: the room above a ceiling panel, the outside of the wall a
    // window is set in. Tested here rather than left to the signs below,
    // because the specular's vector form factor keeps the same orientation
    // from either side of the panel, so a surface behind it facing away read
    // as lit as one in front facing it.
    bool behind = dot(toCentre, cross(halfWidth, halfHeight)) >= 0.0;

    // The cosine-weighted solid angle, which takes the place `n · l` holds for
    // a punctual light: the loop multiplies the shading by `n_dot_l`, so
    // putting the exact integral here makes the diffuse term exact rather than
    // sampled. See [RectangleFormFactor].
    float formFactor = behind ? 0.0 : RectangleFormFactor(corners, s.n);

    // Radiance rather than intensity: `intensity` means the same thing for
    // every kind of light, so a panel's is spread over its own area here.
    // Enlarging a window at a fixed rating then dims it per square metre and
    // leaves the room as bright, which is what the number is supposed to mean.
    float area = length(cross(halfWidth, halfHeight)) * 4.0;
    float radiance = area > 1e-9 ? 1.0 / area : 0.0;

    // The range window only. A punctual light needs the inverse square as
    // well; the form factor already contains it, because a panel twice as far
    // away subtends a quarter of the sky.
    float distance = length(toCentre);
    if (direction.w > 0.0) {
      float ratio = distance / direction.w;
      float window = clamp(1.0 - ratio * ratio * ratio * ratio, 0.0, 1.0);
      radiance *= window * window;
    }

    vec3 mirror = reflect(-s.v, s.n);
    vec3 representative = RectangleClosestPoint(
        position.xyz, halfWidth, halfHeight, v_world_position, mirror);
    vec3 toPoint = representative - v_world_position;
    float pointDistance = length(toPoint);
    light.l = pointDistance > 1e-6 ? toPoint / pointDistance : s.n;

    light.h = normalize(light.l + s.v);
    light.n_dot_l = formFactor;
    light.n_dot_h = max(dot(s.n, light.h), 0.0);
    light.v_dot_h = max(dot(s.v, light.h), 0.0);
    light.radiance = color.rgb * color.w * radiance;
#ifdef F3D_LTC
    // `L7`: the specular over the whole panel rather than at one point of
    // it. The diffuse keeps the exact form factor above.
    light.integrated = 1.0;
    light.ltc = LtcRectangle(s.n, s.v, s.roughness, corners);
#ifdef F3D_LAYERED
    // Kept for the clear coat's own integral; see [g_rect_corners].
    g_rect_corners = corners;
#endif
#endif
    return light;
  }

  vec3 aim = normalize(direction.xyz);
  float attenuation = 1.0;

  if (type < 0.5) {
    // Directional: no position, no falloff. The direction to the light is the
    // reverse of the direction it points.
    light.l = -aim;
  } else {
    vec3 toLight = position.xyz - v_world_position;
    float distance = length(toLight);
    // A light exactly on the surface has no direction; treat it as contributing
    // nothing rather than producing a NaN that spreads through the frame.
    if (distance < 1e-6) {
      light.l = s.n;
      light.h = s.n;
      light.radiance = vec3(0.0);
      light.n_dot_l = 0.0;
      light.n_dot_h = 0.0;
      light.v_dot_h = 0.0;
      return light;
    }
    light.l = toLight / distance;
    attenuation = PunctualAttenuation(distance, direction.w);

    if (type > 1.5) {
      // Spot: a smooth ramp between the two cone cosines. The Dart side already
      // guarantees the denominator is non-zero.
      float cosAngle = dot(aim, -light.l);
      attenuation *= clamp(
          (cosAngle - cone.y) / (cone.x - cone.y), 0.0, 1.0);
    }
  }

  light.h = normalize(light.l + s.v);
  light.n_dot_l = max(dot(s.n, light.l), 0.0);
  light.n_dot_h = max(dot(s.n, light.h), 0.0);
  light.v_dot_h = max(dot(s.v, light.h), 0.0);
  light.radiance = color.rgb * color.w * attenuation;

  return light;
}

/// How much of light [index] reaches this fragment, defined by each fragment
/// shader.
///
/// A prototype rather than a call into shadow.glsl, because the models that
/// sample no shadow map must not declare its sampler — the compiler would drop
/// the slot and leave the engine binding one that is not there. A lit model
/// returns `ShadowFactor(...)`; an unlit one returns 1.
float LightVisibility(Surface s, LightSample light, int index);

/// A model's per-light term, defined by each fragment shader.
///
/// A prototype here and the definition in the model is what lets the loop below
/// be written once. The alternative — repeating the loop in every model — is
/// six copies of the same three lines, and the place a light would go missing.
vec3 ShadeLight(Surface s, LightSample light);

/// Sums every active light's contribution.
///
/// The loop bound is the compile-time maximum with a runtime break, because GLSL
/// wants a constant trip count and the hardware wants the early exit.
// **The point-shadow half of this header, behind a guard.**
//
// A model that never shadows must not *declare* any of this, and the reason is
// the one `unlit.frag` already gives about the shadow sampler — with one
// backend's failure added to the other's. On Impeller the compiler drops what
// nothing reads, and the engine binding a slot that is no longer there is a
// native crash. On WebGL2 nothing is dropped: an active uniform block with no
// buffer under it makes every draw `INVALID_OPERATION`, discarded with nothing
// logged.
//
// That is what `lighting-unlit` was on this backend. Unlit's own metadata says
// `usesPointShadow` is false, so the engine correctly bound no `PointShadow`
// block — and the translated shader declared one anyway, so the sphere was
// never drawn and the frame came back the clear colour.
#ifndef F3D_NO_POINT_SHADOW

/// The cube atlas: three tiles across, two down, each a ninety-degree view
/// from a point light, each storing radial distance normalised by range.
uniform sampler2D point_shadow_texture;

/// The same atlas for the things that never move, rendered once at load.
///
/// Two maps rather than one because a dungeon's walls can be baked and a
/// spinning pickup cannot, and there is no way to draw into part of a texture
/// without redrawing the rest of it. Sampling both and keeping the nearer
/// occluder costs one extra read and saves six views of the level every frame.
uniform sampler2D point_shadow_static_texture;

/// How many lights may have a row of the atlas. Six tiles across each.
// Rows of the cube atlas: six faces across, this many lights down. Must
// match `Renderer.kShadowedLights`, which is where the reasoning lives, and
// `shadowSlots` in the software backend's transcription of this file.
const int kShadowSlots = 6;

layout(std140) uniform PointShadow {
  /// The same view-projections the atlas was rendered with, six per slot.
  ///
  /// Passed rather than reconstructed. Deriving cube face coordinates here
  /// would be a second implementation of a decision the renderer already made,
  /// and the two would disagree about handedness or up vectors on some face
  /// and nowhere else — which shows as one face of every shadow being wrong.
  mat4 faces[6 * kShadowSlots];

  /// Per slot. xyz: the light's world position. w: its range.
  vec4 lights[kShadowSlots];

  /// Per light, in the order the lighting knows them.
  ///
  /// x: the atlas row it owns, or negative when it has none — a fifth torch in
  /// a room lands there. z: the tangent of half the frustum's opening angle,
  /// which is what converts a world width into a fraction of a tile. y and w
  /// are unwritten.
  ///
  /// **z is exactly one for a point light**, because a cube face is a ninety
  /// degree frustum and `tan(45°) == 1`. That is not a convention chosen to be
  /// tidy: it is what lets a narrower frustum share this whole path, since
  /// multiplying by one in IEEE 754 changes no bit of the result. Whatever else
  /// a spot light will need, it does not need a second copy of the filter.
  vec4 slots[kMaxLights];

  /// x: half a texel, in tile-local uv. y: distance bias in metres.
  /// z: strength. w: normal offset, **in texels of the face it lands on**.
  vec4 params;

  /// x: smallest kernel radius in tile-local uv, and the fixed radius used
  /// when contact hardening is off. y: the light's own radius in metres; zero
  /// turns contact hardening off. z: largest kernel radius in tile-local uv.
  /// w: non-zero paints the penumbra estimate into the surface buffer instead
  /// of shading with it.
  vec4 params2;

  /// x: non-zero when this backend stores the atlas bottom-up. y: one over the
  /// edge length of a tile in texels, which is what turns a distance into the
  /// world width of one texel there.
  ///
  /// **Appended after everything else on purpose**, the same way FragInfo's
  /// ambient pair was: std140 lays a block out in declaration order, so adding
  /// here leaves every offset above unchanged and the three backends do not
  /// have to agree about anything they already agreed about. y, z and w are
  /// unwritten.
  vec4 params3;
}
point_shadow;

/// Eight points on a Poisson disk, a common set for filtering cascaded
/// shadows.
///
/// A disk rather than a grid because a grid of taps on a straight shadow edge
/// lands every sample on the same side at once, and the edge steps between
/// kernel widths instead of sliding. Eight rather than sixteen because every
/// tap here reads **two** atlases — the static walls and the movers — so the
/// cost is doubled before it is counted.
vec2 PointShadowDiskTap(int i) {
  if (i == 0) return vec2(-0.94201624, -0.39906216);
  if (i == 1) return vec2(0.94558609, -0.76890725);
  if (i == 2) return vec2(-0.09418410, -0.92938870);
  if (i == 3) return vec2(0.34495938, 0.29387760);
  if (i == 4) return vec2(-0.91588581, 0.45771432);
  if (i == 5) return vec2(-0.81544232, -0.87912464);
  if (i == 6) return vec2(-0.38277543, 0.27676845);
  return vec2(0.97484398, 0.75648379);
}

/// One comparison against the atlas, at [uv] offset within the tile.
///
/// The clamp is applied **after** the offset, not before, and that is the whole
/// reason a kernel can be widened here without touching anything else: each tap
/// is held inside its own tile individually. Clamping the centre and then
/// offsetting would let the outer taps walk straight out of the tile and read a
/// distance measured from a different face, or a different light.
float PointShadowDistance(vec2 uv, vec2 offset, vec2 tile, float range) {
  float inset = point_shadow.params.x;
  vec2 local = clamp(uv + offset, inset, 1.0 - inset);
  vec2 atlas = (local + tile) * vec2(1.0 / 6.0, 1.0 / float(kShadowSlots));
  // **The whole atlas, turned over, where row zero of a render target is at the
  // bottom.** Both halves of the address are wrong there and this is the one
  // place that fixes both: the tile the light owns — a light in slot zero is
  // drawn into the row the shader would call three, because the viewport
  // rectangle is flipped to land it — and the picture inside that tile, which
  // was drawn through a projection built for the other origin.
  //
  // Every check of this atlas missed it for the same reason: the debug view
  // composites the texture through a full-screen pass, which turns it over
  // again and puts the row back. The atlas compared equal on both backends
  // across six scenes while the lit pass, which samples it directly and has no
  // such pass to cancel, read a row that had never been drawn into and found
  // nothing in the way of anything.
  if (point_shadow.params3.x > 0.5) atlas.y = 1.0 - atlas.y;
  // Whichever is nearer occludes: a wall in front of a monster shadows, and so
  // does a monster in front of a wall.
  //
  // **`textureLod` at level zero, because every caller of this function stands
  // behind a branch.** The light loop skips a light the surface faces away
  // from, the blocker search `continue`s past a tap that found nothing, and the
  // slot test returns before any of it — so the invocations of a quad do not
  // arrive here together, and a WGSL backend refuses a sample whose implicit
  // derivative would be read where they disagree. Both atlases are distance
  // render targets with one level, so level zero is the level `texture` was
  // choosing anyway; this names it rather than deriving it, and the picture is
  // the same on every backend.
  return min(textureLod(point_shadow_texture, atlas, 0.0).r,
             textureLod(point_shadow_static_texture, atlas, 0.0).r) * range;
}

float PointShadowTap(vec2 uv, vec2 offset, vec2 tile, float range,
                     float receiver) {
  float stored = PointShadowDistance(uv, offset, tile, range);
  // Nothing was drawn in that direction by either, so nothing is in the way.
  if (stored >= range * 0.999) return 1.0;
  return receiver > stored ? 0.0 : 1.0;
}

/// The disk point for tap [i], rotated by [ca]/[sa] and scaled to [radius].
vec2 PointShadowOffset(int i, float ca, float sa, float radius) {
  vec2 p = PointShadowDiskTap(i);
  return vec2(p.x * ca - p.y * sa, p.x * sa + p.y * ca) * radius;
}

/// How wide the penumbra should be here, in tile-local uv.
///
/// Contact hardening, and the reason a fixed kernel looks wrong: a shadow is
/// sharp where its caster touches the floor and soft a metre away, and one
/// radius for both makes the contact mushy or the distant edge hard.
///
/// The similar-triangles estimate is the standard one — a light of radius `L`
/// with a blocker at `b` and a receiver at `r` throws a penumbra `L * (r - b) /
/// b` wide at the receiver. Converting that to tile uv is exact rather than
/// tuned, because a face is a ninety degree frustum: at distance `r` from the
/// light the face spans `2 * r` in world units across the full `0..1` of uv,
/// so a world width `w` is `w / (2 * r)` of a tile.
///
/// The blocker search runs at the **widest** penumbra allowed, since a blocker
/// outside that circle cannot widen the result anyway, and searching narrower
/// would miss the very blockers that make an edge soft.
///
/// [tanHalf] is where the ninety degrees stop being assumed. The span above is
/// `2 * r` only for a right-angled frustum; in general it is `2 * r * tan(θ/2)`,
/// and for a cube face that factor is one. A narrower frustum covers less world
/// per tile, so the same world width is a *larger* fraction of it — which is
/// why this divides rather than multiplies, and why getting it upside down
/// would make a tight cone's shadows harden instead of soften.
float PointShadowPenumbra(vec2 uv, vec2 tile, float range, float receiver,
                          float ca, float sa, float tanHalf,
                          out float blockerOut) {
  blockerOut = -1.0;
  float lightRadius = point_shadow.params2.y;
  float minRadius = point_shadow.params2.x;
  float maxRadius = point_shadow.params2.z;
  if (lightRadius <= 0.0) {
    // **The debug channel is filled even though the search is skipped**, and
    // leaving it unfilled cost a session. `blockerOut` starts at −1 to mean
    // "nothing was measured"; the debug encoding clamps it into a colour, where
    // −1 becomes zero — the same green as a blocker touching the surface, which
    // reads as the most alarming answer available. A whole theory was built on
    // that zero, and the search it described had never run.
    //
    // The centre tap is what the filter below would use anyway, so this reports
    // a distance the atlas really returned rather than a sentinel.
    blockerOut = PointShadowDistance(uv, vec2(0.0), tile, range);
    return minRadius;
  }


  float sum = 0.0;
  float count = 0.0;
  for (int i = 0; i < 8; i++) {
    float stored =
        PointShadowDistance(uv, PointShadowOffset(i, ca, sa, maxRadius), tile,
                            range);
    if (stored >= range * 0.999) continue;
    if (stored >= receiver) continue;
    sum += stored;
    count += 1.0;
  }
  // Nothing in front of this fragment anywhere in the search: fully lit, and
  // the caller can skip the filter entirely.
  if (count < 0.5) return -1.0;

  float blocker = max(sum / count, 1e-4);
  blockerOut = blocker;
  float world = lightRadius * max(receiver - blocker, 0.0) / blocker;
  return clamp(world / (2.0 * receiver * tanHalf), minRadius, maxRadius);
}

/// How lit [world] is by the point light that owns the cube atlas.
///
/// One, fully lit, when this is not that light or the atlas has nothing to say.
float PointShadowFactor(vec3 world, vec3 normal, int lightIndex) {
  int slot = int(point_shadow.slots[lightIndex].x + 0.5);
  if (point_shadow.slots[lightIndex].x < 0.0) return 1.0;
  float strength = point_shadow.params.z;
  if (strength <= 0.0) return 1.0;

  // Offset along the normal before measuring, and scaled by how steeply the
  // surface leans away from the light.
  //
  // A soft kernel on a tilted surface straddles a depth gradient: the taps at
  // one end of the disk are further from the light than the fragment itself,
  // so a flat offset that clears the surface head-on leaves acne at a grazing
  // angle. The slope term lifts the whole kernel clear instead, and is capped
  // because it runs away as the surface turns edge-on to the light — an
  // uncapped lift detaches the shadow from its caster.
  vec3 toLight = point_shadow.lights[slot].xyz - world;
  float toLightLength = max(length(toLight), 1e-6);
  float nDotL = max(dot(normal, toLight / toLightLength), 0.15);
  float slope = min(sqrt(max(1.0 - nDotL * nDotL, 0.0)) / (nDotL * nDotL), 8.0);

  // **How wide one texel of the face is, out where this fragment is.** The
  // error a normal offset exists to clear is exactly that: a texel of the
  // shadow map covers a patch of surface, the whole patch is recorded at one
  // distance, and a fragment anywhere else in it compares against a distance
  // measured somewhere it is not. That patch grows with range — it is a solid
  // angle, not a length — so an offset fixed in metres is right at one distance
  // and wrong everywhere else.
  //
  // What it was: `params.w` metres, flat. On the golden teapot, at 9.6 m from
  // the lamp, a texel is 3.7 cm and the flat offset was 2 cm, so the floor
  // shadowed itself across everything the light reached — and the acne stopped
  // dead at the *projection of the floor's own edge*, because past it the atlas
  // holds nothing and nothing can occlude. A straight line across a shadow with
  // no straight edge anywhere in the scene.
  float texel =
      2.0 * toLightLength * max(point_shadow.slots[lightIndex].z, 1e-4) *
      point_shadow.params3.y;
  // Both terms are metres. The slope term used to be the kernel radius, which
  // is a fraction of a tile — a unit mismatch carried over from an estimate
  // where a softness radius genuinely was the right quantity. Here it meant
  // widening the kernel also lifted the sample off the surface, by up to ten
  // centimetres at the wider settings, so the softening and the lift
  // cancelled: tripling the kernel moved 184 pixels of the frame,
  // where the kernel alone moves thousands. It is what made contact hardening
  // look inert, and it was hiding in a comparison rather than in the estimate.
  vec3 origin = world + normal * texel * point_shadow.params.w * (1.0 + slope);
  vec3 toFragment = origin - point_shadow.lights[slot].xyz;
  float distance = length(toFragment);
  float range = max(point_shadow.lights[slot].w, 1e-4);
  if (distance >= range) return 1.0;

  // The dominant axis picks the face, in the order the renderer wrote them:
  // +X, -X, +Y, -Y, +Z, -Z, left to right then top to bottom.
  //
  // A spot has one column and no choice to make. Asking the dominant axis
  // anyway would be worse than pointless: a fragment below and to the side of
  // a downlight has −Y dominant, which is column 3, and column 3 of a spot's
  // row is deliberately blank — so the whole cone would read as unshadowed
  // except for the wedge where the aim happens to be the dominant axis.
  int face = 0;
  if (point_shadow.slots[lightIndex].y < 0.5) {
    vec3 a = abs(toFragment);
    if (a.x >= a.y && a.x >= a.z) {
      face = toFragment.x > 0.0 ? 0 : 1;
    } else if (a.y >= a.z) {
      face = toFragment.y > 0.0 ? 2 : 3;
    } else {
      face = toFragment.z > 0.0 ? 4 : 5;
    }
  }

  vec4 clip = point_shadow.faces[slot * 6 + face] * vec4(origin, 1.0);
  if (clip.w <= 0.0) return 1.0;
  vec2 ndc = clip.xy / clip.w;
  if (abs(ndc.x) > 1.0 || abs(ndc.y) > 1.0) return 1.0;

  // v is flipped, the same way the directional map does it: the texture's
  // origin is at the top, where row zero of the render target is. Getting this
  // wrong does not tilt the shadow — it makes the top row of faces read the
  // bottom row, so a whole region compares against an unrelated distance and
  // comes out as a black slab.
  vec2 uv = vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
  // The face across, the light down: six tiles wide, four tall.
  vec2 tile = vec2(float(face), float(slot));

  float receiver = distance - point_shadow.params.y;

  // One rotation, shared by the blocker search and the filter. Per fragment,
  // so eight samples read as a soft edge rather than as eight copies of the
  // silhouette: without it every fragment along an edge tests the same eight
  // directions and the pattern shows.
  //
  // **The three constants are not arbitrary and are not ours.** This is Jorge
  // Jimenez's interleaved gradient noise, from "Next Generation Post
  // Processing in Call of Duty: Advanced Warfare" (SIGGRAPH 2014):
  //
  //   IGN(x, y) = frac(52.9829189 * frac(0.06711056 * x + 0.00583715 * y))
  //
  // The pair inside the dot is a direction whose gradient walks the unit
  // interval as slowly as it can while never repeating over a screen, and the
  // multiplier outside stretches that walk so neighbouring pixels land far
  // apart in the result. What it buys over a hash is the cost: one dot and two
  // fracts, no integer arithmetic, no texture. What a blue-noise texture buys
  // over it is a better spectrum, at a sampler and a fetch — worth it for
  // dithering a whole frame, not for rotating eight taps.
  //
  // Written down because three unexplained decimals read as a magic spell, and
  // the next person to touch this line has no way to tell which of them may be
  // changed. The answer is none of them.
  float noise = fract(52.9829189 * fract(dot(FragCoordFromTop(
                                                frag_info.target_origin.x),
                                            vec2(0.06711056, 0.00583715))));
  float angle = noise * 6.28318530718;
  float ca = cos(angle);
  float sa = sin(angle);

  // Guarded rather than read straight, because a zero here divides by zero and
  // a NaN radius poisons the filter into a black fragment. Zero is what an
  // unwritten channel holds, and "unwritten" is a state this block has been in
  // before: every slot is cleared to −1 each frame.
  float tanHalf = max(point_shadow.slots[lightIndex].z, 1e-4);

  float blocker = -1.0;
  float radius =
      PointShadowPenumbra(uv, tile, range, receiver, ca, sa, tanHalf, blocker);

  // The debug channel, and the reason it exists: two explanations for why the
  // estimate collapses were argued from the finished picture and both were
  // wrong, because the number that decides it never leaves this function.
  //
  // Red is how wide the penumbra came out, against the widest allowed. Green
  // is how far away the blocker was, against the light's range. Blue marks
  // the fragments where the search found nothing at all — which is a different
  // answer from "found something very close", and telling those two apart is
  // most of the question.
  if (point_shadow.params2.w > 0.5) {
    g_debug_surface_on = true;
    g_debug_surface = radius < 0.0
        ? vec3(0.0, 0.0, 1.0)
        : vec3(clamp(radius / max(point_shadow.params2.z, 1e-6), 0.0, 1.0),
               clamp(blocker / range, 0.0, 1.0), 0.0);
  }

  // The search found nothing between here and the light.
  if (radius < 0.0) return 1.0;

  float lit = PointShadowTap(uv, vec2(0.0), tile, range, receiver);
  if (radius > 0.0) {
    for (int i = 0; i < 8; i++) {
      lit += PointShadowTap(uv, PointShadowOffset(i, ca, sa, radius), tile,
                            range, receiver);
    }
    lit *= 1.0 / 9.0;
  }

  // Strength lerps towards fully lit, so the control is "how dark", not "how
  // much of the kernel" — the same convention the directional map uses.
  return mix(1.0, lit, clamp(strength, 0.0, 1.0));
}

#else

/// The stand-in for a model that declares none of the above.
///
/// Fully lit, which is what a model with no shadow term means, and a constant
/// the compiler folds rather than a branch anything pays for.
float PointShadowFactor(vec3 world, vec3 normal, int lightIndex) {
  return 1.0;
}

#endif  // F3D_NO_POINT_SHADOW

vec3 AccumulateLights(Surface s) {
  vec3 total = vec3(0.0);
  int count = LightCount();

  for (int i = 0; i < kTotalLights; i++) {
    if (i >= count) break;
    LightSample light = SampleLight(i, s);
    if (light.n_dot_l <= 0.0) continue;
    // A light from the list has no shadow row to read — see `LightHasShadow`.
    // A branch rather than something folded into the two calls, because both
    // index tables eight entries wide and the ninth light would read past them
    // rather than read a one.
    float visibility = LightHasShadow(i)
        ? LightVisibility(s, light, i) *
              PointShadowFactor(v_world_position, s.n, i)
        : 1.0;
    if (visibility <= 0.0) continue;
    total += ShadeLight(s, light) * light.radiance * light.n_dot_l * visibility;
  }

  return total;
}

#endif  // SURFACE_GLSL_


/// Linear depth from the light's point of view, in the red channel — or,
/// with the `evsm` filter (`S2`), the blurred moments `evsm_filter.frag`
/// made of it, bound to the same slot so the lit stages spend no sampler on
/// the choice.
uniform sampler2D shadow_texture;

/// Point [i] of [n] on a Vogel disc turned by [turn] radians — `S3`: the
/// golden angle between neighbours, so any prefix of the points covers the
/// disc evenly, and a radius growing with the square root, so they cover it
/// at an even density.
vec2 VogelDisc(int i, int n, float turn) {
  float r = sqrt((float(i) + 0.5) / float(n));
  float theta = float(i) * 2.3999632 + turn;
  return r * vec2(cos(theta), sin(theta));
}

/// Interleaved gradient noise at this pixel, in [0, 1), stepped on by the
/// frame's slice while a temporal resolve runs (`target_origin.w`) so the
/// history averages the rotations. The pattern needs no texture, which keeps
/// the lit stages at the samplers they have. Rows are counted from the top
/// (`target_origin.x`), as the point shadow's rotation counts them, so WebGL2
/// turns the kernel on the same pixels as every other backend.
float ShadowNoise() {
  vec2 at = FragCoordFromTop(frag_info.target_origin.x) +
            5.588238 * max(frag_info.target_origin.w, 0.0);
  return fract(52.9829189 * fract(dot(at, vec2(0.06711056, 0.00583715))));
}

/// How much of the light survives at this fragment, from 0 to 1.
///
/// Returns 1 when shadows are off, when the fragment falls outside the map, or
/// when the light in question is not the caster — a fragment beyond the shadow
/// volume is unshadowed, not black, and getting that wrong puts a hard edge
/// across the scene at the edge of the map.
float ShadowFactor(Surface s, LightSample light, int lightIndex) {
  float strength = frag_info.shadow_params.w;
  if (strength <= 0.0) return 1.0;
  if (lightIndex != int(frag_info.frame_params.z + 0.5)) return 1.0;

  // Normal offset: move the sample point along the surface normal before
  // projecting it. It costs nothing and fixes the shadow acne that a depth bias
  // alone cannot, because the error is proportional to the surface's slope
  // relative to the light rather than to depth.
  //
  // **A flat distance plus what the kernel's reach needs, and no more.** The
  // flat part alone was tuned for surfaces the map never recorded: with the
  // default `casterFaces: back` a closed mesh writes only the faces turned
  // away from the sun, so a lit face compares against its own far side. A
  // double-sided material writes its lit faces too, and then the offset has
  // to lift the point clear of its own plane as far out as the 3×3 kernel
  // reads: a tap one texel over lands in a texel whose centre is up to a
  // texel and a half away, where the plane is 1.5·texel·tanθ nearer the
  // light. A step d along the normal clears the plane by d / cosθ along the
  // ray, so d = 1.5·texel·sinθ is exactly enough, taken per axis of the map
  // because a slope running diagonally across it reaches further in texels.
  // Nothing at normal incidence, a texel and a half at grazing. The depth
  // bias covers the rest. Every metre more than this moves the shadow away
  // from its caster, and in the far cascade a texel is decimetres. Measured
  // per cascade in the loop below, since each has a texel of its own.

  // Which cascade covers this fragment.
  //
  // Chosen by distance from the camera and then *checked*, because the volumes
  // are spheres on the line of sight rather than fitted frusta: a fragment at
  // the edge of the view can be past the end of the cascade its distance
  // suggests. Falling through to the next one costs a branch and removes a
  // whole class of missing-shadow bug, and the last cascade is fitted to the
  // entire scene, so the fall-through always terminates somewhere real.
  int cascadeCount = int(frag_info.shadow_cascades.z + 0.5);
  float viewDistance = length(v_world_position - frag_info.camera_position.xyz);
  int cascade = 0;
  if (cascadeCount > 1 && viewDistance > frag_info.shadow_cascades.x) cascade = 1;
  if (cascadeCount > 2 && viewDistance > frag_info.shadow_cascades.y) cascade = 2;

  vec2 uv = vec2(0.0);
  vec3 projected = vec3(0.0);
  bool found = false;
  // `S3`: what the soft path needs of the cascade it lands in — metres per
  // texel across, and metres per unit of stored depth along the light.
  float cascadeTexel = 1.0;
  float cascadeDepth = 1.0;
  for (int attempt = 0; attempt < 3; attempt++) {
    int which = cascade + attempt;
    if (which >= cascadeCount) break;

    mat4 matrix = which == 0
        ? frag_info.shadow_matrix
        : (which == 1 ? frag_info.shadow_matrix_far
                      : frag_info.shadow_matrix_farthest);
    // One texel of this cascade in metres. The projection is orthographic,
    // so its first row is 2 / width, and a tile texel is `shadow_cascades.w`
    // of the width. The rows are also the map's axes in the world, which is
    // what the normal is measured along: its share across each axis is the
    // sine of the slope in that direction.
    vec3 axisX = vec3(matrix[0][0], matrix[1][0], matrix[2][0]);
    vec3 axisY = vec3(matrix[0][1], matrix[1][1], matrix[2][1]);
    float rowX = max(length(axisX), 1e-6);
    float rowY = max(length(axisY), 1e-6);
    float texelMetres = 2.0 * frag_info.shadow_cascades.w / rowX;
    float reach = 1.5 * 2.0 * frag_info.shadow_cascades.w *
        (abs(dot(s.n, axisX)) / (rowX * rowX) +
         abs(dot(s.n, axisY)) / (rowY * rowY));
    vec3 origin = v_world_position + s.n * (frag_info.shadow_params.z + reach);
    vec4 lightSpace = matrix * vec4(origin, 1.0);
    if (lightSpace.w <= 0.0) continue;
    vec3 candidate = lightSpace.xyz / lightSpace.w;

    // Clip space x and y are in [-1, 1]; a tile is in [0, 1] with the origin at
    // the top, matching where the render target's row zero is.
    vec2 inTile = vec2(candidate.x * 0.5 + 0.5, 0.5 - candidate.y * 0.5);
    if (inTile.x < 0.0 || inTile.x > 1.0 || inTile.y < 0.0 || inTile.y > 1.0) {
      continue;
    }
    // Depth is already in [0, 1] here, as every projection in this engine
    // produces. **Past the far plane is behind every caster, not outside the
    // map.** The last cascade's depth is fitted to the casters alone, so a
    // floor that runs on past them — the tip of a long evening shadow — sits
    // beyond it. Skipping that point called it lit and cut the shadow off
    // along the line where the far plane meets the floor. A nearer cascade
    // may still be missing casters and hands the point on; the last one
    // clamps, and 1.0 compares lit only against a texel nothing was drawn in.
    if (candidate.z > 1.0) {
      if (which < cascadeCount - 1) continue;
      candidate.z = 1.0;
    }

    // Into the atlas: the cascades sit side by side in one texture.
    uv = vec2((inTile.x + float(which)) / float(cascadeCount), inTile.y);
    projected = candidate;
    cascade = which;
    cascadeTexel = texelMetres;
    cascadeDepth =
        1.0 / max(length(vec3(matrix[0][2], matrix[1][2], matrix[2][2])), 1e-6);
    found = true;
    break;
  }
  if (!found) return 1.0;

  float bias = cascade == 0
      ? frag_info.shadow_bias.x
      : (cascade == 1 ? frag_info.shadow_bias.y : frag_info.shadow_bias.z);
  // Horizontally a texel of the atlas, vertically a texel of a tile. With one
  // cascade they are the same number and this is the kernel it has always been.
  vec2 texel = vec2(frag_info.shadow_params.x, frag_info.shadow_cascades.w);

  // **Every tap is held inside its own cascade's tile**, half a texel in from
  // the edge, and after the offset rather than before: the cube atlas learned
  // this first (`PointShadowDistance`). The cascades sit side by side, so a
  // tap that stepped past a seam read the neighbouring cascade's depth,
  // measured through another projection, and a fragment at the edge of the
  // near tile took its shadow partly from the far one. With one cascade the
  // tile is the whole texture and the clamp is the sampler's own edge.
  vec2 tileLo = vec2(float(cascade) / float(cascadeCount), 0.0) + 0.5 * texel;
  vec2 tileHi =
      vec2(float(cascade + 1) / float(cascadeCount), 1.0) - 0.5 * texel;

  // **`textureLod` and not `texture`, and the level asked for is the only one
  // there is.** Everything above this loop is a reason not to be here — the
  // cascade search returns early when no cascade contains the fragment, and the
  // light loop that calls it skips a light facing away — so a WGSL backend sees
  // a sample taken where the four invocations of a quad need not agree, and
  // refuses it: the implicit derivative `texture` asks for is only defined
  // where they all arrive. The cascade atlas is a depth render target with a
  // single level, so the derivative was never doing anything but selecting
  // level zero, and naming that level directly costs nothing and changes no
  // pixel on any backend.
  //
  // **The softness, where it rides, and what zero means.**
  //
  // `ambient_ground.w` is the directional light's apparent size. It has
  // nothing to do with ambient light and everything to do with this being the
  // one component left unspent in a block six shaders share: `frame_params.w`
  // was the slot reserved for exactly this and the environment's level count
  // took it, and appending to the block moves offsets four backends have
  // agreed on. The alternative was a second uniform block bound per draw for
  // one float. Named here because a reader arriving at `ambient_ground` has
  // every right to be surprised.
  //
  // Zero is the 3×3 kernel this has always had, which is what keeps every
  // recorded golden where it is. Above zero the edge widens with the distance
  // between the occluder and what it falls on — what a real light does, and
  // what no fixed kernel can.
  //
  // **Below zero is the `evsm` filter** (`S2`), and the texture bound here is
  // then the moments atlas rather than depth: one filtered tap replaces the
  // kernel, and how far under minus one the value sits is the light-bleeding
  // cut. A sign rather than another uniform, for the reason the softness
  // itself rides here.
  float softness = frag_info.ambient_ground.w;
  float lit = 0.0;
  if (softness < 0.0) {
    // The blur already happened, once for the whole atlas, so the one tap
    // is the filter: the sampler's own bilinear step is all it adds.
    vec4 moments = textureLod(shadow_texture, clamp(uv, tileLo, tileHi), 0.0);
    lit = EvsmVisibility(moments, projected.z - bias,
                         clamp(-softness - 1.0, 0.0, 0.95));
  } else if (softness <= 0.0) {
    // PCF 3x3. Four samples would band visibly at this map size and nine is
    // the smallest kernel that reads as a soft edge rather than as stair
    // steps.
    for (int y = -1; y <= 1; y++) {
      for (int x = -1; x <= 1; x++) {
        float occluder = textureLod(
            shadow_texture,
            clamp(uv + vec2(float(x), float(y)) * texel, tileLo, tileHi),
            0.0).r;
        lit += projected.z - bias > occluder ? 0.0 : 1.0;
      }
    }
    lit *= 1.0 / 9.0;
  } else {
    // **Find what is casting before deciding how wide to blur**, then blur by
    // what a light of this size would leave — `S3`. Sixteen taps each way on
    // a Vogel disc turned per pixel, where there were five fixed ones: the
    // turn trades the five's regular pattern for noise the eye reads as
    // grain, and a temporal resolve averages away.
    //
    // **In metres, per cascade.** The gap between the blocker and this
    // fragment is measured in the cascade's stored depth, whose unit is a
    // different length in each cascade; converted to metres, the penumbra is
    // the gap times the light's apparent diameter, and in texels it is that
    // over the cascade's own texel. A shadow keeps its softness crossing
    // from one cascade into the next.
    //
    // **A radius, so half that width.** A disc of radius R swept across an
    // edge ramps from dark to lit over 2R, so the kernel is the gap times
    // the tangent of the light's angular *radius*: the penumbra comes out the
    // full `2·tan(α)·gap` the settings promise, not twice it. The search is
    // the same cone, `tan(α)` of the way back to the light; a wider one only
    // pulls in blockers that cannot reach this fragment.
    float spread = tan(min(softness, 0.5));
    float turn = ShadowNoise() * 6.2831853;

    // As wide as the widest penumbra could be at this depth, and no wider:
    // the whole of the distance back to the light is the largest gap there
    // is.
    float searchRadius =
        clamp(spread * projected.z * cascadeDepth / cascadeTexel, 1.0, 16.0);
    float blockerSum = 0.0;
    float blockerCount = 0.0;
    for (int i = 0; i < 16; i++) {
      float occluder = textureLod(
          shadow_texture,
          clamp(uv + VogelDisc(i, 16, turn) * texel * searchRadius, tileLo,
                tileHi),
          0.0).r;
      if (projected.z - bias > occluder) {
        blockerSum += occluder;
        blockerCount += 1.0;
      }
    }
    // Nothing between this fragment and the light: lit, and no second loop.
    if (blockerCount <= 0.0) return 1.0;

    float gap = max(projected.z - blockerSum / blockerCount, 0.0) * cascadeDepth;
    // One texel at the tightest, so a contact edge stays an edge; the cap
    // keeps a distant occluder from reaching across a whole cascade.
    float radius = clamp(spread * gap / cascadeTexel, 1.0, 16.0);

    for (int i = 0; i < 16; i++) {
      float occluder = textureLod(
          shadow_texture,
          clamp(uv + VogelDisc(i, 16, turn + 1.0) * texel * radius, tileLo,
                tileHi),
          0.0).r;
      lit += projected.z - bias > occluder ? 0.0 : 1.0;
    }
    lit *= 1.0 / 16.0;
  }

  // Strength lerps towards fully lit, so the control is "how dark", not "how
  // much of the kernel".
  return mix(1.0, lit, clamp(strength, 0.0, 1.0));
}

#endif  // SHADOW_GLSL_


// The normal map's slot, declared here rather than through
// `material_maps.glsl`: that header brings four more maps and the irradiance
// field with it, none of which a card reads, and a sampler declared and
// dropped is a reflected slot Metal has no index for.
uniform sampler2D normal_texture;

float LightVisibility(Surface s, LightSample light, int index) {
  return ShadowFactor(s, light, index);
}

vec3 ShadeLight(Surface s, LightSample light) {
  return s.albedo;
}

/// View `cell` of the grid, read at [offset] — this fragment's point on the
/// card, in the node's own space and in radii — as that view saw it.
/// Returns (u, v) in the atlas, or a point outside [0, 1] when the view did
/// not frame this point at all.
vec2 ImpostorViewUv(vec2 cell, vec3 offset) {
  vec3 d = ImpostorDecode(cell / (kImpostorGrid - 1.0));
  vec3 right = ImpostorRight(d);
  vec3 up = cross(d, right);
  vec2 local = vec2(dot(offset, right) * 0.5 + 0.5,
                    0.5 - dot(offset, up) * 0.5);
  return (cell + clamp(local, vec2(0.0), vec2(1.0))) / kImpostorGrid;
}

void main() {
  Surface s = ReadSurface();

  // The eye's direction in the node's own space picks the views; the card's
  // own frame there turns the fragment into a point every view can place.
  vec3 d = normalize(v_color.xyz);
  vec3 right = ImpostorRight(d);
  vec3 up = cross(d, right);
  vec3 offset = right * (v_texcoord.x * 2.0 - 1.0) +
                up * (1.0 - v_texcoord.y * 2.0);

  // Which triangle of the grid, and the weights of its corners. Selects
  // rather than branches, so all three reads below sit in uniform control
  // flow — WGSL refuses an implicit-derivative read anywhere else.
  vec2 g = ImpostorEncode(d) * (kImpostorGrid - 1.0);
  vec2 base = clamp(floor(g), vec2(0.0), vec2(kImpostorGrid - 2.0));
  vec2 f = g - base;
  bool lower = f.x + f.y < 1.0;
  vec2 c0 = lower ? base : base + vec2(1.0, 1.0);
  vec2 c1 = base + vec2(1.0, 0.0);
  vec2 c2 = base + vec2(0.0, 1.0);
  vec3 w = lower ? vec3(1.0 - f.x - f.y, f.x, f.y)
                 : vec3(f.x + f.y - 1.0, 1.0 - f.y, 1.0 - f.x);

  vec2 uv0 = ImpostorViewUv(c0, offset);
  vec2 uv1 = ImpostorViewUv(c1, offset);
  vec2 uv2 = ImpostorViewUv(c2, offset);
  vec4 a0 = textureLod(base_color_texture, uv0, 0.0);
  vec4 a1 = textureLod(base_color_texture, uv1, 0.0);
  vec4 a2 = textureLod(base_color_texture, uv2, 0.0);
  vec4 n0 = textureLod(normal_texture, uv0, 0.0);
  vec4 n1 = textureLod(normal_texture, uv1, 0.0);
  vec4 n2 = textureLod(normal_texture, uv2, 0.0);

  // Weighted by coverage as well, so a view that saw sky here lends neither
  // its colour nor its normal — only its absence, through the alpha.
  vec3 wa = w * vec3(a0.a, a1.a, a2.a);
  float alpha = wa.x + wa.y + wa.z;
  if (alpha < 0.5) discard;
  vec3 srgb = (a0.rgb * wa.x + a1.rgb * wa.y + a2.rgb * wa.z) / alpha;
  vec3 local = (n0.rgb * 2.0 - vec3(1.0)) * wa.x +
               (n1.rgb * 2.0 - vec3(1.0)) * wa.y +
               (n2.rgb * 2.0 - vec3(1.0)) * wa.z;
  local = normalize(dot(local, local) > 1e-12 ? local : d);

  vec3 worldRight = normalize(v_tangent.xyz);
  vec3 worldFacing = normalize(v_normal);
  vec3 worldUp = cross(worldFacing, worldRight);
  s.n = normalize(worldRight * dot(local, right) + worldUp * dot(local, up) +
                  worldFacing * dot(local, d));
  s.n_dot_v = max(dot(s.n, s.v), 1e-4);
  s.albedo = SrgbToLinear(srgb) * SrgbToLinear(frag_info.base_color.rgb);
  s.alpha = 1.0;
  g_albedo = s.albedo;
  s.ambient = mix(frag_info.ambient_ground.rgb, frag_info.ambient_sky.rgb,
                  s.n.y * 0.5 + 0.5) *
              frag_info.material.z;

  vec3 ambient = s.albedo * s.ambient;
  WriteSurface(AccumulateLights(s) + ambient, 1.0, 1.0);
}

''',
  },
);