What's the purpose of `Fn()` in TSL?

In the TSL docs, there’s no mention of .Fn.

Here is an example that works, using Fn(): https://codepen.io/trusktr/pen/VYjLXBX

Here’s the same example without Fn(), not working: https://codepen.io/trusktr/pen/MYewGLQ

It has this error:

Uncaught TypeError: Cannot read properties of null (reading 'If')
    at If (three.webgpu.js:4264:42)

and that line looks like this:

const If = ( ...params ) => currentStack.If( ...params );

Here’s one with no function at all, also not working, with the same error (makes sense it has the same error): https://codepen.io/trusktr/pen/emzNroK

What’s Fn for exactly?

See here:
https://github.com/mrdoob/three.js/wiki/Three.js-Shading-Language#function

Generally, it describes a function-ish entity. If the parameters are only TSL values and are well defined, a Fn is compiled into a GLSL/WGSL function. Otherwise it is inlined and it does not exist as a standalone function. However, IMHO, one of the hidden advantages of Fn is that it can mix JS and TSL statements (e.g. if and If or + and add) - so you can construct conditional functions that have different body commands - I use this heavily for the Disfigure library.

At a low-level perspective, Fn only defines a subtree in the AST (abstract syntax tree), which may later become a function or an expression. So it is first processed by JS, compiled, executed, and then the result is processed by the TSL engine that compiles it ito GLSL or WGSL commands.

At a technical level Fn wraps a normal JS function, but does some additional processing, which is needed for TSL. If you use the function without wrapping it in Fn, these additional things will not be done, and some other things will fail.

1 Like

Thanks. Looks like the documentation is sort of in transition between old docs (written in HTML files), new docs (written in JSDoc comments) (EDIT: oh these are generated to HTML side by side with hand-written docs?), and the wiki.

I read the page you linked, but some things aren’t immediately obvious. For example, you mentioned

and the wiki page mentions

The main difference is that Fn() creates a controllable environment, allowing the use of stack where you can use assign and conditional , while the classic function only allows inline approaches.

but then it does not go and show examples comparing the mentioned differences. The wiki page proceeds to show an example doesn’t demonstrate assign or conditional stuff, but a simpler function that works either way with or without Fn:

// tsl function
const oscSine = Fn( ( [ t = time ] ) => {

	return t.add( 0.75 ).mul( Math.PI * 2 ).sin().mul( 0.5 ).add( 0.5 );

} );

// inline function (no Fn)
export const oscSine = ( t = time ) => t.add( 0.75 ).mul( Math.PI * 2 ).sin().mul( 0.5 ).add( 0.5 );

Maybe this can be updated to precisely explain the differences.


As one example, this code does not throw or log any error, and does not work:

const result = someNode.mul(otherNode)

result.lessThanEqual(0.5).discard() // does not work

mat.opacityNode = result

codepen

This code also does not work, but it does throw an error:

const result = someNode.mul(otherNode)

If(result.lessThanEqual(0.5), () => Discard()) // does not work, throws "Cannot read properties of null (reading 'If')"

mat.opacityNode = result

codepen

But both of the following work (the 1337 conditional check successfully logs the WGSL code to console):

const result = someNode.mul(otherNode)

const opacityNode = Fn(() => {
  result.lessThanEqual(0.5).discard()
  return result
})()

mat.opacityNode = opacityNode

codepen

const result = someNode.mul(otherNode)

const opacityNode = Fn(() => {
  If(result.lessThanEqual(0.5), () => Discard())
  return result
})()

mat.opacityNode = opacityNode

codepen

It isn’t clear why this is required. Seems to me like both versions without Fn (chaining vs If), could (should?) just work, as neither of those graphs (both attached to opacityNode) rely on any arguments such as material being passed in from the renderer, so they seem fully standalone.

Another question may be, what is it about the “node graph” (that is not very exposed via TSL, and remains more of an internal detail), that would prevent those things from simply working?

Is it that branches that lead to nowhere (i.e. dangling paths not leading to the final output node) are not tracked? If so, could a simple no-op merge fix that to join dangling paths into the final result? For example:

