Skip to content

Materials that inherit

A material is a small file — .omat — that says what a surface is. It is JSON, it is meant to be read and merged by hand, and it states only what it changes. Everything else comes from the material it inherits from.

{
"parent": "materials/painted.omat",
"values": {
"roughness": 0.9,
"metallic": 0.0
},
"maps": {
"baseColour": "textures/brick.ktx2",
"normal": "textures/brick_normal.ktx2"
}
}

Two tables, not one, and the split matters: values are numbers the renderer sets, maps are paths the cook has to follow. Anything that wants to know which textures a project uses reads maps and stops.

The parameter names are a fixed table. A file naming something that isn’t in it is kept — the rest of the file still loads — and the unknown name is reported, so a typo shows up instead of quietly doing nothing.

Group Parameters
Surface shading, blend, culling, doubleSided
Physical baseColour, metallic, roughness, reflectance, clearCoat, clearCoatRoughness, anisotropy, sheenColour, sheenRoughness
Light it gives off emissive, emissiveIntensity, ambientOcclusion, normalScale
How maps are sampled tiling, offset, wrap, filter
Drawing maskThreshold, depthWrite, depthBias, screenMapped
Blending two surfaces blendMode, blendAmount, blendSharpness, blendTiling, blendOffset
Wind windBearing, windSpeed, windStrength

The ones that take a name rather than a number take one of a fixed set: shading is lit, unlit, video or shadowCatcher; blend is opaque, transparent, fade, masked or add; culling is back, front or none; wrap is repeat, clamp or mirror; filter is smooth or sharp; blendMode is none, linear, masked or maskedDepth. Spelt out rather than numbered, because a material is something you read in a diff.

The map slots are baseColour, normal, metallicRoughness, occlusion, emissive, blendBaseColour and blendMask. Each names a project path, and what’s on the end of it should be a cooked texture.

Colours are linear, and baseColour has an alpha. sheenColour and emissive don’t, because a sheen can’t be partly present.

A MaterialDocument is what one file says. A ResolvedMaterial is what a surface finally wears, with its ancestors and its group spent. The library does that once and caches it, so four hundred crates wearing eleven materials walk eleven chains.

import 'package:orblit_scene/orblit_scene.dart';
MaterialLibrary projectMaterials() {
final library = MaterialLibrary();
library.put(
'materials/painted.omat',
const MaterialDocument(
values: {'shading': 'lit', 'roughness': 0.6, 'metallic': 0.0},
),
);
library.put(
'materials/brick.omat',
const MaterialDocument(
parent: 'materials/painted.omat',
values: {'roughness': 0.9},
maps: {'baseColour': 'textures/brick.ktx2'},
),
);
return library;
}
void main() {
final brick = projectMaterials().resolve('materials/brick.omat');
print(brick.number('roughness')); // 0.9, this file's own
print(brick.number('metallic')); // 0.0, inherited
print(brick.maps['baseColour']); // textures/brick.ktx2
print(brick.problems); // empty
}

The order is eldest ancestor first, each descendant over the top, then the group. A parameter nothing in the chain set comes back null, and the renderer’s own default stands in — so a material that says nothing looks the same whether it resolved through ten ancestors or none.

problems holds what went wrong on the way: a parent that doesn’t exist, a chain that loops. Reported rather than thrown, because a material that lost its parent still draws, in the colours it states itself.

Reading one off disk is yours to do — the package has no filesystem in it, on purpose, so the same code runs in the editor, in a cook step and in a browser.

import 'dart:convert';
import 'dart:io';
import 'package:orblit_scene/orblit_scene.dart';
MaterialLibrary read(Directory dir) {
final library = MaterialLibrary();
for (final file in dir.listSync().whereType<File>()) {
if (!file.path.endsWith(materialExtension)) continue;
final json = jsonDecode(file.readAsStringSync()) as Map<String, Object?>;
final load = MaterialDocument.fromJson(json);
for (final problem in load.problems) {
stderr.writeln('${file.path}: $problem');
}
library.put(file.path, load.document);
}
return library;
}

A group is an override laid over a whole set of materials from outside. A material names the group it belongs to; the group’s values go on last.

import 'package:orblit_scene/orblit_scene.dart';
void main() {
final library = MaterialLibrary()
..put(
'materials/brick.omat',
const MaterialDocument(group: 'weathered', values: {'roughness': 0.9}),
)
..putGroup('weathered', const MaterialDocument(values: {'roughness': 0.35}));
print(library.resolve('materials/brick.omat').number('roughness')); // 0.35
}

The group wins, deliberately. A group that lost to every material which had bothered to state a value could override almost nothing, which is the one thing a group is for.

put and putGroup clear the whole resolve cache, not just that entry — anything naming it as a parent resolved through it, and finding those costs more than resolving a project’s handful of materials again.

A look reclothes a scene without editing it. Each entity says only which material it swaps to under each named look; anything with nothing to say keeps what it already wears. A winter look is authored by naming the dozen things that change, not by restating the four hundred that don’t.

import 'package:orblit_scene/orblit_scene.dart';
SceneEntity wall() => SceneEntity(
id: 'wall',
name: 'Wall',
components: {
SceneComponents.material: const MaterialComponent(
asset: 'materials/brick.omat',
looks: {'winter': 'materials/brick_snow.omat'},
),
},
);

That’s the same shape KHR_materials_variants uses, and it survives an export to glTF as a variant.

Which look is showing is stated when the document is staged, not in the document. The same file shown in summer and in winter is the same file; the look is how a viewer is asking to see it, so it can change without anything being edited.

import 'package:orblit_scene/orblit_scene.dart';
import 'package:orblit_stage/orblit_stage.dart';
OrblitDocumentView stage(SceneDocument document, MaterialLibrary materials) {
final view = OrblitDocumentView(document, materials: materials);
view.look = 'winter';
return view;
}

view.looks is every look anything in the scene has something of its own for — the list to put in a menu.

Materials are keyed for the renderer by the material path and the look that decided it, not by entity. A hundred crates wearing one material are one material and one batch; keying by entity would be a hundred compiled instances and a hundred draws that can’t be merged.

  • There’s no material editor. .omat files are written by hand or by a tool you write. The library, the inheritance and the looks all work; the panel to drive them doesn’t exist.
  • Values are per material, not per instance. Two entities wearing brick.omat at different roughnesses need two materials today. Per-instance overrides cross the C ABI and every platform shim, so they’re a piece of work on their own.
  • Nothing validates a map path against the project. A material naming a texture that isn’t there resolves fine and reports nothing; the miss shows up when the cook runs.