flutter3d
Showcase Changelog 38 packages API reference

The material expression language

since 0.7.0 Shading and materials

A material written in this language reads like the GLSL it compiles down to: clamp, pow, the swizzles and the broadcasting rules all mean what they mean there, because the emitted shader has to read like the source it came from. What a material may not do is declare a uniform block, sample a texture the engine does not bind, or loop. Those are refusals with a sentence attached, not omissions.

Step 1: A material, as source #

Rim lighting: a glow that grows at the edge of a surface as it turns away from the eye. Two parameters, one input read from the surface, one returned colour.

    const String source = '''
material RimLight {
  param float rimPower = 2.0;
  param vec3 rimColor = vec3(0.2, 0.6, 1.0);

  fragment {
    let facing = clamp(nDotV, 0.0, 1.0);
    let rim = pow(1.0 - facing, rimPower);
    return vec4(albedo + rimColor * rim, alpha);
  }
}
''';
    final MaterialProgram program = parseMaterial(source);

Step 2: Pick a variant, and emit GLSL #

specialiseMaterial folds a variant's values into the parsed tree; a parameter nobody set keeps its default. emitMaterialFragment turns the result into the .frag source the shader build already compiles.

_specialised = specialiseMaterial(
  program,
  const MaterialVariant('RimLight_sharp', {
    'rimPower': <double>[_rimPower],
  }),
);
_glsl = emitMaterialFragment(_specialised);

Step 3: Run it without a GPU #

The same tree emitMaterialFragment reads from is also something evaluateMaterial can run directly, one fragment at a time, in plain Dart. This is what backends with no shading language read instead of GLSL.

_shaded = evaluateMaterial(
  _specialised,
  MaterialSurfaceValues(
    inputs: <String, List<double>>{
      'albedo': <double>[_albedo0, _albedo1, _albedo2],
      'alpha': <double>[_alpha],
      'nDotV': <double>[_nDotV],
    },
    sample: (MaterialTextureSlot slot, double u, double v) =>
        throw StateError('this material declares no texture'),
  ),
);

Step 4: The report #

Which surface inputs the body actually reads, what it evaluates to at one sample point, and the GLSL that came out of the same source.

String _report() =>
    'inputs this body reads: ${_specialised.inputsUsed.toList()..sort()}\n\n'
    'evaluated at nDotV=$_nDotV: $_shaded\n\n'
    '$_glsl';

Step 5: What this page checks #

The evaluated colour has to match the rim-lighting formula worked out by hand, and the emitted GLSL has to actually have an entry point.

if (!_specialised.inputsUsed.containsAll(<String>[
  'albedo',
  'alpha',
  'nDotV',
])) {
  throw StateError('the body should read albedo, alpha and nDotV');
}
final double facing = _nDotV.clamp(0.0, 1.0);
final double rim = math.pow(1.0 - facing, _rimPower).toDouble();
final List<double> expected = <double>[
  _albedo0 + 0.2 * rim,
  _albedo1 + 0.6 * rim,
  _albedo2 + 1.0 * rim,
  _alpha,
];
for (var i = 0; i < expected.length; i++) {
  if ((_shaded[i] - expected[i]).abs() > 1e-9) {
    throw StateError('evaluateMaterial disagrees with the hand-worked sum');
  }
}
if (!_glsl.contains('void main()')) {
  throw StateError('the emitted fragment has no entry point');
}
if (frame.drawCalls < 1) {
  throw StateError('the ball was not drawn');
}