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;

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;

void main() {
  vec4 world = frame_info.model * vec4(position, 1.0);
  v_world_position = world.xyz;
  v_normal = mat3(frame_info.normal_matrix) * 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.
  v_tangent = vec4(mat3(frame_info.model) * tangent.xyz, tangent.w);
  v_color = color;

  gl_Position = frame_info.mvp * vec4(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_VertexIndex, 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;

/// 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;

/// 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[int(joints.x)] +
         w.y * skin_info.joint_matrices[int(joints.y)] +
         w.z * skin_info.joint_matrices[int(joints.z)] +
         w.w * skin_info.joint_matrices[int(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.
  mat4 skinnedModel = frame_info.model * skin;

  vec4 world = skinnedModel * vec4(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 * normal);
  v_tangent = vec4(
      mat3(frame_info.model) * (skinRotation * tangent.xyz), tangent.w);

  v_texcoord = texcoord;
  v_color = color;

  gl_Position = frame_info.mvp * (skin * vec4(position, 1.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);
}

''',
  },
  <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
// --- 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;

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, window-space depth in a.
//
// 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;
#endif

/// 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;

/// 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: window depth.
void WriteSurfaceGeometry(float roughness) {
#ifndef F3D_NO_SURFACE_BUFFER
  // 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, gl_FragCoord.z);
    return;
  }
  frag_surface = vec4(EncodeOctahedral(normalize(v_normal)),
                      clamp(roughness, 0.0, 1.0), gl_FragCoord.z);
#endif
}

/// 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. Two 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;
}
fog_info;

/// 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) {
  float density = fog_info.fog.w;
  if (density <= 0.0) return color;
  float d = distance(v_world_position, fog_info.eye.xyz);
  return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0));
}

/// 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.
void WriteSurface(vec3 linearColor, float alpha, float roughness) {
  frag_color = vec4(ApplyFog(linearColor), alpha);
  WriteSurfaceGeometry(roughness);
}

/// 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_


/// 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

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 unused.
  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), 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 unused.
  ///
  /// 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 unused.
  ///
  /// 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;
}
frag_info;

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;
};

Surface ReadSurface() {
  Surface s;

  vec4 texel = texture(base_color_texture, v_texcoord);
  // 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;

  // 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.
  float cutoff = frag_info.material2.x;
  if (cutoff >= 0.0 && s.alpha < cutoff) discard;

  s.n = normalize(v_normal);
  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() {
  return clamp(int(frag_info.frame_params.y + 0.5), 0, 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;
}

/// 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 — so a model can skip it with
/// one test instead of repeating the classification.
LightSample SampleLight(int index, Surface s) {
  LightSample light;

  vec4 position = frag_info.light_position[index];
  vec4 color = frag_info.light_color[index];
  vec4 direction = frag_info.light_direction[index];
  vec4 cone = frag_info.light_cone[index];

  float type = position.w;
  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, the same set flutter_scene filters its
/// cascades with.
///
/// 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.
  return min(texture(point_shadow_texture, atlas).r,
             texture(point_shadow_static_texture, atlas).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 error copied across from flutter_scene,
  // where the softness it borrows genuinely is the right quantity for their
  // map. 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(gl_FragCoord.xy,
                                            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 < kMaxLights; i++) {
    if (i >= count) break;
    LightSample light = SampleLight(i, s);
    if (light.n_dot_l <= 0.0) continue;
    float visibility = LightVisibility(s, light, i) *
        PointShadowFactor(v_world_position, s.n, i);
    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();
  // 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);
}

''',
    '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;

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, window-space depth in a.
//
// 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;
#endif

/// 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;

/// 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: window depth.
void WriteSurfaceGeometry(float roughness) {
#ifndef F3D_NO_SURFACE_BUFFER
  // 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, gl_FragCoord.z);
    return;
  }
  frag_surface = vec4(EncodeOctahedral(normalize(v_normal)),
                      clamp(roughness, 0.0, 1.0), gl_FragCoord.z);
#endif
}

/// 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. Two 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;
}
fog_info;

/// 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) {
  float density = fog_info.fog.w;
  if (density <= 0.0) return color;
  float d = distance(v_world_position, fog_info.eye.xyz);
  return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0));
}

/// 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.
void WriteSurface(vec3 linearColor, float alpha, float roughness) {
  frag_color = vec4(ApplyFog(linearColor), alpha);
  WriteSurfaceGeometry(roughness);
}

