Skip to content

Commit 80a7e9f

Browse files
committed
Expose layered PBR material descriptor
1 parent 570b811 commit 80a7e9f

10 files changed

Lines changed: 540 additions & 8 deletions

File tree

docs/layered-pbr.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,3 +251,35 @@ layered ABI and do not allocate the LUT.
251251
The CPU scene tracer and GPU path tracer still require an explicit reviewed
252252
migration to these named equations. Iridescence is the next lobe package;
253253
public authoring/debug surfaces and transport parity follow it.
254+
255+
## Public authoring API
256+
257+
`setSceneNodeMaterial(node, descriptor)` is the single public authoring entry
258+
point for base, emissive, clearcoat, specular/IOR, sheen, anisotropy, and
259+
iridescence factors. Every field is optional. An empty descriptor restores the
260+
same base defaults as a new scene node, while an omitted layered lobe is absent
261+
from the native lobe mask and keeps the allocation-free base-material path.
262+
263+
```ts
264+
setSceneNodeMaterial(node, {
265+
color: [210, 68, 32],
266+
roughness: 0.22,
267+
metalness: 0,
268+
layered: {
269+
clearcoat: { factor: 0.8, roughness: 0.1 },
270+
iridescence: {
271+
factor: 0.65,
272+
ior: 1.3,
273+
thicknessMinimum: 120,
274+
thicknessMaximum: 380,
275+
},
276+
},
277+
});
278+
```
279+
280+
Base color follows Bloom's engine-wide 0–255 convention. Layered colors are
281+
linear factors, anisotropy rotation is in radians, and iridescence thickness is
282+
in nanometres. Unsafe scalar input is bounded before it reaches a shader, and
283+
reapplying an unchanged descriptor does not rebuild the material. Layer
284+
textures and texture transforms remain glTF-authored in this first API version;
285+
the importer continues to preserve and render their full UV contract.

