23
←→

Building a TSL boilerplate: where to start

One of the biggest hurdles to learning TSL is knowing where to begin. Here's how to build a boilerplate project that gets you coding shaders fast.

Loading...
←

Ready to start learning?

What you unlock with Fragments ↓

Lifetime access to our collection of 10 creative coding techniques. 36+ utilities, full breakdowns of 110+ sketches, and downloadable R3F and vanilla projects.

One single payment - no subscription required.

Loading...

Fragments

Learn creative coding with shaders. For design engineers, creative coders and shader artists: techniques, tools, deep dives. Powered by ThreeJS and TSL.

Loading...

2025 Phobon

phobon.io↗Shadercraft↗

Pages

Home↗Techniques↗Utilities↗Breakdowns↗Sketches↗Writing↗

Contact

X @thenoumenon↗hey@fragments.supply↗OKAY DEV @phobon↗
All rights reserved.
Sketches114Writing23

The question everyone asks

"Where do I start?"

I hear this all the time from people who want to learn TSL. You've seen the amazing shader work out there, you know Three.js has this powerful new shading language, but when you sit down to code, you're staring at a blank file wondering what comes first.

The setup feels overwhelming. WebGPU renderer, materials, nodes, viewports, cameras - where do you even begin?

I've definitely been there myself, and plenty of great ideas have been lost to the void because of the whole setup process. So, how can we get past that part and just get to the good stuff?

Volumetric raymarching shader

Why a boilerplate matters

A good boilerplate removes all the friction. Instead of spending hours figuring out renderer configuration or camera setup, you get straight to the fun part: writing shaders.

I primarily work in React for many projects in my design engineering day job, so I've built a boilerplate designed to work with React Three Fiber. But increasingly, I've been finding working in vanilla JavaScript really fun.

The vanilla boilerplate I use is intentionally minimal. It's not a framework - it's just enough code to get a fullscreen canvas running with WebGPU and TSL. Everything else is up to you.

Here's what it gives you:

  • A working WebGPU renderer with proper color space settings
  • A fullscreen plane geometry ready for your shader code
  • Basic viewport handling and resize logic
  • A clean animation loop
  • Proper cleanup when you're done

That's it. No magic, no abstractions you need to learn - just the essentials.

Setting up with Vite

First, let's get a project running. We'll use Vite because it's fast and handles a lot of things out of the box.

Create a new directory and initialize a Vite project:

Initialize a Vite project
pnpm create vite@latest my-tsl-boilerplate-project --template vanilla
 
cd my-tsl-boilerplate-project
 
pnpm install

The Vite CLI will ask you a couple of questions, including whether you want to navigate to the folder, install deps and start. You can do that if you like!

Now install Three.js:

Install Three
pnpm install three

That's it for dependencies. Three.js includes everything you need for TSL and WebGPU rendering.

You should have an index.html file in the root of your project - update it to look like this (note the canvas element and script tag):

index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>TSL Boilerplate</title>
  </head>
  <body>
    <canvas id="webgpu-canvas"></canvas>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

The canvas element is where your shader will render. Next, set up some styles for your canvas and clear your main.js file - we'll update that later.

src/style.css
:root {
  font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
  line-height: 1.5;
  font-weight: 400;
 
  color-scheme: light dark;
  color: rgba(255, 255, 255, 0.87);
  background-color: #242424;
 
  font-synthesis: none;
  text-rendering: optimizeLegibility;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}
 
body {
  margin: 0;
  width: 100svw;
  height: 100svh;
}
 
canvas {
  width: 100%;
  height: 100%;
}

There are a bunch of other files in this project that you can remove for now.

A WebGPUSketch component

I like to create a component that renders a fullscreen shader. It's a simple class that can be used in any project and takes a canvas object, a colorNode, and an optional onFrame callback.

webgpu_sketch.js
import {
  WebGPURenderer,
  MeshBasicNodeMaterial,
  PlaneGeometry,
  Scene,
  Mesh,
  OrthographicCamera,
  DoubleSide,
  NoToneMapping,
  LinearSRGBColorSpace,
} from 'three/webgpu'
import { vec3, Fn } from 'three/tsl'
 
class WebGPUSketch {
  constructor(canvas, colorNode = null, onFrame = null) {
    this._canvas = canvas
 
    this._colorNode = colorNode
    this._onFrame = onFrame
 
    this._meshInitialized = false
  }
 
