TSL Shaders: Improving ergonomics using wgsl "tag template literal" functions

tl;dr: jump to the Tag Template Literal Functions section for a look at a suggested new mechanism for writing TSL-compatible wgsl.

I’ve recently taken a first dive into three.js’ new Node Materials and TSL. And admittedly I’m jumping straight into the deep end by working on upgrading three-mesh-bvh and three-gpu-pathtracer to enable generic, compute-based spatial queries and rendering.

In-progress pathtracer using three-mesh-bvh and TSL compute shaders with help from @TheBlek.

Like many I have opinions about TSL but as someone who has been trying to write fairly complex, reusable shaders using brittle string replacement and “onBeforeCompile” for nearly a decade it’s hard not to see TSL as bringing some huge improvements. Implicit dependency inclusion & deduplication, indirect value references for uniforms, and being able to slot functions into each other, to name a few, help make code feel significantly more generalize-able. For the work I do, at least, It’s a step that needed to happen.

Writing the code itself proves to be more difficult, though. As you might imagine declaring variables, performing math operations, and writing ray tracing logic with the function-chaining approach recommended by TSL was not ergonomic for me. This isn’t TSL’s fault - it’s the nature of trying to enable this kind of meta-programming in a language like Javascript. But I still wanted to see if I could find a better way. At least for the way I want to think about things.

As a baseline, here’s a notional example of what TSL might look like for calculating a box intersection for a ray and assigning it to a struct to return. It feels fairly verbose and uses some (for me) unintuitive syntax for things like if statements. For a full blown pathtracer I didn’t feel this would be approachable:

// tsl syntax
const getBoxIntersectionPoint = Fn( ( { ray, box } ) => {

  const dist = intersectsBoundsFn( { ray, box } ).toVar();
  const hit = intersectionStruct().toVar(); 
  If( dist.lessThan( float( 0.0 ) ), () => {

    hit.didHit.assign( false );

  } ).Else( () => {

    hit.didHit.assign( true );
    hit.distance.assign( dist );
    hit.point.assign( ray.origin.add( ray.direction.mul( dist ) ) );

  } );

  return hit;

} ).setLayout( {
    name: 'getBoxIntersectionPoint',
    type: intersectionStruct,
    inputs: [
      { name: 'ray', type: rayStruct },
      { name: 'box', type: boxBoundsStruct },
    ],
} );

What about wgslFn?

But there are “native” function nodes! These nodes allow you to write string literals containing wgsl (or glsl) directly, meaning you can use the traditional shader syntax you’re familiar with while still integrating with TSL’s node syntax via dependencies and function call sites:

// wgsl
const getBoxIntersectionPoint = wgslFn( /* wgsl */`
  fn getBoxIntersectionPoint(
    ray: Ray,
    box: BoxBounds
  ) -> IntersectionResult {

    let dist = intersectsBounds( ray, box );
    var hit: IntersectionResult;
    if ( dist < 0.0 ) {

      hit.didHit = false;

    } else {

      hit.didHit = true;
      hit.distance = dist;
      hit.point = ray.origin + ray.direction * dist;

    }

    return hit;

  }
`, [ rayStruct, boxBoundsStruct, intersectionStruct ] );

With some comment-tag syntax highlighting in an IDE this can actually start to feel pretty good! You can write functions in a familiar format and still use nodes from the broader TSL ecosystem. But it pretty quickly becomes apparent that there are a lot of gotchas and limitations. All structs, functions, uniforms, and variables used must have names explicitly declared so you can use them in the function, which are otherwise not available until build time (notice that “Ray”, “IntersectionResult” etc, struct names must all match). This is a pretty heavy restriction especially when trying to write generic code for users to integrate or slotting user functions into your wrapper and can quickly can start to feel too limiting.

Tag Template Literal Functions

Tag Template Literal functions are functions that allow you to process template string segments and arguments by effectively passing the function to the template function without parentheses. The string tokens and args from the template string are then are then effectively passed to the function as arrays so they can be processed into whatever format is needed. These are an incredibly powerful Javascript feature that I don’t often see get much use. In a lot of ways they’re great for exactly what we’re talking about here.

