Skip to content

Material

A Material bundles a Shader with the glTF 2.0 PBR-MR field set (base color, metallic, roughness, emissive, normal scale, occlusion strength, alpha cutoff, plus the matching set of optional textures) and exposes each as a builder-style setter.

The default Material::pbr constructs the bundle from FragmentColor’s built-in physically-based shader (Cook-Torrance specular with GGX + Smith + Schlick, Lambertian diffuse with energy conservation, glTF-spec texture sampling on top of the factor uniforms), pre-populated with sensible defaults. The five glTF texture slots start unbound; the renderer resolves each one to a 1×1 default from its lazy texture cache the first time a Pass renders the Material, so the Material’s shader has every binding it needs without any further setup. Material::pbr returns Result because the helper snippets it composes (mesh/transform, material/pbr) must be available at build time. They ship in the default shaders-all feature, so this is only an error path for slim opt-out builds. You can also call Material::custom(shader) to wrap your own shader; the same setters apply best-effort, no-op-ing for uniform paths the shader doesn’t declare.

Materials are typically combined with a Mesh into a Model, which is the object you actually add to a Pass. The Material itself doesn’t render anything on its own.

Material clones (and every setter’s return value) point at the same inner shader. Mutating one handle changes the state every other handle reads:

let red = Material::pbr().base_color([1.0, 0.0, 0.0, 1.0]);
let same = red.clone();
same.base_color([0.0, 1.0, 0.0, 1.0]); // both `red` and `same` now read green

Same goes for the chained-setter return: Material::pbr().metallic(...) gives you back a handle to the same shader, not a fresh copy. This is the mechanic that makes setters chain cheaply and lets one Material drive hundreds of Models without per-instance shader duplication.

When you genuinely need an independent material, build a fresh Material::pbr() (and re-set the factor / texture slots you care about). The “many handles to one shader” share semantics is the right default; the explicit-fresh-Material path is the escape hatch.

  • Factor uniforms (base color, metallic, …) are stored on the shader as material.<name> fields and read every frame. They’re per-Material, so many Models that share a Material share these values.
  • Texture bindings (base color texture, …) are stored under the canonical glTF binding names (base_color_map, metallic_roughness_map, normal_map, occlusion_map, emissive_map) and sampled by the default shader. Unset slots resolve to the renderer’s 1×1 defaults so the shader’s bind group is always complete.
  • Per-Model transform rides the per-instance vertex attribute stream at locations 3..6 (four vec4<f32> columns of mat4x4<f32>), populated by Model::sync_transform. It’s not a Material uniform, so Models sharing one Material don’t collide on a mesh.model slot.

alpha_mode (Opaque / Mask / Blend) and double_sided are wired through to the pipeline. See Material::alpha_mode and Material::double_sided. Mask mode uses material.alpha_cutoff to discard transparent fragments; Blend uses standard SrcAlpha/OneMinusSrcAlpha over-blend with depth-write turned off.

Tangent-space TBN normal mapping is supported via the default PBR shader: the sampled normal is decoded from the stored [0, 1] bytes, the XY components are scaled by material.normal_scale, and the result is rotated from tangent space into world space using the per-vertex tangent (with the glTF tangent.w handedness flag preserved). For custom TBN beyond the bundled implementation, use Material::custom.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::{Material, Mesh, Model, Vertex};
let mesh = Mesh::new();
mesh.add_vertex(
Vertex::pbr([0.0, 0.5, 0.0]).set(Vertex::UV0, [0.5, 1.0]),
);
let material = Material::pbr()
.base_color([0.85, 0.2, 0.2, 1.0])
.metallic(0.0)
.roughness(0.4)
.emissive([0.0, 0.0, 0.05]);
let model = Model::new(mesh, material);
3 collapsed lines
let _model = model;
Ok(())
}

Sets the alpha threshold used by AlphaMode::Mask. Fragments whose base_color.a falls below this value are discarded in the fragment shader. Stored on material.alpha_cutoff; default 0.5, matching glTF 2.0.