  async init() {
    this._scene = new Scene()
 
    // Viewport sizes
    this._viewport = {
      width: window.innerWidth,
      height: window.innerHeight,
    }
 
    // Camera
    this._camera = new OrthographicCamera(-1, 1, 1, -1, 0.1, 100)
    this._camera.position.z = 1
    this._scene.add(this._camera)
 
    // Renderer
    this._renderer = new WebGPURenderer({
      canvas: this._canvas,
      antialias: true,
      toneMapping: NoToneMapping,
      outputColorSpace: LinearSRGBColorSpace,
    })
    this._renderer.setSize(this._viewport.width, this._viewport.height)
    this._renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
    this._renderer.setClearColor('#000000')
 
    window.addEventListener('resize', this._resizeHandler)
  }
 
  _initMesh() {
    // Sketch geometry
    this._geometry = new PlaneGeometry(1, 1, 1, 1)
 
    // Sketch material
    this._material = new MeshBasicNodeMaterial({
      transparent: true,
      side: DoubleSide,
      depthWrite: false,
    })
    this._material.colorNode = this._colorNode ? this._colorNode : vec3(0, 0, 0)
 
    // Add a fullscreen sketch plane to the scene
    this._mesh = new Mesh(this._geometry, this._material)
    this._mesh.scale.set(2, 2, 1)
    this._scene.add(this._mesh)
  }
 
  _resizeHandler = () => {
    // Update sizes
    this._viewport.width = window.innerWidth
    this._viewport.height = window.innerHeight
 
    // Update camera
    this._camera.aspect = this._viewport.width / this._viewport.height
    this._camera.updateProjectionMatrix()
 
    // Update renderer
    this._renderer.setSize(this._viewport.width, this._viewport.height)
    this._renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
  }
 
  async render() {
    if (!this._meshInitialized) {
      this._initMesh()
      this._meshInitialized = true
    }
 
    await this._renderer.renderAsync(this._scene, this._camera)
 
    if (this._onFrame) {
      this._onFrame(this._colorNode, this._renderer)
    }
 
    const frame = this.render.bind(this)
    this._animationFrameId = window.requestAnimationFrame(frame)
  }
 
  dispose() {
    // Cancel any pending animation frames
    if (this._animationFrameId) {
      window.cancelAnimationFrame(this._animationFrameId)
    }
 
    window.removeEventListener('resize', this._resizeHandler)
 
    // Dispose geometry
    if (this._geometry) {
      this._geometry.dispose()
    }
 
    // Dispose material
    if (this._material) {
      this._material.dispose()
    }
 
    // Clear references
    this._scene = null
    this._geometry = null
    this._material = null
    this._camera = null
    this._renderer = null
    this._mesh = null
  }
}
 
export default WebGPUSketch

The component pulls in Three.js WebGPU classes (WebGPURenderer, MeshBasicNodeMaterial, etc.) and TSL functions. The three/webgpu import gives us the renderer and materials, while three/tsl gives us node functions.

Breaking down the code

Let's walk through what each part does:

Scene setup - The scene is just a container for your 3D objects. Simple as that.

Viewport tracking - We store window dimensions (window.innerWidth and window.innerHeight) to update the camera and renderer on resize.

Camera - OrthographicCamera(-1, 1, 1, -1, ...) creates a fullscreen view. The bounds match our scaled plane perfectly - with a 2x2 plane, this gives us a fullscreen quad.

Renderer - The WebGPURenderer needs NoToneMapping and LinearSRGBColorSpace for correct colors. Without these, everything looks washed out. We also set antialiasing and cap the pixel ratio at 2 to avoid performance issues on high-DPI displays.

Geometry - PlaneGeometry(1, 1, 1, 1) creates a 1x1 unit plane. The last two parameters are segment counts - we keep them at 1 for a simple quad.

Material - MeshBasicNodeMaterial is what accepts TSL nodes. The settings matter:

  • transparent: true lets you layer effects
  • side: DoubleSide renders both sides of the plane
  • depthWrite: false prevents z-fighting issues

The colorNode property is where we assign our TSL function - this is what actually renders your shader code.