/// 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_


/// 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

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 unused.
  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), 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 unused.
  ///
  /// 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 unused.
  ///
  /// 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;
}
frag_info;

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;
};

Surface ReadSurface() {
  Surface s;

  vec4 texel = texture(base_color_texture, v_texcoord);
  // 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;

  // 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.
  float cutoff = frag_info.material2.x;
  if (cutoff >= 0.0 && s.alpha < cutoff) discard;

  s.n = normalize(v_normal);
  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() {
  return clamp(int(frag_info.frame_params.y + 0.5), 0, 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;
}

/// 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 — so a model can skip it with
/// one test instead of repeating the classification.
LightSample SampleLight(int index, Surface s) {
  LightSample light;

  vec4 position = frag_info.light_position[index];
  vec4 color = frag_info.light_color[index];
  vec4 direction = frag_info.light_direction[index];
  vec4 cone = frag_info.light_cone[index];

  float type = position.w;
  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, the same set flutter_scene filters its
/// cascades with.
///
/// 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.
  return min(texture(point_shadow_texture, atlas).r,
             texture(point_shadow_static_texture, atlas).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 error copied across from flutter_scene,
  // where the softness it borrows genuinely is the right quantity for their
  // map. 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(gl_FragCoord.xy,
                                            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 < kMaxLights; i++) {
    if (i >= count) break;
    LightSample light = SampleLight(i, s);
    if (light.n_dot_l <= 0.0) continue;
    float visibility = LightVisibility(s, light, i) *
        PointShadowFactor(v_world_position, s.n, i);
    if (visibility <= 0.0) continue;
    total += ShadeLight(s, light) * light.radiance * light.n_dot_l * visibility;
  }

  return total;
}

#endif  // SURFACE_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;

/// 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, v_texcoord).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, v_texcoord).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, v_texcoord).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) {
  // 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;

  vec3 sampled = texture(normal_texture, v_texcoord).xyz * 2.0 - 1.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) {
  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_


/// Linear depth from the light's point of view, in the red channel.
uniform sampler2D shadow_texture;

/// 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.
  vec3 origin = v_world_position + s.n * frag_info.shadow_params.z;

  // 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;
  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);
    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; beyond the far plane there is nothing left to shadow.
    if (candidate.z > 1.0) continue;

    // 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;
    found = true;
    break;
  }
  if (!found) return 1.0;

  float bias = frag_info.shadow_params.y;
  // 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);

  // 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.
  float lit = 0.0;
  for (int y = -1; y <= 1; y++) {
    for (int x = -1; x <= 1; x++) {
      float occluder =
          texture(shadow_texture, uv + vec2(float(x), float(y)) * texel).r;
      lit += projected.z - bias > occluder ? 0.0 : 1.0;
    }
  }
  lit *= 1.0 / 9.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 * 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;

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, window-space depth in a.
//
// 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;
#endif

/// 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;

/// 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: window depth.
void WriteSurfaceGeometry(float roughness) {
#ifndef F3D_NO_SURFACE_BUFFER
  // 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, gl_FragCoord.z);
    return;
  }
  frag_surface = vec4(EncodeOctahedral(normalize(v_normal)),
                      clamp(roughness, 0.0, 1.0), gl_FragCoord.z);
#endif
}

/// 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. Two 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;
}
fog_info;

/// 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) {
  float density = fog_info.fog.w;
  if (density <= 0.0) return color;
  float d = distance(v_world_position, fog_info.eye.xyz);
  return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0));
}

/// 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.
void WriteSurface(vec3 linearColor, float alpha, float roughness) {
  frag_color = vec4(ApplyFog(linearColor), alpha);
  WriteSurfaceGeometry(roughness);
}

/// 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_


/// 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

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 unused.
  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), 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 unused.
  ///
  /// 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 unused.
  ///
  /// 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;
}
frag_info;

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;
};

