engineShaders top-level property
Every shader the engine asks for, in GLSL ES 3.00.
Implementation
final ShaderSources engineShaders = ShaderSources(
<String, String>{
'MeshVertex': r'''#version 300 es
// The order and types of the `in` variables define the vertex layout:
// flutter_gpu binds a vertex buffer as one blob, with no attribute descriptors.
// The Dart-side vertex struct must match this declaration byte for byte; see
// VertexLayout.standard.
//
// One layout for every model rather than a permutation per attribute set. The
// layout is structural — it is taken from these declarations — so a second one
// would mean a second vertex shader and a second pipeline per lighting model.
in vec3 position;
in vec3 normal;
in vec2 texcoord;
/// xyz is the tangent direction, w is the bitangent sign (glTF convention).
in vec4 tangent;
/// Vertex colour, multiplied into the albedo. Neutral is opaque white.
in vec4 color;
// --- lib/morph.glsl ---
// Morph targets, applied in the vertex stage from a texture of deltas.
//
// ## Why a texture and not attributes
//
// The vertex layout in this engine is **structural**: the `in` declarations of
// `mesh.vert` are the layout, and one layout serves every model so that a
// lighting model needs one pipeline rather than one per attribute set. Morph
// deltas as attributes would mean a second layout, and with it a second vertex
// shader for every lighting model — six of them — and a second pipeline for
// each. A texture read by vertex index costs one sampler and no layout at all.
//
// That the read is possible is measured rather than assumed:
// `checkVertexTextureSampling` in `flutter3d_conformance` draws through a
// vertex stage that samples, on all three backends. It answers yes on each,
// Impeller included, which was the one that could not be settled by reading a
// header.
//
// ## The layout of the texture
//
// `r32g32b32a32Float`, width = the mesh's vertex count, height = one row per
// delta stream per target. Target *t* occupies rows `t * MORPH_ROWS` upwards:
//
// row + 0 position delta, xyz
// row + 1 normal delta, xyz (zero when the file carried none)
// row + 2 tangent delta, xyz (zero when the file carried none)
//
// Three rows always, so the arithmetic is a multiply rather than a table: a
// target that morphs only positions costs two rows of zeros, which is memory
// and not branches. `MorphTargetTexture` on the Dart side packs exactly this.
//
// **`texture` at a texel centre, and it should have been `texelFetch`.** There
// is nothing to filter — a vertex has exactly one delta per target — so the
// fetch is the operation this wants: no size arithmetic, no sampler state, no
// half-texel to get wrong.
//
// It is not used because **impellerc crashes on `texelFetch` in a vertex
// stage**: SIGABRT, no diagnostic, exit 134. Bisected — the same call in a
// *fragment* stage compiles, `gl_VertexID` alone compiles, and `texture()`
// in a vertex stage compiles, so it is that one combination. So the coordinate
// is built by hand, `(index + 0.5) / size`, and the sampler is bound nearest
// and clamped: exactly the texel, reached the long way round. The size comes
// down in `morph_params` rather than from `textureSize`, which is one more
// thing that would have to survive the same compiler.
#ifndef MORPH_GLSL_
#define MORPH_GLSL_
/// Rows of the delta texture each target occupies. See the header.
const int kMorphRows = 3;
/// The most targets one draw can blend.
///
/// Eight because glTF's own guidance is that an engine support at least eight
/// active targets, and because a `vec4[2]` is two registers. A model carrying
/// more is not refused — the renderer sends the first eight and says so, which
/// is a face missing an expression rather than a face that will not load.
const int kMorphMax = 8;
uniform sampler2D morph_texture;
layout(std140) uniform MorphInfo {
/// Weight of target *i* at `morph_weights[i / 4][i % 4]`.
vec4 morph_weights[2];
/// x: how many targets are active, as a float.
/// y: one texel across, `1 / width`. z: one texel down, `1 / height`.
/// w unused.
///
/// A count rather than a convention that a zero weight means absent: a
/// target held at exactly nought is a face that is not smiling, and reading
/// it as "the list ends here" would stop the ones after it.
vec4 morph_params;
}
morph_info;
/// How many targets this draw blends.
int MorphCount() { return int(morph_info.morph_params.x + 0.5); }
/// The vertex's own column in the delta texture.
///
/// **`gl_VertexID`, spelt the way SPIR-V spells it.** GLSL ES 3.00 calls the
/// same builtin `gl_VertexID`, and the browser backend's translator rewrites
/// the name on its way out — one substitution beside the ones it already makes
/// for `#version` and `layout(std140)`. Written the other way round, impellerc
/// refuses it outright: "undeclared identifier (Did you mean gl_VertexID?)",
/// which is the friendliest error in this repository.
float MorphColumn() {
return (float(gl_VertexID) + 0.5) * morph_info.morph_params.y;
}
/// Adds target *t*'s deltas onto one vertex, scaled by [weight].
///
/// Split out of [ApplyMorph] so that a stage which gets its weights from
/// somewhere else — `lib/morph_instanced.glsl`, where each instance of a batch
/// wears its own — reads the deltas through the same three lines rather than
/// through a second copy of them.
///
/// [column] and [rowStep] are the caller's, worked out once rather than per
/// target.
///
/// **Splitting this out moved the picture, by 31 pixels of silhouette on
/// Impeller**, and the reference set was re-recorded rather than the split
/// abandoned. The arithmetic is the same arithmetic — it was checked against
/// the software backend, which draws it identically either way — so what moved
/// is what impellerc's optimiser does with a function call it can no longer
/// see through. Hoisting the coordinates was the first guess at the cause and
/// was not it: the same 31 pixels moved with them hoisted. Worth writing down,
/// because the next person to factor a line out of a vertex stage will see a
/// golden fail and reach for the same wrong explanation.
void AddMorphTargetAt(int t, float weight, float column, float rowStep,
inout vec3 position, inout vec3 normal,
inout vec4 tangent) {
float row = (float(t * kMorphRows) + 0.5) * rowStep;
position += texture(morph_texture, vec2(column, row)).xyz * weight;
normal += texture(morph_texture, vec2(column, row + rowStep)).xyz * weight;
tangent.xyz +=
texture(morph_texture, vec2(column, row + rowStep * 2.0)).xyz * weight;
}
/// Adds the blended deltas onto one vertex.
///
/// Called with the attributes as they were read and before anything else
/// touches them — skinning included, which is the order glTF specifies: a
/// skinned morphed mesh morphs in its rest pose and is then posed by the
/// skeleton.
///
/// The tangent is a `vec4` and only its xyz move: w is the bitangent sign, a
/// handedness rather than a direction, and glTF does not morph it.
void ApplyMorph(inout vec3 position, inout vec3 normal, inout vec4 tangent) {
int count = MorphCount();
if (count <= 0) return;
float column = MorphColumn();
float rowStep = morph_info.morph_params.z;
for (int i = 0; i < kMorphMax; i++) {
if (i >= count) break;
float weight = morph_info.morph_weights[i / 4][i % 4];
if (weight == 0.0) continue;
AddMorphTargetAt(i, weight, column, rowStep, position, normal, tangent);
}
}
#endif // MORPH_GLSL_
layout(std140) uniform FrameInfo {
mat4 mvp;
mat4 model;
/// Inverse-transpose of the model matrix. Computed on the CPU because doing
/// it per vertex would waste the ALU, and because mat3(model) is only correct
/// while the scale stays uniform.
mat4 normal_matrix;
}
frame_info;
// One varying set shared by every lighting model, matching shaders/lib/color.glsl.
out vec3 v_world_position;
out vec3 v_normal;
out vec2 v_texcoord;
out vec4 v_tangent;
out vec4 v_color;
out vec2 v_lightmap_uv;
void main() {
// Morphed first and in the mesh's own space, which is the order glTF
// specifies: a morphed vertex is then transformed, and a morphed *skinned*
// vertex is morphed in its rest pose before the skeleton poses it.
vec3 morphed_position = position;
vec3 morphed_normal = normal;
vec4 morphed_tangent = tangent;
ApplyMorph(morphed_position, morphed_normal, morphed_tangent);
vec4 world = frame_info.model * vec4(morphed_position, 1.0);
v_world_position = world.xyz;
v_normal = mat3(frame_info.normal_matrix) * morphed_normal;
v_texcoord = texcoord;
// The tangent transforms with the model matrix, not the normal matrix: it
// lies *in* the surface, so it stretches with the geometry rather than
// resisting it. Using the inverse transpose here is the classic way to get a
// TBN that is subtly wrong under non-uniform scale.
v_tangent =
vec4(mat3(frame_info.model) * morphed_tangent.xyz, morphed_tangent.w);
v_color = color;
v_lightmap_uv = vec2(0.0);
gl_Position = frame_info.mvp * vec4(morphed_position, 1.0);
}
''',
'DebugLineVertex': r'''#version 300 es
// Vertex stage for the debug line overlay.
//
// A separate vertex shader rather than a reuse of mesh.vert: the debug buffer is
// position + colour with no normal or texcoord, and flutter_gpu takes the vertex
// layout from the order of `in` declarations, so a different layout means a
// different shader. See VertexLayout.positionColor.
in vec3 position;
in vec4 color;
layout(std140) uniform LineInfo {
mat4 view_projection;
}
line_info;
out vec4 v_line_color;
void main() {
v_line_color = color;
gl_Position = line_info.view_projection * vec4(position, 1.0);
}
''',
'FullscreenVertex': r'''#version 300 es
// Vertex stage for every full-screen pass.
//
// A single oversized triangle, not a quad. A quad has a diagonal seam where the
// two triangles meet, and the GPU rasterizes 2x2 quads of fragments along it
// twice; one triangle that covers the screen has no seam and no duplicated
// work. The extra area outside the viewport is clipped for free.
//
// The three vertices come from a tiny vertex buffer rather than from
// gl_VertexID, because flutter_gpu's draw() renders nothing without an index
// buffer bound, so there is a buffer to bind either way.
in vec2 position;
in vec2 texcoord;
out vec2 v_uv;
void main() {
v_uv = texcoord;
gl_Position = vec4(position, 0.0, 1.0);
}
''',
'MeshSkinnedVertex': r'''#version 300 es
// The skinned vertex stage.
//
// A second vertex shader rather than a branch inside mesh.vert, and the reason
// is structural rather than a performance guess: flutter_gpu takes the vertex
// layout from the `in` declarations, so joints and weights being attributes
// makes this a different layout, and a different layout is a different shader
// whatever the body does. Declaring the joint matrices in the static shader and
// leaving them unread would also be the phantom-uniform trap — reflection would
// report the block while the compiled function bound no buffer.
//
// The fragment side is untouched: skinning moves vertices, and every lighting
// model reads the same varyings either way.
in vec3 position;
in vec3 normal;
in vec2 texcoord;
in vec4 tangent;
in vec4 color;
// --- lib/morph.glsl ---
// Morph targets, applied in the vertex stage from a texture of deltas.
//
// ## Why a texture and not attributes
//
// The vertex layout in this engine is **structural**: the `in` declarations of
// `mesh.vert` are the layout, and one layout serves every model so that a
// lighting model needs one pipeline rather than one per attribute set. Morph
// deltas as attributes would mean a second layout, and with it a second vertex
// shader for every lighting model — six of them — and a second pipeline for
// each. A texture read by vertex index costs one sampler and no layout at all.
//
// That the read is possible is measured rather than assumed:
// `checkVertexTextureSampling` in `flutter3d_conformance` draws through a
// vertex stage that samples, on all three backends. It answers yes on each,
// Impeller included, which was the one that could not be settled by reading a
// header.
//
// ## The layout of the texture
//
// `r32g32b32a32Float`, width = the mesh's vertex count, height = one row per
// delta stream per target. Target *t* occupies rows `t * MORPH_ROWS` upwards:
//
// row + 0 position delta, xyz
// row + 1 normal delta, xyz (zero when the file carried none)
// row + 2 tangent delta, xyz (zero when the file carried none)
//
// Three rows always, so the arithmetic is a multiply rather than a table: a
// target that morphs only positions costs two rows of zeros, which is memory
// and not branches. `MorphTargetTexture` on the Dart side packs exactly this.
//
// **`texture` at a texel centre, and it should have been `texelFetch`.** There
// is nothing to filter — a vertex has exactly one delta per target — so the
// fetch is the operation this wants: no size arithmetic, no sampler state, no
// half-texel to get wrong.
//
// It is not used because **impellerc crashes on `texelFetch` in a vertex
// stage**: SIGABRT, no diagnostic, exit 134. Bisected — the same call in a
// *fragment* stage compiles, `gl_VertexID` alone compiles, and `texture()`
// in a vertex stage compiles, so it is that one combination. So the coordinate
// is built by hand, `(index + 0.5) / size`, and the sampler is bound nearest
// and clamped: exactly the texel, reached the long way round. The size comes
// down in `morph_params` rather than from `textureSize`, which is one more
// thing that would have to survive the same compiler.
#ifndef MORPH_GLSL_
#define MORPH_GLSL_
/// Rows of the delta texture each target occupies. See the header.
const int kMorphRows = 3;
/// The most targets one draw can blend.
///
/// Eight because glTF's own guidance is that an engine support at least eight
/// active targets, and because a `vec4[2]` is two registers. A model carrying
/// more is not refused — the renderer sends the first eight and says so, which
/// is a face missing an expression rather than a face that will not load.
const int kMorphMax = 8;
uniform sampler2D morph_texture;
layout(std140) uniform MorphInfo {
/// Weight of target *i* at `morph_weights[i / 4][i % 4]`.
vec4 morph_weights[2];
/// x: how many targets are active, as a float.
/// y: one texel across, `1 / width`. z: one texel down, `1 / height`.
/// w unused.
///
/// A count rather than a convention that a zero weight means absent: a
/// target held at exactly nought is a face that is not smiling, and reading
/// it as "the list ends here" would stop the ones after it.
vec4 morph_params;
}
morph_info;
/// How many targets this draw blends.
int MorphCount() { return int(morph_info.morph_params.x + 0.5); }
/// The vertex's own column in the delta texture.
///
/// **`gl_VertexID`, spelt the way SPIR-V spells it.** GLSL ES 3.00 calls the
/// same builtin `gl_VertexID`, and the browser backend's translator rewrites
/// the name on its way out — one substitution beside the ones it already makes
/// for `#version` and `layout(std140)`. Written the other way round, impellerc
/// refuses it outright: "undeclared identifier (Did you mean gl_VertexID?)",
/// which is the friendliest error in this repository.
float MorphColumn() {
return (float(gl_VertexID) + 0.5) * morph_info.morph_params.y;
}
/// Adds target *t*'s deltas onto one vertex, scaled by [weight].
///
/// Split out of [ApplyMorph] so that a stage which gets its weights from
/// somewhere else — `lib/morph_instanced.glsl`, where each instance of a batch
/// wears its own — reads the deltas through the same three lines rather than
/// through a second copy of them.
///
/// [column] and [rowStep] are the caller's, worked out once rather than per
/// target.
///
/// **Splitting this out moved the picture, by 31 pixels of silhouette on
/// Impeller**, and the reference set was re-recorded rather than the split
/// abandoned. The arithmetic is the same arithmetic — it was checked against
/// the software backend, which draws it identically either way — so what moved
/// is what impellerc's optimiser does with a function call it can no longer
/// see through. Hoisting the coordinates was the first guess at the cause and
/// was not it: the same 31 pixels moved with them hoisted. Worth writing down,
/// because the next person to factor a line out of a vertex stage will see a
/// golden fail and reach for the same wrong explanation.
void AddMorphTargetAt(int t, float weight, float column, float rowStep,
inout vec3 position, inout vec3 normal,
inout vec4 tangent) {
float row = (float(t * kMorphRows) + 0.5) * rowStep;
position += texture(morph_texture, vec2(column, row)).xyz * weight;
normal += texture(morph_texture, vec2(column, row + rowStep)).xyz * weight;
tangent.xyz +=
texture(morph_texture, vec2(column, row + rowStep * 2.0)).xyz * weight;
}
/// Adds the blended deltas onto one vertex.
///
/// Called with the attributes as they were read and before anything else
/// touches them — skinning included, which is the order glTF specifies: a
/// skinned morphed mesh morphs in its rest pose and is then posed by the
/// skeleton.
///
/// The tangent is a `vec4` and only its xyz move: w is the bitangent sign, a
/// handedness rather than a direction, and glTF does not morph it.
void ApplyMorph(inout vec3 position, inout vec3 normal, inout vec4 tangent) {
int count = MorphCount();
if (count <= 0) return;
float column = MorphColumn();
float rowStep = morph_info.morph_params.z;
for (int i = 0; i < kMorphMax; i++) {
if (i >= count) break;
float weight = morph_info.morph_weights[i / 4][i % 4];
if (weight == 0.0) continue;
AddMorphTargetAt(i, weight, column, rowStep, position, normal, tangent);
}
}
#endif // MORPH_GLSL_
/// Four joint indices, held as floats. See VertexLayout.joints.
in vec4 joints;
/// Their influences. Normalized here rather than trusted, because an exporter
/// that rounds to a normalized byte leaves sums a little off one, and the error
/// shows up as a mesh that breathes.
in vec4 weights;
layout(std140) uniform FrameInfo {
mat4 mvp;
mat4 model;
mat4 normal_matrix;
}
frame_info;
/// Must match Skeleton.maxJoints on the Dart side.
///
/// A fixed array with the count implied by the data, the same shape the lights
/// use: shaders are compiled ahead of time, so a permutation per joint count is
/// not available even if it were desirable.
#define kMaxJoints 64
layout(std140) uniform SkinInfo {
mat4 joint_matrices[kMaxJoints];
}
skin_info;
out vec3 v_world_position;
out vec3 v_normal;
out vec2 v_texcoord;
out vec4 v_tangent;
out vec4 v_color;
out vec2 v_lightmap_uv;
/// 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.
// Morphed in the rest pose and skinned afterwards, which is the order glTF
// specifies and the only one that composes: a face morphs where it was
// modelled and the skeleton then carries it.
vec3 morphed_position = position;
vec3 morphed_normal = normal;
vec4 morphed_tangent = tangent;
ApplyMorph(morphed_position, morphed_normal, morphed_tangent);
mat4 skinnedModel = frame_info.model * skin;
vec4 world = skinnedModel * vec4(morphed_position, 1.0);
v_world_position = world.xyz;
// The joint transform rotates and may scale, so the normal needs the same
// treatment it gets from the model matrix. mat3(skin) is exact while the
// joints only rotate and translate, which is the case for every rig in
// practice; a non-uniformly scaled joint would need the inverse transpose,
// and computing that per vertex is the trade this deliberately does not make.
mat3 skinRotation = mat3(skin);
v_normal =
mat3(frame_info.normal_matrix) * (skinRotation * morphed_normal);
v_tangent = vec4(
mat3(frame_info.model) * (skinRotation * morphed_tangent.xyz), morphed_tangent.w);
v_texcoord = texcoord;
v_color = color;
v_lightmap_uv = vec2(0.0);
gl_Position = frame_info.mvp * (skin * vec4(morphed_position, 1.0));
}
''',
'MeshInstancedVertex': r'''#version 300 es
// Vertex stage for an instanced mesh: one mesh, drawn once per instance, each
// instance with a transform and a colour of its own.
//
// The same varyings as mesh.vert and the same FrameInfo block, and that is the
// whole design: every fragment shader in the bundle and both shadow passes
// take this stage without knowing it is instanced. What differs is slot 1 —
// the placements, stepping once per instance — and that `model` here is the
// node's transform, which the instance transform sits inside. An instance is
// placed relative to its node, so moving the node moves the whole batch and a
// batch of a thousand costs one uniform write.
//
// Twelve floats an instance for the transform, not sixteen: the bottom row of
// an affine matrix is always (0, 0, 0, 1), and a quarter of the buffer would
// be spent saying so. Stored as three rows so the vertex stage assembles a
// mat4 from them in three moves.
in vec3 position;
in vec3 normal;
in vec2 texcoord;
in vec4 tangent;
in vec4 color;
// --- lib/morph_instanced.glsl ---
// Morph weights per instance of a batch, read from a texture by instance id.
//
// ## Why this is a file of its own
//
// One batch of a thousand villagers should be able to wear a thousand
// expressions, and until this existed it wore one: `MorphInfo.morph_weights` is
// a uniform, and a uniform is the same for every instance in the draw by
// definition.
//
// The obvious place to put per-instance weights is the instance record — slot
// one, which already carries a transform and a colour and is rewritten every
// frame. That was rejected: the record's size is part of the instanced vertex
// layout, so eight more floats would be paid by every instanced draw in every
// game, including the overwhelming majority that morph nothing.
//
// So: a texture, one row per instance, read by `gl_InstanceID`. Two texels
// wide, because eight weights are two `vec4`s. It costs one sampler on one
// stage, and the layout is untouched.
//
// **This is included only by `mesh_instanced.vert`.** A sampler declared in
// `lib/morph.glsl` would be declared on all four mesh vertex stages, and every
// one of them would have to bind something to it on every draw for ever. The
// deltas are worth that; a second sampler that three stages can never use is
// not.
//
// ## What it costs to change a weight
//
// A texture in this engine is created with its contents and never written
// again — `GraphicsDevice` has `createTextureFromPixels` and no update, which
// is a decision the HAL makes on purpose. So a batch whose per-instance weights
// change has to build a new texture, and one whose weights are set once pays
// nothing per frame. That is the right way round for what this is for: a crowd
// where each face is *different* rather than a crowd where each face is
// *moving*. `InstancedMeshNode` rebuilds only when a weight actually changed —
// the same skip `MorphBlend` makes, and for the same reason.
#ifndef MORPH_INSTANCED_GLSL_
#define MORPH_INSTANCED_GLSL_
// --- lib/morph.glsl ---
// Morph targets, applied in the vertex stage from a texture of deltas.
//
// ## Why a texture and not attributes
//
// The vertex layout in this engine is **structural**: the `in` declarations of
// `mesh.vert` are the layout, and one layout serves every model so that a
// lighting model needs one pipeline rather than one per attribute set. Morph
// deltas as attributes would mean a second layout, and with it a second vertex
// shader for every lighting model — six of them — and a second pipeline for
// each. A texture read by vertex index costs one sampler and no layout at all.
//
// That the read is possible is measured rather than assumed:
// `checkVertexTextureSampling` in `flutter3d_conformance` draws through a
// vertex stage that samples, on all three backends. It answers yes on each,
// Impeller included, which was the one that could not be settled by reading a
// header.
//
// ## The layout of the texture
//
// `r32g32b32a32Float`, width = the mesh's vertex count, height = one row per
// delta stream per target. Target *t* occupies rows `t * MORPH_ROWS` upwards:
//
// row + 0 position delta, xyz
// row + 1 normal delta, xyz (zero when the file carried none)
// row + 2 tangent delta, xyz (zero when the file carried none)
//
// Three rows always, so the arithmetic is a multiply rather than a table: a
// target that morphs only positions costs two rows of zeros, which is memory
// and not branches. `MorphTargetTexture` on the Dart side packs exactly this.
//
// **`texture` at a texel centre, and it should have been `texelFetch`.** There
// is nothing to filter — a vertex has exactly one delta per target — so the
// fetch is the operation this wants: no size arithmetic, no sampler state, no
// half-texel to get wrong.
//
// It is not used because **impellerc crashes on `texelFetch` in a vertex
// stage**: SIGABRT, no diagnostic, exit 134. Bisected — the same call in a
// *fragment* stage compiles, `gl_VertexID` alone compiles, and `texture()`
// in a vertex stage compiles, so it is that one combination. So the coordinate
// is built by hand, `(index + 0.5) / size`, and the sampler is bound nearest
// and clamped: exactly the texel, reached the long way round. The size comes
// down in `morph_params` rather than from `textureSize`, which is one more
// thing that would have to survive the same compiler.
#ifndef MORPH_GLSL_
#define MORPH_GLSL_
/// Rows of the delta texture each target occupies. See the header.
const int kMorphRows = 3;
/// The most targets one draw can blend.
///
/// Eight because glTF's own guidance is that an engine support at least eight
/// active targets, and because a `vec4[2]` is two registers. A model carrying
/// more is not refused — the renderer sends the first eight and says so, which
/// is a face missing an expression rather than a face that will not load.
const int kMorphMax = 8;
uniform sampler2D morph_texture;
layout(std140) uniform MorphInfo {
/// Weight of target *i* at `morph_weights[i / 4][i % 4]`.
vec4 morph_weights[2];
/// x: how many targets are active, as a float.
/// y: one texel across, `1 / width`. z: one texel down, `1 / height`.
/// w unused.
///
/// A count rather than a convention that a zero weight means absent: a
/// target held at exactly nought is a face that is not smiling, and reading
/// it as "the list ends here" would stop the ones after it.
vec4 morph_params;
}
morph_info;
/// How many targets this draw blends.
int MorphCount() { return int(morph_info.morph_params.x + 0.5); }
/// The vertex's own column in the delta texture.
///
/// **`gl_VertexID`, spelt the way SPIR-V spells it.** GLSL ES 3.00 calls the
/// same builtin `gl_VertexID`, and the browser backend's translator rewrites
/// the name on its way out — one substitution beside the ones it already makes
/// for `#version` and `layout(std140)`. Written the other way round, impellerc
/// refuses it outright: "undeclared identifier (Did you mean gl_VertexID?)",
/// which is the friendliest error in this repository.
float MorphColumn() {
return (float(gl_VertexID) + 0.5) * morph_info.morph_params.y;
}
/// Adds target *t*'s deltas onto one vertex, scaled by [weight].
///
/// Split out of [ApplyMorph] so that a stage which gets its weights from
/// somewhere else — `lib/morph_instanced.glsl`, where each instance of a batch
/// wears its own — reads the deltas through the same three lines rather than
/// through a second copy of them.
///
/// [column] and [rowStep] are the caller's, worked out once rather than per
/// target.
///
/// **Splitting this out moved the picture, by 31 pixels of silhouette on
/// Impeller**, and the reference set was re-recorded rather than the split
/// abandoned. The arithmetic is the same arithmetic — it was checked against
/// the software backend, which draws it identically either way — so what moved
/// is what impellerc's optimiser does with a function call it can no longer
/// see through. Hoisting the coordinates was the first guess at the cause and
/// was not it: the same 31 pixels moved with them hoisted. Worth writing down,
/// because the next person to factor a line out of a vertex stage will see a
/// golden fail and reach for the same wrong explanation.
void AddMorphTargetAt(int t, float weight, float column, float rowStep,
inout vec3 position, inout vec3 normal,
inout vec4 tangent) {
float row = (float(t * kMorphRows) + 0.5) * rowStep;
position += texture(morph_texture, vec2(column, row)).xyz * weight;
normal += texture(morph_texture, vec2(column, row + rowStep)).xyz * weight;
tangent.xyz +=
texture(morph_texture, vec2(column, row + rowStep * 2.0)).xyz * weight;
}
/// Adds the blended deltas onto one vertex.
///
/// Called with the attributes as they were read and before anything else
/// touches them — skinning included, which is the order glTF specifies: a
/// skinned morphed mesh morphs in its rest pose and is then posed by the
/// skeleton.
///
/// The tangent is a `vec4` and only its xyz move: w is the bitangent sign, a
/// handedness rather than a direction, and glTF does not morph it.
void ApplyMorph(inout vec3 position, inout vec3 normal, inout vec4 tangent) {
int count = MorphCount();
if (count <= 0) return;
float column = MorphColumn();
float rowStep = morph_info.morph_params.z;
for (int i = 0; i < kMorphMax; i++) {
if (i >= count) break;
float weight = morph_info.morph_weights[i / 4][i % 4];
if (weight == 0.0) continue;
AddMorphTargetAt(i, weight, column, rowStep, position, normal, tangent);
}
}
#endif // MORPH_GLSL_
uniform sampler2D morph_instance_weights;
layout(std140) uniform MorphInstanceInfo {
/// x: 1 when the weights come from the texture, 0 when they come from
/// `MorphInfo` and every instance wears the same shape.
/// y: one texel across, `1 / width`. z: one texel down, `1 / height`.
/// w unused.
vec4 instance_params;
}
morph_instance_info;
/// Adds the deltas for [instance]'s own weights onto one vertex.
///
/// Falls through to [ApplyMorph] when the batch has no per-instance weights,
/// which is every batch that does not use this feature: the texture is then a
/// stand-in nobody reads, and the shape comes from the uniform exactly as it
/// does on the three stages that never heard of this file.
void ApplyMorphInstanced(int instance, inout vec3 position, inout vec3 normal,
inout vec4 tangent) {
if (morph_instance_info.instance_params.x < 0.5) {
ApplyMorph(position, normal, tangent);
return;
}
int count = MorphCount();
if (count <= 0) return;
float deltaColumn = MorphColumn();
float deltaRowStep = morph_info.morph_params.z;
// The instance's own row, at a texel centre, for the same reason the delta
// read builds its coordinate by hand: nearest and clamped, and no
// `textureSize`.
float row = (float(instance) + 0.5) * morph_instance_info.instance_params.z;
for (int i = 0; i < kMorphMax; i++) {
if (i >= count) break;
// Four weights a texel, so target *i* is in texel `i / 4`, channel `i % 4`
// — the same arithmetic `morph_weights[i / 4][i % 4]` does over the
// uniform, which is what makes the two paths agree without either knowing
// about the other.
float column =
(float(i / 4) + 0.5) * morph_instance_info.instance_params.y;
float weight = texture(morph_instance_weights, vec2(column, row))[i % 4];
if (weight == 0.0) continue;
AddMorphTargetAt(i, weight, deltaColumn, deltaRowStep, position, normal,
tangent);
}
}
#endif // MORPH_INSTANCED_GLSL_
/// Rows of the instance's 3x4 affine transform, in the node's space.
in vec4 i_row0;
in vec4 i_row1;
in vec4 i_row2;
/// Multiplied into the vertex colour, so a batch of one mesh can vary its tint.
in vec4 i_color;
layout(std140) uniform FrameInfo {
mat4 mvp;
mat4 model;
mat4 normal_matrix;
}
frame_info;
out vec3 v_world_position;
out vec3 v_normal;
out vec2 v_texcoord;
out vec4 v_tangent;
out vec4 v_color;
out vec2 v_lightmap_uv;
void main() {
// Columns from rows: GLSL matrices are column-major, so the constructor is
// handed the transpose of what the buffer holds.
mat4 instance = mat4(
vec4(i_row0.x, i_row1.x, i_row2.x, 0.0),
vec4(i_row0.y, i_row1.y, i_row2.y, 0.0),
vec4(i_row0.z, i_row1.z, i_row2.z, 0.0),
vec4(i_row0.w, i_row1.w, i_row2.w, 1.0));
// Morphed before the instance transform: the deltas are in the mesh's own
// space, and every instance of a batch shares the mesh. What they need not
// share is the *shape* — `ApplyMorphInstanced` reads this instance's own
// weights out of a texture when the batch has any, and falls through to the
// batch-wide uniform when it has none. See `lib/morph_instanced.glsl`.
vec3 morphed_position = position;
vec3 morphed_normal = normal;
vec4 morphed_tangent = tangent;
ApplyMorphInstanced(gl_InstanceID, morphed_position, morphed_normal,
morphed_tangent);
vec4 local = instance * vec4(morphed_position, 1.0);
vec4 world = frame_info.model * local;
v_world_position = world.xyz;
// The instance's rotation and scale applied before the node's normal matrix.
// Correct for a rotation and a uniform scale, which is what an instance is
// for; a non-uniform instance scale skews the normal, and that is the
// documented limit rather than an inverse transpose per vertex.
mat3 rotation = mat3(instance);
v_normal =
mat3(frame_info.normal_matrix) * normalize(rotation * morphed_normal);
v_texcoord = texcoord;
v_tangent = vec4(
mat3(frame_info.model) * (rotation * morphed_tangent.xyz),
morphed_tangent.w);
v_color = color * i_color;
v_lightmap_uv = vec2(0.0);
gl_Position = frame_info.mvp * local;
}
''',
'MeshLightmappedVertex': r'''#version 300 es
// `mesh.vert` for a level with a baked lightmap.
//
// The same vertex layout as every other model — see mesh.vert for why there
// is one — with one attribute read differently: `color.xy` carries the
// vertex's place in the lightmap rather than a tint. A brush face has no
// vertex colour to lose, and a fourth vertex layout would be a fourth
// pipeline per lighting model on three backends for two floats. So the
// level's geometry writes its second coordinate where the colour goes, and
// this stage hands the fragment an opaque white tint and the coordinate.
in vec3 position;
in vec3 normal;
in vec2 texcoord;
in vec4 tangent;
in vec4 color;
// --- lib/morph.glsl ---
// Morph targets, applied in the vertex stage from a texture of deltas.
//
// ## Why a texture and not attributes
//
// The vertex layout in this engine is **structural**: the `in` declarations of
// `mesh.vert` are the layout, and one layout serves every model so that a
// lighting model needs one pipeline rather than one per attribute set. Morph
// deltas as attributes would mean a second layout, and with it a second vertex
// shader for every lighting model — six of them — and a second pipeline for
// each. A texture read by vertex index costs one sampler and no layout at all.
//
// That the read is possible is measured rather than assumed:
// `checkVertexTextureSampling` in `flutter3d_conformance` draws through a
// vertex stage that samples, on all three backends. It answers yes on each,
// Impeller included, which was the one that could not be settled by reading a
// header.
//
// ## The layout of the texture
//
// `r32g32b32a32Float`, width = the mesh's vertex count, height = one row per
// delta stream per target. Target *t* occupies rows `t * MORPH_ROWS` upwards:
//
// row + 0 position delta, xyz
// row + 1 normal delta, xyz (zero when the file carried none)
// row + 2 tangent delta, xyz (zero when the file carried none)
//
// Three rows always, so the arithmetic is a multiply rather than a table: a
// target that morphs only positions costs two rows of zeros, which is memory
// and not branches. `MorphTargetTexture` on the Dart side packs exactly this.
//
// **`texture` at a texel centre, and it should have been `texelFetch`.** There
// is nothing to filter — a vertex has exactly one delta per target — so the
// fetch is the operation this wants: no size arithmetic, no sampler state, no
// half-texel to get wrong.
//
// It is not used because **impellerc crashes on `texelFetch` in a vertex
// stage**: SIGABRT, no diagnostic, exit 134. Bisected — the same call in a
// *fragment* stage compiles, `gl_VertexID` alone compiles, and `texture()`
// in a vertex stage compiles, so it is that one combination. So the coordinate
// is built by hand, `(index + 0.5) / size`, and the sampler is bound nearest
// and clamped: exactly the texel, reached the long way round. The size comes
// down in `morph_params` rather than from `textureSize`, which is one more
// thing that would have to survive the same compiler.
#ifndef MORPH_GLSL_
#define MORPH_GLSL_
/// Rows of the delta texture each target occupies. See the header.
const int kMorphRows = 3;
/// The most targets one draw can blend.
///
/// Eight because glTF's own guidance is that an engine support at least eight
/// active targets, and because a `vec4[2]` is two registers. A model carrying
/// more is not refused — the renderer sends the first eight and says so, which
/// is a face missing an expression rather than a face that will not load.
const int kMorphMax = 8;
uniform sampler2D morph_texture;
layout(std140) uniform MorphInfo {
/// Weight of target *i* at `morph_weights[i / 4][i % 4]`.
vec4 morph_weights[2];
/// x: how many targets are active, as a float.
/// y: one texel across, `1 / width`. z: one texel down, `1 / height`.
/// w unused.
///
/// A count rather than a convention that a zero weight means absent: a
/// target held at exactly nought is a face that is not smiling, and reading
/// it as "the list ends here" would stop the ones after it.
vec4 morph_params;
}
morph_info;
/// How many targets this draw blends.
int MorphCount() { return int(morph_info.morph_params.x + 0.5); }
/// The vertex's own column in the delta texture.
///
/// **`gl_VertexID`, spelt the way SPIR-V spells it.** GLSL ES 3.00 calls the
/// same builtin `gl_VertexID`, and the browser backend's translator rewrites
/// the name on its way out — one substitution beside the ones it already makes
/// for `#version` and `layout(std140)`. Written the other way round, impellerc
/// refuses it outright: "undeclared identifier (Did you mean gl_VertexID?)",
/// which is the friendliest error in this repository.
float MorphColumn() {
return (float(gl_VertexID) + 0.5) * morph_info.morph_params.y;
}
/// Adds target *t*'s deltas onto one vertex, scaled by [weight].
///
/// Split out of [ApplyMorph] so that a stage which gets its weights from
/// somewhere else — `lib/morph_instanced.glsl`, where each instance of a batch
/// wears its own — reads the deltas through the same three lines rather than
/// through a second copy of them.
///
/// [column] and [rowStep] are the caller's, worked out once rather than per
/// target.
///
/// **Splitting this out moved the picture, by 31 pixels of silhouette on
/// Impeller**, and the reference set was re-recorded rather than the split
/// abandoned. The arithmetic is the same arithmetic — it was checked against
/// the software backend, which draws it identically either way — so what moved
/// is what impellerc's optimiser does with a function call it can no longer
/// see through. Hoisting the coordinates was the first guess at the cause and
/// was not it: the same 31 pixels moved with them hoisted. Worth writing down,
/// because the next person to factor a line out of a vertex stage will see a
/// golden fail and reach for the same wrong explanation.
void AddMorphTargetAt(int t, float weight, float column, float rowStep,
inout vec3 position, inout vec3 normal,
inout vec4 tangent) {
float row = (float(t * kMorphRows) + 0.5) * rowStep;
position += texture(morph_texture, vec2(column, row)).xyz * weight;
normal += texture(morph_texture, vec2(column, row + rowStep)).xyz * weight;
tangent.xyz +=
texture(morph_texture, vec2(column, row + rowStep * 2.0)).xyz * weight;
}
/// Adds the blended deltas onto one vertex.
///
/// Called with the attributes as they were read and before anything else
/// touches them — skinning included, which is the order glTF specifies: a
/// skinned morphed mesh morphs in its rest pose and is then posed by the
/// skeleton.
///
/// The tangent is a `vec4` and only its xyz move: w is the bitangent sign, a
/// handedness rather than a direction, and glTF does not morph it.
void ApplyMorph(inout vec3 position, inout vec3 normal, inout vec4 tangent) {
int count = MorphCount();
if (count <= 0) return;
float column = MorphColumn();
float rowStep = morph_info.morph_params.z;
for (int i = 0; i < kMorphMax; i++) {
if (i >= count) break;
float weight = morph_info.morph_weights[i / 4][i % 4];
if (weight == 0.0) continue;
AddMorphTargetAt(i, weight, column, rowStep, position, normal, tangent);
}
}
#endif // MORPH_GLSL_
layout(std140) uniform FrameInfo {
mat4 mvp;
mat4 model;
mat4 normal_matrix;
}
frame_info;
out vec3 v_world_position;
out vec3 v_normal;
out vec2 v_texcoord;
out vec4 v_tangent;
out vec4 v_color;
out vec2 v_lightmap_uv;
void main() {
vec3 morphed_position = position;
vec3 morphed_normal = normal;
vec4 morphed_tangent = tangent;
ApplyMorph(morphed_position, morphed_normal, morphed_tangent);
vec4 world = frame_info.model * vec4(morphed_position, 1.0);
v_world_position = world.xyz;
v_normal = mat3(frame_info.normal_matrix) * morphed_normal;
v_texcoord = texcoord;
v_tangent =
vec4(mat3(frame_info.model) * morphed_tangent.xyz, morphed_tangent.w);
v_color = vec4(1.0);
v_lightmap_uv = color.xy;
gl_Position = frame_info.mvp * vec4(morphed_position, 1.0);
}
''',
'PolylineVertex': r'''#version 300 es
// A polyline of constant screen width, widened here rather than on the CPU —
// gfx-86n.
//
// **Why a vertex stage and not a rebuilt buffer.** The width is a number of
// pixels, so where a band's edges go depends on the camera, and working them
// out on the CPU means rebuilding the whole line every time the camera moves.
// For a route of tens of thousands of points that is a rebuild per frame. Here
// the camera is `frame_info.mvp`, which the renderer already sets per draw, so
// the buffer is written once and a camera move costs a uniform.
//
// **The standard vertex layout, repacked.** A material's vertex stage reads the
// same sixteen floats every mesh does (see mesh.vert), because the engine lays
// out one format for every draw. A line needs different things from a vertex,
// and they fit into the same slots:
//
// position this point
// normal the previous point (this one again at the start of the line)
// texcoord x the distance along the line in metres, y 0 on one side and
// 1 on the other — passed through, for a fragment stage that
// wants dashes or an edge falloff
// tangent xyz the next point (this one again at the end), w the half
// width in pixels, signed for which side of the line this is
// color the colour at this point, which is what makes a gradient
//
// Each point is two vertices, one per side.
//
// **The viewport comes from the material's own block**, because nothing the
// engine binds to a vertex stage carries it: FrameInfo is three matrices and is
// shared with every mesh stage. `Material.parameters` is the channel an
// application already had for its own shaders; the renderer now hands it to a
// vertex stage the material brought as well, so a resize is one parameter
// written and not a rebuilt line.
//
// **A joint's own width, not a mitre.** Earlier this offset each point by a
// bisector of its two neighbouring segments, stretched to keep both segments
// full width through the turn — correct on paper, and unstable in practice:
// the stretch depends on the *angle* between two independently projected
// directions, and a projection has no floor on how extreme an angle it will
// report. A route that turns a modest 30 degrees in three dimensions can
// still turn near 180 degrees on screen from the right camera angle — most
// of it looking down the route's own general plane — and at that point the
// stretch a `stroke-miterlimit`-style clamp allows (four half widths) is
// already enough for a handful of neighbouring joints to cover a shape that
// has itself foreshortened to a sliver, which is what a turn's own
// mathematically-correct mitre looked like exploding into unrelated
// triangles as the camera swept past that angle.
//
// The offset here is instead the perpendicular of one direction per point —
// the direction from the point before this one to the point after it, which
// is exactly the *only* direction there is at either end of the line, where
// one of those two is a copy of this point. No angle between two directions
// is ever computed, so there is nothing here for an extreme projection to
// destabilise; the trade is a corner that can pinch inward on a sharp turn
// rather than one that mitres outward to meet both segments exactly — the
// same trade a plain averaged-tangent ribbon makes, for the same reason, and
// the one gfx-86n's original mitre existed to avoid. A pinch is bounded by
// the line's own half width; the mitre it replaced was not
// bounded by anything a viewer could see coming.
in vec3 position;
in vec3 normal;
in vec2 texcoord;
in vec4 tangent;
in vec4 color;
layout(std140) uniform FrameInfo {
mat4 mvp;
mat4 model;
mat4 normal_matrix;
}
frame_info;
layout(std140) uniform MaterialParams {
/// xy: the render target in pixels. zw unused.
vec4 viewport;
}
params;
out vec3 v_world_position;
out vec3 v_normal;
out vec2 v_texcoord;
out vec4 v_tangent;
out vec4 v_color;
out vec2 v_lightmap_uv;
// The w below which a point is treated as at the eye. Dividing by a w near zero
// sends a neighbour to infinity, and dividing by a negative one mirrors it
// through the centre of the screen — either turns the band sideways.
const float kNear = 1e-4;
// `from`, moved along the segment towards `to` until it is in front of the eye.
//
// A neighbour behind the camera would otherwise divide into the wrong half of
// the screen, and the direction of the segment — which is all a neighbour is
// used for — would point backwards.
vec4 InFront(vec4 from, vec4 to) {
if (from.w >= kNear) return from;
float t = (kNear - from.w) / (to.w - from.w);
return mix(from, to, clamp(t, 0.0, 1.0));
}
// A clip position in pixels from the centre of the viewport.
vec2 ToPixels(vec4 clip, vec2 viewport) {
return clip.xy / clip.w * viewport * 0.5;
}
void main() {
vec2 viewport = params.viewport.xy;
float halfWidth = abs(tangent.w);
float side = tangent.w < 0.0 ? -1.0 : 1.0;
vec4 here = frame_info.mvp * vec4(position, 1.0);
vec4 before = frame_info.mvp * vec4(normal, 1.0);
vec4 after = frame_info.mvp * vec4(tangent.xyz, 1.0);
// A point behind the eye is pulled forward along whichever segment reaches
// the front, so the visible part of that segment is drawn where it is. Both
// neighbours behind as well means nothing of this point is visible, and it
// is left where the clipper will discard it.
if (here.w < kNear) {
here = after.w >= kNear ? InFront(here, after) : InFront(here, before);
}
before = InFront(before, here);
after = InFront(after, here);
// From the point before this one straight to the point after it — skipping
// `here` itself, so an end of the line (where one neighbour is a copy of
// `here`) reduces to the one direction that neighbour alone gives, with
// nothing to fall back from.
vec2 dir = ToPixels(after, viewport) - ToPixels(before, viewport);
float dirLength = length(dir);
// A pair of coincident points on screen — a true zero-length line, not
// just a foreshortened one — has no direction to be wide across; any
// perpendicular is as good as any other for the one degenerate pixel it
// affects.
vec2 segmentNormal = dirLength > 1e-9
? vec2(-dir.y, dir.x) / dirLength
: vec2(1.0, 0.0);
vec2 offset = segmentNormal * halfWidth * side;
// Back from pixels to clip space, at this point's own w, so the offset is the
// same number of pixels at every depth.
gl_Position =
vec4(here.xy + offset / (viewport * 0.5) * here.w, here.zw);
v_world_position = (frame_info.model * vec4(position, 1.0)).xyz;
// A band has no normal of its own. Up is what a route drawn over ground
// faces, and it is a unit vector, which is what ReadSurface normalises —
// zero would be a NaN in every screen-space effect that reads the buffer.
v_normal = normalize(mat3(frame_info.normal_matrix) * vec3(0.0, 1.0, 0.0));
v_texcoord = texcoord;
v_tangent = vec4(1.0, 0.0, 0.0, 1.0);
v_color = color;
v_lightmap_uv = vec2(0.0);
}
''',
'ParticleVertex': r'''#version 300 es
// Vertex stage for particles.
//
// The quads arrive already facing the camera. Billboarding on the CPU rather
// than here is the cheaper arrangement for this engine: the alternative expands
// a point into a quad in the vertex stage, which needs either a geometry stage
// — flutter_gpu has none — or four vertices carrying the same centre plus a
// corner index, which is the same bandwidth this uses with an extra
// reconstruction on top.
//
// A third layout, and therefore a third vertex shader: flutter_gpu reads the
// layout from the order of these declarations, so position + colour + texcoord
// cannot share a stage with anything else. See VertexLayout.positionColorTexcoord.
in vec3 position;
in vec4 color;
in vec2 texcoord;
layout(std140) uniform ParticleInfo {
mat4 view_projection;
}
particle_info;
out vec4 v_color;
out vec2 v_uv;
/// Carried so the fragment stage can be fogged. A particle knows where it is
/// only here; the quad's own coordinates say nothing about the world.
out vec3 v_world_position;
void main() {
v_color = color;
v_uv = texcoord;
v_world_position = position;
gl_Position = particle_info.view_projection * vec4(position, 1.0);
}
''',
'ParticleMeshVertex': r'''#version 300 es
// Vertex stage for mesh particles: one mesh, drawn once per particle.
//
// The billboard path (particle.vert) expands each particle into a quad on the
// CPU and sends four vertices per particle. That is the right trade for a
// sprite, where the quad *is* the particle and building it costs four writes.
// It is the wrong trade for a mesh: a hundred embers of forty vertices each
// would be four thousand vertices rewritten every frame, when the geometry
// never changes and only the placement does.
//
// So this reads two buffers. The mesh sits in slot 0 and is uploaded once; the
// placements sit in slot 1, are rebuilt each frame, and step once per instance.
// That split is the whole reason `VertexLayoutSpec` exists.
in vec3 position;
in vec3 normal;
/// Where this instance's copy of the mesh goes, in world space.
in vec3 i_position;
/// Linear RGB with alpha as brightness, matching Particle.color. These draw
/// additively, so alpha is not coverage.
in vec4 i_color;
/// Uniform scale. One number rather than three, because a particle's size is
/// one number everywhere else in this engine and a non-uniform scale would need
/// the normal transformed by an inverse transpose to stay a normal.
in float i_scale;
layout(std140) uniform ParticleMeshInfo {
mat4 view_projection;
}
particle_mesh_info;
out vec4 v_color;
out vec3 v_world_position;
out vec3 v_normal;
void main() {
// Scale and translate, and no rotation. A rotation per instance is four more
// floats and a matrix build per vertex; it is worth having and it is not
// worth guessing at before something asks. What is here is what an ember or a
// shard needs: a size, a place, and a colour.
vec3 world = i_position + position * i_scale;
v_color = i_color;
v_world_position = world;
// Uniform scale leaves a normal a normal, which is the second reason the
// scale is one number.
v_normal = normal;
gl_Position = particle_mesh_info.view_projection * vec4(world, 1.0);
}
''',
'ShadowTileResetVertex': r'''#version 300 es
// Vertex stage for the atlas tile reset. See shadow_tile_reset.frag.
//
// The same oversized triangle every full-screen pass uses, with one difference
// that matters: **z sits on the far plane, not at zero.**
//
// post/fullscreen.vert emits z = 0, which is mid-depth. That is harmless for a
// post pass, where nothing depth-tests afterwards. Here the casters are drawn
// into the same tile immediately after, comparing `less` against a buffer this
// triangle has just covered — and a mid-depth value stamped across the tile
// makes every caster beyond it fail the test and vanish. It showed up as a
// shadow that was present before the tile reset existed and missing after:
// 423 pixels of `cube-shadow`, all inside the one occupied tile.
//
// Depth writes are switched off for this draw as well, so in principle the
// value is never stored. Writing the far plane anyway costs nothing and means
// the pass does not depend on that being true — which is worth more than the
// elegance, given the value written would be invisible right up until it
// silently deleted a shadow.
in vec2 position;
in vec2 texcoord;
out vec2 v_uv;
void main() {
v_uv = texcoord;
gl_Position = vec4(position, 1.0, 1.0);
}
''',
'SkyVertex': r'''#version 300 es
// Vertex stage for the sky: one full-screen triangle, and everything the
// fragment stage needs, carried on the vertices.
//
// **The sky's data travels as attributes because uniforms do not reach this
// pipeline on Impeller.** That is not a guess and not a workaround chosen for
// taste; it is the one channel that was measured to work. What was measured,
// each against a frame recorded from a real Metal device with the golden runner
// (`tool/golden.sh sky`), and each with the picture read back rather than eyed:
//
// * a uniform block bound to this stage — an identity matrix was bound and the
// shader read something else, so the picture never changed;
// * a uniform block bound to the fragment stage — a pure red zenith was bound
// and the shader saw something that was not red;
// * a vertex attribute — arrived exactly, to the value bound;
// * a varying — interpolated across the triangle exactly.
//
// The same two binds work everywhere else in this renderer, in the same pass,
// in the same frame: every mesh takes its matrices this way and every
// post-processing stage takes its settings this way. Why this pipeline is
// different is not known. What is known is which door is open.
//
// The cost is a vertex buffer of three vertices rebuilt each frame, which is
// 348 bytes through the transient allocator — less than one uniform upload.
//
// ---------------------------------------------------------------------------
//
// **The depth.** A post pass writes `gl_Position.z = 0.0`, which is the *near*
// plane; the sky belongs at the far one. This writes 0.999999 — the far plane,
// less a hair. Strictly less than 1.0 so that the ordinary `less` test passes
// against a buffer cleared to 1.0, which is what lets the sky be drawn with the
// pass's own depth state and no `setDepthCompare` at all. With depth writes off
// it never occludes anything, and because it is drawn after the opaque half,
// every pixel already covered by geometry fails the test before the fragment
// stage runs.
//
// **The ray.** One direction per corner, computed on the CPU from the inverse
// view-projection and interpolated across the triangle — which for a
// perspective camera is exact, because the direction is affine in the screen
// position. The renderer builds them; see `Renderer._skyCornerRay`.
precision highp float;
layout(location = 0) in vec2 position;
// The world-space view ray at this corner.
layout(location = 1) in vec3 corner_ray;
// The preset, replicated on all three vertices. Constant across the triangle,
// so any interpolation of it returns exactly what was written.
layout(location = 2) in vec4 zenith;
layout(location = 3) in vec4 horizon;
layout(location = 4) in vec4 nadir;
/// xyz: unit vector pointing at the sun. w: how tight the scattering lobe is.
layout(location = 5) in vec4 sun;
/// rgb: the sun's own colour. a: how bright the lobe is.
layout(location = 6) in vec4 glow;
/// x: cosine of the disc's angular radius. y: cosine of the radius plus its
/// soft edge. z: how bright the disc is. w: unused.
layout(location = 7) in vec4 disc;
out vec3 v_ray;
out vec4 v_zenith;
out vec4 v_horizon;
out vec4 v_nadir;
out vec4 v_sun;
out vec4 v_glow;
out vec4 v_disc;
void main() {
v_ray = corner_ray;
v_zenith = zenith;
v_horizon = horizon;
v_nadir = nadir;
v_sun = sun;
v_glow = glow;
v_disc = disc;
gl_Position = vec4(position, 0.999999, 1.0);
}
''',
'SkyCubeVertex': r'''#version 300 es
// Vertex stage for the cube-map sky: the same full-screen triangle as
// `sky.vert`, with the one value that stage carries instead of a preset.
//
// **Its own stage rather than a shared one**, because the vertex layout is
// derived from these declarations and the two fragment stages want different
// things: the gradient wants six vec4s of preset, this wants a tint. One
// shader serving both would have to declare the union and every draw would
// carry what the other one needed.
//
// Why any of it travels on the vertices at all is written out in `sky.vert`.
precision highp float;
layout(location = 0) in vec2 position;
/// The world-space view ray at this corner.
layout(location = 1) in vec3 corner_ray;
/// rgb: what the sampled cube is multiplied by. a: unused.
layout(location = 2) in vec4 tint;
out vec3 v_ray;
out vec4 v_tint;
void main() {
v_ray = corner_ray;
v_tint = tint;
gl_Position = vec4(position, 0.999999, 1.0);
}
''',
'VertexTextureProbeVertex': r'''#version 300 es
// **A probe, not a feature.** It answers one question that decides how morph
// targets are drawn: can a *vertex* stage sample a texture on this backend?
//
// Nothing else in this engine samples anything in a vertex stage. The two
// routes for morphing on the GPU are a second vertex layout — and with it a
// second vertex shader per lighting model, because the layout here is
// structural — or the deltas in a texture read by vertex id, which needs
// exactly this. WebGL2 guarantees it by specification (GLES 3.0 requires at
// least sixteen vertex texture units) and the software rasteriser is our own
// code; flutter_gpu is the unknown, and an unknown that a comment cannot
// settle.
//
// So the answer is measured on every backend rather than assumed on two and
// hoped for on the third. `checkVertexTextureSampling` in
// flutter3d_conformance is what reads it: this stage passes what it sampled
// through to the fragment stage unchanged, so a frame read back holds the
// texture's own colour when the sample worked and the clear colour when the
// draw never landed.
in vec3 position;
/// The texture the vertex stage reads. One texel is enough: what is being
/// asked is whether the read happens at all, not whether it filters.
uniform sampler2D probe_texture;
layout(std140) uniform ProbeInfo {
/// xy: where to sample. zw unused.
vec4 at;
}
probe_info;
out vec4 v_sampled;
void main() {
// `textureLod` and not `texture`: a vertex stage has no derivatives, so the
// level has to be named. Asking for an implicit one is undefined and is the
// shape of a probe that reports a driver's opinion rather than a fact.
v_sampled = textureLod(probe_texture, probe_info.at.xy, 0.0);
gl_Position = vec4(position, 1.0);
}
''',
},
<String, String>{
'Unlit': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;
// Albedo only. Useful as a baseline: whatever this shows is purely texture and
// tint, with no lighting term involved.
// This model has no shadow term, and `LightingModel.unlit` says so with
// `usesMaterialMaps: false` — so the engine binds no `PointShadow` block. The
// header must therefore not declare one: a block declared and unbound is a
// dropped draw on WebGL2 and a phantom bind on Impeller. See surface.glsl.
#define F3D_NO_POINT_SHADOW
// The light list too, for the same reason, and that one was not caught before
// 0.7.0 shipped: see surface.glsl.
#define F3D_NO_LIGHT_LIST
// --- lib/surface.glsl ---
// Shared material and lighting interface for the lighting models.
//
// flutter_gpu compiles shaders ahead of time into a bundle: there is no runtime
// compilation, so a node-graph material system assembled at run time is
// impossible. Each lighting model is therefore
// its own pre-built fragment shader, and this header is what keeps them
// interchangeable — one identical uniform block, so the Dart binding code never
// needs to know which model is active.
//
// Keep every declaration below byte-identical across models. A member a model
// does not read may be optimized out of the reflected block, which is why the
// Dart side skips absent members instead of failing.
//
// Only include this from a shader that actually reads FragInfo. Declaring the
// block without using it leaves it visible to reflection while the compiled
// shader binds no buffer for it, and binding that phantom block segfaults
// inside Metal. Shaders needing only colour helpers include lib/color.glsl.
#ifndef SURFACE_GLSL_
#define SURFACE_GLSL_
// --- lib/color.glsl ---
// Colour space helpers and the fragment output interface.
//
// Split out of surface.glsl so a shader that needs no material inputs — the
// normals debug view — can avoid DECLARING the FragInfo uniform block at all.
// That matters more than it looks: reflection metadata reports a block as
// present merely because it was declared, even when the compiled shader binds
// no such buffer, so a declared-but-unused block is indistinguishable from a
// used one until Metal crashes on the bind.
#ifndef COLOR_GLSL_
#define COLOR_GLSL_
precision highp float;
const float kPi = 3.14159265359;
// One varying set shared by every fragment shader, matching mesh.vert.
//
// All five are declared here, including the two the debug models never read: a
// fragment shader whose `in` block disagrees with the vertex shader's `out`
// block fails to link, and there is no partial-match rule to lean on.
in vec3 v_world_position;
in vec3 v_normal;
in vec2 v_texcoord;
in vec4 v_tangent;
in vec4 v_color;
/// Where this fragment is in the level's lightmap. Zero from every vertex
/// stage but `mesh_lightmapped.vert`, and read only by the lit models, which
/// sample a one-texel black there when a material has no map.
in vec2 v_lightmap_uv;
layout(location = 0) out vec4 frag_color;
// The second attachment: what a screen-space effect needs to know about the
// surface it is looking at. World-space normal in rgb, and in a the depth along
// the view axis in world metres — not a window depth; `WriteSurfaceGeometry`
// says at length why not.
//
// Depth travels here rather than in a depth texture because flutter_gpu cannot
// sample one — the same reason the shadow pass writes its depth into a colour
// target. See ARCHITECTURE.md §2.
//
// Guarded, because not every stage that includes this header draws into a
// two-attachment target. The shadow pass draws into one, and a pipeline
// declaring an output its target has no slot for is a mismatch worth avoiding
// rather than discovering.
#ifndef F3D_NO_SURFACE_BUFFER
layout(location = 1) out vec4 frag_surface;
#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;
// **A stage that needs none of this must be able to declare none of it.** On
// Vulkan both stages' descriptors are merged into one set layout, and two
// bindings with the same number in it is not a layout the specification
// allows. A driver may accept it anyway; a Galaxy A55's refuses the pipeline
// with `ErrorUnknown` and no other word, which is how the shadow pass came to
// build everywhere except there — its only uniform block was this one, and it
// landed on the same binding as the vertex stage's first.
#ifndef F3D_NO_FOG
/// Distance fog, in its own block rather than folded into FragInfo.
///
/// Its own because color.glsl is included before FragInfo is declared, and
/// because appending to a block that half a dozen shaders already share is a
/// way to move offsets nobody expected to move. Three vec4s is a cheap price
/// for not touching any of that.
layout(std140) uniform FogInfo {
/// rgb: linear fog colour. w: density per metre, zero for no fog.
vec4 fog;
/// xyz: camera position in world space. Duplicated from FragInfo so this
/// block stands alone; a vec3 is cheaper than a coupling.
vec4 eye;
/// xyz: the direction the camera looks, as a unit vector in world space.
/// w unused.
///
/// Here rather than in a block of its own because it answers the same
/// question [eye] does — where the camera is and which way it faces — and
/// this is the block `color.glsl` can see.
vec4 forward;
}
fog_info;
/// How far this fragment is from the eye, in world metres.
///
/// What the fog fades by. Distance rather than depth, because fog is a
/// property of the air between two points and does not care which way the
/// camera happens to face.
float EyeDistance() { return distance(v_world_position, fog_info.eye.xyz); }
/// How far this fragment is *along the view axis*, in world metres.
///
/// What the surface buffer's alpha holds. Depth rather than distance, and the
/// difference only shows on an orthographic camera — where the rays through
/// the pixels are parallel instead of meeting at the eye, so a distance from
/// the eye names a sphere that the pixel's ray crosses somewhere the reader
/// cannot solve for. A depth along the axis names a plane, which every ray
/// crosses exactly once. See `WorldAtDepth` in `post/ssao.frag` for the
/// reconstruction both projections share.
float ViewDepth() {
return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz);
}
#else // F3D_NO_FOG
// The same two questions, answered without the block: a stage that declares no
// fog has no eye position to measure from either. Stubs rather than a guard at
// every call site, so that what includes this file reads the same whichever
// way it was compiled.
float EyeDistance() { return 0.0; }
float ViewDepth() { return 0.0; }
#endif // F3D_NO_FOG
/// Records the geometry of this fragment for whatever runs after the scene.
///
/// Called from the same place that writes colour, so a surface cannot be lit
/// into the frame without also describing itself — which is the failure that
/// leaves a screen-space effect reflecting whatever was in the buffer before.
///
/// rg: octahedral normal. b: perceptual roughness. a: **depth along the view
/// axis, in world metres** — see [ViewDepth].
///
/// **Not `gl_FragCoord.z`, and that is a defect this channel carried until it
/// was looked at.** Window depth crowds every distant surface into the top of
/// its range — with a near plane of a tenth of a metre, everything past twenty
/// metres lives in the last half a hundredth of `[0, 1]` — and this attachment
/// is a half float, whose steps up there are about five ten-thousandths. So two
/// surfaces half a metre apart at twenty metres stored the *same* number, and
/// every screen-space pass that compares against this channel decided whole
/// bands of pixels by rounding. The occlusion pass drew them: vertical stripes
/// along the lines of constant depth on any wall receding from the camera, on
/// both GPU backends. The software rasteriser kept the channel at full
/// precision and drew the effect correctly, so it was the one that looked
/// wrong against the other two.
///
/// A depth in metres has none of that: the exponent carries the range and the
/// mantissa carries the same relative precision everywhere, which at twenty
/// metres is a centimetre. Both numbers are measured in
/// `flutter3d/test/surface_depth_test.dart`.
///
/// Zero still means nothing was drawn. The attachment is cleared to zero and
/// nothing is drawn in front of the near plane.
void WriteSurfaceGeometry(float roughness) {
#ifndef F3D_NO_SURFACE_BUFFER
// A debug pass takes the buffer over rather than getting one of its own.
// The surface buffer already has an attachment, a viewer and a golden; a
// second one would need all three built before it could answer anything.
if (g_debug_surface_on) {
frag_surface = vec4(g_debug_surface, ViewDepth());
return;
}
frag_surface = vec4(EncodeOctahedral(normalize(v_normal)),
clamp(roughness, 0.0, 1.0), ViewDepth());
#endif
}
/// Fades [color] toward the fog with distance from the eye.
///
/// Exponential rather than linear, because linear fog has a visible plane
/// where it starts and a dungeon corridor is exactly where that shows.
vec3 ApplyFog(vec3 color) {
#ifdef F3D_NO_FOG
return color;
#else
float density = fog_info.fog.w;
if (density <= 0.0) return color;
float d = EyeDistance();
return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0));
#endif
}
/// 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
/// How many more lights one draw may be handed — `gfx-74n`.
///
/// **The eight above stay exactly what they were**, which is what keeps this
/// from moving a single recorded frame: a draw with eight lights or fewer runs
/// the loop it has always run, reads the uniform arrays it has always read, and
/// never touches the texture below. The tail is the part that used to be
/// impossible.
///
/// A loop bound rather than a cost. `AccumulateLights` breaks at the draw's own
/// count, so a scene with three lights costs three iterations whatever this
/// says. Twenty-four because the two tables below are `vec4 x[6]` and four
/// lanes fit a `vec4`: two hundred and eight bytes a draw, against the five
/// hundred and twelve the light arrays already cost.
#define kExtraLights 24
#define kTotalLights (kMaxLights + kExtraLights)
/// Every light in the scene, one per row, four texels across — `gfx-74n`.
///
/// **A texture rather than a wider uniform block, and that is the design.**
/// `FragInfo` is uploaded on every draw, so widening its four `vec4` arrays to
/// hold thirty-two lights would be a two-kilobyte upload per draw in every
/// scene, including every scene with one light. This is built once a frame and
/// only when a scene has more lights than a draw can hold in its slots.
///
/// Row layout, which `renderer_light_list.dart` writes and only this reads:
///
/// * texel 0 — xyz world position, w type (0 directional, 1 point, 2 spot)
/// * texel 1 — rgb linear colour, w intensity
/// * texel 2 — xyz the direction it points, w range
/// * texel 3 — x cos(inner), y cos(outer), zw unused
///
/// The same four vectors the uniform arrays hold, in the same order, so one
/// reader serves both.
///
/// **`F3D_NO_LIGHT_LIST` leaves both out**, for a model that accumulates no
/// lights. Such a model never reaches the reader below, so the compiler drops
/// the block and the sampler from the Metal function while reflection still
/// lists them, with no buffer or texture index assigned. The renderer used to
/// bind them for every draw, Unlit included, and that bind is a crash inside
/// `setFragmentBuffer:offset:atIndex:` on Metal. Vulkan took the same draw
/// without a word, which is how 0.7.0 shipped with it.
#ifndef F3D_NO_LIGHT_LIST
uniform sampler2D light_list_texture;
layout(std140) uniform LightListInfo {
/// x: how many rows this draw reads, zero when it reads none.
/// y, z: one over the texture's width and height.
/// w: unused.
vec4 list;
/// Which rows, four to a vector, in the order they are read.
///
/// Indices rather than the light data itself: the data is the same for every
/// draw in the frame and belongs in the texture; what differs per draw is
/// *which* of them reach it, and that is what `Renderer._drawLightsFor`
/// already decides.
vec4 indices[6];
/// How much of each of those survives the edge fade, in the same order.
///
/// Per draw and not in the texture, because the row an index points at is
/// shared by every draw in the frame: a scale written into it would dim that
/// light for all of them. `gfx-12n`'s fade lives at the end of the list now —
/// that is where a light stops contributing, and fading the slots against a
/// water line that no longer marks a cliff would dim a light for no reason
/// while its rival stayed bright, making the swap more visible rather than
/// less.
vec4 scales[6];
}
light_list_info;
/// One lane of a six-vector table, [slot] counting from nought.
float LightListLane(vec4 four, int slot) {
int lane = slot - (slot / 4) * 4;
return lane == 0 ? four.x : lane == 1 ? four.y : lane == 2 ? four.z : four.w;
}
/// The row light [slot] of the list reads.
float LightListRow(int slot) {
return LightListLane(light_list_info.indices[slot / 4], slot);
}
/// How much of light [slot] of the list survives the edge fade.
float LightListScale(int slot) {
return LightListLane(light_list_info.scales[slot / 4], slot);
}
#endif // F3D_NO_LIGHT_LIST
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 is the directional light's apparent size** — `gfx-15n` — which has
/// nothing to do with ambient and everything to do with this being the last
/// unspent component in a block six shaders share. `frame_params.w` was the
/// slot reserved for a frame-wide parameter and the environment's level
/// count took it; appending to this block moves offsets four backends have
/// agreed on. See `shadow.glsl`, which reads it.
///
/// Two colours rather than one is the whole of what makes ambient look like
/// light instead of like a lifted black level. Outdoors the sky is blue and
/// bright and the ground is warm and dim, and a flat grey for both leaves
/// every underside as pale as every upward face — which reads as the model
/// being flat, and gets blamed on the normals.
vec4 ambient_ground;
}
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.
//
// **A cutoff below -1.5 is the fourth mode: hashed** — `gfx-16n`. The
// sentinel rides in the same component because the alternative is a second
// number in a block six shaders share, and -1 already meant "not masked";
// anything more negative was free. See [MaterialAlphaMode.hashed].
float cutoff = frag_info.material2.x;
if (cutoff >= 0.0) {
if (s.alpha < cutoff) discard;
} else if (cutoff < -1.5) {
// **Stochastic instead of a threshold.** A leaf texture at 40% opacity is
// either entirely there or entirely gone under a fixed cutoff, so a fern
// comes out as a hard-edged cardboard cut-out; sorting would fix it and
// costs a sort per frame and a draw per layer. Comparing against noise
// instead keeps 40% of the *pixels*, which resolves as 40% opacity to
// anything that averages several of them — a higher-resolution target,
// a downsample, a person standing back.
//
// **Hashed on world position, not on the screen.** Screen-space noise is
// one line shorter and swims: the pattern stays put while the object
// moves through it, so a moving branch sparkles. Anchoring it to where
// the surface *is* means a given speck of leaf keeps its verdict from
// frame to frame, and the camera moving changes nothing.
//
// The scale is a constant and it is the whole tuning: finer than the
// texture's own detail and the noise disappears into aliasing, coarser
// and the leaf breaks into blotches. Sixteen per metre is about a
// centimetre of grain at a metre away.
vec3 anchored = floor(v_world_position * 16.0);
float noise = fract(
sin(dot(anchored, vec3(12.9898, 78.233, 37.719))) * 43758.5453);
if (s.alpha < noise) discard;
}
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() {
#ifdef F3D_NO_LIGHT_LIST
return clamp(int(frag_info.frame_params.y + 0.5), 0, kMaxLights);
#else
return clamp(int(frag_info.frame_params.y + 0.5), 0, kMaxLights) +
clamp(int(light_list_info.list.x + 0.5), 0, kExtraLights);
#endif
}
/// Whether light [index] carries a shadow — `gfx-74n`.
///
/// Only the first eight do. The cube atlas holds six rows and the slot table is
/// eight entries wide, so a light from the list has no row to read and asking
/// for one would index past the table. That is a real limit and the right one:
/// the eight a draw keeps in its slots are the eight ranked most relevant to
/// it, which is exactly the set worth a shadow map.
bool LightHasShadow(int index) { return index < kMaxLights; }
/// Distance attenuation for a punctual light, following the glTF spec.
///
/// Inverse square with an optional range window. The window is what stops a
/// lamp with a declared range from contributing a faint haze across the whole
/// scene, which matters far more once there are eight of them.
float PunctualAttenuation(float distance, float range) {
float attenuation = 1.0 / max(distance * distance, 1e-4);
if (range > 0.0) {
float ratio = distance / range;
float window = clamp(1.0 - ratio * ratio * ratio * ratio, 0.0, 1.0);
attenuation *= window * window;
}
return attenuation;
}
/// How much of [s]'s sky a rectangle covers, weighted by the cosine —
/// `gfx-77n`.
///
/// **Exact, not fitted.** This is Lambert's own form factor for a polygon, from
/// 1760: for each edge, the angle it subtends at the shading point times how
/// much the edge's plane leans into the surface normal. Summed over four edges
/// and halved, it *is* the integral of `cos θ` over the rectangle's projection
/// on the hemisphere — the quantity a punctual light approximates with a single
/// `n · l`. So there is no table to ship and nothing to fit: the usual
/// linearly-transformed-cosine approach exists to make the *specular* lobe
/// tractable, and buys nothing here, where the diffuse answer is a closed form
/// four `acos` calls long.
///
/// Returns irradiance over radiance, so a surface facing a rectangle that fills
/// its whole sky gets π, the same as a uniform hemisphere. [corners] are the
/// four vertices in order, relative to the shading point.
///
/// **The rectangle emits along `cross(halfWidth, halfHeight)`**, and with the
/// corners wound as `SampleLight` winds them the sum comes out *negative* on
/// that side, so the negation below is the convention rather than a fix. It was
/// measured rather than derived: the first version returned `+total * 0.5`, and
/// against the reference integration it read nought where the answer was 0.349
/// and 1.02 where the answer was nought — the two failures a flipped winding
/// produces, and between them they name the sign with no room left to argue.
float RectangleFormFactor(vec3 corners[4], vec3 n) {
float total = 0.0;
for (int i = 0; i < 4; i++) {
vec3 a = normalize(corners[i]);
vec3 b = normalize(corners[(i + 1) & 3]);
// Clamped before the `acos`: two nearly parallel edge directions can give a
// dot a hair past one through rounding alone, and `acos` of that is a NaN
// that spreads to the whole pixel and then to the bloom.
float angle = acos(clamp(dot(a, b), -1.0, 1.0));
vec3 axis = cross(a, b);
float len = length(axis);
// A degenerate edge — the shading point lies on the line through it —
// subtends nothing, and normalising a zero vector is the other way to get
// that NaN.
if (len > 1e-6) total += angle * dot(axis / len, n);
}
// Clamped rather than tested separately: a surface on the panel's dark side,
// or facing away from it, comes out with the sign reversed, so "one-sided" is
// a property of the arithmetic instead of a flag somebody has to remember.
return max(-total * 0.5, 0.0);
}
/// Where on the rectangle the specular lobe is really looking — `gfx-77n`.
///
/// **The representative point, which is an approximation, unlike the diffuse
/// above.** The mirror direction leaves the surface and either hits the panel
/// or misses it; the closest point of the panel to that ray is treated as a
/// punctual light standing in for the whole rectangle. It is the standard
/// cheap answer and its one visible property is the one the row asked for: as
/// the view moves the closest point slides along the panel, so the highlight
/// is a streak with the panel's own shape and orientation rather than a dot.
///
/// What it does not do is widen the lobe by the panel's solid angle, so a
/// rough surface under a large panel is a little darker than a full integration
/// would make it. That is a known error of this method and not a bug in this
/// transcription; the fix is the fitted table this function exists to avoid.
vec3 RectangleClosestPoint(vec3 centre, vec3 halfWidth, vec3 halfHeight,
vec3 world, vec3 mirror) {
vec3 n = cross(halfWidth, halfHeight);
float nLen = length(n);
// A panel with no area has no surface to find a point on; its centre is the
// only answer that is not a division by zero.
if (nLen < 1e-12) return centre;
n /= nLen;
vec3 toPlane = centre - world;
float denom = dot(mirror, n);
vec3 onPlane;
// Parallel to the panel, or pointing away from it: the ray never lands, so
// the nearest thing to it is the centre projected back, which keeps the
// highlight on the panel instead of sending it to infinity.
if (abs(denom) < 1e-5) {
onPlane = toPlane - n * dot(toPlane, n);
} else {
float t = dot(toPlane, n) / denom;
onPlane = t > 0.0 ? mirror * t : toPlane - n * dot(toPlane, n);
}
// Clamped into the rectangle in its own axes. Dividing by the squared length
// turns a projection into a coordinate in units of the half-extent, so the
// clamp is against one either way round.
vec3 offset = onPlane - toPlane;
float wLen2 = max(dot(halfWidth, halfWidth), 1e-12);
float hLen2 = max(dot(halfHeight, halfHeight), 1e-12);
float u = clamp(dot(offset, halfWidth) / wLen2, -1.0, 1.0);
float v = clamp(dot(offset, halfHeight) / hLen2, -1.0, 1.0);
return centre + halfWidth * u + halfHeight * v;
}
/// 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;
vec4 color;
vec4 direction;
vec4 cone;
if (index < kMaxLights) {
position = frag_info.light_position[index];
color = frag_info.light_color[index];
direction = frag_info.light_direction[index];
cone = frag_info.light_cone[index];
} else {
#ifdef F3D_NO_LIGHT_LIST
// Unreachable: `LightCount` stops at the slots without a list.
position = vec4(0.0);
color = vec4(0.0);
direction = vec4(0.0);
cone = vec4(0.0);
#else
// A row of the light list — `gfx-74n`. Sampled at texel centres so a
// driver's rounding cannot land a fetch on a neighbour, and the four texels
// across the row are the same four vectors the arrays above hold.
int slot = index - kMaxLights;
float v = (LightListRow(slot) + 0.5) * light_list_info.list.z;
float u = light_list_info.list.y;
// `textureLod` and not `texture`, for `shadow.glsl`'s own reason: `index`
// reaches this branch through a function parameter, so a WGSL backend
// cannot see that every invocation of a draw walks the same light count
// and refuses the implicit derivative as possibly non-uniform. The atlas
// has one level, so naming it directly changes no pixel.
position = textureLod(light_list_texture, vec2(0.5 * u, v), 0.0);
color = textureLod(light_list_texture, vec2(1.5 * u, v), 0.0);
direction = textureLod(light_list_texture, vec2(2.5 * u, v), 0.0);
cone = textureLod(light_list_texture, vec2(3.5 * u, v), 0.0);
// The intensity and not the colour, for `LightBuffer._pack`'s own reason:
// the same multiply here, and only one of them is a number nobody authored.
color.w *= LightListScale(slot);
#endif // F3D_NO_LIGHT_LIST
}
float type = position.w;
// **The rectangle leaves before `aim` is taken — `gfx-77n`.** For every other
// kind `direction.xyz` is a unit vector saying which way the light points;
// for this one it is an edge of the panel, with its length carrying half the
// width, and normalising it here would quietly throw the size away.
if (type > 2.5) {
vec3 halfWidth = direction.xyz;
vec3 halfHeight = cone.xyz;
vec3 toCentre = position.xyz - v_world_position;
vec3 corners[4];
corners[0] = toCentre - halfWidth - halfHeight;
corners[1] = toCentre + halfWidth - halfHeight;
corners[2] = toCentre + halfWidth + halfHeight;
corners[3] = toCentre - halfWidth + halfHeight;
// The cosine-weighted solid angle, which takes the place `n · l` holds for
// a punctual light: the loop multiplies the shading by `n_dot_l`, so
// putting the exact integral here makes the diffuse term exact rather than
// sampled. See [RectangleFormFactor].
float formFactor = RectangleFormFactor(corners, s.n);
// Radiance rather than intensity: `intensity` means the same thing for
// every kind of light, so a panel's is spread over its own area here.
// Enlarging a window at a fixed rating then dims it per square metre and
// leaves the room as bright, which is what the number is supposed to mean.
float area = length(cross(halfWidth, halfHeight)) * 4.0;
float radiance = area > 1e-9 ? 1.0 / area : 0.0;
// The range window only. A punctual light needs the inverse square as
// well; the form factor already contains it, because a panel twice as far
// away subtends a quarter of the sky.
float distance = length(toCentre);
if (direction.w > 0.0) {
float ratio = distance / direction.w;
float window = clamp(1.0 - ratio * ratio * ratio * ratio, 0.0, 1.0);
radiance *= window * window;
}
vec3 mirror = reflect(-s.v, s.n);
vec3 representative = RectangleClosestPoint(
position.xyz, halfWidth, halfHeight, v_world_position, mirror);
vec3 toPoint = representative - v_world_position;
float pointDistance = length(toPoint);
light.l = pointDistance > 1e-6 ? toPoint / pointDistance : s.n;
light.h = normalize(light.l + s.v);
light.n_dot_l = formFactor;
light.n_dot_h = max(dot(s.n, light.h), 0.0);
light.v_dot_h = max(dot(s.v, light.h), 0.0);
light.radiance = color.rgb * color.w * radiance;
return light;
}
vec3 aim = normalize(direction.xyz);
float attenuation = 1.0;
if (type < 0.5) {
// Directional: no position, no falloff. The direction to the light is the
// reverse of the direction it points.
light.l = -aim;
} else {
vec3 toLight = position.xyz - v_world_position;
float distance = length(toLight);
// A light exactly on the surface has no direction; treat it as contributing
// nothing rather than producing a NaN that spreads through the frame.
if (distance < 1e-6) {
light.l = s.n;
light.h = s.n;
light.radiance = vec3(0.0);
light.n_dot_l = 0.0;
light.n_dot_h = 0.0;
light.v_dot_h = 0.0;
return light;
}
light.l = toLight / distance;
attenuation = PunctualAttenuation(distance, direction.w);
if (type > 1.5) {
// Spot: a smooth ramp between the two cone cosines. The Dart side already
// guarantees the denominator is non-zero.
float cosAngle = dot(aim, -light.l);
attenuation *= clamp(
(cosAngle - cone.y) / (cone.x - cone.y), 0.0, 1.0);
}
}
light.h = normalize(light.l + s.v);
light.n_dot_l = max(dot(s.n, light.l), 0.0);
light.n_dot_h = max(dot(s.n, light.h), 0.0);
light.v_dot_h = max(dot(s.v, light.h), 0.0);
light.radiance = color.rgb * color.w * attenuation;
return light;
}
/// How much of light [index] reaches this fragment, defined by each fragment
/// shader.
///
/// A prototype rather than a call into shadow.glsl, because the models that
/// sample no shadow map must not declare its sampler — the compiler would drop
/// the slot and leave the engine binding one that is not there. A lit model
/// returns `ShadowFactor(...)`; an unlit one returns 1.
float LightVisibility(Surface s, LightSample light, int index);
/// A model's per-light term, defined by each fragment shader.
///
/// A prototype here and the definition in the model is what lets the loop below
/// be written once. The alternative — repeating the loop in every model — is
/// six copies of the same three lines, and the place a light would go missing.
vec3 ShadeLight(Surface s, LightSample light);
/// Sums every active light's contribution.
///
/// The loop bound is the compile-time maximum with a runtime break, because GLSL
/// wants a constant trip count and the hardware wants the early exit.
// **The point-shadow half of this header, behind a guard.**
//
// A model that never shadows must not *declare* any of this, and the reason is
// the one `unlit.frag` already gives about the shadow sampler — with one
// backend's failure added to the other's. On Impeller the compiler drops what
// nothing reads, and the engine binding a slot that is no longer there is a
// native crash. On WebGL2 nothing is dropped: an active uniform block with no
// buffer under it makes every draw `INVALID_OPERATION`, discarded with nothing
// logged.
//
// That is what `lighting-unlit` was on this backend. Unlit's own metadata says
// `usesPointShadow` is false, so the engine correctly bound no `PointShadow`
// block — and the translated shader declared one anyway, so the sphere was
// never drawn and the frame came back the clear colour.
#ifndef F3D_NO_POINT_SHADOW
/// The cube atlas: three tiles across, two down, each a ninety-degree view
/// from a point light, each storing radial distance normalised by range.
uniform sampler2D point_shadow_texture;
/// The same atlas for the things that never move, rendered once at load.
///
/// Two maps rather than one because a dungeon's walls can be baked and a
/// spinning pickup cannot, and there is no way to draw into part of a texture
/// without redrawing the rest of it. Sampling both and keeping the nearer
/// occluder costs one extra read and saves six views of the level every frame.
uniform sampler2D point_shadow_static_texture;
/// How many lights may have a row of the atlas. Six tiles across each.
// Rows of the cube atlas: six faces across, this many lights down. Must
// match `Renderer.kShadowedLights`, which is where the reasoning lives, and
// `shadowSlots` in the software backend's transcription of this file.
const int kShadowSlots = 6;
layout(std140) uniform PointShadow {
/// The same view-projections the atlas was rendered with, six per slot.
///
/// Passed rather than reconstructed. Deriving cube face coordinates here
/// would be a second implementation of a decision the renderer already made,
/// and the two would disagree about handedness or up vectors on some face
/// and nowhere else — which shows as one face of every shadow being wrong.
mat4 faces[6 * kShadowSlots];
/// Per slot. xyz: the light's world position. w: its range.
vec4 lights[kShadowSlots];
/// Per light, in the order the lighting knows them.
///
/// x: the atlas row it owns, or negative when it has none — a fifth torch in
/// a room lands there. z: the tangent of half the frustum's opening angle,
/// which is what converts a world width into a fraction of a tile. y and w
/// are unwritten.
///
/// **z is exactly one for a point light**, because a cube face is a ninety
/// degree frustum and `tan(45°) == 1`. That is not a convention chosen to be
/// tidy: it is what lets a narrower frustum share this whole path, since
/// multiplying by one in IEEE 754 changes no bit of the result. Whatever else
/// a spot light will need, it does not need a second copy of the filter.
vec4 slots[kMaxLights];
/// x: half a texel, in tile-local uv. y: distance bias in metres.
/// z: strength. w: normal offset, **in texels of the face it lands on**.
vec4 params;
/// x: smallest kernel radius in tile-local uv, and the fixed radius used
/// when contact hardening is off. y: the light's own radius in metres; zero
/// turns contact hardening off. z: largest kernel radius in tile-local uv.
/// w: non-zero paints the penumbra estimate into the surface buffer instead
/// of shading with it.
vec4 params2;
/// x: non-zero when this backend stores the atlas bottom-up. y: one over the
/// edge length of a tile in texels, which is what turns a distance into the
/// world width of one texel there.
///
/// **Appended after everything else on purpose**, the same way FragInfo's
/// ambient pair was: std140 lays a block out in declaration order, so adding
/// here leaves every offset above unchanged and the three backends do not
/// have to agree about anything they already agreed about. y, z and w are
/// unwritten.
vec4 params3;
}
point_shadow;
/// Eight points on a Poisson disk, a common set for filtering cascaded
/// shadows.
///
/// A disk rather than a grid because a grid of taps on a straight shadow edge
/// lands every sample on the same side at once, and the edge steps between
/// kernel widths instead of sliding. Eight rather than sixteen because every
/// tap here reads **two** atlases — the static walls and the movers — so the
/// cost is doubled before it is counted.
vec2 PointShadowDiskTap(int i) {
if (i == 0) return vec2(-0.94201624, -0.39906216);
if (i == 1) return vec2(0.94558609, -0.76890725);
if (i == 2) return vec2(-0.09418410, -0.92938870);
if (i == 3) return vec2(0.34495938, 0.29387760);
if (i == 4) return vec2(-0.91588581, 0.45771432);
if (i == 5) return vec2(-0.81544232, -0.87912464);
if (i == 6) return vec2(-0.38277543, 0.27676845);
return vec2(0.97484398, 0.75648379);
}
/// One comparison against the atlas, at [uv] offset within the tile.
///
/// The clamp is applied **after** the offset, not before, and that is the whole
/// reason a kernel can be widened here without touching anything else: each tap
/// is held inside its own tile individually. Clamping the centre and then
/// offsetting would let the outer taps walk straight out of the tile and read a
/// distance measured from a different face, or a different light.
float PointShadowDistance(vec2 uv, vec2 offset, vec2 tile, float range) {
float inset = point_shadow.params.x;
vec2 local = clamp(uv + offset, inset, 1.0 - inset);
vec2 atlas = (local + tile) * vec2(1.0 / 6.0, 1.0 / float(kShadowSlots));
// **The whole atlas, turned over, where row zero of a render target is at the
// bottom.** Both halves of the address are wrong there and this is the one
// place that fixes both: the tile the light owns — a light in slot zero is
// drawn into the row the shader would call three, because the viewport
// rectangle is flipped to land it — and the picture inside that tile, which
// was drawn through a projection built for the other origin.
//
// Every check of this atlas missed it for the same reason: the debug view
// composites the texture through a full-screen pass, which turns it over
// again and puts the row back. The atlas compared equal on both backends
// across six scenes while the lit pass, which samples it directly and has no
// such pass to cancel, read a row that had never been drawn into and found
// nothing in the way of anything.
if (point_shadow.params3.x > 0.5) atlas.y = 1.0 - atlas.y;
// Whichever is nearer occludes: a wall in front of a monster shadows, and so
// does a monster in front of a wall.
//
// **`textureLod` at level zero, because every caller of this function stands
// behind a branch.** The light loop skips a light the surface faces away
// from, the blocker search `continue`s past a tap that found nothing, and the
// slot test returns before any of it — so the invocations of a quad do not
// arrive here together, and a WGSL backend refuses a sample whose implicit
// derivative would be read where they disagree. Both atlases are distance
// render targets with one level, so level zero is the level `texture` was
// choosing anyway; this names it rather than deriving it, and the picture is
// the same on every backend.
return min(textureLod(point_shadow_texture, atlas, 0.0).r,
textureLod(point_shadow_static_texture, atlas, 0.0).r) * range;
}
float PointShadowTap(vec2 uv, vec2 offset, vec2 tile, float range,
float receiver) {
float stored = PointShadowDistance(uv, offset, tile, range);
// Nothing was drawn in that direction by either, so nothing is in the way.
if (stored >= range * 0.999) return 1.0;
return receiver > stored ? 0.0 : 1.0;
}
/// The disk point for tap [i], rotated by [ca]/[sa] and scaled to [radius].
vec2 PointShadowOffset(int i, float ca, float sa, float radius) {
vec2 p = PointShadowDiskTap(i);
return vec2(p.x * ca - p.y * sa, p.x * sa + p.y * ca) * radius;
}
/// How wide the penumbra should be here, in tile-local uv.
///
/// Contact hardening, and the reason a fixed kernel looks wrong: a shadow is
/// sharp where its caster touches the floor and soft a metre away, and one
/// radius for both makes the contact mushy or the distant edge hard.
///
/// The similar-triangles estimate is the standard one — a light of radius `L`
/// with a blocker at `b` and a receiver at `r` throws a penumbra `L * (r - b) /
/// b` wide at the receiver. Converting that to tile uv is exact rather than
/// tuned, because a face is a ninety degree frustum: at distance `r` from the
/// light the face spans `2 * r` in world units across the full `0..1` of uv,
/// so a world width `w` is `w / (2 * r)` of a tile.
///
/// The blocker search runs at the **widest** penumbra allowed, since a blocker
/// outside that circle cannot widen the result anyway, and searching narrower
/// would miss the very blockers that make an edge soft.
///
/// [tanHalf] is where the ninety degrees stop being assumed. The span above is
/// `2 * r` only for a right-angled frustum; in general it is `2 * r * tan(θ/2)`,
/// and for a cube face that factor is one. A narrower frustum covers less world
/// per tile, so the same world width is a *larger* fraction of it — which is
/// why this divides rather than multiplies, and why getting it upside down
/// would make a tight cone's shadows harden instead of soften.
float PointShadowPenumbra(vec2 uv, vec2 tile, float range, float receiver,
float ca, float sa, float tanHalf,
out float blockerOut) {
blockerOut = -1.0;
float lightRadius = point_shadow.params2.y;
float minRadius = point_shadow.params2.x;
float maxRadius = point_shadow.params2.z;
if (lightRadius <= 0.0) {
// **The debug channel is filled even though the search is skipped**, and
// leaving it unfilled cost a session. `blockerOut` starts at −1 to mean
// "nothing was measured"; the debug encoding clamps it into a colour, where
// −1 becomes zero — the same green as a blocker touching the surface, which
// reads as the most alarming answer available. A whole theory was built on
// that zero, and the search it described had never run.
//
// The centre tap is what the filter below would use anyway, so this reports
// a distance the atlas really returned rather than a sentinel.
blockerOut = PointShadowDistance(uv, vec2(0.0), tile, range);
return minRadius;
}
float sum = 0.0;
float count = 0.0;
for (int i = 0; i < 8; i++) {
float stored =
PointShadowDistance(uv, PointShadowOffset(i, ca, sa, maxRadius), tile,
range);
if (stored >= range * 0.999) continue;
if (stored >= receiver) continue;
sum += stored;
count += 1.0;
}
// Nothing in front of this fragment anywhere in the search: fully lit, and
// the caller can skip the filter entirely.
if (count < 0.5) return -1.0;
float blocker = max(sum / count, 1e-4);
blockerOut = blocker;
float world = lightRadius * max(receiver - blocker, 0.0) / blocker;
return clamp(world / (2.0 * receiver * tanHalf), minRadius, maxRadius);
}
/// How lit [world] is by the point light that owns the cube atlas.
///
/// One, fully lit, when this is not that light or the atlas has nothing to say.
float PointShadowFactor(vec3 world, vec3 normal, int lightIndex) {
int slot = int(point_shadow.slots[lightIndex].x + 0.5);
if (point_shadow.slots[lightIndex].x < 0.0) return 1.0;
float strength = point_shadow.params.z;
if (strength <= 0.0) return 1.0;
// Offset along the normal before measuring, and scaled by how steeply the
// surface leans away from the light.
//
// A soft kernel on a tilted surface straddles a depth gradient: the taps at
// one end of the disk are further from the light than the fragment itself,
// so a flat offset that clears the surface head-on leaves acne at a grazing
// angle. The slope term lifts the whole kernel clear instead, and is capped
// because it runs away as the surface turns edge-on to the light — an
// uncapped lift detaches the shadow from its caster.
vec3 toLight = point_shadow.lights[slot].xyz - world;
float toLightLength = max(length(toLight), 1e-6);
float nDotL = max(dot(normal, toLight / toLightLength), 0.15);
float slope = min(sqrt(max(1.0 - nDotL * nDotL, 0.0)) / (nDotL * nDotL), 8.0);
// **How wide one texel of the face is, out where this fragment is.** The
// error a normal offset exists to clear is exactly that: a texel of the
// shadow map covers a patch of surface, the whole patch is recorded at one
// distance, and a fragment anywhere else in it compares against a distance
// measured somewhere it is not. That patch grows with range — it is a solid
// angle, not a length — so an offset fixed in metres is right at one distance
// and wrong everywhere else.
//
// What it was: `params.w` metres, flat. On the golden teapot, at 9.6 m from
// the lamp, a texel is 3.7 cm and the flat offset was 2 cm, so the floor
// shadowed itself across everything the light reached — and the acne stopped
// dead at the *projection of the floor's own edge*, because past it the atlas
// holds nothing and nothing can occlude. A straight line across a shadow with
// no straight edge anywhere in the scene.
float texel =
2.0 * toLightLength * max(point_shadow.slots[lightIndex].z, 1e-4) *
point_shadow.params3.y;
// Both terms are metres. The slope term used to be the kernel radius, which
// is a fraction of a tile — a unit mismatch carried over from an estimate
// where a softness radius genuinely was the right quantity. Here it meant
// widening the kernel also lifted the sample off the surface, by up to ten
// centimetres at the wider settings, so the softening and the lift
// cancelled: tripling the kernel moved 184 pixels of the frame,
// where the kernel alone moves thousands. It is what made contact hardening
// look inert, and it was hiding in a comparison rather than in the estimate.
vec3 origin = world + normal * texel * point_shadow.params.w * (1.0 + slope);
vec3 toFragment = origin - point_shadow.lights[slot].xyz;
float distance = length(toFragment);
float range = max(point_shadow.lights[slot].w, 1e-4);
if (distance >= range) return 1.0;
// The dominant axis picks the face, in the order the renderer wrote them:
// +X, -X, +Y, -Y, +Z, -Z, left to right then top to bottom.
//
// A spot has one column and no choice to make. Asking the dominant axis
// anyway would be worse than pointless: a fragment below and to the side of
// a downlight has −Y dominant, which is column 3, and column 3 of a spot's
// row is deliberately blank — so the whole cone would read as unshadowed
// except for the wedge where the aim happens to be the dominant axis.
int face = 0;
if (point_shadow.slots[lightIndex].y < 0.5) {
vec3 a = abs(toFragment);
if (a.x >= a.y && a.x >= a.z) {
face = toFragment.x > 0.0 ? 0 : 1;
} else if (a.y >= a.z) {
face = toFragment.y > 0.0 ? 2 : 3;
} else {
face = toFragment.z > 0.0 ? 4 : 5;
}
}
vec4 clip = point_shadow.faces[slot * 6 + face] * vec4(origin, 1.0);
if (clip.w <= 0.0) return 1.0;
vec2 ndc = clip.xy / clip.w;
if (abs(ndc.x) > 1.0 || abs(ndc.y) > 1.0) return 1.0;
// v is flipped, the same way the directional map does it: the texture's
// origin is at the top, where row zero of the render target is. Getting this
// wrong does not tilt the shadow — it makes the top row of faces read the
// bottom row, so a whole region compares against an unrelated distance and
// comes out as a black slab.
vec2 uv = vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
// The face across, the light down: six tiles wide, four tall.
vec2 tile = vec2(float(face), float(slot));
float receiver = distance - point_shadow.params.y;
// One rotation, shared by the blocker search and the filter. Per fragment,
// so eight samples read as a soft edge rather than as eight copies of the
// silhouette: without it every fragment along an edge tests the same eight
// directions and the pattern shows.
//
// **The three constants are not arbitrary and are not ours.** This is Jorge
// Jimenez's interleaved gradient noise, from "Next Generation Post
// Processing in Call of Duty: Advanced Warfare" (SIGGRAPH 2014):
//
// IGN(x, y) = frac(52.9829189 * frac(0.06711056 * x + 0.00583715 * y))
//
// The pair inside the dot is a direction whose gradient walks the unit
// interval as slowly as it can while never repeating over a screen, and the
// multiplier outside stretches that walk so neighbouring pixels land far
// apart in the result. What it buys over a hash is the cost: one dot and two
// fracts, no integer arithmetic, no texture. What a blue-noise texture buys
// over it is a better spectrum, at a sampler and a fetch — worth it for
// dithering a whole frame, not for rotating eight taps.
//
// Written down because three unexplained decimals read as a magic spell, and
// the next person to touch this line has no way to tell which of them may be
// changed. The answer is none of them.
float noise = fract(52.9829189 * fract(dot(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 < kTotalLights; i++) {
if (i >= count) break;
LightSample light = SampleLight(i, s);
if (light.n_dot_l <= 0.0) continue;
// A light from the list has no shadow row to read — see `LightHasShadow`.
// A branch rather than something folded into the two calls, because both
// index tables eight entries wide and the ninth light would read past them
// rather than read a one.
float visibility = LightHasShadow(i)
? LightVisibility(s, light, i) *
PointShadowFactor(v_world_position, s.n, i)
: 1.0;
if (visibility <= 0.0) continue;
total += ShadeLight(s, light) * light.radiance * light.n_dot_l * visibility;
}
return total;
}
#endif // SURFACE_GLSL_
// Never called — nothing here accumulates lights — but the prototype in
// surface.glsl has to be satisfied, and an unlit surface responding with its
// albedo is the honest answer to "what would this look like lit".
vec3 ShadeLight(Surface s, LightSample light) {
return s.albedo;
}
// Never called either, and deliberately not routed through shadow.glsl: an
// unlit shader that declared the shadow sampler would lose it to the optimizer
// and leave the engine binding a slot Metal does not have.
float LightVisibility(Surface s, LightSample light, int index) {
return 1.0;
}
void main() {
Surface s = ReadSurface();
// The albedo is already linear, and an unlit surface is best
// understood as emitting exactly it, so it goes into the HDR
// target as light like everything else.
WriteSurface(s.albedo, s.alpha);
}
''',
'Xray': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;
// `unlit.frag` with its second output taken away: a flat colour that says
// nothing about the surface it covers.
//
// **A shader of its own rather than a blend state, because a blend state does
// not reach here.** The x-ray stage draws every marked node twice inside the
// scene pass, and that pass has two attachments whenever a screen-space effect
// asked for the surface buffer. `BlendState.keepDestination` is what the
// marking draw uses to leave the picture alone, and it protects attachment
// zero only: `setBlend` takes an attachment index that Impeller honours and
// WebGL2 cannot, since per-attachment blending there needs
// `EXT_draw_buffers_indexed`. So the three backends disagreed about what was
// left in attachment one — and the silhouette draw, which blends not at all,
// wrote into it on every one of them.
//
// What that wrote was wrong rather than merely extra. The silhouette's depth
// test is `greater`: it passes exactly where the marked node is BEHIND what
// the depth buffer holds, so `frag_surface` was taking the normal, roughness
// and depth of a monster and stamping them over the wall in front of it. The
// surface buffer's one invariant is that it describes the nearest surface, and
// SSAO and reflections read it as such — a silhouette would have occluded and
// reflected off geometry that is not visible.
//
// Declaring one output into a two-attachment target is the arrangement
// `sky.frag` already ships and `lib/color.glsl` already guards for the shadow
// passes; the reverse — declaring an output the target has no slot for — is
// the one that crashes Metal.
#define F3D_NO_POINT_SHADOW
#define F3D_NO_LIGHT_LIST
#define F3D_NO_SURFACE_BUFFER
// --- lib/surface.glsl ---
// Shared material and lighting interface for the lighting models.
//
// flutter_gpu compiles shaders ahead of time into a bundle: there is no runtime
// compilation, so a node-graph material system assembled at run time is
// impossible. Each lighting model is therefore
// its own pre-built fragment shader, and this header is what keeps them
// interchangeable — one identical uniform block, so the Dart binding code never
// needs to know which model is active.
//
// Keep every declaration below byte-identical across models. A member a model
// does not read may be optimized out of the reflected block, which is why the
// Dart side skips absent members instead of failing.
//
// Only include this from a shader that actually reads FragInfo. Declaring the
// block without using it leaves it visible to reflection while the compiled
// shader binds no buffer for it, and binding that phantom block segfaults
// inside Metal. Shaders needing only colour helpers include lib/color.glsl.
#ifndef SURFACE_GLSL_
#define SURFACE_GLSL_
// --- lib/color.glsl ---
// Colour space helpers and the fragment output interface.
//
// Split out of surface.glsl so a shader that needs no material inputs — the
// normals debug view — can avoid DECLARING the FragInfo uniform block at all.
// That matters more than it looks: reflection metadata reports a block as
// present merely because it was declared, even when the compiled shader binds
// no such buffer, so a declared-but-unused block is indistinguishable from a
// used one until Metal crashes on the bind.
#ifndef COLOR_GLSL_
#define COLOR_GLSL_
precision highp float;
const float kPi = 3.14159265359;
// One varying set shared by every fragment shader, matching mesh.vert.
//
// All five are declared here, including the two the debug models never read: a
// fragment shader whose `in` block disagrees with the vertex shader's `out`
// block fails to link, and there is no partial-match rule to lean on.
in vec3 v_world_position;
in vec3 v_normal;
in vec2 v_texcoord;
in vec4 v_tangent;
in vec4 v_color;
/// Where this fragment is in the level's lightmap. Zero from every vertex
/// stage but `mesh_lightmapped.vert`, and read only by the lit models, which
/// sample a one-texel black there when a material has no map.
in vec2 v_lightmap_uv;
layout(location = 0) out vec4 frag_color;
// The second attachment: what a screen-space effect needs to know about the
// surface it is looking at. World-space normal in rgb, and in a the depth along
// the view axis in world metres — not a window depth; `WriteSurfaceGeometry`
// says at length why not.
//
// Depth travels here rather than in a depth texture because flutter_gpu cannot
// sample one — the same reason the shadow pass writes its depth into a colour
// target. See ARCHITECTURE.md §2.
//
// Guarded, because not every stage that includes this header draws into a
// two-attachment target. The shadow pass draws into one, and a pipeline
// declaring an output its target has no slot for is a mismatch worth avoiding
// rather than discovering.
#ifndef F3D_NO_SURFACE_BUFFER
layout(location = 1) out vec4 frag_surface;
#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;
// **A stage that needs none of this must be able to declare none of it.** On
// Vulkan both stages' descriptors are merged into one set layout, and two
// bindings with the same number in it is not a layout the specification
// allows. A driver may accept it anyway; a Galaxy A55's refuses the pipeline
// with `ErrorUnknown` and no other word, which is how the shadow pass came to
// build everywhere except there — its only uniform block was this one, and it
// landed on the same binding as the vertex stage's first.
#ifndef F3D_NO_FOG
/// Distance fog, in its own block rather than folded into FragInfo.
///
/// Its own because color.glsl is included before FragInfo is declared, and
/// because appending to a block that half a dozen shaders already share is a
/// way to move offsets nobody expected to move. Three vec4s is a cheap price
/// for not touching any of that.
layout(std140) uniform FogInfo {
/// rgb: linear fog colour. w: density per metre, zero for no fog.
vec4 fog;
/// xyz: camera position in world space. Duplicated from FragInfo so this
/// block stands alone; a vec3 is cheaper than a coupling.
vec4 eye;
/// xyz: the direction the camera looks, as a unit vector in world space.
/// w unused.
///
/// Here rather than in a block of its own because it answers the same
/// question [eye] does — where the camera is and which way it faces — and
/// this is the block `color.glsl` can see.
vec4 forward;
}
fog_info;
/// How far this fragment is from the eye, in world metres.
///
/// What the fog fades by. Distance rather than depth, because fog is a
/// property of the air between two points and does not care which way the
/// camera happens to face.
float EyeDistance() { return distance(v_world_position, fog_info.eye.xyz); }
/// How far this fragment is *along the view axis*, in world metres.
///
/// What the surface buffer's alpha holds. Depth rather than distance, and the
/// difference only shows on an orthographic camera — where the rays through
/// the pixels are parallel instead of meeting at the eye, so a distance from
/// the eye names a sphere that the pixel's ray crosses somewhere the reader
/// cannot solve for. A depth along the axis names a plane, which every ray
/// crosses exactly once. See `WorldAtDepth` in `post/ssao.frag` for the
/// reconstruction both projections share.
float ViewDepth() {
return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz);
}
#else // F3D_NO_FOG
// The same two questions, answered without the block: a stage that declares no
// fog has no eye position to measure from either. Stubs rather than a guard at
// every call site, so that what includes this file reads the same whichever
// way it was compiled.
float EyeDistance() { return 0.0; }
float ViewDepth() { return 0.0; }
#endif // F3D_NO_FOG
/// Records the geometry of this fragment for whatever runs after the scene.
///
/// Called from the same place that writes colour, so a surface cannot be lit
/// into the frame without also describing itself — which is the failure that
/// leaves a screen-space effect reflecting whatever was in the buffer before.
///
/// rg: octahedral normal. b: perceptual roughness. a: **depth along the view
/// axis, in world metres** — see [ViewDepth].
///
/// **Not `gl_FragCoord.z`, and that is a defect this channel carried until it
/// was looked at.** Window depth crowds every distant surface into the top of
/// its range — with a near plane of a tenth of a metre, everything past twenty
/// metres lives in the last half a hundredth of `[0, 1]` — and this attachment
/// is a half float, whose steps up there are about five ten-thousandths. So two
/// surfaces half a metre apart at twenty metres stored the *same* number, and
/// every screen-space pass that compares against this channel decided whole
/// bands of pixels by rounding. The occlusion pass drew them: vertical stripes
/// along the lines of constant depth on any wall receding from the camera, on
/// both GPU backends. The software rasteriser kept the channel at full
/// precision and drew the effect correctly, so it was the one that looked
/// wrong against the other two.
///
/// A depth in metres has none of that: the exponent carries the range and the
/// mantissa carries the same relative precision everywhere, which at twenty
/// metres is a centimetre. Both numbers are measured in
/// `flutter3d/test/surface_depth_test.dart`.
///
/// Zero still means nothing was drawn. The attachment is cleared to zero and
/// nothing is drawn in front of the near plane.
void WriteSurfaceGeometry(float roughness) {
#ifndef F3D_NO_SURFACE_BUFFER
// A debug pass takes the buffer over rather than getting one of its own.
// The surface buffer already has an attachment, a viewer and a golden; a
// second one would need all three built before it could answer anything.
if (g_debug_surface_on) {
frag_surface = vec4(g_debug_surface, ViewDepth());
return;
}
frag_surface = vec4(EncodeOctahedral(normalize(v_normal)),
clamp(roughness, 0.0, 1.0), ViewDepth());
#endif
}
/// Fades [color] toward the fog with distance from the eye.
///
/// Exponential rather than linear, because linear fog has a visible plane
/// where it starts and a dungeon corridor is exactly where that shows.
vec3 ApplyFog(vec3 color) {
#ifdef F3D_NO_FOG
return color;
#else
float density = fog_info.fog.w;
if (density <= 0.0) return color;
float d = EyeDistance();
return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0));
#endif
}
/// 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
/// How many more lights one draw may be handed — `gfx-74n`.
///
/// **The eight above stay exactly what they were**, which is what keeps this
/// from moving a single recorded frame: a draw with eight lights or fewer runs
/// the loop it has always run, reads the uniform arrays it has always read, and
/// never touches the texture below. The tail is the part that used to be
/// impossible.
///
/// A loop bound rather than a cost. `AccumulateLights` breaks at the draw's own
/// count, so a scene with three lights costs three iterations whatever this
/// says. Twenty-four because the two tables below are `vec4 x[6]` and four
/// lanes fit a `vec4`: two hundred and eight bytes a draw, against the five
/// hundred and twelve the light arrays already cost.
#define kExtraLights 24
#define kTotalLights (kMaxLights + kExtraLights)
/// Every light in the scene, one per row, four texels across — `gfx-74n`.
///
/// **A texture rather than a wider uniform block, and that is the design.**
/// `FragInfo` is uploaded on every draw, so widening its four `vec4` arrays to
/// hold thirty-two lights would be a two-kilobyte upload per draw in every
/// scene, including every scene with one light. This is built once a frame and
/// only when a scene has more lights than a draw can hold in its slots.
///
/// Row layout, which `renderer_light_list.dart` writes and only this reads:
///
/// * texel 0 — xyz world position, w type (0 directional, 1 point, 2 spot)
/// * texel 1 — rgb linear colour, w intensity
/// * texel 2 — xyz the direction it points, w range
/// * texel 3 — x cos(inner), y cos(outer), zw unused
///
/// The same four vectors the uniform arrays hold, in the same order, so one
/// reader serves both.
///
/// **`F3D_NO_LIGHT_LIST` leaves both out**, for a model that accumulates no
/// lights. Such a model never reaches the reader below, so the compiler drops
/// the block and the sampler from the Metal function while reflection still
/// lists them, with no buffer or texture index assigned. The renderer used to
/// bind them for every draw, Unlit included, and that bind is a crash inside
/// `setFragmentBuffer:offset:atIndex:` on Metal. Vulkan took the same draw
/// without a word, which is how 0.7.0 shipped with it.
#ifndef F3D_NO_LIGHT_LIST
uniform sampler2D light_list_texture;
layout(std140) uniform LightListInfo {
/// x: how many rows this draw reads, zero when it reads none.
/// y, z: one over the texture's width and height.
/// w: unused.
vec4 list;
/// Which rows, four to a vector, in the order they are read.
///
/// Indices rather than the light data itself: the data is the same for every
/// draw in the frame and belongs in the texture; what differs per draw is
/// *which* of them reach it, and that is what `Renderer._drawLightsFor`
/// already decides.
vec4 indices[6];
/// How much of each of those survives the edge fade, in the same order.
///
/// Per draw and not in the texture, because the row an index points at is
/// shared by every draw in the frame: a scale written into it would dim that
/// light for all of them. `gfx-12n`'s fade lives at the end of the list now —
/// that is where a light stops contributing, and fading the slots against a
/// water line that no longer marks a cliff would dim a light for no reason
/// while its rival stayed bright, making the swap more visible rather than
/// less.
vec4 scales[6];
}
light_list_info;
/// One lane of a six-vector table, [slot] counting from nought.
float LightListLane(vec4 four, int slot) {
int lane = slot - (slot / 4) * 4;
return lane == 0 ? four.x : lane == 1 ? four.y : lane == 2 ? four.z : four.w;
}
/// The row light [slot] of the list reads.
float LightListRow(int slot) {
return LightListLane(light_list_info.indices[slot / 4], slot);
}
/// How much of light [slot] of the list survives the edge fade.
float LightListScale(int slot) {
return LightListLane(light_list_info.scales[slot / 4], slot);
}
#endif // F3D_NO_LIGHT_LIST
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 is the directional light's apparent size** — `gfx-15n` — which has
/// nothing to do with ambient and everything to do with this being the last
/// unspent component in a block six shaders share. `frame_params.w` was the
/// slot reserved for a frame-wide parameter and the environment's level
/// count took it; appending to this block moves offsets four backends have
/// agreed on. See `shadow.glsl`, which reads it.
///
/// Two colours rather than one is the whole of what makes ambient look like
/// light instead of like a lifted black level. Outdoors the sky is blue and
/// bright and the ground is warm and dim, and a flat grey for both leaves
/// every underside as pale as every upward face — which reads as the model
/// being flat, and gets blamed on the normals.
vec4 ambient_ground;
}
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.
//
// **A cutoff below -1.5 is the fourth mode: hashed** — `gfx-16n`. The
// sentinel rides in the same component because the alternative is a second
// number in a block six shaders share, and -1 already meant "not masked";
// anything more negative was free. See [MaterialAlphaMode.hashed].
float cutoff = frag_info.material2.x;
if (cutoff >= 0.0) {
if (s.alpha < cutoff) discard;
} else if (cutoff < -1.5) {
// **Stochastic instead of a threshold.** A leaf texture at 40% opacity is
// either entirely there or entirely gone under a fixed cutoff, so a fern
// comes out as a hard-edged cardboard cut-out; sorting would fix it and
// costs a sort per frame and a draw per layer. Comparing against noise
// instead keeps 40% of the *pixels*, which resolves as 40% opacity to
// anything that averages several of them — a higher-resolution target,
// a downsample, a person standing back.
//
// **Hashed on world position, not on the screen.** Screen-space noise is
// one line shorter and swims: the pattern stays put while the object
// moves through it, so a moving branch sparkles. Anchoring it to where
// the surface *is* means a given speck of leaf keeps its verdict from
// frame to frame, and the camera moving changes nothing.
//
// The scale is a constant and it is the whole tuning: finer than the
// texture's own detail and the noise disappears into aliasing, coarser
// and the leaf breaks into blotches. Sixteen per metre is about a
// centimetre of grain at a metre away.
vec3 anchored = floor(v_world_position * 16.0);
float noise = fract(
sin(dot(anchored, vec3(12.9898, 78.233, 37.719))) * 43758.5453);
if (s.alpha < noise) discard;
}
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() {
#ifdef F3D_NO_LIGHT_LIST
return clamp(int(frag_info.frame_params.y + 0.5), 0, kMaxLights);
#else
return clamp(int(frag_info.frame_params.y + 0.5), 0, kMaxLights) +
clamp(int(light_list_info.list.x + 0.5), 0, kExtraLights);
#endif
}
/// Whether light [index] carries a shadow — `gfx-74n`.
///
/// Only the first eight do. The cube atlas holds six rows and the slot table is
/// eight entries wide, so a light from the list has no row to read and asking
/// for one would index past the table. That is a real limit and the right one:
/// the eight a draw keeps in its slots are the eight ranked most relevant to
/// it, which is exactly the set worth a shadow map.
bool LightHasShadow(int index) { return index < kMaxLights; }
/// Distance attenuation for a punctual light, following the glTF spec.
///
/// Inverse square with an optional range window. The window is what stops a
/// lamp with a declared range from contributing a faint haze across the whole
/// scene, which matters far more once there are eight of them.
float PunctualAttenuation(float distance, float range) {
float attenuation = 1.0 / max(distance * distance, 1e-4);
if (range > 0.0) {
float ratio = distance / range;
float window = clamp(1.0 - ratio * ratio * ratio * ratio, 0.0, 1.0);
attenuation *= window * window;
}
return attenuation;
}
/// How much of [s]'s sky a rectangle covers, weighted by the cosine —
/// `gfx-77n`.
///
/// **Exact, not fitted.** This is Lambert's own form factor for a polygon, from
/// 1760: for each edge, the angle it subtends at the shading point times how
/// much the edge's plane leans into the surface normal. Summed over four edges
/// and halved, it *is* the integral of `cos θ` over the rectangle's projection
/// on the hemisphere — the quantity a punctual light approximates with a single
/// `n · l`. So there is no table to ship and nothing to fit: the usual
/// linearly-transformed-cosine approach exists to make the *specular* lobe
/// tractable, and buys nothing here, where the diffuse answer is a closed form
/// four `acos` calls long.
///
/// Returns irradiance over radiance, so a surface facing a rectangle that fills
/// its whole sky gets π, the same as a uniform hemisphere. [corners] are the
/// four vertices in order, relative to the shading point.
///
/// **The rectangle emits along `cross(halfWidth, halfHeight)`**, and with the
/// corners wound as `SampleLight` winds them the sum comes out *negative* on
/// that side, so the negation below is the convention rather than a fix. It was
/// measured rather than derived: the first version returned `+total * 0.5`, and
/// against the reference integration it read nought where the answer was 0.349
/// and 1.02 where the answer was nought — the two failures a flipped winding
/// produces, and between them they name the sign with no room left to argue.
float RectangleFormFactor(vec3 corners[4], vec3 n) {
float total = 0.0;
for (int i = 0; i < 4; i++) {
vec3 a = normalize(corners[i]);
vec3 b = normalize(corners[(i + 1) & 3]);
// Clamped before the `acos`: two nearly parallel edge directions can give a
// dot a hair past one through rounding alone, and `acos` of that is a NaN
// that spreads to the whole pixel and then to the bloom.
float angle = acos(clamp(dot(a, b), -1.0, 1.0));
vec3 axis = cross(a, b);
float len = length(axis);
// A degenerate edge — the shading point lies on the line through it —
// subtends nothing, and normalising a zero vector is the other way to get
// that NaN.
if (len > 1e-6) total += angle * dot(axis / len, n);
}
// Clamped rather than tested separately: a surface on the panel's dark side,
// or facing away from it, comes out with the sign reversed, so "one-sided" is
// a property of the arithmetic instead of a flag somebody has to remember.
return max(-total * 0.5, 0.0);
}
/// Where on the rectangle the specular lobe is really looking — `gfx-77n`.
///
/// **The representative point, which is an approximation, unlike the diffuse
/// above.** The mirror direction leaves the surface and either hits the panel
/// or misses it; the closest point of the panel to that ray is treated as a
/// punctual light standing in for the whole rectangle. It is the standard
/// cheap answer and its one visible property is the one the row asked for: as
/// the view moves the closest point slides along the panel, so the highlight
/// is a streak with the panel's own shape and orientation rather than a dot.
///
/// What it does not do is widen the lobe by the panel's solid angle, so a
/// rough surface under a large panel is a little darker than a full integration
/// would make it. That is a known error of this method and not a bug in this
/// transcription; the fix is the fitted table this function exists to avoid.
vec3 RectangleClosestPoint(vec3 centre, vec3 halfWidth, vec3 halfHeight,
vec3 world, vec3 mirror) {
vec3 n = cross(halfWidth, halfHeight);
float nLen = length(n);
// A panel with no area has no surface to find a point on; its centre is the
// only answer that is not a division by zero.
if (nLen < 1e-12) return centre;
n /= nLen;
vec3 toPlane = centre - world;
float denom = dot(mirror, n);
vec3 onPlane;
// Parallel to the panel, or pointing away from it: the ray never lands, so
// the nearest thing to it is the centre projected back, which keeps the
// highlight on the panel instead of sending it to infinity.
if (abs(denom) < 1e-5) {
onPlane = toPlane - n * dot(toPlane, n);
} else {
float t = dot(toPlane, n) / denom;
onPlane = t > 0.0 ? mirror * t : toPlane - n * dot(toPlane, n);
}
// Clamped into the rectangle in its own axes. Dividing by the squared length
// turns a projection into a coordinate in units of the half-extent, so the
// clamp is against one either way round.
vec3 offset = onPlane - toPlane;
float wLen2 = max(dot(halfWidth, halfWidth), 1e-12);
float hLen2 = max(dot(halfHeight, halfHeight), 1e-12);
float u = clamp(dot(offset, halfWidth) / wLen2, -1.0, 1.0);
float v = clamp(dot(offset, halfHeight) / hLen2, -1.0, 1.0);
return centre + halfWidth * u + halfHeight * v;
}
/// 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;
vec4 color;
vec4 direction;
vec4 cone;
if (index < kMaxLights) {
position = frag_info.light_position[index];
color = frag_info.light_color[index];
direction = frag_info.light_direction[index];
cone = frag_info.light_cone[index];
} else {
#ifdef F3D_NO_LIGHT_LIST
// Unreachable: `LightCount` stops at the slots without a list.
position = vec4(0.0);
color = vec4(0.0);
direction = vec4(0.0);
cone = vec4(0.0);
#else
// A row of the light list — `gfx-74n`. Sampled at texel centres so a
// driver's rounding cannot land a fetch on a neighbour, and the four texels
// across the row are the same four vectors the arrays above hold.
int slot = index - kMaxLights;
float v = (LightListRow(slot) + 0.5) * light_list_info.list.z;
float u = light_list_info.list.y;
// `textureLod` and not `texture`, for `shadow.glsl`'s own reason: `index`
// reaches this branch through a function parameter, so a WGSL backend
// cannot see that every invocation of a draw walks the same light count
// and refuses the implicit derivative as possibly non-uniform. The atlas
// has one level, so naming it directly changes no pixel.
position = textureLod(light_list_texture, vec2(0.5 * u, v), 0.0);
color = textureLod(light_list_texture, vec2(1.5 * u, v), 0.0);
direction = textureLod(light_list_texture, vec2(2.5 * u, v), 0.0);
cone = textureLod(light_list_texture, vec2(3.5 * u, v), 0.0);
// The intensity and not the colour, for `LightBuffer._pack`'s own reason:
// the same multiply here, and only one of them is a number nobody authored.
color.w *= LightListScale(slot);
#endif // F3D_NO_LIGHT_LIST
}
float type = position.w;
// **The rectangle leaves before `aim` is taken — `gfx-77n`.** For every other
// kind `direction.xyz` is a unit vector saying which way the light points;
// for this one it is an edge of the panel, with its length carrying half the
// width, and normalising it here would quietly throw the size away.
if (type > 2.5) {
vec3 halfWidth = direction.xyz;
vec3 halfHeight = cone.xyz;
vec3 toCentre = position.xyz - v_world_position;
vec3 corners[4];
corners[0] = toCentre - halfWidth - halfHeight;
corners[1] = toCentre + halfWidth - halfHeight;
corners[2] = toCentre + halfWidth + halfHeight;
corners[3] = toCentre - halfWidth + halfHeight;
// The cosine-weighted solid angle, which takes the place `n · l` holds for
// a punctual light: the loop multiplies the shading by `n_dot_l`, so
// putting the exact integral here makes the diffuse term exact rather than
// sampled. See [RectangleFormFactor].
float formFactor = RectangleFormFactor(corners, s.n);
// Radiance rather than intensity: `intensity` means the same thing for
// every kind of light, so a panel's is spread over its own area here.
// Enlarging a window at a fixed rating then dims it per square metre and
// leaves the room as bright, which is what the number is supposed to mean.
float area = length(cross(halfWidth, halfHeight)) * 4.0;
float radiance = area > 1e-9 ? 1.0 / area : 0.0;
// The range window only. A punctual light needs the inverse square as
// well; the form factor already contains it, because a panel twice as far
// away subtends a quarter of the sky.
float distance = length(toCentre);
if (direction.w > 0.0) {
float ratio = distance / direction.w;
float window = clamp(1.0 - ratio * ratio * ratio * ratio, 0.0, 1.0);
radiance *= window * window;
}
vec3 mirror = reflect(-s.v, s.n);
vec3 representative = RectangleClosestPoint(
position.xyz, halfWidth, halfHeight, v_world_position, mirror);
vec3 toPoint = representative - v_world_position;
float pointDistance = length(toPoint);
light.l = pointDistance > 1e-6 ? toPoint / pointDistance : s.n;
light.h = normalize(light.l + s.v);
light.n_dot_l = formFactor;
light.n_dot_h = max(dot(s.n, light.h), 0.0);
light.v_dot_h = max(dot(s.v, light.h), 0.0);
light.radiance = color.rgb * color.w * radiance;
return light;
}
vec3 aim = normalize(direction.xyz);
float attenuation = 1.0;
if (type < 0.5) {
// Directional: no position, no falloff. The direction to the light is the
// reverse of the direction it points.
light.l = -aim;
} else {
vec3 toLight = position.xyz - v_world_position;
float distance = length(toLight);
// A light exactly on the surface has no direction; treat it as contributing
// nothing rather than producing a NaN that spreads through the frame.
if (distance < 1e-6) {
light.l = s.n;
light.h = s.n;
light.radiance = vec3(0.0);
light.n_dot_l = 0.0;
light.n_dot_h = 0.0;
light.v_dot_h = 0.0;
return light;
}
light.l = toLight / distance;
attenuation = PunctualAttenuation(distance, direction.w);
if (type > 1.5) {
// Spot: a smooth ramp between the two cone cosines. The Dart side already
// guarantees the denominator is non-zero.
float cosAngle = dot(aim, -light.l);
attenuation *= clamp(
(cosAngle - cone.y) / (cone.x - cone.y), 0.0, 1.0);
}
}
light.h = normalize(light.l + s.v);
light.n_dot_l = max(dot(s.n, light.l), 0.0);
light.n_dot_h = max(dot(s.n, light.h), 0.0);
light.v_dot_h = max(dot(s.v, light.h), 0.0);
light.radiance = color.rgb * color.w * attenuation;
return light;
}
/// How much of light [index] reaches this fragment, defined by each fragment
/// shader.
///
/// A prototype rather than a call into shadow.glsl, because the models that
/// sample no shadow map must not declare its sampler — the compiler would drop
/// the slot and leave the engine binding one that is not there. A lit model
/// returns `ShadowFactor(...)`; an unlit one returns 1.
float LightVisibility(Surface s, LightSample light, int index);
/// A model's per-light term, defined by each fragment shader.
///
/// A prototype here and the definition in the model is what lets the loop below
/// be written once. The alternative — repeating the loop in every model — is
/// six copies of the same three lines, and the place a light would go missing.
vec3 ShadeLight(Surface s, LightSample light);
/// Sums every active light's contribution.
///
/// The loop bound is the compile-time maximum with a runtime break, because GLSL
/// wants a constant trip count and the hardware wants the early exit.
// **The point-shadow half of this header, behind a guard.**
//
// A model that never shadows must not *declare* any of this, and the reason is
// the one `unlit.frag` already gives about the shadow sampler — with one
// backend's failure added to the other's. On Impeller the compiler drops what
// nothing reads, and the engine binding a slot that is no longer there is a
// native crash. On WebGL2 nothing is dropped: an active uniform block with no
// buffer under it makes every draw `INVALID_OPERATION`, discarded with nothing
// logged.
//
// That is what `lighting-unlit` was on this backend. Unlit's own metadata says
// `usesPointShadow` is false, so the engine correctly bound no `PointShadow`
// block — and the translated shader declared one anyway, so the sphere was
// never drawn and the frame came back the clear colour.
#ifndef F3D_NO_POINT_SHADOW
/// The cube atlas: three tiles across, two down, each a ninety-degree view
/// from a point light, each storing radial distance normalised by range.
uniform sampler2D point_shadow_texture;
/// The same atlas for the things that never move, rendered once at load.
///
/// Two maps rather than one because a dungeon's walls can be baked and a
/// spinning pickup cannot, and there is no way to draw into part of a texture
/// without redrawing the rest of it. Sampling both and keeping the nearer
/// occluder costs one extra read and saves six views of the level every frame.
uniform sampler2D point_shadow_static_texture;
/// How many lights may have a row of the atlas. Six tiles across each.
// Rows of the cube atlas: six faces across, this many lights down. Must
// match `Renderer.kShadowedLights`, which is where the reasoning lives, and
// `shadowSlots` in the software backend's transcription of this file.
const int kShadowSlots = 6;
layout(std140) uniform PointShadow {
/// The same view-projections the atlas was rendered with, six per slot.
///
/// Passed rather than reconstructed. Deriving cube face coordinates here
/// would be a second implementation of a decision the renderer already made,
/// and the two would disagree about handedness or up vectors on some face
/// and nowhere else — which shows as one face of every shadow being wrong.
mat4 faces[6 * kShadowSlots];
/// Per slot. xyz: the light's world position. w: its range.
vec4 lights[kShadowSlots];
/// Per light, in the order the lighting knows them.
///
/// x: the atlas row it owns, or negative when it has none — a fifth torch in
/// a room lands there. z: the tangent of half the frustum's opening angle,
/// which is what converts a world width into a fraction of a tile. y and w
/// are unwritten.
///
/// **z is exactly one for a point light**, because a cube face is a ninety
/// degree frustum and `tan(45°) == 1`. That is not a convention chosen to be
/// tidy: it is what lets a narrower frustum share this whole path, since
/// multiplying by one in IEEE 754 changes no bit of the result. Whatever else
/// a spot light will need, it does not need a second copy of the filter.
vec4 slots[kMaxLights];
/// x: half a texel, in tile-local uv. y: distance bias in metres.
/// z: strength. w: normal offset, **in texels of the face it lands on**.
vec4 params;
/// x: smallest kernel radius in tile-local uv, and the fixed radius used
/// when contact hardening is off. y: the light's own radius in metres; zero
/// turns contact hardening off. z: largest kernel radius in tile-local uv.
/// w: non-zero paints the penumbra estimate into the surface buffer instead
/// of shading with it.
vec4 params2;
/// x: non-zero when this backend stores the atlas bottom-up. y: one over the
/// edge length of a tile in texels, which is what turns a distance into the
/// world width of one texel there.
///
/// **Appended after everything else on purpose**, the same way FragInfo's
/// ambient pair was: std140 lays a block out in declaration order, so adding
/// here leaves every offset above unchanged and the three backends do not
/// have to agree about anything they already agreed about. y, z and w are
/// unwritten.
vec4 params3;
}
point_shadow;
/// Eight points on a Poisson disk, a common set for filtering cascaded
/// shadows.
///
/// A disk rather than a grid because a grid of taps on a straight shadow edge
/// lands every sample on the same side at once, and the edge steps between
/// kernel widths instead of sliding. Eight rather than sixteen because every
/// tap here reads **two** atlases — the static walls and the movers — so the
/// cost is doubled before it is counted.
vec2 PointShadowDiskTap(int i) {
if (i == 0) return vec2(-0.94201624, -0.39906216);
if (i == 1) return vec2(0.94558609, -0.76890725);
if (i == 2) return vec2(-0.09418410, -0.92938870);
if (i == 3) return vec2(0.34495938, 0.29387760);
if (i == 4) return vec2(-0.91588581, 0.45771432);
if (i == 5) return vec2(-0.81544232, -0.87912464);
if (i == 6) return vec2(-0.38277543, 0.27676845);
return vec2(0.97484398, 0.75648379);
}
/// One comparison against the atlas, at [uv] offset within the tile.
///
/// The clamp is applied **after** the offset, not before, and that is the whole
/// reason a kernel can be widened here without touching anything else: each tap
/// is held inside its own tile individually. Clamping the centre and then
/// offsetting would let the outer taps walk straight out of the tile and read a
/// distance measured from a different face, or a different light.
float PointShadowDistance(vec2 uv, vec2 offset, vec2 tile, float range) {
float inset = point_shadow.params.x;
vec2 local = clamp(uv + offset, inset, 1.0 - inset);
vec2 atlas = (local + tile) * vec2(1.0 / 6.0, 1.0 / float(kShadowSlots));
// **The whole atlas, turned over, where row zero of a render target is at the
// bottom.** Both halves of the address are wrong there and this is the one
// place that fixes both: the tile the light owns — a light in slot zero is
// drawn into the row the shader would call three, because the viewport
// rectangle is flipped to land it — and the picture inside that tile, which
// was drawn through a projection built for the other origin.
//
// Every check of this atlas missed it for the same reason: the debug view
// composites the texture through a full-screen pass, which turns it over
// again and puts the row back. The atlas compared equal on both backends
// across six scenes while the lit pass, which samples it directly and has no
// such pass to cancel, read a row that had never been drawn into and found
// nothing in the way of anything.
if (point_shadow.params3.x > 0.5) atlas.y = 1.0 - atlas.y;
// Whichever is nearer occludes: a wall in front of a monster shadows, and so
// does a monster in front of a wall.
//
// **`textureLod` at level zero, because every caller of this function stands
// behind a branch.** The light loop skips a light the surface faces away
// from, the blocker search `continue`s past a tap that found nothing, and the
// slot test returns before any of it — so the invocations of a quad do not
// arrive here together, and a WGSL backend refuses a sample whose implicit
// derivative would be read where they disagree. Both atlases are distance
// render targets with one level, so level zero is the level `texture` was
// choosing anyway; this names it rather than deriving it, and the picture is
// the same on every backend.
return min(textureLod(point_shadow_texture, atlas, 0.0).r,
textureLod(point_shadow_static_texture, atlas, 0.0).r) * range;
}
float PointShadowTap(vec2 uv, vec2 offset, vec2 tile, float range,
float receiver) {
float stored = PointShadowDistance(uv, offset, tile, range);
// Nothing was drawn in that direction by either, so nothing is in the way.
if (stored >= range * 0.999) return 1.0;
return receiver > stored ? 0.0 : 1.0;
}
/// The disk point for tap [i], rotated by [ca]/[sa] and scaled to [radius].
vec2 PointShadowOffset(int i, float ca, float sa, float radius) {
vec2 p = PointShadowDiskTap(i);
return vec2(p.x * ca - p.y * sa, p.x * sa + p.y * ca) * radius;
}
/// How wide the penumbra should be here, in tile-local uv.
///
/// Contact hardening, and the reason a fixed kernel looks wrong: a shadow is
/// sharp where its caster touches the floor and soft a metre away, and one
/// radius for both makes the contact mushy or the distant edge hard.
///
/// The similar-triangles estimate is the standard one — a light of radius `L`
/// with a blocker at `b` and a receiver at `r` throws a penumbra `L * (r - b) /
/// b` wide at the receiver. Converting that to tile uv is exact rather than
/// tuned, because a face is a ninety degree frustum: at distance `r` from the
/// light the face spans `2 * r` in world units across the full `0..1` of uv,
/// so a world width `w` is `w / (2 * r)` of a tile.
///
/// The blocker search runs at the **widest** penumbra allowed, since a blocker
/// outside that circle cannot widen the result anyway, and searching narrower
/// would miss the very blockers that make an edge soft.
///
/// [tanHalf] is where the ninety degrees stop being assumed. The span above is
/// `2 * r` only for a right-angled frustum; in general it is `2 * r * tan(θ/2)`,
/// and for a cube face that factor is one. A narrower frustum covers less world
/// per tile, so the same world width is a *larger* fraction of it — which is
/// why this divides rather than multiplies, and why getting it upside down
/// would make a tight cone's shadows harden instead of soften.
float PointShadowPenumbra(vec2 uv, vec2 tile, float range, float receiver,
float ca, float sa, float tanHalf,
out float blockerOut) {
blockerOut = -1.0;
float lightRadius = point_shadow.params2.y;
float minRadius = point_shadow.params2.x;
float maxRadius = point_shadow.params2.z;
if (lightRadius <= 0.0) {
// **The debug channel is filled even though the search is skipped**, and
// leaving it unfilled cost a session. `blockerOut` starts at −1 to mean
// "nothing was measured"; the debug encoding clamps it into a colour, where
// −1 becomes zero — the same green as a blocker touching the surface, which
// reads as the most alarming answer available. A whole theory was built on
// that zero, and the search it described had never run.
//
// The centre tap is what the filter below would use anyway, so this reports
// a distance the atlas really returned rather than a sentinel.
blockerOut = PointShadowDistance(uv, vec2(0.0), tile, range);
return minRadius;
}
float sum = 0.0;
float count = 0.0;
for (int i = 0; i < 8; i++) {
float stored =
PointShadowDistance(uv, PointShadowOffset(i, ca, sa, maxRadius), tile,
range);
if (stored >= range * 0.999) continue;
if (stored >= receiver) continue;
sum += stored;
count += 1.0;
}
// Nothing in front of this fragment anywhere in the search: fully lit, and
// the caller can skip the filter entirely.
if (count < 0.5) return -1.0;
float blocker = max(sum / count, 1e-4);
blockerOut = blocker;
float world = lightRadius * max(receiver - blocker, 0.0) / blocker;
return clamp(world / (2.0 * receiver * tanHalf), minRadius, maxRadius);
}
/// How lit [world] is by the point light that owns the cube atlas.
///
/// One, fully lit, when this is not that light or the atlas has nothing to say.
float PointShadowFactor(vec3 world, vec3 normal, int lightIndex) {
int slot = int(point_shadow.slots[lightIndex].x + 0.5);
if (point_shadow.slots[lightIndex].x < 0.0) return 1.0;
float strength = point_shadow.params.z;
if (strength <= 0.0) return 1.0;
// Offset along the normal before measuring, and scaled by how steeply the
// surface leans away from the light.
//
// A soft kernel on a tilted surface straddles a depth gradient: the taps at
// one end of the disk are further from the light than the fragment itself,
// so a flat offset that clears the surface head-on leaves acne at a grazing
// angle. The slope term lifts the whole kernel clear instead, and is capped
// because it runs away as the surface turns edge-on to the light — an
// uncapped lift detaches the shadow from its caster.
vec3 toLight = point_shadow.lights[slot].xyz - world;
float toLightLength = max(length(toLight), 1e-6);
float nDotL = max(dot(normal, toLight / toLightLength), 0.15);
float slope = min(sqrt(max(1.0 - nDotL * nDotL, 0.0)) / (nDotL * nDotL), 8.0);
// **How wide one texel of the face is, out where this fragment is.** The
// error a normal offset exists to clear is exactly that: a texel of the
// shadow map covers a patch of surface, the whole patch is recorded at one
// distance, and a fragment anywhere else in it compares against a distance
// measured somewhere it is not. That patch grows with range — it is a solid
// angle, not a length — so an offset fixed in metres is right at one distance
// and wrong everywhere else.
//
// What it was: `params.w` metres, flat. On the golden teapot, at 9.6 m from
// the lamp, a texel is 3.7 cm and the flat offset was 2 cm, so the floor
// shadowed itself across everything the light reached — and the acne stopped
// dead at the *projection of the floor's own edge*, because past it the atlas
// holds nothing and nothing can occlude. A straight line across a shadow with
// no straight edge anywhere in the scene.
float texel =
2.0 * toLightLength * max(point_shadow.slots[lightIndex].z, 1e-4) *
point_shadow.params3.y;
// Both terms are metres. The slope term used to be the kernel radius, which
// is a fraction of a tile — a unit mismatch carried over from an estimate
// where a softness radius genuinely was the right quantity. Here it meant
// widening the kernel also lifted the sample off the surface, by up to ten
// centimetres at the wider settings, so the softening and the lift
// cancelled: tripling the kernel moved 184 pixels of the frame,
// where the kernel alone moves thousands. It is what made contact hardening
// look inert, and it was hiding in a comparison rather than in the estimate.
vec3 origin = world + normal * texel * point_shadow.params.w * (1.0 + slope);
vec3 toFragment = origin - point_shadow.lights[slot].xyz;
float distance = length(toFragment);
float range = max(point_shadow.lights[slot].w, 1e-4);
if (distance >= range) return 1.0;
// The dominant axis picks the face, in the order the renderer wrote them:
// +X, -X, +Y, -Y, +Z, -Z, left to right then top to bottom.
//
// A spot has one column and no choice to make. Asking the dominant axis
// anyway would be worse than pointless: a fragment below and to the side of
// a downlight has −Y dominant, which is column 3, and column 3 of a spot's
// row is deliberately blank — so the whole cone would read as unshadowed
// except for the wedge where the aim happens to be the dominant axis.
int face = 0;
if (point_shadow.slots[lightIndex].y < 0.5) {
vec3 a = abs(toFragment);
if (a.x >= a.y && a.x >= a.z) {
face = toFragment.x > 0.0 ? 0 : 1;
} else if (a.y >= a.z) {
face = toFragment.y > 0.0 ? 2 : 3;
} else {
face = toFragment.z > 0.0 ? 4 : 5;
}
}
vec4 clip = point_shadow.faces[slot * 6 + face] * vec4(origin, 1.0);
if (clip.w <= 0.0) return 1.0;
vec2 ndc = clip.xy / clip.w;
if (abs(ndc.x) > 1.0 || abs(ndc.y) > 1.0) return 1.0;
// v is flipped, the same way the directional map does it: the texture's
// origin is at the top, where row zero of the render target is. Getting this
// wrong does not tilt the shadow — it makes the top row of faces read the
// bottom row, so a whole region compares against an unrelated distance and
// comes out as a black slab.
vec2 uv = vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
// The face across, the light down: six tiles wide, four tall.
vec2 tile = vec2(float(face), float(slot));
float receiver = distance - point_shadow.params.y;
// One rotation, shared by the blocker search and the filter. Per fragment,
// so eight samples read as a soft edge rather than as eight copies of the
// silhouette: without it every fragment along an edge tests the same eight
// directions and the pattern shows.
//
// **The three constants are not arbitrary and are not ours.** This is Jorge
// Jimenez's interleaved gradient noise, from "Next Generation Post
// Processing in Call of Duty: Advanced Warfare" (SIGGRAPH 2014):
//
// IGN(x, y) = frac(52.9829189 * frac(0.06711056 * x + 0.00583715 * y))
//
// The pair inside the dot is a direction whose gradient walks the unit
// interval as slowly as it can while never repeating over a screen, and the
// multiplier outside stretches that walk so neighbouring pixels land far
// apart in the result. What it buys over a hash is the cost: one dot and two
// fracts, no integer arithmetic, no texture. What a blue-noise texture buys
// over it is a better spectrum, at a sampler and a fetch — worth it for
// dithering a whole frame, not for rotating eight taps.
//
// Written down because three unexplained decimals read as a magic spell, and
// the next person to touch this line has no way to tell which of them may be
// changed. The answer is none of them.
float noise = fract(52.9829189 * fract(dot(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 < kTotalLights; i++) {
if (i >= count) break;
LightSample light = SampleLight(i, s);
if (light.n_dot_l <= 0.0) continue;
// A light from the list has no shadow row to read — see `LightHasShadow`.
// A branch rather than something folded into the two calls, because both
// index tables eight entries wide and the ninth light would read past them
// rather than read a one.
float visibility = LightHasShadow(i)
? LightVisibility(s, light, i) *
PointShadowFactor(v_world_position, s.n, i)
: 1.0;
if (visibility <= 0.0) continue;
total += ShadeLight(s, light) * light.radiance * light.n_dot_l * visibility;
}
return total;
}
#endif // SURFACE_GLSL_
// Never called, for the reason `unlit.frag` gives: nothing here accumulates
// lights, and the prototypes in surface.glsl still have to be satisfied.
vec3 ShadeLight(Surface s, LightSample light) {
return s.albedo;
}
float LightVisibility(Surface s, LightSample light, int index) {
return 1.0;
}
void main() {
Surface s = ReadSurface();
// The same call unlit makes, and it behaves the same way but for the guarded
// half: colour into the HDR target, and `WriteSurfaceGeometry` compiled away
// to nothing. The stage binds no fog, so `ApplyFog` is the identity here.
WriteSurface(s.albedo, s.alpha);
}
''',
'Lambert': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;
// Pure diffuse. The cheapest model that still reads as three-dimensional, and
// the reference point for judging whether the fancier models are worth their
// cost on a given target.
// --- lib/material_maps.glsl ---
// The texture maps a lit material can carry, beyond base colour.
//
// A separate header from surface.glsl on purpose. Declaring a sampler a shader
// never reads is the same trap as declaring an unused uniform block: the
// compiled function has no such slot, while the Dart side still has metadata
// saying it does. Unlit and the debug models include surface.glsl (or only
// color.glsl) and get none of this; the lit models include both, and
// LightingModel.usesMaterialTextures says which is which.
//
// Every map has a *neutral* fallback texture bound when the material has none,
// so there are no "has this map" flags to keep in sync — a white ORM texture
// multiplies the factors by one, and a flat normal map perturbs nothing. Flags
// would have to be right in two places; a neutral texel is right by
// construction.
#ifndef MATERIAL_MAPS_GLSL_
#define MATERIAL_MAPS_GLSL_
// --- lib/surface.glsl ---
// Shared material and lighting interface for the lighting models.
//
// flutter_gpu compiles shaders ahead of time into a bundle: there is no runtime
// compilation, so a node-graph material system assembled at run time is
// impossible. Each lighting model is therefore
// its own pre-built fragment shader, and this header is what keeps them
// interchangeable — one identical uniform block, so the Dart binding code never
// needs to know which model is active.
//
// Keep every declaration below byte-identical across models. A member a model
// does not read may be optimized out of the reflected block, which is why the
// Dart side skips absent members instead of failing.
//
// Only include this from a shader that actually reads FragInfo. Declaring the
// block without using it leaves it visible to reflection while the compiled
// shader binds no buffer for it, and binding that phantom block segfaults
// inside Metal. Shaders needing only colour helpers include lib/color.glsl.
#ifndef SURFACE_GLSL_
#define SURFACE_GLSL_
// --- lib/color.glsl ---
// Colour space helpers and the fragment output interface.
//
// Split out of surface.glsl so a shader that needs no material inputs — the
// normals debug view — can avoid DECLARING the FragInfo uniform block at all.
// That matters more than it looks: reflection metadata reports a block as
// present merely because it was declared, even when the compiled shader binds
// no such buffer, so a declared-but-unused block is indistinguishable from a
// used one until Metal crashes on the bind.
#ifndef COLOR_GLSL_
#define COLOR_GLSL_
precision highp float;
const float kPi = 3.14159265359;
// One varying set shared by every fragment shader, matching mesh.vert.
//
// All five are declared here, including the two the debug models never read: a
// fragment shader whose `in` block disagrees with the vertex shader's `out`
// block fails to link, and there is no partial-match rule to lean on.
in vec3 v_world_position;
in vec3 v_normal;
in vec2 v_texcoord;
in vec4 v_tangent;
in vec4 v_color;
/// Where this fragment is in the level's lightmap. Zero from every vertex
/// stage but `mesh_lightmapped.vert`, and read only by the lit models, which
/// sample a one-texel black there when a material has no map.
in vec2 v_lightmap_uv;
layout(location = 0) out vec4 frag_color;
// The second attachment: what a screen-space effect needs to know about the
// surface it is looking at. World-space normal in rgb, and in a the depth along
// the view axis in world metres — not a window depth; `WriteSurfaceGeometry`
// says at length why not.
//
// Depth travels here rather than in a depth texture because flutter_gpu cannot
// sample one — the same reason the shadow pass writes its depth into a colour
// target. See ARCHITECTURE.md §2.
//
// Guarded, because not every stage that includes this header draws into a
// two-attachment target. The shadow pass draws into one, and a pipeline
// declaring an output its target has no slot for is a mismatch worth avoiding
// rather than discovering.
#ifndef F3D_NO_SURFACE_BUFFER
layout(location = 1) out vec4 frag_surface;
#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;
// **A stage that needs none of this must be able to declare none of it.** On
// Vulkan both stages' descriptors are merged into one set layout, and two
// bindings with the same number in it is not a layout the specification
// allows. A driver may accept it anyway; a Galaxy A55's refuses the pipeline
// with `ErrorUnknown` and no other word, which is how the shadow pass came to
// build everywhere except there — its only uniform block was this one, and it
// landed on the same binding as the vertex stage's first.
#ifndef F3D_NO_FOG
/// Distance fog, in its own block rather than folded into FragInfo.
///
/// Its own because color.glsl is included before FragInfo is declared, and
/// because appending to a block that half a dozen shaders already share is a
/// way to move offsets nobody expected to move. Three vec4s is a cheap price
/// for not touching any of that.
layout(std140) uniform FogInfo {
/// rgb: linear fog colour. w: density per metre, zero for no fog.
vec4 fog;
/// xyz: camera position in world space. Duplicated from FragInfo so this
/// block stands alone; a vec3 is cheaper than a coupling.
vec4 eye;
/// xyz: the direction the camera looks, as a unit vector in world space.
/// w unused.
///
/// Here rather than in a block of its own because it answers the same
/// question [eye] does — where the camera is and which way it faces — and
/// this is the block `color.glsl` can see.
vec4 forward;
}
fog_info;
/// How far this fragment is from the eye, in world metres.
///
/// What the fog fades by. Distance rather than depth, because fog is a
/// property of the air between two points and does not care which way the
/// camera happens to face.
float EyeDistance() { return distance(v_world_position, fog_info.eye.xyz); }
/// How far this fragment is *along the view axis*, in world metres.
///
/// What the surface buffer's alpha holds. Depth rather than distance, and the
/// difference only shows on an orthographic camera — where the rays through
/// the pixels are parallel instead of meeting at the eye, so a distance from
/// the eye names a sphere that the pixel's ray crosses somewhere the reader
/// cannot solve for. A depth along the axis names a plane, which every ray
/// crosses exactly once. See `WorldAtDepth` in `post/ssao.frag` for the
/// reconstruction both projections share.
float ViewDepth() {
return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz);
}
#else // F3D_NO_FOG
// The same two questions, answered without the block: a stage that declares no
// fog has no eye position to measure from either. Stubs rather than a guard at
// every call site, so that what includes this file reads the same whichever
// way it was compiled.
float EyeDistance() { return 0.0; }
float ViewDepth() { return 0.0; }
#endif // F3D_NO_FOG
/// Records the geometry of this fragment for whatever runs after the scene.
///
/// Called from the same place that writes colour, so a surface cannot be lit
/// into the frame without also describing itself — which is the failure that
/// leaves a screen-space effect reflecting whatever was in the buffer before.
///
/// rg: octahedral normal. b: perceptual roughness. a: **depth along the view
/// axis, in world metres** — see [ViewDepth].
///
/// **Not `gl_FragCoord.z`, and that is a defect this channel carried until it
/// was looked at.** Window depth crowds every distant surface into the top of
/// its range — with a near plane of a tenth of a metre, everything past twenty
/// metres lives in the last half a hundredth of `[0, 1]` — and this attachment
/// is a half float, whose steps up there are about five ten-thousandths. So two
/// surfaces half a metre apart at twenty metres stored the *same* number, and
/// every screen-space pass that compares against this channel decided whole
/// bands of pixels by rounding. The occlusion pass drew them: vertical stripes
/// along the lines of constant depth on any wall receding from the camera, on
/// both GPU backends. The software rasteriser kept the channel at full
/// precision and drew the effect correctly, so it was the one that looked
/// wrong against the other two.
///
/// A depth in metres has none of that: the exponent carries the range and the
/// mantissa carries the same relative precision everywhere, which at twenty
/// metres is a centimetre. Both numbers are measured in
/// `flutter3d/test/surface_depth_test.dart`.
///
/// Zero still means nothing was drawn. The attachment is cleared to zero and
/// nothing is drawn in front of the near plane.
void WriteSurfaceGeometry(float roughness) {
#ifndef F3D_NO_SURFACE_BUFFER
// A debug pass takes the buffer over rather than getting one of its own.
// The surface buffer already has an attachment, a viewer and a golden; a
// second one would need all three built before it could answer anything.
if (g_debug_surface_on) {
frag_surface = vec4(g_debug_surface, ViewDepth());
return;
}
frag_surface = vec4(EncodeOctahedral(normalize(v_normal)),
clamp(roughness, 0.0, 1.0), ViewDepth());
#endif
}
/// Fades [color] toward the fog with distance from the eye.
///
/// Exponential rather than linear, because linear fog has a visible plane
/// where it starts and a dungeon corridor is exactly where that shows.
vec3 ApplyFog(vec3 color) {
#ifdef F3D_NO_FOG
return color;
#else
float density = fog_info.fog.w;
if (density <= 0.0) return color;
float d = EyeDistance();
return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0));
#endif
}
/// 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
/// How many more lights one draw may be handed — `gfx-74n`.
///
/// **The eight above stay exactly what they were**, which is what keeps this
/// from moving a single recorded frame: a draw with eight lights or fewer runs
/// the loop it has always run, reads the uniform arrays it has always read, and
/// never touches the texture below. The tail is the part that used to be
/// impossible.
///
/// A loop bound rather than a cost. `AccumulateLights` breaks at the draw's own
/// count, so a scene with three lights costs three iterations whatever this
/// says. Twenty-four because the two tables below are `vec4 x[6]` and four
/// lanes fit a `vec4`: two hundred and eight bytes a draw, against the five
/// hundred and twelve the light arrays already cost.
#define kExtraLights 24
#define kTotalLights (kMaxLights + kExtraLights)
/// Every light in the scene, one per row, four texels across — `gfx-74n`.
///
/// **A texture rather than a wider uniform block, and that is the design.**
/// `FragInfo` is uploaded on every draw, so widening its four `vec4` arrays to
/// hold thirty-two lights would be a two-kilobyte upload per draw in every
/// scene, including every scene with one light. This is built once a frame and
/// only when a scene has more lights than a draw can hold in its slots.
///
/// Row layout, which `renderer_light_list.dart` writes and only this reads:
///
/// * texel 0 — xyz world position, w type (0 directional, 1 point, 2 spot)
/// * texel 1 — rgb linear colour, w intensity
/// * texel 2 — xyz the direction it points, w range
/// * texel 3 — x cos(inner), y cos(outer), zw unused
///
/// The same four vectors the uniform arrays hold, in the same order, so one
/// reader serves both.
///
/// **`F3D_NO_LIGHT_LIST` leaves both out**, for a model that accumulates no
/// lights. Such a model never reaches the reader below, so the compiler drops
/// the block and the sampler from the Metal function while reflection still
/// lists them, with no buffer or texture index assigned. The renderer used to
/// bind them for every draw, Unlit included, and that bind is a crash inside
/// `setFragmentBuffer:offset:atIndex:` on Metal. Vulkan took the same draw
/// without a word, which is how 0.7.0 shipped with it.
#ifndef F3D_NO_LIGHT_LIST
uniform sampler2D light_list_texture;
layout(std140) uniform LightListInfo {
/// x: how many rows this draw reads, zero when it reads none.
/// y, z: one over the texture's width and height.
/// w: unused.
vec4 list;
/// Which rows, four to a vector, in the order they are read.
///
/// Indices rather than the light data itself: the data is the same for every
/// draw in the frame and belongs in the texture; what differs per draw is
/// *which* of them reach it, and that is what `Renderer._drawLightsFor`
/// already decides.
vec4 indices[6];
/// How much of each of those survives the edge fade, in the same order.
///
/// Per draw and not in the texture, because the row an index points at is
/// shared by every draw in the frame: a scale written into it would dim that
/// light for all of them. `gfx-12n`'s fade lives at the end of the list now —
/// that is where a light stops contributing, and fading the slots against a
/// water line that no longer marks a cliff would dim a light for no reason
/// while its rival stayed bright, making the swap more visible rather than
/// less.
vec4 scales[6];
}
light_list_info;
/// One lane of a six-vector table, [slot] counting from nought.
float LightListLane(vec4 four, int slot) {
int lane = slot - (slot / 4) * 4;
return lane == 0 ? four.x : lane == 1 ? four.y : lane == 2 ? four.z : four.w;
}
/// The row light [slot] of the list reads.
float LightListRow(int slot) {
return LightListLane(light_list_info.indices[slot / 4], slot);
}
/// How much of light [slot] of the list survives the edge fade.
float LightListScale(int slot) {
return LightListLane(light_list_info.scales[slot / 4], slot);
}
#endif // F3D_NO_LIGHT_LIST
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 is the directional light's apparent size** — `gfx-15n` — which has
/// nothing to do with ambient and everything to do with this being the last
/// unspent component in a block six shaders share. `frame_params.w` was the
/// slot reserved for a frame-wide parameter and the environment's level
/// count took it; appending to this block moves offsets four backends have
/// agreed on. See `shadow.glsl`, which reads it.
///
/// Two colours rather than one is the whole of what makes ambient look like
/// light instead of like a lifted black level. Outdoors the sky is blue and
/// bright and the ground is warm and dim, and a flat grey for both leaves
/// every underside as pale as every upward face — which reads as the model
/// being flat, and gets blamed on the normals.
vec4 ambient_ground;
}
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.
//
// **A cutoff below -1.5 is the fourth mode: hashed** — `gfx-16n`. The
// sentinel rides in the same component because the alternative is a second
// number in a block six shaders share, and -1 already meant "not masked";
// anything more negative was free. See [MaterialAlphaMode.hashed].
float cutoff = frag_info.material2.x;
if (cutoff >= 0.0) {
if (s.alpha < cutoff) discard;
} else if (cutoff < -1.5) {
// **Stochastic instead of a threshold.** A leaf texture at 40% opacity is
// either entirely there or entirely gone under a fixed cutoff, so a fern
// comes out as a hard-edged cardboard cut-out; sorting would fix it and
// costs a sort per frame and a draw per layer. Comparing against noise
// instead keeps 40% of the *pixels*, which resolves as 40% opacity to
// anything that averages several of them — a higher-resolution target,
// a downsample, a person standing back.
//
// **Hashed on world position, not on the screen.** Screen-space noise is
// one line shorter and swims: the pattern stays put while the object
// moves through it, so a moving branch sparkles. Anchoring it to where
// the surface *is* means a given speck of leaf keeps its verdict from
// frame to frame, and the camera moving changes nothing.
//
// The scale is a constant and it is the whole tuning: finer than the
// texture's own detail and the noise disappears into aliasing, coarser
// and the leaf breaks into blotches. Sixteen per metre is about a
// centimetre of grain at a metre away.
vec3 anchored = floor(v_world_position * 16.0);
float noise = fract(
sin(dot(anchored, vec3(12.9898, 78.233, 37.719))) * 43758.5453);
if (s.alpha < noise) discard;
}
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() {
#ifdef F3D_NO_LIGHT_LIST
return clamp(int(frag_info.frame_params.y + 0.5), 0, kMaxLights);
#else
return clamp(int(frag_info.frame_params.y + 0.5), 0, kMaxLights) +
clamp(int(light_list_info.list.x + 0.5), 0, kExtraLights);
#endif
}
/// Whether light [index] carries a shadow — `gfx-74n`.
///
/// Only the first eight do. The cube atlas holds six rows and the slot table is
/// eight entries wide, so a light from the list has no row to read and asking
/// for one would index past the table. That is a real limit and the right one:
/// the eight a draw keeps in its slots are the eight ranked most relevant to
/// it, which is exactly the set worth a shadow map.
bool LightHasShadow(int index) { return index < kMaxLights; }
/// Distance attenuation for a punctual light, following the glTF spec.
///
/// Inverse square with an optional range window. The window is what stops a
/// lamp with a declared range from contributing a faint haze across the whole
/// scene, which matters far more once there are eight of them.
float PunctualAttenuation(float distance, float range) {
float attenuation = 1.0 / max(distance * distance, 1e-4);
if (range > 0.0) {
float ratio = distance / range;
float window = clamp(1.0 - ratio * ratio * ratio * ratio, 0.0, 1.0);
attenuation *= window * window;
}
return attenuation;
}
/// How much of [s]'s sky a rectangle covers, weighted by the cosine —
/// `gfx-77n`.
///
/// **Exact, not fitted.** This is Lambert's own form factor for a polygon, from
/// 1760: for each edge, the angle it subtends at the shading point times how
/// much the edge's plane leans into the surface normal. Summed over four edges
/// and halved, it *is* the integral of `cos θ` over the rectangle's projection
/// on the hemisphere — the quantity a punctual light approximates with a single
/// `n · l`. So there is no table to ship and nothing to fit: the usual
/// linearly-transformed-cosine approach exists to make the *specular* lobe
/// tractable, and buys nothing here, where the diffuse answer is a closed form
/// four `acos` calls long.
///
/// Returns irradiance over radiance, so a surface facing a rectangle that fills
/// its whole sky gets π, the same as a uniform hemisphere. [corners] are the
/// four vertices in order, relative to the shading point.
///
/// **The rectangle emits along `cross(halfWidth, halfHeight)`**, and with the
/// corners wound as `SampleLight` winds them the sum comes out *negative* on
/// that side, so the negation below is the convention rather than a fix. It was
/// measured rather than derived: the first version returned `+total * 0.5`, and
/// against the reference integration it read nought where the answer was 0.349
/// and 1.02 where the answer was nought — the two failures a flipped winding
/// produces, and between them they name the sign with no room left to argue.
float RectangleFormFactor(vec3 corners[4], vec3 n) {
float total = 0.0;
for (int i = 0; i < 4; i++) {
vec3 a = normalize(corners[i]);
vec3 b = normalize(corners[(i + 1) & 3]);
// Clamped before the `acos`: two nearly parallel edge directions can give a
// dot a hair past one through rounding alone, and `acos` of that is a NaN
// that spreads to the whole pixel and then to the bloom.
float angle = acos(clamp(dot(a, b), -1.0, 1.0));
vec3 axis = cross(a, b);
float len = length(axis);
// A degenerate edge — the shading point lies on the line through it —
// subtends nothing, and normalising a zero vector is the other way to get
// that NaN.
if (len > 1e-6) total += angle * dot(axis / len, n);
}
// Clamped rather than tested separately: a surface on the panel's dark side,
// or facing away from it, comes out with the sign reversed, so "one-sided" is
// a property of the arithmetic instead of a flag somebody has to remember.
return max(-total * 0.5, 0.0);
}
/// Where on the rectangle the specular lobe is really looking — `gfx-77n`.
///
/// **The representative point, which is an approximation, unlike the diffuse
/// above.** The mirror direction leaves the surface and either hits the panel
/// or misses it; the closest point of the panel to that ray is treated as a
/// punctual light standing in for the whole rectangle. It is the standard
/// cheap answer and its one visible property is the one the row asked for: as
/// the view moves the closest point slides along the panel, so the highlight
/// is a streak with the panel's own shape and orientation rather than a dot.
///
/// What it does not do is widen the lobe by the panel's solid angle, so a
/// rough surface under a large panel is a little darker than a full integration
/// would make it. That is a known error of this method and not a bug in this
/// transcription; the fix is the fitted table this function exists to avoid.
vec3 RectangleClosestPoint(vec3 centre, vec3 halfWidth, vec3 halfHeight,
vec3 world, vec3 mirror) {
vec3 n = cross(halfWidth, halfHeight);
float nLen = length(n);
// A panel with no area has no surface to find a point on; its centre is the
// only answer that is not a division by zero.
if (nLen < 1e-12) return centre;
n /= nLen;
vec3 toPlane = centre - world;
float denom = dot(mirror, n);
vec3 onPlane;
// Parallel to the panel, or pointing away from it: the ray never lands, so
// the nearest thing to it is the centre projected back, which keeps the
// highlight on the panel instead of sending it to infinity.
if (abs(denom) < 1e-5) {
onPlane = toPlane - n * dot(toPlane, n);
} else {
float t = dot(toPlane, n) / denom;
onPlane = t > 0.0 ? mirror * t : toPlane - n * dot(toPlane, n);
}
// Clamped into the rectangle in its own axes. Dividing by the squared length
// turns a projection into a coordinate in units of the half-extent, so the
// clamp is against one either way round.
vec3 offset = onPlane - toPlane;
float wLen2 = max(dot(halfWidth, halfWidth), 1e-12);
float hLen2 = max(dot(halfHeight, halfHeight), 1e-12);
float u = clamp(dot(offset, halfWidth) / wLen2, -1.0, 1.0);
float v = clamp(dot(offset, halfHeight) / hLen2, -1.0, 1.0);
return centre + halfWidth * u + halfHeight * v;
}
/// 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;
vec4 color;
vec4 direction;
vec4 cone;
if (index < kMaxLights) {
position = frag_info.light_position[index];
color = frag_info.light_color[index];
direction = frag_info.light_direction[index];
cone = frag_info.light_cone[index];
} else {
#ifdef F3D_NO_LIGHT_LIST
// Unreachable: `LightCount` stops at the slots without a list.
position = vec4(0.0);
color = vec4(0.0);
direction = vec4(0.0);
cone = vec4(0.0);
#else
// A row of the light list — `gfx-74n`. Sampled at texel centres so a
// driver's rounding cannot land a fetch on a neighbour, and the four texels
// across the row are the same four vectors the arrays above hold.
int slot = index - kMaxLights;
float v = (LightListRow(slot) + 0.5) * light_list_info.list.z;
float u = light_list_info.list.y;
// `textureLod` and not `texture`, for `shadow.glsl`'s own reason: `index`
// reaches this branch through a function parameter, so a WGSL backend
// cannot see that every invocation of a draw walks the same light count
// and refuses the implicit derivative as possibly non-uniform. The atlas
// has one level, so naming it directly changes no pixel.
position = textureLod(light_list_texture, vec2(0.5 * u, v), 0.0);
color = textureLod(light_list_texture, vec2(1.5 * u, v), 0.0);
direction = textureLod(light_list_texture, vec2(2.5 * u, v), 0.0);
cone = textureLod(light_list_texture, vec2(3.5 * u, v), 0.0);
// The intensity and not the colour, for `LightBuffer._pack`'s own reason:
// the same multiply here, and only one of them is a number nobody authored.
color.w *= LightListScale(slot);
#endif // F3D_NO_LIGHT_LIST
}
float type = position.w;
// **The rectangle leaves before `aim` is taken — `gfx-77n`.** For every other
// kind `direction.xyz` is a unit vector saying which way the light points;
// for this one it is an edge of the panel, with its length carrying half the
// width, and normalising it here would quietly throw the size away.
if (type > 2.5) {
vec3 halfWidth = direction.xyz;
vec3 halfHeight = cone.xyz;
vec3 toCentre = position.xyz - v_world_position;
vec3 corners[4];
corners[0] = toCentre - halfWidth - halfHeight;
corners[1] = toCentre + halfWidth - halfHeight;
corners[2] = toCentre + halfWidth + halfHeight;
corners[3] = toCentre - halfWidth + halfHeight;
// The cosine-weighted solid angle, which takes the place `n · l` holds for
// a punctual light: the loop multiplies the shading by `n_dot_l`, so
// putting the exact integral here makes the diffuse term exact rather than
// sampled. See [RectangleFormFactor].
float formFactor = RectangleFormFactor(corners, s.n);
// Radiance rather than intensity: `intensity` means the same thing for
// every kind of light, so a panel's is spread over its own area here.
// Enlarging a window at a fixed rating then dims it per square metre and
// leaves the room as bright, which is what the number is supposed to mean.
float area = length(cross(halfWidth, halfHeight)) * 4.0;
float radiance = area > 1e-9 ? 1.0 / area : 0.0;
// The range window only. A punctual light needs the inverse square as
// well; the form factor already contains it, because a panel twice as far
// away subtends a quarter of the sky.
float distance = length(toCentre);
if (direction.w > 0.0) {
float ratio = distance / direction.w;
float window = clamp(1.0 - ratio * ratio * ratio * ratio, 0.0, 1.0);
radiance *= window * window;
}
vec3 mirror = reflect(-s.v, s.n);
vec3 representative = RectangleClosestPoint(
position.xyz, halfWidth, halfHeight, v_world_position, mirror);
vec3 toPoint = representative - v_world_position;
float pointDistance = length(toPoint);
light.l = pointDistance > 1e-6 ? toPoint / pointDistance : s.n;
light.h = normalize(light.l + s.v);
light.n_dot_l = formFactor;
light.n_dot_h = max(dot(s.n, light.h), 0.0);
light.v_dot_h = max(dot(s.v, light.h), 0.0);
light.radiance = color.rgb * color.w * radiance;
return light;
}
vec3 aim = normalize(direction.xyz);
float attenuation = 1.0;
if (type < 0.5) {
// Directional: no position, no falloff. The direction to the light is the
// reverse of the direction it points.
light.l = -aim;
} else {
vec3 toLight = position.xyz - v_world_position;
float distance = length(toLight);
// A light exactly on the surface has no direction; treat it as contributing
// nothing rather than producing a NaN that spreads through the frame.
if (distance < 1e-6) {
light.l = s.n;
light.h = s.n;
light.radiance = vec3(0.0);
light.n_dot_l = 0.0;
light.n_dot_h = 0.0;
light.v_dot_h = 0.0;
return light;
}
light.l = toLight / distance;
attenuation = PunctualAttenuation(distance, direction.w);
if (type > 1.5) {
// Spot: a smooth ramp between the two cone cosines. The Dart side already
// guarantees the denominator is non-zero.
float cosAngle = dot(aim, -light.l);
attenuation *= clamp(
(cosAngle - cone.y) / (cone.x - cone.y), 0.0, 1.0);
}
}
light.h = normalize(light.l + s.v);
light.n_dot_l = max(dot(s.n, light.l), 0.0);
light.n_dot_h = max(dot(s.n, light.h), 0.0);
light.v_dot_h = max(dot(s.v, light.h), 0.0);
light.radiance = color.rgb * color.w * attenuation;
return light;
}
/// How much of light [index] reaches this fragment, defined by each fragment
/// shader.
///
/// A prototype rather than a call into shadow.glsl, because the models that
/// sample no shadow map must not declare its sampler — the compiler would drop
/// the slot and leave the engine binding one that is not there. A lit model
/// returns `ShadowFactor(...)`; an unlit one returns 1.
float LightVisibility(Surface s, LightSample light, int index);
/// A model's per-light term, defined by each fragment shader.
///
/// A prototype here and the definition in the model is what lets the loop below
/// be written once. The alternative — repeating the loop in every model — is
/// six copies of the same three lines, and the place a light would go missing.
vec3 ShadeLight(Surface s, LightSample light);
/// Sums every active light's contribution.
///
/// The loop bound is the compile-time maximum with a runtime break, because GLSL
/// wants a constant trip count and the hardware wants the early exit.
// **The point-shadow half of this header, behind a guard.**
//
// A model that never shadows must not *declare* any of this, and the reason is
// the one `unlit.frag` already gives about the shadow sampler — with one
// backend's failure added to the other's. On Impeller the compiler drops what
// nothing reads, and the engine binding a slot that is no longer there is a
// native crash. On WebGL2 nothing is dropped: an active uniform block with no
// buffer under it makes every draw `INVALID_OPERATION`, discarded with nothing
// logged.
//
// That is what `lighting-unlit` was on this backend. Unlit's own metadata says
// `usesPointShadow` is false, so the engine correctly bound no `PointShadow`
// block — and the translated shader declared one anyway, so the sphere was
// never drawn and the frame came back the clear colour.
#ifndef F3D_NO_POINT_SHADOW
/// The cube atlas: three tiles across, two down, each a ninety-degree view
/// from a point light, each storing radial distance normalised by range.
uniform sampler2D point_shadow_texture;
/// The same atlas for the things that never move, rendered once at load.
///
/// Two maps rather than one because a dungeon's walls can be baked and a
/// spinning pickup cannot, and there is no way to draw into part of a texture
/// without redrawing the rest of it. Sampling both and keeping the nearer
/// occluder costs one extra read and saves six views of the level every frame.
uniform sampler2D point_shadow_static_texture;
/// How many lights may have a row of the atlas. Six tiles across each.
// Rows of the cube atlas: six faces across, this many lights down. Must
// match `Renderer.kShadowedLights`, which is where the reasoning lives, and
// `shadowSlots` in the software backend's transcription of this file.
const int kShadowSlots = 6;
layout(std140) uniform PointShadow {
/// The same view-projections the atlas was rendered with, six per slot.
///
/// Passed rather than reconstructed. Deriving cube face coordinates here
/// would be a second implementation of a decision the renderer already made,
/// and the two would disagree about handedness or up vectors on some face
/// and nowhere else — which shows as one face of every shadow being wrong.
mat4 faces[6 * kShadowSlots];
/// Per slot. xyz: the light's world position. w: its range.
vec4 lights[kShadowSlots];
/// Per light, in the order the lighting knows them.
///
/// x: the atlas row it owns, or negative when it has none — a fifth torch in
/// a room lands there. z: the tangent of half the frustum's opening angle,
/// which is what converts a world width into a fraction of a tile. y and w
/// are unwritten.
///
/// **z is exactly one for a point light**, because a cube face is a ninety
/// degree frustum and `tan(45°) == 1`. That is not a convention chosen to be
/// tidy: it is what lets a narrower frustum share this whole path, since
/// multiplying by one in IEEE 754 changes no bit of the result. Whatever else
/// a spot light will need, it does not need a second copy of the filter.
vec4 slots[kMaxLights];
/// x: half a texel, in tile-local uv. y: distance bias in metres.
/// z: strength. w: normal offset, **in texels of the face it lands on**.
vec4 params;
/// x: smallest kernel radius in tile-local uv, and the fixed radius used
/// when contact hardening is off. y: the light's own radius in metres; zero
/// turns contact hardening off. z: largest kernel radius in tile-local uv.
/// w: non-zero paints the penumbra estimate into the surface buffer instead
/// of shading with it.
vec4 params2;
/// x: non-zero when this backend stores the atlas bottom-up. y: one over the
/// edge length of a tile in texels, which is what turns a distance into the
/// world width of one texel there.
///
/// **Appended after everything else on purpose**, the same way FragInfo's
/// ambient pair was: std140 lays a block out in declaration order, so adding
/// here leaves every offset above unchanged and the three backends do not
/// have to agree about anything they already agreed about. y, z and w are
/// unwritten.
vec4 params3;
}
point_shadow;
/// Eight points on a Poisson disk, a common set for filtering cascaded
/// shadows.
///
/// A disk rather than a grid because a grid of taps on a straight shadow edge
/// lands every sample on the same side at once, and the edge steps between
/// kernel widths instead of sliding. Eight rather than sixteen because every
/// tap here reads **two** atlases — the static walls and the movers — so the
/// cost is doubled before it is counted.
vec2 PointShadowDiskTap(int i) {
if (i == 0) return vec2(-0.94201624, -0.39906216);
if (i == 1) return vec2(0.94558609, -0.76890725);
if (i == 2) return vec2(-0.09418410, -0.92938870);
if (i == 3) return vec2(0.34495938, 0.29387760);
if (i == 4) return vec2(-0.91588581, 0.45771432);
if (i == 5) return vec2(-0.81544232, -0.87912464);
if (i == 6) return vec2(-0.38277543, 0.27676845);
return vec2(0.97484398, 0.75648379);
}
/// One comparison against the atlas, at [uv] offset within the tile.
///
/// The clamp is applied **after** the offset, not before, and that is the whole
/// reason a kernel can be widened here without touching anything else: each tap
/// is held inside its own tile individually. Clamping the centre and then
/// offsetting would let the outer taps walk straight out of the tile and read a
/// distance measured from a different face, or a different light.
float PointShadowDistance(vec2 uv, vec2 offset, vec2 tile, float range) {
float inset = point_shadow.params.x;
vec2 local = clamp(uv + offset, inset, 1.0 - inset);
vec2 atlas = (local + tile) * vec2(1.0 / 6.0, 1.0 / float(kShadowSlots));
// **The whole atlas, turned over, where row zero of a render target is at the
// bottom.** Both halves of the address are wrong there and this is the one
// place that fixes both: the tile the light owns — a light in slot zero is
// drawn into the row the shader would call three, because the viewport
// rectangle is flipped to land it — and the picture inside that tile, which
// was drawn through a projection built for the other origin.
//
// Every check of this atlas missed it for the same reason: the debug view
// composites the texture through a full-screen pass, which turns it over
// again and puts the row back. The atlas compared equal on both backends
// across six scenes while the lit pass, which samples it directly and has no
// such pass to cancel, read a row that had never been drawn into and found
// nothing in the way of anything.
if (point_shadow.params3.x > 0.5) atlas.y = 1.0 - atlas.y;
// Whichever is nearer occludes: a wall in front of a monster shadows, and so
// does a monster in front of a wall.
//
// **`textureLod` at level zero, because every caller of this function stands
// behind a branch.** The light loop skips a light the surface faces away
// from, the blocker search `continue`s past a tap that found nothing, and the
// slot test returns before any of it — so the invocations of a quad do not
// arrive here together, and a WGSL backend refuses a sample whose implicit
// derivative would be read where they disagree. Both atlases are distance
// render targets with one level, so level zero is the level `texture` was
// choosing anyway; this names it rather than deriving it, and the picture is
// the same on every backend.
return min(textureLod(point_shadow_texture, atlas, 0.0).r,
textureLod(point_shadow_static_texture, atlas, 0.0).r) * range;
}
float PointShadowTap(vec2 uv, vec2 offset, vec2 tile, float range,
float receiver) {
float stored = PointShadowDistance(uv, offset, tile, range);
// Nothing was drawn in that direction by either, so nothing is in the way.
if (stored >= range * 0.999) return 1.0;
return receiver > stored ? 0.0 : 1.0;
}
/// The disk point for tap [i], rotated by [ca]/[sa] and scaled to [radius].
vec2 PointShadowOffset(int i, float ca, float sa, float radius) {
vec2 p = PointShadowDiskTap(i);
return vec2(p.x * ca - p.y * sa, p.x * sa + p.y * ca) * radius;
}
/// How wide the penumbra should be here, in tile-local uv.
///
/// Contact hardening, and the reason a fixed kernel looks wrong: a shadow is
/// sharp where its caster touches the floor and soft a metre away, and one
/// radius for both makes the contact mushy or the distant edge hard.
///
/// The similar-triangles estimate is the standard one — a light of radius `L`
/// with a blocker at `b` and a receiver at `r` throws a penumbra `L * (r - b) /
/// b` wide at the receiver. Converting that to tile uv is exact rather than
/// tuned, because a face is a ninety degree frustum: at distance `r` from the
/// light the face spans `2 * r` in world units across the full `0..1` of uv,
/// so a world width `w` is `w / (2 * r)` of a tile.
///
/// The blocker search runs at the **widest** penumbra allowed, since a blocker
/// outside that circle cannot widen the result anyway, and searching narrower
/// would miss the very blockers that make an edge soft.
///
/// [tanHalf] is where the ninety degrees stop being assumed. The span above is
/// `2 * r` only for a right-angled frustum; in general it is `2 * r * tan(θ/2)`,
/// and for a cube face that factor is one. A narrower frustum covers less world
/// per tile, so the same world width is a *larger* fraction of it — which is
/// why this divides rather than multiplies, and why getting it upside down
/// would make a tight cone's shadows harden instead of soften.
float PointShadowPenumbra(vec2 uv, vec2 tile, float range, float receiver,
float ca, float sa, float tanHalf,
out float blockerOut) {
blockerOut = -1.0;
float lightRadius = point_shadow.params2.y;
float minRadius = point_shadow.params2.x;
float maxRadius = point_shadow.params2.z;
if (lightRadius <= 0.0) {
// **The debug channel is filled even though the search is skipped**, and
// leaving it unfilled cost a session. `blockerOut` starts at −1 to mean
// "nothing was measured"; the debug encoding clamps it into a colour, where
// −1 becomes zero — the same green as a blocker touching the surface, which
// reads as the most alarming answer available. A whole theory was built on
// that zero, and the search it described had never run.
//
// The centre tap is what the filter below would use anyway, so this reports
// a distance the atlas really returned rather than a sentinel.
blockerOut = PointShadowDistance(uv, vec2(0.0), tile, range);
return minRadius;
}
float sum = 0.0;
float count = 0.0;
for (int i = 0; i < 8; i++) {
float stored =
PointShadowDistance(uv, PointShadowOffset(i, ca, sa, maxRadius), tile,
range);
if (stored >= range * 0.999) continue;
if (stored >= receiver) continue;
sum += stored;
count += 1.0;
}
// Nothing in front of this fragment anywhere in the search: fully lit, and
// the caller can skip the filter entirely.
if (count < 0.5) return -1.0;
float blocker = max(sum / count, 1e-4);
blockerOut = blocker;
float world = lightRadius * max(receiver - blocker, 0.0) / blocker;
return clamp(world / (2.0 * receiver * tanHalf), minRadius, maxRadius);
}
/// How lit [world] is by the point light that owns the cube atlas.
///
/// One, fully lit, when this is not that light or the atlas has nothing to say.
float PointShadowFactor(vec3 world, vec3 normal, int lightIndex) {
int slot = int(point_shadow.slots[lightIndex].x + 0.5);
if (point_shadow.slots[lightIndex].x < 0.0) return 1.0;
float strength = point_shadow.params.z;
if (strength <= 0.0) return 1.0;
// Offset along the normal before measuring, and scaled by how steeply the
// surface leans away from the light.
//
// A soft kernel on a tilted surface straddles a depth gradient: the taps at
// one end of the disk are further from the light than the fragment itself,
// so a flat offset that clears the surface head-on leaves acne at a grazing
// angle. The slope term lifts the whole kernel clear instead, and is capped
// because it runs away as the surface turns edge-on to the light — an
// uncapped lift detaches the shadow from its caster.
vec3 toLight = point_shadow.lights[slot].xyz - world;
float toLightLength = max(length(toLight), 1e-6);
float nDotL = max(dot(normal, toLight / toLightLength), 0.15);
float slope = min(sqrt(max(1.0 - nDotL * nDotL, 0.0)) / (nDotL * nDotL), 8.0);
// **How wide one texel of the face is, out where this fragment is.** The
// error a normal offset exists to clear is exactly that: a texel of the
// shadow map covers a patch of surface, the whole patch is recorded at one
// distance, and a fragment anywhere else in it compares against a distance
// measured somewhere it is not. That patch grows with range — it is a solid
// angle, not a length — so an offset fixed in metres is right at one distance
// and wrong everywhere else.
//
// What it was: `params.w` metres, flat. On the golden teapot, at 9.6 m from
// the lamp, a texel is 3.7 cm and the flat offset was 2 cm, so the floor
// shadowed itself across everything the light reached — and the acne stopped
// dead at the *projection of the floor's own edge*, because past it the atlas
// holds nothing and nothing can occlude. A straight line across a shadow with
// no straight edge anywhere in the scene.
float texel =
2.0 * toLightLength * max(point_shadow.slots[lightIndex].z, 1e-4) *
point_shadow.params3.y;
// Both terms are metres. The slope term used to be the kernel radius, which
// is a fraction of a tile — a unit mismatch carried over from an estimate
// where a softness radius genuinely was the right quantity. Here it meant
// widening the kernel also lifted the sample off the surface, by up to ten
// centimetres at the wider settings, so the softening and the lift
// cancelled: tripling the kernel moved 184 pixels of the frame,
// where the kernel alone moves thousands. It is what made contact hardening
// look inert, and it was hiding in a comparison rather than in the estimate.
vec3 origin = world + normal * texel * point_shadow.params.w * (1.0 + slope);
vec3 toFragment = origin - point_shadow.lights[slot].xyz;
float distance = length(toFragment);
float range = max(point_shadow.lights[slot].w, 1e-4);
if (distance >= range) return 1.0;
// The dominant axis picks the face, in the order the renderer wrote them:
// +X, -X, +Y, -Y, +Z, -Z, left to right then top to bottom.
//
// A spot has one column and no choice to make. Asking the dominant axis
// anyway would be worse than pointless: a fragment below and to the side of
// a downlight has −Y dominant, which is column 3, and column 3 of a spot's
// row is deliberately blank — so the whole cone would read as unshadowed
// except for the wedge where the aim happens to be the dominant axis.
int face = 0;
if (point_shadow.slots[lightIndex].y < 0.5) {
vec3 a = abs(toFragment);
if (a.x >= a.y && a.x >= a.z) {
face = toFragment.x > 0.0 ? 0 : 1;
} else if (a.y >= a.z) {
face = toFragment.y > 0.0 ? 2 : 3;
} else {
face = toFragment.z > 0.0 ? 4 : 5;
}
}
vec4 clip = point_shadow.faces[slot * 6 + face] * vec4(origin, 1.0);
if (clip.w <= 0.0) return 1.0;
vec2 ndc = clip.xy / clip.w;
if (abs(ndc.x) > 1.0 || abs(ndc.y) > 1.0) return 1.0;
// v is flipped, the same way the directional map does it: the texture's
// origin is at the top, where row zero of the render target is. Getting this
// wrong does not tilt the shadow — it makes the top row of faces read the
// bottom row, so a whole region compares against an unrelated distance and
// comes out as a black slab.
vec2 uv = vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
// The face across, the light down: six tiles wide, four tall.
vec2 tile = vec2(float(face), float(slot));
float receiver = distance - point_shadow.params.y;
// One rotation, shared by the blocker search and the filter. Per fragment,
// so eight samples read as a soft edge rather than as eight copies of the
// silhouette: without it every fragment along an edge tests the same eight
// directions and the pattern shows.
//
// **The three constants are not arbitrary and are not ours.** This is Jorge
// Jimenez's interleaved gradient noise, from "Next Generation Post
// Processing in Call of Duty: Advanced Warfare" (SIGGRAPH 2014):
//
// IGN(x, y) = frac(52.9829189 * frac(0.06711056 * x + 0.00583715 * y))
//
// The pair inside the dot is a direction whose gradient walks the unit
// interval as slowly as it can while never repeating over a screen, and the
// multiplier outside stretches that walk so neighbouring pixels land far
// apart in the result. What it buys over a hash is the cost: one dot and two
// fracts, no integer arithmetic, no texture. What a blue-noise texture buys
// over it is a better spectrum, at a sampler and a fetch — worth it for
// dithering a whole frame, not for rotating eight taps.
//
// Written down because three unexplained decimals read as a magic spell, and
// the next person to touch this line has no way to tell which of them may be
// changed. The answer is none of them.
float noise = fract(52.9829189 * fract(dot(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 < kTotalLights; i++) {
if (i >= count) break;
LightSample light = SampleLight(i, s);
if (light.n_dot_l <= 0.0) continue;
// A light from the list has no shadow row to read — see `LightHasShadow`.
// A branch rather than something folded into the two calls, because both
// index tables eight entries wide and the ninth light would read past them
// rather than read a one.
float visibility = LightHasShadow(i)
? LightVisibility(s, light, i) *
PointShadowFactor(v_world_position, s.n, i)
: 1.0;
if (visibility <= 0.0) continue;
total += ShadeLight(s, light) * light.radiance * light.n_dot_l * visibility;
}
return total;
}
#endif // SURFACE_GLSL_
/// Tangent-space normal map. Neutral is (0.5, 0.5, 1.0).
uniform sampler2D normal_texture;
/// glTF's ORM packing: g is roughness, b is metallic. Neutral is white.
uniform sampler2D metallic_roughness_texture;
/// Ambient occlusion in r. Neutral is white.
uniform sampler2D occlusion_texture;
/// Emitted colour, multiplied by the emissive factor. Neutral is white, and the
/// factor defaults to black, so a material with neither emits nothing.
uniform sampler2D emissive_texture;
/// The level's baked lightmap, RGBM: colour over a shared multiplier, decoded
/// as `rgb × a × 8`. Sampled at the second coordinate, which every vertex
/// stage but the lightmapped one leaves at the atlas corner; neutral is
/// black, so a material without a map adds nothing.
uniform sampler2D lightmap_texture;
/// The irradiance the lightmap holds at this fragment, in the units a light's
/// `colour × intensity × attenuation × cos` arrives in.
vec3 SampleLightmap() {
vec4 texel = texture(lightmap_texture, v_lightmap_uv);
return texel.rgb * texel.a * 8.0;
}
/// One function per map, rather than one that applies all four.
///
/// Not a style choice. The compiler drops a sampler whose result never reaches
/// the output, so a model that samples the ORM map and then ignores metallic and
/// roughness — Lambert does exactly that — ends up with no
/// `metallic_roughness_texture` in its compiled signature at all, while the Dart
/// side still thinks there is one to bind. That is the phantom-binding trap
/// again, and binding a slot Metal does not have is a native crash.
///
/// Splitting them means a model calls only what it genuinely uses, so the
/// compiled signature matches the source, and `LightingModel` can declare the
/// same set truthfully. `tool/build_shaders.sh` prints the compiled slots so
/// the two cannot drift apart unnoticed.
/// glTF's ORM packing: roughness in g, metallic in b, both multiplying the
/// material factors.
void ApplyMetallicRoughnessMap(inout Surface s) {
vec3 orm = texture(metallic_roughness_texture, 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) {
// **Sampled before the frame is tested, and that order is load-bearing.**
// The test below is a branch on interpolated data, so the four invocations of
// a quad can take different sides of it; a WGSL backend then refuses a
// `texture` call underneath, because the mip level it derives is only defined
// where the whole quad agrees. Unlike the shadow atlases, this map really is
// mipped — a normal map read at full resolution on a surface turned away from
// the camera is the aliasing that made this the widest disagreement between
// backends — so pinning a level here would be a picture change, and hoisting
// the sample is the cure that is not. A degenerate tangent is rare enough
// that paying for its unused texel is nothing, and the texel it reads is the
// same one the branch would have read.
vec4 sampledTexel = texture(normal_texture, v_texcoord);
// 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 = sampledTexel.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;
/// Five points on a disc: the centre and four at the diagonals.
///
/// **Diagonals rather than the axes.** A cross of four axis-aligned taps
/// leaves a shadow whose edge is smooth along x and y and hard at forty-five
/// degrees, which is the angle most edges in a built scene actually run at.
/// Turned by an eighth of a turn, the four taps straddle a vertical or
/// horizontal edge evenly and the artefact has nowhere to line up.
const vec2 kShadowDisc[5] = vec2[5](vec2(0.0, 0.0),
vec2(0.7071, 0.7071),
vec2(-0.7071, 0.7071),
vec2(0.7071, -0.7071),
vec2(-0.7071, -0.7071));
/// 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);
// **`textureLod` and not `texture`, and the level asked for is the only one
// there is.** Everything above this loop is a reason not to be here — the
// cascade search returns early when no cascade contains the fragment, and the
// light loop that calls it skips a light facing away — so a WGSL backend sees
// a sample taken where the four invocations of a quad need not agree, and
// refuses it: the implicit derivative `texture` asks for is only defined
// where they all arrive. The cascade atlas is a depth render target with a
// single level, so the derivative was never doing anything but selecting
// level zero, and naming that level directly costs nothing and changes no
// pixel on any backend.
//
// **The softness, where it rides, and what zero means.**
//
// `ambient_ground.w` is the directional light's apparent size. It has
// nothing to do with ambient light and everything to do with this being the
// one component left unspent in a block six shaders share: `frame_params.w`
// was the slot reserved for exactly this and the environment's level count
// took it, and appending to the block moves offsets four backends have
// agreed on. The alternative was a second uniform block bound per draw for
// one float. Named here because a reader arriving at `ambient_ground` has
// every right to be surprised.
//
// Zero is the 3×3 kernel this has always had, which is what keeps every
// recorded golden where it is. Above zero the edge widens with the distance
// between the occluder and what it falls on — what a real light does, and
// what no fixed kernel can.
float softness = frag_info.ambient_ground.w;
float lit = 0.0;
if (softness <= 0.0) {
// PCF 3x3. Four samples would band visibly at this map size and nine is
// the smallest kernel that reads as a soft edge rather than as stair
// steps.
for (int y = -1; y <= 1; y++) {
for (int x = -1; x <= 1; x++) {
float occluder = textureLod(
shadow_texture, uv + vec2(float(x), float(y)) * texel, 0.0).r;
lit += projected.z - bias > occluder ? 0.0 : 1.0;
}
}
lit *= 1.0 / 9.0;
} else {
// **Find what is casting before deciding how wide to blur.** The five
// taps go out at a fixed search radius first and average the depths of
// whatever they find in front of this fragment; that average is the
// occluder's distance, and the penumbra is proportional to it. A kernel
// sized without this step is the fixed one again with a bigger number.
// Bounded, and not proportional to the softness: the search only has to
// reach far enough to find *a* blocker, and a radius that grew without
// limit would start finding occluders from the other side of the scene
// and report a gap that belongs to them.
float searchRadius = clamp(softness * 0.25, 2.0, 16.0);
float blockerSum = 0.0;
float blockerCount = 0.0;
for (int i = 0; i < 5; i++) {
float occluder = textureLod(
shadow_texture, uv + kShadowDisc[i] * texel * searchRadius, 0.0).r;
if (projected.z - bias > occluder) {
blockerSum += occluder;
blockerCount += 1.0;
}
}
// Nothing between this fragment and the light: lit, and no second loop.
if (blockerCount <= 0.0) return 1.0;
// Linear depth over the cascade's own volume, so the gap between the
// occluder and the receiver *is* the distance — no perspective divide,
// which is what an orthographic light means.
float gap = max(projected.z - blockerSum / blockerCount, 0.0);
// One texel at the tightest, so a contact edge stays an edge; the cap
// keeps a distant occluder from reaching across a whole cascade.
float radius = clamp(gap * softness, 1.0, 16.0);
for (int i = 0; i < 5; i++) {
float occluder = textureLod(
shadow_texture, uv + kShadowDisc[i] * texel * radius, 0.0).r;
lit += projected.z - bias > occluder ? 0.0 : 1.0;
}
lit *= 1.0 / 5.0;
}
// Strength lerps towards fully lit, so the control is "how dark", not "how
// much of the kernel".
return mix(1.0, lit, clamp(strength, 0.0, 1.0));
}
#endif // SHADOW_GLSL_
float LightVisibility(Surface s, LightSample light, int index) {
return ShadowFactor(s, light, index);
}
vec3 ShadeLight(Surface s, LightSample light) {
// The radiance and the N.L factor are applied by AccumulateLights, so the
// model itself only says how the surface responds.
return s.albedo;
}
void main() {
Surface s = ReadSurface();
// No ORM map: a purely diffuse model has no response to metallic or
// roughness, so sampling it would leave a slot the compiler then drops.
ApplyCommonMaps(s);
vec3 ambient = s.albedo * (s.ambient + SampleLightmap()) * s.occlusion;
WriteSurface(
AccumulateLights(s) * s.occlusion + ambient + s.emissive,
s.alpha,
s.roughness);
}
''',
'BlinnPhong': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;
// Blinn-Phong: diffuse plus a half-vector specular lobe.
//
// Not energy conserving and not physically based, but it is what most older
// engines shipped, and it stays useful for stylised looks where a controllable
// highlight matters more than correctness.
// --- lib/material_maps.glsl ---
// The texture maps a lit material can carry, beyond base colour.
//
// A separate header from surface.glsl on purpose. Declaring a sampler a shader
// never reads is the same trap as declaring an unused uniform block: the
// compiled function has no such slot, while the Dart side still has metadata
// saying it does. Unlit and the debug models include surface.glsl (or only
// color.glsl) and get none of this; the lit models include both, and
// LightingModel.usesMaterialTextures says which is which.
//
// Every map has a *neutral* fallback texture bound when the material has none,
// so there are no "has this map" flags to keep in sync — a white ORM texture
// multiplies the factors by one, and a flat normal map perturbs nothing. Flags
// would have to be right in two places; a neutral texel is right by
// construction.
#ifndef MATERIAL_MAPS_GLSL_
#define MATERIAL_MAPS_GLSL_
// --- lib/surface.glsl ---
// Shared material and lighting interface for the lighting models.
//
// flutter_gpu compiles shaders ahead of time into a bundle: there is no runtime
// compilation, so a node-graph material system assembled at run time is
// impossible. Each lighting model is therefore
// its own pre-built fragment shader, and this header is what keeps them
// interchangeable — one identical uniform block, so the Dart binding code never
// needs to know which model is active.
//
// Keep every declaration below byte-identical across models. A member a model
// does not read may be optimized out of the reflected block, which is why the
// Dart side skips absent members instead of failing.
//
// Only include this from a shader that actually reads FragInfo. Declaring the
// block without using it leaves it visible to reflection while the compiled
// shader binds no buffer for it, and binding that phantom block segfaults
// inside Metal. Shaders needing only colour helpers include lib/color.glsl.
#ifndef SURFACE_GLSL_
#define SURFACE_GLSL_
// --- lib/color.glsl ---
// Colour space helpers and the fragment output interface.
//
// Split out of surface.glsl so a shader that needs no material inputs — the
// normals debug view — can avoid DECLARING the FragInfo uniform block at all.
// That matters more than it looks: reflection metadata reports a block as
// present merely because it was declared, even when the compiled shader binds
// no such buffer, so a declared-but-unused block is indistinguishable from a
// used one until Metal crashes on the bind.
#ifndef COLOR_GLSL_
#define COLOR_GLSL_
precision highp float;
const float kPi = 3.14159265359;
// One varying set shared by every fragment shader, matching mesh.vert.
//
// All five are declared here, including the two the debug models never read: a
// fragment shader whose `in` block disagrees with the vertex shader's `out`
// block fails to link, and there is no partial-match rule to lean on.
in vec3 v_world_position;
in vec3 v_normal;
in vec2 v_texcoord;
in vec4 v_tangent;
in vec4 v_color;
/// Where this fragment is in the level's lightmap. Zero from every vertex
/// stage but `mesh_lightmapped.vert`, and read only by the lit models, which
/// sample a one-texel black there when a material has no map.
in vec2 v_lightmap_uv;
layout(location = 0) out vec4 frag_color;
// The second attachment: what a screen-space effect needs to know about the
// surface it is looking at. World-space normal in rgb, and in a the depth along
// the view axis in world metres — not a window depth; `WriteSurfaceGeometry`
// says at length why not.
//
// Depth travels here rather than in a depth texture because flutter_gpu cannot
// sample one — the same reason the shadow pass writes its depth into a colour
// target. See ARCHITECTURE.md §2.
//
// Guarded, because not every stage that includes this header draws into a
// two-attachment target. The shadow pass draws into one, and a pipeline
// declaring an output its target has no slot for is a mismatch worth avoiding
// rather than discovering.
#ifndef F3D_NO_SURFACE_BUFFER
layout(location = 1) out vec4 frag_surface;
#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;
// **A stage that needs none of this must be able to declare none of it.** On
// Vulkan both stages' descriptors are merged into one set layout, and two
// bindings with the same number in it is not a layout the specification
// allows. A driver may accept it anyway; a Galaxy A55's refuses the pipeline
// with `ErrorUnknown` and no other word, which is how the shadow pass came to
// build everywhere except there — its only uniform block was this one, and it
// landed on the same binding as the vertex stage's first.
#ifndef F3D_NO_FOG
/// Distance fog, in its own block rather than folded into FragInfo.
///
/// Its own because color.glsl is included before FragInfo is declared, and
/// because appending to a block that half a dozen shaders already share is a
/// way to move offsets nobody expected to move. Three vec4s is a cheap price
/// for not touching any of that.
layout(std140) uniform FogInfo {
/// rgb: linear fog colour. w: density per metre, zero for no fog.
vec4 fog;
/// xyz: camera position in world space. Duplicated from FragInfo so this
/// block stands alone; a vec3 is cheaper than a coupling.
vec4 eye;
/// xyz: the direction the camera looks, as a unit vector in world space.
/// w unused.
///
/// Here rather than in a block of its own because it answers the same
/// question [eye] does — where the camera is and which way it faces — and
/// this is the block `color.glsl` can see.
vec4 forward;
}
fog_info;
/// How far this fragment is from the eye, in world metres.
///
/// What the fog fades by. Distance rather than depth, because fog is a
/// property of the air between two points and does not care which way the
/// camera happens to face.
float EyeDistance() { return distance(v_world_position, fog_info.eye.xyz); }
/// How far this fragment is *along the view axis*, in world metres.
///
/// What the surface buffer's alpha holds. Depth rather than distance, and the
/// difference only shows on an orthographic camera — where the rays through
/// the pixels are parallel instead of meeting at the eye, so a distance from
/// the eye names a sphere that the pixel's ray crosses somewhere the reader
/// cannot solve for. A depth along the axis names a plane, which every ray
/// crosses exactly once. See `WorldAtDepth` in `post/ssao.frag` for the
/// reconstruction both projections share.
float ViewDepth() {
return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz);
}
#else // F3D_NO_FOG
// The same two questions, answered without the block: a stage that declares no
// fog has no eye position to measure from either. Stubs rather than a guard at
// every call site, so that what includes this file reads the same whichever
// way it was compiled.
float EyeDistance() { return 0.0; }
float ViewDepth() { return 0.0; }
#endif // F3D_NO_FOG
/// Records the geometry of this fragment for whatever runs after the scene.
///
/// Called from the same place that writes colour, so a surface cannot be lit
/// into the frame without also describing itself — which is the failure that
/// leaves a screen-space effect reflecting whatever was in the buffer before.
///
/// rg: octahedral normal. b: perceptual roughness. a: **depth along the view
/// axis, in world metres** — see [ViewDepth].
///
/// **Not `gl_FragCoord.z`, and that is a defect this channel carried until it
/// was looked at.** Window depth crowds every distant surface into the top of
/// its range — with a near plane of a tenth of a metre, everything past twenty
/// metres lives in the last half a hundredth of `[0, 1]` — and this attachment
/// is a half float, whose steps up there are about five ten-thousandths. So two
/// surfaces half a metre apart at twenty metres stored the *same* number, and
/// every screen-space pass that compares against this channel decided whole
/// bands of pixels by rounding. The occlusion pass drew them: vertical stripes
/// along the lines of constant depth on any wall receding from the camera, on
/// both GPU backends. The software rasteriser kept the channel at full
/// precision and drew the effect correctly, so it was the one that looked
/// wrong against the other two.
///
/// A depth in metres has none of that: the exponent carries the range and the
/// mantissa carries the same relative precision everywhere, which at twenty
/// metres is a centimetre. Both numbers are measured in
/// `flutter3d/test/surface_depth_test.dart`.
///
/// Zero still means nothing was drawn. The attachment is cleared to zero and
/// nothing is drawn in front of the near plane.
void WriteSurfaceGeometry(float roughness) {
#ifndef F3D_NO_SURFACE_BUFFER
// A debug pass takes the buffer over rather than getting one of its own.
// The surface buffer already has an attachment, a viewer and a golden; a
// second one would need all three built before it could answer anything.
if (g_debug_surface_on) {
frag_surface = vec4(g_debug_surface, ViewDepth());
return;
}
frag_surface = vec4(EncodeOctahedral(normalize(v_normal)),
clamp(roughness, 0.0, 1.0), ViewDepth());
#endif
}
/// Fades [color] toward the fog with distance from the eye.
///
/// Exponential rather than linear, because linear fog has a visible plane
/// where it starts and a dungeon corridor is exactly where that shows.
vec3 ApplyFog(vec3 color) {
#ifdef F3D_NO_FOG
return color;
#else
float density = fog_info.fog.w;
if (density <= 0.0) return color;
float d = EyeDistance();
return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0));
#endif
}
/// 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
/// How many more lights one draw may be handed — `gfx-74n`.
///
/// **The eight above stay exactly what they were**, which is what keeps this
/// from moving a single recorded frame: a draw with eight lights or fewer runs
/// the loop it has always run, reads the uniform arrays it has always read, and
/// never touches the texture below. The tail is the part that used to be
/// impossible.
///
/// A loop bound rather than a cost. `AccumulateLights` breaks at the draw's own
/// count, so a scene with three lights costs three iterations whatever this
/// says. Twenty-four because the two tables below are `vec4 x[6]` and four
/// lanes fit a `vec4`: two hundred and eight bytes a draw, against the five
/// hundred and twelve the light arrays already cost.
#define kExtraLights 24
#define kTotalLights (kMaxLights + kExtraLights)
/// Every light in the scene, one per row, four texels across — `gfx-74n`.
///
/// **A texture rather than a wider uniform block, and that is the design.**
/// `FragInfo` is uploaded on every draw, so widening its four `vec4` arrays to
/// hold thirty-two lights would be a two-kilobyte upload per draw in every
/// scene, including every scene with one light. This is built once a frame and
/// only when a scene has more lights than a draw can hold in its slots.
///
/// Row layout, which `renderer_light_list.dart` writes and only this reads:
///
/// * texel 0 — xyz world position, w type (0 directional, 1 point, 2 spot)
/// * texel 1 — rgb linear colour, w intensity
/// * texel 2 — xyz the direction it points, w range
/// * texel 3 — x cos(inner), y cos(outer), zw unused
///
/// The same four vectors the uniform arrays hold, in the same order, so one
/// reader serves both.
///
/// **`F3D_NO_LIGHT_LIST` leaves both out**, for a model that accumulates no
/// lights. Such a model never reaches the reader below, so the compiler drops
/// the block and the sampler from the Metal function while reflection still
/// lists them, with no buffer or texture index assigned. The renderer used to
/// bind them for every draw, Unlit included, and that bind is a crash inside
/// `setFragmentBuffer:offset:atIndex:` on Metal. Vulkan took the same draw
/// without a word, which is how 0.7.0 shipped with it.
#ifndef F3D_NO_LIGHT_LIST
uniform sampler2D light_list_texture;
layout(std140) uniform LightListInfo {
/// x: how many rows this draw reads, zero when it reads none.
/// y, z: one over the texture's width and height.
/// w: unused.
vec4 list;
/// Which rows, four to a vector, in the order they are read.
///
/// Indices rather than the light data itself: the data is the same for every
/// draw in the frame and belongs in the texture; what differs per draw is
/// *which* of them reach it, and that is what `Renderer._drawLightsFor`
/// already decides.
vec4 indices[6];
/// How much of each of those survives the edge fade, in the same order.
///
/// Per draw and not in the texture, because the row an index points at is
/// shared by every draw in the frame: a scale written into it would dim that
/// light for all of them. `gfx-12n`'s fade lives at the end of the list now —
/// that is where a light stops contributing, and fading the slots against a
/// water line that no longer marks a cliff would dim a light for no reason
/// while its rival stayed bright, making the swap more visible rather than
/// less.
vec4 scales[6];
}
light_list_info;
/// One lane of a six-vector table, [slot] counting from nought.
float LightListLane(vec4 four, int slot) {
int lane = slot - (slot / 4) * 4;
return lane == 0 ? four.x : lane == 1 ? four.y : lane == 2 ? four.z : four.w;
}
/// The row light [slot] of the list reads.
float LightListRow(int slot) {
return LightListLane(light_list_info.indices[slot / 4], slot);
}
/// How much of light [slot] of the list survives the edge fade.
float LightListScale(int slot) {
return LightListLane(light_list_info.scales[slot / 4], slot);
}
#endif // F3D_NO_LIGHT_LIST
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 is the directional light's apparent size** — `gfx-15n` — which has
/// nothing to do with ambient and everything to do with this being the last
/// unspent component in a block six shaders share. `frame_params.w` was the
/// slot reserved for a frame-wide parameter and the environment's level
/// count took it; appending to this block moves offsets four backends have
/// agreed on. See `shadow.glsl`, which reads it.
///
/// Two colours rather than one is the whole of what makes ambient look like
/// light instead of like a lifted black level. Outdoors the sky is blue and
/// bright and the ground is warm and dim, and a flat grey for both leaves
/// every underside as pale as every upward face — which reads as the model
/// being flat, and gets blamed on the normals.
vec4 ambient_ground;
}
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.
//
// **A cutoff below -1.5 is the fourth mode: hashed** — `gfx-16n`. The
// sentinel rides in the same component because the alternative is a second
// number in a block six shaders share, and -1 already meant "not masked";
// anything more negative was free. See [MaterialAlphaMode.hashed].
float cutoff = frag_info.material2.x;
if (cutoff >= 0.0) {
if (s.alpha < cutoff) discard;
} else if (cutoff < -1.5) {
// **Stochastic instead of a threshold.** A leaf texture at 40% opacity is
// either entirely there or entirely gone under a fixed cutoff, so a fern
// comes out as a hard-edged cardboard cut-out; sorting would fix it and
// costs a sort per frame and a draw per layer. Comparing against noise
// instead keeps 40% of the *pixels*, which resolves as 40% opacity to
// anything that averages several of them — a higher-resolution target,
// a downsample, a person standing back.
//
// **Hashed on world position, not on the screen.** Screen-space noise is
// one line shorter and swims: the pattern stays put while the object
// moves through it, so a moving branch sparkles. Anchoring it to where
// the surface *is* means a given speck of leaf keeps its verdict from
// frame to frame, and the camera moving changes nothing.
//
// The scale is a constant and it is the whole tuning: finer than the
// texture's own detail and the noise disappears into aliasing, coarser
// and the leaf breaks into blotches. Sixteen per metre is about a
// centimetre of grain at a metre away.
vec3 anchored = floor(v_world_position * 16.0);
float noise = fract(
sin(dot(anchored, vec3(12.9898, 78.233, 37.719))) * 43758.5453);
if (s.alpha < noise) discard;
}
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() {
#ifdef F3D_NO_LIGHT_LIST
return clamp(int(frag_info.frame_params.y + 0.5), 0, kMaxLights);
#else
return clamp(int(frag_info.frame_params.y + 0.5), 0, kMaxLights) +
clamp(int(light_list_info.list.x + 0.5), 0, kExtraLights);
#endif
}
/// Whether light [index] carries a shadow — `gfx-74n`.
///
/// Only the first eight do. The cube atlas holds six rows and the slot table is
/// eight entries wide, so a light from the list has no row to read and asking
/// for one would index past the table. That is a real limit and the right one:
/// the eight a draw keeps in its slots are the eight ranked most relevant to
/// it, which is exactly the set worth a shadow map.
bool LightHasShadow(int index) { return index < kMaxLights; }
/// Distance attenuation for a punctual light, following the glTF spec.
///
/// Inverse square with an optional range window. The window is what stops a
/// lamp with a declared range from contributing a faint haze across the whole
/// scene, which matters far more once there are eight of them.
float PunctualAttenuation(float distance, float range) {
float attenuation = 1.0 / max(distance * distance, 1e-4);
if (range > 0.0) {
float ratio = distance / range;
float window = clamp(1.0 - ratio * ratio * ratio * ratio, 0.0, 1.0);
attenuation *= window * window;
}
return attenuation;
}
/// How much of [s]'s sky a rectangle covers, weighted by the cosine —
/// `gfx-77n`.
///
/// **Exact, not fitted.** This is Lambert's own form factor for a polygon, from
/// 1760: for each edge, the angle it subtends at the shading point times how
/// much the edge's plane leans into the surface normal. Summed over four edges
/// and halved, it *is* the integral of `cos θ` over the rectangle's projection
/// on the hemisphere — the quantity a punctual light approximates with a single
/// `n · l`. So there is no table to ship and nothing to fit: the usual
/// linearly-transformed-cosine approach exists to make the *specular* lobe
/// tractable, and buys nothing here, where the diffuse answer is a closed form
/// four `acos` calls long.
///
/// Returns irradiance over radiance, so a surface facing a rectangle that fills
/// its whole sky gets π, the same as a uniform hemisphere. [corners] are the
/// four vertices in order, relative to the shading point.
///
/// **The rectangle emits along `cross(halfWidth, halfHeight)`**, and with the
/// corners wound as `SampleLight` winds them the sum comes out *negative* on
/// that side, so the negation below is the convention rather than a fix. It was
/// measured rather than derived: the first version returned `+total * 0.5`, and
/// against the reference integration it read nought where the answer was 0.349
/// and 1.02 where the answer was nought — the two failures a flipped winding
/// produces, and between them they name the sign with no room left to argue.
float RectangleFormFactor(vec3 corners[4], vec3 n) {
float total = 0.0;
for (int i = 0; i < 4; i++) {
vec3 a = normalize(corners[i]);
vec3 b = normalize(corners[(i + 1) & 3]);
// Clamped before the `acos`: two nearly parallel edge directions can give a
// dot a hair past one through rounding alone, and `acos` of that is a NaN
// that spreads to the whole pixel and then to the bloom.
float angle = acos(clamp(dot(a, b), -1.0, 1.0));
vec3 axis = cross(a, b);
float len = length(axis);
// A degenerate edge — the shading point lies on the line through it —
// subtends nothing, and normalising a zero vector is the other way to get
// that NaN.
if (len > 1e-6) total += angle * dot(axis / len, n);
}
// Clamped rather than tested separately: a surface on the panel's dark side,
// or facing away from it, comes out with the sign reversed, so "one-sided" is
// a property of the arithmetic instead of a flag somebody has to remember.
return max(-total * 0.5, 0.0);
}
/// Where on the rectangle the specular lobe is really looking — `gfx-77n`.
///
/// **The representative point, which is an approximation, unlike the diffuse
/// above.** The mirror direction leaves the surface and either hits the panel
/// or misses it; the closest point of the panel to that ray is treated as a
/// punctual light standing in for the whole rectangle. It is the standard
/// cheap answer and its one visible property is the one the row asked for: as
/// the view moves the closest point slides along the panel, so the highlight
/// is a streak with the panel's own shape and orientation rather than a dot.
///
/// What it does not do is widen the lobe by the panel's solid angle, so a
/// rough surface under a large panel is a little darker than a full integration
/// would make it. That is a known error of this method and not a bug in this
/// transcription; the fix is the fitted table this function exists to avoid.
vec3 RectangleClosestPoint(vec3 centre, vec3 halfWidth, vec3 halfHeight,
vec3 world, vec3 mirror) {
vec3 n = cross(halfWidth, halfHeight);
float nLen = length(n);
// A panel with no area has no surface to find a point on; its centre is the
// only answer that is not a division by zero.
if (nLen < 1e-12) return centre;
n /= nLen;
vec3 toPlane = centre - world;
float denom = dot(mirror, n);
vec3 onPlane;
// Parallel to the panel, or pointing away from it: the ray never lands, so
// the nearest thing to it is the centre projected back, which keeps the
// highlight on the panel instead of sending it to infinity.
if (abs(denom) < 1e-5) {
onPlane = toPlane - n * dot(toPlane, n);
} else {
float t = dot(toPlane, n) / denom;
onPlane = t > 0.0 ? mirror * t : toPlane - n * dot(toPlane, n);
}
// Clamped into the rectangle in its own axes. Dividing by the squared length
// turns a projection into a coordinate in units of the half-extent, so the
// clamp is against one either way round.
vec3 offset = onPlane - toPlane;
float wLen2 = max(dot(halfWidth, halfWidth), 1e-12);
float hLen2 = max(dot(halfHeight, halfHeight), 1e-12);
float u = clamp(dot(offset, halfWidth) / wLen2, -1.0, 1.0);
float v = clamp(dot(offset, halfHeight) / hLen2, -1.0, 1.0);
return centre + halfWidth * u + halfHeight * v;
}
/// 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;
vec4 color;
vec4 direction;
vec4 cone;
if (index < kMaxLights) {
position = frag_info.light_position[index];
color = frag_info.light_color[index];
direction = frag_info.light_direction[index];
cone = frag_info.light_cone[index];
} else {
#ifdef F3D_NO_LIGHT_LIST
// Unreachable: `LightCount` stops at the slots without a list.
position = vec4(0.0);
color = vec4(0.0);
direction = vec4(0.0);
cone = vec4(0.0);
#else
// A row of the light list — `gfx-74n`. Sampled at texel centres so a
// driver's rounding cannot land a fetch on a neighbour, and the four texels
// across the row are the same four vectors the arrays above hold.
int slot = index - kMaxLights;
float v = (LightListRow(slot) + 0.5) * light_list_info.list.z;
float u = light_list_info.list.y;
// `textureLod` and not `texture`, for `shadow.glsl`'s own reason: `index`
// reaches this branch through a function parameter, so a WGSL backend
// cannot see that every invocation of a draw walks the same light count
// and refuses the implicit derivative as possibly non-uniform. The atlas
// has one level, so naming it directly changes no pixel.
position = textureLod(light_list_texture, vec2(0.5 * u, v), 0.0);
color = textureLod(light_list_texture, vec2(1.5 * u, v), 0.0);
direction = textureLod(light_list_texture, vec2(2.5 * u, v), 0.0);
cone = textureLod(light_list_texture, vec2(3.5 * u, v), 0.0);
// The intensity and not the colour, for `LightBuffer._pack`'s own reason:
// the same multiply here, and only one of them is a number nobody authored.
color.w *= LightListScale(slot);
#endif // F3D_NO_LIGHT_LIST
}
float type = position.w;
// **The rectangle leaves before `aim` is taken — `gfx-77n`.** For every other
// kind `direction.xyz` is a unit vector saying which way the light points;
// for this one it is an edge of the panel, with its length carrying half the
// width, and normalising it here would quietly throw the size away.
if (type > 2.5) {
vec3 halfWidth = direction.xyz;
vec3 halfHeight = cone.xyz;
vec3 toCentre = position.xyz - v_world_position;
vec3 corners[4];
corners[0] = toCentre - halfWidth - halfHeight;
corners[1] = toCentre + halfWidth - halfHeight;
corners[2] = toCentre + halfWidth + halfHeight;
corners[3] = toCentre - halfWidth + halfHeight;
// The cosine-weighted solid angle, which takes the place `n · l` holds for
// a punctual light: the loop multiplies the shading by `n_dot_l`, so
// putting the exact integral here makes the diffuse term exact rather than
// sampled. See [RectangleFormFactor].
float formFactor = RectangleFormFactor(corners, s.n);
// Radiance rather than intensity: `intensity` means the same thing for
// every kind of light, so a panel's is spread over its own area here.
// Enlarging a window at a fixed rating then dims it per square metre and
// leaves the room as bright, which is what the number is supposed to mean.
float area = length(cross(halfWidth, halfHeight)) * 4.0;
float radiance = area > 1e-9 ? 1.0 / area : 0.0;
// The range window only. A punctual light needs the inverse square as
// well; the form factor already contains it, because a panel twice as far
// away subtends a quarter of the sky.
float distance = length(toCentre);
if (direction.w > 0.0) {
float ratio = distance / direction.w;
float window = clamp(1.0 - ratio * ratio * ratio * ratio, 0.0, 1.0);
radiance *= window * window;
}
vec3 mirror = reflect(-s.v, s.n);
vec3 representative = RectangleClosestPoint(
position.xyz, halfWidth, halfHeight, v_world_position, mirror);
vec3 toPoint = representative - v_world_position;
float pointDistance = length(toPoint);
light.l = pointDistance > 1e-6 ? toPoint / pointDistance : s.n;
light.h = normalize(light.l + s.v);
light.n_dot_l = formFactor;
light.n_dot_h = max(dot(s.n, light.h), 0.0);
light.v_dot_h = max(dot(s.v, light.h), 0.0);
light.radiance = color.rgb * color.w * radiance;
return light;
}
vec3 aim = normalize(direction.xyz);
float attenuation = 1.0;
if (type < 0.5) {
// Directional: no position, no falloff. The direction to the light is the
// reverse of the direction it points.
light.l = -aim;
} else {
vec3 toLight = position.xyz - v_world_position;
float distance = length(toLight);
// A light exactly on the surface has no direction; treat it as contributing
// nothing rather than producing a NaN that spreads through the frame.
if (distance < 1e-6) {
light.l = s.n;
light.h = s.n;
light.radiance = vec3(0.0);
light.n_dot_l = 0.0;
light.n_dot_h = 0.0;
light.v_dot_h = 0.0;
return light;
}
light.l = toLight / distance;
attenuation = PunctualAttenuation(distance, direction.w);
if (type > 1.5) {
// Spot: a smooth ramp between the two cone cosines. The Dart side already
// guarantees the denominator is non-zero.
float cosAngle = dot(aim, -light.l);
attenuation *= clamp(
(cosAngle - cone.y) / (cone.x - cone.y), 0.0, 1.0);
}
}
light.h = normalize(light.l + s.v);
light.n_dot_l = max(dot(s.n, light.l), 0.0);
light.n_dot_h = max(dot(s.n, light.h), 0.0);
light.v_dot_h = max(dot(s.v, light.h), 0.0);
light.radiance = color.rgb * color.w * attenuation;
return light;
}
/// How much of light [index] reaches this fragment, defined by each fragment
/// shader.
///
/// A prototype rather than a call into shadow.glsl, because the models that
/// sample no shadow map must not declare its sampler — the compiler would drop
/// the slot and leave the engine binding one that is not there. A lit model
/// returns `ShadowFactor(...)`; an unlit one returns 1.
float LightVisibility(Surface s, LightSample light, int index);
/// A model's per-light term, defined by each fragment shader.
///
/// A prototype here and the definition in the model is what lets the loop below
/// be written once. The alternative — repeating the loop in every model — is
/// six copies of the same three lines, and the place a light would go missing.
vec3 ShadeLight(Surface s, LightSample light);
/// Sums every active light's contribution.
///
/// The loop bound is the compile-time maximum with a runtime break, because GLSL
/// wants a constant trip count and the hardware wants the early exit.
// **The point-shadow half of this header, behind a guard.**
//
// A model that never shadows must not *declare* any of this, and the reason is
// the one `unlit.frag` already gives about the shadow sampler — with one
// backend's failure added to the other's. On Impeller the compiler drops what
// nothing reads, and the engine binding a slot that is no longer there is a
// native crash. On WebGL2 nothing is dropped: an active uniform block with no
// buffer under it makes every draw `INVALID_OPERATION`, discarded with nothing
// logged.
//
// That is what `lighting-unlit` was on this backend. Unlit's own metadata says
// `usesPointShadow` is false, so the engine correctly bound no `PointShadow`
// block — and the translated shader declared one anyway, so the sphere was
// never drawn and the frame came back the clear colour.
#ifndef F3D_NO_POINT_SHADOW
/// The cube atlas: three tiles across, two down, each a ninety-degree view
/// from a point light, each storing radial distance normalised by range.
uniform sampler2D point_shadow_texture;
/// The same atlas for the things that never move, rendered once at load.
///
/// Two maps rather than one because a dungeon's walls can be baked and a
/// spinning pickup cannot, and there is no way to draw into part of a texture
/// without redrawing the rest of it. Sampling both and keeping the nearer
/// occluder costs one extra read and saves six views of the level every frame.
uniform sampler2D point_shadow_static_texture;
/// How many lights may have a row of the atlas. Six tiles across each.
// Rows of the cube atlas: six faces across, this many lights down. Must
// match `Renderer.kShadowedLights`, which is where the reasoning lives, and
// `shadowSlots` in the software backend's transcription of this file.
const int kShadowSlots = 6;
layout(std140) uniform PointShadow {
/// The same view-projections the atlas was rendered with, six per slot.
///
/// Passed rather than reconstructed. Deriving cube face coordinates here
/// would be a second implementation of a decision the renderer already made,
/// and the two would disagree about handedness or up vectors on some face
/// and nowhere else — which shows as one face of every shadow being wrong.
mat4 faces[6 * kShadowSlots];
/// Per slot. xyz: the light's world position. w: its range.
vec4 lights[kShadowSlots];
/// Per light, in the order the lighting knows them.
///
/// x: the atlas row it owns, or negative when it has none — a fifth torch in
/// a room lands there. z: the tangent of half the frustum's opening angle,
/// which is what converts a world width into a fraction of a tile. y and w
/// are unwritten.
///
/// **z is exactly one for a point light**, because a cube face is a ninety
/// degree frustum and `tan(45°) == 1`. That is not a convention chosen to be
/// tidy: it is what lets a narrower frustum share this whole path, since
/// multiplying by one in IEEE 754 changes no bit of the result. Whatever else
/// a spot light will need, it does not need a second copy of the filter.
vec4 slots[kMaxLights];
/// x: half a texel, in tile-local uv. y: distance bias in metres.
/// z: strength. w: normal offset, **in texels of the face it lands on**.
vec4 params;
/// x: smallest kernel radius in tile-local uv, and the fixed radius used
/// when contact hardening is off. y: the light's own radius in metres; zero
/// turns contact hardening off. z: largest kernel radius in tile-local uv.
/// w: non-zero paints the penumbra estimate into the surface buffer instead
/// of shading with it.
vec4 params2;
/// x: non-zero when this backend stores the atlas bottom-up. y: one over the
/// edge length of a tile in texels, which is what turns a distance into the
/// world width of one texel there.
///
/// **Appended after everything else on purpose**, the same way FragInfo's
/// ambient pair was: std140 lays a block out in declaration order, so adding
/// here leaves every offset above unchanged and the three backends do not
/// have to agree about anything they already agreed about. y, z and w are
/// unwritten.
vec4 params3;
}
point_shadow;
/// Eight points on a Poisson disk, a common set for filtering cascaded
/// shadows.
///
/// A disk rather than a grid because a grid of taps on a straight shadow edge
/// lands every sample on the same side at once, and the edge steps between
/// kernel widths instead of sliding. Eight rather than sixteen because every
/// tap here reads **two** atlases — the static walls and the movers — so the
/// cost is doubled before it is counted.
vec2 PointShadowDiskTap(int i) {
if (i == 0) return vec2(-0.94201624, -0.39906216);
if (i == 1) return vec2(0.94558609, -0.76890725);
if (i == 2) return vec2(-0.09418410, -0.92938870);
if (i == 3) return vec2(0.34495938, 0.29387760);
if (i == 4) return vec2(-0.91588581, 0.45771432);
if (i == 5) return vec2(-0.81544232, -0.87912464);
if (i == 6) return vec2(-0.38277543, 0.27676845);
return vec2(0.97484398, 0.75648379);
}
/// One comparison against the atlas, at [uv] offset within the tile.
///
/// The clamp is applied **after** the offset, not before, and that is the whole
/// reason a kernel can be widened here without touching anything else: each tap
/// is held inside its own tile individually. Clamping the centre and then
/// offsetting would let the outer taps walk straight out of the tile and read a
/// distance measured from a different face, or a different light.
float PointShadowDistance(vec2 uv, vec2 offset, vec2 tile, float range) {
float inset = point_shadow.params.x;
vec2 local = clamp(uv + offset, inset, 1.0 - inset);
vec2 atlas = (local + tile) * vec2(1.0 / 6.0, 1.0 / float(kShadowSlots));
// **The whole atlas, turned over, where row zero of a render target is at the
// bottom.** Both halves of the address are wrong there and this is the one
// place that fixes both: the tile the light owns — a light in slot zero is
// drawn into the row the shader would call three, because the viewport
// rectangle is flipped to land it — and the picture inside that tile, which
// was drawn through a projection built for the other origin.
//
// Every check of this atlas missed it for the same reason: the debug view
// composites the texture through a full-screen pass, which turns it over
// again and puts the row back. The atlas compared equal on both backends
// across six scenes while the lit pass, which samples it directly and has no
// such pass to cancel, read a row that had never been drawn into and found
// nothing in the way of anything.
if (point_shadow.params3.x > 0.5) atlas.y = 1.0 - atlas.y;
// Whichever is nearer occludes: a wall in front of a monster shadows, and so
// does a monster in front of a wall.
//
// **`textureLod` at level zero, because every caller of this function stands
// behind a branch.** The light loop skips a light the surface faces away
// from, the blocker search `continue`s past a tap that found nothing, and the
// slot test returns before any of it — so the invocations of a quad do not
// arrive here together, and a WGSL backend refuses a sample whose implicit
// derivative would be read where they disagree. Both atlases are distance
// render targets with one level, so level zero is the level `texture` was
// choosing anyway; this names it rather than deriving it, and the picture is
// the same on every backend.
return min(textureLod(point_shadow_texture, atlas, 0.0).r,
textureLod(point_shadow_static_texture, atlas, 0.0).r) * range;
}
float PointShadowTap(vec2 uv, vec2 offset, vec2 tile, float range,
float receiver) {
float stored = PointShadowDistance(uv, offset, tile, range);
// Nothing was drawn in that direction by either, so nothing is in the way.
if (stored >= range * 0.999) return 1.0;
return receiver > stored ? 0.0 : 1.0;
}
/// The disk point for tap [i], rotated by [ca]/[sa] and scaled to [radius].
vec2 PointShadowOffset(int i, float ca, float sa, float radius) {
vec2 p = PointShadowDiskTap(i);
return vec2(p.x * ca - p.y * sa, p.x * sa + p.y * ca) * radius;
}
/// How wide the penumbra should be here, in tile-local uv.
///
/// Contact hardening, and the reason a fixed kernel looks wrong: a shadow is
/// sharp where its caster touches the floor and soft a metre away, and one
/// radius for both makes the contact mushy or the distant edge hard.
///
/// The similar-triangles estimate is the standard one — a light of radius `L`
/// with a blocker at `b` and a receiver at `r` throws a penumbra `L * (r - b) /
/// b` wide at the receiver. Converting that to tile uv is exact rather than
/// tuned, because a face is a ninety degree frustum: at distance `r` from the
/// light the face spans `2 * r` in world units across the full `0..1` of uv,
/// so a world width `w` is `w / (2 * r)` of a tile.
///
/// The blocker search runs at the **widest** penumbra allowed, since a blocker
/// outside that circle cannot widen the result anyway, and searching narrower
/// would miss the very blockers that make an edge soft.
///
/// [tanHalf] is where the ninety degrees stop being assumed. The span above is
/// `2 * r` only for a right-angled frustum; in general it is `2 * r * tan(θ/2)`,
/// and for a cube face that factor is one. A narrower frustum covers less world
/// per tile, so the same world width is a *larger* fraction of it — which is
/// why this divides rather than multiplies, and why getting it upside down
/// would make a tight cone's shadows harden instead of soften.
float PointShadowPenumbra(vec2 uv, vec2 tile, float range, float receiver,
float ca, float sa, float tanHalf,
out float blockerOut) {
blockerOut = -1.0;
float lightRadius = point_shadow.params2.y;
float minRadius = point_shadow.params2.x;
float maxRadius = point_shadow.params2.z;
if (lightRadius <= 0.0) {
// **The debug channel is filled even though the search is skipped**, and
// leaving it unfilled cost a session. `blockerOut` starts at −1 to mean
// "nothing was measured"; the debug encoding clamps it into a colour, where
// −1 becomes zero — the same green as a blocker touching the surface, which
// reads as the most alarming answer available. A whole theory was built on
// that zero, and the search it described had never run.
//
// The centre tap is what the filter below would use anyway, so this reports
// a distance the atlas really returned rather than a sentinel.
blockerOut = PointShadowDistance(uv, vec2(0.0), tile, range);
return minRadius;
}
float sum = 0.0;
float count = 0.0;
for (int i = 0; i < 8; i++) {
float stored =
PointShadowDistance(uv, PointShadowOffset(i, ca, sa, maxRadius), tile,
range);
if (stored >= range * 0.999) continue;
if (stored >= receiver) continue;
sum += stored;
count += 1.0;
}
// Nothing in front of this fragment anywhere in the search: fully lit, and
// the caller can skip the filter entirely.
if (count < 0.5) return -1.0;
float blocker = max(sum / count, 1e-4);
blockerOut = blocker;
float world = lightRadius * max(receiver - blocker, 0.0) / blocker;
return clamp(world / (2.0 * receiver * tanHalf), minRadius, maxRadius);
}
/// How lit [world] is by the point light that owns the cube atlas.
///
/// One, fully lit, when this is not that light or the atlas has nothing to say.
float PointShadowFactor(vec3 world, vec3 normal, int lightIndex) {
int slot = int(point_shadow.slots[lightIndex].x + 0.5);
if (point_shadow.slots[lightIndex].x < 0.0) return 1.0;
float strength = point_shadow.params.z;
if (strength <= 0.0) return 1.0;
// Offset along the normal before measuring, and scaled by how steeply the
// surface leans away from the light.
//
// A soft kernel on a tilted surface straddles a depth gradient: the taps at
// one end of the disk are further from the light than the fragment itself,
// so a flat offset that clears the surface head-on leaves acne at a grazing
// angle. The slope term lifts the whole kernel clear instead, and is capped
// because it runs away as the surface turns edge-on to the light — an
// uncapped lift detaches the shadow from its caster.
vec3 toLight = point_shadow.lights[slot].xyz - world;
float toLightLength = max(length(toLight), 1e-6);
float nDotL = max(dot(normal, toLight / toLightLength), 0.15);
float slope = min(sqrt(max(1.0 - nDotL * nDotL, 0.0)) / (nDotL * nDotL), 8.0);
// **How wide one texel of the face is, out where this fragment is.** The
// error a normal offset exists to clear is exactly that: a texel of the
// shadow map covers a patch of surface, the whole patch is recorded at one
// distance, and a fragment anywhere else in it compares against a distance
// measured somewhere it is not. That patch grows with range — it is a solid
// angle, not a length — so an offset fixed in metres is right at one distance
// and wrong everywhere else.
//
// What it was: `params.w` metres, flat. On the golden teapot, at 9.6 m from
// the lamp, a texel is 3.7 cm and the flat offset was 2 cm, so the floor
// shadowed itself across everything the light reached — and the acne stopped
// dead at the *projection of the floor's own edge*, because past it the atlas
// holds nothing and nothing can occlude. A straight line across a shadow with
// no straight edge anywhere in the scene.
float texel =
2.0 * toLightLength * max(point_shadow.slots[lightIndex].z, 1e-4) *
point_shadow.params3.y;
// Both terms are metres. The slope term used to be the kernel radius, which
// is a fraction of a tile — a unit mismatch carried over from an estimate
// where a softness radius genuinely was the right quantity. Here it meant
// widening the kernel also lifted the sample off the surface, by up to ten
// centimetres at the wider settings, so the softening and the lift
// cancelled: tripling the kernel moved 184 pixels of the frame,
// where the kernel alone moves thousands. It is what made contact hardening
// look inert, and it was hiding in a comparison rather than in the estimate.
vec3 origin = world + normal * texel * point_shadow.params.w * (1.0 + slope);
vec3 toFragment = origin - point_shadow.lights[slot].xyz;
float distance = length(toFragment);
float range = max(point_shadow.lights[slot].w, 1e-4);
if (distance >= range) return 1.0;
// The dominant axis picks the face, in the order the renderer wrote them:
// +X, -X, +Y, -Y, +Z, -Z, left to right then top to bottom.
//
// A spot has one column and no choice to make. Asking the dominant axis
// anyway would be worse than pointless: a fragment below and to the side of
// a downlight has −Y dominant, which is column 3, and column 3 of a spot's
// row is deliberately blank — so the whole cone would read as unshadowed
// except for the wedge where the aim happens to be the dominant axis.
int face = 0;
if (point_shadow.slots[lightIndex].y < 0.5) {
vec3 a = abs(toFragment);
if (a.x >= a.y && a.x >= a.z) {
face = toFragment.x > 0.0 ? 0 : 1;
} else if (a.y >= a.z) {
face = toFragment.y > 0.0 ? 2 : 3;
} else {
face = toFragment.z > 0.0 ? 4 : 5;
}
}
vec4 clip = point_shadow.faces[slot * 6 + face] * vec4(origin, 1.0);
if (clip.w <= 0.0) return 1.0;
vec2 ndc = clip.xy / clip.w;
if (abs(ndc.x) > 1.0 || abs(ndc.y) > 1.0) return 1.0;
// v is flipped, the same way the directional map does it: the texture's
// origin is at the top, where row zero of the render target is. Getting this
// wrong does not tilt the shadow — it makes the top row of faces read the
// bottom row, so a whole region compares against an unrelated distance and
// comes out as a black slab.
vec2 uv = vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
// The face across, the light down: six tiles wide, four tall.
vec2 tile = vec2(float(face), float(slot));
float receiver = distance - point_shadow.params.y;
// One rotation, shared by the blocker search and the filter. Per fragment,
// so eight samples read as a soft edge rather than as eight copies of the
// silhouette: without it every fragment along an edge tests the same eight
// directions and the pattern shows.
//
// **The three constants are not arbitrary and are not ours.** This is Jorge
// Jimenez's interleaved gradient noise, from "Next Generation Post
// Processing in Call of Duty: Advanced Warfare" (SIGGRAPH 2014):
//
// IGN(x, y) = frac(52.9829189 * frac(0.06711056 * x + 0.00583715 * y))
//
// The pair inside the dot is a direction whose gradient walks the unit
// interval as slowly as it can while never repeating over a screen, and the
// multiplier outside stretches that walk so neighbouring pixels land far
// apart in the result. What it buys over a hash is the cost: one dot and two
// fracts, no integer arithmetic, no texture. What a blue-noise texture buys
// over it is a better spectrum, at a sampler and a fetch — worth it for
// dithering a whole frame, not for rotating eight taps.
//
// Written down because three unexplained decimals read as a magic spell, and
// the next person to touch this line has no way to tell which of them may be
// changed. The answer is none of them.
float noise = fract(52.9829189 * fract(dot(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 < kTotalLights; i++) {
if (i >= count) break;
LightSample light = SampleLight(i, s);
if (light.n_dot_l <= 0.0) continue;
// A light from the list has no shadow row to read — see `LightHasShadow`.
// A branch rather than something folded into the two calls, because both
// index tables eight entries wide and the ninth light would read past them
// rather than read a one.
float visibility = LightHasShadow(i)
? LightVisibility(s, light, i) *
PointShadowFactor(v_world_position, s.n, i)
: 1.0;
if (visibility <= 0.0) continue;
total += ShadeLight(s, light) * light.radiance * light.n_dot_l * visibility;
}
return total;
}
#endif // SURFACE_GLSL_
/// Tangent-space normal map. Neutral is (0.5, 0.5, 1.0).
uniform sampler2D normal_texture;
/// glTF's ORM packing: g is roughness, b is metallic. Neutral is white.
uniform sampler2D metallic_roughness_texture;
/// Ambient occlusion in r. Neutral is white.
uniform sampler2D occlusion_texture;
/// Emitted colour, multiplied by the emissive factor. Neutral is white, and the
/// factor defaults to black, so a material with neither emits nothing.
uniform sampler2D emissive_texture;
/// The level's baked lightmap, RGBM: colour over a shared multiplier, decoded
/// as `rgb × a × 8`. Sampled at the second coordinate, which every vertex
/// stage but the lightmapped one leaves at the atlas corner; neutral is
/// black, so a material without a map adds nothing.
uniform sampler2D lightmap_texture;
/// The irradiance the lightmap holds at this fragment, in the units a light's
/// `colour × intensity × attenuation × cos` arrives in.
vec3 SampleLightmap() {
vec4 texel = texture(lightmap_texture, v_lightmap_uv);
return texel.rgb * texel.a * 8.0;
}
/// One function per map, rather than one that applies all four.
///
/// Not a style choice. The compiler drops a sampler whose result never reaches
/// the output, so a model that samples the ORM map and then ignores metallic and
/// roughness — Lambert does exactly that — ends up with no
/// `metallic_roughness_texture` in its compiled signature at all, while the Dart
/// side still thinks there is one to bind. That is the phantom-binding trap
/// again, and binding a slot Metal does not have is a native crash.
///
/// Splitting them means a model calls only what it genuinely uses, so the
/// compiled signature matches the source, and `LightingModel` can declare the
/// same set truthfully. `tool/build_shaders.sh` prints the compiled slots so
/// the two cannot drift apart unnoticed.
/// glTF's ORM packing: roughness in g, metallic in b, both multiplying the
/// material factors.
void ApplyMetallicRoughnessMap(inout Surface s) {
vec3 orm = texture(metallic_roughness_texture, 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) {
// **Sampled before the frame is tested, and that order is load-bearing.**
// The test below is a branch on interpolated data, so the four invocations of
// a quad can take different sides of it; a WGSL backend then refuses a
// `texture` call underneath, because the mip level it derives is only defined
// where the whole quad agrees. Unlike the shadow atlases, this map really is
// mipped — a normal map read at full resolution on a surface turned away from
// the camera is the aliasing that made this the widest disagreement between
// backends — so pinning a level here would be a picture change, and hoisting
// the sample is the cure that is not. A degenerate tangent is rare enough
// that paying for its unused texel is nothing, and the texel it reads is the
// same one the branch would have read.
vec4 sampledTexel = texture(normal_texture, v_texcoord);
// 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 = sampledTexel.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;
/// Five points on a disc: the centre and four at the diagonals.
///
/// **Diagonals rather than the axes.** A cross of four axis-aligned taps
/// leaves a shadow whose edge is smooth along x and y and hard at forty-five
/// degrees, which is the angle most edges in a built scene actually run at.
/// Turned by an eighth of a turn, the four taps straddle a vertical or
/// horizontal edge evenly and the artefact has nowhere to line up.
const vec2 kShadowDisc[5] = vec2[5](vec2(0.0, 0.0),
vec2(0.7071, 0.7071),
vec2(-0.7071, 0.7071),
vec2(0.7071, -0.7071),
vec2(-0.7071, -0.7071));
/// 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);
// **`textureLod` and not `texture`, and the level asked for is the only one
// there is.** Everything above this loop is a reason not to be here — the
// cascade search returns early when no cascade contains the fragment, and the
// light loop that calls it skips a light facing away — so a WGSL backend sees
// a sample taken where the four invocations of a quad need not agree, and
// refuses it: the implicit derivative `texture` asks for is only defined
// where they all arrive. The cascade atlas is a depth render target with a
// single level, so the derivative was never doing anything but selecting
// level zero, and naming that level directly costs nothing and changes no
// pixel on any backend.
//
// **The softness, where it rides, and what zero means.**
//
// `ambient_ground.w` is the directional light's apparent size. It has
// nothing to do with ambient light and everything to do with this being the
// one component left unspent in a block six shaders share: `frame_params.w`
// was the slot reserved for exactly this and the environment's level count
// took it, and appending to the block moves offsets four backends have
// agreed on. The alternative was a second uniform block bound per draw for
// one float. Named here because a reader arriving at `ambient_ground` has
// every right to be surprised.
//
// Zero is the 3×3 kernel this has always had, which is what keeps every
// recorded golden where it is. Above zero the edge widens with the distance
// between the occluder and what it falls on — what a real light does, and
// what no fixed kernel can.
float softness = frag_info.ambient_ground.w;
float lit = 0.0;
if (softness <= 0.0) {
// PCF 3x3. Four samples would band visibly at this map size and nine is
// the smallest kernel that reads as a soft edge rather than as stair
// steps.
for (int y = -1; y <= 1; y++) {
for (int x = -1; x <= 1; x++) {
float occluder = textureLod(
shadow_texture, uv + vec2(float(x), float(y)) * texel, 0.0).r;
lit += projected.z - bias > occluder ? 0.0 : 1.0;
}
}
lit *= 1.0 / 9.0;
} else {
// **Find what is casting before deciding how wide to blur.** The five
// taps go out at a fixed search radius first and average the depths of
// whatever they find in front of this fragment; that average is the
// occluder's distance, and the penumbra is proportional to it. A kernel
// sized without this step is the fixed one again with a bigger number.
// Bounded, and not proportional to the softness: the search only has to
// reach far enough to find *a* blocker, and a radius that grew without
// limit would start finding occluders from the other side of the scene
// and report a gap that belongs to them.
float searchRadius = clamp(softness * 0.25, 2.0, 16.0);
float blockerSum = 0.0;
float blockerCount = 0.0;
for (int i = 0; i < 5; i++) {
float occluder = textureLod(
shadow_texture, uv + kShadowDisc[i] * texel * searchRadius, 0.0).r;
if (projected.z - bias > occluder) {
blockerSum += occluder;
blockerCount += 1.0;
}
}
// Nothing between this fragment and the light: lit, and no second loop.
if (blockerCount <= 0.0) return 1.0;
// Linear depth over the cascade's own volume, so the gap between the
// occluder and the receiver *is* the distance — no perspective divide,
// which is what an orthographic light means.
float gap = max(projected.z - blockerSum / blockerCount, 0.0);
// One texel at the tightest, so a contact edge stays an edge; the cap
// keeps a distant occluder from reaching across a whole cascade.
float radius = clamp(gap * softness, 1.0, 16.0);
for (int i = 0; i < 5; i++) {
float occluder = textureLod(
shadow_texture, uv + kShadowDisc[i] * texel * radius, 0.0).r;
lit += projected.z - bias > occluder ? 0.0 : 1.0;
}
lit *= 1.0 / 5.0;
}
// Strength lerps towards fully lit, so the control is "how dark", not "how
// much of the kernel".
return mix(1.0, lit, clamp(strength, 0.0, 1.0));
}
#endif // SHADOW_GLSL_
float LightVisibility(Surface s, LightSample light, int index) {
return ShadowFactor(s, light, index);
}
vec3 ShadeLight(Surface s, LightSample light) {
// Map perceptual roughness onto a Phong exponent. The mapping is arbitrary;
// it just has to feel monotonic as the roughness slider moves.
float shininess = mix(256.0, 4.0, s.roughness);
float specular = pow(light.n_dot_h, shininess) * frag_info.material.w;
// The caller already dropped lights with N.L at zero, so no separate gate is
// needed to keep the highlight off facing-away geometry.
return s.albedo + vec3(specular);
}
void main() {
Surface s = ReadSurface();
ApplyCommonMaps(s);
// Roughness drives the Phong exponent, so the ORM map does reach the
// output here.
ApplyMetallicRoughnessMap(s);
vec3 ambient = s.albedo * (s.ambient + SampleLightmap()) * s.occlusion;
WriteSurface(
AccumulateLights(s) * s.occlusion + ambient + s.emissive,
s.alpha,
s.roughness);
}
''',
'Pbr': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;
// Metal-rough 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;
/// Where this fragment is in the level's lightmap. Zero from every vertex
/// stage but `mesh_lightmapped.vert`, and read only by the lit models, which
/// sample a one-texel black there when a material has no map.
in vec2 v_lightmap_uv;
layout(location = 0) out vec4 frag_color;
// The second attachment: what a screen-space effect needs to know about the
// surface it is looking at. World-space normal in rgb, and in a the depth along
// the view axis in world metres — not a window depth; `WriteSurfaceGeometry`
// says at length why not.
//
// Depth travels here rather than in a depth texture because flutter_gpu cannot
// sample one — the same reason the shadow pass writes its depth into a colour
// target. See ARCHITECTURE.md §2.
//
// Guarded, because not every stage that includes this header draws into a
// two-attachment target. The shadow pass draws into one, and a pipeline
// declaring an output its target has no slot for is a mismatch worth avoiding
// rather than discovering.
#ifndef F3D_NO_SURFACE_BUFFER
layout(location = 1) out vec4 frag_surface;
#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;
// **A stage that needs none of this must be able to declare none of it.** On
// Vulkan both stages' descriptors are merged into one set layout, and two
// bindings with the same number in it is not a layout the specification
// allows. A driver may accept it anyway; a Galaxy A55's refuses the pipeline
// with `ErrorUnknown` and no other word, which is how the shadow pass came to
// build everywhere except there — its only uniform block was this one, and it
// landed on the same binding as the vertex stage's first.
#ifndef F3D_NO_FOG
/// Distance fog, in its own block rather than folded into FragInfo.
///
/// Its own because color.glsl is included before FragInfo is declared, and
/// because appending to a block that half a dozen shaders already share is a
/// way to move offsets nobody expected to move. Three vec4s is a cheap price
/// for not touching any of that.
layout(std140) uniform FogInfo {
/// rgb: linear fog colour. w: density per metre, zero for no fog.
vec4 fog;
/// xyz: camera position in world space. Duplicated from FragInfo so this
/// block stands alone; a vec3 is cheaper than a coupling.
vec4 eye;
/// xyz: the direction the camera looks, as a unit vector in world space.
/// w unused.
///
/// Here rather than in a block of its own because it answers the same
/// question [eye] does — where the camera is and which way it faces — and
/// this is the block `color.glsl` can see.
vec4 forward;
}
fog_info;
/// How far this fragment is from the eye, in world metres.
///
/// What the fog fades by. Distance rather than depth, because fog is a
/// property of the air between two points and does not care which way the
/// camera happens to face.
float EyeDistance() { return distance(v_world_position, fog_info.eye.xyz); }
/// How far this fragment is *along the view axis*, in world metres.
///
/// What the surface buffer's alpha holds. Depth rather than distance, and the
/// difference only shows on an orthographic camera — where the rays through
/// the pixels are parallel instead of meeting at the eye, so a distance from
/// the eye names a sphere that the pixel's ray crosses somewhere the reader
/// cannot solve for. A depth along the axis names a plane, which every ray
/// crosses exactly once. See `WorldAtDepth` in `post/ssao.frag` for the
/// reconstruction both projections share.
float ViewDepth() {
return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz);
}
#else // F3D_NO_FOG
// The same two questions, answered without the block: a stage that declares no
// fog has no eye position to measure from either. Stubs rather than a guard at
// every call site, so that what includes this file reads the same whichever
// way it was compiled.
float EyeDistance() { return 0.0; }
float ViewDepth() { return 0.0; }
#endif // F3D_NO_FOG
/// Records the geometry of this fragment for whatever runs after the scene.
///
/// Called from the same place that writes colour, so a surface cannot be lit
/// into the frame without also describing itself — which is the failure that
/// leaves a screen-space effect reflecting whatever was in the buffer before.
///
/// rg: octahedral normal. b: perceptual roughness. a: **depth along the view
/// axis, in world metres** — see [ViewDepth].
///
/// **Not `gl_FragCoord.z`, and that is a defect this channel carried until it
/// was looked at.** Window depth crowds every distant surface into the top of
/// its range — with a near plane of a tenth of a metre, everything past twenty
/// metres lives in the last half a hundredth of `[0, 1]` — and this attachment
/// is a half float, whose steps up there are about five ten-thousandths. So two
/// surfaces half a metre apart at twenty metres stored the *same* number, and
/// every screen-space pass that compares against this channel decided whole
/// bands of pixels by rounding. The occlusion pass drew them: vertical stripes
/// along the lines of constant depth on any wall receding from the camera, on
/// both GPU backends. The software rasteriser kept the channel at full
/// precision and drew the effect correctly, so it was the one that looked
/// wrong against the other two.
///
/// A depth in metres has none of that: the exponent carries the range and the
/// mantissa carries the same relative precision everywhere, which at twenty
/// metres is a centimetre. Both numbers are measured in
/// `flutter3d/test/surface_depth_test.dart`.
///
/// Zero still means nothing was drawn. The attachment is cleared to zero and
/// nothing is drawn in front of the near plane.
void WriteSurfaceGeometry(float roughness) {
#ifndef F3D_NO_SURFACE_BUFFER
// A debug pass takes the buffer over rather than getting one of its own.
// The surface buffer already has an attachment, a viewer and a golden; a
// second one would need all three built before it could answer anything.
if (g_debug_surface_on) {
frag_surface = vec4(g_debug_surface, ViewDepth());
return;
}
frag_surface = vec4(EncodeOctahedral(normalize(v_normal)),
clamp(roughness, 0.0, 1.0), ViewDepth());
#endif
}
/// Fades [color] toward the fog with distance from the eye.
///
/// Exponential rather than linear, because linear fog has a visible plane
/// where it starts and a dungeon corridor is exactly where that shows.
vec3 ApplyFog(vec3 color) {
#ifdef F3D_NO_FOG
return color;
#else
float density = fog_info.fog.w;
if (density <= 0.0) return color;
float d = EyeDistance();
return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0));
#endif
}
/// 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
/// How many more lights one draw may be handed — `gfx-74n`.
///
/// **The eight above stay exactly what they were**, which is what keeps this
/// from moving a single recorded frame: a draw with eight lights or fewer runs
/// the loop it has always run, reads the uniform arrays it has always read, and
/// never touches the texture below. The tail is the part that used to be
/// impossible.
///
/// A loop bound rather than a cost. `AccumulateLights` breaks at the draw's own
/// count, so a scene with three lights costs three iterations whatever this
/// says. Twenty-four because the two tables below are `vec4 x[6]` and four
/// lanes fit a `vec4`: two hundred and eight bytes a draw, against the five
/// hundred and twelve the light arrays already cost.
#define kExtraLights 24
#define kTotalLights (kMaxLights + kExtraLights)
/// Every light in the scene, one per row, four texels across — `gfx-74n`.
///
/// **A texture rather than a wider uniform block, and that is the design.**
/// `FragInfo` is uploaded on every draw, so widening its four `vec4` arrays to
/// hold thirty-two lights would be a two-kilobyte upload per draw in every
/// scene, including every scene with one light. This is built once a frame and
/// only when a scene has more lights than a draw can hold in its slots.
///
/// Row layout, which `renderer_light_list.dart` writes and only this reads:
///
/// * texel 0 — xyz world position, w type (0 directional, 1 point, 2 spot)
/// * texel 1 — rgb linear colour, w intensity
/// * texel 2 — xyz the direction it points, w range
/// * texel 3 — x cos(inner), y cos(outer), zw unused
///
/// The same four vectors the uniform arrays hold, in the same order, so one
/// reader serves both.
///
/// **`F3D_NO_LIGHT_LIST` leaves both out**, for a model that accumulates no
/// lights. Such a model never reaches the reader below, so the compiler drops
/// the block and the sampler from the Metal function while reflection still
/// lists them, with no buffer or texture index assigned. The renderer used to
/// bind them for every draw, Unlit included, and that bind is a crash inside
/// `setFragmentBuffer:offset:atIndex:` on Metal. Vulkan took the same draw
/// without a word, which is how 0.7.0 shipped with it.
#ifndef F3D_NO_LIGHT_LIST
uniform sampler2D light_list_texture;
layout(std140) uniform LightListInfo {
/// x: how many rows this draw reads, zero when it reads none.
/// y, z: one over the texture's width and height.
/// w: unused.
vec4 list;
/// Which rows, four to a vector, in the order they are read.
///
/// Indices rather than the light data itself: the data is the same for every
/// draw in the frame and belongs in the texture; what differs per draw is
/// *which* of them reach it, and that is what `Renderer._drawLightsFor`
/// already decides.
vec4 indices[6];
/// How much of each of those survives the edge fade, in the same order.
///
/// Per draw and not in the texture, because the row an index points at is
/// shared by every draw in the frame: a scale written into it would dim that
/// light for all of them. `gfx-12n`'s fade lives at the end of the list now —
/// that is where a light stops contributing, and fading the slots against a
/// water line that no longer marks a cliff would dim a light for no reason
/// while its rival stayed bright, making the swap more visible rather than
/// less.
vec4 scales[6];
}
light_list_info;
/// One lane of a six-vector table, [slot] counting from nought.
float LightListLane(vec4 four, int slot) {
int lane = slot - (slot / 4) * 4;
return lane == 0 ? four.x : lane == 1 ? four.y : lane == 2 ? four.z : four.w;
}
/// The row light [slot] of the list reads.
float LightListRow(int slot) {
return LightListLane(light_list_info.indices[slot / 4], slot);
}
/// How much of light [slot] of the list survives the edge fade.
float LightListScale(int slot) {
return LightListLane(light_list_info.scales[slot / 4], slot);
}
#endif // F3D_NO_LIGHT_LIST
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 is the directional light's apparent size** — `gfx-15n` — which has
/// nothing to do with ambient and everything to do with this being the last
/// unspent component in a block six shaders share. `frame_params.w` was the
/// slot reserved for a frame-wide parameter and the environment's level
/// count took it; appending to this block moves offsets four backends have
/// agreed on. See `shadow.glsl`, which reads it.
///
/// Two colours rather than one is the whole of what makes ambient look like
/// light instead of like a lifted black level. Outdoors the sky is blue and
/// bright and the ground is warm and dim, and a flat grey for both leaves
/// every underside as pale as every upward face — which reads as the model
/// being flat, and gets blamed on the normals.
vec4 ambient_ground;
}
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.
//
// **A cutoff below -1.5 is the fourth mode: hashed** — `gfx-16n`. The
// sentinel rides in the same component because the alternative is a second
// number in a block six shaders share, and -1 already meant "not masked";
// anything more negative was free. See [MaterialAlphaMode.hashed].
float cutoff = frag_info.material2.x;
if (cutoff >= 0.0) {
if (s.alpha < cutoff) discard;
} else if (cutoff < -1.5) {
// **Stochastic instead of a threshold.** A leaf texture at 40% opacity is
// either entirely there or entirely gone under a fixed cutoff, so a fern
// comes out as a hard-edged cardboard cut-out; sorting would fix it and
// costs a sort per frame and a draw per layer. Comparing against noise
// instead keeps 40% of the *pixels*, which resolves as 40% opacity to
// anything that averages several of them — a higher-resolution target,
// a downsample, a person standing back.
//
// **Hashed on world position, not on the screen.** Screen-space noise is
// one line shorter and swims: the pattern stays put while the object
// moves through it, so a moving branch sparkles. Anchoring it to where
// the surface *is* means a given speck of leaf keeps its verdict from
// frame to frame, and the camera moving changes nothing.
//
// The scale is a constant and it is the whole tuning: finer than the
// texture's own detail and the noise disappears into aliasing, coarser
// and the leaf breaks into blotches. Sixteen per metre is about a
// centimetre of grain at a metre away.
vec3 anchored = floor(v_world_position * 16.0);
float noise = fract(
sin(dot(anchored, vec3(12.9898, 78.233, 37.719))) * 43758.5453);
if (s.alpha < noise) discard;
}
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() {
#ifdef F3D_NO_LIGHT_LIST
return clamp(int(frag_info.frame_params.y + 0.5), 0, kMaxLights);
#else
return clamp(int(frag_info.frame_params.y + 0.5), 0, kMaxLights) +
clamp(int(light_list_info.list.x + 0.5), 0, kExtraLights);
#endif
}
/// Whether light [index] carries a shadow — `gfx-74n`.
///
/// Only the first eight do. The cube atlas holds six rows and the slot table is
/// eight entries wide, so a light from the list has no row to read and asking
/// for one would index past the table. That is a real limit and the right one:
/// the eight a draw keeps in its slots are the eight ranked most relevant to
/// it, which is exactly the set worth a shadow map.
bool LightHasShadow(int index) { return index < kMaxLights; }
/// Distance attenuation for a punctual light, following the glTF spec.
///
/// Inverse square with an optional range window. The window is what stops a
/// lamp with a declared range from contributing a faint haze across the whole
/// scene, which matters far more once there are eight of them.
float PunctualAttenuation(float distance, float range) {
float attenuation = 1.0 / max(distance * distance, 1e-4);
if (range > 0.0) {
float ratio = distance / range;
float window = clamp(1.0 - ratio * ratio * ratio * ratio, 0.0, 1.0);
attenuation *= window * window;
}
return attenuation;
}
/// How much of [s]'s sky a rectangle covers, weighted by the cosine —
/// `gfx-77n`.
///
/// **Exact, not fitted.** This is Lambert's own form factor for a polygon, from
/// 1760: for each edge, the angle it subtends at the shading point times how
/// much the edge's plane leans into the surface normal. Summed over four edges
/// and halved, it *is* the integral of `cos θ` over the rectangle's projection
/// on the hemisphere — the quantity a punctual light approximates with a single
/// `n · l`. So there is no table to ship and nothing to fit: the usual
/// linearly-transformed-cosine approach exists to make the *specular* lobe
/// tractable, and buys nothing here, where the diffuse answer is a closed form
/// four `acos` calls long.
///
/// Returns irradiance over radiance, so a surface facing a rectangle that fills
/// its whole sky gets π, the same as a uniform hemisphere. [corners] are the
/// four vertices in order, relative to the shading point.
///
/// **The rectangle emits along `cross(halfWidth, halfHeight)`**, and with the
/// corners wound as `SampleLight` winds them the sum comes out *negative* on
/// that side, so the negation below is the convention rather than a fix. It was
/// measured rather than derived: the first version returned `+total * 0.5`, and
/// against the reference integration it read nought where the answer was 0.349
/// and 1.02 where the answer was nought — the two failures a flipped winding
/// produces, and between them they name the sign with no room left to argue.
float RectangleFormFactor(vec3 corners[4], vec3 n) {
float total = 0.0;
for (int i = 0; i < 4; i++) {
vec3 a = normalize(corners[i]);
vec3 b = normalize(corners[(i + 1) & 3]);
// Clamped before the `acos`: two nearly parallel edge directions can give a
// dot a hair past one through rounding alone, and `acos` of that is a NaN
// that spreads to the whole pixel and then to the bloom.
float angle = acos(clamp(dot(a, b), -1.0, 1.0));
vec3 axis = cross(a, b);
float len = length(axis);
// A degenerate edge — the shading point lies on the line through it —
// subtends nothing, and normalising a zero vector is the other way to get
// that NaN.
if (len > 1e-6) total += angle * dot(axis / len, n);
}
// Clamped rather than tested separately: a surface on the panel's dark side,
// or facing away from it, comes out with the sign reversed, so "one-sided" is
// a property of the arithmetic instead of a flag somebody has to remember.
return max(-total * 0.5, 0.0);
}
/// Where on the rectangle the specular lobe is really looking — `gfx-77n`.
///
/// **The representative point, which is an approximation, unlike the diffuse
/// above.** The mirror direction leaves the surface and either hits the panel
/// or misses it; the closest point of the panel to that ray is treated as a
/// punctual light standing in for the whole rectangle. It is the standard
/// cheap answer and its one visible property is the one the row asked for: as
/// the view moves the closest point slides along the panel, so the highlight
/// is a streak with the panel's own shape and orientation rather than a dot.
///
/// What it does not do is widen the lobe by the panel's solid angle, so a
/// rough surface under a large panel is a little darker than a full integration
/// would make it. That is a known error of this method and not a bug in this
/// transcription; the fix is the fitted table this function exists to avoid.
vec3 RectangleClosestPoint(vec3 centre, vec3 halfWidth, vec3 halfHeight,
vec3 world, vec3 mirror) {
vec3 n = cross(halfWidth, halfHeight);
float nLen = length(n);
// A panel with no area has no surface to find a point on; its centre is the
// only answer that is not a division by zero.
if (nLen < 1e-12) return centre;
n /= nLen;
vec3 toPlane = centre - world;
float denom = dot(mirror, n);
vec3 onPlane;
// Parallel to the panel, or pointing away from it: the ray never lands, so
// the nearest thing to it is the centre projected back, which keeps the
// highlight on the panel instead of sending it to infinity.
if (abs(denom) < 1e-5) {
onPlane = toPlane - n * dot(toPlane, n);
} else {
float t = dot(toPlane, n) / denom;
onPlane = t > 0.0 ? mirror * t : toPlane - n * dot(toPlane, n);
}
// Clamped into the rectangle in its own axes. Dividing by the squared length
// turns a projection into a coordinate in units of the half-extent, so the
// clamp is against one either way round.
vec3 offset = onPlane - toPlane;
float wLen2 = max(dot(halfWidth, halfWidth), 1e-12);
float hLen2 = max(dot(halfHeight, halfHeight), 1e-12);
float u = clamp(dot(offset, halfWidth) / wLen2, -1.0, 1.0);
float v = clamp(dot(offset, halfHeight) / hLen2, -1.0, 1.0);
return centre + halfWidth * u + halfHeight * v;
}
/// 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;
vec4 color;
vec4 direction;
vec4 cone;
if (index < kMaxLights) {
position = frag_info.light_position[index];
color = frag_info.light_color[index];
direction = frag_info.light_direction[index];
cone = frag_info.light_cone[index];
} else {
#ifdef F3D_NO_LIGHT_LIST
// Unreachable: `LightCount` stops at the slots without a list.
position = vec4(0.0);
color = vec4(0.0);
direction = vec4(0.0);
cone = vec4(0.0);
#else
// A row of the light list — `gfx-74n`. Sampled at texel centres so a
// driver's rounding cannot land a fetch on a neighbour, and the four texels
// across the row are the same four vectors the arrays above hold.
int slot = index - kMaxLights;
float v = (LightListRow(slot) + 0.5) * light_list_info.list.z;
float u = light_list_info.list.y;
// `textureLod` and not `texture`, for `shadow.glsl`'s own reason: `index`
// reaches this branch through a function parameter, so a WGSL backend
// cannot see that every invocation of a draw walks the same light count
// and refuses the implicit derivative as possibly non-uniform. The atlas
// has one level, so naming it directly changes no pixel.
position = textureLod(light_list_texture, vec2(0.5 * u, v), 0.0);
color = textureLod(light_list_texture, vec2(1.5 * u, v), 0.0);
direction = textureLod(light_list_texture, vec2(2.5 * u, v), 0.0);
cone = textureLod(light_list_texture, vec2(3.5 * u, v), 0.0);
// The intensity and not the colour, for `LightBuffer._pack`'s own reason:
// the same multiply here, and only one of them is a number nobody authored.
color.w *= LightListScale(slot);
#endif // F3D_NO_LIGHT_LIST
}
float type = position.w;
// **The rectangle leaves before `aim` is taken — `gfx-77n`.** For every other
// kind `direction.xyz` is a unit vector saying which way the light points;
// for this one it is an edge of the panel, with its length carrying half the
// width, and normalising it here would quietly throw the size away.
if (type > 2.5) {
vec3 halfWidth = direction.xyz;
vec3 halfHeight = cone.xyz;
vec3 toCentre = position.xyz - v_world_position;
vec3 corners[4];
corners[0] = toCentre - halfWidth - halfHeight;
corners[1] = toCentre + halfWidth - halfHeight;
corners[2] = toCentre + halfWidth + halfHeight;
corners[3] = toCentre - halfWidth + halfHeight;
// The cosine-weighted solid angle, which takes the place `n · l` holds for
// a punctual light: the loop multiplies the shading by `n_dot_l`, so
// putting the exact integral here makes the diffuse term exact rather than
// sampled. See [RectangleFormFactor].
float formFactor = RectangleFormFactor(corners, s.n);
// Radiance rather than intensity: `intensity` means the same thing for
// every kind of light, so a panel's is spread over its own area here.
// Enlarging a window at a fixed rating then dims it per square metre and
// leaves the room as bright, which is what the number is supposed to mean.
float area = length(cross(halfWidth, halfHeight)) * 4.0;
float radiance = area > 1e-9 ? 1.0 / area : 0.0;
// The range window only. A punctual light needs the inverse square as
// well; the form factor already contains it, because a panel twice as far
// away subtends a quarter of the sky.
float distance = length(toCentre);
if (direction.w > 0.0) {
float ratio = distance / direction.w;
float window = clamp(1.0 - ratio * ratio * ratio * ratio, 0.0, 1.0);
radiance *= window * window;
}
vec3 mirror = reflect(-s.v, s.n);
vec3 representative = RectangleClosestPoint(
position.xyz, halfWidth, halfHeight, v_world_position, mirror);
vec3 toPoint = representative - v_world_position;
float pointDistance = length(toPoint);
light.l = pointDistance > 1e-6 ? toPoint / pointDistance : s.n;
light.h = normalize(light.l + s.v);
light.n_dot_l = formFactor;
light.n_dot_h = max(dot(s.n, light.h), 0.0);
light.v_dot_h = max(dot(s.v, light.h), 0.0);
light.radiance = color.rgb * color.w * radiance;
return light;
}
vec3 aim = normalize(direction.xyz);
float attenuation = 1.0;
if (type < 0.5) {
// Directional: no position, no falloff. The direction to the light is the
// reverse of the direction it points.
light.l = -aim;
} else {
vec3 toLight = position.xyz - v_world_position;
float distance = length(toLight);
// A light exactly on the surface has no direction; treat it as contributing
// nothing rather than producing a NaN that spreads through the frame.
if (distance < 1e-6) {
light.l = s.n;
light.h = s.n;
light.radiance = vec3(0.0);
light.n_dot_l = 0.0;
light.n_dot_h = 0.0;
light.v_dot_h = 0.0;
return light;
}
light.l = toLight / distance;
attenuation = PunctualAttenuation(distance, direction.w);
if (type > 1.5) {
// Spot: a smooth ramp between the two cone cosines. The Dart side already
// guarantees the denominator is non-zero.
float cosAngle = dot(aim, -light.l);
attenuation *= clamp(
(cosAngle - cone.y) / (cone.x - cone.y), 0.0, 1.0);
}
}
light.h = normalize(light.l + s.v);
light.n_dot_l = max(dot(s.n, light.l), 0.0);
light.n_dot_h = max(dot(s.n, light.h), 0.0);
light.v_dot_h = max(dot(s.v, light.h), 0.0);
light.radiance = color.rgb * color.w * attenuation;
return light;
}
/// How much of light [index] reaches this fragment, defined by each fragment
/// shader.
///
/// A prototype rather than a call into shadow.glsl, because the models that
/// sample no shadow map must not declare its sampler — the compiler would drop
/// the slot and leave the engine binding one that is not there. A lit model
/// returns `ShadowFactor(...)`; an unlit one returns 1.
float LightVisibility(Surface s, LightSample light, int index);
/// A model's per-light term, defined by each fragment shader.
///
/// A prototype here and the definition in the model is what lets the loop below
/// be written once. The alternative — repeating the loop in every model — is
/// six copies of the same three lines, and the place a light would go missing.
vec3 ShadeLight(Surface s, LightSample light);
/// Sums every active light's contribution.
///
/// The loop bound is the compile-time maximum with a runtime break, because GLSL
/// wants a constant trip count and the hardware wants the early exit.
// **The point-shadow half of this header, behind a guard.**
//
// A model that never shadows must not *declare* any of this, and the reason is
// the one `unlit.frag` already gives about the shadow sampler — with one
// backend's failure added to the other's. On Impeller the compiler drops what
// nothing reads, and the engine binding a slot that is no longer there is a
// native crash. On WebGL2 nothing is dropped: an active uniform block with no
// buffer under it makes every draw `INVALID_OPERATION`, discarded with nothing
// logged.
//
// That is what `lighting-unlit` was on this backend. Unlit's own metadata says
// `usesPointShadow` is false, so the engine correctly bound no `PointShadow`
// block — and the translated shader declared one anyway, so the sphere was
// never drawn and the frame came back the clear colour.
#ifndef F3D_NO_POINT_SHADOW
/// The cube atlas: three tiles across, two down, each a ninety-degree view
/// from a point light, each storing radial distance normalised by range.
uniform sampler2D point_shadow_texture;
/// The same atlas for the things that never move, rendered once at load.
///
/// Two maps rather than one because a dungeon's walls can be baked and a
/// spinning pickup cannot, and there is no way to draw into part of a texture
/// without redrawing the rest of it. Sampling both and keeping the nearer
/// occluder costs one extra read and saves six views of the level every frame.
uniform sampler2D point_shadow_static_texture;
/// How many lights may have a row of the atlas. Six tiles across each.
// Rows of the cube atlas: six faces across, this many lights down. Must
// match `Renderer.kShadowedLights`, which is where the reasoning lives, and
// `shadowSlots` in the software backend's transcription of this file.
const int kShadowSlots = 6;
layout(std140) uniform PointShadow {
/// The same view-projections the atlas was rendered with, six per slot.
///
/// Passed rather than reconstructed. Deriving cube face coordinates here
/// would be a second implementation of a decision the renderer already made,
/// and the two would disagree about handedness or up vectors on some face
/// and nowhere else — which shows as one face of every shadow being wrong.
mat4 faces[6 * kShadowSlots];
/// Per slot. xyz: the light's world position. w: its range.
vec4 lights[kShadowSlots];
/// Per light, in the order the lighting knows them.
///
/// x: the atlas row it owns, or negative when it has none — a fifth torch in
/// a room lands there. z: the tangent of half the frustum's opening angle,
/// which is what converts a world width into a fraction of a tile. y and w
/// are unwritten.
///
/// **z is exactly one for a point light**, because a cube face is a ninety
/// degree frustum and `tan(45°) == 1`. That is not a convention chosen to be
/// tidy: it is what lets a narrower frustum share this whole path, since
/// multiplying by one in IEEE 754 changes no bit of the result. Whatever else
/// a spot light will need, it does not need a second copy of the filter.
vec4 slots[kMaxLights];
/// x: half a texel, in tile-local uv. y: distance bias in metres.
/// z: strength. w: normal offset, **in texels of the face it lands on**.
vec4 params;
/// x: smallest kernel radius in tile-local uv, and the fixed radius used
/// when contact hardening is off. y: the light's own radius in metres; zero
/// turns contact hardening off. z: largest kernel radius in tile-local uv.
/// w: non-zero paints the penumbra estimate into the surface buffer instead
/// of shading with it.
vec4 params2;
/// x: non-zero when this backend stores the atlas bottom-up. y: one over the
/// edge length of a tile in texels, which is what turns a distance into the
/// world width of one texel there.
///
/// **Appended after everything else on purpose**, the same way FragInfo's
/// ambient pair was: std140 lays a block out in declaration order, so adding
/// here leaves every offset above unchanged and the three backends do not
/// have to agree about anything they already agreed about. y, z and w are
/// unwritten.
vec4 params3;
}
point_shadow;
/// Eight points on a Poisson disk, a common set for filtering cascaded
/// shadows.
///
/// A disk rather than a grid because a grid of taps on a straight shadow edge
/// lands every sample on the same side at once, and the edge steps between
/// kernel widths instead of sliding. Eight rather than sixteen because every
/// tap here reads **two** atlases — the static walls and the movers — so the
/// cost is doubled before it is counted.
vec2 PointShadowDiskTap(int i) {
if (i == 0) return vec2(-0.94201624, -0.39906216);
if (i == 1) return vec2(0.94558609, -0.76890725);
if (i == 2) return vec2(-0.09418410, -0.92938870);
if (i == 3) return vec2(0.34495938, 0.29387760);
if (i == 4) return vec2(-0.91588581, 0.45771432);
if (i == 5) return vec2(-0.81544232, -0.87912464);
if (i == 6) return vec2(-0.38277543, 0.27676845);
return vec2(0.97484398, 0.75648379);
}
/// One comparison against the atlas, at [uv] offset within the tile.
///
/// The clamp is applied **after** the offset, not before, and that is the whole
/// reason a kernel can be widened here without touching anything else: each tap
/// is held inside its own tile individually. Clamping the centre and then
/// offsetting would let the outer taps walk straight out of the tile and read a
/// distance measured from a different face, or a different light.
float PointShadowDistance(vec2 uv, vec2 offset, vec2 tile, float range) {
float inset = point_shadow.params.x;
vec2 local = clamp(uv + offset, inset, 1.0 - inset);
vec2 atlas = (local + tile) * vec2(1.0 / 6.0, 1.0 / float(kShadowSlots));
// **The whole atlas, turned over, where row zero of a render target is at the
// bottom.** Both halves of the address are wrong there and this is the one
// place that fixes both: the tile the light owns — a light in slot zero is
// drawn into the row the shader would call three, because the viewport
// rectangle is flipped to land it — and the picture inside that tile, which
// was drawn through a projection built for the other origin.
//
// Every check of this atlas missed it for the same reason: the debug view
// composites the texture through a full-screen pass, which turns it over
// again and puts the row back. The atlas compared equal on both backends
// across six scenes while the lit pass, which samples it directly and has no
// such pass to cancel, read a row that had never been drawn into and found
// nothing in the way of anything.
if (point_shadow.params3.x > 0.5) atlas.y = 1.0 - atlas.y;
// Whichever is nearer occludes: a wall in front of a monster shadows, and so
// does a monster in front of a wall.
//
// **`textureLod` at level zero, because every caller of this function stands
// behind a branch.** The light loop skips a light the surface faces away
// from, the blocker search `continue`s past a tap that found nothing, and the
// slot test returns before any of it — so the invocations of a quad do not
// arrive here together, and a WGSL backend refuses a sample whose implicit
// derivative would be read where they disagree. Both atlases are distance
// render targets with one level, so level zero is the level `texture` was
// choosing anyway; this names it rather than deriving it, and the picture is
// the same on every backend.
return min(textureLod(point_shadow_texture, atlas, 0.0).r,
textureLod(point_shadow_static_texture, atlas, 0.0).r) * range;
}
float PointShadowTap(vec2 uv, vec2 offset, vec2 tile, float range,
float receiver) {
float stored = PointShadowDistance(uv, offset, tile, range);
// Nothing was drawn in that direction by either, so nothing is in the way.
if (stored >= range * 0.999) return 1.0;
return receiver > stored ? 0.0 : 1.0;
}
/// The disk point for tap [i], rotated by [ca]/[sa] and scaled to [radius].
vec2 PointShadowOffset(int i, float ca, float sa, float radius) {
vec2 p = PointShadowDiskTap(i);
return vec2(p.x * ca - p.y * sa, p.x * sa + p.y * ca) * radius;
}
/// How wide the penumbra should be here, in tile-local uv.
///
/// Contact hardening, and the reason a fixed kernel looks wrong: a shadow is
/// sharp where its caster touches the floor and soft a metre away, and one
/// radius for both makes the contact mushy or the distant edge hard.
///
/// The similar-triangles estimate is the standard one — a light of radius `L`
/// with a blocker at `b` and a receiver at `r` throws a penumbra `L * (r - b) /
/// b` wide at the receiver. Converting that to tile uv is exact rather than
/// tuned, because a face is a ninety degree frustum: at distance `r` from the
/// light the face spans `2 * r` in world units across the full `0..1` of uv,
/// so a world width `w` is `w / (2 * r)` of a tile.
///
/// The blocker search runs at the **widest** penumbra allowed, since a blocker
/// outside that circle cannot widen the result anyway, and searching narrower
/// would miss the very blockers that make an edge soft.
///
/// [tanHalf] is where the ninety degrees stop being assumed. The span above is
/// `2 * r` only for a right-angled frustum; in general it is `2 * r * tan(θ/2)`,
/// and for a cube face that factor is one. A narrower frustum covers less world
/// per tile, so the same world width is a *larger* fraction of it — which is
/// why this divides rather than multiplies, and why getting it upside down
/// would make a tight cone's shadows harden instead of soften.
float PointShadowPenumbra(vec2 uv, vec2 tile, float range, float receiver,
float ca, float sa, float tanHalf,
out float blockerOut) {
blockerOut = -1.0;
float lightRadius = point_shadow.params2.y;
float minRadius = point_shadow.params2.x;
float maxRadius = point_shadow.params2.z;
if (lightRadius <= 0.0) {
// **The debug channel is filled even though the search is skipped**, and
// leaving it unfilled cost a session. `blockerOut` starts at −1 to mean
// "nothing was measured"; the debug encoding clamps it into a colour, where
// −1 becomes zero — the same green as a blocker touching the surface, which
// reads as the most alarming answer available. A whole theory was built on
// that zero, and the search it described had never run.
//
// The centre tap is what the filter below would use anyway, so this reports
// a distance the atlas really returned rather than a sentinel.
blockerOut = PointShadowDistance(uv, vec2(0.0), tile, range);
return minRadius;
}
float sum = 0.0;
float count = 0.0;
for (int i = 0; i < 8; i++) {
float stored =
PointShadowDistance(uv, PointShadowOffset(i, ca, sa, maxRadius), tile,
range);
if (stored >= range * 0.999) continue;
if (stored >= receiver) continue;
sum += stored;
count += 1.0;
}
// Nothing in front of this fragment anywhere in the search: fully lit, and
// the caller can skip the filter entirely.
if (count < 0.5) return -1.0;
float blocker = max(sum / count, 1e-4);
blockerOut = blocker;
float world = lightRadius * max(receiver - blocker, 0.0) / blocker;
return clamp(world / (2.0 * receiver * tanHalf), minRadius, maxRadius);
}
/// How lit [world] is by the point light that owns the cube atlas.
///
/// One, fully lit, when this is not that light or the atlas has nothing to say.
float PointShadowFactor(vec3 world, vec3 normal, int lightIndex) {
int slot = int(point_shadow.slots[lightIndex].x + 0.5);
if (point_shadow.slots[lightIndex].x < 0.0) return 1.0;
float strength = point_shadow.params.z;
if (strength <= 0.0) return 1.0;
// Offset along the normal before measuring, and scaled by how steeply the
// surface leans away from the light.
//
// A soft kernel on a tilted surface straddles a depth gradient: the taps at
// one end of the disk are further from the light than the fragment itself,
// so a flat offset that clears the surface head-on leaves acne at a grazing
// angle. The slope term lifts the whole kernel clear instead, and is capped
// because it runs away as the surface turns edge-on to the light — an
// uncapped lift detaches the shadow from its caster.
vec3 toLight = point_shadow.lights[slot].xyz - world;
float toLightLength = max(length(toLight), 1e-6);
float nDotL = max(dot(normal, toLight / toLightLength), 0.15);
float slope = min(sqrt(max(1.0 - nDotL * nDotL, 0.0)) / (nDotL * nDotL), 8.0);
// **How wide one texel of the face is, out where this fragment is.** The
// error a normal offset exists to clear is exactly that: a texel of the
// shadow map covers a patch of surface, the whole patch is recorded at one
// distance, and a fragment anywhere else in it compares against a distance
// measured somewhere it is not. That patch grows with range — it is a solid
// angle, not a length — so an offset fixed in metres is right at one distance
// and wrong everywhere else.
//
// What it was: `params.w` metres, flat. On the golden teapot, at 9.6 m from
// the lamp, a texel is 3.7 cm and the flat offset was 2 cm, so the floor
// shadowed itself across everything the light reached — and the acne stopped
// dead at the *projection of the floor's own edge*, because past it the atlas
// holds nothing and nothing can occlude. A straight line across a shadow with
// no straight edge anywhere in the scene.
float texel =
2.0 * toLightLength * max(point_shadow.slots[lightIndex].z, 1e-4) *
point_shadow.params3.y;
// Both terms are metres. The slope term used to be the kernel radius, which
// is a fraction of a tile — a unit mismatch carried over from an estimate
// where a softness radius genuinely was the right quantity. Here it meant
// widening the kernel also lifted the sample off the surface, by up to ten
// centimetres at the wider settings, so the softening and the lift
// cancelled: tripling the kernel moved 184 pixels of the frame,
// where the kernel alone moves thousands. It is what made contact hardening
// look inert, and it was hiding in a comparison rather than in the estimate.
vec3 origin = world + normal * texel * point_shadow.params.w * (1.0 + slope);
vec3 toFragment = origin - point_shadow.lights[slot].xyz;
float distance = length(toFragment);
float range = max(point_shadow.lights[slot].w, 1e-4);
if (distance >= range) return 1.0;
// The dominant axis picks the face, in the order the renderer wrote them:
// +X, -X, +Y, -Y, +Z, -Z, left to right then top to bottom.
//
// A spot has one column and no choice to make. Asking the dominant axis
// anyway would be worse than pointless: a fragment below and to the side of
// a downlight has −Y dominant, which is column 3, and column 3 of a spot's
// row is deliberately blank — so the whole cone would read as unshadowed
// except for the wedge where the aim happens to be the dominant axis.
int face = 0;
if (point_shadow.slots[lightIndex].y < 0.5) {
vec3 a = abs(toFragment);
if (a.x >= a.y && a.x >= a.z) {
face = toFragment.x > 0.0 ? 0 : 1;
} else if (a.y >= a.z) {
face = toFragment.y > 0.0 ? 2 : 3;
} else {
face = toFragment.z > 0.0 ? 4 : 5;
}
}
vec4 clip = point_shadow.faces[slot * 6 + face] * vec4(origin, 1.0);
if (clip.w <= 0.0) return 1.0;
vec2 ndc = clip.xy / clip.w;
if (abs(ndc.x) > 1.0 || abs(ndc.y) > 1.0) return 1.0;
// v is flipped, the same way the directional map does it: the texture's
// origin is at the top, where row zero of the render target is. Getting this
// wrong does not tilt the shadow — it makes the top row of faces read the
// bottom row, so a whole region compares against an unrelated distance and
// comes out as a black slab.
vec2 uv = vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
// The face across, the light down: six tiles wide, four tall.
vec2 tile = vec2(float(face), float(slot));
float receiver = distance - point_shadow.params.y;
// One rotation, shared by the blocker search and the filter. Per fragment,
// so eight samples read as a soft edge rather than as eight copies of the
// silhouette: without it every fragment along an edge tests the same eight
// directions and the pattern shows.
//
// **The three constants are not arbitrary and are not ours.** This is Jorge
// Jimenez's interleaved gradient noise, from "Next Generation Post
// Processing in Call of Duty: Advanced Warfare" (SIGGRAPH 2014):
//
// IGN(x, y) = frac(52.9829189 * frac(0.06711056 * x + 0.00583715 * y))
//
// The pair inside the dot is a direction whose gradient walks the unit
// interval as slowly as it can while never repeating over a screen, and the
// multiplier outside stretches that walk so neighbouring pixels land far
// apart in the result. What it buys over a hash is the cost: one dot and two
// fracts, no integer arithmetic, no texture. What a blue-noise texture buys
// over it is a better spectrum, at a sampler and a fetch — worth it for
// dithering a whole frame, not for rotating eight taps.
//
// Written down because three unexplained decimals read as a magic spell, and
// the next person to touch this line has no way to tell which of them may be
// changed. The answer is none of them.
float noise = fract(52.9829189 * fract(dot(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 < kTotalLights; i++) {
if (i >= count) break;
LightSample light = SampleLight(i, s);
if (light.n_dot_l <= 0.0) continue;
// A light from the list has no shadow row to read — see `LightHasShadow`.
// A branch rather than something folded into the two calls, because both
// index tables eight entries wide and the ninth light would read past them
// rather than read a one.
float visibility = LightHasShadow(i)
? LightVisibility(s, light, i) *
PointShadowFactor(v_world_position, s.n, i)
: 1.0;
if (visibility <= 0.0) continue;
total += ShadeLight(s, light) * light.radiance * light.n_dot_l * visibility;
}
return total;
}
#endif // SURFACE_GLSL_
/// Tangent-space normal map. Neutral is (0.5, 0.5, 1.0).
uniform sampler2D normal_texture;
/// glTF's ORM packing: g is roughness, b is metallic. Neutral is white.
uniform sampler2D metallic_roughness_texture;
/// Ambient occlusion in r. Neutral is white.
uniform sampler2D occlusion_texture;
/// Emitted colour, multiplied by the emissive factor. Neutral is white, and the
/// factor defaults to black, so a material with neither emits nothing.
uniform sampler2D emissive_texture;
/// The level's baked lightmap, RGBM: colour over a shared multiplier, decoded
/// as `rgb × a × 8`. Sampled at the second coordinate, which every vertex
/// stage but the lightmapped one leaves at the atlas corner; neutral is
/// black, so a material without a map adds nothing.
uniform sampler2D lightmap_texture;
/// The irradiance the lightmap holds at this fragment, in the units a light's
/// `colour × intensity × attenuation × cos` arrives in.
vec3 SampleLightmap() {
vec4 texel = texture(lightmap_texture, v_lightmap_uv);
return texel.rgb * texel.a * 8.0;
}
/// One function per map, rather than one that applies all four.
///
/// Not a style choice. The compiler drops a sampler whose result never reaches
/// the output, so a model that samples the ORM map and then ignores metallic and
/// roughness — Lambert does exactly that — ends up with no
/// `metallic_roughness_texture` in its compiled signature at all, while the Dart
/// side still thinks there is one to bind. That is the phantom-binding trap
/// again, and binding a slot Metal does not have is a native crash.
///
/// Splitting them means a model calls only what it genuinely uses, so the
/// compiled signature matches the source, and `LightingModel` can declare the
/// same set truthfully. `tool/build_shaders.sh` prints the compiled slots so
/// the two cannot drift apart unnoticed.
/// glTF's ORM packing: roughness in g, metallic in b, both multiplying the
/// material factors.
void ApplyMetallicRoughnessMap(inout Surface s) {
vec3 orm = texture(metallic_roughness_texture, 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) {
// **Sampled before the frame is tested, and that order is load-bearing.**
// The test below is a branch on interpolated data, so the four invocations of
// a quad can take different sides of it; a WGSL backend then refuses a
// `texture` call underneath, because the mip level it derives is only defined
// where the whole quad agrees. Unlike the shadow atlases, this map really is
// mipped — a normal map read at full resolution on a surface turned away from
// the camera is the aliasing that made this the widest disagreement between
// backends — so pinning a level here would be a picture change, and hoisting
// the sample is the cure that is not. A degenerate tangent is rare enough
// that paying for its unused texel is nothing, and the texel it reads is the
// same one the branch would have read.
vec4 sampledTexel = texture(normal_texture, v_texcoord);
// 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 = sampledTexel.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;
/// Five points on a disc: the centre and four at the diagonals.
///
/// **Diagonals rather than the axes.** A cross of four axis-aligned taps
/// leaves a shadow whose edge is smooth along x and y and hard at forty-five
/// degrees, which is the angle most edges in a built scene actually run at.
/// Turned by an eighth of a turn, the four taps straddle a vertical or
/// horizontal edge evenly and the artefact has nowhere to line up.
const vec2 kShadowDisc[5] = vec2[5](vec2(0.0, 0.0),
vec2(0.7071, 0.7071),
vec2(-0.7071, 0.7071),
vec2(0.7071, -0.7071),
vec2(-0.7071, -0.7071));
/// 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);
// **`textureLod` and not `texture`, and the level asked for is the only one
// there is.** Everything above this loop is a reason not to be here — the
// cascade search returns early when no cascade contains the fragment, and the
// light loop that calls it skips a light facing away — so a WGSL backend sees
// a sample taken where the four invocations of a quad need not agree, and
// refuses it: the implicit derivative `texture` asks for is only defined
// where they all arrive. The cascade atlas is a depth render target with a
// single level, so the derivative was never doing anything but selecting
// level zero, and naming that level directly costs nothing and changes no
// pixel on any backend.
//
// **The softness, where it rides, and what zero means.**
//
// `ambient_ground.w` is the directional light's apparent size. It has
// nothing to do with ambient light and everything to do with this being the
// one component left unspent in a block six shaders share: `frame_params.w`
// was the slot reserved for exactly this and the environment's level count
// took it, and appending to the block moves offsets four backends have
// agreed on. The alternative was a second uniform block bound per draw for
// one float. Named here because a reader arriving at `ambient_ground` has
// every right to be surprised.
//
// Zero is the 3×3 kernel this has always had, which is what keeps every
// recorded golden where it is. Above zero the edge widens with the distance
// between the occluder and what it falls on — what a real light does, and
// what no fixed kernel can.
float softness = frag_info.ambient_ground.w;
float lit = 0.0;
if (softness <= 0.0) {
// PCF 3x3. Four samples would band visibly at this map size and nine is
// the smallest kernel that reads as a soft edge rather than as stair
// steps.
for (int y = -1; y <= 1; y++) {
for (int x = -1; x <= 1; x++) {
float occluder = textureLod(
shadow_texture, uv + vec2(float(x), float(y)) * texel, 0.0).r;
lit += projected.z - bias > occluder ? 0.0 : 1.0;
}
}
lit *= 1.0 / 9.0;
} else {
// **Find what is casting before deciding how wide to blur.** The five
// taps go out at a fixed search radius first and average the depths of
// whatever they find in front of this fragment; that average is the
// occluder's distance, and the penumbra is proportional to it. A kernel
// sized without this step is the fixed one again with a bigger number.
// Bounded, and not proportional to the softness: the search only has to
// reach far enough to find *a* blocker, and a radius that grew without
// limit would start finding occluders from the other side of the scene
// and report a gap that belongs to them.
float searchRadius = clamp(softness * 0.25, 2.0, 16.0);
float blockerSum = 0.0;
float blockerCount = 0.0;
for (int i = 0; i < 5; i++) {
float occluder = textureLod(
shadow_texture, uv + kShadowDisc[i] * texel * searchRadius, 0.0).r;
if (projected.z - bias > occluder) {
blockerSum += occluder;
blockerCount += 1.0;
}
}
// Nothing between this fragment and the light: lit, and no second loop.
if (blockerCount <= 0.0) return 1.0;
// Linear depth over the cascade's own volume, so the gap between the
// occluder and the receiver *is* the distance — no perspective divide,
// which is what an orthographic light means.
float gap = max(projected.z - blockerSum / blockerCount, 0.0);
// One texel at the tightest, so a contact edge stays an edge; the cap
// keeps a distant occluder from reaching across a whole cascade.
float radius = clamp(gap * softness, 1.0, 16.0);
for (int i = 0; i < 5; i++) {
float occluder = textureLod(
shadow_texture, uv + kShadowDisc[i] * texel * radius, 0.0).r;
lit += projected.z - bias > occluder ? 0.0 : 1.0;
}
lit *= 1.0 / 5.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 strength in the slot the flat term above reads, which is
// why the two are interchangeable rather than additive: whichever term
// runs, it runs at `material.z`. **What that number is depends on what is
// bound.** A scene's own environment is scaled by `Scene.ambientIntensity`,
// the same knob the flat term uses, so a scene that dials its indirect
// light down dials both; a reflection probe brings its own
// `ReflectionProbeNode.intensity` instead, because a probe is the room's
// light already measured. The renderer decides which — see `_encodeNode`
// in renderer_mesh_encode.dart — and this stage cannot tell them apart.
ambient = (diffuseColor * irradiance + prefiltered * (f0 * ab.x + ab.y)) *
frag_info.material.z * s.occlusion;
}
// The light the level's walls throw on each other, baked: diffuse only,
// since a lightmap holds irradiance and a metal has no diffuse response.
// Zero from the one-texel black a material without a map is bound to.
//
// **Added rather than chosen between, and the choosing happens above this
// shader.** A lightmap and an environment's roughest level are two answers
// to the same question — how much indirect light reaches this point — so a
// draw that had both would count it twice. There is no flag here to branch
// on: a material without a map is bound the neutral black by design (see
// material_maps.glsl), which is what makes this a plain add. The renderer
// keeps the two apart instead, by handing no reflection probe to a
// lightmapped draw; see `_encodeNode` in renderer_mesh_encode.dart. A sky
// environment over a lightmapped level still adds, and should: sky light
// is not what the bake measured.
ambient += diffuseColor * SampleLightmap() * s.occlusion;
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;
/// Where this fragment is in the level's lightmap. Zero from every vertex
/// stage but `mesh_lightmapped.vert`, and read only by the lit models, which
/// sample a one-texel black there when a material has no map.
in vec2 v_lightmap_uv;
layout(location = 0) out vec4 frag_color;
// The second attachment: what a screen-space effect needs to know about the
// surface it is looking at. World-space normal in rgb, and in a the depth along
// the view axis in world metres — not a window depth; `WriteSurfaceGeometry`
// says at length why not.
//
// Depth travels here rather than in a depth texture because flutter_gpu cannot
// sample one — the same reason the shadow pass writes its depth into a colour
// target. See ARCHITECTURE.md §2.
//
// Guarded, because not every stage that includes this header draws into a
// two-attachment target. The shadow pass draws into one, and a pipeline
// declaring an output its target has no slot for is a mismatch worth avoiding
// rather than discovering.
#ifndef F3D_NO_SURFACE_BUFFER
layout(location = 1) out vec4 frag_surface;
#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;
// **A stage that needs none of this must be able to declare none of it.** On
// Vulkan both stages' descriptors are merged into one set layout, and two
// bindings with the same number in it is not a layout the specification
// allows. A driver may accept it anyway; a Galaxy A55's refuses the pipeline
// with `ErrorUnknown` and no other word, which is how the shadow pass came to
// build everywhere except there — its only uniform block was this one, and it
// landed on the same binding as the vertex stage's first.
#ifndef F3D_NO_FOG
/// Distance fog, in its own block rather than folded into FragInfo.
///
/// Its own because color.glsl is included before FragInfo is declared, and
/// because appending to a block that half a dozen shaders already share is a
/// way to move offsets nobody expected to move. Three vec4s is a cheap price
/// for not touching any of that.
layout(std140) uniform FogInfo {
/// rgb: linear fog colour. w: density per metre, zero for no fog.
vec4 fog;
/// xyz: camera position in world space. Duplicated from FragInfo so this
/// block stands alone; a vec3 is cheaper than a coupling.
vec4 eye;
/// xyz: the direction the camera looks, as a unit vector in world space.
/// w unused.
///
/// Here rather than in a block of its own because it answers the same
/// question [eye] does — where the camera is and which way it faces — and
/// this is the block `color.glsl` can see.
vec4 forward;
}
fog_info;
/// How far this fragment is from the eye, in world metres.
///
/// What the fog fades by. Distance rather than depth, because fog is a
/// property of the air between two points and does not care which way the
/// camera happens to face.
float EyeDistance() { return distance(v_world_position, fog_info.eye.xyz); }
/// How far this fragment is *along the view axis*, in world metres.
///
/// What the surface buffer's alpha holds. Depth rather than distance, and the
/// difference only shows on an orthographic camera — where the rays through
/// the pixels are parallel instead of meeting at the eye, so a distance from
/// the eye names a sphere that the pixel's ray crosses somewhere the reader
/// cannot solve for. A depth along the axis names a plane, which every ray
/// crosses exactly once. See `WorldAtDepth` in `post/ssao.frag` for the
/// reconstruction both projections share.
float ViewDepth() {
return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz);
}
#else // F3D_NO_FOG
// The same two questions, answered without the block: a stage that declares no
// fog has no eye position to measure from either. Stubs rather than a guard at
// every call site, so that what includes this file reads the same whichever
// way it was compiled.
float EyeDistance() { return 0.0; }
float ViewDepth() { return 0.0; }
#endif // F3D_NO_FOG
/// Records the geometry of this fragment for whatever runs after the scene.
///
/// Called from the same place that writes colour, so a surface cannot be lit
/// into the frame without also describing itself — which is the failure that
/// leaves a screen-space effect reflecting whatever was in the buffer before.
///
/// rg: octahedral normal. b: perceptual roughness. a: **depth along the view
/// axis, in world metres** — see [ViewDepth].
///
/// **Not `gl_FragCoord.z`, and that is a defect this channel carried until it
/// was looked at.** Window depth crowds every distant surface into the top of
/// its range — with a near plane of a tenth of a metre, everything past twenty
/// metres lives in the last half a hundredth of `[0, 1]` — and this attachment
/// is a half float, whose steps up there are about five ten-thousandths. So two
/// surfaces half a metre apart at twenty metres stored the *same* number, and
/// every screen-space pass that compares against this channel decided whole
/// bands of pixels by rounding. The occlusion pass drew them: vertical stripes
/// along the lines of constant depth on any wall receding from the camera, on
/// both GPU backends. The software rasteriser kept the channel at full
/// precision and drew the effect correctly, so it was the one that looked
/// wrong against the other two.
///
/// A depth in metres has none of that: the exponent carries the range and the
/// mantissa carries the same relative precision everywhere, which at twenty
/// metres is a centimetre. Both numbers are measured in
/// `flutter3d/test/surface_depth_test.dart`.
///
/// Zero still means nothing was drawn. The attachment is cleared to zero and
/// nothing is drawn in front of the near plane.
void WriteSurfaceGeometry(float roughness) {
#ifndef F3D_NO_SURFACE_BUFFER
// A debug pass takes the buffer over rather than getting one of its own.
// The surface buffer already has an attachment, a viewer and a golden; a
// second one would need all three built before it could answer anything.
if (g_debug_surface_on) {
frag_surface = vec4(g_debug_surface, ViewDepth());
return;
}
frag_surface = vec4(EncodeOctahedral(normalize(v_normal)),
clamp(roughness, 0.0, 1.0), ViewDepth());
#endif
}
/// Fades [color] toward the fog with distance from the eye.
///
/// Exponential rather than linear, because linear fog has a visible plane
/// where it starts and a dungeon corridor is exactly where that shows.
vec3 ApplyFog(vec3 color) {
#ifdef F3D_NO_FOG
return color;
#else
float density = fog_info.fog.w;
if (density <= 0.0) return color;
float d = EyeDistance();
return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0));
#endif
}
/// 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
/// How many more lights one draw may be handed — `gfx-74n`.
///
/// **The eight above stay exactly what they were**, which is what keeps this
/// from moving a single recorded frame: a draw with eight lights or fewer runs
/// the loop it has always run, reads the uniform arrays it has always read, and
/// never touches the texture below. The tail is the part that used to be
/// impossible.
///
/// A loop bound rather than a cost. `AccumulateLights` breaks at the draw's own
/// count, so a scene with three lights costs three iterations whatever this
/// says. Twenty-four because the two tables below are `vec4 x[6]` and four
/// lanes fit a `vec4`: two hundred and eight bytes a draw, against the five
/// hundred and twelve the light arrays already cost.
#define kExtraLights 24
#define kTotalLights (kMaxLights + kExtraLights)
/// Every light in the scene, one per row, four texels across — `gfx-74n`.
///
/// **A texture rather than a wider uniform block, and that is the design.**
/// `FragInfo` is uploaded on every draw, so widening its four `vec4` arrays to
/// hold thirty-two lights would be a two-kilobyte upload per draw in every
/// scene, including every scene with one light. This is built once a frame and
/// only when a scene has more lights than a draw can hold in its slots.
///
/// Row layout, which `renderer_light_list.dart` writes and only this reads:
///
/// * texel 0 — xyz world position, w type (0 directional, 1 point, 2 spot)
/// * texel 1 — rgb linear colour, w intensity
/// * texel 2 — xyz the direction it points, w range
/// * texel 3 — x cos(inner), y cos(outer), zw unused
///
/// The same four vectors the uniform arrays hold, in the same order, so one
/// reader serves both.
///
/// **`F3D_NO_LIGHT_LIST` leaves both out**, for a model that accumulates no
/// lights. Such a model never reaches the reader below, so the compiler drops
/// the block and the sampler from the Metal function while reflection still
/// lists them, with no buffer or texture index assigned. The renderer used to
/// bind them for every draw, Unlit included, and that bind is a crash inside
/// `setFragmentBuffer:offset:atIndex:` on Metal. Vulkan took the same draw
/// without a word, which is how 0.7.0 shipped with it.
#ifndef F3D_NO_LIGHT_LIST
uniform sampler2D light_list_texture;
layout(std140) uniform LightListInfo {
/// x: how many rows this draw reads, zero when it reads none.
/// y, z: one over the texture's width and height.
/// w: unused.
vec4 list;
/// Which rows, four to a vector, in the order they are read.
///
/// Indices rather than the light data itself: the data is the same for every
/// draw in the frame and belongs in the texture; what differs per draw is
/// *which* of them reach it, and that is what `Renderer._drawLightsFor`
/// already decides.
vec4 indices[6];
/// How much of each of those survives the edge fade, in the same order.
///
/// Per draw and not in the texture, because the row an index points at is
/// shared by every draw in the frame: a scale written into it would dim that
/// light for all of them. `gfx-12n`'s fade lives at the end of the list now —
/// that is where a light stops contributing, and fading the slots against a
/// water line that no longer marks a cliff would dim a light for no reason
/// while its rival stayed bright, making the swap more visible rather than
/// less.
vec4 scales[6];
}
light_list_info;
/// One lane of a six-vector table, [slot] counting from nought.
float LightListLane(vec4 four, int slot) {
int lane = slot - (slot / 4) * 4;
return lane == 0 ? four.x : lane == 1 ? four.y : lane == 2 ? four.z : four.w;
}
/// The row light [slot] of the list reads.
float LightListRow(int slot) {
return LightListLane(light_list_info.indices[slot / 4], slot);
}
/// How much of light [slot] of the list survives the edge fade.
float LightListScale(int slot) {
return LightListLane(light_list_info.scales[slot / 4], slot);
}
#endif // F3D_NO_LIGHT_LIST
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 is the directional light's apparent size** — `gfx-15n` — which has
/// nothing to do with ambient and everything to do with this being the last
/// unspent component in a block six shaders share. `frame_params.w` was the
/// slot reserved for a frame-wide parameter and the environment's level
/// count took it; appending to this block moves offsets four backends have
/// agreed on. See `shadow.glsl`, which reads it.
///
/// Two colours rather than one is the whole of what makes ambient look like
/// light instead of like a lifted black level. Outdoors the sky is blue and
/// bright and the ground is warm and dim, and a flat grey for both leaves
/// every underside as pale as every upward face — which reads as the model
/// being flat, and gets blamed on the normals.
vec4 ambient_ground;
}
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.
//
// **A cutoff below -1.5 is the fourth mode: hashed** — `gfx-16n`. The
// sentinel rides in the same component because the alternative is a second
// number in a block six shaders share, and -1 already meant "not masked";
// anything more negative was free. See [MaterialAlphaMode.hashed].
float cutoff = frag_info.material2.x;
if (cutoff >= 0.0) {
if (s.alpha < cutoff) discard;
} else if (cutoff < -1.5) {
// **Stochastic instead of a threshold.** A leaf texture at 40% opacity is
// either entirely there or entirely gone under a fixed cutoff, so a fern
// comes out as a hard-edged cardboard cut-out; sorting would fix it and
// costs a sort per frame and a draw per layer. Comparing against noise
// instead keeps 40% of the *pixels*, which resolves as 40% opacity to
// anything that averages several of them — a higher-resolution target,
// a downsample, a person standing back.
//
// **Hashed on world position, not on the screen.** Screen-space noise is
// one line shorter and swims: the pattern stays put while the object
// moves through it, so a moving branch sparkles. Anchoring it to where
// the surface *is* means a given speck of leaf keeps its verdict from
// frame to frame, and the camera moving changes nothing.
//
// The scale is a constant and it is the whole tuning: finer than the
// texture's own detail and the noise disappears into aliasing, coarser
// and the leaf breaks into blotches. Sixteen per metre is about a
// centimetre of grain at a metre away.
vec3 anchored = floor(v_world_position * 16.0);
float noise = fract(
sin(dot(anchored, vec3(12.9898, 78.233, 37.719))) * 43758.5453);
if (s.alpha < noise) discard;
}
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() {
#ifdef F3D_NO_LIGHT_LIST
return clamp(int(frag_info.frame_params.y + 0.5), 0, kMaxLights);
#else
return clamp(int(frag_info.frame_params.y + 0.5), 0, kMaxLights) +
clamp(int(light_list_info.list.x + 0.5), 0, kExtraLights);
#endif
}
/// Whether light [index] carries a shadow — `gfx-74n`.
///
/// Only the first eight do. The cube atlas holds six rows and the slot table is
/// eight entries wide, so a light from the list has no row to read and asking
/// for one would index past the table. That is a real limit and the right one:
/// the eight a draw keeps in its slots are the eight ranked most relevant to
/// it, which is exactly the set worth a shadow map.
bool LightHasShadow(int index) { return index < kMaxLights; }
/// Distance attenuation for a punctual light, following the glTF spec.
///
/// Inverse square with an optional range window. The window is what stops a
/// lamp with a declared range from contributing a faint haze across the whole
/// scene, which matters far more once there are eight of them.
float PunctualAttenuation(float distance, float range) {
float attenuation = 1.0 / max(distance * distance, 1e-4);
if (range > 0.0) {
float ratio = distance / range;
float window = clamp(1.0 - ratio * ratio * ratio * ratio, 0.0, 1.0);
attenuation *= window * window;
}
return attenuation;
}
/// How much of [s]'s sky a rectangle covers, weighted by the cosine —
/// `gfx-77n`.
///
/// **Exact, not fitted.** This is Lambert's own form factor for a polygon, from
/// 1760: for each edge, the angle it subtends at the shading point times how
/// much the edge's plane leans into the surface normal. Summed over four edges
/// and halved, it *is* the integral of `cos θ` over the rectangle's projection
/// on the hemisphere — the quantity a punctual light approximates with a single
/// `n · l`. So there is no table to ship and nothing to fit: the usual
/// linearly-transformed-cosine approach exists to make the *specular* lobe
/// tractable, and buys nothing here, where the diffuse answer is a closed form
/// four `acos` calls long.
///
/// Returns irradiance over radiance, so a surface facing a rectangle that fills
/// its whole sky gets π, the same as a uniform hemisphere. [corners] are the
/// four vertices in order, relative to the shading point.
///
/// **The rectangle emits along `cross(halfWidth, halfHeight)`**, and with the
/// corners wound as `SampleLight` winds them the sum comes out *negative* on
/// that side, so the negation below is the convention rather than a fix. It was
/// measured rather than derived: the first version returned `+total * 0.5`, and
/// against the reference integration it read nought where the answer was 0.349
/// and 1.02 where the answer was nought — the two failures a flipped winding
/// produces, and between them they name the sign with no room left to argue.
float RectangleFormFactor(vec3 corners[4], vec3 n) {
float total = 0.0;
for (int i = 0; i < 4; i++) {
vec3 a = normalize(corners[i]);
vec3 b = normalize(corners[(i + 1) & 3]);
// Clamped before the `acos`: two nearly parallel edge directions can give a
// dot a hair past one through rounding alone, and `acos` of that is a NaN
// that spreads to the whole pixel and then to the bloom.
float angle = acos(clamp(dot(a, b), -1.0, 1.0));
vec3 axis = cross(a, b);
float len = length(axis);
// A degenerate edge — the shading point lies on the line through it —
// subtends nothing, and normalising a zero vector is the other way to get
// that NaN.
if (len > 1e-6) total += angle * dot(axis / len, n);
}
// Clamped rather than tested separately: a surface on the panel's dark side,
// or facing away from it, comes out with the sign reversed, so "one-sided" is
// a property of the arithmetic instead of a flag somebody has to remember.
return max(-total * 0.5, 0.0);
}
/// Where on the rectangle the specular lobe is really looking — `gfx-77n`.
///
/// **The representative point, which is an approximation, unlike the diffuse
/// above.** The mirror direction leaves the surface and either hits the panel
/// or misses it; the closest point of the panel to that ray is treated as a
/// punctual light standing in for the whole rectangle. It is the standard
/// cheap answer and its one visible property is the one the row asked for: as
/// the view moves the closest point slides along the panel, so the highlight
/// is a streak with the panel's own shape and orientation rather than a dot.
///
/// What it does not do is widen the lobe by the panel's solid angle, so a
/// rough surface under a large panel is a little darker than a full integration
/// would make it. That is a known error of this method and not a bug in this
/// transcription; the fix is the fitted table this function exists to avoid.
vec3 RectangleClosestPoint(vec3 centre, vec3 halfWidth, vec3 halfHeight,
vec3 world, vec3 mirror) {
vec3 n = cross(halfWidth, halfHeight);
float nLen = length(n);
// A panel with no area has no surface to find a point on; its centre is the
// only answer that is not a division by zero.
if (nLen < 1e-12) return centre;
n /= nLen;
vec3 toPlane = centre - world;
float denom = dot(mirror, n);
vec3 onPlane;
// Parallel to the panel, or pointing away from it: the ray never lands, so
// the nearest thing to it is the centre projected back, which keeps the
// highlight on the panel instead of sending it to infinity.
if (abs(denom) < 1e-5) {
onPlane = toPlane - n * dot(toPlane, n);
} else {
float t = dot(toPlane, n) / denom;
onPlane = t > 0.0 ? mirror * t : toPlane - n * dot(toPlane, n);
}
// Clamped into the rectangle in its own axes. Dividing by the squared length
// turns a projection into a coordinate in units of the half-extent, so the
// clamp is against one either way round.
vec3 offset = onPlane - toPlane;
float wLen2 = max(dot(halfWidth, halfWidth), 1e-12);
float hLen2 = max(dot(halfHeight, halfHeight), 1e-12);
float u = clamp(dot(offset, halfWidth) / wLen2, -1.0, 1.0);
float v = clamp(dot(offset, halfHeight) / hLen2, -1.0, 1.0);
return centre + halfWidth * u + halfHeight * v;
}
/// 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;
vec4 color;
vec4 direction;
vec4 cone;
if (index < kMaxLights) {
position = frag_info.light_position[index];
color = frag_info.light_color[index];
direction = frag_info.light_direction[index];
cone = frag_info.light_cone[index];
} else {
#ifdef F3D_NO_LIGHT_LIST
// Unreachable: `LightCount` stops at the slots without a list.
position = vec4(0.0);
color = vec4(0.0);
direction = vec4(0.0);
cone = vec4(0.0);
#else
// A row of the light list — `gfx-74n`. Sampled at texel centres so a
// driver's rounding cannot land a fetch on a neighbour, and the four texels
// across the row are the same four vectors the arrays above hold.
int slot = index - kMaxLights;
float v = (LightListRow(slot) + 0.5) * light_list_info.list.z;
float u = light_list_info.list.y;
// `textureLod` and not `texture`, for `shadow.glsl`'s own reason: `index`
// reaches this branch through a function parameter, so a WGSL backend
// cannot see that every invocation of a draw walks the same light count
// and refuses the implicit derivative as possibly non-uniform. The atlas
// has one level, so naming it directly changes no pixel.
position = textureLod(light_list_texture, vec2(0.5 * u, v), 0.0);
color = textureLod(light_list_texture, vec2(1.5 * u, v), 0.0);
direction = textureLod(light_list_texture, vec2(2.5 * u, v), 0.0);
cone = textureLod(light_list_texture, vec2(3.5 * u, v), 0.0);
// The intensity and not the colour, for `LightBuffer._pack`'s own reason:
// the same multiply here, and only one of them is a number nobody authored.
color.w *= LightListScale(slot);
#endif // F3D_NO_LIGHT_LIST
}
float type = position.w;
// **The rectangle leaves before `aim` is taken — `gfx-77n`.** For every other
// kind `direction.xyz` is a unit vector saying which way the light points;
// for this one it is an edge of the panel, with its length carrying half the
// width, and normalising it here would quietly throw the size away.
if (type > 2.5) {
vec3 halfWidth = direction.xyz;
vec3 halfHeight = cone.xyz;
vec3 toCentre = position.xyz - v_world_position;
vec3 corners[4];
corners[0] = toCentre - halfWidth - halfHeight;
corners[1] = toCentre + halfWidth - halfHeight;
corners[2] = toCentre + halfWidth + halfHeight;
corners[3] = toCentre - halfWidth + halfHeight;
// The cosine-weighted solid angle, which takes the place `n · l` holds for
// a punctual light: the loop multiplies the shading by `n_dot_l`, so
// putting the exact integral here makes the diffuse term exact rather than
// sampled. See [RectangleFormFactor].
float formFactor = RectangleFormFactor(corners, s.n);
// Radiance rather than intensity: `intensity` means the same thing for
// every kind of light, so a panel's is spread over its own area here.
// Enlarging a window at a fixed rating then dims it per square metre and
// leaves the room as bright, which is what the number is supposed to mean.
float area = length(cross(halfWidth, halfHeight)) * 4.0;
float radiance = area > 1e-9 ? 1.0 / area : 0.0;
// The range window only. A punctual light needs the inverse square as
// well; the form factor already contains it, because a panel twice as far
// away subtends a quarter of the sky.
float distance = length(toCentre);
if (direction.w > 0.0) {
float ratio = distance / direction.w;
float window = clamp(1.0 - ratio * ratio * ratio * ratio, 0.0, 1.0);
radiance *= window * window;
}
vec3 mirror = reflect(-s.v, s.n);
vec3 representative = RectangleClosestPoint(
position.xyz, halfWidth, halfHeight, v_world_position, mirror);
vec3 toPoint = representative - v_world_position;
float pointDistance = length(toPoint);
light.l = pointDistance > 1e-6 ? toPoint / pointDistance : s.n;
light.h = normalize(light.l + s.v);
light.n_dot_l = formFactor;
light.n_dot_h = max(dot(s.n, light.h), 0.0);
light.v_dot_h = max(dot(s.v, light.h), 0.0);
light.radiance = color.rgb * color.w * radiance;
return light;
}
vec3 aim = normalize(direction.xyz);
float attenuation = 1.0;
if (type < 0.5) {
// Directional: no position, no falloff. The direction to the light is the
// reverse of the direction it points.
light.l = -aim;
} else {
vec3 toLight = position.xyz - v_world_position;
float distance = length(toLight);
// A light exactly on the surface has no direction; treat it as contributing
// nothing rather than producing a NaN that spreads through the frame.
if (distance < 1e-6) {
light.l = s.n;
light.h = s.n;
light.radiance = vec3(0.0);
light.n_dot_l = 0.0;
light.n_dot_h = 0.0;
light.v_dot_h = 0.0;
return light;
}
light.l = toLight / distance;
attenuation = PunctualAttenuation(distance, direction.w);
if (type > 1.5) {
// Spot: a smooth ramp between the two cone cosines. The Dart side already
// guarantees the denominator is non-zero.
float cosAngle = dot(aim, -light.l);
attenuation *= clamp(
(cosAngle - cone.y) / (cone.x - cone.y), 0.0, 1.0);
}
}
light.h = normalize(light.l + s.v);
light.n_dot_l = max(dot(s.n, light.l), 0.0);
light.n_dot_h = max(dot(s.n, light.h), 0.0);
light.v_dot_h = max(dot(s.v, light.h), 0.0);
light.radiance = color.rgb * color.w * attenuation;
return light;
}
/// How much of light [index] reaches this fragment, defined by each fragment
/// shader.
///
/// A prototype rather than a call into shadow.glsl, because the models that
/// sample no shadow map must not declare its sampler — the compiler would drop
/// the slot and leave the engine binding one that is not there. A lit model
/// returns `ShadowFactor(...)`; an unlit one returns 1.
float LightVisibility(Surface s, LightSample light, int index);
/// A model's per-light term, defined by each fragment shader.
///
/// A prototype here and the definition in the model is what lets the loop below
/// be written once. The alternative — repeating the loop in every model — is
/// six copies of the same three lines, and the place a light would go missing.
vec3 ShadeLight(Surface s, LightSample light);
/// Sums every active light's contribution.
///
/// The loop bound is the compile-time maximum with a runtime break, because GLSL
/// wants a constant trip count and the hardware wants the early exit.
// **The point-shadow half of this header, behind a guard.**
//
// A model that never shadows must not *declare* any of this, and the reason is
// the one `unlit.frag` already gives about the shadow sampler — with one
// backend's failure added to the other's. On Impeller the compiler drops what
// nothing reads, and the engine binding a slot that is no longer there is a
// native crash. On WebGL2 nothing is dropped: an active uniform block with no
// buffer under it makes every draw `INVALID_OPERATION`, discarded with nothing
// logged.
//
// That is what `lighting-unlit` was on this backend. Unlit's own metadata says
// `usesPointShadow` is false, so the engine correctly bound no `PointShadow`
// block — and the translated shader declared one anyway, so the sphere was
// never drawn and the frame came back the clear colour.
#ifndef F3D_NO_POINT_SHADOW
/// The cube atlas: three tiles across, two down, each a ninety-degree view
/// from a point light, each storing radial distance normalised by range.
uniform sampler2D point_shadow_texture;
/// The same atlas for the things that never move, rendered once at load.
///
/// Two maps rather than one because a dungeon's walls can be baked and a
/// spinning pickup cannot, and there is no way to draw into part of a texture
/// without redrawing the rest of it. Sampling both and keeping the nearer
/// occluder costs one extra read and saves six views of the level every frame.
uniform sampler2D point_shadow_static_texture;
/// How many lights may have a row of the atlas. Six tiles across each.
// Rows of the cube atlas: six faces across, this many lights down. Must
// match `Renderer.kShadowedLights`, which is where the reasoning lives, and
// `shadowSlots` in the software backend's transcription of this file.
const int kShadowSlots = 6;
layout(std140) uniform PointShadow {
/// The same view-projections the atlas was rendered with, six per slot.
///
/// Passed rather than reconstructed. Deriving cube face coordinates here
/// would be a second implementation of a decision the renderer already made,
/// and the two would disagree about handedness or up vectors on some face
/// and nowhere else — which shows as one face of every shadow being wrong.
mat4 faces[6 * kShadowSlots];
/// Per slot. xyz: the light's world position. w: its range.
vec4 lights[kShadowSlots];
/// Per light, in the order the lighting knows them.
///
/// x: the atlas row it owns, or negative when it has none — a fifth torch in
/// a room lands there. z: the tangent of half the frustum's opening angle,
/// which is what converts a world width into a fraction of a tile. y and w
/// are unwritten.
///
/// **z is exactly one for a point light**, because a cube face is a ninety
/// degree frustum and `tan(45°) == 1`. That is not a convention chosen to be
/// tidy: it is what lets a narrower frustum share this whole path, since
/// multiplying by one in IEEE 754 changes no bit of the result. Whatever else
/// a spot light will need, it does not need a second copy of the filter.
vec4 slots[kMaxLights];
/// x: half a texel, in tile-local uv. y: distance bias in metres.
/// z: strength. w: normal offset, **in texels of the face it lands on**.
vec4 params;
/// x: smallest kernel radius in tile-local uv, and the fixed radius used
/// when contact hardening is off. y: the light's own radius in metres; zero
/// turns contact hardening off. z: largest kernel radius in tile-local uv.
/// w: non-zero paints the penumbra estimate into the surface buffer instead
/// of shading with it.
vec4 params2;
/// x: non-zero when this backend stores the atlas bottom-up. y: one over the
/// edge length of a tile in texels, which is what turns a distance into the
/// world width of one texel there.
///
/// **Appended after everything else on purpose**, the same way FragInfo's
/// ambient pair was: std140 lays a block out in declaration order, so adding
/// here leaves every offset above unchanged and the three backends do not
/// have to agree about anything they already agreed about. y, z and w are
/// unwritten.
vec4 params3;
}
point_shadow;
/// Eight points on a Poisson disk, a common set for filtering cascaded
/// shadows.
///
/// A disk rather than a grid because a grid of taps on a straight shadow edge
/// lands every sample on the same side at once, and the edge steps between
/// kernel widths instead of sliding. Eight rather than sixteen because every
/// tap here reads **two** atlases — the static walls and the movers — so the
/// cost is doubled before it is counted.
vec2 PointShadowDiskTap(int i) {
if (i == 0) return vec2(-0.94201624, -0.39906216);
if (i == 1) return vec2(0.94558609, -0.76890725);
if (i == 2) return vec2(-0.09418410, -0.92938870);
if (i == 3) return vec2(0.34495938, 0.29387760);
if (i == 4) return vec2(-0.91588581, 0.45771432);
if (i == 5) return vec2(-0.81544232, -0.87912464);
if (i == 6) return vec2(-0.38277543, 0.27676845);
return vec2(0.97484398, 0.75648379);
}
/// One comparison against the atlas, at [uv] offset within the tile.
///
/// The clamp is applied **after** the offset, not before, and that is the whole
/// reason a kernel can be widened here without touching anything else: each tap
/// is held inside its own tile individually. Clamping the centre and then
/// offsetting would let the outer taps walk straight out of the tile and read a
/// distance measured from a different face, or a different light.
float PointShadowDistance(vec2 uv, vec2 offset, vec2 tile, float range) {
float inset = point_shadow.params.x;
vec2 local = clamp(uv + offset, inset, 1.0 - inset);
vec2 atlas = (local + tile) * vec2(1.0 / 6.0, 1.0 / float(kShadowSlots));
// **The whole atlas, turned over, where row zero of a render target is at the
// bottom.** Both halves of the address are wrong there and this is the one
// place that fixes both: the tile the light owns — a light in slot zero is
// drawn into the row the shader would call three, because the viewport
// rectangle is flipped to land it — and the picture inside that tile, which
// was drawn through a projection built for the other origin.
//
// Every check of this atlas missed it for the same reason: the debug view
// composites the texture through a full-screen pass, which turns it over
// again and puts the row back. The atlas compared equal on both backends
// across six scenes while the lit pass, which samples it directly and has no
// such pass to cancel, read a row that had never been drawn into and found
// nothing in the way of anything.
if (point_shadow.params3.x > 0.5) atlas.y = 1.0 - atlas.y;
// Whichever is nearer occludes: a wall in front of a monster shadows, and so
// does a monster in front of a wall.
//
// **`textureLod` at level zero, because every caller of this function stands
// behind a branch.** The light loop skips a light the surface faces away
// from, the blocker search `continue`s past a tap that found nothing, and the
// slot test returns before any of it — so the invocations of a quad do not
// arrive here together, and a WGSL backend refuses a sample whose implicit
// derivative would be read where they disagree. Both atlases are distance
// render targets with one level, so level zero is the level `texture` was
// choosing anyway; this names it rather than deriving it, and the picture is
// the same on every backend.
return min(textureLod(point_shadow_texture, atlas, 0.0).r,
textureLod(point_shadow_static_texture, atlas, 0.0).r) * range;
}
float PointShadowTap(vec2 uv, vec2 offset, vec2 tile, float range,
float receiver) {
float stored = PointShadowDistance(uv, offset, tile, range);
// Nothing was drawn in that direction by either, so nothing is in the way.
if (stored >= range * 0.999) return 1.0;
return receiver > stored ? 0.0 : 1.0;
}
/// The disk point for tap [i], rotated by [ca]/[sa] and scaled to [radius].
vec2 PointShadowOffset(int i, float ca, float sa, float radius) {
vec2 p = PointShadowDiskTap(i);
return vec2(p.x * ca - p.y * sa, p.x * sa + p.y * ca) * radius;
}
/// How wide the penumbra should be here, in tile-local uv.
///
/// Contact hardening, and the reason a fixed kernel looks wrong: a shadow is
/// sharp where its caster touches the floor and soft a metre away, and one
/// radius for both makes the contact mushy or the distant edge hard.
///
/// The similar-triangles estimate is the standard one — a light of radius `L`
/// with a blocker at `b` and a receiver at `r` throws a penumbra `L * (r - b) /
/// b` wide at the receiver. Converting that to tile uv is exact rather than
/// tuned, because a face is a ninety degree frustum: at distance `r` from the
/// light the face spans `2 * r` in world units across the full `0..1` of uv,
/// so a world width `w` is `w / (2 * r)` of a tile.
///
/// The blocker search runs at the **widest** penumbra allowed, since a blocker
/// outside that circle cannot widen the result anyway, and searching narrower
/// would miss the very blockers that make an edge soft.
///
/// [tanHalf] is where the ninety degrees stop being assumed. The span above is
/// `2 * r` only for a right-angled frustum; in general it is `2 * r * tan(θ/2)`,
/// and for a cube face that factor is one. A narrower frustum covers less world
/// per tile, so the same world width is a *larger* fraction of it — which is
/// why this divides rather than multiplies, and why getting it upside down
/// would make a tight cone's shadows harden instead of soften.
float PointShadowPenumbra(vec2 uv, vec2 tile, float range, float receiver,
float ca, float sa, float tanHalf,
out float blockerOut) {
blockerOut = -1.0;
float lightRadius = point_shadow.params2.y;
float minRadius = point_shadow.params2.x;
float maxRadius = point_shadow.params2.z;
if (lightRadius <= 0.0) {
// **The debug channel is filled even though the search is skipped**, and
// leaving it unfilled cost a session. `blockerOut` starts at −1 to mean
// "nothing was measured"; the debug encoding clamps it into a colour, where
// −1 becomes zero — the same green as a blocker touching the surface, which
// reads as the most alarming answer available. A whole theory was built on
// that zero, and the search it described had never run.
//
// The centre tap is what the filter below would use anyway, so this reports
// a distance the atlas really returned rather than a sentinel.
blockerOut = PointShadowDistance(uv, vec2(0.0), tile, range);
return minRadius;
}
float sum = 0.0;
float count = 0.0;
for (int i = 0; i < 8; i++) {
float stored =
PointShadowDistance(uv, PointShadowOffset(i, ca, sa, maxRadius), tile,
range);
if (stored >= range * 0.999) continue;
if (stored >= receiver) continue;
sum += stored;
count += 1.0;
}
// Nothing in front of this fragment anywhere in the search: fully lit, and
// the caller can skip the filter entirely.
if (count < 0.5) return -1.0;
float blocker = max(sum / count, 1e-4);
blockerOut = blocker;
float world = lightRadius * max(receiver - blocker, 0.0) / blocker;
return clamp(world / (2.0 * receiver * tanHalf), minRadius, maxRadius);
}
/// How lit [world] is by the point light that owns the cube atlas.
///
/// One, fully lit, when this is not that light or the atlas has nothing to say.
float PointShadowFactor(vec3 world, vec3 normal, int lightIndex) {
int slot = int(point_shadow.slots[lightIndex].x + 0.5);
if (point_shadow.slots[lightIndex].x < 0.0) return 1.0;
float strength = point_shadow.params.z;
if (strength <= 0.0) return 1.0;
// Offset along the normal before measuring, and scaled by how steeply the
// surface leans away from the light.
//
// A soft kernel on a tilted surface straddles a depth gradient: the taps at
// one end of the disk are further from the light than the fragment itself,
// so a flat offset that clears the surface head-on leaves acne at a grazing
// angle. The slope term lifts the whole kernel clear instead, and is capped
// because it runs away as the surface turns edge-on to the light — an
// uncapped lift detaches the shadow from its caster.
vec3 toLight = point_shadow.lights[slot].xyz - world;
float toLightLength = max(length(toLight), 1e-6);
float nDotL = max(dot(normal, toLight / toLightLength), 0.15);
float slope = min(sqrt(max(1.0 - nDotL * nDotL, 0.0)) / (nDotL * nDotL), 8.0);
// **How wide one texel of the face is, out where this fragment is.** The
// error a normal offset exists to clear is exactly that: a texel of the
// shadow map covers a patch of surface, the whole patch is recorded at one
// distance, and a fragment anywhere else in it compares against a distance
// measured somewhere it is not. That patch grows with range — it is a solid
// angle, not a length — so an offset fixed in metres is right at one distance
// and wrong everywhere else.
//
// What it was: `params.w` metres, flat. On the golden teapot, at 9.6 m from
// the lamp, a texel is 3.7 cm and the flat offset was 2 cm, so the floor
// shadowed itself across everything the light reached — and the acne stopped
// dead at the *projection of the floor's own edge*, because past it the atlas
// holds nothing and nothing can occlude. A straight line across a shadow with
// no straight edge anywhere in the scene.
float texel =
2.0 * toLightLength * max(point_shadow.slots[lightIndex].z, 1e-4) *
point_shadow.params3.y;
// Both terms are metres. The slope term used to be the kernel radius, which
// is a fraction of a tile — a unit mismatch carried over from an estimate
// where a softness radius genuinely was the right quantity. Here it meant
// widening the kernel also lifted the sample off the surface, by up to ten
// centimetres at the wider settings, so the softening and the lift
// cancelled: tripling the kernel moved 184 pixels of the frame,
// where the kernel alone moves thousands. It is what made contact hardening
// look inert, and it was hiding in a comparison rather than in the estimate.
vec3 origin = world + normal * texel * point_shadow.params.w * (1.0 + slope);
vec3 toFragment = origin - point_shadow.lights[slot].xyz;
float distance = length(toFragment);
float range = max(point_shadow.lights[slot].w, 1e-4);
if (distance >= range) return 1.0;
// The dominant axis picks the face, in the order the renderer wrote them:
// +X, -X, +Y, -Y, +Z, -Z, left to right then top to bottom.
//
// A spot has one column and no choice to make. Asking the dominant axis
// anyway would be worse than pointless: a fragment below and to the side of
// a downlight has −Y dominant, which is column 3, and column 3 of a spot's
// row is deliberately blank — so the whole cone would read as unshadowed
// except for the wedge where the aim happens to be the dominant axis.
int face = 0;
if (point_shadow.slots[lightIndex].y < 0.5) {
vec3 a = abs(toFragment);
if (a.x >= a.y && a.x >= a.z) {
face = toFragment.x > 0.0 ? 0 : 1;
} else if (a.y >= a.z) {
face = toFragment.y > 0.0 ? 2 : 3;
} else {
face = toFragment.z > 0.0 ? 4 : 5;
}
}
vec4 clip = point_shadow.faces[slot * 6 + face] * vec4(origin, 1.0);
if (clip.w <= 0.0) return 1.0;
vec2 ndc = clip.xy / clip.w;
if (abs(ndc.x) > 1.0 || abs(ndc.y) > 1.0) return 1.0;
// v is flipped, the same way the directional map does it: the texture's
// origin is at the top, where row zero of the render target is. Getting this
// wrong does not tilt the shadow — it makes the top row of faces read the
// bottom row, so a whole region compares against an unrelated distance and
// comes out as a black slab.
vec2 uv = vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
// The face across, the light down: six tiles wide, four tall.
vec2 tile = vec2(float(face), float(slot));
float receiver = distance - point_shadow.params.y;
// One rotation, shared by the blocker search and the filter. Per fragment,
// so eight samples read as a soft edge rather than as eight copies of the
// silhouette: without it every fragment along an edge tests the same eight
// directions and the pattern shows.
//
// **The three constants are not arbitrary and are not ours.** This is Jorge
// Jimenez's interleaved gradient noise, from "Next Generation Post
// Processing in Call of Duty: Advanced Warfare" (SIGGRAPH 2014):
//
// IGN(x, y) = frac(52.9829189 * frac(0.06711056 * x + 0.00583715 * y))
//
// The pair inside the dot is a direction whose gradient walks the unit
// interval as slowly as it can while never repeating over a screen, and the
// multiplier outside stretches that walk so neighbouring pixels land far
// apart in the result. What it buys over a hash is the cost: one dot and two
// fracts, no integer arithmetic, no texture. What a blue-noise texture buys
// over it is a better spectrum, at a sampler and a fetch — worth it for
// dithering a whole frame, not for rotating eight taps.
//
// Written down because three unexplained decimals read as a magic spell, and
// the next person to touch this line has no way to tell which of them may be
// changed. The answer is none of them.
float noise = fract(52.9829189 * fract(dot(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 < kTotalLights; i++) {
if (i >= count) break;
LightSample light = SampleLight(i, s);
if (light.n_dot_l <= 0.0) continue;
// A light from the list has no shadow row to read — see `LightHasShadow`.
// A branch rather than something folded into the two calls, because both
// index tables eight entries wide and the ninth light would read past them
// rather than read a one.
float visibility = LightHasShadow(i)
? LightVisibility(s, light, i) *
PointShadowFactor(v_world_position, s.n, i)
: 1.0;
if (visibility <= 0.0) continue;
total += ShadeLight(s, light) * light.radiance * light.n_dot_l * visibility;
}
return total;
}
#endif // SURFACE_GLSL_
/// Tangent-space normal map. Neutral is (0.5, 0.5, 1.0).
uniform sampler2D normal_texture;
/// glTF's ORM packing: g is roughness, b is metallic. Neutral is white.
uniform sampler2D metallic_roughness_texture;
/// Ambient occlusion in r. Neutral is white.
uniform sampler2D occlusion_texture;
/// Emitted colour, multiplied by the emissive factor. Neutral is white, and the
/// factor defaults to black, so a material with neither emits nothing.
uniform sampler2D emissive_texture;
/// The level's baked lightmap, RGBM: colour over a shared multiplier, decoded
/// as `rgb × a × 8`. Sampled at the second coordinate, which every vertex
/// stage but the lightmapped one leaves at the atlas corner; neutral is
/// black, so a material without a map adds nothing.
uniform sampler2D lightmap_texture;
/// The irradiance the lightmap holds at this fragment, in the units a light's
/// `colour × intensity × attenuation × cos` arrives in.
vec3 SampleLightmap() {
vec4 texel = texture(lightmap_texture, v_lightmap_uv);
return texel.rgb * texel.a * 8.0;
}
/// One function per map, rather than one that applies all four.
///
/// Not a style choice. The compiler drops a sampler whose result never reaches
/// the output, so a model that samples the ORM map and then ignores metallic and
/// roughness — Lambert does exactly that — ends up with no
/// `metallic_roughness_texture` in its compiled signature at all, while the Dart
/// side still thinks there is one to bind. That is the phantom-binding trap
/// again, and binding a slot Metal does not have is a native crash.
///
/// Splitting them means a model calls only what it genuinely uses, so the
/// compiled signature matches the source, and `LightingModel` can declare the
/// same set truthfully. `tool/build_shaders.sh` prints the compiled slots so
/// the two cannot drift apart unnoticed.
/// glTF's ORM packing: roughness in g, metallic in b, both multiplying the
/// material factors.
void ApplyMetallicRoughnessMap(inout Surface s) {
vec3 orm = texture(metallic_roughness_texture, 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) {
// **Sampled before the frame is tested, and that order is load-bearing.**
// The test below is a branch on interpolated data, so the four invocations of
// a quad can take different sides of it; a WGSL backend then refuses a
// `texture` call underneath, because the mip level it derives is only defined
// where the whole quad agrees. Unlike the shadow atlases, this map really is
// mipped — a normal map read at full resolution on a surface turned away from
// the camera is the aliasing that made this the widest disagreement between
// backends — so pinning a level here would be a picture change, and hoisting
// the sample is the cure that is not. A degenerate tangent is rare enough
// that paying for its unused texel is nothing, and the texel it reads is the
// same one the branch would have read.
vec4 sampledTexel = texture(normal_texture, v_texcoord);
// 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 = sampledTexel.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;
/// Five points on a disc: the centre and four at the diagonals.
///
/// **Diagonals rather than the axes.** A cross of four axis-aligned taps
/// leaves a shadow whose edge is smooth along x and y and hard at forty-five
/// degrees, which is the angle most edges in a built scene actually run at.
/// Turned by an eighth of a turn, the four taps straddle a vertical or
/// horizontal edge evenly and the artefact has nowhere to line up.
const vec2 kShadowDisc[5] = vec2[5](vec2(0.0, 0.0),
vec2(0.7071, 0.7071),
vec2(-0.7071, 0.7071),
vec2(0.7071, -0.7071),
vec2(-0.7071, -0.7071));
/// 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);
// **`textureLod` and not `texture`, and the level asked for is the only one
// there is.** Everything above this loop is a reason not to be here — the
// cascade search returns early when no cascade contains the fragment, and the
// light loop that calls it skips a light facing away — so a WGSL backend sees
// a sample taken where the four invocations of a quad need not agree, and
// refuses it: the implicit derivative `texture` asks for is only defined
// where they all arrive. The cascade atlas is a depth render target with a
// single level, so the derivative was never doing anything but selecting
// level zero, and naming that level directly costs nothing and changes no
// pixel on any backend.
//
// **The softness, where it rides, and what zero means.**
//
// `ambient_ground.w` is the directional light's apparent size. It has
// nothing to do with ambient light and everything to do with this being the
// one component left unspent in a block six shaders share: `frame_params.w`
// was the slot reserved for exactly this and the environment's level count
// took it, and appending to the block moves offsets four backends have
// agreed on. The alternative was a second uniform block bound per draw for
// one float. Named here because a reader arriving at `ambient_ground` has
// every right to be surprised.
//
// Zero is the 3×3 kernel this has always had, which is what keeps every
// recorded golden where it is. Above zero the edge widens with the distance
// between the occluder and what it falls on — what a real light does, and
// what no fixed kernel can.
float softness = frag_info.ambient_ground.w;
float lit = 0.0;
if (softness <= 0.0) {
// PCF 3x3. Four samples would band visibly at this map size and nine is
// the smallest kernel that reads as a soft edge rather than as stair
// steps.
for (int y = -1; y <= 1; y++) {
for (int x = -1; x <= 1; x++) {
float occluder = textureLod(
shadow_texture, uv + vec2(float(x), float(y)) * texel, 0.0).r;
lit += projected.z - bias > occluder ? 0.0 : 1.0;
}
}
lit *= 1.0 / 9.0;
} else {
// **Find what is casting before deciding how wide to blur.** The five
// taps go out at a fixed search radius first and average the depths of
// whatever they find in front of this fragment; that average is the
// occluder's distance, and the penumbra is proportional to it. A kernel
// sized without this step is the fixed one again with a bigger number.
// Bounded, and not proportional to the softness: the search only has to
// reach far enough to find *a* blocker, and a radius that grew without
// limit would start finding occluders from the other side of the scene
// and report a gap that belongs to them.
float searchRadius = clamp(softness * 0.25, 2.0, 16.0);
float blockerSum = 0.0;
float blockerCount = 0.0;
for (int i = 0; i < 5; i++) {
float occluder = textureLod(
shadow_texture, uv + kShadowDisc[i] * texel * searchRadius, 0.0).r;
if (projected.z - bias > occluder) {
blockerSum += occluder;
blockerCount += 1.0;
}
}
// Nothing between this fragment and the light: lit, and no second loop.
if (blockerCount <= 0.0) return 1.0;
// Linear depth over the cascade's own volume, so the gap between the
// occluder and the receiver *is* the distance — no perspective divide,
// which is what an orthographic light means.
float gap = max(projected.z - blockerSum / blockerCount, 0.0);
// One texel at the tightest, so a contact edge stays an edge; the cap
// keeps a distant occluder from reaching across a whole cascade.
float radius = clamp(gap * softness, 1.0, 16.0);
for (int i = 0; i < 5; i++) {
float occluder = textureLod(
shadow_texture, uv + kShadowDisc[i] * texel * radius, 0.0).r;
lit += projected.z - bias > occluder ? 0.0 : 1.0;
}
lit *= 1.0 / 5.0;
}
// Strength lerps towards fully lit, so the control is "how dark", not "how
// much of the kernel".
return mix(1.0, lit, clamp(strength, 0.0, 1.0));
}
#endif // SHADOW_GLSL_
float LightVisibility(Surface s, LightSample light, int index) {
return ShadowFactor(s, light, index);
}
vec3 ShadeLight(Surface s, LightSample light) {
// Fewer bands as roughness rises, so the slider still does something here.
float bands = mix(5.0, 2.0, s.roughness);
// smoothstep on the band edge keeps the step from aliasing into jagged
// terminator lines.
float quantized = floor(light.n_dot_l * bands) / bands;
float fraction = fract(light.n_dot_l * bands);
quantized += smoothstep(0.85, 1.0, fraction) / bands;
// AccumulateLights multiplies by N.L, which is exactly what banding is meant
// to replace, so divide it back out and keep the quantized ramp instead.
float ramp = quantized / max(light.n_dot_l, 1e-3);
return s.albedo * ramp;
}
void main() {
Surface s = ReadSurface();
ApplyCommonMaps(s);
// Roughness sets the band count, so the ORM map matters here too.
ApplyMetallicRoughnessMap(s);
// The rim is a property of the view, not of any one light, so it belongs
// outside the loop — adding it per light would make it brighten with the
// number of lamps in the scene.
float rim = pow(1.0 - s.n_dot_v, 3.0) * frag_info.material.w;
vec3 ambient = s.albedo * (s.ambient + SampleLightmap()) * s.occlusion;
WriteSurface(
AccumulateLights(s) * s.occlusion + ambient + vec3(rim * 0.35) +
s.emissive,
s.alpha,
s.roughness);
}
''',
'Normals': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;
// Debug view: world-space normal mapped into RGB.
//
// The fastest way to tell a geometry bug from a lighting bug. Hard edges show as
// flat colour blocks, smooth ones as gradients, and inverted winding shows as
// the complement of the expected colour.
//
// Includes lib/color.glsl rather than lib/surface.glsl on purpose: this shader
// reads no material inputs, and merely DECLARING the FragInfo block would leave
// it visible to reflection while the compiled shader binds no buffer for it.
// Binding that phantom block segfaults inside Metal's
// setFragmentBuffer:offset:atIndex:. LightingModel.usesFragInfo encodes the same
// fact on the Dart side, because reflection alone cannot be trusted here.
// --- lib/color.glsl ---
// Colour space helpers and the fragment output interface.
//
// Split out of surface.glsl so a shader that needs no material inputs — the
// normals debug view — can avoid DECLARING the FragInfo uniform block at all.
// That matters more than it looks: reflection metadata reports a block as
// present merely because it was declared, even when the compiled shader binds
// no such buffer, so a declared-but-unused block is indistinguishable from a
// used one until Metal crashes on the bind.
#ifndef COLOR_GLSL_
#define COLOR_GLSL_
precision highp float;
const float kPi = 3.14159265359;
// One varying set shared by every fragment shader, matching mesh.vert.
//
// All five are declared here, including the two the debug models never read: a
// fragment shader whose `in` block disagrees with the vertex shader's `out`
// block fails to link, and there is no partial-match rule to lean on.
in vec3 v_world_position;
in vec3 v_normal;
in vec2 v_texcoord;
in vec4 v_tangent;
in vec4 v_color;
/// Where this fragment is in the level's lightmap. Zero from every vertex
/// stage but `mesh_lightmapped.vert`, and read only by the lit models, which
/// sample a one-texel black there when a material has no map.
in vec2 v_lightmap_uv;
layout(location = 0) out vec4 frag_color;
// The second attachment: what a screen-space effect needs to know about the
// surface it is looking at. World-space normal in rgb, and in a the depth along
// the view axis in world metres — not a window depth; `WriteSurfaceGeometry`
// says at length why not.
//
// Depth travels here rather than in a depth texture because flutter_gpu cannot
// sample one — the same reason the shadow pass writes its depth into a colour
// target. See ARCHITECTURE.md §2.
//
// Guarded, because not every stage that includes this header draws into a
// two-attachment target. The shadow pass draws into one, and a pipeline
// declaring an output its target has no slot for is a mismatch worth avoiding
// rather than discovering.
#ifndef F3D_NO_SURFACE_BUFFER
layout(location = 1) out vec4 frag_surface;
#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;
// **A stage that needs none of this must be able to declare none of it.** On
// Vulkan both stages' descriptors are merged into one set layout, and two
// bindings with the same number in it is not a layout the specification
// allows. A driver may accept it anyway; a Galaxy A55's refuses the pipeline
// with `ErrorUnknown` and no other word, which is how the shadow pass came to
// build everywhere except there — its only uniform block was this one, and it
// landed on the same binding as the vertex stage's first.
#ifndef F3D_NO_FOG
/// Distance fog, in its own block rather than folded into FragInfo.
///
/// Its own because color.glsl is included before FragInfo is declared, and
/// because appending to a block that half a dozen shaders already share is a
/// way to move offsets nobody expected to move. Three vec4s is a cheap price
/// for not touching any of that.
layout(std140) uniform FogInfo {
/// rgb: linear fog colour. w: density per metre, zero for no fog.
vec4 fog;
/// xyz: camera position in world space. Duplicated from FragInfo so this
/// block stands alone; a vec3 is cheaper than a coupling.
vec4 eye;
/// xyz: the direction the camera looks, as a unit vector in world space.
/// w unused.
///
/// Here rather than in a block of its own because it answers the same
/// question [eye] does — where the camera is and which way it faces — and
/// this is the block `color.glsl` can see.
vec4 forward;
}
fog_info;
/// How far this fragment is from the eye, in world metres.
///
/// What the fog fades by. Distance rather than depth, because fog is a
/// property of the air between two points and does not care which way the
/// camera happens to face.
float EyeDistance() { return distance(v_world_position, fog_info.eye.xyz); }
/// How far this fragment is *along the view axis*, in world metres.
///
/// What the surface buffer's alpha holds. Depth rather than distance, and the
/// difference only shows on an orthographic camera — where the rays through
/// the pixels are parallel instead of meeting at the eye, so a distance from
/// the eye names a sphere that the pixel's ray crosses somewhere the reader
/// cannot solve for. A depth along the axis names a plane, which every ray
/// crosses exactly once. See `WorldAtDepth` in `post/ssao.frag` for the
/// reconstruction both projections share.
float ViewDepth() {
return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz);
}
#else // F3D_NO_FOG
// The same two questions, answered without the block: a stage that declares no
// fog has no eye position to measure from either. Stubs rather than a guard at
// every call site, so that what includes this file reads the same whichever
// way it was compiled.
float EyeDistance() { return 0.0; }
float ViewDepth() { return 0.0; }
#endif // F3D_NO_FOG
/// Records the geometry of this fragment for whatever runs after the scene.
///
/// Called from the same place that writes colour, so a surface cannot be lit
/// into the frame without also describing itself — which is the failure that
/// leaves a screen-space effect reflecting whatever was in the buffer before.
///
/// rg: octahedral normal. b: perceptual roughness. a: **depth along the view
/// axis, in world metres** — see [ViewDepth].
///
/// **Not `gl_FragCoord.z`, and that is a defect this channel carried until it
/// was looked at.** Window depth crowds every distant surface into the top of
/// its range — with a near plane of a tenth of a metre, everything past twenty
/// metres lives in the last half a hundredth of `[0, 1]` — and this attachment
/// is a half float, whose steps up there are about five ten-thousandths. So two
/// surfaces half a metre apart at twenty metres stored the *same* number, and
/// every screen-space pass that compares against this channel decided whole
/// bands of pixels by rounding. The occlusion pass drew them: vertical stripes
/// along the lines of constant depth on any wall receding from the camera, on
/// both GPU backends. The software rasteriser kept the channel at full
/// precision and drew the effect correctly, so it was the one that looked
/// wrong against the other two.
///
/// A depth in metres has none of that: the exponent carries the range and the
/// mantissa carries the same relative precision everywhere, which at twenty
/// metres is a centimetre. Both numbers are measured in
/// `flutter3d/test/surface_depth_test.dart`.
///
/// Zero still means nothing was drawn. The attachment is cleared to zero and
/// nothing is drawn in front of the near plane.
void WriteSurfaceGeometry(float roughness) {
#ifndef F3D_NO_SURFACE_BUFFER
// A debug pass takes the buffer over rather than getting one of its own.
// The surface buffer already has an attachment, a viewer and a golden; a
// second one would need all three built before it could answer anything.
if (g_debug_surface_on) {
frag_surface = vec4(g_debug_surface, ViewDepth());
return;
}
frag_surface = vec4(EncodeOctahedral(normalize(v_normal)),
clamp(roughness, 0.0, 1.0), ViewDepth());
#endif
}
/// Fades [color] toward the fog with distance from the eye.
///
/// Exponential rather than linear, because linear fog has a visible plane
/// where it starts and a dungeon corridor is exactly where that shows.
vec3 ApplyFog(vec3 color) {
#ifdef F3D_NO_FOG
return color;
#else
float density = fog_info.fog.w;
if (density <= 0.0) return color;
float d = EyeDistance();
return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0));
#endif
}
/// 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: halation for this level of the chain, 0 for none.
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);
result *= (1.0 / 16.0);
// **Halation: the wide part of the glow goes red — `gfx-30n`.** On film the
// halo around a highlight is warm, because light that made it through the
// emulsion scatters off the backing and comes back, and the red layer sits
// deepest so it catches the most of it. The same asymmetry is what stops a
// digital bloom reading as a grey smear.
//
// Applied here rather than in the composite because here is where the
// *levels* are: the caller hands each level its own amount, so the tight
// core stays neutral and only the broad skirt warms. The composite sees one
// glow and could not tell them apart.
//
// Zero is an exact identity — the multiplier is one on every channel — and
// that is what keeps every recorded frame where it is.
float halation = bloom_info.params.w;
if (halation > 0.0) {
result *= vec3(1.0 + halation * 0.5, 1.0, 1.0 - halation * 0.35);
}
frag_color = vec4(result, 1.0);
}
''',
'Composite': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;
// The last pass: add the bloom, tone map, encode to sRGB.
//
// Tone mapping lives here rather than in each lighting model, which is the
// point of having an HDR target at all. Applying it per model meant every
// shader wrote display-referred colour into an 8-bit buffer, so anything above
// display white was gone before post-processing could see it — and bloom is
// entirely a function of what is above display white.
precision highp float;
in vec2 v_uv;
layout(location = 0) out vec4 frag_color;
/// The scene, linear and unbounded.
uniform sampler2D scene_texture;
/// The bloom chain's top level, or a black texture when bloom is off.
uniform sampler2D bloom_texture;
/// Ambient occlusion at half resolution, or a white texture when it is off.
///
/// White rather than absent, because a sampler a shader declares and nobody
/// binds is a native crash on Metal rather than a black texture — the same rule
/// that kept the sky's cube map out of `sky.frag`. One white texel costs
/// nothing and removes the branch.
uniform sampler2D ao_texture;
/// The contact shadow's own factor — `gfx-76n`. Bound on every frame with a
/// white stand-in when the pass did not run, the same rule `ao_texture` above
/// follows and for the same reason: a declared sampler nobody binds is a native
/// crash on Metal rather than a black texture.
uniform sampler2D contact_shadow_texture;
/// The colour table, as a strip: N slices of N×N laid out left to right, so
/// the image is N² wide and N tall. Bound to whatever the engine has when no
/// table is set — the strength is zero then and nothing samples it, but a
/// sampler this shader declares and nobody binds is a native crash on Metal
/// rather than a black texture, which is the same rule `ao_texture` above
/// already follows.
uniform sampler2D lut_texture;
layout(std140) uniform CompositeInfo {
/// x: exposure, y: bloom intensity, z: which tone curve, w: how much of the
/// occlusion to apply, 0 for none.
///
/// **z is a curve number, not a flag, and 1 is still the old flag's
/// meaning.** 0 leaves the colour alone, 1 is Khronos PBR Neutral — what
/// every golden in this repository was recorded with — 2 is ACES, 3 is AgX
/// and 4 is Reinhard. Numbering the default 1 is what lets a `> 0.5` read
/// of the old flag and an `int()` read of the new number agree about every
/// scene already recorded.
vec4 params;
/// x, y: one texel of the ao texture. z: how much of the colour table to
/// apply, 0 for none. w: how many slices the table has, its N.
vec4 ao_texel;
/// The look, half of it. x: contrast, y: saturation, z: temperature,
/// w: chromatic aberration.
///
/// **Neutral is (1, 1, 0, 0) and has to stay exactly that.** Every golden in
/// the repository composites with this block; a default that only nearly
/// cancels moves thirty reference images by a bit each.
vec4 look;
/// The look, the rest. x: vignette, y: vignette roundness, z: grain,
/// w: the target's aspect, width over height.
vec4 look_more;
/// x: dither amount, in display units — 1/255 is one 8-bit step, and 0 is
/// off exactly. y: white balance, warm above zero. z: tint, green against
/// magenta. w: unclaimed.
///
/// **A fifth block rather than a spare component of a fourth**, because the
/// other four are full and because a number that means "one output step"
/// does not belong beside three that mean "a look". Neutral is
/// (0, 0, 0, 0) and must stay exactly that: every golden in the repository
/// goes through this block.
vec4 output_encode;
/// Lift, in xyz — what is added, so it moves the shadows and leaves white
/// where it was. w: unclaimed. Neutral is (0, 0, 0, 0).
vec4 lift;
/// Gamma, in xyz — the exponent, so it moves the midtones and leaves both
/// ends. w: unclaimed. Neutral is (1, 1, 1, 0).
vec4 gamma;
/// Gain, in xyz — what is multiplied, so it moves the highlights and leaves
/// black where it was. w: unclaimed. Neutral is (1, 1, 1, 0).
vec4 gain;
/// x: how much of the contact shadow reaches the picture, nought to one —
/// `gfx-76n`. y, z, w unclaimed.
///
/// Appended after everything else, the way this block has grown before: a
/// std140 block is laid out in declaration order, so adding here leaves every
/// offset above unchanged and the four backends do not have to agree about
/// anything they had not already agreed on.
vec4 contact;
}
composite_info;
/// Rec. 709 luma, which is what the sRGB primaries weight to.
float Luma(vec3 color) { return dot(color, vec3(0.2126, 0.7152, 0.0722)); }
/// A value in [0, 1) from a screen position, with no state and no frame count.
///
/// Static by construction: a shader that read a frame counter would produce a
/// different golden on every run, so the grain is fixed to the pixel. See
/// `LookSettings.grain`, which says the same thing from the other side.
float Hash(vec2 at) {
return fract(sin(dot(at, vec2(12.9898, 78.233))) * 43758.5453);
}
/// One cell of a 4x4 Bayer matrix, as a value in [-0.5, 0.5).
///
/// **Ordered rather than random, and that is the whole choice.** The grain
/// above already uses a hash, and a hash here would work — but blue-ish noise
/// on a flat gradient reads as noise, where an ordered matrix reads as a
/// gradient. The pattern repeats every four pixels and is fixed to screen
/// position, so it is as golden-stable as the grain is and for the same
/// reason: nothing here is a function of time.
///
/// The matrix is the standard recursive one, written out because computing it
/// costs more than reading it.
float BayerCell(vec2 at) {
int x = int(mod(at.x, 4.0));
int y = int(mod(at.y, 4.0));
int index = y * 4 + x;
// 0, 8, 2, 10 / 12, 4, 14, 6 / 3, 11, 1, 9 / 15, 7, 13, 5
float value = 0.0;
if (index == 0) value = 0.0;
else if (index == 1) value = 8.0;
else if (index == 2) value = 2.0;
else if (index == 3) value = 10.0;
else if (index == 4) value = 12.0;
else if (index == 5) value = 4.0;
else if (index == 6) value = 14.0;
else if (index == 7) value = 6.0;
else if (index == 8) value = 3.0;
else if (index == 9) value = 11.0;
else if (index == 10) value = 1.0;
else if (index == 11) value = 9.0;
else if (index == 12) value = 15.0;
else if (index == 13) value = 7.0;
else if (index == 14) value = 13.0;
else value = 5.0;
return value / 16.0 - 0.5;
}
vec3 LinearToSrgb(vec3 linear) {
return mix(
linear * 12.92,
1.055 * pow(max(linear, vec3(0.0)), vec3(1.0 / 2.4)) - vec3(0.055),
step(vec3(0.0031308), linear));
}
/// Khronos PBR Neutral tone mapper.
///
/// The mapper the glTF ecosystem settled on, which matters because the renderer
/// targets glTF materials — the same asset should not look different here than
/// in a reference viewer. It leaves everything below the compression threshold
/// untouched, so midtones keep their values and only highlights roll off. That
/// is the property a filmic curve like ACES lacks: ACES would darken the whole
/// image to tame one highlight.
vec3 TonemapNeutral(vec3 color) {
const float kStartCompression = 0.8 - 0.04;
const float kDesaturation = 0.15;
float minChannel = min(color.r, min(color.g, color.b));
float offset =
minChannel < 0.08 ? minChannel - 6.25 * minChannel * minChannel : 0.04;
color -= offset;
float peak = max(color.r, max(color.g, color.b));
if (peak < kStartCompression) return color;
const float d = 1.0 - kStartCompression;
float newPeak = 1.0 - d * d / (peak + d - kStartCompression);
color *= newPeak / peak;
float desaturate = 1.0 - 1.0 / (kDesaturation * (peak - newPeak) + 1.0);
return mix(color, vec3(newPeak), desaturate);
}
/// ACES, the Narkowicz fit.
///
/// **It lifts the midtones, and that is why it is offered rather than
/// imposed.** Measured rather than recited: 18% grey comes out at 0.267 where
/// the neutral curve leaves it at 0.140, because this fit carries roughly a
/// stop of exposure inside it — the RRT and ODT it approximates were never
/// meant to be fed display-referred values. The result is the brighter,
/// contrastier image people recognise from film, and it is also exactly why
/// it is wrong as a default here: a glTF asset is authored against a
/// reference viewer, and a curve that moves the midtones moves the asset away
/// from how its author saw it.
///
/// The three-term rational fit rather than the full RRT/ODT: the matrices in
/// front and behind cost more than the curve is worth at this end of the
/// pipeline. Its own ceiling arrives early — everything above about 7.2 comes
/// out at exactly one, so two different highlights an artist can tell apart
/// become one flat patch. [TonemapAgx] is the answer to that.
vec3 TonemapAces(vec3 color) {
const float a = 2.51;
const float b = 0.03;
const float c = 2.43;
const float d = 0.59;
const float e = 0.14;
return clamp((color * (a * color + b)) / (color * (c * color + d) + e),
vec3(0.0), vec3(1.0));
}
/// AgX, as a curve without the rotation matrices.
///
/// **What it is for: bright saturated light that does not turn into a flat
/// disc of colour.** A coloured lamp four stops over white comes out of ACES
/// with its channels 0.36 apart and out of this with 0.22 — the highlight
/// walks towards white rather than towards its own primary. And it keeps
/// separating values long after ACES has stopped: at 8 and at 40 ACES returns
/// one and one, where this returns 0.971 and 0.999, so the inside of a bright
/// patch still has shape in it.
///
/// **It is a much more exposed curve than the other three**, which is a
/// decision to make with open eyes rather than a side effect: 18% grey lands
/// at 0.50 here against 0.14 through the neutral curve, because AgX is built
/// to put middle grey at middle display and the log encoding below does
/// exactly that. A scene switched to this without re-lighting looks washed
/// out, and correctly so.
///
/// A log-encoded sigmoid on each channel, then a pull towards the luminance
/// by how far each channel climbed. The full transform rotates into and out
/// of a wider gamut first; that rotation is what keeps deep blues from going
/// purple, and it needs two matrices this pass has nowhere to keep. Named as
/// missing rather than implied: this is AgX's curve, not AgX.
vec3 TonemapAgx(vec3 color) {
const float kMinEv = -12.47393;
const float kMaxEv = 4.026069;
vec3 v = clamp(log2(max(color, vec3(1e-10))), vec3(kMinEv), vec3(kMaxEv));
v = (v - vec3(kMinEv)) / (kMaxEv - kMinEv);
// A sixth-order fit of AgX's own sigmoid, which is the part that does the
// work: gentle through the middle, long shoulders at both ends.
vec3 v2 = v * v;
vec3 v4 = v2 * v2;
v = 15.5 * v4 * v2 - 40.14 * v4 * v + 31.96 * v4 - 6.868 * v2 * v +
0.4298 * v2 + 0.1191 * v - 0.00232;
v = clamp(v, vec3(0.0), vec3(1.0));
// The desaturation AgX is known for, applied where the curve lifted the
// most. Without it the sigmoid alone leaves highlights as saturated as ACES
// does and the point of the curve is lost.
float luma = Luma(v);
return mix(vec3(luma), v, 0.84);
}
/// AgX with the rotation this pass used to have nowhere to keep — `gfx-26n`.
///
/// **What the two matrices buy, and it is one specific thing.** [TonemapAgx]
/// compresses each channel on its own, so a channel that clips takes its hue
/// with it: a deep blue four stops over white loses blue last and arrives at
/// the display having drifted through purple, because red and green were
/// driven up towards it while blue was already at the ceiling. The inset
/// matrix mixes a little of each channel into the others *before* the curve,
/// which means no channel is ever compressed alone, and the outset matrix —
/// its inverse — takes the mixing back out afterwards. The hue that comes out
/// is the hue that went in. That is the whole of the rotation, and it is why
/// the comment on [TonemapAgx] named the absence rather than implying the
/// curve was the transform.
///
/// **A fifth curve rather than a correction to the fourth.** Every golden in
/// this repository that names a curve names one of the four codes, and 18%
/// grey lands in a different place through the rotation than through the bare
/// sigmoid — so quietly improving `agx` would move pictures somebody recorded
/// on purpose. `agx` stays exactly the curve it was, and this is the one to
/// reach for when a hue has to survive being over-bright.
///
/// The matrices are the published AgX ones, written out rather than derived,
/// and they are inverses to about six decimal places — checked as arithmetic
/// in `tonemap_curve_test.dart` rather than trusted, because a transposed row
/// here would look like a subtle grade rather than like a bug.
vec3 TonemapAgxFull(vec3 color) {
// The published pair, written in the same layout and used in the same order
// as the reference implementation — `M * v`, with the literals as that
// implementation lists them. Taken as a matched pair on purpose: an inset
// from one variant beside an outset from another is a matrix product that is
// *nearly* the identity, which reads as a grade nobody asked for rather than
// as a mistake.
const mat3 kInset = mat3(
0.842479062253094, 0.0423282422610123, 0.0423756549057051,
0.0784335999999992, 0.878468636469772, 0.0784336,
0.0792237451477643, 0.0791661274605434, 0.879142973793104);
const mat3 kOutset = mat3(
1.19687900512017, -0.0528968517574562, -0.0529716355144438,
-0.0980208811401368, 1.15190312990417, -0.0980434501171241,
-0.0990297440797205, -0.0989611768448433, 1.15107367264116);
vec3 v = kInset * color;
v = TonemapAgx(v);
// Out of the wider gamut, then clamped: the outset can push a channel a
// little past one or a little below zero on a colour that was already at
// the edge, and anything above display white is display white.
return clamp(kOutset * v, vec3(0.0), vec3(1.0));
}
/// Reinhard, extended so that white maps to white.
///
/// The plain `c / (1 + c)` never reaches one, so a sky that should clip to
/// paper white comes out grey; the extension takes the value that *should*
/// become white and normalises to it. Here that value is 4 — two stops over
/// display white — which is the point past which this engine's bloom has
/// taken over anyway.
///
/// **Clamped, because the extension keeps climbing past its own white
/// point.** The curve maps 4 to exactly one and 40 to 3.4, which is not a
/// tone mapper's job: anything above white is white. Without the clamp the
/// only thing bounding the output is the sRGB encode at the very end, and a
/// curve whose contract is "this fits on a display" should be the thing that
/// makes it fit.
///
/// Kept because it is the plainest of the four and the one everything else
/// gets compared against.
vec3 TonemapReinhard(vec3 color) {
const float kWhite = 4.0;
vec3 numerator = color * (vec3(1.0) + color / vec3(kWhite * kWhite));
return clamp(numerator / (vec3(1.0) + color), vec3(0.0), vec3(1.0));
}
/// [color] through whichever curve [curve] names.
///
/// A chain of comparisons rather than a `switch`: the number is a uniform,
/// so every backend takes the same branch for a whole frame, and `switch` on
/// a non-constant is the construct that has needed a workaround on one
/// backend or another every time it has been used here.
/// [color] looked up in the colour table, which holds `size` slices.
///
/// **A strip, not a 3D texture**, because three of the four backends this
/// engine runs on either have no 3D sampler or have one that costs a
/// capability check — and a strip is an ordinary 2D image an artist can open,
/// which is how every grading tool exports one anyway.
///
/// The blue axis picks a pair of neighbouring slices and mixes between them;
/// red and green come out of the sampler's own bilinear filtering inside a
/// slice. The half-texel inset on red is what keeps the first and last
/// entries reachable: without it the ends of the ramp are never sampled and a
/// table that should be an identity darkens white.
vec3 SampleLut(vec3 color, float size) {
vec3 c = clamp(color, vec3(0.0), vec3(1.0));
float sliceWidth = 1.0 / size;
float texel = 1.0 / (size * size);
float innerWidth = texel * (size - 1.0);
float u = texel * 0.5 + c.r * innerWidth;
float v = (0.5 / size) + c.g * ((size - 1.0) / size);
float slice = c.b * (size - 1.0);
float lower = floor(slice);
float upper = min(lower + 1.0, size - 1.0);
vec3 a = texture(lut_texture, vec2(lower * sliceWidth + u, v)).rgb;
vec3 b = texture(lut_texture, vec2(upper * sliceWidth + u, v)).rgb;
return mix(a, b, slice - lower);
}
vec3 TonemapBy(vec3 color, int curve) {
if (curve == 1) return TonemapNeutral(color);
if (curve == 2) return TonemapAces(color);
if (curve == 3) return TonemapAgx(color);
if (curve == 4) return TonemapReinhard(color);
if (curve == 5) return TonemapAgxFull(color);
return color;
}
void main() {
// **Dispersion happens at the lens, so it happens at sampling.** Sampling the
// scene three times at radially offset coordinates is the whole effect; doing
// it after the tone map would smear an already-compressed image and could not
// separate the channels of a highlight that had already clipped together.
//
// The offset grows from the centre outwards, which is what a real lens does:
// a ray through the middle of the glass is not dispersed at all.
float dispersion = composite_info.look.w;
vec4 scene;
if (dispersion > 0.0) {
vec2 fromCentre = v_uv - vec2(0.5);
vec2 step_uv = fromCentre * dispersion;
scene = texture(scene_texture, v_uv);
scene.r = texture(scene_texture, v_uv + step_uv).r;
scene.b = texture(scene_texture, v_uv - step_uv).b;
} else {
scene = texture(scene_texture, v_uv);
}
vec3 bloom = texture(bloom_texture, v_uv).rgb;
// Four taps in a 2×2, which is not a general-purpose blur: the occlusion pass
// rotates its kernel by the parity of the pixel, leaving a 2×2 pattern, and
// this averages exactly that away. The size is derived from the artefact
// rather than tuned against it, so the two have to move together — widening
// one without the other either leaves the pattern or smears the contact
// shadows this whole pass exists to draw.
vec2 half_texel = composite_info.ao_texel.xy * 0.5;
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));
// **The contact shadow, folded into the same multiplier — `gfx-76n`.** Its
// own strength, because it answers a different question from the occlusion:
// one is how enclosed a point is and the other is whether the sun reaches
// it, and a scene wants them at different amounts. Its own `mix` for the
// reason the line above has one — "off" has to be a multiplier of exactly
// one, which every golden in this repository depends on.
//
// Full resolution rather than the occlusion's half, so no four-tap average:
// the whole point of a contact shadow is the first few centimetres at the
// join, and a half-resolution one is the seam it exists to draw, blurred
// away.
float contact = texture(contact_shadow_texture, v_uv).r;
ao *= mix(1.0, contact, clamp(composite_info.contact.x, 0.0, 1.0));
// Applied to the scene and **not** to the bloom, which is the whole reason
// this lives in the composite rather than in a pass that reads and rewrites
// the HDR colour. Multiplying before bloom would take the glow out of a lit
// crack along with the ambient, and a crack that stops glowing is a worse
// error than a crack that stays bright.
//
// The cost, stated rather than left to be discovered: this multiplies the
// *sum* of the light, not the indirect part of it alone. Separating them
// would mean a third attachment and rewriting all six lit stages. So an
// emissive strip in a corner dims, which is physically wrong — the same
// compromise `pbr.frag` already makes with the occlusion map from a glTF.
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);
color = TonemapBy(color, int(composite_info.params.z + 0.5));
// **After the tone map, and that is the point.** Grading is a decision about
// an image somebody can see; applied to unbounded scene-referred colour it
// would be pulling on values the display will never show anyway.
float contrast = composite_info.look.x;
float saturation = composite_info.look.y;
float temperature = composite_info.look.z;
// Pivoted about mid grey, so contrast does not double as an exposure knob.
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.
// `white_balance` below is the conversion, and the two are deliberately
// separate: this one is a look, that one is a correction.
color *= vec3(1.0 + temperature * 0.1, 1.0, 1.0 - temperature * 0.1);
// **Lift, gamma, gain — `gfx-27n`, and the three ranges a colourist
// actually reaches for.** Contrast and saturation move the whole picture at
// once; these move one end of it. Lift adds, so it raises the shadows and
// leaves white alone. Gain multiplies, so it moves the highlights and
// leaves black alone. Gamma is the exponent between them, so it moves the
// midtones and leaves both ends. Applied in that order, which is the order
// they are named in and the order a grading panel applies them.
//
// Each is a vec3, not a scalar: the whole reason to have them is a warm
// highlight over a cool shadow, which one number per stage cannot say.
vec3 lift = composite_info.lift.xyz;
vec3 gammaCurve = composite_info.gamma.xyz;
vec3 gain = composite_info.gain.xyz;
color = color + lift;
// Guarded, because a channel at zero under a fractional exponent is a
// divide by zero on some drivers and a black pixel on others, and the
// defaults have to be an exact identity rather than nearly one.
color = max(color, vec3(0.0));
if (gammaCurve != vec3(1.0)) color = pow(color, vec3(1.0) / gammaCurve);
color *= gain;
// **White balance, which the temperature above is not.** A gain on red
// against blue is a look; this is the correction — a shift along the
// warm-to-cool axis with a green-magenta tint across it, the pair every
// camera and every grading panel offers together. Approximated in the
// display space rather than converted through a chromatic adaptation
// matrix: the exact transform wants the scene's own white point, and this
// pass has the picture rather than the light that made it.
float balance = composite_info.output_encode.y;
float tint = composite_info.output_encode.z;
if (balance != 0.0 || tint != 0.0) {
color *= vec3(
1.0 + balance * 0.20,
1.0 + tint * 0.15,
1.0 - balance * 0.20);
// The tint takes its green out of the other two rather than adding light,
// so a tint alone changes the hue and not the level.
color.r -= tint * 0.075;
color.b -= tint * 0.075;
}
// **The table goes after the grade and before the barrel**, which is where
// a grading suite puts it: a LUT is somebody's finished look, so it should
// see the contrast and saturation decisions rather than have them applied
// on top of it — and it should not see the vignette or the grain, which
// belong to the lens and the film rather than to the colour.
//
// Branched on the strength rather than mixed by it, so a frame with no
// table does not sample one. The branch is on a uniform, so the whole draw
// takes the same side of it.
float lutStrength = composite_info.ao_texel.z;
if (lutStrength > 0.0) {
vec3 graded = SampleLut(color, max(composite_info.ao_texel.w, 2.0));
color = mix(color, graded, clamp(lutStrength, 0.0, 1.0));
}
// The barrel and the film, last, and in that order: a vignette darkens what
// the grain then lands on, which is the way round a camera does it.
float vignette = composite_info.look_more.x;
if (vignette > 0.0) {
vec2 fromCentre = v_uv - vec2(0.5);
// **The aspect has to be in the uniform for this to mean anything.** UV
// space is square and the frame is not, so a falloff computed on UV alone
// is an ellipse on screen. Roundness 1 undoes that and keeps the vignette
// circular; 0 lets it follow the frame and reach the short edges first.
float aspect = max(composite_info.look_more.w, 1e-4);
fromCentre.x *= mix(1.0, aspect, composite_info.look_more.y);
float radius = length(fromCentre) * 1.41421356;
color *= mix(1.0, 1.0 - vignette, clamp(radius, 0.0, 1.0));
}
// **Grain and dither are both after the encode, and for the same reason.**
// Banding is a quantisation artefact of the 8-bit target, so the noise that
// breaks it up has to be the size of one output step: a fixed distance in
// display space and a wildly varying one in linear space, where a step near
// black is a thousandth of a step near white.
vec3 encoded = LinearToSrgb(max(color, vec3(0.0)));
// **This used to be added in linear light, and the comment on it was
// wrong.** It said the noise was centred on zero so grain neither lifts nor
// lowers the average level. Symmetric in linear it was; symmetric by the
// time anybody saw it, it was not. `max(color, 0.0)` clipped the negative
// half, and the sRGB encode then stretched what was left: at a grain of
// 0.08 on black, the surviving half ran up to a linear 0.04, which encodes
// to 56 of 255. Measured over the `look` parity fixture, the darkest cell
// sat at 14 instead of 0, across a picture that is 188 cells of black.
//
// In display space the two halves are the same size, nothing is clipped
// before the average is taken, and the sentence above is finally true. What
// it costs is that the amount means something different: 0.08 is now eight
// percent of the output range rather than of the light, which is what a
// film grain control has always meant on every other tool.
float grain = composite_info.look_more.z;
if (grain > 0.0) encoded += vec3((Hash(gl_FragCoord.xy) - 0.5) * grain);
// Dither last, because it is the one aimed at the quantiser itself.
float dither = composite_info.output_encode.x;
if (dither > 0.0) encoded += vec3(BayerCell(gl_FragCoord.xy) * dither);
frag_color = vec4(encoded, scene.a);
}
''',
'Fxaa': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;
// Edges smoothed after the fact, on the finished picture.
//
// **This exists to end a choice nobody should have to make.** The scene pass
// turns MSAA off whenever anything consumes the surface buffer, because a
// multisampled attachment and a buffer something else reads back are the same
// decision made two ways — so switching ambient occlusion on cost every edge
// in the frame its smoothing. A game got shadows in its corners or clean
// silhouettes, and not both.
//
// Working on the composited image rather than on the scene is what makes that
// possible, and it is also what makes this cheap: one pass, one texture, no
// depth, no second attachment, no knowledge of geometry at all. What it
// cannot do is help an edge the rasteriser never saw — a thin wire that fell
// between two pixel centres is gone before this reads it, which MSAA would
// have caught. Named here because it is the honest limit of the technique
// rather than a defect in this implementation.
precision highp float;
in vec2 v_uv;
layout(location = 0) out vec4 frag_color;
/// The composited frame, already tone mapped and sRGB encoded.
uniform sampler2D source_texture;
layout(std140) uniform FxaaInfo {
/// x, y: one texel. z: the contrast a pixel needs before it is worth
/// touching, as a fraction of the local maximum. w: how far along the edge
/// to sample, in texels.
vec4 params;
/// x: contrast-adaptive sharpening, 0 for none. y, z, w: unclaimed.
///
/// **A second block member rather than a fifth component**, because
/// `params` is full — and because sharpening is not an anti-aliasing
/// parameter. It rides in this pass for one reason: the four taps it needs
/// are the four this pass already fetches.
vec4 sharpen;
}
fxaa_info;
/// The smallest of the three, which GLSL has no builtin for.
float MinChannel(vec3 v) { return min(v.x, min(v.y, v.z)); }
/// Contrast-adaptive sharpening over the cross this pass already sampled —
/// `gfx-29n`.
///
/// **Why it is here and not in a pass of its own.** Output sharpening is what
/// normally follows an FXAA-softened image, and the neighbourhood it needs is
/// the four taps the smoothing already fetched. A pass of its own would be a
/// second full-screen draw and four more texture reads for the same answer.
///
/// **Adaptive, which is the whole of the name.** A plain unsharp mask
/// overshoots wherever the neighbourhood is already near an extreme — the
/// bright halo along a hard edge that reads as a cheap filter. The amplitude
/// here is taken from how much headroom the darkest and lightest neighbours
/// leave, so a pixel with room is sharpened and a pixel already against the
/// ceiling is not.
///
/// **Pushed away from the neighbourhood average, with no denominator.** The
/// first version here used the normalised kernel a sharpener usually has —
/// `(c + w*(n+s+w+e)) / (1 + 4w)` — and that form has a hole in it with four
/// neighbours: the denominator is zero at `w = -0.25`, which is exactly where
/// full strength on a pixel with full headroom lands. A flat grey frame came
/// back white. Measured, not reasoned about: 96 went to 255 on every pixel.
///
/// This form has no denominator to vanish. A flat neighbourhood has the
/// centre equal to its own average, so the difference is zero and the pixel
/// is returned untouched — an identity by construction rather than by
/// algebra that happens to cancel.
vec3 Sharpen(vec3 centre, vec3 n, vec3 s, vec3 w, vec3 e) {
float strength = fxaa_info.sharpen.x;
if (strength <= 0.0) return centre;
vec3 lowest = min(centre, min(min(n, s), min(w, e)));
vec3 highest = max(centre, max(max(n, s), max(w, e)));
// How much room is left at the nearer end. Per channel, because a red edge
// against white has headroom in two channels and none in the third.
vec3 room = min(lowest, vec3(1.0) - highest) / max(highest, vec3(1e-5));
float amount = clamp(sqrt(clamp(MinChannel(room), 0.0, 1.0)), 0.0, 1.0);
vec3 average = (n + s + w + e) * 0.25;
return centre + (centre - average) * amount * strength;
}
/// Perceptual weight, on the encoded image.
///
/// Green-weighted rather than Rec. 709 luma: this runs *after* the sRGB
/// encode, so the values are not linear light and a photometric weighting
/// would be measuring the wrong space. What the algorithm needs is a number
/// that moves when a person would see an edge, and green carries most of
/// that.
float Weight(vec3 color) { return dot(color, vec3(0.299, 0.587, 0.114)); }
void main() {
vec2 texel = fxaa_info.params.xy;
// `textureLod` throughout this pass, for `shadow.glsl`'s own reason: the
// last of these six taps sits after the early return below, so a WGSL
// backend sees a sample that need not be reached by every invocation of a
// quad and refuses the implicit derivative as possibly non-uniform. The
// composited frame is read at its native size with no mipmap of its own, so
// naming level zero directly changes no pixel.
vec3 middle = textureLod(source_texture, v_uv, 0.0).rgb;
float mid = Weight(middle);
// The four edge neighbours. Diagonals are deliberately left out: they cost
// four more samples and only sharpen the direction estimate on a corner,
// which is the one place this pass should be doing the least.
// The colours are kept, not just their weights: the sharpening at the end
// needs the neighbourhood itself, and these are the same four taps either
// way. Discarding the colour and re-fetching it would be four more.
vec3 northRgb = textureLod(source_texture, v_uv + vec2(0.0, -texel.y), 0.0).rgb;
vec3 southRgb = textureLod(source_texture, v_uv + vec2(0.0, texel.y), 0.0).rgb;
vec3 westRgb = textureLod(source_texture, v_uv + vec2(-texel.x, 0.0), 0.0).rgb;
vec3 eastRgb = textureLod(source_texture, v_uv + vec2(texel.x, 0.0), 0.0).rgb;
float north = Weight(northRgb);
float south = Weight(southRgb);
float west = Weight(westRgb);
float east = Weight(eastRgb);
float lowest = min(mid, min(min(north, south), min(west, east)));
float highest = max(mid, max(max(north, south), max(west, east)));
float contrast = highest - lowest;
// **Relative to the local brightness, not absolute.** A step of 0.02 across
// a dark surface is an edge somebody can see; the same step across a white
// wall is dithering. A fixed threshold either scrubs the dark parts of the
// frame or leaves the bright parts crawling, and this frame has both.
if (contrast < max(0.0312, highest * fxaa_info.params.z)) {
frag_color = vec4(
Sharpen(middle, northRgb, southRgb, westRgb, eastRgb), 1.0);
return;
}
// Which way the edge runs. The vertical difference is larger on a
// horizontal edge, which is the one to blend across.
float vertical = abs(north + south - 2.0 * mid);
float horizontal = abs(west + east - 2.0 * mid);
bool horizontalEdge = vertical >= horizontal;
// And which side of it is the darker one, so the blend moves towards the
// neighbour rather than away from it.
float towards = horizontalEdge ? south - mid : east - mid;
float away = horizontalEdge ? north - mid : west - mid;
float step_length = horizontalEdge ? texel.y : texel.x;
if (abs(away) > abs(towards)) step_length = -step_length;
// **How far to go: how wrong this pixel is against its neighbourhood.** A
// white pixel with a black neighbour sits far from the average of the four
// and has to move most; a pixel already near that average is already the
// blend and moves least.
//
// Measuring the distance from the *end* of the range instead — which the
// first version of this did — gives exactly zero on a hard black-to-white
// edge, because every pixel there is at one end or the other. The pass ran,
// cost a draw, and changed nothing, which is the failure this arithmetic
// exists to avoid.
float average = (north + south + west + east) * 0.25;
float blend = clamp(abs(average - mid) / max(contrast, 1e-5), 0.0, 1.0);
// Squared, so a faint gradient is left alone and a real edge gets the whole
// step: the difference between smoothing an edge and smearing a texture.
blend = blend * blend * fxaa_info.params.w;
vec2 offset = horizontalEdge ? vec2(0.0, step_length * blend)
: vec2(step_length * blend, 0.0);
vec3 smoothed = textureLod(source_texture, v_uv + offset, 0.0).rgb;
frag_color =
vec4(Sharpen(smoothed, northRgb, southRgb, westRgb, eastRgb), 1.0);
}
''',
'MrtProbe': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;
// PROBE, not part of the pipeline: does Impeller honour more than one colour
// attachment?
//
// `RenderTarget.colorAttachments` is a list and `setColorBlendEnable` takes an
// attachment index, so MRT is there structurally — but structure in the Dart
// bindings has already proved to be a poor predictor of runtime behaviour
// twice in this project. Writing two distinct constants and reading both
// targets back settles it.
//
// Enabled with `--dart-define=FLUTTER3D_MRT_PROBE=true`; the entry stays in the
// bundle because the answer is tied to a Flutter version and the next SDK bump
// should re-run the check rather than re-derive it.
precision highp float;
in vec2 v_uv;
layout(location = 0) out vec4 out_first;
layout(location = 1) out vec4 out_second;
void main() {
// Two values nothing else in the engine produces, so reading them back is
// unambiguous evidence that each attachment received its own output.
out_first = vec4(0.25, 0.5, 0.75, 1.0);
out_second = vec4(0.75, 0.5, 0.25, 1.0);
}
''',
'ShadowDepth': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;
// The shadow pass: write depth, nothing else.
//
// Depth goes into a **colour** target rather than being read back out of the
// depth buffer. flutter_gpu gives no way to sample a depth texture — the format
// enum has depth formats, but a `DepthStencilAttachment` texture is not
// something `bindTexture` will take — so the workaround is chosen up front
// rather than discovered: render linear depth into `r16g16b16a16Float`, which
// is a format sampling is known to work for.
//
// `gl_FragCoord.z` is exactly what is wanted here *because* the shadow camera is
// orthographic. Under a perspective projection that value is hyperbolic and
// would concentrate all its precision near the near plane; an orthographic one
// is linear in view space, so the stored value is a distance and comparing two
// of them is meaningful.
//
// It includes lib/color.glsl for the varying declarations and the output, not
// for the colour helpers: a fragment shader whose inputs disagree with the
// vertex shader's outputs does not link, and mesh.vert emits all five.
// One attachment, not two: this pass writes a shadow map, and the surface
// buffer belongs to the scene pass.
// And no fog block either: this shader reads neither, and a uniform block it
// declares without using is a descriptor that collides with the vertex stage's
// on Vulkan. See the guard in lib/color.glsl.
#define F3D_NO_SURFACE_BUFFER
#define F3D_NO_FOG
// --- lib/color.glsl ---
// Colour space helpers and the fragment output interface.
//
// Split out of surface.glsl so a shader that needs no material inputs — the
// normals debug view — can avoid DECLARING the FragInfo uniform block at all.
// That matters more than it looks: reflection metadata reports a block as
// present merely because it was declared, even when the compiled shader binds
// no such buffer, so a declared-but-unused block is indistinguishable from a
// used one until Metal crashes on the bind.
#ifndef COLOR_GLSL_
#define COLOR_GLSL_
precision highp float;
const float kPi = 3.14159265359;
// One varying set shared by every fragment shader, matching mesh.vert.
//
// All five are declared here, including the two the debug models never read: a
// fragment shader whose `in` block disagrees with the vertex shader's `out`
// block fails to link, and there is no partial-match rule to lean on.
in vec3 v_world_position;
in vec3 v_normal;
in vec2 v_texcoord;
in vec4 v_tangent;
in vec4 v_color;
/// Where this fragment is in the level's lightmap. Zero from every vertex
/// stage but `mesh_lightmapped.vert`, and read only by the lit models, which
/// sample a one-texel black there when a material has no map.
in vec2 v_lightmap_uv;
layout(location = 0) out vec4 frag_color;
// The second attachment: what a screen-space effect needs to know about the
// surface it is looking at. World-space normal in rgb, and in a the depth along
// the view axis in world metres — not a window depth; `WriteSurfaceGeometry`
// says at length why not.
//
// Depth travels here rather than in a depth texture because flutter_gpu cannot
// sample one — the same reason the shadow pass writes its depth into a colour
// target. See ARCHITECTURE.md §2.
//
// Guarded, because not every stage that includes this header draws into a
// two-attachment target. The shadow pass draws into one, and a pipeline
// declaring an output its target has no slot for is a mismatch worth avoiding
// rather than discovering.
#ifndef F3D_NO_SURFACE_BUFFER
layout(location = 1) out vec4 frag_surface;
#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;
// **A stage that needs none of this must be able to declare none of it.** On
// Vulkan both stages' descriptors are merged into one set layout, and two
// bindings with the same number in it is not a layout the specification
// allows. A driver may accept it anyway; a Galaxy A55's refuses the pipeline
// with `ErrorUnknown` and no other word, which is how the shadow pass came to
// build everywhere except there — its only uniform block was this one, and it
// landed on the same binding as the vertex stage's first.
#ifndef F3D_NO_FOG
/// Distance fog, in its own block rather than folded into FragInfo.
///
/// Its own because color.glsl is included before FragInfo is declared, and
/// because appending to a block that half a dozen shaders already share is a
/// way to move offsets nobody expected to move. Three vec4s is a cheap price
/// for not touching any of that.
layout(std140) uniform FogInfo {
/// rgb: linear fog colour. w: density per metre, zero for no fog.
vec4 fog;
/// xyz: camera position in world space. Duplicated from FragInfo so this
/// block stands alone; a vec3 is cheaper than a coupling.
vec4 eye;
/// xyz: the direction the camera looks, as a unit vector in world space.
/// w unused.
///
/// Here rather than in a block of its own because it answers the same
/// question [eye] does — where the camera is and which way it faces — and
/// this is the block `color.glsl` can see.
vec4 forward;
}
fog_info;
/// How far this fragment is from the eye, in world metres.
///
/// What the fog fades by. Distance rather than depth, because fog is a
/// property of the air between two points and does not care which way the
/// camera happens to face.
float EyeDistance() { return distance(v_world_position, fog_info.eye.xyz); }
/// How far this fragment is *along the view axis*, in world metres.
///
/// What the surface buffer's alpha holds. Depth rather than distance, and the
/// difference only shows on an orthographic camera — where the rays through
/// the pixels are parallel instead of meeting at the eye, so a distance from
/// the eye names a sphere that the pixel's ray crosses somewhere the reader
/// cannot solve for. A depth along the axis names a plane, which every ray
/// crosses exactly once. See `WorldAtDepth` in `post/ssao.frag` for the
/// reconstruction both projections share.
float ViewDepth() {
return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz);
}
#else // F3D_NO_FOG
// The same two questions, answered without the block: a stage that declares no
// fog has no eye position to measure from either. Stubs rather than a guard at
// every call site, so that what includes this file reads the same whichever
// way it was compiled.
float EyeDistance() { return 0.0; }
float ViewDepth() { return 0.0; }
#endif // F3D_NO_FOG
/// Records the geometry of this fragment for whatever runs after the scene.
///
/// Called from the same place that writes colour, so a surface cannot be lit
/// into the frame without also describing itself — which is the failure that
/// leaves a screen-space effect reflecting whatever was in the buffer before.
///
/// rg: octahedral normal. b: perceptual roughness. a: **depth along the view
/// axis, in world metres** — see [ViewDepth].
///
/// **Not `gl_FragCoord.z`, and that is a defect this channel carried until it
/// was looked at.** Window depth crowds every distant surface into the top of
/// its range — with a near plane of a tenth of a metre, everything past twenty
/// metres lives in the last half a hundredth of `[0, 1]` — and this attachment
/// is a half float, whose steps up there are about five ten-thousandths. So two
/// surfaces half a metre apart at twenty metres stored the *same* number, and
/// every screen-space pass that compares against this channel decided whole
/// bands of pixels by rounding. The occlusion pass drew them: vertical stripes
/// along the lines of constant depth on any wall receding from the camera, on
/// both GPU backends. The software rasteriser kept the channel at full
/// precision and drew the effect correctly, so it was the one that looked
/// wrong against the other two.
///
/// A depth in metres has none of that: the exponent carries the range and the
/// mantissa carries the same relative precision everywhere, which at twenty
/// metres is a centimetre. Both numbers are measured in
/// `flutter3d/test/surface_depth_test.dart`.
///
/// Zero still means nothing was drawn. The attachment is cleared to zero and
/// nothing is drawn in front of the near plane.
void WriteSurfaceGeometry(float roughness) {
#ifndef F3D_NO_SURFACE_BUFFER
// A debug pass takes the buffer over rather than getting one of its own.
// The surface buffer already has an attachment, a viewer and a golden; a
// second one would need all three built before it could answer anything.
if (g_debug_surface_on) {
frag_surface = vec4(g_debug_surface, ViewDepth());
return;
}
frag_surface = vec4(EncodeOctahedral(normalize(v_normal)),
clamp(roughness, 0.0, 1.0), ViewDepth());
#endif
}
/// Fades [color] toward the fog with distance from the eye.
///
/// Exponential rather than linear, because linear fog has a visible plane
/// where it starts and a dungeon corridor is exactly where that shows.
vec3 ApplyFog(vec3 color) {
#ifdef F3D_NO_FOG
return color;
#else
float density = fog_info.fog.w;
if (density <= 0.0) return color;
float d = EyeDistance();
return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0));
#endif
}
/// 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 lit shaders' block, declared again because this shader shares none of
/// their headers — it has a different vertex layout and none of their varyings.
///
/// **The first two members of it, not all three.** `color.glsl` carries a
/// `forward` beside these, for the view axis the surface buffer measures its
/// depths along; a particle writes no surface buffer, so it neither declares
/// that member nor is bound one. The two blocks share a name and not a shape,
/// which is fine — they belong to different programs — and a check that binds
/// this one has to bind what it declares.
layout(std140) uniform FogInfo {
/// rgb: linear fog colour. w: density per metre, zero for no fog.
vec4 fog;
/// xyz: camera position in world space.
vec4 eye;
}
fog_info;
void main() {
// Distance from the middle of the quad, where the corners sit at 1.
vec2 centred = v_uv * 2.0 - 1.0;
float radius = length(centred);
// Soft edge, and a brighter core: a flat disc reads as a paper cut-out, and
// the falloff is what makes a cluster of these look like light rather than
// like confetti.
float falloff = 1.0 - smoothstep(0.0, 1.0, radius);
float intensity = falloff * falloff;
// Fog on an additive particle is attenuation, not a mix. Blending toward
// the fog colour would make a distant flame *add* fog to the wall behind it
// and come out brighter than the wall it is supposed to be fading into;
// multiplying toward zero is what "further away contributes less" means when
// the destination is only ever added to.
float fogged = 1.0;
if (fog_info.fog.w > 0.0) {
fogged = clamp(
exp(-fog_info.fog.w * distance(v_world_position, fog_info.eye.xyz)),
0.0,
1.0);
}
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);
}
''',
'Splat': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;
// A Gaussian splat, as the falloff inside a quad — `gfx-80n`.
//
// **Why this is a fragment stage and nothing else.** A splat is an ellipsoid of
// fading opacity, and what reaches the screen is that ellipsoid's projection: an
// ellipse whose brightness falls off as a Gaussian from its middle. Two ways to
// draw one. Expand a point into an ellipse here, which needs a geometry stage
// flutter_gpu does not have; or hand the stage a quad already shaped and
// oriented, and evaluate the falloff across it. This engine already made that
// choice once, for particles, and `particle.vert`'s own comment says why — so
// splats reuse that vertex stage exactly rather than adding a second one that
// would read the same three attributes.
//
// The quad's `texcoord` is therefore not a texture coordinate. It is the
// fragment's position in the *Gaussian's own* space, in units of its standard
// deviation, centred at zero: the CPU builds the quad from the projected
// ellipse's axes, so `v_uv` arrives already in the frame where the falloff is
// round and the arithmetic here is one dot product.
//
// **Alpha blended, back to front, and that is the whole difference from a
// particle.** Particles are additive, which is why they need no sorting —
// addition does not care about order. A splat is translucent: two overlapping
// ones give a different colour depending on which was drawn first, so the cloud
// is sorted every time the camera moves and this stage writes straight alpha.
in vec4 v_color;
in vec2 v_uv;
in vec3 v_world_position;
layout(location = 0) out vec4 frag_color;
/// The same block the particle stage declares, for the same reason it declares
/// it: a different vertex layout and none of the lit shaders' varyings, so none
/// of their headers apply. Two members, not three — nothing here writes a
/// surface buffer, so there is no view axis to measure a depth along.
layout(std140) uniform FogInfo {
/// rgb: linear fog colour. w: density per metre, zero for no fog.
vec4 fog;
/// xyz: camera position in world space.
vec4 eye;
}
fog_info;
void main() {
// `exp(-½ dᵀd)` with d already in standard deviations, which is what the
// quad's own coordinates are. No matrix here: the inverse covariance was
// applied when the corners were placed, because it is one ellipse per splat
// and four corners, not one per fragment.
float power = -0.5 * dot(v_uv, v_uv);
// **Cut off rather than trailed to nothing.** A Gaussian never reaches zero,
// so a quad sized to hold all of it would be infinite; the corners sit at
// three standard deviations, where the falloff is under a hundredth, and
// anything past that is discarded so the quad's own square edge can never
// show. Without this the cloud reads as a field of faint rectangles.
if (power < -4.5) discard;
float alpha = v_color.a * exp(power);
if (alpha < 1.0 / 255.0) discard;
// Fog as a mix rather than as attenuation, which is the opposite of the
// particle stage above and for the opposite reason: this is blended, so the
// destination is *replaced* in proportion to alpha, and a distant splat that
// faded toward black would put black into the wall behind it instead of
// fading into the air.
vec3 colour = v_color.rgb;
if (fog_info.fog.w > 0.0) {
float visibility = clamp(
exp(-fog_info.fog.w * distance(v_world_position, fog_info.eye.xyz)),
0.0,
1.0);
colour = mix(fog_info.fog.rgb, colour, visibility);
}
// Premultiplied, because that is what the blend state this is drawn under
// expects: `one, one_minus_src_alpha` composites a stack of translucent
// layers correctly in one pass, and straight alpha under the same state
// double-counts the colour of everything in front.
frag_color = vec4(colour * alpha, alpha);
}
''',
'ParticleMesh': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;
// Mesh particles: additive, fogged, and shaded by which way each face points.
//
// ## Why there is a facing term at all
//
// The billboard path needs none: its quad is a procedural disc, so the falloff
// from the middle to the edge is what gives a sprite its form. A mesh has no
// such coordinate, and additive blending flattens everything it touches — every
// face adds the same colour, so a tumbling shard comes back as a solid
// silhouette of its own outline. It reads as a hole in the world rather than as
// an object.
//
// One term fixes it: how squarely a face points at the eye. A face turned away
// contributes less, so the shape's own geometry separates itself, and a shard
// spinning through a torch's light flickers because its faces do.
//
// **This is not lighting.** It reads no light in the scene, casts nothing, and
// receives nothing; the same shape is equally bright in a dark corridor. That
// is deliberate: an additive particle is *emissive by definition* — it adds to
// what is behind it — and shading one by the room's lights would mean binding
// the whole lit path's uniform set to something that has no business being lit.
in vec4 v_color;
in vec3 v_world_position;
in vec3 v_normal;
layout(location = 0) out vec4 frag_color;
/// The same block the other particle stage declares, for the same reason: this
/// shader shares none of the lit shaders' headers.
layout(std140) uniform FogInfo {
/// rgb: linear fog colour. w: density per metre, zero for no fog.
vec4 fog;
/// xyz: camera position in world space.
vec4 eye;
}
fog_info;
void main() {
vec3 to_eye = fog_info.eye.xyz - v_world_position;
float distance_to_eye = length(to_eye);
// `abs`, not `max(dot, 0)`. Nothing here is culled — a particle mesh is seen
// from every side as it tumbles — so a back face is as visible as a front
// one, and clamping would make half of every shard go black rather than dim.
vec3 n = normalize(v_normal);
float facing = distance_to_eye > 0.0
? abs(dot(n, to_eye / distance_to_eye))
: 1.0;
// Never all the way to zero. A silhouette edge is exactly perpendicular to
// the eye, and a face that vanished there would carve a dark seam across the
// shape at precisely the place the eye is best at noticing one.
float intensity = mix(0.35, 1.0, facing);
// Attenuation rather than a mix, for the reason spelled out in
// lighting/particle.frag: blending toward the fog colour makes a distant
// additive particle *add* fog to the wall behind it.
float fogged = 1.0;
if (fog_info.fog.w > 0.0) {
fogged = clamp(exp(-fog_info.fog.w * distance_to_eye), 0.0, 1.0);
}
frag_color = vec4(v_color.rgb * v_color.a * intensity * fogged, 1.0);
}
''',
'Reflections': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;
// Screen-space reflections.
//
// Reflects what is already on screen, and nothing else. That is the whole
// bargain: a torch behind the camera does not appear in the floor, and a
// surface at a grazing angle reflects a stretched smear of whatever the ray
// happened to hit. It is bought cheaply — one texture read per march step, no
// second pass over the geometry, no cube maps and so no mip levels, which this
// channel does not have.
//
// The surface buffer is what makes it possible at all: a forward renderer
// throws its normals away inside the fragment shader, and there is nothing to
// reflect against without them. rg is the world-space normal, octahedrally
// encoded; b is perceptual roughness; a is the depth along the view axis in
// world metres — depth is here rather than in a depth texture because
// flutter_gpu cannot sample one, and it is in metres rather than a window depth
// because a half float cannot hold the second one usefully past a few metres.
//
// Roughness is why the normal is squeezed into two channels. Without it the
// shader reflects off rough stone as readily as off a wet floor, which is what
// the first version did: the walls of the crypt lit up and the floor did not.
precision highp float;
in vec2 v_uv;
layout(location = 0) out vec4 frag_color;
uniform sampler2D scene_texture;
uniform sampler2D surface_texture;
layout(std140) uniform ReflectionInfo {
/// World to clip, and back. Both carry the framebuffer origin — see
/// [UvFromNdc] — so neither is the camera's own matrix on every backend.
mat4 view_projection;
mat4 inverse_view_projection;
/// xyz: camera position. w: unused.
vec4 camera;
/// xyz: the direction the camera looks, a unit vector in world space.
/// w: unused. With [camera] it names the planes the buffer's depths measure
/// against; see [WorldAt].
vec4 forward;
/// x: steps. y: stride in world metres. z: thickness in world metres.
/// w: intensity.
vec4 params;
/// x: 1/width, y: 1/height, z: unused, w: 1 to show only what the march
/// found, which is the only way to see whether it found anything.
vec4 screen;
}
reflection_info;
vec3 DecodeOctahedral(vec2 e) {
e = e * 2.0 - 1.0;
vec3 n = vec3(e.xy, 1.0 - abs(e.x) - abs(e.y));
float t = max(-n.z, 0.0);
n.x += n.x >= 0.0 ? -t : t;
n.y += n.y >= 0.0 ? -t : t;
return normalize(n);
}
/// Where a point at clip-space [ndc] lands in the textures this pass reads.
///
/// **v runs the other way from y, and the matrix is what makes that true on
/// both backends.** Row zero of a rendered texture is its top on Impeller and
/// its bottom in WebGL, so the conversion cannot be written once for both in
/// GLSL — `toFramebufferOrigin` negates y in the matrix handed down here for
/// the backend that needs it, exactly as it does for the shadow lookup in
/// `lib/surface.glsl`, which has used this convention all along.
///
/// This pass used the opposite one — `ndc * 0.5 + 0.5`, with an unadjusted
/// matrix — and was therefore right in a browser and mirrored top to bottom
/// everywhere else: the march read the surface buffer at the pixel reflected
/// about the middle of the frame, found nothing there that had anything to do
/// with the ray, and drew a reflection of whatever happened to be in the way.
vec2 UvFromNdc(vec2 ndc) {
return vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
}
/// World position of the pixel at [uv], [depth] metres along the view axis.
///
/// **A ray crossing a plane**, because that is what the buffer holds now — see
/// `WriteSurfaceGeometry`. The inverse matrix gives both ends of the pixel's
/// ray; the stored depth names the plane the surface sits on, and every ray
/// crosses that plane once. A window depth in a half float could not name it at
/// range: past twenty metres its steps are wider than the differences this
/// march turns on.
///
/// Both ends rather than the camera and one end, so that an orthographic camera
/// — whose rays are parallel and meet nowhere — reconstructs correctly too.
/// `post/ssao.frag` says the same at more length.
///
/// y undoes [UvFromNdc], so that reconstructing the point a march projected
/// hands back the point it started from.
void PixelRay(vec2 uv, out vec3 origin, out vec3 along) {
vec2 xy = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
vec4 nearH = reflection_info.inverse_view_projection * vec4(xy, 0.0, 1.0);
vec4 farH = reflection_info.inverse_view_projection * vec4(xy, 1.0, 1.0);
origin = nearH.xyz / nearH.w;
along = normalize(farH.xyz / farH.w - origin);
}
vec3 WorldAt(vec2 uv, float depth) {
vec3 origin, along;
PixelRay(uv, origin, along);
vec3 axis = reflection_info.forward.xyz;
return origin +
along * ((depth - dot(origin - reflection_info.camera.xyz, axis)) /
dot(along, axis));
}
/// How deep [at] is, in the metres the buffer holds.
float DepthOf(vec3 at) {
return dot(at - reflection_info.camera.xyz, reflection_info.forward.xyz);
}
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);
// Back along the ray this pixel looks down, rather than towards the camera
// position. The two are the same thing under a perspective camera and are
// not under an orthographic one, whose rays are parallel: there the vector to
// the camera *position* leans further off the view axis the nearer a pixel is
// to the edge of the frame, and every reflection in an isometric scene would
// be angled by where it happened to sit on screen.
vec3 rayOrigin, viewRay;
PixelRay(v_uv, rayOrigin, viewRay);
vec3 toEye = -viewRay;
// Facing away, or so nearly edge-on that the march would crawl along the
// surface it started from.
float facing = dot(normal, toEye);
if (facing <= 0.05) {
frag_color = vec4(background, 1.0);
return;
}
vec3 ray = reflect(-toEye, normal);
int steps = int(reflection_info.params.x);
float stride = reflection_info.params.y;
float thickness = reflection_info.params.z;
float intensity = reflection_info.params.w;
// Started 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 = UvFromNdc(ndc.xy);
// Off screen is where this technique ends. Fading rather than cutting,
// because a hard edge at the border of the frame is more distracting than
// a missing reflection.
if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) break;
// **`textureLod` at level zero for every read inside this march.** The loop
// breaks the moment a ray leaves the frustum or the frame, so no two
// invocations of a quad are guaranteed to be on the same step, and a WGSL
// backend will not derive a mip level under a branch like that. Both
// textures are full-screen render targets with a single level and are read
// at one texel per pixel, so level zero is what the derivative was
// selecting; asking for it by name is the same picture.
float sceneDepth = textureLod(surface_texture, uv, 0.0).a;
// The march's own depth, in the same metres the buffer holds — so the two
// are comparable without a projection between them.
float marchDepth = DepthOf(march);
// Behind whatever was drawn at this pixel, in metres both sides of the
// comparison are already in.
if (sceneDepth > 0.0 && marchDepth > sceneDepth) {
// *How far* behind, in metres, and that is the whole fix. This used to
// read the window-depth difference as a thickness, and a window-depth
// difference is a different number of metres at every range: near the
// camera 0.006 was a few centimetres and one stride stepped clean over
// every surface in the frame, while at twenty metres it was several metres
// and every ray passing in front of a distant wall "hit" it. That is why
// the effect was off in every scene and looked wrong the moment it was
// switched on. `ssao.frag` splits the same two questions the same way.
//
// The gap between the two points rather than between their depths: they
// sit on one ray from the eye, and along a ray running away from the
// camera a depth difference is shorter than the distance it stands for.
// Thickness is a size in the world, so it is compared against one.
vec3 seen = WorldAt(uv, sceneDepth);
float behind = distance(march, seen);
if (behind < thickness) {
hitColor = textureLod(scene_texture, uv, 0.0).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 — as metres along the view
// axis, which is not what a depth buffer holds and is deliberate; see
// `WriteSurfaceGeometry` in `lib/color.glsl`. Every screen-space effect in this
// renderer is built on that one decision, and this stage inherits it rather
// than working around it.
//
// The cost that must be stated rather than discovered: reading the surface
// buffer turns MSAA off for the whole scene pass, because the average of two
// octahedral normals is not the encoding of any normal. Switching this on
// therefore changes the antialiasing of the entire frame, not just the shading
// in its corners.
precision highp float;
in vec2 v_uv;
layout(location = 0) out vec4 frag_color;
uniform sampler2D surface_texture;
layout(std140) uniform SsaoInfo {
/// Screen to world, for turning a stored depth back into a point.
///
/// Carries the framebuffer origin, as its partner below does — see
/// [UvFromNdc].
mat4 inverse_view_projection;
/// World to screen, for finding where a sampled point lands.
mat4 view_projection;
/// x: radius in world metres. y: how many samples. w: bias in metres, which
/// keeps a flat surface from occluding itself.
///
/// z 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;
/// xyz: where the eye is. w unused.
///
/// Needed because the surface buffer holds a *depth along the view axis in
/// metres* rather than a window depth — see `WriteSurfaceGeometry`. Turning
/// one back into a point takes a ray and a plane rather than a matrix
/// multiply, and this is where the ray starts.
vec4 camera;
/// xyz: the direction the camera looks, a unit vector in world space.
/// w unused. The normal of the planes the stored depth measures against.
vec4 forward;
}
ssao_info;
vec3 DecodeOctahedral(vec2 e) {
e = e * 2.0 - 1.0;
vec3 n = vec3(e.xy, 1.0 - abs(e.x) - abs(e.y));
float t = max(-n.z, 0.0);
n.x += n.x >= 0.0 ? -t : t;
n.y += n.y >= 0.0 ? -t : t;
return normalize(n);
}
/// Where a point at clip-space [ndc] lands in the surface buffer.
///
/// **v runs the other way from y, and the matrix is what makes that true on
/// both backends** — the convention `lib/surface.glsl` reads shadow maps with,
/// and the one this pass should have had. `toFramebufferOrigin` negates y in
/// the matrices below for the backend whose row zero is at the bottom.
///
/// Written the other way round — `ndc * 0.5 + 0.5`, with unadjusted matrices —
/// this pass reconstructed the point at the pixel mirrored about the middle of
/// the frame and took its taps around that, on every backend but the browser.
vec2 UvFromNdc(vec2 ndc) {
return vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
}
/// Where the depth stored for [uv] is, in the world.
///
/// **A ray crossing a plane**, rather than a matrix multiply. The buffer stores
/// metres along the view axis rather than a window depth, so the inverse matrix
/// is used to find the pixel's ray — both ends of it — and the stored depth
/// picks the point on that ray lying [depth] metres in front of the eye. What
/// that buys is precision: a window depth in a half float cannot tell twenty
/// metres from twenty and a half, which is what used to draw bands across every
/// wall.
///
/// **Both ends, rather than one and the camera**, and that is what makes it
/// true of an orthographic camera as well. Its rays do not meet at the eye;
/// they are parallel, and a reconstruction that starts every ray at the camera
/// position puts an isometric scene's geometry somewhere it is not. Two
/// unprojections cost one extra matrix multiply and are right either way.
///
/// y undoes [UvFromNdc]: a point projected and then reconstructed has to come
/// back where it started.
vec3 WorldAtDepth(vec2 uv, float depth) {
vec2 xy = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
vec4 nearH = ssao_info.inverse_view_projection * vec4(xy, 0.0, 1.0);
vec4 farH = ssao_info.inverse_view_projection * vec4(xy, 1.0, 1.0);
vec3 origin = nearH.xyz / nearH.w;
vec3 along = normalize(farH.xyz / farH.w - origin);
vec3 axis = ssao_info.forward.xyz;
return origin +
along * ((depth - dot(origin - ssao_info.camera.xyz, axis)) /
dot(along, axis));
}
/// How deep [at] is, in the metres the buffer holds.
float DepthOf(vec3 at) {
return dot(at - ssao_info.camera.xyz, ssao_info.forward.xyz);
}
/// Twelve directions on a hemisphere, as a fixed table.
///
/// A table rather than a hash of the fragment coordinate, and the reason is the
/// conformance suite rather than taste: the cross-backend budgets in this
/// repository are measured in hundredths of a per cent, and a float hash agrees
/// between a GPU and a software rasteriser nowhere. A table is the same twelve
/// numbers everywhere.
///
/// Lengths vary deliberately, packing more samples near the origin: occlusion
/// falls off with distance, so uniform spacing spends most of its taps where
/// they matter least.
vec3 KernelTap(int i) {
if (i == 0) return vec3(0.5381, 0.1856, 0.4319);
if (i == 1) return vec3(0.1379, 0.2486, 0.4430);
if (i == 2) return vec3(0.3371, 0.5679, 0.0057);
if (i == 3) return vec3(-0.6999, -0.0451, 0.0019);
if (i == 4) return vec3(0.0689, -0.1598, -0.8547);
if (i == 5) return vec3(0.0560, 0.0069, -0.1843);
if (i == 6) return vec3(-0.0146, 0.1402, 0.0762);
if (i == 7) return vec3(0.0100, -0.1924, -0.0344);
if (i == 8) return vec3(-0.3577, -0.5301, -0.4358);
if (i == 9) return vec3(-0.3169, 0.1063, 0.0158);
if (i == 10) return vec3(0.0103, -0.5869, 0.0046);
return vec3(-0.0897, -0.4940, 0.3287);
}
/// One of four rotations, chosen by the parity of the pixel.
///
/// Four constants rather than a random angle, for the same reason the kernel is
/// a table. It leaves a 2×2 pattern in the result, which is exactly what the
/// composite's 2×2 average cancels — the blur is sized to the artefact rather
/// than guessed at, and the two have to change together or neither works.
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 =
WorldAtDepth(v_uv, surface.a) + normal * ssao_info.params.w;
vec2 rot = Rotation(v_uv);
float occluded = 0.0;
for (int i = 0; i < 12; i++) {
if (i >= samples) break;
vec3 tap = KernelTap(i);
// Rotated about the vertical axis of the kernel's own space, before it is
// oriented to the surface: rotating afterwards would turn the hemisphere
// off the normal and let taps fall behind the surface.
vec3 spun =
vec3(tap.x * rot.x - tap.y * rot.y, tap.x * rot.y + tap.y * rot.x, tap.z);
// Flipped into the hemisphere the surface faces, rather than built from a
// tangent frame. A frame needs a tangent, this pass has none, and any it
// invented would rotate along a silhouette and shimmer.
if (dot(spun, normal) < 0.0) spun = -spun;
vec3 at = origin + spun * radius;
vec4 clip = ssao_info.view_projection * vec4(at, 1.0);
if (clip.w <= 0.0) continue;
vec3 ndc = clip.xyz / clip.w;
if (abs(ndc.x) > 1.0 || abs(ndc.y) > 1.0) continue;
vec2 uv = UvFromNdc(ndc.xy);
// **`textureLod` at level zero, for the same reason the march in
// `reflections.frag` uses it.** Two `continue`s stand above this line, so
// the invocations of a quad are not all here, and a WGSL backend refuses to
// derive a mip level where they are not. The surface buffer is a
// full-screen render target with one level, and this pass binds it
// unfiltered besides, so level zero is the only level there has ever been
// to read.
vec4 there = textureLod(surface_texture, uv, 0.0);
// The sky occludes nothing: a sample that lands on it is a sample looking
// out of the scene, which is the opposite of being enclosed.
if (there.a <= 0.0) continue;
// Nearer to the eye than the point we sampled towards means something
// stands between them. **Compared in metres**, which is what the buffer
// holds: the same test in window depth is a comparison whose resolution
// collapses with range, and at twenty metres a half float cannot separate
// two surfaces half a metre apart. `reflections.frag` reached this
// conclusion first and says so at more length.
if (there.a >= DepthOf(at)) continue;
// The range check, and the reason a version without one draws haloes: a
// wall four metres behind a railing is nearer to the camera than every
// sample taken around the railing, and would occlude all of them. Distance
// measured in the world, because "four metres behind" is a world fact and
// the depth buffer's answer to it depends on where the camera is.
vec3 seen = WorldAtDepth(uv, there.a);
occluded +=
smoothstep(0.0, 1.0, radius / max(distance(seen, origin), 1e-4));
}
// Raw, with no strength applied. The strength lives in the composite, and it
// lives in exactly one place on purpose: applied here as well it would be
// squared, and — more to the point — "off" has to mean a multiplier of
// exactly one, which is a property of the composite's `mix` rather than of
// any arithmetic done here.
frag_color = vec4(clamp(1.0 - occluded / float(samples), 0.0, 1.0));
}
''',
'SsaoBlur': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;
// A depth-aware blur over the occlusion buffer — `gfx-32n`.
//
// **Why the occlusion needs one and the composite cannot give it.** The
// composite already averages a 2x2, and that is not a blur: the occlusion
// pass rotates its kernel by the parity of the pixel and the 2x2 averages
// exactly that pattern away. Widening it would smear the contact shadows the
// pass exists to draw, and the two are sized to each other on purpose. So the
// quality has to come from somewhere else, and that somewhere is a pass of
// its own over the occlusion buffer, before the composite reads it.
//
// **Depth-aware, because occlusion is the one signal a blur must not spread
// across a silhouette.** A plain blur pulls the dark of a corner out past the
// object that made it, which reads as a halo around every shape — the
// artefact that makes people switch ambient occlusion off. Each tap is
// weighted by how close its depth is to the centre's, so a tap on the other
// side of an edge contributes nothing.
//
// Depth comes from the surface buffer's alpha, which carries view-axis
// distance in metres — the same channel `ssao.frag` reconstructs positions
// from, and the reason this pass needs no depth attachment of its own.
in vec2 v_uv;
layout(location = 0) out vec4 frag_color;
uniform sampler2D ao_texture;
uniform sampler2D surface_texture;
layout(std140) uniform SsaoBlurInfo {
// x, y: one texel of the occlusion buffer. z: how many taps to each side,
// 0 for none. w: how much difference in depth, in metres, halves a tap's
// weight.
vec4 params;
}
blur_info;
void main() {
float taps = blur_info.params.z;
float centre = texture(ao_texture, v_uv).r;
if (taps < 1.0) {
frag_color = vec4(centre, centre, centre, 1.0);
return;
}
float centreDepth = texture(surface_texture, v_uv).a;
float falloff = max(blur_info.params.w, 1e-4);
float total = centre;
float weightSum = 1.0;
// Bounded at eight to each side whatever the uniform says, the same rule
// `ssao.frag`'s own sample loop keeps: a loop a uniform can lengthen
// without limit is a hang rather than a slow frame.
for (int i = 1; i <= 8; i++) {
if (float(i) > taps) break;
float offset = float(i);
vec2 steps[4];
steps[0] = vec2(blur_info.params.x * offset, 0.0);
steps[1] = vec2(-blur_info.params.x * offset, 0.0);
steps[2] = vec2(0.0, blur_info.params.y * offset);
steps[3] = vec2(0.0, -blur_info.params.y * offset);
for (int s = 0; s < 4; s++) {
vec2 at = v_uv + steps[s];
float depth = texture(surface_texture, at).a;
// A tap across a silhouette is a tap from another surface, and the
// whole reason this is depth-aware is that it must not count. The
// weight falls off with the difference rather than cutting at a
// threshold, so a curved surface does not band where the cut would be.
float closeness = exp(-abs(depth - centreDepth) / falloff);
// And further taps count for less, which is what makes this a blur
// rather than a box.
float weight = closeness / offset;
total += texture(ao_texture, at).r * weight;
weightSum += weight;
}
}
float blurred = total / weightSum;
frag_color = vec4(blurred, blurred, blurred, 1.0);
}
''',
'ContactShadow': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;
// Contact shadows: a short march toward the light, in screen space —
// `gfx-76n`.
//
// **What a shadow map cannot do at any resolution.** A box resting on a plane
// meets it along a line, and the shadow that belongs at that line is a texel
// wide or less. Raise the map's resolution and the line moves closer to right
// without arriving; raise the bias enough to stop the acne a tight contact
// produces and the shadow detaches from the object entirely — which is the
// familiar look of a prop floating a centimetre above the floor. The gap is
// structural, and the answer everywhere is to stop asking the map about the
// first few centimetres and march the depth buffer instead.
//
// **Not contact *hardening*, which this repository already has.** `shadow.glsl`
// searches for blockers and sizes its penumbra from what it finds, so a shadow
// is sharp where its caster is close. That is a different thing with a
// confusingly similar name, and it is the grep hit that made a survey record
// this row as already done.
//
// **The march is the same arithmetic `ssao.frag` does**, with one direction
// instead of a hemisphere: reconstruct the surface point from the buffer's
// depth, step toward the light in world metres, project each step back into the
// buffer, and compare in metres. Everything about why — the reconstruction as a
// ray crossing a plane, the comparison in metres rather than window depth,
// `textureLod` at level zero — is written out there and holds here unchanged.
//
// The strength is not applied here. It lives in the composite, for the reason
// the occlusion's does: "off" has to mean a multiplier of exactly one, and that
// is a property of a `mix` in one place rather than of arithmetic in two.
// A fullscreen stage declares its own varying and its own output, the way
// every other pass in this directory does: `lib/color.glsl` is the mesh
// fragment's preamble and brings a surface this pass does not have.
in vec2 v_uv;
layout(location = 0) out vec4 frag_color;
uniform sampler2D surface_texture;
layout(std140) uniform ContactShadowInfo {
/// Screen to world, for turning a stored depth back into a point.
mat4 inverse_view_projection;
/// World to screen, for finding where a marched point lands.
mat4 view_projection;
/// x: how far to march, in world metres. y: how many steps.
/// z: how thick an occluder is assumed to be, in metres — a surface nearer
/// than the ray by more than this is something else in front rather than the
/// thing casting. w: bias in metres, which lifts the ray off its own surface.
vec4 params;
/// xyz: where the eye is. w unused.
vec4 camera;
/// xyz: the direction the camera looks, a unit vector. The normal of the
/// planes the stored depth measures against.
vec4 forward;
/// xyz: the direction *to* the light, a unit vector in world space — the
/// reverse of the direction a directional light points. w unused.
///
/// One light and not eight. A march is a march per light, and eight of them
/// per pixel is a different pass with a different budget; the sun is the one
/// whose contact is missing from a shadow map that has to cover a level.
vec4 to_light;
}
contact_info;
vec3 DecodeOctahedral(vec2 e) {
e = e * 2.0 - 1.0;
vec3 n = vec3(e.xy, 1.0 - abs(e.x) - abs(e.y));
float t = max(-n.z, 0.0);
n.x += n.x >= 0.0 ? -t : t;
n.y += n.y >= 0.0 ? -t : t;
return normalize(n);
}
/// Where a point at clip-space [ndc] lands in the surface buffer.
///
/// v runs the other way from y, and the matrices carry the framebuffer origin
/// — `ssao.frag` says at length what going the other way costs.
vec2 UvFromNdc(vec2 ndc) {
return vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
}
/// Where the depth stored for [uv] is, in the world.
vec3 WorldAtDepth(vec2 uv, float depth) {
vec2 xy = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
vec4 nearH = contact_info.inverse_view_projection * vec4(xy, 0.0, 1.0);
vec4 farH = contact_info.inverse_view_projection * vec4(xy, 1.0, 1.0);
vec3 origin = nearH.xyz / nearH.w;
vec3 along = normalize(farH.xyz / farH.w - origin);
vec3 axis = contact_info.forward.xyz;
return origin +
along * ((depth - dot(origin - contact_info.camera.xyz, axis)) /
dot(along, axis));
}
/// How deep [at] is, in the metres the buffer holds.
float DepthOf(vec3 at) {
return dot(at - contact_info.camera.xyz, contact_info.forward.xyz);
}
void main() {
vec4 surface = texture(surface_texture, v_uv);
// Nothing was drawn here. The buffer is cleared to zero and a zero alpha is
// the sky, not a surface sitting on the near plane.
if (surface.a <= 0.0) {
frag_color = vec4(1.0);
return;
}
vec3 normal = DecodeOctahedral(surface.rg);
vec3 toLight = normalize(contact_info.to_light.xyz);
// A surface already facing away from the light is unlit by the light term
// itself, and marching from it would find its own far side. Returning one
// leaves it to the lighting, which is the half that knows about the normal.
if (dot(normal, toLight) <= 0.0) {
frag_color = vec4(1.0);
return;
}
float reach = max(contact_info.params.x, 1e-4);
int steps = clamp(int(contact_info.params.y + 0.5), 1, 16);
float thickness = max(contact_info.params.z, 1e-4);
// Lifted along the normal, in metres, for `ssao.frag`'s reason: a bias in
// window depth is a different number of millimetres at every distance.
vec3 origin = WorldAtDepth(v_uv, surface.a) + normal * contact_info.params.w;
float stride = reach / float(steps);
for (int i = 0; i < 16; i++) {
if (i >= steps) break;
vec3 at = origin + toLight * (stride * float(i + 1));
vec4 clip = contact_info.view_projection * vec4(at, 1.0);
// Behind the eye: the march has left the frame, and a division by a
// negative w would fold it back into view somewhere it is not.
if (clip.w <= 0.0) break;
vec2 uv = UvFromNdc(clip.xy / clip.w);
// Off the edge of the buffer. Nothing is known out there, and guessing
// would put a dark rim around every frame.
if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) break;
vec4 there = textureLod(surface_texture, uv, 0.0);
if (there.a <= 0.0) continue;
float marched = DepthOf(at);
float gap = marched - there.a;
// In front of the ray, and not so far in front that it is a different
// object seen past the one casting: without the thickness test a wall four
// metres nearer than the floor shadows everything the ray crosses, which
// is the same halo `ssao.frag`'s range check exists to stop.
if (gap > 0.0 && gap < thickness) {
// **Darker the nearer the blocker, which is what makes this a contact
// shadow rather than a stencil.** A hit on the first step is a surface
// touching this one and gets nothing; a hit at the far end of the march
// is most of a metre away and barely counts. Without the fade the pass
// writes zero or one and the march's own reach becomes a visible edge on
// the floor — a hard band that ends where the loop does, which is a
// number in a settings object rather than anything in the scene.
frag_color = vec4(float(i) / float(steps));
return;
}
}
frag_color = vec4(1.0);
}
''',
'LightShafts': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;
// Volumetric light shafts, marched through the directional shadow map —
// `gfx-33n`.
//
// **What it draws, and why it is not a screen-brightness trick.** The
// familiar cheap version takes the bright pixels of the frame and smears them
// radially from the sun's position on screen. That needs the sun to be *in*
// the frame, it brightens anything else that happens to be bright, and it has
// no idea what is casting. This marches the view ray instead and asks the
// shadow map, at each step, whether that point in the air is lit. What comes
// out is a shaft where the light actually reaches and none where something is
// in the way — so a beam through a doorway is the doorway's shape, and
// turning the caster's shadow off leaves nothing at all.
//
// **Additive, and it reads the scene only for where to stop.** The in-scatter
// is added to the lit colour; the surface buffer's depth says how far along
// the ray there is still air to march. Past that the ray is inside geometry
// and anything accumulated would be light inside a wall.
//
// The dithered start is what makes sixteen steps look like a beam rather than
// sixteen bands. Each pixel starts a fraction of a step further along, from a
// 4x4 Bayer cell — ordered rather than random for the reason the dither in
// `composite.frag` is: it is a function of screen position and of nothing
// else, so a golden recorded with shafts on stays recorded.
in vec2 v_uv;
layout(location = 0) out vec4 frag_color;
uniform sampler2D scene_texture;
uniform sampler2D surface_texture;
uniform sampler2D shadow_texture;
layout(std140) uniform ShaftInfo {
// Screen to world, for turning a stored depth back into a point.
mat4 inverse_view_projection;
// The three cascade matrices, world to light clip. Unused ones are the
// identity and are never reached, because `cascades.z` says how many there
// are.
mat4 shadow_matrix;
mat4 shadow_matrix_far;
mat4 shadow_matrix_farthest;
// xyz: where the eye is. w: how far to march, in world metres.
vec4 camera;
// xyz: the direction the camera looks. w: how many steps.
vec4 forward;
// xyz: the colour the air scatters, already multiplied by the strength.
// w: unused.
vec4 scatter;
// x, y: the two cascade split distances. z: how many cascades. w: the
// depth bias, in the same units the map holds.
vec4 cascades;
}
shaft_info;
// One cell of a 4x4 Bayer matrix, in [0, 1). The same table
// `composite.frag` keeps, for the same reason.
float BayerCell(vec2 at) {
int x = int(mod(at.x, 4.0));
int y = int(mod(at.y, 4.0));
int index = y * 4 + x;
float value = 0.0;
if (index == 0) value = 0.0;
else if (index == 1) value = 8.0;
else if (index == 2) value = 2.0;
else if (index == 3) value = 10.0;
else if (index == 4) value = 12.0;
else if (index == 5) value = 4.0;
else if (index == 6) value = 14.0;
else if (index == 7) value = 6.0;
else if (index == 8) value = 3.0;
else if (index == 9) value = 11.0;
else if (index == 10) value = 1.0;
else if (index == 11) value = 9.0;
else if (index == 12) value = 15.0;
else if (index == 13) value = 7.0;
else if (index == 14) value = 13.0;
else value = 5.0;
return value / 16.0;
}
// Whether [world] is lit by the caster: 1 in the light, 0 in shadow.
//
// The cascade walk `lib/shadow.glsl` does, without the surface it needs. A
// point in the air has no normal, so there is no normal offset here and no
// soft kernel either — one tap, because sixteen of them per pixel is already
// the cost of this pass.
float LitAt(vec3 world, float viewDistance) {
int cascadeCount = int(shaft_info.cascades.z + 0.5);
int cascade = 0;
if (cascadeCount > 1 && viewDistance > shaft_info.cascades.x) cascade = 1;
if (cascadeCount > 2 && viewDistance > shaft_info.cascades.y) cascade = 2;
for (int attempt = 0; attempt < 3; attempt++) {
int which = cascade + attempt;
if (which >= cascadeCount) break;
mat4 matrix = which == 0
? shaft_info.shadow_matrix
: (which == 1 ? shaft_info.shadow_matrix_far
: shaft_info.shadow_matrix_farthest);
vec4 lightSpace = matrix * vec4(world, 1.0);
if (lightSpace.w <= 0.0) continue;
vec3 candidate = lightSpace.xyz / lightSpace.w;
vec2 inTile = vec2(candidate.x * 0.5 + 0.5, 0.5 - candidate.y * 0.5);
if (inTile.x < 0.0 || inTile.x > 1.0 || inTile.y < 0.0 || inTile.y > 1.0) {
continue;
}
if (candidate.z > 1.0) continue;
vec2 uv = vec2((inTile.x + float(which)) / float(cascadeCount), inTile.y);
// `textureLod`, for `shadow.glsl`'s own reason: the cascade search above
// continues and breaks on values computed per fragment, so a WGSL backend
// refuses the implicit derivative here as possibly non-uniform. One level,
// so naming it directly changes no pixel.
float stored = textureLod(shadow_texture, uv, 0.0).r;
// Outside the map is lit rather than dark: a point beyond the shadow
// volume has nothing recorded about it, and calling that shadow would
// put a wall of darkness across the far half of every shaft.
return candidate.z - shaft_info.cascades.w > stored ? 0.0 : 1.0;
}
return 1.0;
}
void main() {
vec4 scene = texture(scene_texture, v_uv);
int steps = int(shaft_info.forward.w + 0.5);
if (steps < 1) {
frag_color = scene;
return;
}
// Where the ray starts and which way it goes.
vec2 xy = vec2(v_uv.x * 2.0 - 1.0, 1.0 - v_uv.y * 2.0);
vec4 nearH = shaft_info.inverse_view_projection * vec4(xy, 0.0, 1.0);
vec4 farH = shaft_info.inverse_view_projection * vec4(xy, 1.0, 1.0);
vec3 origin = nearH.xyz / nearH.w;
vec3 along = normalize(farH.xyz / farH.w - origin);
// How far there is air. The surface buffer holds depth along the view axis
// in metres, so the distance along *this* ray is that over the cosine
// between the two — a ray at the corner of the frame travels further than
// the axis does to reach the same plane.
float surfaceDepth = texture(surface_texture, v_uv).a;
float cosine = max(dot(along, shaft_info.forward.xyz), 1e-4);
float toSurface = surfaceDepth > 0.0 ? surfaceDepth / cosine : 1e9;
float distance = min(shaft_info.camera.w, toSurface);
if (distance <= 0.0) {
frag_color = scene;
return;
}
float stride = distance / float(steps);
// The dithered start: a fraction of a step, so the banding sixteen samples
// would otherwise draw is broken into a pattern the eye integrates.
float offset = BayerCell(gl_FragCoord.xy) * stride;
float lit = 0.0;
for (int i = 0; i < 64; i++) {
if (i >= steps) break;
float travelled = offset + float(i) * stride;
vec3 at = origin + along * travelled;
lit += LitAt(at, travelled * cosine);
}
// The average share of the ray that was in light, times the scatter colour.
// An average rather than a sum, so changing the step count changes the
// quality and not the brightness.
vec3 shaft = shaft_info.scatter.rgb * (lit / float(steps));
frag_color = vec4(scene.rgb + shaft, scene.a);
}
''',
'DepthOfField': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;
// Depth of field: a thin lens, a circle of confusion, and a gather — `gfx-34n`.
//
// **The arithmetic is a lens rather than a curve somebody liked.** A point at
// distance `d` images to a disc whose diameter is
//
// |d - focus| / d * f^2 / (N * (focus - f))
//
// where `f` is the focal length and `N` the f-number. That is the thin-lens
// formula, and the reason to use it rather than a hand-drawn ramp is that
// every number in it is one a photographer already knows: open the aperture
// and the background goes softer by an amount somebody can predict.
//
// **The depth is the surface buffer's alpha**, which carries view-axis
// distance in metres — so the circle is computed from a real distance rather
// than from a window depth, where the same blur would mean different things
// near and far. That channel is also why no depth attachment is needed:
// flutter_gpu cannot sample one.
//
// **A gather, not a scatter.** Each output pixel reads the neighbourhood and
// asks which of those samples would have landed on it. That gets the near
// field wrong in a way a scatter would not — a foreground blur cannot spread
// *over* a sharp background, because the sharp pixel never looks that far —
// and it is the trade every real-time implementation makes, because a scatter
// needs per-pixel splatting the hardware here has no path for. Written down
// rather than discovered: this is why a foreground bokeh has a hard outer
// edge where a photograph's would not.
//
// Sampled on a spiral rather than a grid: a square kernel makes a square
// bokeh, and the shape of an out-of-focus highlight is the one thing anybody
// looks at in this effect.
in vec2 v_uv;
layout(location = 0) out vec4 frag_color;
uniform sampler2D scene_texture;
uniform sampler2D surface_texture;
layout(std140) uniform DofInfo {
// x: focus distance in metres. y: focal length in metres. z: f-number.
// w: how many samples in the gather.
vec4 lens;
// x, y: one texel. z: the largest circle, in texels, whatever the lens
// arithmetic says — a bound on the gather rather than on the optics.
// w: texels per metre across the sensor, which is the frame's width in
// texels over the sensor's width in metres.
vec4 params;
}
dof_info;
// The circle of confusion at [depth], as a radius in texels.
float CircleAt(float depth) {
float focus = max(dof_info.lens.x, 1e-3);
float focal = max(dof_info.lens.y, 1e-4);
float fnumber = max(dof_info.lens.z, 1e-3);
if (depth <= 0.0) return 0.0;
// The thin-lens diameter, in metres on the sensor.
float denominator = max(fnumber * (focus - focal), 1e-6);
float diameter = abs(depth - focus) / depth * (focal * focal) / denominator;
// Metres on the sensor into texels on the screen, and a diameter into a
// radius. The conversion needs a sensor size, which is what makes a
// millimetre of focal length mean something; the frame's width supplies the
// other half of it. **Derived rather than a constant**, because a constant
// would mean a lens whose blur changed with the resolution — the same scene
// rendered twice as wide would be a different photograph rather than a
// larger one.
return min(diameter * 0.5 * dof_info.params.w, max(dof_info.params.z, 0.0));
}
void main() {
// `textureLod` throughout this pass, for `shadow.glsl`'s own reason: the
// gather below sits behind two early returns keyed on a per-fragment circle
// of confusion, so a WGSL backend refuses the implicit derivative as
// possibly non-uniform. Both textures are read at native size with no
// mipmap of their own, so naming level zero directly changes no pixel.
vec4 centre = textureLod(scene_texture, v_uv, 0.0);
int samples = int(dof_info.lens.w + 0.5);
if (samples < 1) {
frag_color = centre;
return;
}
float centreDepth = textureLod(surface_texture, v_uv, 0.0).a;
float radius = CircleAt(centreDepth);
if (radius < 0.5) {
// Inside half a texel there is nothing to gather: the disc this point
// images to is smaller than the pixel it lands on, which is what "in
// focus" means.
frag_color = centre;
return;
}
vec3 total = centre.rgb;
float weight = 1.0;
// The golden angle, so consecutive samples never line up into a spoke.
const float kGolden = 2.39996323;
for (int i = 1; i <= 64; i++) {
if (i > samples) break;
float t = float(i) / float(samples);
// sqrt so the samples spread evenly over the disc's *area* rather than
// bunching at the middle, which would leave the rim of a bokeh thin.
float r = sqrt(t) * radius;
float angle = float(i) * kGolden;
vec2 at = v_uv + vec2(cos(angle), sin(angle)) * r * dof_info.params.xy;
vec4 tap = textureLod(scene_texture, at, 0.0);
float tapDepth = textureLod(surface_texture, at, 0.0).a;
float tapRadius = CircleAt(tapDepth);
// Would this sample's own disc have reached here? A sharp background
// pixel behind a blurred foreground says no, and letting it in anyway is
// the bleed that makes a distant object glow through a near one.
float reach = r <= max(tapRadius, radius) ? 1.0 : 0.0;
total += tap.rgb * reach;
weight += reach;
}
frag_color = vec4(total / weight, centre.a);
}
''',
'ViewportShade': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;
// Viewport shading, read out of the surface buffer — `gfx-43n`, `gfx-44n` and
// `gfx-45n`, three branches of one stage.
//
// **This deletes a bug class rather than adding a look.** The modeller's
// normals view works by walking the subject and swapping every material for a
// debug one, remembering the old one to put back; its own docstring documents
// what happens when the remembering fails. Nothing here touches a material.
// The scene pass already wrote a world normal, a roughness and a view-axis
// depth into the second attachment, and every mode below is arithmetic on
// those — so the subject is never modified, there is nothing to restore, and
// a mode is a uniform rather than a traversal.
//
// **The cost is real and is stated where a caller can see it.** Declaring a
// read of the surface buffer attaches the second colour attachment, and
// attachments in one target must agree on sample count, so a frame with any
// of these modes on is a frame the scene pass did not multisample.
// `FrameResult.antiAliasing.msaaDeclined` says so; `anchor_identity_test.dart`
// is where that trade is pinned.
//
// One stage with branches rather than three stages, because all three read the
// same two channels and the difference between them is a handful of lines. A
// branch on a uniform is coherent across the whole draw — every fragment takes
// the same one — so it costs a compare and not a divergence.
in vec2 v_uv;
layout(location = 0) out vec4 frag_color;
uniform sampler2D scene_texture;
uniform sampler2D surface_texture;
layout(std140) uniform ShadeInfo {
// x: which mode. 0 leaves the picture alone, 1 normals as colour, 2 clay,
// 3 outline, 4 curvature. z and w below mean different things per mode,
// which is what keeps this to one block.
// y: how much of the shaded result to mix over the lit picture, 0 to 1.
// z: mode 2 — how much ambient sits under the studio light. mode 3 — the
// depth difference, in metres, an edge starts at. mode 4 — the gain on
// the curvature estimate.
// w: mode 3 — how far apart in angle two normals must be to count as an
// edge, in cosine. mode 4 — how much of the cavity is darkened rather
// than lit.
vec4 params;
// x, y: one texel. z: the outline's width in texels. w: unused.
vec4 screen;
// xyz: which way the studio light points, for mode 2. w: unused.
vec4 light;
}
shade_info;
// The octahedral decode every reader of this buffer keeps — see `ssao.frag`,
// which carries the argument for the encoding.
vec3 DecodeOctahedral(vec2 e) {
e = e * 2.0 - 1.0;
vec3 n = vec3(e.xy, 1.0 - abs(e.x) - abs(e.y));
float t = max(-n.z, 0.0);
n.x += n.x >= 0.0 ? -t : t;
n.y += n.y >= 0.0 ? -t : t;
return normalize(n);
}
void main() {
// `textureLod` throughout this pass, for `shadow.glsl`'s own reason: modes 3
// and 4 read the surface buffer again behind the early return below, which
// is keyed on a per-fragment depth rather than on the uniform `mode` the
// docstring above talks about — so a WGSL backend refuses the implicit
// derivative there as possibly non-uniform. Both textures are read at native
// size with no mipmap of their own, so naming level zero directly changes no
// pixel.
vec4 scene = textureLod(scene_texture, v_uv, 0.0);
int mode = int(shade_info.params.x + 0.5);
float mix_amount = clamp(shade_info.params.y, 0.0, 1.0);
if (mode < 1 || mix_amount <= 0.0) {
frag_color = scene;
return;
}
vec4 surface = textureLod(surface_texture, v_uv, 0.0);
float depth = surface.a;
// Nothing was drawn here: the buffer is cleared to zero and a normal
// decoded from that is a direction pointing nowhere. The background keeps
// whatever the scene left, which is what makes every mode below a shading
// of the *subject* rather than a wash over the frame.
if (depth <= 0.0) {
frag_color = scene;
return;
}
vec3 normal = DecodeOctahedral(surface.rg);
vec3 shaded = scene.rgb;
if (mode == 1) {
// **Normals as colour**, the mode the material swap existed for. The
// usual half-and-half mapping, so a surface facing the camera is the
// pale blue everybody recognises from every other modeller.
shaded = normal * 0.5 + 0.5;
} else if (mode == 2) {
// **Clay**: one studio light and an ambient floor, no texture, no
// material. What it is for is shape — a form with its albedo taken away,
// which is the whole reason a sculptor turns it on.
float ambient = clamp(shade_info.params.z, 0.0, 1.0);
float lambert = max(dot(normal, normalize(shade_info.light.xyz)), 0.0);
shaded = vec3(ambient + (1.0 - ambient) * lambert);
} else if (mode == 3) {
// **Outline**, from depth *and* normal, because either alone misses half
// the edges a modeller is looking for. A depth step finds a silhouette
// and misses a crease in a flat wall; a normal step finds the crease and
// misses two surfaces at the same angle one behind the other. Both, and
// an edge is either.
vec2 texel = shade_info.screen.xy * max(shade_info.screen.z, 1.0);
float depthEdge = 0.0;
float normalEdge = 0.0;
// Four neighbours rather than eight: a Sobel would weight diagonals it
// then has to normalise, and the answer here is a threshold rather than a
// gradient direction.
vec2 offsets[4] = vec2[4](
vec2(texel.x, 0.0), vec2(-texel.x, 0.0),
vec2(0.0, texel.y), vec2(0.0, -texel.y));
for (int i = 0; i < 4; i++) {
vec4 tap = textureLod(surface_texture, v_uv + offsets[i], 0.0);
if (tap.a <= 0.0) {
// Against the background: that is a silhouette, and the strongest
// edge there is.
depthEdge = 1.0;
continue;
}
depthEdge = max(depthEdge, abs(tap.a - depth));
normalEdge =
max(normalEdge, 1.0 - dot(DecodeOctahedral(tap.rg), normal));
}
float depthHit = step(max(shade_info.params.z, 1e-4), depthEdge);
float normalHit = step(max(shade_info.params.w, 1e-4), normalEdge);
float edge = max(depthHit, normalHit);
// The line is drawn *dark over the picture* rather than as its own
// colour: an outline that replaced the pixel would hide the shading it is
// meant to clarify.
shaded = scene.rgb * (1.0 - edge);
} else if (mode == 4) {
// **Curvature and cavity**, from how fast the normal field turns. The
// divergence of the normals across a pixel: a convex ridge turns one way,
// a concave crease the other, and a flat face does not turn at all.
//
// Read from the normal buffer rather than from depth, deliberately: a
// depth-based curvature is dominated by how far away the surface is, so a
// model twice as far reads as half as detailed. A normal field is the same
// at any distance.
vec2 texel = shade_info.screen.xy;
vec3 right = DecodeOctahedral(
textureLod(surface_texture, v_uv + vec2(texel.x, 0.0), 0.0).rg);
vec3 left = DecodeOctahedral(
textureLod(surface_texture, v_uv - vec2(texel.x, 0.0), 0.0).rg);
vec3 down = DecodeOctahedral(
textureLod(surface_texture, v_uv + vec2(0.0, texel.y), 0.0).rg);
vec3 up = DecodeOctahedral(
textureLod(surface_texture, v_uv - vec2(0.0, texel.y), 0.0).rg);
// The x component of the horizontal change plus the y of the vertical:
// the screen-space divergence, which is positive on a ridge and negative
// in a groove.
float curvature = ((right.x - left.x) + (down.y - up.y)) *
max(shade_info.params.z, 0.0);
float cavity = clamp(-curvature, 0.0, 1.0) *
clamp(shade_info.params.w, 0.0, 1.0);
float ridge = clamp(curvature, 0.0, 1.0);
// Grey, lit on the ridges and darkened in the cavities, which is what a
// cavity map is read for: the creases a sculpt has, seen without its
// colour.
shaded = vec3(clamp(0.5 + ridge * 0.5 - cavity, 0.0, 1.0));
}
frag_color = vec4(mix(scene.rgb, shaded, mix_amount), scene.a);
}
''',
'ProbePrefilter': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;
// One face of one level of a reflection probe: the captured cube, convolved
// by roughness, written where a lit shader's `textureLod` will read it.
//
// The device-side twin of `EnvironmentMap.prefilter`, and deliberately the
// same convolution rather than a better one: the same fixed spiral of taps,
// the same cosine-power lobe for the roughness, the same weighting. A GPU
// could importance-sample GGX here and look a little nicer on a rough metal;
// what it would lose is the agreement with the software rasteriser that the
// three golden sets are measured by, and the software side is this file read
// aloud. Where the two differ — bilinear taps here against nearest ones there
// — the difference is noise well under the cross-backend budgets.
//
// **Where this writes decides the direction, not the vertex stage.** The
// full-screen triangle hands over a uv with v = 0 at row zero of the level on
// every backend, and a cube face maps (s, t) to a direction by the table every
// graphics API agrees on: row zero of the +X face looks up +Y, column zero
// looks along +Z. `FaceDirection` is that table inverted, face by face, and
// it has to agree with `BoundTexture.sampleCube` on the software side and the
// hardware sampler on the other two, which the conformance check that clears
// one face and reads it back through this stage is for.
//
// The capture is read through the sampler, not through a table of its own:
// the renderer drew each face so that a hardware lookup returns the picture
// in that direction — see `probeFaceViewProjection` for what that took.
precision highp float;
in vec2 v_uv;
layout(location = 0) out vec4 frag_color;
/// The six views the probe captured, base level only.
uniform samplerCube capture_texture;
layout(std140) uniform ProbeInfo {
/// x: which face is being written, 0..5 in +X, −X, +Y, −Y, +Z, −Z order.
/// y: the roughness of this level, 0 for the mirror.
/// z: the level of the capture to read, 0 for a capture with one.
/// w: how many taps; 1 copies the capture along the axis and nothing else.
vec4 params;
}
probe_info;
/// The direction the texel at [st] of [face] looks along, with t measured
/// down the face from row zero.
vec3 FaceDirection(int face, vec2 st) {
float u = st.x * 2.0 - 1.0;
float v = st.y * 2.0 - 1.0;
if (face == 0) return normalize(vec3(1.0, -v, -u));
if (face == 1) return normalize(vec3(-1.0, -v, u));
if (face == 2) return normalize(vec3(u, 1.0, v));
if (face == 3) return normalize(vec3(u, -1.0, -v));
if (face == 4) return normalize(vec3(u, -v, 1.0));
return normalize(vec3(-u, -v, -1.0));
}
void main() {
int face = int(probe_info.params.x + 0.5);
float roughness = probe_info.params.y;
float lod = probe_info.params.z;
int samples = clamp(int(probe_info.params.w + 0.5), 1, 128);
vec3 axis = FaceDirection(face, v_uv);
// One tap is a copy: the mirror level, and the conformance check's way of
// reading one texel of one face at one level back out.
if (samples == 1) {
frag_color = vec4(textureLod(capture_texture, axis, lod).rgb, 1.0);
return;
}
// Roughness to a specular power, squared first because roughness is
// authored perceptually — the mapping `EnvironmentMap` uses, so a level here
// and a level built on the host are the same lobe.
float alpha = max(roughness * roughness, 1e-3);
float power = 2.0 / (alpha * alpha) - 2.0;
// A frame about the axis, so one tap set serves every texel.
vec3 up = abs(axis.z) < 0.999 ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0);
vec3 right = normalize(cross(up, axis));
vec3 ahead = cross(axis, right);
// A fixed spiral rather than a hash of the fragment: the same texel has to
// come out the same on every backend, or the golden means nothing.
const float golden = 3.14159265 * (3.0 - sqrt(5.0));
vec3 sum = vec3(0.0);
float weight = 0.0;
for (int i = 0; i < 128; i++) {
if (i >= samples) break;
float z = 1.0 - (float(i) + 0.5) / float(samples);
float radius = sqrt(max(1.0 - z * z, 0.0));
float theta = golden * float(i);
// Concentrated towards the axis by the power, so a sharp level does not
// spend its taps on directions it weights to nothing.
float spread = pow(z, 1.0 / (power + 1.0));
vec3 tap = normalize(vec3(radius * cos(theta) * (1.0 - spread),
radius * sin(theta) * (1.0 - spread),
spread));
vec3 dir = right * tap.x + ahead * tap.y + axis * tap.z;
float cosine = dot(dir, axis);
if (cosine <= 0.0) continue;
sum += textureLod(capture_texture, dir, lod).rgb * cosine;
weight += cosine;
}
frag_color = vec4(weight > 0.0 ? sum / weight : vec3(0.0), 1.0);
}
''',
'ShadowDistance': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;
// The depth pass for a point light: radial distance rather than clip depth.
//
// A cube shadow compares how far a fragment is from the light against how far
// the nearest caster in that direction was. Clip depth cannot answer that: it
// is measured along one face's axis, so the same distance reads differently
// depending on which face a direction lands on, and every face boundary would
// show a seam. Distance from the light is the same number whichever face
// recorded it.
//
// Normalised by the light's range so it fits an 8-bit-ish target and so the
// comparison is a plain fraction. Beyond the range there is no light, so the
// shadow there is nobody's business.
//
// One attachment: this writes a shadow atlas, and the surface buffer belongs
// to the scene pass.
#define F3D_NO_SURFACE_BUFFER
// No fog: shadow_depth.frag gives the reason.
#define F3D_NO_FOG
// --- lib/color.glsl ---
// Colour space helpers and the fragment output interface.
//
// Split out of surface.glsl so a shader that needs no material inputs — the
// normals debug view — can avoid DECLARING the FragInfo uniform block at all.
// That matters more than it looks: reflection metadata reports a block as
// present merely because it was declared, even when the compiled shader binds
// no such buffer, so a declared-but-unused block is indistinguishable from a
// used one until Metal crashes on the bind.
#ifndef COLOR_GLSL_
#define COLOR_GLSL_
precision highp float;
const float kPi = 3.14159265359;
// One varying set shared by every fragment shader, matching mesh.vert.
//
// All five are declared here, including the two the debug models never read: a
// fragment shader whose `in` block disagrees with the vertex shader's `out`
// block fails to link, and there is no partial-match rule to lean on.
in vec3 v_world_position;
in vec3 v_normal;
in vec2 v_texcoord;
in vec4 v_tangent;
in vec4 v_color;
/// Where this fragment is in the level's lightmap. Zero from every vertex
/// stage but `mesh_lightmapped.vert`, and read only by the lit models, which
/// sample a one-texel black there when a material has no map.
in vec2 v_lightmap_uv;
layout(location = 0) out vec4 frag_color;
// The second attachment: what a screen-space effect needs to know about the
// surface it is looking at. World-space normal in rgb, and in a the depth along
// the view axis in world metres — not a window depth; `WriteSurfaceGeometry`
// says at length why not.
//
// Depth travels here rather than in a depth texture because flutter_gpu cannot
// sample one — the same reason the shadow pass writes its depth into a colour
// target. See ARCHITECTURE.md §2.
//
// Guarded, because not every stage that includes this header draws into a
// two-attachment target. The shadow pass draws into one, and a pipeline
// declaring an output its target has no slot for is a mismatch worth avoiding
// rather than discovering.
#ifndef F3D_NO_SURFACE_BUFFER
layout(location = 1) out vec4 frag_surface;
#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;
// **A stage that needs none of this must be able to declare none of it.** On
// Vulkan both stages' descriptors are merged into one set layout, and two
// bindings with the same number in it is not a layout the specification
// allows. A driver may accept it anyway; a Galaxy A55's refuses the pipeline
// with `ErrorUnknown` and no other word, which is how the shadow pass came to
// build everywhere except there — its only uniform block was this one, and it
// landed on the same binding as the vertex stage's first.
#ifndef F3D_NO_FOG
/// Distance fog, in its own block rather than folded into FragInfo.
///
/// Its own because color.glsl is included before FragInfo is declared, and
/// because appending to a block that half a dozen shaders already share is a
/// way to move offsets nobody expected to move. Three vec4s is a cheap price
/// for not touching any of that.
layout(std140) uniform FogInfo {
/// rgb: linear fog colour. w: density per metre, zero for no fog.
vec4 fog;
/// xyz: camera position in world space. Duplicated from FragInfo so this
/// block stands alone; a vec3 is cheaper than a coupling.
vec4 eye;
/// xyz: the direction the camera looks, as a unit vector in world space.
/// w unused.
///
/// Here rather than in a block of its own because it answers the same
/// question [eye] does — where the camera is and which way it faces — and
/// this is the block `color.glsl` can see.
vec4 forward;
}
fog_info;
/// How far this fragment is from the eye, in world metres.
///
/// What the fog fades by. Distance rather than depth, because fog is a
/// property of the air between two points and does not care which way the
/// camera happens to face.
float EyeDistance() { return distance(v_world_position, fog_info.eye.xyz); }
/// How far this fragment is *along the view axis*, in world metres.
///
/// What the surface buffer's alpha holds. Depth rather than distance, and the
/// difference only shows on an orthographic camera — where the rays through
/// the pixels are parallel instead of meeting at the eye, so a distance from
/// the eye names a sphere that the pixel's ray crosses somewhere the reader
/// cannot solve for. A depth along the axis names a plane, which every ray
/// crosses exactly once. See `WorldAtDepth` in `post/ssao.frag` for the
/// reconstruction both projections share.
float ViewDepth() {
return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz);
}
#else // F3D_NO_FOG
// The same two questions, answered without the block: a stage that declares no
// fog has no eye position to measure from either. Stubs rather than a guard at
// every call site, so that what includes this file reads the same whichever
// way it was compiled.
float EyeDistance() { return 0.0; }
float ViewDepth() { return 0.0; }
#endif // F3D_NO_FOG
/// Records the geometry of this fragment for whatever runs after the scene.
///
/// Called from the same place that writes colour, so a surface cannot be lit
/// into the frame without also describing itself — which is the failure that
/// leaves a screen-space effect reflecting whatever was in the buffer before.
///
/// rg: octahedral normal. b: perceptual roughness. a: **depth along the view
/// axis, in world metres** — see [ViewDepth].
///
/// **Not `gl_FragCoord.z`, and that is a defect this channel carried until it
/// was looked at.** Window depth crowds every distant surface into the top of
/// its range — with a near plane of a tenth of a metre, everything past twenty
/// metres lives in the last half a hundredth of `[0, 1]` — and this attachment
/// is a half float, whose steps up there are about five ten-thousandths. So two
/// surfaces half a metre apart at twenty metres stored the *same* number, and
/// every screen-space pass that compares against this channel decided whole
/// bands of pixels by rounding. The occlusion pass drew them: vertical stripes
/// along the lines of constant depth on any wall receding from the camera, on
/// both GPU backends. The software rasteriser kept the channel at full
/// precision and drew the effect correctly, so it was the one that looked
/// wrong against the other two.
///
/// A depth in metres has none of that: the exponent carries the range and the
/// mantissa carries the same relative precision everywhere, which at twenty
/// metres is a centimetre. Both numbers are measured in
/// `flutter3d/test/surface_depth_test.dart`.
///
/// Zero still means nothing was drawn. The attachment is cleared to zero and
/// nothing is drawn in front of the near plane.
void WriteSurfaceGeometry(float roughness) {
#ifndef F3D_NO_SURFACE_BUFFER
// A debug pass takes the buffer over rather than getting one of its own.
// The surface buffer already has an attachment, a viewer and a golden; a
// second one would need all three built before it could answer anything.
if (g_debug_surface_on) {
frag_surface = vec4(g_debug_surface, ViewDepth());
return;
}
frag_surface = vec4(EncodeOctahedral(normalize(v_normal)),
clamp(roughness, 0.0, 1.0), ViewDepth());
#endif
}
/// Fades [color] toward the fog with distance from the eye.
///
/// Exponential rather than linear, because linear fog has a visible plane
/// where it starts and a dungeon corridor is exactly where that shows.
vec3 ApplyFog(vec3 color) {
#ifdef F3D_NO_FOG
return color;
#else
float density = fog_info.fog.w;
if (density <= 0.0) return color;
float d = EyeDistance();
return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0));
#endif
}
/// 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);
}
''',
'ShadowDepthMasked': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;
// The shadow pass for a cut-out caster: write depth, or write nothing —
// `gfx-60n`.
//
// **A separate stage rather than a branch in `shadow_depth.frag`.** Every
// caster that is not cut out keeps the stage it has always had, which is a
// pipeline with no sampler in it and no texture bound per draw. That is worth
// a second entry point twice over: the common path pays nothing, and the
// forty-four golden frames recorded against the old stage cannot move, because
// the old stage is still the one they go through — the same split other
// engines draw here, for the same reason.
//
// **Why the shadow pass has to know about alpha at all.** A leaf card is a
// quad with a texture that is transparent almost everywhere. Depth-only, that
// quad is opaque, so a tree casts the shadow of its bounding rectangles — a
// stack of dark slabs where the eye expects dappled light. Nothing about the
// lit pass can repair it, because by then the shadow map already says the
// ground is in shadow.
//
// The threshold is the material's own `alphaCutoff` and the comparison is the
// same one glTF's MASK mode specifies: alpha below the cutoff is not drawn,
// alpha at or above it is fully drawn. There is no partial coverage here on
// purpose; a shadow map holds one depth per texel, so a half-transparent
// fragment either records or does not.
#define F3D_NO_SURFACE_BUFFER
#define F3D_NO_FOG
// --- lib/color.glsl ---
// Colour space helpers and the fragment output interface.
//
// Split out of surface.glsl so a shader that needs no material inputs — the
// normals debug view — can avoid DECLARING the FragInfo uniform block at all.
// That matters more than it looks: reflection metadata reports a block as
// present merely because it was declared, even when the compiled shader binds
// no such buffer, so a declared-but-unused block is indistinguishable from a
// used one until Metal crashes on the bind.
#ifndef COLOR_GLSL_
#define COLOR_GLSL_
precision highp float;
const float kPi = 3.14159265359;
// One varying set shared by every fragment shader, matching mesh.vert.
//
// All five are declared here, including the two the debug models never read: a
// fragment shader whose `in` block disagrees with the vertex shader's `out`
// block fails to link, and there is no partial-match rule to lean on.
in vec3 v_world_position;
in vec3 v_normal;
in vec2 v_texcoord;
in vec4 v_tangent;
in vec4 v_color;
/// Where this fragment is in the level's lightmap. Zero from every vertex
/// stage but `mesh_lightmapped.vert`, and read only by the lit models, which
/// sample a one-texel black there when a material has no map.
in vec2 v_lightmap_uv;
layout(location = 0) out vec4 frag_color;
// The second attachment: what a screen-space effect needs to know about the
// surface it is looking at. World-space normal in rgb, and in a the depth along
// the view axis in world metres — not a window depth; `WriteSurfaceGeometry`
// says at length why not.
//
// Depth travels here rather than in a depth texture because flutter_gpu cannot
// sample one — the same reason the shadow pass writes its depth into a colour
// target. See ARCHITECTURE.md §2.
//
// Guarded, because not every stage that includes this header draws into a
// two-attachment target. The shadow pass draws into one, and a pipeline
// declaring an output its target has no slot for is a mismatch worth avoiding
// rather than discovering.
#ifndef F3D_NO_SURFACE_BUFFER
layout(location = 1) out vec4 frag_surface;
#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;
// **A stage that needs none of this must be able to declare none of it.** On
// Vulkan both stages' descriptors are merged into one set layout, and two
// bindings with the same number in it is not a layout the specification
// allows. A driver may accept it anyway; a Galaxy A55's refuses the pipeline
// with `ErrorUnknown` and no other word, which is how the shadow pass came to
// build everywhere except there — its only uniform block was this one, and it
// landed on the same binding as the vertex stage's first.
#ifndef F3D_NO_FOG
/// Distance fog, in its own block rather than folded into FragInfo.
///
/// Its own because color.glsl is included before FragInfo is declared, and
/// because appending to a block that half a dozen shaders already share is a
/// way to move offsets nobody expected to move. Three vec4s is a cheap price
/// for not touching any of that.
layout(std140) uniform FogInfo {
/// rgb: linear fog colour. w: density per metre, zero for no fog.
vec4 fog;
/// xyz: camera position in world space. Duplicated from FragInfo so this
/// block stands alone; a vec3 is cheaper than a coupling.
vec4 eye;
/// xyz: the direction the camera looks, as a unit vector in world space.
/// w unused.
///
/// Here rather than in a block of its own because it answers the same
/// question [eye] does — where the camera is and which way it faces — and
/// this is the block `color.glsl` can see.
vec4 forward;
}
fog_info;
/// How far this fragment is from the eye, in world metres.
///
/// What the fog fades by. Distance rather than depth, because fog is a
/// property of the air between two points and does not care which way the
/// camera happens to face.
float EyeDistance() { return distance(v_world_position, fog_info.eye.xyz); }
/// How far this fragment is *along the view axis*, in world metres.
///
/// What the surface buffer's alpha holds. Depth rather than distance, and the
/// difference only shows on an orthographic camera — where the rays through
/// the pixels are parallel instead of meeting at the eye, so a distance from
/// the eye names a sphere that the pixel's ray crosses somewhere the reader
/// cannot solve for. A depth along the axis names a plane, which every ray
/// crosses exactly once. See `WorldAtDepth` in `post/ssao.frag` for the
/// reconstruction both projections share.
float ViewDepth() {
return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz);
}
#else // F3D_NO_FOG
// The same two questions, answered without the block: a stage that declares no
// fog has no eye position to measure from either. Stubs rather than a guard at
// every call site, so that what includes this file reads the same whichever
// way it was compiled.
float EyeDistance() { return 0.0; }
float ViewDepth() { return 0.0; }
#endif // F3D_NO_FOG
/// Records the geometry of this fragment for whatever runs after the scene.
///
/// Called from the same place that writes colour, so a surface cannot be lit
/// into the frame without also describing itself — which is the failure that
/// leaves a screen-space effect reflecting whatever was in the buffer before.
///
/// rg: octahedral normal. b: perceptual roughness. a: **depth along the view
/// axis, in world metres** — see [ViewDepth].
///
/// **Not `gl_FragCoord.z`, and that is a defect this channel carried until it
/// was looked at.** Window depth crowds every distant surface into the top of
/// its range — with a near plane of a tenth of a metre, everything past twenty
/// metres lives in the last half a hundredth of `[0, 1]` — and this attachment
/// is a half float, whose steps up there are about five ten-thousandths. So two
/// surfaces half a metre apart at twenty metres stored the *same* number, and
/// every screen-space pass that compares against this channel decided whole
/// bands of pixels by rounding. The occlusion pass drew them: vertical stripes
/// along the lines of constant depth on any wall receding from the camera, on
/// both GPU backends. The software rasteriser kept the channel at full
/// precision and drew the effect correctly, so it was the one that looked
/// wrong against the other two.
///
/// A depth in metres has none of that: the exponent carries the range and the
/// mantissa carries the same relative precision everywhere, which at twenty
/// metres is a centimetre. Both numbers are measured in
/// `flutter3d/test/surface_depth_test.dart`.
///
/// Zero still means nothing was drawn. The attachment is cleared to zero and
/// nothing is drawn in front of the near plane.
void WriteSurfaceGeometry(float roughness) {
#ifndef F3D_NO_SURFACE_BUFFER
// A debug pass takes the buffer over rather than getting one of its own.
// The surface buffer already has an attachment, a viewer and a golden; a
// second one would need all three built before it could answer anything.
if (g_debug_surface_on) {
frag_surface = vec4(g_debug_surface, ViewDepth());
return;
}
frag_surface = vec4(EncodeOctahedral(normalize(v_normal)),
clamp(roughness, 0.0, 1.0), ViewDepth());
#endif
}
/// Fades [color] toward the fog with distance from the eye.
///
/// Exponential rather than linear, because linear fog has a visible plane
/// where it starts and a dungeon corridor is exactly where that shows.
vec3 ApplyFog(vec3 color) {
#ifdef F3D_NO_FOG
return color;
#else
float density = fog_info.fog.w;
if (density <= 0.0) return color;
float d = EyeDistance();
return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0));
#endif
}
/// 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_
/// The base colour map, whose alpha is the mask. The same texture and the same
/// slot name the lit stages bind, so a caller that has one has it already.
uniform sampler2D base_color_texture;
layout(std140) uniform MaskInfo {
// x: the cutoff, from `Material.alphaCutoff`. y: the base colour's own
// alpha, which glTF multiplies the texture's by, so a material faded to
// nothing casts nothing. z, w: unused.
vec4 mask;
}
mask_info;
void main() {
float alpha = texture(base_color_texture, v_texcoord).a * mask_info.mask.y;
if (alpha < mask_info.mask.x) discard;
frag_color = vec4(gl_FragCoord.z, 0.0, 0.0, 1.0);
}
''',
'ShadowDistanceMasked': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;
// The point-light depth pass for a cut-out caster — `gfx-60n`.
//
// `shadow_distance.frag` with the same mask test `shadow_depth_masked.frag`
// carries, and a separate stage for the same reason: a caster that is not cut
// out keeps a pipeline with no sampler in it.
//
// Both stages exist because both shadow paths record a caster, and a leaf card
// lit by a torch is exactly as wrong as one lit by the sun. Point lights are
// where it shows worst, in fact: a cube face is a ninety-degree frustum with a
// caster close to it, so the slab of a foliage quad fills much more of the
// tile than it would in a cascade.
#define F3D_NO_SURFACE_BUFFER
// No fog: shadow_depth.frag gives the reason.
#define F3D_NO_FOG
// --- lib/color.glsl ---
// Colour space helpers and the fragment output interface.
//
// Split out of surface.glsl so a shader that needs no material inputs — the
// normals debug view — can avoid DECLARING the FragInfo uniform block at all.
// That matters more than it looks: reflection metadata reports a block as
// present merely because it was declared, even when the compiled shader binds
// no such buffer, so a declared-but-unused block is indistinguishable from a
// used one until Metal crashes on the bind.
#ifndef COLOR_GLSL_
#define COLOR_GLSL_
precision highp float;
const float kPi = 3.14159265359;
// One varying set shared by every fragment shader, matching mesh.vert.
//
// All five are declared here, including the two the debug models never read: a
// fragment shader whose `in` block disagrees with the vertex shader's `out`
// block fails to link, and there is no partial-match rule to lean on.
in vec3 v_world_position;
in vec3 v_normal;
in vec2 v_texcoord;
in vec4 v_tangent;
in vec4 v_color;
/// Where this fragment is in the level's lightmap. Zero from every vertex
/// stage but `mesh_lightmapped.vert`, and read only by the lit models, which
/// sample a one-texel black there when a material has no map.
in vec2 v_lightmap_uv;
layout(location = 0) out vec4 frag_color;
// The second attachment: what a screen-space effect needs to know about the
// surface it is looking at. World-space normal in rgb, and in a the depth along
// the view axis in world metres — not a window depth; `WriteSurfaceGeometry`
// says at length why not.
//
// Depth travels here rather than in a depth texture because flutter_gpu cannot
// sample one — the same reason the shadow pass writes its depth into a colour
// target. See ARCHITECTURE.md §2.
//
// Guarded, because not every stage that includes this header draws into a
// two-attachment target. The shadow pass draws into one, and a pipeline
// declaring an output its target has no slot for is a mismatch worth avoiding
// rather than discovering.
#ifndef F3D_NO_SURFACE_BUFFER
layout(location = 1) out vec4 frag_surface;
#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;
// **A stage that needs none of this must be able to declare none of it.** On
// Vulkan both stages' descriptors are merged into one set layout, and two
// bindings with the same number in it is not a layout the specification
// allows. A driver may accept it anyway; a Galaxy A55's refuses the pipeline
// with `ErrorUnknown` and no other word, which is how the shadow pass came to
// build everywhere except there — its only uniform block was this one, and it
// landed on the same binding as the vertex stage's first.
#ifndef F3D_NO_FOG
/// Distance fog, in its own block rather than folded into FragInfo.
///
/// Its own because color.glsl is included before FragInfo is declared, and
/// because appending to a block that half a dozen shaders already share is a
/// way to move offsets nobody expected to move. Three vec4s is a cheap price
/// for not touching any of that.
layout(std140) uniform FogInfo {
/// rgb: linear fog colour. w: density per metre, zero for no fog.
vec4 fog;
/// xyz: camera position in world space. Duplicated from FragInfo so this
/// block stands alone; a vec3 is cheaper than a coupling.
vec4 eye;
/// xyz: the direction the camera looks, as a unit vector in world space.
/// w unused.
///
/// Here rather than in a block of its own because it answers the same
/// question [eye] does — where the camera is and which way it faces — and
/// this is the block `color.glsl` can see.
vec4 forward;
}
fog_info;
/// How far this fragment is from the eye, in world metres.
///
/// What the fog fades by. Distance rather than depth, because fog is a
/// property of the air between two points and does not care which way the
/// camera happens to face.
float EyeDistance() { return distance(v_world_position, fog_info.eye.xyz); }
/// How far this fragment is *along the view axis*, in world metres.
///
/// What the surface buffer's alpha holds. Depth rather than distance, and the
/// difference only shows on an orthographic camera — where the rays through
/// the pixels are parallel instead of meeting at the eye, so a distance from
/// the eye names a sphere that the pixel's ray crosses somewhere the reader
/// cannot solve for. A depth along the axis names a plane, which every ray
/// crosses exactly once. See `WorldAtDepth` in `post/ssao.frag` for the
/// reconstruction both projections share.
float ViewDepth() {
return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz);
}
#else // F3D_NO_FOG
// The same two questions, answered without the block: a stage that declares no
// fog has no eye position to measure from either. Stubs rather than a guard at
// every call site, so that what includes this file reads the same whichever
// way it was compiled.
float EyeDistance() { return 0.0; }
float ViewDepth() { return 0.0; }
#endif // F3D_NO_FOG
/// Records the geometry of this fragment for whatever runs after the scene.
///
/// Called from the same place that writes colour, so a surface cannot be lit
/// into the frame without also describing itself — which is the failure that
/// leaves a screen-space effect reflecting whatever was in the buffer before.
///
/// rg: octahedral normal. b: perceptual roughness. a: **depth along the view
/// axis, in world metres** — see [ViewDepth].
///
/// **Not `gl_FragCoord.z`, and that is a defect this channel carried until it
/// was looked at.** Window depth crowds every distant surface into the top of
/// its range — with a near plane of a tenth of a metre, everything past twenty
/// metres lives in the last half a hundredth of `[0, 1]` — and this attachment
/// is a half float, whose steps up there are about five ten-thousandths. So two
/// surfaces half a metre apart at twenty metres stored the *same* number, and
/// every screen-space pass that compares against this channel decided whole
/// bands of pixels by rounding. The occlusion pass drew them: vertical stripes
/// along the lines of constant depth on any wall receding from the camera, on
/// both GPU backends. The software rasteriser kept the channel at full
/// precision and drew the effect correctly, so it was the one that looked
/// wrong against the other two.
///
/// A depth in metres has none of that: the exponent carries the range and the
/// mantissa carries the same relative precision everywhere, which at twenty
/// metres is a centimetre. Both numbers are measured in
/// `flutter3d/test/surface_depth_test.dart`.
///
/// Zero still means nothing was drawn. The attachment is cleared to zero and
/// nothing is drawn in front of the near plane.
void WriteSurfaceGeometry(float roughness) {
#ifndef F3D_NO_SURFACE_BUFFER
// A debug pass takes the buffer over rather than getting one of its own.
// The surface buffer already has an attachment, a viewer and a golden; a
// second one would need all three built before it could answer anything.
if (g_debug_surface_on) {
frag_surface = vec4(g_debug_surface, ViewDepth());
return;
}
frag_surface = vec4(EncodeOctahedral(normalize(v_normal)),
clamp(roughness, 0.0, 1.0), ViewDepth());
#endif
}
/// Fades [color] toward the fog with distance from the eye.
///
/// Exponential rather than linear, because linear fog has a visible plane
/// where it starts and a dungeon corridor is exactly where that shows.
vec3 ApplyFog(vec3 color) {
#ifdef F3D_NO_FOG
return color;
#else
float density = fog_info.fog.w;
if (density <= 0.0) return color;
float d = EyeDistance();
return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0));
#endif
}
/// 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;
/// The base colour map, whose alpha is the mask.
uniform sampler2D base_color_texture;
layout(std140) uniform MaskInfo {
// x: the cutoff, from `Material.alphaCutoff`. y: the base colour's own
// alpha. z, w: unused.
vec4 mask;
}
mask_info;
void main() {
float alpha = texture(base_color_texture, v_texcoord).a * mask_info.mask.y;
if (alpha < mask_info.mask.x) discard;
float range = max(shadow_light.light.w, 1e-4);
float distance = length(v_world_position - shadow_light.light.xyz);
frag_color = vec4(clamp(distance / range, 0.0, 1.0), 0.0, 0.0, 1.0);
}
''',
'ShadowTileReset': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;
// Clearing one tile of the shadow atlas, by drawing over it.
//
// A render pass clears its whole colour attachment: viewport and scissor bound
// where the rasteriser may write, and neither bounds the load action. That is
// fine while every tile is redrawn every frame, and fatal the moment they are
// not — refreshing one light's face would erase every other face in the atlas.
//
// So the atlas pass loads its previous contents instead of clearing them, and
// a tile that *is* being refreshed is reset by drawing this over it first,
// inside that tile's viewport. A draw is bounded by the viewport where a clear
// is not, which is the whole reason this shader exists.
//
// One, the far end of the range: a texel no caster covers means "nothing
// between the light and its range", which is the right answer for a direction
// with nothing in it. The same value the pass used to clear to.
precision highp float;
in vec2 v_uv;
layout(location = 0) out vec4 frag_color;
void main() {
frag_color = vec4(1.0);
}
''',
'Sky': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;
// The sky, evaluated per pixel from the view ray.
//
// What this buys over a painted dome, which is the thing it replaces: a sun
// disc. A disc is about half a degree across — two, if you are being generous
// about glare — and a dome fine enough to resolve one would need rings a
// fraction of a degree apart. Here the shape is analytic and its size is a
// number, so it costs the same at any angular radius.
//
// It also escapes the fog. `ApplyFog` lives inside `WriteSurface` in
// `lib/color.glsl` and every lit model goes through it, so a dome ten metres
// across is fogged by ten metres of air whether that makes sense or not. This
// stage includes none of that.
//
// **Deliberately not including `lib/color.glsl`.** That header declares the
// five varyings `mesh.vert` emits, and a fragment shader whose inputs disagree
// with its vertex stage's outputs does not link — there is no partial-match
// rule. `shadow_depth.frag` includes it *because* it runs off `mesh.vert`; this
// runs off `sky.vert`, whose varyings are its own.
//
// **The preset arrives on the varyings rather than in a uniform block**, and
// `sky.vert` sets out at length what was measured to make that the design: on
// Impeller a uniform block bound to this pipeline never arrives, in either
// stage, while an attribute does.
precision highp float;
in vec3 v_ray;
in vec4 v_zenith;
in vec4 v_horizon;
in vec4 v_nadir;
in vec4 v_sun;
in vec4 v_glow;
in vec4 v_disc;
layout(location = 0) out vec4 frag_color;
// **The surface buffer is deliberately not written here, and the sentence this
// replaces cost a working sky.**
//
// It used to say: "Writing it costs nothing and does not depend on that. When
// the scene draws into one attachment rather than two, the extra output is
// discarded; the renderer decides whether anyone is listening." Every clause of
// that is wrong on Impeller. Measured, both ways round, in the `sky` golden
// scene:
//
// * one attachment (the usual path — no screen-space effect asked for the
// surface buffer, so the pass multisamples instead) and this shader
// declaring `frag_surface`: **the process dies**, inside Metal, at
// `-[AGXG15XFamilyRenderContext setFragmentBuffer:offset:atIndex:]` with a
// bad address. When it survives long enough to draw, `SkyInfo` and `SkyRay`
// arrive as rubbish, which is a flat maroon sky over a racing circuit.
// * two attachments (`surfaceBuffer: true`), same shader: draws correctly.
//
// `lib/color.glsl` already knew — "a pipeline declaring an output its target has
// no slot for is a mismatch worth avoiding rather than discovering" — and
// guards its own second output behind `F3D_NO_SURFACE_BUFFER` for the shadow
// pass. This file was the one place that declared it anyway.
//
// Nothing is lost by leaving it out. The attachment is cleared to zero and zero
// alpha is exactly what `reflections.frag` reads as "nothing was drawn here" —
// the same answer this shader was writing by hand.
// **No uniform block, and `sky.vert` says at length why.** Its members reach
// this stage as varyings, written on all three vertices of the full-screen
// triangle: the only channel measured to arrive on this pipeline.
void main() {
vec3 direction = normalize(v_ray);
// The gradient, smoothstepped in height rather than linear: the first fifteen
// degrees above the horizon are most of what anybody looks at, and a straight
// ramp spends its range on the part they do not.
float height = clamp(direction.y, -1.0, 1.0);
vec3 far = height >= 0.0 ? v_zenith.rgb : v_nadir.rgb;
float t = abs(height);
t = t * t * (3.0 - 2.0 * t);
vec3 colour = mix(v_horizon.rgb, far, t);
float towards = dot(direction, v_sun.xyz);
// The wide scattering lobe. Guarded, because `pow` of a negative base is
// undefined and a NaN here is a pixel that is black on one backend and white
// on another.
if (towards > 0.0) {
colour += v_glow.rgb * (v_glow.a * pow(towards, v_sun.w));
}
// And the disc itself, added on top of the lobe rather than replacing it: the
// sun is a bright thing seen through the glow around it, not instead of it.
// Its brightness is free to sit above one — this target is HDR, and a sun
// that cannot blow out is a sun bloom has nothing to find.
//
// Guarded, and the guard is not defensive programming. `smoothstep` is
// undefined when its two edges are equal — GLSL says so, and Metal computes
// `(x - e0) / (e1 - e0)`, which is 0/0 and therefore NaN. The two edges here
// are cosines of angles a third of a degree apart, so they are equal for any
// caller who leaves the disc at its default size, and a single NaN channel
// poisons the whole pixel through the tone map. A sky is exactly where that
// is least visible as a NaN and most visible as "the gradient went away".
//
// **The other branch was `0.0`, and that turned the sun off.** A soft edge of
// nothing is a sun with a hard edge — which is a thing to ask for — and the
// answer to it is a step, not an absence. `SkySettings.sample`, which is the
// same model written in Dart, has always drawn one; this drew nothing, so a
// hard-edged sun existed in the fog colour and not in the sky.
float disc = v_disc.y > 0.0
? smoothstep(v_disc.x - v_disc.y, v_disc.x, towards)
: step(v_disc.x, towards);
colour += v_glow.rgb * (disc * v_disc.z);
frag_color = vec4(colour, 1.0);
}
''',
'SkyCube': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;
// A textured sky: the same view ray, sampled out of a cube map.
//
// A second fragment stage rather than a branch inside `sky.frag`, and rather
// than a new vertex stage. The ray `sky.vert` emits is already a world-space
// direction, which is exactly what a cube sampler takes — so the whole of the
// difference between a procedural sky and a photographed one is this file.
//
// A branch would have been the wrong shape twice over. The uniform block would
// have had to carry both descriptions whichever was in use, and every pixel
// would have paid for a sampler bind that half the callers never fill; and a
// shader that declares a sampler nobody binds is a native crash on Metal rather
// than a black texture. Two entry points, one manifest, one pipeline each.
//
// **The faces are +X, −X, +Y, −Y, +Z, −Z.** That order is documented once, on
// `GraphicsDevice.createCubeTextureFromPixels`, and it is what every backend
// here uploads in — Impeller by slice index, WebGL by consecutive face target,
// the software rasteriser by the table in `BoundTexture.sampleCube`. Nothing in
// a picture says whether two of them are transposed, which is why the
// conformance suite draws six known directions against six known colours.
precision highp float;
in vec3 v_ray;
in vec4 v_tint;
layout(location = 0) out vec4 frag_color;
// No second output — see `sky.frag`.
uniform samplerCube sky_texture;
// **No uniform block, and `sky.vert` says at length why.** The tint arrives as
// a varying, written on all three vertices of the full-screen triangle.
void main() {
// Decoded from sRGB, because a cube map is an image and an image is authored
// in display space — the same rule `lib/surface.glsl` applies to a base
// colour texture, and the same rule a vertex colour is exempt from. Without
// this a photographed sky arrives with its midtones lifted, which reads as
// haze rather than as a colour-space mistake.
vec3 texel = texture(sky_texture, normalize(v_ray)).rgb;
vec3 linear = mix(texel / 12.92,
pow((texel + 0.055) / 1.055, vec3(2.4)),
step(vec3(0.04045), texel));
frag_color = vec4(linear * v_tint.rgb, 1.0);
}
''',
'Luminance': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;
// The scene's brightness at low resolution, for the exposure meter to read
// back.
//
// Drawn into a small target — 64×64 — so a readback of it is a few kilobytes
// rather than a frame, and encoded as **log luminance in eight bits**: linear
// values would spend most of the byte on the top stop and nothing on the
// shadows, and an exposure is a stops question. Each texel averages sixteen
// taps across its own footprint of the scene, so the estimate is the mean of
// what it covers rather than one point in it.
//
// What comes out is a picture only in the sense that a histogram is: nothing
// reads it as an image, so which way up it lands is nobody's concern — the
// meter counts texels. See `ExposureMeter` for the other end of the encoding.
precision highp float;
in vec2 v_uv;
layout(location = 0) out vec4 frag_color;
/// The lit scene, linear and unbounded.
uniform sampler2D scene_texture;
layout(std140) uniform LuminanceInfo {
/// x, y: one texel of *this* target, in uv — the footprint each texel
/// averages over. z: the stop the encoding starts at. w: one over how many
/// stops the byte spans.
vec4 params;
}
luminance_info;
/// Rec. 709 luma, the same weights the composite uses.
float Luma(vec3 color) { return dot(color, vec3(0.2126, 0.7152, 0.0722)); }
void main() {
vec2 footprint = luminance_info.params.xy;
float sum = 0.0;
for (int j = 0; j < 4; j++) {
for (int i = 0; i < 4; i++) {
// Four by four, centred: from three eighths of a texel before the
// middle to three eighths after it.
vec2 offset = ((vec2(float(i), float(j)) + 0.5) / 4.0 - 0.5) * footprint;
sum += Luma(texture(scene_texture, v_uv + offset).rgb);
}
}
float mean = sum / 16.0;
// A floor well below anything a scene lights, so black encodes as the first
// stop rather than as minus infinity.
float stops = log2(max(mean, 1e-6));
float encoded =
clamp((stops - luminance_info.params.z) * luminance_info.params.w, 0.0, 1.0);
frag_color = vec4(encoded, encoded, encoded, 1.0);
}
''',
'ObjectId': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;
// The picking pass: the id the renderer gave this node, and nothing else.
//
// A `Normals`-shaped stage — every mesh drawn again through the same vertex
// stages, with a fragment stage that writes a constant instead of a colour —
// into an RGBA8 target one pixel of which is then read back. The id arrives
// as three bytes in [0, 1] so an eight-bit store hands it back exactly; the
// renderer decodes `r + g·256 + b·65536`, and zero is the clear colour, which
// is what "nothing here" reads as.
//
// One attachment, not two: the target is the id texture and there is no
// surface buffer beside it, so the second output is left undeclared the way the
// shadow passes leave it — see `shadow_depth.frag`.
//
// Includes lib/color.glsl rather than lib/surface.glsl for the reason
// `normals.frag` gives: merely declaring FragInfo would leave it visible to
// reflection while the compiled shader binds no buffer for it, and binding
// that phantom block is a native crash on Metal. This stage declares a block of
// its own and reads that.
#define F3D_NO_SURFACE_BUFFER
// No fog: shadow_depth.frag gives the reason.
#define F3D_NO_FOG
// --- lib/color.glsl ---
// Colour space helpers and the fragment output interface.
//
// Split out of surface.glsl so a shader that needs no material inputs — the
// normals debug view — can avoid DECLARING the FragInfo uniform block at all.
// That matters more than it looks: reflection metadata reports a block as
// present merely because it was declared, even when the compiled shader binds
// no such buffer, so a declared-but-unused block is indistinguishable from a
// used one until Metal crashes on the bind.
#ifndef COLOR_GLSL_
#define COLOR_GLSL_
precision highp float;
const float kPi = 3.14159265359;
// One varying set shared by every fragment shader, matching mesh.vert.
//
// All five are declared here, including the two the debug models never read: a
// fragment shader whose `in` block disagrees with the vertex shader's `out`
// block fails to link, and there is no partial-match rule to lean on.
in vec3 v_world_position;
in vec3 v_normal;
in vec2 v_texcoord;
in vec4 v_tangent;
in vec4 v_color;
/// Where this fragment is in the level's lightmap. Zero from every vertex
/// stage but `mesh_lightmapped.vert`, and read only by the lit models, which
/// sample a one-texel black there when a material has no map.
in vec2 v_lightmap_uv;
layout(location = 0) out vec4 frag_color;
// The second attachment: what a screen-space effect needs to know about the
// surface it is looking at. World-space normal in rgb, and in a the depth along
// the view axis in world metres — not a window depth; `WriteSurfaceGeometry`
// says at length why not.
//
// Depth travels here rather than in a depth texture because flutter_gpu cannot
// sample one — the same reason the shadow pass writes its depth into a colour
// target. See ARCHITECTURE.md §2.
//
// Guarded, because not every stage that includes this header draws into a
// two-attachment target. The shadow pass draws into one, and a pipeline
// declaring an output its target has no slot for is a mismatch worth avoiding
// rather than discovering.
#ifndef F3D_NO_SURFACE_BUFFER
layout(location = 1) out vec4 frag_surface;
#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;
// **A stage that needs none of this must be able to declare none of it.** On
// Vulkan both stages' descriptors are merged into one set layout, and two
// bindings with the same number in it is not a layout the specification
// allows. A driver may accept it anyway; a Galaxy A55's refuses the pipeline
// with `ErrorUnknown` and no other word, which is how the shadow pass came to
// build everywhere except there — its only uniform block was this one, and it
// landed on the same binding as the vertex stage's first.
#ifndef F3D_NO_FOG
/// Distance fog, in its own block rather than folded into FragInfo.
///
/// Its own because color.glsl is included before FragInfo is declared, and
/// because appending to a block that half a dozen shaders already share is a
/// way to move offsets nobody expected to move. Three vec4s is a cheap price
/// for not touching any of that.
layout(std140) uniform FogInfo {
/// rgb: linear fog colour. w: density per metre, zero for no fog.
vec4 fog;
/// xyz: camera position in world space. Duplicated from FragInfo so this
/// block stands alone; a vec3 is cheaper than a coupling.
vec4 eye;
/// xyz: the direction the camera looks, as a unit vector in world space.
/// w unused.
///
/// Here rather than in a block of its own because it answers the same
/// question [eye] does — where the camera is and which way it faces — and
/// this is the block `color.glsl` can see.
vec4 forward;
}
fog_info;
/// How far this fragment is from the eye, in world metres.
///
/// What the fog fades by. Distance rather than depth, because fog is a
/// property of the air between two points and does not care which way the
/// camera happens to face.
float EyeDistance() { return distance(v_world_position, fog_info.eye.xyz); }
/// How far this fragment is *along the view axis*, in world metres.
///
/// What the surface buffer's alpha holds. Depth rather than distance, and the
/// difference only shows on an orthographic camera — where the rays through
/// the pixels are parallel instead of meeting at the eye, so a distance from
/// the eye names a sphere that the pixel's ray crosses somewhere the reader
/// cannot solve for. A depth along the axis names a plane, which every ray
/// crosses exactly once. See `WorldAtDepth` in `post/ssao.frag` for the
/// reconstruction both projections share.
float ViewDepth() {
return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz);
}
#else // F3D_NO_FOG
// The same two questions, answered without the block: a stage that declares no
// fog has no eye position to measure from either. Stubs rather than a guard at
// every call site, so that what includes this file reads the same whichever
// way it was compiled.
float EyeDistance() { return 0.0; }
float ViewDepth() { return 0.0; }
#endif // F3D_NO_FOG
/// Records the geometry of this fragment for whatever runs after the scene.
///
/// Called from the same place that writes colour, so a surface cannot be lit
/// into the frame without also describing itself — which is the failure that
/// leaves a screen-space effect reflecting whatever was in the buffer before.
///
/// rg: octahedral normal. b: perceptual roughness. a: **depth along the view
/// axis, in world metres** — see [ViewDepth].
///
/// **Not `gl_FragCoord.z`, and that is a defect this channel carried until it
/// was looked at.** Window depth crowds every distant surface into the top of
/// its range — with a near plane of a tenth of a metre, everything past twenty
/// metres lives in the last half a hundredth of `[0, 1]` — and this attachment
/// is a half float, whose steps up there are about five ten-thousandths. So two
/// surfaces half a metre apart at twenty metres stored the *same* number, and
/// every screen-space pass that compares against this channel decided whole
/// bands of pixels by rounding. The occlusion pass drew them: vertical stripes
/// along the lines of constant depth on any wall receding from the camera, on
/// both GPU backends. The software rasteriser kept the channel at full
/// precision and drew the effect correctly, so it was the one that looked
/// wrong against the other two.
///
/// A depth in metres has none of that: the exponent carries the range and the
/// mantissa carries the same relative precision everywhere, which at twenty
/// metres is a centimetre. Both numbers are measured in
/// `flutter3d/test/surface_depth_test.dart`.
///
/// Zero still means nothing was drawn. The attachment is cleared to zero and
/// nothing is drawn in front of the near plane.
void WriteSurfaceGeometry(float roughness) {
#ifndef F3D_NO_SURFACE_BUFFER
// A debug pass takes the buffer over rather than getting one of its own.
// The surface buffer already has an attachment, a viewer and a golden; a
// second one would need all three built before it could answer anything.
if (g_debug_surface_on) {
frag_surface = vec4(g_debug_surface, ViewDepth());
return;
}
frag_surface = vec4(EncodeOctahedral(normalize(v_normal)),
clamp(roughness, 0.0, 1.0), ViewDepth());
#endif
}
/// Fades [color] toward the fog with distance from the eye.
///
/// Exponential rather than linear, because linear fog has a visible plane
/// where it starts and a dungeon corridor is exactly where that shows.
vec3 ApplyFog(vec3 color) {
#ifdef F3D_NO_FOG
return color;
#else
float density = fog_info.fog.w;
if (density <= 0.0) return color;
float d = EyeDistance();
return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0));
#endif
}
/// 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 IdInfo {
/// xyz: the id, low byte first, each as a fraction of 255. w: unused.
vec4 id;
/// x: the material's alpha cutoff, negative when it is not masked — the
/// encoding `FragInfo.material2.x` uses. y: the tint's alpha, the
/// `base_color.a` the scene pass multiplies the texel by. zw: unused.
vec4 mask;
}
id_info;
/// The same texture the scene pass reads, for the one thing it reads it for
/// here: where a masked material's alpha falls under its cutoff is a hole, and
/// a hole is where the thing behind it is on the screen.
uniform sampler2D base_color_texture;
void main() {
// What the scene pass threw away, thrown away here too, before the write: a
// click through a fence's hole has to answer with what is seen through it.
// The alpha is the one `ReadSurface` computes — texel, tint, vertex colour —
// or the two stages would disagree about where the hole is.
float cutoff = id_info.mask.x;
if (cutoff >= 0.0) {
float alpha = texture(base_color_texture, v_texcoord).a *
id_info.mask.y * v_color.a;
if (alpha < cutoff) discard;
}
frag_color = vec4(id_info.id.xyz, 1.0);
}
''',
'VertexTextureProbe': r'''#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;
// The fragment half of the vertex-texture probe. See `vertex_texture.vert`.
//
// It writes what the *vertex* stage sampled, and that is the whole design: a
// probe whose fragment stage did its own sampling would come back green on a
// backend where the vertex stage read nothing, which is the answer it exists to
// distinguish.
precision highp float;
in vec4 v_sampled;
layout(location = 0) out vec4 frag_color;
void main() {
frag_color = v_sampled;
}
''',
},
);