Lamina is a library that allows the definition of shader materials via a declarative, layer-based system. Lamina materials can be declared in JSX for React Three Fiber or a nested object, and is based on THREE-CustomShaderMaterial for the shader composition. A shader material created with lamina with JSX looks a little like this:

<LayerMaterial side={THREE.BackSide}>
  <Base color="blue" alpha={1} mode="normal" />
  <Depth
    colorA="#00ffff"
    colorB="#ff8f00"
    alpha={0.5}
    mode="normal"
    near={0}
    far={300}
    origin={[100, 100, 100]}
  />
  <Noise mapping="local" type="cell" scale={0.5} mode="softlight" />
</LayerMaterial>

Lamina compiles transpiles JSX declaration, during the build step, into GLSL code suitable for use in Three.js.

The future of Lamina

As of July 2023 the lamina project on Github has been archived and the library is no longer being actively developed. A reason for this was provided by the main contributor:

This project needs maintainers and a good rewrite from scratch. Lamina does a lot of hacky processing to achieve its API goals. As time has gone by I have started to doubt if it’s worth it. These hacks make it unreliable, unpredictable and slow. Not to mentaion, quite convoluted to maintain and debug. There might be better APIs or implimentations for this kind of library but I currently do not have the bandwidth to dedicate to finding them. Perhaps in the future.

This means lamina would become more difficult to use due to missing features, unsolved bugs and won’t be recommended going forward.

Replacing lamina

I couldn’t find any alternatives and decided this would be a good time to learn to compose WebGL shaders in a similar fashion by digging into Lamina’s source code. This article attempts to compose a custom environment material, similar to the example above without the use of Lamina. The end result should look a little like this:

layer environment screenshot

This blog post assumes you’re familiar with these concepts: Javascript, React, Three.js, GLSL shaders

This article will explore the container material, LayerMaterial.

The core layer material

A Lamina shader material is composed by adding layers as children to the base <LayerMaterial /> .

<LayerMaterial
  color="#ffffff" //
  lighting="basic"
>
  {/* layers */}
</LayerMaterial>

A LayerMaterial is an instance of the LayerMaterial class , which is based on the CustomShaderMaterial class.

class LayerMaterial extends CustomShaderMaterial {
    ...
}

The CustomShaderMaterial is outside the scope of our exploration as we’ll be recreating the environment material’s shader from the ground up.

Structure

The LayerMaterial is instantiated with a few key parameters:

  • color: the base color to be used in generated fragment shader; defaults to ‘white’
  • alpha: the alpha to be used in the generated fragment shader; defaults to 1
  • lighting: the lighting mode that determines the base code for both the shaders. For the scope of this article, we’ll focus on a LayerMaterial created with the 'basic' lighting mode which provides the base code from the THREE.BaseMaterial’s shaders.
  • layers: an ordered list of the constituent layers.

A few base uniforms , global GLSL variables, are created based on some of the initialization parameters.

class LayerMaterial extends CustomShaderMaterial {
    name: string = 'LayerMaterial'
    layers: Abstract[] = []
    lighting: ShadingType = 'basic'

    constructor({ color, alpha, lighting, layers, name, ...props }: LayerMaterialParameters & AllMaterialParams = {}) {
        super({
            baseMaterial: ShadingTypes[lighting || 'basic'],
            ...props,
        })

        const _baseColor = color || 'white'
        const _alpha = alpha ?? 1

        this.uniforms = {
            u_lamina_color: {
                value: typeof _baseColor === 'string' ? new THREE.Color(_baseColor).convertSRGBToLinear() : _baseColor,
            },
            u_lamina_alpha: {
                value: _alpha,
            },
        }

        this.layers = layers || this.layers
        this.lighting = lighting || this.lighting
        this.name = name || this.name

        this.refresh()
    }
    ...
}

After initialization, and on each update to the material, the LayerMaterial is transpiled via its genShaders method, which composes its constituent layers into both the vertex and fragment shaders that are then passed on to the THREE.ShaderMaterial .

In the order in which the layers are added, each layer’s uniforms, vertex shader and fragment shader are combined into the LayerMaterial’s uniforms, vertex shader and fragment shader respectively.

genShaders() {
    let vertexVariables = ''
    let fragmentVariables = ''
    let vertexShader = ''
    let fragmentShader = ''
    let uniforms: any = {}

    this.layers
      .filter((l) => l.visible)
      .forEach((l) => {
        vertexVariables += l.vertexVariables + '\n'
        fragmentVariables += l.fragmentVariables + '\n'
        vertexShader += l.vertexShader + '\n'
        fragmentShader += l.fragmentShader + '\n'

        uniforms = {
          ...uniforms,
          ...l.uniforms,
        }
      })

    uniforms = {
      ...uniforms,
      ...this.uniforms,
    }

    return {
      uniforms,
      vertexShader: `
        ${HelpersChunk}
        ${NoiseChunk}
        ${vertexVariables}

        void main() {
          vec3 lamina_finalPosition = position;
          vec3 lamina_finalNormal = normal;

          ${vertexShader}

          csm_Position = lamina_finalPosition;
          csm_Normal = lamina_finalNormal;
        }
        `,
      fragmentShader: `
        ${HelpersChunk}
        ${NoiseChunk}
        ${BlendModesChunk}
        ${fragmentVariables}

        uniform vec3 u_lamina_color;
        uniform float u_lamina_alpha;

        void main() {
          vec4 lamina_finalColor = vec4(u_lamina_color, u_lamina_alpha);

          ${fragmentShader}

          csm_DiffuseColor = lamina_finalColor;
         
        }
        `,
    }
  }

The vertexVariables and fragmentVariables are variables defined and scoped to each layer. We’ll explore those when we look at the layers.

The HelpersChunk, NoiseChunk and BlendModesChunk are pre-defined shader functions that are used in the layers and have to be hoisted to the top of the shaders.

To better understand the output of a transpiled LayerMaterial, we’ll explore a LayerMaterial with no layers added and the ‘chunks’ removed.

<LayerMaterial
    color="#ffffff" //
    lighting="basic"
>
</LayerMaterial>

The layer material when added to an environment and rendered produces the result:

If we expand the layer material to its constituent parts, we’ll see the following:

uniforms

const uniforms = {
  u_lamina_color: new THREE.Color("#ffffff").convertSRGBToLinear(),
  u_lamina_alpha: 1.0
};

vertex shader

#ifdef GL_ES
precision mediump float;
#endif

void main() {
    vec3 lamina_finalPosition = position;
    vec3 lamina_finalNormal = normal;

    gl_Position = projectionMatrix * modelViewMatrix * vec4(lamina_finalPosition, 1.0);
}

fragment shader

#ifdef GL_ES
precision mediump float;
#endif

uniform vec3 u_lamina_color;
uniform float u_lamina_alpha;

void main() {
    vec4 lamina_finalColor = vec4(u_lamina_color, u_lamina_alpha);

    gl_FragColor = lamina_finalColor;
}

The recreated shader when added to an environment and rendered produces the result:

While the shader does not change the material much, we’ve been able to achieve a similar result to the lamina-based shader material.

What next?

In the next article, we’ll explore one of the layers that can be added to a LayerMaterial and how they affect the final shader.