For our use we can write a tag template literal function that can take TSL nodes as template arguments and return a node that can be used, then evaluated at build-time by the TSL node graph. This turns our function declaration into something like so:

// tag template wgsl
const getBoxIntersectionPoint = wgslTagFn/* wgsl */`
  fn getBoxIntersectionPoint(
    ray: ${ rayStruct },
    box: ${ boxBoundsStruct }
  ) -> ${ intersectionStruct } {

    let dist = ${ intersectsBounds( 'ray', 'box' ) };
    var hit: ${ intersectionStruct };

    if ( dist < 0.0 ) {

      hit.didHit = false;

    } else {

      hit.didHit = true;
      hit.distance = dist;
      hit.point = ray.origin + ray.direction * dist;

    }

    return hit;

  }
`;

Notice that the structs and functions can be used directly as template string arguments and the names no longer need to be declared! The node returned by wgslTagFn caches the referenced nodes and can evaluate the variable names to inline when the shader is being built. This can work for structs, function names, uniforms, literals, etc. With some other clever processing we can allow for strings to be passed to the functions being inlined in order to refer to local function variables, as you see above. And, of course, all these nodes are then implicitly added to the dependency graph and deduped.

Opinions will vary, I’m sure, but for me this is nearly exactly what I’d like to be writing in terms of shader definitions: all the benefits of dependencies and TSL nodes while still being able to write code in a familiar syntax with run-time composition and requirements for brittle dependencies on things like fixed naming.

What’s Next

If you’re interested in trying this out I’ve separated the code into this repository along with some more descriptions of the available features - please feel free to give feedback, as well! As development on three-mesh-bvh and the pathtracer continue I plan to keep exercising this approach and if it continues to prove useful perhaps there are ways it can be made more reusable or recommended as an addition to the main project.

5 Likes

Personal curiosity:

I’ve seen onBeforeCompile mentioned a lot when talking about some of the problems that TSL solves. I’m curious though how come it got to that point that it was seeing so much use.

If you were writing something akin to compute shaders, just general logic, it seems less likely that you would be using onBeforeCompile over a ShaderMaterial?

Could you give more detail on how this used to tie into a path tracer? Would the same shader require both some ray tracing logic and the PBR light models, but say, much more PBR to warrant modifying the standard material over just a shader material with some of the threes include syntax?

Lambert and Phong seem super trivial to write from scratch and include in any shader so I never imagined that someone would reach for onBeforeCompile for those. MeshBasicMaterial seems to be the extreme case, I don’t think there would ever be a reason to use it like this, maybe the common chunk is the only you’d ever have to include, for the rest you’re just writing your own shader.

PBR does seem to have more paths, but it’s also possible to just copy the template into a shader material and have it behaving more or less the same. I think the only thing that does seem a bit tricky is how to have the ShaderMaterial interact with the scenes environment map, but then it’s not hard to just provide your own PMREM.

On topic:

I’m not entirely sure if I understand what is going on here. Both seem somewhat magical, and the original way seems to make slightly more sense:

It’s similar to how you write hooks in react and how you declare dependencies.

Since all those variables are nodes, they work with a nodes system, it kinda makes sense that another part of the nodes system, the function, takes some nodes as dependencies. It’s not clear to me at all how including “rayStruct” a JavaScript variable, makes the “Ray” struct available in this function and not “Ray123”. But if I dive into the docs, learn more about it, it would probably become clear. Still, even without that, I can reason that a “node” depends on other “nodes” and it’s fairly obvious how that works on a JavaScript level. Like - “the second argument is a list of dependencies”. And what goes in is (pure) WGSL.

In your example here it seems that there is one more layer of magic happening. The tag thing seems rather esoteric/advanced - you’ve said it yourself that you haven’t seen it used often. The typing kinda sorta makes sense but “intersectBounds” does not.