Surface ReadSurface() {
  Surface s;

  vec4 texel = texture(base_color_texture, v_texcoord);
  // 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;

  // 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.
  float cutoff = frag_info.material2.x;
  if (cutoff >= 0.0 && s.alpha < cutoff) discard;

  s.n = normalize(v_normal);
  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() {
  return clamp(int(frag_info.frame_params.y + 0.5), 0, 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;
}

/// 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 — so a model can skip it with
/// one test instead of repeating the classification.
LightSample SampleLight(int index, Surface s) {
  LightSample light;

  vec4 position = frag_info.light_position[index];
  vec4 color = frag_info.light_color[index];
  vec4 direction = frag_info.light_direction[index];
  vec4 cone = frag_info.light_cone[index];

  float type = position.w;
  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, the same set flutter_scene filters its
/// cascades with.
///
/// 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.
  return min(texture(point_shadow_texture, atlas).r,
             texture(point_shadow_static_texture, atlas).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 error copied across from flutter_scene,
  // where the softness it borrows genuinely is the right quantity for their
  // map. 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(gl_FragCoord.xy,
                                            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 < kMaxLights; i++) {
    if (i >= count) break;
    LightSample light = SampleLight(i, s);
    if (light.n_dot_l <= 0.0) continue;
    float visibility = LightVisibility(s, light, i) *
        PointShadowFactor(v_world_position, s.n, i);
    if (visibility <= 0.0) continue;
    total += ShadeLight(s, light) * light.radiance * light.n_dot_l * visibility;
  }

  return total;
}

#endif  // SURFACE_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;

/// 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, v_texcoord).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, v_texcoord).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, v_texcoord).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) {
  // 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;

  vec3 sampled = texture(normal_texture, v_texcoord).xyz * 2.0 - 1.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) {
  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_


/// Linear depth from the light's point of view, in the red channel.
uniform sampler2D shadow_texture;

/// 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.
  vec3 origin = v_world_position + s.n * frag_info.shadow_params.z;

  // 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;
  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);
    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; beyond the far plane there is nothing left to shadow.
    if (candidate.z > 1.0) continue;

    // 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;
    found = true;
    break;
  }
  if (!found) return 1.0;

  float bias = frag_info.shadow_params.y;
  // 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);

  // 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.
  float lit = 0.0;
  for (int y = -1; y <= 1; y++) {
    for (int x = -1; x <= 1; x++) {
      float occluder =
          texture(shadow_texture, uv + vec2(float(x), float(y)) * texel).r;
      lit += projected.z - bias > occluder ? 0.0 : 1.0;
    }
  }
  lit *= 1.0 / 9.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 * 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 physically based shading: Cook-Torrance specular with the GGX
// distribution, height-correlated Smith visibility and a Schlick Fresnel.
// 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.
// --- 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;

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, window-space depth in a.
//
// 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;
#endif

/// 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;

/// 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: window depth.
void WriteSurfaceGeometry(float roughness) {
#ifndef F3D_NO_SURFACE_BUFFER
  // 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, gl_FragCoord.z);
    return;
  }
  frag_surface = vec4(EncodeOctahedral(normalize(v_normal)),
                      clamp(roughness, 0.0, 1.0), gl_FragCoord.z);
#endif
}

/// 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. Two 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;
}
fog_info;

/// 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) {
  float density = fog_info.fog.w;
  if (density <= 0.0) return color;
  float d = distance(v_world_position, fog_info.eye.xyz);
  return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0));
}

/// 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.
void WriteSurface(vec3 linearColor, float alpha, float roughness) {
  frag_color = vec4(ApplyFog(linearColor), alpha);
  WriteSurfaceGeometry(roughness);
}

/// 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_


/// 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

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 unused.
  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), 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 unused.
  ///
  /// 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 unused.
  ///
  /// 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;
}
frag_info;

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;
};