Only AlphaMode::Mask reads this value; in Opaque and Blend modes the fragment shader ignores it. Use it together with Material::alpha_mode when you want hard-edged cut-out transparency (foliage, chain-link, decals).

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::Material;
let foliage = Material::pbr().alpha_cutoff(0.3);
3 collapsed lines
let _foliage = foliage;
Ok(())
}

Set how the renderer interprets the alpha channel of the material’s output. Mirrors the glTF 2.0 alphaMode semantics. Pipeline state is baked into the WebGPU pipeline, so different AlphaMode values for the same shader cache to distinct pipelines.

The three variants:

  • AlphaMode::Opaque (default): depth-test is on, blending is off. Alpha is written out but ignored by the framebuffer. Pick this for solid surfaces; it’s the cheapest option and the only one that lets the depth buffer fully describe the geometry.
  • AlphaMode::Mask: depth-test is on, blending is off, but the fragment shader discards any pixel whose material.base_color.a falls below material.alpha_cutoff. The classic foliage / chain-link / decal trick: keeps the perf profile of opaque geometry (no order dependence, full depth writes) while letting silhouettes punch out hard-edged transparency. Tune the cut-off with Material::alpha_cutoff.
  • AlphaMode::Blend: depth-test stays on but depth-write turns off, and the color target uses standard SrcAlpha / OneMinusSrcAlpha over-blend. Use for glass, smoke, fades, decals that need soft edges. The renderer sorts blend Models on the Pass back-to-front by eye-space Z before drawing, using the Camera attached via Pass::add(&camera). Translucent surfaces over-blend in the right order without the caller managing it. Limitation: sorts by per-Model AABB centroid (not per-fragment), so self-intersecting or interpenetrating translucent meshes can still show artifacts. Cross-Material interleaving works correctly: translucent draws across every shader in the Pass merge into one globally-sorted back-to-front pass.

This is a pipeline-state flag. Changing it forces the renderer to rebuild the pipeline for the affected (shader, alpha_mode, double_sided) key. Switching it every frame is fine in practice (the second build hits the cache from then on) but avoid toggling per draw call inside a frame.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::{AlphaMode, Material};
let foliage = Material::pbr()
.alpha_mode(AlphaMode::Mask)
.alpha_cutoff(0.3);
let glass = Material::pbr()
.base_color([0.9, 0.95, 1.0, 0.25])
.alpha_mode(AlphaMode::Blend);
let solid = Material::pbr().alpha_mode(AlphaMode::Opaque);
3 collapsed lines
let _ = (foliage, glass, solid);
Ok(())
}

Set the base color factor (linear RGBA). For dielectrics this is the diffuse albedo; for metals it’s the F0 reflectance. Alpha goes through the fragment output unchanged.

Maps to the material.base_color uniform on the underlying shader. Default is [1, 1, 1, 1].

Returns a handle to the same Material (Arc-shared) so calls chain. See Material: Cloning is an Arc-share for what that means for mutation visibility.

1 collapsed line
async fn run() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::{Material, Renderer};
let renderer = Renderer::new();
let red = Material::pbr().base_color([1.0, 0.2, 0.2, 1.0]);
4 collapsed lines
let _ = red;
Ok(())
}
fn main() -> Result<(), Box<dyn std::error::Error>> { pollster::block_on(run()) }

Bind a texture to the base_color_map slot. The default PBR shader samples it in fs_main and multiplies by the factor. The albedo for each fragment is material.base_color * textureSample(base_color_map, sampler, in.uv).

Accepts any Into<TextureInput>:

  • A pre-built &Texture. The setter stores the texture’s ID immediately and the GPU texture stays Arc-shared across every Material that points at it. Passing the same &shared_texture to N Materials produces one GPU upload and N shader-uniform references: the cheap way to reuse one texture map across a scene full of objects without paying for N texture allocations.
  • Bytes / path / URL / DynamicImage. The setter queues the input on the Material’s Shader, and the renderer drains queued uploads on the first Renderer::render (or earlier via the explicit Renderer::load).

Unset, this slot resolves to a 1×1 white default the renderer hands out lazily, so calling Material::pbr() without binding a texture renders correctly under the factor alone.

