The Making of Shader Lab

Development

Laboratory

May 1, 2026 Tobias Moccagatta, Valentina Bearzotti


At basement, we’re always raising the bar and pushing the limits on what’s possible. That means we’re constantly experimenting, say motion, texture, typography, image treatment, and realtime visuals. For that reason, we wanted a space where those explorations could happen faster and more freely.

Many of our processes involve explorations, in which we gather information about the visual direction we should head to. We find ourselves building internal tools that would enable this collaboration between our design and development squads.

In the era of personal software, almost anything is possible. As we developed and tested it internally, we realized it was something worth sharing. We take pride in what we build, and in an effort to encourage more people to do so, we’re embracing a more open approach—creating and sharing our work publicly.

Our Shader Lab

After a month of development we decided to publicly release our latest tool that makes it easier to play around with shaders by creating, mixing and tweaking multiple layers.

It’s been a real time saver for the team, especially during early explorations. Instead of coding ideas from scratch, we can quickly build and test them in the Lab. It also lets designers develop their ideas further without needing to code, helping to close the gap between concept and execution.

Hands on

Start with a simple source, like a video layer or a custom shader sketch. From there, build the composition by stacking effects, refining the image, and shaping it into a more defined visual direction.

Once the visual feels right, add motion directly in the timeline. A few keyframes are often enough to make the composition feel alive, without turning the process into a full animation workflow.

That package lets the same composition live inside a React app, either as its own rendered piece or as post-processing on top of an existing scene.

The workflow doesn’t stop at making the visual. It can also become part of the final product. Let’s try it with this composition: The goal was to roughly imitate a Star Wars’ holoprojector.

A holoprojector composition in Shader Lab

With the composition saved, the useShaderLab hook exposes a postprocessing handle that hands the whole stack back to your scene as a single effect. The wiring is pretty straightforward:

scene.tsx
"use client";

import { type ShaderLabConfig, useShaderLab } from "@basementstudio/shader-lab";
import {
  Canvas,
  createPortal,
  type DefaultRendererProps,
  useFrame,
  useThree,
} from "@react-three/fiber/webgpu";
import { useEffect, useMemo, useRef } from "react";
import {
  OrthographicCamera,
  PerspectiveCamera,
  Scene,
  Texture,
  WebGLRenderTarget,
} from "three";
import { float, mix, texture as tslTexture, uv, vec2 } from "three/tsl";
import { MeshBasicNodeMaterial, WebGPURenderer } from "three/webgpu";

// Your effect stack — exported from shader-lab's editor.
// bloom → dithering → circuit-bent in this example.
const shaderLabConfig: ShaderLabConfig = {
  layers: [
    /* ... */
  ],
  timeline: { duration: 8, loop: true, tracks: [] },
};

