Browse Source

Merge pull request #291 from molstar/lighting

Lighting
Alexander Rose 3 years ago
parent
commit
d1c4cf69cb
52 changed files with 1049 additions and 239 deletions
  1. 4 0
      CHANGELOG.md
  2. 12 14
      src/apps/docking-viewer/viewport.tsx
  3. 2 4
      src/extensions/geo-export/controls.ts
  4. 24 10
      src/extensions/geo-export/glb-exporter.ts
  5. 14 15
      src/extensions/geo-export/usdz-exporter.ts
  6. 16 6
      src/mol-geo/geometry/base.ts
  7. 3 0
      src/mol-geo/geometry/cylinders/cylinders.ts
  8. 4 2
      src/mol-geo/geometry/direct-volume/direct-volume.ts
  9. 3 0
      src/mol-geo/geometry/image/image.ts
  10. 3 0
      src/mol-geo/geometry/lines/lines.ts
  11. 34 0
      src/mol-geo/geometry/mesh/color-smoothing.ts
  12. 3 0
      src/mol-geo/geometry/mesh/mesh.ts
  13. 3 0
      src/mol-geo/geometry/points/points.ts
  14. 3 0
      src/mol-geo/geometry/spheres/spheres.ts
  15. 76 0
      src/mol-geo/geometry/substance-data.ts
  16. 3 0
      src/mol-geo/geometry/text/text.ts
  17. 36 0
      src/mol-geo/geometry/texture-mesh/color-smoothing.ts
  18. 3 0
      src/mol-geo/geometry/texture-mesh/texture-mesh.ts
  19. 1 1
      src/mol-gl/_spec/renderer.spec.ts
  20. 1 2
      src/mol-gl/renderable/points.ts
  21. 22 9
      src/mol-gl/renderable/schema.ts
  22. 57 66
      src/mol-gl/renderer.ts
  23. 33 14
      src/mol-gl/shader-code.ts
  24. 26 17
      src/mol-gl/shader/chunks/apply-light-color.glsl.ts
  25. 14 0
      src/mol-gl/shader/chunks/assign-color-varying.glsl.ts
  26. 7 0
      src/mol-gl/shader/chunks/assign-material-color.glsl.ts
  27. 7 0
      src/mol-gl/shader/chunks/color-frag-params.glsl.ts
  28. 17 0
      src/mol-gl/shader/chunks/color-vert-params.glsl.ts
  29. 3 1
      src/mol-gl/shader/chunks/common-clip.glsl.ts
  30. 64 40
      src/mol-gl/shader/chunks/light-frag-params.glsl.ts
  31. 23 1
      src/mol-gl/shader/direct-volume.frag.ts
  32. 5 9
      src/mol-gl/shader/mesh.frag.ts
  33. 3 5
      src/mol-gl/webgl/extensions.ts
  34. 13 0
      src/mol-math/linear-algebra/3d/vec3.ts
  35. 76 0
      src/mol-plugin-state/helpers/structure-substance.ts
  36. 24 10
      src/mol-plugin-state/manager/structure/component.ts
  37. 119 0
      src/mol-plugin-state/transforms/representation.ts
  38. 2 5
      src/mol-plugin-ui/viewport/simple-settings.tsx
  39. 4 2
      src/mol-plugin/behavior/dynamic/selection/structure-focus-representation.ts
  40. 1 0
      src/mol-plugin/spec.ts
  41. 8 1
      src/mol-repr/representation.ts
  42. 3 0
      src/mol-repr/shape/representation.ts
  43. 6 0
      src/mol-repr/structure/complex-representation.ts
  44. 5 0
      src/mol-repr/structure/complex-visual.ts
  45. 7 2
      src/mol-repr/structure/units-representation.ts
  46. 5 0
      src/mol-repr/structure/units-visual.ts
  47. 55 2
      src/mol-repr/visual.ts
  48. 4 0
      src/mol-repr/volume/representation.ts
  49. 1 1
      src/mol-theme/overpaint.ts
  50. 127 0
      src/mol-theme/substance.ts
  51. 56 0
      src/mol-util/material.ts
  52. 4 0
      src/mol-util/number.ts

+ 4 - 0
CHANGELOG.md

@@ -6,6 +6,10 @@ Note that since we don't clearly distinguish between a public and private interf
 
 ## [Unreleased]
 
+- Add multiple lights support (with color, intensity, and direction parameters)
+- [Breaking] Add per-object material rendering properties
+- Add substance theme with per-group material rendering properties
+
 ## [v2.4.1] - 2021-11-28
 
 - Fix: allow atoms in aromatic rings to do hydrogen bonds

+ 12 - 14
src/apps/docking-viewer/viewport.tsx