The setter itself is infallible: it queues the upload (lazy path) or takes an Arc-clone (eager path) and returns. Failures from the lazy path (file not found, decode error, unsupported format) surface when the renderer actually drains the queue: either at first render or when you call Renderer::load(&material).await. Until then the Material renders against its 1×1 default and a log::warn! line names the slot.

For deterministic error handling, pre-build the texture eagerly: renderer.create_texture(path).await? returns a Texture that can’t fail at setter time, and Material::base_color_texture(&texture) takes an Arc-shared reference. The lazy path stays useful when the URL / path resolves at render time but the caller doesn’t want to await yet.

1 collapsed line
async fn run() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::{Material, Renderer};
let renderer = Renderer::new();
let albedo_bytes: Vec<u8> = vec![
255, 200, 120, 255,
255, 240, 180, 255,
230, 180, 100, 255,
255, 220, 150, 255,
];
let albedo = renderer.create_texture((albedo_bytes, [2, 2])).await?;
// Every Material that points at `albedo` reuses the same uploaded GPU
// texture; passing the same handle into N Material instances costs one
// upload and N shader-uniform references.
let blob = Material::pbr().base_color_texture(&albedo);
4 collapsed lines
let _ = blob;
Ok(())
}
fn main() -> Result<(), Box<dyn std::error::Error>> { pollster::block_on(run()) }

Wrap an arbitrary Shader as a Material so it can be paired with a Mesh to form a Model. The PBR-named setters (base_color, metallic, …) still work; they call shader.set(...) under matching uniform paths (material.base_color, etc.). If your shader doesn’t declare those uniforms the setters log-warn and return self unchanged; set custom uniforms directly via material.shader().set(...).

Material::custom is the escape hatch for any shading model that isn’t glTF-spec PBR: toon shading, flat unlit colors, wireframe debug views, volumetric or post-process effects that you want to attach to a Mesh.

What custom shaders should declare for full Material-style ergonomics

Section titled “What custom shaders should declare for full Material-style ergonomics”

Optional but recommended:

  • struct PbrMaterial { base_color: vec4<f32>, … } at @group(1) @binding(1) bound as material. This lets the PBR factor setters work as-is.
  • struct MeshTransform { model: mat4x4<f32>, … } at @group(1) @binding(0) bound as mesh. This lets Model::transform / translate / rotate / scale work as-is.

Without these the Material is still functional; only the high-level setters become no-ops.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::{Material, Shader};
let wireframe = Shader::new(r#"
struct MeshTransform { model: mat4x4<f32> }
struct Camera { view_proj: mat4x4<f32>, position: vec3<f32> }
@group(0) @binding(0) var<uniform> camera: Camera;
@group(1) @binding(0) var<uniform> mesh: MeshTransform;
@vertex
fn vs_main(@location(0) p: vec3<f32>) -> @builtin(position) vec4<f32> {
return camera.view_proj * mesh.model * vec4<f32>(p, 1.0);
}
@fragment fn fs_main() -> @location(0) vec4<f32> {
return vec4<f32>(0.0, 1.0, 0.4, 1.0);
}
"#)?;
let wire_mat = Material::custom(wireframe);
3 collapsed lines
let _ = wire_mat;
Ok(())
}

Toggle backface culling for this material’s pipeline. When true, the renderer skips its default back-face cull and draws both faces of every triangle. When false (the default), back faces are culled, the standard CCW-winding-is-front convention.

Mirrors glTF 2.0’s doubleSided. Reach for it on thin geometry that legitimately reads from both sides: a single-quad leaf card, a cloth banner, decals applied to non-closed meshes, or any authored asset that comes out of a tool that didn’t bother to mirror its back faces. For closed solid geometry, leave it off; the culled back faces are a free ~50% fragment-shader saving on every draw.

Like alpha_mode, this is a pipeline-state flag and bakes into the WebGPU pipeline. The renderer caches a separate pipeline per (shader, alpha_mode, double_sided) key, so toggling it doesn’t fight the shader cache as long as you stay on a small set of values.