function HoloprojectorScene() {
  const { gl, size } = useThree();
  const renderer = gl as unknown as WebGPURenderer;

  // ...Load the glb, make a solid copy with flatShading,
  // and an `edge detect` looking copy with EdgesGeometry.

  // Two scenes — one for each pass
  const solidScene = useMemo(() => new Scene(), []);
  const edgeScene = useMemo(() => new Scene(), []);
  const camera = useMemo(() => new PerspectiveCamera(35, 1, 0.1, 50), []);
  // (camera aspect updated on resize)

  // ...Pavel's fluid sim runs on a hidden canvas. We expose its output as a
  // CanvasTexture and use it as the reveal mask in the composite pass below.

  // Three offscreen render targets: solid pass, edge pass, composite.
  // (created/disposed in a useEffect on { size.width, size.height })
  const solidRt = useRef<WebGLRenderTarget | null>(null);
  const edgeRt = useRef<WebGLRenderTarget | null>(null);
  const compositeRt = useRef<WebGLRenderTarget | null>(null);

  // One orthographic camera, reused for the composite + screen fullscreen quads.
  const quadCamera = useMemo(
    () => new OrthographicCamera(-1, 1, 1, -1, 0, 1),
    [],
  );

  // Composite scene: a fullscreen quad that mixes solid + edge using the
  // fluid's luminance as the reveal mask.
  const compositeScene = useMemo(() => new Scene(), []);
  const compositeMaterial = useMemo(() => {
    const flipped = vec2(uv().x, float(1).sub(uv().y));
    const solidTex = tslTexture(new Texture(), flipped);
    const edgeTex = tslTexture(new Texture(), flipped);
    const fluidTex = tslTexture(new Texture(), uv());
    const lum = fluidTex.rgb.x
      .mul(0.299)
      .add(fluidTex.rgb.y.mul(0.587))
      .add(fluidTex.rgb.z.mul(0.114));
    const m = new MeshBasicNodeMaterial();
    m.colorNode = mix(solidTex.rgb, edgeTex.rgb, lum.clamp(0, 1));
    return { material: m, solidTex, edgeTex, fluidTex };
  }, []);

  // Screen scene: a fullscreen quad that paints the final post-processed texture.
  const screenScene = useMemo(() => new Scene(), []);
  const screenTexture = useMemo(
    () => tslTexture(new Texture(), vec2(uv().x, float(1).sub(uv().y))),
    [],
  );
  const screenMaterial = useMemo(() => {
    const m = new MeshBasicNodeMaterial();
    m.colorNode = screenTexture.rgb;
    return m;
  }, [screenTexture]);

  // Shader Lab's postprocessing handle
  const { postprocessing } = useShaderLab(shaderLabConfig, {
    renderer,
    width: size.width,
    height: size.height,
  });

  useEffect(() => {
    postprocessing.resize(size.width, size.height);
  }, [postprocessing, size.width, size.height]);

  const t = useRef(0);
  useFrame(
    (_, delta) => {
      t.current += delta;
      const sRt = solidRt.current;
      const eRt = edgeRt.current;
      const cRt = compositeRt.current;
      if (!(sRt && eRt && cRt)) return;

      // Step the fluid sim and sync its CanvasTexture into the composite material
      // compositeMaterial.fluidTex.value = fluidCanvasTexture

      // Pass 1 — render the solid scene to render target A
      renderer.setRenderTarget(sRt);
      renderer.render(solidScene, camera);

      // Pass 2 — render the edge scene to render target B
      renderer.setRenderTarget(eRt);
      renderer.render(edgeScene, camera);

      // Pass 3 — composite both using the fluid as a mask, into render target C
      compositeMaterial.solidTex.value = sRt.texture;
      compositeMaterial.edgeTex.value = eRt.texture;
      renderer.setRenderTarget(cRt);
      renderer.render(compositeScene, quadCamera);

      // Pass 4 — feed the composite into postprocessing, get the final texture back and paint it
      if (postprocessing.ready) {
        const out = postprocessing.render(cRt.texture, t.current, delta);
        if (out) screenTexture.value = out;
        renderer.setRenderTarget(null);
        renderer.render(screenScene, quadCamera);
      }
    },
    { phase: "render" },
  );

  return (
    <>
      {createPortal(
        <mesh frustumCulled={false}>
          <planeGeometry args={[2, 2]} />
          <primitive attach="material" object={compositeMaterial.material} />
        </mesh>,
        compositeScene,
      )}
      {createPortal(
        <mesh frustumCulled={false}>
          <planeGeometry args={[2, 2]} />
          <primitive attach="material" object={screenMaterial} />
        </mesh>,
        screenScene,
      )}
    </>
  );
}

export function HermitVisual() {
  return (
    <Canvas
      renderer={async (props: DefaultRendererProps) => {
        const r = new WebGPURenderer(
          props as ConstructorParameters<typeof WebGPURenderer>[0],
        );
        await r.init();
        return r;
      }}
    >
      <HoloprojectorScene />
    </Canvas>
  );
}

That’s the whole integration. Tweaking parameters from here means either jumping back into the Shader Lab webapp and re-exporting, or wiring the imported scene up to Leva (or whatever controls you prefer) and driving the uniforms yourself. Here it is running over our custom scene:

What’s to come

We quickly saw a strong response from the community, which we’re genuinely grateful for.

It’s been super exciting to see people take the tool in directions we hadn’t anticipated, exploring use cases we didn’t initially have in mind.

The tool continues to evolve and is open for everyone to come and build with us in the process. Looking forward to the near future where we plan on adding a collaboration mode, keyframe interpolation, layer and masking groups, and effect presets.

As always, thanks to the community for the support. And remember to ship your experiments to the world.


May 1, 2026 Tobias Moccagatta, Valentina Bearzotti