Just my 2 cents.

Tl:dr;

Original example is a pure wgsl that you can use anywhere else. You can keep it in its own file and get syntax highlighting. Or you can put it in a TSL node and make it work with TSL.

The caveat is, in order for it to make sense in the wider system, you have to process this snippet with a function that takes a list of dependencies.

Alternative feels like a somewhat awkward hybrid. It’s by no means pure wgsl, and it’s more complicated than “just a string”.

It isn’t complaining that Ray or BoxBounds don’t exist, but i’m able to format it the way i want to. I then plug it into the wgslNode via

import myWGSL from 'my.wgsl' 
const myNode = wgslNode(myWGSL, myDependencies) 

And i get the proper hoisting and all that stuff the dependencies need to do?

I’ve seen onBeforeCompile mentioned a lot when talking about some of the problems that TSL solves.

I wasn’t just writing general logic. My use cases span 10 years of writing shaders for visualization of all varieties of heatmaps, overlays, spatial data, etc. Often needing to be layered or blended in different ways with support for all lighting types including environment so it ran in the environments where we wanted to to use it (or were considering using it). You can make it sound simple by saying “just write custom phong lighting” but it’s missing the point.

If you were writing something akin to compute shaders, just general logic, it seems less likely that you would be using onBeforeCompile over a ShaderMaterial

I was not using onBeforeCompile for the WebGL path tracer. I’m just using it as an example and current use case here.

it’s also possible to just copy the template into a shader material and have it behaving more or less the same.

This make shaders even more susceptible to breakage on three.js version update. You’ve mentioned many times you don’t update three.js versions - if that’s true then you’re missing the reality of the issues that projects that keep dependencies up to date for any variety of reasons have to deal with.

It’s similar to how you write hooks in react and how you declare dependencies.

Maybe only superficially. The need and solution here is more akin to templating languages like React, Vue, or lit-html - all of which have a notation for including javascript variables or functions etc to the template (usually using some combination of braces). What’s happening in the template is not really all that complicated. Passing the struct layout to the template will ultimately just get resolved to the struct name at build time. There’s almost no extra overhead text or syntax-wise:

wgslTagCode/* wgsl */`
  const myStruct: ${ userDefinedStruct };
  myStruct.color = vec4( 1, 1, 0, 1 );
`;

// expands to:
// const myStruct: generatedStructName1;
// myStruct.color = vec4( 1, 1, 0, 1 );

Original example is a pure wgsl that you can use anywhere else. You can keep it in its own file and get syntax highlighting.

All of the examples I’ve given above support syntax highlighting in an IDE, including the content in the template strings. This is what the /* wgsl */ hint comment provides.

It’s not clear to me at all how including “rayStruct” a JavaScript variable, makes the “Ray” struct available in this function and not “Ray123”. But if I dive into the docs, learn more about it, it would probably become clear.

TSL structs, functions, etc do not require having fixed names defined a-priori. These are almost always generated at build time. Having to rely on a string name means that if you ever have to embed or wrap a user function then you need to require that they either name the functions explicitly to exactly what you need (which introduces a new point of breakage) or at least have a fixed name for the function defined at which point you have to embed that user-defined name using a template, anyway and you introduce a new restriction that shouldn’t be needed by TSL simply because you’ve chosen to use wgslFn.

1 Like

Fair. I think the processing function still adds a bit more magic, but yeah it’s basically just a way to not have to repeat yourself.

I don’t like the whole WebGLPrograms class and the copying to uniforms when they could just be referenced with getters and setters. I’ve only so far encountered the PMREM as a problem, but I don’t think that should really be in the renderer it’s too much magic.

I think I’d favor even a more verbose solution where you do declare the deps, and then do simple interpolation to get whatever is the variable/struct name. But with decent docs I guess it doesn’t matter.

The one thing I do love about this in general is that it feels like it’s making a full circle and going back to “chunks”. You can declare your own library of chunks, and there is a more elaborate system than just parsing out the #include .