examples/renderer-test/main.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ import {
5555
import {
5656
createSceneNode, setSceneNodeTransform,
5757
updateSceneNodeGeometry,
58-
setSceneNodeColor, setSceneNodePbr,
58+
setSceneNodeColor, setSceneNodePbr, setSceneNodeMaterial,
5959
setSceneNodeCastShadow, setSceneNodeReceiveShadow,
6060
enableShadows, dumpShadowMap,
6161
addDirectionalLight, addPointLight,
@@ -242,8 +242,11 @@ function placeSphere(
242242
roughness: number, metalness: number,
243243
): number {
244244
const node = placeNode(sphereHandle, 0, px, py, pz, scale, scale, scale);
245-
setSceneNodeColor(node, cr * 255, cg * 255, cb * 255);
246-
setSceneNodePbr(node, roughness, metalness);
245+
setSceneNodeMaterial(node, {
246+
color: [cr * 255, cg * 255, cb * 255],
247+
roughness,
248+
metalness,
249+
});
247250
return node;
248251
}
249252

@@ -254,8 +257,11 @@ function placeCube(
254257
roughness: number, metalness: number,
255258
): number {
256259
const node = placeNode(cubeHandle, 0, px, py, pz, sx, sy, sz);
257-
setSceneNodeColor(node, cr * 255, cg * 255, cb * 255);
258-
setSceneNodePbr(node, roughness, metalness);
260+
setSceneNodeMaterial(node, {
261+
color: [cr * 255, cg * 255, cb * 255],
262+
roughness,
263+
metalness,
264+
});
259265
// Thin horizontal slabs (floors) should receive but not cast
260266
// shadows — otherwise they fill the shadow map with their own
261267
// depth and everything reads as "in shadow of the ground".

native/shared/src/ffi_core/scene.rs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,73 @@ macro_rules! __bloom_ffi_scene {
343343
})
344344
}
345345

346+
// bloom_scene_set_material_emissive [source: macos]
347+
#[no_mangle]
348+
pub extern "C" fn bloom_scene_set_material_emissive(handle: f64, r: f64, g: f64, b: f64) {
349+
$crate::ffi::guard("bloom_scene_set_material_emissive", move || {
350+
let finite_non_negative = |value: f64| {
351+
if value.is_finite() {
352+
(value as f32).max(0.0)
353+
} else {
354+
0.0
355+
}
356+
};
357+
engine().scene.set_material_emissive_factor(
358+
handle,
359+
finite_non_negative(r),
360+
finite_non_negative(g),
361+
finite_non_negative(b),
362+
);
363+
})
364+
}
365+
366+
// bloom_scene_set_material_layered_pbr [source: macos]
367+
#[no_mangle]
368+
#[allow(clippy::too_many_arguments)]
369+
pub extern "C" fn bloom_scene_set_material_layered_pbr(
370+
handle: f64,
371+
lobe_mask: f64,
372+
clearcoat_factor: f64,
373+
clearcoat_roughness: f64,
374+
clearcoat_normal_scale: f64,
375+
specular_factor: f64,
376+
specular_r: f64,
377+
specular_g: f64,
378+
specular_b: f64,
379+
ior: f64,
380+
sheen_r: f64,
381+
sheen_g: f64,
382+
sheen_b: f64,
383+
sheen_roughness: f64,
384+
anisotropy_strength: f64,
385+
anisotropy_rotation: f64,
386+
iridescence_factor: f64,
387+
iridescence_ior: f64,
388+
iridescence_thickness_minimum: f64,
389+
iridescence_thickness_maximum: f64,
390+
) {
391+
$crate::ffi::guard("bloom_scene_set_material_layered_pbr", move || {
392+
let layered = $crate::models::MaterialLayeredPbr::from_authoring_factors(
393+
lobe_mask as u32,
394+
clearcoat_factor as f32,
395+
clearcoat_roughness as f32,
396+
clearcoat_normal_scale as f32,
397+
specular_factor as f32,
398+
[specular_r as f32, specular_g as f32, specular_b as f32],
399+
ior as f32,
400+
[sheen_r as f32, sheen_g as f32, sheen_b as f32],
401+
sheen_roughness as f32,
402+
anisotropy_strength as f32,
403+
anisotropy_rotation as f32,
404+
iridescence_factor as f32,
405+
iridescence_ior as f32,
406+
iridescence_thickness_minimum as f32,
407+
iridescence_thickness_maximum as f32,
408+
);
409+
engine().scene.set_material_layered_pbr(handle, layered);
410+
})
411+
}
412+
346413
// bloom_scene_set_material_texture [source: macos]
347414
#[no_mangle]
348415
pub extern "C" fn bloom_scene_set_material_texture(handle: f64, texture_idx: f64) {

native/shared/src/models.rs

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,93 @@ impl Default for MaterialLayeredPbr {
132132
}
133133

134134
impl MaterialLayeredPbr {
135+
pub const CLEARCOAT_LOBE: u32 = 1 << 0;
136+
pub const SHEEN_LOBE: u32 = 1 << 1;
137+
pub const ANISOTROPY_LOBE: u32 = 1 << 2;
138+
pub const IRIDESCENCE_LOBE: u32 = 1 << 3;
139+
pub const SPECULAR_IOR_LOBE: u32 = 1 << 4;
140+
141+
/// Build the texture-free layered material used by the public authoring
142+
/// API. The lobe mask is authoritative: values belonging to an absent
143+
/// lobe are ignored so an omitted descriptor always restores glTF
144+
/// defaults and the allocation-free base-material path.
145+
#[doc(hidden)]
146+
#[allow(clippy::too_many_arguments)]
147+
pub fn from_authoring_factors(
148+
lobe_mask: u32,
149+
clearcoat_factor: f32,
150+
clearcoat_roughness: f32,
151+
clearcoat_normal_scale: f32,
152+
specular_factor: f32,
153+
specular_color: [f32; 3],
154+
ior: f32,
155+
sheen_color: [f32; 3],
156+
sheen_roughness: f32,
157+
anisotropy_strength: f32,
158+
anisotropy_rotation: f32,
159+
iridescence_factor: f32,
160+
iridescence_ior: f32,
161+
iridescence_thickness_minimum: f32,
162+
iridescence_thickness_maximum: f32,
163+
) -> Self {
164+
fn finite_or(value: f32, fallback: f32) -> f32 {
165+
if value.is_finite() {
166+
value
167+
} else {
168+
fallback
169+
}
170+
}
171+
fn unit(value: f32, fallback: f32) -> f32 {
172+
finite_or(value, fallback).clamp(0.0, 1.0)
173+
}
174+
fn non_negative(value: f32, fallback: f32) -> f32 {
175+
finite_or(value, fallback).max(0.0)
176+
}
177+
fn material_ior(value: f32) -> f32 {
178+
let value = finite_or(value, 1.5);
179+
if value == 0.0 {
180+
0.0
181+
} else {
182+
value.max(1.0)
183+
}
184+
}
185+
186+
let mut material = Self::default();
187+
if lobe_mask & Self::CLEARCOAT_LOBE != 0 {
188+
material.clearcoat_authored = true;
189+
material.clearcoat_factor = unit(clearcoat_factor, 0.0);
190+
material.clearcoat_roughness_factor = unit(clearcoat_roughness, 0.0);
191+
material.clearcoat_normal_scale = finite_or(clearcoat_normal_scale, 1.0);
192+
}
193+
if lobe_mask & Self::SPECULAR_IOR_LOBE != 0 {
194+
material.specular_authored = true;
195+
material.specular_factor = unit(specular_factor, 1.0);
196+
material.specular_color_factor = specular_color.map(|value| non_negative(value, 1.0));
197+
material.ior_authored = true;
198+
material.ior = material_ior(ior);
199+
}
200+
if lobe_mask & Self::SHEEN_LOBE != 0 {
201+
material.sheen_authored = true;
202+
material.sheen_color_factor = sheen_color.map(|value| unit(value, 0.0));
203+
material.sheen_roughness_factor = unit(sheen_roughness, 0.0);
204+
}
205+
if lobe_mask & Self::ANISOTROPY_LOBE != 0 {
206+
material.anisotropy_authored = true;
207+
material.anisotropy_strength = unit(anisotropy_strength, 0.0);
208+
material.anisotropy_rotation = finite_or(anisotropy_rotation, 0.0);
209+
}
210+
if lobe_mask & Self::IRIDESCENCE_LOBE != 0 {
211+
material.iridescence_authored = true;
212+
material.iridescence_factor = unit(iridescence_factor, 0.0);
213+
material.iridescence_ior = finite_or(iridescence_ior, 1.3).max(1.0);
214+
material.iridescence_thickness_minimum =
215+
non_negative(iridescence_thickness_minimum, 100.0);
216+
material.iridescence_thickness_maximum =
217+
non_negative(iridescence_thickness_maximum, 400.0);
218+
}
219+
material
220+
}
221+
135222
pub(crate) fn is_active(self) -> bool {
136223
self.has_clearcoat()
137224
|| self.has_specular_ior()
@@ -241,6 +328,75 @@ impl MaterialLayeredPbr {
241328
}
242329
}
243330

331+
#[cfg(test)]
332+
mod layered_pbr_authoring_tests {
333+
use super::MaterialLayeredPbr;
334+
335+
#[test]
336+
fn omitted_lobes_restore_exact_defaults() {
337+
let material = MaterialLayeredPbr::from_authoring_factors(
338+
0,
339+
1.0,
340+
1.0,
341+
2.0,
342+
0.0,
343+
[3.0, 2.0, 1.0],
344+
1.1,
345+
[1.0; 3],
346+
1.0,
347+
1.0,
348+
2.0,
349+
1.0,
350+
1.8,
351+
20.0,
352+
80.0,
353+
);
354+
assert_eq!(material, MaterialLayeredPbr::default());
355+
assert!(!material.is_active());
356+
}
357+
358+
#[test]
359+
fn authoring_factors_are_finite_and_range_safe() {
360+
let mask = MaterialLayeredPbr::CLEARCOAT_LOBE
361+
| MaterialLayeredPbr::SPECULAR_IOR_LOBE
362+
| MaterialLayeredPbr::SHEEN_LOBE
363+
| MaterialLayeredPbr::ANISOTROPY_LOBE
364+
| MaterialLayeredPbr::IRIDESCENCE_LOBE;
365+
let material = MaterialLayeredPbr::from_authoring_factors(
366+
mask,
367+
2.0,
368+
-1.0,
369+
f32::NAN,
370+
-2.0,
371+
[-1.0, 2.0, f32::INFINITY],
372+
0.5,
373+
[-1.0, 0.5, 2.0],
374+
f32::NAN,
375+
4.0,
376+
f32::INFINITY,
377+
3.0,
378+
0.2,
379+
-10.0,
380+
f32::NAN,
381+
);
382+
assert_eq!(material.clearcoat_factor, 1.0);
383+
assert_eq!(material.clearcoat_roughness_factor, 0.0);
384+
assert_eq!(material.clearcoat_normal_scale, 1.0);
385+
assert_eq!(material.specular_factor, 0.0);
386+
assert_eq!(material.specular_color_factor, [0.0, 2.0, 1.0]);
387+
assert_eq!(material.ior, 1.0);
388+
assert_eq!(material.sheen_color_factor, [0.0, 0.5, 1.0]);
389+
assert_eq!(material.sheen_roughness_factor, 0.0);
390+
assert_eq!(material.anisotropy_strength, 1.0);
391+
assert_eq!(material.anisotropy_rotation, 0.0);
392+
assert_eq!(material.iridescence_factor, 1.0);
393+
assert_eq!(material.iridescence_ior, 1.0);
394+
assert_eq!(material.iridescence_thickness_minimum, 0.0);
395+
assert_eq!(material.iridescence_thickness_maximum, 400.0);
396+
assert!(material.is_active());
397+
}
398+
}
399+
244400
pub(crate) fn effective_material_ior(ior: f32) -> f32 {
245401
if ior == 0.0 {
246402
1_000_000.0

native/shared/src/scene.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1037,6 +1037,9 @@ impl SceneGraph {
10371037

10381038
pub fn set_material_texture(&mut self, handle: f64, texture_idx: u32) {
10391039
if let Some(node) = self.nodes.get_mut(handle) {
1040+
if node.material.texture_idx == texture_idx {
1041+
return;
1042+
}
10401043
node.material.texture_idx = texture_idx;
10411044
node.mat_dirty = true;
10421045
}
@@ -1065,7 +1068,11 @@ impl SceneGraph {
10651068

10661069
pub fn set_material_emissive_factor(&mut self, handle: f64, r: f32, g: f32, b: f32) {
10671070
if let Some(node) = self.nodes.get_mut(handle) {
1068-
node.material.emissive = [r, g, b];
1071+
let emissive = [r, g, b];
1072+
if node.material.emissive == emissive {
1073+
return;
1074+
}
1075+
node.material.emissive = emissive;
10691076
node.mat_dirty = true;
10701077
}
10711078
}
@@ -2835,6 +2842,12 @@ mod gpu_driven_cache_tests {
28352842
!scene.nodes.get(node).unwrap().mat_dirty,
28362843
"identical PBR factors must remain allocation-free"
28372844
);
2845+
scene.set_material_emissive_factor(node, 0.0, 0.0, 0.0);
2846+
scene.set_material_texture(node, 0);
2847+
assert!(
2848+
!scene.nodes.get(node).unwrap().mat_dirty,
2849+
"default descriptor fields must not rebuild an unchanged material"
2850+
);
28382851
}
28392852

28402853
#[test]

native/watchos/src/ffi_stubs.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,10 @@
500500
}
501501
#[no_mangle] pub extern "C" fn bloom_scene_attach_model_lod(_p0: f64, _p1: f64, _p2: f64, _p3: f64, _p4: f64) {
502502
}
503+
#[no_mangle] pub extern "C" fn bloom_scene_set_material_emissive(_p0: f64, _p1: f64, _p2: f64, _p3: f64) {
504+
}
505+
#[no_mangle] pub extern "C" fn bloom_scene_set_material_layered_pbr(_p0: f64, _p1: f64, _p2: f64, _p3: f64, _p4: f64, _p5: f64, _p6: f64, _p7: f64, _p8: f64, _p9: f64, _p10: f64, _p11: f64, _p12: f64, _p13: f64, _p14: f64, _p15: f64, _p16: f64, _p17: f64, _p18: f64, _p19: f64) {
506+
}
503507
#[no_mangle] pub extern "C" fn bloom_scene_node_vertex_count(_p0: f64) -> f64 {
504508
0.0
505509
}

0 commit comments

Comments
 (0)