Writing volumetric refraction in glTF 2.0

Before After
Classic 'Suzanne' monkey head, inside a textured box. Head is opaque off-white. Classic 'Suzanne' monkey head, inside a textured box. Head shows glass-like transmission and refraction, with blue attenuation of transmitted light.

With its release in 2017, glTF 2.0 created a baseline metal/rough physically-based-rendering (PBR) material workflow — the first of its kind to become widely-adopted in web and realtime applications. Since then, Khronos has published multiple advanced extensions for newer PBR effects like transmission, volumetric refraction, and clearcoat, IOR, and more. These extensions push the capabilities of realtime web viewers, in a good way.

Support for reading and writing these extensions in modeling tools generally lags behind the standards a bit. Blender can render refraction with its Principled BSDF material, but can only export transmission — not volumetric refraction — as of this writing.

In the meantime, one workaround is to write a short script adding the latest PBR material extensions to an existing glTF 2.0 asset. The same general approach should work for other upcoming extensions too, not just volumetric refraction. I'll use glTF Report as a sandbox for quickly previewing changes, and glTF Transform as the scripting API. The script could just as easily be used in a Node.js environment or a custom web application.

import {
    MaterialsIOR,
    MaterialsSpecular,
    MaterialsTransmission,
    MaterialsVolume
} from '@gltf-transform/extensions';

// Register extensions, and define extension material properties.

const transmissionExt = document.createExtension(MaterialsTransmission);
const transmission = transmissionExt.createTransmission()
    .setTransmissionFactor(1.0);

const volumeExt = document.createExtension(MaterialsVolume);
const volume = volumeExt.createVolume()
    .setThicknessFactor(1.0)
    .setAttenuationDistance(1.0)
    .setAttenuationColorHex(0x4285f4);

const iorExt = document.createExtension(MaterialsIOR);
const ior = iorExt.createIOR()
    .setIOR(1.75);

const specularExt = document.createExtension(MaterialsSpecular);
const specular = specularExt.createSpecular()
    .setSpecularFactor(1.0);

// Update material.

const material = document.getRoot()
    .listMaterials()
    .find((mat) => mat.getName() === 'MyMaterial');

material
    .setAlphaMode('OPAQUE')
    .setMetallicFactor(0.0)
    .setRoughnessFactor(0.2)
    .setExtension('KHR_materials_transmission', transmission)
    .setExtension('KHR_materials_volume', volume)
    .setExtension('KHR_materials_ior', ior)
    .setExtension('KHR_materials_specular', specular);

Note that metallicFactor is set to 0.0. Fully-metallic materials are never transmissive, and disabling the metallic factor entirely is a quick way to ensure we don't have that problem. Materials with metallicRoughnessTexture controlling the metal/rough effect could keep it at 1.0 and let the texture do the rest.

Additionally, most realtime renderers today cannot display a transmissive material through another transmissive material. This may change as renderers improve over time, but for now it's important to keep it in mind and ensure that transmissive objects and opaque objects use separate materials. Transmission is also a fairly expensive effect — costing more GPU time for every pixel affected — so this separation should improve performance.

For testing, I've provided a sample .blend and .glb model, exported without any material extensions. Running the script should give the result shown below.

Resources: SuzanneInBox.zip