The material expression language
Open the live demo · Read the source · View on GitHub
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');
}