1 collapsed line
async fn run() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::{AlphaMode, Material, Renderer};
let renderer = Renderer::new();
// Leaf cards: thin, single-quad geometry; needs both sides + alpha cut-out.
let leaf = Material::pbr()
.double_sided(true)
.alpha_mode(AlphaMode::Mask)
.alpha_cutoff(0.5);
// Default is single-sided — back-face culling on.
let solid_mesh = Material::pbr().double_sided(false);
4 collapsed lines
let _ = (leaf, solid_mesh);
Ok(())
}
fn main() -> Result<(), Box<dyn std::error::Error>> { pollster::block_on(run()) }

Set the emissive factor (linear RGB): light the surface emits regardless of incident illumination. Added on top of the PBR shading and clamped only by the output format; for tonemapped pipelines use values in [0, 1], for HDR you can go beyond.

Maps to the material.emissive uniform. Default is [0, 0, 0].

1 collapsed line
async fn run() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::{Material, Renderer};
let renderer = Renderer::new();
let lava = Material::pbr()
.base_color([0.1, 0.05, 0.0, 1.0])
.emissive([1.5, 0.4, 0.1]);
4 collapsed lines
let _ = lava;
Ok(())
}
fn main() -> Result<(), Box<dyn std::error::Error>> { pollster::block_on(run()) }

Bind an emissive map to the canonical emissive_map slot. The default PBR shader samples it in fs_main and multiplies by the factor: per-fragment emission is material.emissive * textureSample(emissive_map, sampler, in.uv).rgb.

Unset, this slot resolves to a 1×1 white default so the multiplied emission falls back to the material.emissive factor as-is.

1 collapsed line
async fn run() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::{Material, Renderer};
let renderer = Renderer::new();
let glow_bytes: Vec<u8> = vec![
255, 0, 0, 255,
255, 0, 0, 255,
255, 0, 0, 255,
255, 0, 0, 255,
];
let glow = renderer.create_texture((glow_bytes, [2, 2])).await?;
let mat = Material::pbr()
.emissive([0.8, 0.0, 0.0])
.emissive_texture(&glow);
4 collapsed lines
let _ = mat;
Ok(())
}
fn main() -> Result<(), Box<dyn std::error::Error>> { pollster::block_on(run()) }

Set the metallic factor in [0, 1]. 0 is a pure dielectric (uses the default F0 of 0.04), 1 is a pure metal (F0 = base_color). Values in between linearly interpolate.

Maps to the material.metallic uniform. Default is 0.0 (dielectric); Material::pbr deviates from the glTF spec’s 1.0 default here so the out-of-the-box surface reads as a clean dielectric rather than dark gunmetal under the factor-driven defaults.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::Material;
let chrome = Material::pbr().metallic(1.0).roughness(0.05);
3 collapsed lines
let _chrome = chrome;
Ok(())
}

Bind a texture to the canonical metallic_roughness_map slot. Following glTF 2.0, the green channel encodes per-fragment roughness, the blue channel encodes metallic. Both are multiplied by their respective factors at sample time inside the default PBR shader.

Unset, this slot resolves to a 1×1 default with (R=0, G=1, B=1, A=1) so the factor multiplication passes both material.metallic and material.roughness through unchanged.

1 collapsed line
async fn run() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::{Material, Renderer};
let renderer = Renderer::new();
let mr_map_bytes: Vec<u8> = vec![
0, 200, 50, 255,
0, 240, 80, 255,
0, 180, 30, 255,
0, 220, 60, 255,
];
let mr_map = renderer.create_texture((mr_map_bytes, [2, 2])).await?;
let mat = Material::pbr().metallic_roughness_texture(&mr_map);
4 collapsed lines
let _ = mat;
Ok(())
}
fn main() -> Result<(), Box<dyn std::error::Error>> { pollster::block_on(run()) }

Scales the per-fragment normal perturbation read from a tangent-space normal map, mirroring glTF 2.0’s normalTextureInfo.scale. Higher values exaggerate the bumps; values below 1 soften them.