Surface ReadSurface() {
  Surface s;

  vec4 texel = texture(base_color_texture, v_texcoord);
  // 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;

  // 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.
  float cutoff = frag_info.material2.x;
  if (cutoff >= 0.0 && s.alpha < cutoff) discard;

  s.n = normalize(v_normal);
  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() {
  return clamp(int(frag_info.frame_params.y + 0.5), 0, 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;
}

/// 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 — so a model can skip it with
/// one test instead of repeating the classification.
LightSample SampleLight(int index, Surface s) {
  LightSample light;

  vec4 position = frag_info.light_position[index];
  vec4 color = frag_info.light_color[index];
  vec4 direction = frag_info.light_direction[index];
  vec4 cone = frag_info.light_cone[index];

  float type = position.w;
  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, the same set flutter_scene filters its
/// cascades with.
///
/// 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.
  return min(texture(point_shadow_texture, atlas).r,
             texture(point_shadow_static_texture, atlas).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 error copied across from flutter_scene,
  // where the softness it borrows genuinely is the right quantity for their
  // map. 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(gl_FragCoord.xy,
                                            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 < kMaxLights; i++) {
    if (i >= count) break;
    LightSample light = SampleLight(i, s);
    if (light.n_dot_l <= 0.0) continue;
    float visibility = LightVisibility(s, light, i) *
        PointShadowFactor(v_world_position, s.n, i);
    if (visibility <= 0.0) continue;
    total += ShadeLight(s, light) * light.radiance * light.n_dot_l * visibility;
  }

  return total;
}

#endif  // SURFACE_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;

/// 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, v_texcoord).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, v_texcoord).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, v_texcoord).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) {
  // 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;

  vec3 sampled = texture(normal_texture, v_texcoord).xyz * 2.0 - 1.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) {
  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_


/// Linear depth from the light's point of view, in the red channel.
uniform sampler2D shadow_texture;

/// 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.
  vec3 origin = v_world_position + s.n * frag_info.shadow_params.z;

  // 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;
  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);
    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; beyond the far plane there is nothing left to shadow.
    if (candidate.z > 1.0) continue;

    // 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;
    found = true;
    break;
  }
  if (!found) return 1.0;

  float bias = frag_info.shadow_params.y;
  // 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);

  // 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.
  float lit = 0.0;
  for (int y = -1; y <= 1; y++) {
    for (int x = -1; x <= 1; x++) {
      float occluder =
          texture(shadow_texture, uv + vec2(float(x), float(y)) * texel).r;
      lit += projected.z - bias > occluder ? 0.0 : 1.0;
    }
  }
  lit *= 1.0 / 9.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 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, as arithmetic rather than as a lookup table.
///
/// The usual form of this is a 2D texture indexed by roughness and view angle.
/// Karis' analytic fit replaces it at a cost too small to see on anything but a
/// grazing mirror, and what it buys is a third texture binding this renderer
/// does not have to find, bind on every backend, and mirror in the software
/// rasteriser. Returns the scale and bias to apply to F0.
vec2 EnvBrdfApprox(float roughness, float n_dot_v) {
  const vec4 c0 = vec4(-1.0, -0.0275, -0.572, 0.022);
  const vec4 c1 = vec4(1.0, 0.0425, 1.04, -0.04);
  vec4 r = roughness * c0 + c1;
  float a004 = min(r.x * r.x, exp2(-9.28 * n_dot_v)) * r.x + r.y;
  return vec2(-1.04, 1.04) * a004 + r.zw;
}

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-6);
  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;
}

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

vec3 ShadeLight(Surface s, LightSample light) {
  // Perceptual roughness is squared to get the GGX alpha; this is what makes
  // the roughness slider feel linear.
  float alpha = s.roughness * s.roughness;

  // Dielectrics reflect ~4% at normal incidence; metals tint the reflection
  // with their own albedo and have no diffuse response.
  vec3 f0 = mix(vec3(0.04), s.albedo, s.metallic);
  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);
  vec3 f = F_Schlick(f0, light.v_dot_h);

  vec3 specular = d * vis * f * frag_info.material.w;
  // Energy left over after reflection is what scatters diffusely.
  vec3 diffuse = diffuseColor * (vec3(1.0) - f) / kPi;

  // The pi puts the result back on the scale the tone mapper and the exposure
  // default were calibrated against.
  return (diffuse + specular) * kPi;
}

void main() {
  Surface s = ReadSurface();
  ApplyCommonMaps(s);
  ApplyMetallicRoughnessMap(s);

  float metallic = clamp(s.metallic, 0.0, 1.0);
  vec3 diffuseColor = s.albedo * (1.0 - metallic);

  // 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;

  float levels = frag_info.frame_params.w;
  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.
    vec3 f0 = mix(vec3(0.04), s.albedo, metallic);
    vec3 reflected = reflect(-s.v, s.n);

    // 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 = EnvBrdfApprox(s.roughness, s.n_dot_v);

    // Scaled by the same ambient strength the flat term uses, so a scene that
    // dials its indirect light down dials both, and the two are interchangeable
    // rather than additive.
    ambient = (diffuseColor * irradiance + prefiltered * (f0 * ab.x + ab.y)) *
              frag_info.material.z * s.occlusion;
  }

  WriteSurface(
      AccumulateLights(s) * s.occlusion + ambient + s.emissive,
      s.alpha,
      s.roughness);
}

