A rim light brightens a surface where it turns away from the viewer, the Fresnel effect of Cook-Torrance BRDF used as an outline. BookNest's version lights the cover texture through a LightBlock (the scene's lights), then adds a rim color scaled by 1 - fresnel: Babylon 89,603 's FresnelBlock returns the facing term pow(bias + |dot(v, n)|, power), so its complement is 0 facing the camera and 1 at grazing angles. Two helpers give one line per block:
function createSpineHighlight(scene, texture, rimHex = '#ffe082') { // Section 7.6.3
const material = new BABYLON.NodeMaterial('spineHighlight', scene, { emitComments: true });
const input = (name, value) => { // attribute, system value or uniform
const node = new BABYLON.InputBlock(name);
if (value === 'attribute') node.setAsAttribute(name);
else if (typeof value !== 'string') node.value = value;
else node.setAsSystemValue(BABYLON.NodeMaterialSystemValues[value]);
return node.output;
};
const block = (type, name, inputs, props = {}) => { // a block with its inputs wired
const node = Object.assign(new BABYLON[type](name), props);
for (const [port, source] of Object.entries(inputs)) source.connectTo(node[port]);
return node;
};
const world = input('world', 'World');
const eye = input('cameraPosition', 'CameraPosition');
const worldPos = block('TransformBlock', 'worldPos',
{ vector: input('position', 'attribute'), transform: world }).output;
const clipPos = block('TransformBlock', 'clipPos',
{ vector: worldPos, transform: input('viewProjection', 'ViewProjection') });
const vertexOut = block('VertexOutputBlock', 'vertexOut', { vector: clipPos.output });
const normal = block('TransformBlock', 'worldNormal', { vector: input('normal', 'attribute'),
transform: world }, { complementW: 0 }).output; // a direction: no translation
const cover = block('TextureBlock', 'cover', { uv: input('uv', 'attribute') }, { texture });
const light = block('LightBlock', 'light', { worldPosition: worldPos, worldNormal: normal,
cameraPosition: eye, diffuseColor: cover.rgb, // the scene's lights
view: input('view', 'View') }); // cascaded shadows read the view matrix
const toEye = block('ViewDirectionBlock', 'toEye',
{ worldPosition: worldPos, cameraPosition: eye });
const fresnel = block('FresnelBlock', 'fresnel', { worldNormal: normal,
viewDirection: toEye.output, bias: input('bias', 0), power: input('power', 1) });
const edge = block('OneMinusBlock', 'edge', { input: fresnel.fresnel }); // 1 at the rim
const amount = block('MultiplyBlock', 'amount', { left: edge.output,
right: input('strength', 1) });
const rim = block('ScaleBlock', 'rim', { factor: amount.output,
input: input('rimColor', BABYLON.Color3.FromHexString(rimHex)) });
const sum = block('AddBlock', 'sum', { left: light.diffuseOutput, right: rim.output });
const fragmentOut = block('FragmentOutputBlock', 'fragmentOut', { rgb: sum.output });
[vertexOut, fragmentOut].forEach((node) => material.addOutputNode(node));
material.build();
return material;
}The LightBlock's view input matters once cascaded shadows pick a cascade by view-space depth: without it, Room Lighting's lightRoom breaks the build ("'view' : undeclared identifier"). The graph compiles when a mesh first needs it; compiledShaders then returns the generated source, and emitComments marks each block's code with the block's name:
books[1].material = BookNestStore.createSpineHighlight(scene, covers[1].albedoTexture);
scene.executeWhenReady(() => { // after the effect has compiled
const main = books[1].material.compiledShaders.split('void main')[2]; // fragment main()
const rim = main.slice(main.indexOf('//fresnel'), main.indexOf('//fragmentOut'));
console.log(rim.split('\n').filter((line) => line.trim() && !line.startsWith('//'))
.join('\n'));
});float fresnel = computeFresnelTerm(output7.xyz, v_output2.xyz, u_bias, u_power); float output6 = 1. - fresnel; float output5 = output6 * u_strength; vec3 output4 = u_rimColor * output5; vec3 output3 = diffuseOutput + output4;
Each block became one GLSL line, and the inputs became uniforms (u_strength, u_rimColor) you can change at run time. The whole fragment shader runs to 672 lines, mostly the LightBlock's lighting and shadow code.
<!doctype html>
<style>
body { margin: 0; background: #f7f3ec; font: 13px system-ui, sans-serif; color: #333; }
canvas { width: 100%; max-width: 600px; height: 300px; display: block; touch-action: none; }
#glsl { max-width: 600px; margin: 0; padding: 4px 8px; font: 11px monospace; white-space: pre-wrap; color: #555; }
</style>
<canvas id="view"></canvas>
<pre id="glsl">Compiling…</pre>
<script src="https://cdn.jsdelivr.net/npm/babylonjs@9.28.0/babylon.js"></script>
<script>
const canvas = document.getElementById('view');
const engine = new BABYLON.Engine(canvas, true);
const scene = new BABYLON.Scene(engine);
scene.clearColor = BABYLON.Color4.FromHexString('#dfe3e8ff');
const camera = new BABYLON.ArcRotateCamera('cam', -Math.PI / 2, 1.3, 3.4, BABYLON.Vector3.Zero(), scene);
camera.attachControl(canvas, true);
new BABYLON.HemisphericLight('sky', new BABYLON.Vector3(0.3, 1, -0.6), scene).intensity = 0.8;
function createSpineHighlight(scene, texture, rimHex = '#ffe082') {
const material = new BABYLON.NodeMaterial('spineHighlight', scene, { emitComments: true });
const input = (name, value) => { // attribute, system value or uniform
const node = new BABYLON.InputBlock(name);
if (value === 'attribute') node.setAsAttribute(name);
else if (typeof value !== 'string') node.value = value;
else node.setAsSystemValue(BABYLON.NodeMaterialSystemValues[value]);
return node.output;
};
const block = (type, name, inputs, props = {}) => { // a block with its inputs wired
const node = Object.assign(new BABYLON[type](name), props);
for (const [port, source] of Object.entries(inputs)) source.connectTo(node[port]);
return node;
};
const world = input('world', 'World');
const eye = input('cameraPosition', 'CameraPosition');
const worldPos = block('TransformBlock', 'worldPos',
{ vector: input('position', 'attribute'), transform: world }).output;
const clipPos = block('TransformBlock', 'clipPos',
{ vector: worldPos, transform: input('viewProjection', 'ViewProjection') });
const vertexOut = block('VertexOutputBlock', 'vertexOut', { vector: clipPos.output });
const normal = block('TransformBlock', 'worldNormal', { vector: input('normal', 'attribute'),
transform: world }, { complementW: 0 }).output; // a direction: no translation
const cover = block('TextureBlock', 'cover', { uv: input('uv', 'attribute') }, { texture });
const light = block('LightBlock', 'light', { worldPosition: worldPos, worldNormal: normal,
cameraPosition: eye, diffuseColor: cover.rgb, view: input('view', 'View') }); // the scene's lights
const toEye = block('ViewDirectionBlock', 'toEye', { worldPosition: worldPos, cameraPosition: eye });
const fresnel = block('FresnelBlock', 'fresnel', { worldNormal: normal,
viewDirection: toEye.output, bias: input('bias', 0), power: input('power', 1) });
const edge = block('OneMinusBlock', 'edge', { input: fresnel.fresnel }); // 1 at the rim
const amount = block('MultiplyBlock', 'amount', { left: edge.output, right: input('strength', 1) });
const rim = block('ScaleBlock', 'rim', { factor: amount.output,
input: input('rimColor', BABYLON.Color3.FromHexString(rimHex)) });
const sum = block('AddBlock', 'sum', { left: light.diffuseOutput, right: rim.output });
const fragmentOut = block('FragmentOutputBlock', 'fragmentOut', { rgb: sum.output });
[vertexOut, fragmentOut].forEach((node) => material.addOutputNode(node));
material.build();
return material;
}
// A cover texture drawn on a canvas
const coverTex = new BABYLON.DynamicTexture('cover', { width: 256, height: 384 }, scene);
const c = coverTex.getContext();
c.fillStyle = '#5b3f99'; c.fillRect(0, 0, 256, 384);
c.fillStyle = '#fff8e1'; c.textAlign = 'center'; c.font = 'bold 30px Georgia';
['Patterns', 'of the', 'Deep Web'].forEach((line, i) => c.fillText(line, 128, 120 + i * 40));
c.strokeStyle = '#fff8e1'; c.lineWidth = 3; c.strokeRect(20, 20, 216, 344);
coverTex.update();
const plain = new BABYLON.StandardMaterial('plain', scene);
plain.diffuseTexture = coverTex;
plain.specularColor = BABYLON.Color3.Black();
const highlight = createSpineHighlight(scene, coverTex);
const books = [plain, highlight].map((material, i) => {
const book = BABYLON.MeshBuilder.CreateBox('book' + i, { width: 0.9, height: 1.3, depth: 0.25 }, scene);
book.position.x = i ? 0.75 : -0.75;
book.material = material;
return book;
});
scene.onBeforeRenderObservable.add(() => {
books.forEach((book) => { book.rotation.y = Math.sin(performance.now() / 1400) * 0.9; });
});
scene.executeWhenReady(() => { // after the effect has compiled
const main = highlight.compiledShaders.split('void main')[2]; // the fragment shader's main()
const rim = main.slice(main.indexOf('//fresnel'), main.indexOf('//fragmentOut'));
document.getElementById('glsl').textContent = 'Generated GLSL, one line per block:\n' +
rim.split('\n').filter((line) => line.trim() && !line.trim().startsWith('//')).join('\n');
engine.runRenderLoop(() => scene.render());
});
addEventListener('resize', () => engine.resize());
</script>