Maps to the material.normal_scale uniform. Default is 1.0. The default PBR shader samples normal_map, decodes the bytes, and scales the XY perturbation by this value before combining with the world-space normal.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::Material;
let detailed = Material::pbr().normal_scale(1.5);
3 collapsed lines
let _detailed = detailed;
Ok(())
}

Bind a tangent-space normal map to the canonical normal_map slot. The default PBR shader samples the map in fs_main, decodes the stored [0, 1] bytes into [-1, 1] floats, scales the XY components by material.normal_scale, and rotates the perturbed normal from tangent space into world space using the per-vertex tangent (the glTF tangent.w handedness flag is preserved).

Unset, this slot resolves to a 1×1 flat tangent-space default (128, 128, 255, 255) so the decoded normal is (0, 0, 1) and the world normal passes through unchanged.

1 collapsed line
async fn run() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::{Material, Renderer};
let renderer = Renderer::new();
let normal_map_bytes: Vec<u8> = vec![
128, 128, 255, 255,
128, 128, 255, 255,
128, 128, 255, 255,
128, 128, 255, 255,
];
let normal_map = renderer.create_texture((normal_map_bytes, [2, 2])).await?;
let mat = Material::pbr().normal_texture(&normal_map).normal_scale(1.2);
4 collapsed lines
let _ = mat;
Ok(())
}
fn main() -> Result<(), Box<dyn std::error::Error>> { pollster::block_on(run()) }

Blends the per-fragment ambient-occlusion factor (read from an occlusion map) toward 1.0, matching glTF 2.0’s occlusionTextureInfo.strength. 1.0 uses the map’s value as-is; 0.0 ignores the map entirely.

Maps to the material.occlusion_strength uniform. Default is 1.0. The default PBR shader samples occlusion_map.r and mix(1.0, sampled, strength)s the result into the diffuse term.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::Material;
let crevices = Material::pbr().occlusion_strength(0.8);
3 collapsed lines
let _crevices = crevices;
Ok(())
}

Bind an ambient-occlusion map to the canonical occlusion_map slot. Following glTF 2.0, only the red channel is read; the sampled value is blended toward 1.0 by 1 - material.occlusion_strength inside the default PBR shader, then multiplied into the diffuse term.

Unset, this slot resolves to a 1×1 white default so the multiplied occlusion factor is 1.0 and no darkening is applied.

1 collapsed line
async fn run() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::{Material, Renderer};
let renderer = Renderer::new();
let ao_bytes: Vec<u8> = vec![
220, 0, 0, 255,
180, 0, 0, 255,
200, 0, 0, 255,
160, 0, 0, 255,
];
let ao = renderer.create_texture((ao_bytes, [2, 2])).await?;
let mat = Material::pbr().occlusion_texture(&ao);
4 collapsed lines
let _ = mat;
Ok(())
}
fn main() -> Result<(), Box<dyn std::error::Error>> { pollster::block_on(run()) }

Construct a Material whose shader is FragmentColor’s built-in physically-based-rendering default. The bundle ships pre-configured with glTF 2.0 PBR-MR defaults, lightly tweaked so a freshly-constructed Material renders as a clean white surface under the default light rather than dark gunmetal.

The five glTF texture slots (base_color_map, metallic_roughness_map, normal_map, occlusion_map, emissive_map) start unbound; the renderer resolves each one to a sensible 1×1 default from its lazy texture cache the first time a Pass renders the Material. That way the underlying shader has every binding it needs and the per-factor render is correct even when no map is attached. Call the texture setters to override any of them.

The shader uses:

  • Cook-Torrance specular with GGX normal distribution, Smith geometry, and Schlick Fresnel.
  • Lambertian diffuse with energy conservation against the specular Fresnel and metalness.
  • One directional light (uniform light), one camera (uniform camera), per-Model transform via the instance-attribute stream.
  • glTF 2.0 PBR-MR texture sampling: five sampled maps (base_color_map, metallic_roughness_map, normal_map, occlusion_map, emissive_map) combined with their matching factors per the spec.