''',
    '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;

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, window-space depth in a.
//
// 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;
#endif

/// 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;

/// 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: window depth.
void WriteSurfaceGeometry(float roughness) {
#ifndef F3D_NO_SURFACE_BUFFER
  // 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, gl_FragCoord.z);
    return;
  }
  frag_surface = vec4(EncodeOctahedral(normalize(v_normal)),
                      clamp(roughness, 0.0, 1.0), gl_FragCoord.z);
#endif
}

/// 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. Two 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;
}
fog_info;

/// 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) {
  float density = fog_info.fog.w;
  if (density <= 0.0) return color;
  float d = distance(v_world_position, fog_info.eye.xyz);
  return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0));
}

/// 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.
void WriteSurface(vec3 linearColor, float alpha, float roughness) {
  frag_color = vec4(ApplyFog(linearColor), alpha);
  WriteSurfaceGeometry(roughness);
}

/// 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_


/// 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

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 unused.
  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), 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 unused.
  ///
  /// 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 unused.
  ///
  /// 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;
}
frag_info;

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;
};

Surface ReadSurface() {
  Surface s;

  vec4 texel = texture(base_color_texture, v_texcoord);
  // 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;

  // 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.
  float cutoff = frag_info.material2.x;
  if (cutoff >= 0.0 && s.alpha < cutoff) discard;

  s.n = normalize(v_normal);
  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() {
  return clamp(int(frag_info.frame_params.y + 0.5), 0, 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;
}

/// 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 — so a model can skip it with
/// one test instead of repeating the classification.
LightSample SampleLight(int index, Surface s) {
  LightSample light;

  vec4 position = frag_info.light_position[index];
  vec4 color = frag_info.light_color[index];
  vec4 direction = frag_info.light_direction[index];
  vec4 cone = frag_info.light_cone[index];

  float type = position.w;
  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, the same set flutter_scene filters its
/// cascades with.
///
/// 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.
  return min(texture(point_shadow_texture, atlas).r,
             texture(point_shadow_static_texture, atlas).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 error copied across from flutter_scene,
  // where the softness it borrows genuinely is the right quantity for their
  // map. 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(gl_FragCoord.xy,
                                            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 < kMaxLights; i++) {
    if (i >= count) break;
    LightSample light = SampleLight(i, s);
    if (light.n_dot_l <= 0.0) continue;
    float visibility = LightVisibility(s, light, i) *
        PointShadowFactor(v_world_position, s.n, i);
    if (visibility <= 0.0) continue;
    total += ShadeLight(s, light) * light.radiance * light.n_dot_l * visibility;
  }

  return total;
}

#endif  // SURFACE_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;

/// 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, v_texcoord).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, v_texcoord).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, v_texcoord).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) {
  // 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;

  vec3 sampled = texture(normal_texture, v_texcoord).xyz * 2.0 - 1.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) {
  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_


/// Linear depth from the light's point of view, in the red channel.
uniform sampler2D shadow_texture;

/// 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.
  vec3 origin = v_world_position + s.n * frag_info.shadow_params.z;

  // 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;
  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);
    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; beyond the far plane there is nothing left to shadow.
    if (candidate.z > 1.0) continue;

    // 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;
    found = true;
    break;
  }
  if (!found) return 1.0;

  float bias = frag_info.shadow_params.y;
  // 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);

  // 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.
  float lit = 0.0;
  for (int y = -1; y <= 1; y++) {
    for (int x = -1; x <= 1; x++) {
      float occluder =
          texture(shadow_texture, uv + vec2(float(x), float(y)) * texel).r;
      lit += projected.z - bias > occluder ? 0.0 : 1.0;
    }
  }
  lit *= 1.0 / 9.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 * 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;

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, window-space depth in a.
//
// 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;
#endif

/// 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;

/// 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: window depth.
void WriteSurfaceGeometry(float roughness) {
#ifndef F3D_NO_SURFACE_BUFFER
  // 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, gl_FragCoord.z);
    return;
  }
  frag_surface = vec4(EncodeOctahedral(normalize(v_normal)),
                      clamp(roughness, 0.0, 1.0), gl_FragCoord.z);
#endif
}

/// 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. Two 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;
}
fog_info;

/// 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) {
  float density = fog_info.fog.w;
  if (density <= 0.0) return color;
  float d = distance(v_world_position, fog_info.eye.xyz);
  return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0));
}

/// 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.
void WriteSurface(vec3 linearColor, float alpha, float roughness) {
  frag_color = vec4(ApplyFog(linearColor), alpha);
  WriteSurfaceGeometry(roughness);
}

/// 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.
  vec3 sum = texture(source_texture, v_uv + texel * vec2(-0.5, -0.5)).rgb +
             texture(source_texture, v_uv + texel * vec2(0.5, -0.5)).rgb +
             texture(source_texture, v_uv + texel * vec2(-0.5, 0.5)).rgb +
             texture(source_texture, v_uv + texel * vec2(0.5, 0.5)).rgb;
  vec3 color = sum * 0.25;

  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;
}
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);
  frag_color = vec4(result * (1.0 / 16.0), 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;

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;

layout(std140) uniform CompositeInfo {
  /// x: exposure, y: bloom intensity, z: 1 to tone map, w: how much of the
  /// occlusion to apply, 0 for none.
  vec4 params;

  /// x, y: one texel of the ao texture. z, w unused.
  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;
}
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);
}

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));
}

/// 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);
}

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;
  float ao = 0.25 * (texture(ao_texture, v_uv + vec2(half_texel.x, half_texel.y)).r +
                     texture(ao_texture, v_uv + vec2(-half_texel.x, half_texel.y)).r +
                     texture(ao_texture, v_uv + vec2(half_texel.x, -half_texel.y)).r +
                     texture(ao_texture, v_uv + vec2(-half_texel.x, -half_texel.y)).r);
  // 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));

  // 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.
  vec3 color = scene.rgb * ao + bloom * composite_info.params.y;

  // 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);

  if (composite_info.params.z > 0.5) color = TonemapNeutral(color);

  // **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.
  color = (color - vec3(0.5)) * contrast + vec3(0.5);
  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.
  color *= vec3(1.0 + temperature * 0.1, 1.0, 1.0 - temperature * 0.1);

  // 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));
  }

  float grain = composite_info.look_more.z;
  // Centred on zero so grain neither lifts nor lowers the average level, and
  // added rather than multiplied so it stays visible in the shadows, which is
  // where film grain lives.
  if (grain > 0.0) color += vec3((Hash(gl_FragCoord.xy) - 0.5) * grain);

  frag_color = vec4(LinearToSrgb(max(color, vec3(0.0))), scene.a);
}

''',
    '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.