const result = someNode.mul(otherNode)
const discardCheck = result.lessThanEqual(0.5).discard()
mat.opacityNode = result.merge(discardCheck) // either the first node is only considered for the output
// or
mat.opacityNode = discardCheck.merge(result) // or the last node is only considered for the output

and

const result = someNode.mul(otherNode)
const discardCheck = If(result.lessThanEqual(0.5), () => Discard())
mat.opacityNode = result.merge(discardCheck) // either the first node is only considered for the output
// or
mat.opacityNode = discardCheck.merge(result) // or the last node is only considered for the output

which implies that the arrow function for Discard() would not be needed, because Fn would no longer be needed, and Discard() would simply be a declarative node connected in the graph:

const discardCheck = If(result.lessThanEqual(0.5), Discard()) // merges two nodes into a boolean.

The idea is, if the graph of nodes are ultimately connected, then they should all just work. And if dangling paths are too complex or undesirable to handle (too much perf overhead to traverse and find them?) then the merge idea could solve that: there’d always be an easy-to-follow path from any top-level input node to the final output node, or vice versa.

Can those use cases be made to just work without Fn?

It looks like you are aiming at complete and deep understanding of Fn and other TSL things. As far as I know, there is no such complete guide or tutorial. And my hope is than when you reach this level of understanding, you could write the tutorial.

My approach is more practical - instead of first building a solid theoretical knowledge and then writing code, I experiment with just the things I need. This allows me to use TSL even if my knowledge is rather scattered and disorganized.

Here is example of conditional code - this is JavaScript’s if, not the TSL’s If. The result is that in this way the body of a TSL function is changed before TSL processes it. So, it is possible to optimize, fine-tune, or customize code in the shader, instead of putting all options inside the shader code and branching there.

// a fake function, just for illustrative purpose
var myfunc = Fn( ({x,y,smooth})=>{

	var result = float(5).toVar();

	// note the use of javascript `if`
	if( smooth.value ) {
		var k = x.pow(y).abs().mul(100).sin().toVar();
		result.addAssign( x.smoothstep(k,y.sub(k)) )
	} else {
		result.addAssign( x );
	}
	
	return result;
})

When used with myfunc(1,2,true), the compiled code is:

nodeVar0 = 5.0;
nodeVar1 = sin( ( abs( pow( 1.0, 2.0 ) ) * 100.0 ) );
nodeVar0 = ( nodeVar0 + smoothstep( nodeVar1, ( 2.0 - nodeVar1 ), 1.0 ) );

When used with myfunc(1,2,false), the compiled code is:

nodeVar0 = 5.0;
nodeVar0 = ( nodeVar0 + 1.0 );

Note, that only one of the branches exists in the shader.


Now, if the TSL If is used:

If( smooth, ()=> {
	var k = x.pow(y).abs().mul(100).sin().toVar();
	result.addAssign( x.smoothstep(k,y.sub(k)) )
}).Else( ()=> {
	result.addAssign( x );
});

the compiled code has both branches:

if ( nodeVar3 ) {
	nodeVar4 = sin( ( abs( pow( 1.0, 2.0 ) ) * 100.0 ) );
	nodeVar0 = ( nodeVar0 + smoothstep( nodeVar4, ( 2.0 - nodeVar4 ), 1.0 ) );
} else {
	nodeVar0 = ( nodeVar0 + 1.0 );
}

In some awkward sense, we may use JavaScript as preprocessor to TSL :open_mouth:

1 Like

Might be worth clarifying what the stack means in this case. Making a call to that If node in some random place in code would probably be pretty meaningless.

But because it is happening “within” that Fn call, it has a context and can generate that block of code.

You need to create that context. When TSL encounters the Fn with this callback, it can do magic. So if there are any side effects they can be applied in the right place (since you don’t return a discard for example to connect to other nodes).

Notice in @PavelBoytchev examples what is being returned to whom. Nodes can be chained, but you always get a reference (var foo) that you further pass into other nodes.

If i imagine is not returned to anyone, so the side effects would matter.

1 Like