@@ -26,7 +26,6 @@ function shinyStyle(plugin: PluginContext) {
     return PluginCommands.Canvas3D.SetSettings(plugin, { settings: {
         renderer: {
             ...plugin.canvas3d!.props.renderer,
-            style: { name: 'plastic', params: {} },
         },
         postprocessing: {
             ...plugin.canvas3d!.props.postprocessing,
@@ -40,7 +39,6 @@ function occlusionStyle(plugin: PluginContext) {
     return PluginCommands.Canvas3D.SetSettings(plugin, { settings: {
         renderer: {
             ...plugin.canvas3d!.props.renderer,
-            style: { name: 'flat', params: {} }
         },
         postprocessing: {
             ...plugin.canvas3d!.props.postprocessing,
@@ -94,8 +92,8 @@ export const StructurePreset = StructureRepresentationPresetProvider({
 
         const { update, builder, typeParams } = StructureRepresentationPresetProvider.reprBuilder(plugin, params);
         const representations = {
-            ligand: builder.buildRepresentation(update, components.ligand, { type: 'ball-and-stick', typeParams: { ...typeParams, sizeFactor: 0.35 }, color: 'element-symbol', colorParams: { carbonColor: { name: 'element-symbol', params: {} } } }, { tag: 'ligand' }),
-            polymer: builder.buildRepresentation(update, components.polymer, { type: 'cartoon', typeParams: { ...typeParams }, color: 'chain-id', colorParams: { palette: (plugin.customState as any).colorPalette } }, { tag: 'polymer' }),
+            ligand: builder.buildRepresentation(update, components.ligand, { type: 'ball-and-stick', typeParams: { ...typeParams, roughness: 0.2, sizeFactor: 0.35 }, color: 'element-symbol', colorParams: { carbonColor: { name: 'element-symbol', params: {} } } }, { tag: 'ligand' }),
+            polymer: builder.buildRepresentation(update, components.polymer, { type: 'cartoon', typeParams: { ...typeParams, roughness: 0.2 }, color: 'chain-id', colorParams: { palette: (plugin.customState as any).colorPalette } }, { tag: 'polymer' }),
         };
 
         await update.commit({ revertOnError: true });
@@ -121,8 +119,8 @@ export const IllustrativePreset = StructureRepresentationPresetProvider({
 
         const { update, builder, typeParams } = StructureRepresentationPresetProvider.reprBuilder(plugin, params);
         const representations = {
-            ligand: builder.buildRepresentation(update, components.ligand, { type: 'spacefill', typeParams: { ...typeParams }, color: 'element-symbol', colorParams: { carbonColor: { name: 'element-symbol', params: {} } } }, { tag: 'ligand' }),
-            polymer: builder.buildRepresentation(update, components.polymer, { type: 'spacefill', typeParams: { ...typeParams }, color: 'illustrative', colorParams: { palette: (plugin.customState as any).colorPalette } }, { tag: 'polymer' }),
+            ligand: builder.buildRepresentation(update, components.ligand, { type: 'spacefill', typeParams: { ...typeParams, ignoreLight: true }, color: 'element-symbol', colorParams: { carbonColor: { name: 'element-symbol', params: {} } } }, { tag: 'ligand' }),
+            polymer: builder.buildRepresentation(update, components.polymer, { type: 'spacefill', typeParams: { ...typeParams, ignoreLight: true }, color: 'illustrative', colorParams: { palette: (plugin.customState as any).colorPalette } }, { tag: 'polymer' }),
         };
 
         await update.commit({ revertOnError: true });
@@ -149,8 +147,8 @@ const SurfacePreset = StructureRepresentationPresetProvider({
 
         const { update, builder, typeParams } = StructureRepresentationPresetProvider.reprBuilder(plugin, params);
         const representations = {
-            ligand: builder.buildRepresentation(update, components.ligand, { type: 'ball-and-stick', typeParams: { ...typeParams, sizeFactor: 0.26 }, color: 'element-symbol', colorParams: { carbonColor: { name: 'element-symbol', params: {} } } }, { tag: 'ligand' }),
-            polymer: builder.buildRepresentation(update, components.polymer, { type: 'molecular-surface', typeParams: { ...typeParams, quality: 'custom', resolution: 0.5, doubleSided: true }, color: 'partial-charge' }, { tag: 'polymer' }),
+            ligand: builder.buildRepresentation(update, components.ligand, { type: 'ball-and-stick', typeParams: { ...typeParams, roughness: 0.2, sizeFactor: 0.26 }, color: 'element-symbol', colorParams: { carbonColor: { name: 'element-symbol', params: {} } } }, { tag: 'ligand' }),
+            polymer: builder.buildRepresentation(update, components.polymer, { type: 'molecular-surface', typeParams: { ...typeParams, roughness: 0.2, quality: 'custom', resolution: 0.5, doubleSided: true }, color: 'partial-charge' }, { tag: 'polymer' }),
         };
 
         await update.commit({ revertOnError: true });
@@ -177,8 +175,8 @@ const PocketPreset = StructureRepresentationPresetProvider({
 
         const { update, builder, typeParams } = StructureRepresentationPresetProvider.reprBuilder(plugin, params);
         const representations = {
-            ligand: builder.buildRepresentation(update, components.ligand, { type: 'ball-and-stick', typeParams: { ...typeParams, sizeFactor: 0.26 }, color: 'element-symbol', colorParams: { carbonColor: { name: 'element-symbol', params: {} } } }, { tag: 'ligand' }),
-            surroundings: builder.buildRepresentation(update, components.surroundings, { type: 'molecular-surface', typeParams: { ...typeParams, includeParent: true, quality: 'custom', resolution: 0.2, doubleSided: true }, color: 'partial-charge' }, { tag: 'surroundings' }),
+            ligand: builder.buildRepresentation(update, components.ligand, { type: 'ball-and-stick', typeParams: { ...typeParams, roughness: 0.2, sizeFactor: 0.26 }, color: 'element-symbol', colorParams: { carbonColor: { name: 'element-symbol', params: {} } } }, { tag: 'ligand' }),
+            surroundings: builder.buildRepresentation(update, components.surroundings, { type: 'molecular-surface', typeParams: { ...typeParams, roughness: 0.2, includeParent: true, quality: 'custom', resolution: 0.2, doubleSided: true }, color: 'partial-charge' }, { tag: 'surroundings' }),
         };
 
         await update.commit({ revertOnError: true });
@@ -206,10 +204,10 @@ const InteractionsPreset = StructureRepresentationPresetProvider({
 
         const { update, builder, typeParams } = StructureRepresentationPresetProvider.reprBuilder(plugin, params);
         const representations = {
-            ligand: builder.buildRepresentation(update, components.ligand, { type: 'ball-and-stick', typeParams: { ...typeParams, sizeFactor: 0.3 }, color: 'element-symbol', colorParams: { carbonColor: { name: 'element-symbol', params: {} } } }, { tag: 'ligand' }),
-            ballAndStick: builder.buildRepresentation(update, components.surroundings, { type: 'ball-and-stick', typeParams: { ...typeParams, sizeFactor: 0.1, sizeAspectRatio: 1 }, color: 'element-symbol', colorParams: { carbonColor: { name: 'element-symbol', params: {} } } }, { tag: 'ball-and-stick' }),
-            interactions: builder.buildRepresentation(update, components.interactions, { type: InteractionsRepresentationProvider, typeParams: { ...typeParams }, color: InteractionTypeColorThemeProvider }, { tag: 'interactions' }),
-            label: builder.buildRepresentation(update, components.surroundings, { type: 'label', typeParams: { ...typeParams, background: false, borderWidth: 0.1 }, color: 'uniform', colorParams: { value: Color(0x000000) } }, { tag: 'label' }),
+            ligand: builder.buildRepresentation(update, components.ligand, { type: 'ball-and-stick', typeParams: { ...typeParams, roughness: 0.2, sizeFactor: 0.3 }, color: 'element-symbol', colorParams: { carbonColor: { name: 'element-symbol', params: {} } } }, { tag: 'ligand' }),
+            ballAndStick: builder.buildRepresentation(update, components.surroundings, { type: 'ball-and-stick', typeParams: { ...typeParams, roughness: 0.2, sizeFactor: 0.1, sizeAspectRatio: 1 }, color: 'element-symbol', colorParams: { carbonColor: { name: 'element-symbol', params: {} } } }, { tag: 'ball-and-stick' }),
+            interactions: builder.buildRepresentation(update, components.interactions, { type: InteractionsRepresentationProvider, typeParams: { ...typeParams, roughness: 0.2 }, color: InteractionTypeColorThemeProvider }, { tag: 'interactions' }),
+            label: builder.buildRepresentation(update, components.surroundings, { type: 'label', typeParams: { ...typeParams, roughness: 0.2, background: false, borderWidth: 0.1 }, color: 'uniform', colorParams: { value: Color(0x000000) } }, { tag: 'label' }),
         };
 
         await update.commit({ revertOnError: true });

+ 2 - 4
src/extensions/geo-export/controls.ts

@@ -4,7 +4,6 @@
  * @author Sukolsak Sakshuwong <sukolsak@stanford.edu>
  */
 
-import { getStyle } from '../../mol-gl/renderer';
 import { Box3D } from '../../mol-math/geometry';
 import { PluginComponent } from '../../mol-plugin-state/component';
 import { PluginContext } from '../../mol-plugin/context';
@@ -46,13 +45,12 @@ export class GeometryControls extends PluginComponent {
                 const renderObjects = this.plugin.canvas3d?.getRenderObjects()!;
                 const filename = this.getFilename();
 
-                const style = getStyle(this.plugin.canvas3d?.props.renderer.style!);
                 const boundingSphere = this.plugin.canvas3d?.boundingSphereVisible!;
                 const boundingBox = Box3D.fromSphere3D(Box3D(), boundingSphere);
                 let renderObjectExporter: GlbExporter | ObjExporter | StlExporter | UsdzExporter;
                 switch (this.behaviors.params.value.format) {
                     case 'glb':
-                        renderObjectExporter = new GlbExporter(style, boundingBox);
+                        renderObjectExporter = new GlbExporter(boundingBox);
                         break;
                     case 'obj':
                         renderObjectExporter = new ObjExporter(filename, boundingBox);
@@ -61,7 +59,7 @@ export class GeometryControls extends PluginComponent {
                         renderObjectExporter = new StlExporter(boundingBox);
                         break;
                     case 'usdz':
-                        renderObjectExporter = new UsdzExporter(style, boundingBox, boundingSphere.radius);
+                        renderObjectExporter = new UsdzExporter(boundingBox, boundingSphere.radius);
                         break;
                     default: throw new Error('Unsupported format.');
                 }

+ 24 - 10
src/extensions/geo-export/glb-exporter.ts

@@ -5,7 +5,6 @@
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
 
-import { Style } from '../../mol-gl/renderer';
 import { asciiWrite } from '../../mol-io/common/ascii';
 import { IsNativeEndianLittle, flipByteOrder } from '../../mol-io/common/binary';
 import { Box3D } from '../../mol-math/geometry';
@@ -44,6 +43,8 @@ export class GlbExporter extends MeshExporter<GlbData> {
     readonly fileExtension = 'glb';
     private nodes: Record<string, any>[] = [];
     private meshes: Record<string, any>[] = [];
+    private materials: Record<string, any>[] = [];
+    private materialMap = new Map<string, number>();
     private accessors: Record<string, any>[] = [];
     private bufferViews: Record<string, any>[] = [];
     private binaryBuffer: ArrayBuffer[] = [];
@@ -157,6 +158,21 @@ export class GlbExporter extends MeshExporter<GlbData> {
         return this.addBuffer(colorBuffer, UNSIGNED_BYTE, 'VEC4', vertexCount, ARRAY_BUFFER, undefined, undefined, true);
     }
 
+    private addMaterial(metalness: number, roughness: number) {
+        const hash = `${metalness}|${roughness}`;
+        if (!this.materialMap.has(hash)) {
+            this.materialMap.set(hash, this.materials.length);
+            this.materials.push({
+                pbrMetallicRoughness: {
+                    baseColorFactor: [1, 1, 1, 1],
+                    metallicFactor: metalness,
+                    roughnessFactor: roughness
+                }
+            });
+        }
+        return this.materialMap.get(hash)!;
+    }
+
     protected async addMeshWithColors(input: AddMeshInput) {
         const { mesh, values, isGeoTexture, webgl, ctx } = input;
 
@@ -168,6 +184,10 @@ export class GlbExporter extends MeshExporter<GlbData> {
         const dTransparency = values.dTransparency.ref.value;
         const aTransform = values.aTransform.ref.value;
         const instanceCount = values.uInstanceCount.ref.value;
+        const metalness = values.uMetalness.ref.value;
+        const roughness = values.uRoughness.ref.value;
+
+        const material = this.addMaterial(metalness, roughness);
 
         let interpolatedColors: Uint8Array | undefined;
         if (colorType === 'volume' || colorType === 'volumeInstance') {
@@ -228,7 +248,7 @@ export class GlbExporter extends MeshExporter<GlbData> {
                             COLOR_0: colorAccessorIndex!
                         },
                         indices: indexAccessorIndex,
-                        material: 0
+                        material
                     }]
                 });
             }
@@ -262,13 +282,7 @@ export class GlbExporter extends MeshExporter<GlbData> {
             }],
             bufferViews: this.bufferViews,
             accessors: this.accessors,
-            materials: [{
-                pbrMetallicRoughness: {
-                    baseColorFactor: [1, 1, 1, 1],
-                    metallicFactor: this.style.metalness,
-                    roughnessFactor: this.style.roughness
-                }
-            }]
+            materials: this.materials
         };
 
         const createChunk = (chunkType: number, data: ArrayBuffer[], byteLength: number, padChar: number): [ArrayBuffer[], number] => {
@@ -317,7 +331,7 @@ export class GlbExporter extends MeshExporter<GlbData> {
         return new Blob([(await this.getData()).glb], { type: 'model/gltf-binary' });
     }
 
-    constructor(private style: Style, boundingBox: Box3D) {
+    constructor(boundingBox: Box3D) {
         super();
         const tmpV = Vec3();
         Vec3.add(tmpV, boundingBox.min, boundingBox.max);

+ 14 - 15
src/extensions/geo-export/usdz-exporter.ts

@@ -5,7 +5,6 @@
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
 
-import { Style } from '../../mol-gl/renderer';
 import { asciiWrite } from '../../mol-io/common/ascii';
 import { Box3D } from '../../mol-math/geometry';
 import { Vec3, Mat3, Mat4 } from '../../mol-math/linear-algebra';
@@ -32,17 +31,16 @@ export class UsdzExporter extends MeshExporter<UsdzData> {
     readonly fileExtension = 'usdz';
     private meshes: string[] = [];
     private materials: string[] = [];
-    private materialSet = new Set<number>();
+    private materialMap = new Map<string, number>();
     private centerTransform: Mat4;
 
-    private static getMaterialKey(color: Color, alpha: number) {
-        return color * 256 + Math.round(alpha * 255);
-    }
+    private addMaterial(color: Color, alpha: number, metalness: number, roughness: number): number {
+        const hash = `${color}|${alpha}|${metalness}|${roughness}`;
+        if (this.materialMap.has(hash)) return this.materialMap.get(hash)!;
+
+        const materialKey = this.materialMap.size;
+        this.materialMap.set(hash, materialKey);
 
-    private addMaterial(color: Color, alpha: number) {
-        const materialKey = UsdzExporter.getMaterialKey(color, alpha);
-        if (this.materialSet.has(materialKey)) return;
-        this.materialSet.add(materialKey);
         const [r, g, b] = Color.toRgbNormalized(color).map(v => Math.round(v * 1000) / 1000);
         this.materials.push(`
 def Material "material${materialKey}"
@@ -53,12 +51,13 @@ def Material "material${materialKey}"
         uniform token info:id = "UsdPreviewSurface"
         color3f inputs:diffuseColor = (${r},${g},${b})
         float inputs:opacity = ${alpha}
-        float inputs:metallic = ${this.style.metalness}
-        float inputs:roughness = ${this.style.roughness}
+        float inputs:metallic = ${metalness}
+        float inputs:roughness = ${roughness}
         token outputs:surface
     }
 }
 `);
+        return materialKey;
     }
 
     protected async addMeshWithColors(input: AddMeshInput) {
@@ -75,6 +74,8 @@ def Material "material${materialKey}"
         const uAlpha = values.uAlpha.ref.value;
         const aTransform = values.aTransform.ref.value;
         const instanceCount = values.uInstanceCount.ref.value;
+        const metalness = values.uMetalness.ref.value;
+        const roughness = values.uRoughness.ref.value;
 
         let interpolatedColors: Uint8Array | undefined;
         if (colorType === 'volume' || colorType === 'volumeInstance') {
@@ -158,9 +159,7 @@ def Material "material${materialKey}"
                 const transparency = UsdzExporter.getTransparency(i, geoData, interpolatedTransparency);
                 const alpha = Math.round(uAlpha * (1 - transparency) * 10) / 10; // quantized
 
-                this.addMaterial(color, alpha);
-
-                const materialKey = UsdzExporter.getMaterialKey(color, alpha);
+                const materialKey = this.addMaterial(color, alpha, metalness, roughness);
                 let faceIndices = faceIndicesByMaterial.get(materialKey);
                 if (faceIndices === undefined) {
                     faceIndices = [];
@@ -232,7 +231,7 @@ def Mesh "mesh${this.meshes.length}"
         return new Blob([usdz], { type: 'model/vnd.usdz+zip' });
     }
 
-    constructor(private style: Style, boundingBox: Box3D, radius: number) {
+    constructor(boundingBox: Box3D, radius: number) {
         super();
         const t = Mat4();
         // scale the model so that it fits within 1 meter

+ 16 - 6
src/mol-geo/geometry/base.ts

@@ -16,6 +16,7 @@ import { NullLocation } from '../../mol-model/location';
 import { UniformColorTheme } from '../../mol-theme/color/uniform';
 import { UniformSizeTheme } from '../../mol-theme/size/uniform';
 import { smoothstep } from '../../mol-math/interpolate';
+import { Material } from '../../mol-util/material';
 
 export const VisualQualityInfo = {
     'custom': {},
@@ -69,18 +70,20 @@ export function getColorSmoothingProps(smoothColors: PD.Values<ColorSmoothingPar
 //
 
 export namespace BaseGeometry {
-    export const Params = {
-        alpha: PD.Numeric(1, { min: 0, max: 1, step: 0.01 }, { label: 'Opacity', isEssential: true, description: 'How opaque/transparent the representation is rendered.' }),
-        quality: PD.Select<VisualQuality>('auto', VisualQualityOptions, { isEssential: true, description: 'Visual/rendering quality of the representation.' }),
-    };
-    export type Params = typeof Params
-
+    export const MaterialCategory: PD.Info = { category: 'Material' };
     export const ShadingCategory: PD.Info = { category: 'Shading' };
     export const CustomQualityParamInfo: PD.Info = {
         category: 'Custom Quality',
         hideIf: (params: PD.Values<Params>) => typeof params.quality !== 'undefined' && params.quality !== 'custom'
     };
 
+    export const Params = {
+        alpha: PD.Numeric(1, { min: 0, max: 1, step: 0.01 }, { label: 'Opacity', isEssential: true, description: 'How opaque/transparent the representation is rendered.' }),
+        quality: PD.Select<VisualQuality>('auto', VisualQualityOptions, { isEssential: true, description: 'Visual/rendering quality of the representation.' }),
+        material: Material.getParam(),
+    };
+    export type Params = typeof Params
+
     export type Counts = { drawCount: number, vertexCount: number, groupCount: number, instanceCount: number }
 
     export function createSimple(colorValue = ColorNames.grey, sizeValue = 1, transform?: TransformData) {
@@ -94,17 +97,24 @@ export namespace BaseGeometry {
     }
 
     export function createValues(props: PD.Values<Params>, counts: Counts) {
+        const { metalness, roughness } = Material.toObjectNormalized(props.material);
         return {
             alpha: ValueCell.create(props.alpha),
             uAlpha: ValueCell.create(props.alpha),
             uVertexCount: ValueCell.create(counts.vertexCount),
             uGroupCount: ValueCell.create(counts.groupCount),
             drawCount: ValueCell.create(counts.drawCount),
+            uMetalness: ValueCell.create(metalness),
+            uRoughness: ValueCell.create(roughness),
+            dLightCount: ValueCell.create(1),
         };
     }
 
     export function updateValues(values: BaseValues, props: PD.Values<Params>) {
+        const { metalness, roughness } = Material.toObjectNormalized(props.material);
         ValueCell.updateIfChanged(values.alpha, props.alpha); // `uAlpha` is set in renderable.render
+        ValueCell.updateIfChanged(values.uMetalness, metalness);
+        ValueCell.updateIfChanged(values.uRoughness, roughness);
     }
 
     export function createRenderableState(props: Partial<PD.Values<Params>> = {}): RenderableState {

+ 3 - 0
src/mol-geo/geometry/cylinders/cylinders.ts

@@ -25,6 +25,7 @@ import { hashFnv32a } from '../../../mol-data/util';
 import { createEmptyClipping } from '../clipping-data';
 import { CylindersValues } from '../../../mol-gl/renderable/cylinders';
 import { RenderableState } from '../../../mol-gl/renderable';
+import { createEmptySubstance } from '../substance-data';
 
 export interface Cylinders {
     readonly kind: 'cylinders',
@@ -200,6 +201,7 @@ export namespace Cylinders {
         const marker = createMarkers(instanceCount * groupCount);
         const overpaint = createEmptyOverpaint();
         const transparency = createEmptyTransparency();
+        const material = createEmptySubstance();
         const clipping = createEmptyClipping();
 
         const counts = { drawCount: cylinders.cylinderCount * 4 * 3, vertexCount: cylinders.cylinderCount * 6, groupCount, instanceCount };
@@ -224,6 +226,7 @@ export namespace Cylinders {
             ...marker,
             ...overpaint,
             ...transparency,
+            ...material,
             ...clipping,
             ...transform,
 

+ 4 - 2
src/mol-geo/geometry/direct-volume/direct-volume.ts

@@ -28,6 +28,7 @@ import { createTransferFunctionTexture, getControlPointsFromVec2Array } from './
 import { createEmptyClipping } from '../clipping-data';
 import { Grid, Volume } from '../../../mol-model/volume';
 import { ColorNames } from '../../../mol-util/color/names';
+import { createEmptySubstance } from '../substance-data';
 
 const VolumeBox = Box();
 
@@ -246,6 +247,7 @@ export namespace DirectVolume {
         const marker = createMarkers(instanceCount * groupCount);
         const overpaint = createEmptyOverpaint();
         const transparency = createEmptyTransparency();
+        const material = createEmptySubstance();
         const clipping = createEmptyClipping();
 
         const [x, y, z] = gridDimension.ref.value;
@@ -270,6 +272,7 @@ export namespace DirectVolume {
             ...marker,
             ...overpaint,
             ...transparency,
+            ...material,
             ...clipping,
             ...transform,
             ...BaseGeometry.createValues(props, counts),
@@ -319,8 +322,7 @@ export namespace DirectVolume {
     }
 
     function updateValues(values: DirectVolumeValues, props: PD.Values<Params>) {
-        ValueCell.updateIfChanged(values.alpha, props.alpha);
-        ValueCell.updateIfChanged(values.uAlpha, props.alpha);
+        BaseGeometry.updateValues(values, props);
         ValueCell.updateIfChanged(values.dDoubleSided, props.doubleSided);
         ValueCell.updateIfChanged(values.dFlatShaded, props.flatShaded);
         ValueCell.updateIfChanged(values.dFlipSided, props.flipSided);

+ 3 - 0
src/mol-geo/geometry/image/image.ts

@@ -26,6 +26,7 @@ import { fillSerial } from '../../../mol-util/array';
 import { createEmptyClipping } from '../clipping-data';
 import { NullLocation } from '../../../mol-model/location';
 import { QuadPositions } from '../../../mol-gl/compute/util';
+import { createEmptySubstance } from '../substance-data';
 
 const QuadIndices = new Uint32Array([
     0, 1, 2,
@@ -145,6 +146,7 @@ namespace Image {
         const marker = createMarkers(instanceCount * groupCount);
         const overpaint = createEmptyOverpaint();
         const transparency = createEmptyTransparency();
+        const material = createEmptySubstance();
         const clipping = createEmptyClipping();
 
         const counts = { drawCount: QuadIndices.length, vertexCount: QuadPositions.length / 3, groupCount, instanceCount };
@@ -157,6 +159,7 @@ namespace Image {
             ...marker,
             ...overpaint,
             ...transparency,
+            ...material,
             ...clipping,
             ...transform,
             ...BaseGeometry.createValues(props, counts),

+ 3 - 0
src/mol-geo/geometry/lines/lines.ts

@@ -26,6 +26,7 @@ import { createEmptyOverpaint } from '../overpaint-data';
 import { createEmptyTransparency } from '../transparency-data';
 import { hashFnv32a } from '../../../mol-data/util';
 import { createEmptyClipping } from '../clipping-data';
+import { createEmptySubstance } from '../substance-data';
 
 /** Wide line */
 export interface Lines {
@@ -210,6 +211,7 @@ export namespace Lines {
         const marker = createMarkers(instanceCount * groupCount);
         const overpaint = createEmptyOverpaint();
         const transparency = createEmptyTransparency();
+        const material = createEmptySubstance();
         const clipping = createEmptyClipping();
 
         const counts = { drawCount: lines.lineCount * 2 * 3, vertexCount: lines.lineCount * 4, groupCount, instanceCount };
@@ -231,6 +233,7 @@ export namespace Lines {
             ...marker,
             ...overpaint,
             ...transparency,
+            ...material,
             ...clipping,
             ...transform,
 

+ 34 - 0
src/mol-geo/geometry/mesh/color-smoothing.ts

@@ -356,3 +356,37 @@ export function applyMeshTransparencySmoothing(values: MeshValues, resolution: n
         ValueCell.update(values.uTransparencyTexDim, smoothingData.texDim);
     }
 }
+
+function isSupportedSubstanceType(x: string): x is 'groupInstance' {
+    return x === 'groupInstance';
+}
+
+export function applyMeshSubstanceSmoothing(values: MeshValues, resolution: number, stride: number, webgl?: WebGLContext, colorTexture?: Texture) {
+    if (!isSupportedSubstanceType(values.dSubstanceType.ref.value)) return;
+
+    const smoothingData = calcMeshColorSmoothing({
+        vertexCount: values.uVertexCount.ref.value,
+        instanceCount: values.uInstanceCount.ref.value,
+        groupCount: values.uGroupCount.ref.value,
+        transformBuffer: values.aTransform.ref.value,
+        instanceBuffer: values.aInstance.ref.value,
+        positionBuffer: values.aPosition.ref.value,
+        groupBuffer: values.aGroup.ref.value,
+        colorData: values.tSubstance.ref.value,
+        colorType: values.dSubstanceType.ref.value,
+        boundingSphere: values.boundingSphere.ref.value,
+        invariantBoundingSphere: values.invariantBoundingSphere.ref.value,
+        itemSize: 3
+    }, resolution, stride, webgl, colorTexture);
+    if (smoothingData.kind === 'volume') {
+        ValueCell.updateIfChanged(values.dSubstanceType, smoothingData.type);
+        ValueCell.update(values.tSubstanceGrid, smoothingData.texture);
+        ValueCell.update(values.uSubstanceTexDim, smoothingData.gridTexDim);
+        ValueCell.update(values.uSubstanceGridDim, smoothingData.gridDim);
+        ValueCell.update(values.uSubstanceGridTransform, smoothingData.gridTransform);
+    } else if (smoothingData.kind === 'vertex') {
+        ValueCell.updateIfChanged(values.dSubstanceType, smoothingData.type);
+        ValueCell.update(values.tSubstance, smoothingData.texture);
+        ValueCell.update(values.uSubstanceTexDim, smoothingData.texDim);
+    }
+}

+ 3 - 0
src/mol-geo/geometry/mesh/mesh.ts

@@ -27,6 +27,7 @@ import { createEmptyClipping } from '../clipping-data';
 import { RenderableState } from '../../../mol-gl/renderable';
 import { arraySetAdd } from '../../../mol-util/array';
 import { degToRad } from '../../../mol-math/misc';
+import { createEmptySubstance } from '../substance-data';
 
 export interface Mesh {
     readonly kind: 'mesh',
@@ -665,6 +666,7 @@ export namespace Mesh {
         const marker = createMarkers(instanceCount * groupCount);
         const overpaint = createEmptyOverpaint();
         const transparency = createEmptyTransparency();
+        const material = createEmptySubstance();
         const clipping = createEmptyClipping();
 
         const counts = { drawCount: mesh.triangleCount * 3, vertexCount: mesh.vertexCount, groupCount, instanceCount };
@@ -684,6 +686,7 @@ export namespace Mesh {
             ...marker,
             ...overpaint,
             ...transparency,
+            ...material,
             ...clipping,
             ...transform,
 

+ 3 - 0
src/mol-geo/geometry/points/points.ts

@@ -25,6 +25,7 @@ import { createEmptyOverpaint } from '../overpaint-data';
 import { createEmptyTransparency } from '../transparency-data';
 import { hashFnv32a } from '../../../mol-data/util';
 import { createEmptyClipping } from '../clipping-data';
+import { createEmptySubstance } from '../substance-data';
 
 /** Point cloud */
 export interface Points {
@@ -172,6 +173,7 @@ export namespace Points {
         const marker = createMarkers(instanceCount * groupCount);
         const overpaint = createEmptyOverpaint();
         const transparency = createEmptyTransparency();
+        const material = createEmptySubstance();
         const clipping = createEmptyClipping();
 
         const counts = { drawCount: points.pointCount, vertexCount: points.pointCount, groupCount, instanceCount };
@@ -190,6 +192,7 @@ export namespace Points {
             ...marker,
             ...overpaint,
             ...transparency,
+            ...material,
             ...clipping,
             ...transform,
 

+ 3 - 0
src/mol-geo/geometry/spheres/spheres.ts

@@ -25,6 +25,7 @@ import { GroupMapping, createGroupMapping } from '../../util';
 import { createEmptyClipping } from '../clipping-data';
 import { Vec3, Vec4 } from '../../../mol-math/linear-algebra';
 import { RenderableState } from '../../../mol-gl/renderable';
+import { createEmptySubstance } from '../substance-data';
 
 export interface Spheres {
     readonly kind: 'spheres',
@@ -170,6 +171,7 @@ export namespace Spheres {
         const marker = createMarkers(instanceCount * groupCount);
         const overpaint = createEmptyOverpaint();
         const transparency = createEmptyTransparency();
+        const material = createEmptySubstance();
         const clipping = createEmptyClipping();
 
         const counts = { drawCount: spheres.sphereCount * 2 * 3, vertexCount: spheres.sphereCount * 4, groupCount, instanceCount };
@@ -191,6 +193,7 @@ export namespace Spheres {
             ...marker,
             ...overpaint,
             ...transparency,
+            ...material,
             ...clipping,
             ...transform,
 

+ 76 - 0
src/mol-geo/geometry/substance-data.ts

@@ -0,0 +1,76 @@
+/**
+ * Copyright (c) 2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ */
+
+import { ValueCell } from '../../mol-util/value-cell';
+import { Vec2, Vec3, Vec4 } from '../../mol-math/linear-algebra';
+import { TextureImage, createTextureImage } from '../../mol-gl/renderable/util';
+import { createNullTexture, Texture } from '../../mol-gl/webgl/texture';
+import { Material } from '../../mol-util/material';
+
+export type SubstanceData = {
+    tSubstance: ValueCell<TextureImage<Uint8Array>>
+    uSubstanceTexDim: ValueCell<Vec2>
+    dSubstance: ValueCell<boolean>,
+
+    tSubstanceGrid: ValueCell<Texture>,
+    uSubstanceGridDim: ValueCell<Vec3>,
+    uSubstanceGridTransform: ValueCell<Vec4>,
+    dSubstanceType: ValueCell<string>,
+}
+
+export function applySubstanceMaterial(array: Uint8Array, start: number, end: number, material: Material) {
+    for (let i = start; i < end; ++i) {
+        Material.toArray(material, array, i * 3);
+        array[i * 3 + 2] = 255;
+    }
+    return true;
+}
+
+export function clearSubstance(array: Uint8Array, start: number, end: number) {
+    array.fill(0, start * 3, end * 3);
+    return true;
+}
+
+export function createSubstance(count: number, substanceData?: SubstanceData): SubstanceData {
+    const substance = createTextureImage(Math.max(1, count), 4, Uint8Array, substanceData && substanceData.tSubstance.ref.value.array);
+    if (substanceData) {
+        ValueCell.update(substanceData.tSubstance, substance);
+        ValueCell.update(substanceData.uSubstanceTexDim, Vec2.create(substance.width, substance.height));
+        ValueCell.updateIfChanged(substanceData.dSubstance, count > 0);
+        return substanceData;
+    } else {
+        return {
+            tSubstance: ValueCell.create(substance),
+            uSubstanceTexDim: ValueCell.create(Vec2.create(substance.width, substance.height)),
+            dSubstance: ValueCell.create(count > 0),
+
+            tSubstanceGrid: ValueCell.create(createNullTexture()),
+            uSubstanceGridDim: ValueCell.create(Vec3.create(1, 1, 1)),
+            uSubstanceGridTransform: ValueCell.create(Vec4.create(0, 0, 0, 1)),
+            dSubstanceType: ValueCell.create('groupInstance'),
+        };
+    }
+}
+
+const emptySubstanceTexture = { array: new Uint8Array(4), width: 1, height: 1 };
+export function createEmptySubstance(substanceData?: SubstanceData): SubstanceData {
+    if (substanceData) {
+        ValueCell.update(substanceData.tSubstance, emptySubstanceTexture);
+        ValueCell.update(substanceData.uSubstanceTexDim, Vec2.create(1, 1));
+        return substanceData;
+    } else {
+        return {
+            tSubstance: ValueCell.create(emptySubstanceTexture),
+            uSubstanceTexDim: ValueCell.create(Vec2.create(1, 1)),
+            dSubstance: ValueCell.create(false),
+
+            tSubstanceGrid: ValueCell.create(createNullTexture()),
+            uSubstanceGridDim: ValueCell.create(Vec3.create(1, 1, 1)),
+            uSubstanceGridTransform: ValueCell.create(Vec4.create(0, 0, 0, 1)),
+            dSubstanceType: ValueCell.create('groupInstance'),
+        };
+    }
+}

+ 3 - 0
src/mol-geo/geometry/text/text.ts

@@ -29,6 +29,7 @@ import { createEmptyTransparency } from '../transparency-data';
 import { hashFnv32a } from '../../../mol-data/util';
 import { GroupMapping, createGroupMapping } from '../../util';
 import { createEmptyClipping } from '../clipping-data';
+import { createEmptySubstance } from '../substance-data';
 
 type TextAttachment = (
     'bottom-left' | 'bottom-center' | 'bottom-right' |
@@ -213,6 +214,7 @@ export namespace Text {
         const marker = createMarkers(instanceCount * groupCount);
         const overpaint = createEmptyOverpaint();
         const transparency = createEmptyTransparency();
+        const substance = createEmptySubstance();
         const clipping = createEmptyClipping();
 
         const counts = { drawCount: text.charCount * 2 * 3, vertexCount: text.charCount * 4, groupCount, instanceCount };
@@ -235,6 +237,7 @@ export namespace Text {
             ...marker,
             ...overpaint,
             ...transparency,
+            ...substance,
             ...clipping,
             ...transform,
 

+ 36 - 0
src/mol-geo/geometry/texture-mesh/color-smoothing.ts

@@ -500,3 +500,39 @@ export function applyTextureMeshTransparencySmoothing(values: TextureMeshValues,
     ValueCell.update(values.uTransparencyGridDim, smoothingData.gridDim);
     ValueCell.update(values.uTransparencyGridTransform, smoothingData.gridTransform);
 }
+
+function isSupportedSubstanceType(x: string): x is 'groupInstance' {
+    return x === 'groupInstance';
+}
+
+export function applyTextureMeshSubstanceSmoothing(values: TextureMeshValues, resolution: number, stride: number, webgl: WebGLContext, colorTexture?: Texture) {
+    if (!isSupportedSubstanceType(values.dSubstanceType.ref.value)) return;
+
+    stride *= 3; // triple because TextureMesh is never indexed (no elements buffer)
+
+    if (!webgl.namedTextures[ColorSmoothingRgbName]) {
+        webgl.namedTextures[ColorSmoothingRgbName] = webgl.resources.texture('image-uint8', 'rgb', 'ubyte', 'nearest');
+    }
+    const colorData = webgl.namedTextures[ColorSmoothingRgbName];
+    colorData.load(values.tSubstance.ref.value);
+
+    const smoothingData = calcTextureMeshColorSmoothing({
+        vertexCount: values.uVertexCount.ref.value,
+        instanceCount: values.uInstanceCount.ref.value,
+        groupCount: values.uGroupCount.ref.value,
+        transformBuffer: values.aTransform.ref.value,
+        instanceBuffer: values.aInstance.ref.value,
+        positionTexture: values.tPosition.ref.value,
+        groupTexture: values.tGroup.ref.value,
+        colorData,
+        colorType: values.dSubstanceType.ref.value,
+        boundingSphere: values.boundingSphere.ref.value,
+        invariantBoundingSphere: values.invariantBoundingSphere.ref.value,
+    }, resolution, stride, webgl, colorTexture);
+
+    ValueCell.updateIfChanged(values.dSubstanceType, smoothingData.type);
+    ValueCell.update(values.tSubstanceGrid, smoothingData.texture);
+    ValueCell.update(values.uSubstanceTexDim, smoothingData.gridTexDim);
+    ValueCell.update(values.uSubstanceGridDim, smoothingData.gridDim);
+    ValueCell.update(values.uSubstanceGridTransform, smoothingData.gridTransform);
+}

+ 3 - 0
src/mol-geo/geometry/texture-mesh/texture-mesh.ts

@@ -23,6 +23,7 @@ import { createNullTexture, Texture } from '../../../mol-gl/webgl/texture';
 import { Vec2, Vec4 } from '../../../mol-math/linear-algebra';
 import { createEmptyClipping } from '../clipping-data';
 import { NullLocation } from '../../../mol-model/location';
+import { createEmptySubstance } from '../substance-data';
 
 export interface TextureMesh {
     readonly kind: 'texture-mesh',
@@ -135,6 +136,7 @@ export namespace TextureMesh {
         const marker = createMarkers(instanceCount * groupCount);
         const overpaint = createEmptyOverpaint();
         const transparency = createEmptyTransparency();
+        const substance = createEmptySubstance();
         const clipping = createEmptyClipping();
 
         const counts = { drawCount: textureMesh.vertexCount, vertexCount: textureMesh.vertexCount, groupCount, instanceCount };
@@ -156,6 +158,7 @@ export namespace TextureMesh {
             ...marker,
             ...overpaint,
             ...transparency,
+            ...substance,
             ...clipping,
             ...transform,
 

+ 1 - 1
src/mol-gl/_spec/renderer.spec.ts

@@ -52,7 +52,7 @@ describe('renderer', () => {
         scene.add(points);
         scene.commit();
         expect(ctx.stats.resourceCounts.attribute).toBe(ctx.isWebGL2 ? 4 : 5);
-        expect(ctx.stats.resourceCounts.texture).toBe(7);
+        expect(ctx.stats.resourceCounts.texture).toBe(8);
         expect(ctx.stats.resourceCounts.vertexArray).toBe(ctx.extensions.vertexArrayObject ? 8 : 0);
         expect(ctx.stats.resourceCounts.program).toBe(8);
         expect(ctx.stats.resourceCounts.shader).toBe(16);

+ 1 - 2
src/mol-gl/renderable/points.ts

@@ -10,7 +10,6 @@ import { createGraphicsRenderItem } from '../webgl/render-item';
 import { GlobalUniformSchema, BaseSchema, AttributeSpec, DefineSpec, Values, InternalSchema, SizeSchema, InternalValues, GlobalTextureSchema } from './schema';
 import { PointsShaderCode } from '../shader-code';
 import { ValueCell } from '../../mol-util';
-import { Points } from '../../mol-geo/geometry/points/points';
 
 export const PointsSchema = {
     ...BaseSchema,
@@ -18,7 +17,7 @@ export const PointsSchema = {
     aGroup: AttributeSpec('float32', 1, 0),
     aPosition: AttributeSpec('float32', 3, 0),
     dPointSizeAttenuation: DefineSpec('boolean'),
-    dPointStyle: DefineSpec('string', Points.StyleTypeNames),
+    dPointStyle: DefineSpec('string', ['square', 'circle', 'fuzzy']),
 };
 export type PointsSchema = typeof PointsSchema
 export type PointsValues = Values<PointsSchema>

+ 22 - 9
src/mol-gl/renderable/schema.ts

@@ -141,15 +141,9 @@ export const GlobalUniformSchema = {
     uClipObjectRotation: UniformSpec('v4[]'),
     uClipObjectScale: UniformSpec('v3[]'),
 
-    // all the following could in principle be per object
-    // as a kind of 'material' parameter set
-    // would need to test performance implications
-    uLightIntensity: UniformSpec('f'),
-    uAmbientIntensity: UniformSpec('f'),
-
-    uMetalness: UniformSpec('f'),
-    uRoughness: UniformSpec('f'),
-    uReflectivity: UniformSpec('f'),
+    uLightDirection: UniformSpec('v3[]'),
+    uLightColor: UniformSpec('v3[]'),
+    uAmbientColor: UniformSpec('v3'),
 
     uPickingAlphaThreshold: UniformSpec('f'),
 
@@ -247,6 +241,19 @@ export const TransparencySchema = {
 export type TransparencySchema = typeof TransparencySchema
 export type TransparencyValues = Values<TransparencySchema>
 
+export const SubstanceSchema = {
+    uSubstanceTexDim: UniformSpec('v2'),
+    tSubstance: TextureSpec('image-uint8', 'rgb', 'ubyte', 'nearest'),
+    dSubstance: DefineSpec('boolean'),
+
+    uSubstanceGridDim: UniformSpec('v3'),
+    uSubstanceGridTransform: UniformSpec('v4'),
+    tSubstanceGrid: TextureSpec('texture', 'rgb', 'ubyte', 'linear'),
+    dSubstanceType: DefineSpec('string', ['groupInstance', 'volumeInstance']),
+} as const;
+export type SubstanceSchema = typeof SubstanceSchema
+export type SubstanceValues = Values<SubstanceSchema>
+
 export const ClippingSchema = {
     dClipObjectCount: DefineSpec('number'),
     dClipVariant: DefineSpec('string', ['instance', 'pixel']),
@@ -263,8 +270,11 @@ export const BaseSchema = {
     ...MarkerSchema,
     ...OverpaintSchema,
     ...TransparencySchema,
+    ...SubstanceSchema,
     ...ClippingSchema,
 
+    dLightCount: DefineSpec('number'),
+
     aInstance: AttributeSpec('float32', 1, 1),
     /**
      * final per-instance transform calculated for instance `i` as
@@ -276,6 +286,9 @@ export const BaseSchema = {
      * final alpha, calculated as `values.alpha * state.alpha`
      */
     uAlpha: UniformSpec('f', 'material'),
+    uMetalness: UniformSpec('f', 'material'),
+    uRoughness: UniformSpec('f', 'material'),
+
     uVertexCount: UniformSpec('i'),
     uInstanceCount: UniformSpec('i'),
     uGroupCount: UniformSpec('i'),

+ 57 - 66
src/mol-gl/renderer.ts

@@ -70,7 +70,6 @@ interface Renderer {
 export const RendererParams = {
     backgroundColor: PD.Color(Color(0x000000), { description: 'Background color of the 3D canvas' }),
 
-    // the following are general 'material' parameters
     pickingAlphaThreshold: PD.Numeric(0.5, { min: 0.0, max: 1.0, step: 0.01 }, { description: 'The minimum opacity value needed for an object to be pickable.' }),
 
     interiorDarkening: PD.Numeric(0.5, { min: 0.0, max: 1.0, step: 0.01 }),
@@ -85,20 +84,19 @@ export const RendererParams = {
 
     xrayEdgeFalloff: PD.Numeric(1, { min: 0.0, max: 3.0, step: 0.1 }),
 
-    style: PD.MappedStatic('matte', {
-        custom: PD.Group({
-            lightIntensity: PD.Numeric(0.6, { min: 0.0, max: 1.0, step: 0.01 }),
-            ambientIntensity: PD.Numeric(0.4, { min: 0.0, max: 1.0, step: 0.01 }),
-            metalness: PD.Numeric(0.0, { min: 0.0, max: 1.0, step: 0.01 }),
-            roughness: PD.Numeric(1.0, { min: 0.0, max: 1.0, step: 0.01 }),
-            reflectivity: PD.Numeric(0.5, { min: 0.0, max: 1.0, step: 0.01 }),
-        }, { isExpanded: true }),
-        flat: PD.Group({}),
-        matte: PD.Group({}),
-        glossy: PD.Group({}),
-        metallic: PD.Group({}),
-        plastic: PD.Group({}),
-    }, { label: 'Lighting', description: 'Style in which the 3D scene is rendered/lighted' }),
+    light: PD.ObjectList({
+        inclination: PD.Numeric(180, { min: 0, max: 180, step: 1 }),
+        azimuth: PD.Numeric(0, { min: 0, max: 360, step: 1 }),
+        color: PD.Color(Color.fromNormalizedRgb(1.0, 1.0, 1.0)),
+        intensity: PD.Numeric(0.6, { min: 0.0, max: 1.0, step: 0.01 }),
+    }, o => Color.toHexString(o.color), { defaultValue: [{
+        inclination: 180,
+        azimuth: 0,
+        color: Color.fromNormalizedRgb(1.0, 1.0, 1.0),
+        intensity: 0.6
+    }] }),
+    ambientColor: PD.Color(Color.fromNormalizedRgb(1.0, 1.0, 1.0)),
+    ambientIntensity: PD.Numeric(0.4, { min: 0.0, max: 1.0, step: 0.01 }),
 
     clip: PD.Group({
         variant: PD.Select('instance', PD.arrayToOptions<Clipping.Variant>(['instance', 'pixel'])),
@@ -116,44 +114,27 @@ export const RendererParams = {
 };
 export type RendererProps = PD.Values<typeof RendererParams>
 
-export type Style = {
-    lightIntensity: number
-    ambientIntensity: number
-    metalness: number
-    roughness: number
-    reflectivity: number
+type Light = {
+    count: number
+    direction: number[]
+    color: number[]
 }
 
-export function getStyle(props: RendererProps['style']): Style {
-    switch (props.name) {
-        case 'custom':
-            return props.params as Style;
-        case 'flat':
-            return {
-                lightIntensity: 0, ambientIntensity: 1,
-                metalness: 0, roughness: 0.4, reflectivity: 0.5
-            };
-        case 'matte':
-            return {
-                lightIntensity: 0.7, ambientIntensity: 0.3,
-                metalness: 0, roughness: 1, reflectivity: 0.5
-            };
-        case 'glossy':
-            return {
-                lightIntensity: 0.7, ambientIntensity: 0.3,
-                metalness: 0, roughness: 0.4, reflectivity: 0.5
-            };
-        case 'metallic':
-            return {
-                lightIntensity: 0.7, ambientIntensity: 0.7,
-                metalness: 0.6, roughness: 0.6, reflectivity: 0.5
-            };
-        case 'plastic':
-            return {
-                lightIntensity: 0.7, ambientIntensity: 0.3,
-                metalness: 0, roughness: 0.2, reflectivity: 0.5
-            };
+const tmpDir = Vec3();
+const tmpColor = Vec3();
+function getLight(props: RendererProps['light'], light?: Light): Light {
+    const { direction, color } = light || {
+        direction: (new Array(5 * 3)).fill(0),
+        color: (new Array(5 * 3)).fill(0),
+    };
+    for (let i = 0, il = props.length; i < il; ++i) {
+        const p = props[i];
+        Vec3.directionFromSpherical(tmpDir, degToRad(p.inclination), degToRad(p.azimuth), 1);
+        Vec3.toArray(tmpDir, direction, i * 3);
+        Vec3.scale(tmpColor, Color.toVec3Normalized(tmpColor, p.color), p.intensity);
+        Vec3.toArray(tmpColor, color, i * 3);
     }
+    return { count: props.length, direction, color };
 }
 
 type Clip = {
@@ -195,7 +176,7 @@ namespace Renderer {
     export function create(ctx: WebGLContext, props: Partial<RendererProps> = {}): Renderer {
         const { gl, state, stats, extensions: { fragDepth } } = ctx;
         const p = PD.merge(RendererParams, PD.getDefaultValues(RendererParams), props);
-        const style = getStyle(p.style);
+        const light = getLight(p.light);
         const clip = getClip(p.clip);
 
         const viewport = Viewport();
@@ -220,6 +201,9 @@ namespace Renderer {
         const cameraDir = Vec3();
         const viewOffset = Vec2();
 
+        const ambientColor = Vec3();
+        Vec3.scale(ambientColor, Color.toArrayNormalized(p.ambientColor, ambientColor, 0), p.ambientIntensity);
+
         const globalUniforms: GlobalUniformValues = {
             uModel: ValueCell.create(Mat4.identity()),
             uView: ValueCell.create(view),
@@ -257,13 +241,9 @@ namespace Renderer {
             uClipObjectRotation: ValueCell.create(clip.objects.rotation),
             uClipObjectScale: ValueCell.create(clip.objects.scale),
 
-            // the following are general 'material' uniforms
-            uLightIntensity: ValueCell.create(style.lightIntensity),
-            uAmbientIntensity: ValueCell.create(style.ambientIntensity),
-
-            uMetalness: ValueCell.create(style.metalness),
-            uRoughness: ValueCell.create(style.roughness),
-            uReflectivity: ValueCell.create(style.reflectivity),
+            uLightDirection: ValueCell.create(light.direction),
+            uLightColor: ValueCell.create(light.color),
+            uAmbientColor: ValueCell.create(ambientColor),
 
             uPickingAlphaThreshold: ValueCell.create(p.pickingAlphaThreshold),
 
@@ -304,6 +284,10 @@ namespace Renderer {
                     definesNeedUpdate = true;
                 }
             }
+            if (r.values.dLightCount.ref.value !== light.count) {
+                ValueCell.update(r.values.dLightCount, light.count);
+                definesNeedUpdate = true;
+            }
             if (definesNeedUpdate) r.update();
 
             const program = r.getProgram(variant);
@@ -686,14 +670,21 @@ namespace Renderer {
                     ValueCell.update(globalUniforms.uXrayEdgeFalloff, p.xrayEdgeFalloff);
                 }
 
-                if (props.style !== undefined) {
-                    p.style = props.style;
-                    Object.assign(style, getStyle(props.style));
-                    ValueCell.updateIfChanged(globalUniforms.uLightIntensity, style.lightIntensity);
-                    ValueCell.updateIfChanged(globalUniforms.uAmbientIntensity, style.ambientIntensity);
-                    ValueCell.updateIfChanged(globalUniforms.uMetalness, style.metalness);
-                    ValueCell.updateIfChanged(globalUniforms.uRoughness, style.roughness);
-                    ValueCell.updateIfChanged(globalUniforms.uReflectivity, style.reflectivity);
+                if (props.light !== undefined && !deepEqual(props.light, p.light)) {
+                    p.light = props.light;
+                    Object.assign(light, getLight(props.light, light));
+                    ValueCell.update(globalUniforms.uLightDirection, light.direction);
+                    ValueCell.update(globalUniforms.uLightColor, light.color);
+                }
+                if (props.ambientColor !== undefined && props.ambientColor !== p.ambientColor) {
+                    p.ambientColor = props.ambientColor;
+                    Vec3.scale(ambientColor, Color.toArrayNormalized(p.ambientColor, ambientColor, 0), p.ambientIntensity);
+                    ValueCell.update(globalUniforms.uAmbientColor, ambientColor);
+                }
+                if (props.ambientIntensity !== undefined && props.ambientIntensity !== p.ambientIntensity) {
+                    p.ambientIntensity = props.ambientIntensity;
+                    Vec3.scale(ambientColor, Color.toArrayNormalized(p.ambientColor, ambientColor, 0), p.ambientIntensity);
+                    ValueCell.update(globalUniforms.uAmbientColor, ambientColor);
                 }
 
                 if (props.clip !== undefined && !deepEqual(props.clip, p.clip)) {

+ 33 - 14
src/mol-gl/shader-code.ts

@@ -17,7 +17,6 @@ const shaderCodeId = idFactory();
 
 type ShaderExtensionsValue = 'required' | 'optional'
 export interface ShaderExtensions {
-    readonly standardDerivatives?: ShaderExtensionsValue
     readonly fragDepth?: ShaderExtensionsValue
     readonly drawBuffers?: ShaderExtensionsValue
     readonly shaderTextureLod?: ShaderExtensionsValue
@@ -101,7 +100,8 @@ const ShaderChunks: { [k: string]: string } = {
     wboit_write
 };
 
-const reInclude = /^(?!\/\/)\s*#include\s+(\S+)/gmi;
+const reInclude = /^(?!\/\/)\s*#include\s+(\S+)/gm;
+const reUnrollLoop = /#pragma unroll_loop_start\s+for\s*\(\s*int\s+i\s*=\s*(\d+)\s*;\s*i\s*<\s*(\d+)\s*;\s*\+\+i\s*\s*\)\s*{([\s\S]+?)}\s+#pragma unroll_loop_end/g;
 const reSingleLineComment = /[ \t]*\/\/.*\n/g;
 const reMultiLineComment = /[ \t]*\/\*[\s\S]*?\*\//g;
 const reMultipleLinebreaks = /\n{2,}/g;
@@ -119,6 +119,30 @@ function addIncludes(text: string) {
         .replace(reMultipleLinebreaks, '\n');
 }
 
+function unrollLoops(str: string) {
+    return str.replace(reUnrollLoop, loopReplacer);
+}
+
+function loopReplacer(match: string, start: string, end: string, snippet: string) {
+    let out = '';
+    for (let i = parseInt(start); i < parseInt(end); ++i) {
+        out += snippet
+            .replace(/\[\s*i\s*\]/g, `[${i}]`)
+            .replace(/UNROLLED_LOOP_INDEX/g, `${i}`);
+    }
+    return out;
+}
+
+function replaceCounts(str: string, defines: ShaderDefines) {
+    if (defines.dLightCount) str = str.replace(/dLightCount/g, `${defines.dLightCount.ref.value}`);
+    if (defines.dClipObjectCount) str = str.replace(/dClipObjectCount/g, `${defines.dClipObjectCount.ref.value}`);
+    return str;
+}
+
+function preprocess(str: string, defines: ShaderDefines) {
+    return unrollLoops(replaceCounts(str, defines));
+}
+
 export function ShaderCode(name: string, vert: string, frag: string, extensions: ShaderExtensions = {}, outTypes: FragOutTypes = {}): ShaderCode {
     return { id: shaderCodeId(), name, vert: addIncludes(vert), frag: addIncludes(frag), extensions, outTypes };
 }
@@ -139,7 +163,7 @@ export const CylindersShaderCode = ShaderCode('cylinders', cylinders_vert, cylin
 
 import { text_vert } from './shader/text.vert';
 import { text_frag } from './shader/text.frag';
-export const TextShaderCode = ShaderCode('text', text_vert, text_frag, { standardDerivatives: 'required', drawBuffers: 'optional' });
+export const TextShaderCode = ShaderCode('text', text_vert, text_frag, { drawBuffers: 'optional' });
 
 import { lines_vert } from './shader/lines.vert';
 import { lines_frag } from './shader/lines.frag';
@@ -147,7 +171,7 @@ export const LinesShaderCode = ShaderCode('lines', lines_vert, lines_frag, { dra
 
 import { mesh_vert } from './shader/mesh.vert';
 import { mesh_frag } from './shader/mesh.frag';
-export const MeshShaderCode = ShaderCode('mesh', mesh_vert, mesh_frag, { standardDerivatives: 'optional', drawBuffers: 'optional' });
+export const MeshShaderCode = ShaderCode('mesh', mesh_vert, mesh_frag, { drawBuffers: 'optional' });
 
 import { directVolume_vert } from './shader/direct-volume.vert';
 import { directVolume_frag } from './shader/direct-volume.frag';
@@ -185,11 +209,9 @@ function getDefinesCode(defines: ShaderDefines) {
 }
 
 function getGlsl100FragPrefix(extensions: WebGLExtensions, shaderExtensions: ShaderExtensions) {
-    const prefix: string[] = [];
-    if (shaderExtensions.standardDerivatives) {
-        prefix.push('#extension GL_OES_standard_derivatives : enable');
-        prefix.push('#define enabledStandardDerivatives');
-    }
+    const prefix: string[] = [
+        '#extension GL_OES_standard_derivatives : enable'
+    ];
     if (shaderExtensions.fragDepth) {
         if (extensions.fragDepth) {
             prefix.push('#extension GL_EXT_frag_depth : enable');
@@ -244,9 +266,6 @@ function getGlsl300FragPrefix(gl: WebGL2RenderingContext, extensions: WebGLExten
         `layout(location = 0) out highp ${outTypes[0] || 'vec4'} out_FragData0;`
     ];
 
-    if (shaderExtensions.standardDerivatives) {
-        prefix.push('#define enabledStandardDerivatives');
-    }
     if (shaderExtensions.fragDepth) {
         prefix.push('#define enabledFragDepth');
     }
@@ -278,8 +297,8 @@ export function addShaderDefines(gl: GLRenderingContext, extensions: WebGLExtens
     return {
         id: shaderCodeId(),
         name: shaders.name,
-        vert: `${vertPrefix}${header}${shaders.vert}`,
-        frag: `${fragPrefix}${header}${frag}`,
+        vert: `${vertPrefix}${header}${preprocess(shaders.vert, defines)}`,
+        frag: `${fragPrefix}${header}${preprocess(frag, defines)}`,
         extensions: shaders.extensions,
         outTypes: shaders.outTypes
     };

+ 26 - 17
src/mol-gl/shader/chunks/apply-light-color.glsl.ts

@@ -1,10 +1,10 @@
 /**
- * Copyright (c) 2017-2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2017-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  *
  * adapted from three.js (https://github.com/mrdoob/three.js/)
- * which under the MIT License, Copyright © 2010-2019 three.js authors
+ * which under the MIT License, Copyright © 2010-2021 three.js authors
  */
 
 export const apply_light_color = `
@@ -14,21 +14,21 @@ export const apply_light_color = `
 // - vec3 normal
 // - float uMetalness
 // - float uRoughness
-// - float uReflectivity
-// - float uLightIntensity
-// - float uAmbientIntensity
+// - vec3 uLightColor
+// - vec3 uAmbientColor
 
 // outputs
 // - sets gl_FragColor
 
 vec4 color = material;
 
-ReflectedLight reflectedLight = ReflectedLight(vec3(0.0), vec3(0.0), vec3(0.0));
+ReflectedLight reflectedLight = ReflectedLight(vec3(0.0), vec3(0.0), vec3(0.0), vec3(0.0));
 
 PhysicalMaterial physicalMaterial;
-physicalMaterial.diffuseColor = color.rgb * (1.0 - uMetalness);
-physicalMaterial.specularRoughness = clamp(uRoughness, 0.04, 1.0);
-physicalMaterial.specularColor = mix(vec3(0.16 * pow2(uReflectivity)), color.rgb, uMetalness);
+physicalMaterial.diffuseColor = color.rgb * (1.0 - metalness);
+physicalMaterial.roughness = max(roughness, 0.0525);
+physicalMaterial.specularColor = mix(vec3(0.04), color.rgb, metalness);
+physicalMaterial.specularF90 = 1.0;
 
 GeometricContext geometry;
 geometry.position = -vViewPosition;
@@ -36,19 +36,28 @@ geometry.normal = normal;
 geometry.viewDir = normalize(vViewPosition);
 
 IncidentLight directLight;
-directLight.direction = vec3(0.0, 0.0, -1.0);
-directLight.color = vec3(uLightIntensity);
-
-RE_Direct_Physical(directLight, geometry, physicalMaterial, reflectedLight);
-
-vec3 irradiance = vec3(uAmbientIntensity) * PI;
+#pragma unroll_loop_start
+for (int i = 0; i < dLightCount; ++i) {
+    directLight.direction = uLightDirection[i];
+    directLight.color = uLightColor[i] * PI; // * PI for punctual light
+    RE_Direct_Physical(directLight, geometry, physicalMaterial, reflectedLight);
+}
+#pragma unroll_loop_end
+
+vec3 irradiance = uAmbientColor * PI; // * PI for punctual light
 RE_IndirectDiffuse_Physical(irradiance, geometry, physicalMaterial, reflectedLight);
 
-vec3 outgoingLight = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse + reflectedLight.directSpecular;
+// indirect specular only metals
+vec3 radiance = uAmbientColor * metalness;
+vec3 iblIrradiance = uAmbientColor * metalness;
+vec3 clearcoatRadiance = vec3(0.0);
+RE_IndirectSpecular_Physical(radiance, iblIrradiance, clearcoatRadiance, geometry, physicalMaterial, reflectedLight);
+
+vec3 outgoingLight = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse + reflectedLight.directSpecular + reflectedLight.indirectSpecular;
 
 gl_FragColor = vec4(outgoingLight, color.a);
 
 #ifdef dXrayShaded
-    gl_FragColor.a *= 1.0 - pow(abs(dot(normal, vec3(0, 0, 1))), uXrayEdgeFalloff);
+    gl_FragColor.a *= 1.0 - pow(abs(dot(normal, vec3(0.0, 0.0, 1.0))), uXrayEdgeFalloff);
 #endif
 `;

+ 14 - 0
src/mol-gl/shader/chunks/assign-color-varying.glsl.ts

@@ -41,6 +41,20 @@ export const assign_color_varying = `
             vOverpaint.rgb = mix(vColor.rgb, vOverpaint.rgb, vOverpaint.a);
         #endif
     #endif
+
+    #ifdef dSubstance
+        #if defined(dSubstanceType_groupInstance)
+            vSubstance = readFromTexture(tSubstance, aInstance * float(uGroupCount) + group, uSubstanceTexDim).rgb;
+        #elif defined(dSubstanceType_vertexInstance)
+            vSubstance = readFromTexture(tSubstance, int(aInstance) * uVertexCount + VertexID, uSubstanceTexDim).rgb;
+        #elif defined(dSubstanceType_volumeInstance)
+            vec3 sgridPos = (uSubstanceGridTransform.w * (vModelPosition - uSubstanceGridTransform.xyz)) / uSubstanceGridDim;
+            vSubstance = texture3dFrom2dLinear(tSubstanceGrid, sgridPos, uSubstanceGridDim, uSubstanceTexDim).rgb;
+        #endif
+
+        // pre-mix to avoid artifacts due to empty substance
+        vSubstance.rg = mix(vec2(uMetalness, uRoughness), vSubstance.rg, vSubstance.b);
+    #endif
 #elif defined(dRenderVariant_pick)
     #if defined(dRenderVariant_pickObject)
         vColor = vec4(encodeFloatRGB(float(uObjectId)), 1.0);

+ 7 - 0
src/mol-gl/shader/chunks/assign-material-color.glsl.ts

@@ -20,6 +20,13 @@ export const assign_material_color = `
     #if defined(dOverpaint)
         material.rgb = mix(material.rgb, vOverpaint.rgb, vOverpaint.a);
     #endif
+
+    float metalness = uMetalness;
+    float roughness = uRoughness;
+    #ifdef dSubstance
+        metalness = mix(metalness, vSubstance.r, vSubstance.b);
+        roughness = mix(roughness, vSubstance.g, vSubstance.b);
+    #endif
 #elif defined(dRenderVariant_pick)
     vec4 material = vColor;
 #elif defined(dRenderVariant_depth)

+ 7 - 0
src/mol-gl/shader/chunks/color-frag-params.glsl.ts

@@ -1,4 +1,7 @@
 export const color_frag_params = `
+uniform float uMetalness;
+uniform float uRoughness;
+
 #if defined(dRenderVariant_color)
     #if defined(dColorType_uniform)
         uniform vec3 uColor;
@@ -9,6 +12,10 @@ export const color_frag_params = `
     #ifdef dOverpaint
         varying vec4 vOverpaint;
     #endif
+
+    #ifdef dSubstance
+        varying vec3 vSubstance;
+    #endif
 #elif defined(dRenderVariant_pick)
     #if __VERSION__ == 100
         varying vec4 vColor;

+ 17 - 0
src/mol-gl/shader/chunks/color-vert-params.glsl.ts

@@ -1,4 +1,7 @@
 export const color_vert_params = `
+uniform float uMetalness;
+uniform float uRoughness;
+
 #if defined(dRenderVariant_color)
     #if defined(dColorType_uniform)
         uniform vec3 uColor;
@@ -30,6 +33,20 @@ export const color_vert_params = `
             uniform sampler2D tOverpaintGrid;
         #endif
     #endif
+
+    #ifdef dSubstance
+        #if defined(dSubstanceType_groupInstance) || defined(dSubstanceType_vertexInstance)
+            varying vec3 vSubstance;
+            uniform vec2 uSubstanceTexDim;
+            uniform sampler2D tSubstance;
+        #elif defined(dSubstanceType_volumeInstance)
+            varying vec3 vSubstance;
+            uniform vec2 uSubstanceTexDim;
+            uniform vec3 uSubstanceGridDim;
+            uniform vec4 uSubstanceGridTransform;
+            uniform sampler2D tSubstanceGrid;
+        #endif
+    #endif
 #elif defined(dRenderVariant_pick)
     #if __VERSION__ == 100
         varying vec4 vColor;

+ 3 - 1
src/mol-gl/shader/chunks/common-clip.glsl.ts

@@ -89,8 +89,9 @@ export const common_clip = `
 
     // flag is a bit-flag for clip-objects to ignore (note, object ids start at 1 not 0)
     bool clipTest(vec4 sphere, int flag) {
+        #pragma unroll_loop_start
         for (int i = 0; i < dClipObjectCount; ++i) {
-            if (flag == 0 || hasBit(flag, i + 1)) {
+            if (flag == 0 || hasBit(flag, UNROLLED_LOOP_INDEX + 1)) {
                 // TODO take sphere radius into account?
                 bool test = getSignedDistance(sphere.xyz, uClipObjectType[i], uClipObjectPosition[i], uClipObjectRotation[i], uClipObjectScale[i]) <= 0.0;
                 if ((!uClipObjectInvert[i] && test) || (uClipObjectInvert[i] && !test)) {
@@ -98,6 +99,7 @@ export const common_clip = `
                 }
             }
         }
+        #pragma unroll_loop_end
         return false;
     }
 #endif

+ 64 - 40
src/mol-gl/shader/chunks/light-frag-params.glsl.ts

@@ -1,23 +1,22 @@
 /**
- * Copyright (c) 2017-2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2017-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  *
  * adapted from three.js (https://github.com/mrdoob/three.js/)
- * which under the MIT License, Copyright © 2010-2019 three.js authors
+ * which under the MIT License, Copyright © 2010-2021 three.js authors
  */
 
 export const light_frag_params = `
-uniform float uLightIntensity;
-uniform float uAmbientIntensity;
-uniform float uReflectivity;
-uniform float uMetalness;
-uniform float uRoughness;
+uniform vec3 uLightDirection[dLightCount];
+uniform vec3 uLightColor[dLightCount];
+uniform vec3 uAmbientColor;
 
 struct PhysicalMaterial {
     vec3 diffuseColor;
-    float specularRoughness;
+    float roughness;
     vec3 specularColor;
+    float specularF90;
 };
 
 struct IncidentLight {
@@ -29,6 +28,7 @@ struct ReflectedLight {
     vec3 directDiffuse;
     vec3 directSpecular;
     vec3 indirectDiffuse;
+    vec3 indirectSpecular;
 };
 
 struct GeometricContext {
@@ -37,20 +37,23 @@ struct GeometricContext {
     vec3 viewDir;
 };
 
-vec3 F_Schlick(const in vec3 specularColor, const in float dotLH) {
+vec3 BRDF_Lambert(const in vec3 diffuseColor) {
+    return RECIPROCAL_PI * diffuseColor;
+}
+
+vec3 F_Schlick(const in vec3 f0, const in float f90, const in float dotVH) {
     // Original approximation by Christophe Schlick '94
-    // float fresnel = pow( 1.0 - dotLH, 5.0 );
+    // float fresnel = pow( 1.0 - dotVH, 5.0 );
     // Optimized variant (presented by Epic at SIGGRAPH '13)
     // https://cdn2.unrealengine.com/Resources/files/2013SiggraphPresentationsNotes-26915738.pdf
-    float fresnel = exp2((-5.55473 * dotLH - 6.98316) * dotLH);
-    return (1.0 - specularColor) * fresnel + specularColor;
+    float fresnel = exp2((-5.55473 * dotVH - 6.98316) * dotVH);
+    return f0 * (1.0 - fresnel) + (f90 * fresnel);
 }
 
 // Moving Frostbite to Physically Based Rendering 3.0 - page 12, listing 2
 // https://seblagarde.files.wordpress.com/2015/07/course_notes_moving_frostbite_to_pbr_v32.pdf
-float G_GGX_SmithCorrelated(const in float alpha, const in float dotNL, const in float dotNV) {
+float V_GGX_SmithCorrelated(const in float alpha, const in float dotNL, const in float dotNV) {
     float a2 = pow2(alpha);
-    // dotNL and dotNV are explicitly swapped. This is not a mistake.
     float gv = dotNL * sqrt(a2 + (1.0 - a2) * pow2(dotNV));
     float gl = dotNV * sqrt(a2 + (1.0 - a2) * pow2(dotNL));
     return 0.5 / max(gv + gl, EPSILON);
@@ -65,47 +68,68 @@ float D_GGX(const in float alpha, const in float dotNH) {
     return RECIPROCAL_PI * a2 / pow2(denom);
 }
 
-vec3 BRDF_Diffuse_Lambert(const in vec3 diffuseColor) {
-    return RECIPROCAL_PI * diffuseColor;
-}
-
-// GGX Distribution, Schlick Fresnel, GGX-Smith Visibility
-vec3 BRDF_Specular_GGX(const in IncidentLight incidentLight, const in GeometricContext geometry, const in vec3 specularColor, const in float roughness) {
+// GGX Distribution, Schlick Fresnel, GGX_SmithCorrelated Visibility
+vec3 BRDF_GGX(const in vec3 lightDir, const in vec3 viewDir, const in vec3 normal, const in vec3 f0, const in float f90, const in float roughness) {
     float alpha = pow2(roughness); // UE4's roughness
-    vec3 halfDir = normalize(incidentLight.direction + geometry.viewDir);
-
-    float dotNL = saturate(dot(geometry.normal, incidentLight.direction));
-    float dotNV = saturate(dot(geometry.normal, geometry.viewDir));
-    float dotNH = saturate(dot(geometry.normal, halfDir));
-    float dotLH = saturate(dot(incidentLight.direction, halfDir));
-
-    vec3 F = F_Schlick(specularColor, dotLH);
-    float G = G_GGX_SmithCorrelated(alpha, dotNL, dotNV);
+    vec3 halfDir = normalize( lightDir + viewDir);
+    float dotNL = saturate(dot(normal, lightDir));
+    float dotNV = saturate(dot(normal, viewDir));
+    float dotNH = saturate(dot(normal, halfDir));
+    float dotVH = saturate(dot(viewDir, halfDir));
+    vec3 F = F_Schlick(f0, f90, dotVH);
+    float V = V_GGX_SmithCorrelated(alpha, dotNL, dotNV);
     float D = D_GGX(alpha, dotNH);
-    return F * (G * D);
+    return F * (V * D);
 }
 
-// ref: https://www.unrealengine.com/blog/physically-based-shading-on-mobile - environmentBRDF for GGX on mobile
-vec3 BRDF_Specular_GGX_Environment(const in GeometricContext geometry, const in vec3 specularColor, const in float roughness) {
-    float dotNV = saturate(dot(geometry.normal, geometry.viewDir));
+// Analytical approximation of the DFG LUT, one half of the
+// split-sum approximation used in indirect specular lighting.
+// via 'environmentBRDF' from "Physically Based Shading on Mobile"
+// https://www.unrealengine.com/blog/physically-based-shading-on-mobile
+vec2 DFGApprox(const in vec3 normal, const in vec3 viewDir, const in float roughness) {
+    float dotNV = saturate(dot(normal, viewDir));
     const vec4 c0 = vec4(-1, -0.0275, -0.572, 0.022);
     const vec4 c1 = vec4(1, 0.0425, 1.04, -0.04);
     vec4 r = roughness * c0 + c1;
     float a004 = min(r.x * r.x, exp2(-9.28 * dotNV)) * r.x + r.y;
-    vec2 AB = vec2(-1.04, 1.04) * a004 + r.zw;
-    return specularColor * AB.x + AB.y;
+    vec2 fab = vec2(-1.04, 1.04) * a004 + r.zw;
+    return fab;
+}
+
+// Fdez-Agüera's "Multiple-Scattering Microfacet Model for Real-Time Image Based Lighting"
+// Approximates multiscattering in order to preserve energy.
+// http://www.jcgt.org/published/0008/01/03/
+void computeMultiscattering(const in vec3 normal, const in vec3 viewDir, const in vec3 specularColor, const in float specularF90, const in float roughness, inout vec3 singleScatter, inout vec3 multiScatter) {
+    vec2 fab = DFGApprox(normal, viewDir, roughness);
+    vec3 FssEss = specularColor * fab.x + specularF90 * fab.y;
+    float Ess = fab.x + fab.y;
+    float Ems = 1.0 - Ess;
+    vec3 Favg = specularColor + (1.0 - specularColor) * 0.047619; // 1/21
+    vec3 Fms = FssEss * Favg / (1.0 - Ems * Favg);
+    singleScatter += FssEss;
+    multiScatter += Fms * Ems;
 }
 
 void RE_Direct_Physical(const in IncidentLight directLight, const in GeometricContext geometry, const in PhysicalMaterial material, inout ReflectedLight reflectedLight) {
     float dotNL = saturate(dot(geometry.normal, directLight.direction));
     vec3 irradiance = dotNL * directLight.color;
-    irradiance *= PI; // punctual light
-
-    reflectedLight.directSpecular += irradiance * BRDF_Specular_GGX(directLight, geometry, material.specularColor, material.specularRoughness);
-    reflectedLight.directDiffuse += irradiance * BRDF_Diffuse_Lambert(material.diffuseColor);
+    reflectedLight.directSpecular += irradiance * BRDF_GGX(directLight.direction, geometry.viewDir, geometry.normal, material.specularColor, material.specularF90, material.roughness);
+    reflectedLight.directDiffuse += irradiance * BRDF_Lambert(material.diffuseColor);
 }
 
 void RE_IndirectDiffuse_Physical(const in vec3 irradiance, const in GeometricContext geometry, const in PhysicalMaterial material, inout ReflectedLight reflectedLight) {
-    reflectedLight.indirectDiffuse += irradiance * BRDF_Diffuse_Lambert(material.diffuseColor);
+    reflectedLight.indirectDiffuse += irradiance * BRDF_Lambert(material.diffuseColor);
+}
+
+void RE_IndirectSpecular_Physical( const in vec3 radiance, const in vec3 irradiance, const in vec3 clearcoatRadiance, const in GeometricContext geometry, const in PhysicalMaterial material, inout ReflectedLight reflectedLight) {
+    // Both indirect specular and indirect diffuse light accumulate here
+    vec3 singleScattering = vec3(0.0);
+    vec3 multiScattering = vec3(0.0);
+    vec3 cosineWeightedIrradiance = irradiance * RECIPROCAL_PI;
+    computeMultiscattering(geometry.normal, geometry.viewDir, material.specularColor, material.specularF90, material.roughness, singleScattering, multiScattering);
+    vec3 diffuse = material.diffuseColor * (1.0 - ( singleScattering + multiScattering));
+    reflectedLight.indirectSpecular += radiance * singleScattering;
+    reflectedLight.indirectSpecular += multiScattering * cosineWeightedIrradiance;
+    reflectedLight.indirectDiffuse += diffuse * cosineWeightedIrradiance;
 }
 `;

+ 23 - 1
src/mol-gl/shader/direct-volume.frag.ts

@@ -64,6 +64,9 @@ uniform int uMarkerPriority;
     uniform sampler2D tMarker;
 #endif
 
+uniform float uMetalness;
+uniform float uRoughness;
+
 uniform float uFogNear;
 uniform float uFogFar;
 uniform vec3 uFogColor;
@@ -115,6 +118,13 @@ uniform mat4 uCartnToUnit;
             uniform sampler2D tOverpaint;
         #endif
     #endif
+
+    #ifdef dSubstance
+        #if defined(dSubstanceType_groupInstance) || defined(dSubstanceType_vertexInstance)
+            uniform vec2 uSubstanceTexDim;
+            uniform sampler2D tSubstance;
+        #endif
+    #endif
 #endif
 
 #if defined(dGridTexType_2d)
@@ -194,6 +204,9 @@ vec4 raymarch(vec3 startLoc, vec3 step, vec3 rayDir) {
 
     vec3 color = vec3(0.45, 0.55, 0.8);
     vec4 overpaint = vec4(0.0);
+    vec3 substance = vec3(0.0);
+    float metalness = uMetalness;
+    float roughness = uRoughness;
 
     vec3 gradient = vec3(1.0);
     vec3 dx = vec3(gradOffset * scaleVol.x, 0.0, 0.0);
@@ -304,7 +317,7 @@ vec4 raymarch(vec3 startLoc, vec3 step, vec3 rayDir) {
                         #if defined(dOverpaintType_groupInstance)
                             overpaint = readFromTexture(tOverpaint, vInstance * float(uGroupCount) + group, uOverpaintTexDim);
                         #elif defined(dOverpaintType_vertexInstance)
-                            overpaint = texture3dFrom1dTrilinear(tOverpaint, isoPos, uGridDim, uOverpaintTexDim, vInstance * float(uVertexCount)).rgb;
+                            overpaint = texture3dFrom1dTrilinear(tOverpaint, isoPos, uGridDim, uOverpaintTexDim, vInstance * float(uVertexCount));
                         #endif
 
                         color = mix(color, overpaint.rgb, overpaint.a);
@@ -345,6 +358,15 @@ vec4 raymarch(vec3 startLoc, vec3 step, vec3 rayDir) {
                         vec3 normal = -normalize(normalMatrix * normalize(gradient));
                         normal = normal * (float(flipped) * 2.0 - 1.0);
                         normal = normal * -(float(interior) * 2.0 - 1.0);
+                        #ifdef dSubstance
+                            #if defined(dSubstanceType_groupInstance)
+                                substance = readFromTexture(tSubstance, vInstance * float(uGroupCount) + group, uSubstanceTexDim).rgb;
+                            #elif defined(dSubstanceType_vertexInstance)
+                                substance = texture3dFrom1dTrilinear(tSubstance, isoPos, uGridDim, uSubstanceTexDim, vInstance * float(uVertexCount)).rgb;
+                            #endif
+                            metalness = mix(metalness, substance.r, substance.b);
+                            roughness = mix(roughness, substance.g, substance.b);
+                        #endif
                         #include apply_light_color
                     #endif
 

+ 5 - 9
src/mol-gl/shader/mesh.frag.ts

@@ -19,14 +19,10 @@ void main() {
     #include clip_pixel
 
     // Workaround for buggy gl_FrontFacing (e.g. on some integrated Intel GPUs)
-    #if defined(enabledStandardDerivatives)
-        vec3 fdx = dFdx(vViewPosition);
-        vec3 fdy = dFdy(vViewPosition);
-        vec3 faceNormal = normalize(cross(fdx,fdy));
-        bool frontFacing = dot(vNormal, faceNormal) > 0.0;
-    #else
-        bool frontFacing = dot(vNormal, vViewPosition) < 0.0;
-    #endif
+    vec3 fdx = dFdx(vViewPosition);
+    vec3 fdy = dFdy(vViewPosition);
+    vec3 faceNormal = normalize(cross(fdx,fdy));
+    bool frontFacing = dot(vNormal, faceNormal) > 0.0;
 
     #if defined(dFlipSided)
         interior = frontFacing;
@@ -48,7 +44,7 @@ void main() {
         #ifdef dIgnoreLight
             gl_FragColor = material;
         #else
-            #if defined(dFlatShaded) && defined(enabledStandardDerivatives)
+            #if defined(dFlatShaded)
                 vec3 normal = -faceNormal;
             #else
                 vec3 normal = -normalize(vNormal);

+ 3 - 5
src/mol-gl/webgl/extensions.ts

@@ -36,13 +36,11 @@ export function createExtensions(gl: GLRenderingContext): WebGLExtensions {
     if (elementIndexUint === null) {
         throw new Error('Could not find support for "element_index_uint"');
     }
-
     const standardDerivatives = getStandardDerivatives(gl);
-    if (isDebugMode && standardDerivatives === null) {
-        // - non-support handled downstream (flat shading option is ignored)
-        // - can't be a required extension because it is not supported by `headless-gl`
-        console.log('Could not find support for "standard_derivatives"');
+    if (standardDerivatives === null) {
+        throw new Error('Could not find support for "standard_derivatives"');
     }
+
     const textureFloat = getTextureFloat(gl);
     if (isDebugMode && textureFloat === null) {
         console.log('Could not find support for "texture_float"');

+ 13 - 0
src/mol-math/linear-algebra/3d/vec3.ts

@@ -504,6 +504,19 @@ namespace Vec3 {
         return dot(tmp_dh_cb, tmp_dh_cross) > 0 ? _angle : -_angle;
     }
 
+    /**
+     * @param inclination in radians [0, PI]
+     * @param azimuth in radians [0, 2 * PI]
+     * @param radius [0, +Inf]
+     */
+    export function directionFromSpherical(out: Vec3, inclination: number, azimuth: number, radius: number): Vec3 {
+        return Vec3.set(out,
+            radius * Math.cos(azimuth) * Math.sin(inclination),
+            radius * Math.sin(azimuth) * Math.sin(inclination),
+            radius * Math.cos(inclination)
+        );
+    }
+
     /**
      * Returns whether or not the vectors have exactly the same elements in the same position (when compared with ===)
      */

+ 76 - 0
src/mol-plugin-state/helpers/structure-substance.ts

@@ -0,0 +1,76 @@
+/**
+ * Copyright (c) 2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { Structure, StructureElement } from '../../mol-model/structure';
+import { PluginStateObject } from '../../mol-plugin-state/objects';
+import { StateTransforms } from '../../mol-plugin-state/transforms';
+import { PluginContext } from '../../mol-plugin/context';
+import { StateBuilder, StateObjectCell, StateSelection, StateTransform } from '../../mol-state';
+import { Substance } from '../../mol-theme/substance';
+import { StructureComponentRef } from '../manager/structure/hierarchy-state';
+import { EmptyLoci, isEmptyLoci, Loci } from '../../mol-model/loci';
+import { Material } from '../../mol-util/material';
+
+type SubstanceEachReprCallback = (update: StateBuilder.Root, repr: StateObjectCell<PluginStateObject.Molecule.Structure.Representation3D, StateTransform<typeof StateTransforms.Representation.StructureRepresentation3D>>, substance?: StateObjectCell<any, StateTransform<typeof StateTransforms.Representation.SubstanceStructureRepresentation3DFromBundle>>) => Promise<void>
+const SubstanceManagerTag = 'substance-controls';
+
+export async function setStructureSubstance(plugin: PluginContext, components: StructureComponentRef[], material: Material | -1, lociGetter: (structure: Structure) => Promise<StructureElement.Loci | EmptyLoci>, types?: string[]) {
+    await eachRepr(plugin, components, async (update, repr, substanceCell) => {
+        if (types && types.length > 0 && !types.includes(repr.params!.values.type.name)) return;
+
+        const structure = repr.obj!.data.sourceData;
+        // always use the root structure to get the loci so the substance
+        // stays applicable as long as the root structure does not change
+        const loci = await lociGetter(structure.root);
+        if (Loci.isEmpty(loci) || isEmptyLoci(loci)) return;
+
+        const layer = {
+            bundle: StructureElement.Bundle.fromLoci(loci),
+            material: material === -1 ? Material(0) : material,
+            clear: material === -1
+        };
+
+        if (substanceCell) {
+            const bundleLayers = [...substanceCell.params!.values.layers, layer];
+            const filtered = getFilteredBundle(bundleLayers, structure);
+            update.to(substanceCell).update(Substance.toBundle(filtered));
+        } else {
+            const filtered = getFilteredBundle([layer], structure);
+            update.to(repr.transform.ref)
+                .apply(StateTransforms.Representation.SubstanceStructureRepresentation3DFromBundle, Substance.toBundle(filtered), { tags: SubstanceManagerTag });
+        }
+    });
+}
+
+export async function clearStructureSubstance(plugin: PluginContext, components: StructureComponentRef[], types?: string[]) {
+    await eachRepr(plugin, components, async (update, repr, substanceCell) => {
+        if (types && types.length > 0 && !types.includes(repr.params!.values.type.name)) return;
+        if (substanceCell) {
+            update.delete(substanceCell.transform.ref);
+        }
+    });
+}
+
+async function eachRepr(plugin: PluginContext, components: StructureComponentRef[], callback: SubstanceEachReprCallback) {
+    const state = plugin.state.data;
+    const update = state.build();
+    for (const c of components) {
+        for (const r of c.representations) {
+            const substance = state.select(StateSelection.Generators.ofTransformer(StateTransforms.Representation.SubstanceStructureRepresentation3DFromBundle, r.cell.transform.ref).withTag(SubstanceManagerTag));
+            await callback(update, r.cell, substance[0]);
+        }
+    }
+
+    return update.commit({ doNotUpdateCurrent: true });
+}
+
+/** filter substance layers for given structure */
+function getFilteredBundle(layers: Substance.BundleLayer[], structure: Structure) {
+    const substance = Substance.ofBundle(layers, structure.root);
+    const merged = Substance.merge(substance);
+    return Substance.filter(merged, structure);
+}

+ 24 - 10
src/mol-plugin-state/manager/structure/component.ts

@@ -30,6 +30,8 @@ import { Clipping } from '../../../mol-theme/clipping';
 import { setStructureClipping } from '../../helpers/structure-clipping';
 import { setStructureTransparency } from '../../helpers/structure-transparency';
 import { StructureFocusRepresentation } from '../../../mol-plugin/behavior/dynamic/selection/structure-focus-representation';
+import { setStructureSubstance } from '../../helpers/structure-substance';
+import { Material } from '../../../mol-util/material';
 
 export { StructureComponentManager };
 
@@ -67,22 +69,24 @@ class StructureComponentManager extends StatefulPluginComponent<StructureCompone
             await update.commit();
             await this.plugin.state.updateBehavior(StructureFocusRepresentation, p => {
                 p.ignoreHydrogens = !options.showHydrogens;
+                p.material = options.materialStyle;
             });
             if (interactionChanged) await this.updateInterationProps();
         });
     }
 
     private updateReprParams(update: StateBuilder.Root, component: StructureComponentRef) {
-        const { showHydrogens, visualQuality: quality } = this.state.options;
+        const { showHydrogens, visualQuality: quality, materialStyle: material } = this.state.options;
         const ignoreHydrogens = !showHydrogens;
         for (const r of component.representations) {
             if (r.cell.transform.transformer !== StructureRepresentation3D) continue;
 
             const params = r.cell.transform.params as StateTransformer.Params<StructureRepresentation3D>;
-            if (!!params.type.params.ignoreHydrogens !== ignoreHydrogens || params.type.params.quality !== quality) {
+            if (!!params.type.params.ignoreHydrogens !== ignoreHydrogens || params.type.params.quality !== quality || params.type.params.material !== material) {
                 update.to(r.cell).update(old => {
                     old.type.params.ignoreHydrogens = ignoreHydrogens;
                     old.type.params.quality = quality;
+                    old.type.params.material = material;
                 });
             }
         }
@@ -301,9 +305,9 @@ class StructureComponentManager extends StatefulPluginComponent<StructureCompone
     addRepresentation(components: ReadonlyArray<StructureComponentRef>, type: string) {
         if (components.length === 0) return;
 
-        const { showHydrogens, visualQuality: quality } = this.state.options;
+        const { showHydrogens, visualQuality: quality, materialStyle: material } = this.state.options;
         const ignoreHydrogens = !showHydrogens;
-        const typeParams = { ignoreHydrogens, quality };
+        const typeParams = { ignoreHydrogens, quality, material };
 
         return this.plugin.dataTransaction(async () => {
             for (const component of components) {
@@ -338,9 +342,9 @@ class StructureComponentManager extends StatefulPluginComponent<StructureCompone
             const xs = structures || this.currentStructures;
             if (xs.length === 0) return;
 
-            const { showHydrogens, visualQuality: quality } = this.state.options;
+            const { showHydrogens, visualQuality: quality, materialStyle: material } = this.state.options;
             const ignoreHydrogens = !showHydrogens;
-            const typeParams = { ignoreHydrogens, quality };
+            const typeParams = { ignoreHydrogens, quality, material };
 
             const componentKey = UUID.create22();
             for (const s of xs) {
@@ -372,14 +376,19 @@ class StructureComponentManager extends StatefulPluginComponent<StructureCompone
 
             const getLoci = async (s: Structure) => StructureSelection.toLociWithSourceUnits(await params.selection.getSelection(this.plugin, ctx, s));
             for (const s of xs) {
-                if (params.action.name === 'reset') {
-                    await setStructureOverpaint(this.plugin, s.components, -1, getLoci, params.representations);
-                } else if (params.action.name === 'color') {
+                if (params.action.name === 'color') {
                     const p = params.action.params;
                     await setStructureOverpaint(this.plugin, s.components, p.color, getLoci, params.representations);
+                } else if (params.action.name === 'resetColor') {
+                    await setStructureOverpaint(this.plugin, s.components, -1, getLoci, params.representations);
                 } else if (params.action.name === 'transparency') {
                     const p = params.action.params;
                     await setStructureTransparency(this.plugin, s.components, p.value, getLoci, params.representations);
+                } else if (params.action.name === 'material') {
+                    const p = params.action.params;
+                    await setStructureSubstance(this.plugin, s.components, p.material, getLoci, params.representations);
+                } else if (params.action.name === 'resetMaterial') {
+                    await setStructureSubstance(this.plugin, s.components, -1, getLoci, params.representations);
                 } else if (params.action.name === 'clipping') {
                     const p = params.action.params;
                     await setStructureClipping(this.plugin, s.components, Clipping.Groups.fromNames(p.excludeGroups), getLoci, params.representations);
@@ -445,6 +454,7 @@ namespace StructureComponentManager {
     export const OptionsParams = {
         showHydrogens: PD.Boolean(true, { description: 'Toggle display of hydrogen atoms in representations' }),
         visualQuality: PD.Select('auto', VisualQualityOptions, { description: 'Control the visual/rendering quality of representations' }),
+        materialStyle: Material.getParam(),
         interactions: PD.Group(InteractionsProvider.defaultParams, { label: 'Non-covalent Interactions' }),
     };
     export type Options = PD.Values<typeof OptionsParams>
@@ -477,10 +487,14 @@ namespace StructureComponentManager {
                 color: PD.Group({
                     color: PD.Color(ColorNames.blue, { isExpanded: true }),
                 }, { isFlat: true }),
-                reset: PD.EmptyGroup({ label: 'Reset Color' }),
+                resetColor: PD.EmptyGroup({ label: 'Reset Color' }),
                 transparency: PD.Group({
                     value: PD.Numeric(0.5, { min: 0, max: 1, step: 0.01 }),
                 }, { isFlat: true }),
+                material: PD.Group({
+                    material: Material.getParam({ isFlat: true }),
+                }, { isFlat: true }),
+                resetMaterial: PD.EmptyGroup({ label: 'Reset Material' }),
                 clipping: PD.Group({
                     excludeGroups: PD.MultiSelect([] as Clipping.Groups.Names[], PD.objectToOptions(Clipping.Groups.Names)),
                 }, { isFlat: true }),

+ 119 - 0
src/mol-plugin-state/transforms/representation.ts

@@ -41,6 +41,8 @@ import { getBoxMesh } from './shape';
 import { Shape } from '../../mol-model/shape';
 import { Box3D } from '../../mol-math/geometry';
 import { PlaneParams, PlaneRepresentation } from '../../mol-repr/shape/loci/plane';
+import { Substance } from '../../mol-theme/substance';
+import { Material } from '../../mol-util/material';
 
 export { StructureRepresentation3D };
 export { ExplodeStructureRepresentation3D };
@@ -50,6 +52,8 @@ export { OverpaintStructureRepresentation3DFromScript };
 export { OverpaintStructureRepresentation3DFromBundle };
 export { TransparencyStructureRepresentation3DFromScript };
 export { TransparencyStructureRepresentation3DFromBundle };
+export { SubstanceStructureRepresentation3DFromScript };
+export { SubstanceStructureRepresentation3DFromBundle };
 export { ClippingStructureRepresentation3DFromScript };
 export { ClippingStructureRepresentation3DFromBundle };
 export { VolumeRepresentation3D };
@@ -529,6 +533,121 @@ const TransparencyStructureRepresentation3DFromBundle = PluginStateTransform.Bui
     }
 });
 
+type SubstanceStructureRepresentation3DFromScript = typeof SubstanceStructureRepresentation3DFromScript
+const SubstanceStructureRepresentation3DFromScript = PluginStateTransform.BuiltIn({
+    name: 'substance-structure-representation-3d-from-script',
+    display: 'Substance 3D Representation',
+    from: SO.Molecule.Structure.Representation3D,
+    to: SO.Molecule.Structure.Representation3DState,
+    params: () => ({
+        layers: PD.ObjectList({
+            script: PD.Script(Script('(sel.atom.all)', 'mol-script')),
+            material: Material.getParam(),
+            clear: PD.Boolean(false)
+        }, e => `${e.clear ? 'Clear' : Material.toString(e.material)}`, {
+            defaultValue: [{
+                script: Script('(sel.atom.all)', 'mol-script'),
+                material: Material.fromNormalized(0, 1),
+                clear: false
+            }]
+        }),
+    })
+})({
+    canAutoUpdate() {
+        return true;
+    },
+    apply({ a, params }) {
+        const structure = a.data.sourceData;
+        const geometryVersion = a.data.repr.geometryVersion;
+        const substance = Substance.ofScript(params.layers, structure);
+
+        return new SO.Molecule.Structure.Representation3DState({
+            state: { substance },
+            initialState: { substance: Substance.Empty },
+            info: { structure, geometryVersion },
+            repr: a.data.repr
+        }, { label: `Substance (${substance.layers.length} Layers)` });
+    },
+    update({ a, b, newParams, oldParams }) {
+        const info = b.data.info as { structure: Structure, geometryVersion: number };
+        const newStructure = a.data.sourceData;
+        if (newStructure !== info.structure) return StateTransformer.UpdateResult.Recreate;
+        if (a.data.repr !== b.data.repr) return StateTransformer.UpdateResult.Recreate;
+
+        const newGeometryVersion = a.data.repr.geometryVersion;
+        // smoothing needs to be re-calculated when geometry changes
+        if (newGeometryVersion !== info.geometryVersion && hasColorSmoothingProp(a.data.repr.props)) return StateTransformer.UpdateResult.Unchanged;
+
+        const oldSubstance = b.data.state.substance!;
+        const newSubstance = Substance.ofScript(newParams.layers, newStructure);
+        if (Substance.areEqual(oldSubstance, newSubstance)) return StateTransformer.UpdateResult.Unchanged;
+
+        info.geometryVersion = newGeometryVersion;
+        b.data.state.substance = newSubstance;
+        b.data.repr = a.data.repr;
+        b.label = `Substance (${newSubstance.layers.length} Layers)`;
+        return StateTransformer.UpdateResult.Updated;
+    }
+});
+
+type SubstanceStructureRepresentation3DFromBundle = typeof SubstanceStructureRepresentation3DFromBundle
+const SubstanceStructureRepresentation3DFromBundle = PluginStateTransform.BuiltIn({
+    name: 'substance-structure-representation-3d-from-bundle',
+    display: 'Substance 3D Representation',
+    from: SO.Molecule.Structure.Representation3D,
+    to: SO.Molecule.Structure.Representation3DState,
+    params: () => ({
+        layers: PD.ObjectList({
+            bundle: PD.Value<StructureElement.Bundle>(StructureElement.Bundle.Empty),
+            material: Material.getParam(),
+            clear: PD.Boolean(false)
+        }, e => `${e.clear ? 'Clear' : Material.toString(e.material)}`, {
+            defaultValue: [{
+                bundle: StructureElement.Bundle.Empty,
+                material: Material.fromNormalized(0, 1),
+                clear: false
+            }],
+            isHidden: true
+        }),
+    })
+})({
+    canAutoUpdate() {
+        return true;
+    },
+    apply({ a, params }) {
+        const structure = a.data.sourceData;
+        const geometryVersion = a.data.repr.geometryVersion;
+        const substance = Substance.ofBundle(params.layers, structure);
+
+        return new SO.Molecule.Structure.Representation3DState({
+            state: { substance },
+            initialState: { substance: Substance.Empty },
+            info: { structure, geometryVersion },
+            repr: a.data.repr
+        }, { label: `Substance (${substance.layers.length} Layers)` });
+    },
+    update({ a, b, newParams, oldParams }) {
+        const info = b.data.info as { structure: Structure, geometryVersion: number };
+        const newStructure = a.data.sourceData;
+        if (newStructure !== info.structure) return StateTransformer.UpdateResult.Recreate;
+        if (a.data.repr !== b.data.repr) return StateTransformer.UpdateResult.Recreate;
+
+        const newGeometryVersion = a.data.repr.geometryVersion;
+        // smoothing needs to be re-calculated when geometry changes
+        if (newGeometryVersion !== info.geometryVersion && hasColorSmoothingProp(a.data.repr.props)) return StateTransformer.UpdateResult.Unchanged;
+
+        const oldSubstance = b.data.state.substance!;
+        const newSubstance = Substance.ofBundle(newParams.layers, newStructure);
+        if (Substance.areEqual(oldSubstance, newSubstance)) return StateTransformer.UpdateResult.Unchanged;
+
+        info.geometryVersion = newGeometryVersion;
+        b.data.state.substance = newSubstance;
+        b.data.repr = a.data.repr;
+        b.label = `Substance (${newSubstance.layers.length} Layers)`;
+        return StateTransformer.UpdateResult.Updated;
+    }
+});
+
 type ClippingStructureRepresentation3DFromScript = typeof ClippingStructureRepresentation3DFromScript
 const ClippingStructureRepresentation3DFromScript = PluginStateTransform.BuiltIn({
     name: 'clipping-structure-representation-3d-from-script',

+ 2 - 5
src/mol-plugin-ui/viewport/simple-settings.tsx

@@ -56,11 +56,10 @@ const SimpleSettingsParams = {
         transparent: PD.Boolean(false)
     }, { pivot: 'color' }),
     lighting: PD.Group({
-        renderStyle: Canvas3DParams.renderer.params.style,
         occlusion: Canvas3DParams.postprocessing.params.occlusion,
         outline: Canvas3DParams.postprocessing.params.outline,
         fog: Canvas3DParams.cameraFog,
-    }, { pivot: 'renderStyle' }),
+    }, { isFlat: true }),
     clipping: PD.Group<any>({
         ...Canvas3DParams.cameraClipping.params,
         ...(Canvas3DParams.renderer.params.clip as any).params as any
@@ -105,10 +104,9 @@ const SimpleSettingsMapping = ParamMapping({
                 transparent: canvas.transparentBackground
             },
             lighting: {
-                renderStyle: renderer.style,
                 occlusion: canvas.postprocessing.occlusion,
                 outline: canvas.postprocessing.outline,
-                fog: canvas.cameraFog
+                fog: canvas.cameraFog,
             },
             clipping: {
                 ...canvas.cameraClipping,
@@ -123,7 +121,6 @@ const SimpleSettingsMapping = ParamMapping({
         canvas.camera = s.camera;
         canvas.transparentBackground = s.background.transparent;
         canvas.renderer.backgroundColor = s.background.color;
-        canvas.renderer.style = s.lighting.renderStyle;
         canvas.postprocessing.occlusion = s.lighting.occlusion;
         canvas.postprocessing.outline = s.lighting.outline;
         canvas.cameraFog = s.lighting.fog;

+ 4 - 2
src/mol-plugin/behavior/dynamic/selection/structure-focus-representation.ts

@@ -18,6 +18,7 @@ import { SizeTheme } from '../../../../mol-theme/size';
 import { ParamDefinition as PD } from '../../../../mol-util/param-definition';
 import { PluginCommands } from '../../../commands';
 import { PluginContext } from '../../../context';
+import { Material } from '../../../../mol-util/material';
 
 const StructureFocusRepresentationParams = (plugin: PluginContext) => {
     const reprParams = StateTransforms.Representation.StructureRepresentation3D.definition.params!(void 0, plugin) as PD.Params;
@@ -41,7 +42,8 @@ const StructureFocusRepresentationParams = (plugin: PluginContext) => {
         }),
         components: PD.MultiSelect(FocusComponents, PD.arrayToOptions(FocusComponents)),
         excludeTargetFromSurroundings: PD.Boolean(false, { label: 'Exclude Target', description: 'Exclude the focus "target" from the surroudings component.' }),
-        ignoreHydrogens: PD.Boolean(false)
+        ignoreHydrogens: PD.Boolean(false),
+        material: Material.getParam(),
     };
 };
 
@@ -67,7 +69,7 @@ class StructureFocusRepresentationBehavior extends PluginBehavior.WithSubscriber
             ...this.params.targetParams,
             type: {
                 name: reprParams.type.name,
-                params: { ...reprParams.type.params, ignoreHydrogens: this.params.ignoreHydrogens }
+                params: { ...reprParams.type.params, ignoreHydrogens: this.params.ignoreHydrogens, material: this.params.material }
             }
         };
     }

+ 1 - 0
src/mol-plugin/spec.ts

@@ -101,6 +101,7 @@ export const DefaultPluginSpec = (): PluginSpec => ({
         PluginSpec.Action(StateTransforms.Representation.UnwindStructureAssemblyRepresentation3D),
         PluginSpec.Action(StateTransforms.Representation.OverpaintStructureRepresentation3DFromScript),
         PluginSpec.Action(StateTransforms.Representation.TransparencyStructureRepresentation3DFromScript),
+        PluginSpec.Action(StateTransforms.Representation.SubstanceStructureRepresentation3DFromScript),
 
         PluginSpec.Action(AssignColorVolume),
         PluginSpec.Action(StateTransforms.Volume.VolumeFromCcp4),

+ 8 - 1
src/mol-repr/representation.ts

@@ -25,6 +25,7 @@ import { CustomProperty } from '../mol-model-props/common/custom-property';
 import { Clipping } from '../mol-theme/clipping';
 import { SetUtils } from '../mol-util/set';
 import { cantorPairing } from '../mol-data/util';
+import { Substance } from '../mol-theme/substance';
 
 export type RepresentationProps = { [k: string]: any }
 
@@ -186,6 +187,8 @@ namespace Representation {
         overpaint: Overpaint
         /** Per group transparency applied to the representation's renderobjects */
         transparency: Transparency
+        /** Per group material applied to the representation's renderobjects */
+        substance: Substance
         /** Bit mask of per group clipping applied to the representation's renderobjects */
         clipping: Clipping
         /** Controls if the representation's renderobjects are synced automatically with GPU or not */
@@ -196,7 +199,7 @@ namespace Representation {
         markerActions: MarkerActions
     }
     export function createState(): State {
-        return { visible: true, alphaFactor: 1, pickable: true, colorOnly: false, syncManually: false, transform: Mat4.identity(), overpaint: Overpaint.Empty, transparency: Transparency.Empty, clipping: Clipping.Empty, markerActions: MarkerActions.All };
+        return { visible: true, alphaFactor: 1, pickable: true, colorOnly: false, syncManually: false, transform: Mat4.identity(), overpaint: Overpaint.Empty, transparency: Transparency.Empty, substance: Substance.Empty, clipping: Clipping.Empty, markerActions: MarkerActions.All };
     }
     export function updateState(state: State, update: Partial<State>) {
         if (update.visible !== undefined) state.visible = update.visible;
@@ -205,6 +208,7 @@ namespace Representation {
         if (update.colorOnly !== undefined) state.colorOnly = update.colorOnly;
         if (update.overpaint !== undefined) state.overpaint = update.overpaint;
         if (update.transparency !== undefined) state.transparency = update.transparency;
+        if (update.substance !== undefined) state.substance = update.substance;
         if (update.clipping !== undefined) state.clipping = update.clipping;
         if (update.syncManually !== undefined) state.syncManually = update.syncManually;
         if (update.transform !== undefined) Mat4.copy(state.transform, update.transform);
@@ -410,6 +414,9 @@ namespace Representation {
                 if (state.transparency !== undefined) {
                     // TODO
                 }
+                if (state.substance !== undefined) {
+                    // TODO
+                }
                 if (state.transform !== undefined) Visual.setTransform(renderObject, state.transform);
 
                 Representation.updateState(currentState, state);

+ 3 - 0
src/mol-repr/shape/representation.ts

@@ -216,6 +216,9 @@ export function ShapeRepresentation<D, G extends Geometry, P extends Geometry.Pa
                 if (state.transparency !== undefined) {
                     Visual.setTransparency(_renderObject, state.transparency, lociApply, true);
                 }
+                if (state.substance !== undefined) {
+                    Visual.setSubstance(_renderObject, state.substance, lociApply, true);
+                }
                 if (state.transform !== undefined) Visual.setTransform(_renderObject, state.transform);
             }
 

+ 6 - 0
src/mol-repr/structure/complex-representation.ts

@@ -21,6 +21,7 @@ import { StructureParams } from './params';
 import { Clipping } from '../../mol-theme/clipping';
 import { Transparency } from '../../mol-theme/transparency';
 import { WebGLContext } from '../../mol-gl/webgl/context';
+import { Substance } from '../../mol-theme/substance';
 
 export function ComplexRepresentation<P extends StructureParams>(label: string, ctx: RepresentationContext, getParams: RepresentationParamsGetter<Structure, P>, visualCtor: (materialId: number, structure: Structure, props: PD.Values<P>, webgl?: WebGLContext) => ComplexVisual<P>): StructureRepresentation<P> {
     let version = 0;
@@ -113,6 +114,11 @@ export function ComplexRepresentation<P extends StructureParams>(label: string,
             const remappedTransparency = Transparency.remap(state.transparency, _structure);
             visual.setTransparency(remappedTransparency, webgl);
         }
+        if (state.substance !== undefined && visual) {
+            // Remap loci from equivalent structure to the current structure
+            const remappedSubstance = Substance.remap(state.substance, _structure);
+            visual.setSubstance(remappedSubstance, webgl);
+        }
         if (state.clipping !== undefined && visual) {
             // Remap loci from equivalent structure to the current structure
             const remappedClipping = Clipping.remap(state.clipping, _structure);

+ 5 - 0
src/mol-repr/structure/complex-visual.ts

@@ -36,6 +36,7 @@ import { Clipping } from '../../mol-theme/clipping';
 import { TextureMesh } from '../../mol-geo/geometry/texture-mesh/texture-mesh';
 import { WebGLContext } from '../../mol-gl/webgl/context';
 import { isPromiseLike } from '../../mol-util/type-helpers';
+import { Substance } from '../../mol-theme/substance';
 
 export interface ComplexVisual<P extends StructureParams> extends Visual<Structure, P> { }
 
@@ -266,6 +267,10 @@ export function ComplexVisual<G extends Geometry, P extends StructureParams & Ge
             const smoothing = { geometry, props: currentProps, webgl };
             Visual.setTransparency(renderObject, transparency, lociApply, true, smoothing);
         },
+        setSubstance(substance: Substance, webgl?: WebGLContext) {
+            const smoothing = { geometry, props: currentProps, webgl };
+            Visual.setSubstance(renderObject, substance, lociApply, true, smoothing);
+        },
         setClipping(clipping: Clipping) {
             Visual.setClipping(renderObject, clipping, lociApply, true);
         },

+ 7 - 2
src/mol-repr/structure/units-representation.ts

@@ -25,6 +25,7 @@ import { StructureParams } from './params';
 import { Clipping } from '../../mol-theme/clipping';
 import { WebGLContext } from '../../mol-gl/webgl/context';
 import { StructureGroup } from './visual/util/common';
+import { Substance } from '../../mol-theme/substance';
 
 export interface UnitsVisual<P extends StructureParams> extends Visual<StructureGroup, P> { }
 
@@ -218,13 +219,14 @@ export function UnitsRepresentation<P extends StructureParams>(label: string, ct
     }
 
     function setVisualState(visual: UnitsVisual<P>, group: Unit.SymmetryGroup, state: Partial<StructureRepresentationState>) {
-        const { visible, alphaFactor, pickable, overpaint, transparency, clipping, transform, unitTransforms } = state;
+        const { visible, alphaFactor, pickable, overpaint, transparency, substance, clipping, transform, unitTransforms } = state;
 
         if (visible !== undefined) visual.setVisibility(visible);
         if (alphaFactor !== undefined) visual.setAlphaFactor(alphaFactor);
         if (pickable !== undefined) visual.setPickable(pickable);
         if (overpaint !== undefined) visual.setOverpaint(overpaint, webgl);
         if (transparency !== undefined) visual.setTransparency(transparency, webgl);
+        if (substance !== undefined) visual.setSubstance(substance, webgl);
         if (clipping !== undefined) visual.setClipping(clipping);
         if (transform !== undefined) visual.setTransform(transform);
         if (unitTransforms !== undefined) {
@@ -238,7 +240,7 @@ export function UnitsRepresentation<P extends StructureParams>(label: string, ct
     }
 
     function setState(state: Partial<StructureRepresentationState>) {
-        const { visible, alphaFactor, pickable, overpaint, transparency, clipping, transform, unitTransforms, syncManually, markerActions } = state;
+        const { visible, alphaFactor, pickable, overpaint, transparency, substance, clipping, transform, unitTransforms, syncManually, markerActions } = state;
         const newState: Partial<StructureRepresentationState> = {};
 
         if (visible !== _state.visible) newState.visible = visible;
@@ -250,6 +252,9 @@ export function UnitsRepresentation<P extends StructureParams>(label: string, ct
         if (transparency !== undefined && _structure) {
             newState.transparency = Transparency.remap(transparency, _structure);
         }
+        if (substance !== undefined && _structure) {
+            newState.substance = Substance.remap(substance, _structure);
+        }
         if (clipping !== undefined && _structure) {
             newState.clipping = Clipping.remap(clipping, _structure);
         }

+ 5 - 0
src/mol-repr/structure/units-visual.ts

@@ -40,6 +40,7 @@ import { StructureParams, StructureMeshParams, StructureSpheresParams, Structure
 import { Clipping } from '../../mol-theme/clipping';
 import { WebGLContext } from '../../mol-gl/webgl/context';
 import { isPromiseLike } from '../../mol-util/type-helpers';
+import { Substance } from '../../mol-theme/substance';
 
 export interface UnitsVisual<P extends RepresentationProps = {}> extends Visual<StructureGroup, P> { }
 
@@ -331,6 +332,10 @@ export function UnitsVisual<G extends Geometry, P extends StructureParams & Geom
             const smoothing = { geometry, props: currentProps, webgl };
             Visual.setTransparency(renderObject, transparency, lociApply, true, smoothing);
         },
+        setSubstance(substance: Substance, webgl?: WebGLContext) {
+            const smoothing = { geometry, props: currentProps, webgl };
+            Visual.setSubstance(renderObject, substance, lociApply, true, smoothing);
+        },
         setClipping(clipping: Clipping) {
             Visual.setClipping(renderObject, clipping, lociApply, true);
         },

+ 55 - 2
src/mol-repr/visual.ts

@@ -27,8 +27,10 @@ import { getMarkersAverage } from '../mol-geo/geometry/marker-data';
 import { Texture } from '../mol-gl/webgl/texture';
 import { Geometry } from '../mol-geo/geometry/geometry';
 import { getColorSmoothingProps, hasColorSmoothingProp } from '../mol-geo/geometry/base';
-import { applyMeshOverpaintSmoothing, applyMeshTransparencySmoothing } from '../mol-geo/geometry/mesh/color-smoothing';
-import { applyTextureMeshOverpaintSmoothing, applyTextureMeshTransparencySmoothing } from '../mol-geo/geometry/texture-mesh/color-smoothing';
+import { applyMeshOverpaintSmoothing, applyMeshSubstanceSmoothing, applyMeshTransparencySmoothing } from '../mol-geo/geometry/mesh/color-smoothing';
+import { applyTextureMeshOverpaintSmoothing, applyTextureMeshSubstanceSmoothing, applyTextureMeshTransparencySmoothing } from '../mol-geo/geometry/texture-mesh/color-smoothing';
+import { Substance } from '../mol-theme/substance';
+import { applySubstanceMaterial, clearSubstance, createSubstance } from '../mol-geo/geometry/substance-data';
 
 export interface VisualContext {
     readonly runtime: RuntimeContext
@@ -51,6 +53,7 @@ interface Visual<D, P extends PD.Params> {
     setTransform: (matrix?: Mat4, instanceMatrices?: Float32Array | null) => void
     setOverpaint: (overpaint: Overpaint, webgl?: WebGLContext) => void
     setTransparency: (transparency: Transparency, webgl?: WebGLContext) => void
+    setSubstance: (substance: Substance, webgl?: WebGLContext) => void
     setClipping: (clipping: Clipping) => void
     destroy: () => void
     mustRecreate?: (data: D, props: PD.Values<P>, webgl?: WebGLContext) => boolean
@@ -143,6 +146,7 @@ namespace Visual {
         resolution?: number
         overpaintTexture?: Texture
         transparencyTexture?: Texture
+        substanceTexture?: Texture
     }
 
     type SmoothingContext = {
@@ -248,6 +252,55 @@ namespace Visual {
         }
     }
 
+    export function setSubstance(renderObject: GraphicsRenderObject | undefined, substance: Substance, lociApply: LociApply, clear: boolean, smoothing?: SmoothingContext) {
+        if (!renderObject) return;
+
+        const { tSubstance, dSubstanceType, uGroupCount, instanceCount } = renderObject.values;
+        const count = uGroupCount.ref.value * instanceCount.ref.value;
+
+        // ensure texture has right size
+        createSubstance(substance.layers.length ? count : 0, renderObject.values);
+        const { array } = tSubstance.ref.value;
+
+        // clear all if requested
+        if (clear) clearSubstance(array, 0, count);
+
+        for (let i = 0, il = substance.layers.length; i < il; ++i) {
+            const { loci, material, clear } = substance.layers[i];
+            const apply = (interval: Interval) => {
+                const start = Interval.start(interval);
+                const end = Interval.end(interval);
+                return clear
+                    ? clearSubstance(array, start, end)
+                    : applySubstanceMaterial(array, start, end, material);
+            };
+            lociApply(loci, apply, false);
+        }
+        ValueCell.update(tSubstance, tSubstance.ref.value);
+        ValueCell.updateIfChanged(dSubstanceType, 'groupInstance');
+
+        if (substance.layers.length === 0) return;
+
+        if (smoothing && hasColorSmoothingProp(smoothing.props)) {
+            const { geometry, props, webgl } = smoothing;
+            if (geometry.kind === 'mesh') {
+                const { resolution, substanceTexture } = geometry.meta as SurfaceMeta;
+                const csp = getColorSmoothingProps(props.smoothColors, true, resolution);
+                if (csp) {
+                    applyMeshSubstanceSmoothing(renderObject.values as any, csp.resolution, csp.stride, webgl, substanceTexture);
+                    (geometry.meta as SurfaceMeta).substanceTexture = renderObject.values.tSubstanceGrid.ref.value;
+                }
+            } else if (webgl && geometry.kind === 'texture-mesh') {
+                const { resolution, substanceTexture } = geometry.meta as SurfaceMeta;
+                const csp = getColorSmoothingProps(props.smoothColors, true, resolution);
+                if (csp) {
+                    applyTextureMeshSubstanceSmoothing(renderObject.values as any, csp.resolution, csp.stride, webgl, substanceTexture);
+                    (geometry.meta as SurfaceMeta).substanceTexture = renderObject.values.tSubstanceGrid.ref.value;
+                }
+            }
+        }
+    }
+
     export function setClipping(renderObject: GraphicsRenderObject | undefined, clipping: Clipping, lociApply: LociApply, clear: boolean) {
         if (!renderObject) return;
 

+ 4 - 0
src/mol-repr/volume/representation.ts

@@ -32,6 +32,7 @@ import { SizeValues } from '../../mol-gl/renderable/schema';
 import { Clipping } from '../../mol-theme/clipping';
 import { WebGLContext } from '../../mol-gl/webgl/context';
 import { isPromiseLike } from '../../mol-util/type-helpers';
+import { Substance } from '../../mol-theme/substance';
 
 export interface VolumeVisual<P extends VolumeParams> extends Visual<Volume, P> { }
 
@@ -211,6 +212,9 @@ export function VolumeVisual<G extends Geometry, P extends VolumeParams & Geomet
         setTransparency(transparency: Transparency) {
             return Visual.setTransparency(renderObject, transparency, lociApply, true);
         },
+        setSubstance(substance: Substance) {
+            return Visual.setSubstance(renderObject, substance, lociApply, true);
+        },
         setClipping(clipping: Clipping) {
             return Visual.setClipping(renderObject, clipping, lociApply, true);
         },

+ 1 - 1
src/mol-theme/overpaint.ts

@@ -68,7 +68,7 @@ namespace Overpaint {
         const layers: Overpaint.Layer[] = [];
         map.forEach((loci, colorOrClear) => {
             const clear = colorOrClear === -1;
-            const color = colorOrClear === -1 ? Color(0) : colorOrClear;
+            const color = clear ? Color(0) : colorOrClear;
             layers.push({ loci, color, clear });
         });
         return { layers };

+ 127 - 0
src/mol-theme/substance.ts

@@ -0,0 +1,127 @@
+/**
+ * Copyright (c) 2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ */
+
+import { Loci } from '../mol-model/loci';
+import { Structure, StructureElement } from '../mol-model/structure';
+import { Script } from '../mol-script/script';
+import { Material } from '../mol-util/material';
+
+export { Substance };
+
+type Substance = { readonly layers: ReadonlyArray<Substance.Layer> }
+
+function Substance(layers: ReadonlyArray<Substance.Layer>): Substance {
+    return { layers };
+}
+
+namespace Substance {
+    export type Layer = { readonly loci: StructureElement.Loci, readonly material: Material, readonly clear: boolean }
+    export const Empty: Substance = { layers: [] };
+
+    export function areEqual(sA: Substance, sB: Substance) {
+        if (sA.layers.length === 0 && sB.layers.length === 0) return true;
+        if (sA.layers.length !== sB.layers.length) return false;
+        for (let i = 0, il = sA.layers.length; i < il; ++i) {
+            if (sA.layers[i].clear !== sB.layers[i].clear) return false;
+            if (sA.layers[i].material !== sB.layers[i].material) return false;
+            if (!Loci.areEqual(sA.layers[i].loci, sB.layers[i].loci)) return false;
+        }
+        return true;
+    }
+
+    export function isEmpty(overpaint: Substance) {
+        return overpaint.layers.length === 0;
+    }
+
+    export function remap(substance: Substance, structure: Structure) {
+        const layers: Substance.Layer[] = [];
+        for (const layer of substance.layers) {
+            let { loci, material, clear } = layer;
+            loci = StructureElement.Loci.remap(loci, structure);
+            if (!StructureElement.Loci.isEmpty(loci)) {
+                layers.push({ loci, material, clear });
+            }
+        }
+        return { layers };
+    }
+
+    export function merge(substance: Substance): Substance {
+        if (isEmpty(substance)) return substance;
+        const { structure } = substance.layers[0].loci;
+        const map = new Map<Material | -1, StructureElement.Loci>();
+        let shadowed = StructureElement.Loci.none(structure);
+        for (let i = 0, il = substance.layers.length; i < il; ++i) {
+            let { loci, material, clear } = substance.layers[il - i - 1]; // process from end
+            loci = StructureElement.Loci.subtract(loci, shadowed);
+            shadowed = StructureElement.Loci.union(loci, shadowed);
+            if (!StructureElement.Loci.isEmpty(loci)) {
+                const materialOrClear = clear ? -1 : material;
+                if (map.has(materialOrClear)) {
+                    loci = StructureElement.Loci.union(loci, map.get(materialOrClear)!);
+                }
+                map.set(materialOrClear, loci);
+            }
+        }
+        const layers: Substance.Layer[] = [];
+        map.forEach((loci, materialOrClear) => {
+            const clear = materialOrClear === -1;
+            const material = clear ? Material(0) : materialOrClear;
+            layers.push({ loci, material, clear });
+        });
+        return { layers };
+    }
+
+    export function filter(substance: Substance, filter: Structure): Substance {
+        if (isEmpty(substance)) return substance;
+        const { structure } = substance.layers[0].loci;
+        const layers: Substance.Layer[] = [];
+        for (const layer of substance.layers) {
+            let { loci, material, clear } = layer;
+            // filter by first map to the `filter` structure and
+            // then map back to the original structure of the substance loci
+            const filtered = StructureElement.Loci.remap(loci, filter);
+            loci = StructureElement.Loci.remap(filtered, structure);
+            if (!StructureElement.Loci.isEmpty(loci)) {
+                layers.push({ loci, material, clear });
+            }
+        }
+        return { layers };
+    }
+
+    export type ScriptLayer = { script: Script, material: Material, clear: boolean }
+    export function ofScript(scriptLayers: ScriptLayer[], structure: Structure): Substance {
+        const layers: Substance.Layer[] = [];
+        for (let i = 0, il = scriptLayers.length; i < il; ++i) {
+            const { script, material, clear } = scriptLayers[i];
+            const loci = Script.toLoci(script, structure);
+            if (!StructureElement.Loci.isEmpty(loci)) {
+                layers.push({ loci, material, clear });
+            }
+        }
+        return { layers };
+    }
+
+    export type BundleLayer = { bundle: StructureElement.Bundle, material: Material, clear: boolean }
+    export function ofBundle(bundleLayers: BundleLayer[], structure: Structure): Substance {
+        const layers: Substance.Layer[] = [];
+        for (let i = 0, il = bundleLayers.length; i < il; ++i) {
+            const { bundle, material, clear } = bundleLayers[i];
+            const loci = StructureElement.Bundle.toLoci(bundle, structure.root);
+            layers.push({ loci, material, clear });
+        }
+        return { layers };
+    }
+
+    export function toBundle(overpaint: Substance) {
+        const layers: BundleLayer[] = [];
+        for (let i = 0, il = overpaint.layers.length; i < il; ++i) {
+            const { loci, material, clear } = overpaint.layers[i];
+            const bundle = StructureElement.Bundle.fromLoci(loci);
+            layers.push({ bundle, material, clear });
+        }
+        return { layers };
+    }
+}

+ 56 - 0
src/mol-util/material.ts

@@ -0,0 +1,56 @@
+/**
+ * Copyright (c) 2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ */
+
+import { NumberArray } from './type-helpers';
+import { ParamDefinition as PD } from './param-definition';
+import { toFixed } from './number';
+
+/** Material properties expressed as a single number */
+export type Material = { readonly '@type': 'material' } & number
+
+export function Material(hex: number) { return hex as Material; }
+
+export namespace Material {
+    export function fromNormalized(metalness: number, roughness: number): Material {
+        return (((metalness * 255) << 16) | ((roughness * 255) << 8)) as Material;
+    }
+
+    export function fromObjectNormalized(v: { metalness: number, roughness: number }): Material {
+        return fromNormalized(v.metalness, v.roughness);
+    }
+
+    export function toObjectNormalized(material: Material, fractionDigits?: number) {
+        const metalness = (material >> 16 & 255) / 255;
+        const roughness = (material >> 8 & 255) / 255;
+        return {
+            metalness: fractionDigits ? toFixed(metalness, fractionDigits) : metalness,
+            roughness: fractionDigits ? toFixed(roughness, fractionDigits) : roughness
+        };
+    }
+
+    export function toArray(material: Material, array: NumberArray, offset: number) {
+        array[offset] = (material >> 16 & 255);
+        array[offset + 1] = (material >> 8 & 255);
+        return array;
+    }
+
+    export function toString(material: Material) {
+        const metalness = (material >> 16 & 255) / 255;
+        const roughness = (material >> 8 & 255) / 255;
+        return `M ${metalness} | R ${roughness}`;
+    }
+
+    export function getParam(info?: { isExpanded?: boolean, isFlat?: boolean }) {
+        return PD.Converted(
+            (v: Material) => toObjectNormalized(v, 2),
+            (v: { metalness: number, roughness: number }) => fromObjectNormalized(v),
+            PD.Group({
+                metalness: PD.Numeric(0, { min: 0, max: 1, step: 0.01 }),
+                roughness: PD.Numeric(1, { min: 0, max: 1, step: 0.01 }),
+            }, info)
+        );
+    }
+}

+ 4 - 0
src/mol-util/number.ts

@@ -68,4 +68,8 @@ export function getPrecision(v: number) {
 
 export function toPrecision(v: number, precision: number) {
     return parseFloat(v.toPrecision(precision));
+}
+
+export function toFixed(v: number, fractionDigits: number) {
+    return parseFloat(v.toFixed(fractionDigits));
 }