Mesh scaling - scale.set(2, 2, 1) makes the plane fill the screen. The camera bounds are -1 to 1, so a 2x2 plane covers everything.

Resize handler - When the window resizes, we update the viewport dimensions, camera aspect ratio, and renderer size. The pixel ratio is capped at 2 to avoid performance issues on high-DPI displays.

Animation loop - The render() method calls renderAsync to render each frame. Note the await - WebGPU rendering is async, so we need to wait for each frame before starting the next. After rendering, if an onFrame callback is provided, it's called with the color node and renderer for any per-frame updates.

Cleanup - The dispose() method cancels animation frames, removes event listeners, and properly disposes of geometry, materials, and other resources to prevent memory leaks.

Save this as webgpu_sketch.js and place it in the src directory (I like to keep these in a separate components folder for easy reuse).

The main.js file

Here's the full code that goes in src/main.js:

src/main.js
import './style.css'
 
import { vec3, Fn } from 'three/tsl'
import WebGPUSketch from './webgpu_sketch'
 
/**
 * A colorNode that outputs a red color.
 */
const uvGradientNode = Fn(() => {
  return vec3(1, 0, 0)
})
 
const canvas = document.querySelector('#webgpu-canvas')
const sketch = new WebGPUSketch(canvas, uvGradientNode())
 
sketch.init()
sketch.render()

Breaking down the code

Imports - We're pulling in TSL functions from three/tsl (vec3, Fn) and our WebGPUSketch component. The TSL imports give us the node functions we'll use to build shaders.

Color node definition - uvGradientNode is a TSL function node created with Fn(() => ...). Inside, vec3() creates a color vector. Here we're returning a simple red color vec3(1, 0, 0) - you can replace this with any TSL expression to create your shader.

Canvas selection - document.querySelector('#webgpu-canvas') grabs the canvas element from the HTML.

Sketch initialization - We create a new WebGPUSketch instance, passing the canvas and our color node. The component handles all the setup internally.

Run pnpm dev and you should see a red background filling your screen. That's your first TSL shader running.

Common gotchas

There are a few things that trip people up:

  • Color space - WebGPU needs LinearSRGBColorSpace and NoToneMapping for correct color output. Without these, colors look washed out or wrong.

  • Camera setup - The orthographic camera uses -1, 1, 1, -1 bounds for a fullscreen view. This matches the plane geometry scaled to 2x2.

  • Material settings - transparent: true and depthWrite: false prevent z-fighting and let you layer effects properly.

  • Async rendering - WebGPU uses renderAsync instead of render. Don't forget the await or your frame loop breaks.

  • Initialization order - Call sketch.init() before sketch.render(). The init method sets up the renderer, camera, and scene, while render starts the animation loop.

Where to go from here

Once you have the boilerplate running, the real learning begins. Start with simple patterns:

  • Output UV coordinates as color - change colorNode to vec3(uv(), 0.0)
  • Animate with the time node - multiply values by time for motion
  • Mix colors using mix or lerp - blend between two colors
  • Add noise functions for texture - import noise utilities from Fragments

Each small experiment teaches you something new. The boilerplate stays the same - you're just changing what happens in that colorNode.

You can find a Sketch that you like and recreate it with the boilerplate.

The real value

A boilerplate isn't about copying code. It's about removing barriers so you can focus on what matters: learning TSL and creating visuals.

When setup is solved, you spend your time experimenting, not debugging renderer configuration. That's when the magic happens - when you're tweaking values, trying new combinations, and discovering what's possible.

The vanilla boilerplate is available on GitHub if you want to use it directly, or you can build your own following the code above. Either way, you'll have a solid foundation to start learning TSL.

If you're looking for a React boilerplate, you can use the Fragments R3F Boilerplate directly from GitHub, and also check out the Boilerplate Project Utility page for more information.

Volumetric raymarching shader

Going further

Ready to dive deeper into TSL and creative coding? Unlock Fragments to get access to the full collection including:

  • 11 creative coding techniques
  • 36+ helpful utility functions and components
  • 110+ sketches with full source code and breakdowns
  • Access to the private Fragments Discord community

I'm just getting started too! There's plenty more to come in the coming months.

Unlock the CollectionJoin 134+ design engineers, creative coders and shader artists→$149USD
You'll be redirected to our secured payment platform and directly get access to the Fragments collection.

Be the first to know what's next