Fresnel Rim Light

Building a Fresnel Rim-Light Node Material

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:

bookstore.js: a node material that adds a Fresnel rim to a lit cover textureJavaScript
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:

Printing the fragment code generated for the rim blocks (7.5.9's shelf)JavaScript
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'));
});
Output
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.

A node material that lights a cover texture and adds a Fresnel rim, beside the same book without itHTMLLive
<!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>