The vertex inputs the shader expects, in this order:

  • @location(0) position : vec3<f32>, set as Vertex::new([...]).
  • @location(1) normal : vec3<f32>, set as .set(Vertex::NORMAL, ...).
  • @location(2) uv0 : vec2<f32>, set as .set(Vertex::UV0, ...).

If your Mesh’s first vertex doesn’t carry these three properties in this order, attaching it to the Material’s shader will fail with a layout mismatch. Use Material::custom(...) to bring your own vertex layout.

key value
material.base_color [1, 1, 1, 1]
material.metallic 0.0
material.roughness 1.0
material.normal_scale 1.0
material.occlusion_strength 1.0
material.emissive [0, 0, 0]
material.alpha_cutoff 0.5
base_color_map 1×1 white
metallic_roughness_map 1×1 (R=0, G=1, B=1)
normal_map 1×1 flat (128,128,255)
occlusion_map 1×1 white
emissive_map 1×1 white
camera.view_proj identity 4×4
camera.position [0, 0, 0]
light.direction [0, -1, 0] (down)
light.color [1, 1, 1]

The camera and light are the user’s domain, not the Material’s. Pass them to Pass::add. The typed Camera and Light handles seed the matching camera.* / light.* uniforms and propagate any later updates back into the Material’s shader. Dropping down to material.shader().set(...) directly is still supported if you need it.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::Material;
let bronze = Material::pbr()
.base_color([0.8, 0.5, 0.2, 1.0])
.metallic(1.0)
.roughness(0.3);
3 collapsed lines
let _bronze = bronze;
Ok(())
}

Set the surface roughness in [0, 1]. 0 is a perfect mirror; 1 is a fully matte surface. The PBR shader uses the squared form (alpha = r²) for the GGX normal distribution, which is the GGX/Trowbridge-Reitz convention shared with glTF 2.0.

Maps to the material.roughness uniform. Default is 1.0.

1 collapsed line
async fn run() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::{Material, Renderer};
let renderer = Renderer::new();
let satin = Material::pbr().roughness(0.35);
4 collapsed lines
let _ = satin;
Ok(())
}
fn main() -> Result<(), Box<dyn std::error::Error>> { pollster::block_on(run()) }

Borrow the underlying Shader to drop down to direct uniform manipulation. This is the escape hatch when the Material’s typed setters don’t cover what you need (custom uniforms beyond the glTF PBR field set, raw texture binds, anything the Camera and Light helpers don’t speak to).

The returned reference is the same Shader the Material is built around, so changes propagate immediately to every Model that uses this Material. If you want a Material variant with different state, build a fresh Material::pbr().<setters> rather than cloning. Material clones share their Shader handle (Arc-clone) so mutations are visible across all clones.

For camera + light state, prefer absorbing the typed Camera and Light handles into the Pass that’s about to render this Material rather than calling shader().set(...) by hand. The typed handles propagate updates live across every absorbed shader.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::Material;
// Direct uniform access for a custom field that isn't covered by the
// Material setters or by Camera / Light.
let material = Material::pbr();
material.shader().set("material.alpha_cutoff", 0.25)?;
2 collapsed lines
Ok(())
}

Set a global UV transform applied to every map sample. Matches glTF’s KHR_texture_transform composition: scale → rotate → offset, with the result fed to every textureSample in the PBR fragment shader.

  • offset translates the UV after scale + rotation ([0, 0] is no translation)
  • scale multiplies the UV first ([1, 1] is no scale)
  • rotation rotates the scaled UV in radians (positive = counter-clockwise in WGSL’s (u, v) convention)

Defaults to identity (scale [1, 1], rotation 0, offset [0, 0]) so an unset Material samples textures untransformed. The transform is shared across all five texture slots; per-map transforms (and the per-map texCoord selector that picks UV0 vs UV1) are out of scope for the built-in PBR shader. For glTFs that carry KHR_texture_transform only on the base-color map (the most common case), the global path is lossless.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::Material;
// Tile the texture 4× in both directions, rotate 45°, shift by half a tile.
let brick = Material::pbr()
.uv_transform([0.5, 0.0], [4.0, 4.0], 0.785); // 45° in radians
3 collapsed lines
let _ = brick;
Ok(())
}