#define F3D_NO_SURFACE_BUFFER
// --- 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;

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, window-space depth in a.
//
// 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;
#endif

/// 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;

/// 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: window depth.
void WriteSurfaceGeometry(float roughness) {
#ifndef F3D_NO_SURFACE_BUFFER
  // 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, gl_FragCoord.z);
    return;
  }
  frag_surface = vec4(EncodeOctahedral(normalize(v_normal)),
                      clamp(roughness, 0.0, 1.0), gl_FragCoord.z);
#endif
}

/// 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. Two 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;
}
fog_info;

/// 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) {
  float density = fog_info.fog.w;
  if (density <= 0.0) return color;
  float d = distance(v_world_position, fog_info.eye.xyz);
  return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0));
}

/// 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.
void WriteSurface(vec3 linearColor, float alpha, float roughness) {
  frag_color = vec4(ApplyFog(linearColor), alpha);
  WriteSurfaceGeometry(roughness);
}

/// 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.
//
// 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.

in vec4 v_color;
in vec2 v_uv;
in vec3 v_world_position;

layout(location = 0) out vec4 frag_color;

/// The same block the lit shaders use, declared again because this shader
/// shares none of their headers — it has a different vertex layout and none of
/// their varyings.
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);
  }

  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, beside the procedural one rather than replacing it.
//
// `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.

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;
  frag_color = vec4(v_color.rgb * texel.rgb * scale, 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 window depth — depth is here rather
// than in a depth texture because flutter_gpu cannot sample one.
//
// 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;

in vec2 v_uv;

layout(location = 0) out vec4 frag_color;

uniform sampler2D scene_texture;
uniform sampler2D surface_texture;

layout(std140) uniform ReflectionInfo {
  mat4 view_projection;
  mat4 inverse_view_projection;
  /// xyz: camera position. w: unused.
  vec4 camera;
  /// x: steps. y: stride in world metres. z: thickness. 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;
}
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);
}

