Custom materials
When PhysicallyBasedMaterial and UnlitMaterial are not enough, you write your own shader. The supported way to do that is the .fmat format. You declare typed parameters and a small Surface() function in GLSL (plus an optional Vertex() function to displace and animate geometry), a build hook compiles it, and you load the result by source path. There is no uniform packing to get right by hand, parameters are type checked, and you can opt into the engine’s physically based lighting.
This guide assumes you have run the build hook setup once (dart run flutter_scene:init plus the two flutter config flags). See Set up the build hook if you have not.
A minimal material
Section titled “A minimal material”A .fmat file has two blocks. The material { } block is metadata (a name, render state, and the parameter list), and the fragment { } block holds the GLSL Surface() function.
material { name: "Glow", shading_model: unlit, parameters: [ { type: vec4, name: color, hint: source_color, default: [0.2, 0.6, 1.0, 1.0] }, { type: float, name: falloff, hint: range(0.5, 8, 0.1), default: 3.0 }, ],}
fragment { void Surface(inout MaterialInputs material) { vec3 normal = GetWorldNormal(); vec3 view_dir = GetViewDirection(); float fresnel = pow(1.0 - max(dot(normal, view_dir), 0.0), material_params.falloff); material.base_color = vec4(material_params.color.rgb * fresnel, 1.0); PrepareMaterial(material); }}Load it by its source path and set its parameters by name.
final glow = await loadFmatMaterial('assets/glow.fmat');glow.parameters ..setColor('color', const Color(0xFF33A0FF)) ..setFloat('falloff', 3.0);
node.mesh!.primitives.first.material = glow;loadFmatMaterial returns a PreprocessedMaterial. Everything else on this page builds on those two blocks.
Parameters
Section titled “Parameters”Each parameter is { type, name, hint?, default? }. Scalar and vector types (float, int, vec2, vec3, vec4, mat4) are packed into a uniform block, and you read them in the shader as material_params.<name>. Sampler types (sampler2d, samplerCube) are read by their bare name.
You set them from Dart through material.parameters, which is type checked and name addressed. There are no std140 offsets to compute, and a wrong-typed value throws instead of corrupting the block.
glow.parameters ..setFloat('falloff', 3.0) ..setVec4('color', vm.Vector4(0.2, 0.6, 1.0, 1.0)) ..setColor('color', const Color(0xFF33A0FF)) // sRGB-decoded if source_color ..setTexture('base_color_texture', myTexture);Hints add semantics. source_color marks a color as sRGB-authored, so setColor decodes it to linear. range(min, max, step) records a bounded numeric range for tooling. The default_white / default_black / default_normal / default_transparent sampler hints pick the placeholder texture used until you set one, so an unset sampler still renders. Defaults apply when the material is constructed, so an unset parameter renders sensibly.
Unlit and lit shading
Section titled “Unlit and lit shading”The shading_model decides what your Surface() produces.
An unlit material writes the final color straight into material.base_color. The Glow material above is unlit. This is the path for fully custom shading, toon shading, stylized looks, anything where you own the final color.
A lit material fills a surface description and the engine lights it with image-based lighting and the scene’s directional light (with shadows). You set base_color, metallic, roughness, normal, occlusion, and emissive on the MaterialInputs.
struct MaterialInputs { vec4 base_color; // linear rgb, straight (non-premultiplied) alpha vec3 normal; // world-space shading normal vec3 emissive; // linear emissive radiance float metallic; // 0 dielectric .. 1 conductor float roughness; // perceptual roughness, 0..1 float occlusion; // ambient occlusion, 1 = unoccluded};The material below is lit. It keeps a near-black base and drives emissive with an animated time parameter, so bands of glow sweep the surface while the environment still glares off it.
Always call PrepareMaterial(material) before returning from Surface().
Engine inputs
Section titled “Engine inputs”Read the engine’s per-fragment values through accessors rather than raw varyings.
vec3 GetWorldPosition(); // world-space fragment positionvec3 GetWorldNormal(); // normalized world-space geometric normalvec3 GetViewDirection(); // normalized direction toward the cameravec2 GetUV0(); // primary texture coordinatesvec4 GetVertexColor(); // interpolated per-vertex color (white if none)The engine’s standard GLSL helpers are included for you, including SRGBToLinear, the Cook-Torrance BRDF pieces, PerturbNormal, and SamplePrefilteredRadiance.
The vertex stage
Section titled “The vertex stage”A material may add an optional vertex { } block to customize the vertex stage, displacing geometry, animating it, or reshaping normals. You write one function, and the engine runs it on every mesh type and pass (static, skinned, and the depth/shadow pass), with skinning already applied by the time it runs, so you never branch on the mesh type.
vertex { void Vertex(inout VertexInputs vertex) { // Read and modify the vertex, in place. }}VertexInputs carries the vertex through the stage.
struct VertexInputs { vec3 position; // object space (post-skinning on a skinned mesh) vec3 normal; // object space vec3 world_position; // world space, after the model/skin transform vec3 world_normal; // world space vec2 uv; vec4 color; vec3 camera_position; // read-only, world space};Write world_position to displace geometry (the engine projects it to clip space after Vertex() returns) and world_normal to change how the surface is shaded. Parameters are available in Vertex() just as in Surface(), so one parameter drives both stages. The demo below is the classic false-horizon world curve, a two-line displacement that bends everything down with distance from the camera.
vertex { void Vertex(inout VertexInputs vertex) { vec3 rel = vertex.world_position - vertex.camera_position; vertex.world_position.y -= material_params.curvature * dot(rel.xz, rel.xz); }}When reshaping normals, prefer perturbing the provided value (vertex.world_normal = normalize(vertex.world_normal + delta)) over assigning a fresh one; it keeps the mesh normal meaningful.
Varyings, vertex to fragment
Section titled “Varyings, vertex to fragment”Declare named interpolants in a varyings list; Vertex() writes them and Surface() reads them, by name. The compiler generates the matching declarations, so you never pick a location. The road demo above uses one to dim geometry as it curves away.
material { name: "Curve", varyings: [ { type: float, name: curve_fade } ], // float/vec2/vec3/vec4}vertex { void Vertex(inout VertexInputs vertex) { /* ... */ curve_fade = ...; }}fragment { void Surface(inout MaterialInputs material) { material.base_color.rgb *= mix(1.0, 0.4, curve_fade); PrepareMaterial(material); }}Custom vertex attributes, mesh to vertex
Section titled “Custom vertex attributes, mesh to vertex”Declare named per-vertex inputs in an attributes list, and the mesh supplies the data, matched by name. Vertex() reads each by its bare name.
material { name: "Waves", attributes: [ { type: float, name: wave_seed } ], // float/vec2/vec3/vec4}vertex { void Vertex(inout VertexInputs vertex) { vertex.world_position.y += sin(material_params.time + wave_seed); }}Supply the data on the geometry, one value per vertex.
geometry.setCustomAttribute('wave_seed', seeds, components: 1);Custom attributes work on unskinned geometry (MeshGeometry and the built-in primitives); skinned meshes do not support them yet. The depth/shadow pass fetches only positions, so an attribute reads zero there, meaning attribute-driven displacement is not reflected in cast shadows, while displacement driven by world_position and parameters is.
The demo below puts the whole vertex surface together in one material, animated wave displacement plus the world curve, a wave_seed attribute for organic motion, an analytic normal write the engine lights, and two varyings coloring crest foam and the horizon fade.
The output contract
Section titled “The output contract”A material outputs linear HDR premultiplied by alpha. Flutter Scene renders into a floating-point scene target and then applies exposure, the tone-mapping operator, and the display encoding in one resolve pass. So do not tone-map or gamma-encode in your shader, and premultiply rgb by alpha. Values above 1.0 are fine, the tone curve rolls them off. When you sample an sRGB texture, linearize it first with SRGBToLinear. A .fmat material gets the premultiplied output for free. This is the same contract the built-in materials follow (see Materials).
Render state
Section titled “Render state”The material block declares render state. culling is back (the default), front, or none (double sided). blending is opaque (the default) or alpha, which routes the material through the depth-sorted translucent pass and blends it premultiplied source-over.
material { name: "Glass", shading_model: lit, blending: alpha, culling: none, parameters: [ /* ... */ ],}Animated parameters
Section titled “Animated parameters”A parameter is just a value you set from Dart, so animate one by writing it each frame. The lit demo above declares a float time and advances it from the view’s tick.
SceneView( scene, onTick: (elapsed, deltaSeconds) { material.parameters.setFloat('time', elapsed.inMicroseconds / 1e6); },);Hot reload
Section titled “Hot reload”A .fmat loaded with loadFmatMaterial hot reloads in place. Render the scene through a SceneView, and editing the .fmat (its GLSL body, render state, or parameter defaults) updates the running app with no restart. A value you set at runtime is preserved, an unset parameter takes the edited default. Hot reload is debug only.
Custom skies
Section titled “Custom skies”A .fmat can also author a procedural sky. Instead of fragment { }, it has a sky { vec3 Sky(vec3 direction) } block, and loadFmatSky returns a PreprocessedSky you assign to the scene’s skybox. See Lighting and environment for the full workflow and a demo.
The escape hatch: ShaderMaterial
Section titled “The escape hatch: ShaderMaterial”ShaderMaterial runs a complete raw fragment shader you write, with your own uniform blocks and samplers bound by name and packed std140 by hand. Reach for it only when you need a shader shape the .fmat format does not cover yet, since it gives up the type checking, automatic packing, and hot reload that .fmat provides.
Key API
Section titled “Key API”loadFmatMaterial, PreprocessedMaterial, MaterialParameters, loadFmatSky, PreprocessedSky, and ShaderMaterial.