/// World position of the pixel at [uv] with window depth [depth].
vec3 WorldAt(vec2 uv, float depth) {
  // Depth runs 0..1 here rather than -1..1: Impeller is Metal-like, and using
  // the OpenGL convention puts every reconstructed point behind the camera.
  vec4 ndc = vec4(uv * 2.0 - 1.0, depth, 1.0);
  vec4 world = reflection_info.inverse_view_projection * ndc;
  return world.xyz / world.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.
  float polish = 1.0 - smoothstep(0.18, 0.45, roughness);
  if (polish <= 0.0) {
    frag_color = vec4(background, 1.0);
    return;
  }
  vec3 position = WorldAt(v_uv, surface.a);
  vec3 toEye = normalize(reflection_info.camera.xyz - position);

  // 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 one stride out. Beginning at the surface makes the first sample
  // hit the pixel we came from, and every surface reflects itself.
  vec3 march = position + normal * 0.02 + ray * stride;
  vec3 hitColor = vec3(0.0);
  float hit = 0.0;
  float travelled = stride;

  for (int i = 0; i < 64; i++) {
    if (i >= steps) break;

    vec4 clip = reflection_info.view_projection * vec4(march, 1.0);
    if (clip.w <= 0.0) break;
    vec3 ndc = clip.xyz / clip.w;
    vec2 uv = ndc.xy * 0.5 + 0.5;

    // 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;

    float sceneDepth = texture(surface_texture, uv).a;
    if (sceneDepth > 0.0) {
      float difference = ndc.z - sceneDepth;
      // In front of the recorded surface by less than its assumed thickness:
      // the ray went behind something. Without the upper bound the ray would
      // "hit" every distant wall it passed in front of.
      if (difference > 0.0 && difference < thickness) {
        hitColor = texture(scene_texture, uv).rgb;
        // Fade at the edges of the frame and with distance travelled, so a
        // reflection thins out instead of stopping.
        vec2 edge = abs(uv * 2.0 - 1.0);
        float border = 1.0 - max(edge.x, edge.y);
        hit = smoothstep(0.0, 0.15, border);
        break;
      }
    }

    march += ray * stride;
    travelled += stride;
  }

  // Grazing angles reflect more, straight-on less: the Fresnel term, minus the
  // parts that need a material.
  float fresnel = pow(1.0 - facing, 4.0);
  vec3 reflection = hitColor * hit * intensity * polish * (0.15 + 0.85 * fresnel);
  frag_color = vec4(debugOnly ? reflection : scene + reflection, 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 — 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.
  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 is 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, w unused.
  vec4 screen;
}
ssao_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);
}

vec3 WorldFromDepth(vec2 uv, float depth) {
  vec4 ndc = vec4(uv * 2.0 - 1.0, depth, 1.0);
  vec4 world = ssao_info.inverse_view_projection * ndc;
  return world.xyz / world.w;
}

/// 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.
vec2 Rotation(vec2 uv) {
  vec2 pixel = floor(uv / ssao_info.screen.xy);
  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);
}

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 — the same test
  // `reflections.frag` makes, and for the same reason.
  if (surface.a <= 0.0) {
    frag_color = vec4(1.0);
    return;
  }

  vec3 normal = DecodeOctahedral(surface.rg);

  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 = WorldFromDepth(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 = ndc.xy * 0.5 + 0.5;
    vec4 there = texture(surface_texture, uv);
    // 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 camera than the point we sampled towards means something
    // stands between them. Compared in window depth, which is what the buffer
    // holds and what `ndc.z` already is — reconstructing both to world space
    // and measuring there would be the same test with two extra matrix
    // multiplies and a division.
    if (there.a >= ndc.z) 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 = WorldFromDepth(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));
}

''',
    '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
// --- 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;

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, window-space depth in a.
//
// 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;
#endif

/// 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;

/// 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: window depth.
void WriteSurfaceGeometry(float roughness) {
#ifndef F3D_NO_SURFACE_BUFFER
  // 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, gl_FragCoord.z);
    return;
  }
  frag_surface = vec4(EncodeOctahedral(normalize(v_normal)),
                      clamp(roughness, 0.0, 1.0), gl_FragCoord.z);
#endif
}

/// 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. Two 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;
}
fog_info;

/// 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) {
  float density = fog_info.fog.w;
  if (density <= 0.0) return color;
  float d = distance(v_world_position, fog_info.eye.xyz);
  return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0));
}

/// 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.
void WriteSurface(vec3 linearColor, float alpha, float roughness) {
  frag_color = vec4(ApplyFog(linearColor), alpha);
  WriteSurfaceGeometry(roughness);
}

/// 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);
}

''',
    '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);
}

''',
  },
);