diff --git a/docs/TSL.md b/docs/TSL.md deleted file mode 100644 index b383d26226149c..00000000000000 --- a/docs/TSL.md +++ /dev/null @@ -1,1649 +0,0 @@ -## TSL Specification - -An Approach to Productive and Maintainable Shader Creation. - -- [Introduction](#introduction) - - [Why TSL?](#why-tsl) - - [Example](#example) - - [Architecture](#architecture) -- [Learning TSL](#learning-tsl) -- [Constants and explicit conversions](#constants-and-explicit-conversions) -- [Conversions](#conversions) -- [Uniform](#uniform) - - [onUpdate](#uniformonupdate) -- [Swizzle](#swizzle) -- [Operators](#operators) -- [Function](#function) -- [Variables](#variables) -- [Array](#array) - - [Uniform](#array-uniform) -- [Varying](#varying) -- [Conditional](#conditional) - - [If-else](#if-else) - - [Switch-case](#switch-case) - - [Ternary](#ternary) -- [Loop](#loop) -- [Math](#math) -- [Method chaining](#method-chaining) -- [Texture](#texture) -- [Attributes](#attributes) -- [Position](#position) -- [Normal](#normal) -- [Tangent](#tangent) -- [Bitangent](#bitangent) -- [Camera](#camera) -- [Model](#model) -- [Screen](#screen) -- [Viewport](#viewport) -- [Blend Modes](#blend-modes) -- [Reflect](#reflect) -- [UV Utils](#uv-utils) -- [Interpolation](#interpolation) -- [Random](#random) -- [Rotate](#rotate) -- [Oscillator](#oscillator) -- [Timer](#timer) -- [Packing](#packing) -- [Render Pipeline](#render-pipeline) - - [Multiple Render Targets](#multiple-render-targets-mrt) - - [Post-Processing](#post-processing) - - [Render Pass](#render-pass) - - [Compute](#compute) -- [Storage](#storage) -- [Struct](#struct) -- [Flow Control](#flow-control) -- [Override Node](#override-node) -- [Fog](#fog) -- [Color Adjustments](#color-adjustments) -- [Utilities](#utilities) -- [NodeMaterial](#nodematerial) - - [LineDashedNodeMaterial](#linedashednodematerial) - - [MeshPhongNodeMaterial](#meshphongnodematerial) - - [MeshStandardNodeMaterial](#meshstandardnodematerial) - - [MeshPhysicalNodeMaterial](#meshphysicalnodematerial) - - [SpriteNodeMaterial](#spritenodematerial) -- [Transitioning common GLSL properties to TSL](#transitioning-common-glsl-properties-to-tsl) - -## Introduction - -### Why TSL? - -Creating shaders has always been an advanced step for most developers; many game developers have never created GLSL code from scratch. The shader graph solution adopted today by the industry has allowed developers more focused on dynamics to create the necessary graphic effects to meet the demands of their projects. - -The aim of the project is to create an easy-to-use environment for shader creation. Even if for this we need to create complexity behind it, this happened initially with `Renderer` and now with the `TSL`. - -Other benefits that TSL brings besides simplifying shading creation are keeping the `renderer agnostic`, while all the complexity of a material can be imported into different modules and use `tree shaking` without breaking during the process. - -### Example - -A `detail map` makes things look more real in games. It adds tiny details like cracks or bumps to surfaces. In this example we will scale uv to improve details when seen up close and multiply with a base texture. - -#### Old - -This is how we would achieve that using `.onBeforeCompile()`: - -```js -const material = new THREE.MeshStandardMaterial(); -material.map = colorMap; -material.onBeforeCompile = ( shader ) => { - - shader.uniforms.detailMap = { value: detailMap }; - - let token = '#define STANDARD'; - - let insert = /* glsl */` - uniform sampler2D detailMap; - `; - - shader.fragmentShader = shader.fragmentShader.replace( token, token + insert ); - - token = '#include '; - - insert = /* glsl */` - diffuseColor *= texture2D( detailMap, vMapUv * 10.0 ); - `; - - shader.fragmentShader = shader.fragmentShader.replace( token, token + insert ); - -}; -``` - -Any simple change from this makes the code increasingly complicated using `.onBeforeCompile`, the result we have today in the community are countless types of parametric materials that do not communicate with each other, and that need to be updated periodically to be operating, limiting the creativity to create unique materials reusing modules in a simple way. - -#### New - -With `TSL` the code would look like this: - -```js -import { texture, uv } from 'three/tsl'; - -const detail = texture( detailMap, uv().mul( 10 ) ); - -const material = new THREE.MeshStandardNodeMaterial(); -material.colorNode = texture( colorMap ).mul( detail ); -``` - -`TSL` is also capable of encoding code into different outputs such as `WGSL`/`GLSL` - `WebGPU`/`WebGL`, in addition to optimizing the shader graph automatically and through codes that can be inserted within each `Node`. This allows the developer to focus on productivity and leave the graphical management part to the `Node System`. - -Another important feature of a graph shader is that we will no longer need to care about the sequence in which components are created, because the `Node System` will only declare and include it once. - -Let's say that you import `positionWorld` into your code, even if another component uses it, the calculations performed to obtain `position world` will only be performed once, as is the case with any other node such as: `normalWorld`, `modelPosition`, etc. - -### Architecture - -All `TSL` components are extended from `Node` class. The `Node` allows it to communicate with any other, value conversions can be automatic or manual, a `Node` can receive the output value expected by the parent `Node` and modify its own output snippet. It's possible to modulate them using `tree shaking` in the shader construction process, the `Node` will have important information such as `geometry`, `material`, `renderer` as well as the `backend`, which can influence the type and value of output. - -The main class responsible for creating the code is `NodeBuilder`. This class can be extended to any output programming language, so you can use TSL for a third language if you wish. Currently `NodeBuilder` has two extended classes, the `WGSLNodeBuilder` aimed at WebGPU and `GLSLNodeBuilder` aimed at WebGL2. - -The build process is based on three pillars: `setup`, `analyze` and `generate`. - -| | | -| -- | -- | -| `setup` | Use `TSL` to create a completely customized code for the `Node` output. The `Node` can use many others within itself, have countless inputs, but there will always be a single output. | -| `analyze` | This proccess will check the `nodes` that were created in order to create useful information for `generate` the snippet, such as the need to create or not a cache/variable for optimizing a node. | -| `generate` | An output of `string` will be returned from each `node`. Any node will also be able to create code in the flow of shader, supporting multiple lines. | - -`Node` also have a native update process invoked by the `update()` function, these events be called by `frame`, `render call` and `object draw`. - -It is also possible to serialize or deserialize a `Node` using `serialize()` and `deserialize()` functions. - -## Learning TSL - -TSL is a Node-based shader abstraction, written in JavaScript. TSL's functions are inspired by GLSL, but follow a very different concept. WGSL and GLSL are focused on creating GPU programs, in TSL this is one of the features. - -### Seamless Integration with JavaScript/TypeScript - -- Unified Code - - Write shader logic directly in JS/TS, eliminating the need to manipulate strings. - - Create and manipulate render objects just like any other JavaScript logic inside a TSL function. - - Advanced events to control a Node before and after the object is rendered. -- JS Ecosystem - - Use native **import/export**, **NPM**, and integrate **JS/TS** components directly into your shader logic. -- Typing - - Benefit from better type checking (especially with **TypeScript** and **[@three-types](https://github.com/three-types/three-ts-types)**), increasing code robustness. - -### Shader-Graph Inspired Structure - -- Focus on Intent - - Build materials by connecting nodes through: [positionWorld](#position), [normalWorld](#normal), [screenUV](#screen), [attribute()](#attributes), etc. -More declarative("what") vs. imperative("how"). -- Composition & High-Level Concepts - - Work with high-level concepts for Node Material like [colorNode](#basic), [roughnessNode](#meshstandardnodematerial), [metalnessNode](#meshstandardnodematerial), [positionNode](#basic), etc. This preserves the integrity of the lighting model while allowing customizations, helping to avoid mistakes from incorrect setups. -- Keeping an eye on software exchange - - Modern 3D authoring software uses Shader-Graph based material composition to exchange between other software. TSL already has its own MaterialX integration. -- Easier Migration - - Many functions are directly inspired by GLSL to smooth the learning curve for those with prior experience. - -### Rendering Manipulation - -- Control rendering steps and create new render-passes per individual TSL functions. - - Implement complex effects is easily with nodes using a single function call either in post-processing and in materials allowing the node itself to manage the rendering process as it needs. - - `gaussianBlur()`: Double render-pass gaussian blur node. It can be used in the material or in post-processing through a single function. - - Easy access to renderer buffers using TSL functions like: - - `viewportSharedTexture()`: Accesses the beauty what has already been rendered, preserving the render-order. - - `viewportLinearDepth()`: Accesses the depth what has already been rendered, preserving the render-order. - - Integrated Compute Shaders - - Perform calculations on buffers using compute stage directly during an object's rendering. - - TSL allows dynamic manipulation of renderer functions, which makes it more customizable than intermediate languages ​​that would have to use flags in fixed pipelines for this. - - You just need to use the events of a Node for the renderer manipulations, without needing to modify the core. - -### Automatic Optimization and Workarounds - -- Your TSL code automatically benefits from optimizations and workarounds implemented in the Three.js compiler with each new version. - - Simplifications - - Automatic type conversions. - - Execute a block of code in vertex-stage and get it in fragment-stage just using `vertexStage( node )`. - - Automatically choose interpolation method for varyings depending on type. - - Don't worry about collisions of global variables internally when using Nodes. - - Polyfills - - e.g: `textureSample()` function in the vertex shader (not natively supported in WGSL) is correctly transpiled to work. - - e.g: Automatic correction for the `pow()` function, which didn't accept negative bases on Windows/DirectX using WGSL. - - Optimizations - - Repeated expressions: TSL can automatically create temporary variables to avoid redundant calculations. - - Automatic reuse of uniforms and attributes. - - Creating varying only if necessary. Otherwise they are replaced by simple variables. - -### Target audience - - Beginners users - - You only need one line to create your first custom shader. - - Advanced users - - Makes creating shaders simple but not limited. Example: https://www.youtube.com/watch?v=C2gDL9Qk_vo - - If you don't like fixed pipelines and low level, you'll love this. - -### Share everything - -#### TSL is based on Nodes, so don’t worry about sharing your **functions** and **uniforms** across materials and post-processing. - -```js -// Shared the same uniform with various materials - -const sharedColor = uniform( new THREE.Color() ); - -materialA.colorNode = sharedColor.div( 2 ); -materialB.colorNode = sharedColor.mul( .5 ); -materialC.colorNode = sharedColor.add( .5 ); -``` - -#### Deferred Function: High level of customization, goodby **#defines** - -Access **material**, **geometry**, **object**, **camera**, **scene**, **renderer** and more directly from a TSL function. Function calls are only performed at the time of building the shader allowing you to customize the function according to the object's setup. - -```js -// Returns an uniform of the material's custom color if it exists - -const customColor = Fn( ( { material, geometry, object } ) => { - - if ( material.userData.customColor !== undefined ) { - - return uniform( material.userData.customColor ); - - } - - return vec3( 0 ); - -} ); - -// - -material.colorNode = customColor(); - -``` - -#### Load a texture-based matrix inside a TSL function - -This can be used for any other JS and Three.js ecosystem needs. You can manipulate your assets according to the needs of a function. This can work for creating buffers, attributes, uniforms and any other JavaScript operation. - -```js -let bayer16Texture = null; - -export const bayer16 = Fn( ( [ uv ] ) => { - - if ( bayer16Texture === null ) { - - const bayer16Base64 = 'data:image/png;base64,...=='; - - bayer16Texture = new TextureLoader().load( bayer16Base64 ); - - } - - return textureLoad( bayer16Texture, ivec2( uv ).mod( int( 16 ) ) ); - -} ); - -// - -material.colorNode = bayer16( screenCoordinate ); - -``` - -#### The node architecture allows the creation of instances of custom attributes and buffers through simple functions. - -```js -// Range values node example - -const randomColor = range( new THREE.Color( 0x000000 ), new THREE.Color( 0xFFFFFF ) ); - -material.colorNode = randomColor; - -//... - -const mesh = new THREE.InstancedMesh( geometry, material, count ); -``` - -#### TSL loves JavaScript - -TSL syntax follows JavaScript style because they are the same thing, so if you come from GLSL you can explore new possibilities. - -```js -// A simple example of Function closure - -const mainTask = Fn( () => { - - const task2 = Fn( ( [ a, b ] ) => { - - return a.add( b ).mul( 0.5 ); - - } ); - - - return task2( color( 0x00ff00 ), color( 0x0000ff ) ); - -} ); - -// - -material.colorNode = mainTask(); -``` - -#### Simplification - -Double render-pass `gaussianBlur()` node. It can be used in the material or in post-processing through a single function. - -```js -// Applies a double render-pass gaussianBlur and then a grayscale filter before the object with the material is rendered. - -const myTexture = texture( map ); - -material.colorNode = grayscale( gaussianBlur( myTexture, 4 ) ); -``` - -Accesses what has already been rendered, preserving the render-order for easy refraction effects, avoiding multiple render-pass and manual sorts. - -```js -// Leaving the back in grayscale. - -material.colorNode = grayscale( viewportSharedTexture( screenUV ) ); -material.transparent = true; -``` - -#### Extend the TSL - -You no longer need to create a Material for each desired effect, instead create Nodes. A Node can have access to the Material and can be used in many ways. Extend the TSL from Nodes and let the user use it in creative ways. - -A great example of this is [TSL-Textures](https://boytchev.github.io/tsl-textures/). - -```js -import * as THREE from 'three'; -import { simplexNoise } from 'tsl-textures'; - -material.colorNode = simplexNoise ( { - scale: 2, - balance: 0, - contrast: 0, - color: new THREE.Color(16777215), - background: new THREE.Color(0), - seed: 0 -} ); - -``` - -## Constants and explicit conversions - -Input functions can be used to create contants and do explicit conversions. -> Conversions are also performed automatically if the output and input are of different types. - -| Name | Returns a constant or convertion of type: | -| -- | -- | -| `float( node\|number )` | `float` | -| `int( node\|number )` | `int` | -| `uint( node\|number )` | `uint` | -| `bool( node\|value )` | `boolean` | -| `color( node\|hex\|r,g,b )` | `color` | -| `vec2( node\|Vector2\|x,y )` | `vec2` | -| `vec3( node\|Vector3\|x,y,z )` | `vec3` | -| `vec4( node\|Vector4\|x,y,z,w )` | `vec4` | -| `mat2( node\|Matrix2\|a,b,c,d )` | `mat2` | -| `mat3( node\|Matrix3\|a,b,c,d,e,f,g,h,i )` | `mat3` | -| `mat4( node\|Matrix4\|a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p )` | `mat4` | -| `ivec2( node\|x,y )` | `ivec2` | -| `ivec3( node\|x,y,z )` | `ivec3` | -| `ivec4( node\|x,y,z,w )` | `ivec4` | -| `uvec2( node\|x,y )` | `uvec2` | -| `uvec3( node\|x,y,z )` | `uvec3` | -| `uvec4( node\|x,y,z,w )` | `uvec4` | -| `bvec2( node\|x,y )` | `bvec2` | -| `bvec3( node\|x,y,z )` | `bvec3` | -| `bvec4( node\|x,y,z,w )` | `bvec4` | - -Example: - -```js -import { color, vec2, positionWorld } from 'three/tsl'; - -// constant -material.colorNode = color( 0x0066ff ); - -// conversion -material.colorNode = vec2( positionWorld ); // result positionWorld.xy -``` - -## Conversions - -It is also possible to perform conversions using the `method chaining`: - -| Name | Returns a constant or conversion of type: | -| -- | -- | -| `.toFloat()` | `float` | -| `.toInt()` | `int` | -| `.toUint()` | `uint` | -| `.toBool()` | `boolean` | -| `.toColor()` | `color` | -| `.toVec2()` | `vec2` | -| `.toVec3()` | `vec3` | -| `.toVec4()` | `vec4` | -| `.toMat2()` | `mat2` | -| `.toMat3()` | `mat3` | -| `.toMat4()` | `mat4` | -| | | -| `.toIVec2()` | `ivec2` | -| `.toIVec3()` | `ivec3` | -| `.toIVec4()` | `ivec4` | -| `.toUVec2()` | `uvec2` | -| `.toUVec3()` | `uvec3` | -| `.toUVec4()` | `uvec4` | -| `.toBVec2()` | `bvec2` | -| `.toBVec3()` | `bvec3` | -| `.toBVec4()` | `bvec4` | - -Example: - -```js -import { positionWorld } from 'three/tsl'; - -// conversion -material.colorNode = positionWorld.toVec2(); // result positionWorld.xy -``` - -## Uniform - -Uniforms are useful to update values of variables like colors, lighting, or transformations without having to recreate the shader program. They are the true variables from a GPU's point of view. - -| Name | Description | -| -- | -- | -| `uniform( boolean \| number \| Color \| Vector2 \| Vector3 \| Vector4 \| Matrix3 \| Matrix4, type = null )` | Dynamic values. | - -Example: - -```js -const myColor = uniform( new THREE.Color( 0x0066FF ) ); - -material.colorNode = myColor; -``` - -### `uniform.on*Update()` - -It is also possible to create update events on `uniforms`, which can be defined by the user: - -| Name | Description | -| -- | -- | -| `.onObjectUpdate( function )` | It will be updated every time an object like `Mesh` is rendered with this `node` in `Material`. | -| `.onRenderUpdate( function )` | It will be updated once per render, common and shared materials, fog, tone mapping, etc. | -| `.onFrameUpdate( function )` | It will be updated only once per frame, recommended for values ​​that will be updated only once per frame, regardless of when `render-pass` the frame has, cases like `time` for example. | - -Example: - -```js -const posY = uniform( 0 ); // it's possible use uniform( 'float' ) - -// or using event to be done automatically -// { object } will be the current rendering object -posY.onObjectUpdate( ( { object } ) => object.position.y ); - -// you can also update manually using the .value property -posY.value = object.position.y; - -material.colorNode = posY; -``` - -## Swizzle - -Swizzling is the technique that allows you to access, reorder, or duplicate the components of a vector using a specific notation within TSL. This is done by combining the identifiers: - -```js -const original = vec3( 1.0, 2.0, 3.0 ); // (x, y, z) -const swizzled = original.zyx; // swizzled = (3.0, 2.0, 1.0) -``` - -It's possible use `xyzw`, `rgba` or `stpq`. - -## Operators - -| Name | Description | -| -- | -- | -| `.add( node \| value, ... )` | Return the addition of two or more value. | -| `.sub( node \| value )` | Return the subtraction of two or more value. | -| `.mul( node \| value )` | Return the multiplication of two or more value. | -| `.div( node \| value )` | Return the division of two or more value. | -| `.mod( node \| value )` | Computes the remainder of dividing the first node by the second. | -| `.equal( node \| value )` | Checks if two nodes are equal. | -| `.notEqual( node \| value )` | Checks if two nodes are not equal. | -| `.lessThan( node \| value )` | Checks if the first node is less than the second. | -| `.greaterThan( node \| value )` | Checks if the first node is greater than the second. | -| `.lessThanEqual( node \| value )` | Checks if the first node is less than or equal to the second. | -| `.greaterThanEqual( node \| value )` | Checks if the first node is greater than or equal to the second. | -| `.and( node \| value )` | Performs logical AND on two nodes. | -| `.or( node \| value )` | Performs logical OR on two nodes. | -| `.not( node \| value )` | Performs logical NOT on a node. | -| `.xor( node \| value )` | Performs logical XOR on two nodes. | -| `.bitAnd( node \| value )` | Performs bitwise AND on two nodes. | -| `.bitNot( node \| value )` | Performs bitwise NOT on a node. | -| `.bitOr( node \| value )` | Performs bitwise OR on two nodes. | -| `.bitXor( node \| value )` | Performs bitwise XOR on two nodes. | -| `.shiftLeft( node \| value )` | Shifts a node to the left. | -| `.shiftRight( node \| value )` | Shifts a node to the right. | -| | | -| `.assign( node \| value )` | Assign one or more value to a and return the same. | -| `.addAssign( node \| value )` | Adds a value and assigns the result. | -| `.subAssign( node \| value )` | Subtracts a value and assigns the result. | -| `.mulAssign( node \| value )` | Multiplies a value and assigns the result. | -| `.divAssign( node \| value )` | Divides a value and assigns the result. | - -```js -const a = float( 1 ); -const b = float( 2 ); - -const result = a.add( b ); // output: 3 -``` - -## Function - -### `Fn( function, layout = null )` - -It is possible to use classic JS functions or a `Fn()` interface. 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. - -Example: - -```js -// 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 -export const oscSine = ( t = time ) => t.add( 0.75 ).mul( Math.PI * 2 ).sin().mul( 0.5 ).add( 0.5 ); -``` -> Both above can be called with `oscSin( value )`. - -TSL allows the entry of parameters as object, this is useful in functions that have many optional arguments. - -Example: - -```js -const oscSine = Fn( ( { timer = time } ) => { - - return timer.add( 0.75 ).mul( Math.PI * 2 ).sin().mul( 0.5 ).add( 0.5 ); - -} ); - -const value = oscSine( { timer: value } ); -``` - -Parameters as object also allows traditional calls as an array, enabling different types of usage. - -```js -const col = Fn( ( { r, g, b } ) => { - - return vec3( r, g, b ); - -} ); - - -// Any of the options below will return a green color. - -material.colorNode = col( 0, 1, 0 ); // option 1 -material.colorNode = col( { r: 0, g: 1, b: 0 } ); // option 2 -``` - -If you want to use an export function compatible with `tree shaking`, remember to use `/*@__PURE__*/` - -```js -export const oscSawtooth = /*@__PURE__*/ Fn( ( [ timer = time ] ) => timer.fract() ); -``` - -The second parameter of the function, if there are any parameters, will always be the first if there are none, and is dedicated to `NodeBuilder`. In `NodeBuilder` you can find out details about the current construction process and also obtain objects related to the shader construction, such as `material`, `geometry`, `object`, `camera`, etc. - -[See an example](#deferred-function-high-level-of-customization-goodby-defines) - -## Variables - -Functions used to declare variables. - -| Name | Description | -| -- | -- | -| `.toVar( node, name = null )` or `Var( node, name = null )` | Converts a node into a reusable variable in the shader. | -| `.toConst( node, name = null )` or `Const( node, name = null )` | Converts a node into an inline constant. | -| `property( type, name = null )` | Declares an property but does not assign an initial value. | - -The name is optional; if set to `null`, the node system will generate one automatically. -Creating a variable, constant, or property can help optimize the shader graph manually or assist in debugging. - -```js -const uvScaled = uv().mul( 10 ).toVar(); - -material.colorNode = texture( map, uvScaled ); -``` - -*** - -## Array - -The array() function in TSL allows creating constant or dynamic value arrays; there are many ways to create arrays in TSL. - -#### The standard way - -```js -const colors = array( [ - vec3( 1, 0, 0 ), - vec3( 0, 1, 0 ), - vec3( 0, 0, 1 ) -] ); - -const greenColor = colors.element( 1 ); - -// greenColor: vec3( 0, 1, 0 ) -``` - -#### Fixed size - -```js -const a = array( 'vec3', 2 ); - -// a: [ vec3( 0, 0, 0 ), vec3( 0, 0, 0 ) ] -``` - -#### Fill with a default value - -```js -const a = vec3( 0, 0, 1 ).toArray( 2 ); - -// a: [ vec3( 0, 0, 1 ), vec3( 0, 0, 1 ) ] -``` - -#### Define a type explicitly - -```js -const a = array( [ 0, 1, 2 ], 'uint' ); -const value = a.element( 1 ); - -// value: 1u -``` - -### Array Uniform - -It is possible to use the same array logic for uniforms using Three.js native components or primitive values. - -```js -const tintColors = uniformArray( [ - new Color( 1, 0, 0 ), - new Color( 0, 1, 0 ), - new Color( 0, 0, 1 ) -], 'color' ); - -const redColor = tintColors.element( 0 ); -``` - -#### Accessing values - -To access the values you can use `a[ 1 ]` or `a.element( 1 )`. The difference is that `a[ 1 ]` only allows constant values, while `a.element( 1 )` allows the use of dynamic values such as `a.element( index )` where index is a node. - -### Array Storage - -It is possible to create arrays that can be used in compute shaders and storage operations. - -| Name | Description | -| -- | -- | -| `instancedArray( array, type )` | Creates an instanced buffer attribute array. | -| `attributeArray( array, type )` | Creates a buffer attribute array. | - -## Varying - -Functions used to declare varying. - -| Name | Description | -| -- | -- | -| `vertexStage( node )` | Computes the node in the vertex stage. | -| `varying( node, name = null )` | Computes the node in the vertex stage and passes interpolated values to the fragment shader. | -| `varyingProperty( type, name = null )` | Declares an varying property but does not assign an initial value. | - -Let's suppose you want to optimize some calculation in the `vertex stage` but are using it in a slot like `material.colorNode`. - -For example: - -```js -// multiplication will be executed in vertex stage -const normalView = vertexStage( modelNormalMatrix.mul( normalLocal ) ); - -// normalize will be computed in fragment stage while `normalView` is computed on vertex stage -material.colorNode = normalView.normalize(); -``` - -The first parameter of `vertexStage()` `modelNormalMatrix.mul( normalLocal )` will be computed in `vertex stage`, and the return from `vertexStage()` will be a `varying` as we are used in WGSL/GLSL, this can optimize extra calculations in the `fragment stage`. The second parameter of `varying()` allows you to add a custom name in code generation. - -If `varying()` is added only to `material.positionNode`, it will only return a simple variable and varying will not be created because `material.positionNode` is one of the only node material input that are computed at the vertex stage. - -## Conditional - -### If-else - -`If-else` conditionals can be used within `Fn()`. Conditionals in `TSL` are built using the `If` function: - -```js -If( conditional, function ) -.ElseIf( conditional, function ) -.Else( function ) -``` -> Notice here the `i` in `If` is capitalized. - -Example: - -In this example below, we will limit the y position of the geometry to 10. - -```js -const limitPosition = Fn( ( { position } ) => { - - const limit = 10; - - const result = vec3( position ); - - If( result.y.greaterThan( limit ), () => { - - result.y = limit; - - } ); - - return result; - -} ); - -material.positionNode = limitPosition( { position: positionLocal } ); -``` - -Example using `elseif`: - -```js -const limitPosition = Fn( ( { position } ) => { - - const limit = 10; - - const result = vec3( position ); - - If( result.y.greaterThan( limit ), () => { - - result.y = limit; - - } ).ElseIf( result.y.lessThan( limit ), () => { - - result.y = limit; - - } ); - - return result; - -} ); - -material.positionNode = limitPosition( { position: positionLocal } ); -``` -### Switch-Case - -A Switch-Case statement is an alternative way to express conditional logic compared to If-Else. - -```js -const col = color(); - -Switch( 0 ) - .Case( 0, () => { - - col.assign( color( 1, 0, 0 ) ); - - } ).Case( 1, () => { - - col.assign( color( 0, 1, 0 ) ); - - } ).Case( 2, 3, () => { - - col.assign( color( 0, 0, 1 ) ); - - } ).Default( () => { - - col.assign( color( 1, 1, 1 ) ); - - } ); -``` -Notice that there are some rules when using this syntax which differentiate TSL from JavaScript: - -- There is no fallthrough support. So each `Case()` statement has an implicit break. -- A `Case()` statement can hold multiple values (selectors) for testing. - -### Ternary - -Different from `if-else`, a ternary conditional will return a value and can be used outside of `Fn()`. - -```js -const result = select( value.greaterThan( 1 ), 1.0, value ); -``` -> Equivalent in JavaScript should be: `value > 1 ? 1.0 : value` - -## Loop - -This module offers a variety of ways to implement loops in TSL. In it's basic form it's: -```js -Loop( count, ( { i } ) => { - -} ); -``` -However, it is also possible to define a start and end ranges, data types and loop conditions: -```js -Loop( { start: int( 0 ), end: int( 10 ), type: 'int', condition: '<', name: 'i' }, ( { i } ) => { - -} ); -``` -Nested loops can be defined in a compacted form: -```js -Loop( 10, 5, ( { i, j } ) => { - -} ); -``` -Loops that should run backwards can be defined like so: -```js -Loop( { start: 10 }, () => {} ); -``` -It is possible to execute with boolean values, similar to the `while` syntax. -```js -const value = float( 0 ); - -Loop( value.lessThan( 10 ), () => { - - value.addAssign( 1 ); - -} ); -``` -The module also provides `Break()` and `Continue()` TSL expression for loop control. - -## Math - -| Name | Description | -| -- | -- | -| `EPSILON` | A small value used to handle floating-point precision errors. | -| `INFINITY` | Represent infinity. | -| `PI` | The mathematical constant π (pi). | -| `TWO_PI` | Two times π (2π). | -| `HALF_PI` | Half of π (π/2). | -| | | -| `abs( x )` | Return the absolute value of the parameter. | -| `acos( x )` | Return the arccosine of the parameter. | -| `all( x )` | Return true if all components of x are true. | -| `any( x )` | Return true if any component of x is true. | -| `asin( x )` | Return the arcsine of the parameter. | -| `atan( y, x )` | Return the arc-tangent of the parameters. | -| `bitcast( x, y )` | Reinterpret the bits of a value as a different type. | -| `cbrt( x )` | Return the cube root of the parameter. | -| `ceil( x )` | Find the nearest integer that is greater than or equal to the parameter. | -| `clamp( x, min, max )` | Constrain a value to lie between two further values. | -| `cos( x )` | Return the cosine of the parameter. | -| `cross( x, y )` | Calculate the cross product of two vectors. | -| `dFdx( p )` | Return the partial derivative of an argument with respect to x. | -| `dFdy( p )` | Return the partial derivative of an argument with respect to y. | -| `degrees( radians )` | Convert a quantity in radians to degrees. | -| `difference( x, y )` | Calculate the absolute difference between two values. | -| `distance( x, y )` | Calculate the distance between two points. | -| `dot( x, y )` | Calculate the dot product of two vectors. | -| `equals( x, y )` | Return true if x equals y. | -| `exp( x )` | Return the natural exponentiation of the parameter. | -| `exp2( x )` | Return 2 raised to the power of the parameter. | -| `faceforward( N, I, Nref )` | Return a vector pointing in the same direction as another. | -| `floor( x )` | Find the nearest integer less than or equal to the parameter. | -| `fract( x )` | Compute the fractional part of the argument. | -| `fwidth( x )` | Return the sum of the absolute derivatives in x and y. | -| `inverseSqrt( x )` | Return the inverse of the square root of the parameter. | -| `length( x )` | Calculate the length of a vector. | -| `lengthSq( x )` | Calculate the squared length of a vector. | -| `log( x )` | Return the natural logarithm of the parameter. | -| `log2( x )` | Return the base 2 logarithm of the parameter. | -| `max( x, y )` | Return the greater of two values. | -| `min( x, y )` | Return the lesser of two values. | -| `mix( x, y, a )` | Linearly interpolate between two values. | -| `negate( x )` | Negate the value of the parameter ( -x ). | -| `normalize( x )` | Calculate the unit vector in the same direction as the original vector. | -| `oneMinus( x )` | Return 1 minus the parameter. | -| `pow( x, y )` | Return the value of the first parameter raised to the power of the second. | -| `pow2( x )` | Return the square of the parameter. | -| `pow3( x )` | Return the cube of the parameter. | -| `pow4( x )` | Return the fourth power of the parameter. | -| `radians( degrees )` | Convert a quantity in degrees to radians. | -| `reciprocal( x )` | Return the reciprocal of the parameter (1/x). | -| `reflect( I, N )` | Calculate the reflection direction for an incident vector. | -| `refract( I, N, eta )` | Calculate the refraction direction for an incident vector. | -| `round( x )` | Round the parameter to the nearest integer. | -| `saturate( x )` | Constrain a value between 0 and 1. | -| `sign( x )` | Extract the sign of the parameter. | -| `sin( x )` | Return the sine of the parameter. | -| `smoothstep( e0, e1, x )` | Perform Hermite interpolation between two values. | -| `sqrt( x )` | Return the square root of the parameter. | -| `step( edge, x )` | Generate a step function by comparing two values. | -| `tan( x )` | Return the tangent of the parameter. | -| `transformDirection( dir, matrix )` | Transform the direction of a vector by a matrix and then normalize the result. | -| `transformNormalByViewMatrix( normal, viewMatrix )` | Transform a normal vector (given in world space) by the view matrix and normalize the result. | -| `transformNormalByInverseViewMatrix( normal, viewMatrix )` | Transform a normal vector (given in view space) by the inverse of the view matrix and normalize the result. | -| `trunc( x )` | Truncate the parameter, removing the fractional part. | - -```js -const value = float( -1 ); - -// It's possible use `value.abs()` too. -const positiveValue = abs( value ); // output: 1 -``` - -## Method chaining - -`Method chaining` will only be including operators, converters, math and some core functions. These functions, however, can be used on any `node`. - -Example: - -`oneMinus()` is a mathematical function like `abs()`, `sin()`. This example uses `.oneMinus()` as a built-in function in the class that returns a new node instead of classic C function like `oneMinus( texture( map ).rgb )`. - -```js -// it will invert the texture color -material.colorNode = texture( map ).rgb.oneMinus(); -``` - -You can use mathematical operators on any node, e.g: - -```js -const contrast = .5; -const brightness = .5; - -material.colorNode = texture( map ).mul( contrast ).add( brightness ); -``` - -## Texture - -| Name | Description | Type | -| -- | -- | -- | -| `texture( texture, uv = uv(), level = null )` | Retrieves texels from a texture. | `vec4` | -| `textureLoad( texture, uv, level = null )` | Fetches/loads texels without interpolation. | `vec4` | -| `textureStore( texture, uv, value )` | Stores a value into a storage texture. | `void` | -| `textureSize( texture, level = null )` | Returns the size of a texture. | `ivec2` | -| `textureBicubic( textureNode, strength = null )` | Applies mipped bicubic texture filtering. | `vec4` | -| `cubeTexture( texture, uvw = reflectVector, level = null )` | Retrieves texels from a cube texture. | `vec4` | -| `texture3D( texture, uvw = null, level = null )` | Retrieves texels from a 3D texture. | `vec4` | -| `triplanarTexture( textureX, textureY = null, textureZ = null, scale = float( 1 ), position = positionLocal, normal = normalLocal )` | Computes texture using triplanar mapping based on provided parameters. | `vec4` | - -## Attributes - -| Name | Description | Type | -| -- | -- | -- | -| `attribute( name, type = null )` | Getting geometry attribute using name and type. | `any` | -| `uv( index = 0 )` | UV attribute named `uv + index`. | `vec2` | -| `vertexColor( index = 0 )` | Vertex color node for the specified index. | `color` | -| `instanceIndex` | The index of the current instance. | `uint` | -| `vertexIndex` | The index of a vertex within a mesh. | `uint` | -| `drawIndex` | The draw index when using multi-draw. | `uint` | -| `batch( batchMesh )` | Creates a batch node for BatchedMesh. | `BatchNode` | -| `instance( instancedMesh )` | Creates an instance node for InstancedMesh. | `InstanceNode` | - -## Position - -The transformed term reflects the modifications applied by processes such as `skinning`, `morphing`, and similar techniques. - -| Name | Description | Type | -| -- | -- | -- | -| `positionGeometry` | Position attribute of geometry. | `vec3` | -| `positionLocal` | Transformed local position. | `vec3` | -| `positionWorld` | Transformed world position. | `vec3` | -| `positionWorldDirection` | Normalized world direction. | `vec3` | -| `positionView` | View position. | `vec3` | -| `positionViewDirection` | Normalized view direction. | `vec3` | - -## Normal - -The term transformed here also includes following the correct orientation of the face, so that the normals are inverted inside the geometry. - -| Name | Description | Type | -| -- | -- | -- | -| `normalGeometry` | Normal attribute of geometry. | `vec3` | -| `normalLocal` | Local variable for normal. | `vec3` | -| `normalView` | Normalized transformed view normal. | `vec3` | -| `normalViewGeometry` | Normalized view normal. | `vec3` | -| `normalWorld` | Normalized transformed world normal. | `vec3` | -| `normalWorldGeometry` | Normalized world normal. | `vec3` | - -## Tangent - -| Name | Description | Type | -| -- | -- | -- | -| `tangentGeometry` | Tangent attribute of geometry. | `vec4` | -| `tangentLocal` | Local variable for tangent. | `vec3` | -| `tangentView` | Normalized transformed view tangent. | `vec3` | -| `tangentWorld` | Normalized transformed world tangent. | `vec3` | - -### Bitangent - -| Name | Description | Type | -| -- | -- | -- | -| `bitangentGeometry` | Normalized bitangent in geometry space. | `vec3` | -| `bitangentLocal` | Normalized bitangent in local space. | `vec3` | -| `bitangentView` | Normalized transformed bitangent in view space. | `vec3` | -| `bitangentWorld` | Normalized transformed bitangent in world space. | `vec3` | - -## Camera - -| Name | Description | Type | -| -- | -- | -- | -| `cameraNear` | Near plane distance of the camera. | `float` | -| `cameraFar` | Far plane distance of the camera. | `float` | -| `cameraProjectionMatrix` | Projection matrix of the camera. | `mat4` | -| `cameraProjectionMatrixInverse` | Inverse projection matrix of the camera. | `mat4` | -| `cameraViewMatrix` | View matrix of the camera. | `mat4` | -| `cameraWorldMatrix` | World matrix of the camera. | `mat4` | -| `cameraNormalMatrix` | Normal matrix of the camera. | `mat3` | -| `cameraPosition` | World position of the camera. | `vec3` | - -## Model - -| Name | Description | Type | -| -- | -- | -- | -| `modelDirection` | Direction of the model. | `vec3` | -| `modelViewMatrix` | View-space matrix of the model. | `mat4` | -| `modelNormalMatrix` | View-space matrix of the model. | `mat3` | -| `modelWorldMatrix` | World-space matrix of the model. | `mat4` | -| `modelPosition` | Position of the model. | `vec3` | -| `modelScale` | Scale of the model. | `vec3` | -| `modelViewPosition` | View-space position of the model. | `vec3` | -| `modelWorldMatrixInverse` | Inverse world matrix of the model. | `mat4` | -| | | -| `highpModelViewMatrix` | View-space matrix of the model computed on CPU using 64-bit. | `mat4` | -| `highpModelNormalViewMatrix` | View-space normal matrix of the model computed on CPU using 64-bit. | `mat3` | - -## Screen - -Screen nodes will return the values related to the current `frame buffer`, either normalized or in `physical pixel units` considering the current `Pixel Ratio`. - -| Variable | Description | Type | -| -- | -- | -- | -| `screenUV` | Returns the normalized frame buffer coordinate. | `vec2` | -| `screenCoordinate` | Returns the frame buffer coordinate in physical pixel units. | `vec2` | -| `screenSize` | Returns the frame buffer size in physical pixel units. | `vec2` | -| `screenDPR` | Returns the device pixel ratio (DPR). | `float` | - -## Viewport - -`viewport` is influenced by the area defined in `renderer.setViewport()`, different of the values ​​defined in the renderer that are `logical pixel units`, it use `physical pixel units` considering the current `Pixel Ratio`. - -| Variable | Description | Type | -| -- | -- | -- | -| `viewportUV` | Returns the normalized viewport coordinate. | `vec2` | -| `viewport` | Returns the viewport dimension in physical pixel units. | `vec4` | -| `viewportCoordinate` | Returns the viewport coordinate in physical pixel units. | `vec2` | -| `viewportSize` | Returns the viewport size in physical pixel units. | `vec2` | -| `viewportSharedTexture( uvNode = screenUV, levelNode = null )` | Accesses what has already been rendered, preserving render-order. | `vec4` | -| `viewportDepthTexture( uvNode = screenUV, levelNode = null )` | Returns the depth texture of the viewport. | `float` | -| `viewportLinearDepth` | Returns the linear (orthographic) depth value of the current fragment. | `float` | -| `viewportMipTexture( uvNode = screenUV, levelNode = null, framebufferTexture = null )` | Returns a viewport texture with mipmap generation enabled. | `vec4` | -| `viewportSafeUV( uv = screenUV )` | Returns safe UV coordinates for refraction purposes. | `vec2` | - -## Blend Modes - -| Variable | Description | Type | -| -- | -- | -- | -| `blendBurn( a, b )` | Returns the burn blend mode. | `color` | -| `blendDodge( a, b )` | Returns the dodge blend mode. | `color` | -| `blendOverlay( a, b )` | Returns the overlay blend mode. | `color` | -| `blendScreen( a, b )` | Returns the screen blend mode. | `color` | -| `blendColor( a, b )` | Returns the (normal) color blend mode. | `color` | - -## Reflect - -| Name | Description | Type | -| -- | -- | -- | -| `reflectView` | Computes reflection direction in view space. | `vec3` | -| `reflectVector` | Transforms the reflection direction to world space. | `vec3` | - -## UV Utils - -| Name | Description | Type | -| -- | -- | -- | -| `matcapUV` | UV coordinates for matcap texture. | `vec2` | -| `rotateUV( uv, rotation, centerNode = vec2( 0.5 ) )` | Rotates UV coordinates around a center point. | `vec2` | -| `spherizeUV( uv, strength, centerNode = vec2( 0.5 ) )` | Distorts UV coordinates with a spherical effect around a center point. | `vec2` | -| `spritesheetUV( count, uv = uv(), frame = float( 0 ) )` | Computes UV coordinates for a sprite sheet based on the number of frames, UV coordinates, and frame index. | `vec2` | -| `equirectUV( direction = positionWorldDirection )` | Computes UV coordinates for equirectangular mapping based on the direction vector. | `vec2` | -| `equirectDirection( uv = uv() )` | Computes a direction vector from the given equirectangular UV coordinates (inverse of `equirectUV`). | `vec3` | - -```js -import { texture, matcapUV } from 'three/tsl'; - -const matcap = texture( matcapMap, matcapUV ); -``` - -## Interpolation - -| Variable | Description | Type | -| -- | -- | -- | -| `remap( node, inLow, inHigh, outLow = float( 0 ), outHigh = float( 1 ) )` | Remaps a value from one range to another. | `any` | -| `remapClamp( node, inLow, inHigh, outLow = float( 0 ), outHigh = float( 1 ) )` | Remaps a value from one range to another, with clamping. | `any` | - -## Random - -| Variable | Description | Type | -| -- | -- | -- | -| `hash( seed )` | Generates a hash value in the range [ 0, 1 ] from the given seed. | `float` | -| `range( min, max )` | Generates a range `attribute` of values between min and max. Attribute randomization is useful when you want to randomize values ​​between instances and not between pixels. | `any` | - -## Rotate - -| Name | Description | Type | -| -- | -- | -- | -| `rotate( position, rotation )` | Applies a rotation to the given position node. Depending on whether the position data are 2D or 3D, the rotation is expressed a single float value or an Euler value. | `vec2`, `vec3` - -## Oscillator - -| Variable | Description | Type | -| -- | -- | -- | -| `oscSine( timer = time )` | Generates a sine wave oscillation based on a timer. | `float` | -| `oscSquare( timer = time )` | Generates a square wave oscillation based on a timer. | `float` | -| `oscTriangle( timer = time )` | Generates a triangle wave oscillation based on a timer. | `float` | -| `oscSawtooth( timer = time )` | Generates a sawtooth wave oscillation based on a timer. | `float` | - -## Timer - -| Variable | Description | Type | -| -- | -- | -- | -| `time` | Represents the elapsed time in seconds. | `float` | -| `deltaTime` | Represents the delta time in seconds. | `float` | - -## Packing - -| Variable | Description | Type | -| -- | -- | -- | -| `packNormalToRGB( value )` | Converts normal vector to color. | `color` | -| `unpackRGBToNormal( value )` | Converts color to normal vector. | `vec3` | - -## Render Pipeline - -The `RenderPipeline` provides full control over the rendering process. It enables developers to build complex multi-pass rendering pipelines entirely in JavaScript, combining scene rendering, post-processing, and compute operations in a unified, composable workflow. - -#### Basic Usage - -```js -import * as THREE from 'three/webgpu'; -import { pass } from 'three/tsl'; - -// Create the render pipeline -const renderPipeline = new THREE.RenderPipeline( renderer ); - -// Create a scene pass -const scenePass = pass( scene, camera ); - -// Set the output -renderPipeline.outputNode = scenePass; - -// In the animation loop -function animate() { - - renderPipeline.render(); - -} -``` - -### Multiple Render Targets (MRT) - -MRT allows capturing multiple outputs from a single render pass. Instead of rendering the scene multiple times to get different data (color, normals, depth, velocity), MRT captures all of them in one draw call—significantly improving performance. - -#### Setting up MRT - -Use `setMRT()` with the `mrt()` function to define which outputs to capture: - -```js -import { pass, mrt, output, normalView, velocity, packNormalToRGB } from 'three/tsl'; - -const scenePass = pass( scene, camera ); - -scenePass.setMRT( mrt( { - output: output, // Final color output - normal: packNormalToRGB( normalView ), // View-space normals encoded as colors - velocity: velocity // Motion vectors for temporal effects -} ) ); -``` - -Each MRT entry accepts any TSL node, allowing you to customize outputs using formulas, encoders, or material accessors. For example, `packNormalToRGB( normalView )` encodes view-space normals into RGB values. You can use any TSL function to transform, combine, or encode data before writing to the render target. - -Within a TSL function `Fn( ( { material, object } ) => { ... } )`, you have complete access to the current material and object being rendered, enabling full customization of outputs. - -#### Accessing MRT Buffers - -Each MRT output becomes available as a texture node via `getTextureNode()`: - -```js -// Access individual buffers as texture nodes -const colorTexture = scenePass.getTextureNode( 'output' ); -const normalTexture = scenePass.getTextureNode( 'normal' ); -const velocityTexture = scenePass.getTextureNode( 'velocity' ); - -// Depth is always available, even without MRT -const depthTexture = scenePass.getTextureNode( 'depth' ); -``` - -These texture nodes can be sampled, transformed, and passed to post-processing effects or other passes. - -#### Optimizing MRT Textures - -You can access the textures to optimize memory usage and bandwidth. Using smaller data types reduces GPU memory transfers, which is critical for performance on bandwidth-limited devices: - -```js -// Use 8-bit format for encoded normals, default is 16-bit -const normalTexture = scenePass.getTexture( 'normal' ); -normalTexture.type = THREE.UnsignedByteType; -``` - -#### Dynamic Pipeline Updates - -The pipeline can be updated at runtime: - -```js -if ( showNormals ) { - - renderPipeline.outputNode = prePass; - -} else { - - renderPipeline.outputNode = traaPass; - -} - -renderPipeline.needsUpdate = true; -``` - -### Post-Processing - -TSL utilities for post-processing effects. They can be used in materials or post-processing passes. - -| Name | Description | -| -- | -- | -| `afterImage( node, damp = 0.96 )` | Creates an after image effect. | -| `anamorphic( node, threshold = 0.9, scale = 3, samples = 32 )` | Creates an anamorphic flare effect. | -| `bloom( node, strength = 1, radius = 0, threshold = 0 )` | Creates a bloom effect. | -| `boxBlur( textureNode, options = {} )` | Applies a box blur effect. | -| `chromaticAberration( node, strength = 1.0, center = null, scale = 1.1 )` | Creates a chromatic aberration effect. | -| `denoise( node, depthNode, normalNode, camera )` | Creates a denoise effect. | -| `dof( node, viewZNode, focusDistance, focalLength, bokehScale )` | Creates a depth-of-field effect. | -| `dotScreen( node, angle = 1.57, scale = 1 )` | Creates a dot-screen effect. | -| `film( inputNode, intensityNode = null, uvNode = null )` | Creates a film grain effect. | -| `fxaa( node )` | Creates a FXAA anti-aliasing effect. | -| `gaussianBlur( node, directionNode, sigma, options = {} )` | Creates a gaussian blur effect. | -| `grayscale( color )` | Converts color to grayscale. | -| `hashBlur( textureNode, bluramount = float( 0.1 ), options = {} )` | Applies a hash blur effect. | -| `lut3D( node, lut, size, intensity )` | Creates a LUT color grading effect. | -| `motionBlur( inputNode, velocity, numSamples = int( 16 ) )` | Creates a motion blur effect. | -| `outline( scene, camera, params )` | Creates an outline effect around selected objects. | -| `rgbShift( node, amount = 0.005, angle = 0 )` | Creates an RGB shift effect. | -| `sepia( color )` | Applies a sepia effect. | -| `smaa( node )` | Creates a SMAA anti-aliasing effect. | -| `sobel( node )` | Creates a sobel edge detection effect. | -| `ssr( colorNode, depthNode, normalNode, metalnessNode, roughnessNode = null, camera = null )` | Creates screen space reflections. | -| `ssgi( beautyNode, depthNode, normalNode, camera )` | Creates a SSGI effect. | -| `ao( depthNode, normalNode, camera )` | Creates a Ground Truth Ambient Occlusion (GTAO) effect. | -| `transition( nodeA, nodeB, mixTextureNode, mixRatio, threshold, useTexture )` | Creates a transition effect between two scenes. | -| `traa( beautyNode, depthNode, velocityNode, camera )` | Creates a TRAA temporal anti-aliasing effect. | -| `renderOutput( node, targetColorSpace, targetToneMapping )` | Apply the renderer output settings in the node. | - -Example: - -```js -import { grayscale, pass } from 'three/tsl'; -import { gaussianBlur } from 'three/addons/tsl/display/GaussianBlurNode.js'; - -// Post-processing -const scenePass = pass( scene, camera ); -const output = scenePass.getTextureNode(); // default parameter is 'output' - -renderPipeline.outputNode = grayscale( gaussianBlur( output, 4 ) ); -``` - -### Render Pass - -Functions for creating and managing render passes. - -| Name | Description | -| -- | -- | -| `pass( scene, camera, options = {} )` | Creates a pass node for rendering a scene. | -| `mrt( outputNodes )` | Creates a Multiple Render Targets (MRT) node. | - -Example: - -```js -import { pass, mrt, output, emissive } from 'three/tsl'; - -const scenePass = pass( scene, camera ); - -// Setup MRT -scenePass.setMRT( mrt( { - output: output, - emissive: emissive -} ) ); - -const outputNode = scenePass.getTextureNode( 'output' ); -const emissiveNode = scenePass.getTextureNode( 'emissive' ); -``` - -### Compute - -Compute shaders allow general-purpose GPU computations. TSL provides functions for creating and managing compute operations. - -| Name | Description | -| -- | -- | -| `compute( node, count = null, workgroupSize = [ 64 ] )` | Creates a compute node. | -| `atomicAdd( node, value )` | Performs an atomic addition. | -| `atomicSub( node, value )` | Performs an atomic subtraction. | -| `atomicMax( node, value )` | Performs an atomic max operation. | -| `atomicMin( node, value )` | Performs an atomic min operation. | -| `atomicAnd( node, value )` | Performs an atomic AND operation. | -| `atomicOr( node, value )` | Performs an atomic OR operation. | -| `atomicXor( node, value )` | Performs an atomic XOR operation. | -| `atomicStore( node, value )` | Stores a value atomically. | -| `atomicLoad( node )` | Loads a value atomically. | -| `workgroupBarrier()` | Creates a workgroup barrier. | -| `storageBarrier()` | Creates a storage barrier. | -| `textureBarrier()` | Creates a texture barrier. | -| `barrier()` | Creates a memory barrier. | -| `workgroupId` | The workgroup ID. | -| `localId` | The local invocation ID within the workgroup. | -| `globalId` | The global invocation ID. | -| `numWorkgroups` | The number of workgroups. | -| `subgroupSize` | The size of the subgroup. | - -Example: - -```js -import { Fn, instancedArray, instanceIndex, deltaTime } from 'three/tsl'; - -const count = 1000; -const positionArray = instancedArray( count, 'vec3' ); - -// create a compute function - -const computeShader = Fn( () => { - - const position = positionArray.element( instanceIndex ); - - position.x.addAssign( deltaTime ); - -} )().compute( count ); - -// - -renderer.compute( computeShader ); -``` - -## Storage - -Storage functions allow reading and writing to GPU buffers. - -| Name | Description | -| -- | -- | -| `storage( attribute, type, count )` | Creates a storage buffer. | -| `storageTexture( texture )` | Creates a storage texture for read/write operations. | - -## Struct - -Structs allow you to create custom data types with multiple members. They can be used to organize related data in shaders, define structures for attributes and uniforms. - -| Name | Description | -| -- | -- | -| `struct( membersLayout, name = null )` | Creates a struct type with the specified member layout. | -| `outputStruct( ...members )` | Creates an output struct node for returning multiple values. | - -Example: - -```js -import { struct, vec3 } from 'three/tsl'; - -// Define a custom struct -const BoundingBox = struct( { min: 'vec3', max: 'vec3' } ); - -// Create a new instance of the struct -const bb = BoundingBox( vec3( 0 ), vec3( 1 ) ); // style 1 -const bb2 = BoundingBox( { min: vec3( 0 ), max: vec3( 1 ) } ); // style 2 - -// Access the struct members -const min = bb.get( 'min' ); - -// Assign a new value to a member -min.assign( vec3( -1, -1, -1 ) ); -``` - -## Flow Control - -Functions for controlling shader flow. - -| Name | Description | -| -- | -- | -| `Discard()` | Discards the current fragment. | -| `Return()` | Returns from the current function. | -| `Break()` | Breaks out of a loop. | -| `Continue()` | Continues to the next iteration of a loop. | - -Example: - -```js -import { Fn, If, Discard, uv } from 'three/tsl'; - -const customFragment = Fn( () => { - - If( uv().x.lessThan( 0.5 ), () => { - - Discard(); - - } ); - - return vec4( 1, 0, 0, 1 ); - -} ); - -material.colorNode = customFragment(); -``` - -## Override Node - -Override nodes allow you to replace specific target nodes within a node sub-graph or flow dynamically during compilation, without having to reconstruct or duplicate the source nodes. This is useful, for example, to inject a custom `positionLocal` or normal into an existing flow through the material's `contextNode`. - -| Name | Description | -| -- | -- | -| `overrideNode( targetNode, callback = null, flowNode = null )` | Overrides a single target node. `callback` returns the overriding node (receiving the builder as argument) or can be the overriding node itself. | -| `overrideNodes( overrides, flowNode = null )` | Overrides multiple target nodes at once using a `Map` or an array of `[ targetNode, callback \| node ]` pairs. | - -Example: - -```js -import { overrideNode, overrideNodes, positionLocal, positionView, vec3 } from 'three/tsl'; - -// Override a single node through the material context -material.contextNode = overrideNode( positionLocal, () => positionLocal.add( vec3( 1, 0, 0 ) ) ); - -// Override multiple nodes at once -material.contextNode = overrideNodes( [ - [ positionView, customPositionView ], // You can use a node directly like customPositionView. - [ positionLocal, ( builder ) => positionLocal.add( vec3( 1, 0, 0 ) ) ] -] ); - -// Method chaining is also supported -node.overrideNode( positionLocal, () => positionLocal.add( vec3( 1, 0, 0 ) ) ); -``` - -## Fog - -Functions for creating fog effects in the scene. Assign the fog node to `scene.fogNode`. - -| Name | Description | Type | -| -- | -- | -- | -| `fog( color, factor )` | Creates a fog node with specified color and fog factor. | `FogNode` | -| `rangeFogFactor( near, far )` | Creates a linear fog factor based on distance from camera. | `float` | -| `densityFogFactor( density )` | Creates an exponential squared fog factor for denser fog. | `float` | - -Example: - -```js -import { fog, rangeFogFactor, densityFogFactor, color } from 'three/tsl'; - -// Linear fog (starts at 10 units, fully opaque at 100 units) -scene.fogNode = fog( color( 0x000000 ), rangeFogFactor( 10, 100 ) ); - -// Exponential fog (density-based) -scene.fogNode = fog( color( 0xcccccc ), densityFogFactor( 0.02 ) ); -``` - -## Color Adjustments - -Functions for adjusting and manipulating colors. - -| Name | Description | Type | -| -- | -- | -- | -| `luminance( node )` | Calculates the luminance (perceived brightness) of a color. | `float` | -| `saturation( node, adjustment = 1 )` | Adjusts the saturation of a color. Values > 1 increase saturation, < 1 decrease. | `color` | -| `vibrance( node, adjustment = 1 )` | Selectively enhances less saturated colors while preserving already saturated ones. | `color` | -| `hue( node, adjustment = 0 )` | Rotates the hue of a color. Value is in radians. | `color` | -| `posterize( node, steps )` | Reduces the number of color levels, creating a poster-like effect. | `color` | - -Example: - -```js -import { texture, saturation, hue, posterize } from 'three/tsl'; - -// Increase saturation -material.colorNode = saturation( texture( map ), 1.5 ); - -// Rotate hue by 90 degrees -material.colorNode = hue( texture( map ), Math.PI / 2 ); - -// Posterize to 4 color levels -material.colorNode = posterize( texture( map ), 4 ); -``` - -## Utilities - -Utility functions for common shader tasks. - -| Name | Description | Type | -| -- | -- | -- | -| `billboarding( { position, horizontal, vertical } )` | Orients flat meshes always towards the camera. `position`: vertex positions in world space (default: `null`). `horizontal`: follow camera horizontally (default: `true`). `vertical`: follow camera vertically (default: `false`). | `vec3` | -| `checker( coord )` | Creates a 2x2 checkerboard pattern. | `float` | -| `negateOnBackSide( vector )` | Negates a vector when rendering the back side of a face, according to the material's `side` configuration (`BackSide`, `DoubleSide` or `FrontSide`). | `vec3` | - -Example: - -```js -import { billboarding } from 'three/tsl'; - -// Default: Horizontal only (like trees) - rotates around Y axis only -material.vertexNode = billboarding(); - -// Full billboarding (like particles) - faces camera in all directions -material.vertexNode = billboarding( { horizontal: true, vertical: true } ); -``` - -## NodeMaterial - -Check below for more details about `NodeMaterial` inputs. - -#### Core - -| Name | Description | Type | -|--|--|--| -| `.fragmentNode` | Replaces the built-in material logic used in the fragment stage. | `vec4` | -| `.vertexNode` | Replaces the built-in material logic used in the vertex stage. | `vec4` | -| `.geometryNode` | Allows you to execute a TSL function to deal with Geometry. | `Fn()` | - -#### Basic - -| Name | Description | Reference | Type | -|--|--|--|--| -| `.colorNode` | Replace the logic of `material.color * material.map`. | `materialColor` | `vec4` | -| `.depthNode` | Customize the `depth` output. | `depth` | `float` | -| `.opacityNode` | Replace the logic of `material.opacity * material.alphaMap`. | `materialOpacity` | `float` | -| `.alphaTestNode` | Sets a threshold to discard pixels with low opacity. | `materialAlphaTest` | `float` | -| `.positionNode` | Represents the vertex positions in local-space. Replace the logic of `material.displacementMap * material.displacementScale + material.displacementBias`. | `positionLocal` | `vec3` | - -#### Lighting - -| Name | Description | Reference | Type | -|--|--|--|--| -| `.emissiveNode` | Replace the logic of `material.emissive * material.emissiveIntensity * material.emissiveMap`. | `materialEmissive` | `color` | -| `.normalNode` | Represents the normals direction in view-space. Replace the logic of `material.normalMap * material.normalScale` and `material.bumpMap * material.bumpScale`. | `materialNormal` | `vec3` | -| `.lightsNode` | Defines the lights and lighting model that will be used by the material. | | `lights()` | -| `.envNode` | Replace the logic of `material.envMap * material.envMapRotation * material.envMapIntensity`. | | `color` | - -#### Backdrop - -| Name | Description | Type | -|--|--|--| -| `.backdropNode` | Set the current render color to be used before applying `Specular`, useful for `transmission` and `refraction` effects. | `color` | -| `.backdropAlphaNode` | Define the alpha of `backdropNode`. | `float` | - -#### Shadows - -| Name | Description | Reference | Type | -|--|--|--|--| -| `.castShadowNode` | Control the `color` and `opacity` of the shadow that will be projected by the material. | | `vec4` | -| `.maskShadowNode` | Define a custom mask for the shadow. | | `bool` | -| `.receivedShadowNode` | Handle the shadow cast on the material. | | `Fn()` | -| `.receivedShadowPositionNode` | Define the shadow projection position in world-space. | `shadowPositionWorld` | `vec3` | -| `.aoNode` | Replace the logic of `material.aoMap * aoMapIntensity`. | `materialAO` | `float` | - -#### Output - -| Name | Description | Reference | Type | -|--|--|--|--| -| `.maskNode` | Define the material's mask. Unlike opacity, it is discarded at the beginning of rendering, optimizing the process. | | `bool` | -| `.mrtNode` | Define a different MRT than the one defined in `pass()`. | | `mrt()` | -| `.outputNode` | Defines the material's final output. | `output` | `vec4` | - -## LineDashedNodeMaterial - -| Name | Description | Reference | Type | -|--|--|--|--| -| `.dashScaleNode` | Replace the logic of `material.scale`. | `materialLineScale` | `float` | -| `.dashSizeNode` | Replace the logic of `material.dashSize`. | `materialLineDashSize` | `float` | -| `.gapSizeNode` | Replace the logic of `material.gapSize`. | `materialLineGapSize` | `float` | -| `.offsetNode` | Replace the logic of `material.dashOffset`. | `materialLineDashOffset` | `float` | - -## MeshPhongNodeMaterial - -| Name | Description | Reference | Type | -|--|--|--|--| -| `.shininessNode` | Replace the logic of `material.shininess`. | `materialShininess` | `float` | -| `.specularNode` | Replace the logic of `material.specular`. | `materialSpecular` | `color` | - -## MeshStandardNodeMaterial - -| Name | Description | Reference | Type | -|--|--|--|--| -| `.metalnessNode` | Replace the logic of `material.metalness * material.metalnessMap`. | `materialMetalness` | `float` | -| `.roughnessNode` | Replace the logic of `material.roughness * material.roughnessMap`. | `materialRoughness` | `float` | - -## MeshPhysicalNodeMaterial - -| Name | Description | Reference | Type | -|--|--|--|--| -| `.clearcoatNode` | Replace the logic of `material.clearcoat * material.clearcoatMap`. | `materialClearcoat` | `float` | -| `.clearcoatRoughnessNode` | Replace the logic of `material.clearcoatRoughness * material.clearcoatRoughnessMap`. | `materialClearcoatRoughness` | `float` | -| `.clearcoatNormalNode` | Replace the logic of `material.clearcoatNormalMap * material.clearcoatNormalMapScale`. | `materialClearcoatNormal` | `vec3` | -| `.sheenNode` | Replace the logic of `material.sheenColor * material.sheenColorMap`. | `materialSheen` | `color` | -| `.iridescenceNode` | Replace the logic of `material.iridescence`. | `materialIridescence` | `float` | -| `.iridescenceIORNode` | Replace the logic of `material.iridescenceIOR`. | `materialIridescenceIOR` | `float` | -| `.iridescenceThicknessNode` | Replace the logic of `material.iridescenceThicknessRange * material.iridescenceThicknessMap`. | `materialIridescenceThickness` | `float` | -| `.specularIntensityNode` | Replace the logic of `material.specularIntensity * material.specularIntensityMap`. | `materialSpecularIntensity` | `float` | -| `.specularColorNode` | Replace the logic of `material.specularColor * material.specularColorMap`. | `materialSpecularColor` | `color` | -| `.iorNode` | Replace the logic of `material.ior`. | `materialIOR` | `float` | -| `.transmissionNode` | Replace the logic of `material.transmission * material.transmissionMap`. | `materialTransmission` | `color` | -| `.thicknessNode` | Replace the logic of `material.thickness * material.thicknessMap`. | `materialTransmission` | `float` | -| `.attenuationDistanceNode` | Replace the logic of `material.attenuationDistance`. | `materialAttenuationDistance` | `float` | -| `.attenuationColorNode` | Replace the logic of `material.attenuationColor`. | `materialAttenuationColor` | `color` | -| `.dispersionNode` | Replace the logic of `material.dispersion`. | `materialDispersion` | `float` | -| `.anisotropyNode` | Replace the logic of `material.anisotropy * material.anisotropyMap`. | `materialAnisotropy` | `vec2` | - -## SpriteNodeMaterial - -| Name | Description | Type | -|--|--|--| -| `.positionNode` | Defines the position. | `vec3` | -| `.rotationNode` | Defines the rotation. | `float` | -| `.scaleNode` | Defines the scale. | `vec2` | - -## Transitioning common GLSL properties to TSL - -| GLSL | TSL | Type | -| -- | -- | -- | -| `position` | `positionGeometry` | `vec3` | -| `transformed` | `positionLocal` | `vec3` | -| `transformedNormal` | `normalLocal` | `vec3` | -| `vWorldPosition` | `positionWorld` | `vec3` | -| `vColor` | `vertexColor()` | `vec3` | -| `vUv` \| `uv` | `uv()` | `vec2` | -| `vNormal` | `normalView` | `vec3` | -| `viewMatrix` | `cameraViewMatrix` | `mat4` | -| `modelMatrix` | `modelWorldMatrix` | `mat4` | -| `modelViewMatrix` | `modelViewMatrix` | `mat4` | -| `projectionMatrix` | `cameraProjectionMatrix` | `mat4` | -| `diffuseColor` | `material.colorNode` | `vec4` | -| `gl_FragColor` | `material.fragmentNode` | `vec4` | diff --git a/examples/textures/equirectangular/ferndale_studio_04_1k.hdr b/examples/textures/equirectangular/ferndale_studio_04_1k.hdr new file mode 100644 index 00000000000000..eb14c9c1a4f8f8 Binary files /dev/null and b/examples/textures/equirectangular/ferndale_studio_04_1k.hdr differ diff --git a/src/nodes/gpgpu/AtomicFunctionNode.js b/src/nodes/gpgpu/AtomicFunctionNode.js index bb17b2b0ab641d..b84ac663ddf90c 100644 --- a/src/nodes/gpgpu/AtomicFunctionNode.js +++ b/src/nodes/gpgpu/AtomicFunctionNode.js @@ -1,6 +1,7 @@ import Node from '../core/Node.js'; import { expression } from '../code/ExpressionNode.js'; import { nodeProxy } from '../tsl/TSLCore.js'; +import { error } from '../../utils.js'; /** * `AtomicFunctionNode` represents any function that can operate on atomic variable types @@ -90,11 +91,17 @@ class AtomicFunctionNode extends Node { generate( builder ) { + const method = this.method; + + if ( builder.shaderStage === 'vertex' ) { + + error( `TSL: "${this.method}" is not supported in the vertex stage.` ); + + } + const properties = builder.getNodeProperties( this ); const parents = properties.parents; - const method = this.method; - const type = this.getNodeType( builder ); const inputType = this.getInputType( builder ); diff --git a/src/nodes/gpgpu/BarrierNode.js b/src/nodes/gpgpu/BarrierNode.js index 028dbbd2205c4e..08676e478f39ad 100644 --- a/src/nodes/gpgpu/BarrierNode.js +++ b/src/nodes/gpgpu/BarrierNode.js @@ -1,3 +1,4 @@ +import { error } from '../../utils.js'; import Node from '../core/Node.js'; import { nodeProxy } from '../tsl/TSLCore.js'; @@ -35,15 +36,23 @@ class BarrierNode extends Node { generate( builder ) { const { scope } = this; - const { renderer } = builder; + const { renderer, shaderStage } = builder; + + const barrierMethod = `${scope}Barrier`; + + if ( shaderStage !== 'compute' ) { + + error( `TSL: "${barrierMethod}" is not supported in the ${shaderStage} stage and can only be executed in compute.` ); + + } if ( renderer.backend.isWebGLBackend === true ) { - builder.addFlowCode( `\t// ${scope}Barrier \n` ); + builder.addFlowCode( `\t// ${barrierMethod}() \n` ); } else { - builder.addLineFlowCode( `${scope}Barrier()`, this ); + builder.addLineFlowCode( `${barrierMethod}()`, this ); } diff --git a/src/nodes/gpgpu/ComputeBuiltinNode.js b/src/nodes/gpgpu/ComputeBuiltinNode.js index 69ec6bc2511573..d76d04538ef180 100644 --- a/src/nodes/gpgpu/ComputeBuiltinNode.js +++ b/src/nodes/gpgpu/ComputeBuiltinNode.js @@ -110,7 +110,7 @@ class ComputeBuiltinNode extends Node { } else { - warn( `ComputeBuiltinNode: Compute built-in value ${builtinName} can not be accessed in the ${builder.shaderStage} stage` ); + warn( `TSL: Compute built-in value "${builtinName}" can not be accessed in the ${builder.shaderStage} stage` ); return builder.generateConst( nodeType ); } diff --git a/src/nodes/gpgpu/SubgroupFunctionNode.js b/src/nodes/gpgpu/SubgroupFunctionNode.js index 2420045a1ceb8a..b8684023f1a6f0 100644 --- a/src/nodes/gpgpu/SubgroupFunctionNode.js +++ b/src/nodes/gpgpu/SubgroupFunctionNode.js @@ -1,3 +1,4 @@ +import { error } from '../../utils.js'; import TempNode from '../core/TempNode.js'; import { nodeProxyIntent } from '../tsl/TSLCore.js'; @@ -99,6 +100,12 @@ class SubgroupFunctionNode extends TempNode { const method = this.method; + if ( builder.shaderStage === 'vertex' ) { + + error( `TSL: "${this.method}" is not supported in the vertex shader stage.` ); + + } + const type = this.getNodeType( builder ); const inputType = this.getInputType( builder ); diff --git a/src/nodes/gpgpu/WorkgroupInfoNode.js b/src/nodes/gpgpu/WorkgroupInfoNode.js index b7a75e9c6cacf5..6a671095014d82 100644 --- a/src/nodes/gpgpu/WorkgroupInfoNode.js +++ b/src/nodes/gpgpu/WorkgroupInfoNode.js @@ -1,6 +1,6 @@ import ArrayElementNode from '../utils/ArrayElementNode.js'; import Node from '../core/Node.js'; -import { warn } from '../../utils.js'; +import { error, warn } from '../../utils.js'; import StackTrace from '../core/StackTrace.js'; /** @@ -209,6 +209,12 @@ class WorkgroupInfoNode extends Node { generate( builder ) { + if ( builder.shaderStage !== 'compute' ) { + + error( 'TSL: "workgroupArray()" can only be executed within the compute shader stage' ); + + } + const name = ( this.name !== '' ) ? this.name : `${this.scope}Array_${this.id}`; return builder.getScopedArray( name, this.scope.toLowerCase(), this.bufferType, this.bufferCount ); diff --git a/src/nodes/math/MathNode.js b/src/nodes/math/MathNode.js index bdba93fb297f3a..65a349789edb75 100644 --- a/src/nodes/math/MathNode.js +++ b/src/nodes/math/MathNode.js @@ -2,7 +2,7 @@ import TempNode from '../core/TempNode.js'; import { sub, mul, div, mod } from './OperatorNode.js'; import { addMethodChaining, nodeObject, nodeProxyIntent, float, vec2, vec3, vec4, Fn } from '../tsl/TSLCore.js'; import { WebGLCoordinateSystem, WebGPUCoordinateSystem } from '../../constants.js'; -import { warn } from '../../utils.js'; +import { error } from '../../utils.js'; /** * This node represents a variety of mathematical methods available in shaders. @@ -289,7 +289,7 @@ class MathNode extends TempNode { if ( builder.shaderStage !== 'fragment' && ( method === MathNode.DFDX || method === MathNode.DFDY ) ) { - warn( `TSL: '${ method }' is not supported in the ${ builder.shaderStage } stage.`, this.stackTrace ); + error( `TSL: '${ method }' is not supported in the ${ builder.shaderStage } stage.`, this.stackTrace ); method = '/*' + method + '*/'; diff --git a/tsl/content/Tour.md b/tsl/content/Tour.md new file mode 100644 index 00000000000000..a464b09bf5b454 --- /dev/null +++ b/tsl/content/Tour.md @@ -0,0 +1,7294 @@ + + + + +An Approach to Productive and Maintainable Shader Creation. + +TSL (Three.js Shading Language) is the new shader standard for Three.js, built to support the rendering capabilities introduced by WebGPU. It allows shader logic to be written in JavaScript and structured through a flexible node-based system, enabling advanced rendering workflows, compute operations, improved GPU integration, and compatibility across different graphics backends. + +- **Node-System Power**: Built entirely on top of Three.js's Node system, TSL creates a dynamic graph of operations. Going beyond a standard GPU program, nodes have direct control over the renderer itself, enabling CPU-side setup, dynamic render target allocations, and custom pipeline orchestration directly from the shading graph. + +- **Improved Productivity**: Write modular, reusable shader functions, import them like regular JS modules, and enjoy full IDE autocomplete and instant feedback. + +- **Easier Maintenance**: Instead of relying on fragile string concatenation, TSL provides structured node expressions that are easier to understand, reuse, and refactor. Its component-based architecture allows you to maintain, update, and modify individual parts of the shader and pipeline independently, avoiding variable and layout collisions. + +- **Future-Proof Portability**: TSL is backend-agnostic, compiling automatically to WebGPU (WGSL) or WebGL (GLSL) behind the scenes, ensuring your visuals run everywhere. + +### Projects Using TSL + +https://www.youtube.com/watch?v=BE5JcpuWHG4 + +https://www.youtube.com/watch?v=oRx606IbIGo + +https://www.youtube.com/watch?v=iklqjgIpVG8 + +### User Testimonials + +https://x.com/mrdoob/status/1886416782673789317 +https://x.com/mustache_dev/status/2010375315218944086 +https://x.com/marcinignac/status/1805550271017144780&short +https://x.com/mamesoncom/status/1842812329484017950 +https://x.com/onirenaud/status/1984863378284896377&short +https://x.com/shotamatsuda/status/1951453583117045775&short +https://x.com/MaximeHeckel/status/1978116334245302493&short +https://x.com/akella/status/1912614090377142317&short +https://x.com/makio64/status/1963160279795065084 +https://x.com/SoundSafari_io/status/2015195333177872528&short +https://x.com/holtsetio/status/1932870179161321566 +https://x.com/vg_head/status/1991611248559988749&short +https://x.com/Andersonmancini/status/1794348913660772505&short +https://x.com/thenoumenon/status/2010526571556475351&short +https://x.com/Ademola_4life/status/2012205043185762330&short + + + + + +Creating shaders has always been an advanced step for most developers; many game developers have never created shader code from scratch. The shader graph solution adopted today by the industry has allowed developers more focused on dynamics to create the necessary graphic effects to meet the demands of their projects. + +The aim of the project is to create an easy-to-use environment for shader creation. Even if for this we need to create complexity behind it, this happened initially with Renderer and now with the TSL. + +Other benefits of TSL, besides simplifying shader creation, include remaining **renderer-agnostic**, while all the complexity of a material can be modularized and benefit from **tree shaking** without breaking during the process. + +### Example + +A **detail map** makes things look more real in games. It adds tiny details like cracks or bumps to surfaces. In this example we will scale uv to improve details when seen up close and multiply with a base texture. + +#### Old + +This is how we would achieve that using `.onBeforeCompile()`: + +```js +const material = new THREE.MeshStandardMaterial(); +material.map = colorMap; +material.onBeforeCompile = ( shader ) => { + + shader.uniforms.detailMap = { value: detailMap }; + + let token = '#define STANDARD'; + + let insert = /* glsl */` + uniform sampler2D detailMap; + `; + + shader.fragmentShader = shader.fragmentShader.replace( token, token + insert ); + + token = '#include '; + + insert = /* glsl */` + diffuseColor *= texture2D( detailMap, vMapUv * 10.0 ); + `; + + shader.fragmentShader = shader.fragmentShader.replace( token, token + insert ); + +}; +``` + +Any simple change beyond this makes code increasingly complicated using `.onBeforeCompile()`. The result in the community is countless parametric materials that cannot interoperate and need to be updated periodically to remain operational, limiting the ability to create unique materials by reusing modular components. + +#### New + +With TSL the code would look like this: + +```js +import { texture, uv } from 'three/tsl'; + +const detail = texture( detailMap, uv().mul( 10 ) ); + +const material = new THREE.MeshStandardNodeMaterial(); +material.colorNode = texture( colorMap ).mul( detail ); +``` + +TSL is also capable of encoding code into different outputs such as WGSL/GLSL - WebGPU/WebGL, in addition to optimizing the shader graph automatically and through code that can be inserted within each Node. This allows the developer to focus on productivity and leave the graphical management part to the Node System. + +Another important feature of a graph shader is that we will no longer need to care about the sequence in which components are created, because the Node System will only declare and include it once. + +Let's say that you import positionWorld into your code, even if another component uses it, the calculations performed to obtain positionWorld will only be performed once, as is the case with any other node such as: normalWorld, modelPosition, etc. + + + + + +All TSL components extend from the `Node` class. A `Node` can communicate with other nodes, value conversions can be automatic or manual, and a `Node` can receive the output value expected by the parent `Node` and modify its own output snippet. It's possible to modularize them using tree shaking in the shader construction process. The `Node` has access to contextual information such as geometry, material, renderer, and graphics backend, which can influence the type and value of its output. + +The main class responsible for creating the code and pipeline configuration is `NodeBuilder`. This class can be extended to any output programming language, so you can use TSL for a third language if you wish. Currently, `NodeBuilder` has two concrete implementations: `WGSLNodeBuilder` for WebGPU and `GLSLNodeBuilder` for WebGL2. + +Beyond generating shader source code, `NodeBuilder` orchestrates GPU memory allocation and pipeline layouts: GPU buffers (uniform buffers, storage buffers, and attributes) can be dedicated per-object or shared across multiple materials and passes, avoiding redundant GPU memory allocations and state transitions. + +### Compilation Process + +The build process is based on three pillars: setup, analyze and generate. + +```mermaid +flowchart TD + subgraph TSL["TSL"] + Graph["Node Graph (AST)
JavaScript Objects"] + end + + subgraph Material["Node Material"] + Inputs["Material Inputs
colorNode, opacityNode, ..."] + end + + subgraph Phases["Node Builder (Target Backend)"] + Setup["Setup
node.setup( builder )"] + Analyze["Analyze
node.analyze( builder )"] + Generate["Generate
node.generate( builder, output )"] + Setup --> Analyze --> Generate + end + + subgraph Buffers["GPU Buffers"] + BufferItems["Uniforms & Storage Buffers
Dedicated or Shared Buffers"] + end + + subgraph Bindings["GPU Bindings"] + direction TB + Attributes["Attributes
@location(0), @location(1)..."] + BindGroups["Bind Groups
@group(0), @group(1)..."] + Attributes --> BindGroups + end + + subgraph Shaders["GPU Shaders"] + direction TB + ShaderItems["WGSL / GLSL
Vertex, Fragment, Compute"] + end + + subgraph Events["CPU Lifecycle"] + direction TB + LifeItems["Execution Hooks
updateBeforeupdateupdateAfter"] + end + + subgraph Operations["Runtime Operations"] + direction TB + Ops1["Compute & Draw Calls
Pre-passes, shadow maps, compute dispatches"] + Ops2["Viewport Texture Sampling
Capture viewport color / depth textures"] + Ops3["Uniform & Buffer Updates
Dynamic matrices, uniforms, buffer swaps"] + Ops4["Custom Logic & Passes
User callbacks, custom pipelines & readbacks"] + Ops1 --> Ops2 --> Ops3 --> Ops4 + end + + TSL --> Material + Material --> Phases + Phases --> Shaders + Phases --> Buffers + Buffers --> Bindings + Phases --> Events + Events --> Operations +``` + +### Node + +The `Node` class is the fundamental abstraction representing every operation, value, texture sample, uniform, and expression within the TSL shading graph. Each node defines its own sub-graph, manages how it is compiled into backend shader code, and interfaces with the compilation pipeline through three primary stages: + +::: api .setup( builder ) : Node - Use TSL to create customized logic for the node output, transforming expressions into child node connections. ::: + +::: api .analyze( builder, output ) : void - Analyzes the node graph to determine reference counts and assign cached variables for optimization. ::: + +::: api .generate( builder, output ) : string - Emits and returns the concrete shader code string snippet for the active graphics backend. ::: + +### Update Events & Lifecycle + +Nodes can manage CPU-side operations and synchronize data with the GPU during the rendering loop through lifecycle hooks and execution frequency properties: + +::: api .updateBeforeType : NodeUpdateType - The update frequency for `.updateBefore()`, executed before rendering begins. ::: + +::: api .updateType : NodeUpdateType - The update frequency ('frame', 'render', 'object') for `.update()`. ::: + +::: api .updateAfterType : NodeUpdateType - The update frequency for `.updateAfter()`, executed after rendering completes. ::: + +::: api .updateBefore( frame ) : void - Executed before rendering operations begin. Ideal for pre-pass setup, allocating dynamic storage buffers and render targets, or evaluating simulation states. ::: + +::: api .update( frame ) : void - Executed during object preparation right before drawing. Used to update node uniforms, animation matrices, or dynamic parameters. ::: + +::: api .updateAfter( frame ) : void - Executed after rendering operations have completed. Used for post-render cleanup, ping-pong buffer swaps, or GPU readbacks. ::: + +#### Update Frequencies + +Constants defined in `NodeUpdateType` controlling the execution frequency of update properties: + +::: api NodeUpdateType.NONE : string - The update method is disabled and will not be executed ('none'). ::: + +::: api NodeUpdateType.FRAME : string - Executed once per animation frame (per requestAnimationFrame tick) ('frame'). ::: + +::: api NodeUpdateType.RENDER : string - Executed once per renderer.render() call (useful for multi-pass rendering, shadow maps, and cubemaps) ('render'). ::: + +::: api NodeUpdateType.OBJECT : string - Executed once per individual Object3D drawn with the material or node ('object'). ::: + +### Serialization + +Nodes support JSON-based serialization and deserialization, allowing entire node graphs, materials, and custom shader configurations to be saved to disk, shared across projects, or integrated with visual node editors: + +::: api .serialize( json ) : object - Serializes the node structure and connections into a JSON object for storage, material exchange, or visual node editors. ::: + +::: api .deserialize( json ) : void - Restores the node state and connections from a serialized JSON representation. ::: + +
+ + + +TSL (Three.js Shading Language) elevates shader development from a monolithic script model to a **Component-Based Architecture**, allowing developers to maintain and share individual parts of the shader and rendering pipeline independently, scalably, and without collisions. + +### Architectural Approaches + +| Aspect | Traditional Shaders (Direct GPU Code) | Node System (TSL Abstraction) | +| --- | --- | --- | +| **Structure** | Text-based programs (GLSL / WGSL source files) | Composable JavaScript node graph (AST) | +| **Workflow** | Direct imperative shader code (`main()`, explicit steps) | Declarative channel assignment (`colorNode`, `normalNode`, etc.) | +| **Pipeline Integration** | Focuses strictly on per-draw GPU execution | Extends to multi-pass orchestration, compute stages, and CPU hooks | +| **Scope & Sharing** | Uniforms and bindings are scoped to individual programs | Nodes and uniforms can be shared across materials, MRT, and post-processing | +| **Stage Interfacing** | Manual declaration and management of varyings across stages | Automatic varying allocation and stage routing (e.g. `.toVertexStage()`) | +| **Backend Target** | Written specifically for a single backend (e.g. GLSL or WGSL) | Backend-agnostic; compiles automatically to WGSL or GLSL | + +### Imperative Code vs. Declarative Nodes + +- **Direct Imperative Shaders (GLSL / WGSL)**: + Developers write explicit, step-by-step instructions executed sequentially inside stage entry points like `void main()` or `@fragment fn main()`. Variables, varyings, texture sampling, and output registers (`gl_FragColor` / `@location(0)`) must be manually declared and wired: + + ```glsl + // Uniforms and varyings must be manually declared and managed + uniform vec3 uLightDirection; + uniform vec3 uBaseColor; + + varying vec3 vNormal; + + // Imperative step-by-step instructions in entry point + void main() { + + vec3 normal = normalize( vNormal ); + float diff = max( dot( normal, uLightDirection ), 0.0 ); + + gl_FragColor = vec4( uBaseColor * diff, 1.0 ); + + } + ``` + +- **Declarative Node System (TSL)**: + Developers declare *what* each channel or material should compute by assigning composable nodes. `NodeBuilder` automatically resolves execution stages, routes varyings, deduplicates calculations into cached variables, and synthesizes the optimal GPU program: + + ```js + import * as THREE from 'three'; + import { normalView, uniform } from 'three/tsl'; + + // Declarative component assignment + const lightDirection = uniform( new THREE.Vector3( 0.0, 1.0, 0.0 ) ); + const baseColor = uniform( new THREE.Color( 0x0066ff ) ); + + const diff = normalView.dot( lightDirection ).max( 0.0 ); + + material.colorNode = baseColor.mul( diff ); + ``` + +### Component-Based Design + +In TSL, materials and rendering pipelines are built by composing independent node components: + +- **Modular Maintenance**: Individual shading components (e.g. lighting, diffuse color, normal evaluation) are maintained independently, avoiding variable name collisions and layout conflicts. +- **Cross-Pipeline Sharing**: A single node or uniform instance can be connected simultaneously to surface materials, deferred MRT channels, post-processing passes `pass( scene, camera )`, and compute shaders. +- **Rendering Orchestration**: Nodes can interact with the broader pipeline by scheduling compute dispatches, requesting viewport textures `viewportSharedTexture`, and hooking into CPU lifecycle events (`updateBefore`, `update`, `updateAfter`). +- **Graph Compilation & Optimization**: `NodeBuilder` analyzes the dependency graph, deduplicates repeated expressions, and generates optimized, backend-specific GPU programs. + +```mermaid +flowchart TD + Position["positionView"] + Normal["normalView"] + Custom["customNode"] + + Scene["Scene
fogNode = fog( color, positionView.z.negate() )"] + Light["Lighting & Shadows
...normalView.dot( lightDirection )"] + Mat["Material
normalNode = normalView, colorNode = customNode"] + MRT["Multiple Render Target
mrt( { normal: normalView, custom: customNode } )"] + + Pipeline["Render Pipeline
pass( scene, camera )"] + + subgraph NodeBuilder["TSL NodeBuilder Engine"] + direction LR + AST["AST Synthesis
Unifies graph without string concatenation"] + Cache["Single-Evaluation Cache
Shared expressions evaluated only once"] + Wiring["Auto-Wiring
Resolves varyings, layout locations & uniforms"] + AST --> Cache --> Wiring + end + + Shader["Compiled Unified Shader
Conflict-free
WGSL / GLSL program
"] + + Scene ~~~ Light + Light ~~~ Mat + Mat ~~~ MRT + + Position --> Scene + Position --> Light + + Normal --> Light + Normal --> Mat + Normal --> MRT + + Custom --> Mat + Custom --> MRT + + Scene --> Pipeline + Light --> Pipeline + Mat --> Pipeline + MRT --> Pipeline + + Pipeline --> NodeBuilder + NodeBuilder -->|"Emit Shader"| Shader +``` + +
+ + + +- Unified Code + - Write shader logic directly in JS/TS, eliminating the need to manipulate strings. + - Use the same TSL syntax across all GPU components: + - Materials, Post-Processing, Compute (GPGPU), Particles, Lights, etc. + - Create and manipulate render objects just like any other JavaScript logic inside a TSL function. + - Advanced events to control a Node before and after the object is rendered. +- JS Ecosystem + - Use native **import/export**, **NPM**, and integrate **JS/TS** components directly into your shader logic. +- Typing + - Benefit from better type checking (**TypeScript** and **[@three-types](https://github.com/three-types/three-ts-types)**), increasing code robustness. + + + + + +- Focus on Intent + - Build materials by connecting nodes through: [position](#position), [normal](#normal), [screen](#screen), [attribute](#attributes), etc. + - More **declarative** ('what') rather than **imperative** ('how'). +- Composition & High-Level Concepts + - Work with high-level concepts for Node Material like [colorNode](#node-material), [roughnessNode](#node-material), [metalnessNode](#node-material), [positionNode](#node-material), etc. This preserves the integrity of the lighting model while allowing customizations, helping to avoid mistakes from incorrect setups. +- Keeping an eye on software exchange + - Modern 3D authoring software uses Shader-Graph based material composition to exchange between other software. TSL already has its own MaterialX integration. +- Easier Migration + - Many functions are directly inspired by GLSL to smooth the learning curve for those with prior experience. + + + + + +Control rendering steps and create new render-passes per individual TSL functions. + +- Implementing complex effects is easy with nodes using a single function call, either in post-processing or in materials, allowing the node itself to manage the rendering process. + - `gaussianBlur()`: A two-pass Gaussian blur node usable directly in materials or post-processing passes. +- Easy access to renderer buffers using TSL functions like: + - `viewportSharedTexture()`: Accesses what has already been rendered (beauty pass), preserving render order. + - `viewportLinearDepth()`: Accesses the depth buffer that has already been rendered, preserving render order. + - Integrated Compute Shaders + - Perform calculations on buffers using compute stage directly during an object's rendering. + - TSL allows dynamic manipulation of renderer functions, which makes it more customizable than intermediate languages that would have to use flags in fixed pipelines for this. + - You just need to use the events of a Node for the renderer manipulations, without needing to modify the core. + + + + + +TSL is based on Nodes, so don’t worry about sharing your **functions** and **uniforms** across materials and post-processing. + +```js +// Share the same uniform across various materials + +const sharedColor = uniform( new THREE.Color() ); + +materialA.colorNode = sharedColor.div( 2 ); +materialB.colorNode = sharedColor.mul( 0.5 ); +materialC.colorNode = sharedColor.add( 0.5 ); +``` + +#### Deferred Function: High level of customization, goodbye `#defines` + +Access **material**, **geometry**, **object**, **camera**, **scene**, **renderer** and more directly from a TSL function. Function calls are evaluated when building the shader, allowing you to customize logic dynamically according to object setups. + +```js +// Returns a uniform of the material's custom color if it exists + +const customColor = Fn( ( { material, geometry, object } ) => { + + if ( material.userData.customColor !== undefined ) { + + return uniform( material.userData.customColor ); + + } + + return vec3( 0 ); + +} ); + +// + +material.colorNode = customColor(); + +``` + +#### Load a texture-based matrix inside a TSL function + +This can be used for any other JS and Three.js ecosystem needs. You can manipulate your assets according to the needs of a function. This can work for creating buffers, attributes, uniforms and any other JavaScript operation. + +```js +let bayer16Texture = null; + +export const bayer16 = Fn( ( [ uv ] ) => { + + if ( bayer16Texture === null ) { + + const bayer16Base64 = 'data:image/png;base64,...=='; + + bayer16Texture = new TextureLoader().load( bayer16Base64 ); + + } + + return textureLoad( bayer16Texture, ivec2( uv ).mod( int( 16 ) ) ); + +} ); + +// + +material.colorNode = bayer16( screenCoordinate ); + +``` + +#### TSL loves JavaScript + +TSL syntax follows JavaScript style because they are the same thing, so if you come from GLSL you can explore new possibilities. + +```js +// A simple example of Function closure + +const mainTask = Fn( () => { + + const task2 = Fn( ( [ a, b ] ) => { + + return a.add( b ).mul( 0.5 ); + + } ); + + return task2( color( 0x00ff00 ), color( 0x0000ff ) ); + +} ); + +// + +material.colorNode = mainTask(); +``` + +#### Simplified rendering tree + +Two-pass `gaussianBlur()` node usable seamlessly within materials or post-processing pipelines. + +```js +// Applies a double render-pass gaussianBlur and then a grayscale filter before the object with the material is rendered. + +const myTexture = texture( map ); + +material.colorNode = grayscale( gaussianBlur( myTexture, 4 ) ); +``` + +Accesses what has already been rendered, preserving render order for easy refraction effects, avoiding multiple render passes and manual sorting. + +```js +// Rendering the background in grayscale. + +material.colorNode = grayscale( viewportSharedTexture( screenUV ) ); +material.transparent = true; +``` + +#### Extend the TSL + +You no longer need to create a Material for each desired effect, instead create Nodes. A Node can have access to the Material and can be used in many ways. Extend TSL with custom Nodes to unlock creative workflows. + +A great example of this is [TSL-Textures](https://boytchev.github.io/tsl-textures/): + +```tsl:embed +import 'scenes/shaderball'; +import * as THREE from 'three'; +import { caustics } from 'tsl-textures'; + +model.material.colorNode = caustics( { + scale: 2, + speed: 0, + color: new THREE.Color( 0x1ca3ec ), + seed: 0 +} ); +``` + + + + + +### TSL Textures + +https://github.com/boytchev/tsl-textures + +A collection of Three.js Shading Language (TSL) textures – these are online real-time procedural generators of 3D textures. + +```tsl:embed +import 'scenes/shaderball'; +import * as THREE from 'three'; +import { positionLocal, time } from 'three/tsl'; +import { clouds } from 'tsl-textures'; + +const position = positionLocal.add( time.mul( 0.1 ) ); + +model.material.colorNode = clouds( { + position, + scale: 2, + density: 0.5, + opacity: 1, + color: new THREE.Color( 0xffffff ), + subcolor: new THREE.Color( 0xa0a0a0 ), + seed: 0 +} ); + +model.material.transparent = true; +model.material.side = THREE.DoubleSide; +model.material.opacityNode = clouds.opacity( { + position, + scale: 2, + density: 0.5, + opacity: 1, + color: new THREE.Color( 0xffffff ), + subcolor: new THREE.Color( 0xa0a0a0 ), + seed: 0 +} ); + +``` + +### Vite TSL Operator + +https://github.com/Makio64/vite-plugin-tsl-operator + +Use normal JavaScript operators like `+`, `-`, `*`, `/`, `%`, `**`, `+=`, `>`, `&&`, and `!` directly inside Three.js TSL `Fn()` blocks. + +`vite-plugin-tsl-operator` is a plug-and-play Vite plugin for Three.js Shading Language (TSL), WebGPU, and shader node projects. It rewrites readable operator syntax to TSL node methods during Vite transforms, so you can write shader logic naturally without hand-chaining `.add()`, `.mul()`, `.greaterThan()`, and friends. + +```js +import { uniform, Fn } from 'three/tsl'; + +const myFn = Fn( () => { + + const alpha = uniform( 1 ); + const color = uniform( new THREE.Color() ); + + let x = 1 - alpha * color.r; + x = x * 4; + + return x; + +} ); + +model.material.colorNode = myFn(); +``` + +### TypeGPU + +https://github.com/software-mansion/TypeGPU + +TypeGPU brings TypeScript type safety and a JavaScript-friendly syntax to WebGPU and TSL. Through the `@typegpu/three` package, you can write shaders with standard JS/TS constructs (such as `if`, `for`, and typed data structures) and seamlessly convert them to TSL nodes with `toTSL()`, or access TSL nodes inside TypeGPU using `fromTSL()`. + +Just like TypeScript requires compilation, TypeGPU functions marked with `'use gpu'` rely on a build-time compiler/bundler plugin (such as `unplugin-typegpu` for Vite, Webpack, Rollup, or Babel) to transpile the code into GPU shaders. + +```js +import * as THREE from 'three'; +import * as t3 from '@typegpu/three'; +import * as d from 'typegpu/data'; +import { fract } from 'typegpu/std'; + +const material = new THREE.MeshBasicNodeMaterial(); + +material.colorNode = t3.toTSL( () => { + + 'use gpu'; + + const uv = t3.uv().$; + + if ( uv.x < 0.5 ) { + + return d.vec4f( fract( uv.mul( 4 ) ), 0, 1 ); + + } + + return d.vec4f( 1, 0, 0, 1 ); + +} ); +``` + +### TSL Sandbox + +https://github.com/brunosimon/three.js-tsl-sandbox + +A collection of interactive experiments and shaders created by [Bruno Simon](https://bruno-simon.com/) (creator of Three.js Journey) as a playground to learn and demonstrate the capabilities of TSL (Three.js Shading Language) and WebGPU. + + + + + +- Beginner users + - You only need one line to create your first custom shader. +- Advanced users + - Makes creating shaders simple yet powerful without artificial limits. + - If you want high-level productivity without the constraints of rigid fixed pipelines, you'll love this. + +https://www.youtube.com/watch?v=C2gDL9Qk_vo + + + +
+ + + + + +`NodeMaterial` is the core foundation for shader creation and material rendering in Three.js when using WebGPU and TSL. + +It constructs a dynamic, modular node graph where every property—from diffuse colors and normal maps to physical properties and vertex displacement—is expressed as a composable TSL node. + +NodeMaterial Showcase + +### How NodeMaterial Works + +In Three.js WebGPU, standard material classes (`MeshStandardMaterial`, `MeshPhysicalMaterial`, `MeshBasicMaterial`, etc.) automatically inherit from or map to their `*NodeMaterial` counterparts (`MeshStandardNodeMaterial`, `MeshPhysicalNodeMaterial`, `MeshBasicNodeMaterial`, etc.). + +This means you can assign TSL nodes directly to any material's `*Node` properties while retaining full compatibility with standard material properties (like `.roughness`, `.metalness`, and `.map`). + +```js +import * as THREE from 'three'; +import { color, sin, time, uv } from 'three/tsl'; + +// Works with both standard and explicit node material constructors +const material = new THREE.MeshStandardNodeMaterial(); + +// 1. Procedural color via colorNode +material.colorNode = color( 0x00aaff ).mul( sin( time ).mul( 0.5 ).add( 0.5 ) ); + +// 2. Dynamic roughness via roughnessNode +material.roughnessNode = uv().y; +material.metalness = 0.8; +``` + +### Node Material Inputs + +`NodeMaterial` provides modular node slots (`*Node`) that control and override individual stages of the shader pipeline—including surface color, vertex displacement, lighting evaluation, shadows, and alpha testing: + +::: api-class NodeMaterial [open] + +::: api .colorNode : vec3 - Diffuse / base surface color. Overrides `color` and `map`. ::: + +::: api .positionNode : vec3 - Local vertex displacement before model-view transformation. ::: + +::: api .normalNode : vec3 - Surface normal direction in view space. Overrides `normalMap` and `bumpMap`. ::: + +::: api .opacityNode : float - Surface alpha / opacity value. Overrides `opacity` and `alphaMap`. ::: + +::: api .alphaTestNode : float - Threshold for discarding transparent fragments. ::: + +::: api .emissiveNode : vec3 - Emissive light color emitted by the surface. ::: + +::: api .envNode : vec3 - Custom environment reflections and PBR IBL lighting. ::: + +::: api .aoNode : float - Ambient occlusion influence on diffuse and ambient light. ::: + +::: api .outputNode : vec4 - Final output color composite, retaining lighting evaluation. ::: + +::: api .fragmentNode : vec4 - Complete override of the fragment shader stage. ::: + +::: api .vertexNode : vec4 - Complete override of the vertex shader stage. ::: + +::: api .depthNode : float - Custom fragment depth written to the depth buffer. ::: + +::: api .backdropNode : vec3 - Background texture sampled behind transparent surfaces. ::: + +::: api .backdropAlphaNode : float - Modulates the influence of `backdropNode` on outgoing light. ::: + +::: api .lightsNode : LightsNode - Selective lighting node restricting which scene lights illuminate the material. ::: + +::: api .castShadowNode : vec4 - Defines custom color and opacity for cast shadows. Requires `renderer.shadowMap.transmitted = true`. ::: + +::: api .receivedShadowNode : FunctionNode - Custom shading logic or function `Fn( ( [ shadow ] ) => ... )` for received shadows. ::: + +::: api .castShadowPositionNode : vec3 - Overrides local vertex position used during shadow map depth projection. ::: + +::: api .receivedShadowPositionNode : vec3 - Overrides world position used when sampling received shadow maps. ::: + +::: api .maskNode : bool - Discards surface fragments if the mask evaluates to `false`. ::: + +::: api .maskShadowNode : bool - Alpha mask node applied during the shadow pass to discard shadow fragments. ::: + +::: + +::: api-class MeshStandardNodeMaterial extends NodeMaterial [open] + +::: api .roughnessNode : float - Surface roughness factor (smooth vs rough surface). ::: + +::: api .metalnessNode : float - Surface metalness factor (dielectric vs conductive metal). ::: + +::: + +::: api-class MeshPhysicalNodeMaterial extends MeshStandardNodeMaterial + +::: api .clearcoatNode : float - Clearcoat layer intensity. ::: + +::: api .clearcoatRoughnessNode : float - Clearcoat layer roughness factor. ::: + +::: api .clearcoatNormalNode : vec3 - Normal direction override for the clearcoat layer. ::: + +::: api .sheenNode : vec3 - Sheen layer tint color. ::: + +::: api .sheenRoughnessNode : float - Sheen layer roughness factor. ::: + +::: api .transmissionNode : float - Optical transmission factor through transparent media. ::: + +::: api .thicknessNode : float - Volume thickness for transmission and subsurface scattering. ::: + +::: api .iorNode : float - Index of Refraction (IOR) for physical reflections and refractions. ::: + +::: api .iridescenceNode : float - Thin-film iridescence layer intensity. ::: + +::: api .iridescenceIORNode : float - Index of refraction for the thin-film iridescence layer. ::: + +::: api .iridescenceThicknessNode : float - Physical thickness of the thin-film layer in nanometers. ::: + +::: api .specularColorNode : vec3 - Specular highlight tint color. ::: + +::: api .specularIntensityNode : float - Specular reflection intensity factor. ::: + +::: api .anisotropyNode : vec2 - Directional anisotropy vector for brushed metal surfaces. ::: + +::: api .dispersionNode : float - Chromatic dispersion (Abbe number) splitting light into spectral colors. ::: + +::: api .attenuationColorNode : vec3 - Medium absorption color for light traveling through the volume. ::: + +::: api .attenuationDistanceNode : float - Distance light must travel through the medium to reach attenuation color. ::: + +::: api-class MeshSSSNodeMaterial extends MeshPhysicalNodeMaterial + +::: api .thicknessColorNode : vec3 - Subsurface scattering color node. Assigning a node enables the SSS lighting model. ::: + +::: api .thicknessDistortionNode : float - Normal distortion factor directing light around surface curvature. ::: + +::: api .thicknessAmbientNode : float - Minimum ambient subsurface light level scattered within the volume. ::: + +::: api .thicknessAttenuationNode : float - Distance attenuation factor for light traveling through the medium. ::: + +::: api .thicknessPowerNode : float - Exponent controlling the forward-scattering cone focus. ::: + +::: api .thicknessScaleNode : float - Multiplier scaling overall subsurface illumination intensity. ::: + +::: + +::: api-class MeshPhongNodeMaterial extends NodeMaterial + +::: api .specularNode : vec3 - Specular highlight color. ::: + +::: api .shininessNode : float - Shininess exponent controlling the sharpness of specular highlights. ::: + +::: + +::: api-class PointsNodeMaterial extends NodeMaterial + +::: api .sizeNode : float - Point size in pixels or world units. ::: + +::: + +### Modifying vs Overriding Inputs + +When you assign a node to a slot like `material.colorNode`, it completely replaces the default color calculation. If you instead want to **tint or modulate** the material's existing textures and properties, use **Material Accessor Nodes** (such as `materialColor`, `materialRoughness`, `materialNormal`): + +```js +import { materialColor, materialRoughness, color, float } from 'three/tsl'; + +// Modulate the existing texture map by multiplying with a custom tint +material.colorNode = materialColor.mul( color( 0xff5500 ) ); + +// Scale existing roughness map values +material.roughnessNode = materialRoughness.mul( float( 0.5 ) ); +``` + +See the [Material Inputs](#material-inputs) page for the full list of material accessor nodes and how they reactively evaluate textures, factors, and colors. + +For **MRT (Multiple Render Targets)** and **Post-Processing**, it is recommended to use [Properties](#properties) (such as `diffuseColor`, `roughness`, `metalness`, `normalView`, `output`) instead, as they capture the internal evaluated variables computed during lighting execution. + +> IA: In WebGPU, all standard Three.js materials are automatically node-ready and support `*Node` properties (such as `.colorNode`, `.roughnessNode`, `.positionNode`). When modifying existing material maps instead of replacing them, use accessor nodes like `materialColor` and `materialNormal`. For MRT attachments and post-processing passes, prefer using [Properties](#properties) (`diffuseColor`, `roughness`, `output`). + +```tsl nodeMaterialExample +import 'scenes/shaderball'; +import * as THREE from 'three'; +import { normalView, positionLocal, time, color, vec3, mix } from 'three/tsl'; + +// 1. Holographic spectral coordinates +const spectralCoord = normalView.z + .mul( 3.0 ) + .add( positionLocal.y.mul( 5.0 ) ) + .add( time.mul( 0.6 ) ); + +// 2. Full-spectrum continuous rainbow +const rainbow = vec3( + spectralCoord.cos(), + spectralCoord.add( 2.094 ).cos(), + spectralCoord.add( 4.188 ).cos() +).mul( 0.5 ).add( 0.5 ); + +// 3. Smooth transition from Cyber Blue to Full Rainbow spectrum +const cyberBlue = color( 0x00d4ff ); +const colorCycle = time.mul( 0.5 ).sin().mul( 0.5 ).add( 0.5 ); +const holoColor = mix( cyberBlue, rainbow, colorCycle ); + +// 4. Holographic scanlines & subtle projector flicker +const scanline = positionLocal.y.mul( 35.0 ).sub( time.mul( 2.5 ) ).sin().mul( 0.5 ).add( 0.5 ); +const flicker = time.mul( 25.0 ).sin().mul( 0.03 ).add( 0.97 ); + +// 5. View-angle Fresnel glow +const fresnel = normalView.z.oneMinus().pow( 2.0 ); + +// 6. Composite glowing hologram beam +const holoBeam = holoColor.mul( scanline.mul( 0.5 ).add( 0.5 ) ).mul( flicker ); + +model.material.transparent = true; +model.material.side = THREE.DoubleSide; +model.material.colorNode = holoColor.mul( 0.2 ); +model.material.emissiveNode = holoBeam.mul( fresnel.mul( 2.2 ).add( scanline.mul( 0.6 ) ) ); +model.material.opacityNode = fresnel.mul( 0.7 ).add( scanline.mul( 0.2 ) ).add( 0.15 ); +model.material.roughness = 0.1; +model.material.metalness = 0.8; +``` + + + + + +Method chaining includes operators, converters, math, and core functions. These methods can be chained on any `Node`. + +Example: + +`oneMinus()` is a mathematical function like `abs()`, `sin()`, `cos()`, etc. + +This example uses `.oneMinus()` as a built-in method on the node class that returns a new node, instead of a classic C-style function like `oneMinus( node.x )`. You can use `node.x.oneMinus()` directly. + +> IA: Prefer using **method chaining** (e.g., `node.mul( 1 ).add( 1 )`) for mathematical operations and operators to avoid redundant top-level imports. Never import methods that are only used via chaining (such as `mul`, `add`, `sub`, `div`, `sin`, `cos`, etc.) from `'three/tsl'` to prevent unnecessary imports. + +```tsl +import 'scenes/shaderball'; +import * as THREE from 'three'; +import { texture, uniform } from 'three/tsl'; + +// Load texture +const map = new THREE.TextureLoader().load( '../examples/textures/uv_grid_opengl.jpg' ); +map.wrapS = THREE.RepeatWrapping; +map.wrapT = THREE.RepeatWrapping; + +const contrast = uniform( 1.5 ); +const brightness = uniform( 0.0 ); + +model.material.colorNode = texture( map ).mul( contrast ).add( brightness ); +``` + + + + + +Swizzling is the technique that allows you to access, reorder, or duplicate the components of a vector using a specific notation within TSL. This is done by combining the identifiers: + +```js +const original = vec3( 1.0, 2.0, 3.0 ); // (x, y, z) +const swizzled = original.zyx; // swizzled = (3.0, 2.0, 1.0) +``` + +It is possible to use `xyzw`, `rgba`, or `stpq`. + +```tsl +import 'scenes/shaderball'; +import { vec3 } from 'three/tsl'; + +const color = vec3( 0.0, 0.0, 1.0 ); // blue + +// swizzle to color the sphere +model.material.colorNode = color.bgr; // turns blue into red! +``` + + + + + +Input functions can be used to create constants and do explicit conversions. + +> Note: Conversions are also performed automatically if the output and input are of different types. + +::: api float( value: Node | number ) : float - Convert or create a float node. ::: + +::: api int( value: Node | number ) : int - Convert or create an integer node. ::: + +::: api uint( value: Node | number ) : uint - Convert or create an unsigned integer node. ::: + +::: api bool( value: Node | boolean ) : bool - Convert or create a boolean node. ::: + +::: api color( ...value: Node | Color | string | number ) : color - Convert or create a color node. ::: + +::: api vec2( ...value: Node | Vector2 | number ) : vec2 - Convert or create a Vector2 node. ::: + +::: api vec3( ...value: Node | Vector3 | number ) : vec3 - Convert or create a Vector3 node. ::: + +::: api vec4( ...value: Node | Vector4 | number ) : vec4 - Convert or create a Vector4 node. ::: + +::: api mat2( ...value: Node | Matrix2 | number ) : mat2 - Convert or create a Matrix2 node. ::: + +::: api mat3( ...value: Node | Matrix3 | number ) : mat3 - Convert or create a Matrix3 node. ::: + +::: api mat4( ...value: Node | Matrix4 | number ) : mat4 - Convert or create a Matrix4 node. ::: + +::: api ivec2( ...value: Node | number ) : ivec2 - Convert or create an integer Vector2 node. ::: + +::: api ivec3( ...value: Node | number ) : ivec3 - Convert or create an integer Vector3 node. ::: + +::: api ivec4( ...value: Node | number ) : ivec4 - Convert or create an integer Vector4 node. ::: + +::: api uvec2( ...value: Node | number ) : uvec2 - Convert or create an unsigned integer Vector2 node. ::: + +::: api uvec3( ...value: Node | number ) : uvec3 - Convert or create an unsigned integer Vector3 node. ::: + +::: api uvec4( ...value: Node | number ) : uvec4 - Convert or create an unsigned integer Vector4 node. ::: + +::: api bvec2( ...value: Node | boolean ) : bvec2 - Convert or create a boolean Vector2 node. ::: + +::: api bvec3( ...value: Node | boolean ) : bvec3 - Convert or create a boolean Vector3 node. ::: + +::: api bvec4( ...value: Node | boolean ) : bvec4 - Convert or create a boolean Vector4 node. ::: + +Example: + +```js +import { vec2, positionWorld } from 'three/tsl'; + +// constant +material.colorNode = vec2( 0.5, 0.5 ); + +// three.js object +material.colorNode = vec2( new THREE.Vector2( 0.5, 0.5 ) ); + +// conversion +material.colorNode = vec2( positionWorld ); // result positionWorld.xy +``` + +### Method chaining conversions + +It is also possible to perform conversions using the **method chaining**: + +::: api .toFloat() : float - Convert the node value to float. ::: + +::: api .toInt() : int - Convert the node value to integer. ::: + +::: api .toUint() : uint - Convert the node value to unsigned integer. ::: + +::: api .toBool() : bool - Convert the node value to boolean. ::: + +::: api .toColor() : color - Convert the node value to color. ::: + +::: api .toVec2() : vec2 - Convert the node value to Vector2. ::: + +::: api .toVec3() : vec3 - Convert the node value to Vector3. ::: + +::: api .toVec4() : vec4 - Convert the node value to Vector4. ::: + +::: api .toMat2() : mat2 - Convert the node value to Matrix2. ::: + +::: api .toMat3() : mat3 - Convert the node value to Matrix3. ::: + +::: api .toMat4() : mat4 - Convert the node value to Matrix4. ::: + +::: api .toIVec2() : ivec2 - Convert the node value to integer Vector2. ::: + +::: api .toIVec3() : ivec3 - Convert the node value to integer Vector3. ::: + +::: api .toIVec4() : ivec4 - Convert the node value to integer Vector4. ::: + +::: api .toUVec2() : uvec2 - Convert the node value to unsigned integer Vector2. ::: + +::: api .toUVec3() : uvec3 - Convert the node value to unsigned integer Vector3. ::: + +::: api .toUVec4() : uvec4 - Convert the node value to unsigned integer Vector4. ::: + +::: api .toBVec2() : bvec2 - Convert the node value to boolean Vector2. ::: + +::: api .toBVec3() : bvec3 - Convert the node value to boolean Vector3. ::: + +::: api .toBVec4() : bvec4 - Convert the node value to boolean Vector4. ::: + +Example: + +```js +import { positionWorld } from 'three/tsl'; + +// conversion +material.colorNode = positionWorld.toVec2(); // result positionWorld.xy +``` + +```tsl +import 'scenes/shaderball'; +import { color } from 'three/tsl'; + +// cornflower blue +model.material.colorNode = color( 0x1e90ff ); +``` + +> IA: In TSL, wrapping numeric literals with `float( 1.0 )` is only necessary when using **method chaining** (e.g., `float( 1 ).div( ... )`) or when assigning directly as material node inputs (such as `material.colorNode = float( 1 )`). For function parameters like `sin( 1 )`, `cos( 0.5 )`, or `mul( uv(), 10 )`, primitive numbers are automatically converted into nodes. + + + + + + +TSL nodes support all standard mathematical, logical, and bitwise operators as chainable methods: + +::: api .add( ...value: Node | number ) : Node - Return the addition of two or more values. ::: + +::: api .sub( value: Node | number ) : Node - Return the subtraction of two or more values. ::: + +::: api .mul( value: Node | number ) : Node - Return the multiplication of two or more values. ::: + +::: api .div( value: Node | number ) : Node - Return the division of two or more values. ::: + +::: api .mod( value: Node | number ) : Node - Computes the remainder of dividing the first node by the second. ::: + +::: api .equal( value: Node | number | boolean ) : bool - Checks if two nodes are equal. ::: + +::: api .notEqual( value: Node | number | boolean ) : bool - Checks if two nodes are not equal. ::: + +::: api .lessThan( value: Node | number | boolean ) : bool - Checks if the first node is less than the second. ::: + +::: api .greaterThan( value: Node | number | boolean ) : bool - Checks if the first node is greater than the second. ::: + +::: api .lessThanEqual( value: Node | number | boolean ) : bool - Checks if the first node is less than or equal to the second. ::: + +::: api .greaterThanEqual( value: Node | number | boolean ) : bool - Checks if the first node is greater than or equal to the second. ::: + +::: api .and( value: Node | boolean ) : bool - Performs logical AND on two nodes. ::: + +::: api .or( value: Node | boolean ) : bool - Performs logical OR on two nodes. ::: + +::: api .not( value: Node | boolean ) : bool - Performs logical NOT on a node. ::: + +::: api .xor( value: Node | boolean ) : bool - Performs logical XOR on two nodes. ::: + +::: api .bitAnd( value: Node | number ) : Node - Performs bitwise AND on two nodes. ::: + +::: api .bitNot( value: Node | number ) : Node - Performs bitwise NOT on a node. ::: + +::: api .bitOr( value: Node | number ) : Node - Performs bitwise OR on two nodes. ::: + +::: api .bitXor( value: Node | number ) : Node - Performs bitwise XOR on two nodes. ::: + +::: api .shiftLeft( value: Node | number ) : Node - Shifts a node to the left. ::: + +::: api .shiftRight( value: Node | number ) : Node - Shifts a node to the right. ::: + +```tsl +import 'scenes/shaderball'; +import { color } from 'three/tsl'; + +// simple color manipulation using operators +const red = color( 1.0, 0.0, 0.0 ); +const blue = color( 0.0, 0.0, 1.0 ); + +// Mix the two colors using add and mul operators +const mixedColor = red.mul( 0.5 ).add( blue.mul( 0.5 ) ); // results in purple! + +model.material.colorNode = mixedColor; +``` + + + + + +TSL provides all standard mathematical constants and functions as both direct functions and chainable methods: + +### Constants + +::: api EPSILON : float - Small floating-point precision value `1e-6`. ::: + +::: api INFINITY : float - Represents positive infinity. ::: + +::: api PI : float - Mathematical constant π `3.141592653589793`. ::: + +::: api TWO_PI : float - Two times π `6.283185307179586`. ::: + +::: api HALF_PI : float - Half of π `1.5707963267948966`. ::: + +### Functions + +::: api abs( x ) : Node - Computes the absolute value of `x`. +- **x**: `Node | number` - Input value or node. +::: + +::: api acos( x ) : Node - Computes the arccosine of `x` in radians. +- **x**: `Node | number` - Input value or node in range `[-1, 1]`. +::: + +::: api all( x ) : bool - Returns `true` if all components of `x` are non-zero or true. +- **x**: `Node` - Vector node. +::: + +::: api any( x ) : bool - Returns `true` if any component of `x` is non-zero or true. +- **x**: `Node` - Vector node. +::: + +::: api asin( x ) : Node - Computes the arcsine of `x` in radians. +- **x**: `Node | number` - Input value or node in range `[-1, 1]`. +::: + +::: api atan( y, x? ) : Node - Computes the arc-tangent of `y` or `y / x` in radians. +- **y**: `Node | number` - Y coordinate or single tangent ratio. +- **x**: `Node | number` - (Optional) X coordinate for two-argument arc-tangent (`atan2`). +::: + +::: api bitcast( x, type ) : Node - Reinterprets the bit pattern of `x` as a different type without type conversion. +- **x**: `Node` - Input node. +- **type**: `string` - Target primitive type name (e.g. `'float'`, `'int'`, `'uint'`). +::: + +::: api cbrt( x ) : Node - Computes the cube root of `x`. +- **x**: `Node | number` - Input value or node. +::: + +::: api ceil( x ) : Node - Rounds `x` up to the nearest integer. +- **x**: `Node | number` - Input value or node. +::: + +::: api clamp( x, min, max ) : Node - Constrains `x` to lie between `min` and `max`. +- **x**: `Node | number` - Value to constrain. +- **min**: `Node | number` - Lower bound. +- **max**: `Node | number` - Upper bound. +::: + +::: api cos( x ) : Node - Computes the cosine of `x`. +- **x**: `Node | number` - Angle in radians. +::: + +::: api cross( a, b ) : vec3 - Computes the cross product of 3D vectors `a` and `b`. +- **a**: `vec3` - First 3D vector. +- **b**: `vec3` - Second 3D vector. +::: + +::: api dFdx( p ) : Node - Computes the partial derivative of `p` with respect to screen X axis. +- **p**: `Node` - Input expression node. +::: + +::: api dFdy( p ) : Node - Computes the partial derivative of `p` with respect to screen Y axis. +- **p**: `Node` - Input expression node. +::: + +::: api degrees( radians ) : Node - Converts an angle from radians to degrees. +- **radians**: `Node | number` - Angle in radians. +::: + +::: api difference( a, b ) : Node - Computes the absolute difference `|a - b|`. +- **a**: `Node | number` - First value or node. +- **b**: `Node | number` - Second value or node. +::: + +::: api distance( a, b ) : float - Computes the Euclidean distance between two points `length(a - b)`. +- **a**: `Node` - First point or vector node. +- **b**: `Node` - Second point or vector node. +::: + +::: api dot( a, b ) : float - Computes the dot product of vectors `a` and `b`. +- **a**: `Node` - First vector node. +- **b**: `Node` - Second vector node. +::: + +::: api equals( a, b ) : bool - Returns `true` if `a` equals `b`. +- **a**: `Node | number` - First value or node. +- **b**: `Node | number` - Second value or node. +::: + +::: api exp( x ) : Node - Computes the natural exponential e^`x`. +- **x**: `Node | number` - Input value or node. +::: + +::: api exp2( x ) : Node - Computes `2` raised to the power of `x`. +- **x**: `Node | number` - Input value or node. +::: + +::: api faceforward( N, I, Nref ) : vec3 - Orients a normal vector to point away from a surface. +- **N**: `vec3` - Surface normal vector. +- **I**: `vec3` - Incident vector. +- **Nref**: `vec3` - Reference normal vector. +::: + +::: api floor( x ) : Node - Rounds `x` down to the nearest integer. +- **x**: `Node | number` - Input value or node. +::: + +::: api fract( x ) : Node - Computes the fractional part of `x` (`x - floor(x)`). +- **x**: `Node | number` - Input value or node. +::: + +::: api fwidth( p ) : Node - Computes the sum of absolute partial derivatives `|dFdx(p)| + |dFdy(p)|`. +- **p**: `Node` - Input expression node. +::: + +::: api inverseSqrt( x ) : Node - Computes the reciprocal of the square root `1 / sqrt(x)`. +- **x**: `Node | number` - Input value or node. +::: + +::: api length( x ) : float - Computes the Euclidean length of vector `x`. +- **x**: `Node` - Vector node. +::: + +::: api lengthSq( x ) : float - Computes the squared length of vector `x` (`dot(x, x)`). +- **x**: `Node` - Vector node. +::: + +::: api log( x ) : Node - Computes the natural logarithm ln(`x`). +- **x**: `Node | number` - Input value or node. +::: + +::: api log2( x ) : Node - Computes the base-2 logarithm log₂(`x`). +- **x**: `Node | number` - Input value or node. +::: + +::: api max( a, b ) : Node - Returns the greater of two values. +- **a**: `Node | number` - First value or node. +- **b**: `Node | number` - Second value or node. +::: + +::: api min( a, b ) : Node - Returns the lesser of two values. +- **a**: `Node | number` - First value or node. +- **b**: `Node | number` - Second value or node. +::: + +::: api mix( a, b, t ) : Node - Linearly interpolates between `a` and `b`. +- **a**: `Node | number` - Start value or node (returned when `t = 0`). +- **b**: `Node | number` - End value or node (returned when `t = 1`). +- **t**: `Node | number` - Interpolation factor between `0` and `1`. +::: + +::: api negate( x ) : Node - Negates the value of `x` (`-x`). +- **x**: `Node | number` - Input value or node. +::: + +::: api normalize( x ) : Node - Computes the unit vector in the same direction as vector `x`. +- **x**: `Node` - Vector node. +::: + +::: api oneMinus( x ) : Node - Computes `1 - x`. +- **x**: `Node | number` - Input value or node. +::: + +::: api pow( x, y ) : Node - Computes `x` raised to power `y` (`x^y`). +- **x**: `Node | number` - Base value or node. +- **y**: `Node | number` - Exponent value or node. +::: + +::: api pow2( x ) : Node - Computes the square of `x` (`x * x`). +- **x**: `Node | number` - Input value or node. +::: + +::: api pow3( x ) : Node - Computes the cube of `x` (`x * x * x`). +- **x**: `Node | number` - Input value or node. +::: + +::: api pow4( x ) : Node - Computes the fourth power of `x`. +- **x**: `Node | number` - Input value or node. +::: + +::: api radians( degrees ) : Node - Converts an angle from degrees to radians. +- **degrees**: `Node | number` - Angle in degrees. +::: + +::: api reciprocal( x ) : Node - Computes the reciprocal `1 / x`. +- **x**: `Node | number` - Input value or node. +::: + +::: api reflect( I, N ) : vec3 - Computes the reflection direction for an incident vector. +- **I**: `vec3` - Incident vector pointing towards the surface. +- **N**: `vec3` - Normalized surface normal vector. +::: + +::: api refract( I, N, eta ) : vec3 - Computes the refraction direction for an incident vector. +- **I**: `vec3` - Incident vector pointing towards the surface. +- **N**: `vec3` - Normalized surface normal vector. +- **eta**: `float | number` - Ratio of indices of refraction. +::: + +::: api round( x ) : Node - Rounds `x` to the nearest integer. +- **x**: `Node | number` - Input value or node. +::: + +::: api saturate( x ) : Node - Constrains `x` to range `[0, 1]`. +- **x**: `Node | number` - Input value or node. +::: + +::: api sign( x ) : Node - Extracts the sign of `x` (`-1.0`, `0.0`, or `1.0`). +- **x**: `Node | number` - Input value or node. +::: + +::: api sin( x ) : Node - Computes the sine of `x`. +- **x**: `Node | number` - Angle in radians. +::: + +::: api smoothstep( low, high, x ) : Node - Performs smooth Hermite interpolation between `low` and `high` edges. +- **low**: `Node | number` - Lower edge threshold. +- **high**: `Node | number` - Upper edge threshold. +- **x**: `Node | number` - Source value to evaluate. +::: + +::: api sqrt( x ) : Node - Computes the square root of `x`. +- **x**: `Node | number` - Input value or node. +::: + +::: api step( edge, x ) : Node - Generates a step function, returning `0.0` if `x < edge`, else `1.0`. +- **edge**: `Node | number` - Threshold edge. +- **x**: `Node | number` - Source value. +::: + +::: api tan( x ) : Node - Computes the tangent of `x`. +- **x**: `Node | number` - Angle in radians. +::: + +::: api transformDirection( dir, matrix ) : vec3 - Transforms direction vector `dir` by `matrix` and normalizes the result. +- **dir**: `vec3` - Direction vector node. +- **matrix**: `mat4` - Transformation matrix node. +::: + +::: api transformNormalByViewMatrix( normal, viewMatrix? ) : vec3 - Transforms a normal vector from world space to view space and normalizes the result. +- **normal**: `vec3` - World-space normal vector. +- **viewMatrix**: `mat4` - (Optional) View matrix node. Defaults to camera view matrix. +::: + +::: api transformNormalByInverseViewMatrix( normal, viewMatrix? ) : vec3 - Transforms a normal vector from view space to world space and normalizes the result. +- **normal**: `vec3` - View-space normal vector. +- **viewMatrix**: `mat4` - (Optional) View matrix node. Defaults to camera view matrix. +::: + +::: api trunc( x ) : Node - Truncates `x` towards zero, removing its fractional part. +- **x**: `Node | number` - Input value or node. +::: + +> Important: Method Chaining Exceptions: In TSL method chaining `node.method(...)`, functions that accept interpolation or comparison factors use the calling node as the **last parameter** (the evaluation factor or source value): + +::: api t.mix( a, b ) : Node - Method chaining form of `mix( a, b, t )`. Calling node `t` is the interpolation factor (0 to 1). +- **a**: `Node` - Start value node (returned when `t = 0`). +- **b**: `Node` - End value node (returned when `t = 1`). +::: + +::: api x.smoothstep( low, high ) : Node - Method chaining form of `smoothstep( low, high, x )`. Calling node `x` is the source value evaluated between `low` and `high`. +- **low**: `Node` - Lower edge threshold. +- **high**: `Node` - Upper edge threshold. +::: + +::: api x.step( edge ) : Node - Method chaining form of `step( edge, x )`. Calling node `x` is the source value compared against `edge`. +- **edge**: `Node` - Threshold edge node. +::: + +```tsl +import 'scenes/shaderball'; +import { abs, float } from 'three/tsl'; + +const value = float( - 1 ); + +// It's possible to use `value.abs()` too. +const positiveValue = abs( value ); // output: 1 + +model.material.colorNode = positiveValue; +``` + + + + + +It is possible to use classic JS functions or a `Fn()` interface. 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. + +```js +// tsl function +export const oscSine = Fn( ( [ t = time ] ) => { + + return t.add( 0.75 ).mul( Math.PI * 2 ).sin().mul( 0.5 ).add( 0.5 ); + +} ); + +// inline function +export const oscSineInline = ( t = time ) => t.add( 0.75 ).mul( Math.PI * 2 ).sin().mul( 0.5 ).add( 0.5 ); +``` +> Note: Both above can be called with `oscSine( value )` or `oscSineInline( value )`. + +oscSine example + +### Parameters as an Object + +TSL allows passing parameters as an object, which is useful in functions with many optional arguments. + +Passing parameters as an object also allows traditional positional arguments as an array, enabling flexible usage styles: + +```js +const col = Fn( ( { r, g, b } ) => { + + return vec3( r, g, b ); + +} ); + +// Any of the options below will return a green color: + +material.colorNode = col( 0, 1, 0 ); // option 1 (positional) +material.colorNode = col( { r: 0, g: 1, b: 0 } ); // option 2 (named object) +``` + +If you want to export a function compatible with **tree shaking**, remember to annotate with `/*@__PURE__*/`: + +```js +export const oscSawtooth = /*@__PURE__*/ Fn( ( [ timer = time ] ) => timer.fract() ); +``` + +In a TSL `Fn()`, the `NodeBuilder` instance is automatically passed as the last parameter (or the first if no custom arguments are defined). Through `NodeBuilder`, you can inspect the current compilation context and access scene objects such as **material**, **geometry**, **object**, **camera**, etc. + +Accessing Material example + +```tsl oscSine +import 'scenes/shaderball'; +import { Fn, time } from 'three/tsl'; + +// Define a custom TSL Fn to animate the color +const oscSine = Fn( ( { t = time } ) => { + + return t.add( 0.75 ).mul( Math.PI * 2 ).sin().mul( 0.5 ).add( 0.5 ); + +} ); + +// Assign it to the red component of the material color +model.material.colorNode = oscSine(); +``` + +```tsl accessingMaterial +import 'scenes/shaderball'; +import * as THREE from 'three'; +import { Fn, color } from 'three/tsl'; + +// Store color +model.material.userData.customColor = new THREE.Color( 0x0066ff ); + +// Retrieve the color from builder +const getMaterialColor = Fn( ( { material } ) => { + + if ( material.userData.customColor !== undefined ) { + + return color( material.userData.customColor ); + + } + + return color( 0 ); + +} ); + +// Assign color +model.material.colorNode = getMaterialColor(); +``` + +### Function as Parameter + +Functions in TSL can accept other functions or callbacks as parameters. This allows designing higher-order shader functions that delegate specific evaluations (such as sampling height maps, applying custom math transformations, or evaluating procedural channels) to the caller. + +Function as Parameter example + +```js +const sample = ( sampleUV = uv() ) => texture( map, sampleUV ).r; + +material.normalNode = customBumpMap( sample, 3.0 ); +``` + +```tsl bumpMapFunctionExample +import 'scenes/shaderball'; +import * as THREE from 'three'; +import { float, vec2, uv, normalView, positionView, faceDirection, texture, color } from 'three/tsl'; + +// Load texture map for bump height sampling and configure repeat wrapping +const map = new THREE.TextureLoader().load( '../examples/textures/uv_grid_opengl.jpg' ); +map.wrapS = THREE.RepeatWrapping; +map.wrapT = THREE.RepeatWrapping; + +// Recreated custom bumpMap function accepting a height sampler function, scale, and optional uv parameter +const customBumpMap = ( sampleHeightFn, bumpScale = float( 1.0 ), bumpUV = uv() ) => { + + const Hll = float( sampleHeightFn( bumpUV ) ); + + // Calculate forward height derivatives using screen-space UV derivatives + const dHdx = float( sampleHeightFn( bumpUV.add( bumpUV.dFdx() ) ) ).sub( Hll ).mul( bumpScale ); + const dHdy = float( sampleHeightFn( bumpUV.add( bumpUV.dFdy() ) ) ).sub( Hll ).mul( bumpScale ); + const dHdxy = vec2( dHdx, dHdy ); + + // Calculate perturbed surface normal vector + const vSigmaX = positionView.dFdx().normalize(); + const vSigmaY = positionView.dFdy().normalize(); + const vN = normalView; + + const R1 = vSigmaY.cross( vN ); + const R2 = vN.cross( vSigmaX ); + + const fDet = vSigmaX.dot( R1 ).mul( faceDirection ); + const vGrad = fDet.sign().mul( dHdxy.x.mul( R1 ).add( dHdxy.y.mul( R2 ) ) ); + + return fDet.abs().mul( vN ).sub( vGrad ).normalize(); + +}; + +// Height sampling function evaluating the red channel (.r) of the texture +const sample = ( sampleUV = uv() ) => texture( map, sampleUV ).r; + +// Apply custom bump map with custom UV scaling directly passed to bumpMap +model.material.normalNode = customBumpMap( sample, 3.0, uv().mul( 3 ) ); + +// Set base material color +model.material.colorNode = color( 0x3b82f6 ); +``` + +### Layout + +A **Layout** defines the signature of a TSL function, specifying its parameter types and return type: + +- **No-Layout (Default)**: + - Generates inlined shader code directly into the execution stack. + - Allows the function to adapt to contextual inputs, return multi-property JavaScript objects, or execute dynamic node graphs per material. + +- **Layout**: + - Generates an equivalent native function based on the `NodeBuilder` target backend (e.g. WGSL or GLSL). + - Compiled once into the shader program and efficiently reused across materials via a persistent cache. + +Layout example + +```js +// No-Layout (Default): Inlined with assignments +const clampColor = Fn( ( { val } ) => { + + const result = float( val ); + + If( val.greaterThan( 1.0 ), () => { + + result.assign( 1.0 ); + + } ); + + return result; + +} ); + +// Layout: Native GPU function with signature and return +const clampColorLayout = Fn( ( { val } ) => { + + If( val.greaterThan( 1.0 ), () => { + + return 1.0; + + } ); + + return val; + +}, { val: 'float', return: 'float' } ); +``` + +```tsl layoutExample +import 'scenes/shaderball'; +import { Fn, color, vec3, time, uv, If } from 'three/tsl'; + +// 1. No-Layout (Default): inlined function using assignments across branches +const getThresholdColor = Fn( ( { baseColor, threshold } ) => { + + const result = vec3( baseColor ); + + If( uv().y.greaterThan( threshold ), () => { + + result.assign( color( 0x00aaff ) ); + + } ); + + return result; + +} ); + +// 2. Layout: native GPU function with typed signature compiled and cached globally +const getThresholdColorLayout = Fn( ( { baseColor, threshold, uv } ) => { + + const result = vec3( baseColor ); + + If( uv.y.greaterThan( threshold ), () => { + + result.assign( color( 0x00aaff ) ); + + } ); + + return result; + +}, { baseColor: 'vec3', threshold: 'float', uv: 'vec2', return: 'vec3' } ); + +const threshold = time.sin().mul( 0.5 ).add( 0.5 ); + +// Use the default No-Layout function +model.material.colorNode = getThresholdColor( { baseColor: color( 0xff3366 ), threshold } ); +// model.material.colorNode = getThresholdColorLayout( { baseColor: color( 0xff3366 ), threshold, uv: uv() } ); +``` + +### Closure + +TSL functions support JavaScript closures. A function defined with `Fn()` can contain a nested `Fn()` inside its body. The inner `Fn()` captures variables, constants, and parameters defined in the outer `Fn()` scope, allowing modular and reusable sub-functions within TSL shader graphs. + +Closure example + +```js +const createChecker = Fn( ( [ scale ] ) => { + + const tintColor = vec3( 0.1, 0.6, 1.0 ); + const computeChecker = Fn( ( [ customUV ] ) => checker( customUV ).mul( tintColor ) ); + + return computeChecker( uv().mul( scale ) ); + +} ); +``` + +> Note: Although closures are allowed, they are not always recommended because inner functions create new instances that cannot be efficiently reused in the shader cache. + +```tsl closureExample +import 'scenes/shaderball'; +import { Fn, vec3, uv, checker } from 'three/tsl'; + +// Outer TSL Fn accepting a scale parameter +const createChecker = Fn( ( [ scale ] ) => { + + const tintColor = vec3( 0.1, 0.6, 1.0 ); + + // Inner TSL Fn nested inside outer Fn (capturing outer parameter 'scale' and variable 'tintColor') + const computeChecker = Fn( ( [ customUV ] ) => { + + return checker( customUV ).mul( tintColor ); + + } ); + + return computeChecker( uv().mul( scale ) ); + +} ); + +model.material.colorNode = createChecker( 8.0 ); +``` + +#### Related + - [Sub-Builds](#sub-builds) + - [JavaScript Synergy](#javascript-synergy) + + + + + + +TSL variables and parameters inside a custom function `Fn` can be updated dynamically using assignment methods: +::: api .assign( value: Node | number ) : Node - Assigns a value and returns the node. ::: + +::: api .addAssign( value: Node | number ) : Node - Adds a value and assigns the result. ::: + +::: api .subAssign( value: Node | number ) : Node - Subtracts a value and assigns the result. ::: + +::: api .mulAssign( value: Node | number ) : Node - Multiplies a value and assigns the result. ::: + +::: api .divAssign( value: Node | number ) : Node - Divides a value and assigns the result. ::: + +::: api .modAssign( value: Node | number ) : Node - Computes the remainder and assigns the result. ::: + +::: api .bitAndAssign( value: Node | number ) : Node - Performs bitwise AND and assigns the result. ::: + +::: api .bitOrAssign( value: Node | number ) : Node - Performs bitwise OR and assigns the result. ::: + +::: api .bitXorAssign( value: Node | number ) : Node - Performs bitwise XOR and assigns the result. ::: + +::: api .shiftLeftAssign( value: Node | number ) : Node - Shifts left and assigns the result. ::: + +::: api .shiftRightAssign( value: Node | number ) : Node - Shifts right and assigns the result. ::: + +```tsl +import 'scenes/shaderball'; +import { Fn, vec3 } from 'three/tsl'; + +// A TSL Fn where arguments act as mutable variables +const modifyColor = Fn( ( [ color ] ) => { + + // Add blue to the incoming color node directly + color.addAssign( vec3( 0.0, 0.0, 1.0 ) ); + + return color; + +} ); + +const baseColor = vec3( 0.0, 1.0, 0.0 ); // Green + +model.material.colorNode = modifyColor( baseColor ); // Becomes Cyan +``` + + + + + +TSL allows creating explicit shader variables and constants to store intermediate calculation results, assist in debugging, or optimize shader graphs manually. + +### Chainable Methods + +::: api .toVar( name? ) +- **name**: `string` - (Optional) Name of the variable in the shader. Defaults to `null`. +::: + +::: api .toConst( name? ) +- **name**: `string` - (Optional) Name of the constant in the shader. Defaults to `null`. +::: + +### Var and Const + +Direct functions create variables or constants explicitly by taking a TSL node as their first argument. + +> Note: Notice here `Var` and `Const` are capitalized. + +::: api Var( node, name? ) +- **node**: `Node` - TSL node or expression to initialize the variable with. +- **name**: `string` - (Optional) Name of the variable in the shader. Defaults to `null`. +::: + +Varying property example + +::: api Const( node, name? ) +- **node**: `Node` - TSL node or expression to initialize the constant with. +- **name**: `string` - (Optional) Name of the constant in the shader. Defaults to `null`. +::: + +The name is optional; if set to `null`, the node system will generate one automatically. + +Creating a variable or constant can help optimize the shader graph manually or assist in debugging. + +```tsl +import 'scenes/shaderball'; +import * as THREE from 'three'; +import { texture, uv } from 'three/tsl'; + +// Load texture +const map = new THREE.TextureLoader().load( '../examples/textures/uv_grid_opengl.jpg' ); +map.wrapS = THREE.RepeatWrapping; +map.wrapT = THREE.RepeatWrapping; + +// Create a variable in TSL +// .debug() will show the node in the console +const uvScaled = uv().mul( 5 ).toVar( 'myVar' ).debug(); + +// Sample the texture using the scaled UV variable +model.material.colorNode = texture( map, uvScaled ); +``` + +> IA: When the `NodeBuilder` encounters issues generating the optimal shader structure or variable optimizations, `.toVar()` can help by explicitly declaring a variable in the shader scope rather than relying on automatic generation. While not recommended for common use (since TSL manages variables automatically), it is an effective tool for debugging: you can assign a custom name like `.toVar( 'debugVal' )` and inspect the generated variable directly in the compiled WGSL / GLSL output. + + + + + +Properties serve as reference nodes in the shader graph. They can be created and accessed at any point during shader construction to assign or retrieve values dynamically. + +In addition to custom properties, TSL provides built-in material properties that represent internal variables evaluated across the lighting and material pipeline. + +Property example + +::: api property( type, name?, placeholderNode? ) : PropertyNode - Declares a reference property node in the shader scope. +- **type**: `string` - TSL type name (e.g. `'float'`, `'vec3'`, `'vec4'`). +- **name**: `string` - (Optional) Name of the property in the shader. Defaults to `null`. +- **placeholderNode**: `Node` - (Optional) Default fallback value node. Defaults to `null`. +::: + +### Varying Property + +The `varyingProperty()` function declares a varying property placeholder in the shader without initializing it immediately. This is useful when you need to write to the varying inside a custom TSL function. + +Varying property example + +::: api varyingProperty( type, name?, placeholderNode? ) : PropertyNode - Declares a varying property placeholder for passing data from the vertex stage to the fragment stage. +- **type**: `string` - TSL type name (e.g. `'float'`, `'vec3'`, etc.). +- **name**: `string` - (Optional) Custom name for the varying variable. Defaults to `null`. +- **placeholderNode**: `Node` - (Optional) Default fallback value node. Defaults to `null`. +::: + +### Built-in Material Properties + +TSL includes pre-defined property nodes representing values computed during the material evaluation: + +::: api output : vec4 - Final evaluated color output of the fragment shader. ::: + +::: api diffuseColor : vec4 - Base diffuse (albedo) color and opacity. ::: + +::: api roughness : float - Surface roughness factor. ::: + +::: api metalness : float - Surface metalness factor. ::: + +::: api emissive : vec3 - Emissive radiance color. ::: + +::: api specularColor : color - Specular reflection tint color. ::: + +::: api clearcoat : float - Clearcoat layer intensity. ::: + +::: api clearcoatRoughness : float - Clearcoat surface roughness. ::: + +::: api sheen : vec3 - Sheen color tint. ::: + +::: api sheenRoughness : float - Sheen roughness. ::: + +::: api iridescence : float - Iridescence intensity. ::: + +::: api transmission : float - Optical transmission (refraction) factor. ::: + +::: api thickness : float - Volume thickness for subsurface scattering and transmission. ::: + +::: api ior : float - Index of refraction. ::: + +::: api ambientOcclusion : float - Ambient occlusion factor (defaults to `1.0`). ::: + +### Using Properties with MRT + +Built-in material properties are particularly powerful when combined with [MRT](#mrt) (Multiple Render Targets). Because properties like `output`, `diffuseColor`, `roughness`, `metalness`, and `emissive` are computed during material lighting execution, they can be captured directly into G-Buffer texture attachments for deferred rendering, post-processing effects (such as SSAO, SSR, SSGI, and Bloom), or custom compositing passes: + +```js +import { mrt, output, diffuseColor, roughness, metalness, normalView } from 'three/tsl'; + +// G-Buffer pass: route material properties into dedicated render target textures +scenePass.setMRT( mrt( { + output: output, + albedo: diffuseColor.rgb, + normal: normalView, + roughness: roughness, + metalness: metalness +} ) ); +``` + +See the [MRT](#mrt) page for a complete guide on configuring and reading multi-target render passes. + +```tsl propertyExample +import 'scenes/shaderball'; +import * as THREE from 'three'; +import { diffuseColor, grayscale } from 'three/tsl'; + +// 1. Load texture and set it on the material map +const map = new THREE.TextureLoader().load( '../examples/textures/uv_grid_opengl.jpg' ); +model.material.map = map; + +// 2. Read diffuseColor property and convert it to grayscale on outputNode +model.material.outputNode = grayscale( diffuseColor ); +``` + +```tsl varyingPropertyExample +import 'scenes/shaderball'; +import { Fn, varyingProperty, positionLocal, vertexStage, time, vec3 } from 'three/tsl'; + +// Declare a varying property placeholder +const myVarying = varyingProperty( 'vec3', 'vCustomColor' ); + +const mainVertex = Fn( () => { + + // Animate/offset position in the vertex stage + const offsetPosition = positionLocal.add( vec3( 0, time.sin().mul( 0.2 ), 0 ) ); + + // Assign the animated position to our varying property + myVarying.assign( offsetPosition ); + + return offsetPosition; + +} ); + +// Link the vertex function to positionNode to execute it on the vertex stage +model.material.positionNode = vertexStage( mainVertex() ); + +// Read from the varying property in the fragment stage +model.material.colorNode = myVarying; +``` + + + + + +The `array()` function in TSL allows creating constant or dynamic value arrays; there are many ways to create arrays in TSL. + +::: api array( array, type? ) : Node - Creates an array node. +- **array**: `Array` - Array of initial values (e.g. `Color`, `Vector3`, numbers, etc.). +- **type**: `string` - (Optional) TSL type name (e.g. `'float'`, `'vec3'`, etc.). +::: + +To access the values you can use `a[ 1 ]` or `a.element( 1 )`. The difference is that `a[ 1 ]` only allows constant values, while `a.element( 1 )` allows the use of dynamic values such as `a.element( index )` where index is a node. + +```js +const colors = array( [ + vec3( 1, 0, 0 ), + vec3( 0, 1, 0 ), + vec3( 0, 0, 1 ) +] ); + +const greenColor = colors.element( 1 ); // vec3( 0, 1, 0 ) +``` + +Define an array type explicitly: + +```js +const a = array( [ 0, 1, 2 ], 'uint' ); +const value = a.element( 1 ); // 1u +``` + +Array fixed size: + +```js +const a = array( 'vec3', 2 ); // [ vec3( 0, 0, 0 ), vec3( 0, 0, 0 ) ] +``` + +Fill an array with a default value: + +```js +const a = vec3( 0, 0, 1 ).toArray( 2 ); // [ vec3( 0, 0, 1 ), vec3( 0, 0, 1 ) ] +``` + +```tsl +import 'scenes/shaderball'; +import { array, vec3, int, time } from 'three/tsl'; + +// Define a constant array of colors in TSL +const colors = array( [ + vec3( 1, 0, 0 ), // Red + vec3( 0, 1, 0 ), // Green + vec3( 0, 0, 1 ) // Blue +] ); + +// Dynamically cycle the index from 0 to 2 using time +const index = int( time.mul( 1.5 ).mod( 3 ) ); + +// Select the color from the array +const activeColor = colors.element( index ); + +model.material.colorNode = activeColor; +``` + + + + + +Structs allow you to create custom data types with multiple members. They can be used to organize related data in shaders, define structures for attributes and uniforms. + +::: api struct( membersLayout, name? ) : Function - Creates a struct type with the specified member layout. +- **membersLayout**: `object` - An object defining the fields and their type strings (e.g., `{ min: 'vec3', max: 'vec3' }`). Members can also be declared as objects to enable WebGPU atomic operations (e.g., `{ x: { type: 'int', atomic: true } }`). +- **name**: `string` - (Optional) The name of the struct type in the generated WGSL/GLSL shader source code. Defaults to `null`. +::: + +::: api outputStruct( ...members ) : Node - Creates an output struct node for returning multiple values. +- **members**: `...Node` - The nodes to return as members of the output structure (commonly used in MRT). +::: + +Example: + +```js +import { struct, vec3 } from 'three/tsl'; + +// Define a custom struct +const BoundingBox = struct( { min: 'vec3', max: 'vec3' } ); + +// Create a new instance of the struct +const bb = BoundingBox( vec3( 0 ), vec3( 1 ) ); // style 1 +const bb2 = BoundingBox( { min: vec3( 0 ), max: vec3( 1 ) } ); // style 2 + +// Access the struct members +const min = bb.get( 'min' ); + +// Assign a new value to a member +min.assign( vec3( - 1, - 1, - 1 ) ); + +// Define a custom struct with atomic fields +const Cell = struct( { + x: { type: 'int', atomic: true }, + y: { type: 'int', atomic: true }, + mass: { type: 'int', atomic: true } +} ); +``` + +Struct Showcase + +```tsl structExample +import 'scenes/shaderball'; +import { struct, vec3 } from 'three/tsl'; + +// Define a custom struct type +const CustomColor = struct( { r: 'float', g: 'float', b: 'float' } ); + +// Instantiate the struct +const myColor = CustomColor( 0.1, 0.5, 0.9 ); + +// Retrieve the components and construct a vec3 color node +const finalColor = vec3( myColor.get( 'r' ), myColor.get( 'g' ), myColor.get( 'b' ) ); + +model.material.colorNode = finalColor; +``` + + + + + + + +TSL's `If` builds dynamic conditional branches that execute directly on the GPU (per-vertex or per-pixel). This differs from standard JavaScript `if` statements, which only run once on the CPU during the shader construction phase. + +> Important: TSL conditionals must be defined inside a TSL function `Fn()` because they rely on the function's execution stack to build conditional shader branches. + +> Note: Notice here `If`, `ElseIf`, `Else` are capitalized. + +```js +If( conditional, () => { + + // Do something... + +} ).ElseIf( conditional, () => { + + // Do something else... + +} ).Else( () => { + + // Do something else... + +} ); +``` + +```tsl +import 'scenes/shaderball'; +import { Fn, float, color, vec3, time, positionLocal, If } from 'three/tsl'; + +const limitPosition = Fn( ( { position } ) => { + + const limit = float( time.sin().abs() ); + const result = vec3( position ); + + If( result.y.greaterThan( limit ), () => { + + result.y = limit; + + } ).ElseIf( result.y.lessThan( limit.negate() ), () => { + + result.y = limit.negate(); + + } ); + + return result; + +} ); + +model.material.colorNode = color( 0x1e90ff ); +model.material.positionNode = limitPosition( positionLocal ); +``` + + + + + +A Switch-Case statement is an alternative way to express conditional logic compared to [If-Else](#if-else). + +> Important: TSL conditionals must be defined inside a TSL function `Fn()` because they rely on the function's execution stack to build conditional shader branches. + +> Note: Notice here `Switch`, `Case` and `Default` are capitalized. + +```js +const col = color(); + +Switch( selector ) + .Case( 0, () => { + + col.assign( color( 1, 0, 0 ) ); + + } ).Case( 1, () => { + + col.assign( color( 0, 1, 0 ) ); + + } ).Case( 2, 3, () => { + + col.assign( color( 0, 0, 1 ) ); + + } ).Default( () => { + + col.assign( color( 1, 1, 1 ) ); + + } ); +``` + +Notice that there are some rules when using this syntax which differentiate TSL from JavaScript: + +- There is no fallthrough support. So each `Case()` statement has an implicit break. +- A `Case()` statement can hold multiple values (selectors) for testing. + +```tsl +import 'scenes/shaderball'; +import { Fn, color, time, int, Switch } from 'three/tsl'; + +const selectColor = Fn( () => { + + const col = color(); + + // Cycle selector 0, 1, 2, 3 based on elapsed time + const selector = int( time.mul( 1.5 ).mod( 4 ) ); + + Switch( selector ) + .Case( 0, () => { + + col.assign( color( 1, 0, 0 ) ); // Red + + } ) + .Case( 1, () => { + + col.assign( color( 0, 1, 0 ) ); // Green + + } ) + .Case( 2, 3, () => { + + col.assign( color( 0, 0, 1 ) ); // Blue + + } ) + .Default( () => { + + col.assign( color( 1, 1, 1 ) ); // White + + } ); + + return col; + +} ); + +model.material.colorNode = selectColor(); +``` + + + + + +Different from [If-Else](#if-else), a ternary conditional will return a value and can be used outside of `Fn()`. + +Ternary Example + +::: api select( conditionNode, trueNode, falseNode ) +- **conditionNode**: `Node` - TSL condition expression. +- **trueNode**: `Node` - Node or value returned if the condition is true. +- **falseNode**: `Node` - Node or value returned if the condition is false. +::: + +```js +const result = select( value.greaterThan( 1 ), 1.0, value ); +``` +> Note: Equivalent in JavaScript should be: `value > 1 ? 1.0 : value` + +```tsl ternaryExample +import 'scenes/shaderball'; +import { select, time, color } from 'three/tsl'; + +// Alternate color based on time.sin() being greater than 0 +const isPositive = time.sin().greaterThan( 0.0 ); +const chromaColor = select( isPositive, color( 0x3b82f6 ), color( 0x10b981 ) ); + +model.material.colorNode = chromaColor; +``` + + + + + +This module offers a variety of ways to implement loops in TSL. + +Fractal Loop Example + +::: api Loop( count/config, callback ) +- **count/config**: `number | object` - Either the iteration count (e.g. `5`), or a configuration object (e.g. `{ start, end, type, condition, name }`). +- **callback**: `Function` - Loop body callback function, receiving index variables destructured (e.g. `( { i } ) => {}`). +::: + +In its basic form: + +```js +Loop( count, ( { i } ) => { + +} ); +``` + +However, it is also possible to define start and end ranges, data types, and loop conditions: + +```js +Loop( { start: int( 0 ), end: int( 10 ), type: 'int', condition: '<', name: 'i' }, ( { i } ) => { + +} ); +``` + +Nested loops can be defined in a compacted form: + +```js +Loop( 10, 5, ( { i, j } ) => { + +} ); +``` + +Loops that should run backwards can be defined like so: + +```js +Loop( { start: 10 }, () => {} ); +``` + +It is possible to execute with boolean values, similar to the `while` syntax: + +```js +const value = float( 0 ); + +Loop( value.lessThan( 10 ), () => { + + value.addAssign( 1 ); + +} ); +``` + +The module also provides `Break()` and `Continue()` TSL expressions for loop control. + +```tsl fractalExample +import 'scenes/empty'; +import { Fn, float, Loop, screenUV, color, time, vec2, If, Break } from 'three/tsl'; + +const julia = Fn( () => { + + // Scale and center screen UV coordinates + const z = screenUV.sub( 0.5 ).mul( 3.0 ); + + // Animate the complex constant c over time + const c = vec2( time.cos().mul( 0.3 ).sub( 0.7 ), time.sin().mul( 0.2 ).add( 0.27015 ) ); + const iterations = float( 0.0 ); + + // Loop 32 times to calculate the fractal escape depth + Loop( 32, ( { i } ) => { + + // Complex number square: z = z^2 + c + const x = z.x.mul( z.x ).sub( z.y.mul( z.y ) ); + const y = z.x.mul( z.y ).mul( 2.0 ); + z.assign( vec2( x, y ).add( c ) ); + + // Break early if the point escapes the threshold + If( z.length().greaterThan( 2.0 ), () => { + + iterations.assign( i.toFloat() ); + Break(); + + } ); + + } ); + + // Return normalized value based on loop iterations + return iterations.div( 32.0 ); + +} ); + +const fractalVal = julia(); + +// Assign the procedural fractal directly to renderPipeline +renderPipeline.outputNode = fractalVal.mix( color( 0x050510 ), color( 0x3b82f6 ) ).add( fractalVal.pow( 2.0 ).mul( color( 0x10b981 ) ) ); +``` + + + + + + + + + +Functions and methods used to optimize computations by moving them to the vertex shader stage and passing them as interpolated variables to the fragment shader stage. + +Vertex stage example +Varying example + +### Vertex Stage + +::: api vertexStage( node ) +- **node**: `Node` - TSL expression to compute on the vertex stage. +::: + +::: api .toVertexStage() - Chainable method to convert any existing node or expression directly into a vertex-stage calculation. ::: + +The `vertexStage()` function forces a calculation to be performed in the vertex stage of the GPU pipeline, rather than in the fragment stage. This is useful for optimizing expensive operations by performing them per-vertex and interpolating the results. + +Example: + +```js +// Multiplication will be executed in vertex stage +const normalView = modelNormalMatrix.mul( normalLocal ).toVertexStage(); + +// Normalize will be computed in fragment stage +material.colorNode = normalView.normalize(); +``` + +### Varying + +Similarly to `vertexStage()`, `varying()` function forces a calculation to be performed in the vertex stage of the GPU pipeline, but it also declares a named varying variable. + +::: api varying( node, name? ) +- **node**: `Node` - TSL expression to compute in the vertex stage and pass to the fragment stage. +- **name**: `string` - (Optional) Custom name for the varying variable. Defaults to `null`. +::: + +::: api .toVarying( name? ) - Chainable method to convert any existing node or expression directly into a varying variable. +- **name**: `string` - (Optional) Custom name for the varying variable. Defaults to `null`. +::: + +If `varying()` is added only to `material.positionNode`, it will only return a simple variable and a varying will not be created because `material.positionNode` is computed at the vertex stage. + +```tsl vertexStageExample +import 'scenes/shaderball'; +import { modelNormalMatrix, normalLocal } from 'three/tsl'; + +// Using .toVertexStage() chainable method syntax +const normalView = modelNormalMatrix.mul( normalLocal ).toVertexStage(); + +// Normalization is interpolated and computed in the fragment stage +model.material.colorNode = normalView.normalize(); +``` + +```tsl varyingExample +import 'scenes/shaderball'; +import { uv } from 'three/tsl'; + +// Using .toVarying() chainable method syntax +const myVaryingUv = uv().mul( 10.0 ).toVarying( 'vScaledUv' ); + +// Sample colors in the fragment shader using sine wave of the varying UV +model.material.colorNode = myVaryingUv.sin(); +``` + +#### Related + - [Properties](#properties) + + + + + +The **Compute Stage** allows you to perform general-purpose parallel computations (GPGPU) directly on the GPU using compute shaders. This is useful for complex physics simulations, particle updates, procedural geometry deformations, and image processing. + +GPU compute execution is structured into a hierarchy of execution units: +- **Grid / Dispatch**: The entire global execution grid containing all invocations. +- **Workgroups**: Local thread blocks (e.g. `[ 64 ]`, `[ 16, 16 ]`) executing concurrently with shared on-chip memory `workgroupArray()` and synchronization barriers `workgroupBarrier()`. +- **Subgroups (Warps / Wavefronts)**: Hardware SIMD execution units (e.g. 32 or 64 threads) that can share and reduce data directly via hardware wave intrinsics (`subgroupAdd()`, `subgroupBroadcast()`, `subgroupElect()`) without shared memory overhead. + +Particle example +Compute geometry example +Workgroup example + +### Functions + +::: api compute( node, count, workgroupSize? ) : ComputeNode - Wraps a TSL function into a compute node with a specified total invocation count and workgroup dimensions. +- **node**: `Node` - TSL function containing the compute shader logic. +- **count**: `number` - Total number of invocations to dispatch. +- **workgroupSize**: `Array` - (Optional) 1D, 2D, or 3D workgroup dimensions. Defaults to `[ 64 ]`. +::: + +::: api .compute( count, workgroupSize? ) : ComputeNode - Chains a compute dispatch definition directly onto a TSL function call. +- **count**: `number` - Total number of invocations to dispatch. +- **workgroupSize**: `Array` - (Optional) Workgroup thread dimensions. Defaults to `[ 64 ]`. +::: + +::: api workgroupArray( type, count ) : Node - Allocates high-speed on-chip shared memory accessible by all invocations within the local workgroup. +- **type**: `string` - The data type of the buffer elements (e.g. `'float'`, `'vec3'`, `'vec4'`). +- **count**: `number` - Total number of elements in the workgroup buffer. +::: + +::: api workgroupBarrier() : Node - Emits an execution and memory barrier ensuring all invocations in the workgroup reach this point before proceeding. +::: + +::: api storageBarrier() : Node - Emits a memory barrier ensuring all pending storage buffer read and write operations are synchronized. +::: + +### Built-in Identifiers + +::: api instanceIndex : uint - Linearized 1D global invocation index across the entire compute dispatch. ::: + +::: api globalId : uvec3 - 3D coordinates of the current invocation within the global compute grid. ::: + +::: api localId : uvec3 - 3D coordinates of the current invocation within its local workgroup. ::: + +::: api workgroupId : uvec3 - 3D index of the workgroup the current invocation belongs to. ::: + +::: api numWorkgroups : uvec3 - Total number of dispatched workgroups along the X, Y, and Z dimensions. ::: + +::: api subgroupSize : uint - Hardware size of the active subgroup (warp size, typically 32 or 64). ::: + +### Subgroup Functions (Wave Intrinsics) + +::: api subgroupElect() : bool - Returns true for the lowest active invocation ID in the subgroup, electing a single leader thread. ::: + +::: api subgroupAdd( value ) : Node - Performs a parallel sum reduction across all active invocations in the subgroup. ::: + +::: api subgroupInclusiveAdd( value ) : Node - Calculates a prefix sum scan inclusive of the current invocation's value. ::: + +::: api subgroupExclusiveAdd( value ) : Node - Calculates a prefix sum scan exclusive of the current invocation's value. ::: + +::: api subgroupMul( value ) : Node - Performs a parallel multiplication reduction across all active invocations in the subgroup. ::: + +::: api subgroupMin( value ) : Node - Returns the minimum value across all active invocations in the subgroup. ::: + +::: api subgroupMax( value ) : Node - Returns the maximum value across all active invocations in the subgroup. ::: + +::: api subgroupAll( boolNode ) : bool - Returns true if the boolean predicate is true for all active invocations in the subgroup. ::: + +::: api subgroupAny( boolNode ) : bool - Returns true if the boolean predicate is true for any active invocation in the subgroup. ::: + +::: api subgroupBroadcast( value, id ) : Node - Broadcasts the value from invocation `id` to all invocations in the subgroup. ::: + +::: api subgroupBroadcastFirst( value ) : Node - Broadcasts the value from the first active invocation in the subgroup. ::: + +::: api subgroupShuffle( value, index ) : Node - Exchanges values between invocations in the subgroup at specified lane indices. ::: + +::: api subgroupBallot( boolNode ) : uvec4 - Returns a bitmask representing which active invocations satisfy the boolean condition. ::: + +```tsl computeGeometry +import 'scenes/empty'; +import * as THREE from 'three'; +import { Fn, storage, attributeArray, instanceIndex, time, vertexIndex } from 'three/tsl'; + +// 1. Create a Torus geometry +const geometry = new THREE.TorusGeometry( 1, 0.35, 64, 128 ); +const count = geometry.attributes.position.count; + +// 2. Create storage buffers for base and computed positions +const basePositions = storage( new THREE.StorageBufferAttribute( geometry.attributes.position.array, 3 ), 'vec3', count ); +const currentPositions = attributeArray( count, 'vec3' ); + +// 3. Define a compute shader that deforms vertices over time +const computeWave = Fn( () => { + + const basePos = basePositions.element( instanceIndex ); + const currentPos = currentPositions.element( instanceIndex ); + + // Calculate waving displacement based on vertex position and time + const waveOffset = basePos.x.mul( 3.0 ).add( time.mul( 2.0 ) ).sin().mul( 0.15 ); + const displacedPos = basePos.add( basePos.normalize().mul( waveOffset ) ); + + currentPos.assign( displacedPos ); + + return currentPositions.element( vertexIndex ); + +} )().compute( count ); + +// 4. Create a node material and trigger compute execution before each render +const material = new THREE.MeshStandardNodeMaterial( { roughness: 0.3, metalness: 0.8 } ); + +material.positionNode = computeWave; + +// Set dynamic colors based on computed positions +material.colorNode = computeWave.add( 0.5 ); + +// 5. Create the mesh and add it to the scene +const mesh = new THREE.Mesh( geometry, material ); +mesh.position.set( 0, 1.2, 0 ); +scene.add( mesh ); +``` + +```tsl computeParticleSystem +import 'scenes/empty'; +import * as THREE from 'three'; +import { Fn, instancedArray, instanceIndex, time, OnBeforeMaterialUpdate, hash, If } from 'three/tsl'; + +const particleCount = 1024; + +// 1. Declare Storage Buffers and Spawn Area Config +const area = { width: 7.0, height: 10.0, depth: 7.0 }; + +const positions = instancedArray( particleCount, 'vec3' ); +const velocities = instancedArray( particleCount, 'vec3' ); + +// 2. Define the Initialization Compute Shader +const computeInit = Fn( () => { + + const pos = positions.element( instanceIndex ); + const vel = velocities.element( instanceIndex ); + + // Stagger Y heights to distribute the starts + pos.x = hash( instanceIndex ).sub( 0.5 ).mul( area.width ); + pos.y = hash( instanceIndex.add( 1.0 ) ).mul( area.height + 2.0 ).sub( 2.0 ); // Staggered height + pos.z = hash( instanceIndex.add( 2.0 ) ).sub( 0.5 ).mul( area.depth ); + + // Small downward starting velocity + vel.x = hash( instanceIndex.add( 3.0 ) ).sub( 0.5 ).mul( 0.02 ); + vel.y = hash( instanceIndex.add( 4.0 ) ).mul( - 0.02 ); + vel.z = hash( instanceIndex.add( 5.0 ) ).sub( 0.5 ).mul( 0.02 ); + +} )().compute( particleCount ); + +// 3. Define the Update Compute Shader +const computeUpdate = Fn( () => { + + const pos = positions.element( instanceIndex ); + const vel = velocities.element( instanceIndex ); + + // Apply gravity + vel.y.subAssign( 0.002 ); + + // Update position + pos.addAssign( vel ); + + // Floor collision (grid helper height in scenes/empty is -2) + const floorLevel = - 2.0; + If( pos.y.lessThan( floorLevel ), () => { + + pos.y = floorLevel; + + // Bounce with randomized damping (between 0.4 and 0.7) + const bounceDamping = hash( instanceIndex.add( time ) ).mul( 0.3 ).add( 0.4 ); + vel.y = vel.y.negate().mul( bounceDamping ); + + // Friction + vel.x = vel.x.mul( 0.9 ); + vel.z = vel.z.mul( 0.9 ); + + // Reset particle when it comes to rest on the floor to loop the animation + If( vel.y.abs().lessThan( 0.02 ), () => { + + pos.x = hash( instanceIndex.add( time ) ).sub( 0.5 ).mul( area.width ); + pos.y = area.height; // Reset to the top spawn height + pos.z = hash( instanceIndex.add( time.add( 1.0 ) ) ).sub( 0.5 ).mul( area.depth ); + + vel.x = hash( instanceIndex.add( time.add( 2.0 ) ) ).sub( 0.5 ).mul( 0.02 ); + vel.y = hash( instanceIndex.add( time.add( 3.0 ) ) ).mul( - 0.02 ); // Falling start + vel.z = hash( instanceIndex.add( time.add( 4.0 ) ) ).sub( 0.5 ).mul( 0.02 ); + + } ); + + } ); + +} )().compute( particleCount ); + +// 4. Create a sprite material and register automatic compute updates +const material = new THREE.SpriteNodeMaterial( { + scaleNode: 0.12, + colorNode: velocities.toAttribute().normalize().mul( 0.5 ).add( 0.5 ) +} ); + +material.positionNode = Fn( () => { + + let initialized = false; + + OnBeforeMaterialUpdate( ( { renderer } ) => { + + if ( ! initialized ) { + + renderer.compute( computeInit ); + + initialized = true; + + } + + renderer.compute( computeUpdate ); + + } ); + + return positions.element( instanceIndex ); + +} )(); + +// 5. Create sprite object and add to scene +const particles = new THREE.Sprite( material ); +particles.count = particleCount; +particles.frustumCulled = false; +scene.add( particles ); +``` + +```tsl computeWorkgroup +import 'scenes/plane'; +import * as THREE from 'three'; +import { Fn, workgroupArray, workgroupBarrier, localId, instanceIndex, uvec2, vec3, vec4, float, uint, time, texture, textureStore } from 'three/tsl'; + +// 1. Create a 2D grid (128x128) and Storage Texture displayed on a Plane +const width = 128, height = 128; +const storageTex = new THREE.StorageTexture( width, height ); + +// 2. Allocate 2D shared workgroup memory (16x16 = 256 threads per tile) +const workgroupSizeX = 16, workgroupSizeY = 16; +const sharedCache = workgroupArray( 'vec3', workgroupSizeX * workgroupSizeY ); + +// 3. Define compute shader with a cross-thread read hazard +const computeStep = Fn( () => { + + const posX = instanceIndex.mod( width ); + const posY = instanceIndex.div( width ); + const indexUV = uvec2( posX, posY ); + + // Local 1D index within the 16x16 workgroup tile (0 to 255) + const lid = localId.y.mul( workgroupSizeX ).add( localId.x ); + + // Phase 1 (Write): Each thread writes a smooth wave color to shared memory + const t = time.mul( 2.5 ); + const gx = float( posX ).div( float( width ) ).mul( 6.0 ); + const gy = float( posY ).div( float( height ) ).mul( 6.0 ); + + const r = gx.add( t ).sin().mul( 0.5 ).add( 0.5 ); + const g = gy.add( t.mul( 0.7 ) ).sin().mul( 0.5 ).add( 0.5 ); + const b = gx.add( gy ).sub( t ).sin().mul( 0.5 ).add( 0.5 ); + + sharedCache.element( lid ).assign( vec3( r, g, b ) ); + + // Synchronization Barrier: + // Ensures all 256 threads in the tile finish writing before any thread reads. + // -> Try commenting out the barrier below to see severe tearing and tile corruption across the plane! + workgroupBarrier(); + + // Phase 2 (Cross-thread Read): Read the diagonally inverted lane in the tile + const invertedLid = uint( ( workgroupSizeX * workgroupSizeY ) - 1 ).sub( lid ); + const finalColor = sharedCache.element( invertedLid ); + + textureStore( storageTex, indexUV, vec4( finalColor, 1.0 ) ).toWriteOnly(); + +} )().compute( width * height, [ workgroupSizeX, workgroupSizeY ] ); + +// 4. Run compute step on every frame +export function update() { + + renderer.compute( computeStep ); + +} + +// 5. Display the storage texture on the plane +plane.material.colorNode = texture( storageTex ); +``` + +#### Related +- [Storage](#storage) +- [Storage Texture](#storage-texture) +- [Atomic](#atomic) + + + + + + + + + +TSL nodes have active CPU-side lifecycles that can execute JavaScript callbacks at specific stages of the rendering pipeline. + +Events allow you to synchronize GPU shader variables with CPU calculations, update uniforms per frame or per object, and orchestrate rendering states before or after objects, materials, and render pipelines execute. + +Events are registered directly inside a TSL function `Fn()` using event functions (`OnFrameUpdate`, `OnMaterialUpdate`, `OnObjectUpdate`, etc.). + +Centralized Material Updates + +::: api OnFrameUpdate( callback: Function ) : EventNode - Executes a callback once per animation frame on the CPU. ::: + +::: api OnBeforeFrameUpdate( callback: Function ) : EventNode - Executes a callback before frame node updates begin. ::: + +::: api OnMaterialUpdate( callback: Function ) : EventNode - Executes a callback when the material is rendered. ::: + +::: api OnBeforeMaterialUpdate( callback: Function ) : EventNode - Executes a callback before the material is updated. ::: + +::: api OnObjectUpdate( callback: Function ) : EventNode - Executes a callback each time an individual object using the material is rendered. ::: + +::: api OnBeforeObjectUpdate( callback: Function ) : EventNode - Executes a callback before each individual object is rendered. ::: + +::: api OnAfterObjectUpdate( callback: Function ) : EventNode - Executes a callback after an individual object finishes rendering. ::: + +::: api OnBeforeRenderPipeline( callback: Function ) : EventNode - Executes a callback before the post-processing render pipeline starts. ::: + +::: api OnAfterRenderPipeline( callback: Function ) : EventNode - Executes a callback after the post-processing render pipeline completes. ::: + +```tsl eventsExample +import 'scenes/shaderball'; +import * as THREE from 'three'; +import { uniform, Fn, OnMaterialUpdate, sin, cos, positionLocal, normalView, positionViewDirection } from 'three/tsl'; + +// Define a self-contained TSL shader function with encapsulated uniforms and lifecycle events +const energySphere = Fn( () => { + + // Declare uniforms inside the function + const baseColor = uniform( new THREE.Color() ); + const glowColor = uniform( new THREE.Color() ); + const waveParams = uniform( new THREE.Vector3() ); // ( frequency, animation phase, swirl ) + const glowIntensity = uniform( 0.0 ); + + // Update all material uniforms simultaneously on the CPU in a single callback + OnMaterialUpdate( ( { time } ) => { + + const t = time * 0.7; + + // 1. Dynamic harmonic palette cycling across HSL color space + baseColor.value.setHSL( ( t * 0.05 + 0.55 ) % 1.0, 0.9, 0.35 ); + glowColor.value.setHSL( ( t * 0.08 + 0.12 ) % 1.0, 1.0, 0.65 ); + + // 2. Synchronize spatial frequency, animation phase, and ripple curvature + waveParams.value.set( + Math.sin( t * 1.3 ) * 3.0 + 9.0, // frequency + t * 2.5, // phase + Math.cos( t * 0.8 ) * 0.5 + 1.0 // swirl + ); + + // 3. Compute pulsating energy burst intensity + glowIntensity.value = Math.pow( Math.sin( t * 2.2 ) * 0.5 + 0.5, 3.0 ) * 2.5 + 0.5; + + } ); + + // 3D coordinate warping for dynamic energy bands + const p = positionLocal.mul( waveParams.x ); + const ripple = sin( p.y.mul( waveParams.z ).add( waveParams.y ) ) + .add( cos( p.x.mul( 0.8 ).add( p.z.mul( waveParams.z ) ) ) ) + .mul( 0.5 ) + .add( 0.5 ); + + // Crisp energy contour rings + const bands = sin( ripple.mul( 12.0 ) ).pow( 4.0 ); + + // Dynamic Fresnel rim lighting + const fresnel = normalView.dot( positionViewDirection ).oneMinus().pow( 3.0 ); + + // Composite multi-layered iridescent energy shading + const core = ripple.mix( baseColor, glowColor ); + const energyGlow = glowColor.mul( bands.mul( glowIntensity ).add( fresnel.mul( 2.0 ) ) ); + + return core.add( energyGlow ); + +} ); + +// Apply the reactive event shader to the material +model.material.colorNode = energySphere(); +model.material.roughness = 0.2; +model.material.metalness = 0.9; +``` + + + + + + + +Atomic operations in TSL allow performing synchronization-safe read-modify-write operations on GPU memory. In WebGPU, atomic operations are performed on elements of storage buffers or workgroup memory declared as atomic variables. + +To create an atomic storage buffer in TSL, you declare a `storage()` node and chain the `.toAtomic()` method on it. + +Example: + +```js +import * as THREE from 'three'; +import { storage, atomicAdd } from 'three/tsl'; + +// 1. Create a storage buffer attribute (e.g., 1 unsigned integer for a counter) +const counterAttr = new THREE.StorageBufferAttribute( new Uint32Array( [ 0 ] ), 1 ); + +// 2. Define the storage buffer in TSL and mark it as atomic +const counter = storage( counterAttr, 'uint', 1 ).toAtomic(); + +// 3. Perform an atomic add operation in your shader (increments the counter and returns the previous value) +const previousValue = atomicAdd( counter.element( 0 ), 1 ); +``` + + + + + + + + + +Attributes are inputs that are defined per-vertex or per-instance in the geometry of a mesh. + +Vertex index example +Attributes example + +### Constants + +::: api instanceIndex : `uint` - The index of the current instance. ::: + +::: api vertexIndex : `uint` - The index of a vertex within a mesh. ::: + +::: api drawIndex : `uint` - The draw index when using multi-draw. ::: + +### Functions + +::: api attribute( name, type? ) +- **name**: `string` - Name of the geometry attribute. +- **type**: `string` - (Optional) Explicit TSL type name. Defaults to `null`. +::: + +::: api uv( index? ) +- **index**: `number` - (Optional) The UV coordinate set index. Defaults to `0`. +::: + +::: api vertexColor( index? ) +- **index**: `number` - (Optional) The vertex color set index. Defaults to `0`. +::: + +::: api batch( batchMesh ) +- **batchMesh**: `BatchedMesh` - Creates a batch node for BatchedMesh. +::: + +::: api instance( instancedMesh ) +- **instancedMesh**: `InstancedMesh` - Creates an instance node for InstancedMesh. +::: + +```tsl attributesExample +import 'scenes/shaderball'; +import { uv } from 'three/tsl'; + +// Map the UV coordinate attribute directly to colorNode +model.material.colorNode = uv(); +``` + +```tsl vertexIndexExample +import 'scenes/shaderball'; +import { vec3, vertexIndex, hash, positionLocal, time, color } from 'three/tsl'; + +// Oscillate explosion factor between 0.0 (assembled) and 1.0 (fully exploded) +const factor = time.mul( 0.8 ).sin().mul( 0.5 ).add( 0.5 ); + +// Group vertices by triangle (3 vertices per face) to move faces as rigid bodies +const faceIndex = vertexIndex.div( 3 ); + +// Generate a random explosion direction for each face using hash and faceIndex +const randomDir = vec3( + hash( faceIndex.add( 11.0 ) ).sub( 0.5 ), + hash( faceIndex.add( 22.0 ) ).sub( 0.5 ), + hash( faceIndex.add( 33.0 ) ).sub( 0.5 ) +).normalize(); + +// Randomize explosion speed for each face +const speed = hash( faceIndex ).add( 0.5 ); + +// Displace vertices outward (each face flies away as a flat triangle) +const displacement = randomDir.mul( factor.mul( speed ).mul( 1.5 ) ); +model.material.positionNode = positionLocal.add( displacement ); + +// Transition color from blue (stable/cold) to orange (exploded/hot gas) +model.material.colorNode = factor.mix( color( 0x3b82f6 ), color( 0xffaa76 ) ); +``` + + + + + +Textures provide image data for surface colors, normal maps, roughness, height displacement, environment reflections, and lookup tables on the GPU. + +In TSL, `texture( map, uv? )` samples a 2D texture with automatic filtering, mipmapping, and coordinate transformation. + +Animated Texture + +### Functions + +::: api texture( value, uv? ) : vec4 - Samples a 2D texture with custom UV coordinates. +- **value**: `Texture | Node` - The Three.js texture instance or an existing texture node. +- **uv**: `vec2` - (Optional) Texture coordinate node to sample with. Defaults to `uv()`. +::: + +::: api cubeTexture( value, uv? ) : vec4 - Samples a cube texture with a 3D direction vector. +- **value**: `CubeTexture | Node` - The cube texture instance. +- **uv**: `vec3` - (Optional) 3D sample direction vector. Defaults to `reflectVector`. +::: + +::: api texture3D( value, uv? ) : vec4 - Samples a 3D volumetric texture. +- **value**: `Data3DTexture` - The 3D data texture instance. +- **uv**: `vec3` - (Optional) 3D coordinate vector. +::: + +::: api textureLoad( value, uv? ) : vec4 - Fetches texel values directly from pixel coordinates without filtering or interpolation. +- **value**: `Texture | Node` - The texture instance or node. +- **uv**: `ivec2 | vec2` - (Optional) Integer or normalized pixel coordinates. +::: + +::: api textureSize( texture, level? ) : uvec2 - Returns the width and height dimensions of a texture at a specified mip level. +- **texture**: `Texture | Node` - The texture whose dimensions to query. +- **level**: `int` - (Optional) The mip level to query. Defaults to `0`. +::: + +::: api sampler( value ) : Node - Converts a texture into a GPU sampler. +- **value**: `Texture | Node` - The texture instance or node. +::: + +### Methods + +::: api .uv( uvNode: vec2 ) : Node - Returns a sample of the texture using new UV coordinates. ::: + +::: api .level( levelNode: int ) : Node - Explicitly selects the mipmap level for sampling. ::: + +::: api .bias( biasNode: float ) : Node - Applies a level-of-detail bias to mipmap selection. ::: + +::: api .size( level?: int ) : uvec2 - Returns the dimensions of the texture at the specified mip level. ::: + +::: api .sample( uvNode: vec2 ) : vec4 - Samples the texture with filtering at the given UV coordinates. ::: + +::: api .load( uvNode: ivec2 ) : vec4 - Loads the texel at the given pixel coordinates without filtering. ::: + +```tsl textureExample +import 'scenes/shaderball'; +import * as THREE from 'three'; +import { texture, uv, vec2, time } from 'three/tsl'; + +// 1. Load a texture map +const loader = new THREE.TextureLoader(); +const map = loader.load( '../examples/textures/uv_grid_opengl.jpg' ); +map.wrapS = THREE.RepeatWrapping; +map.wrapT = THREE.RepeatWrapping; + +// 2. Create an animated texture node with tiling and panning UVs +const animatedUV = uv().mul( 3.0 ).add( vec2( time.mul( 0.05 ), 0.0 ) ); +const mapNode = texture( map, animatedUV ); + +// 3. Composite texture color with roughness modulation +model.material.colorNode = mapNode.rgb; +``` + +> IA: Avoid using `textureNode.uv( uv() )` to sample a texture with different coordinates; use `textureNode.sample( uv() )` instead. Calling `.uv()` mutates the texture node's UV property, whereas `.sample( customUV )` performs a dedicated sample operation at the given coordinates. + + + + + +Uniforms are useful to update values of variables like colors, lighting, or transformations without having to recreate the shader program. They are the true variables from a GPU. + +Uniform material update example +Uniform inline update example + +::: api uniform( value, type? ) +- **value**: `boolean | number | Color | Vector2 | Vector3 | Vector4 | Matrix3 | Matrix4` - Dynamic value to initialize the uniform with. +- **type**: `string` - (Optional) Explicit TSL type name (e.g. `'float'`, `'vec3'`, etc.). Defaults to `null`. +::: + +It is also possible to create update events on `uniforms`, which can be defined by the user: + +::: api .onObjectUpdate( callback: Function ) - It will be updated every time an object like `Mesh` is rendered with this `Node` in `Material`. ::: + +::: api .onRenderUpdate( callback: Function ) - It will be updated once per render, common and shared materials, fog, tone mapping, etc. ::: + +::: api .onFrameUpdate( callback: Function ) - It will be updated only once per frame, regardless of when `render-pass` the frame has, cases like `time` for example. ::: + +```tsl uniformEventUpdate +import 'scenes/shaderball'; +import { uniform, Fn, OnMaterialUpdate } from 'three/tsl'; + +const main = Fn( () => { + + const ramp = uniform( 0 ); + + OnMaterialUpdate( ( { time } ) => { + + // update uniform value + ramp.value = Math.abs( Math.sin( time ) ); + + } ); + + return ramp; + +} ); + +model.material.colorNode = main(); +``` + +```tsl uniformInlineUpdate +import 'scenes/shaderball'; +import { uniform, color } from 'three/tsl'; + +// Inline update using onFrameUpdate event +const ramp = uniform( 0 ).onFrameUpdate( ( { time } ) => time % 1.0 ); + +// Assign to colorNode +model.material.colorNode = ramp.mul( color( 0x1e90ff ) ); +``` + + + + + +Uniform groups allow grouping multiple uniforms into a single Uniform Buffer Object (UBO) on the GPU. This improves performance by reducing the number of individual uniform transfers. + +Predefined group example +Custom group example + +::: api uniform.setGroup( group ) - Assigns the uniform to a specific uniform group. +- **group**: `UniformGroupNode` - The uniform group node (e.g. `objectGroup`, `renderGroup`, `frameGroup` or a custom group). +::: + +By default, all uniforms belong to the predefined `objectGroup` (updated once per object). However, you can create custom uniform groups to control exactly when groups of related values are updated and uploaded to the GPU as a single block of memory (Uniform Buffer Object). + +::: api uniformGroup( name ) - Creates a custom uniform group. +- **name**: `string` - The group name. +::: + +::: api sharedUniformGroup( name ) - Creates a shared custom uniform group. +- **name**: `string` - The group name. +::: + +#### Predefined Groups + +- **`objectGroup`**: (Default) Updated once per object. Good for uniforms that vary between meshes. +- **`renderGroup`**: Shared group updated once per render call. Used for uniforms like lights, view/projection matrices, fog settings, and camera properties. +- **`frameGroup`**: Shared group updated once per frame. Used for uniforms that update once per frame, like global time or frame IDs. + +```tsl predefinedUniformGroupExample +import 'scenes/shaderball'; +import { uniform, color, renderGroup } from 'three/tsl'; + +// Create a uniform in the renderGroup (updated once per render call) +const myTimer = uniform( 0 ).setGroup( renderGroup ).onRenderUpdate( ( { time } ) => time ); + +// Use the render-grouped uniform to animate the color +model.material.colorNode = myTimer.sin().mul( color( 0x1e90ff ) ); +``` + +```tsl customUniformGroupExample +import 'scenes/shaderball'; +import { uniform, color, uniformGroup, Fn, OnMaterialUpdate } from 'three/tsl'; + +// 1. Create a custom uniform group +const configGroup = uniformGroup( 'config' ); + +// 2. Create uniforms and associate them with the group +const intensity = uniform( 1.0 ).setGroup( configGroup ); +const tintColor = uniform( color( 0x1e90ff ) ).setGroup( configGroup ); + +model.material.colorNode = Fn( () => { + + // 3. Update the values dynamically and flag the group for update + OnMaterialUpdate( ( { time } ) => { + + intensity.value = Math.abs( Math.sin( time ) ); + configGroup.needsUpdate = true; // Marks the entire group (UBO) to be uploaded to the GPU + + } ); + + return tintColor.mul( intensity ); + +} )(); +``` + + + + + +It is possible to use the same [Array](#array) logic for uniforms using Three.js native components or primitive values. + +::: api uniformArray( values, type? ) +- **values**: `Array` - Array of initial values (e.g. `Color`, `Vector3`, numbers, etc.). +- **type**: `string` - (Optional) Explicit TSL type name (e.g. `'color'`, `'vec3'`, etc.). Defaults to `null`. +::: + +Example: + +```js +const tintColors = uniformArray( [ + new Color( 1, 0, 0 ), + new Color( 0, 1, 0 ), + new Color( 0, 0, 1 ) +] ); + +const redColor = tintColors.element( 0 ); +``` + +```tsl +import 'scenes/shaderball'; +import * as THREE from 'three'; +import { uniformArray, int, time } from 'three/tsl'; + +// Define a uniform array of Colors using THREE.Color +const tintColors = uniformArray( [ + new THREE.Color( 1, 0, 0 ), // Red + new THREE.Color( 0, 1, 0 ), // Green + new THREE.Color( 0, 0, 1 ) // Blue +] ); + +// Dynamically select the element index based on time +const index = int( time.mul( 1.5 ).mod( 3 ) ); + +// Apply the selected color to the sphere +model.material.colorNode = tintColors.element( index ); +``` + + + + + +Storage buffers provide read/write GPU memory for compute shaders and vertex/fragment rendering pipelines. + +Unlike standard uniforms, storage buffers can be modified directly on the GPU — enabling high-performance particle physics, GPGPU simulations, and procedural geometry operations without CPU roundtrips. + +Compute Storage Buffer + +### Functions + +::: api storage( value, type?, count? ) : Node - Creates a storage buffer node for read/write GPU buffer access. +- **value**: `StorageBufferAttribute | StorageInstancedBufferAttribute | BufferAttribute` - The buffer data attribute. +- **type**: `string` - (Optional) TSL type name (e.g. `'float'`, `'vec3'`, `'mat4'`, or a Struct). +- **count**: `number` - (Optional) Number of elements in the buffer. +::: + +::: api storageBarrier() : Node - Emits a memory barrier ensuring all pending storage reads and writes are synchronized across GPU invocations. +::: + +### Methods + +::: api .element( index: int ) : Node - Accesses an element in the storage buffer at the specified index. ::: + +::: api .toAttribute() : Node - Converts the storage buffer into an attribute node for vertex or instance rendering. ::: + +::: api .toReadOnly() : Node - Sets the storage buffer access mode to read-only. ::: + +::: api .toWriteOnly() : Node - Sets the storage buffer access mode to write-only. ::: + +::: api .toReadWrite() : Node - Sets the storage buffer access mode to read-write. ::: + +::: api .toAtomic() : Node - Configures the storage buffer for atomic operations. ::: + +```tsl storageExample +import 'scenes/shaderball'; +import * as THREE from 'three'; +import { storage, Fn, instanceIndex, time, float, color, positionLocal, normalLocal } from 'three/tsl'; + +// 1. Create a storage buffer for dynamic vertex displacement +const count = 1024; +const bufferAttribute = new THREE.StorageBufferAttribute( count, 1 ); +const displacementBuffer = storage( bufferAttribute, 'float', count ); + +// 2. Compute shader that writes harmonic wave oscillations into the storage buffer +const computeDisplacement = Fn( () => { + + const idx = float( instanceIndex ); + const wave1 = time.mul( 3.0 ).add( idx.mul( 0.05 ) ).sin().mul( 0.08 ); + const wave2 = time.mul( 1.7 ).sub( idx.mul( 0.08 ) ).cos().mul( 0.04 ); + + displacementBuffer.element( instanceIndex ).assign( wave1.add( wave2 ) ); + +} )().compute( count ); + +// 3. Dispatch compute pass on each frame +export function update() { + + renderer.compute( computeDisplacement ); + +} + +// 4. Sample the computed buffer to deform the shaderball surface +const disp = displacementBuffer.element( instanceIndex.mod( count ) ); +model.material.positionNode = positionLocal.add( normalLocal.mul( disp ) ); + +// 5. Color the mesh based on displacement intensity +const heatColor = disp.mul( 10.0 ).add( 0.5 ); +model.material.colorNode = heatColor.mix( color( 0x112244 ), color( 0x00ffcc ) ); +``` + +#### Related +- [Storage Texture](#storage-texture) +- [Storage Array](#storage-array) +- [Compute Stage](#compute-stage) +- [Atomic](#atomic) + + + + + +Storage textures allow compute shaders to read and write pixel/texel data directly on the GPU. + +They are ideal for procedural texture generation, image processing filters, fluid simulations, and GPGPU cellular automata. + +Compute Storage Texture + +### Functions + +::: api storageTexture( value, uv? ) : vec4 - Creates a storage texture node for read/write texel access. +- **value**: `StorageTexture` - The storage texture instance. +- **uv**: `uvec2 | vec2` - (Optional) Texel coordinates. +::: + +::: api textureStore( texture, uv, value ) : Node - Writes a value to a storage texture at specified texel coordinates. +- **texture**: `StorageTexture | Node` - The storage texture instance or node. +- **uv**: `uvec2 | vec2` - Texel coordinate where the value will be stored. +- **value**: `vec4` - The color or data value to write. +::: + +::: api storageTexture3D( value, uv? ) : vec4 - Creates a 3D volumetric storage texture node. +- **value**: `Storage3DTexture` - The 3D storage texture instance. +- **uv**: `uvec3 | vec3` - (Optional) 3D texel coordinates. +::: + +### Methods + +::: api .toWriteOnly() : Node - Sets the storage texture access mode to write-only. ::: + +::: api .toReadOnly() : Node - Sets the storage texture access mode to read-only. ::: + +::: api .toReadWrite() : Node - Sets the storage texture access mode to read-write. ::: + +::: api .setMipLevel( level: int ) : Node - Sets the mipmap level to write to. ::: + +```tsl storageTextureExample +import 'scenes/shaderball'; +import * as THREE from 'three'; +import { Fn, instanceIndex, float, uvec2, vec2, vec3, vec4, texture, textureStore, time, color, mx_fractal_noise_float, mx_noise_vec3 } from 'three/tsl'; + +// 1. Create a 256x256 StorageTexture on the GPU +const width = 256, height = 256; +const storageTex = new THREE.StorageTexture( width, height ); + +// 2. Define a compute shader that writes seamless procedural noise into the storage texture +const computeTexture = Fn( () => { + + const posX = instanceIndex.mod( width ); + const posY = instanceIndex.div( width ); + const indexUV = uvec2( posX, posY ); + + // Normalized texture coordinates + const uvCoord = vec2( float( posX ).div( float( width ) ), float( posY ).div( float( height ) ) ); + + // Seamless periodic torus mapping (eliminates all texture UV seams) + const angleU = uvCoord.x.mul( Math.PI * 2.0 ); + const angleV = uvCoord.y.mul( Math.PI * 2.0 ); + + const torusX = angleU.cos().mul( 1.5 ).add( angleV.cos().mul( 0.5 ) ); + const torusY = angleU.sin().mul( 1.5 ).add( angleV.cos().mul( 0.5 ) ); + const torusZ = angleV.sin().mul( 1.5 ); + + // Animate noise domain with time + const speed = time.mul( 0.3 ); + const noiseInput = vec3( torusX, torusY, torusZ.add( speed ) ); + + // Domain warped organic fractal noise + const warp = mx_noise_vec3( noiseInput ).mul( 0.35 ); + const n = mx_fractal_noise_float( noiseInput.add( warp ), 4 ); + + // Color mapping: deep indigo -> vibrant cyan -> glowing gold + const colA = color( 0x050818 ); + const colB = color( 0x00d4ff ); + const colC = color( 0xff9900 ); + + const col = n.mix( colA, n.mul( 1.5 ).mix( colB, colC ) ); + + textureStore( storageTex, indexUV, vec4( col, 1.0 ) ).toWriteOnly(); + +} )().compute( width * height ); + +// 3. Compute texture updates on each frame +export function update() { + + renderer.compute( computeTexture ); + +} + +// 4. Sample the storage texture in the shaderball material +const texNode = texture( storageTex ); +model.material.colorNode = texNode; +``` + +#### Related +- [Storage](#storage) +- [Texture](#texture) +- [Compute Stage](#compute-stage) + + + + + +It is possible to create arrays that can be used in compute shaders and storage operations. + +Under the hood, `instancedArray` creates a `StorageInstancedBufferAttribute`: + +::: api instancedArray( array, type ) +- **array**: `TypedArray | Array` - Primitive values or typed arrays to initialize the buffer. +- **type**: `string` - TSL type name (e.g. `'float'`, `'vec3'`, etc.). +::: + +Under the hood, `attributeArray` creates a `StorageBufferAttribute`: + +::: api attributeArray( array, type ) +- **array**: `TypedArray | Array` - Primitive values or typed arrays to initialize the buffer. +- **type**: `string` - TSL type name (e.g. `'float'`, `'vec3'`, etc.). +::: + +Example: + +```js +const myArray = attributeArray( new Float32Array( [ 0.05, 0.1, 0.15 ] ), 'float' ); +``` + +```tsl +import 'scenes/shaderball'; +import { attributeArray, positionLocal, normalLocal, time, int, vec3 } from 'three/tsl'; + +// Define a palette of 6 colors in a Float32Array (r, g, b components) +const colorPalette = attributeArray( new Float32Array( [ + 0.95, 0.15, 0.15, // Hot Red + 0.95, 0.45, 0.00, // Vivid Orange + 0.95, 0.85, 0.00, // Neon Yellow + 0.05, 0.85, 0.45, // Teal Green + 0.05, 0.45, 0.95, // Bright Blue + 0.75, 0.05, 0.95 // Electric Purple +] ), 'vec3' ); + +// Calculate the 3D distance from the center of the preview sphere (0, 1, 0) +const sphereCenter = vec3( 0.0, 1.0, 0.0 ); +const distance = positionLocal.sub( sphereCenter ).length().mul( 4.0 ); + +// Animate concentric rings expanding outwards over time +const scroll = distance.sub( time.mul( 1.5 ) ).fract(); + +// Index into the color palette based on the scroll factor +const index = int( scroll.mul( 5.9 ) ); +const stripeColor = colorPalette.element( index ); + +// Generate physical concentric ridges matching the color wave +const wave = scroll.mul( 3.14159 ).sin().pow( 4.0 ).mul( 0.025 ); +model.material.positionNode = positionLocal.add( normalLocal.mul( wave ) ); + +// Apply the scrolling palette colors to the shaderball material +model.material.colorNode = stripeColor; +``` + + + + + + + + + +TSL provides dedicated accessor nodes to query geometric properties — such as positions, normals, tangents, and bitangents — across each stage of the GPU transformation pipeline. + +Understanding coordinate spaces is essential for procedural shading, lighting calculations, triplanar texturing, normal mapping, and view-dependent effects. + +### MVP Pipeline (Model - View - Projection) + +The standard rendering pipeline transforms vertex positions forward through **Model**, **View**, and **Projection** matrices: + +```mermaid +flowchart LR + Geom["Geometry
positionGeometry
Raw Buffer
"] + Local["Local
positionLocal
Object Center
Skinning & Morphing
"] + World["World
positionWorld
Global Scene
"] + View["View
positionView
Camera Eye
"] + Clip["Clip
modelViewProjection
Projected Clip
"] + + Geom --> Local + Local --> World + World --> View + View --> Clip +``` + +### Coordinate Spaces Overview + +| Space | Origin | Description & Use Cases | +| :--- | :--- | :--- | +| **Geometry** | Raw buffer | Raw, unmodified vertex attribute buffer before any CPU or GPU transformations. Ideal for rest-pose computations and base coordinate derivations. | +| **Local (Object)** | Mesh object | Object-space coordinates after applying GPU transformations (skeletal skinning, blend shapes, morph targets). Used for procedural textures that transform with the mesh. | +| **World** | Scene global | Global scene coordinates. Essential for scene lighting, shadow projections, world-space reflections, triplanar mapping, and cross-object interactions. | +| **View (Camera)** | Active camera eye position | Coordinates relative to the camera eye. Essential for Fresnel edge glow, view-dependent specular highlights, matcaps, and camera distance effects. | + +
+ + + +Position nodes provide access to the coordinates of vertices or fragments at different transformation stages. In TSL, these values are mapped to specific [Coordinate Spaces](#coordinate-spaces) (Geometry, Local, World, or View) to allow precise control over vertex displacement, morphing, and view-dependent effects. + +Local vs World fSpace + +::: api positionGeometry : vec3 - Position attribute of geometry. ::: + +::: api positionLocal : vec3 - Transformed local position. ::: + +::: api positionWorld : vec3 - Transformed world position. ::: + +::: api positionWorldDirection : vec3 - Normalized world direction. ::: + +::: api positionView : vec3 - View position. ::: + +::: api positionViewDirection : vec3 - Normalized view direction. ::: + +> Note: The transformed term reflects the modifications applied by processes such as **skinning**, **morphing**, and similar techniques. + +```tsl positionExample +import 'scenes/shaderball'; +import { positionLocal, positionWorld, Fn, float, fract, abs, fwidth, max, saturate, color } from 'three/tsl'; + +// Simple 3D grid generator +const grid = Fn( ( [ pos ] ) => { + + const scale = pos.mul( 8.0 ); + const g = fract( scale ); + const fw = fwidth( scale ); + const dist = abs( g.sub( 0.5 ) ); + const line = saturate( float( 0.05 ).sub( dist ).div( fw ).add( 0.5 ) ); + return max( line.x, line.y, line.z ); + +} ); + +// Split the model: Left side uses positionLocal, Right side uses positionWorld +const isRightSide = positionWorld.x.greaterThan( 0.0 ); +const coords = isRightSide.select( positionWorld, positionLocal ); + +// Render the grid: the left side rotates, the right side stays static in space! +const gridLines = grid( coords ); +const stripeColor = isRightSide.select( color( 0x06b6d4 ), color( 0xec4899 ) ); // Cyan (World) vs Pink (Local) + +model.material.colorNode = gridLines.mix( color( 0x1f2937 ), stripeColor ); +``` + + + + + +Normal nodes provide access to surface direction vectors at different transformation stages. In TSL, these values are mapped to specific [Coordinate Spaces](#coordinate-spaces) (Geometry, Local, World, or View) to allow precise control over lighting, reflections, and normal mapping. + +::: api normalGeometry : vec3 - Normal attribute of geometry. ::: + +::: api normalLocal : vec3 - Local variable for normal. ::: + +::: api normalView : vec3 - Normalized transformed view normal. ::: + +::: api normalViewGeometry : vec3 - Normalized view normal. ::: + +::: api normalWorld : vec3 - Normalized transformed world normal. ::: + +::: api normalWorldGeometry : vec3 - Normalized world normal. ::: + +> Note: The transformed term here also includes following the correct orientation of the face, so that the normals are inverted inside the geometry. + +```tsl +import 'scenes/shaderball'; +import * as THREE from 'three'; +import { normalView, positionViewDirection, color } from 'three/tsl'; + +// Calculate X-ray factor (opaque at edges, transparent in the center) +const viewDot = normalView.dot( positionViewDirection ).clamp(); +const xray = viewDot.oneMinus().pow( 2.0 ); + +// Assign glowing cyan color and map the X-ray factor to the opacity +model.material.colorNode = color( 0x00f3ff ); +model.material.opacityNode = xray; +model.material.transparent = true; +model.material.side = THREE.DoubleSide; +``` + + + + + +Tangent nodes provide access to surface tangent vectors at different transformation stages. In TSL, these values are mapped to specific [Coordinate Spaces](#coordinate-spaces) (Geometry, Local, World, or View) to allow precise control over normal mapping, anisotropic reflections, and local coordinate orientation. + +Anisotropic Directional Glow + +::: api tangentGeometry : vec4 - Tangent attribute of geometry. ::: + +::: api tangentLocal : vec3 - Local variable for tangent. ::: + +::: api tangentView : vec3 - Normalized transformed view tangent. ::: + +::: api tangentWorld : vec3 - Normalized transformed world tangent. ::: + +```tsl tangentExample +import 'scenes/shaderball'; +import * as THREE from 'three'; +import { tangentView, positionViewDirection, color } from 'three/tsl'; + +// Calculate alignment between view-space tangents and view direction +const alignment = tangentView.dot( positionViewDirection ).abs(); +const edgeGlow = alignment.pow( 4.0 ); // Concentrated highlight on the left and right edges + +// Mix a dark background with a glowing neon purple directional highlight +model.material = new THREE.NodeMaterial(); +model.material.colorNode = edgeGlow.mix( color( 0x070c1b ), color( 0xbd00ff ) ); +``` + + + + + +Bitangent nodes provide access to surface bitangent vectors at different transformation stages. In TSL, these values are mapped to specific [Coordinate Spaces](#coordinate-spaces) (Geometry, Local, World, or View). Together with normals and tangents, they complete the three-dimensional local coordinate basis (TBN) at the surface of the geometry. + +Vertical Anisotropic Glow + +::: api bitangentGeometry : vec3 - Normalized bitangent in geometry space. ::: + +::: api bitangentLocal : vec3 - Normalized bitangent in local space. ::: + +::: api bitangentView : vec3 - Normalized transformed bitangent in view space. ::: + +::: api bitangentWorld : vec3 - Normalized transformed bitangent in world space. ::: + +```tsl bitangentExample +import 'scenes/shaderball'; +import * as THREE from 'three'; +import { bitangentView, positionViewDirection, color } from 'three/tsl'; + +// Calculate alignment between view-space bitangents and view direction +const alignment = bitangentView.dot( positionViewDirection ).abs(); +const edgeGlow = alignment.pow( 4.0 ); // Concentrated highlight on the top and bottom edges + +// Mix a dark background with a glowing warm gold directional highlight +model.material = new THREE.NodeMaterial(); +model.material.colorNode = edgeGlow.mix( color( 0x0a0603 ), color( 0xffaa00 ) ); +``` + + + + + +Camera nodes provide access to the active camera's parameters, transformation matrices, and spatial orientation properties. These are crucial for depth-based calculations, projection transformations, and screen-space coordinates. + +Dithering Dissolve + +::: api cameraNear : float - Near plane distance of the camera. ::: + +::: api cameraFar : float - Far plane distance of the camera. ::: + +::: api cameraProjectionMatrix : mat4 - Projection matrix of the camera. ::: + +::: api cameraProjectionMatrixInverse : mat4 - Inverse projection matrix of the camera. ::: + +::: api cameraViewMatrix : mat4 - View matrix of the camera. ::: + +::: api cameraWorldMatrix : mat4 - World matrix of the camera. ::: + +::: api cameraNormalMatrix : mat3 - Normal matrix of the camera. ::: + +::: api cameraPosition : vec3 - World position of the camera. ::: + +```tsl cameraExample +import 'scenes/shaderball'; +import { cameraPosition, positionWorld, viewportCoordinate, color, float } from 'three/tsl'; + +// 1. Calculate the distance from the camera to the surface +const distanceToCamera = cameraPosition.distance( positionWorld ); + +// 2. Define a dissolve threshold that increases (from 0 to 1) as the camera gets closer +// It starts dissolving at 5.0 units away, and is completely dissolved at 1.5 units. +const dissolveStart = float( 5.0 ); +const dissolveEnd = float( 1.5 ); +const threshold = dissolveStart.sub( distanceToCamera ).div( dissolveStart.sub( dissolveEnd ) ).clamp( 0.0, 1.0 ); + +// 3. Generate a screen-space pseudo-random dither threshold based on pixel coordinates +const pixelCoords = viewportCoordinate.floor(); +const ditherVal = pixelCoords.x.mul( 12.9898 ).add( pixelCoords.y.mul( 78.233 ) ).sin().mul( 43758.5453 ).fract(); + +// 4. Assign the dither comparison as the material's maskNode (true to keep, false to discard) +model.material.maskNode = ditherVal.greaterThanEqual( threshold ); + +// Set a glowing orange color +model.material.colorNode = color( 0xff5500 ); +``` + + + + + +Model nodes provide access to the object's transformation matrices, scale, position, and orientation properties. These are crucial for converting coordinates from local to world space, and adjusting material properties dynamically based on the object's physical transform in the scene. + +Pulsing Energy Ripples + +::: api modelDirection : vec3 - Direction of the model. ::: + +::: api modelViewMatrix : mat4 - View-space matrix of the model. ::: + +::: api modelNormalMatrix : mat3 - View-space matrix of the model. ::: + +::: api modelWorldMatrix : mat4 - World-space matrix of the model. ::: + +::: api modelPosition : vec3 - Position of the model. ::: + +::: api modelScale : vec3 - Scale of the model. ::: + +::: api modelViewPosition : vec3 - View-space position of the model. ::: + +::: api modelWorldMatrixInverse : mat4 - Inverse world matrix of the model. ::: + +::: api highpModelViewMatrix : mat4 - View-space matrix of the model computed on CPU using 64-bit. ::: + +::: api highpModelNormalViewMatrix : mat3 - View-space normal matrix of the model computed on CPU using 64-bit. ::: + +```tsl modelExample +import 'scenes/shaderball'; +import { positionWorld, modelPosition, time, color } from 'three/tsl'; + +// Calculate the world-space vector from the model's center pivot +const localOffset = positionWorld.sub( modelPosition ); + +// Get the distance from the center of the model +const distance = localOffset.length(); + +// Create animated concentric wave ripples expanding from the model's center +const wave = distance.sub( time.mul( .3 ) ).mul( 7.0 ); +const ripple = wave.sin().abs().oneMinus().pow( 3.0 ); // Soft, high-contrast glow bands + +// Mix a sleek dark metallic blue with glowing neon energy ripples +model.material.colorNode = ripple.mix( color( 0x050c18 ), color( 0xffaa00 ) ); +``` + + + + + +Screen nodes return values related to the current frame buffer, either normalized or in physical pixel units considering the current device pixel ratio (DPR). + +Screen-Space Projection + +::: api screenUV : vec2 - Returns the normalized frame buffer coordinate. ::: + +::: api screenCoordinate : vec2 - Returns the frame buffer coordinate in physical pixel units. ::: + +::: api screenSize : vec2 - Returns the frame buffer size in physical pixel units. ::: + +::: api screenDPR : float - Returns the device pixel ratio (DPR). ::: + +```tsl screenExample +import 'scenes/shaderball'; +import * as THREE from 'three'; +import { screenUV, texture } from 'three/tsl'; + +// Load a test grid texture and disable flipY on the texture instance +const map = new THREE.TextureLoader().load( '../examples/textures/uv_grid_opengl.jpg' ); +map.flipY = false; + +// Project the texture directly onto screen-space coordinates +// The texture will appear completely fixed to the 2D screen as you orbit or pan the camera! +model.material.colorNode = texture( map, screenUV ); +``` + + + + + +Viewport nodes return values and textures representing the screen-space viewport area. They are relative to the active viewport region and support physical pixel units, enabling advanced screen-space effects like refraction, depth testing, and volumetric rendering. + +Glass Refraction +Depth Refraction +Private Glass +Invert Glass + +::: api viewport : vec4 - Returns the viewport dimension in physical pixel units. ::: + +::: api viewportUV : vec2 - Returns the normalized viewport coordinate. ::: + +::: api viewportCoordinate : vec2 - Returns the viewport coordinate in physical pixel units. ::: + +::: api viewportSize : vec2 - Returns the viewport size in physical pixel units. ::: + +### Texture + +::: api viewportSharedTexture( uv?, level? ) - Accesses the screen framebuffer texture already rendered in the current scene, sharing a single texture instance across all calls for optimal performance while preserving render order. +- **uv**: `Node` - (Optional) Coordinate node used for sampling the shared viewport texture. Defaults to `screenUV`. +- **level**: `Node` - (Optional) Mipmap level node to sample from. Defaults to `null`. +::: + +::: api viewportMipTexture( uv?, level?, framebufferTexture? ) - Returns a viewport texture with mipmap generation enabled for blurred or LOD screen-space effects. +- **uv**: `Node` - (Optional) Coordinate node used for sampling the viewport texture. Defaults to `screenUV`. +- **level**: `Node` - (Optional) Mipmap level node to sample from. Defaults to `null`. +- **framebufferTexture**: `FramebufferTexture` - (Optional) Custom framebuffer texture instance. Defaults to `null`. +::: + +### Depth + +::: api viewportLinearDepth : float - Returns the linear (orthographic) depth value of the current fragment. ::: + +::: api viewportDepthTexture( uv?, level? ) - Returns the depth texture of the current viewport for screen-space depth evaluation and volume effects. +- **uv**: `Node` - (Optional) Coordinate node used for sampling the depth texture. Defaults to `screenUV`. +- **level**: `Node` - (Optional) Mipmap level node to sample from. Defaults to `null`. +::: + +### Utils + +::: api viewportSafeUV( uv? ) - Generates depth-aware safe UV coordinates for screen-space refraction. Performs depth testing to prevent foreground objects located in front of the refractive surface from leaking into the refraction sample. Returns `vec2`. +- **uv**: `vec2` - (Optional) Refracted UV coordinate node to evaluate. Defaults to `screenUV`. +::: + +```tsl refractionExample +import 'scenes/shaderball'; +import { color, normalLocal, positionLocal, modelNormalMatrix, viewportUV, viewportSharedTexture, positionView, positionViewDirection } from 'three/tsl'; + +// 1. Isolate high-frequency surface details by subtracting +// the smooth base normal from the actual geometry normal +const smoothNormal = positionLocal.normalize(); +const detailNormal = normalLocal.sub( smoothNormal ); + +// 2. Transform the detail normal to view-space +const detailNormalView = modelNormalMatrix.mul( detailNormal ); + +// 3. Calculate a refracted UV coordinate using only the details normal (scaled by camera distance) +const distance = positionView.negate().dot( positionViewDirection ); +const refractedUV = viewportUV.add( detailNormalView.xy.mul( 0.4 ).div( distance ) ); + +// 4. Sample the background scene using the refracted UV +model.material.backdropNode = viewportSharedTexture( refractedUV ).mul( color( 0x7dd3fc ) ); +model.material.transparent = true; +``` + +```tsl depthVolumeExample +import 'scenes/shaderball'; +import { color, normalLocal, positionLocal, modelNormalMatrix, viewportUV, viewportSharedTexture, positionView, positionViewDirection, viewportLinearDepth, linearDepth, cameraNear, cameraFar } from 'three/tsl'; +import { hashBlur } from 'three/addons/tsl/display/hashBlur.js'; + +// 1. Isolate high-frequency surface details by subtracting +// the smooth base normal from the actual geometry normal +const smoothNormal = positionLocal.normalize(); +const detailNormal = normalLocal.sub( smoothNormal ); + +// 2. Transform the detail normal to view-space +const detailNormalView = modelNormalMatrix.mul( detailNormal ); + +// 3. Calculate a refracted UV coordinate using only the details normal (scaled by camera distance) +const distance = positionView.negate().dot( positionViewDirection ); +const refractedUV = viewportUV.add( detailNormalView.xy.mul( 0.4 ).div( distance ) ); + +// 4. Calculate the distance (thickness) between the surface and the background in actual scene units +const thickness = viewportLinearDepth.sub( linearDepth() ).mul( cameraFar.sub( cameraNear ) ); + +// 5. Compute the blur amount based on depth (objects further behind look blurrier) +const blurAmount = thickness.mul( 0.025 ).clamp( 0.0, 0.12 ); + +// 6. Sample the background scene with hash-blur at the refracted coordinates +model.material.backdropNode = hashBlur( viewportSharedTexture( refractedUV ), blurAmount ).mul( color( 0x7dd3fc ) ); +model.material.transparent = true; +``` + +```tsl privateGlassExample +import 'scenes/shaderball'; +import { viewportSharedTexture, viewportUV, viewportSize, vec2 } from 'three/tsl'; + +// 1. Correct for screen aspect ratio to keep the mosaic cells perfectly square +const blocksY = 40.0; +const blocksX = viewportSize.x.div( viewportSize.y ).mul( blocksY ); +const blocks = vec2( blocksX, blocksY ); + +// 2. Quantize the screen coordinates into a grid (pixelation effect) +const pixelGrid = viewportSharedTexture( viewportUV.mul( blocks ).floor().div( blocks ) ); + +// 3. Sample the standard texture at the quantized screen coordinates +model.material.colorNode = pixelGrid; +model.material.transparent = true; +``` + +```tsl invertExample +import 'scenes/shaderball'; +import * as THREE from 'three'; +import { viewportSharedTexture } from 'three/tsl'; + +// Replace the material with a transparent NodeMaterial +model.material = new THREE.NodeMaterial(); +model.material.colorNode = viewportSharedTexture().rgb.oneMinus(); +model.material.transparent = true; +``` + + + +
+ + + + + +In Three.js WebGPU and TSL, lighting is fully node-based. Scene lights (such as `DirectionalLight`, `PointLight`, `SpotLight`, `HemisphereLight`, `AmbientLight`, and `RectAreaLight`) are automatically converted into analytic light node graphs that evaluate direct and indirect lighting terms during shader compilation. + +The lighting pipeline is orchestrated through two core components: +- **`LightsNode`**: Collects scene lights and calculates the total outgoing diffuse (`totalDiffuse`) and specular (`totalSpecular`) illumination. +- **`LightingContextNode`**: Provides runtime lighting context (`reflectedLight` with `directDiffuse`, `directSpecular`, `indirectDiffuse`, `indirectSpecular`) to the active `LightingModel` (Standard, Physical, Phong, Lambert, Toon). + +You can decouple an individual material from global scene lights by assigning a custom `lights( [ ... ] )` node to `material.lightsNode`. + +TSL Lighting System + +::: api lights( lights: Array = [] ) : LightsNode - Creates a lighting node that manages a specific set of lights and their shadow evaluations. +- **lights**: `Array` - (Optional) Array of Three.js light instances to include in the lighting group. +::: + +::: api material.lightsNode : LightsNode - Property on NodeMaterial to override or isolate the lights illuminating the material. ::: + +::: api lightingContext( lightsNode: LightsNode, lightingModel: LightingModel = null ) : LightingContextNode - Wraps lighting execution within a custom lights node and optional lighting model. +- **lightsNode**: `LightsNode` - The target lights node to evaluate. +- **lightingModel**: `LightingModel` - (Optional) Custom lighting model (e.g. Lambert, Phong, Standard, Physical, Toon). +::: + +```tsl tslLightingSystem +import 'scenes/shaderball'; +import * as THREE from 'three'; +import { lights, color } from 'three/tsl'; + +// 1. Create distinct scene lights +const keyLight = new THREE.PointLight( 0x00d4ff, 80, 10 ); +const rimLight = new THREE.PointLight( 0xff0066, 120, 10 ); + +scene.add( keyLight ); +scene.add( rimLight ); + +// 2. Isolate lighting on the model using a custom lights node +model.material.lightsNode = lights( [ keyLight, rimLight ] ); + +model.material.colorNode = color( 0xffffff ); +model.material.roughness = 0.2; +model.material.metalness = 0.6; + +// 3. Animate orbiting lights in world space +export function update() { + + const t = performance.now() * 0.0015; + + keyLight.position.set( Math.cos( t ) * 3.0, 2.0, Math.sin( t ) * 3.0 ); + rimLight.position.set( Math.cos( t + Math.PI ) * 3.5, 1.5, Math.sin( t + Math.PI ) * 3.5 ); + +} +``` + +### Lights API Reference + +| API | Type | Description | +| :--- | :--- | :--- | +| `lights( lightsArray )` | Function | Creates a `LightsNode` containing a designated array of lights. | +| `material.lightsNode` | Property | Per-material override for scene light sources. | +| `lightingContext( lightsNode, model )` | Function | Wraps lighting computation within a custom lights node and lighting model. | + +#### Related +- [Light Functions](#light-functions) +- [Shadows](#shadows) +- [Projector Light](#projector-light) +- [Material Inputs](#material-inputs) + + + + + +TSL provides specialized accessor functions to query light transforms, matrices, view vectors, and shadow projection coordinates directly in shader graphs. + +These functions return uniform nodes that update automatically when light objects move or rotate in the scene. + +Light Functions + +::: api lightPosition( light: Light ) : vec3 - Returns a uniform node representing the light source's position in world space. +- **light**: `Light` - The light instance to access. +::: + +::: api lightTargetPosition( light: Light ) : vec3 - Returns a uniform node for the target position of a directional or spot light in world space. +- **light**: `Light` - The light instance to access. +::: + +::: api lightViewPosition( light: Light ) : vec3 - Returns a uniform node representing the light source's position in camera view space. +- **light**: `Light` - The light instance to access. +::: + +::: api lightTargetDirection( light: Light ) : vec3 - Returns the normalized target direction vector of the light in camera view space. +- **light**: `Light` - The light instance to access. +::: + +::: api lightShadowMatrix( light: Light ) : mat4 - Returns the shadow projection matrix uniform node for the specified light. +- **light**: `Light` - The light source whose shadow matrix to retrieve. +::: + +::: api lightProjectionUV( light: Light, position: vec3 = null ) : vec3 - Computes projected UV coordinates from a light's shadow projection matrix for spotlights and projector effects. +- **light**: `Light` - The light source used for projection. +- **position**: `vec3` - (Optional) The world-space position to project. Defaults to `positionWorld`. +::: + +```tsl lightFunctionsExample +import 'scenes/empty'; +import * as THREE from 'three'; +import { Fn, lightProjectionUV, color, time } from 'three/tsl'; + +// 1. Create a dynamic SpotLight in the empty scene +const spotLight = new THREE.SpotLight( 0xffffff, 80, 20, Math.PI / 4, 0.4 ); +spotLight.position.set( 0, 5, 0 ); +spotLight.target.position.set( 0, 0, 0 ); +spotLight.castShadow = true; + +scene.add( spotLight ); +scene.add( spotLight.target ); + +// 2. Assign a procedural projected pattern to spotLight.colorNode using lightProjectionUV() +spotLight.colorNode = Fn( () => { + + const projUV = lightProjectionUV( spotLight ); + const dist = projUV.xy.sub( 0.5 ).length(); + const rings = dist.mul( 30.0 ).sub( time.mul( 3.0 ) ).sin().mul( 0.5 ).add( 0.5 ); + + return color( 0x00d4ff ).mix( color( 0xff0066 ), rings ); + +} ); + +// 3. Add a central sphere mesh to receive the projected spotlight pattern +const geometry = new THREE.SphereGeometry( 0.8, 64, 64 ); +const material = new THREE.MeshStandardNodeMaterial( { roughness: 0.2, metalness: 0.1 } ); +const sphere = new THREE.Mesh( geometry, material ); +sphere.position.set( 0, 1.2, 0 ); +sphere.castShadow = true; +sphere.receiveShadow = true; +scene.add( sphere ); +``` + +#### Related +- [Lights](#lights) +- [Shadows](#shadows) +- [Projector Light](#projector-light) +- [Position](#position) +- [Camera](#camera) + + + + + +In Three.js WebGPU and TSL, shadows are fully node-based and integrated into the material and lighting evaluation pipeline. + +Beyond standard shadow mapping, [Node Material](#node-material) provides dedicated properties to fully customize shadow behavior: `material.castShadowNode` enables colored transmitted shadows, `material.receivedShadowNode` customizes attenuation and tinting on receiving surfaces, while `material.castShadowPositionNode` and `material.receivedShadowPositionNode` allow overriding vertex positions during shadow map generation and sampling. + +Cast Shadow Node +Displaced Shadow Position + +::: api shadow( light: Light, shadow?: LightShadow ) : ShadowNode - Creates a shadow node for directional or spot lights. +- **light**: `Light` - The shadow casting light. +- **shadow**: `LightShadow` - (Optional) The light shadow instance. Defaults to `light.shadow`. +::: + +::: api pointShadow( light: PointLight, shadow?: LightShadow ) : PointShadowNode - Creates an omnidirectional point shadow node. +- **light**: `PointLight` - The shadow casting point light. +- **shadow**: `LightShadow` - (Optional) The point light shadow instance. +::: + +`shadowPositionWorld` represents the world-space position evaluated when sampling shadow maps. It defaults to `positionWorld`, but can be overridden per-material via `receivedShadowPositionNode` or inside custom shading contexts (e.g. volumetric raymarching) via `context.shadowPositionWorld`: + +::: api shadowPositionWorld : vec3 - Accessor representing the world-space position evaluated during shadow map sampling and shadow passes. ::: + +```tsl castShadowNode +import 'scenes/plane'; +import * as THREE from 'three'; +import { texture, vec4 } from 'three/tsl'; + +// 1. Enable transmitted shadow maps on the renderer for colored translucent shadows +renderer.shadowMap.transmitted = true; + +// 2. Add key spotlight with shadows +const spotLight = new THREE.SpotLight( 0xffffff, 250, 30, Math.PI / 4, 0.5, 2.0 ); +spotLight.position.set( - 3, 6, 3 ); +spotLight.target.position.set( 0, 0, 0 ); +spotLight.castShadow = true; +spotLight.shadow.mapSize.set( 2048, 2048 ); +spotLight.shadow.camera.near = 1; +spotLight.shadow.camera.far = 15; +spotLight.shadow.bias = - 0.001; +scene.add( spotLight ); +scene.add( spotLight.target ); + +// 3. Load color map texture +const colorMap = new THREE.TextureLoader().load( '../examples/textures/colors.png' ); +colorMap.wrapS = colorMap.wrapT = THREE.RepeatWrapping; +colorMap.colorSpace = THREE.SRGBColorSpace; + +// 4. Configure plane material and enable shadow casting +plane.material = new THREE.MeshStandardNodeMaterial( { + map: colorMap, + roughness: 0.0, + metalness: 0.0, + side: THREE.DoubleSide, + transparent: true, + opacity: .2 +} ); +plane.castShadow = true; +plane.rotation.x = - Math.PI / 4; + +// 5. Cast translucent colored shadow matching the texture +plane.material.castShadowNode = vec4( texture( colorMap ).rgb, 0.8 ); + +// 6. Mark floor material to recompile and receive shadows from the new light +floor.material.needsUpdate = true; +``` + +```tsl displacedShadowPosition +import 'scenes/shaderball'; +import { Fn, positionLocal, normalLocal, sin, time, color } from 'three/tsl'; + +// 1. Procedural vertex displacement wave +const displacement = Fn( () => { + + const wave = sin( positionLocal.y.mul( 6.0 ).add( time.mul( 4.0 ) ) ).mul( 0.15 ); + return normalLocal.mul( wave ); + +} ); + +// 2. Apply displacement just to shadow map generation +const offset = displacement(); + +model.material.castShadowPositionNode = positionLocal.add( offset ); + +model.material.colorNode = color( 0x00ffaa ); +model.material.roughness = 0.3; +model.material.metalness = 0.2; +``` + + + + + +TSL allows you to extend not only materials and post-processing, but also lighting. + +`ProjectorLight` is a specialized light source that projects in a rectangular frustum (similar to a slide or video projector) instead of a standard circular cone. + +### Extending Lights with `colorNode` + +By assigning a TSL function to `light.colorNode`, you can project custom procedural patterns (such as animated water caustics, gobos, or textures) directly into the light beam. TSL automatically calculates the projected coordinates (`projectorUV`) and passes them into your shader function: + +```js +const projectorLight = new THREE.ProjectorLight(); +projectorLight.colorNode = Fn( ( [ projectorUV ] ) => { + + return projectorUV; + +} ); +``` + +In this example, a procedural caustics shader is pre-rendered once per frame using an offscreen `rtt` (Render-to-Texture) node for optimal performance, and then projected across the 3D scene. + +Projector Light Example + +```tsl projectorLightExample +import 'scenes/empty'; +import * as THREE from 'three'; +import { Fn, color, vec3, mat3, float, time, min, length, smoothstep, mx_noise_float, rtt, uv } from 'three/tsl'; + +// Reference: https://www.shadertoy.com/view/3tlfR7 (adapted from David Hoskins) + +const caustics = Fn( ( [ p, t ] ) => { + + const m = mat3( + - 2.0, - 1.0, 2.0, + 3.0, - 2.0, 1.0, + 1.0, 2.0, 2.0 + ); + + const n = mx_noise_float( p ); + const k = vec3( p, t ); + + k.assign( k.mul( m ).mul( 0.5 ) ); + const l = length( float( 0.5 ).sub( k.add( n ).fract() ) ); + + k.assign( k.mul( m ).mul( 0.4 ) ); + l.assign( min( l, length( float( 0.5 ).sub( k.add( n ).fract() ) ) ) ); + + k.assign( k.mul( m ).mul( 0.3 ) ); + l.assign( min( l, length( float( 0.5 ).sub( k.add( n ).fract() ) ) ) ); + + return l.pow( 7.0 ).mul( 25.0 ); + +} ); + +// 1. In this example, a procedural caustics shader is pre-rendered once per frame using +// an offscreen rtt (Render-to-Texture) node for optimal performance, and then projected across the 3D scene. +const causticMap = rtt( caustics( uv().sub( 0.5 ).mul( 6.0 ), time.mul( 0.4 ) ), 512, 512 ); + +// 2. Procedural water caustics projection sampling from RTT map +const projectorPattern = Fn( ( [ projectorUV ] ) => { + + const uvCoord = projectorUV.xy; + + // Sample pre-rendered caustic texture + const caustic = causticMap.sample( uvCoord ); + + // Soft rectangular aperture edge mask (vignette) + const edgeMask = smoothstep( 0.5, 0.42, uvCoord.sub( 0.5 ).abs().x ).mul( smoothstep( 0.5, 0.42, uvCoord.sub( 0.5 ).abs().y ) ); + + // Cyan aquatic light palette + const lightColor = color( 0x5abcd8 ).mul( caustic ); + + return lightColor.mul( edgeMask ); + +} ); + +// 3. Add sample 3D geometry to catch the projection and cast shadows +const objectMaterial = new THREE.MeshStandardNodeMaterial( { roughness: 0.3, metalness: 0.1, color: 0xffffff } ); + +const torusKnot = new THREE.Mesh( new THREE.TorusKnotGeometry( 0.7, 0.25, 128, 32 ), objectMaterial ); +torusKnot.position.set( 0, 1.2, 0 ); +torusKnot.castShadow = true; +torusKnot.receiveShadow = true; +scene.add( torusKnot ); + +// 4. Create ProjectorLight with custom colorNode and shadows +const projectorLight = new THREE.ProjectorLight( 0xffffff, 500 ); +projectorLight.position.set( 3, 5, 3 ); +projectorLight.target.position.set( 0, 0.5, 0 ); +projectorLight.angle = Math.PI / 5; +projectorLight.penumbra = 0.4; +projectorLight.decay = 1.5; +projectorLight.distance = 0; + +// Assign the TSL procedural projection shader +projectorLight.colorNode = projectorPattern; + +// Configure projector shadows +projectorLight.castShadow = true; +projectorLight.shadow.mapSize.set( 1024, 1024 ); +projectorLight.shadow.camera.near = 0.5; +projectorLight.shadow.camera.far = 15; +projectorLight.shadow.bias = - 0.001; + +scene.add( projectorLight ); +scene.add( projectorLight.target ); +``` + + + + + + + + + +Functions for creating fog effects in the scene. Assign the fog node to `scene.fogNode`. + +Volumetric Fog + +::: api scene.fogNode : Node - Assign a node to control the scene's fog effect. ::: + +::: api fog( color, factor ) : FogNode - Creates a fog node with specified color and fog factor. +- **color**: `Node | Color | string` - Color node or value for the fog. +- **factor**: `Node` - Fog factor node determining fog density or falloff (e.g. `rangeFogFactor`, `densityFogFactor`). +::: + +::: api rangeFogFactor( near?, far? ) : float - Creates a linear fog factor based on distance from camera. +- **near**: `Node | number` - (Optional) Distance from camera where fog begins. Defaults to camera near plane. +- **far**: `Node | number` - (Optional) Distance from camera where fog reaches maximum density. Defaults to camera far plane. +::: + +::: api densityFogFactor( density? ) : float - Creates an exponential squared fog factor for denser fog. +- **density**: `Node | number` - (Optional) Fog density coefficient. Defaults to `0.00025`. +::: + +::: api exponentialHeightFogFactor( density?, height? ) : float - Creates an exponential height fog factor below a specified world height. +- **density**: `Node | number` - (Optional) Fog density coefficient. Defaults to `0.00025`. +- **height**: `Node | number` - (Optional) World-space height threshold for exponential falloff. Defaults to `0.0`. +::: + +```tsl volumetricFog +import 'scenes/shaderball'; +import { fog, positionWorld, cameraPosition, float, color } from 'three/tsl'; + +// Volumetric Fog Parameters (Beer-Lambert Law) +const groundDensity = float( 1.00 ); // Base ground fog density (m⁻¹) +const heightFalloff = float( 1.25 ); // Exponential height scale +const fogGroundHeight = float( 0.0 ); // Ground height Y +const atmosphericHaze = float( 0.02 ); // Uniform background haze density + +// 1. Ray vector from camera to fragment +const ray = positionWorld.sub( cameraPosition ); +const rayLength = ray.length(); +const dy = ray.y; // Vertical delta (P_y - C_y) + +// 2. Camera-level ground fog density: g0 * exp( -heightFalloff * (C_y - fogGroundHeight) ) +const cameraHeightOffset = cameraPosition.y.sub( fogGroundHeight ); +const cameraDensity = groundDensity.mul( heightFalloff.negate().mul( cameraHeightOffset ).exp() ); + +// 3. Analytical integration of optical depth along the ray path +const x = dy.mul( heightFalloff ); +const safeX = x.abs().lessThan( 0.001 ).select( float( 1.0 ), x ); +const expr = float( 1.0 ).sub( x.negate().exp() ).div( safeX ); +const integratedHeight = x.abs().lessThan( 0.001 ).select( float( 1.0 ).sub( x.mul( 0.5 ) ), expr ); + +// Ground fog optical depth + uniform atmospheric haze optical depth +const groundOpticalDepth = cameraDensity.mul( integratedHeight ).mul( rayLength ).max( 0.0 ); +const atmosphericOpticalDepth = atmosphericHaze.mul( rayLength ); +const totalOpticalDepth = groundOpticalDepth.add( atmosphericOpticalDepth ); + +// 4. Transmittance & fog factor according to Beer-Lambert Law: F = 1 - exp( -totalOpticalDepth ) +const fogFactor = totalOpticalDepth.negate().exp().oneMinus(); + +const fogColor = color( 0x06b6d4 ); +scene.fogNode = fog( fogColor, fogFactor ); +scene.backgroundNode = fogColor.mul( 6.7 ); + +model.material.colorNode = color( 0xffaa00 ); +``` + + + + + +Custom procedural backgrounds and skyboxes assigned directly to `scene.backgroundNode`. + +IBL Atmosphere & Clouds +3D Aurora & Stars + +::: api scene.backgroundNode : Node - Assign a node to control the scene's background color or texture graph. ::: + +```tsl iblSky +import 'scenes/empty'; +import { RepeatWrapping } from 'three'; +import { positionWorldDirection, pmremTexture, color, float, vec3, time, smoothstep, mx_noise_float, Fn, rtt, uv } from 'three/tsl'; + +// 1. Ray Direction Vector & Corrected IBL Sampling Direction +const dir = positionWorldDirection; +const iblDir = vec3( dir.x, dir.y.negate(), dir.z ); +const horizonFade = smoothstep( 0.01, 0.2, dir.y ); + +// 2. Single Completely Blurred PMREM Environment Texture (blur level = 1.0 for smooth ambient sky) +const iblSky = pmremTexture( scene.environment, iblDir, float( 1.0 ) ); +const groundColor = iblSky.mul( 0.2 ); + +// 3. Perspective Sky Ceiling Projection (Perspective Foreshortening) +const skyY = dir.y.clamp( 0.001, 1.0 ).pow( 0.7 ).max( 0.08 ); +const perspectivePos = vec3( dir.x.div( skyY ), float( 1.0 ), dir.z.div( skyY ) ); + +// 4. Volumetric FBM Cloud Noise RTT +const rttScale = float( 0.02 ); + +const noise = Fn( ( [ coord ] ) => { + + const p = vec3( coord.x, float( 1.0 ), coord.y ).div( rttScale ); + const wind = vec3( time.mul( 0.1 ), 0.0, time.mul( 0.015 ) ); + const animatedP = p.mul( 0.3 ).add( wind ); + + const n1 = mx_noise_float( animatedP ).mul( 0.5 ).add( 0.5 ).mul( 0.50 ); + const n2 = mx_noise_float( animatedP.mul( 2.0 ) ).mul( 0.5 ).add( 0.5 ).mul( 0.25 ); + const n3 = mx_noise_float( animatedP.mul( 4.0 ) ).mul( 0.5 ).add( 0.5 ).mul( 0.125 ); + const n4 = mx_noise_float( animatedP.mul( 8.0 ) ).mul( 0.5 ).add( 0.5 ).mul( 0.0625 ); + + return n1.add( n2 ).add( n3 ).add( n4 ); + +} ); + +const cloudNoiseRTT = rtt( noise( uv() ), 512, 512, { wrapS: RepeatWrapping, wrapT: RepeatWrapping } ); + +const cloudNoise = ( uv ) => cloudNoiseRTT.sample( uv.xz.mul( rttScale ).add( 0.5 ) ).x; +// const cloudNoise = ( uv ) => noise( uv.xz.mul( rttScale ) ).x; + +// Smooth anti-aliased cloud density +const fbmVal = cloudNoise( perspectivePos ); +const cloudDensity = smoothstep( 0.25, 0.55, fbmVal ).mul( horizonFade ).clamp( 0.0, 1.0 ); + +// 5. Cloud Normal Gradient & Color Derived 100% from iblSky +const fbmDx = cloudNoise( perspectivePos.add( vec3( 0.05, 0.0, 0.0 ) ) ); +const fbmDz = cloudNoise( perspectivePos.add( vec3( 0.0, 0.0, 0.05 ) ) ); +const rawNormal = vec3( fbmVal.sub( fbmDx ), float( 0.35 ), fbmVal.sub( fbmDz ) ).normalize(); + +// Cloud Lit & Shadow colors derived directly from iblSky (NO second pmremTexture call!) +const cloudLitColor = iblSky.mul( 1.5 ).add( color( 0xffffff ).mul( 0.35 ) ); +const cloudShadowColor = iblSky.mul( 0.45 ); + +// In TSL method chaining: t.mix( a, b ) interpolates from a to b by factor t +const lightFactor = rawNormal.y.clamp( 0.0, 1.0 ).pow( 0.5 ); +const cloudColor = lightFactor.mix( cloudShadowColor, cloudLitColor ); + +// 6. Smooth Horizon Transition & Composite into scene.backgroundNode +const cloudAlpha = cloudDensity.mul( 0.85 ); +const finalSky = cloudAlpha.mix( iblSky, cloudColor ); + +const horizonBlend = smoothstep( - 0.15, 0.15, dir.y ); +const finalBackground = horizonBlend.mix( groundColor, finalSky ); + +// Assign to scene.backgroundNode +scene.backgroundNode = finalBackground; + +// Adjust camera angle and floor visibility for a better view of the sky and clouds +camera.position.set( 4, 1, 4 ); +``` + +```tsl auroraSky +import 'scenes/empty'; +import { positionWorldDirection, color, float, time, smoothstep } from 'three/tsl'; + +// 1. Continuous 3D Direction Vector +const dir = positionWorldDirection; + +// 2. 3D Celestial Twinkling Stars Field (using 3D spatial hashing, NO wrap seams) +const starGrid = dir.mul( 30.0 ); +const starId = starGrid.floor(); +const starUv = starGrid.fract().sub( 0.5 ); + +// Pseudo-random 3D star hash & twinkling animation +const starHash = starId.x.mul( 12.9898 ).add( starId.y.mul( 78.233 ) ).add( starId.z.mul( 37.719 ) ).sin().mul( 43758.5453 ).fract(); +const twinkleSpeed = starHash.mul( 6.0 ).add( 2.0 ); +const twinklePhase = starHash.mul( 62.8 ); +const twinkle = time.mul( twinkleSpeed ).add( twinklePhase ).sin().mul( 0.5 ).add( 0.5 ); + +// Smooth horizon fade for stars +const starFade = smoothstep( 0.0, 0.2, dir.y ); +const isStar = starHash.greaterThan( 0.85 ).and( dir.y.greaterThan( 0.05 ) ); + +// Sharp 4-Pointed Star Flare Sparkle (Pontuda & Bounded without distortion) +const starDist = starUv.length(); +const absUv = starUv.abs(); +const sparkArm = float( 0.0015 ).div( absUv.x.mul( absUv.y ).add( 0.0015 ) ); +const starSpark = sparkArm.mul( smoothstep( 0.35, 0.0, starDist ) ).clamp( 0.0, 4.0 ); + +const stars = isStar.select( starSpark.mul( twinkle ).mul( starFade ), float( 0.0 ) ); + +// 3. 3D Organic Volumetric Aurora Waves +const w1 = dir.x.mul( 2.5 ).add( time.mul( 0.4 ) ).sin().mul( 0.15 ); +const w2 = dir.z.mul( 5.0 ).sub( time.mul( 0.7 ) ).cos().mul( 0.08 ); +const totalWave = w1.add( w2 ); + +const auroraPos = dir.y.sub( 0.2 ).add( totalWave ); +const auroraMask = smoothstep( 0.0, 0.12, auroraPos ).mul( smoothstep( 0.55, 0.2, auroraPos ) ); + +const colorShift = dir.x.mul( 1.5 ).add( time.mul( 0.3 ) ).sin().mul( 0.5 ).add( 0.5 ); +const greenCyan = colorShift.mix( color( 0x059669 ), color( 0x06b6d4 ) ); +const violetPink = colorShift.mix( color( 0xa855f7 ), color( 0xec4899 ) ); + +const auroraColor = auroraPos.mix( greenCyan, violetPink ); +const aurora = auroraColor.mul( auroraMask ).mul( 1.2 ); + +// 4. Smooth 3D Deep Space Skybox Gradient (100% continuous from Zenith to Nadir) +const spaceBg = dir.y.mul( 0.5 ).add( 0.5 ).clamp( 0.0, 1.0 ).pow( 0.6 ).mix( color( 0x03020c ), color( 0x0d0722 ) ); + +// Assign to scene.backgroundNode +scene.backgroundNode = spaceBg.add( color( 0xffffff ).mul( stars ) ).add( aurora ); + +// Adjust camera angle for a better view of the sky and clouds +camera.position.set( 4, .1, 6 ); +``` + + + + + + + + + +In TSL, **`pass( scene, camera, options )`** creates a `PassNode` that renders a scene from a given camera into an internal render target and returns its output as a TSL texture/expression. + +This is the cornerstone of post-processing pipelines and compositing in WebGPU: it allows entire scenes to be rendered, manipulated with TSL math or effects (like blur, color grading, tone mapping, edge detection, vignette), and chained together using `RenderPipeline`. + +Post-processing + +::: api pass( scene, camera, options? ) : PassNode - Creates a render pass node for a scene and camera. +- **scene**: `Scene` - The scene to render. +- **camera**: `Camera` - The camera to render from. +- **options**: `Object` - (Optional) Options for the internal render target (e.g. `minFilter`, `magFilter`, `type`, `depthBuffer`, `samples`). +::: + +::: api depthPass( scene, camera, options? ) : PassNode - Creates a dedicated depth pass node that renders the depth buffer of the scene. ::: + +::: api pass.getTextureNode( name? ) : TextureNode - Returns the texture node for the primary output or named MRT attachment. ::: + +::: api pass.getDepthNode() : Node - Returns the non-linear depth node from the pass's depth buffer. ::: + +::: api pass.getLinearDepthNode() : Node - Returns the linear depth (normalized 0 to 1) from the pass's depth buffer. ::: + +::: api pass.getViewZNode() : Node - Returns the view-space Z depth node from the pass's depth buffer. ::: + +::: api pass.setResolutionScale( scale ) - Sets a multiplier for the pass resolution relative to the renderer size (e.g. `0.5` for half resolution). ::: + +```js +import { pass, screenUV, vec2 } from 'three/tsl'; + +// 1. Create a scene render pass +const scenePass = pass( scene, camera ); + +// 2. Manipulate the render pass output using TSL operations (e.g., Vignette effect) +const vignette = screenUV.distance( vec2( 0.5 ) ).mul( 1.5 ).oneMinus().clamp(); +const finalOutput = scenePass.rgb.mul( vignette ); + +// 3. Assign the composited pass to the render pipeline +renderPipeline.outputNode = finalOutput; +``` + +```tsl postProcessing +import 'scenes/shaderball'; +import { pass, screenUV, vec2 } from 'three/tsl'; + +// 1. Create a scene render pass +const scenePass = pass( scene, camera ); + +// 2. Create a smooth circular vignette effect +const distFromCenter = screenUV.distance( vec2( 0.5 ) ); +const vignette = distFromCenter.mul( 1.4 ).oneMinus().clamp(); + +// 3. Apply color grading and vignette to the scene pass output +const postProcess = scenePass.rgb.mul( vignette ); + +// const renderPipeline = new THREE.RenderPipeline(); +renderPipeline.outputNode = postProcess; +``` + + + + + +**MRT** (Multiple Render Targets) allows a single render pass to output to multiple render target textures simultaneously from a single fragment shader execution. + +This is essential for deferred rendering pipelines, G-Buffer generation, and advanced screen-space post-processing effects (such as SSAO, SSR, SSGI, Motion Blur, Bloom, and selective masks) without rendering the scene geometry multiple times. + +Color, Normals and Positions + +::: api mrt( outputNodes ) : MRTNode - Creates a Multiple Render Target (MRT) node mapping named targets to node expressions. +- **outputNodes**: `Object` - Dictionary mapping output attachment names (e.g., `output`, `normal`, `position`, `mask`) to their corresponding node expressions. +::: + +::: api pass.setMRT( mrtNode ) - Configures a render pass to output to Multiple Render Targets. ::: + +::: api pass.getTextureNode( name ) : TextureNode - Retrieves the texture node corresponding to a named MRT output attachment from the pass. ::: + +### Material-Level MRT + +::: api material.mrtNode - Assigns a custom MRT node directly to a material to override or append specific output attachments. ::: + +In addition to setting MRT on the render pass, individual materials can define their own `material.mrtNode` to output custom data (such as selective bloom masks, object IDs, or custom depth) into separate attachments: + +```js +// This material outputs a custom glow mask into the 'mask' attachment +glowMaterial.mrtNode = mrt( { + mask: output +} ); +``` + +```tsl splitView +import 'scenes/shaderball'; +import { pass, mrt, output, normalWorld, positionWorld, screenUV, step, mix } from 'three/tsl'; + +// 1. Create a scene render pass +const scenePass = pass( scene, camera ); + +// 2. Configure the pass to output Color, World Normals, and World Positions simultaneously via MRT +scenePass.setMRT( mrt( { + output: output, + normal: normalWorld, + position: positionWorld +} ) ); + +// 3. Retrieve individual MRT texture attachments +const colorTexture = scenePass.getTextureNode( 'output' ); +const normalTexture = scenePass.getTextureNode( 'normal' ); +const positionTexture = scenePass.getTextureNode( 'position' ); + +// 4. Display split-screen (Left: Color / Center: World Normals / Right: World Positions) +let splitScreen = colorTexture; +splitScreen = mix( splitScreen, normalTexture, step( 0.333, screenUV.x ) ); +splitScreen = mix( splitScreen, positionTexture, step( 0.666, screenUV.x ) ); + +// const renderPipeline = new THREE.RenderPipeline(); +renderPipeline.outputNode = splitScreen; +``` + + + + + +TSL provides a suite of modular post-processing nodes to create screen-space effects, image processing passes, and anti-aliasing techniques in WebGPU. + +Effects are encapsulated into reusable nodes from `three/addons/tsl/display/` that process render pass outputs `pass( scene, camera )` and can be seamlessly combined and assigned to `renderPipeline.outputNode`. + +Bloom +Gaussian Blur +Depth of Field +Film Grain +Dot Screen +RGB Shift +Sobel Edge Detection +After Image +Radial Blur + +::: api bloom( node, strength?, radius?, threshold? ) : BloomNode - Creates a bloom glow effect extracting high-luminance areas. +- **node**: `Node` - Input texture or pass node. +- **strength**: `number` - (Optional) Strength / intensity multiplier of the bloom. Defaults to `1`. +- **radius**: `number` - (Optional) Bloom blur radius. Defaults to `0`. +- **threshold**: `number` - (Optional) Luminance threshold limit below which pixels do not glow. Defaults to `0`. +::: + +::: api gaussianBlur( node, directionNode?, sigma?, options? ) : GaussianBlurNode - Applies a two-pass separable Gaussian blur filter. +- **node**: `Node` - Input texture or pass node. +- **directionNode**: `Node | vec2 | number` - (Optional) Direction vector or radius scale. Defaults to `null`. +- **sigma**: `number` - (Optional) Standard deviation kernel radius. Defaults to `4`. +- **options**: `Object` - (Optional) Configuration options (`premultipliedAlpha`, `resolutionScale`). +::: + +::: api radialBlur( node, options? ) : Node - Applies a radial blur centered on the screen. +- **node**: `Node` - Input texture or pass node. +- **options**: `Object` - (Optional) Blur options (e.g. `center`, `samples`, `factor`). +::: + +::: api hashBlur( node, blurAmount?, options? ) : Node - Applies a randomized stochastic hash blur to the input. +- **node**: `Node` - Input texture or pass node. +- **blurAmount**: `Node | number` - (Optional) Intensity of the blur. Defaults to `0.1`. +- **options**: `Object` - (Optional) Additional options. +::: + +::: api bilateralBlur( node, options? ) : BilateralBlurNode - Applies an edge-preserving bilateral filter. +- **node**: `Node` - Input texture or pass node. +- **options**: `Object` - (Optional) Filter radius, spatial sigma, and range sigma. +::: + +::: api boxBlur( node, options? ) : Node - Applies a fast box blur filter. +- **node**: `Node` - Input texture or pass node. +- **options**: `Object` - (Optional) Box blur options. +::: + +::: api lensflare( node, params? ) : LensflareNode - Generates bloom-based anamorphic streaks and lens flares. +- **node**: `Node` - Input bloom or emissive texture. +- **params**: `Object` - (Optional) Lensflare parameters. +::: + +::: api dof( node, viewZNode, focusDistance?, focalLength?, bokehScale? ) : DepthOfFieldNode - Creates a realistic bokeh Depth of Field lens blur. +- **node**: `Node` - Input color texture or pass node. +- **viewZNode**: `Node` - View-space Z depth buffer node (e.g. `pass.getViewZNode()`). +- **focusDistance**: `Node | number` - (Optional) Distance to the focal plane in world units. Defaults to `1`. +- **focalLength**: `Node | number` - (Optional) Lens focal depth range. Defaults to `1`. +- **bokehScale**: `Node | number` - (Optional) Bokeh blur disc size. Defaults to `1`. +::: + +::: api chromaticAberration( node, strength?, center?, scale? ) : ChromaticAberrationNode - Simulates optical lens dispersion by offsetting color channels radially. +- **node**: `Node` - Input texture or pass node. +- **strength**: `Node | number` - (Optional) Chromatic separation strength. Defaults to `1.0`. +- **center**: `Node | vec2` - (Optional) Center coordinate of dispersion. Defaults to screen center `(0.5, 0.5)`. +- **scale**: `Node | number` - (Optional) Distortion scale factor. Defaults to `1.1`. +::: + +::: api film( node, intensity?, uvNode? ) : FilmNode - Adds cinematic film grain noise. +- **node**: `Node` - Input color node or pass. +- **intensity**: `Node | number` - (Optional) Grain noise intensity factor. Defaults to `null` (full strength). +- **uvNode**: `Node` - (Optional) Custom or animated UV coordinates node. Defaults to screen `uv()`. +::: + +::: api dotScreen( node, angle?, scale? ) : Node - Generates a halftone dot raster printing pattern. +- **node**: `Node` - Input color node. +- **angle**: `Node | number` - (Optional) Grid rotation angle in radians. Defaults to `1.57`. +- **scale**: `Node | number` - (Optional) Dot grid frequency. Defaults to `1.0`. +::: + +::: api rgbShift( node, amount?, angle? ) : Node - Offsets red and blue channels along a directional vector. +- **node**: `Node` - Input color node. +- **amount**: `Node | number` - (Optional) Channel offset distance. Defaults to `0.005`. +- **angle**: `Node | number` - (Optional) Offset angle in radians. Defaults to `0.0`. +::: + +::: api sobel( node ) : Node - Applies a 3x3 Sobel operator for gradient edge detection. +- **node**: `Node` - Input color or depth node (typically after tone mapping). Returns `vec3` grayscale edges. +::: + +::: api sharpen( node, sharpness?, denoise? ) : SharpenNode - Enhances edge contrast and image sharpness. +- **node**: `Node` - Input texture or pass node. +- **sharpness**: `Node | number` - (Optional) Sharpness strength. Defaults to `0.5`. +- **denoise**: `Node | number` - (Optional) Denoising threshold to avoid amplifying noise. Defaults to `0.0`. +::: + +::: api afterImage( node, damp? ) : AfterImageNode - Blends previous frames to produce persistent motion blur trails. +- **node**: `Node` - Input texture or pass node. +- **damp**: `Node | number` - (Optional) Trail persistence factor between 0 (no trail) and 1 (infinite trail). Defaults to `0.96`. +::: + +::: api lut3D( node, lut, size?, intensity? ) : Node - Color grades the image using a 3D Color Lookup Table. +- **node**: `Node` - Input color node. +- **lut**: `Data3DTexture | Texture` - 3D LUT texture. +- **size**: `number` - (Optional) LUT resolution cube size (e.g. `64`). Defaults to `64`. +- **intensity**: `Node | number` - (Optional) Blend intensity. Defaults to `1.0`. +::: + +::: api pixelationPass( scene, camera, pixelSize, normalEdgeStrength?, depthEdgeStrength? ) : PixelationPassNode - Renders a stylized pixelated pass with geometric edge outlines. +- **scene**: `Scene` - Scene to render. +- **camera**: `Camera` - Active camera. +- **pixelSize**: `number` - Pixelation block size. +- **normalEdgeStrength**: `number` - (Optional) Outline strength from normal buffer. Defaults to `0.3`. +- **depthEdgeStrength**: `number` - (Optional) Outline strength from depth buffer. Defaults to `0.4`. +::: + +::: api transition( nodeA, nodeB, mixTextureNode, mixRatio, threshold?, useTexture? ) : Node - Transitions between two passes using a gradient wipe texture. +- **nodeA**: `Node` - Initial scene pass node. +- **nodeB**: `Node` - Destination scene pass node. +- **mixTextureNode**: `TextureNode` - Wipe / dissolve pattern texture. +- **mixRatio**: `Node | number` - Progress ratio (0 to 1). +- **threshold**: `Node | number` - (Optional) Edge softness threshold. Defaults to `0.1`. +- **useTexture**: `boolean` - (Optional) Whether to sample texture values. Defaults to `true`. +::: + +::: api fxaa( node ) : FXAANode - Computes Fast Approximate Anti-Aliasing (FXAA) in sRGB display space. +- **node**: `Node` - Input sRGB color node (apply `renderOutput()` before FXAA). +::: + +::: api smaa( node ) : SMAANode - Applies Subpixel Morphological Anti-Aliasing (SMAA) with subpixel pattern reconstruction. +- **node**: `Node` - Input linear scene pass node. +- **options**: `Object` - (Optional) Additional options. +::: + +```tsl bloomExample +import 'scenes/shaderball'; +import { pass } from 'three/tsl'; +import { bloom } from 'three/addons/tsl/display/BloomNode.js'; + +// 1. Create a scene render pass +const scenePass = pass( scene, camera ); + +// 2. Apply Bloom effect to the scene pass and scale intensity +const bloomPass = bloom( scenePass ).mul( .2 ); + +// 3. Composite the bloom glow over the scene pass +// const renderPipeline = new THREE.RenderPipeline(); +renderPipeline.outputNode = scenePass.add( bloomPass ); +``` + +```tsl gaussianBlurExample +import 'scenes/shaderball'; +import { pass } from 'three/tsl'; +import { gaussianBlur } from 'three/addons/tsl/display/GaussianBlurNode.js'; + +// 1. Render scene pass +const scenePass = pass( scene, camera ); + +// 2. Apply a two-pass Gaussian blur filter +const blurredPass = gaussianBlur( scenePass, 3 ); + +// 3. Assign blurred output to render pipeline +// const renderPipeline = new THREE.RenderPipeline(); +renderPipeline.outputNode = blurredPass; +``` + +```tsl dofExample +import 'scenes/shaderball'; +import { pass } from 'three/tsl'; +import { dof } from 'three/addons/tsl/display/DepthOfFieldNode.js'; + +// 1. Render scene pass +const scenePass = pass( scene, camera ); +const colorTexture = scenePass.getTextureNode(); +const viewZTexture = scenePass.getViewZNode(); + +// 2. Apply Depth of Field bokeh blur focused on the central model +const focusDistance = 4.5; +const focalLength = 1.2; +const bokehScale = 2.5; + +const dofPass = dof( colorTexture, viewZTexture, focusDistance, focalLength, bokehScale ); + +// const renderPipeline = new THREE.RenderPipeline(); +renderPipeline.outputNode = dofPass; +``` + +```tsl filmExample +import 'scenes/shaderball'; +import { pass } from 'three/tsl'; +import { film } from 'three/addons/tsl/display/FilmNode.js'; + +// 1. Render scene pass +const scenePass = pass( scene, camera ); + +// 2. Apply cinematic film grain noise +const filmPass = film( scenePass ); + +// const renderPipeline = new THREE.RenderPipeline(); +renderPipeline.outputNode = filmPass; +``` + +```tsl dotScreenExample +import 'scenes/shaderball'; +import { pass } from 'three/tsl'; +import { dotScreen } from 'three/addons/tsl/display/DotScreenNode.js'; + +// 1. Render scene pass +const scenePass = pass( scene, camera ); + +// 2. Apply halftone dot raster screen effect +const dotPass = dotScreen( scenePass, 1.57, 0.35 ); + +// const renderPipeline = new THREE.RenderPipeline(); +renderPipeline.outputNode = dotPass; +``` + +```tsl rgbShiftExample +import 'scenes/shaderball'; +import { pass } from 'three/tsl'; +import { rgbShift } from 'three/addons/tsl/display/RGBShiftNode.js'; + +// 1. Render scene pass +const scenePass = pass( scene, camera ); + +// 2. Apply horizontal RGB channel displacement +const shiftedPass = rgbShift( scenePass, 0.006, 0.0 ); + +// const renderPipeline = new THREE.RenderPipeline(); +renderPipeline.outputNode = shiftedPass; +``` + +```tsl sobelExample +import 'scenes/shaderball'; +import { pass, renderOutput } from 'three/tsl'; +import { sobel } from 'three/addons/tsl/display/SobelOperatorNode.js'; + +// 1. Render scene pass +const scenePass = pass( scene, camera ); + +// 2. Convert to display space before extracting edges +const outputPass = renderOutput( scenePass ); + +// 3. Extract edges using Sobel gradient operator +const edgePass = sobel( outputPass ); + +// const renderPipeline = new THREE.RenderPipeline(); +renderPipeline.outputNode = edgePass; +``` + +```tsl afterImageExample +import 'scenes/shaderball'; +import { pass } from 'three/tsl'; +import { afterImage } from 'three/addons/tsl/display/AfterImageNode.js'; + +// 1. Render scene pass +const scenePass = pass( scene, camera ); + +// 2. Retain previous frames to produce an afterimage motion trail +const trailPass = afterImage( scenePass, 0.94 ); + +// const renderPipeline = new THREE.RenderPipeline(); +renderPipeline.outputNode = trailPass; +``` + +```tsl radialBlurExample +import 'scenes/shaderball'; +import { pass } from 'three/tsl'; +import { radialBlur } from 'three/addons/tsl/display/radialBlur.js'; + +// 1. Render scene pass +const scenePass = pass( scene, camera ); + +// 2. Apply radial zoom blur expanding from screen center +const blurredPass = radialBlur( scenePass ); + +// const renderPipeline = new THREE.RenderPipeline(); +renderPipeline.outputNode = blurredPass; +``` + + + + + +By default, **`RenderPipeline`** automatically applies tone mapping and color space transformation to `renderPipeline.outputNode` as the final step before presenting pixels to the screen framebuffer `outputColorTransform = true`. + +However, in advanced post-processing setups, applying color transformation at the very end can be too late. Certain screen-space effects—such as **FXAA** (Fast Approximate Anti-Aliasing) or stylization filters—expect **sRGB** (display-referred) input rather than linear HDR values. + +For such scenarios, set **`renderPipeline.outputColorTransform = false`** and use **`renderOutput()`** to explicitly apply tone mapping and color space conversion at the exact desired position in your effect chain. + +### Automatic vs. Manual Color Transformation + +| Output Color Transform | Description | +| :--- | :--- | +| `true` (Default) | `RenderPipeline` automatically wraps the final `outputNode` with `renderOutput( outputNode, toneMapping, outputColorSpace )` at the end of the pipeline. Ideal for standard rendering pipelines and linear-space post-processing effects (such as Bloom, Depth of Field, or Motion Blur). | +| `false` (Manual) | Disables automatic end-of-pipeline conversion. You must manually insert `renderOutput()` in the effect chain before any passes that require sRGB input (such as FXAA). | + +Output Color Transform + +::: api renderPipeline.outputColorTransform : boolean - Controls whether default tone mapping and color space transformation are automatically applied to the pipeline's output node. Defaults to `true`. ::: + +::: api renderOutput( colorNode, toneMapping?, outputColorSpace? ) : RenderOutputNode - Applies tone mapping and color space transformation to a color node. +- **colorNode**: `Node` - The color or pass node to transform. +- **toneMapping**: `number` - (Optional) Tone mapping technique to apply. Defaults to the renderer's active tone mapping. +- **outputColorSpace**: `string` - (Optional) Target color space. Defaults to the renderer's active output color space (typically `SRGBColorSpace`). +::: + +```js +import { pass, renderOutput } from 'three/tsl'; +import { fxaa } from 'three/addons/tsl/display/FXAANode.js'; + +// 1. Disable automatic output color transform at the end of the pipeline +renderPipeline.outputColorTransform = false; + +// 2. Render the scene pass +const scenePass = pass( scene, camera ); + +// 3. Manually convert from linear HDR to output color space (sRGB) with tone mapping +const outputPass = renderOutput( scenePass ); + +// 4. Compute FXAA in sRGB color space +const fxaaPass = fxaa( outputPass ); + +// 5. Assign to the pipeline output +renderPipeline.outputNode = fxaaPass; +``` + +```tsl outputColorTransform +import 'scenes/shaderball'; +import { pass, renderOutput } from 'three/tsl'; +import { fxaa } from 'three/addons/tsl/display/FXAANode.js'; + +// 1. Disable default automatic output color transformation +renderPipeline.outputColorTransform = false; + +// 2. Create the 3D scene pass +const scenePass = pass( scene, camera ); + +// 3. Manually apply tone mapping and color space transformation (Linear -> sRGB) +const outputPass = renderOutput( scenePass ); + +// 4. Apply FXAA after color transformation (FXAA requires sRGB input) +const fxaaPass = fxaa( outputPass ); + +// 5. Output the anti-aliased image +renderPipeline.outputNode = fxaaPass; +``` + + + + + + + + + +**RTT** (Render-to-Texture) allows any TSL node expression or fragment graph to be rendered into an offscreen texture using an internal `RenderTarget` and full-screen `QuadMesh`. + +The resulting `RTTNode` extends `TextureNode`, enabling the baked output to be sampled across materials, passed into multi-tap image filters (e.g. blurs, blooms, Sobel filters), downscaled for performance, or cached for static procedural generation. + +Procedural Texture + +### Functions + +::: api rtt( node, width?, height?, options? ) : RTTNode - Renders a TSL node into an internal render target texture. +- **node**: `Node` - The TSL node expression to render into a texture. +- **width**: `int` - (Optional) Fixed width in pixels. If `null`, the render target automatically resizes with the renderer. Defaults to `null`. +- **height**: `int` - (Optional) Fixed height in pixels. If `null`, the render target automatically resizes with the renderer. Defaults to `null`. +- **options**: `Object` - (Optional) Configuration options for the internal render target. +- **options.type**: `number` - (Optional) Texture data type (e.g. `HalfFloatType`, `UnsignedByteType`). Defaults to `HalfFloatType`. +- **options.autoUpdate**: `boolean` - (Optional) Whether the texture should automatically update on each render. Defaults to `true`. +- **options.resolutionScale**: `float` - (Optional) Resolution scaling factor relative to the drawing buffer size. Defaults to `1`. +- **options.wrapS**: `number` - (Optional) Horizontal wrapping mode (e.g. `RepeatWrapping`, `ClampToEdgeWrapping`). +- **options.wrapT**: `number` - (Optional) Vertical wrapping mode. +- **options.minFilter**: `number` - (Optional) Texture minification filter. +- **options.magFilter**: `number` - (Optional) Texture magnification filter. +- **options.generateMipmaps**: `boolean` - (Optional) Whether to generate mipmaps for the texture. +- **options.depthBuffer**: `boolean` - (Optional) Whether to allocate a depth buffer. Defaults to `true`. +::: + +::: api convertToTexture( node, width?, height?, options? ) : TextureNode | RTTNode - Ensures a node is converted to a sampleable texture node. +- **node**: `Node` - The node to convert. If already a `TextureNode`, it is returned directly; if a `PassNode`, its texture node is extracted; otherwise, an `rtt()` node is created. +- **width**: `int` - (Optional) Fixed width in pixels. Defaults to `null`. +- **height**: `int` - (Optional) Fixed height in pixels. Defaults to `null`. +- **options**: `Object` - (Optional) Configuration options forwarded to `rtt()`. +::: + +### Why RTT? + +Evaluating heavy procedural math or noise functions directly inside a surface fragment shader means the GPU recalculates the entire equation for every single pixel on screen. **RTT turns any node graph into a reusable GPU texture**, unlocking significant rendering and performance advantages: + +- **Compute Caching**: Complex procedural formulas, fractal noise, or math patterns are evaluated **once** onto a 2D texture, converting millions of per-pixel GPU calculations into lightweight texture lookups. + +- **Multi-Tap Image Processing**: Spatial effects—such as **Gaussian Blur**, **Bloom**, **Sobel edge detection**, and screen distortions—require sampling neighboring texels at offset UVs. RTT allows any node expression to be sampled with discrete convolution kernels. + +- **Static & On-Demand Baking**: Setting `autoUpdate = false` allows procedural textures to be rendered once at startup, yielding zero ongoing per-frame shader evaluation overhead. + +- **Resolution Decoupling**: Costly effects and intermediate passes can be rendered at fractional resolutions (via `setResolutionScale( 0.5 )` or fixed dimensions) to significantly reduce GPU memory bandwidth and fill-rate demands. + +```tsl proceduralTexture +import 'scenes/shaderball'; +import * as THREE from 'three'; +import { rtt, uv, time, color, sin, cos, checker } from 'three/tsl'; + +// 1. Create an animated procedural pattern node +const scaledUV = uv().mul( 10.0 ); +const wave = sin( scaledUV.x.add( time.mul( 2.0 ) ) ).mul( cos( scaledUV.y.add( time.mul( 1.5 ) ) ) ); +const pattern = checker( scaledUV ).mix( color( 0x0a192f ), color( 0x00ffcc ) ).add( wave.mul( 0.25 ) ); + +// 2. Render the procedural node to an offscreen texture (RTT) +const proceduralRTT = rtt( pattern, 512, 512, { + wrapS: THREE.RepeatWrapping, + wrapT: THREE.RepeatWrapping +} ); + +// 3. Sample the baked RTT texture across the model's material +model.material.colorNode = proceduralRTT.sample( uv().mul( 2.0 ) ); +model.material.roughnessNode = proceduralRTT.r.mul( 0.6 ).add( 0.2 ); +model.material.metalness = 0.8; +``` + + + + + +Timer nodes allow accessing the elapsed time and the delta time of the current frame in seconds. These nodes are useful for driving procedural animations, physics simulations in compute shaders, and dynamic visual effects. + +Time + +::: api time : float - Represents the elapsed time in seconds. ::: + +::: api deltaTime : float - Represents the delta time in seconds. ::: + +```tsl timeExample +import 'scenes/shaderball'; +import { positionLocal, time, color } from 'three/tsl'; + +// Continuous pulsing wave driven by elapsed time +const wave = positionLocal.y.mul( 4.0 ).add( time.mul( 2.0 ) ).sin().mul( 0.5 ).add( 0.5 ); + +// Interpolate colors based on wave intensity +const baseColor = color( 0x050c1a ); +const glowColor = color( 0x00f0ff ); + +model.material.colorNode = wave.mix( baseColor, glowColor ); +model.material.roughness = 0.25; +model.material.metalness = 0.75; +``` + + + + + +The oscillator functions generate periodic waveforms in the range `[0, 1]` based on a timer node (which defaults to `time`). They are useful for creating cycles, fading transitions, flashing effects, and driving procedural math animations. + +Oscilloscope Waves + +::: api oscSine( timer? ) : float - Generates a sine wave oscillation based on a timer (defaults to `time`). ::: + +::: api oscSquare( timer? ) : float - Generates a square wave oscillation based on a timer (defaults to `time`). ::: + +::: api oscTriangle( timer? ) : float - Generates a triangle wave oscillation based on a timer (defaults to `time`). ::: + +::: api oscSawtooth( timer? ) : float - Generates a sawtooth wave oscillation based on a timer (defaults to `time`). ::: + +```tsl oscillatorExample +import 'scenes/empty'; +import { screenUV, color, float, time, smoothstep, min, max, oscSine, oscSquare, oscTriangle, oscSawtooth } from 'three/tsl'; + +const x = screenUV.x; +const y = screenUV.y; +const speed = time.mul( 0.5 ); + +// Helper function to draw a continuous wave line in its track +const drawWave = ( waveFunc, offset ) => { + + // Sample the wave at x - dx and x + dx to connect vertical jumps + const dx = float( 0.0015 ); + const tLeft = x.sub( dx ).mul( 8.0 ).sub( speed ); + const tRight = x.add( dx ).mul( 8.0 ).sub( speed ); + + const valLeft = waveFunc( tLeft ); + const valRight = waveFunc( tRight ); + + const valMin = min( valLeft, valRight ); + const valMax = max( valLeft, valRight ); + + // Scale wave range [0, 1] to track height (0.16) and apply vertical offset + const targetMin = valMin.mul( 0.16 ).add( offset ).sub( 0.003 ); + const targetMax = valMax.mul( 0.16 ).add( offset ).add( 0.003 ); + + // Draw a smooth line between targetMin and targetMax + const d1 = y.sub( targetMin ); + const d2 = targetMax.sub( y ); + + return smoothstep( 0.0, 0.002, d1 ).mul( smoothstep( 0.0, 0.002, d2 ) ); + +}; + +// 1. Color-code each wave in its respective vertical track (each 0.25 high) +const color0 = color( 0x00ffcc ).mul( drawWave( oscSine, 0.045 ) ); // Track 0: Sine (bottom) +const color1 = color( 0xffaa00 ).mul( drawWave( oscSquare, 0.295 ) ); // Track 1: Square +const color2 = color( 0xff00bb ).mul( drawWave( oscTriangle, 0.545 ) ); // Track 2: Triangle +const color3 = color( 0x00aaff ).mul( drawWave( oscSawtooth, 0.795 ) ); // Track 3: Sawtooth (top) + +// Combine wave colors +const wavesColor = color0.add( color1 ).add( color2 ).add( color3 ); + +// Dark screen background +const bg = color( 0x050508 ); + +// Assign oscilloscope to renderPipeline +renderPipeline.outputNode = bg.add( wavesColor ); +``` + + + + + +Rotation functions allow you to rotate 2D coordinates or 3D positions/vectors. This is essential for spinning instances in particle systems, rotating UV coordinates for animated textures, or orienting meshes. + +Teapot Emitter + +::: api rotate( position, rotation, order='XYZ' ) : Node - Applies a rotation to the given position or vector node. +- **position**: `vec2 | vec3` - The 2D or 3D vector to rotate. +- **rotation**: `float | vec3` - For 2D positions, a single float angle (in radians). For 3D positions, a Euler rotation vector containing rotation angles for the X, Y, and Z axes. +- **order**: `string` - The Euler rotation order (e.g. `'XYZ'`, `'YZX'`, `'ZXY'`, `'XZY'`, `'YXZ'`, `'ZYX'`). Only used for 3D rotation. Defaults to `'XYZ'`. +::: + +::: api .rotate( rotation, order='XYZ' ) : Node - Method chaining helper to rotate the current position or vector node. ::: + +```tsl teapotEmitter +import 'scenes/empty'; +import * as THREE from 'three'; +import { TeapotGeometry } from 'three/addons/geometries/TeapotGeometry.js'; +import { time, color, vec3, vec4, mix, range, rotate, positionLocal, normalLocal } from 'three/tsl'; + +// Instantiate the instanced mesh geometry and material +const geometry = new TeapotGeometry( 0.25, 8 ); +const material = new THREE.MeshStandardNodeMaterial(); +material.roughness = 0.1; +material.metalness = 0.95; + +const count = 300; + +// Setup randomized properties per instance +const rand = range( vec4( 0.0, 0.4, 0.0, 0.0 ), vec4( 1.0, 0.9, 1.0, 1.0 ) ); +const offset = rand.x; +const speed = rand.y; + +// Lifetime tracking +const life = time.mul( speed ).add( offset ).fract(); + +// Fountain mechanics: teapots spout upwards and fall back down (parabolic arc) +const Y = life.mul( 6.0 ).sub( life.pow( 2.0 ).mul( 6.0 ) ); +const horizontalSpread = life.mul( 3.0 ); +const angle = offset.mul( Math.PI * 2.0 ); +const X = angle.cos().mul( horizontalSpread ); +const Z = angle.sin().mul( horizontalSpread ); +const instancePosition = vec3( X, Y, Z ); + +// 3D rotation angles over time for each instance +const rotX = offset.mul( 10.0 ).add( time.mul( 1.8 ) ); +const rotY = offset.mul( 20.0 ).add( time.mul( 2.5 ) ); +const rotZ = offset.mul( 30.0 ).add( time.mul( 1.2 ) ); +const instanceRotation = vec3( rotX, rotY, rotZ ); + +// Apply local rotation to positions and normals so shading remains correct +const rotatedPosition = rotate( positionLocal, instanceRotation ); +const rotatedNormal = rotate( normalLocal, instanceRotation ); + +// Translate the rotated local vertices to the instanced fountain positions +material.positionNode = rotatedPosition.add( instancePosition ); +material.normalNode = rotatedNormal; + +// Shifting rainbow colors over instance index +material.colorNode = mix( color( 0x00aaff ), color( 0xff00bb ), offset ); + +const instancedMesh = new THREE.Mesh( geometry, material ); +instancedMesh.count = count; +instancedMesh.castShadow = true; +instancedMesh.receiveShadow = true; +instancedMesh.frustumCulled = false; +scene.add( instancedMesh ); + +// Add a SpotLight to illuminate the teapot fountain and cast shadows +const spotLight = new THREE.SpotLight( 0xffffff, 1000.0, 25.0 ); +spotLight.angle = Math.PI / 3.0; +spotLight.penumbra = 0.8; +spotLight.position.set( - 4, 5, 4 ); +spotLight.castShadow = true; +scene.add( spotLight ); + +// Set camera perspective further back to capture the whole area and shadows +camera.position.set( 0, 3.0, 8.0 ); +``` + + + + + +TSL provides dedicated utility functions for transforming 2D UV texture coordinates, such as rotating around pivot centers and applying spherical lens distortion. + +UV Manipulation + +::: api rotateUV( uv, rotation, center? ) : vec2 - Rotates the given UV coordinates around a specified 2D center point. +- **uv**: `vec2` - The UV coordinates to rotate. +- **rotation**: `float` - The rotation angle defined in radians. +- **center**: `vec2` - (Optional) The pivot center of rotation. Defaults to `vec2( 0.5, 0.5 )`. +::: + +::: api spherizeUV( uv, strength, center? ) : vec2 - Applies a spherical warping (fisheye / lens bulge) effect to the given UV coordinates. +- **uv**: `vec2` - The UV coordinates. +- **strength**: `float` - The strength and direction of the spherical warping effect. +- **center**: `vec2` - (Optional) The center point of the spherical distortion. Defaults to `vec2( 0.5, 0.5 )`. +::: + +### Replace Default UV + +`replaceDefaultUV()` creates a context that intercepts and overrides default UV coordinates for all textures within a material or node sub-graph. + +Replace Default UV + +::: api replaceDefaultUV( callback, node = null ) : ContextNode - Replaces the default UV coordinates used in texture lookups across a material or sub-graph. +- **callback**: `Function(Node): Node | Node` - A callback receiving the texture node and returning the new UV coordinates, or a replacement UV node directly. +- **node**: `Node` - (Optional) An optional target node to which the context will be applied. Defaults to `null`. +::: + +### Example + +```js +import { rotateUV, replaceDefaultUV, materialColor, uv, time, vec2 } from 'three/tsl'; + +// 1. Assign standard texture map +material.map = myTexture; + +// 2. Override default UV coordinates for materialColor +material.colorNode = replaceDefaultUV( rotateUV( uv(), time, vec2( 0.5 ) ), materialColor ); +``` + +```tsl replaceDefaultUVExample +import 'scenes/plane'; +import * as THREE from 'three'; +import { uv, time, rotateUV, replaceDefaultUV, materialColor, vec2 } from 'three/tsl'; + +// Load texture map +const map = new THREE.TextureLoader().load( '../examples/textures/uv_grid_opengl.jpg' ); +map.wrapS = THREE.RepeatWrapping; +map.wrapT = THREE.RepeatWrapping; + +// 1. Assign texture map to the plane material +plane.material.map = map; + +// 2. Continuous rotation angle +const angle = time.mul( 0.5 ); + +// 3. Override default UVs for materialColor +plane.material.colorNode = replaceDefaultUV( rotateUV( uv().mul( 2.0 ), angle, vec2( 0.5 ) ), materialColor ); +``` + +```tsl uvExample +import 'scenes/plane'; +import * as THREE from 'three'; +import { uv, time, texture, rotateUV, spherizeUV, vec2 } from 'three/tsl'; + +// Load texture map +const map = new THREE.TextureLoader().load( '../examples/textures/uv_grid_opengl.jpg' ); +map.wrapS = THREE.RepeatWrapping; +map.wrapT = THREE.RepeatWrapping; + +// 1. Continuous rotation angle in radians +const angle = time.mul( 0.5 ); + +// 2. Dynamic pulsating spherical warp strength +const strength = time.mul( 2.0 ).sin().mul( 1.2 ); + +// 3. Rotate UV coordinates and apply spherical lens distortion +const transformedUV = spherizeUV( rotateUV( uv().mul( 2.0 ), angle, vec2( 0.5 ) ), strength, vec2( 0.5 ) ); + +// 4. Sample texture on the plane material +plane.material.colorNode = texture( map, transformedUV ); +``` + + + + + +TSL provides utilities for generating pseudo-random values. These are useful for procedural generation, noise, and randomized instanced attributes (e.g., varying speed, size, or color across thousands of particle instances). + +Instanced Range +Hash Grid +Realistic Bonfire + +::: api range( min, max ) : Node - Generates a range `attribute` of values between min and max. Attribute randomization is useful when you want to randomize values between instances and not between pixels. +- **min**: `Node | number | Vector2 | Vector3 | Vector4 | Color` - The minimum value. +- **max**: `Node | number | Vector2 | Vector3 | Vector4 | Color` - The maximum value. +::: + +::: api hash( seed ) : float - Generates a hash value in the range `[ 0, 1 ]` from the given seed. +- **seed**: `Node | float | int | uint` - The input value to generate the hash from. +::: + +```tsl rangeExample +import 'scenes/empty'; +import * as THREE from 'three'; +import { time, color, vec3, range, positionLocal } from 'three/tsl'; + +// Instantiate 100 spheres using a standard Mesh with .count +const geometry = new THREE.SphereGeometry( 0.15, 16, 16 ); +const material = new THREE.MeshStandardNodeMaterial(); + +const count = 100; + +// Randomize positions along the X and Z axes per instance +const randomPosition = range( vec3( - 2.5, 0.0, - 2.5 ), vec3( 2.5, 0.0, 2.5 ) ); + +// Randomize animation speed and maximum bounce height per instance +const randomSpeed = range( 1.5, 4.0 ); +const randomHeight = range( 0.5, 1.8 ); + +// Animate vertical position dynamically using the instance-specific speed and height (adding 0.15 radius offset to stay above floor) +const posY = time.mul( randomSpeed ).sin().add( 1.0 ).mul( randomHeight ).add( 0.15 ); + +// Apply position transformation +material.positionNode = positionLocal.add( randomPosition ).add( vec3( 0.0, posY, 0.0 ) ); + +// Randomize material colors between bright blue and pink per instance +const randomColor = range( color( 0x00aaff ), color( 0xff00bb ) ); +material.colorNode = randomColor; + +const instancedMesh = new THREE.Mesh( geometry, material ); +instancedMesh.count = count; +instancedMesh.frustumCulled = false; +scene.add( instancedMesh ); + +// Add a light to illuminate the spheres +const dirLight = new THREE.DirectionalLight( 0xffffff, 2.0 ); +dirLight.position.set( 5, 10, 5 ); +scene.add( dirLight ); + +camera.position.set( 0, 4.0, 6.0 ); +``` + +```tsl hashExample +import 'scenes/empty'; +import { screenUV, floor, hash } from 'three/tsl'; + +// Divide the screen coordinates into a 16x16 grid of cells +const gridCoords = floor( screenUV.mul( 16.0 ) ); + +// Hash each cell's 2D coordinate to generate a pseudo-random value [0, 1] per cell +const randomVal = hash( gridCoords ); + +// Output the random value as a grayscale color +renderPipeline.outputNode = randomVal; +``` + +```tsl fireParticles +import 'scenes/empty'; +import * as THREE from 'three'; +import { range, time, uv, color, float, vec3, mix, smoothstep, mx_noise_float, vec4, spherizeUV, vec2, hash, instanceIndex } from 'three/tsl'; +import { curlNoise } from 'three/addons/tsl/math/curlNoise.js'; + +// Particle count parameters grouped at the top of the code +const fireCount = 400; +const smokeCount = 700; +const sparkCount = 300; + +// Global simulation speed multiplier constant +const simSpeed = 0.9; +const speedTime = time.mul( simSpeed ); + +// Fire Particle Emitter (Flame Core) +// Slower fire speed range +const fireRand = range( vec4( 0.0, 0.22, 0.0, 0.0 ), vec4( 1.0, 0.7, 1.0, 1.0 ) ); +const fireOffset = fireRand.x; +const fireSpeed = fireRand.y; + +// Map Z and W components of range() to a circular disk base of radius 0.35 +const fireRadiusBase = fireRand.z.mul( 0.35 ); +const fireAngleBase = fireRand.w.mul( Math.PI * 2.0 ); +const fireBaseX = fireAngleBase.cos().mul( fireRadiusBase ); +const fireBaseZ = fireAngleBase.sin().mul( fireRadiusBase ); + +const fireScale = range( 0.22, 0.42 ); +const fireLife = speedTime.mul( fireSpeed ).add( fireOffset ).fract(); + +// Calculate fire position: starts narrow at base, then expands/funnels outwards as it rises and dissipates (upward cone) +const fireSpread = fireLife.mix( float( 0.5 ), float( 1.7 ), fireLife ); // starts narrow, expands outwards widely +const fireCurrentAngle = fireOffset.mul( Math.PI * 2.0 ).add( fireLife.mul( 1.5 ) ); + +// Add randomized dispersion offsets that grow with life to scatter particles as they rise +const fireScatterX = hash( instanceIndex.add( 11.0 ) ).sub( 0.5 ).mul( 0.8 ).mul( fireLife.pow( 1.5 ) ); +const fireScatterZ = hash( instanceIndex.add( 22.0 ) ).sub( 0.5 ).mul( 0.8 ).mul( fireLife.pow( 1.5 ) ); + +const fireX = fireBaseX.mul( fireSpread ).add( fireScatterX ).add( fireCurrentAngle.cos().mul( 0.08 ).mul( fireLife ) ); +const fireZ = fireBaseZ.mul( fireSpread ).add( fireScatterZ ).add( fireCurrentAngle.sin().mul( 0.08 ).mul( fireLife ) ); +const fireY = range( 1.0, 1.5 ).mul( fireLife ); // lower upward rising height +const firePos = vec3( fireX, fireY, fireZ ); + +// Perturb UV coordinates with 3D noise for organic, fluid-like shapes +const fireNoiseInput = vec3( uv().x.mul( 2.5 ), uv().y.mul( 2.5 ).sub( fireLife.mul( 2.0 ) ), fireOffset.mul( 10.0 ) ); +const fireNoiseOffset = mx_noise_float( fireNoiseInput ).mul( 0.15 ); +const fireDist = uv().sub( 0.5 ).add( fireNoiseOffset ).length(); +const fireShape = smoothstep( 0.5, 0.0, fireDist ); +const firePuff = fireShape.clamp(); + +// Fire material setup using SpriteNodeMaterial +const fireMaterial = new THREE.SpriteNodeMaterial(); +fireMaterial.positionNode = firePos; + +// Grow quickly from the base, fade out slowly at the top +const fireScaleEnvelope = smoothstep( float( 0.0 ), float( 0.1 ), fireLife ).mul( fireLife.oneMinus().pow( 0.5 ) ); +fireMaterial.scaleNode = fireScale.mul( fireScaleEnvelope ); + +fireMaterial.colorNode = mix( color( 0xffaa00 ), color( 0xff3b00 ), fireLife ); // gold to hot red-orange +fireMaterial.opacityNode = firePuff.mul( fireLife.oneMinus().pow( 4.0 ) ).mul( .5 ); // smoother fade out +fireMaterial.transparent = true; +fireMaterial.depthWrite = false; +fireMaterial.blending = THREE.AdditiveBlending; + +const fireParticles = new THREE.Sprite( fireMaterial ); +fireParticles.count = fireCount; +fireParticles.renderOrder = 2; +fireParticles.frustumCulled = false; +scene.add( fireParticles ); + +// Smoke Particle Emitter (Rising Ash) +// Slower smoke speed range +const smokeRand = range( vec4( 0.0, 0.12, 0.0, 0.0 ), vec4( 1.0, 0.26, 1.0, 1.0 ) ); +const smokeOffset = smokeRand.x; +const smokeSpeed = smokeRand.y; + +// Map Z and W to match the circular base area +const smokeRadiusBase = smokeRand.z.mul( 0.35 ); +const smokeAngleBase = smokeRand.w.mul( Math.PI * 2.0 ); +const smokeBaseX = smokeAngleBase.cos().mul( smokeRadiusBase ); +const smokeBaseZ = smokeAngleBase.sin().mul( smokeRadiusBase ); + +const smokeScale = range( 0.35, 0.8 ); +const smokeLife = speedTime.mul( smokeSpeed ).add( smokeOffset ).fract(); + +// Calculate smoke position: rises twisting upward, dispersing/expanding outwards as it rises +const smokeCurrentAngle = smokeOffset.mul( Math.PI * 2.0 ); +const smokeSpread = smokeLife.mix( float( 0.4 ), float( 2.8 ), smokeLife ); // wider upward expansion + +// Add randomized dispersion offsets that grow with life to scatter particles as they rise +const smokeScatterX = hash( instanceIndex.add( 33.0 ) ).sub( 0.5 ).mul( 1.5 ).mul( smokeLife.pow( 1.5 ) ); +const smokeScatterZ = hash( instanceIndex.add( 44.0 ) ).sub( 0.5 ).mul( 1.5 ).mul( smokeLife.pow( 1.5 ) ); + +const smokeX = smokeBaseX.mul( smokeSpread ).add( smokeScatterX ).add( smokeCurrentAngle.cos().mul( 0.3 ).mul( smokeSpread ) ); +const smokeZ = smokeBaseZ.mul( smokeSpread ).add( smokeScatterZ ).add( smokeCurrentAngle.sin().mul( 0.3 ).mul( smokeSpread ) ); +const smokeY = mix( range( 0.0, 0.1 ), range( 2.4, 4.8 ), smokeLife ); // starts right at the floor level +const smokePos = vec3( smokeX, smokeY, smokeZ ); + +// Deform smoke shape with 3D noise for wispy, textured smoke clouds (perturbed UV method) +const smokeUv = spherizeUV( uv(), 4.0 ).mul( 0.95 ).add( 0.025 ); +const smokeNoiseInput = vec3( smokeUv.x.mul( 3.0 ), smokeUv.y.mul( 3.0 ).sub( smokeLife.mul( 1.8 ) ), smokeOffset.mul( 20.0 ) ); +const smokeNoiseOffset = mx_noise_float( smokeNoiseInput ).mul( 0.18 ); +const smokeDist = smokeUv.sub( 0.5 ).add( smokeNoiseOffset ).length(); +const smokeShape = smoothstep( 0.5, 0.15, smokeDist ); +const smokePuff = smokeShape.clamp(); + +const smokeMaterial = new THREE.SpriteNodeMaterial(); +smokeMaterial.positionNode = smokePos; + +// Smoke starts large and expands to 1.85x as it rises +const smokeScaleEnvelope = smokeLife.mix( float( 0.85 ), float( 1.85 ), smokeLife ).mul( smokeLife.oneMinus().pow( 0.5 ) ); +smokeMaterial.scaleNode = smokeScale.mul( smokeScaleEnvelope ); + +// Volumetric Light Simulation with Falloff over distance/lifetime: +// - As the particle rises (higher smokeLife), the orange fire light strength fades to 0 +const fireLightStrength = smoothstep( float( 0.8 ), float( 0.0 ), smokeLife ); +const lightFromFlame = smoothstep( 1.0, 0.0, uv().y ).mul( fireLightStrength ); +const lightFromAmbient = uv().y; +const smokeBaseColor = mix( color( 0x111111 ), color( 0xff5500 ).mul( 0.45 ), lightFromFlame ); +const smokeLitColor = mix( smokeBaseColor, color( 0x2c3540 ).mul( 0.3 ), lightFromAmbient ); + +smokeMaterial.colorNode = smokeLitColor; + +// Smoke starts gently with a fade-in at the bottom +const smokeFadeIn = smoothstep( float( 0.0 ), float( 0.25 ), smokeLife ); +smokeMaterial.opacityNode = smokePuff.mul( smokeLife.oneMinus() ).mul( 0.32 ).mul( smokeFadeIn ); // semi-transparent +smokeMaterial.transparent = true; +smokeMaterial.depthWrite = false; +smokeMaterial.blending = THREE.NormalBlending; + +const smokeParticles = new THREE.Sprite( smokeMaterial ); +smokeParticles.count = smokeCount; +smokeParticles.renderOrder = 1; +smokeParticles.frustumCulled = false; +scene.add( smokeParticles ); + +// Spark Particle Emitter (Tiny Embers) +// Extra slow spark range (speed: 0.1 to 0.22, heightY: 0.6 to 1.2 spawning) +const sparkRand = range( vec4( 0.0, 0.10, 0.0, 0.0 ), vec4( 1.0, 0.22, 1.0, 1.0 ) ); +const sparkOffset = sparkRand.x; +const sparkSpeed = sparkRand.y; + +// Map Z and W components to a wider circular base of radius 0.65 to disperse them +const sparkRadiusBase = sparkRand.z.mul( 0.65 ); +const sparkAngleBase = sparkRand.w.mul( Math.PI * 2.0 ); +const sparkBaseX = sparkAngleBase.cos().mul( sparkRadiusBase ); +const sparkBaseZ = sparkAngleBase.sin().mul( sparkRadiusBase ); + +const sparkScale = range( 0.01, 0.06 ); +const sparkLife = speedTime.mul( sparkSpeed ).add( sparkOffset ).fract(); + +// Spark starts right at ground level (together with the fire) and rises to a higher altitude +const sparkY = mix( range( 0.0, 0.2 ), range( 2.2, 3.8 ), sparkLife ); + +// Sparks spawn in a narrower circle at the base and spread out significantly as they rise +const sparkSpread = sparkLife.mix( float( 0.35 ), float( 2.5 ), sparkLife ); +const sparkBasePos = vec3( sparkBaseX.mul( sparkSpread ), sparkY, sparkBaseZ.mul( sparkSpread ) ); + +// Add static random dispersion offsets that grow with life to scatter sparks as they rise +const sparkScatterX = hash( instanceIndex.add( 88.0 ) ).sub( 0.5 ).mul( 1.5 ).mul( sparkLife.pow( 1.2 ) ); +const sparkScatterZ = hash( instanceIndex.add( 99.0 ) ).sub( 0.5 ).mul( 1.5 ).mul( sparkLife.pow( 1.2 ) ); + +// Apply curlNoise displacement dynamically over time for fluid-like vortex sway (stronger multiplier for dispersion) +const sparkNoiseCoord = vec3( + sparkOffset.mul( 5.0 ), + sparkLife.mul( 0.6 ), // slow vertical progression along noise field + speedTime.mul( 0.15 ) // slow field animation +); +const sparkNoiseOffset = curlNoise( sparkNoiseCoord ).mul( 0.35 ).mul( sparkLife.pow( 1.0 ) ); +const sparkPos = sparkBasePos.add( vec3( sparkScatterX, float( 0.0 ), sparkScatterZ ) ).add( sparkNoiseOffset ); + +// pointed sparks: stretch UVs horizontally to compress spark into thin vertically elongated dashes +const sparkUv = uv().sub( vec2( 0.5, 0.5 ) ).mul( vec2( 3.5, 1.0 ) ).add( vec2( 0.5, 0.5 ) ); + +// High-fidelity circular glow using stretched UVs: yields sharp vertical needle streaks +const sparkDist = sparkUv.sub( 0.5 ).length(); +const sparkGlow = sparkDist.mul( 2.0 ).oneMinus().clamp().pow( 3.0 ); + +// Varied intensities based on instance index to simulate different temperatures +const sparkIntensity = hash( instanceIndex.add( 77.0 ) ).mul( 5.0 ).add( 2.0 ); // 2.0 to 7.0 + +const sparkMaterial = new THREE.SpriteNodeMaterial(); +sparkMaterial.positionNode = sparkPos; +sparkMaterial.scaleNode = sparkScale.mul( sparkLife.oneMinus().pow( 0.5 ) ); // shrink slowly +sparkMaterial.colorNode = color( 0xffaa00 ).mul( sparkIntensity ); // glowing gold with varied brightness + +// Sparks fade in smoothly at the start of their lifetime +const sparkFadeIn = smoothstep( float( 0.0 ), float( 0.6 ), sparkLife ); +sparkMaterial.opacityNode = sparkGlow.mul( sparkLife.oneMinus() ).mul( sparkFadeIn ); +sparkMaterial.transparent = true; +sparkMaterial.depthWrite = false; +sparkMaterial.blending = THREE.AdditiveBlending; + +const sparkParticles = new THREE.Sprite( sparkMaterial ); +sparkParticles.count = sparkCount; +sparkParticles.renderOrder = 3; +sparkParticles.frustumCulled = false; +scene.add( sparkParticles ); + +// Point Light (Flickering Fire Glow) +const fireLight = new THREE.PointLight(); +fireLight.distance = 6.0; +fireLight.position.set( 0, 0.8, 0 ); + +// Flicker intensity calculated purely via TSL using noise over time (ranges between 10.0 and 14.0) +const lightFlicker = mx_noise_float( vec3( speedTime.mul( 15.0 ), float( 0.0 ), float( 0.0 ) ) ).add( 1.0 ).mul( 0.5 ); +const lightIntensity = mix( float( 10.0 ), float( 14.0 ), lightFlicker ); + +fireLight.colorNode = color( 0xff5500 ).mul( lightIntensity ); +scene.add( fireLight ); + +// Adjust camera angle for a better view +camera.position.set( - 4, 0.4, - 1 ); +``` + + + + + +Remapping functions are used to convert values from an input range to a custom output range. This is incredibly useful for normalizing arbitrary data ranges (e.g. noise values, coordinates, or angles) into suitable inputs for color mixing, opacity envelopes, or procedural sizing factors. + +Remap Visualizer + +::: api remap( node, inLow, inHigh, outLow?, outHigh? ) : Node - Remaps a value from one range to another. +- **node**: `Node` - The input value to remap. +- **inLow**: `Node | float` - The lower bound of the input range. +- **inHigh**: `Node | float` - The upper bound of the input range. +- **outLow**: `Node | float` - The lower bound of the target output range. Defaults to `float( 0 )`. +- **outHigh**: `Node | float` - The upper bound of the target output range. Defaults to `float( 1 )`. +::: + +::: api remapClamp( node, inLow, inHigh, outLow?, outHigh? ) : Node - Remaps a value from one range to another, with clamping. +- **node**: `Node` - The input value to remap. +- **inLow**: `Node | float` - The lower bound of the input range. +- **inHigh**: `Node | float` - The upper bound of the input range. +- **outLow**: `Node | float` - The lower bound of the target output range. Defaults to `float( 0 )`. +- **outHigh**: `Node | float` - The upper bound of the target output range. Defaults to `float( 1 )`. +::: + +```tsl remapExample +import 'scenes/empty'; +import { screenUV, color, float, mix, smoothstep, remap, remapClamp } from 'three/tsl'; + +// This example compares remapping methods across three horizontal bands: +// - Top: Original gradient (x coordinate from 0.0 to 1.0). +// - Middle: Unclamped remap. The gradient naturally extrapolates outside the input range. +// - Bottom: Clamped remap. The gradient cleanly clamps at the input boundaries. + +// Define the remapping input range boundaries [0.2, 0.8] +const inLow = float( 0.2 ); +const inHigh = float( 0.8 ); + +// Base gradient colors matching the theme: dark grey to accent blue +const colorStart = color( 0x222222 ); +const colorEnd = color( 0x00aaff ); + +// Track 1: Original gradient (top 1/3) +const valOriginal = screenUV.x; +const colorOriginal = mix( colorStart, colorEnd, valOriginal ); + +// Track 2: Unclamped remap (middle 1/3) +const valRemap = remap( screenUV.x, inLow, inHigh, float( 0.0 ), float( 1.0 ) ); +// Underflow and overflow naturally extrapolate outside [0.0, 1.0] range +const colorRemap = mix( colorStart, colorEnd, valRemap ); + +// Track 3: Clamped remap (bottom 1/3) +const valRemapClamp = remapClamp( screenUV.x, inLow, inHigh, float( 0.0 ), float( 1.0 ) ); +// Underflow is clamped to 0.0 (colorStart), and overflow is clamped to 1.0 (colorEnd) +const colorRemapClamp = mix( colorStart, colorEnd, valRemapClamp ); + +// Segment the screen vertically into the three tracks (Original on top, Clamped at the bottom) +// Due to screen Y-coordinate mapping, Y < 0.33 is the top track, and Y >= 0.66 is the bottom track +const screenColor = screenUV.y.lessThan( 0.33 ).select( + colorOriginal, + screenUV.y.lessThan( 0.66 ).select( colorRemap, colorRemapClamp ) +); + +// Draw black horizontal dividers between the tracks +const divider1 = smoothstep( 0.0, 0.004, screenUV.y.sub( 0.33 ).abs() ).oneMinus(); +const divider2 = smoothstep( 0.0, 0.004, screenUV.y.sub( 0.66 ).abs() ).oneMinus(); +const dividers = divider1.add( divider2 ); + +// Composite dividers on top of the tracks +const finalColor = mix( screenColor, color( 0x000000 ), dividers ); + +renderPipeline.outputNode = finalColor; +``` + + + + + +Packing and unpacking functions compress mathematical vectors, normals, and floating-point values into compact data formats and color spaces. + +In modern rendering and deferred pipelines (MRT), packing reduces memory bandwidth and render target attachment count by packing high-dimensional data (such as normals and roughness) into standard 8-bit or 16-bit textures. + +Cel-Shading Outline + +### Normal Vector Packing + +::: api packNormalToRGB( node ) : vec3 - Packs a normalized 3D direction vector (in `[-1, 1]`) into an RGB color (in `[0, 1]`). +- **node**: `vec3` - The 3D normal or direction vector to pack. +::: + +::: api unpackRGBToNormal( node ) : vec3 - Unpacks an RGB color (in `[0, 1]`) back into a normalized 3D direction vector (in `[-1, 1]`). +- **node**: `vec3` - The RGB color to unpack. +::: + +::: api unpackNormal( xy ) : vec3 - Reconstructs a full 3D normal vector from 2D XY coordinates by projecting onto a hemisphere. +- **xy**: `vec2` - The X and Y coordinates in the `[-1, 1]` range. +::: + +### Float & Vector Bit-Packing + +::: api packSnorm2x16( value ) : uint - Packs two signed normalized floats into a single 32-bit unsigned integer. ::: + +::: api unpackSnorm2x16( value ) : vec2 - Unpacks a 32-bit unsigned integer into two signed normalized floats. ::: + +::: api packUnorm2x16( value ) : uint - Packs two unsigned normalized floats into a single 32-bit unsigned integer. ::: + +::: api unpackUnorm2x16( value ) : vec2 - Unpacks a 32-bit unsigned integer into two unsigned normalized floats. ::: + +::: api packHalf2x16( value ) : uint - Packs two 16-bit half-precision floats into a single 32-bit unsigned integer. ::: + +::: api unpackHalf2x16( value ) : vec2 - Unpacks a 32-bit unsigned integer into two 16-bit half-precision floats. ::: + +::: api packSnorm4x8( value ) : uint - Packs four signed 8-bit normalized floats into a 32-bit unsigned integer. ::: + +::: api unpackSnorm4x8( value ) : vec4 - Unpacks a 32-bit unsigned integer into four signed 8-bit normalized floats. ::: + +::: api packUnorm4x8( value ) : uint - Packs four unsigned 8-bit normalized floats into a 32-bit unsigned integer. ::: + +::: api unpackUnorm4x8( value ) : vec4 - Unpacks a 32-bit unsigned integer into four unsigned 8-bit normalized floats. ::: + +```tsl packingMRTExample +import 'scenes/shaderball'; +import * as THREE from 'three'; +import { pass, mrt, output, normalView, packNormalToRGB, unpackRGBToNormal, screenUV, viewportSize, texture, color, vec2, vec4, float, max, abs, dot } from 'three/tsl'; + +// 1. Create a render pass that writes packed normals into an MRT G-Buffer +const scenePass = pass( scene, camera ); + +// Set the normal attachment texture type to 8-bit per channel (UnsignedByteType) +const normalTex = scenePass.getTexture( 'normal' ); +normalTex.type = THREE.UnsignedByteType; + +scenePass.setMRT( mrt( { + output: output, + normal: vec4( packNormalToRGB( normalView ), float( 1.0 ) ) +} ) ); + +// 2. Retrieve the color and normal MRT attachments +const colorTexture = scenePass.getTextureNode( 'output' ); +const normalTexture = texture( normalTex ); + +// 3. Sample the packed normal at the 4 cardinal neighboring pixels (Sobel kernel) +const px = vec2( 1.0 ).div( viewportSize ); // one texel size in UV space + +const nC = unpackRGBToNormal( normalTexture.sample( screenUV ).rgb ); +const nN = unpackRGBToNormal( normalTexture.sample( screenUV.add( vec2( 0, 1 ).mul( px ) ) ).rgb ); +const nS = unpackRGBToNormal( normalTexture.sample( screenUV.add( vec2( 0, - 1 ).mul( px ) ) ).rgb ); +const nE = unpackRGBToNormal( normalTexture.sample( screenUV.add( vec2( 1, 0 ).mul( px ) ) ).rgb ); +const nW = unpackRGBToNormal( normalTexture.sample( screenUV.add( vec2( - 1, 0 ).mul( px ) ) ).rgb ); + +// 4. Detect normal discontinuities — large differences between neighbors signal edges +const edgeH = abs( dot( nC, nN ).oneMinus() ).add( abs( dot( nC, nS ).oneMinus() ) ); +const edgeV = abs( dot( nC, nE ).oneMinus() ).add( abs( dot( nC, nW ).oneMinus() ) ); +const edgeStrength = max( edgeH, edgeV ).clamp( 0.0, 1.0 ); + +// 5. Threshold the edge strength to create clean contour lines +const outlineThreshold = float( 0.15 ); +const isEdge = edgeStrength.step( outlineThreshold ).oneMinus(); + +// 6. Apply cel-shading quantization to the base color inside the model +const brightness = colorTexture.r.add( colorTexture.g ).add( colorTexture.b ).div( 3.0 ); +const celSteps = float( 4.0 ); +const quantized = brightness.mul( celSteps ).floor().div( celSteps ); +const celColor = colorTexture.mul( quantized.div( brightness.add( 0.001 ) ) ); + +// 7. Composite: draw black outline over cel-shaded color +const outlineColor = color( 0x000000 ); +const finalOutput = celColor.mix( outlineColor, isEdge ); + +renderPipeline.outputNode = finalOutput; +``` + + + + + +Debugging shader graphs involves inspecting intermediate mathematical values, checking variable generation, and analyzing compiled backend shader code (WGSL or GLSL). + +TSL provides the built-in `debug()` utility for logging compilation state and isolating node expressions during the build process. + +Debug Node + +### Functions + +::: api debug( node, callback? ) : DebugNode - Creates a debug node that logs the compiled shader code and highlights the current node expression during shader generation. +- **node**: `Node` - The node or expression to debug. +- **callback**: `Function` - (Optional) Custom callback function with signature `( builder, snippet ) => void`. If omitted, prints formatted shader code with surrounding context to the console. +::: + +::: api .debug( callback? ) : DebugNode - Chainable method to attach debugging to any existing node or expression. +- **callback**: `Function` - (Optional) Custom callback function with signature `( builder, snippet ) => void`. +::: + +### Code Inspection with `debug()` + +When `.debug()` or `debug()` is attached to an expression, TSL intercepts shader generation during the build process: + +1. It identifies the active shader stage (`vertex`, `fragment`, or `compute`). +2. It prints the generated shader flow lines up to that node in the console. +3. It clearly demarcates the current node's generated code snippet with `/* ... */ /* ... */`. + +```js +import { uv, sin, time, color } from 'three/tsl'; + +// Attach .debug() to inspect the generated code for the wave calculation +const wave = sin( uv().x.mul( 10.0 ).add( time ) ).debug(); + +material.colorNode = color( 0x00ffcc ).mul( wave ); +``` + +#### Custom Callback + +You can pass a custom callback to receive the active `NodeBuilder` instance and the generated snippet string: + +```js +const node = uv().x.mul( 2.0 ).debug( ( builder, snippet ) => { + + console.log( `Stage: ${ builder.shaderStage }, Snippet: ${ snippet }` ); + +} ); +``` + +```tsl debugExample +import 'scenes/shaderball'; +import { uv, sin, time, color, float } from 'three/tsl'; + +// 1. Calculate an animated pulse wave +const wave = sin( uv().x.mul( 12.0 ).add( time.mul( 3.0 ) ) ).mul( 0.5 ).add( 0.5 ); + +// 2. Debug the wave calculation +// debug() or .debug() outputs the generated shader flow and highlights the node in the console below +const debuggedWave = wave.debug(); + +// 3. Apply color blending driven by the debugged wave +const colorBase = color( 0x050c1a ); +const colorHighlight = color( 0x00f0ff ); +const finalColor = colorBase.mix( colorHighlight, debuggedWave ); + +model.material.colorNode = finalColor; +model.material.roughness = float( 0.2 ); +model.material.metalness = float( 0.85 ); +``` + + + + + + + + + +Functions for blending colors and layers together using standard blend mode algorithms. + +Blend Modes Showcase + +::: api blendColor( base, blend ) : vec4 - Blends two colors based on their alpha values by replicating normal alpha blending. +- **base**: `vec4` - The base color (non-premultiplied alpha). +- **blend**: `vec4` - The blend color (non-premultiplied alpha). +::: + +::: api blendScreen( base, blend ) : vec3 - Lightens the base layer's colors based on the color of the blend layer. +- **base**: `vec3` - The base color. +- **blend**: `vec3` - The blend color. A black `#000000` blend color does not alter the base color. +::: + +::: api blendOverlay( base, blend ) : vec3 - Increases contrast of the base layer by combining Multiply and Screen blend modes based on base lightness. +- **base**: `vec3` - The base color. +- **blend**: `vec3` - The blend color. +::: + +::: api blendDodge( base, blend ) : vec3 - Significantly increases brightness and contrast of the base layer based on the blend layer. +- **base**: `vec3` - The base color. +- **blend**: `vec3` - The blend color. A black `#000000` blend color does not alter the base color. +::: + +::: api blendBurn( base, blend ) : vec3 - Darkens the base layer's colors and increases contrast based on the blend layer. +- **base**: `vec3` - The base color. +- **blend**: `vec3` - The blend color. A white `#ffffff` blend color does not alter the base color. +::: + +```tsl blendModesExample +import 'scenes/shaderball'; +import { screenUV, normalWorld, vec3, float, sin, step, mix, blendScreen, blendOverlay, blendDodge, blendBurn } from 'three/tsl'; + +// Set vibrant normal vectors as the material color for the 3D ShaderBall +model.material.colorNode = normalWorld; + +// Split screen showing 5 columns side by side on defaultPass: +// 1. Gradient (colorful vertical gradient blend layer) +// 2. Screen (lightens scene with gradient) +// 3. Overlay (enhances contrast) +// 4. Dodge (brightens highlights) +// 5. Burn (darkens shadows) + +// Get screen coordinates for panel division +const u = screenUV; + +// Base 3D scene render +const base = defaultPass.rgb; + +// Import sin for smooth color wave spectrum +// Define a smooth, vibrant multi-color vertical spectrum gradient +const t = u.y.mul( 5.0 ); +const r = sin( t ).mul( 0.5 ).add( 0.5 ); +const g = sin( t.add( 2.094 ) ).mul( 0.5 ).add( 0.5 ); +const b = sin( t.add( 4.188 ) ).mul( 0.5 ).add( 0.5 ); +const blendLayer = vec3( r, g, b ).pow( 0.85 ).mul( 1.2 ); + +// Column 0: Gradient (unblended colorful gradient layer) +const col0 = blendLayer; + +// Column 1: Screen (lightens base with gradient) +const col1 = blendScreen( base, blendLayer.mul( 0.6 ) ); + +// Column 2: Overlay (enhances contrast) +const col2 = blendOverlay( base, blendLayer ); + +// Column 3: Dodge (brightens highlights) +const col3 = blendDodge( base, blendLayer.mul( 0.6 ) ); + +// Column 4: Burn (darkens shadows) +const col4 = blendBurn( base, blendLayer ); + +// Combine 5 vertical columns across screen X (0.0 to 1.0) +let panelColor = col0; +panelColor = mix( panelColor, col1, step( 0.2, u.x ) ); +panelColor = mix( panelColor, col2, step( 0.4, u.x ) ); +panelColor = mix( panelColor, col3, step( 0.6, u.x ) ); +panelColor = mix( panelColor, col4, step( 0.8, u.x ) ); + +// Sleek dark vertical grid lines between columns +const numColumns = float( 5.0 ); +const lineCoord = u.x.mul( numColumns ).fract(); +const divider = step( 0.97, lineCoord ); +const finalColor = mix( panelColor, vec3( 0.0 ), divider.mul( 0.6 ) ); + +// Assign the split-screen post-processing result to renderPipeline +renderPipeline.outputNode = finalColor; +``` + + + + + +Functions for adjusting and manipulating colors. + +Color Adjustments Showcase + +::: api grayscale( color ) - Computes a grayscale color value for the given RGB color based on luminance. Returns `vec3`. +- **color**: `vec3` - Input RGB color value. +::: + +::: api luminance( color, luminanceCoefficients? ) - Calculates the luminance (perceived brightness) of an RGB color. Returns `float`. +- **color**: `vec3` - Input RGB color value. +- **luminanceCoefficients**: `vec3` - (Optional) Luminance coefficients node. Defaults to current working color space coefficients. +::: + +::: api saturation( color, adjustment? ) - Adjusts the saturation of an RGB color. Returns `vec3`. +- **color**: `vec3` - Input RGB color value. +- **adjustment**: `float` - (Optional) Conversion factor. Values `< 1` desaturate, values `> 1` super-saturate. Defaults to `float( 1 )`. +::: + +::: api vibrance( color, adjustment? ) - Selectively enhances the intensity of less saturated RGB colors while preserving saturated ones. Returns `vec3`. +- **color**: `vec3` - Input RGB color value. +- **adjustment**: `float` - (Optional) Intensity factor for vibrance effect. Defaults to `float( 0 )`. +::: + +::: api hue( color, adjustment? ) - Rotates the hue of an RGB color while preserving its luminance and saturation. Returns `vec3`. +- **color**: `vec3` - Input RGB color value. +- **adjustment**: `float` - (Optional) Hue rotation angle in radians (positive = clockwise, negative = counterclockwise). Defaults to `float( 1 )`. +::: + +::: api posterize( source, steps ) - Reduces the number of color levels, creating a poster-like effect. Returns `Node`. +- **source**: `Node` - Input color value. +- **steps**: `Node` - Number of color levels. Lower values produce a more blocky, stylized effect. +::: + +::: api cdl( color, slope?, offset?, power?, saturation?, luminanceCoefficients? ) - Compact representation of ASC Color Decision List (CDL) v1.2 color grading information. Returns `vec4`. +- **color**: `vec4` - Input color (typically in a log color space such as LogC, ACEScc, or AgX Log). +- **slope**: `vec3` - (Optional) Slope adjustment multiplier for RGB channels. Defaults to `vec3( 1 )`. +- **offset**: `vec3` - (Optional) Offset adjustment added to RGB channels. Defaults to `vec3( 0 )`. +- **power**: `vec3` - (Optional) Power gamma exponent applied to RGB channels. Defaults to `vec3( 1 )`. +- **saturation**: `float` - (Optional) Overall saturation adjustment factor. Defaults to `float( 1 )`. +- **luminanceCoefficients**: `vec3` - (Optional) Luminance coefficients used for saturation calculation (defaults to Rec. 709). +::: + +```tsl colorAdjustmentsExample +import 'scenes/shaderball'; +import { screenUV, normalWorld, vec3, float, step, mix, hue, saturation, vibrance, posterize } from 'three/tsl'; + +// Set vibrant normal vectors as the material color for the 3D ShaderBall +model.material.colorNode = normalWorld; + +// Split screen showing 5 color adjustments side by side on defaultPass: +// 1. Original (unadjusted base scene) +// 2. Hue (fixed hue rotation of 1.5 rad) +// 3. Saturation (desaturated to 0) +// 4. Vibrance (fixed 3.0 vibrance boost) +// 5. Posterize (fixed 4 color levels) + +// Get screen coordinates for panel division +const u = screenUV; + +// Column 0: Original (unadjusted base scene) +const col0 = defaultPass.rgb; + +// Column 1: Hue (fixed hue rotation of 1.5 radians) +const col1 = hue( defaultPass, 1.5 ); + +// Column 2: Saturation (desaturated to 0) +const col2 = saturation( defaultPass, 0.0 ); + +// Column 3: Vibrance (fixed 3.0 vibrance boost) +const col3 = vibrance( defaultPass, 3.0 ); + +// Column 4: Posterize (fixed 4 color levels) +const col4 = posterize( defaultPass, 4.0 ); + +// Combine 5 vertical columns across screen X (0.0 to 1.0) +let panelColor = col0; +panelColor = mix( panelColor, col1, step( 0.2, u.x ) ); +panelColor = mix( panelColor, col2, step( 0.4, u.x ) ); +panelColor = mix( panelColor, col3, step( 0.6, u.x ) ); +panelColor = mix( panelColor, col4, step( 0.8, u.x ) ); + +// Sleek dark vertical grid lines between columns +const numColumns = float( 5.0 ); +const lineCoord = u.x.mul( numColumns ).fract(); +const divider = step( 0.97, lineCoord ); +const finalColor = mix( panelColor, vec3( 0.0 ), divider.mul( 0.6 ) ); + +// Assign the split-screen post-processing result to renderPipeline +renderPipeline.outputNode = finalColor; +``` + + + + + + + + + + +Material input nodes provide reactive GPU access to the input channels and texture maps of the material currently rendering the object. + +When referenced in a TSL graph, nodes like `materialColor`, `materialRoughness`, and `materialMetalness` automatically evaluate the inputs assigned to the material, combining base values with texture maps (e.g. `color * map`, `roughness * roughnessMap.g`). + +Material Inputs + +### Surface Inputs + +::: api materialColor : vec3 - Diffuse color of the material (composed via `color * map`). ::: + +::: api materialOpacity : float - Opacity of the material (composed via `opacity * alphaMap`). ::: + +::: api materialEmissive : vec3 - Emissive color (composed via `emissive * emissiveIntensity * emissiveMap`). ::: + +::: api materialNormal : vec3 - Surface normal direction (evaluated from `normalMap`, `bumpMap`, or `normalView`). ::: + +::: api materialRoughness : float - Roughness factor (composed via `roughness * roughnessMap.g`). ::: + +::: api materialMetalness : float - Metalness factor (composed via `metalness * metalnessMap.b`). ::: + +### Physical & Advanced Inputs + +::: api materialSpecular : vec3 - Specular tint color of the material. ::: + +::: api materialSpecularIntensity : float - Specular intensity factor (composed via `specularIntensity * specularMap.a`). ::: + +::: api materialReflectivity : float - Surface reflectivity coefficient. ::: + +::: api materialClearcoat : float - Clearcoat layer intensity (composed via `clearcoat * clearcoatMap.r`). ::: + +::: api materialClearcoatRoughness : float - Clearcoat roughness factor (composed via `clearcoatRoughness * clearcoatRoughnessMap.r`). ::: + +::: api materialClearcoatNormal : vec3 - Normal direction of the clearcoat layer. ::: + +::: api materialSheen : vec3 - Sheen layer color (composed via `sheen * sheenColor * sheenColorMap`). ::: + +::: api materialSheenRoughness : float - Sheen roughness factor (composed via `sheenRoughness * sheenRoughnessMap.a`). ::: + +::: api materialTransmission : float - Transmission factor through transparent materials (composed via `transmission * transmissionMap.r`). ::: + +::: api materialThickness : float - Volume thickness for transmission and subsurface scattering (composed via `thickness * thicknessMap.g`). ::: + +::: api materialIOR : float - Index of Refraction (IOR). ::: + +::: api materialIridescence : float - Iridescence layer intensity. ::: + +::: api materialAnisotropy : vec2 - Anisotropy direction vector for directional and brushed surfaces. ::: + +::: api materialDispersion : float - Chromatic dispersion strength. ::: + +### Environment & Occlusion + +::: api materialAO : float - Ambient occlusion value (composed via `aoMap.r - 1 * aoMapIntensity + 1`). ::: + +::: api materialLightMap : vec3 - Baked lightmap color (composed via `lightMapIntensity * lightMap.rgb`). ::: + +::: api materialEnvIntensity : float - Environment reflection intensity factor. ::: + +::: api materialEnvRotation : mat4 - Environment map rotation matrix. ::: + +### Material Reference + +::: api materialReference( name, type, material? ) : MaterialReferenceNode - Creates a reactive node linked directly to a property on a material. +- **name**: `string` - Name of the property on the material (e.g. `'opacity'`, `'roughness'`, or a custom property). +- **type**: `string` - Uniform type used to represent the value (`'float'`, `'color'`, `'vec2'`, `'vec3'`, `'vec4'`). +- **material**: `Material | null` - (Optional) Target material. If `null`, dynamically tracks the material of the current rendered object. +::: + +> Note: `materialReference()` creates a live link to any JavaScript material property: whenever `material[ name ]` is modified on the CPU, the GPU uniform updates automatically without triggering a shader recompilation. + +```tsl materialInputsExample +import 'scenes/shaderball'; +import * as THREE from 'three'; +import { materialColor, materialRoughness, materialMetalness, normalView, positionViewDirection, color, float } from 'three/tsl'; + +// 1. Configure base material inputs in JavaScript +model.material.color = new THREE.Color( 0x0a1e3f ); +model.material.roughness = 0.35; +model.material.metalness = 0.85; + +// 2. Access the material inputs inside TSL expressions +const baseColor = materialColor; +const baseRoughness = materialRoughness; +const baseMetalness = materialMetalness; + +// 3. Compute dynamic Fresnel rim effect +const fresnel = normalView.dot( positionViewDirection ).oneMinus().pow( 3.0 ); + +// 4. Modulate color and roughness dynamically using material inputs +const glowColor = color( 0x00f0ff ); +model.material.colorNode = fresnel.mix( baseColor, glowColor ); +model.material.roughnessNode = fresnel.mix( baseRoughness, float( 0.05 ) ); +model.material.metalnessNode = baseMetalness; +``` + + + + + + + + + +MaterialX procedural noise nodes provide GPU-native, resolution-independent texture and value generators based on the open standards developed by Industrial Light & Magic (ILM) and the Academy Software Foundation (ASWF). + +In TSL, these noise nodes run directly on the GPU across both WebGPU and WebGL backends, enabling organic surfaces, dynamic terrain displacement, fluid visual effects, and animated patterns without needing any external texture image assets. + +### Perlin Noise + +Perlin (Float) Perlin (vec3) + +::: api mx_noise_float( texcoord?, amplitude?, pivot? ) : float - Computes 2D/3D Perlin value noise returning a scalar float. +- **texcoord**: `vec2 | vec3` - Evaluation coordinate node. Defaults to `uv()`. +- **amplitude**: `float | number` - Amplitude scaling multiplier. Defaults to `1`. +- **pivot**: `float | number` - Value offset added to the result. Defaults to `0`. +::: + +::: api mx_noise_vec3( texcoord?, amplitude?, pivot? ) : vec3 - Computes 2D/3D Perlin vector noise returning a 3D vector. +- **texcoord**: `vec2 | vec3` - Evaluation coordinate node. Defaults to `uv()`. +- **amplitude**: `float | number` - Amplitude scaling multiplier. Defaults to `1`. +- **pivot**: `float | number` - Value offset added to the result. Defaults to `0`. +::: + +### Cell Noise + +Cell (Float) Cell (Color) + +::: api mx_cell_noise_float( texcoord? ) : float - Generates 2D/3D Voronoi cellular noise returning a scalar float per cell. +- **texcoord**: `vec2 | vec3` - Coordinate node. Defaults to `uv()`. +::: + +::: api mx_cell_noise_vec3( texcoord? ) : vec3 - Generates 2D/3D Voronoi cellular noise returning random RGB color per cell. +- **texcoord**: `vec2 | vec3` - Coordinate node. Defaults to `uv()`. +::: + +### Worley Noise + +Worley (F1) Worley (Borders) Worley (vec2) Worley (vec3) + +::: api mx_worley_noise_float( texcoord?, jitter?, style? ) : float - Generates 3D Worley distance noise. `style = 0` calculates F1 distance, `style = 1` calculates F2 - F1 boundary distance. +- **texcoord**: `vec2 | vec3` - Coordinate node. Defaults to `uv()`. +- **jitter**: `float | number` - Feature jitter randomness factor. Defaults to `1`. +- **style**: `int | number` - Distance style formula (`0` for F1, `1` for F2 - F1). Defaults to `0`. +::: + +::: api mx_worley_noise_vec2( texcoord?, jitter? ) : vec2 - Computes Worley closest feature distances `vec2(F1, F2)`. +- **texcoord**: `vec2 | vec3` - Coordinate node. Defaults to `uv()`. +- **jitter**: `float | number` - Jitter factor. Defaults to `1`. +::: + +::: api mx_worley_noise_vec3( texcoord?, jitter?, metric? ) : vec3 - Computes Worley feature distances `vec3(F1, F2, F3)`. +- **texcoord**: `vec2 | vec3` - Coordinate node. Defaults to `uv()`. +- **jitter**: `float | number` - Jitter factor. Defaults to `1`. +- **metric**: `int | number` - Distance metric mode (`0` Euclidean, `1` Manhattan, `2` Chebyshev). Defaults to `1`. +::: + +### Fractal Noise (FBM) + +Fractal (Float) Fractal (vec3) + +::: api mx_fractal_noise_float( position?, octaves?, lacunarity?, diminish?, amplitude? ) : float - Computes scalar multi-octave Fractal Brownian Motion (FBM) noise. +- **position**: `vec2 | vec3` - Coordinate node. Defaults to `uv()`. +- **octaves**: `int | number` - Number of octave layers. Defaults to `3`. +- **lacunarity**: `float | number` - Frequency multiplier between octaves. Defaults to `2`. +- **diminish**: `float | number` - Amplitude decay factor per octave. Defaults to `0.5`. +- **amplitude**: `float | number` - Output amplitude multiplier. Defaults to `1`. +::: + +::: api mx_fractal_noise_vec3( position?, octaves?, lacunarity?, diminish?, amplitude? ) : vec3 - Computes 3D vector multi-octave Fractal noise. +- **position**: `vec2 | vec3` - Coordinate node. Defaults to `uv()`. +- **octaves**: `int | number` - Number of octave layers. Defaults to `3`. +- **lacunarity**: `float | number` - Frequency multiplier per octave. Defaults to `2`. +- **diminish**: `float | number` - Amplitude decay factor per octave. Defaults to `0.5`. +- **amplitude**: `float | number` - Output amplitude multiplier. Defaults to `1`. +::: + +### Unified Noise + +Unified (3D) + +::: api mx_unifiednoise3d( noiseType, texcoord?, freq?, offset?, jitter?, outmin?, outmax?, clampoutput?, octaves?, lacunarity?, diminish?, style? ) : Node - Unified 3D noise interface supporting `0: Perlin`, `1: Cell`, `2: Worley`, `3: Fractal`. +- **noiseType**: `int | number` - Noise algorithm (`0` to `3`). +- **texcoord**: `vec3` - Coordinate vector node. Defaults to `uv()`. +- **freq**: `vec3` - Spatial frequency vector. Defaults to `vec3(1, 1, 1)`. +- **offset**: `vec3` - Spatial animation offset. Defaults to `vec3(0, 0, 0)`. +- **jitter**: `float` - Worley jitter factor. Defaults to `1`. +- **outmin**: `float` - Minimum mapped output. Defaults to `0`. +- **outmax**: `float` - Maximum mapped output. Defaults to `1`. +- **clampoutput**: `bool` - Clamp output between `outmin` and `outmax`. Defaults to `false`. +- **octaves**: `int` - Octaves for fractal mode. Defaults to `1`. +- **lacunarity**: `float` - Lacunarity factor. Defaults to `2`. +- **diminish**: `float` - Diminish factor. Defaults to `0.5`. +- **style**: `int` - Worley noise style. Defaults to `0`. +::: + +```tsl perlinNoiseFloat +import 'scenes/shaderball'; +import { uv, mx_noise_float } from 'three/tsl'; + +model.material.colorNode = mx_noise_float( uv().mul( 100.0 ) ); +``` + +```tsl perlinNoiseVec3 +import 'scenes/shaderball'; +import { uv, mx_noise_vec3 } from 'three/tsl'; + +model.material.colorNode = mx_noise_vec3( uv().mul( 100.0 ) ); +``` + +```tsl cellNoiseFloat +import 'scenes/shaderball'; +import { uv, mx_cell_noise_float } from 'three/tsl'; + +model.material.colorNode = mx_cell_noise_float( uv().mul( 100.0 ) ); +``` + +```tsl cellNoiseVec3 +import 'scenes/shaderball'; +import { uv, mx_cell_noise_vec3 } from 'three/tsl'; + +model.material.colorNode = mx_cell_noise_vec3( uv().mul( 100.0 ) ); +``` + +```tsl worleyNoiseF1 +import 'scenes/shaderball'; +import { uv, mx_worley_noise_float } from 'three/tsl'; + +model.material.colorNode = mx_worley_noise_float( uv().mul( 100.0 ), 1.0, 0 ); +``` + +```tsl worleyNoiseBorders +import 'scenes/shaderball'; +import { uv, mx_worley_noise_float } from 'three/tsl'; + +model.material.colorNode = mx_worley_noise_float( uv().mul( 100.0 ), 1.0, 1 ); +``` + +```tsl worleyNoiseVec2 +import 'scenes/shaderball'; +import { uv, mx_worley_noise_vec2 } from 'three/tsl'; + +model.material.colorNode = mx_worley_noise_vec2( uv().mul( 100.0 ) ); +``` + +```tsl worleyNoiseVec3 +import 'scenes/shaderball'; +import { uv, mx_worley_noise_vec3 } from 'three/tsl'; + +model.material.colorNode = mx_worley_noise_vec3( uv().mul( 100.0 ) ); +``` + +```tsl fractalNoiseFloat +import 'scenes/shaderball'; +import { uv, mx_fractal_noise_float } from 'three/tsl'; + +model.material.colorNode = mx_fractal_noise_float( uv().mul( 100.0 ), 4 ); +``` + +```tsl fractalNoiseVec3 +import 'scenes/shaderball'; +import { uv, mx_fractal_noise_vec3 } from 'three/tsl'; + +model.material.colorNode = mx_fractal_noise_vec3( uv().mul( 100.0 ), 4 ); +``` + +```tsl unifiedNoise3D +import 'scenes/shaderball'; +import { positionLocal, int, vec3, mx_unifiednoise3d } from 'three/tsl'; + +model.material.colorNode = mx_unifiednoise3d( int( 0 ), positionLocal.mul( 20.0 ), vec3( 1, 1, 1 ) ); +``` + + + + + +MaterialX standard helper functions for procedural ramps, anti-aliased transitions, 2D and 3D spatial transformations, normal reconstruction, and math operations. + +::: api mx_aastep( threshold, value ) : float - Anti-aliased step function using screen derivatives (`dFdx`, `dFdy`) to eliminate sub-pixel aliasing and jagged edges. +- **threshold**: `Node | float | number` - Step threshold boundary. +- **value**: `Node | float | number` - Input value evaluated against threshold. +::: + +::: api mx_ramplr( valuel, valuer, texcoord? ) : Node - Linear horizontal ramp interpolating from `valuel` at `u=0` to `valuer` at `u=1`. +- **valuel**: `Node | Color | number` - Left value at `u = 0`. +- **valuer**: `Node | Color | number` - Right value at `u = 1`. +- **texcoord**: `vec2` - (Optional) UV coordinates. Defaults to `uv()`. +::: + +::: api mx_ramptb( valueb, valuet, texcoord? ) : Node - Linear vertical ramp interpolating from `valueb` at `v=0` to `valuet` at `v=1`. +- **valueb**: `Node | Color | number` - Bottom value at `v = 0`. +- **valuet**: `Node | Color | number` - Top value at `v = 1`. +- **texcoord**: `vec2` - (Optional) UV coordinates. Defaults to `uv()`. +::: + +::: api mx_ramp4( valuetl, valuetr, valuebl, valuebr, texcoord? ) : Node - Bilinear 4-corner ramp interpolating four corner values across UV space. +- **valuetl**: `Node | Color | number` - Top-Left value `(0, 1)`. +- **valuetr**: `Node | Color | number` - Top-Right value `(1, 1)`. +- **valuebl**: `Node | Color | number` - Bottom-Left value `(0, 0)`. +- **valuebr**: `Node | Color | number` - Bottom-Right value `(1, 0)`. +- **texcoord**: `vec2` - (Optional) UV coordinates. Defaults to `uv()`. +::: + +::: api mx_splitlr( valuel, valuer, center?, texcoord? ) : Node - Anti-aliased horizontal step splitting `valuel` and `valuer` at `center`. +- **valuel**: `Node | Color | number` - Left value. +- **valuer**: `Node | Color | number` - Right value. +- **center**: `Node | float | number` - Split coordinate threshold. Defaults to `0.5`. +- **texcoord**: `vec2` - (Optional) UV coordinates. Defaults to `uv()`. +::: + +::: api mx_splittb( valueb, valuet, center?, texcoord? ) : Node - Anti-aliased vertical step splitting `valueb` and `valuet` at `center`. +- **valueb**: `Node | Color | number` - Bottom value. +- **valuet**: `Node | Color | number` - Top value. +- **center**: `Node | float | number` - Split coordinate threshold. Defaults to `0.5`. +- **texcoord**: `vec2` - (Optional) UV coordinates. Defaults to `uv()`. +::: + +::: api mx_transform_uv( uv_scale?, uv_offset?, uv_geo? ) : vec2 - Scales and offsets 2D UV texture coordinates. +- **uv_scale**: `Node | vec2 | number` - Scale factor. Defaults to `1`. +- **uv_offset**: `Node | vec2 | number` - Translation offset. Defaults to `0`. +- **uv_geo**: `vec2` - Base UV coordinates. Defaults to `uv()`. +::: + +::: api mx_place2d( texcoord, pivot?, scale?, rotate?, offset?, operationorder? ) : vec2 - Full 2D texture coordinate placement matrix transformation. +- **texcoord**: `vec2` - Base UV coordinates. +- **pivot**: `vec2` - Center pivot point. Defaults to `vec2(0, 0)`. +- **scale**: `vec2` - Coordinate scale factors `(u, v)`. Defaults to `vec2(1, 1)`. +- **rotate**: `float | number` - Rotation angle in degrees. Defaults to `0`. +- **offset**: `vec2` - Translation offset vector. Defaults to `vec2(0, 0)`. +- **operationorder**: `int | number` - Transformation order: `0: SRT` (Scale, Rotate, Translate), `1: TRS` (Translate, Rotate, Scale). Defaults to `0`. +::: + +::: api mx_rotate2d( input, amount? ) : vec2 - Rotates a 2D vector by `amount` degrees around the origin. +- **input**: `vec2` - Vector to rotate. +- **amount**: `float | number` - Rotation angle in degrees. Defaults to `0`. +::: + +::: api mx_rotate3d( input, amount?, axis? ) : vec3 - Rotates a 3D vector around an arbitrary 3D axis by `amount` degrees using Rodrigues' rotation formula. +- **input**: `vec3` - 3D vector or position to rotate. +- **amount**: `float | number` - Rotation angle in degrees. Defaults to `0`. +- **axis**: `vec3` - Rotation axis vector (automatically normalized). Defaults to `vec3(0, 1, 0)`. +::: + +::: api mx_heighttonormal( input, scale?, texcoord? ) : vec3 - Reconstructs tangent-space surface normal vectors from a scalar procedural height field using Sobel screen/UV partial derivatives. +- **input**: `Node | float` - Scalar height input node. +- **scale**: `float | number` - Bump/height strength scale factor. Defaults to `1`. +- **texcoord**: `vec2` - Texture coordinate node. Defaults to `uv()`. +::: + +::: api mx_safepower( in1, in2? ) : Node - Computes `sign(in1) * |in1|^in2`, safely preserving sign without NaN errors on negative inputs. +- **in1**: `Node | float` - Base input value. +- **in2**: `Node | float | number` - Exponent. Defaults to `1`. +::: + +::: api mx_contrast( input, amount?, pivot? ) : Node - Adjusts contrast around a midpoint pivot. +- **input**: `Node | float | vec3` - Input value or color. +- **amount**: `float | number` - Contrast multiplier factor. Defaults to `1`. +- **pivot**: `float | number` - Center pivot value. Defaults to `0.5`. +::: + +::: api mx_smoothstep( inNode, low?, high? ) : Node - Hermite smoothstep interpolation with safe fallback to prevent zero-range division artifacts. +- **inNode**: `Node | float` - Input value. +- **low**: `Node | float | number` - Lower bound. Defaults to `0`. +- **high**: `Node | float | number` - Upper bound. Defaults to `1`. +::: + + + + + + + + + +In TSL, `ContextNode` is a cascading configuration and environment system that flows downward through the node graph (Abstract Syntax Tree) during compilation. + +It allows materials, render passes, and individual sub-graphs to inject configuration parameters, override global behaviors (such as UV coordinates, shadow sampling, or ambient occlusion), and assign custom variable names without modifying the underlying nodes. + +Context Showcase + +::: api context( nodeOrValue, value ) : ContextNode - Wraps a node with contextual dictionary data that flows downward to all child nodes during compilation. +- **nodeOrValue**: `Node | Object` - The target node to wrap, or the context dictionary object if creating a standalone context wrapper. +- **value**: `Object` - Key-value dictionary containing contextual parameters and hooks. +::: + +::: api .context( value ) : ContextNode - Method chaining helper to wrap the current node expression with contextual data. ::: + +### Context Properties + +::: api getUV : Function - Callback `( builder ) => Node` to override the UV coordinate used by all textures and UV-dependent nodes in the active sub-tree. ::: + +::: api getShadow : Function - Callback `( { light, shadowColorNode } ) => Node` to customize or filter shadow calculations across the contextual sub-tree. ::: + +::: api getAO : Function - Callback `( inputNode, { material } ) => Node` to customize or modulate ambient occlusion evaluation across the sub-tree. ::: + +### Context Hierarchy Cascade + +Context configuration flows downward from the highest level of the rendering engine down to individual node expressions: + +```mermaid +flowchart TD + Renderer["WebGPURenderer
renderer.contextNode
Global scene context
"] + Pass["RenderPipeline / PassNode
pass.contextNode
Per-pass context
"] + Material["NodeMaterial
material.contextNode
Material-wide context
"] + SubGraph["ContextNode / Sub-Graph
node.context( { ... } )
Scoped expression context
"] + Target["Target Nodes
Inherit and evaluate within active context"] + + Renderer --> Pass + Pass --> Material + Material --> SubGraph + SubGraph --> Target +``` + +#### Overriding UVs for All Child Textures +Assigning `getUV` to `material.contextNode` or wrapping an expression automatically redirects texture sampling across the entire sub-tree: + +```js +import * as THREE from 'three'; +import { uv, vec2, time, texture } from 'three/tsl'; + +const map = new THREE.TextureLoader().load( '../examples/textures/uv_grid_opengl.jpg' ); +map.wrapS = THREE.RepeatWrapping; +map.wrapT = THREE.RepeatWrapping; + +// Dynamic animated and scaled UV coordinates +const animatedUV = uv().mul( 3.0 ).add( vec2( time.mul( 0.2 ), 0.0 ) ); + +// All textures in this material automatically use animatedUV instead of standard uv() +material.contextNode = material.context( { + getUV: () => animatedUV +} ); + +material.colorNode = texture( map ); +``` + +```tsl contextExample +import 'scenes/shaderball'; +import * as THREE from 'three'; +import { uv, time, texture, vec2 } from 'three/tsl'; + +// Load texture map +const map = new THREE.TextureLoader().load( '../examples/textures/uv_grid_opengl.jpg' ); +map.wrapS = THREE.RepeatWrapping; +map.wrapT = THREE.RepeatWrapping; + +// 1. Custom animated and scaled UV coordinates +const animatedUV = uv().mul( 3.0 ).add( vec2( time.mul( 0.2 ), 0.0 ) ); + +// 2. Wrap material context to redirect getUV for all textures across the material +model.material.contextNode = uv().context( { + getUV: () => animatedUV +} ); + +// 3. Texture sampling automatically inherits the contextual animated UVs +model.material.colorNode = texture( map ); +model.material.roughness = 0.2; +model.material.metalness = 0.5; +``` + +
+ + + +In TSL, **`setName()`** (and `.setName()`) assigns an explicit, readable identifier name to a node expression in the generated shader source code (WGSL or GLSL). + +By default, `NodeBuilder` names intermediate variables with auto-incrementing identifiers (e.g. `nodeVar0`, `nodeVar1`, `nodeVar2`). Using `setName()` makes compiled shaders clean, self-documenting, and easy to inspect in browser developer tools (such as Chrome WebGPU DevTools, Spector.js, or RenderDoc). + +Named Variables Showcase + +::: api setName( node, name ) : ContextNode - Assigns an explicit variable name to a node in the generated shader code. +- **node**: `Node` - The target node to assign an identifier name. +- **name**: `string` - The identifier name to emit in the compiled shader. +::: + +::: api .setName( name ) : ContextNode - Method chaining helper to assign an explicit variable name to the current node expression. ::: + +### Generated Code Comparison + +```js +import { uniform, color } from 'three/tsl'; + +// 1. Without setName: NodeBuilder generates generic uniform identifiers +const speedA = uniform( 2.0 ); +// Emits uniform buffer member: nodeUniform0: f32 + +// 2. With setName: NodeBuilder emits clear, self-documenting uniform names +const speedB = uniform( 2.0 ).setName( 'waveSpeed' ); +// Emits uniform buffer member: waveSpeed: f32 + +const glowColor = uniform( color( 0x00f0ff ) ).setName( 'glowColor' ); +// Emits uniform buffer member: glowColor: vec3 +``` + +> AI: `label()` was previously used for this purpose and is deprecated in favor of `setName()`. + +```tsl setNameExample +import 'scenes/shaderball'; +import { uniform, color, positionLocal, time } from 'three/tsl'; + +// Define explicit named uniforms for shader inspection +const speed = uniform( 2.0 ).setName( 'waveSpeed' ); +const frequency = uniform( 10.0 ).setName( 'waveFrequency' ); +const glowColor = uniform( color( 0x00f0ff ) ).setName( 'glowColor' ); +const baseColor = uniform( color( 0x070b1a ) ).setName( 'baseColor' ); + +// Named intermediate calculation +const wave = positionLocal.y.mul( frequency ).add( time.mul( speed ) ).sin().abs().setName( 'wavePattern' ); + +model.material.colorNode = baseColor.add( glowColor.mul( wave ) ); +model.material.roughness = 0.2; +model.material.metalness = 0.8; +model.material.emissiveNode = glowColor.mul( wave.pow( 2.0 ) ); +``` + + + + + +In modern graphics APIs like **WebGPU (WGSL)**, operations that rely on implicit screen-space derivatives (such as `fwidth()`, `dFdx()`, `dFdy()`, or mipmapped texture sampling) require execution within **Uniform Control Flow**. + +On the GPU, derivatives are calculated by comparing values between adjacent 2×2 fragment pixels (quads). When calculations with derivatives are placed inside a divergent conditional branch (where adjacent pixels take different execution paths), neighboring quad threads become desynchronized, causing corrupted derivatives and visual artifacts along the boundary. + +`uniformFlow( node )` (or `.uniformFlow()`) forces `NodeBuilder` to evaluate all expressions in the root uniform scope across all threads *before* selecting the result. + +Uniform Flow Showcase + +::: api uniformFlow( node ) : ContextNode - Enforces that all child node dependencies execute strictly within uniform control-flow paths. +- **node**: `Node` - The node whose dependencies must evaluate in uniform control flow. +::: + +::: api .uniformFlow() : ContextNode - Method chaining helper to enforce uniform control-flow execution on the current node expression. ::: + +### Example + +Conditionals compile into dynamic `if/else` branching (evaluated locally). When inside a `uniformFlow()`, the code uses native `select()`, executing both branches instead of only one. + +```js +// 1. Without uniformFlow(): emits dynamic if/else branching +const resultA = select( condition, valueA, valueB ); + +// 2. With uniformFlow(): evaluates both branches uniformly before selecting +const resultB = select( condition, valueA, valueB ).uniformFlow(); +``` + +> Important: Because `uniformFlow()` evaluates both branches unconditionally to maintain quad thread synchronization, it can impact performance if the branches involve heavy math or expensive texture lookups. + +```tsl uniformFlowExample +import 'scenes/plane'; +import { uv, time, select, fwidth, color, float, fract } from 'three/tsl'; + +// 1. Dynamic animated division boundary across the plane +const splitPos = time.mul( 0.4 ).sin().mul( 0.3 ).add( 0.5 ); +const condition = uv().x.greaterThan( splitPos ); + +// 2. Anti-aliased procedural stripe pattern that relies on fwidth() derivatives +const stripePattern = ( scale ) => { + + const coord = uv().mul( scale ); + const f = fract( coord.x ); + const fw = fwidth( coord.x ); // Screen-space derivative across neighbor pixels in 2x2 quad + return f.div( fw.mul( 100.0 ) ).clamp( 0.0, 1.0 ); + +}; + +const patternA = stripePattern( float( 10.0 ) ); +const patternB = stripePattern( float( 25.0 ) ); + +const cyan = color( 0x00f0ff ).mul( patternA ); +const magenta = color( 0xff0055 ).mul( patternB ); + +// 3. select() with uniformFlow(): +// - WITH uniformFlow(): quad threads evaluate derivatives synchronously in uniform scope (clean boundary) +// - WITHOUT uniformFlow(): quad threads diverge across the split, corrupting the derivative along the boundary seam +const finalColor = select( condition, cyan, magenta ).uniformFlow(); + +// 4. Assign to plane material +plane.material.colorNode = finalColor.debug(); +``` + + + + + +TSL provides pre-built context helpers such as **`builtinAOContext()`** and **`builtinShadowContext()`** to quickly modify ambient occlusion and lighting shadow behavior across a material or node sub-graph without manually writing custom `getAO` or `getShadow` context handler objects. + +::: api builtinAOContext( aoNode, node = null ) : ContextNode - Intercepts `getAO` to modulate ambient occlusion for non-transparent materials by `aoNode`. +- **aoNode**: `Node` - The ambient occlusion node to multiply. +- **node**: `Node` - Optional node expression to wrap with this AO context. Defaults to `null`. +::: + +::: api .builtinAOContext( aoNode ) : ContextNode - Method chaining helper to wrap the current node expression with a built-in AO context. ::: + +::: api builtinShadowContext( shadowNode, light, node = null ) : ContextNode - Intercepts `getShadow` to modulate the shadow color of a specific light by `shadowNode`. +- **shadowNode**: `Node` - The shadow modulation node. +- **light**: `Light` - The target light whose shadow should be modulated. +- **node**: `Node` - Optional node expression to wrap with this shadow context. Defaults to `null`. +::: + +::: api .builtinShadowContext( shadowNode, light ) : ContextNode - Method chaining helper to wrap the current node expression with a built-in shadow context. ::: + +### Example + +```js +import { builtinAOContext } from 'three/tsl'; + +// Modulate global ambient occlusion and shadow for the material +myPass.contextNode = builtinAOContext( screenSpaceAO ).builtinShadowContext( screenSpaceShadow, dirLight ); +``` + + + + + +In TSL, **`overrideNode`** (and `overrideNodes`) provides a mechanism to dynamically intercept and substitute specific target nodes within a node sub-graph, material, or pass during compilation. + +This acts as dynamic dependency injection for shaders, allowing you to replace fundamental inputs (such as `positionLocal`, `positionView`, `normalView`, or `positionViewDirection`) without modifying or duplicating existing node graphs. + +Override position + +::: api overrideNode( targetNode, callbackOrNode ) : OverrideContextNode - Overrides a single target node during compilation within a contextual flow. +- **targetNode**: `Node` - The target node to intercept and replace. +- **callbackOrNode**: `Function | Node` - A callback `(builder) => Node` returning the replacement, or the replacement `Node` directly. +::: + +::: api overrideNodes( overrides ) : OverrideContextNode - Overrides multiple target nodes simultaneously during compilation. +- **overrides**: `Array<[Node, Function | Node]> | Map` - Map or array of pairs mapping target nodes to their respective replacement callbacks or nodes. +::: + +```js +// Override a single node +material.contextNode = overrideNode( positionLocal, () => positionLocal.add( vec3( 1, 0, 0 ) ) ); + +// Override multiple nodes +material.contextNode = overrideNodes( [ + [ positionView, customPositionView ], + [ normalView, customNormalView ] +] ); +``` + +::: api .overrideNode( targetNode, callbackOrNode ) : OverrideContextNode - Method chaining helper to override a single target node for a specific node expression. ::: + +::: api .overrideNodes( overrides ) : OverrideContextNode - Method chaining helper to override multiple target nodes for a specific node expression. ::: + +### Compilation Flow + +When `OverrideContextNode` wraps a node expression, `NodeBuilder` intercepts references to the target node during compilation and evaluates the replacement node instead. + +### Common Use Cases + +#### 1. Material-Wide Input Substitution +Assigning an override to `material.contextNode` replaces the target node across all stages (vertex and fragment) of that material: + +```js +// Displaces vertex positions and keeps fragment calculations in sync +material.contextNode = overrideNode( positionLocal, () => positionLocal.add( normalLocal.mul( wave ) ) ); +``` + +#### 2. Deferred Rendering (G-Buffer Resolve) +In deferred rendering, standard lighting materials require view-space positions and normals. Instead of scene geometry, `overrideNodes()` redirects the material to sample G-Buffer MRT textures: + +```js +// Resolve pass: standard lighting material evaluating from G-Buffer textures +resolveMaterial.contextNode = overrideNodes( [ + [ positionView, gBufferPositionView ], + [ positionViewDirection, gBufferPositionView.negate().normalize() ], + [ normalView, gBufferNormalView ] +] ); +``` + +#### 3. Scoped Sub-Graph Substitution +Calling `.overrideNode()` on a specific node expression restricts the override strictly to that sub-tree: + +Sub-graph override + +```js +// Base stripe sub-graph along the Y-axis (horizontal) +const stripe = positionLocal.y.mul( 14.0 ).sin().abs(); + +// Branch 1: Standard horizontal stripes +const branch1 = color( 0x00ffff ).mul( stripe ).isolate(); + +// Branch 2: Rotated 90° into vertical stripes via swizzling (.yxz) +const branch2 = color( 0xff8800 ).mul( + stripe.overrideNode( positionLocal, () => positionLocal.yxz ) +).isolate(); + +// Unify both branches into a cross-hatched grid +material.colorNode = branch1.add( branch2 ); +``` + +> Note: To evaluate the exact same node multiple times under different contextual parameters in the same graph, use [.isolate()](#isolate) to prevent cache reuse. + +```tsl overridePosition +import 'scenes/shaderball'; +import { overrideNode, positionLocal, normalLocal, time, color } from 'three/tsl'; + +model.material.colorNode = color( 0x0077ff ); +model.material.roughness = 0.5; +model.material.metalness = 0.0; + +// Override positionLocal dynamically across the material context +model.material.contextNode = overrideNode( positionLocal, () => { + + // Safe to reference positionLocal inside the callback without infinite recursion + const wave = positionLocal.y.mul( 10.0 ).add( time.mul( 3.0 ) ).sin().mul( 0.08 ); + + return positionLocal.add( normalLocal.mul( wave ) ); + +} ); +``` + +```tsl subGraphOverride +import 'scenes/shaderball'; +import { color, positionLocal, time } from 'three/tsl'; + +// Base stripe pattern computed along the Y-axis (horizontal stripes) +const stripe = positionLocal.y.mul( 14.0 ).add( time.mul( 2.0 ) ).sin().abs(); + +// Branch 1: Evaluates stripe with standard positionLocal (Horizontal stripes) +const branch1 = color( 0x00ffff ).mul( stripe ).isolate(); + +// Branch 2: Swaps coordinates (.yxz) to rotate the pattern 90° into vertical stripes +const branch2 = color( 0xff8800 ).mul( + stripe.overrideNode( positionLocal, () => positionLocal.yxz ) +).isolate(); + +// Unify: Combines horizontal and vertical stripes into a glowing grid +model.material.colorNode = branch1.add( branch2 ); +``` + + + + + +By default, TSL automatically caches node evaluations. If you use the same node in multiple places, TSL builds it once and reuses the result to avoid redundant GPU calculations. + +However, if you want to evaluate the **same node** under different parameters (such as sampling a texture at different UV scales or offsets with `.context()`), the default caching will return the first evaluation and ignore your changes. + +`isolate( node )` or `.isolate()` tells TSL to create an **isolated cache scope**, forcing the node to be evaluated fresh without reusing or overwriting existing cache data. + +::: api isolate( node: Node ) : IsolateNode - Creates an isolated cache wrapper for a node. +- **node**: `Node` - The node whose evaluation cache should be isolated. +::: + +::: api .isolate() : IsolateNode - Method chaining helper to create an isolated cache wrapper. +::: + +### Caching vs Isolate + +```mermaid +flowchart TD + Node["Base Node
Shared node graph"] + Call1["1st Call
Initial build & cached"] + Call2["2nd Call
Subsequent evaluation"] + Call2A["node.context( ... )
Reuses 1st cache"] + Call2B["node.isolate().context( ... )
Fresh isolated scope"] + + Node --> Call1 + Call1 --> Call2 + Call2 -->|"Standard"| Call2A + Call2 -->|"Isolated"| Call2B +``` + +### When to Use + +Use `isolate()` whenever you need to recreate or re-evaluate a node's code flow under a different context. Because TSL caches and reuses previously built expressions by default, wrapping a node with `.isolate()` allows its entire sub-graph to be built fresh in a separate scope—enabling you to safely apply new contextual parameters. + +```tsl +import 'scenes/shaderball'; +import * as THREE from 'three'; +import { texture, uv, vec2, time } from 'three/tsl'; + +// Load base texture map +const map = new THREE.TextureLoader().load( '../examples/textures/uv_grid_opengl.jpg' ); +map.wrapS = THREE.RepeatWrapping; +map.wrapT = THREE.RepeatWrapping; + +const textureNode = texture( map ); + +// Sample the same textureNode with different UV coordinates using .isolate() +const sampleLayer = ( scale, offset ) => { + + return textureNode.isolate().context( { + getUV: () => uv().mul( scale ).add( offset ) + } ); + +}; + +// Base texture layer with slow horizontal drift +const baseLayer = sampleLayer( 1.0, vec2( time.mul( 0.05 ), 0.0 ) ); + +// Detail texture layer with 4x scaling and vertical drift +const detailLayer = sampleLayer( 4.0, vec2( 0.0, time.mul( 0.1 ) ) ); + +// Composite layers together on the shaderball material +const composite = baseLayer.rgb.mul( detailLayer.rgb.add( 0.3 ) ); + +model.material.colorNode = composite; +model.material.roughnessNode = detailLayer.r.mul( 0.4 ); +``` + +
+ + + +Calling `.once()` on a `Fn()` creates a **singleton function**: TSL evaluates it once and reuses the result across your shader graph. + +**Sub-Builds** solve this by creating scoped compilation layers (like `'POSITION'` or `'NORMAL'`). Passing `.once( [ 'POSITION' ] )` tells TSL to maintain a separate cache for that specific stage instead of reusing a single global value. + +::: api Fn().once( subBuilds: Array = null ) : FunctionNode - Configures a TSL function to execute and cache its output node once per build, with optional isolated caching across specified sub-build layers. +- **subBuilds**: `Array` - (Optional) Array of sub-build layer names (e.g. `[ 'NORMAL', 'VERTEX' ]`) under which the function is cached independently. +::: + +::: api subBuild( node: Node, name: string, type: string = null ) : SubBuildNode - Wraps a node to be built within an isolated sub-build scope (e.g. `'VERTEX'`, `'NORMAL'`, `'POSITION'`). +- **node**: `Node` - The target node to evaluate inside the sub-build layer. +- **name**: `string` - The name of the sub-build compilation layer. +- **type**: `string` - (Optional) The output type of the node. +::: + +::: api builder.getSubBuildProperty( property: string = '', node: Node = null ) : string - Returns a sub-build prefixed property or varying identifier (e.g. `'POSITION_v_positionWorld'`). +- **property**: `string` - The base property or varying name to prefix. +- **node**: `Node` - (Optional) Target node used to resolve the closest sub-build scope. +::: + +### How `positionWorld` Works + +Here is the core implementation of `positionWorld` in Three.js: + +```js +export const positionWorld = /*@__PURE__*/ ( Fn( ( builder ) => { + + return modelWorldMatrix.mul( positionLocal ).xyz.toVarying( builder.getSubBuildProperty( 'v_positionWorld' ) ); + +}, 'vec3' ).once( [ 'POSITION' ] ) )(); +``` + +When `positionWorld` is used in both `material.positionNode` and `material.colorNode`, TSL manages the compilation flow through sub-builds: + +1. `material.positionNode`: Runs inside the `'POSITION'` sub-build. `positionWorld` evaluates in the vertex stage and caches it under `'POSITION'` as `'POSITION_v_positionWorld'`. +2. `material.colorNode`: Runs in the default fragment scope. Because `positionWorld` is only cached for `'POSITION'`, TSL evaluates it again for the fragment stage and outputs `'v_positionWorld'`. + +```mermaid +flowchart TD + FnCall["positionWorld
.once( [ 'POSITION' ] )"] + PosScope["material.positionNode
Vertex Stage
POSITION_v_positionWorld
"] + FragScope["material.colorNode
Fragment Stage
v_positionWorld
"] + + FnCall -->|"Inside positionNode"| PosScope + FnCall -->|"Inside colorNode"| FragScope +``` + +Sub-Builds Showcase + +```tsl positionWorldSubBuildExample +import 'scenes/shaderball'; +import { positionLocal, positionWorld, normalLocal, time, color } from 'three/tsl'; + +// 1. Modify vertex positions using world coordinates +const wave = positionWorld.y.mul( 6.0 ).add( time.mul( 2.5 ) ).sin().mul( 0.08 ); +model.material.positionNode = positionLocal.add( normalLocal.mul( wave ) ); + +// 2. Color surface based on the resulting world coordinates in fragment stage +const waveFactor = positionWorld.y.mul( 6.0 ).add( time.mul( 2.5 ) ).sin().mul( 0.5 ).add( 0.5 ); +const waveColor = color( 0x00aaff ).mix( color( 0xff0066 ), waveFactor ); + +model.material.colorNode = waveColor; +model.material.roughness = 0.2; +``` + +### Sub-Build API Reference + +| API | Type | Description | +| :--- | :--- | :--- | +| `Fn( function ).once( subBuilds )` | Method | Caches function evaluation per shader build, partitioned by `subBuilds` array. | +| `subBuild( node, name, type )` | Node Function | Wraps a node to be evaluated within an isolated sub-build scope. | +| `builder.subBuildFn` | Property | Identifies the active sub-build layer name currently executing. | +| `builder.getSubBuildProperty( prop, node )` | Method | Returns a sub-build prefixed identifier (e.g. `'POSITION_v_positionWorld'`). | + +#### Related +- [Isolate](#isolate) +- [Context](#context) +- [Function](#function) +- [Shader Stages](#shader-stages) + +
+ +
+ + + + + +In real-world applications, TSL enables developers to create entirely new syntaxes and custom abstractions to accommodate different graphics workflows. Rather than being restricted to a fixed shader language, you can construct custom DSLs (Domain-Specific Languages), modular utility functions, or dedicated shading models that align with the architectural needs of your project. + +This flexibility allows shader logic to be composed dynamically like JavaScript components, making it possible to design clean APIs for complex math, custom material models, or post-processing pipelines. + +### Raymarching as a TSL Extension + +The `RaymarchingBox` utility is a practical example of how TSL's core syntax was extended to support a specialized volumetric rendering workflow. It abstracts the local ray calculation relative to a bounding volume (from `-0.5` to `0.5`), computes bounding-box intersections, and runs the step-by-step marching loop inside a custom utility. + +By wrapping this complex pipeline, the workflow for creating volumetric materials is simplified into a callback interface where you only define what happens at each sample point along the ray. + +Raymarching Box Example + +```tsl raymarchingExample +import 'scenes/empty'; +import * as THREE from 'three'; +import { Fn, vec4, vec3, time, If, smoothstep, exp, Break, modelWorldMatrixInverse, triNoise3D, pmremTexture, modelWorldMatrix } from 'three/tsl'; +import { RaymarchingBox } from 'three/addons/tsl/utils/Raymarching.js'; + +// Volumetric cloud density calculator (simplified ellipsoid shape with warping and edge noise erosion) +const getCloudDensity = Fn( ( [ p ] ) => { + + // 1. Warp coordinate space to deform the geometric boundaries organically (Domain Warping) + const warp = triNoise3D( p, 0.5, time.mul( 0.5 ) ); + const pWarped = p.add( vec3( warp ).sub( 0.5 ).mul( 0.4 ) ); + + // 2. Base Ellipsoid Mask (wider than it is tall to look like a flat cumulus cloud) + const baseMask = smoothstep( 0.48, 0.22, pWarped.mul( vec3( 1, 1.3, 1 ) ).length() ); + + // 3. Volumetric details with slow wind drift + const pNoise = pWarped.add( vec3( time.mul( 0.05 ), 0, time.mul( 0.02 ) ) ); + const noiseVal = triNoise3D( pNoise.mul( 1.2 ), 1.2, time.mul( 0.6 ) ); + + // Erode only the edges of the cloud, keeping the center core solid + const shape = baseMask.sub( noiseVal.mul( baseMask.pow( 2 ).oneMinus().mul( 0.48 ) ) ); + + // Soft threshold to get beautiful rounded boundaries with smooth fade-out + return smoothstep( - 0.02, 0.35, shape ); + +} ); + +const raymarchClouds = Fn( () => { + + const steps = 48; + + const finalColor = vec4( 0 ); + + // Direct light source (constant direction from top-right-front, matching the reference image) + const lightDir = vec3( 1, 1.2, 0.8 ).normalize(); + const localLightDir = modelWorldMatrixInverse.mul( vec4( lightDir, 0 ) ).xyz.normalize(); + + // Direct light color matching the reference sun light + const directLightColor = vec3( 0.5, 1.0, 1.4 ); + + RaymarchingBox( steps, ( { positionRay, stepSize } ) => { + + const density = getCloudDensity( positionRay ); + + If( density.greaterThan( 0.01 ), () => { + + // Shadow ray: sample density offset towards the light source in local space + const shadowPos = positionRay.add( localLightDir.mul( 0.08 ) ); + const shadowDensity = getCloudDensity( shadowPos ); + + // Dual-Lobe Beer's Law for realistic multiple scattering & light penetration + const shadowVal = shadowDensity.mul( 10 ); + const transmittance = exp( shadowVal.negate() ).mul( 0.5 ).add( exp( shadowVal.mul( 0.1 ).negate() ).mul( 0.65 ) ); + + // Calculate local normal based on positionRay relative to cloud center + const normal = positionRay.normalize(); + + // Transform normal to world space so environment directions align correctly with the sky + const worldNormal = modelWorldMatrix.mul( vec4( normal, 0 ) ).xyz.normalize(); + + // Sample ambient light dynamically in the direction of the world normal from the environment map (IBL) + const ambientLightColor = pmremTexture( scene.environment, worldNormal, 0.9 ).rgb.mul( 1.5 ); + + // Combine direct light and ambient sky contribution (offset positionRay.y by 0.5 to keep factors positive) + const directLight = directLightColor.mul( transmittance ); + const ambientLight = ambientLightColor.mul( positionRay.y.add( 0.5 ) ).mul( density ); + const cloudColor = directLight.add( ambientLight ); + + // Front-to-back blending with accumulated color (higher opacity for solid volume appearance) + const alpha = density.mul( stepSize ).mul( 8 ); + const colSample = cloudColor.mul( alpha ); + + finalColor.rgb.addAssign( finalColor.a.oneMinus().mul( colSample ) ); + finalColor.a.addAssign( finalColor.a.oneMinus().mul( alpha ) ); + + // Early loop termination if cloud gets opaque + If( finalColor.a.greaterThanEqual( 0.95 ), () => { + + Break(); + + } ); + + } ); + + } ); + + return finalColor; + +} ); + +// Create custom cube geometry +const geometry = new THREE.BoxGeometry( 1, 1, 1 ); + +// Setup material with raymarched clouds, render the back side, and enable transparency +const material = new THREE.MeshBasicNodeMaterial(); +material.colorNode = raymarchClouds(); +material.side = THREE.BackSide; +material.transparent = true; + +// Create mesh, scale, position, disable frustum culling, and add to empty scene +const mesh = new THREE.Mesh( geometry, material ); +mesh.scale.set( 4, 3, 4 ); +mesh.position.y = 1.6; +mesh.frustumCulled = false; +scene.add( mesh ); +``` + + + + + + + + + +The Three.js **Transpiler** is an AST-driven shader translation system that converts shader code between different shading languages. It allows developers to automatically translate legacy **GLSL** shaders into modern **TSL** (Three.js Shading Language) or native **WGSL** (WebGPU Shading Language). + +glslTFn (Cross-Backend) +Manual Transpilation + +### Architecture + +The transpiler relies on a modular architecture where a **Decoder** parses source code into an intermediate Abstract Syntax Tree (AST), the **Linker** resolves symbol dependencies, and an **Encoder** generates the target language: + +| Language | Decoder | Encoder | +| :--- | :--- | :--- | +| **GLSL** | `GLSLDecoder` | - | +| **WGSL** | - | `WGSLEncoder` | +| **TSL** | - | `TSLEncoder` | + +```js +import Transpiler from 'three/addons/transpiler/Transpiler.js'; +import GLSLDecoder from 'three/addons/transpiler/GLSLDecoder.js'; +import TSLEncoder from 'three/addons/transpiler/TSLEncoder.js'; +import WGSLEncoder from 'three/addons/transpiler/WGSLEncoder.js'; + +const glslCode = ` + vec3 desaturate( vec3 color ) { + vec3 lum = vec3( 0.299, 0.587, 0.114 ); + return vec3( dot( lum, color ) ); + } +`; + +// Transpile GLSL to TSL JavaScript +const tslTranspiler = new Transpiler( new GLSLDecoder(), new TSLEncoder() ); +const tslCode = tslTranspiler.parse( glslCode ); + +// Transpile GLSL to WGSL +const wgslTranspiler = new Transpiler( new GLSLDecoder(), new WGSLEncoder() ); +const wgslCode = wgslTranspiler.parse( glslCode ); +``` + +### Cross-Backend Functions: `glslTFn` + +While `glslFn` runs natively on WebGL backends, WebGPU requires WGSL. By leveraging the second `builder` parameter inside a TSL `Fn()`, we can create **`glslTFn`**: a hybrid function node that detects the active renderer backend and dynamically transpiles GLSL to WGSL on WebGPU, while retaining native GLSL execution on WebGL. + +```js +import Transpiler from 'three/addons/transpiler/Transpiler.js'; +import GLSLDecoder from 'three/addons/transpiler/GLSLDecoder.js'; +import WGSLEncoder from 'three/addons/transpiler/WGSLEncoder.js'; +import { Fn, glslFn, wgslFn } from 'three/tsl'; + +export const glslTFn = ( code, includes = [] ) => { + + let compiledWGSLFn = null; + let compiledGLSLFn = null; + + return Fn( ( params, builder ) => { + + // Detect active renderer backend + if ( builder.renderer.backend.isWebGPUBackend ) { + + if ( compiledWGSLFn === null ) { + + const transpiler = new Transpiler( new GLSLDecoder(), new WGSLEncoder() ); + const wgslCode = transpiler.parse( code ); + + compiledWGSLFn = wgslFn( wgslCode, includes ); + + } + + return compiledWGSLFn( ...params ); + + } else { + + if ( compiledGLSLFn === null ) { + + compiledGLSLFn = glslFn( code, includes ); + + } + + return compiledGLSLFn( ...params ); + + } + + } ); + +}; +``` + +```tsl glslTFnExample +import 'scenes/shaderball'; +import Transpiler from 'three/addons/transpiler/Transpiler.js'; +import GLSLDecoder from 'three/addons/transpiler/GLSLDecoder.js'; +import WGSLEncoder from 'three/addons/transpiler/WGSLEncoder.js'; +import { Fn, glslFn, wgslFn, positionLocal, time } from 'three/tsl'; + +// Dynamic transpiling GLSL function helper +const glslTFn = ( code, includes = [] ) => { + + let compiledWGSLFn = null; + let compiledGLSLFn = null; + + return Fn( ( params, builder ) => { + + if ( builder.renderer.backend.isWebGPUBackend ) { + + if ( compiledWGSLFn === null ) { + + const transpiler = new Transpiler( new GLSLDecoder(), new WGSLEncoder() ); + const wgslCode = transpiler.parse( code ); + + compiledWGSLFn = wgslFn( wgslCode, includes ); + + } + + return compiledWGSLFn( ...params ); + + } else { + + if ( compiledGLSLFn === null ) { + + compiledGLSLFn = glslFn( code, includes ); + + } + + return compiledGLSLFn( ...params ); + + } + + } ); + +}; + +// Standard GLSL code running seamlessly on both WebGPU and WebGL +const verticalWaves = glslTFn( ` + vec3 verticalWaves( vec3 pos, float t ) { + + float wave = sin( pos.y * 12.0 + t * 3.0 ) * 0.5 + 0.5; + + return mix( vec3( 0.05, 0.8, 0.7 ), vec3( 0.95, 0.2, 0.4 ), wave ); + + } +` ); + +model.material.colorNode = verticalWaves( { pos: positionLocal, t: time } ); +``` + +```tsl manualTranspileExample +import 'scenes/shaderball'; +import Transpiler from 'three/addons/transpiler/Transpiler.js'; +import GLSLDecoder from 'three/addons/transpiler/GLSLDecoder.js'; +import WGSLEncoder from 'three/addons/transpiler/WGSLEncoder.js'; +import { wgslFn, positionLocal, time } from 'three/tsl'; + +// 1. Original GLSL shader code +const glslSource = ` + vec3 verticalWaves( vec3 pos, float t ) { + + float wave = sin( pos.y * 12.0 + t * 3.0 ) * 0.5 + 0.5; + + return mix( vec3( 0.05, 0.8, 0.7 ), vec3( 0.95, 0.2, 0.4 ), wave ); + + } +`; + +// 2. Transpile to WGSL using Transpiler +const transpiler = new Transpiler( new GLSLDecoder(), new WGSLEncoder() ); +const wgslSource = transpiler.parse( glslSource ); + +// 3. Create native WGSL node +const verticalWavesWGSL = wgslFn( wgslSource ); + +model.material.colorNode = verticalWavesWGSL( { pos: positionLocal, t: time } ); +``` + + + + + +**WebGPU Shading Language (WGSL)** is the native shader programming language of WebGPU. While TSL enables writing node-based shaders using pure JavaScript, Three.js also provides **`wgslFn`** to integrate raw native WGSL code directly into your TSL graphs. + +Native functions declared with `wgslFn` behave like standard TSL nodes: they accept TSL expressions as inputs, output typed values, and can be composed with other native functions or node materials. + +Basic WGSL +WGSL with Includes +WGSL Texture Sampling + +### Defining WGSL Functions + +To define a native WGSL function, pass a standard WGSL function string to `wgslFn`. The function signature defines the input parameter names and types, as well as the return type. + +::: api wgslFn( code, includes? ) : FunctionNode - Creates a native WGSL shader function node from a WGSL function definition. +- **code**: `string` - The WGSL function source code. +- **includes**: `Array` - (Optional) Array of dependency WGSL function nodes included in the generated shader. Defaults to `[]`. +::: + +```js +import { wgslFn, positionLocal, time } from 'three/tsl'; + +// 1. Define a native WGSL function +const proceduralPattern = wgslFn( ` + fn proceduralPattern( pos: vec3, t: f32 ) -> vec3 { + + let waves = sin( pos.x * 6.0 + t ) * cos( pos.y * 6.0 - t ); + let glow = sin( pos.z * 10.0 + t * 2.0 ) * 0.5 + 0.5; + + let r = sin( waves * 3.14159 ) * 0.5 + 0.5; + let g = glow; + let b = cos( waves * 3.14159 ) * 0.5 + 0.5; + + return vec3( r, g, b ); + + } +` ); + +// 2. Call the function node with named parameters or positional arguments +material.colorNode = proceduralPattern( { pos: positionLocal, t: time } ); +``` + +### Passing Parameters + +WGSL functions can be called either with an object containing keys matching the function parameter names, or with positional arguments: + +```js +// Named parameters (recommended for clarity) +material.colorNode = proceduralPattern( { pos: positionLocal, t: time } ); + +// Positional arguments +material.colorNode = proceduralPattern( positionLocal, time ); +``` + +### Why Modular Functions? + +| Aspect | Modular Functions (`wgslFn` / TSL) | Monolithic Shader Files | +| :--- | :--- | :--- | +| **Composability & Integration** | Effortlessly connects with other TSL nodes, materials, post-processing passes, and ecosystem extensions (such as `tsl-textures` or TypeGPU) with flexible parameter exchange. | Rigid structure; difficult to interface with other components or reuse without manual string manipulation and global uniforms. | +| **Maintainability** | Isolated single-responsibility functions make debugging, unit testing, and refactoring math simple without cascading side effects. | Fragile; updating logic risks breaking unrelated shader parts and requires maintaining large complex files. | +| **Automatic Imports** | Dependencies, helper functions, structs, and uniform bindings are automatically resolved and injected into the shader on demand as needed. | Requires manually maintaining `#include` directives, forward declarations, struct definitions, and strict declaration order. | +| **Tree Shaking** | Only imported and referenced functions are bundled into the application. | The entire shader file is bundled even if most logic is unused. | +| **Encapsulation** | Isolated local scope and explicit parameters; no name collisions. | Shared global namespace; frequent variable and uniform collisions. | + +```tsl wgslBasicExample +import 'scenes/shaderball'; +import { wgslFn, positionLocal, time } from 'three/tsl'; + +// Define a native WGSL function +const proceduralPattern = wgslFn( ` + fn proceduralPattern( pos: vec3, t: f32 ) -> vec3 { + + let waves = sin( pos.x * 6.0 + t ) * cos( pos.y * 6.0 - t ); + let glow = sin( pos.z * 10.0 + t * 2.0 ) * 0.5 + 0.5; + + let r = sin( waves * 3.14159 ) * 0.5 + 0.5; + let g = glow; + let b = cos( waves * 3.14159 ) * 0.5 + 0.5; + + return vec3( r, g, b ); + + } +` ); + +// Assign to material colorNode +model.material.colorNode = proceduralPattern( { pos: positionLocal, t: time } ); +``` + +```tsl wgslIncludesExample +import 'scenes/shaderball'; +import { wgslFn, positionLocal, time, color } from 'three/tsl'; + +// 1. Helper WGSL function: luminance calculation +const luminanceWGSL = wgslFn( ` + fn calcLuminance( rgb: vec3 ) -> f32 { + + let weights = vec3( 0.299, 0.587, 0.114 ); + + return dot( rgb, weights ); + + } +` ); + +// 2. Main WGSL function that includes and calls the helper function +const duotoneWGSL = wgslFn( ` + fn duotone( pos: vec3, t: f32, colorA: vec3, colorB: vec3 ) -> vec3 { + + let rawPattern = sin( pos * 4.0 + vec3( t ) ) * 0.5 + 0.5; + let lum = calcLuminance( rawPattern ); + + return mix( colorA, colorB, lum ); + + } +`, [ luminanceWGSL ] ); + +// 3. Invoke with named arguments +model.material.colorNode = duotoneWGSL( { + pos: positionLocal, + t: time, + colorA: color( 0x0055ff ), + colorB: color( 0xffaa00 ) +} ); +``` + +```tsl wgslTextureExample +import 'scenes/shaderball'; +import * as THREE from 'three'; +import { wgslFn, texture, uv, color } from 'three/tsl'; + +const map = new THREE.TextureLoader().load( '../examples/textures/uv_grid_opengl.jpg' ); +map.wrapS = THREE.RepeatWrapping; +map.wrapT = THREE.RepeatWrapping; + +// Native WGSL function receiving a texture and sampler +const sampleWGSL = wgslFn( ` + fn sampleAndTint( tex: texture_2d, texSampler: sampler, uvCoord: vec2, tintColor: vec3 ) -> vec4 { + + let sampled = textureSample( tex, texSampler, uvCoord * 2.0 ); + + return vec4( sampled.rgb * tintColor, sampled.a ); + + } +` ); + +const textureNode = texture( map ); + +model.material.colorNode = sampleWGSL( { + tex: textureNode, + texSampler: textureNode, + uvCoord: uv(), + tintColor: color( 0x00ff88 ) +} ); +``` + + + + + +**OpenGL Shading Language (GLSL)** is the traditional shading language of WebGL and OpenGL. Three.js provides **`glslFn`** to integrate existing or legacy GLSL function code directly into your TSL node graphs. + +Using `glslFn`, you can easily reuse shader code from previous WebGL projects, Shadertoy snippets, or community libraries, allowing seamless interoperability and gradual migration to the new node system. + +### Defining GLSL Functions + +To define a native GLSL function, pass a standard GLSL function string to `glslFn`. The function signature defines the input parameter names and types, as well as the return type. + +::: api glslFn( code, includes? ) : FunctionNode - Creates a native GLSL shader function node from a GLSL function definition. +- **code**: `string` - The GLSL function source code. +- **includes**: `Array` - (Optional) Array of dependency GLSL function nodes included in the generated shader. Defaults to `[]`. +::: + +```js +import { glslFn, positionLocal, time } from 'three/tsl'; + +// 1. Define a native GLSL function +const proceduralGLSL = glslFn( ` + vec3 proceduralPattern( vec3 pos, float t ) { + + float waves = sin( pos.x * 6.0 + t ) * cos( pos.y * 6.0 - t ); + float glow = sin( pos.z * 10.0 + t * 2.0 ) * 0.5 + 0.5; + + float r = sin( waves * 3.14159 ) * 0.5 + 0.5; + float g = glow; + float b = cos( waves * 3.14159 ) * 0.5 + 0.5; + + return vec3( r, g, b ); + + } +` ); + +// 2. Call the function node with named parameters or positional arguments +material.colorNode = proceduralGLSL( { pos: positionLocal, t: time } ); +``` + +### Passing Parameters + +GLSL functions can be called either with an object containing keys matching the function parameter names, or with positional arguments: + +```js +// Named parameters (recommended for clarity) +material.colorNode = proceduralGLSL( { pos: positionLocal, t: time } ); + +// Positional arguments +material.colorNode = proceduralGLSL( positionLocal, time ); +``` + +### Automatic Stage Resolution + +In traditional GLSL shaders, passing data from vertex to fragment stages required manual plumbing: defining attributes, calculating projections in the vertex shader, declaring `varying` variables, and assigning values across stages. + +In TSL, **Vertex Stage** and **Fragment Stage** are resolved automatically by the nodes themselves (`NodeBuilder`). When you reference nodes such as `positionWorld`, `normalView`, or `uv()` inside a fragment property (like `material.colorNode`), TSL automatically determines what geometry data must be fetched, generates the vertex calculations, and transparently routes the interpolated varyings to the fragment stage without requiring manual transportation. + +### GLSL to TSL Mapping + +| Vertex (GLSL) | Fragment (GLSL) | TSL Equivalent | +| :--- | :--- | :--- | +| `gl_Position` | - | `modelViewProjection` | +| `position` | `vPosition` | `positionLocal` / `positionWorld` | +| `normal` | `vNormal` | `normalLocal` / `normalWorld` | +| `uv` | `vUv` | `uv()` | +| `attribute name` | `varying name` | `attribute( 'name' )` | +| `modelMatrix` | - | `modelWorldMatrix` | +| `modelViewMatrix` | - | `modelViewMatrix` | +| `projectionMatrix` | - | `cameraProjectionMatrix` | +| `normalMatrix` | - | `modelNormalMatrix` | +| `varying = ...` | `vName` | `varying( node )` / `.toVarying()` | +| `position + offset` | - | `material.positionNode` | +| - | `gl_FragColor` | `material.colorNode` | +| `uniform name` | `uniform name` | `uniform( value )` | +| `uniform float time` | `uniform float time` | `time` | +| - | `cameraPosition` | `cameraPosition` | +| - | `texture2D( map, uv )` | `texture( map, uv() )` | +| - | `discard;` | `Discard()` / `.discard()` | +| - | `dFdx()`, `dFdy()` | `dFdx()`, `dFdy()` | + + + + + + diff --git a/tsl/css/tour.css b/tsl/css/tour.css new file mode 100644 index 00000000000000..c311aa271c2119 --- /dev/null +++ b/tsl/css/tour.css @@ -0,0 +1,3950 @@ +*, +::before, +::after { + box-sizing: border-box; +} + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; + font-size: inherit; + line-height: inherit; + color: inherit; +} + +:root { + --bg-main: #1e1e24; + --bg-sidebar: #15151a; + --bg-card: #2a2a33; + --accent: #00aaff; + --text-muted: #9a9aab; + --border-color: #3f3f4e; +} + +html, +body { + margin: 0; + padding: 0; + height: 100%; + height: 100dvh; + font-family: 'Inter', system-ui, -apple-system, sans-serif; + font-feature-settings: 'calt' 0; + line-height: 1.5; + background-color: var(--bg-main); + color: #d1d5db; + overflow: hidden; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.markdown-content h1 { + font-size: 2rem; + font-weight: 700; + margin-bottom: 1.5rem; + color: #f9fafb; + letter-spacing: -0.025em; +} + +.markdown-content h2 { + font-size: 1.5rem; + font-weight: 600; + margin-top: 2rem; + margin-bottom: 1rem; + color: #f3f4f6; + letter-spacing: -0.015em; + border-bottom: 1px solid var(--border-color); + padding-bottom: 0.5rem; +} + +.markdown-content h3 { + font-size: 1.15rem; + font-weight: 600; + margin-top: 1.75rem; + margin-bottom: 0.75rem; + color: #fff; + letter-spacing: -0.01em; + line-height: 1.4; +} + +.markdown-content h4 { + font-size: 1rem; + font-weight: 600; + margin-top: 1.5rem; + margin-bottom: 0.5rem; + color: #fff; + letter-spacing: -0.005em; + line-height: 1.4; +} + +.markdown-content p { + margin-bottom: 1.25rem; + line-height: 1.75; + color: #d1d5db; + font-size: 0.95rem; +} + +.markdown-content blockquote { + margin: 1.5rem 0; + padding: 0.85rem 1.25rem; + border: 1px solid rgba(0, 170, 255, 0.15); + border-left: 4px solid var(--accent, #00aaff); + background-color: rgba(0, 170, 255, 0.04); + border-radius: 6px; + color: #d1d5db; +} + +.markdown-content blockquote p { + margin-top: 0; + margin-bottom: 0; + line-height: 1.75; + font-style: normal; + color: #d1d5db; +} + +.markdown-content code { + background: #15151a; + padding: 0.2rem 0.45rem; + border-radius: 6px; + font-family: 'Fira Code', monospace; + font-size: 0.85em; + border: 1px solid var(--border-color); + color: #e0e0e0; + white-space: nowrap; +} + +.markdown-content pre { + background-color: #15151a; + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 1.1rem 1.35rem; + margin: 1.5rem 0; + overflow-x: auto; + line-height: 1.65; +} + +.markdown-content pre code { + background: transparent; + border: none; + padding: 0; + border-radius: 0; + color: #e0e0e8; + font-size: 0.88rem; + white-space: pre; + word-break: normal; + overflow-wrap: normal; + display: block; +} + +.markdown-content ul { + list-style-type: none; + padding-left: 0; + margin-left: 0; + margin-bottom: 1.5rem; + margin-top: 0.5rem; +} + +.markdown-content ul li { + position: relative; + padding-left: 1.5rem; + margin-bottom: 0.6rem; + line-height: 1.7; + color: #d1d5db; +} + +.markdown-content ul li::before { + content: "•"; + position: absolute; + left: 0.5rem; + color: #fff; + font-weight: bold; + font-size: inherit; + top: 0; + height: 1.7em; + display: inline-flex; + align-items: center; +} + +.markdown-content ol { + list-style-type: none; + counter-reset: custom-counter; + padding-left: 0; + margin-left: 0; + margin-bottom: 1.5rem; +} + +.markdown-content ol li { + position: relative; + padding-left: 1.75rem; + margin-bottom: 0.6rem; + line-height: 1.7; + color: #d1d5db; + counter-increment: custom-counter; +} + +.markdown-content ol li::before { + content: counter(custom-counter) "."; + position: absolute; + left: 0; + color: #fff; + font-weight: 600; + font-size: inherit; + top: 0; + height: 1.7em; + display: inline-flex; + align-items: center; +} + +/* Tables */ +.markdown-content table { + width: 100%; + max-width: 100%; + border-collapse: collapse; + margin-bottom: 1.5rem; + font-size: 0.9rem; + text-align: left; + background-color: transparent; +} + +.markdown-content th { + background-color: var(--bg-card); + color: #f9fafb; + font-weight: 600; + padding: 0.85rem 1rem; + border: 1px solid var(--border-color); + line-height: 1.5; +} + +.markdown-content td { + padding: 0.95rem 1rem; + border: 1px solid var(--border-color); + color: #d1d5db; + vertical-align: middle; + line-height: 1.7; +} + +.markdown-content td code { + display: inline-block; + vertical-align: baseline; + margin: 0.1rem 0; + white-space: normal; + word-break: break-word; +} + +.markdown-content tr:nth-child(even) { + background-color: rgba(255, 255, 255, 0.02); +} + +.markdown-content tr:hover { + background-color: rgba(255, 255, 255, 0.04); +} + +.custom-scrollbar::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +.custom-scrollbar::-webkit-scrollbar-track { + background: transparent; +} + +.custom-scrollbar::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.15); + border-radius: 3px; +} + +.custom-scrollbar::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.25); +} + +.page-transition { + animation: fadeIn 0.3s ease-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Link styles for virtual navigation */ +.markdown-content a, +.nav-link { + color: var(--accent); + text-decoration: underline; + cursor: pointer; + transition: color 0.15s ease; +} + +.markdown-content code[name], +.markdown-content code:has(.code-modifier-inline-btn) { + display: inline-flex; + align-items: center; + vertical-align: middle; + margin: 0.25rem 0.4rem 0.25rem 0; + cursor: pointer; + padding: 0.2rem 0.7rem; + transition: background-color 0.2s, border-color 0.2s, color 0.2s; +} + +.markdown-content code[name]:hover, +.markdown-content code:has(.code-modifier-inline-btn):hover { + background: #1e1e24 !important; + border-color: rgba(255, 255, 255, 0.25) !important; +} + +.markdown-content code[name].active, +.markdown-content code:has(.code-modifier-inline-btn.active) { + background: rgba(0, 102, 255, 0.08) !important; + border-color: rgba(0, 102, 255, 0.3) !important; + color: #fff !important; +} + +.code-modifier-inline-btn { + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.1); + color: var(--text-muted); + width: 14px; + height: 14px; + border-radius: 50%; + cursor: pointer; + vertical-align: middle; + margin-left: 0.7rem; + margin-top: -2px; + padding: 0 !important; + transition: all 0.2s ease; +} + +.markdown-content code:hover .code-modifier-inline-btn, +.code-modifier-inline-btn:hover { + background: rgba(255, 255, 255, 0.2); + border-color: rgba(255, 255, 255, 0.3); + color: #fff; + transform: scale(1.08); +} + +.code-modifier-inline-btn.active { + color: var(--accent) !important; + background: rgba(0, 102, 255, 0.1) !important; + border-color: var(--accent) !important; + box-shadow: 0 0 6px rgba(0, 102, 255, 0.2); + transform: scale(1.08); +} + +body.preview-hidden .markdown-content code:has(.code-modifier-inline-btn.active) { + background: rgba(16, 185, 129, 0.08) !important; + border-color: rgba(16, 185, 129, 0.3) !important; +} + +body.preview-hidden .code-modifier-inline-btn.active { + color: #10b981 !important; + background: rgba(16, 185, 129, 0.1) !important; + border-color: #10b981 !important; + box-shadow: 0 0 6px rgba(16, 185, 129, 0.2); +} + +.code-modifier-inline-btn svg { + width: 6px; + height: 6px; + display: block; + transform: translateX(0.5px); +} + +.markdown-content a:hover, +.nav-link:hover { + color: #fff; +} + +/* Layout & Header */ +.app-container { + height: 100%; + height: 100dvh; + min-height: 100dvh; + max-height: 100dvh; + display: flex; + flex-direction: row; + color: var(--text-muted); + overflow: hidden; + background-color: var(--bg-main); +} + +.main-wrap { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + height: 100%; + height: 100dvh; + position: relative; + overflow: hidden; +} + +.app-header { + height: 3.5rem; + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 1rem; + background-color: var(--bg-main); + z-index: 10; + position: relative; + flex-shrink: 0; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); +} + +.header-logo-container { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.header-logo-bg { + width: 1.75rem; + height: 1.75rem; + border-radius: 0.375rem; + display: flex; + align-items: center; + justify-content: center; +} + +.header-title-container { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; +} + +.header-title-wrapper { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 0.5rem; + pointer-events: auto; +} + +.header-title { + font-weight: 100; + font-size: 1.05rem; + letter-spacing: -0.01em; + line-height: 1; + color: #ececec; +} + +.header-subtitle { + font-size: 0.75rem; + color: var(--text-muted); + font-weight: 500; + letter-spacing: 0.02em; + border-left: 1px solid var(--border-color); + padding-left: 0.6rem; + margin-top: 0.1rem; +} + +.header-release { + font-size: 0.75rem; + font-weight: 500; + font-family: 'Fira Code', monospace; + color: var(--text-muted); + border-left: 1px solid var(--border-color); + padding-left: 0.6rem; + padding-top: 0.1rem; + padding-bottom: 0.1rem; + margin-left: 0.4rem; + margin-top: 0.1rem; + user-select: none; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; + text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.9), 0 1px 0 rgba(255, 255, 255, 0.07); +} + +.header-release .badge-suffix { + opacity: 0.35; + font-weight: 400; + margin-left: 0.2rem; +} + +.header-title-accent { + color: #fff; + font-weight: 500; +} + +.header-actions { + display: flex; + align-items: center; + gap: 1rem; + position: relative; + z-index: 10; +} + +.icon-btn { + padding: 0.5rem; + border-radius: 0.375rem; + transition: background-color 0.2s, color 0.2s; + color: var(--text-muted); + background: transparent; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + font-size: 0.875rem; + font-weight: 500; +} + +.icon-btn:hover { + background-color: rgba(255, 255, 255, 0.08); + color: #fff; +} + +.icon-btn.active { + color: var(--accent); + background-color: rgba(255, 255, 255, 0.05); +} + +.icon-btn:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +::backdrop { + background-color: rgba(0, 0, 0, 0.4); +} + +/* Main Content Area */ +.main-layout { + flex: 1; + display: flex; + overflow: hidden; + position: relative; +} + +.sidebar { + position: fixed; + top: 0; + bottom: 0; + left: 0; + width: 18rem; + background-color: var(--bg-sidebar); + border-right: 1px solid var(--border-color); + transform: translateX(-100%); + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), margin-left 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s ease; + z-index: 20; + display: flex; + flex-direction: column; + box-shadow: 0 0 0 0 rgba(0, 0, 0, 0); +} + +.sidebar.open { + transform: translateX(0); + box-shadow: 4px 0 24px rgba(0, 0, 0, 0.3); +} + +.sidebar-header { + height: 3.5rem; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: flex-start; + padding: 0 1rem; + gap: 0.75rem; + border-bottom: 1px solid transparent; +} + +.sidebar-content { + flex: 1; + padding: 1.25rem 1rem; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.toc-category-container { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.toc-folder-btn { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-muted); + margin-top: 1rem; + margin-bottom: 0; + padding: 0.4rem 0.75rem; + background: transparent; + border: none; + cursor: pointer; + text-align: left; + user-select: none; + transition: color 0.15s ease; +} + +.toc-folder-btn:hover:not(:disabled) { + color: #ececec; +} + +.toc-folder-btn:disabled { + opacity: 0.35; + cursor: not-allowed; + pointer-events: none; +} + +.toc-folder-btn.nested-folder { + text-transform: none; + margin-top: 0; + margin-bottom: 0; + font-size: 0.85rem; + font-weight: 500; + letter-spacing: normal; + padding: 0.6rem 0.75rem; +} + +.toc-category-pages { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding-left: 0; +} + +.toc-category-container.collapsed .toc-category-pages { + display: none; +} + +.toc-category-container.collapsed .toc-chevron { + transform: rotate(-90deg); +} + +.toc-chevron { + transition: transform 0.2s ease; + flex-shrink: 0; + width: 1rem; + height: 1rem; +} + +.toc-btn { + width: 100%; + text-align: left; + padding: 0.6rem 0.75rem; + border-radius: 0.5rem; + font-size: 0.85rem; + font-weight: 500; + transition: all 0.15s ease; + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--text-muted); + background: transparent; + border: none; + cursor: pointer; +} + +.toc-btn:hover { + background-color: var(--bg-card); + color: #ececec; +} + +.toc-btn.active { + background-color: rgba(0, 170, 255, 0.1) !important; + color: var(--accent) !important; + border-radius: 0.5rem; + font-weight: 600; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); +} + +/* Left Column (Markdown) */ +.content-col { + width: 100%; + display: flex; + flex-direction: column; + border-right: 1px solid var(--border-color); + background-color: var(--bg-main); + flex: 0 0 auto; + min-width: 0; + position: relative; +} + +.content-area-wrap { + flex: 1; + padding: 2rem 3rem; + overflow-y: auto; +} + +.breadcrumb { + font-size: 0.875rem; + font-weight: 500; + color: var(--text-muted); + margin-bottom: 1rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.breadcrumb-link, +.breadcrumb a { + color: inherit; + text-decoration: none; + cursor: pointer; + transition: color 0.15s ease; +} + +.breadcrumb-link:hover, +.breadcrumb a:hover { + color: #fff; + text-decoration: underline; +} + +.floating-nav-container { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 4rem; + padding-top: 1.5rem; + border-top: 1px solid var(--border-color); + margin-bottom: 2rem; +} + +.floating-nav-btn { + padding: 0.5rem 0.75rem; + border-radius: 0.375rem; + transition: background-color 0.2s, color 0.2s; + color: var(--text-muted); + background: transparent; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + font-size: 0.875rem; + font-weight: 500; +} + +.floating-nav-btn:hover:not(.disabled) { + background-color: rgba(255, 255, 255, 0.08); + color: #fff; +} + +.floating-nav-btn.disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.copy-code-btn { + position: absolute; + top: 0.5rem; + right: 0; + z-index: 20; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--border-color); + border-radius: 0.375rem; + color: var(--text-muted); + width: 2rem; + height: 2rem; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.copy-code-btn:hover { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.2); + color: #fff; +} + +.copy-code-btn.success { + color: #4caf50 !important; + border-color: rgba(76, 175, 80, 0.4) !important; + background: rgba(76, 175, 80, 0.08) !important; +} + +#copy-code-btn-header { + display: none !important; +} + +#copy-code-btn-header.success, +#share-btn-header.success { + color: #4caf50 !important; + background: rgba(76, 175, 80, 0.08) !important; +} + +#share-btn-header { + display: none; +} + +body.playground-mode #share-btn-header { + display: flex !important; +} + +/* Right Column (Editor) */ +.editor-col { + display: none; + flex: 1; + flex-direction: column; + background-color: var(--bg-sidebar); + min-width: 0; + min-height: 0; +} + +.editor-workspace { + flex: 1; + position: relative; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.code-container-wrap { + flex: 1; + overflow: auto; + padding: 0; + background-color: var(--bg-sidebar); + position: relative; + z-index: 1; +} + +.code-container-wrap:focus-within { + z-index: 5; +} + +.preview-section { + flex-shrink: 0; + height: 50%; + border-bottom: 1px solid var(--border-color); + background-color: var(--bg-main); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + z-index: 10; + padding: 0; +} + +.preview-box { + width: 100%; + height: 100%; + border-radius: 0; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--bg-card); + overflow: hidden; + position: relative; +} + +.h-resizer-container { + position: relative; + width: 4px; + z-index: 1000; + flex-shrink: 0; + display: flex; +} + +.resizer-h { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + cursor: col-resize; + background-color: var(--border-color); + touch-action: none; + z-index: 100; + transition: background-color 0.2s; +} + +.resizer-h-line { + position: absolute; + top: 0; + bottom: 0; + left: -1px; + width: 1px; + background-color: var(--border-color); + z-index: 5; + transition: all 0.2s ease; +} + +@media (hover: hover) { + .resizer-h:not(.collapsed):hover { + background-color: var(--accent); + } + + .resizer-toggle-btn:hover~.resizer-h-line { + background-color: var(--accent); + } + + .resizer-toggle-btn:hover~.resizer-h.collapsed+.resizer-h-line { + background-color: var(--accent); + left: auto; + right: 0; + } +} + +.resizer-h:not(.collapsed):active, +.resizer-h:not(.collapsed).dragging { + background-color: var(--accent); +} + +.resizer-toggle-btn:active~.resizer-h-line { + background-color: var(--accent); +} + +.resizer-toggle-btn:active~.resizer-h.collapsed+.resizer-h-line { + background-color: var(--accent); + left: auto; + right: 0; +} + +.resizer-h.collapsed~.resizer-h-line { + left: auto; + right: 0; + background-color: var(--border-color); +} + +.resizer-toggle-btn { + position: absolute; + top: 50%; + left: 1px; + transform: translate(-100%, -50%); + width: 17px; + height: 70px; + border-radius: 16px 0 0 16px; + background-color: var(--bg-sidebar); + border: 1px solid var(--border-color); + border-right: none; + color: var(--text-muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: -2px 2px 4px rgba(0, 0, 0, 0.15); + transition: all 0.2s ease; + z-index: 15; + padding: 0; +} + +.resizer-toggle-btn::before { + content: ''; + position: absolute; + width: 10px; + height: 18px; + top: -15px; + right: 1px; + background: transparent; + border-bottom-right-radius: 17px; + box-shadow: 4px 4px 0 0 var(--bg-sidebar); + border-bottom: 1px solid var(--border-color); + border-right: 1px solid var(--border-color); + pointer-events: none; + transition: all 0.2s ease; +} + +.resizer-toggle-btn::after { + content: ''; + position: absolute; + width: 10px; + height: 18px; + bottom: -15px; + right: 1px; + background: transparent; + border-top-right-radius: 17px; + box-shadow: 4px -4px 0 0 var(--bg-sidebar); + border-top: 1px solid var(--border-color); + border-right: 1px solid var(--border-color); + pointer-events: none; + transition: all 0.2s ease; +} + +@media (hover: hover) { + .resizer-toggle-btn:hover { + background-color: var(--bg-card); + color: var(--accent); + border-color: var(--accent); + } + + .resizer-toggle-btn:hover::before { + box-shadow: 4px 4px 0 0 var(--bg-card); + border-bottom-color: var(--accent); + border-right-color: var(--accent); + } + + .resizer-toggle-btn:hover::after { + box-shadow: 4px -4px 0 0 var(--bg-card); + border-top-color: var(--accent); + border-right-color: var(--accent); + } +} + +.resizer-toggle-btn:active { + background-color: var(--bg-card); + color: var(--accent); + border-color: var(--accent); +} + +.resizer-toggle-btn:active::before { + box-shadow: 4px 4px 0 0 var(--bg-card); + border-bottom-color: var(--accent); + border-right-color: var(--accent); +} + +.resizer-toggle-btn:active::after { + box-shadow: 4px -4px 0 0 var(--bg-card); + border-top-color: var(--accent); + border-right-color: var(--accent); +} + +.v-resizer-container { + position: relative; + height: 4px; + z-index: 500; + flex-shrink: 0; + display: flex; + flex-direction: column; + background-color: #15151a; +} + +.resizer-v { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + cursor: row-resize; + background-color: transparent; + touch-action: none; + z-index: 10; + transition: background-color 0.2s; +} + +.resizer-v-line { + position: absolute; + top: -1px; + left: 0; + right: 0; + height: 1px; + background-color: var(--border-color); + z-index: 5; + pointer-events: none; + transition: all 0.2s ease; +} + +@media (hover: hover) { + .resizer-v:not(.collapsed):not(.editor-collapsed):hover { + background-color: var(--accent); + } + + .resizer-v-toggle-btn:hover~.resizer-v-line { + background-color: var(--accent); + } + + .resizer-v-toggle-btn:hover~.resizer-v.collapsed+.resizer-v-line { + background-color: var(--accent); + top: 0; + } +} + +.resizer-v:not(.collapsed):not(.editor-collapsed):active, +.resizer-v:not(.collapsed):not(.editor-collapsed).dragging { + background-color: var(--accent); +} + +.resizer-v.collapsed, +.resizer-v.editor-collapsed { + pointer-events: none; +} + +.resizer-v-toggle-btn:active~.resizer-v-line { + background-color: var(--accent); + top: -1px; +} + +.resizer-v-toggle-btn:active~.resizer-v.collapsed+.resizer-v-line { + background-color: var(--accent); + top: 0; +} + +.resizer-v.collapsed+.resizer-v-line { + background-color: var(--border-color); + top: 0; +} + +.resizer-v-toggle-btn { + position: absolute; + bottom: 3px; + left: 50%; + transform: translateX(-50%); + width: 70px; + height: 17px; + border-radius: 16px 16px 0 0; + background-color: var(--bg-sidebar); + border: 1px solid var(--border-color); + border-bottom: none; + color: var(--text-muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 -2px 4px rgba(0, 0, 0, 0.15); + transition: all 0.2s ease; + z-index: 25; + padding: 0; +} + +.resizer-v-toggle-btn::before { + content: ''; + position: absolute; + width: 18px; + height: 8px; + left: -15px; + bottom: 1px; + background: transparent; + border-bottom-right-radius: 12px; + box-shadow: 4px 0 0 0 var(--bg-sidebar); + border-bottom: 1px solid var(--border-color); + border-right: 1px solid var(--border-color); + pointer-events: none; + transition: all 0.2s ease; +} + +.resizer-v-toggle-btn::after { + content: ''; + position: absolute; + width: 18px; + height: 8px; + right: -15px; + bottom: 1px; + background: transparent; + border-bottom-left-radius: 12px; + box-shadow: -4px 0 0 0 var(--bg-sidebar); + border-bottom: 1px solid var(--border-color); + border-left: 1px solid var(--border-color); + pointer-events: none; + transition: all 0.2s ease; +} + +/* Inverted Vertical Resizer Toggle Button */ +.resizer-v-toggle-btn.inverted { + bottom: auto; + top: -2px; + border-radius: 0 0 16px 16px; + border: 1px solid transparent; + border-top: none; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15); + height: 15px; +} + +.resizer-v-toggle-btn.inverted::before { + content: ''; + position: absolute; + width: 18px; + height: 7px; + left: -15px; + bottom: auto; + top: 1px; + background: transparent; + border-bottom-right-radius: 0; + border-top-right-radius: 12px; + box-shadow: 4px 0 0 0 var(--bg-sidebar); + border-bottom: none; + border-top: 1px solid transparent; + border-right: 1px solid transparent; + pointer-events: none; + transition: all 0.2s ease; +} + +.resizer-v-toggle-btn.inverted::after { + content: ''; + position: absolute; + width: 18px; + height: 7px; + right: -15px; + bottom: auto; + top: 1px; + background: transparent; + border-bottom-left-radius: 0; + border-top-left-radius: 12px; + box-shadow: -4px 0 0 0 var(--bg-sidebar); + border-bottom: none; + border-top: 1px solid transparent; + border-left: 1px solid transparent; + pointer-events: none; + transition: all 0.2s ease; +} + +@media (hover: hover) { + .resizer-v-toggle-btn.inverted:hover { + background-color: var(--bg-card); + color: var(--accent); + border-color: var(--accent); + } + + .resizer-v-toggle-btn.inverted:hover::before { + box-shadow: 4px 0 0 0 var(--bg-card); + border-top-color: var(--accent); + border-right-color: var(--accent); + } + + .resizer-v-toggle-btn.inverted:hover::after { + box-shadow: -4px 0 0 0 var(--bg-card); + border-top-color: var(--accent); + border-left-color: var(--accent); + } +} + +.resizer-v-toggle-btn.inverted:active { + background-color: var(--bg-card); + color: var(--accent); + border-color: var(--accent); +} + +.resizer-v-toggle-btn.inverted:active::before { + box-shadow: 4px 0 0 0 var(--bg-card); + border-top-color: var(--accent); + border-right-color: var(--accent); +} + +.resizer-v-toggle-btn.inverted:active::after { + box-shadow: -4px 0 0 0 var(--bg-card); + border-top-color: var(--accent); + border-left-color: var(--accent); +} + +@media (hover: hover) { + .resizer-v-toggle-btn:hover { + background-color: var(--bg-card); + color: var(--accent); + border-color: var(--accent); + } + + .resizer-v-toggle-btn:hover::before { + box-shadow: 4px 0 0 0 var(--bg-card); + border-bottom-color: var(--accent); + border-right-color: var(--accent); + } + + .resizer-v-toggle-btn:hover::after { + box-shadow: -4px 0 0 0 var(--bg-card); + border-bottom-color: var(--accent); + border-left-color: var(--accent); + } +} + +.resizer-v-toggle-btn:active { + background-color: var(--bg-card); + color: var(--accent); + border-color: var(--accent); +} + +.resizer-v-toggle-btn:active::before { + box-shadow: 4px 0 0 0 var(--bg-card); + border-bottom-color: var(--accent); + border-right-color: var(--accent); +} + +.resizer-v-toggle-btn:active::after { + box-shadow: -4px 0 0 0 var(--bg-card); + border-bottom-color: var(--accent); + border-left-color: var(--accent); +} + +.v-resizer-container:has(.resizer-v.collapsed) .resizer-v-toggle-btn { + bottom: auto; + top: -2px; + z-index: 25; + border-radius: 0 0 16px 16px; + border: 1px solid var(--border-color); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15); +} + +.v-resizer-container:has(.resizer-v.collapsed) .resizer-v-toggle-btn::before { + bottom: auto; + top: 1px; + border-bottom-right-radius: 0; + border-top-right-radius: 12px; + box-shadow: 4px 0 0 0 var(--bg-sidebar); + border-bottom: none; + border-top: 1px solid var(--border-color); + border-right: 1px solid var(--border-color); +} + +.v-resizer-container:has(.resizer-v.collapsed) .resizer-v-toggle-btn::after { + bottom: auto; + top: 1px; + border-bottom-left-radius: 0; + border-top-left-radius: 12px; + box-shadow: -4px 0 0 0 var(--bg-sidebar); + border-bottom: none; + border-top: 1px solid var(--border-color); + border-left: 1px solid var(--border-color); +} + +@media (hover: hover) { + .v-resizer-container:has(.resizer-v.collapsed) .resizer-v-toggle-btn:hover { + background-color: var(--bg-card); + color: var(--accent); + border-color: var(--accent); + } + + .v-resizer-container:has(.resizer-v.collapsed) .resizer-v-toggle-btn:hover::before { + box-shadow: 4px 0 0 0 var(--bg-card); + border-top-color: var(--accent); + border-right-color: var(--accent); + } + + .v-resizer-container:has(.resizer-v.collapsed) .resizer-v-toggle-btn:hover::after { + box-shadow: -4px 0 0 0 var(--bg-card); + border-top-color: var(--accent); + border-left-color: var(--accent); + } +} + +.v-resizer-container:has(.resizer-v.collapsed) .resizer-v-toggle-btn:active { + background-color: var(--bg-card); + color: var(--accent); + border-color: var(--accent); +} + +.v-resizer-container:has(.resizer-v.collapsed) .resizer-v-toggle-btn:active::before { + box-shadow: 4px 0 0 0 var(--bg-card); + border-top-color: var(--accent); + border-right-color: var(--accent); +} + +.v-resizer-container:has(.resizer-v.collapsed) .resizer-v-toggle-btn:active::after { + box-shadow: -4px 0 0 0 var(--bg-card); + border-top-color: var(--accent); + border-left-color: var(--accent); +} + +/* Inverted Button when Editor is Collapsed */ +.v-resizer-container:has(.resizer-v.editor-collapsed) .resizer-v-toggle-btn.inverted, +body.v-resizer-editor-collapsed .resizer-v-toggle-btn.inverted { + top: auto; + bottom: 4px; + z-index: 250; + pointer-events: auto; + border-radius: 16px 16px 0 0; + border: 1px solid var(--border-color); + border-bottom: none; + box-shadow: 0 -2px 4px rgba(0, 0, 0, 0.3); +} + +.v-resizer-container:has(.resizer-v.editor-collapsed) .resizer-v-toggle-btn.inverted::before, +body.v-resizer-editor-collapsed .resizer-v-toggle-btn.inverted::before { + bottom: 0; + top: auto; + border-top-right-radius: 0; + border-bottom-right-radius: 12px; + box-shadow: 4px 0 0 0 var(--bg-sidebar); + border-top: none; + border-bottom: 1px solid var(--border-color); + border-right: 1px solid var(--border-color); +} + +.v-resizer-container:has(.resizer-v.editor-collapsed) .resizer-v-toggle-btn.inverted::after, +body.v-resizer-editor-collapsed .resizer-v-toggle-btn.inverted::after { + bottom: 0; + top: auto; + border-top-left-radius: 0; + border-bottom-left-radius: 12px; + box-shadow: -4px 0 0 0 var(--bg-sidebar); + border-top: none; + border-bottom: 1px solid var(--border-color); + border-left: 1px solid var(--border-color); +} + +@media (hover: hover) { + + .v-resizer-container:has(.resizer-v.editor-collapsed) .resizer-v-toggle-btn.inverted:hover, + body.v-resizer-editor-collapsed .resizer-v-toggle-btn.inverted:hover { + background-color: var(--bg-card); + color: var(--accent); + border-color: var(--accent); + } + + .v-resizer-container:has(.resizer-v.editor-collapsed) .resizer-v-toggle-btn.inverted:hover::before, + body.v-resizer-editor-collapsed .resizer-v-toggle-btn.inverted::before { + box-shadow: 4px 0 0 0 var(--bg-card); + border-bottom-color: var(--accent); + border-right-color: var(--accent); + } + + .v-resizer-container:has(.resizer-v.editor-collapsed) .resizer-v-toggle-btn.inverted:hover::after, + body.v-resizer-editor-collapsed .resizer-v-toggle-btn.inverted::after { + box-shadow: -4px 0 0 0 var(--bg-card); + border-bottom-color: var(--accent); + border-left-color: var(--accent); + } +} + +.v-resizer-container:has(.resizer-v.editor-collapsed) .resizer-v-toggle-btn.inverted:active, +body.v-resizer-editor-collapsed .resizer-v-toggle-btn.inverted:active { + background-color: var(--bg-card); + color: var(--accent); + border-color: var(--accent); +} + +.v-resizer-container:has(.resizer-v.editor-collapsed) .resizer-v-toggle-btn.inverted:active::before, +body.v-resizer-editor-collapsed .resizer-v-toggle-btn.inverted:active::before { + box-shadow: 4px 0 0 0 var(--bg-card); + border-bottom-color: var(--accent); + border-right-color: var(--accent); +} + +.v-resizer-container:has(.resizer-v.editor-collapsed) .resizer-v-toggle-btn.inverted:active::after, +body.v-resizer-editor-collapsed .resizer-v-toggle-btn.inverted:active::after { + box-shadow: -4px 0 0 0 var(--bg-card); + border-bottom-color: var(--accent); + border-left-color: var(--accent); +} + +/* Show / Hide Toggle Buttons depending on layout state */ +.v-resizer-container:has(.resizer-v.collapsed) .resizer-v-toggle-btn.inverted, +body.v-resizer-collapsed .resizer-v-toggle-btn.inverted { + display: none !important; +} + +.v-resizer-container:has(.resizer-v.editor-collapsed) .resizer-v-toggle-btn:not(.inverted), +body.v-resizer-editor-collapsed .resizer-v-toggle-btn:not(.inverted) { + display: none !important; +} + + +@media (max-width: 767.98px) { + .header-release { + display: none !important; + } + + .content-area-wrap { + padding: 1.25rem 1rem; + } + + /* Position h-resizer-container & toggle button correctly on mobile */ + body:not(.collapsed-workspace) .h-resizer-container { + position: absolute; + left: 0; + top: 0; + bottom: 0; + z-index: 1000; + } + + body:not(.collapsed-workspace) .resizer-toggle-btn { + left: 0; + transform: translate(0, -50%); + border-radius: 0 16px 16px 0; + border-left: none; + border-right: 1px solid var(--border-color); + box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.15); + } + + body:not(.collapsed-workspace) .resizer-toggle-btn::before { + right: auto; + left: 1px; + border-bottom-right-radius: 0; + border-bottom-left-radius: 17px; + box-shadow: -4px 4px 0 0 var(--bg-sidebar); + border-right: none; + border-left: 1px solid var(--border-color); + } + + body:not(.collapsed-workspace) .resizer-toggle-btn::after { + right: auto; + left: 1px; + border-top-right-radius: 0; + border-top-left-radius: 17px; + box-shadow: -4px -4px 0 0 var(--bg-sidebar); + border-right: none; + border-left: 1px solid var(--border-color); + } + + body.collapsed-workspace .resizer-toggle-btn { + left: 1px; + transform: translate(-100%, -50%); + border-radius: 16px 0 0 16px; + } + + /* Hide inverted v-resizer button on mobile unless editor is explicitly collapsed */ + .resizer-v-toggle-btn.inverted { + display: none !important; + } + + body.v-resizer-editor-collapsed .resizer-v-toggle-btn.inverted { + display: flex !important; + } +} + +@media (max-width: 600px) { + .header-subtitle { + display: none; + } +} + +@media (min-width: 768px) { + .content-col { + width: 50%; + flex: 0 0 auto; + min-width: 0; + } + + .editor-col { + display: flex; + min-width: 0; + min-height: 0; + } + + .sidebar { + position: relative; + top: auto; + bottom: auto; + left: auto; + transform: none; + margin-left: -18rem; + box-shadow: none; + } + + .sidebar.open { + margin-left: 0; + } +} + +.collapsed-workspace .editor-col { + width: 0 !important; + height: 0 !important; + overflow: visible !important; + position: absolute !important; + pointer-events: none; +} + +.collapsed-workspace .editor-workspace { + display: block !important; + height: 0 !important; + overflow: visible !important; + pointer-events: none; +} + +body:not(.preview-maximized).collapsed-workspace .preview-section { + position: fixed !important; + top: calc(3.5rem + 24px) !important; + right: 24px !important; + width: 250px !important; + height: 250px !important; + z-index: 1000 !important; + border-radius: 12px !important; + border: 1px solid var(--border-color) !important; + background: var(--bg-sidebar) !important; + box-shadow: 0 12px 36px rgba(0, 0, 0, 0.6) !important; + overflow: hidden !important; + pointer-events: auto !important; +} + +body:not(.preview-maximized).playground-mode.collapsed-workspace .preview-section { + top: calc(3.5rem + 48px) !important; +} + +body:not(.playground-mode).collapsed-workspace #code-container { + display: none !important; +} + +body:not(.playground-mode).collapsed-workspace #editor-console { + display: none !important; +} + +.collapsed-workspace .v-resizer-container { + display: none !important; +} + +.collapsed-workspace .h-resizer-container { + width: 0 !important; +} + +.collapsed-workspace #header-editor-toggle { + color: var(--accent) !important; +} + +.preview-controls-top { + position: absolute; + top: 12px; + right: 12px; + z-index: 100; + display: flex; + gap: 6px; +} + +.preview-hide-btn, +.preview-fullscreen-btn, +.preview-copy-btn, +.preview-playground-btn, +.preview-refresh-btn { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(18, 18, 18, 0.6); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-muted); + cursor: pointer; + transition: background-color 0.2s, color 0.2s, opacity 0.2s, border-color 0.2s; + opacity: 0.8; +} + +.preview-hide-btn:hover, +.preview-fullscreen-btn:hover, +.preview-copy-btn:hover, +.preview-playground-btn:hover, +.preview-refresh-btn:hover { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.35); + color: #fff; + opacity: 1; +} + +body:not(.collapsed-workspace) .preview-hide-btn { + display: none !important; +} + +body.playground-mode .preview-playground-btn, +body.playground-mode .preview-refresh-btn { + display: none !important; +} + +.preview-copy-btn { + position: absolute; + bottom: 12px; + right: 12px; +} + +.preview-copy-btn.success { + color: #10b981; + border-color: #10b981; +} + +body.preview-hidden .preview-section { + display: none !important; +} + +.collapsed-workspace.preview-hidden #code-container { + height: 100% !important; +} + +body:not(.collapsed-workspace):not(.v-resizer-collapsed) #header-preview-toggle { + display: none !important; +} + +body:not(.collapsed-workspace) #copy-code-btn-header { + display: none !important; +} + +body.playground-mode:not(.collapsed-workspace):not(.v-resizer-collapsed) #header-preview-toggle { + display: none !important; +} + +body.v-resizer-collapsed #header-preview-toggle { + display: flex !important; +} + +body.v-resizer-editor-collapsed:not(.playground-mode) #code-container, +body.v-resizer-editor-collapsed:not(.playground-mode) #editor-console, +body.v-resizer-editor-collapsed #debug-container { + display: none !important; +} + +body.v-resizer-editor-collapsed .preview-section { + height: calc(100% - 4px) !important; +} + +body:not(.preview-maximized) .editor-workspace:has(.resizer-v.collapsed) .preview-section { + position: fixed !important; + top: calc(3.5rem + 48px) !important; + right: 24px !important; + width: 250px !important; + height: 250px !important; + z-index: 1000 !important; + border-radius: 12px !important; + border: 1px solid var(--border-color) !important; + background: var(--bg-sidebar) !important; + box-shadow: 0 12px 36px rgba(0, 0, 0, 0.6) !important; + overflow: hidden !important; + pointer-events: auto !important; +} + +.debug-container { + display: none; + flex-direction: column; + background: var(--bg-sidebar); + flex: 1; + min-height: 0; + overflow: hidden; +} + +.debug-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 6px 2px 12px; + height: 34px; + box-sizing: border-box; + border-bottom: 1px solid #2a2a35; + font-family: 'Inter', sans-serif; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.05em; + color: var(--text-muted); +} + +.debug-title { + font-size: 0.725rem; + font-family: 'Fira Code', monospace; + letter-spacing: 0.05em; + color: var(--text-muted); + font-weight: 500; + display: flex; + align-items: center; + line-height: 1; + user-select: none; +} + +.debug-select { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + background: rgba(255, 255, 255, 0.04) url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='rgba(255,255,255,0.5)' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e") no-repeat right 8px center; + background-size: 10px; + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--text-muted); + font-family: 'Inter', sans-serif; + font-size: 0.7rem; + font-weight: 600; + padding: 3px 22px 3px 8px; + cursor: pointer; + outline: none; + transition: all 0.2s; +} + +.debug-select:hover, +.debug-select:focus { + color: #fff; + border-color: rgba(255, 255, 255, 0.25); + background-color: rgba(255, 255, 255, 0.08); +} + +.debug-select option { + background: var(--bg-main); + color: #fff; +} + +.debug-selectors { + display: flex; + gap: 8px; + margin-top: -1px; +} + +#debug-editor-container { + flex: 1; + min-height: 0; +} + +body.preview-maximized .content-col { + display: none !important; +} + +body.preview-maximized .editor-col { + position: absolute !important; + top: 0 !important; + left: 0 !important; + width: 100% !important; + height: 100% !important; + z-index: 2000 !important; + pointer-events: auto !important; + display: flex !important; +} + +body.preview-maximized .editor-workspace { + position: absolute !important; + top: 0 !important; + left: 0 !important; + width: 100% !important; + height: 100% !important; + z-index: 2000 !important; + pointer-events: auto !important; + display: flex !important; + overflow: visible !important; +} + +body.preview-maximized .preview-section { + position: absolute !important; + top: 0 !important; + left: 0 !important; + width: 100% !important; + height: 100% !important; + z-index: 2000 !important; + pointer-events: auto !important; + border-radius: 0 !important; + border: none !important; + box-shadow: none !important; +} + +body.preview-maximized .preview-hide-btn { + display: none !important; +} + +/* Editor Console Panel */ +#editor-console { + display: flex; + flex-direction: column; + height: 150px; + background-color: #15151a; + border-top: 1px solid var(--border-color); + flex-shrink: 0; + z-index: 10; + transition: height 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +#editor-console.minimized { + height: 33px; + /* Header height + top border */ +} + +.console-header { + display: flex; + align-items: center; + justify-content: space-between; + background-color: #1a1a22; + border-bottom: 1px solid var(--border-color); + padding: 6px 12px; + flex-shrink: 0; + height: 32px; + cursor: pointer; + user-select: none; +} + +.console-title { + font-size: 0.725rem; + font-family: 'Fira Code', monospace; + letter-spacing: 0.05em; + color: var(--text-muted); + font-weight: 500; + display: flex; + align-items: center; + user-select: none; +} + +.console-header-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.console-close { + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; + opacity: 0.7; + transition: all 0.2s; + padding: 4px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; +} + +.console-close:hover { + opacity: 1; + color: #fff; + background-color: rgba(255, 255, 255, 0.05); +} + +.console-close.success { + opacity: 1; + color: #10b981; +} + +.console-close:disabled { + opacity: 0.2; + cursor: default; + pointer-events: none; +} + +.console-body { + flex: 1; + font-family: 'Fira Code', monospace; + font-size: 0.8rem; + color: #fca5a5; + padding: 0; + overflow-y: auto; + white-space: pre-wrap; + word-break: break-word; + line-height: 1.5; + background-color: #111115; +} + +#editor-console.minimized .console-body { + display: none; +} + +#editor-console.minimized .console-header { + border-bottom: none; +} + +.console-line { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 6px 14px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + gap: 8px; +} + +.console-line-text { + flex: 1; +} + +.console-jump-btn { + background: transparent; + border: none; + color: inherit; + cursor: pointer; + opacity: 0.6; + transition: all 0.2s ease; + padding: 3px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-top: -1px; +} + +.console-jump-btn:hover { + opacity: 1; +} + +.console-jump-btn i, +.console-jump-btn svg { + width: 0.9rem; + height: 0.9rem; + display: block; +} + +/* Sidebar Search styling */ +.sidebar-header:has(.sidebar-search.focused) #menu-toggle-close, +.sidebar-header:has(.sidebar-search.focused) .header-logo-container { + display: none !important; +} + +.sidebar-header:has(.sidebar-search.focused) { + border-bottom: 1px solid var(--border-color); +} + +.sidebar-search { + display: flex; + align-items: center; + position: relative; + margin-left: auto; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + width: 32px; + height: 32px; + border-radius: 50%; + cursor: pointer; +} + +.sidebar-search:hover { + background-color: rgba(255, 255, 255, 0.08); +} + +.sidebar-search.focused { + width: 100%; + height: 100%; + margin-left: 0; + background: transparent; + cursor: default; +} + +.sidebar-search.focused:hover { + background: transparent; +} + +.sidebar-search-input { + width: 100%; + height: 100%; + background: transparent; + border: none; + padding: 0 2rem 0 2.25rem; + font-size: 0.85rem; + color: #fff; + outline: none; + cursor: pointer; + opacity: 0; + transition: opacity 0.15s ease; +} + +.sidebar-search.focused .sidebar-search-input { + opacity: 1; + cursor: text; +} + +.sidebar-search-icon { + position: absolute; + left: 8px; + color: var(--text-muted); + pointer-events: none; + width: 1rem; + height: 1rem; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.15s ease, left 0.25s cubic-bezier(0.4, 0, 0.2, 1); +} + +.sidebar-search.focused .sidebar-search-icon { + left: 0.5rem; + color: #fff; +} + +.sidebar-search-clear { + position: absolute; + right: 8px; + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 0; + display: none; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + transition: color 0.15s ease; +} + +.sidebar-search-clear:hover { + color: #fff; +} + +.sidebar-search-clear svg { + width: 0.875rem; + height: 0.875rem; +} + +.toc-search-snippet { + font-size: 0.75rem; + color: var(--text-muted); + padding: 0.25rem 0.75rem 0.5rem 0.75rem; + line-height: 1.4; + word-break: break-word; + cursor: pointer; + border-left: 2px solid rgba(0, 170, 255, 0.2); + transition: color 0.15s ease, border-left-color 0.15s ease, background-color 0.15s ease; + border-radius: 0 4px 4px 0; +} + +.toc-item-wrapper:hover .toc-btn { + background-color: var(--bg-card); + color: #ececec; +} + +.toc-item-wrapper:hover .toc-search-snippet { + color: #ececec; + background-color: rgba(255, 255, 255, 0.02); + border-left-color: var(--accent); +} + +.search-active.search-focused .first-search-result .toc-btn, +.sidebar:has(.sidebar-search-input:focus) .search-active .first-search-result .toc-btn { + background-color: var(--bg-card); + color: #ececec; +} + +.search-active.search-focused .first-search-result .toc-search-snippet, +.sidebar:has(.sidebar-search-input:focus) .search-active .first-search-result .toc-search-snippet { + color: #ececec; + background-color: rgba(255, 255, 255, 0.02); + border-left-color: var(--accent); +} + +.toc-list.search-active:has(.toc-item-wrapper:hover) .first-search-result:not(:hover) .toc-btn { + background-color: transparent; + color: var(--text-muted); +} + +.toc-list.search-active:has(.toc-item-wrapper:hover) .first-search-result:not(:hover) .toc-search-snippet { + color: var(--text-muted); + background-color: transparent; + border-left-color: rgba(0, 170, 255, 0.2); +} + +.toc-btn.active+.toc-search-snippet { + border-left-color: var(--accent); + color: #fff; + background-color: rgba(255, 255, 255, 0.03); +} + +.search-highlight { + color: var(--accent); + font-weight: 600; +} + +.toc-no-results { + padding: 2rem 1rem; + text-align: center; + color: var(--text-muted); + font-size: 0.875rem; + font-style: italic; +} + +.search-active .toc-chevron, +.search-active .toc-chevron-btn { + display: none !important; +} + +@keyframes search-flash { + 0% { + background-color: rgba(0, 170, 255, 0.4); + } + + 100% { + background-color: transparent; + } +} + +@keyframes search-flash-overlay { + 0% { + opacity: 1; + background-color: rgba(28, 163, 236, 0.5); + } + + 100% { + opacity: 0; + background-color: transparent; + } +} + +@keyframes search-flash-glow { + 0% { + box-shadow: 0 0 15px rgba(0, 170, 255, 0.7); + } + + 100% { + box-shadow: none; + } +} + +.search-match-flash { + animation: search-flash 1.5s ease-out; + border-radius: 4px; +} + +.inline-code-editor-container.search-match-flash, +.tsl-embed-container.search-match-flash { + animation: search-flash-glow 1.5s ease-out; +} + +.tsl-api-table-row.search-match-flash, +.tsl-api-signature.search-match-flash, +.tsl-api-class-summary.search-match-flash, +.tsl-api-inherited-summary.search-match-flash { + animation: none !important; + position: relative; + z-index: 10; +} + +.tsl-api-table-row.search-match-flash::after, +.tsl-api-signature.search-match-flash::after, +.tsl-api-class-summary.search-match-flash::after, +.tsl-api-inherited-summary.search-match-flash::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; + animation: search-flash-overlay 1.5s ease-out forwards; + z-index: 10; + border-radius: inherit; +} + + +@media (max-width: 767px) { + .sidebar { + z-index: 3000 !important; + } + + /* On mobile, when workspace is expanded (NOT collapsed) */ + body:not(.collapsed-workspace) .resizer-toggle-btn { + left: auto; + right: 2px; + transform: translate(100%, -50%); + border-radius: 0 16px 16px 0; + border: 1px solid var(--border-color); + box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.15); + z-index: 250; + } + + body:not(.collapsed-workspace) .resizer-toggle-btn::before { + right: auto; + left: 0; + border-bottom-left-radius: 17px; + border-bottom-right-radius: 0; + box-shadow: -4px 4px 0 0 var(--bg-sidebar); + border-left: 1px solid var(--border-color); + border-right: none; + } + + body:not(.collapsed-workspace) .resizer-toggle-btn::after { + right: auto; + left: 0; + border-top-left-radius: 17px; + border-top-right-radius: 0; + box-shadow: -4px -4px 0 0 var(--bg-sidebar); + border-top: 1px solid var(--border-color); + border-left: 1px solid var(--border-color); + border-right: none; + } + + body:not(.collapsed-workspace) .resizer-toggle-btn:active::before { + box-shadow: -4px 4px 0 0 var(--bg-card); + border-bottom-color: var(--accent); + border-left-color: var(--accent); + } + + body:not(.collapsed-workspace) .resizer-toggle-btn:active::after { + box-shadow: -4px -4px 0 0 var(--bg-card); + border-top-color: var(--accent); + border-left-color: var(--accent); + } + + body:not(.collapsed-workspace) .resizer-toggle-btn:active { + border-color: var(--accent); + } + + /* Float preview section stuck to top right on mobile in collapsed workspace mode */ + .collapsed-workspace .preview-section { + top: 3.5rem !important; + right: 0 !important; + width: 200px !important; + height: 200px !important; + border-radius: 0 0 0 8px !important; + border-top: none !important; + border-right: none !important; + box-shadow: -4px 4px 16px rgba(0, 0, 0, 0.5) !important; + } + + /* On mobile, make resizer bar 1px wide when expanded to prevent double border lines */ + body:not(.collapsed-workspace) .h-resizer-container { + width: 0px !important; + } + + body:not(.collapsed-workspace) .resizer-h-line { + left: 0px !important; + } + + .v-resizer-container { + background-color: var(--border-color) !important; + } + + /* Hide subtitle on mobile viewports */ + .header-subtitle { + display: none !important; + } + + .header-title-prefix { + display: none !important; + } + + #copy-code-btn-header { + display: flex !important; + } + + #copy-code-btn { + display: none !important; + } + + body.preview-maximized .preview-section { + position: fixed !important; + top: 3.5rem !important; + left: 0 !important; + right: 0 !important; + bottom: 0 !important; + width: 100vw !important; + height: calc(100vh - 3.5rem) !important; + z-index: 2000 !important; + border-radius: 0 !important; + border: none !important; + box-shadow: none !important; + pointer-events: auto !important; + background: var(--bg-main) !important; + } + + .collapsed-workspace #code-container { + display: none !important; + } + + .collapsed-workspace #editor-console { + display: none !important; + } + + body.playground-mode #h-resizer-container { + display: none !important; + } + + .tsl-embed-lock-overlay:not(.unlocked) { + pointer-events: auto; + visibility: visible; + background: rgba(15, 15, 20, 0.25); + } + + .tsl-embed-lock-overlay:not(.unlocked) .tsl-embed-lock-circle { + opacity: 1; + transform: translate3d(0, 0, 0) scale(1); + } +} + +/* Playground Tabs Styles */ +.playground-tabs-bar { + display: flex; + align-items: center; + /*background: #111115;*/ + border-bottom: 1px solid #2a2a35; + height: 38px; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + user-select: none; +} + +.playground-tabs-bar::-webkit-scrollbar { + display: none; +} + +.playground-tab { + display: flex; + align-items: center; + padding: 0 16px; + height: 100%; + background: #15151a; + border-right: 1px solid #2a2a35; + cursor: pointer; + color: #8e8e93; + font-family: 'Fira Code', sans-serif; + font-size: 12px; + position: relative; + transition: background 0.15s, color 0.15s; +} + +.playground-tab:hover { + background: #191920; + color: #ffffff; +} + +.playground-tab.active { + background: #1d1d24; + color: #ffffff; + border-bottom: 2px solid #007acc; +} + +.playground-tab-label { + white-space: nowrap; +} + +.playground-tab-close { + display: flex; + align-items: center; + justify-content: center; + margin-left: 12px; + width: 14px; + height: 14px; + color: #8e8e93; + line-height: 1; + transition: color 0.15s; +} + +.playground-tab-close:hover { + color: #ff453a; +} + +.playground-tab-add { + display: flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + min-width: 38px; + cursor: pointer; + color: #8e8e93; + font-size: 18px; + transition: background 0.15s, color 0.15s; +} + +.playground-tab-add:hover { + background: #191920; + color: #ffffff; +} + +.playground-tab-rename-input { + background: #2a2a35; + border: 1px solid #007acc; + color: #ffffff; + font-family: 'Fira Code', sans-serif; + font-size: 12px; + padding: 2px 4px; + outline: none; + width: 80px; + border-radius: 2px; +} + +/* Playground Tab Buttons Styles */ +.playground-tab-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 38px; + min-width: 32px; + border: none; + background: transparent; + color: #8e8e93; + cursor: pointer; + transition: color 0.15s, opacity 0.15s; +} + +.playground-tab-btn svg { + width: 16px; + height: 16px; + display: block; +} + +.playground-tab-btn:hover:not(:disabled) { + color: #ffffff; +} + +.playground-tab-btn:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.playground-clean-btn { + margin-left: auto; +} + +.inline-code-editor-container { + margin-bottom: 1.25rem; + border-radius: 6px; + border: 1px solid var(--border-color); + overflow: hidden; +} + +.video-container { + position: relative; + padding-bottom: 56.25%; + /* 16:9 Aspect Ratio */ + height: 0; + overflow: hidden; + margin-top: 1rem; + margin-bottom: 1.5rem; + border-radius: 8px; + border: 1px solid var(--border-color); +} + +.video-container iframe { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + + +/* Fix Monaco Editor autocomplete/suggest widget and other overflow widgets being covered by the resizer container (z-index: 200) */ +.suggest-widget, +.monaco-hover, +.parameter-hints-widget, +.context-view, +.monaco-editor .suggest-widget, +.monaco-editor .monaco-hover, +.monaco-editor .parameter-hints-widget, +.monaco-editor .context-view { + z-index: 9999 !important; +} + +.tsl-embed-container { + display: flex; + flex-direction: column; + margin-top: 1.5rem; + margin-bottom: 2rem; + border: 1px solid var(--border-color); + border-radius: 8px; + overflow: hidden; + background-color: #15151a; +} + +.tsl-embed-preview { + width: 100%; + height: 350px; + position: relative; + background-color: var(--bg-main); + border-bottom: 1px solid var(--border-color); +} + +.tsl-embed-code { + width: 100%; +} + +.embed-playground-btn, +.embed-expand-btn { + position: absolute; + top: 12px; + z-index: 10; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(18, 18, 18, 0.6); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-muted); + cursor: pointer; + transition: background-color 0.2s, color 0.2s, opacity 0.2s, border-color 0.2s; + opacity: 0.8; +} + +.embed-playground-btn { + right: 12px; +} + +.embed-expand-btn { + right: 52px; +} + +.embed-playground-btn:hover, +.embed-expand-btn:hover { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.25); + color: #fff; + opacity: 1; +} + +/* Embed Lock Overlay styling */ +.tsl-embed-lock-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 9; + background: rgba(15, 15, 20, 0); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 1; + pointer-events: none; + visibility: hidden; + transition: background-color 0.4s ease, visibility 0.4s; +} + +/* Only show the overlay on hover when NOT unlocked */ +.tsl-embed-preview:hover .tsl-embed-lock-overlay:not(.unlocked) { + pointer-events: auto; + visibility: visible; + background: rgba(15, 15, 20, 0.25); +} + +.tsl-embed-lock-overlay.unlocked { + background: rgba(15, 15, 20, 0) !important; + pointer-events: none !important; + visibility: hidden !important; +} + +.tsl-embed-lock-circle { + display: flex; + align-items: center; + justify-content: center; + width: 3.5rem; + height: 3.5rem; + border-radius: 50%; + background: rgba(255, 255, 255, 0.4); + border: 1px solid rgba(255, 255, 255, 0.4); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.1); + transform: translate3d(0, 10px, 0) scale(0.95); + opacity: 0; + transition: opacity 0.4s ease, transform 0.4s cubic-bezier(0.25, 1, 0.5, 1), border-color 0.3s ease, background-color 0.3s ease, box-shadow 0.3s ease; + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + will-change: transform, opacity; +} + +/* Slide up and scale circle when hover active */ +.tsl-embed-preview:hover .tsl-embed-lock-overlay:not(.unlocked) .tsl-embed-lock-circle { + opacity: 1; + transform: translate3d(0, 0, 0) scale(1); +} + +.tsl-embed-lock-overlay:hover .tsl-embed-lock-circle { + background: rgba(255, 255, 255, 0.75); + border-color: rgba(255, 255, 255, 0.85); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25), 0 0 12px rgba(255, 255, 255, 0.2); +} + +/* Disable scroll/clipping and clear positioning on all intermediate parents when an embed is maximized */ +.content-col:has(.tsl-embed-preview.maximized) .content-area-wrap, +.content-col:has(.tsl-embed-preview.maximized) .content-area-wrap>div, +.content-col:has(.tsl-embed-preview.maximized) .tsl-embed-container { + position: static !important; + overflow: visible !important; +} + +/* Maximized Embed Preview - Spans the entire .content-col */ +.tsl-embed-preview.maximized { + position: absolute !important; + top: 0 !important; + left: 0 !important; + width: 100% !important; + height: 100% !important; + z-index: 999 !important; + background-color: var(--bg-main) !important; + border-bottom: none !important; + border-radius: 8px; + /* Match the rounded corners of .content-col if applicable, or keep it square */ +} + +.tsl-embed-preview.maximized .tsl-embed-lock-overlay { + display: none !important; +} + +.tsl-embed-preview.maximized .embed-playground-btn { + top: 20px; + right: 20px; +} + +.tsl-embed-preview.maximized .embed-expand-btn { + top: 20px; + right: 60px; +} + +/* TSL Tour API Parameter Styling */ +.tsl-api-card { + margin: 1.5rem 0; + background-color: var(--bg-sidebar); + border: 1px solid var(--border-color); + border-radius: 8px; + overflow: hidden; + transition: border-color 0.25s ease, box-shadow 0.25s ease; +} + +.tsl-api-card:hover { + border-color: rgba(28, 163, 236, 0.3); + box-shadow: 0 4px 20px rgba(28, 163, 236, 0.08); +} + +.tsl-api-card-inline { + display: block; + margin: 0.5rem 0; +} + +.tsl-api-signature { + background-color: var(--bg-card); + padding: 0.75rem 1.25rem; + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: flex-start; + flex-wrap: wrap; +} + +.tsl-api-card-inline .tsl-api-signature { + border-bottom: 1px solid var(--border-color); + padding: 0.5rem 1rem; +} + +.tsl-api-card-inline .tsl-api-signature code, +.tsl-api-card-inline .tsl-sig-param-name, +.tsl-api-card-inline .tsl-sig-param { + font-size: 0.8rem; +} + +.tsl-api-card-inline .tsl-params { + padding: 0.5rem 1rem; + background-color: rgba(0, 0, 0, 0.08); +} + +.tsl-api-card-inline .tsl-param { + margin-bottom: 0; +} + +.tsl-api-card-inline .tsl-param-name { + font-weight: 400; +} + +/* Unified API Table Card Styling */ +.tsl-api-table-card { + margin: 1.5rem 0; + background-color: var(--bg-sidebar); + border: 1px solid var(--border-color); + border-radius: 8px; + overflow: hidden; +} + +.tsl-api-table-row { + border-bottom: 6px solid var(--bg-main); + transition: background-color 0.25s ease; +} + +.tsl-api-table-row:last-child { + border-bottom: none; +} + +.tsl-api-table-row .tsl-api-signature { + background-color: var(--bg-card); + border-bottom: 1px solid var(--border-color); + padding: 0.5rem 1rem; + transition: background-color 0.25s ease; +} + +.tsl-api-table-row:hover .tsl-api-signature { + background-color: #1ca3ec3d; +} + +.tsl-api-table-row .tsl-params { + padding: 0.5rem 1rem; + background-color: rgba(0, 0, 0, 0.08); + transition: background-color 0.25s ease; +} + +.tsl-api-table-row:hover .tsl-params { + background-color: rgba(28, 163, 236, 0.04); +} + +.tsl-api-table-row .tsl-api-signature code, +.tsl-api-table-row .tsl-sig-param-name, +.tsl-api-table-row .tsl-sig-param { + font-size: 0.8rem; +} + +.tsl-api-table-row .tsl-param { + margin-bottom: 0; +} + +.tsl-api-table-row .tsl-param-name { + font-weight: 400; +} + +.tsl-api-sig-left, +.tsl-api-sig-right { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.tsl-api-sig-left, +.tsl-api-sig-left * { + font-family: 'Fira Code', monospace !important; + font-size: 0.8rem !important; +} + +.tsl-api-return-arrow { + color: var(--text-muted); + margin-left: 0.6rem; + margin-right: 0.3rem; + font-weight: 300; +} + +.tsl-api-signature code { + font-family: 'Fira Code', monospace; + font-size: 0.9rem; + font-weight: 400; + color: #ffffff; + background: none !important; + border: none !important; + padding: 0 !important; +} + +.tsl-params { + padding: 1.25rem; +} + +.tsl-param { + display: flex; + flex-direction: column; + gap: 0.35rem; + margin-bottom: 1rem; +} + +.tsl-param:last-child { + margin-bottom: 0; +} + +.tsl-param-header { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; +} + +.tsl-param-name { + font-family: 'Fira Code', monospace; + font-size: 0.85rem; + font-weight: 600; + color: var(--accent); +} + +.tsl-param-type { + font-family: 'Fira Code', monospace; + font-size: 0.7rem; + line-height: normal; + color: var(--text-muted); + background-color: var(--bg-main); + border: 1px solid var(--border-color); + padding: 0.1rem 0.3rem; + border-radius: 4px; + white-space: nowrap; +} + +.tsl-param-type-string { + color: #ce9178; +} + +.tsl-param-type-keyword { + color: #569cd6; +} + +.tsl-param-type-separator { + color: var(--text-muted); + font-size: 0.5rem; + font-weight: 300; +} + +.tsl-sig-param { + font-family: 'Fira Code', monospace; + font-size: 0.9rem; + color: #ffffff; +} + +.tsl-sig-param-name { + font-family: 'Fira Code', monospace; + font-size: 0.9rem; + color: #9cdcfe; +} + +.tsl-sig-param-optional { + font-family: 'Fira Code', monospace; + font-size: 0.85rem; + color: #9cdcfe; + font-weight: 500; + margin-left: 0.05rem; + opacity: 0.85; +} + +.tsl-sig-param-colon { + color: #d4d4d4; + margin-right: 0.15rem; +} + +.tsl-param-desc { + font-size: 0.9rem; + color: #d1d5db; + line-height: 1.5; +} + +.tsl-api-sig-desc { + color: var(--text-muted); + font-size: 0.8rem; + margin-left: auto; + font-weight: 300; +} + +/* Tour Important Callout Block */ +.tour-important-block { + margin: 1.5rem 0; + background-color: rgba(240, 160, 0, 0.04); + border: 1px solid rgba(240, 160, 0, 0.15); + border-left: 4px solid #f0a000; + border-radius: 6px; + padding: 0.85rem 1.25rem; +} + +.tour-important-header { + display: flex; + align-items: center; + gap: 0.4rem; + font-weight: 700; + font-size: 0.9rem; + color: #f0a000; + margin-bottom: 0.4rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.tour-important-content { + font-size: 0.9rem; + color: #d1d5db; + line-height: 1.75; +} + +.tour-important-item { + line-height: 1.75; +} + +.tour-important-divider { + height: 1px; + background: rgba(240, 160, 0, 0.15); + margin: 0.65rem 0; +} + +/* Tour Note Callout Block */ +.tour-note-block { + margin: 1.5rem 0; + background-color: rgba(0, 170, 255, 0.04); + border: 1px solid rgba(0, 170, 255, 0.15); + border-left: 4px solid var(--accent, #00aaff); + border-radius: 6px; + padding: 0.85rem 1.25rem; +} + +.tour-note-header { + display: flex; + align-items: center; + gap: 0.4rem; + font-weight: 700; + font-size: 0.9rem; + color: var(--accent, #00aaff); + margin-bottom: 0.5rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.tour-note-content { + font-size: 0.9rem; + color: #d1d5db; + line-height: 1.75; +} + +.tour-note-item { + line-height: 1.75; +} + +.tour-note-divider { + height: 1px; + background: rgba(0, 170, 255, 0.15); + margin: 0.65rem 0; +} + +/* Tour AI / LLM Accordion Callout Block */ +.tour-ai-accordion { + margin: 1.5rem 0; + background: linear-gradient(135deg, rgba(168, 85, 247, 0.07) 0%, rgba(99, 102, 241, 0.04) 100%); + border: 1px solid rgba(168, 85, 247, 0.25); + border-left: 4px solid #a855f7; + border-radius: 8px; + overflow: hidden; + transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; +} + +.tour-ai-accordion:hover { + border-color: rgba(168, 85, 247, 0.45); + box-shadow: 0 4px 20px -4px rgba(168, 85, 247, 0.15); +} + +.tour-ai-summary { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.85rem 1.25rem; + cursor: pointer; + user-select: none; + list-style: none; + background: transparent; + transition: background 0.15s ease; +} + +.tour-ai-summary::-webkit-details-marker, +.tour-ai-summary::marker { + display: none; +} + +.tour-ai-summary:hover { + background: rgba(168, 85, 247, 0.06); +} + +.tour-ai-summary-left { + display: flex; + align-items: center; + gap: 0.55rem; +} + +.tour-ai-icon { + display: inline-flex; + align-items: center; + justify-content: center; + color: #c084fc; +} + +.tour-ai-badge { + font-weight: 700; + font-size: 0.85rem; + letter-spacing: 0.05em; + text-transform: uppercase; + background: linear-gradient(135deg, #c084fc, #818cf8); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.tour-ai-hint { + font-size: 0.78rem; + color: #9ca3af; + opacity: 0.65; + margin-left: 0.35rem; + font-style: italic; +} + +.tour-ai-chevron { + display: inline-flex; + align-items: center; + justify-content: center; + color: #a855f7; + transition: transform 0.25s ease; +} + +details.tour-ai-accordion[open] .tour-ai-chevron { + transform: rotate(180deg); +} + +details.tour-ai-accordion[open] .tour-ai-hint { + display: none; +} + +.tour-ai-content { + padding: 0.85rem 1.25rem 1rem 1.25rem; + font-size: 0.9rem; + color: #d1d5db; + line-height: 1.75; + border-top: 1px solid rgba(168, 85, 247, 0.15); + background: rgba(0, 0, 0, 0.15); +} + +.tour-ai-item { + line-height: 1.75; + margin-bottom: 0.5rem; +} + +.tour-ai-item:last-child { + margin-bottom: 0; +} + +/* TSL API Class Accordion */ +.tsl-api-class-accordion { + margin: 1.25rem 0; + background-color: var(--bg-sidebar); + border: 1px solid var(--border-color); + border-radius: 8px; + overflow: hidden; + transition: border-color 0.25s ease, box-shadow 0.25s ease; +} + +.tsl-api-class-accordion:hover { + border-color: rgba(0, 170, 255, 0.4); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); +} + +.tsl-api-class-accordion[open] { + border-color: var(--border-color); +} + +.tsl-api-class-summary { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.85rem 1.25rem; + background-color: var(--bg-card); + cursor: pointer; + user-select: none; + list-style: none; + border-bottom: 1px solid transparent; + transition: background-color 0.25s ease, border-color 0.25s ease; +} + +.tsl-api-class-summary::-webkit-details-marker, +.tsl-api-class-summary::marker { + display: none; +} + +.tsl-api-class-accordion[open] .tsl-api-class-summary { + border-bottom-color: var(--border-color); +} + +.tsl-api-class-summary:hover { + background-color: #1ca3ec3d; +} + +.tsl-api-class-summary-left { + display: flex; + align-items: center; + gap: 0.65rem; + flex-wrap: wrap; +} + +.tsl-api-class-icon { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--accent); +} + +.tsl-api-class-name { + font-family: 'Fira Code', monospace; + font-weight: 500; + font-size: 0.95rem; + color: #ffffff; + letter-spacing: 0.02em; +} + +.tsl-api-class-extends { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.15rem 0.55rem; + background: rgba(0, 170, 255, 0.08); + border: 1px solid rgba(0, 170, 255, 0.25); + border-radius: 4px; + font-size: 0.75rem; +} + +.tsl-api-class-extends-keyword { + color: var(--text-muted); + font-style: italic; +} + +.tsl-api-class-extends-name { + font-family: 'Fira Code', monospace; + color: var(--accent); + font-weight: 500; +} + +.tsl-api-class-summary-right { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.tsl-api-class-count { + font-size: 0.75rem; + color: var(--text-muted); + background: rgba(0, 0, 0, 0.35); + padding: 0.15rem 0.55rem; + border-radius: 4px; + border: 1px solid var(--border-color); +} + +.tsl-api-class-chevron { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + transition: transform 0.25s ease, color 0.2s ease; +} + +.tsl-api-class-summary:hover .tsl-api-class-chevron { + color: var(--accent); +} + +.tsl-api-class-accordion[open] .tsl-api-class-chevron { + transform: rotate(180deg); +} + +.tsl-api-class-content { + padding: 0.75rem 1rem 1rem 1rem; + background: var(--bg-sidebar); +} + +.tsl-api-class-content .tsl-api-table-card { + margin: 0; + border: 1px solid var(--border-color); + border-radius: 6px; +} + +.tsl-api-class-desc { + margin: 0.5rem 0.25rem 0.75rem 0.25rem; + font-size: 0.85rem; + color: var(--text-muted); +} + +/* Inherited Properties Accordion inside API Class */ +.tsl-api-inherited-group { + margin-top: 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.tsl-api-inherited-accordion { + background-color: var(--bg-sidebar); + border: 1px solid var(--border-color); + border-radius: 6px; + overflow: hidden; + transition: border-color 0.2s ease; +} + +.tsl-api-inherited-accordion:hover { + border-color: rgba(0, 170, 255, 0.4); +} + +.tsl-api-inherited-accordion[open] { + border-color: var(--border-color); +} + +.tsl-api-inherited-summary { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.65rem 1rem; + background-color: var(--bg-card); + cursor: pointer; + user-select: none; + list-style: none; + border-bottom: 1px solid transparent; + transition: background-color 0.25s ease, border-color 0.25s ease; +} + +.tsl-api-inherited-summary::-webkit-details-marker, +.tsl-api-inherited-summary::marker { + display: none; +} + +.tsl-api-inherited-accordion[open] .tsl-api-inherited-summary { + border-bottom-color: var(--border-color); +} + +.tsl-api-inherited-summary:hover { + background-color: #1ca3ec3d; +} + +.tsl-api-inherited-summary-left { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.tsl-api-inherited-icon { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--accent); +} + +.tsl-api-inherited-label { + font-size: 0.8rem; + color: var(--text-muted); + font-weight: 400; +} + +.tsl-api-inherited-summary-right { + display: flex; + align-items: center; + gap: 0.65rem; +} + +.tsl-api-inherited-accordion[open] .tsl-api-class-chevron { + transform: rotate(180deg); +} + +.tsl-api-inherited-content { + padding: 0.6rem 0.8rem; + background: var(--bg-sidebar); +} + +.tsl-api-inherited-content .tsl-api-table-card { + margin: 0; + border: 1px solid var(--border-color); + border-radius: 6px; +} + +/* Inline Code Comments styling */ +.tsl-comment { + color: #6a9955 !important; + font-style: italic; +} + +/* Robust API Table Row Styles */ +.tsl-api-table-row-robust .tsl-api-signature { + padding: 1.0rem !important; +} + +.tsl-api-table-row-robust .tsl-params { + padding: 1.0rem !important; + background-color: transparent !important; +} + +.tsl-api-table-row-robust .tsl-param-name { + font-weight: 600 !important; + font-size: 0.85rem !important; +} + +.tsl-api-table-row-robust .tsl-param { + margin-bottom: 1rem !important; +} + +.tsl-api-table-row-robust .tsl-param:last-child { + margin-bottom: 0 !important; +} + +.tsl-api-table-row-robust .tsl-api-signature code, +.tsl-api-table-row-robust .tsl-sig-param-name, +.tsl-api-table-row-robust .tsl-sig-param { + font-size: 0.8rem !important; +} + +/* API Signature Syntax Highlighting */ +.tsl-sig-func-name { + color: #dcdcaa !important; + /* Golden/warm yellow for Monaco functions */ + font-weight: 500; +} + +.tsl-sig-const-name { + color: #00aeff !important; + /* Soft orange for TSL constants/properties */ + font-weight: 500; +} + +.tsl-sig-param-op { + color: #d4d4d4 !important; + /* Neutral gray for operators (= and :) */ +} + +.tsl-param-type-number { + color: #b5cea8 !important; + /* Light green for numbers */ +} + +.tsl-sig-param-val { + color: #9cdcfe !important; + /* Monaco light blue for identifiers/variables */ +} + +.tsl-sig-dot { + color: #d4d4d4 !important; + /* Neutral white/gray for dots */ +} + +.tsl-sig-paren { + color: #ffd700 !important; + /* Gold/yellow Monaco parenthesis matching */ +} + +.tsl-sig-param-optional { + color: #569cd6 !important; + /* Monaco / TypeScript keyword blue for optional ? */ + font-weight: bold; +} + +/* Search Sections Styling */ +.toc-search-section-header { + font-size: 0.725rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + font-weight: 600; + padding: 0.85rem 0.75rem 0.35rem 0.75rem; + opacity: 0.8; +} + +.toc-search-section.featured { + margin-bottom: 0.5rem; +} + +.sidebar-search-suggestion { + background-color: rgba(0, 170, 255, 0.08); + border-bottom: 1px solid var(--border-color); + padding: 0.65rem 1rem; + font-size: 0.85rem; + color: var(--text-muted); + display: none; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; +} + +.sidebar-search-suggestion a { + color: var(--accent); + text-decoration: underline; + font-weight: 500; + transition: color 0.2s; +} + +.sidebar-search-suggestion a:hover { + color: #fff; +} + +/* X/Twitter Grid and Card Styles */ +.x-tweets-grid { + column-count: 3; + column-gap: 1rem; + margin: 1.5rem 0; + width: 100%; +} + +@media (max-width: 900px) { + .x-tweets-grid { + column-count: 2; + } +} + +@media (max-width: 600px) { + .x-tweets-grid { + column-count: 1; + } +} + +.x-tweet-single { + display: flex; + justify-content: center; + margin: 1.5rem 0; + width: 100%; +} + +.x-tweet-single .x-tweet-card { + max-width: 500px; + width: 100%; +} + +.x-tweet-card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 12px; + overflow: hidden; + width: 100%; + display: inline-block; + min-height: 120px; + position: relative; + transition: border-color 0.25s, transform 0.25s; + break-inside: avoid; + margin-bottom: 1rem; +} + +.x-tweet-card:hover { + border-color: var(--accent); +} + +.x-tweet-card:has(iframe) { + background: transparent; + border-color: transparent; +} + +/* Fallback styling when widgets.js is offline/blocked */ +.x-tweet-fallback { + padding: 1.25rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + font-family: inherit; + height: 100%; + justify-content: space-between; +} + +.x-tweet-fallback-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.x-tweet-author { + font-weight: 600; + color: #fff; + font-size: 0.9rem; +} + +.x-tweet-platform-icon { + font-size: 1.1rem; + font-weight: bold; + color: var(--text-muted); +} + +.x-tweet-fallback-body a { + color: var(--accent) !important; + text-decoration: none !important; + font-size: 0.85rem; + font-weight: 500; +} + +.x-tweet-fallback-body a:hover { + text-decoration: underline !important; +} + +.x-tweet-card .twitter-tweet { + margin: 0 !important; + width: 100% !important; +} + +/* Inline Code Syntax Highlighting styles */ +.tsl-identifier { + color: #ffffff !important; +} + +.tsl-identifier-builtin { + color: #9cdcfe !important; +} + +.tsl-namespace { + color: #4ec9b0 !important; + font-weight: 500; +} + +.tsl-function-builtin { + color: #4ec9b0 !important; + font-weight: normal; +} + +.tsl-function { + color: #dcdcaa !important; +} + +.tsl-bracket { + color: #ffd700 !important; +} + +.tsl-brace { + color: #da70d6 !important; +} + +.tsl-operator { + color: #d4d4d4 !important; +} + +/* Pre-loading state visibility controls */ +body.loading #h-resizer-container, +body.loading .sidebar, +body.loading .main-layout, +body.loading .app-header { + opacity: 0 !important; + pointer-events: none !important; +} + +#h-resizer-container, +.main-layout, +.app-header { + transition: opacity 0.5s ease; +} + +.sidebar { + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), margin-left 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s ease; +} + +/* Loading Screen Styling */ +.loading-screen { + position: fixed; + top: 0; + left: 0; + width: 100%; + width: 100dvw; + height: 100%; + height: 100dvh; + background: radial-gradient(circle at 50% 50%, #131720 0%, #14151a 50%, var(--bg-sidebar) 80%); + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + transition: opacity 0.5s ease; + overflow: hidden; +} + +.loading-screen::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 900px; + height: 900px; + margin-top: -450px; + margin-left: -450px; + border-radius: 50%; + background: radial-gradient(circle at 50% 50%, + rgba(0, 229, 255, 0.15) 0%, + rgba(147, 51, 234, 0.09) 35%, + rgba(59, 130, 246, 0.04) 60%, + transparent 75%); + pointer-events: none; + animation: forward-zoom 12s cubic-bezier(0.1, 0.7, 0.3, 1) infinite; +} + +.loading-spinner-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.75rem; + margin-top: -23px; +} + +.loading-logo-wrapper { + position: relative; + width: 125px; + height: 125px; + display: flex; + align-items: center; + justify-content: center; +} + +.loading-spinner { + animation: rotate 2s linear infinite; + position: absolute; + width: 100%; + height: 100%; + z-index: 1; +} + +.loading-spinner .path { + stroke-linecap: round; + animation: dash 1.5s ease-in-out infinite; +} + +.loading-logo { + width: 125px; + height: 125px; + position: relative; + z-index: 2; + display: flex; + align-items: center; + justify-content: center; + -webkit-mask: linear-gradient(90deg, rgba(0, 0, 0, 0.75) 35%, rgba(0, 0, 0, 1) 50%, rgba(0, 0, 0, 0.75) 65%); + -webkit-mask-size: 200% 100%; + mask: linear-gradient(90deg, rgba(0, 0, 0, 0.75) 35%, rgba(0, 0, 0, 1) 50%, rgba(0, 0, 0, 0.75) 65%); + mask-size: 200% 100%; + animation: shimmer-skeleton 2s linear infinite; +} + +.loading-logo svg { + width: 100%; + height: 100%; +} + +.loading-text-container { + position: relative; + display: flex; + flex-direction: column; + align-items: center; +} + +.loading-text { + font-family: 'Inter', sans-serif; + font-size: 1.15rem; + letter-spacing: 0.05em; + color: #fff; + -webkit-mask: linear-gradient(90deg, rgba(0, 0, 0, 0.75) 35%, rgba(0, 0, 0, 1) 50%, rgba(0, 0, 0, 0.75) 65%); + -webkit-mask-size: 200% 100%; + mask: linear-gradient(90deg, rgba(0, 0, 0, 0.75) 35%, rgba(0, 0, 0, 1) 50%, rgba(0, 0, 0, 0.75) 65%); + mask-size: 200% 100%; + animation: shimmer-skeleton 2s linear infinite; +} + +@keyframes shimmer-skeleton { + 0% { + -webkit-mask-position: 150% 0; + mask-position: 150% 0; + } + + 100% { + -webkit-mask-position: -50% 0; + mask-position: -50% 0; + } +} + +@keyframes forward-zoom { + 0% { + opacity: 0.1; + transform: scale(0.35); + } + + 40% { + opacity: 0.5; + } + + 70% { + opacity: 0.5; + } + + 100% { + opacity: 0.1; + transform: scale(2.8); + } +} + +.loading-text-light { + font-weight: 200; +} + +.loading-text-bold { + font-weight: 700; +} + +.loading-subtext { + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + margin-top: 0.35rem; + white-space: nowrap; + font-family: 'Inter', sans-serif; + font-size: 0.72rem; + font-weight: 500; + color: #b0b0c4; + letter-spacing: 0.12em; + text-transform: uppercase; + opacity: 0.85; +} + +@keyframes rotate { + 100% { + transform: rotate(360deg); + } +} + +@keyframes dash { + 0% { + stroke-dasharray: 1, 150; + stroke-dashoffset: 0; + } + + 50% { + stroke-dasharray: 90, 150; + stroke-dashoffset: -35; + } + + 100% { + stroke-dasharray: 90, 150; + stroke-dashoffset: -124; + } +} + +/* Mermaid Diagrams Centering & Styles */ +.mermaid { + display: flex; + justify-content: center; + align-items: center; + background: transparent !important; + border: none !important; + padding: 0.5rem 0 !important; + margin: 2rem 0; + width: 100%; + overflow-x: auto; +} + +.mermaid svg { + max-width: 100%; + height: auto; +} + +.mermaid, +.mermaid svg, +.mermaid .node, +.mermaid .cluster, +.mermaid text, +.mermaid tspan, +.mermaid span, +.mermaid div, +.mermaid p, +.mermaid label { + font-family: 'Inter', system-ui, -apple-system, sans-serif !important; + -webkit-font-smoothing: antialiased !important; + -moz-osx-font-smoothing: grayscale !important; +} + +.mermaid .node rect, +.mermaid .node polygon, +.mermaid .node circle { + fill: #202028 !important; + stroke: #38384a !important; + stroke-width: 1.2px !important; + rx: 8px; + ry: 8px; +} + +.mermaid .cluster rect { + fill: url(#cluster-radial-gradient) !important; + stroke: #2b2b38 !important; + stroke-width: 1.2px !important; + rx: 10px; + ry: 10px; +} + +/* Cluster Subgraph Title */ +.mermaid .cluster span.nodeLabel, +.mermaid .cluster-label span, +.mermaid .cluster text { + color: #00aaff !important; + fill: #00aaff !important; + font-weight: 700 !important; + font-size: 0.74rem !important; + text-transform: uppercase !important; + letter-spacing: 0.08em !important; + display: inline-block !important; + padding: 8px 16px 14px 16px !important; +} + +/* Node Label & Subtitles */ +.mermaid .node .label, +.mermaid .node foreignObject div, +.mermaid .node span.nodeLabel { + color: #9da0b2 !important; + fill: #9da0b2 !important; + font-weight: 400 !important; + font-size: 0.80rem !important; + letter-spacing: normal !important; + line-height: 1.4 !important; + padding: 0 !important; + margin: 0 !important; +} + +.mermaid .node b, +.mermaid .node strong { + color: #ffffff !important; + font-weight: 600 !important; + font-size: 0.90rem !important; + letter-spacing: -0.01em !important; + display: inline-block !important; + margin-bottom: 10px !important; +} + +.mermaid .node small { + color: #9da0b2 !important; + font-weight: 400 !important; + font-size: 0.78rem !important; + display: inline-block !important; + line-height: 1.8 !important; +} + +.mermaid .edgePath path.path { + stroke: #00aaff !important; + stroke-width: 1.75px !important; + opacity: 0.9; +} + +.mermaid .edgeLabel { + background: transparent !important; + border: none !important; +} + +.mermaid .edgeLabel div, +.mermaid .edgeLabel foreignObject { + background: transparent !important; + border: none !important; + padding: 0 !important; + margin: 0 !important; +} + +/* Hide empty edge labels */ +.mermaid .edgeLabel:empty, +.mermaid .edgeLabel span:empty, +.mermaid .edgeLabel span.edgeLabel:empty { + display: none !important; + border: none !important; + padding: 0 !important; + background: transparent !important; +} + +/* Edge Label badge (only when text exists) */ +.mermaid .edgeLabel span.edgeLabel:not(:empty), +.mermaid .edgeLabel span:not(:empty) { + background-color: #191921 !important; + color: #a5a5b8 !important; + fill: #a5a5b8 !important; + font-size: 0.68rem !important; + font-weight: 500 !important; + padding: 2px 7px !important; + border-radius: 4px !important; + border: 1px solid rgba(255, 255, 255, 0.1) !important; + display: inline-block !important; + line-height: 1.25 !important; + margin: 0 !important; +} + +/* Prevent double borders on nested spans */ +.mermaid .edgeLabel span span { + background: transparent !important; + border: none !important; + padding: 0 !important; +} + +.mermaid .edgeLabel rect { + fill: transparent !important; + stroke: none !important; +} + +.mermaid marker path { + fill: #00aaff !important; + stroke: #00aaff !important; +} + +.mermaid code { + background: rgba(0, 170, 255, 0.08) !important; + color: #9cdcfe !important; + font-family: 'Fira Code', monospace !important; + font-size: 0.88em !important; + font-weight: 500 !important; + padding: 2px 6px !important; + margin: 3px 2px !important; + border-radius: 4px !important; + border: 1px solid rgba(0, 170, 255, 0.25) !important; + display: inline-block !important; + white-space: nowrap !important; + vertical-align: middle !important; +} + +.mermaid code .tsl-param-type-string { + color: #ce9178 !important; +} + +.mermaid code .tsl-param-type-number { + color: #b5cea8 !important; +} + +.mermaid code .tsl-param-type-keyword { + color: #569cd6 !important; +} + +.mermaid code .tsl-function, +.mermaid code .tsl-function-builtin { + color: #dcdcaa !important; +} + +.mermaid code .tsl-operator, +.mermaid code .tsl-bracket, +.mermaid code .tsl-brace { + color: #d4d4d4 !important; +} + +.mermaid code .tsl-identifier { + color: #9cdcfe !important; +} \ No newline at end of file diff --git a/tsl/index.html b/tsl/index.html new file mode 100644 index 00000000000000..accaa77e1ba67b --- /dev/null +++ b/tsl/index.html @@ -0,0 +1,245 @@ + + + Tour of TSL - Interactive Guide + + + + + + + + + + + + + + + + + + + + + +
+
+
+ +
+ +
+
+ + + + +
+ +
+
+ + + +
+ + +
+
+

Tour of TSL

+ Three.js Shading Language + +
+
+ +
+ + + + +
+
+ + +
+ + +
+
+ +
+
+ +
+ +
+
+
+ + +
+
+ +
+
+
+ + + + +
+ +
+ + +
+ + +
+
+
+ +
+ +
+ + +
+
+
> CODE
+
+ + +
+
+
+
+ + +
+
+
> CONSOLE
+
+ + + +
+
+
+
+
+
+
+
+ + + + \ No newline at end of file diff --git a/tsl/js/Tour.js b/tsl/js/Tour.js new file mode 100644 index 00000000000000..a27f8e4a806220 --- /dev/null +++ b/tsl/js/Tour.js @@ -0,0 +1,2725 @@ +import * as THREE from 'three'; +import * as TSL from 'three/tsl'; + +import { Inspector } from 'three/addons/inspector/Inspector.js'; + +import { parseTour, parse, tokenizeCodeToElement } from './utils/MarkdownUtils.js'; +import { CodeRunner } from './code/CodeRunner.js'; +import { CodeCompiler } from './code/CodeCompiler.js'; +import { CodeEditor } from './editor/CodeEditor.js'; +import { SearchManager } from './managers/SearchManager.js'; +import { HistoryManager } from './managers/HistoryManager.js'; +import { LayoutManager } from './managers/LayoutManager.js'; +import { ConsoleManager } from './managers/ConsoleManager.js'; +import { PlaygroundManager } from './managers/PlaygroundManager.js'; +import { getSVG, compressString } from './utils/TourUtils.js'; +import mermaid from 'mermaid'; + +const MOBILE_BREAKPOINT = 768; + +let twttr; + +function getTwitterWidgets() { + + if ( twttr ) return twttr; + + twttr = ( function ( d, s, id ) { + + var js, fjs = d.getElementsByTagName( s )[ 0 ], + t = twttr || {}; + if ( d.getElementById( id ) ) return t; + js = d.createElement( s ); + js.id = id; + js.src = 'https://platform.twitter.com/widgets.js'; + fjs.parentNode.insertBefore( js, fjs ); + + t._e = []; + t.ready = function ( f ) { + + t._e.push( f ); + + }; + + return t; + + }( document, 'script', 'twitter-wjs' ) ); + + return twttr; + +} + +class Tour { + + constructor( title = 'Tour of *TSL*' ) { + + this.tourTitle = title; + this.pages = []; + this.pageTree = []; + this.currentPageIndex = 0; + + const initialHash = window.location.hash.substring( 1 ); + const isInitialPlayground = initialHash.startsWith( 'playground=' ) || initialHash.startsWith( 'playground/' ); + + this.isSidebarOpen = ( window.innerWidth >= MOBILE_BREAKPOINT ) && ! isInitialPlayground; + this.isEditorCollapsed = window.innerWidth < MOBILE_BREAKPOINT; + this.isPreviewVisible = window.innerWidth >= MOBILE_BREAKPOINT; + this.lastReaderPreviewState = window.innerWidth >= MOBILE_BREAKPOINT; + this.isPreviewMaximized = false; + this.lastContentWidth = '50%'; + this.runner = new CodeRunner(); + + this.renderer = null; + this._refreshPromise = null; + this.codeEditor = null; + this.debugCodeEditor = null; + this.readOnlyEditors = []; + this.isPlaygroundActive = false; + this.isContentRendered = false; + this.hasCriticalError = false; + this.refreshOnPageChange = true; + this.searchManager = new SearchManager( this ); + this.historyManager = new HistoryManager( this ); + this.layoutManager = new LayoutManager( this ); + this.consoleManager = new ConsoleManager( this ); + this.playgroundManager = new PlaygroundManager( this ); + + this.lastTourPageHash = ''; + this.debugStage = 'fragment'; + this.debugLanguage = 'WGSL'; + + this.dom = {}; + + this.animate = this.animate.bind( this ); + + mermaid.initialize( { + startOnLoad: false, + theme: 'base', + themeVariables: { + darkMode: true, + background: '#1e1e24', + mainBkg: '#2a2a33', + primaryColor: '#2a2a33', + primaryTextColor: '#f3f4f6', + primaryBorderColor: '#3f3f4e', + lineColor: '#00aaff', + secondaryColor: '#15151a', + tertiaryColor: '#1e1e24', + secondaryBorderColor: '#3f3f4e', + secondaryTextColor: '#d1d5db', + fontFamily: 'Inter, system-ui, -apple-system, sans-serif', + fontSize: '13px', + edgeLabelBackground: 'transparent', + clusterBkg: 'rgba(21, 21, 26, 0.65)', + clusterBorder: '#3f3f4e', + titleColor: '#00aaff', + nodeBorder: '#3f3f4e' + }, + flowchart: { + useMaxWidth: true, + htmlLabels: true, + curve: 'linear', + padding: 14, + nodeSpacing: 28, + rankSpacing: 34, + subGraphTitleMargin: { + top: 20, + bottom: 28 + } + } + } ); + + this.setTitle( title ); + + } + + setTitle( title ) { + + this.tourTitle = title; + + const parseParts = ( str ) => { + + const parts = []; + const regex = /\*([^*]+)\*/g; + let lastIndex = 0; + let match; + + while ( ( match = regex.exec( str ) ) !== null ) { + + if ( match.index > lastIndex ) { + + parts.push( { text: str.substring( lastIndex, match.index ), highlight: false } ); + + } + + parts.push( { text: match[ 1 ], highlight: true } ); + lastIndex = regex.lastIndex; + + } + + if ( lastIndex < str.length ) { + + parts.push( { text: str.substring( lastIndex ), highlight: false } ); + + } + + return parts; + + }; + + const parts = parseParts( this.tourTitle ); + + const cleanTitle = this.tourTitle.replace( /\*/g, '' ); + + const headerTitleEl = this.dom?.headerTitle || document.querySelector( '.header-title' ); + if ( headerTitleEl ) { + + headerTitleEl.textContent = ''; + parts.forEach( ( part ) => { + + const span = document.createElement( 'span' ); + span.className = part.highlight ? 'header-title-accent' : 'header-title-prefix'; + span.textContent = part.text; + headerTitleEl.appendChild( span ); + + } ); + + } + + const loadingTextEl = this.dom?.loadingText || document.querySelector( '.loading-text' ); + if ( loadingTextEl ) { + + loadingTextEl.textContent = ''; + parts.forEach( ( part ) => { + + const span = document.createElement( 'span' ); + span.className = part.highlight ? 'loading-text-bold' : 'loading-text-light'; + span.textContent = part.text; + loadingTextEl.appendChild( span ); + + } ); + + } + + document.title = `${cleanTitle} - Interactive Guide`; + + return this; + + } + + async load( url ) { + + try { + + const response = await fetch( url ); + const text = await response.text(); + const result = parseTour( text ); + this.pages = result.pages; + this.pageTree = result.pageTree; + + this.init(); + + } catch ( err ) { + + console.error( 'Error loading tour:', err ); + + } + + } + + init() { + + this.searchManager.buildIndex(); + + // Cache DOM Elements + this.dom = { + headerTitle: document.querySelector( '.header-title' ), + loadingText: document.querySelector( '.loading-text' ), + contentArea: document.getElementById( 'content-area' ), + codeContainer: document.getElementById( 'code-container' ), + previewContainer: document.getElementById( 'preview-container' ), + sidebar: document.getElementById( 'sidebar' ), + menuToggleMain: document.getElementById( 'menu-toggle-main' ), + headerSearchBtn: document.getElementById( 'header-search-btn' ), + menuToggleClose: document.getElementById( 'menu-toggle-close' ), + tocList: document.getElementById( 'toc-list' ), + contentCol: document.querySelector( '.content-col' ), + editorCol: document.querySelector( '.editor-col' ), + previewSection: document.querySelector( '.preview-section' ), + hResizerContainer: document.getElementById( 'h-resizer-container' ), + hResizer: document.getElementById( 'h-resizer' ), + hResizerToggle: document.getElementById( 'h-resizer-toggle' ), + vResizer: document.getElementById( 'v-resizer' ), + vResizerToggle: document.getElementById( 'v-resizer-toggle' ), + vResizerToggleInverted: document.getElementById( 'v-resizer-toggle-inverted' ), + headerEditorToggle: document.getElementById( 'header-editor-toggle' ), + headerPreviewToggle: document.getElementById( 'header-preview-toggle' ), + previewHide: document.getElementById( 'preview-hide' ), + previewFullscreen: document.getElementById( 'preview-fullscreen' ), + previewRefresh: document.getElementById( 'preview-refresh' ), + previewPlayground: document.getElementById( 'preview-playground' ), + previewCopy: document.getElementById( 'preview-copy' ), + editorConsole: document.getElementById( 'editor-console' ), + consoleHeader: document.getElementById( 'console-header' ), + consoleClearBtn: document.getElementById( 'console-clear-btn' ), + consoleCopyBtn: document.getElementById( 'console-copy-btn' ), + consoleToggleBtn: document.getElementById( 'console-toggle-btn' ), + consoleToggleIcon: document.getElementById( 'console-toggle-icon' ), + consoleErrorMessage: document.getElementById( 'console-error-message' ), + copyCodeBtnHeader: document.getElementById( 'copy-code-btn-header' ), + shareBtnHeader: document.getElementById( 'share-btn-header' ), + playgroundBtn: document.getElementById( 'playground-btn' ), + debugContainer: document.getElementById( 'debug-container' ), + debugEditorContainer: document.getElementById( 'debug-editor-container' ), + debugLanguageSelect: document.getElementById( 'debug-language-select' ), + debugStageSelect: document.getElementById( 'debug-stage-select' ) + }; + + // Create playground tabs bar and editor sub-container + const tabsBar = document.createElement( 'div' ); + tabsBar.id = 'playground-tabs-bar'; + tabsBar.className = 'playground-tabs-bar'; + tabsBar.style.display = 'none'; + + const editorSubContainer = document.createElement( 'div' ); + editorSubContainer.id = 'editor-sub-container'; + editorSubContainer.className = 'editor-sub-container'; + editorSubContainer.style.flex = '1'; + editorSubContainer.style.width = '100%'; + editorSubContainer.style.height = '100%'; + editorSubContainer.style.minHeight = '0'; + + this.dom.codeContainer.style.display = 'flex'; + this.dom.codeContainer.style.flexDirection = 'column'; + this.dom.codeContainer.style.overflow = 'hidden'; + + this.dom.codeContainer.appendChild( tabsBar ); + this.dom.codeContainer.appendChild( editorSubContainer ); + this.dom.tabsBar = tabsBar; + this.dom.editorSubContainer = editorSubContainer; + + // Create and insert search box in sidebar header + const sidebarHeader = this.dom.sidebar.querySelector( '.sidebar-header' ); + const searchContainer = document.createElement( 'div' ); + searchContainer.className = 'sidebar-search'; + searchContainer.innerHTML = ` + + + + `; + sidebarHeader.appendChild( searchContainer ); + + const suggestionContainer = document.createElement( 'div' ); + suggestionContainer.id = 'sidebar-search-suggestion'; + suggestionContainer.className = 'sidebar-search-suggestion'; + this.dom.sidebar.insertBefore( suggestionContainer, this.dom.sidebar.querySelector( '.sidebar-content' ) ); + this.dom.searchSuggestionContainer = suggestionContainer; + + this.dom.searchContainer = searchContainer; + this.dom.searchInput = document.getElementById( 'sidebar-search-input' ); + this.dom.searchClear = document.getElementById( 'sidebar-search-clear' ); + + this.createIcons( searchContainer ); + + this.openedViaHeaderSearch = false; + + + this.dom.debugLanguageSelect.onchange = () => { + + this.debugLanguage = this.dom.debugLanguageSelect.value; + this.updateDebugWGSL(); + + }; + + this.dom.debugStageSelect.onchange = () => { + + this.debugStage = this.dom.debugStageSelect.value; + this.updateDebugWGSL(); + + }; + + + + // Set Three.js release badge with prefix and dev suffix wrapped in spans + const releaseEl = document.getElementById( 'threejs-release' ); + if ( releaseEl ) { + + const revision = THREE.REVISION; + const isDev = revision.endsWith( 'dev' ); + const num = isDev ? revision.slice( 0, - 3 ) : revision; + + let html = `r${ num }`; + if ( isDev ) { + + html += 'dev'; + + } + + releaseEl.innerHTML = html; + + } + + // Initialize Lucide Icons + this.createIcons(); + + // Set initial state for console clear and copy buttons + this.consoleManager.updateConsoleButtonsState(); + + if ( window.innerWidth < MOBILE_BREAKPOINT ) { + + document.body.classList.add( 'preview-hidden' ); + this.dom.headerPreviewToggle.innerHTML = ''; + this.createIcons( this.dom.headerPreviewToggle ); + + } + + this.dom.consoleHeader.onclick = ( e ) => { + + if ( e.target.closest( '.console-header-actions' ) ) return; + this.toggleConsole(); + + }; + + this.dom.consoleClearBtn.onclick = ( e ) => { + + e.stopPropagation(); + this.clearConsole(); + + }; + + this.dom.consoleCopyBtn.onclick = ( e ) => { + + e.stopPropagation(); + this.copyConsole(); + + }; + + this.dom.consoleToggleBtn.onclick = ( e ) => { + + e.stopPropagation(); + this.toggleConsole(); + + }; + + this.setupTOC(); + this.toggleSidebar( this.isSidebarOpen ); + + // capture stack traces for nodes + //THREE.Node.captureStackTrace = true; + + // Setup 3D Preview + this.createRenderer(); + + // Hide inspector when in collapsed-workspace (.preview-box) mode + const updateInspectorVisibility = () => { + + if ( this.renderer && this.renderer.inspector ) { + + const isCollapsed = document.body.classList.contains( 'collapsed-workspace' ); + this.renderer.inspector.setVisible( ! isCollapsed ); + + } + + }; + + updateInspectorVisibility(); + + const layoutObserver = new MutationObserver( updateInspectorVisibility ); + layoutObserver.observe( document.body, { attributes: true, attributeFilter: [ 'class' ] } ); + + this.webGLRenderer = new THREE.WebGPURenderer( { forceWebGL: true } ); + this.webGLRenderer.debug.diagnostics.keywords = true; + + this.runner.setValue( 'renderer', this.renderer ); + this.runner.setImport( 'three', THREE ); + this.runner.setImport( 'three/tsl', TSL ); + + let resizeTimeout; + this.resizeObserver = new ResizeObserver( ( entries ) => { + + for ( const entry of entries ) { + + const width = Math.floor( entry.contentRect.width ); + const height = Math.floor( entry.contentRect.height ); + if ( width > 0 && height > 0 ) { + + cancelAnimationFrame( resizeTimeout ); + resizeTimeout = requestAnimationFrame( () => { + + this.renderer.setSize( width, height ); + this.runner.call( 'resize', width, height ); + + } ); + + } + + } + + } ); + this.resizeObserver.observe( this.dom.previewContainer ); + + // Window resize event handler + let wasMobile = window.innerWidth < MOBILE_BREAKPOINT; + this.onWindowResize = () => { + + if ( ! this.renderer ) return; + + const isMobile = window.innerWidth < MOBILE_BREAKPOINT; + + if ( isMobile !== wasMobile ) { + + wasMobile = isMobile; + + if ( isMobile ) { + + this.toggleSidebar( false ); + + } + + if ( this.isPlaygroundActive ) { + + this.isPlaygroundActive = false; + this.togglePlayground( true ); + + } + + } + + // Re-evaluate layouts on window resize to ensure correct width properties + const page = this.pages[ this.currentPageIndex ]; + if ( page && page.hasCode ) { + + if ( this.isEditorCollapsed ) { + + this.dom.contentCol.style.width = '100%'; + this.dom.contentCol.style.display = 'flex'; + if ( isMobile ) { + + this.dom.editorCol.style.width = '0%'; + + } else { + + this.dom.editorCol.style.width = ''; + + } + + this.setResizerToggleIcon( 'chevron-left' ); + + } else { + + if ( isMobile ) { + + this.dom.contentCol.style.width = '0%'; + this.dom.contentCol.style.display = 'none'; + this.dom.editorCol.style.width = '100%'; + + } else { + + this.dom.contentCol.style.width = this.lastContentWidth || '50%'; + this.dom.contentCol.style.display = 'flex'; + this.dom.editorCol.style.width = ''; + + } + + this.setResizerToggleIcon( 'chevron-right' ); + + } + + } + + }; + + window.addEventListener( 'resize', this.onWindowResize ); + + // Hook UI Actions + this.dom.menuToggleMain.onclick = () => { + + this.openedViaHeaderSearch = false; + this.toggleSidebar(); + + }; + + this.dom.menuToggleClose.onclick = () => { + + this.openedViaHeaderSearch = false; + this.toggleSidebar(); + + }; + + this.dom.headerSearchBtn.onclick = () => { + + this.openedViaHeaderSearch = true; + this.toggleSidebar( true ); + this.dom.searchInput.focus(); + + }; + + const updateSearchFocus = () => { + + const query = this.dom.searchInput.value; + const isInputFocused = ( document.activeElement === this.dom.searchInput ); + + if ( isInputFocused || query.trim().length > 0 ) { + + this.dom.searchContainer.classList.add( 'focused' ); + + } else { + + this.dom.searchContainer.classList.remove( 'focused' ); + + } + + if ( isInputFocused ) { + + this.dom.tocList.classList.add( 'search-focused' ); + + } else { + + this.dom.tocList.classList.remove( 'search-focused' ); + + } + + }; + + this.dom.searchContainer.onclick = () => { + + if ( ! this.dom.searchContainer.classList.contains( 'focused' ) ) { + + this.dom.searchInput.focus(); + + } + + }; + + this.dom.searchInput.onfocus = updateSearchFocus; + this.dom.searchInput.onblur = ( e ) => { + + updateSearchFocus(); + + if ( window.innerWidth < MOBILE_BREAKPOINT ) return; + + // Check if focus is moving outside the sidebar + const focusMovedOutside = e.relatedTarget && ! this.dom.sidebar.contains( e.relatedTarget ); + if ( this.openedViaHeaderSearch && this.dom.searchInput.value.trim().length === 0 && focusMovedOutside ) { + + this.toggleSidebar( false ); + + } + + }; + + this.dom.searchInput.onkeydown = ( e ) => { + + if ( e.key === 'Escape' ) { + + if ( this.dom.searchInput.value !== '' ) { + + this.dom.searchInput.value = ''; + this.dom.searchClear.style.display = 'none'; + this.searchManager.performSearch( '' ); + this.searchManager.updateHashWithSearch( '' ); + updateSearchFocus(); + + } else { + + this.dom.searchInput.blur(); + + } + + } + + if ( e.key === 'Enter' ) { + + const firstBtn = this.dom.tocList.querySelector( '.toc-btn' ); + if ( firstBtn ) { + + const firstPageId = firstBtn.getAttribute( 'data-page-id' ); + if ( firstPageId ) { + + e.preventDefault(); + + this.dom.searchInput.blur(); + + if ( window.location.hash === '#' + firstPageId ) { + + this.dom.searchInput.value = ''; + this.dom.searchClear.style.display = 'none'; + this.searchManager.performSearch( '' ); + this.searchManager.updateHashWithSearch( '' ); + updateSearchFocus(); + + } else { + + window.location.hash = firstPageId; + + } + + if ( window.innerWidth < MOBILE_BREAKPOINT ) { + + this.toggleSidebar( false ); + + } + + } + + } + + } + + }; + + this.dom.searchInput.oninput = () => { + + const query = this.dom.searchInput.value; + if ( query.trim().length > 0 ) { + + this.dom.searchClear.style.display = 'flex'; + + } else { + + this.dom.searchClear.style.display = 'none'; + + } + + this.searchManager.performSearch( query ); + this.searchManager.updateHashWithSearch( query ); + updateSearchFocus(); + + }; + + this.dom.searchClear.onclick = ( e ) => { + + e.stopPropagation(); + this.dom.searchInput.value = ''; + this.dom.searchClear.style.display = 'none'; + this.searchManager.performSearch( '' ); + this.searchManager.updateHashWithSearch( '' ); + updateSearchFocus(); + this.dom.searchInput.focus(); + + }; + + this.onDocumentPointerDown = ( e ) => { + + if ( window.innerWidth < MOBILE_BREAKPOINT && this.isSidebarOpen ) { + + const isClickInsideSidebar = this.dom.sidebar.contains( e.target ); + const isClickOnToggle = this.dom.menuToggleMain.contains( e.target ); + const isClickOnSearchToggle = this.dom.headerSearchBtn.contains( e.target ); + + if ( ! isClickInsideSidebar && ! isClickOnToggle && ! isClickOnSearchToggle ) { + + this.toggleSidebar( false ); + + } + + } + + // Relock embed overlays if user clicks outside of their respective preview areas + const unlockedOverlays = document.querySelectorAll( '.tsl-embed-lock-overlay.unlocked' ); + unlockedOverlays.forEach( ( overlay ) => { + + const previewEl = overlay.closest( '.tsl-embed-preview' ); + if ( previewEl && ! previewEl.contains( e.target ) ) { + + overlay.classList.remove( 'unlocked' ); + + } + + } ); + + }; + + document.addEventListener( 'pointerdown', this.onDocumentPointerDown ); + + this.onContentAreaScroll = () => { + + const unlockedOverlays = document.querySelectorAll( '.tsl-embed-lock-overlay.unlocked' ); + unlockedOverlays.forEach( ( overlay ) => { + + overlay.classList.remove( 'unlocked' ); + + } ); + + }; + + this.dom.contentArea.addEventListener( 'scroll', this.onContentAreaScroll ); + + this.dom.headerEditorToggle.onclick = () => { + + this.dom.hResizerToggle.click(); + + }; + + this.dom.shareBtnHeader.onclick = async () => { + + if ( ! this.isPlaygroundActive ) return; + + if ( this.playgroundManager.playgroundTabs && this.codeEditor ) { + + const activeTab = this.playgroundManager.playgroundTabs.find( t => t.name === this.playgroundManager.activePlaygroundTabName ); + if ( activeTab ) { + + activeTab.code = this.codeEditor.getValue(); + + } + + } + + const encoded = await compressString( JSON.stringify( { + tabs: this.playgroundManager.playgroundTabs + } ) ); + const release = THREE.RELEASE || THREE.REVISION; + const newHash = 'playground=' + encoded + ( release ? '&release=' + release : '' ); + window.location.hash = newHash; + + const shareUrl = window.location.href; + + navigator.clipboard.writeText( shareUrl ).then( () => { + + this.dom.shareBtnHeader.classList.add( 'success' ); + this.dom.shareBtnHeader.innerHTML = ''; + this.createIcons( this.dom.shareBtnHeader ); + + setTimeout( () => { + + this.dom.shareBtnHeader.classList.remove( 'success' ); + this.dom.shareBtnHeader.innerHTML = ''; + this.createIcons( this.dom.shareBtnHeader ); + + }, 2000 ); + + } ); + + }; + + this.dom.headerPreviewToggle.onclick = () => { + + this.isPreviewVisible = ! this.isPreviewVisible; + this.lastReaderPreviewState = this.isPreviewVisible; + document.body.classList.toggle( 'preview-hidden', ! this.isPreviewVisible ); + + if ( this.isPreviewVisible ) { + + this.dom.headerPreviewToggle.innerHTML = ''; + + + + } else { + + this.dom.headerPreviewToggle.innerHTML = ''; + + } + + this.createIcons( this.dom.headerPreviewToggle ); + + // Update inline code modifier active state styling when toggling preview visibility + const hash = window.location.hash.substring( 1 ); + const hashParts = hash.split( '&' ); + const activeNode = hashParts[ 1 ] || ''; + const page = this.pages[ this.currentPageIndex ]; + if ( page ) { + + const originalCode = ( page.codes && page.codes[ activeNode ] ) || page.code || ''; + const currentVal = this.codeEditor ? this.codeEditor.getValue() : ''; + const isCodeModified = ( currentVal.trim() !== originalCode.trim() ); + + const modifierButtons = document.querySelectorAll( '.code-modifier-inline-btn' ); + modifierButtons.forEach( btn => { + + const nodeName = btn.getAttribute( 'data-node' ); + if ( nodeName === activeNode && ! isCodeModified ) { + + btn.classList.add( 'active' ); + + } else { + + btn.classList.remove( 'active' ); + + } + + } ); + + } + + }; + + this.dom.previewHide.onclick = () => { + + this.dom.headerPreviewToggle.click(); + + }; + + this.dom.previewFullscreen.onclick = () => { + + this.isPreviewMaximized = ! this.isPreviewMaximized; + document.body.classList.toggle( 'preview-maximized', this.isPreviewMaximized ); + + if ( this.isPreviewMaximized ) { + + this.dom.previewFullscreen.innerHTML = ''; + + } else { + + this.dom.previewFullscreen.innerHTML = ''; + + } + + this.createIcons( this.dom.previewFullscreen ); + + }; + + this.dom.previewCopy.onclick = async () => { + + let code = ''; + if ( this.playgroundManager.playgroundTabs ) { + + const mainTab = this.playgroundManager.playgroundTabs.find( t => t.name === 'main' ); + if ( mainTab ) { + + code = mainTab.code; + + } + + } + + if ( ! code && this.codeEditor ) { + + code = this.codeEditor.getValue(); + + } + + // Ensure all virtual tabs are in this.runner.scripts before compiling + if ( this.playgroundManager.playgroundTabs ) { + + this.playgroundManager.playgroundTabs.forEach( tab => { + + if ( tab.name !== 'main' ) { + + this.runner.scripts[ tab.name ] = { + url: null, + text: tab.code, + instance: this.runner.scripts[ tab.name ] ? this.runner.scripts[ tab.name ].instance : null, + promise: this.runner.scripts[ tab.name ] ? this.runner.scripts[ tab.name ].promise : null + }; + + } + + } ); + + } + + const compiler = new CodeCompiler(); + const compiledCode = await compiler.compile( code, this.runner.scripts ); + + navigator.clipboard.writeText( compiledCode ).then( () => { + + this.dom.previewCopy.classList.add( 'success' ); + this.dom.previewCopy.innerHTML = ''; + this.createIcons( this.dom.previewCopy ); + + setTimeout( () => { + + this.dom.previewCopy.classList.remove( 'success' ); + this.dom.previewCopy.innerHTML = ''; + + }, 2000 ); + + } ); + + }; + + this.dom.previewRefresh.onclick = async () => { + + await this.refresh(); + + }; + + this.dom.previewPlayground.onclick = () => { + + this.dom.playgroundBtn.click(); + + }; + + this.onWindowHashChange = () => { + + const hash = window.location.hash.substring( 1 ); + if ( hash.startsWith( 'playground=' ) || hash.startsWith( 'playground/' ) ) { + + this.playgroundManager.loadPlaygroundFromHash( hash ); + return; + + } + + if ( this.isPlaygroundActive ) { + + this.playgroundManager.togglePlayground( false ); + + } + + this.lastTourPageHash = hash; + + const hashParts = hash.split( '&' ); + const pageId = hashParts[ 0 ]; + + let selectedNode = ''; + for ( let i = 1; i < hashParts.length; i ++ ) { + + const part = hashParts[ i ]; + if ( ! part.startsWith( 'q=' ) ) { + + selectedNode = part; + + } + + } + + const targetIndex = this.pages.findIndex( p => p.id === pageId ); + + if ( targetIndex !== - 1 ) { + + const shouldScroll = targetIndex !== this.currentPageIndex; + this.renderPage( targetIndex, selectedNode, shouldScroll ); + + } else { + + this.renderPage( 0 ); + history.replaceState( null, null, ' ' ); + + } + + this.searchManager.restoreSearchFromHash( hash ); + + }; + + window.addEventListener( 'hashchange', this.onWindowHashChange ); + + // Resizer Listeners + this.layoutManager.setupResizer(); + + // Code Editor Setup + this.codeEditor = new CodeEditor( { + container: this.dom.editorSubContainer, + value: this.pages[ 0 ].code + } ); + + this.debugCodeEditor = new CodeEditor( { + container: this.dom.debugEditorContainer, + value: 'No shader generated yet...', + readOnly: true, + language: 'wgsl' + } ); + + let timeout; + this.codeEditor.addEventListener( 'change', async ( event ) => { + + const currentCode = event.value; + + if ( this.isPlaygroundActive ) { + + const activeTab = this.playgroundManager.playgroundTabs.find( t => t.name === this.playgroundManager.activePlaygroundTabName ); + if ( activeTab ) { + + activeTab.code = currentCode; + + } + + const encoded = await compressString( JSON.stringify( { + tabs: this.playgroundManager.playgroundTabs + } ) ); + const release = THREE.RELEASE || THREE.REVISION; + const newHash = 'playground=' + encoded + ( release ? '&release=' + release : '' ); + window.location.hash = newHash; + + } + + const page = this.pages[ this.currentPageIndex ]; + if ( page ) { + + let activeNode = page.defaultNode || ''; + if ( this.isPlaygroundActive ) { + + if ( this.lastTourPageHash ) { + + const lastHashParts = this.lastTourPageHash.split( '&' ); + if ( lastHashParts[ 1 ] ) { + + activeNode = lastHashParts[ 1 ]; + + } + + } + + } else { + + const currentHash = window.location.hash.substring( 1 ); + activeNode = currentHash.split( '&' )[ 1 ] || page.defaultNode; + + } + + if ( activeNode && page.codes && page.codes[ activeNode ] !== undefined ) { + + if ( ! page.modifiedCodes ) page.modifiedCodes = {}; + page.modifiedCodes[ activeNode ] = currentCode; + + } else { + + page.modifiedCode = currentCode; + + } + + const originalCode = ( activeNode && page.codes && page.codes[ activeNode ] !== undefined ) + ? page.codes[ activeNode ] + : page.code; + + const isModified = ( currentCode !== originalCode ); + + const activeButtons = this.dom.contentArea.querySelectorAll( '.code-modifier-inline-btn' ); + activeButtons.forEach( btn => { + + const btnNode = btn.getAttribute( 'data-node' ); + if ( btnNode === activeNode && ! isModified ) { + + btn.classList.add( 'active' ); + + } else { + + btn.classList.remove( 'active' ); + + } + + } ); + + } + + clearTimeout( timeout ); + timeout = setTimeout( async () => { + + if ( this.hasCriticalError ) { + + this.hasCriticalError = false; + await this.refresh(); + + } else { + + this.renderer.setAnimationLoop( null ); + + try { + + if ( this.isPlaygroundActive ) { + + this.runPlayground(); + + } else { + + await this.runner.run( currentCode ); + + } + + } finally { + + this.renderer.setAnimationLoop( this.animate ); + + } + + } + + }, 500 ); + + } ); + + this.codeEditor.addEventListener( 'init', () => { + + this.dom.playgroundBtn.onclick = async () => { + + if ( this.isPlaygroundActive ) { + + window.location.hash = this.lastTourPageHash || this.pages[ 0 ].id; + + } else { + + const activePage = this.pages[ this.currentPageIndex ]; + let currentCode = ''; + + if ( activePage && ! activePage.hasCode ) { + + currentCode = '// No example available.\nimport \'scenes/empty\';\n'; + + } else { + + currentCode = this.codeEditor ? this.codeEditor.getValue() : ''; + + } + + const encoded = await compressString( currentCode ); + const release = THREE.RELEASE || THREE.REVISION; + const newHash = 'playground=' + encoded + ( release ? '&release=' + release : '' ); + window.location.hash = newHash; + + } + + }; + + const initialHash = window.location.hash.substring( 1 ); + if ( initialHash.startsWith( 'playground=' ) || initialHash.startsWith( 'playground/' ) ) { + + this.playgroundManager.loadPlaygroundFromHash( initialHash ); + + } else { + + const hashParts = initialHash.split( '&' ); + const pageId = hashParts[ 0 ]; + + let selectedNode = ''; + for ( let i = 1; i < hashParts.length; i ++ ) { + + const part = hashParts[ i ]; + if ( ! part.startsWith( 'q=' ) ) { + + selectedNode = part; + + } + + } + + const initialIndex = this.pages.findIndex( p => p.id === pageId ); + + if ( initialIndex !== - 1 ) { + + this.renderPage( initialIndex, selectedNode ); + + } else { + + this.renderPage( 0 ); + + } + + this.searchManager.restoreSearchFromHash( initialHash ); + + } + + this.setTitle( this.tourTitle ); + + document.body.classList.remove( 'loading' ); + const loadingScreen = document.getElementById( 'loading-screen' ); + if ( loadingScreen ) { + + loadingScreen.style.opacity = '0'; + setTimeout( () => { + + loadingScreen.remove(); + + }, 500 ); + + } + + } ); + + } + + + + renderPage( index, activeNodeName = '', shouldScrollToTop = true ) { + + if ( index < 0 || index >= this.pages.length ) return; + + this.isContentRendered = true; + this.openedViaHeaderSearch = false; + + if ( this.currentPageIndex !== index ) { + + const prevPage = this.pages[ this.currentPageIndex ]; + if ( prevPage ) { + + delete prevPage.modifiedCode; + delete prevPage.modifiedCodes; + + } + + } + + this.currentPageIndex = index; + const page = this.pages[ index ]; + + this.lastTourPageHash = page.id + ( activeNodeName ? '&' + activeNodeName : '' ); + + // Reset preview maximized state on page transitions + this.isPreviewMaximized = false; + document.body.classList.remove( 'preview-maximized' ); + this.dom.previewFullscreen.innerHTML = ''; + this.createIcons( this.dom.previewFullscreen ); + + // Resolve page code from the active node modifier, falling back to primary code + const activeNodeForCode = activeNodeName || page.defaultNode; + const originalPageCode = ( activeNodeForCode && page.codes && page.codes[ activeNodeForCode ] !== undefined ) + ? page.codes[ activeNodeForCode ] + : page.code; + + let pageCode = originalPageCode; + let isModified = false; + if ( activeNodeForCode && page.modifiedCodes && page.modifiedCodes[ activeNodeForCode ] !== undefined ) { + + pageCode = page.modifiedCodes[ activeNodeForCode ]; + isModified = ( pageCode !== originalPageCode ); + + } else if ( ! activeNodeForCode && page.modifiedCode !== undefined ) { + + pageCode = page.modifiedCode; + isModified = ( pageCode !== originalPageCode ); + + } + + // Render HTML content + let headerHTML = ''; + if ( page.path && page.path.length > 0 ) { + + const segments = page.path.map( segment => { + + const targetPage = this.pages.find( p => p.title === segment ); + + if ( targetPage ) { + + return ` + ${segment} + + `; + + } else { + + return ` + ${segment} + + `; + + } + + } ).join( '' ); + + headerHTML = ``; + + } + + const description = page.description; + + this.dom.contentArea.innerHTML = '
' + + '' + + headerHTML + parse( description ) + '
'; + + mermaid.run( { + querySelector: '.mermaid' + } ).then( () => { + + this.dom.contentArea.querySelectorAll( '.mermaid svg' ).forEach( ( svg ) => { + + let defs = svg.querySelector( 'defs' ); + if ( ! defs ) { + + defs = document.createElementNS( 'http://www.w3.org/2000/svg', 'defs' ); + svg.insertBefore( defs, svg.firstChild ); + + } + + if ( ! svg.querySelector( '#cluster-radial-gradient' ) ) { + + const grad = document.createElementNS( 'http://www.w3.org/2000/svg', 'radialGradient' ); + grad.setAttribute( 'id', 'cluster-radial-gradient' ); + grad.setAttribute( 'cx', '50%' ); + grad.setAttribute( 'cy', '100%' ); + grad.setAttribute( 'r', '80%' ); + grad.setAttribute( 'fx', '50%' ); + grad.setAttribute( 'fy', '100%' ); + grad.innerHTML = ` + + + + `; + defs.appendChild( grad ); + + } + + svg.querySelectorAll( '.cluster rect' ).forEach( ( rect ) => { + + rect.setAttribute( 'fill', 'url(#cluster-radial-gradient)' ); + + } ); + + } ); + + this.dom.contentArea.querySelectorAll( '.mermaid .edgeLabel' ).forEach( ( el ) => { + + if ( ! el.textContent.trim() ) { + + el.style.display = 'none'; + + } + + } ); + + this.dom.contentArea.querySelectorAll( '.mermaid code' ).forEach( ( el ) => { + + tokenizeCodeToElement( el.textContent, el ); + + } ); + + } ).catch( err => console.error( 'Mermaid render error:', err ) ); + + getTwitterWidgets().ready( ( twttr ) => { + + twttr.widgets.load( this.dom.contentArea ); + + } ); + + if ( shouldScrollToTop ) { + + this.dom.contentArea.scrollTo( 0, 0 ); + + } + + + + // Append floating navigation buttons at the bottom of content + const navDiv = document.createElement( 'div' ); + navDiv.className = 'floating-nav-container'; + + const prevButton = document.createElement( 'button' ); + prevButton.className = 'floating-nav-btn prev'; + if ( this.currentPageIndex === 0 ) prevButton.classList.add( 'disabled' ); + prevButton.innerHTML = 'Previous'; + prevButton.onclick = () => { + + if ( this.currentPageIndex > 0 ) { + + window.location.hash = this.pages[ this.currentPageIndex - 1 ].id; + + } + + }; + + const nextButton = document.createElement( 'button' ); + nextButton.className = 'floating-nav-btn next'; + if ( this.currentPageIndex === this.pages.length - 1 ) nextButton.classList.add( 'disabled' ); + nextButton.innerHTML = 'Next'; + nextButton.onclick = () => { + + if ( this.currentPageIndex < this.pages.length - 1 ) { + + window.location.hash = this.pages[ this.currentPageIndex + 1 ].id; + + } else { + + alert( 'You have completed the tour!' ); + + } + + }; + + navDiv.appendChild( prevButton ); + navDiv.appendChild( nextButton ); + this.dom.contentArea.querySelector( 'div' ).appendChild( navDiv ); + + // Handle virtual links in the rendered HTML + const links = this.dom.contentArea.querySelectorAll( 'a' ); + links.forEach( link => { + + const href = link.getAttribute( 'href' ); + if ( href && href.startsWith( '#' ) ) { + + link.className = 'nav-link'; + link.onclick = ( e ) => { + + e.preventDefault(); + const targetId = href.substring( 1 ); + window.location.hash = targetId; + + }; + + } + + } ); + + // Inject circular play buttons inside tags + const codeNodes = this.dom.contentArea.querySelectorAll( 'code[name]' ); + codeNodes.forEach( codeTag => { + + const nodeName = codeTag.getAttribute( 'name' ); + if ( nodeName ) { + + const textVal = codeTag.textContent.trim(); + codeTag.textContent = textVal; + + const button = document.createElement( 'button' ); + button.className = 'code-modifier-inline-btn'; + button.setAttribute( 'data-node', nodeName ); + button.innerHTML = ''; + + codeTag.appendChild( button ); + + } + + } ); + + // Initialize Read-Only Monaco Editors for inline ```js blocks + this.readOnlyEditors.forEach( editor => editor.dispose() ); + + this.readOnlyEditors = []; + + const jsBlocks = this.dom.contentArea.querySelectorAll( 'pre code.language-js' ); + jsBlocks.forEach( ( block ) => { + + const codeText = block.textContent; + const pre = block.parentElement; + const subContainer = document.createElement( 'div' ); + subContainer.className = 'inline-code-editor-container'; + + const lines = codeText.trim().split( '\n' ).length; + subContainer.style.height = ( lines * 19 + 24 ) + 'px'; + + pre.replaceWith( subContainer ); + + const readOnlyEditor = new CodeEditor( { + container: subContainer, + value: codeText.trim(), + readOnly: true, + scrollable: false + } ); + + this.readOnlyEditors.push( readOnlyEditor ); + + } ); + + // Update Code Editor + if ( this.codeEditor ) { + + this.codeEditor.setValue( pageCode ); + + const hash = window.location.hash.substring( 1 ); + const isInitialPlayground = hash.startsWith( 'playground=' ) || hash.startsWith( 'playground/' ); + + if ( ! this.isPlaygroundActive && ! isInitialPlayground && ! page.hasEmbed ) { + + if ( this.refreshOnPageChange ) { + + this.refresh(); + + } else { + + if ( this.renderer && this.renderer.domElement.parentElement !== this.dom.previewContainer ) { + + this.dom.previewContainer.appendChild( this.renderer.domElement ); + + } + + this.runner.run( pageCode ); + + } + + } + + } + + // Handle tsl:embed blocks + this.resizeObserver.disconnect(); + + const embedContainers = this.dom.contentArea.querySelectorAll( '.tsl-embed-container' ); + if ( embedContainers.length > 0 ) { + + if ( ! page.modifiedEmbeds ) { + + page.modifiedEmbeds = []; + + } + + embedContainers.forEach( ( container ) => { + + const index = parseInt( container.getAttribute( 'data-index' ) ); + const originalEmbedCode = page.embeds[ index ]; + const codeText = ( page.modifiedEmbeds[ index ] !== undefined ) + ? page.modifiedEmbeds[ index ] + : originalEmbedCode; + + container.innerHTML = ` +
+ + +
+
+ +
+
+
+
+ `; + + this.createIcons( container ); + + const previewEl = container.querySelector( '.tsl-embed-preview' ); + const codeEl = container.querySelector( '.tsl-embed-code' ); + const pgBtn = container.querySelector( '.embed-playground-btn' ); + const expandBtn = container.querySelector( '.embed-expand-btn' ); + const lockOverlay = container.querySelector( '.tsl-embed-lock-overlay' ); + + // Create CodeEditor for the code section + const inlineEditor = new CodeEditor( { + container: codeEl, + value: codeText, + readOnly: false, + scrollable: false + } ); + this.readOnlyEditors.push( inlineEditor ); + + expandBtn.onclick = () => { + + previewEl.classList.toggle( 'maximized' ); + const isMaximized = previewEl.classList.contains( 'maximized' ); + + if ( isMaximized ) { + + expandBtn.innerHTML = ''; + + } else { + + expandBtn.innerHTML = ''; + + } + + this.createIcons( expandBtn ); + + }; + + lockOverlay.onclick = ( e ) => { + + e.stopPropagation(); + lockOverlay.classList.add( 'unlocked' ); + + }; + + pgBtn.onclick = async () => { + + const currentVal = inlineEditor.getValue(); + const encoded = await compressString( currentVal ); + const release = THREE.RELEASE || THREE.REVISION; + const newHash = 'playground=' + encoded + ( release ? '&release=' + release : '' ); + window.location.hash = newHash; + + }; + + let embedTimeout; + inlineEditor.addEventListener( 'change', ( event ) => { + + const currentEmbedCode = event.value; + page.modifiedEmbeds[ index ] = currentEmbedCode; + + if ( index === 0 ) { + + clearTimeout( embedTimeout ); + embedTimeout = setTimeout( () => { + + this.runner.run( currentEmbedCode ); + + }, 500 ); + + } + + } ); + + if ( index === 0 ) { + + // Append renderer's canvas + previewEl.appendChild( this.renderer.domElement ); + this.renderer.setSize( previewEl.clientWidth, previewEl.clientHeight ); + + // Run the code + this.runner.run( codeText ); + + // Observe client size changes to resize the canvas + this.resizeObserver.observe( previewEl ); + + } + + } ); + + } else { + + // Observe standard previewContainer if page hasCode + if ( page.hasCode || this.isPlaygroundActive ) { + + this.resizeObserver.observe( this.dom.previewContainer ); + + } + + } + + const copyMarkdown = ( btn ) => { + + const title = page.title; + const breadcrumbs = page.path ? page.path.join( ' > ' ) + ' > ' + title : title; + const currentCode = ( page.hasCode && this.codeEditor ) ? this.codeEditor.getValue() : ( page.hasCode ? page.code : '' ); + let markdownToCopy = `# ${breadcrumbs}\n\n${page.description}`; + if ( page.hasCode && currentCode ) { + + markdownToCopy += `\n\n## Code Example\n\`\`\`javascript\n${currentCode}\n\`\`\``; + + } + + navigator.clipboard.writeText( markdownToCopy ).then( () => { + + btn.classList.add( 'success' ); + const isHeaderBtn = btn.id === 'copy-code-btn-header'; + const size = isHeaderBtn ? '1.25rem' : '1rem'; + btn.innerHTML = ``; + this.createIcons( btn ); + + setTimeout( () => { + + btn.classList.remove( 'success' ); + btn.innerHTML = ``; + this.createIcons( btn ); + + }, 2000 ); + + } ); + + }; + + const copyCodeBtn = document.getElementById( 'copy-code-btn' ); + copyCodeBtn.onclick = () => copyMarkdown( copyCodeBtn ); + + const copyCodeBtnHeader = document.getElementById( 'copy-code-btn-header' ); + copyCodeBtnHeader.onclick = () => copyMarkdown( copyCodeBtnHeader ); + + // Bind Welcome page interactive normal space buttons + const modifierButtons = this.dom.contentArea.querySelectorAll( '.code-modifier-inline-btn' ); + + let activeNode = activeNodeName || page.defaultNode; + if ( ! activeNode ) { + + const currentCode = this.codeEditor ? this.codeEditor.getValue() : page.code; + for ( const btn of modifierButtons ) { + + const nodeName = btn.getAttribute( 'data-node' ); + if ( nodeName && new RegExp( '\\b' + nodeName + '\\b' ).test( currentCode ) ) { + + activeNode = nodeName; + break; + + } + + } + + } + + modifierButtons.forEach( btn => { + + const nodeName = btn.getAttribute( 'data-node' ); + if ( nodeName === activeNode && ! isModified ) { + + btn.classList.add( 'active' ); + + } else { + + btn.classList.remove( 'active' ); + + } + + const codeParent = btn.closest( 'code' ); + const clickTarget = codeParent || btn; + clickTarget.onclick = () => { + + if ( nodeName ) { + + const newHash = `${page.id}&${nodeName}`; + const isAlreadyActiveHash = ( window.location.hash === `#${newHash}` ); + + // On mobile or collapsed reading mode, show preview if hidden when clicking a code modifier option (only if not already active) + if ( ! isAlreadyActiveHash && ( window.innerWidth < MOBILE_BREAKPOINT || this.isEditorCollapsed ) && ! this.isPreviewVisible ) { + + this.isPreviewVisible = true; + this.lastReaderPreviewState = true; + document.body.classList.remove( 'preview-hidden' ); + this.dom.headerPreviewToggle.innerHTML = ''; + this.createIcons( this.dom.headerPreviewToggle ); + + } + + if ( isAlreadyActiveHash ) { + + if ( window.innerWidth < MOBILE_BREAKPOINT || this.isEditorCollapsed ) { + + if ( this.isPreviewVisible ) { + + // Reset code and hide preview + this.resetToOriginalCode( nodeName ); + this.isPreviewVisible = false; + this.lastReaderPreviewState = false; + document.body.classList.add( 'preview-hidden' ); + this.dom.headerPreviewToggle.innerHTML = ''; + this.createIcons( this.dom.headerPreviewToggle ); + + modifierButtons.forEach( b => b.classList.remove( 'active' ) ); + + } else { + + // Show preview again and restore active highlight + this.isPreviewVisible = true; + this.lastReaderPreviewState = true; + document.body.classList.remove( 'preview-hidden' ); + this.dom.headerPreviewToggle.innerHTML = ''; + this.createIcons( this.dom.headerPreviewToggle ); + modifierButtons.forEach( btn => { + + const btnNode = btn.getAttribute( 'data-node' ); + if ( btnNode === nodeName ) { + + btn.classList.add( 'active' ); + + } + + } ); + + + + } + + } else { + + // Desktop expanded resets default code + this.resetToOriginalCode( nodeName ); + + } + + } else { + + window.location.hash = newHash; + + } + + } + + }; + + } ); + + this.createIcons( this.dom.contentArea ); + + // Auto-scroll when user clicks to expand API class accordions to frame them in view + const apiAccordions = this.dom.contentArea.querySelectorAll( '.tsl-api-class-accordion, .tsl-api-inherited-accordion' ); + apiAccordions.forEach( ( details ) => { + + const summary = details.querySelector( 'summary' ); + if ( summary ) { + + summary.addEventListener( 'click', () => { + + // If currently closed, it will open after click + const willOpen = ! details.open; + if ( willOpen ) { + + setTimeout( () => { + + const contentRect = this.dom.contentArea.getBoundingClientRect(); + const detailsRect = details.getBoundingClientRect(); + const currentScrollTop = this.dom.contentArea.scrollTop; + + let targetOffset; + + if ( detailsRect.height < contentRect.height ) { + + // Center vertically in viewport if the accordion fits + const centerMargin = ( contentRect.height - detailsRect.height ) / 2; + targetOffset = detailsRect.top - contentRect.top + currentScrollTop - centerMargin; + + } else { + + // Align to the top with a 20px padding if larger than viewport + targetOffset = detailsRect.top - contentRect.top + currentScrollTop - 20; + + } + + this.dom.contentArea.scrollTo( { + top: Math.max( 0, targetOffset ), + behavior: 'smooth' + } ); + + }, 60 ); + + } + + } ); + + } + + } ); + + // Hide/Show layout division depending on whether the page contains a TSL code example + const hash = window.location.hash.substring( 1 ); + const isInitialPlayground = hash.startsWith( 'playground=' ) || hash.startsWith( 'playground/' ); + + if ( ( ! page.hasCode || page.hasEmbed ) && ! this.isPlaygroundActive && ! isInitialPlayground ) { + + this.dom.contentCol.style.width = '100%'; + this.dom.contentCol.style.display = 'flex'; + this.dom.editorCol.style.display = 'none'; + this.dom.hResizer.style.display = 'none'; + this.dom.hResizerContainer.style.display = 'none'; + document.body.classList.remove( 'collapsed-workspace' ); + this.dom.headerEditorToggle.style.display = 'none'; + this.dom.headerPreviewToggle.style.display = 'none'; + this.dom.copyCodeBtnHeader.style.display = 'none'; + + } else { + + this.dom.hResizer.style.display = 'block'; + this.dom.hResizerContainer.style.display = 'flex'; + this.dom.headerEditorToggle.style.display = 'flex'; + this.dom.headerPreviewToggle.style.display = 'flex'; + this.dom.copyCodeBtnHeader.style.display = 'flex'; + if ( this.isEditorCollapsed ) { + + this.dom.contentCol.style.width = '100%'; + this.dom.contentCol.style.display = 'flex'; + if ( window.innerWidth < MOBILE_BREAKPOINT ) { + + this.dom.editorCol.style.width = '0%'; + + } else { + + this.dom.editorCol.style.width = ''; + + } + + this.setResizerToggleIcon( 'chevron-left' ); + this.dom.editorCol.style.display = 'flex'; + document.body.classList.add( 'collapsed-workspace' ); + + } else { + + if ( window.innerWidth < MOBILE_BREAKPOINT ) { + + this.dom.contentCol.style.width = '0%'; + this.dom.contentCol.style.display = 'none'; + this.dom.editorCol.style.width = '100%'; + + } else { + + this.dom.contentCol.style.width = this.lastContentWidth || '50%'; + this.dom.contentCol.style.display = 'flex'; + this.dom.editorCol.style.width = ''; + + } + + this.setResizerToggleIcon( 'chevron-right' ); + this.dom.editorCol.style.display = 'flex'; + document.body.classList.remove( 'collapsed-workspace' ); + + } + + } + + // Update UI State + this.updateUI(); + + this.searchManager.scrollToSearchMatch(); + + } + + updateUI() { + + const activePage = this.pages[ this.currentPageIndex ]; + + // Show/hide header buttons depending on hasCode or isPlaygroundActive + const isMobile = window.innerWidth < MOBILE_BREAKPOINT; + const showHeaderToggles = activePage && ( activePage.hasCode || this.isPlaygroundActive ); + + let showEditorToggle = showHeaderToggles; + if ( isMobile && this.isPlaygroundActive ) { + + showEditorToggle = false; + + } + + this.dom.headerEditorToggle.style.display = showEditorToggle ? 'flex' : 'none'; + this.dom.headerPreviewToggle.style.display = showHeaderToggles ? 'flex' : 'none'; + this.dom.copyCodeBtnHeader.style.display = ( showHeaderToggles && ! this.isPlaygroundActive ) ? 'flex' : 'none'; + this.dom.shareBtnHeader.style.display = this.isPlaygroundActive ? 'flex' : 'none'; + + // Manage hResizer display + if ( isMobile && this.isPlaygroundActive ) { + + this.dom.hResizer.style.display = 'none'; + this.dom.hResizerContainer.style.display = 'none'; + + } else { + + if ( activePage ) { + + const hash = window.location.hash.substring( 1 ); + const isInitialPlayground = hash.startsWith( 'playground=' ) || hash.startsWith( 'playground/' ); + if ( ! activePage.hasCode && ! this.isPlaygroundActive && ! isInitialPlayground ) { + + this.dom.hResizer.style.display = 'none'; + this.dom.hResizerContainer.style.display = 'none'; + + } else { + + this.dom.hResizer.style.display = 'block'; + this.dom.hResizerContainer.style.display = 'flex'; + + } + + } + + } + + // TOC Active State + const tocItems = this.dom.tocList.querySelectorAll( '.toc-btn' ); + tocItems.forEach( ( btn ) => { + + const pageId = btn.getAttribute( 'data-page-id' ); + if ( ! this.isPlaygroundActive && pageId && pageId === activePage.id ) { + + btn.classList.add( 'active' ); + + // Automatically expand parent categories/folders if they are collapsed + let parent = btn.parentElement; + while ( parent && parent !== this.dom.tocList ) { + + if ( parent.classList.contains( 'toc-category-container' ) ) { + + parent.classList.remove( 'collapsed' ); + + } + + parent = parent.parentElement; + + } + + // Scroll the active item into view within the sidebar + if ( ! this.dom.searchInput.value.trim() ) { + + setTimeout( () => { + + btn.scrollIntoView( { behavior: 'smooth', block: 'center' } ); + + }, 50 ); + + } + + } else { + + btn.classList.remove( 'active' ); + + } + + } ); + + this.renderPlaygroundTabs(); + + } + + setupTOC( tree = this.pageTree, featuredPage = null, suggestion = null ) { + + if ( suggestion ) { + + this.dom.searchSuggestionContainer.textContent = 'Did you mean: '; + const link = document.createElement( 'a' ); + link.href = '#'; + link.className = 'search-suggestion-link'; + link.textContent = suggestion; + link.onclick = ( e ) => { + + e.preventDefault(); + this.dom.searchInput.value = suggestion; + this.searchManager.performSearch( suggestion ); + this.searchManager.updateHashWithSearch( suggestion ); + this.dom.searchInput.focus(); + + }; + + this.dom.searchSuggestionContainer.appendChild( link ); + this.dom.searchSuggestionContainer.appendChild( document.createTextNode( '?' ) ); + this.dom.searchSuggestionContainer.style.display = 'flex'; + + } else { + + this.dom.searchSuggestionContainer.textContent = ''; + this.dom.searchSuggestionContainer.style.display = 'none'; + + } + + this.dom.tocList.textContent = ''; + + if ( tree.length === 0 && ! featuredPage && this.dom.searchInput.value.trim().length > 0 ) { + + const noResults = document.createElement( 'div' ); + noResults.className = 'toc-no-results'; + noResults.innerText = 'No results found'; + this.dom.tocList.appendChild( noResults ); + return; + + } + + // Helper function to recursively generate the DOM for a node + const createTOCNode = ( node, level = 0 ) => { + + if ( node.isFolder ) { + + // Category/Folder container + const container = document.createElement( 'div' ); + container.className = 'toc-category-container'; + const btn = document.createElement( 'button' ); + btn.className = 'toc-folder-btn'; + if ( level > 0 ) { + + btn.classList.add( 'nested-folder' ); + + } + + const titleSpan = document.createElement( 'span' ); + titleSpan.style.paddingLeft = `${ level * 0.75 }rem`; + titleSpan.style.display = 'inline-flex'; + titleSpan.style.alignItems = 'center'; + titleSpan.textContent = node.title; + btn.appendChild( titleSpan ); + + if ( node.children.length === 0 ) { + + btn.disabled = true; + + } else { + + const chevron = document.createElement( 'i' ); + chevron.setAttribute( 'data-icon', 'chevron-down' ); + chevron.className = 'toc-chevron'; + btn.appendChild( chevron ); + + btn.onclick = () => { + + if ( this.dom.searchInput.value.trim().length > 0 ) return; + container.classList.toggle( 'collapsed' ); + + }; + + } + + container.appendChild( btn ); + + if ( node.children.length > 0 ) { + + const pagesDiv = document.createElement( 'div' ); + pagesDiv.className = 'toc-category-pages'; + + node.children.forEach( child => { + + pagesDiv.appendChild( createTOCNode( child, level + 1 ) ); + + } ); + + container.appendChild( pagesDiv ); + + } + + return container; + + } else { + + if ( node.children.length > 0 ) { + + // Parent page + const container = document.createElement( 'div' ); + container.className = 'toc-category-container'; + + const wrapper = document.createElement( 'div' ); + wrapper.className = 'toc-item-wrapper'; + + const btn = document.createElement( 'button' ); + btn.className = 'toc-btn toc-parent-link'; + btn.style.paddingLeft = '0.75rem'; + btn.style.justifyContent = 'space-between'; + btn.setAttribute( 'data-page-id', node.id ); + + if ( level > 0 ) { + + btn.style.fontSize = '0.8rem'; + btn.style.opacity = '0.85'; + + } + + const titleSpan = document.createElement( 'span' ); + titleSpan.style.paddingLeft = `${ level * 0.75 }rem`; + titleSpan.style.display = 'inline-flex'; + titleSpan.style.alignItems = 'center'; + titleSpan.textContent = node.title; + btn.appendChild( titleSpan ); + + const chevronBtn = document.createElement( 'span' ); + chevronBtn.className = 'toc-chevron-btn'; + chevronBtn.style.display = 'inline-flex'; + chevronBtn.style.alignItems = 'center'; + chevronBtn.style.padding = '0.2rem 0 0.2rem 0.5rem'; + + const chevron = document.createElement( 'i' ); + chevron.setAttribute( 'data-icon', 'chevron-down' ); + chevron.className = 'toc-chevron'; + chevronBtn.appendChild( chevron ); + + chevronBtn.onclick = ( e ) => { + + e.stopPropagation(); + if ( this.dom.searchInput.value.trim().length > 0 ) return; + container.classList.toggle( 'collapsed' ); + + }; + + btn.appendChild( chevronBtn ); + + btn.onclick = () => { + + const query = this.dom.searchInput.value.trim(); + const newHash = node.id + ( query ? '&q=' + encodeURIComponent( query ) : '' ); + const isSamePage = ( window.location.hash === `#${newHash}` ); + window.location.hash = newHash; + if ( isSamePage ) { + + this.searchManager.scrollToSearchMatch(); + + } + + if ( window.innerWidth < MOBILE_BREAKPOINT ) { + + this.toggleSidebar( false ); + + } + + }; + + wrapper.appendChild( btn ); + + if ( node.searchSnippet ) { + + const snippetDiv = document.createElement( 'div' ); + snippetDiv.className = 'toc-search-snippet'; + snippetDiv.style.marginLeft = `${ ( level * 0.75 ) + 0.75 }rem`; + snippetDiv.innerHTML = node.searchSnippet; + snippetDiv.onclick = () => { + + const query = this.dom.searchInput.value.trim(); + const newHash = node.id + ( query ? '&q=' + encodeURIComponent( query ) : '' ); + const isSamePage = ( window.location.hash === `#${newHash}` ); + window.location.hash = newHash; + if ( isSamePage ) { + + this.searchManager.scrollToSearchMatch(); + + } + + if ( window.innerWidth < MOBILE_BREAKPOINT ) { + + this.toggleSidebar( false ); + + } + + }; + + wrapper.appendChild( snippetDiv ); + + } + + container.appendChild( wrapper ); + + const pagesDiv = document.createElement( 'div' ); + pagesDiv.className = 'toc-category-pages'; + + node.children.forEach( child => { + + pagesDiv.appendChild( createTOCNode( child, level + 1 ) ); + + } ); + + container.appendChild( pagesDiv ); + return container; + + } else { + + // Leaf page + const container = document.createElement( 'div' ); + container.className = 'toc-page-container'; + + const wrapper = document.createElement( 'div' ); + wrapper.className = 'toc-item-wrapper'; + + const btn = document.createElement( 'button' ); + btn.className = 'toc-btn'; + btn.style.paddingLeft = '0.75rem'; + btn.setAttribute( 'data-page-id', node.id ); + + if ( level > 0 ) { + + btn.style.fontSize = '0.8rem'; + btn.style.opacity = '0.85'; + + } + + const titleSpan = document.createElement( 'span' ); + titleSpan.style.paddingLeft = `${ level * 0.75 }rem`; + titleSpan.style.display = 'inline-flex'; + titleSpan.style.alignItems = 'center'; + titleSpan.textContent = node.title; + btn.appendChild( titleSpan ); + + btn.onclick = () => { + + const query = this.dom.searchInput.value.trim(); + const newHash = node.id + ( query ? '&q=' + encodeURIComponent( query ) : '' ); + const isSamePage = ( window.location.hash === `#${newHash}` ); + window.location.hash = newHash; + if ( isSamePage ) { + + this.searchManager.scrollToSearchMatch(); + + } + + if ( window.innerWidth < MOBILE_BREAKPOINT ) { + + this.toggleSidebar( false ); + + } + + }; + + wrapper.appendChild( btn ); + + if ( node.searchSnippet ) { + + const snippetDiv = document.createElement( 'div' ); + snippetDiv.className = 'toc-search-snippet'; + snippetDiv.style.marginLeft = `${ ( level * 0.75 ) + 0.75 }rem`; + snippetDiv.innerHTML = node.searchSnippet; + snippetDiv.onclick = () => { + + const query = this.dom.searchInput.value.trim(); + const newHash = node.id + ( query ? '&q=' + encodeURIComponent( query ) : '' ); + const isSamePage = ( window.location.hash === `#${newHash}` ); + window.location.hash = newHash; + if ( isSamePage ) { + + this.searchManager.scrollToSearchMatch(); + + } + + if ( window.innerWidth < MOBILE_BREAKPOINT ) { + + this.toggleSidebar( false ); + + } + + }; + + wrapper.appendChild( snippetDiv ); + + } + + container.appendChild( wrapper ); + return container; + + } + + } + + }; + + if ( featuredPage ) { + + if ( ! featuredPage.searchSnippet && featuredPage.description ) { + + const query = this.dom.searchInput.value.trim(); + const queryTerms = query.toLowerCase().split( /\s+/ ).filter( t => t.length > 0 ); + const cleanText = this.searchManager.getCleanText( featuredPage.description ); + featuredPage.searchSnippet = this.searchManager.getSearchSnippet( cleanText, queryTerms, featuredPage.title ); + + } + + let featuredTreeRoot = featuredPage; + const path = featuredPage.path || []; + for ( let i = path.length - 1; i >= 0; i -- ) { + + featuredTreeRoot = { + title: path[ i ], + isFolder: true, + children: [ featuredTreeRoot ] + }; + + } + + this.dom.tocList.appendChild( createTOCNode( featuredTreeRoot, 0 ) ); + + } + + tree.forEach( rootNode => { + + this.dom.tocList.appendChild( createTOCNode( rootNode, 0 ) ); + + } ); + + if ( this.dom.searchInput && this.dom.searchInput.value.trim().length > 0 ) { + + const firstWrapper = this.dom.tocList.querySelector( '.toc-item-wrapper' ); + if ( firstWrapper ) { + + firstWrapper.classList.add( 'first-search-result' ); + + } + + if ( document.activeElement === this.dom.searchInput ) { + + this.dom.tocList.classList.add( 'search-focused' ); + + } + + } + + // Re-initialize Lucide icons to render the chevrons + this.createIcons( this.dom.tocList ); + + this.updateUI(); + + } + + toggleSidebar( force ) { + + this.isSidebarOpen = force !== undefined ? force : ! this.isSidebarOpen; + if ( this.isSidebarOpen ) { + + this.dom.sidebar.classList.add( 'open' ); + this.dom.menuToggleMain.style.display = 'none'; + this.dom.headerSearchBtn.style.display = 'none'; + + } else { + + this.dom.sidebar.classList.remove( 'open' ); + this.dom.menuToggleMain.style.display = 'flex'; + this.dom.headerSearchBtn.style.display = 'flex'; + this.openedViaHeaderSearch = false; + + } + + } + + setResizerToggleIcon( iconName ) { + + const hResizerToggle = this.dom.hResizerToggle; + const currentIcon = hResizerToggle.querySelector( '[data-icon]' ); + if ( currentIcon && currentIcon.getAttribute( 'data-icon' ) === iconName ) return; + hResizerToggle.innerHTML = ``; + this.createIcons( hResizerToggle ); + + } + + + setVResizerToggleIcon( iconName ) { + + const btn = this.dom.vResizerToggle; + if ( ! btn ) return; + const currentIcon = btn.querySelector( '[data-icon]' ); + if ( currentIcon && currentIcon.getAttribute( 'data-icon' ) === iconName ) return; + btn.innerHTML = ``; + this.createIcons( btn ); + + } + + setVResizerToggleInvertedIcon( iconName ) { + + const btn = this.dom.vResizerToggleInverted; + if ( ! btn ) return; + const currentIcon = btn.querySelector( '[data-icon]' ); + if ( currentIcon && currentIcon.getAttribute( 'data-icon' ) === iconName ) return; + btn.innerHTML = ``; + this.createIcons( btn ); + + } + + resetToOriginalCode( nodeName ) { + + const page = this.pages[ this.currentPageIndex ]; + if ( ! page ) return; + + if ( nodeName && page.modifiedCodes ) { + + delete page.modifiedCodes[ nodeName ]; + + } else { + + delete page.modifiedCode; + + } + + const originalCode = ( nodeName && page.codes && page.codes[ nodeName ] !== undefined ) + ? page.codes[ nodeName ] + : page.code; + + if ( this.codeEditor ) { + + this.codeEditor.setValue( originalCode ); + this.runner.run( originalCode ); + + } + + } + + async cleanAndFormatActiveTab() { + + await this.playgroundManager.cleanAndFormatActiveTab(); + + } + + updateUndoRedoButtons() { + + this.historyManager.updateButtons(); + + } + + toggleConsole( forceState ) { + + this.consoleManager.toggleConsole( forceState ); + + } + + clearConsole() { + + this.consoleManager.clearConsole(); + + } + + copyConsole() { + + this.consoleManager.copyConsole(); + + } + + togglePlayground( active ) { + + this.playgroundManager.togglePlayground( active ); + + } + + renderPlaygroundTabs() { + + this.playgroundManager.renderPlaygroundTabs(); + + } + + runPlayground() { + + this.playgroundManager.runPlayground(); + + } + + getDebugTarget() { + + return this.playgroundManager.getDebugTarget(); + + } + + updateDebugWGSL() { + + this.playgroundManager.updateDebugWGSL(); + + } + + animate( t ) { + + const page = this.pages[ this.currentPageIndex ]; + const isInitialized = this.renderer.hasInitialized(); + + if ( ! isInitialized || ! page || ( ! page.hasCode && ! page.hasEmbed && ! this.isPlaygroundActive ) || ( ! page.hasEmbed && ! this.isPreviewVisible ) ) return; + + + this.renderer.clear(); + + this.runner.call( 'update', t ); + + } + + async createRenderer() { + + this.renderer = new THREE.WebGPURenderer( { antialias: false, alpha: true } ); + this.renderer.setPixelRatio( window.devicePixelRatio ); + this.renderer.setSize( Math.max( this.dom.previewContainer.clientWidth, 1 ), Math.max( this.dom.previewContainer.clientHeight, 1 ) ); + this.renderer.setAnimationLoop( this.animate ); + this.renderer.inspector = new Inspector(); + this.renderer.inspector.setHorizontalAlign( 'left' ); + this.renderer.inspector.setVerticalAlign( 'top' ); + this.renderer.shadowMap.enabled = true; + this.renderer.shadowMap.type = THREE.PCFShadowMap; + this.renderer.toneMapping = THREE.ACESFilmicToneMapping; + this.dom.previewContainer.appendChild( this.renderer.domElement ); + + try { + + await this.renderer.init(); + + const onError = this.renderer.onError; + const onDeviceLost = this.renderer.onDeviceLost; + + this.renderer.onError = ( info ) => { + + onError( info ); + + this.renderer.setAnimationLoop( null ); + this.hasCriticalError = true; + + }; + + this.renderer.onDeviceLost = ( info ) => { + + onDeviceLost( info ); + + this.renderer.setAnimationLoop( null ); + this.hasCriticalError = true; + + }; + + + } catch ( err ) { + + console.error( 'Failed to initialize WebGPU renderer:', err ); + + } + + this.runner.setValue( 'renderer', this.renderer ); + + const isCollapsed = document.body.classList.contains( 'collapsed-workspace' ); + this.renderer.inspector.setVisible( ! isCollapsed ); + + } + + disposeRenderer() { + + if ( this.renderer ) { + + this.renderer.dispose(); + + this.renderer.domElement.remove(); + + this.renderer = null; + + } + + } + + async refresh() { + + if ( this._refreshPromise !== null ) { + + return this._refreshPromise; + + } + + this._refreshPromise = new Promise( async ( resolve, reject ) => { + + try { + + this.disposeRenderer(); + + this.runner.dispose(); + + await this.createRenderer(); + + const currentCode = this.codeEditor.getValue(); + + if ( this.isPlaygroundActive ) { + + this.runPlayground(); + + } else { + + await this.runner.run( currentCode ); + + } + + resolve(); + + } catch ( error ) { + + reject( error ); + + } finally { + + this._refreshPromise = null; + + } + + } ); + + return this._refreshPromise; + + } + + createIcons( root = document ) { + + const elements = root.querySelectorAll( '[data-icon]' ); + elements.forEach( el => { + + const name = el.getAttribute( 'data-icon' ); + const svgEl = getSVG( name ); + if ( svgEl ) { + + for ( const attr of el.attributes ) { + + if ( attr.name !== 'data-icon' ) { + + svgEl.setAttribute( attr.name, attr.value ); + + } + + } + + if ( el.id ) { + + svgEl.id = el.id; + + } + + el.replaceWith( svgEl ); + + } + + } ); + + } + + dispose() { + + this.resizeObserver.disconnect(); + + window.removeEventListener( 'resize', this.onWindowResize ); + document.removeEventListener( 'pointerdown', this.onDocumentPointerDown ); + this.dom.contentArea.removeEventListener( 'scroll', this.onContentAreaScroll ); + window.removeEventListener( 'hashchange', this.onWindowHashChange ); + + this.layoutManager.dispose(); + this.consoleManager.dispose(); + this.codeEditor.dispose(); + this.debugCodeEditor.dispose(); + + this.readOnlyEditors.forEach( editor => editor.dispose() ); + this.readOnlyEditors = []; + + this.renderer.setAnimationLoop( null ); + this.renderer.dispose(); + this.webGLRenderer.dispose(); + + } + +} + +export { Tour }; diff --git a/tsl/js/code/CodeCompiler.js b/tsl/js/code/CodeCompiler.js new file mode 100644 index 00000000000000..a30f5cf462e569 --- /dev/null +++ b/tsl/js/code/CodeCompiler.js @@ -0,0 +1,1545 @@ +import * as acorn from 'acorn'; +import { Linter } from 'eslint-linter-browserify'; + +function renameIdentifier( code, oldName, newName ) { + + let ast; + try { + + ast = acorn.parse( code, { ecmaVersion: 'latest', sourceType: 'module' } ); + + } catch { + + return code; + + } + + const rangesToReplace = []; + const scopes = [ new Set() ]; + + const addDeclarations = ( pattern, scope ) => { + + if ( ! pattern ) return; + if ( pattern.type === 'Identifier' ) { + + scope.add( pattern.name ); + + } else if ( pattern.type === 'ObjectPattern' ) { + + pattern.properties.forEach( prop => { + + if ( prop.type === 'Property' ) { + + addDeclarations( prop.value, scope ); + + } else if ( prop.type === 'RestElement' ) { + + addDeclarations( prop.argument, scope ); + + } + + } ); + + } else if ( pattern.type === 'ArrayPattern' ) { + + pattern.elements.forEach( elem => { + + if ( elem ) addDeclarations( elem, scope ); + + } ); + + } else if ( pattern.type === 'AssignmentPattern' ) { + + addDeclarations( pattern.left, scope ); + + } + + }; + + const isShadowed = ( name ) => { + + for ( let i = scopes.length - 1; i > 0; i -- ) { + + if ( scopes[ i ].has( name ) ) return true; + + } + + return false; + + }; + + const walk = ( node ) => { + + if ( ! node ) return; + + const isFunction = node.type === 'FunctionDeclaration' || node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression'; + const isBlock = node.type === 'BlockStatement'; + const isCatch = node.type === 'CatchClause'; + + if ( isFunction ) { + + if ( node.type === 'FunctionDeclaration' && node.id ) { + + scopes[ scopes.length - 1 ].add( node.id.name ); + + } + + const newScope = new Set(); + node.params.forEach( p => addDeclarations( p, newScope ) ); + if ( node.type === 'FunctionExpression' && node.id ) { + + newScope.add( node.id.name ); + + } + + scopes.push( newScope ); + + } else if ( isBlock ) { + + scopes.push( new Set() ); + + } else if ( isCatch ) { + + const newScope = new Set(); + if ( node.param ) addDeclarations( node.param, newScope ); + scopes.push( newScope ); + + } else if ( node.type === 'ClassDeclaration' ) { + + if ( node.id ) { + + scopes[ scopes.length - 1 ].add( node.id.name ); + + } + + } + + if ( node.type === 'Identifier' ) { + + if ( node.name === oldName && ! isShadowed( oldName ) ) { + + rangesToReplace.push( { start: node.start, end: node.end } ); + + } + + } + + if ( node.type === 'MemberExpression' && ! node.computed ) { + + walk( node.object ); + + } else if ( node.type === 'Property' ) { + + if ( node.computed ) { + + walk( node.key ); + + } else if ( node.shorthand ) { + + if ( node.key.name === oldName && ! isShadowed( oldName ) ) { + + rangesToReplace.push( { + start: node.start, + end: node.end, + replacement: `${oldName}: ${newName}` + } ); + + } + + } else { + + walk( node.value ); + + } + + } else if ( node.type === 'VariableDeclarator' ) { + + addDeclarations( node.id, scopes[ scopes.length - 1 ] ); + walk( node.id ); + walk( node.init ); + + } else { + + for ( const key in node ) { + + const child = node[ key ]; + if ( child && typeof child === 'object' ) { + + if ( Array.isArray( child ) ) { + + child.forEach( walk ); + + } else if ( child.type ) { + + walk( child ); + + } + + } + + } + + } + + if ( isFunction || isBlock || isCatch ) { + + scopes.pop(); + + } + + }; + + walk( ast ); + + rangesToReplace.sort( ( a, b ) => b.start - a.start ); + let result = code; + rangesToReplace.forEach( r => { + + const replacement = r.replacement !== undefined ? r.replacement : newName; + result = result.substring( 0, r.start ) + replacement + result.substring( r.end ); + + } ); + + return result; + +} + +class CodeCompiler { + + constructor() {} + + formatBody( bodyText ) { + + const lines = bodyText.split( '\n' ); + + // Trim empty lines from start + while ( lines.length > 0 && lines[ 0 ].trim() === '' ) { + + lines.shift(); + + } + + // Trim empty lines from end + while ( lines.length > 0 && lines[ lines.length - 1 ].trim() === '' ) { + + lines.pop(); + + } + + let minIndent = Infinity; + lines.forEach( line => { + + if ( line.trim() === '' ) return; + const match = line.match( /^(\t*)/ ); + if ( match ) { + + const indent = match[ 1 ].length; + if ( indent < minIndent ) { + + minIndent = indent; + + } + + } + + } ); + + if ( minIndent === Infinity ) minIndent = 0; + + const formattedLines = lines.map( line => { + + if ( line.trim() === '' ) return ''; + const stripped = line.substring( minIndent ); + return '\t' + stripped; + + } ); + + return formattedLines.join( '\n' ); + + } + + isSimpleValue( valStr ) { + + const trimmed = valStr.trim(); + if ( trimmed === 'true' || trimmed === 'false' || trimmed === 'null' || trimmed === 'undefined' ) return true; + if ( ! isNaN( Number( trimmed ) ) ) return true; + if ( /^(['"`])[\s\S]*\1$/.test( trimmed ) ) return true; + return false; + + } + + async compile( code, scripts ) { + + const standardImports = new Set(); + const globalVars = new Map(); + globalVars.set( 'renderer', undefined ); + const customScripts = []; + const customScriptsSet = new Set(); + const scriptsToScan = [ code ]; + + // Traverse custom script imports transitively using AST parsing + while ( scriptsToScan.length > 0 ) { + + const currentCode = scriptsToScan.shift(); + let currentAst; + try { + + currentAst = acorn.parse( currentCode, { ecmaVersion: 'latest', sourceType: 'module' } ); + + } catch ( err ) { + + console.error( 'Error parsing dependencies:', err ); + continue; + + } + + currentAst.body.forEach( node => { + + if ( node.type === 'ImportDeclaration' ) { + + const importedName = node.source.value; + const cleanName = importedName.replace( /^\.\//, '' ).replace( /^\.\..+/, '' ).replace( /\.js$/, '' ).toLowerCase(); + const isExternal = cleanName.startsWith( 'http://' ) || cleanName.startsWith( 'https://' ) || cleanName.startsWith( '/' ); + if ( scripts && scripts[ cleanName ] && ! isExternal ) { + + if ( ! customScriptsSet.has( cleanName ) ) { + + customScriptsSet.add( cleanName ); + customScripts.push( cleanName ); + if ( scripts[ cleanName ].text ) { + + scriptsToScan.push( scripts[ cleanName ].text ); + + } + + } + + } + + } + + } ); + + } + + // Reverse dependencies to ensure deeply imported scripts are compiled/initialized first + customScripts.reverse(); + + // Preprocess code and scripts to detect naming conflicts and rename non-exported symbols + const scriptTexts = {}; + customScripts.forEach( scriptName => { + + if ( scripts[ scriptName ] && scripts[ scriptName ].text ) { + + scriptTexts[ scriptName ] = scripts[ scriptName ].text; + + } + + } ); + scriptTexts[ 'main' ] = code; + + const compiledGlobalNames = new Set(); + + const getAstInfo = ( fileCode ) => { + + let ast; + try { + + ast = acorn.parse( fileCode, { ecmaVersion: 'latest', sourceType: 'module' } ); + + } catch { + + return { declared: new Set(), exported: new Set() }; + + } + + const declared = new Set(); + const exported = new Set(); + + const extractNames = ( pattern, set ) => { + + if ( ! pattern ) return; + if ( pattern.type === 'Identifier' ) { + + if ( pattern.name !== 'debug' && ! [ 'init', 'refresh', 'update', 'resize', 'dispose' ].includes( pattern.name ) ) { + + set.add( pattern.name ); + + } + + } else if ( pattern.type === 'ObjectPattern' ) { + + pattern.properties.forEach( prop => { + + if ( prop.type === 'Property' ) { + + extractNames( prop.value, set ); + + } else if ( prop.type === 'RestElement' ) { + + extractNames( prop.argument, set ); + + } + + } ); + + } else if ( pattern.type === 'ArrayPattern' ) { + + pattern.elements.forEach( elem => { + + if ( elem ) extractNames( elem, set ); + + } ); + + } else if ( pattern.type === 'AssignmentPattern' ) { + + extractNames( pattern.left, set ); + + } + + }; + + ast.body.forEach( node => { + + let decl = node; + if ( node.type === 'ExportNamedDeclaration' ) { + + decl = node.declaration; + if ( node.specifiers ) { + + node.specifiers.forEach( spec => { + + if ( spec.local && ! [ 'init', 'refresh', 'update', 'resize', 'dispose' ].includes( spec.local.name ) ) { + + declared.add( spec.local.name ); + exported.add( spec.local.name ); + + } + + } ); + + } + + } else if ( node.type === 'ExportDefaultDeclaration' ) { + + decl = node.declaration; + if ( decl && ( decl.type === 'FunctionDeclaration' || decl.type === 'ClassDeclaration' ) && decl.id && ! [ 'init', 'refresh', 'update', 'resize', 'dispose' ].includes( decl.id.name ) ) { + + declared.add( decl.id.name ); + exported.add( decl.id.name ); + + } + + } + + if ( decl ) { + + if ( decl.type === 'VariableDeclaration' ) { + + decl.declarations.forEach( d => { + + extractNames( d.id, declared ); + if ( node.type === 'ExportNamedDeclaration' ) { + + extractNames( d.id, exported ); + + } + + } ); + + } else if ( decl.type === 'FunctionDeclaration' || decl.type === 'ClassDeclaration' ) { + + if ( decl.id && ! [ 'init', 'refresh', 'update', 'resize', 'dispose' ].includes( decl.id.name ) ) { + + declared.add( decl.id.name ); + if ( node.type === 'ExportNamedDeclaration' ) { + + exported.add( decl.id.name ); + + } + + } + + } + + } + + } ); + + return { declared, exported }; + + }; + + const renameScript = ( fileCode ) => { + + const { declared, exported } = getAstInfo( fileCode ); + let updatedCode = fileCode; + + for ( const name of declared ) { + + if ( compiledGlobalNames.has( name ) ) { + + if ( ! exported.has( name ) ) { + + let newName = name; + let counter = 1; + while ( compiledGlobalNames.has( newName ) ) { + + newName = `${name}_${counter}`; + counter ++; + + } + + updatedCode = renameIdentifier( updatedCode, name, newName ); + declared.delete( name ); + declared.add( newName ); + + } + + } + + } + + for ( const name of declared ) { + + compiledGlobalNames.add( name ); + + } + + return updatedCode; + + }; + + customScripts.forEach( scriptName => { + + if ( scriptTexts[ scriptName ] ) { + + scriptTexts[ scriptName ] = renameScript( scriptTexts[ scriptName ] ); + scripts[ scriptName ].text = scriptTexts[ scriptName ]; + + } + + } ); + + code = renameScript( scriptTexts[ 'main' ] ); + + // Build scriptBasenameMap to avoid naming conflicts while using simple names + const scriptBasenameMap = new Map(); + const basenameCounts = {}; + + customScripts.forEach( scriptName => { + + const parts = scriptName.split( '/' ); + const basename = parts[ parts.length - 1 ]; + basenameCounts[ basename ] = ( basenameCounts[ basename ] || 0 ) + 1; + + } ); + + customScripts.forEach( scriptName => { + + const parts = scriptName.split( '/' ); + const basename = parts[ parts.length - 1 ]; + if ( basenameCounts[ basename ] > 1 ) { + + scriptBasenameMap.set( scriptName, scriptName.replace( /\//g, '_' ) ); + + } else { + + scriptBasenameMap.set( scriptName, basename ); + + } + + } ); + + const getDeclaredVars = ( fileCode ) => { + + const vars = new Set(); + let ast; + try { + + ast = acorn.parse( fileCode, { ecmaVersion: 'latest', sourceType: 'module' } ); + + } catch { + + return vars; + + } + + const extractNames = ( pattern ) => { + + if ( pattern.type === 'Identifier' ) { + + if ( pattern.name !== 'debug' ) vars.add( pattern.name ); + + } else if ( pattern.type === 'ObjectPattern' ) { + + pattern.properties.forEach( prop => { + + if ( prop.type === 'Property' ) { + + extractNames( prop.value ); + + } else if ( prop.type === 'RestElement' ) { + + extractNames( prop.argument ); + + } + + } ); + + } else if ( pattern.type === 'ArrayPattern' ) { + + pattern.elements.forEach( elem => { + + if ( elem ) extractNames( elem ); + + } ); + + } else if ( pattern.type === 'AssignmentPattern' ) { + + extractNames( pattern.left ); + + } + + }; + + ast.body.forEach( node => { + + let decl = node; + if ( node.type === 'ExportNamedDeclaration' ) { + + decl = node.declaration; + + } + + if ( decl && decl.type === 'VariableDeclaration' ) { + + decl.declarations.forEach( d => { + + extractNames( d.id ); + + } ); + + } + + } ); + + return vars; + + }; + + const allowedVars = new Set(); + customScripts.forEach( scriptName => { + + const scriptConfig = scripts[ scriptName ]; + if ( scriptConfig && scriptConfig.text ) { + + const vars = getDeclaredVars( scriptConfig.text ); + vars.forEach( v => allowedVars.add( v ) ); + + } + + } ); + + const isSymbolUsed = ( ast, symbol ) => { + + let used = false; + const walk = ( node ) => { + + if ( used ) return; + if ( ! node ) return; + if ( node.type === 'Identifier' && node.name === symbol ) { + + used = true; + return; + + } + + for ( const key in node ) { + + const child = node[ key ]; + if ( child && typeof child === 'object' ) { + + if ( Array.isArray( child ) ) { + + child.forEach( walk ); + + } else if ( child.type ) { + + walk( child ); + + } + + } + + } + + }; + + ast.body.forEach( node => { + + if ( node.type !== 'ImportDeclaration' ) { + + walk( node ); + + } + + } ); + return used; + + }; + + const parseFile = ( fileCode, fileName, cleanName ) => { + + const fileStruct = { + name: fileName, + cleanName: cleanName, + setup: [], + functions: [], + lifecycles: { init: null, update: null, resize: null, dispose: null } + }; + + let ast; + try { + + ast = acorn.parse( fileCode, { ecmaVersion: 'latest', sourceType: 'module' } ); + + } catch ( err ) { + + console.error( `Error parsing file ${fileName}:`, err ); + return fileStruct; + + } + + const isCustom = ( moduleName ) => { + + if ( ! moduleName ) return false; + const clean = moduleName.replace( /^\.\//, '' ).replace( /^\.\..+/, '' ).replace( /\.js$/, '' ).toLowerCase(); + return customScripts.includes( clean ); + + }; + + const extractNames = ( pattern, list ) => { + + if ( pattern.type === 'Identifier' ) { + + list.push( pattern.name ); + + } else if ( pattern.type === 'ObjectPattern' ) { + + pattern.properties.forEach( prop => { + + if ( prop.type === 'Property' ) { + + extractNames( prop.value, list ); + + } else if ( prop.type === 'RestElement' ) { + + extractNames( prop.argument, list ); + + } + + } ); + + } else if ( pattern.type === 'ArrayPattern' ) { + + pattern.elements.forEach( elem => { + + if ( elem ) extractNames( elem, list ); + + } ); + + } else if ( pattern.type === 'AssignmentPattern' ) { + + extractNames( pattern.left, list ); + + } + + }; + + const processNode = ( node ) => { + + if ( node.type === 'ImportDeclaration' ) { + + const moduleName = node.source.value; + if ( ! isCustom( moduleName ) ) { + + node.specifiers.forEach( spec => { + + const isUsed = isSymbolUsed( ast, spec.local.name ); + if ( isUsed ) { + + if ( spec.type === 'ImportSpecifier' ) { + + const importedName = spec.imported.name; + const localName = spec.local.name; + const importStr = importedName === localName + ? `import { ${importedName} } from '${moduleName}';` + : `import { ${importedName} as ${localName} } from '${moduleName}';`; + standardImports.add( importStr ); + + } else if ( spec.type === 'ImportDefaultSpecifier' ) { + + standardImports.add( `import ${spec.local.name} from '${moduleName}';` ); + + } else if ( spec.type === 'ImportNamespaceSpecifier' ) { + + standardImports.add( `import * as ${spec.local.name} from '${moduleName}';` ); + + } + + } + + } ); + + if ( node.specifiers.length === 0 ) { + + standardImports.add( `import '${moduleName}';` ); + + } + + } + + return; + + } + + if ( node.type === 'FunctionDeclaration' ) { + + const fnName = node.id ? node.id.name : '__default_export__'; + if ( [ 'init', 'refresh', 'update', 'resize', 'dispose' ].includes( fnName ) ) { + + const params = node.params.map( p => fileCode.substring( p.start, p.end ) ); + const body = fileCode.substring( node.body.start + 1, node.body.end - 1 ); + fileStruct.lifecycles[ fnName ] = { + params: params, + body: body + }; + + } else { + + if ( fnName !== 'debug' ) { + + fileStruct.functions.push( fileCode.substring( node.start, node.end ) ); + + } + + } + + return; + + } + + if ( node.type === 'ClassDeclaration' ) { + + const className = node.id ? node.id.name : ''; + if ( className !== 'debug' ) { + + fileStruct.functions.push( fileCode.substring( node.start, node.end ) ); + + } + + return; + + } + + if ( node.type === 'VariableDeclaration' ) { + + const globalAssignments = []; + const localDeclarators = []; + + const isFunctionInit = ( init ) => { + + if ( ! init ) return false; + if ( init.type === 'FunctionExpression' || init.type === 'ArrowFunctionExpression' ) return true; + if ( init.type === 'CallExpression' && init.callee.type === 'Identifier' && init.callee.name === 'Fn' ) return true; + return false; + + }; + + node.declarations.forEach( decl => { + + const declaredNames = []; + extractNames( decl.id, declaredNames ); + + if ( isFunctionInit( decl.init ) ) { + + const fnDeclStr = `${node.kind} ${fileCode.substring( decl.id.start, decl.init.end )};`; + fileStruct.functions.push( fnDeclStr ); + + } else { + + const isGlobal = declaredNames.some( name => allowedVars.has( name ) ); + + if ( isGlobal ) { + + declaredNames.forEach( name => { + + if ( allowedVars.has( name ) ) { + + if ( decl.init ) { + + const valStr = fileCode.substring( decl.init.start, decl.init.end ); + if ( this.isSimpleValue( valStr ) ) { + + globalVars.set( name, valStr ); + + } else { + + globalVars.set( name, undefined ); + globalAssignments.push( `${name} = ${valStr}` ); + + } + + } else { + + globalVars.set( name, undefined ); + + } + + } + + } ); + + } else { + + localDeclarators.push( fileCode.substring( decl.start, decl.end ) ); + + } + + } + + } ); + + if ( globalAssignments.length > 0 ) { + + fileStruct.setup.push( globalAssignments.join( ', ' ) + ';' ); + + } + + if ( localDeclarators.length > 0 ) { + + fileStruct.setup.push( `${node.kind} ${localDeclarators.join( ', ' )};` ); + + } + + return; + + } + + if ( node.type === 'ExportNamedDeclaration' ) { + + if ( node.declaration ) { + + processNode( node.declaration ); + + } + + return; + + } + + if ( node.type === 'ExportDefaultDeclaration' ) { + + if ( node.declaration ) { + + if ( node.declaration.type === 'FunctionDeclaration' || node.declaration.type === 'ClassDeclaration' ) { + + const innerNode = node.declaration; + if ( ! innerNode.id ) { + + const name = '__default_export__'; + const codeStr = fileCode.substring( innerNode.start, innerNode.end ); + const kind = innerNode.type === 'FunctionDeclaration' ? 'function' : 'class'; + const replacedCode = codeStr.replace( new RegExp( `^(${innerNode.async ? 'async\\s+' : ''})${kind}\\s*\\(` ), `$1${kind} ${name}(` ); + fileStruct.functions.push( replacedCode ); + + } else { + + processNode( innerNode ); + + } + + } else { + + const exprStr = fileCode.substring( node.declaration.start, node.declaration.end ); + fileStruct.setup.push( `let defaultValue = ${exprStr};` ); + + } + + } + + return; + + } + + const statementStr = fileCode.substring( node.start, node.end ); + if ( statementStr.trim() !== ';' ) { + + fileStruct.setup.push( statementStr ); + + } + + }; + + ast.body.forEach( processNode ); + + return fileStruct; + + }; + + // Parse all custom scripts + const parsedScripts = []; + customScripts.forEach( scriptName => { + + const scriptConfig = scripts[ scriptName ]; + if ( scriptConfig && scriptConfig.text ) { + + const cleanName = scriptBasenameMap.get( scriptName ); + parsedScripts.push( parseFile( scriptConfig.text, `${scriptName}.js`, cleanName ) ); + + } + + } ); + + // Parse main code + const parsedMain = parseFile( code, 'main', 'main' ); + + // Build unified lifecycle functions + const buildUnifiedLifecycle = ( lifecycleName, standardParams ) => { + + const declarations = []; + const calls = []; + + const processBody = ( s, lifecycle, setupStatements = [] ) => { + + if ( ! lifecycle && setupStatements.length === 0 ) return; + let mapping = ''; + if ( lifecycle ) { + + lifecycle.params.forEach( ( p, idx ) => { + + if ( standardParams[ idx ] && p !== standardParams[ idx ] ) { + + mapping += `\tvar ${p} = ${standardParams[ idx ]};\n`; + + } + + } ); + + } + + let bodyContent = ''; + if ( setupStatements.length > 0 ) { + + bodyContent += this.formatBody( setupStatements.join( '\n' ) ); + + } + + if ( lifecycle ) { + + const formattedBody = this.formatBody( lifecycle.body ); + if ( bodyContent ) { + + bodyContent += '\n\n' + formattedBody; + + } else { + + bodyContent += formattedBody; + + } + + } + + const bodyStr = mapping ? mapping + bodyContent : bodyContent; + + const subFuncName = `${lifecycleName}_${s.cleanName}`; + + const paramsStr = standardParams.join( ', ' ); + const formattedParams = paramsStr ? ` ${paramsStr} ` : ''; + const isAsync = lifecycleName === 'init' ? 'async ' : ''; + const declText = `${isAsync}function ${subFuncName}(${formattedParams}) {\n\n${bodyStr}\n\n}`; + declarations.push( declText ); + + const callPrefix = lifecycleName === 'init' ? 'await ' : ''; + calls.push( `\t${callPrefix}${subFuncName}(${formattedParams});` ); + + }; + + parsedScripts.forEach( s => processBody( s, s.lifecycles[ lifecycleName ], lifecycleName === 'init' ? s.setup : [] ) ); + processBody( parsedMain, parsedMain.lifecycles[ lifecycleName ], lifecycleName === 'init' ? parsedMain.setup : [] ); + + let middleSetup = ''; + if ( lifecycleName === 'init' ) { + + middleSetup += '\t// Renderer Setup\n\trenderer = new THREE.WebGPURenderer();\n\trenderer.setPixelRatio( window.devicePixelRatio );\n\trenderer.setSize( window.innerWidth, window.innerHeight );\n\trenderer.setClearColor( 0x2a2a33 );\n\tdocument.body.appendChild( renderer.domElement );\n\n\tawait renderer.init();\n\n'; + + } else if ( lifecycleName === 'resize' ) { + + middleSetup += '\trenderer.setSize( width, height );\n\n'; + + } + + let endSetup = ''; + if ( lifecycleName === 'init' ) { + + endSetup += '\n\n\trenderer.setAnimationLoop( update );'; + endSetup += '\n\n\twindow.addEventListener( \'resize\', () => resize( window.innerWidth, window.innerHeight ) );'; + + } + + const paramsStr = standardParams.join( ', ' ); + const formattedParams = paramsStr ? ` ${paramsStr} ` : ''; + + const isAsync = lifecycleName === 'init' ? 'async ' : ''; + const unifiedFunction = `${isAsync}function ${lifecycleName}(${formattedParams}) {\n\n${middleSetup}${calls.join( '\n' )}${endSetup}\n\n}`; + + return { + declarations: declarations.join( '\n\n' ), + unified: unifiedFunction + }; + + }; + + const unifiedInit = buildUnifiedLifecycle( 'init', [] ); + const unifiedUpdate = buildUnifiedLifecycle( 'update', [ 't' ] ); + const unifiedResize = buildUnifiedLifecycle( 'resize', [ 'width', 'height' ] ); + + // Assemble standard imports and global variables + const mergedImports = this.mergeImports( standardImports ); + const importsStr = mergedImports.join( '\n' ); + const globalsList = []; + for ( const [ name, val ] of globalVars.entries() ) { + + if ( val !== undefined ) { + + globalsList.push( `${name} = ${val}` ); + + } else { + + globalsList.push( name ); + + } + + } + + const globalsStr = globalsList.length > 0 ? `let ${globalsList.join( ', ' )};` : ''; + + const declarationsStr = [ unifiedInit.declarations, unifiedUpdate.declarations, unifiedResize.declarations ].filter( Boolean ).join( '\n\n' ); + const unifiedStr = [ unifiedInit.unified, unifiedUpdate.unified, unifiedResize.unified ].filter( Boolean ).join( '\n\n' ); + + const helperFunctions = []; + parsedScripts.forEach( s => { + + s.functions.forEach( fn => helperFunctions.push( fn ) ); + + } ); + parsedMain.functions.forEach( fn => helperFunctions.push( fn ) ); + + const helpersStr = helperFunctions.join( '\n\n' ); + + const finalCode = ` +${importsStr} + +${globalsStr} + +${helpersStr} + +await init(); + +${unifiedStr} + +${declarationsStr} +`.trim() + '\n'; + + return await this.format( finalCode ); + + } + + mergeImports( importsSet ) { + + const grouped = {}; + + importsSet.forEach( stmt => { + + const trimmed = stmt.trim(); + if ( ! trimmed ) return; + + // Namespace import: import * as THREE from 'three'; + const namespaceMatch = trimmed.match( /^import\s+\*\s+as\s+([a-zA-Z0-9_$]+)\s+from\s+['"]([^'"]+)['"];?$/ ); + if ( namespaceMatch ) { + + const local = namespaceMatch[ 1 ]; + const moduleName = namespaceMatch[ 2 ]; + if ( ! grouped[ moduleName ] ) { + + grouped[ moduleName ] = { defaultImport: null, namespaceImports: [], namedImports: new Set(), bare: false }; + + } + + if ( ! grouped[ moduleName ].namespaceImports.includes( local ) ) { + + grouped[ moduleName ].namespaceImports.push( local ); + + } + + return; + + } + + // Named import: import { a, b } from 'module'; + const namedMatch = trimmed.match( /^import\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"];?$/ ); + if ( namedMatch ) { + + const symbols = namedMatch[ 1 ].split( ',' ).map( s => s.trim() ).filter( Boolean ); + const moduleName = namedMatch[ 2 ]; + if ( ! grouped[ moduleName ] ) { + + grouped[ moduleName ] = { defaultImport: null, namespaceImports: [], namedImports: new Set(), bare: false }; + + } + + symbols.forEach( s => grouped[ moduleName ].namedImports.add( s ) ); + return; + + } + + // Default import (with optional named imports): import defaultVal, { named1 } from 'module'; + const defaultAndNamedMatch = trimmed.match( /^import\s+([a-zA-Z0-9_$]+)\s*,\s*\{([^}]+)\}\s+from\s+['"]([^'"]+)['"];?$/ ); + if ( defaultAndNamedMatch ) { + + const defaultVal = defaultAndNamedMatch[ 1 ]; + const symbols = defaultAndNamedMatch[ 2 ].split( ',' ).map( s => s.trim() ).filter( Boolean ); + const moduleName = defaultAndNamedMatch[ 3 ]; + if ( ! grouped[ moduleName ] ) { + + grouped[ moduleName ] = { defaultImport: null, namespaceImports: [], namedImports: new Set(), bare: false }; + + } + + grouped[ moduleName ].defaultImport = defaultVal; + symbols.forEach( s => grouped[ moduleName ].namedImports.add( s ) ); + return; + + } + + // Default import only: import defaultVal from 'module'; + const defaultMatch = trimmed.match( /^import\s+([a-zA-Z0-9_$]+)\s+from\s+['"]([^'"]+)['"];?$/ ); + if ( defaultMatch ) { + + const defaultVal = defaultMatch[ 1 ]; + const moduleName = defaultMatch[ 2 ]; + if ( ! grouped[ moduleName ] ) { + + grouped[ moduleName ] = { defaultImport: null, namespaceImports: [], namedImports: new Set(), bare: false }; + + } + + grouped[ moduleName ].defaultImport = defaultVal; + return; + + } + + // Bare import: import 'module'; + const bareMatch = trimmed.match( /^import\s+['"]([^'"]+)['"];?$/ ); + if ( bareMatch ) { + + const moduleName = bareMatch[ 1 ]; + if ( ! grouped[ moduleName ] ) { + + grouped[ moduleName ] = { defaultImport: null, namespaceImports: [], namedImports: new Set(), bare: false }; + + } + + grouped[ moduleName ].bare = true; + return; + + } + + // Fallback: keep exactly as is + const fallbackModule = 'fallback_' + Math.random(); + grouped[ fallbackModule ] = { fallback: trimmed }; + + } ); + + const mergedLines = []; + + const sortedModuleNames = Object.keys( grouped ).sort(); + + for ( const moduleName of sortedModuleNames ) { + + const info = grouped[ moduleName ]; + + if ( info.fallback ) { + + mergedLines.push( info.fallback ); + continue; + + } + + // Generate namespace imports (kept on their own lines) + info.namespaceImports.forEach( ns => { + + mergedLines.push( `import * as ${ns} from '${moduleName}';` ); + + } ); + + // Generate merged default + named imports + const hasDefault = info.defaultImport !== null; + const hasNamed = info.namedImports.size > 0; + + if ( hasDefault && hasNamed ) { + + const sortedNamed = Array.from( info.namedImports ).sort(); + mergedLines.push( `import ${info.defaultImport}, { ${sortedNamed.join( ', ' )} } from '${moduleName}';` ); + + } else if ( hasDefault ) { + + mergedLines.push( `import ${info.defaultImport} from '${moduleName}';` ); + + } else if ( hasNamed ) { + + const sortedNamed = Array.from( info.namedImports ).sort(); + mergedLines.push( `import { ${sortedNamed.join( ', ' )} } from '${moduleName}';` ); + + } else if ( info.bare && info.namespaceImports.length === 0 ) { + + // Only add bare import if there are no namespace/default/named imports for this module + mergedLines.push( `import '${moduleName}';` ); + + } + + } + + return mergedLines; + + } + + removeUnusedImports( code ) { + + let ast; + try { + + ast = acorn.parse( code, { ecmaVersion: 'latest', sourceType: 'module' } ); + + } catch ( err ) { + + return code; + + } + + const isSymbolUsed = ( symbol ) => { + + let used = false; + const walk = ( node ) => { + + if ( used ) return; + if ( ! node ) return; + if ( node.type === 'Identifier' && node.name === symbol ) { + + used = true; + return; + + } + + for ( const key in node ) { + + const child = node[ key ]; + if ( child && typeof child === 'object' ) { + + if ( Array.isArray( child ) ) { + + child.forEach( walk ); + + } else if ( child.type ) { + + walk( child ); + + } + + } + + } + + }; + + ast.body.forEach( node => { + + if ( node.type !== 'ImportDeclaration' ) { + + walk( node ); + + } + + } ); + + return used; + + }; + + const importNodes = ast.body.filter( node => node.type === 'ImportDeclaration' ); + const replacements = []; + + importNodes.forEach( node => { + + const moduleName = node.source.value; + const specifiers = node.specifiers; + + if ( specifiers.length === 0 ) { + + return; + + } + + const usedSpecifiers = specifiers.filter( spec => isSymbolUsed( spec.local.name ) ); + + if ( usedSpecifiers.length === 0 ) { + + replacements.push( { + start: node.start, + end: node.end, + replacement: '' + } ); + + } else if ( usedSpecifiers.length < specifiers.length ) { + + const defaultSpec = usedSpecifiers.find( s => s.type === 'ImportDefaultSpecifier' ); + const namespaceSpec = usedSpecifiers.find( s => s.type === 'ImportNamespaceSpecifier' ); + const namedSpecs = usedSpecifiers.filter( s => s.type === 'ImportSpecifier' ); + + let importStr = 'import '; + const parts = []; + + if ( defaultSpec ) { + + parts.push( defaultSpec.local.name ); + + } + + if ( namespaceSpec ) { + + parts.push( `* as ${namespaceSpec.local.name}` ); + + } + + if ( namedSpecs.length > 0 ) { + + const namedParts = namedSpecs.map( spec => { + + const imported = spec.imported.name; + const local = spec.local.name; + return imported === local ? imported : `${imported} as ${local}`; + + } ); + parts.push( `{ ${namedParts.join( ', ' )} }` ); + + } + + importStr += parts.join( ', ' ) + ` from '${moduleName}';`; + + replacements.push( { + start: node.start, + end: node.end, + replacement: importStr + } ); + + } + + } ); + + let result = code; + replacements.sort( ( a, b ) => b.start - a.start ); + replacements.forEach( r => { + + result = result.substring( 0, r.start ) + r.replacement + result.substring( r.end ); + + } ); + + return result; + + } + + async format( code ) { + + try { + + code = this.removeUnusedImports( code ); + + const linter = new Linter(); + const formatRules = { + 'array-bracket-spacing': [ 'error', 'always', { 'singleValue': true, 'arraysInArrays': false } ], + 'block-spacing': [ 'error', 'always' ], + 'brace-style': [ 'error', '1tbs', { 'allowSingleLine': true } ], + 'comma-spacing': [ 'error', { 'before': false, 'after': true } ], + 'comma-style': [ 2, 'last' ], + 'computed-property-spacing': [ 'error', 'always' ], + 'eol-last': [ 'error', 'always' ], + 'func-call-spacing': [ 'error', 'never' ], + 'indent': [ 'error', 'tab', { 'SwitchCase': 1 } ], + 'key-spacing': [ 'error', { 'beforeColon': false } ], + 'new-parens': [ 'error' ], + 'no-trailing-spaces': [ 'error', { 'skipBlankLines': false } ], + 'no-whitespace-before-property': [ 'error' ], + 'object-curly-spacing': [ 'error', 'always' ], + 'padded-blocks': [ 'error', { + 'blocks': 'always', + 'switches': 'always', + 'classes': 'always' + } ], + 'semi': [ 'error', 'always', { 'omitLastInOneLineBlock': true } ], + 'semi-spacing': [ 'error', { 'before': false, 'after': true } ], + 'space-before-blocks': [ 'error', { 'functions': 'always', 'keywords': 'always', 'classes': 'always' } ], + 'space-before-function-paren': [ 'error', { + 'anonymous': 'always', + 'named': 'never', + 'asyncArrow': 'ignore' + } ], + 'space-in-parens': [ 'error', 'always' ], + 'space-infix-ops': [ 'error' ], + 'space-unary-ops': [ 'error', { + 'words': true, + 'nonwords': true, + 'overrides': {} + } ], + 'keyword-spacing': [ 'error', { 'before': true, 'after': true } ], + 'padding-line-between-statements': [ + 'error', + { 'blankLine': 'always', 'prev': 'block-like', 'next': '*' } + ], + 'no-multi-spaces': 2, + 'no-extra-semi': 1, + 'quotes': [ 'error', 'single' ], + 'prefer-const': [ 'error', { + 'destructuring': 'any', + 'ignoreReadBeforeAssign': false + } ] + }; + + const result = linter.verifyAndFix( code, { + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module' + }, + rules: formatRules + } ); + + return result.fixed ? result.output : code; + + } catch ( err ) { + + console.error( 'Error formatting code:', err ); + return code; + + } + + } + + static async format( code ) { + + return new CodeCompiler().format( code ); + + } + +} + +export { CodeCompiler }; diff --git a/tsl/js/code/CodeRunner.js b/tsl/js/code/CodeRunner.js new file mode 100644 index 00000000000000..f44f51bccb6e4a --- /dev/null +++ b/tsl/js/code/CodeRunner.js @@ -0,0 +1,1099 @@ +import { EventDispatcher } from 'three'; +import * as acorn from 'acorn'; + +let importMap = { imports: {} }; + +try { + + const importMapEl = document.querySelector( 'script[type="importmap"]' ); + if ( importMapEl ) { + + importMap = JSON.parse( importMapEl.textContent ); + + } + +} catch ( e ) { + + console.error( 'Error parsing importmap', e ); + +} + +// + +function parseScript( code ) { + + const importDeclarations = []; + const declaredSymbols = new Set(); + + let ast; + try { + + ast = acorn.parse( code, { ecmaVersion: 'latest', sourceType: 'module' } ); + + } catch { + + return { importDeclarations, declaredSymbols }; + + } + + const extractPattern = ( pattern ) => { + + if ( ! pattern ) return; + if ( pattern.type === 'Identifier' ) { + + declaredSymbols.add( pattern.name ); + + } else if ( pattern.type === 'ObjectPattern' ) { + + pattern.properties.forEach( prop => extractPattern( prop.value || prop.argument ) ); + + } else if ( pattern.type === 'ArrayPattern' ) { + + pattern.elements.forEach( elem => extractPattern( elem ) ); + + } else if ( pattern.type === 'AssignmentPattern' ) { + + extractPattern( pattern.left ); + + } else if ( pattern.type === 'RestElement' ) { + + extractPattern( pattern.argument ); + + } + + }; + + ast.body.forEach( node => { + + if ( node.type === 'ImportDeclaration' ) { + + const moduleName = node.source.value; + const fullMatch = code.substring( node.start, node.end ); + + const specifiers = []; + node.specifiers.forEach( spec => { + + if ( spec.type === 'ImportSpecifier' ) { + + specifiers.push( { + type: 'named', + imported: spec.imported.type === 'Identifier' ? spec.imported.name : spec.imported.value, + local: spec.local.name + } ); + + } else if ( spec.type === 'ImportDefaultSpecifier' ) { + + specifiers.push( { + type: 'default', + imported: 'default', + local: spec.local.name + } ); + + } else if ( spec.type === 'ImportNamespaceSpecifier' ) { + + specifiers.push( { + type: 'namespace', + imported: '*', + local: spec.local.name + } ); + + } + + } ); + + importDeclarations.push( { + start: node.start, + end: node.end, + moduleName: moduleName, + fullMatch: fullMatch, + specifiers: specifiers + } ); + + } + + let decl = node; + if ( node.type === 'ExportNamedDeclaration' || node.type === 'ExportDefaultDeclaration' ) { + + decl = node.declaration; + if ( node.specifiers ) { + + node.specifiers.forEach( s => { + + if ( s.local ) declaredSymbols.add( s.local.name ); + + } ); + + } + + } + + if ( decl ) { + + if ( decl.type === 'VariableDeclaration' ) { + + decl.declarations.forEach( d => extractPattern( d.id ) ); + + } else if ( decl.type === 'FunctionDeclaration' || decl.type === 'ClassDeclaration' ) { + + if ( decl.id ) declaredSymbols.add( decl.id.name ); + + } + + } + + } ); + + return { importDeclarations, declaredSymbols }; + +} + +function stripImportDeclarations( code, declarations ) { + + const sorted = [ ...declarations ].sort( ( a, b ) => b.start - a.start ); + let result = code; + sorted.forEach( decl => { + + const snippet = code.substring( decl.start, decl.end ); + const linePreserved = snippet.replace( /[^\n]/g, '' ); + result = result.substring( 0, decl.start ) + linePreserved + result.substring( decl.end ); + + } ); + return result; + +} + +function processExportDeclarations( code ) { + + let cleanText = code; + const exportedSymbols = []; + + // 1. Parse braced exports (e.g., export { foo, bar as baz };) + const bracedExportRegex = /export\s*\{([\s\S]*?)\};?/g; + let bracedMatch; + while ( ( bracedMatch = bracedExportRegex.exec( cleanText ) ) !== null ) { + + const symbolList = bracedMatch[ 1 ].split( ',' ).map( s => s.trim() ).filter( Boolean ); + symbolList.forEach( symbol => { + + let localName = symbol; + let exportName = symbol; + if ( symbol.includes( ' as ' ) ) { + + const parts = symbol.split( /\s+as\s+/ ); + localName = parts[ 0 ].trim(); + exportName = parts[ 1 ].trim(); + + } + + exportedSymbols.push( { local: localName, export: exportName } ); + + } ); + + } + + cleanText = cleanText.replace( bracedExportRegex, '' ); + + // 2. Parse inline variable exports (e.g., export const foo = 1; or export let a = 1, b = 2;) + cleanText = cleanText.replace( /export\s+(const|let|var)\s+([^;\n]+)/g, ( match, type, decls ) => { + + const parts = decls.split( ',' ); + parts.forEach( p => { + + const name = p.trim().split( '=' )[ 0 ].trim().split( /\s+/ )[ 0 ]; + if ( /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test( name ) ) { + + exportedSymbols.push( { local: name, export: name } ); + + } + + } ); + + return `${type} ${decls}`; + + } ); + + // 3. Parse inline function or class exports (e.g., export function foo() {}, export async function foo() {}) + cleanText = cleanText.replace( /export\s+(async\s+)?(function\*?|class)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)/g, ( match, asyncPrefix, type, name ) => { + + exportedSymbols.push( { local: name, export: name } ); + return `${asyncPrefix || ''}${type} ${name}`; + + } ); + + // 4. Parse default function/class declaration exports (e.g., export default function foo() {}) + cleanText = cleanText.replace( /export\s+default\s+(async\s+)?(function\*?|class)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)/g, ( match, asyncPrefix, type, name ) => { + + exportedSymbols.push( { local: name, export: 'default' } ); + return `${asyncPrefix || ''}${type} ${name}`; + + } ); + + // 5. Parse default anonymous function/class exports (e.g., export default function() {}) + cleanText = cleanText.replace( /export\s+default\s+(async\s+)?(function\*?|class)\s*\(/g, ( match, asyncPrefix, type ) => { + + const name = '__default_export__'; + exportedSymbols.push( { local: name, export: 'default' } ); + return `${asyncPrefix || ''}${type} ${name}(`; + + } ); + + // 6. Parse default expression exports (e.g., export default foo;) + cleanText = cleanText.replace( /export\s+default\s+([^;]+);?/g, ( match, expression ) => { + + const name = '__default_export__'; + exportedSymbols.push( { local: name, export: 'default' } ); + return `const ${name} = ${expression};`; + + } ); + + return { cleanText, exportedSymbols }; + +} + +function serializeArg( arg, depth = 0, seen = new WeakSet() ) { + + if ( arg === null ) return 'null'; + if ( arg === undefined ) return 'undefined'; + if ( typeof arg === 'string' ) return arg; + if ( typeof arg === 'number' || typeof arg === 'boolean' || typeof arg === 'symbol' || typeof arg === 'bigint' ) return String( arg ); + if ( typeof arg === 'function' ) return `[Function: ${arg.name || 'anonymous'}]`; + + if ( arg instanceof Error ) { + + return arg.message || String( arg ); + + } + + if ( seen.has( arg ) ) return '[Circular]'; + seen.add( arg ); + + if ( arg instanceof HTMLElement ) { + + return `<${arg.tagName.toLowerCase()}${arg.id ? '#' + arg.id : ''}${arg.className ? '.' + arg.className.split( ' ' ).join( '.' ) : ''}>`; + + } + + if ( Array.isArray( arg ) ) { + + if ( depth > 2 ) return '[Array]'; + const items = arg.slice( 0, 10 ).map( item => serializeArg( item, depth + 1, seen ) ); + if ( arg.length > 10 ) items.push( `... ${arg.length - 10} more` ); + return `[ ${items.join( ', ' )} ]`; + + } + + const constructorName = arg.constructor ? arg.constructor.name : 'Object'; + if ( constructorName && constructorName !== 'Object' ) { + + if ( [ 'Vector2', 'Vector3', 'Vector4', 'Color' ].includes( constructorName ) ) { + + if ( constructorName === 'Color' ) { + + return `Color( r: ${arg.r}, g: ${arg.g}, b: ${arg.b} )`; + + } + + const coords = [ arg.x, arg.y, arg.z, arg.w ].filter( v => v !== undefined ); + return `${constructorName}( ${coords.join( ', ' )} )`; + + } + + const desc = []; + if ( arg.type ) desc.push( `type: "${arg.type}"` ); + if ( arg.name ) desc.push( `name: "${arg.name}"` ); + if ( arg.uuid ) desc.push( `uuid: "${arg.uuid.substring( 0, 8 )}..."` ); + + const descStr = desc.length > 0 ? ` { ${desc.join( ', ' )} }` : ''; + return `${constructorName}${descStr}`; + + } + + if ( depth > 2 ) return '[Object]'; + const keys = Object.keys( arg ); + const entries = keys.slice( 0, 10 ).map( key => { + + return `${key}: ${serializeArg( arg[ key ], depth + 1, seen )}`; + + } ); + if ( keys.length > 10 ) entries.push( `... ${keys.length - 10} more` ); + return `{ ${entries.join( ', ' )} }`; + +} + +function isStandardModule( moduleName, imports ) { + + const inRunnerImports = Object.keys( imports ).some( lib => moduleName === lib || moduleName.startsWith( lib + '/' ) ); + if ( inRunnerImports ) return true; + + const inImportMap = Object.keys( importMap.imports ).some( lib => { + + if ( lib.endsWith( '/' ) ) { + + return moduleName.startsWith( lib ); + + } + + return moduleName === lib || moduleName.startsWith( lib + '/' ); + + } ); + + return inImportMap; + +} + +function resolvePath( importerName, importPath ) { + + if ( importPath.startsWith( './' ) || importPath.startsWith( '../' ) ) { + + const importerParts = importerName.split( '/' ); + importerParts.pop(); // Remove the filename/leaf name + + const importParts = importPath.split( '/' ); + for ( const part of importParts ) { + + if ( part === '.' ) { + + continue; + + } else if ( part === '..' ) { + + importerParts.pop(); + + } else if ( part !== '' ) { + + importerParts.push( part ); + + } + + } + + return importerParts.join( '/' ); + + } + + return importPath; + +} + + +const LIFECYCLE_METHODS = [ 'init', 'refresh', 'update', 'resize', 'dispose' ]; + +class CodeRunner extends EventDispatcher { + + constructor( env = {} ) { + + super(); + + this.env = env; + this.imports = {}; + + this.activeScriptNames = []; + + this.scripts = {}; // Cache of loaded scripts + this.loadingScripts = new Set(); + + this.customConsole = new Proxy( console, { + get: ( target, prop ) => { + + if ( prop === 'log' || prop === 'error' || prop === 'warn' || prop === 'info' ) { + + return ( ...args ) => { + + target[ prop ]( ...args ); + + const firstArg = args[ 0 ]; + if ( typeof firstArg === 'string' && firstArg.includes( '%c' ) ) { + + return; + + } + + const msg = args.map( arg => serializeArg( arg ) ).join( ' ' ); + + let eventType = 'log'; + if ( prop === 'error' ) eventType = 'error-log'; + else if ( prop === 'warn' ) eventType = 'warn-log'; + + this.dispatchEvent( { type: eventType, message: msg } ); + + }; + + } + + const val = target[ prop ]; + return typeof val === 'function' ? val.bind( target ) : val; + + } + } ); + + } + + setImport( name, module ) { + + this.imports[ name ] = module; + + } + + setValue( name, value ) { + + this.env[ name ] = value; + + } + + activateScript( name ) { + + const scriptConfig = this.scripts[ name ]; + if ( ! scriptConfig ) return; + + if ( scriptConfig.dependencies ) { + + for ( const dep of scriptConfig.dependencies ) { + + this.activateScript( dep ); + + } + + } + + if ( ! this.activeScriptNames.includes( name ) ) { + + this.activeScriptNames.push( name ); + + } + + } + + async load( name ) { + + const scriptConfig = this.scripts[ name ]; + if ( ! scriptConfig ) return null; + + if ( ! scriptConfig.dependencies ) { + + scriptConfig.dependencies = []; + + } + + if ( scriptConfig.instance ) return scriptConfig.instance; + + if ( this.loadingScripts.has( name ) ) { + + return scriptConfig.instance || {}; + + } + + if ( ! scriptConfig.promise ) { + + this.loadingScripts.add( name ); + + scriptConfig.promise = ( async () => { + + try { + + let text; + if ( scriptConfig.text !== undefined && scriptConfig.text !== null ) { + + text = scriptConfig.text; + + } else { + + const response = await fetch( scriptConfig.url ); + if ( ! response.ok ) { + + throw new Error( `Failed to load script "${name}": Server returned status ${response.status}.` ); + + } + + text = await response.text(); + scriptConfig.text = text; + + } + + const { importDeclarations, declaredSymbols } = parseScript( text ); + + const symbols = []; + const values = []; + + for ( const [ key, val ] of Object.entries( this.env ) ) { + + if ( ! declaredSymbols.has( key ) ) { + + symbols.push( key ); + values.push( val ); + + } + + } + + symbols.push( 'console' ); + values.push( this.customConsole ); + + const importPromises = []; + + importDeclarations.forEach( decl => { + + const moduleName = decl.moduleName; + const fullMatch = decl.fullMatch; + + importPromises.push( ( async () => { + + let moduleObj = this.imports[ moduleName ]; + if ( ! moduleObj ) { + + const isStandard = isStandardModule( moduleName, this.imports ); + if ( ! isStandard ) { + + const resolvedPath = resolvePath( name, moduleName ); + const baseName = resolvedPath.replace( /\.js$/, '' ); + if ( ! this.scripts[ baseName ] ) { + + this.scripts[ baseName ] = { + url: `./js/imports/scripts/${baseName}.js`, + instance: null, + promise: null, + dependencies: [] + }; + + } + + if ( ! scriptConfig.dependencies.includes( baseName ) ) { + + scriptConfig.dependencies.push( baseName ); + + } + + moduleObj = await this.load( baseName ); + + } else { + + try { + + moduleObj = await import( moduleName ); + + } catch ( err ) { + + const charIndex = text.indexOf( fullMatch ); + const lineNumber = charIndex !== - 1 ? text.substring( 0, charIndex ).split( '\n' ).length : 1; + const error = new Error( `Failed to load import "${moduleName}" in script "${name}.js". Make sure the module path is correct.` ); + error.customLineNumber = lineNumber; + throw error; + + } + + } + + } + + if ( moduleObj ) { + + decl.specifiers.forEach( spec => { + + if ( spec.type === 'named' ) { + + symbols.push( spec.local ); + values.push( moduleObj[ spec.imported ] ); + + } else if ( spec.type === 'namespace' ) { + + symbols.push( spec.local ); + values.push( moduleObj ); + + } else if ( spec.type === 'default' ) { + + symbols.push( spec.local ); + values.push( moduleObj[ 'default' ] ); + + } + + } ); + + } + + } )() ); + + } ); + + if ( importPromises.length > 0 ) { + + await Promise.all( importPromises ); + + } + + const cleanImportsText = stripImportDeclarations( text, importDeclarations ); + const { cleanText, exportedSymbols } = processExportDeclarations( cleanImportsText ); + + const returnFields = LIFECYCLE_METHODS.map( name => `${name}: typeof ${name} !== 'undefined' ? ${name} : undefined` ); + exportedSymbols.forEach( symbol => { + + if ( ! LIFECYCLE_METHODS.includes( symbol.export ) ) { + + returnFields.push( `get "${symbol.export}"() { return typeof ${symbol.local} !== \'undefined\' ? ${symbol.local} : undefined; }` ); + + } + + } ); + + const wrapperFn = new Function( ...symbols, `${cleanText}\nreturn { ${returnFields.join( ', ' )} };\n//# sourceURL=${name}.js` ); + + scriptConfig.instance = wrapperFn( ...values ); + + if ( scriptConfig.instance ) { + + for ( const key of Object.keys( scriptConfig.instance ) ) { + + if ( ! LIFECYCLE_METHODS.includes( key ) ) { + + Object.defineProperty( this.env, key, { + get: () => scriptConfig.instance ? scriptConfig.instance[ key ] : undefined, + configurable: true, + enumerable: true + } ); + + } + + } + + } + + if ( scriptConfig.instance && scriptConfig.instance.init ) { + + await scriptConfig.instance.init(); + + } + + return scriptConfig.instance; + + } finally { + + this.loadingScripts.delete( name ); + + } + + } )(); + + } + + return scriptConfig.promise; + + } + call( name, ...args ) { + + this.activeScriptNames.forEach( scriptName => { + + const scriptConfig = this.scripts[ scriptName ]; + if ( scriptConfig && scriptConfig.instance && scriptConfig.instance[ name ] ) { + + scriptConfig.instance[ name ]( ...args ); + + } + + } ); + + } + + async run( code ) { + + this.dispatchEvent( { type: 'start' } ); + + // Dispose previous main script + const prevMain = this.scripts[ '__main__' ]; + if ( prevMain && prevMain.instance && prevMain.instance.dispose ) { + + prevMain.instance.dispose(); + + } + + try { + + const { importDeclarations, declaredSymbols } = parseScript( code ); + + const symbols = []; + const values = []; + + const importedCustomScripts = []; + + for ( const decl of importDeclarations ) { + + const moduleName = decl.moduleName; + const fullMatch = decl.fullMatch; + + const isStandard = isStandardModule( moduleName, this.imports ); + if ( ! isStandard ) { + + const resolvedPath = resolvePath( '__main__', moduleName ); + const baseName = resolvedPath.replace( /\.js$/, '' ); + if ( ! importedCustomScripts.includes( baseName ) ) { + + importedCustomScripts.push( baseName ); + + } + + } else { + + let moduleObj = this.imports[ moduleName ]; + + if ( ! moduleObj ) { + + try { + + moduleObj = await import( moduleName ); + + } catch ( err ) { + + const charIndex = code.indexOf( fullMatch ); + const lineNumber = charIndex !== - 1 ? code.substring( 0, charIndex ).split( '\n' ).length : 1; + const error = new Error( `Failed to load import "${moduleName}" in script. Make sure the module path/importmap is correct.` ); + error.customLineNumber = lineNumber; + throw error; + + } + + } + + if ( moduleObj ) { + + decl.specifiers.forEach( spec => { + + if ( spec.type === 'named' ) { + + if ( ! symbols.includes( spec.local ) ) { + + symbols.push( spec.local ); + values.push( moduleObj[ spec.imported ] ); + + } + + } else if ( spec.type === 'namespace' ) { + + if ( ! symbols.includes( spec.local ) ) { + + symbols.push( spec.local ); + values.push( moduleObj ); + + } + + } else if ( spec.type === 'default' ) { + + if ( ! symbols.includes( spec.local ) ) { + + symbols.push( spec.local ); + values.push( moduleObj[ 'default' ] ); + + } + + } + + } ); + + } + + } + + } + + // Execute scene scripts dynamically + const activeModules = {}; + const prevActiveCustomScripts = this.activeScriptNames.filter( name => name !== '__main__' ); + + this.activeScriptNames = []; + + // Load / Create active scripts + for ( const baseName of importedCustomScripts ) { + + if ( ! this.scripts[ baseName ] ) { + + this.scripts[ baseName ] = { + url: `./js/imports/scripts/${baseName}.js`, + instance: null, + promise: null, + dependencies: [] + }; + + } + + try { + + await this.load( baseName ); + + } catch ( err ) { + + // Find where the script was imported in the main editor code + const matchRegex = new RegExp( `import\\s+(?:[\\s\\S]*?\\s+from\\s+)?['"](\\.\\/)?${baseName}(\\.js)?['"];?`, 'i' ); + const match = code.match( matchRegex ); + if ( match ) { + + const charIndex = code.indexOf( match[ 0 ] ); + if ( charIndex !== - 1 ) { + + err.customLineNumber = code.substring( 0, charIndex ).split( '\n' ).length; + + } + + } + + throw err; + + } + + } + + // Activate scripts recursively (building correct activeScriptNames order) + for ( const baseName of importedCustomScripts ) { + + this.activateScript( baseName ); + + } + + // Dispose and clear removed scripts (using complete activeScriptNames list) + const removedCustomScripts = prevActiveCustomScripts.filter( name => ! this.activeScriptNames.includes( name ) ); + for ( const baseName of removedCustomScripts ) { + + const scriptConfig = this.scripts[ baseName ]; + if ( scriptConfig ) { + + if ( scriptConfig.instance ) { + + if ( scriptConfig.instance.dispose ) { + + scriptConfig.instance.dispose(); + + } + + for ( const key of Object.keys( scriptConfig.instance ) ) { + + if ( ! LIFECYCLE_METHODS.includes( key ) ) { + + delete this.env[ key ]; + + } + + } + + } + + scriptConfig.instance = null; + scriptConfig.promise = null; + + } + + } + + // Refresh, resize, and expose exports for all active custom scripts + for ( const baseName of this.activeScriptNames ) { + + const scriptConfig = this.scripts[ baseName ]; + const instance = scriptConfig ? scriptConfig.instance : null; + if ( instance ) { + + if ( instance.refresh ) { + + await instance.refresh(); + + } + + if ( instance.resize && this.env.renderer ) { + + const width = this.env.renderer.domElement.clientWidth; + const height = this.env.renderer.domElement.clientHeight; + if ( width > 0 && height > 0 ) { + + instance.resize( width, height ); + + } + + } + + for ( const key of Object.keys( instance ) ) { + + if ( ! LIFECYCLE_METHODS.includes( key ) && instance[ key ] !== undefined ) { + + activeModules[ key ] = instance[ key ]; + + const desc = Object.getOwnPropertyDescriptor( this.env, key ); + if ( ! desc || ! desc.get ) { + + this.env[ key ] = instance[ key ]; + + } + + } + + } + + } + + } + + // Inject active modules into parameters + for ( const [ name, obj ] of Object.entries( activeModules ) ) { + + if ( ! symbols.includes( name ) ) { + + symbols.push( name ); + values.push( obj ); + + } + + } + + // Inject runner env variables (e.g. renderer) not shadowed by local declarations + for ( const [ key, val ] of Object.entries( this.env ) ) { + + if ( ! symbols.includes( key ) && ! declaredSymbols.has( key ) ) { + + symbols.push( key ); + values.push( val ); + + } + + } + + symbols.push( 'console' ); + values.push( this.customConsole ); + + // Strip all import and export statements from code so it can run inside Function body + const strippedImportsCode = stripImportDeclarations( code, importDeclarations ); + const { cleanText: strippedCode, exportedSymbols } = processExportDeclarations( strippedImportsCode ); + + const returnFields = LIFECYCLE_METHODS.map( name => `${name}: typeof ${name} !== 'undefined' ? ${name} : undefined` ); + exportedSymbols.forEach( symbol => { + + if ( ! LIFECYCLE_METHODS.includes( symbol.export ) ) { + + returnFields.push( `get "${symbol.export}"() { return typeof ${symbol.local} !== \'undefined\' ? ${symbol.local} : undefined; }` ); + + } + + } ); + + const executor = new Function( ...symbols, `${strippedCode}\nreturn { ${returnFields.join( ', ' )} };\n//# sourceURL=playground-eval.js` ); + const instance = executor( ...values ); + + this.scripts[ '__main__' ] = { + url: null, + instance: instance, + promise: Promise.resolve( instance ) + }; + this.activeScriptNames.push( '__main__' ); + + if ( instance && instance.init ) { + + await instance.init(); + + } + + if ( instance && instance.resize && this.env.renderer ) { + + const width = this.env.renderer.domElement.clientWidth; + const height = this.env.renderer.domElement.clientHeight; + if ( width > 0 && height > 0 ) { + + instance.resize( width, height ); + + } + + } + + this.dispatchEvent( { type: 'success' } ); + + } catch ( e ) { + + // Parse error stack to find line/col + let line = e.customLineNumber !== undefined ? e.customLineNumber : null; + let column = null; + if ( line === null && e.stack ) { + + const pgMatch = e.stack.match( /playground-eval\.js:(\d+):(\d+)/ ); + if ( pgMatch ) { + + line = parseInt( pgMatch[ 1 ] ) - 2; + column = parseInt( pgMatch[ 2 ] ); + + } else { + + // Chrome / Safari + const match = e.stack.match( /:(\d+):(\d+)/ ); + if ( match ) { + + line = parseInt( match[ 1 ] ) - 2; + column = parseInt( match[ 2 ] ); + + } else { + + // Firefox fallback + const ffMatch = e.stack.match( /Function:(\d+):(\d+)/ ); + if ( ffMatch ) { + + line = parseInt( ffMatch[ 1 ] ) - 2; + column = parseInt( ffMatch[ 2 ] ); + + } + + } + + } + + } + + let displayMessage = e.message || e.toString(); + if ( line !== null && line > 0 ) { + + displayMessage = `Line ${line}: ${displayMessage}`; + + } + + this.dispatchEvent( { + type: 'error', + error: e, + line: line, + column: column, + message: displayMessage + } ); + + } + + } + + dispose() { + + for ( const baseName of Object.keys( this.scripts ) ) { + + const scriptConfig = this.scripts[ baseName ]; + if ( scriptConfig && scriptConfig.instance ) { + + if ( scriptConfig.instance.dispose ) { + + try { + + scriptConfig.instance.dispose(); + + } catch ( e ) { + + console.error( `Error disposing script ${baseName}:`, e ); + + } + + } + + for ( const key of Object.keys( scriptConfig.instance ) ) { + + if ( ! LIFECYCLE_METHODS.includes( key ) ) { + + delete this.env[ key ]; + + } + + } + + } + + } + + this.scripts = {}; + this.activeScriptNames = []; + + } + +} + +export { CodeRunner }; diff --git a/tsl/js/editor/CodeEditor.js b/tsl/js/editor/CodeEditor.js new file mode 100644 index 00000000000000..d33940381276d4 --- /dev/null +++ b/tsl/js/editor/CodeEditor.js @@ -0,0 +1,1027 @@ +import * as THREE from 'three'; +import * as TSL from 'three/tsl'; +import { EventDispatcher } from 'three'; +import { generateDeclarations } from '../utils/CodeEditorUtils.js'; +import { CodeCompiler } from '../code/CodeCompiler.js'; + +let _monaco; +let _monacoConfigured = false; +let _currentImportedSymbolsStr = ''; + +const tslConstants = new Set(); +const tslFunctions = new Set(); +const tslChaining = new Set(); + +const buildRegex = ( words, prefix = '', suffix = '\\b' ) => { + + const escaped = Array.from( words ) + .map( w => w.replace( /[-\/\\^$*+?.()|[\]{}]/g, '\\$&' ) ) + .sort( ( a, b ) => b.length - a.length ); + return new RegExp( `${prefix}(${escaped.join( '|' )})${suffix}` ); + +}; + +const updateTokenizerForCode = async () => { + + try { + + const importedSymbols = new Set(); + const importRegex = /import\s*\{([^}]+)\}\s*from\s*['"](?:three\/tsl|three\/addons\/tsl\/[^'"]+)['"]/g; + + // Gather imports from all open models in Monaco + _monaco.editor.getModels().forEach( model => { + + const langId = model.getLanguageId(); + if ( langId === 'javascript' || langId === 'typescript' ) { + + let match; + importRegex.lastIndex = 0; + while ( ( match = importRegex.exec( model.getValue() ) ) !== null ) { + + match[ 1 ].split( ',' ).forEach( s => { + + const trimmed = s.trim(); + if ( trimmed ) importedSymbols.add( trimmed ); + + } ); + + } + + } + + } ); + + const importedSymbolsStr = Array.from( importedSymbols ).sort().join( ',' ); + if ( importedSymbolsStr === _currentImportedSymbolsStr ) { + + return; + + } + + _currentImportedSymbolsStr = importedSymbolsStr; + + const activeConstants = Array.from( tslConstants ).filter( key => importedSymbols.has( key ) ); + const activeFunctions = Array.from( tslFunctions ).filter( key => importedSymbols.has( key ) ); + + const allLangs = _monaco.languages.getLanguages(); + for ( const langId of [ 'javascript', 'typescript' ] ) { + + const langDef = allLangs.find( ( { id } ) => id === langId ); + if ( langDef && typeof langDef.loader === 'function' ) { + + const langMod = await langDef.loader(); + const lang = langMod.language; + + if ( lang && lang.tokenizer && lang.tokenizer.root ) { + + // Clean previous rules + lang.tokenizer.root = lang.tokenizer.root.filter( rule => { + + if ( Array.isArray( rule ) ) { + + const action = rule[ 1 ]; + if ( typeof action === 'string' ) { + + return action !== 'tsl-function-symbol' && action !== 'tsl-constant-symbol'; + + } else if ( Array.isArray( action ) ) { + + return ! action.includes( 'tsl-chained-symbol' ); + + } + + } + + return true; + + } ); + + // Register new rules + if ( tslChaining.size > 0 ) { + + lang.tokenizer.root.unshift( [ buildRegex( tslChaining, '(\\.)' ), [ 'delimiter', 'tsl-chained-symbol' ]] ); + + } + + if ( activeFunctions.length > 0 ) { + + lang.tokenizer.root.unshift( [ buildRegex( activeFunctions, '\\b' ), 'tsl-function-symbol' ] ); + + } + + if ( activeConstants.length > 0 ) { + + lang.tokenizer.root.unshift( [ buildRegex( activeConstants, '\\b' ), 'tsl-constant-symbol' ] ); + + } + + _monaco.languages.setMonarchTokensProvider( langId, lang ); + + } + + } + + } + + } catch ( e ) { + + console.error( 'Failed to update Monaco tokenizer for TSL imports', e ); + + } + +}; + +const ADDONS_TSL_IMPORTS = { + + // display + hashBlur: 'three/addons/tsl/display/hashBlur.js', + gaussianBlur: 'three/addons/tsl/display/GaussianBlurNode.js', + premultipliedGaussianBlur: 'three/addons/tsl/display/GaussianBlurNode.js', + boxBlur: 'three/addons/tsl/display/boxBlur.js', + radialBlur: 'three/addons/tsl/display/radialBlur.js', + depthAwareBlur: 'three/addons/tsl/display/depthAwareBlur.js', + depthAwareBlend: 'three/addons/tsl/display/depthAwareBlend.js', + bilateralBlur: 'three/addons/tsl/display/BilateralBlurNode.js', + afterImage: 'three/addons/tsl/display/AfterImageNode.js', + anaglyphPass: 'three/addons/tsl/display/AnaglyphPassNode.js', + bleachBypass: 'three/addons/tsl/display/BleachBypass.js', + bloom: 'three/addons/tsl/display/BloomNode.js', + crt: 'three/addons/tsl/display/CRT.js', + chromaticAberration: 'three/addons/tsl/display/ChromaticAberrationNode.js', + denoise: 'three/addons/tsl/display/DenoiseNode.js', + depthOfField: 'three/addons/tsl/display/DepthOfFieldNode.js', + dof: 'three/addons/tsl/display/DepthOfFieldNode.js', + dotScreen: 'three/addons/tsl/display/DotScreenNode.js', + film: 'three/addons/tsl/display/FilmNode.js', + fsr1: 'three/addons/tsl/display/FSR1Node.js', + fxaa: 'three/addons/tsl/display/FXAANode.js', + godrays: 'three/addons/tsl/display/GodraysNode.js', + gtao: 'three/addons/tsl/display/GTAONode.js', + importanceSampledEnvironment: 'three/addons/tsl/display/ImportanceSampledEnvironment.js', + lensflare: 'three/addons/tsl/display/LensflareNode.js', + lut3D: 'three/addons/tsl/display/Lut3DNode.js', + motionBlur: 'three/addons/tsl/display/MotionBlur.js', + outline: 'three/addons/tsl/display/OutlineNode.js', + parallaxBarrierPass: 'three/addons/tsl/display/ParallaxBarrierPassNode.js', + pixelationPass: 'three/addons/tsl/display/PixelationPassNode.js', + recurrentDenoise: 'three/addons/tsl/display/RecurrentDenoiseNode.js', + retroPass: 'three/addons/tsl/display/RetroPassNode.js', + rgbShift: 'three/addons/tsl/display/RGBShiftNode.js', + sepia: 'three/addons/tsl/display/Sepia.js', + shape: 'three/addons/tsl/display/Shape.js', + sharpen: 'three/addons/tsl/display/SharpenNode.js', + smaa: 'three/addons/tsl/display/SMAANode.js', + sobelOperator: 'three/addons/tsl/display/SobelOperatorNode.js', + ssaaPass: 'three/addons/tsl/display/SSAAPassNode.js', + ssao: 'three/addons/tsl/display/SSAONode.js', + ssgi: 'three/addons/tsl/display/SSGINode.js', + ssr: 'three/addons/tsl/display/SSRNode.js', + sss: 'three/addons/tsl/display/SSSNode.js', + stereoCompositePass: 'three/addons/tsl/display/StereoCompositePassNode.js', + stereoPass: 'three/addons/tsl/display/StereoPassNode.js', + taau: 'three/addons/tsl/display/TAAUNode.js', + temporalReproject: 'three/addons/tsl/display/TemporalReprojectNode.js', + traa: 'three/addons/tsl/display/TRAANode.js', + transition: 'three/addons/tsl/display/TransitionNode.js', + + // math + bayer16: 'three/addons/tsl/math/Bayer.js', + bayerDither: 'three/addons/tsl/math/Bayer.js', + curlNoise: 'three/addons/tsl/math/curlNoise.js', + snoise: 'three/addons/tsl/math/curlNoise.js', + snoiseVec3: 'three/addons/tsl/math/curlNoise.js', + + // utils + getGroundProjectedNormal: 'three/addons/tsl/utils/GroundedSkybox.js', + RaymarchingBox: 'three/addons/tsl/utils/Raymarching.js', + bindAnalyticNoise: 'three/addons/tsl/utils/RNoise.js', + softParticles: 'three/addons/tsl/utils/SoftParticles.js' +}; + +let _monacoLoaderPromise = null; + +function ensureMonaco() { + + if ( _monaco ) return Promise.resolve( _monaco ); + if ( _monacoLoaderPromise ) return _monacoLoaderPromise; + + _monacoLoaderPromise = new Promise( ( resolve, reject ) => { + + const script = document.createElement( 'script' ); + script.src = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.55.1/min/vs/loader.js'; + script.onload = () => { + + require.config( { paths: { 'vs': 'https://cdn.jsdelivr.net/npm/monaco-editor@0.55.1/min/vs' } } ); + require( [ 'vs/editor/editor.main' ], () => { + + _monaco = window.monaco; + + resolve( _monaco ); + + }, reject ); + + }; + + script.onerror = reject; + document.head.appendChild( script ); + + } ); + + return _monacoLoaderPromise; + +} + +class CodeEditor extends EventDispatcher { + + constructor( { container, value = '', readOnly = false, language = 'javascript', scrollable = true } = {} ) { + + super(); + + this.container = container; + this.value = value; + this.readOnly = readOnly; + this.language = language; + this.scrollable = scrollable; + + this.editor = null; + this.isProgrammaticChange = false; + + this._initMonaco(); + + } + + _initMonaco() { + + ensureMonaco().then( () => { + + if ( ! _monacoConfigured ) { + + _monaco.editor.defineTheme( 'chatgpt-dark', { + base: 'vs-dark', + inherit: true, + rules: [ + { token: 'tsl-constant-symbol', foreground: '#00aeff' }, + { token: 'tsl-function-symbol', foreground: '#dcdcaa' }, + { token: 'tsl-chained-symbol', foreground: '#59d592' } + ], + colors: { + 'editor.background': '#15151a', + 'editor.lineHighlightBackground': '#2a2a33' + } + } ); + + // Gather TSL keywords dynamically once on configuration + try { + + // 1. Gather keys from TSL + Object.keys( TSL ).forEach( key => { + + if ( /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test( key ) ) { + + const val = TSL[ key ]; + if ( typeof val === 'function' ) { + + tslFunctions.add( key ); + + } else { + + tslConstants.add( key ); + + } + + } + + } ); + + // 2. Gather only methods (functions) from THREE.Node.prototype to support chaining + if ( THREE.Node && THREE.Node.prototype ) { + + let proto = THREE.Node.prototype; + while ( proto && proto !== Object.prototype ) { + + Object.getOwnPropertyNames( proto ).forEach( name => { + + if ( name === 'constructor' || name.startsWith( '_' ) ) return; + + if ( /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test( name ) ) { + + const desc = Object.getOwnPropertyDescriptor( proto, name ); + if ( desc && ! desc.get && ! desc.set && typeof desc.value === 'function' ) { + + tslChaining.add( name ); + + } + + } + + } ); + proto = Object.getPrototypeOf( proto ); + + } + + } + + // 3. Gather keys from TSL Addons + Object.keys( ADDONS_TSL_IMPORTS ).forEach( key => { + + if ( /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test( key ) ) { + + tslFunctions.add( key ); + + } + + } ); + + } catch ( e ) { + + console.error( 'Failed to populate TSL keyword sets', e ); + + } + + // Register completion provider for TSL and THREE auto-imports + const suggestionsTemplates = []; + + const customSnippets = [ + { + label: 'Fn', + kind: _monaco.languages.CompletionItemKind.Snippet, + detail: 'TSL Function (Object parameters)', + insertText: 'Fn( ( { ${1:arg} } ) => {\n\t${0}\n} )', + insertTextRules: _monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + moduleName: 'three/tsl' + }, + { + label: 'Fn', + kind: _monaco.languages.CompletionItemKind.Snippet, + detail: 'TSL Function (Array parameters)', + insertText: 'Fn( ( [ ${1:arg} ] ) => {\n\t${0}\n} )', + insertTextRules: _monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + moduleName: 'three/tsl' + }, + { + label: 'If', + kind: _monaco.languages.CompletionItemKind.Snippet, + detail: 'TSL Conditional branch', + insertText: 'If( ${1:condition}, () => {\n\t${0}\n} )', + insertTextRules: _monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + moduleName: 'three/tsl' + }, + { + label: 'ElseIf', + kind: _monaco.languages.CompletionItemKind.Snippet, + detail: 'TSL ElseIf chain', + insertText: 'ElseIf( ${1:condition}, () => {\n\t${0}\n} )', + insertTextRules: _monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet + }, + { + label: 'Else', + kind: _monaco.languages.CompletionItemKind.Snippet, + detail: 'TSL Else chain', + insertText: 'Else( () => {\n\t${0}\n} )', + insertTextRules: _monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet + }, + { + label: 'Loop', + kind: _monaco.languages.CompletionItemKind.Snippet, + detail: 'TSL Loop (count)', + insertText: 'Loop( ${1:count}, ( { i } ) => {\n\t${0}\n} )', + insertTextRules: _monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + moduleName: 'three/tsl' + }, + { + label: 'Loop', + kind: _monaco.languages.CompletionItemKind.Snippet, + detail: 'TSL Loop (range)', + insertText: 'Loop( { start: ${1:int( 0 )}, end: ${2:int( 10 )}, type: \'int\' }, ( { i } ) => {\n\t${0}\n} )', + insertTextRules: _monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + moduleName: 'three/tsl' + }, + { + label: 'Switch', + kind: _monaco.languages.CompletionItemKind.Snippet, + detail: 'TSL Switch-Case', + insertText: 'Switch( ${1:value} )\n\t.Case( ${2:0}, () => {\n\t\t${3}\n\t} )\n\t.Default( () => {\n\t\t${4}\n\t} );', + insertTextRules: _monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + moduleName: 'three/tsl' + }, + { + label: 'uniform', + kind: _monaco.languages.CompletionItemKind.Snippet, + detail: 'TSL uniform variable', + insertText: 'uniform( ${1:value} )', + insertTextRules: _monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + moduleName: 'three/tsl' + } + ]; + + const overriddenKeys = new Set( customSnippets.map( snippet => snippet.label ) ); + + customSnippets.forEach( snippet => suggestionsTemplates.push( snippet ) ); + + suggestionsTemplates.push( { + label: 'THREE', + kind: _monaco.languages.CompletionItemKind.Module, + detail: 'Auto-import THREE namespace', + insertText: 'THREE', + moduleName: 'three-namespace' + } ); + + Object.keys( TSL ).forEach( key => { + + if ( /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test( key ) ) { + + if ( overriddenKeys.has( key ) ) return; + + const val = TSL[ key ]; + let kind = _monaco.languages.CompletionItemKind.Variable; + if ( typeof val === 'function' ) { + + kind = _monaco.languages.CompletionItemKind.Function; + + } + + suggestionsTemplates.push( { + label: key, + kind: kind, + detail: 'Auto-import from three/tsl', + insertText: key, + moduleName: 'three/tsl' + } ); + + } + + } ); + + Object.keys( THREE ).forEach( key => { + + if ( /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test( key ) ) { + + const val = THREE[ key ]; + let kind = _monaco.languages.CompletionItemKind.Variable; + if ( typeof val === 'function' ) { + + if ( key[ 0 ] === key[ 0 ].toUpperCase() ) { + + kind = _monaco.languages.CompletionItemKind.Class; + + } else { + + kind = _monaco.languages.CompletionItemKind.Function; + + } + + } + + suggestionsTemplates.push( { + label: key, + kind: kind, + detail: 'Auto-import from three', + insertText: key, + moduleName: 'three' + } ); + + } + + } ); + + Object.entries( ADDONS_TSL_IMPORTS ).forEach( ( [ key, modulePath ] ) => { + + let kind = _monaco.languages.CompletionItemKind.Function; + if ( key[ 0 ] === key[ 0 ].toUpperCase() ) { + + kind = _monaco.languages.CompletionItemKind.Class; + + } + + suggestionsTemplates.push( { + label: key, + kind: kind, + detail: `Auto-import from ${modulePath}`, + insertText: key, + moduleName: modulePath + } ); + + } ); + + _monaco.languages.registerCompletionItemProvider( 'javascript', { + + provideCompletionItems: ( model, position ) => { + + const word = model.getWordUntilPosition( position ); + const range = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn + }; + + const hasImportForSymbol = ( code, symbol, moduleName ) => { + + const baseSymbol = symbol.split( '.' )[ 0 ]; + const escapedModule = moduleName.replace( /[-\/\\^$*+?.()|[\]{}]/g, '\\$&' ); + const regex = new RegExp( `import\\s+\\{([^\\}]*\\b${baseSymbol}\\b[^\\}]*)\\}\\s+from\\s+['"]${escapedModule}['"]` ); + return regex.test( code ); + + }; + + const getAdditionalTextEdits = ( model, symbol, moduleName ) => { + + const code = model.getValue(); + + if ( symbol === 'THREE' || moduleName === 'three-namespace' ) { + + const hasThreeImport = /import\s+\*\s+as\s+THREE\s+from\s+['"]three(?:|\/webgpu)['"];?/.test( code ); + if ( hasThreeImport ) return []; + + return [ + { + range: new _monaco.Range( 1, 1, 1, 1 ), + text: 'import * as THREE from \'three\';\n' + } + ]; + + } + + const baseSymbol = symbol.split( '.' )[ 0 ]; + if ( hasImportForSymbol( code, baseSymbol, moduleName ) ) { + + return []; + + } + + const escapedModule = moduleName.replace( /[-\/\\^$*+?.()|[\]{}]/g, '\\$&' ); + const importRegex = new RegExp( `import\\s+\\{([^\\}]*)\\}\\s+from\\s+['"]${escapedModule}['"];?`, 'g' ); + const match = importRegex.exec( code ); + + if ( match ) { + + const existingImportsStr = match[ 1 ]; + const startIdx = match.index; + const endIdx = importRegex.lastIndex; + + const symbolList = existingImportsStr.split( ',' ).map( s => s.trim() ).filter( Boolean ); + if ( ! symbolList.includes( baseSymbol ) ) { + + symbolList.push( baseSymbol ); + + } + + const newImportStatement = `import { ${symbolList.join( ', ' )} } from '${moduleName}';`; + + const startPos = model.getPositionAt( startIdx ); + const endPos = model.getPositionAt( endIdx ); + + return [ + { + range: new _monaco.Range( startPos.lineNumber, startPos.column, endPos.lineNumber, endPos.column ), + text: newImportStatement + } + ]; + + } else { + + return [ + { + range: new _monaco.Range( 1, 1, 1, 1 ), + text: `import { ${baseSymbol} } from '${moduleName}';\n` + } + ]; + + } + + }; + + const lineContent = model.getLineContent( position.lineNumber ); + const textBeforeCursor = lineContent.substring( 0, position.column - 1 ); + const isNewKeyword = /\bnew\s+[a-zA-Z0-9_$]*$/.test( textBeforeCursor ); + + let filteredTemplates = suggestionsTemplates; + if ( isNewKeyword ) { + + filteredTemplates = suggestionsTemplates.filter( item => item.moduleName !== 'three/tsl' ); + + } + + const suggestions = filteredTemplates.map( item => ( { + label: item.label, + kind: item.kind, + detail: item.detail, + insertText: item.insertText, + insertTextRules: item.insertTextRules, + range: range, + additionalTextEdits: item.moduleName ? getAdditionalTextEdits( model, item.label, item.moduleName ) : [] + } ) ); + + return { suggestions }; + + } + + } ); + + // Configure TypeScript/JavaScript language service settings + const typescriptDefaults = _monaco.languages.typescript.javascriptDefaults; + + typescriptDefaults.setCompilerOptions( { + target: _monaco.languages.typescript.ScriptTarget.ES2020, + allowNonTsExtensions: true, + checkJs: true, + moduleResolution: _monaco.languages.typescript.ModuleResolutionKind.NodeJs, + allowSyntheticDefaultImports: true, + autoImportSuggestions: false + } ); + + typescriptDefaults.setDiagnosticsOptions( { + noSemanticValidation: true, + noSyntaxValidation: false + } ); + + // Procedurally generate and register type definitions + const dtsContent = generateDeclarations( THREE, TSL, ADDONS_TSL_IMPORTS ); + + typescriptDefaults.addExtraLib( dtsContent, 'ts:three-tsl.d.ts' ); + + // Register formatting provider + _monaco.languages.registerDocumentFormattingEditProvider( 'javascript', { + + provideDocumentFormattingEdits: async ( model ) => { + + const formatted = await CodeCompiler.format( model.getValue() ); + + return [ + { + range: model.getFullModelRange(), + text: formatted + } + ]; + + } + + } ); + + _monacoConfigured = true; + + } + + let options; + + if ( this.readOnly ) { + + options = { + value: this.value, + language: this.language, + theme: 'chatgpt-dark', + automaticLayout: true, + readOnly: true, + minimap: { enabled: false }, + scrollBeyondLastLine: false, + renderLineHighlight: 'none', + hideCursorInOverviewRuler: true, + overviewRulerBorder: false, + fontSize: 13, + fontFamily: '\'Fira Code\', monospace', + tabSize: 4, + insertSpaces: false, + detectIndentation: false, + padding: { top: 12, bottom: 12 }, + scrollbar: { + vertical: 'hidden', + horizontal: 'auto' + }, + bracketPairColorization: { enabled: true }, + cursorBlinking: 'smooth', + smoothScrolling: true + }; + + } else { + + options = { + value: this.value, + language: this.language, + theme: 'chatgpt-dark', + minimap: { enabled: false }, + automaticLayout: true, + fixedOverflowWidgets: true, + fontSize: 13, + fontFamily: '\'Fira Code\', monospace', + tabSize: 4, + padding: { top: 16, bottom: 16 }, + insertSpaces: false, + detectIndentation: false, + snippetSuggestions: 'top', + bracketPairColorization: { enabled: true }, + cursorBlinking: 'smooth', + smoothScrolling: true + }; + + } + + if ( ! this.scrollable ) { + + options.scrollBeyondLastLine = false; + options.scrollbar = { + vertical: 'hidden', + horizontal: 'auto', + handleMouseWheel: false, + alwaysConsumeMouseWheel: false + }; + + } + + this.editor = _monaco.editor.create( this.container, options ); + + const model = this.editor.getModel(); + if ( model ) { + + model.updateOptions( { + tabSize: 4, + insertSpaces: false, + detectIndentation: false, + trimAutoWhitespace: true + } ); + + } + + // Dynamic TSL syntax highlighting updates based on file imports + const updateHighlights = () => { + + if ( this.language === 'javascript' || this.language === 'typescript' ) { + + updateTokenizerForCode(); + + } + + }; + + if ( ! this.readOnly ) { + + this.editor.onDidChangeModelContent( () => { + + updateHighlights(); + + if ( this.isProgrammaticChange ) return; + + this.dispatchEvent( { type: 'change', value: this.editor.getValue() } ); + + } ); + + } + + updateHighlights(); + + if ( ! this.scrollable ) { + + const updateHeight = () => { + + const contentHeight = this.editor.getContentHeight(); + this.container.style.height = `${contentHeight}px`; + this.editor.layout(); + + }; + + this.editor.onDidContentSizeChange( updateHeight ); + updateHeight(); + + this._wheelListener = ( event ) => { + + const scrollParent = this.container.closest( '#content-area' ) || this.container.closest( '.custom-scrollbar' ) || document.documentElement; + if ( scrollParent ) { + + scrollParent.scrollTop += event.deltaY; + event.preventDefault(); + + } + + }; + + this.container.addEventListener( 'wheel', this._wheelListener, { passive: false } ); + + let startX = 0; + let startY = 0; + let lastY = 0; + let isScrolling = false; + + this._touchStartListener = ( event ) => { + + if ( event.touches.length === 1 ) { + + startX = event.touches[ 0 ].clientX; + startY = event.touches[ 0 ].clientY; + lastY = startY; + isScrolling = false; + + } + + }; + + this._touchMoveListener = ( event ) => { + + if ( event.touches.length === 1 ) { + + const currentX = event.touches[ 0 ].clientX; + const currentY = event.touches[ 0 ].clientY; + const totalDeltaY = Math.abs( currentY - startY ); + const totalDeltaX = Math.abs( currentX - startX ); + + if ( isScrolling || ( totalDeltaY > 5 && totalDeltaY > totalDeltaX ) ) { + + isScrolling = true; + + const deltaY = lastY - currentY; + lastY = currentY; + + const scrollParent = this.container.closest( '#content-area' ) || this.container.closest( '.custom-scrollbar' ) || document.documentElement; + if ( scrollParent ) { + + scrollParent.scrollTop += deltaY; + + } + + event.preventDefault(); + event.stopPropagation(); + + } + + } + + }; + + this.container.addEventListener( 'touchstart', this._touchStartListener, { capture: true, passive: true } ); + this.container.addEventListener( 'touchmove', this._touchMoveListener, { capture: true, passive: false } ); + + } + + document.fonts.ready.then( () => { + + _monaco.editor.remeasureFonts(); + if ( ! this.scrollable && this.editor ) { + + const contentHeight = this.editor.getContentHeight(); + this.container.style.height = `${contentHeight}px`; + this.editor.layout(); + + } + + } ); + + this.dispatchEvent( { type: 'init' } ); + + } ); + + } + + getValue() { + + if ( ! this.editor ) return this.value; + return this.editor.getValue(); + + } + + setValue( value ) { + + if ( ! this.editor ) { + + this.value = value; + return; + + } + + this.isProgrammaticChange = true; + + try { + + this.editor.setValue( value ); + + const model = this.editor.getModel(); + if ( model ) { + + model.updateOptions( { + tabSize: 4, + insertSpaces: false, + detectIndentation: false, + trimAutoWhitespace: true + } ); + + } + + this.editor.setScrollTop( 0 ); + this.editor.setScrollLeft( 0 ); + this.editor.setPosition( { lineNumber: 1, column: 1 } ); + + } finally { + + this.isProgrammaticChange = false; + + } + + // Update highlights after setting the value + if ( this.language === 'javascript' || this.language === 'typescript' ) { + + updateTokenizerForCode(); + + } + + } + + format( formatted ) { + + if ( ! this.editor ) return; + const model = this.editor.getModel(); + this.isProgrammaticChange = true; + try { + + this.editor.executeEdits( 'clean-and-format', [ { + range: model.getFullModelRange(), + text: formatted, + forceMoveMarkers: true + } ] ); + + } finally { + + this.isProgrammaticChange = false; + + } + + } + + layout() { + + if ( this.editor ) this.editor.layout(); + + } + + focus() { + + if ( this.editor ) this.editor.focus(); + + } + + revealLine( line, column = 1 ) { + + if ( ! this.editor ) return; + this.editor.revealLineInCenter( line ); + this.editor.setPosition( { lineNumber: line, column: column } ); + this.editor.focus(); + + } + + clearMarkers() { + + if ( ! this.editor || ! _monaco ) return; + _monaco.editor.setModelMarkers( this.editor.getModel(), 'tsl', [] ); + + } + + setErrorMarker( line, column, message ) { + + if ( ! this.editor || ! _monaco ) return; + const lineCount = this.editor.getModel().getLineCount(); + if ( line <= lineCount ) { + + _monaco.editor.setModelMarkers( this.editor.getModel(), 'tsl', [ { + startLineNumber: line, + startColumn: column || 1, + endLineNumber: line, + endColumn: ( column || 1 ) + 100, + message: message, + severity: _monaco.MarkerSeverity.Error + } ] ); + + } + + } + + dispose() { + + if ( this._wheelListener ) { + + this.container.removeEventListener( 'wheel', this._wheelListener ); + this._wheelListener = null; + + } + + if ( this._touchStartListener ) { + + this.container.removeEventListener( 'touchstart', this._touchStartListener, { capture: true } ); + this._touchStartListener = null; + + } + + if ( this._touchMoveListener ) { + + this.container.removeEventListener( 'touchmove', this._touchMoveListener, { capture: true } ); + this._touchMoveListener = null; + + } + + if ( this.editor ) { + + this.editor.dispose(); + this.editor = null; + + } + + } + +} + +export { CodeEditor }; diff --git a/tsl/js/imports/scripts/scenes/empty.js b/tsl/js/imports/scripts/scenes/empty.js new file mode 100644 index 00000000000000..6bbcd126a8d85a --- /dev/null +++ b/tsl/js/imports/scripts/scenes/empty.js @@ -0,0 +1,139 @@ +import * as THREE from 'three'; +import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; +import { HDRLoader } from 'three/addons/loaders/HDRLoader.js'; +import { Fn, float, fract, fwidth, abs, saturate, max, smoothstep, length, positionWorld, vec4, reflector, pass } from 'three/tsl'; +import { smaa } from 'three/addons/tsl/display/SMAANode.js'; + +let scene, camera, controls, defaultPass, defaultAA, renderPipeline, floor, reflection, dragging = false; + +const gridTexture = Fn( ( [ coord, lineWidth = float( 0.01 ), dotSize = float( 0.03 ) ] ) => { + + const g = fract( coord ); + const fw = fwidth( coord ); + const gx = abs( g.x.sub( 0.5 ) ); + const gy = abs( g.y.sub( 0.5 ) ); + + const lineX = saturate( lineWidth.sub( gx ).div( fw.x ).add( 0.5 ) ); + const lineY = saturate( lineWidth.sub( gy ).div( fw.y ).add( 0.5 ) ); + const lines = max( lineX, lineY ); + + const squareDist = max( gx, gy ); + const aa = max( fw.x, fw.y ); + const dots = smoothstep( dotSize.add( aa ), dotSize.sub( aa ), squareDist ); + + return max( dots, lines ); + +} ); + +export function refresh() { + + scene.clear(); + + scene.add( reflection.target ); + scene.add( floor ); + + scene.fogNode = null; + scene.backgroundNode = null; + + floor.visible = true; + + renderPipeline.outputNode = defaultAA; + renderPipeline.outputColorTransform = true; + renderPipeline.needsUpdate = true; + +} + +export async function init() { + + scene = new THREE.Scene(); + + camera = new THREE.PerspectiveCamera( 45, renderer.domElement.clientWidth / renderer.domElement.clientHeight, 0.1, 100 ); + camera.position.set( 2, 3, 4 ); + camera.lookAt( 0, 1, 0 ); + + defaultPass = pass( scene, camera ); + defaultAA = smaa( defaultPass ); + + renderPipeline = new THREE.RenderPipeline( renderer ); + + controls = new OrbitControls( camera, renderer.domElement ); + controls.enableDamping = true; + controls.minDistance = 2; + controls.maxDistance = 20; + controls.target.set( 0, 1, 0 ); + controls.addEventListener( 'start', () => dragging = true ); + controls.addEventListener( 'end', () => dragging = false ); + controls.update(); + + // Ground plane with procedural grid (glossy reflective showroom floor) + const floorMaterial = new THREE.MeshStandardNodeMaterial( { roughness: 0.6, metalness: 0.8 } ); + + const fade = Fn( ( [ radius = float( 10.0 ), falloff = float( 1.0 ) ] ) => { + + return smoothstep( radius, radius.sub( falloff ), length( positionWorld ) ); + + } ); + + // Planar Reflector for glossy floor reflections + reflection = reflector( { resolutionScale: 1 } ); + reflection.target.rotateX( - Math.PI / 2 ); + + const gridColor = vec4( 0.45, 0.45, 0.45, 1.0 ); + const baseColor = vec4( 0.08, 0.08, 0.08, 1.0 ); // Neutral dark metallic base + + // Combine procedural grid, dark showroom base, and mirror reflections + const floorColor = gridTexture( positionWorld.xz, 0.007, 0.03 ).mix( baseColor, gridColor ).add( reflection.mul( 0.25 ) ); + floorMaterial.colorNode = floorColor; + floorMaterial.transparent = true; + floorMaterial.opacityNode = fade( 25.0, 15.0 ); + + floor = new THREE.Mesh( new THREE.CircleGeometry( 40 ), floorMaterial ); + floor.rotation.x = - Math.PI / 2; + floor.renderOrder = - 1; + floor.receiveShadow = true; + + // Load environment map + const texture = await new HDRLoader() + .setPath( '../examples/textures/equirectangular/' ) + .loadAsync( 'ferndale_studio_04_1k.hdr' ); + + texture.mapping = THREE.EquirectangularReflectionMapping; + scene.environment = texture; + scene.environmentIntensity = 0.25; // Soft HDR reflections + + // Blur and dim background environment for cinematic look + scene.background = texture; + scene.backgroundBlurriness = 0.65; + scene.backgroundIntensity = 0.15; + + refresh(); + +} + +export function update() { + + controls.update(); + renderPipeline.render(); + +} + +export function resize( width, height ) { + + camera.aspect = width / height; + camera.updateProjectionMatrix(); + +} + +export function dispose() { + + // Implement dispose + +} + +export function debug() { + + return { scene, camera, object: floor }; + +} + +export { scene, camera, controls, defaultPass, defaultAA, renderPipeline, floor, dragging }; diff --git a/tsl/js/imports/scripts/scenes/plane.js b/tsl/js/imports/scripts/scenes/plane.js new file mode 100644 index 00000000000000..0866b7a70cc18c --- /dev/null +++ b/tsl/js/imports/scripts/scenes/plane.js @@ -0,0 +1,158 @@ +import * as THREE from 'three'; +import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; +import { HDRLoader } from 'three/addons/loaders/HDRLoader.js'; +import { Fn, float, fract, fwidth, abs, saturate, max, smoothstep, length, positionWorld, vec4, reflector, pass } from 'three/tsl'; +import { smaa } from 'three/addons/tsl/display/SMAANode.js'; + +let scene, camera, controls, defaultPass, defaultAA, renderPipeline, floor, reflection, plane, model, dragging = false; + +const gridTexture = Fn( ( [ coord, lineWidth = float( 0.01 ), dotSize = float( 0.03 ) ] ) => { + + const g = fract( coord ); + const fw = fwidth( coord ); + const gx = abs( g.x.sub( 0.5 ) ); + const gy = abs( g.y.sub( 0.5 ) ); + + const lineX = saturate( lineWidth.sub( gx ).div( fw.x ).add( 0.5 ) ); + const lineY = saturate( lineWidth.sub( gy ).div( fw.y ).add( 0.5 ) ); + const lines = max( lineX, lineY ); + + const squareDist = max( gx, gy ); + const aa = max( fw.x, fw.y ); + const dots = smoothstep( dotSize.add( aa ), dotSize.sub( aa ), squareDist ); + + return max( dots, lines ); + +} ); + +export function refresh() { + + scene.clear(); + + scene.add( reflection.target ); + scene.add( floor ); + + scene.fogNode = null; + scene.backgroundNode = null; + + floor.visible = true; + + if ( plane ) { + + plane.material.dispose(); + plane.material = new THREE.MeshBasicNodeMaterial( { side: THREE.DoubleSide } ); + scene.add( plane ); + + } + + renderPipeline.outputNode = defaultAA; + renderPipeline.outputColorTransform = true; + renderPipeline.needsUpdate = true; + +} + +export async function init() { + + scene = new THREE.Scene(); + + camera = new THREE.PerspectiveCamera( 45, renderer.domElement.clientWidth / renderer.domElement.clientHeight, 0.1, 100 ); + camera.position.set( 2, 3, 4 ); + camera.lookAt( 0, 1.5, 0 ); + + defaultPass = pass( scene, camera ); + defaultAA = smaa( defaultPass ); + + renderPipeline = new THREE.RenderPipeline( renderer ); + + controls = new OrbitControls( camera, renderer.domElement ); + controls.enableDamping = true; + controls.minDistance = 2; + controls.maxDistance = 20; + controls.target.set( 0, 1.5, 0 ); + controls.addEventListener( 'start', () => dragging = true ); + controls.addEventListener( 'end', () => dragging = false ); + controls.update(); + + // Ground plane with procedural grid (glossy reflective showroom floor) + const floorMaterial = new THREE.MeshStandardNodeMaterial( { roughness: 0.6, metalness: 0.8 } ); + + const fade = Fn( ( [ radius = float( 10.0 ), falloff = float( 1.0 ) ] ) => { + + return smoothstep( radius, radius.sub( falloff ), length( positionWorld ) ); + + } ); + + // Planar Reflector for glossy floor reflections + reflection = reflector( { resolutionScale: 1 } ); + reflection.target.rotateX( - Math.PI / 2 ); + + const gridColor = vec4( 0.45, 0.45, 0.45, 1.0 ); + const baseColor = vec4( 0.08, 0.08, 0.08, 1.0 ); // Neutral dark metallic base + + // Combine procedural grid, dark showroom base, and mirror reflections + const floorColor = gridTexture( positionWorld.xz, 0.007, 0.03 ).mix( baseColor, gridColor ).add( reflection.mul( 0.25 ) ); + floorMaterial.colorNode = floorColor; + floorMaterial.transparent = true; + floorMaterial.opacityNode = fade( 25.0, 15.0 ); + + floor = new THREE.Mesh( new THREE.CircleGeometry( 40 ), floorMaterial ); + floor.rotation.x = - Math.PI / 2; + floor.renderOrder = - 1; + floor.receiveShadow = true; + + // Load environment map + const texture = await new HDRLoader() + .setPath( '../examples/textures/equirectangular/' ) + .loadAsync( 'ferndale_studio_04_1k.hdr' ); + + texture.mapping = THREE.EquirectangularReflectionMapping; + scene.environment = texture; + scene.environmentIntensity = 0.25; + + scene.background = texture; + scene.backgroundBlurriness = 0.65; + scene.backgroundIntensity = 0.15; + + // Setup Plane Mesh + const geometry = new THREE.PlaneGeometry( 3, 3 ); + const material = new THREE.MeshBasicNodeMaterial( { side: THREE.DoubleSide } ); + plane = new THREE.Mesh( geometry, material ); + plane.position.set( 0, 1.5, 0 ); + model = plane; + + refresh(); + +} + +export function update() { + + controls.update(); + renderPipeline.render(); + +} + +export function resize( width, height ) { + + camera.aspect = width / height; + camera.updateProjectionMatrix(); + +} + +export function dispose() { + + if ( plane ) { + + plane.geometry.dispose(); + plane.material.dispose(); + + } + +} + +export function debug() { + + return { scene, camera, object: plane }; + +} + +export { scene, camera, controls, defaultPass, defaultAA, renderPipeline, floor, plane, model, dragging }; diff --git a/tsl/js/imports/scripts/scenes/shaderball.js b/tsl/js/imports/scripts/scenes/shaderball.js new file mode 100644 index 00000000000000..b67a41356fdb82 --- /dev/null +++ b/tsl/js/imports/scripts/scenes/shaderball.js @@ -0,0 +1,201 @@ +import * as THREE from 'three'; +import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; +import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; +import { HDRLoader } from 'three/addons/loaders/HDRLoader.js'; +import { Fn, float, fract, fwidth, abs, saturate, max, smoothstep, length, positionWorld, positionLocal, vec4, reflector, pass } from 'three/tsl'; +import { smaa } from 'three/addons/tsl/display/SMAANode.js'; + +let scene, camera, controls, defaultPass, defaultAA, renderPipeline, prefab, previewMesh, calibrationMesh, floor, reflection, dragging = false; +let model; + +const gridTexture = Fn( ( [ coord, lineWidth = float( 0.01 ), dotSize = float( 0.03 ) ] ) => { + + const g = fract( coord ); + const fw = fwidth( coord ); + const gx = abs( g.x.sub( 0.5 ) ); + const gy = abs( g.y.sub( 0.5 ) ); + + const lineX = saturate( lineWidth.sub( gx ).div( fw.x ).add( 0.5 ) ); + const lineY = saturate( lineWidth.sub( gy ).div( fw.y ).add( 0.5 ) ); + const lines = max( lineX, lineY ); + + const squareDist = max( gx, gy ); + const aa = max( fw.x, fw.y ); + const dots = smoothstep( dotSize.add( aa ), dotSize.sub( aa ), squareDist ); + + return max( dots, lines ); + +} ); + +export async function init() { + + scene = new THREE.Scene(); + + camera = new THREE.PerspectiveCamera( 45, renderer.domElement.clientWidth / renderer.domElement.clientHeight, 0.1, 100 ); + camera.position.set( 2, 3, 4 ); + camera.lookAt( 0, 1, 0 ); + + defaultPass = pass( scene, camera ); + defaultAA = smaa( defaultPass ); + + renderPipeline = new THREE.RenderPipeline( renderer ); + + controls = new OrbitControls( camera, renderer.domElement ); + controls.enableDamping = true; + controls.minDistance = 2; + controls.maxDistance = 20; + controls.target.set( 0, 1, 0 ); + controls.addEventListener( 'start', () => dragging = true ); + controls.addEventListener( 'end', () => dragging = false ); + controls.update(); + + // Ground plane with procedural grid (glossy reflective showroom floor) + const floorMaterial = new THREE.MeshStandardNodeMaterial( { roughness: 0.6, metalness: 0.8 } ); + + const fade = Fn( ( [ radius = float( 10.0 ), falloff = float( 1.0 ) ] ) => { + + return smoothstep( radius, radius.sub( falloff ), length( positionWorld ) ); + + } ); + + // Planar Reflector for glossy floor reflections + reflection = reflector( { resolutionScale: 1 } ); + reflection.target.rotateX( - Math.PI / 2 ); + scene.add( reflection.target ); + + const gridColor = vec4( 0.45, 0.45, 0.45, 1.0 ); + const baseColor = vec4( 0.08, 0.08, 0.08, 1.0 ); // Neutral dark metallic base + + // Combine procedural grid, dark showroom base, and mirror reflections + const floorColor = gridTexture( positionWorld.xz, 0.007, 0.03 ).mix( baseColor, gridColor ).add( reflection.mul( 0.25 ) ); + floorMaterial.colorNode = floorColor; + floorMaterial.transparent = true; + floorMaterial.opacityNode = fade( 25.0, 15.0 ); + + floor = new THREE.Mesh( new THREE.CircleGeometry( 40 ), floorMaterial ); + floor.rotation.x = - Math.PI / 2; + floor.renderOrder = - 1; + floor.receiveShadow = true; + scene.add( floor ); + + // Key SpotLight with Shadows (placed front/left to cast shadow to back/right) + const spotLight = new THREE.SpotLight( 0xffffff, 250, 30, Math.PI / 4, 0.5, 2.0 ); + spotLight.position.set( - 4, 6, 4 ); + spotLight.target.position.set( 0, 1, 0 ); + spotLight.castShadow = true; + spotLight.shadow.mapSize.width = 2048; + spotLight.shadow.mapSize.height = 2048; + spotLight.shadow.camera.near = 1; + spotLight.shadow.camera.far = 10; + spotLight.shadow.bias = - 0.001; + scene.add( spotLight ); + scene.add( spotLight.target ); + + // Neutral White Rim/Fill Light (placed back/right) + const rimLight = new THREE.DirectionalLight( 0xffffff, 1.2 ); + rimLight.position.set( 4, 6, - 4 ); + scene.add( rimLight ); + + // Soft Ambient Light to fill deep shadows + const ambientLight = new THREE.AmbientLight( 0xffffff, 0.15 ); + scene.add( ambientLight ); + + // Load environment map and model + const texture = await new HDRLoader() + .setPath( '../examples/textures/equirectangular/' ) + .loadAsync( 'ferndale_studio_04_1k.hdr' ); + + texture.mapping = THREE.EquirectangularReflectionMapping; + scene.environment = texture; + scene.environmentIntensity = 0.25; // Soft HDR reflections + + // Blur and dim background environment for cinematic look + scene.background = texture; + scene.backgroundBlurriness = 0.65; + scene.backgroundIntensity = 0.15; + + prefab = ( await new GLTFLoader().loadAsync( '../examples/models/gltf/ShaderBall.glb' ) ).scene; + prefab.traverse( ( child ) => { + + if ( child.isMesh ) { + + child.castShadow = true; + child.receiveShadow = true; + + } + + } ); + scene.add( prefab ); + + calibrationMesh = prefab.getObjectByName( 'Calibration_Mesh' ); + + previewMesh = prefab.getObjectByName( 'Preview_Mesh' ); + + // Convert sphere geometry to non-indexed so the faces can separate + previewMesh.geometry.computeTangents(); + previewMesh.geometry = previewMesh.geometry.toNonIndexed(); + + model = previewMesh; + + refresh(); + +} + +export function update() { + + // TODO: Probably a cache-key issue, see #normal and goto playground + model.material.needsUpdate = true; + + controls.update(); + renderPipeline.render(); + +} + +export function resize( width, height ) { + + camera.aspect = width / height; + camera.updateProjectionMatrix(); + +} + +export function refresh() { + + scene.fogNode = null; + scene.backgroundNode = null; + + floor.visible = true; + + prefab.rotation.set( 0, 0, 0 ); + + previewMesh.material.dispose(); + calibrationMesh.material.dispose(); + + previewMesh.material = new THREE.MeshStandardNodeMaterial( { roughness: 0.8, metalness: 0.2 } ); + + // White checker calibration board material + const calibMaterial = new THREE.MeshStandardNodeMaterial( { roughness: 0.5, metalness: 0.0 } ); + const calibGridColor = vec4( 0.25, 0.25, 0.25, 1.0 ); // Darker crisp grey lines + const calibBaseColor = vec4( 0.95, 0.95, 0.95, 1.0 ); // Clean off-white squares + calibMaterial.colorNode = gridTexture( positionLocal.xy.mul( 10.0 ), 0.02, 0.0 ).mix( calibBaseColor, calibGridColor ); + + calibrationMesh.material = calibMaterial; + + renderPipeline.outputNode = defaultAA; + renderPipeline.outputColorTransform = true; + renderPipeline.needsUpdate = true; + +} + +export function dispose() { + + // TODO: Implement dispose + +} + +export function debug() { + + return { scene, camera, object: previewMesh || model }; + +} + +export { scene, camera, controls, defaultPass, defaultAA, renderPipeline, model, floor, dragging }; diff --git a/tsl/js/managers/ConsoleManager.js b/tsl/js/managers/ConsoleManager.js new file mode 100644 index 00000000000000..d96d02c9cf960e --- /dev/null +++ b/tsl/js/managers/ConsoleManager.js @@ -0,0 +1,468 @@ +import { setConsoleFunction, getConsoleFunction } from 'three'; + +class ConsoleManager { + + constructor( tour ) { + + this.tour = tour; + this.originalConsoleError = console.error; + this.originalConsoleWarn = console.warn; + + this._initConsoleOverrides(); + this._initRunnerListeners(); + + } + + _initConsoleOverrides() { + + const previousConsoleFn = getConsoleFunction(); + const handledMessages = new Set(); + + setConsoleFunction( ( type, message, ...params ) => { + + if ( previousConsoleFn ) { + + previousConsoleFn( type, message, ...params ); + + } + + handledMessages.add( message ); + queueMicrotask( () => handledMessages.delete( message ) ); + + if ( typeof message === 'string' && message.includes( '%c' ) ) { + + return; + + } + + let line = null; + let column = null; + + const stackTrace = params.find( arg => arg && arg.isStackTrace ); + if ( stackTrace && stackTrace.stack && stackTrace.stack.length > 0 ) { + + const frame = stackTrace.stack.find( f => f.file === 'playground-eval.js' ); + if ( frame ) { + + line = frame.line - 2; + column = frame.column; + + } + + } + + const filteredParams = params.filter( arg => ! ( arg && arg.isStackTrace ) ); + + let msg = [ message, ...filteredParams ].map( arg => { + + if ( typeof arg === 'object' && arg !== null ) { + + try { + + return JSON.stringify( arg ); + + } catch { + + return String( arg ); + + } + + } + + return String( arg ); + + } ).join( ' ' ); + + if ( msg.startsWith( 'THREE.' ) ) { + + msg = msg.substring( 6 ); + + } + + let cleanMsg = msg; + if ( line !== null ) { + + cleanMsg = cleanMsg.replace( /\s+(?:["']?[a-zA-Z0-9_$]+\(\)["']?\s+at\s+)?["']?[^"'\s]+\.js:\d+["']?/, '' ); + + } + + const displayMessage = line !== null ? `Line ${line}: ${cleanMsg}` : cleanMsg; + + let eventType = 'log'; + if ( type === 'error' ) eventType = 'error-log'; + else if ( type === 'warn' ) eventType = 'warn-log'; + + if ( eventType === 'error-log' || eventType === 'warn-log' ) { + + this.tour.runner.dispatchEvent( { + type: eventType, + message: displayMessage, + line: line, + column: column, + errorMsg: cleanMsg + } ); + + } else { + + this.tour.runner.dispatchEvent( { type: eventType, message: msg } ); + + } + + } ); + + console.error = ( ...args ) => { + + this.originalConsoleError.apply( console, args ); + + const firstArg = args[ 0 ]; + const rawMsg = firstArg instanceof Error ? firstArg.message : firstArg; + if ( typeof rawMsg === 'string' && ( rawMsg.includes( '%c' ) || handledMessages.has( rawMsg ) ) ) { + + return; + + } + + const msg = args.map( arg => { + + if ( arg instanceof Error ) { + + return arg.message || String( arg ); + + } + + if ( typeof arg === 'object' && arg !== null ) { + + try { + + return JSON.stringify( arg ); + + } catch { + + return String( arg ); + + } + + } + + return String( arg ); + + } ).join( ' ' ); + + let line = null; + let column = null; + const stack = new Error().stack || ''; + let match = stack.match( /playground-eval\.js:(\d+):(\d+)/ ); + if ( match ) { + + line = parseInt( match[ 1 ] ) - 2; + column = parseInt( match[ 2 ] ); + + } else { + + match = stack.match( /:(\d+):(\d+)/ ); + if ( match ) { + + line = parseInt( match[ 1 ] ) - 2; + column = parseInt( match[ 2 ] ); + + } + + } + + let cleanMsg = msg.split( '\n' )[ 0 ]; + cleanMsg = cleanMsg.replace( /\s+["']?eval\(\)["']?\s+at\s+["']?[^"'\s]+\.js:\d+["']?/, '' ); + + const displayMessage = line !== null ? `Line ${line}: ${cleanMsg}` : cleanMsg; + + this.tour.runner.dispatchEvent( { + type: 'error-log', + message: displayMessage, + line: line, + column: column, + errorMsg: cleanMsg + } ); + + }; + + console.warn = ( ...args ) => { + + this.originalConsoleWarn.apply( console, args ); + + const firstArg = args[ 0 ]; + const rawMsg = firstArg instanceof Error ? firstArg.message : firstArg; + if ( typeof rawMsg === 'string' && ( rawMsg.includes( '%c' ) || handledMessages.has( rawMsg ) ) ) { + + return; + + } + + const msg = args.map( arg => { + + if ( arg instanceof Error ) { + + return arg.message || String( arg ); + + } + + if ( typeof arg === 'object' && arg !== null ) { + + try { + + return JSON.stringify( arg ); + + } catch { + + return String( arg ); + + } + + } + + return String( arg ); + + } ).join( ' ' ); + + this.tour.runner.dispatchEvent( { type: 'warn-log', message: msg } ); + + }; + + } + + _initRunnerListeners() { + + this.onStart = () => { + + this.tour.dom.consoleErrorMessage.textContent = ''; + if ( this.tour.codeEditor ) { + + this.tour.codeEditor.clearMarkers(); + + } + + this.updateConsoleButtonsState(); + + }; + + this.onLog = ( event ) => { + + this.appendConsoleLine( event.message, '#e2e8f0' ); + this.toggleConsole( false ); + + }; + + this.onWarn = ( event ) => { + + this.appendConsoleLine( event.message, '#fde047', event ); + this.toggleConsole( false ); + + }; + + this.onErrorLog = ( event ) => { + + this.appendConsoleLine( event.message, '#fca5a5', event ); + this.toggleConsole( false ); + + if ( event.line !== null && event.line > 0 && this.tour.codeEditor ) { + + this.tour.codeEditor.setErrorMarker( event.line, event.column, event.errorMsg || event.message ); + + } + + }; + + this.onSuccess = () => { + + if ( this.tour.isPlaygroundActive ) { + + this.tour.updateDebugWGSL(); + setTimeout( () => this.tour.updateDebugWGSL(), 500 ); + + } + + if ( ! this.tour.dom.consoleErrorMessage.hasChildNodes() ) { + + this.toggleConsole( true ); + + } + + }; + + this.onError = ( event ) => { + + this.appendConsoleLine( event.message, '#fca5a5', event ); + + if ( event.line !== null && event.line > 0 && this.tour.codeEditor ) { + + this.tour.codeEditor.setErrorMarker( event.line, event.column, event.error.toString() ); + + } + + this.toggleConsole( false ); + + }; + + this.tour.runner.addEventListener( 'start', this.onStart ); + this.tour.runner.addEventListener( 'log', this.onLog ); + this.tour.runner.addEventListener( 'warn-log', this.onWarn ); + this.tour.runner.addEventListener( 'error-log', this.onErrorLog ); + this.tour.runner.addEventListener( 'success', this.onSuccess ); + this.tour.runner.addEventListener( 'error', this.onError ); + + } + + appendConsoleLine( message, color, clickableEvent = null ) { + + const line = document.createElement( 'div' ); + line.className = 'console-line'; + line.style.color = color; + + const textSpan = document.createElement( 'span' ); + textSpan.className = 'console-line-text'; + textSpan.textContent = message; + line.appendChild( textSpan ); + + if ( clickableEvent && clickableEvent.line !== null && clickableEvent.line > 0 && this.tour.codeEditor ) { + + const jumpBtn = document.createElement( 'button' ); + jumpBtn.className = 'console-jump-btn'; + jumpBtn.title = 'Click to jump to error'; + + const icon = document.createElement( 'i' ); + icon.setAttribute( 'data-icon', 'external-link' ); + jumpBtn.appendChild( icon ); + + jumpBtn.onclick = ( e ) => { + + e.stopPropagation(); + this.tour.codeEditor.revealLine( clickableEvent.line, clickableEvent.column || 1 ); + + }; + + line.appendChild( jumpBtn ); + + } + + this.tour.dom.consoleErrorMessage.appendChild( line ); + + // Instantiate icons if any were added + this.tour.createIcons( line ); + + while ( this.tour.dom.consoleErrorMessage.childElementCount > 100 ) { + + this.tour.dom.consoleErrorMessage.removeChild( this.tour.dom.consoleErrorMessage.firstChild ); + + } + + this.tour.dom.consoleErrorMessage.scrollTop = this.tour.dom.consoleErrorMessage.scrollHeight; + + this.updateConsoleButtonsState(); + + } + + toggleConsole( forceState ) { + + const consolePanel = this.tour.dom.editorConsole; + const toggleIcon = this.tour.dom.consoleToggleIcon; + + const isMinimized = forceState !== undefined ? forceState : ! consolePanel.classList.contains( 'minimized' ); + + if ( isMinimized ) { + + consolePanel.classList.add( 'minimized' ); + toggleIcon.setAttribute( 'data-icon', 'chevron-up' ); + + } else { + + consolePanel.classList.remove( 'minimized' ); + toggleIcon.setAttribute( 'data-icon', 'chevron-down' ); + + } + + this.tour.createIcons( this.tour.dom.consoleToggleBtn ); + + if ( this.tour.codeEditor ) { + + this.tour.codeEditor.layout(); + + } + + } + + clearConsole() { + + this.tour.dom.consoleErrorMessage.textContent = ''; + this.updateConsoleButtonsState(); + + } + + copyConsole() { + + const lines = Array.from( this.tour.dom.consoleErrorMessage.querySelectorAll( '.console-line-text' ) ) + .map( span => span.textContent ); + const text = lines.join( '\n' ); + if ( ! text ) return; + + navigator.clipboard.writeText( text ).then( () => { + + const btn = this.tour.dom.consoleCopyBtn; + btn.classList.add( 'success' ); + btn.innerHTML = ''; + this.tour.createIcons( btn ); + + setTimeout( () => { + + btn.classList.remove( 'success' ); + btn.innerHTML = ''; + this.tour.createIcons( btn ); + + }, 2000 ); + + } ); + + } + + updateConsoleButtonsState() { + + const hasLogs = this.tour.dom.consoleErrorMessage.childElementCount > 0; + const clearBtn = this.tour.dom.consoleClearBtn; + const copyBtn = this.tour.dom.consoleCopyBtn; + + if ( hasLogs ) { + + clearBtn.removeAttribute( 'disabled' ); + + } else { + + clearBtn.setAttribute( 'disabled', 'true' ); + + } + + if ( hasLogs ) { + + copyBtn.removeAttribute( 'disabled' ); + + } else { + + copyBtn.setAttribute( 'disabled', 'true' ); + + } + + } + + dispose() { + + console.error = this.originalConsoleError; + console.warn = this.originalConsoleWarn; + + this.tour.runner.removeEventListener( 'start', this.onStart ); + this.tour.runner.removeEventListener( 'log', this.onLog ); + this.tour.runner.removeEventListener( 'warn-log', this.onWarn ); + this.tour.runner.removeEventListener( 'error-log', this.onErrorLog ); + this.tour.runner.removeEventListener( 'success', this.onSuccess ); + this.tour.runner.removeEventListener( 'error', this.onError ); + + } + +} + +export { ConsoleManager }; diff --git a/tsl/js/managers/HistoryManager.js b/tsl/js/managers/HistoryManager.js new file mode 100644 index 00000000000000..6d66be7162e71e --- /dev/null +++ b/tsl/js/managers/HistoryManager.js @@ -0,0 +1,92 @@ +class HistoryManager { + + constructor( tour ) { + + this.tour = tour; + this.history = []; + this.index = - 1; + this.isUndoRedoAction = false; + this.limit = 50; + + } + + pushState( hash ) { + + if ( this.isUndoRedoAction ) { + + this.isUndoRedoAction = false; + return; + + } + + if ( this.index < this.history.length - 1 ) { + + this.history = this.history.slice( 0, this.index + 1 ); + + } + + if ( this.history[ this.index ] !== hash ) { + + this.history.push( hash ); + if ( this.history.length > this.limit ) { + + this.history.shift(); + + } + + this.index = this.history.length - 1; + + } + + this.updateButtons(); + + } + + undo() { + + if ( this.index > 0 ) { + + this.index --; + this.isUndoRedoAction = true; + window.location.hash = this.history[ this.index ]; + this.updateButtons(); + + } + + } + + redo() { + + if ( this.index < this.history.length - 1 ) { + + this.index ++; + this.isUndoRedoAction = true; + window.location.hash = this.history[ this.index ]; + this.updateButtons(); + + } + + } + + updateButtons() { + + const undoBtn = this.tour.dom.tabsBar.querySelector( '.playground-undo-btn' ); + const redoBtn = this.tour.dom.tabsBar.querySelector( '.playground-redo-btn' ); + + if ( undoBtn ) { + + undoBtn.disabled = this.index <= 0; + + } + + if ( redoBtn ) { + + redoBtn.disabled = this.index >= this.history.length - 1; + + } + + } + +} + +export { HistoryManager }; diff --git a/tsl/js/managers/LayoutManager.js b/tsl/js/managers/LayoutManager.js new file mode 100644 index 00000000000000..526340217a9cfa --- /dev/null +++ b/tsl/js/managers/LayoutManager.js @@ -0,0 +1,399 @@ +class LayoutManager { + + constructor( tour ) { + + this.tour = tour; + + } + + toggleSidebar( force ) { + + this.tour.isSidebarOpen = force !== undefined ? force : ! this.tour.isSidebarOpen; + if ( this.tour.isSidebarOpen ) { + + this.tour.dom.sidebar.classList.add( 'open' ); + this.tour.dom.menuToggleMain.style.display = 'none'; + this.tour.dom.headerSearchBtn.style.display = 'none'; + + } else { + + this.tour.dom.sidebar.classList.remove( 'open' ); + this.tour.dom.menuToggleMain.style.display = 'flex'; + this.tour.dom.headerSearchBtn.style.display = 'flex'; + this.tour.openedViaHeaderSearch = false; + + } + + } + + setResizerToggleIcon( iconName ) { + + const hResizerToggle = this.tour.dom.hResizerToggle; + const currentIcon = hResizerToggle.querySelector( '[data-icon]' ); + if ( currentIcon && currentIcon.getAttribute( 'data-icon' ) === iconName ) return; + hResizerToggle.innerHTML = ``; + this.tour.createIcons( hResizerToggle ); + + } + + updateVResizerIcons( height ) { + + const isCollapsed = height === '0%' || height === '0px'; + const isEditorCollapsed = height === '100%'; + + const iconName = isCollapsed ? 'chevron-down' : 'chevron-up'; + this.tour.setVResizerToggleIcon( iconName ); + + const invertedIconName = isEditorCollapsed ? 'chevron-up' : 'chevron-down'; + this.tour.setVResizerToggleInvertedIcon( invertedIconName ); + + if ( isCollapsed ) { + + this.tour.dom.vResizer.classList.add( 'collapsed' ); + document.body.classList.add( 'v-resizer-collapsed' ); + + document.body.classList.add( 'preview-hidden' ); + this.tour.dom.headerPreviewToggle.innerHTML = ''; + this.tour.createIcons( this.tour.dom.headerPreviewToggle ); + this.tour.isPreviewVisible = false; + + } else { + + this.tour.dom.vResizer.classList.remove( 'collapsed' ); + document.body.classList.remove( 'v-resizer-collapsed' ); + + if ( ! isEditorCollapsed ) { + + document.body.classList.remove( 'preview-hidden' ); + this.tour.dom.headerPreviewToggle.innerHTML = ''; + this.tour.createIcons( this.tour.dom.headerPreviewToggle ); + this.tour.isPreviewVisible = true; + + } + + } + + if ( isEditorCollapsed ) { + + this.tour.dom.vResizer.classList.add( 'editor-collapsed' ); + document.body.classList.add( 'v-resizer-editor-collapsed' ); + + } else { + + this.tour.dom.vResizer.classList.remove( 'editor-collapsed' ); + document.body.classList.remove( 'v-resizer-editor-collapsed' ); + + } + + } + + toggleConsole( forceState ) { + + const consolePanel = this.tour.dom.editorConsole; + const toggleIcon = this.tour.dom.consoleToggleIcon; + + const isMinimized = forceState !== undefined ? forceState : ! consolePanel.classList.contains( 'minimized' ); + + if ( isMinimized ) { + + consolePanel.classList.add( 'minimized' ); + toggleIcon.setAttribute( 'data-icon', 'chevron-up' ); + + } else { + + consolePanel.classList.remove( 'minimized' ); + toggleIcon.setAttribute( 'data-icon', 'chevron-down' ); + + } + + this.tour.createIcons( this.tour.dom.consoleToggleBtn ); + + if ( this.tour.codeEditor ) this.tour.codeEditor.layout(); + if ( this.tour.debugCodeEditor ) this.tour.debugCodeEditor.layout(); + + } + + setupResizer() { + + const MOBILE_BREAKPOINT = 768; + + this.tour.dom.hResizerToggle.addEventListener( 'pointerdown', ( e ) => { + + e.stopPropagation(); + + } ); + + this.tour.dom.hResizerToggle.addEventListener( 'click', ( e ) => { + + e.stopPropagation(); + + if ( this.tour.isEditorCollapsed ) { + + document.body.classList.remove( 'collapsed-workspace' ); + if ( window.innerWidth < MOBILE_BREAKPOINT ) { + + this.tour.dom.contentCol.style.width = '0%'; + this.tour.dom.contentCol.style.display = 'none'; + this.tour.dom.editorCol.style.width = '100%'; + + } else { + + this.tour.dom.contentCol.style.width = this.tour.lastContentWidth; + this.tour.dom.contentCol.style.display = 'flex'; + this.tour.dom.editorCol.style.width = ''; + + } + + this.tour.dom.editorCol.style.display = 'flex'; + this.tour.dom.hResizer.classList.remove( 'collapsed' ); + this.setResizerToggleIcon( 'chevron-right' ); + this.tour.isEditorCollapsed = false; + + this.tour.isPreviewVisible = true; + document.body.classList.remove( 'preview-hidden' ); + this.tour.dom.headerPreviewToggle.innerHTML = ''; + this.tour.createIcons( this.tour.dom.headerPreviewToggle ); + + } else { + + document.body.classList.add( 'collapsed-workspace' ); + if ( window.innerWidth < MOBILE_BREAKPOINT ) { + + this.tour.dom.contentCol.style.width = '100%'; + this.tour.dom.contentCol.style.display = 'flex'; + this.tour.dom.editorCol.style.width = '0%'; + + } else { + + this.tour.lastContentWidth = this.tour.dom.contentCol.style.width || '50%'; + this.tour.dom.contentCol.style.width = '100%'; + this.tour.dom.contentCol.style.display = 'flex'; + this.tour.dom.editorCol.style.width = ''; + + } + + this.tour.dom.editorCol.style.display = 'flex'; + this.tour.dom.hResizer.classList.add( 'collapsed' ); + this.setResizerToggleIcon( 'chevron-left' ); + this.tour.isEditorCollapsed = true; + + this.tour.isPreviewVisible = this.tour.lastReaderPreviewState; + document.body.classList.toggle( 'preview-hidden', ! this.tour.isPreviewVisible ); + this.tour.dom.headerPreviewToggle.innerHTML = this.tour.isPreviewVisible + ? '' + : ''; + this.tour.createIcons( this.tour.dom.headerPreviewToggle ); + + } + + if ( this.tour.isPlaygroundActive ) { + + if ( this.tour.codeEditor ) this.tour.codeEditor.layout(); + if ( this.tour.debugCodeEditor ) this.tour.debugCodeEditor.layout(); + + } else { + + const currentHash = window.location.hash.substring( 1 ); + const activeNode = currentHash.split( '&' )[ 1 ] || ''; + this.tour.renderPage( this.tour.currentPageIndex, activeNode, false ); + + } + + } ); + + this.tour.dom.vResizerToggle.addEventListener( 'pointerdown', ( e ) => { + + e.stopPropagation(); + + } ); + + this.tour.dom.vResizerToggle.addEventListener( 'click', ( e ) => { + + e.stopPropagation(); + + const previewSection = this.tour.dom.previewSection; + const currentHeight = previewSection.style.height; + const isCollapsed = currentHeight === '0px' || currentHeight === '0%'; + + if ( isCollapsed ) { + + const targetHeight = this.tour.lastPreviewHeight || '50%'; + previewSection.style.height = targetHeight; + this.updateVResizerIcons( targetHeight ); + + } else { + + this.tour.lastPreviewHeight = currentHeight; + previewSection.style.height = '0%'; + this.updateVResizerIcons( '0%' ); + + } + + if ( this.tour.codeEditor ) this.tour.codeEditor.layout(); + if ( this.tour.debugCodeEditor ) this.tour.debugCodeEditor.layout(); + + } ); + + this.tour.dom.vResizerToggleInverted.addEventListener( 'pointerdown', ( e ) => { + + e.stopPropagation(); + + } ); + + this.tour.dom.vResizerToggleInverted.addEventListener( 'click', ( e ) => { + + e.stopPropagation(); + + const previewSection = this.tour.dom.previewSection; + const currentHeight = previewSection.style.height; + const isEditorCollapsed = currentHeight === '100%'; + + if ( isEditorCollapsed ) { + + const targetHeight = this.tour.lastPreviewHeight || '50%'; + previewSection.style.height = targetHeight; + this.updateVResizerIcons( targetHeight ); + + } else { + + this.tour.lastPreviewHeight = currentHeight; + previewSection.style.height = '100%'; + this.updateVResizerIcons( '100%' ); + + } + + if ( this.tour.codeEditor ) this.tour.codeEditor.layout(); + if ( this.tour.debugCodeEditor ) this.tour.debugCodeEditor.layout(); + + } ); + + // Initial icon setup + if ( window.innerWidth >= MOBILE_BREAKPOINT ) { + + this.updateVResizerIcons( this.tour.dom.previewSection.style.height || '50%' ); + + } else { + + const height = this.tour.dom.previewSection.style.height || '50%'; + const isCollapsed = height === '0%' || height === '0px'; + const isEditorCollapsed = height === '100%'; + + const iconName = isCollapsed ? 'chevron-down' : 'chevron-up'; + this.tour.setVResizerToggleIcon( iconName ); + + const invertedIconName = isEditorCollapsed ? 'chevron-up' : 'chevron-down'; + this.tour.setVResizerToggleInvertedIcon( invertedIconName ); + + if ( isCollapsed ) { + + this.tour.dom.vResizer.classList.add( 'collapsed' ); + document.body.classList.add( 'v-resizer-collapsed' ); + + } else { + + this.tour.dom.vResizer.classList.remove( 'collapsed' ); + + } + + if ( isEditorCollapsed ) { + + this.tour.dom.vResizer.classList.add( 'editor-collapsed' ); + document.body.classList.add( 'v-resizer-editor-collapsed' ); + + } else { + + this.tour.dom.vResizer.classList.remove( 'editor-collapsed' ); + document.body.classList.remove( 'v-resizer-editor-collapsed' ); + + } + + } + + let isResizingH = false; + let isResizingV = false; + + this.tour.dom.hResizer.addEventListener( 'pointerdown', ( e ) => { + + if ( this.tour.isEditorCollapsed ) return; + isResizingH = true; + this.tour.dom.hResizer.classList.add( 'dragging' ); + this.tour.dom.hResizer.setPointerCapture( e.pointerId ); + document.body.style.userSelect = 'none'; + + } ); + + this.tour.dom.vResizer.addEventListener( 'pointerdown', ( e ) => { + + if ( this.tour.dom.vResizer.classList.contains( 'collapsed' ) || this.tour.dom.vResizer.classList.contains( 'editor-collapsed' ) ) return; + isResizingV = true; + this.tour.dom.vResizer.classList.add( 'dragging' ); + this.tour.dom.vResizer.setPointerCapture( e.pointerId ); + document.body.style.userSelect = 'none'; + + } ); + + this.onPointerMove = ( e ) => { + + if ( ! isResizingH && ! isResizingV ) return; + + if ( isResizingH ) { + + if ( window.innerWidth < MOBILE_BREAKPOINT ) return; + + const mainLayout = document.querySelector( '.main-layout' ); + const leftOffset = mainLayout.getBoundingClientRect().left; + const newWidth = ( ( e.clientX - leftOffset ) / mainLayout.clientWidth ) * 100; + + if ( newWidth > 20 && newWidth < 80 ) { + + this.tour.dom.contentCol.style.width = `${ newWidth }%`; + + } + + } + + if ( isResizingV ) { + + const editorWorkspace = document.querySelector( '.editor-workspace' ); + const containerHeight = editorWorkspace.clientHeight; + const topOffset = editorWorkspace.getBoundingClientRect().top; + const pointerYRelative = e.clientY - topOffset; + const newHeight = ( pointerYRelative / containerHeight ) * 100; + + if ( newHeight > 10 && newHeight < 90 ) { + + const targetHeight = `${ newHeight }%`; + this.tour.dom.previewSection.style.height = targetHeight; + this.updateVResizerIcons( targetHeight ); + + } + + } + + }; + + this.onPointerUp = () => { + + isResizingH = false; + isResizingV = false; + this.tour.dom.hResizer.classList.remove( 'dragging' ); + this.tour.dom.vResizer.classList.remove( 'dragging' ); + document.body.style.userSelect = ''; + + }; + + window.addEventListener( 'pointermove', this.onPointerMove ); + window.addEventListener( 'pointerup', this.onPointerUp ); + + } + + dispose() { + + window.removeEventListener( 'pointermove', this.onPointerMove ); + window.removeEventListener( 'pointerup', this.onPointerUp ); + + } + +} + +export { LayoutManager }; diff --git a/tsl/js/managers/PlaygroundManager.js b/tsl/js/managers/PlaygroundManager.js new file mode 100644 index 00000000000000..41027f24a27750 --- /dev/null +++ b/tsl/js/managers/PlaygroundManager.js @@ -0,0 +1,839 @@ +import * as THREE from 'three'; +import { CodeCompiler } from '../code/CodeCompiler.js'; +import { compressString, decompressString } from '../utils/TourUtils.js'; + +class PlaygroundManager { + + constructor( tour ) { + + this.tour = tour; + this.playgroundTabs = null; + this.activePlaygroundTabName = null; + + } + + togglePlayground( active ) { + + const MOBILE_BREAKPOINT = 768; + if ( this.tour.isPlaygroundActive === active ) return; + + this.tour.isPlaygroundActive = active; + document.body.classList.toggle( 'playground-mode', active ); + this.tour.dom.playgroundBtn.classList.toggle( 'active', active ); + + if ( active ) { + + if ( this.tour.renderer && this.tour.renderer.domElement.parentElement !== this.tour.dom.previewContainer ) { + + this.tour.dom.previewContainer.appendChild( this.tour.renderer.domElement ); + + } + + if ( this.tour.resizeObserver ) { + + this.tour.resizeObserver.disconnect(); + this.tour.resizeObserver.observe( this.tour.dom.previewContainer ); + + } + + this.tour.isPreviewVisible = true; + document.body.classList.remove( 'preview-hidden' ); + + if ( window.innerWidth < MOBILE_BREAKPOINT ) { + + // Mobile layout: go to workspace-editor mode + const editorWorkspace = document.querySelector( '.editor-workspace' ); + editorWorkspace.insertBefore( this.tour.dom.codeContainer, this.tour.dom.debugContainer ); + editorWorkspace.appendChild( this.tour.dom.editorConsole ); + + document.body.classList.remove( 'collapsed-workspace' ); + this.tour.dom.contentCol.style.width = '0%'; + this.tour.dom.contentCol.style.display = 'none'; + this.tour.dom.editorCol.style.width = '100%'; + this.tour.dom.editorCol.style.display = 'flex'; + this.tour.dom.vResizer.style.display = ''; + this.tour.dom.previewSection.style.height = ''; + this.tour.dom.previewSection.style.flex = ''; + this.tour.dom.debugContainer.style.display = 'none'; + + } else { + + // Desktop layout: code editor on the left (replacing contentArea), preview taking top half of right column, debug container taking bottom half + this.tour.dom.contentArea.style.display = 'none'; + this.tour.dom.contentCol.appendChild( this.tour.dom.codeContainer ); + this.tour.dom.contentCol.appendChild( this.tour.dom.editorConsole ); + this.tour.dom.vResizer.style.display = 'block'; + this.tour.dom.previewSection.style.height = '50%'; + this.tour.dom.previewSection.style.flex = ''; + this.tour.dom.codeContainer.style.height = ''; + this.tour.dom.debugContainer.style.display = 'flex'; + + // Set column widths to default (50/50) or keep current horizontal split + if ( this.tour.isEditorCollapsed ) { + + document.body.classList.add( 'collapsed-workspace' ); + this.tour.dom.hResizer.classList.add( 'collapsed' ); + this.tour.setResizerToggleIcon( 'chevron-left' ); + this.tour.dom.contentCol.style.width = '100%'; + this.tour.dom.contentCol.style.display = 'flex'; + this.tour.dom.editorCol.style.width = '0%'; + this.tour.dom.editorCol.style.display = 'flex'; + + } else { + + document.body.classList.remove( 'collapsed-workspace' ); + this.tour.dom.hResizer.classList.remove( 'collapsed' ); + this.tour.setResizerToggleIcon( 'chevron-right' ); + this.tour.dom.contentCol.style.width = '50%'; + this.tour.dom.contentCol.style.display = 'flex'; + this.tour.dom.editorCol.style.width = '50%'; + this.tour.dom.editorCol.style.display = 'flex'; + + } + + this.updateDebugWGSL(); + + } + + if ( this.tour.codeEditor ) this.tour.codeEditor.layout(); + + } else { + + if ( ! this.tour.isContentRendered ) { + + this.tour.renderPage( this.tour.currentPageIndex || 0 ); + + } + + this.tour.dom.debugContainer.style.display = 'none'; + this.tour.dom.contentArea.style.display = ''; + + if ( window.innerWidth < MOBILE_BREAKPOINT ) { + + // Restore mobile layout (reader mode by default) + this.tour.isEditorCollapsed = true; + document.body.classList.add( 'collapsed-workspace' ); + this.tour.dom.contentCol.style.width = '100%'; + this.tour.dom.contentCol.style.display = 'flex'; + this.tour.dom.editorCol.style.width = '0%'; + this.tour.dom.editorCol.style.display = 'none'; + + } else { + + // Restore desktop layout + this.tour.dom.contentArea.style.display = ''; + const editorWorkspace = document.querySelector( '.editor-workspace' ); + editorWorkspace.insertBefore( this.tour.dom.codeContainer, this.tour.dom.debugContainer ); + editorWorkspace.appendChild( this.tour.dom.editorConsole ); + this.tour.dom.vResizer.style.display = ''; + this.tour.dom.previewSection.style.height = ''; + this.tour.dom.previewSection.style.flex = ''; + this.tour.dom.codeContainer.style.height = ''; + + this.tour.layoutManager.updateVResizerIcons( '' ); + + if ( this.tour.isEditorCollapsed ) { + + document.body.classList.add( 'collapsed-workspace' ); + this.tour.dom.hResizer.classList.add( 'collapsed' ); + this.tour.setResizerToggleIcon( 'chevron-left' ); + this.tour.dom.contentCol.style.width = '100%'; + this.tour.dom.editorCol.style.width = '0%'; + + } else { + + document.body.classList.remove( 'collapsed-workspace' ); + this.tour.dom.hResizer.classList.remove( 'collapsed' ); + this.tour.setResizerToggleIcon( 'chevron-right' ); + this.tour.dom.contentCol.style.width = this.tour.lastContentWidth || '50%'; + this.tour.dom.editorCol.style.width = ''; + + } + + } + + if ( this.tour.codeEditor ) this.tour.codeEditor.layout(); + + } + + this.tour.updateUI(); + + } + + async loadPlaygroundFromHash( hash ) { + + const base64Str = hash.replace( /^playground[=\/]/, '' ).split( '&' )[ 0 ]; + let decodedCode = ''; + try { + + decodedCode = await decompressString( base64Str ); + + } catch ( e ) { + + console.error( 'Failed to decode playground code from hash:', e ); + return; + + } + + // Enable playground layout + this.togglePlayground( true ); + + this.tour.historyManager.pushState( hash ); + + let decodedTabs = null; + try { + + const parsed = JSON.parse( decodedCode ); + if ( parsed && Array.isArray( parsed.tabs ) && parsed.tabs.length > 0 ) { + + decodedTabs = parsed.tabs; + + } + + } catch { + // Not JSON, fallback to single Main tab + } + + if ( decodedTabs ) { + + let changedTabName = null; + if ( this.playgroundTabs ) { + + for ( const newTab of decodedTabs ) { + + const cleanNewName = newTab.name.toLowerCase(); + const oldTab = this.playgroundTabs.find( t => t.name === cleanNewName ); + if ( ! oldTab || oldTab.code !== newTab.code ) { + + changedTabName = cleanNewName; + break; + + } + + } + + } + + this.playgroundTabs = decodedTabs.map( t => ( { ...t, name: t.name.toLowerCase() } ) ); + if ( changedTabName ) { + + this.activePlaygroundTabName = changedTabName; + + } else if ( ! this.activePlaygroundTabName || ! this.playgroundTabs.some( t => t.name === this.activePlaygroundTabName ) ) { + + this.activePlaygroundTabName = this.playgroundTabs[ 0 ].name; + + } + + } else { + + this.playgroundTabs = [ { name: 'main', code: decodedCode } ]; + this.activePlaygroundTabName = 'main'; + + } + + // Render the playground tabs UI + this.renderPlaygroundTabs(); + + const activeTab = this.playgroundTabs.find( t => t.name === this.activePlaygroundTabName ) || this.playgroundTabs[ 0 ]; + + if ( this.tour.codeEditor ) { + + const currentVal = this.tour.codeEditor.getValue(); + if ( currentVal !== activeTab.code ) { + + this.tour.codeEditor.setValue( activeTab.code ); + + } + + } + + this.runPlayground(); + + } + + renderPlaygroundTabs() { + + if ( ! this.tour.isPlaygroundActive ) { + + this.tour.dom.tabsBar.style.display = 'none'; + return; + + } + + this.tour.dom.tabsBar.style.display = 'flex'; + this.tour.dom.tabsBar.innerHTML = ''; + + if ( ! this.playgroundTabs ) { + + this.playgroundTabs = [ { name: 'main', code: '// Play here!\n' } ]; + this.activePlaygroundTabName = 'main'; + + } + + this.playgroundTabs.forEach( ( tab ) => { + + const tabEl = document.createElement( 'div' ); + tabEl.className = 'playground-tab'; + if ( tab.name === this.activePlaygroundTabName ) { + + tabEl.classList.add( 'active' ); + + } + + const labelEl = document.createElement( 'span' ); + labelEl.className = 'playground-tab-label'; + labelEl.textContent = tab.name; + tabEl.appendChild( labelEl ); + + if ( tab.name !== 'main' ) { + + const closeEl = document.createElement( 'span' ); + closeEl.className = 'playground-tab-close'; + closeEl.innerHTML = ''; + closeEl.title = 'Delete tab'; + closeEl.onclick = ( e ) => { + + e.stopPropagation(); + this.closePlaygroundTab( tab.name ); + + }; + + tabEl.appendChild( closeEl ); + + } + + tabEl.onclick = () => { + + this.activatePlaygroundTab( tab.name ); + + }; + + labelEl.ondblclick = ( e ) => { + + e.stopPropagation(); + this.startRenameTab( tab.name, labelEl ); + + }; + + this.tour.dom.tabsBar.appendChild( tabEl ); + + } ); + + const addBtn = document.createElement( 'div' ); + addBtn.className = 'playground-tab-add'; + addBtn.innerHTML = '+'; + addBtn.title = 'Add new tab'; + addBtn.onclick = () => { + + this.addNewPlaygroundTab(); + + }; + + this.tour.dom.tabsBar.appendChild( addBtn ); + + // Create Clean & Format button + const cleanBtn = document.createElement( 'button' ); + cleanBtn.className = 'playground-tab-btn playground-clean-btn'; + cleanBtn.innerHTML = ''; + cleanBtn.title = 'Clean imports & Format code'; + cleanBtn.onclick = async ( e ) => { + + e.stopPropagation(); + + await this.cleanAndFormatActiveTab(); + + }; + + // Create Refresh button + const refreshBtn = document.createElement( 'button' ); + refreshBtn.className = 'playground-tab-btn playground-refresh-btn'; + refreshBtn.innerHTML = ''; + refreshBtn.title = 'Refresh WebGPU Renderer & Runner'; + refreshBtn.onclick = async ( e ) => { + + e.stopPropagation(); + + await this.tour.refresh(); + + }; + + // Create Undo & Redo buttons + const undoBtn = document.createElement( 'button' ); + undoBtn.className = 'playground-tab-btn playground-undo-btn'; + undoBtn.innerHTML = ''; + undoBtn.title = 'Undo'; + undoBtn.onclick = ( e ) => { + + e.stopPropagation(); + this.undoPlayground(); + + }; + + const redoBtn = document.createElement( 'button' ); + redoBtn.className = 'playground-tab-btn playground-redo-btn'; + redoBtn.innerHTML = ''; + redoBtn.title = 'Redo'; + redoBtn.onclick = ( e ) => { + + e.stopPropagation(); + this.redoPlayground(); + + }; + + this.tour.dom.tabsBar.appendChild( cleanBtn ); + this.tour.dom.tabsBar.appendChild( undoBtn ); + this.tour.dom.tabsBar.appendChild( redoBtn ); + this.tour.dom.tabsBar.appendChild( refreshBtn ); + + this.updateUndoRedoButtons(); + + } + + activatePlaygroundTab( name ) { + + if ( this.activePlaygroundTabName === name ) return; + + const activeTab = this.playgroundTabs.find( t => t.name === this.activePlaygroundTabName ); + if ( activeTab && this.tour.codeEditor ) { + + activeTab.code = this.tour.codeEditor.getValue(); + + } + + this.activePlaygroundTabName = name; + + const newActiveTab = this.playgroundTabs.find( t => t.name === this.activePlaygroundTabName ); + if ( newActiveTab && this.tour.codeEditor ) { + + const currentVal = this.tour.codeEditor.getValue(); + if ( currentVal !== newActiveTab.code ) { + + this.tour.codeEditor.setValue( newActiveTab.code ); + + } + + } + + this.renderPlaygroundTabs(); + this.runPlayground(); + + this.updatePlaygroundHash(); + + } + + addNewPlaygroundTab() { + + let counter = 1; + let newTabName = ''; + while ( true ) { + + newTabName = `script${counter}`; + if ( ! this.playgroundTabs.some( t => t.name === newTabName ) ) { + + break; + + } + + counter ++; + + } + + const newTabCode = `// Script: ${newTabName}\nexport { };\n`; + + const activeTab = this.playgroundTabs.find( t => t.name === this.activePlaygroundTabName ); + if ( activeTab && this.tour.codeEditor ) { + + activeTab.code = this.tour.codeEditor.getValue(); + + } + + this.playgroundTabs.push( { name: newTabName, code: newTabCode } ); + this.activePlaygroundTabName = newTabName; + + if ( this.tour.codeEditor ) { + + this.tour.codeEditor.setValue( newTabCode ); + + } + + this.renderPlaygroundTabs(); + this.runPlayground(); + + this.updatePlaygroundHash(); + + } + + closePlaygroundTab( name ) { + + const index = this.playgroundTabs.findIndex( t => t.name === name ); + if ( index === - 1 ) return; + + const activeTab = this.playgroundTabs.find( t => t.name === this.activePlaygroundTabName ); + if ( activeTab && this.tour.codeEditor ) { + + activeTab.code = this.tour.codeEditor.getValue(); + + } + + this.playgroundTabs.splice( index, 1 ); + + if ( this.activePlaygroundTabName === name ) { + + this.activePlaygroundTabName = this.playgroundTabs[ Math.max( 0, index - 1 ) ].name; + + } + + const newActiveTab = this.playgroundTabs.find( t => t.name === this.activePlaygroundTabName ) || this.playgroundTabs[ 0 ]; + if ( newActiveTab && this.tour.codeEditor ) { + + const currentVal = this.tour.codeEditor.getValue(); + if ( currentVal !== newActiveTab.code ) { + + this.tour.codeEditor.setValue( newActiveTab.code ); + + } + + } + + this.renderPlaygroundTabs(); + this.runPlayground(); + + this.updatePlaygroundHash(); + + } + + startRenameTab( name, labelEl ) { + + if ( name === 'main' ) return; + + const currentName = name; + const input = document.createElement( 'input' ); + input.type = 'text'; + input.className = 'playground-tab-rename-input'; + input.value = currentName; + + const parent = labelEl.parentNode; + parent.replaceChild( input, labelEl ); + input.focus(); + input.select(); + + let finished = false; + const finishRename = () => { + + if ( finished ) return; + finished = true; + + const newName = input.value.trim().toLowerCase(); + const isValidIdentifier = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test( newName ); + const isUnique = ! this.playgroundTabs.some( t => t.name === newName && t.name !== currentName ); + + if ( newName && isValidIdentifier && isUnique ) { + + const activeTab = this.playgroundTabs.find( t => t.name === this.activePlaygroundTabName ); + if ( activeTab && this.tour.codeEditor ) { + + activeTab.code = this.tour.codeEditor.getValue(); + + } + + const tabToRename = this.playgroundTabs.find( t => t.name === currentName ); + if ( tabToRename ) { + + tabToRename.name = newName; + + } + + if ( this.activePlaygroundTabName === currentName ) { + + this.activePlaygroundTabName = newName; + + } + + this.renderPlaygroundTabs(); + this.runPlayground(); + this.updatePlaygroundHash(); + + } else { + + parent.replaceChild( labelEl, input ); + + } + + }; + + input.onkeydown = ( e ) => { + + if ( e.key === 'Enter' ) { + + finishRename(); + + } else if ( e.key === 'Escape' ) { + + finished = true; + parent.replaceChild( labelEl, input ); + + } + + }; + + input.onblur = () => { + + finishRename(); + + }; + + } + + async updatePlaygroundHash() { + + const encoded = await compressString( JSON.stringify( { + tabs: this.playgroundTabs + } ) ); + const revision = THREE.REVISION; + const newHash = 'playground=' + encoded + '&release=' + revision; + window.location.hash = newHash; + + } + + undoPlayground() { + + this.tour.historyManager.undo(); + + } + + redoPlayground() { + + this.tour.historyManager.redo(); + + } + + updateUndoRedoButtons() { + + this.tour.historyManager.updateButtons(); + + } + + async cleanAndFormatActiveTab() { + + if ( ! this.tour.codeEditor ) return; + + const code = this.tour.codeEditor.getValue(); + const formatted = await CodeCompiler.format( code ); + + // Set the new formatted value in the editor and update state/hash + const currentVal = this.tour.codeEditor.getValue(); + if ( currentVal !== formatted ) { + + this.tour.codeEditor.format( formatted ); + + const activeTab = this.playgroundTabs.find( t => t.name === this.activePlaygroundTabName ); + if ( activeTab ) { + + activeTab.code = formatted; + + } + + this.runPlayground(); + this.updatePlaygroundHash(); + + } + + } + + runPlayground() { + + if ( ! this.playgroundTabs ) return; + + const mainTab = this.playgroundTabs.find( t => t.name === 'main' ) || this.playgroundTabs[ 0 ]; + + const tabNames = this.playgroundTabs.map( t => t.name ); + + // 1. Identify virtual scripts that are no longer in tabs (i.e. deleted tabs) + for ( const key of Object.keys( this.tour.runner.scripts ) ) { + + if ( this.tour.runner.scripts[ key ].url === null && key !== '__main__' ) { + + if ( ! tabNames.includes( key ) ) { + + const scriptConfig = this.tour.runner.scripts[ key ]; + if ( scriptConfig && scriptConfig.instance && scriptConfig.instance.dispose ) { + + scriptConfig.instance.dispose(); + + } + + delete this.tour.runner.scripts[ key ]; + + } + + } + + } + + // 2. Add or update virtual script configs based on tabs + this.playgroundTabs.forEach( tab => { + + if ( tab.name !== 'main' ) { + + const existing = this.tour.runner.scripts[ tab.name ]; + if ( ! existing || existing.text !== tab.code ) { + + if ( existing && existing.instance && existing.instance.dispose ) { + + existing.instance.dispose(); + + } + + this.tour.runner.scripts[ tab.name ] = { + url: null, + text: tab.code, + instance: null, + promise: null + }; + + } + + } + + } ); + + this.tour.runner.run( mainTab.code ); + + } + + getDebugTarget() { + + // 1. Check main script + const mainScript = this.tour.runner.scripts[ '__main__' ]; + if ( mainScript && mainScript.instance && typeof mainScript.instance.debug === 'function' ) { + + return mainScript.instance.debug(); + + } + + // 2. Check other scripts + for ( const scriptName of this.tour.runner.activeScriptNames ) { + + const script = this.tour.runner.scripts[ scriptName ]; + if ( script && script.instance && typeof script.instance.debug === 'function' ) { + + return script.instance.debug(); + + } + + } + + return null; + + } + + updateDebugWGSL() { + + if ( ! this.tour.isPlaygroundActive ) return; + + const debugData = this.getDebugTarget(); + if ( ! debugData ) { + + this.tour.debugCodeEditor.setValue( 'No debug() function exported or debug target object found.' ); + + // Collapse/hide debug container similar to clicking v-resizer-toggle-inverted + const previewSection = this.tour.dom.previewSection; + const currentHeight = previewSection.style.height; + if ( currentHeight !== '100%' ) { + + this.tour.lastPreviewHeight = currentHeight; + + } + + previewSection.style.height = '100%'; + this.tour.layoutManager.updateVResizerIcons( '100%' ); + + this.tour.codeEditor.layout(); + this.tour.debugCodeEditor.layout(); + return; + + } + + let scene, camera, object; + if ( debugData.scene && debugData.camera && debugData.object ) { + + scene = debugData.scene; + camera = debugData.camera; + object = debugData.object; + + } else { + + object = debugData; + scene = this.tour.runner.env.scene || this.tour.scene; + camera = this.tour.runner.env.camera || this.tour.camera; + + } + + if ( ! scene || ! camera ) { + + this.tour.debugCodeEditor.setValue( 'Invalid debug data. Ensure scene, camera, and object are provided.' ); + + // Collapse/hide debug container + const previewSection = this.tour.dom.previewSection; + const currentHeight = previewSection.style.height; + if ( currentHeight !== '100%' ) { + + this.tour.lastPreviewHeight = currentHeight; + + } + + previewSection.style.height = '100%'; + this.tour.layoutManager.updateVResizerIcons( '100%' ); + + this.tour.codeEditor.layout(); + this.tour.debugCodeEditor.layout(); + return; + + } + + // Restore/expand debug container since we have valid debug data + const previewSection = this.tour.dom.previewSection; + const currentHeight = previewSection.style.height; + if ( currentHeight === '100%' ) { + + const targetHeight = this.tour.lastPreviewHeight || '50%'; + previewSection.style.height = targetHeight; + this.tour.layoutManager.updateVResizerIcons( targetHeight ); + + this.tour.codeEditor.layout(); + this.tour.debugCodeEditor.layout(); + + } + + const targetRenderer = this.tour.debugLanguage === 'GLSL' ? this.tour.webGLRenderer : this.tour.renderer; + + if ( targetRenderer && targetRenderer.debug && typeof targetRenderer.debug.getShaderAsync === 'function' ) { + + targetRenderer.debug.getShaderAsync( scene, camera, object ) + .then( ( shader ) => { + + const code = this.tour.debugStage === 'vertex' + ? ( shader.vertexShader || 'No vertex shader generated.' ) + : ( shader.fragmentShader || 'No fragment shader generated.' ); + + this.tour.debugCodeEditor.setValue( code ); + + } ) + .catch( ( err ) => { + + this.tour.debugCodeEditor.setValue( 'Error retrieving shader: ' + err.message ); + + } ); + + } else { + + this.tour.debugCodeEditor.setValue( 'WebGPURenderer debug.getShaderAsync is not available.' ); + + } + + } + +} + +export { PlaygroundManager }; diff --git a/tsl/js/managers/SearchManager.js b/tsl/js/managers/SearchManager.js new file mode 100644 index 00000000000000..d362f5e73e14e6 --- /dev/null +++ b/tsl/js/managers/SearchManager.js @@ -0,0 +1,772 @@ +class SearchManager { + + constructor( tour ) { + + this.tour = tour; + this.index = new Map(); // word -> Set of page IDs / node references + this.casedVocabulary = new Map(); // lowercase -> original case + this.debounceTimeout = null; + + } + + buildIndex() { + + this.index.clear(); + this.casedVocabulary.clear(); + + // Index words from all pages and category folders + const collectAllNodes = ( nodes ) => { + + const all = []; + for ( const n of nodes ) { + + all.push( n ); + if ( n.children && n.children.length > 0 ) { + + all.push( ...collectAllNodes( n.children ) ); + + } + + } + + return all; + + }; + + const allNodes = collectAllNodes( this.tour.pageTree || [] ); + + allNodes.forEach( node => { + + const rawContent = [ + node.title || '', + ( node.path || [] ).join( ' ' ), + this.getCleanText( node.description || '' ) + ].join( ' ' ); + + const casedWords = rawContent.split( /[^a-zA-Z0-9_]+/ ).filter( w => w.length > 1 ); + casedWords.forEach( word => { + + const lower = word.toLowerCase(); + if ( ! this.casedVocabulary.has( lower ) || ( word !== lower && this.casedVocabulary.get( lower ) === lower ) ) { + + this.casedVocabulary.set( lower, word ); + + } + + } ); + + const content = rawContent.toLowerCase(); + const words = content.split( /[^a-z0-9_]+/ ).filter( w => w.length > 1 ); + words.forEach( word => { + + if ( ! this.index.has( word ) ) { + + this.index.set( word, new Set() ); + + } + + this.index.get( word ).add( node.id || node.title ); + + } ); + + } ); + + } + + calculateScore( item, rawQuery, queryTerms ) { + + const title = item.title || ''; + const titleLower = title.toLowerCase(); + const queryLower = rawQuery.toLowerCase(); + const pathText = ( item.path || [] ).join( ' ' ).toLowerCase(); + const rawDesc = item.description || ''; + const cleanDesc = rawDesc ? this.getCleanText( rawDesc ).toLowerCase() : ''; + + let score = 0; + let matchedTermsCount = 0; + + // 1. Exact Match on Title + if ( titleLower === queryLower ) { + + score += 10000; + + } else if ( titleLower.startsWith( queryLower ) ) { + + score += 6000; + + } else if ( titleLower.includes( queryLower ) ) { + + score += 4000; + + } + + // 2. Term-by-term Title Matching + const titleWords = titleLower.split( /[^a-z0-9_]+/ ).filter( Boolean ); + let termsInTitleCount = 0; + + for ( const term of queryTerms ) { + + let termMatched = false; + + if ( titleWords.includes( term ) ) { + + score += 1500; + termMatched = true; + termsInTitleCount ++; + + } else if ( titleWords.some( w => w.startsWith( term ) ) ) { + + score += 1000; + termMatched = true; + termsInTitleCount ++; + + } else if ( titleLower.includes( term ) ) { + + score += 600; + termMatched = true; + termsInTitleCount ++; + + } + + // Path / Category matches + if ( pathText.includes( term ) ) { + + score += 300; + termMatched = true; + + } + + // API signatures match (::: api name or .name) + if ( rawDesc ) { + + const apiRegex = new RegExp( `:::\\s*api\\s+\\.?${term}`, 'i' ); + if ( apiRegex.test( rawDesc ) ) { + + score += 2500; + termMatched = true; + + } else if ( cleanDesc.includes( term ) ) { + + score += 150; + termMatched = true; + + const freq = ( cleanDesc.split( term ).length - 1 ); + score += Math.min( freq * 15, 150 ); + + } + + } + + if ( termMatched ) { + + matchedTermsCount ++; + + } + + } + + // All terms matched in title bonus + if ( queryTerms.length > 1 && termsInTitleCount === queryTerms.length ) { + + score += 3500; + + } + + // Exact phrase in description + if ( cleanDesc && queryLower.length > 3 && cleanDesc.includes( queryLower ) ) { + + score += 1000; + + } + + // Folder title boost: if a category/folder matches the query, rank it very high + if ( item.isFolder && ( titleLower === queryLower || titleLower.includes( queryLower ) || termsInTitleCount > 0 ) ) { + + score += 5000; + + } + + // Coverage Multiplier: + // Highly favor matches that cover ALL search terms vs only 1 term in multi-term queries + if ( queryTerms.length > 1 ) { + + const ratio = matchedTermsCount / queryTerms.length; + if ( ratio >= 1.0 ) { + + score *= 2.5; + + } else if ( ratio >= 0.5 ) { + + score *= 0.8; + + } else { + + score *= 0.2; + + } + + } + + return Math.round( score ); + + } + + getRankedTree( tree, query, queryTerms ) { + + const processNodes = ( nodes ) => { + + const result = []; + + for ( const node of nodes ) { + + const copy = { ...node }; + const nodeScore = this.calculateScore( node, query, queryTerms ); + + let filteredChildren = []; + let childrenMaxScore = 0; + + if ( node.children && node.children.length > 0 ) { + + filteredChildren = processNodes( node.children ); + if ( filteredChildren.length > 0 ) { + + childrenMaxScore = Math.max( ...filteredChildren.map( c => c.searchScore || 0 ) ); + + } + + } + + // If the folder itself matched strongly, but some children had 0 individual score, + // include all children so the category contents are visible! + if ( node.isFolder && nodeScore > 2000 && filteredChildren.length === 0 && node.children.length > 0 ) { + + filteredChildren = node.children.map( c => ( { + ...c, + searchScore: nodeScore - 500 + } ) ); + + } + + const isMatch = ( nodeScore > 0 ) || ( filteredChildren.length > 0 ); + + if ( isMatch ) { + + copy.children = filteredChildren; + copy.searchScore = Math.max( nodeScore, childrenMaxScore ); + + // Generate snippet for leaf pages + if ( ! copy.isFolder && copy.description ) { + + const cleanText = this.getCleanText( copy.description ); + copy.searchSnippet = this.getSearchSnippet( cleanText, queryTerms, copy.title ); + + } else { + + delete copy.searchSnippet; + + } + + result.push( copy ); + + } + + } + + // Sort by searchScore descending! + result.sort( ( a, b ) => ( b.searchScore || 0 ) - ( a.searchScore || 0 ) ); + + return result; + + }; + + return processNodes( tree ); + + } + + performSearch( query ) { + + const trimmed = query.trim(); + + if ( trimmed.length === 0 ) { + + this.tour.dom.tocList.classList.remove( 'search-active' ); + + const cleanTree = ( nodes ) => { + + nodes.forEach( n => { + + delete n.searchSnippet; + delete n.searchScore; + if ( n.children ) cleanTree( n.children ); + + } ); + + }; + + cleanTree( this.tour.pageTree ); + this.tour.setupTOC( this.tour.pageTree ); + + } else { + + this.tour.dom.tocList.classList.add( 'search-active' ); + + const queryLower = trimmed.toLowerCase(); + const queryTerms = queryLower.split( /\s+/ ).map( t => t.replace( /^[^a-z0-9_]+|[^a-z0-9_]+$/g, '' ) ).filter( Boolean ); + + let rankedTree = this.getRankedTree( this.tour.pageTree, trimmed, queryTerms ); + + let suggestion = null; + if ( rankedTree.length === 0 ) { + + suggestion = this.getSpellingSuggestion( query ); + if ( suggestion ) { + + const suggTrimmed = suggestion.trim().toLowerCase(); + const suggQueryTerms = suggTrimmed.split( /\s+/ ).map( t => t.replace( /^[^a-z0-9_]+|[^a-z0-9_]+$/g, '' ) ).filter( Boolean ); + rankedTree = this.getRankedTree( this.tour.pageTree, suggestion, suggQueryTerms ); + + } + + } + + this.tour.setupTOC( rankedTree, null, suggestion ); + + } + + const sidebarContent = this.tour.dom.sidebar.querySelector( '.sidebar-content' ); + if ( sidebarContent ) { + + sidebarContent.scrollTop = 0; + + } + + } + + scrollToSearchMatch() { + + const query = this.tour.dom.searchInput.value.trim(); + if ( query.length === 0 ) return; + + const queryTerms = query.toLowerCase().split( /\s+/ ).map( t => t.replace( /^[^a-z0-9_]+|[^a-z0-9_]+$/g, '' ) ).filter( t => t.length > 0 ); + if ( queryTerms.length === 0 ) return; + + let targetElement = null; + + // 1. Prioritize API classes, summaries, signatures, and rows + const apiElements = this.tour.dom.contentArea.querySelectorAll( '.tsl-api-class-summary, .tsl-api-class-name, .tsl-api-class-extends-name, .tsl-api-table-row, .tsl-api-signature, .tsl-api-sig-name, .tsl-api-card, .tsl-api-param, .tsl-api-inherited-summary' ); + for ( const el of apiElements ) { + + const text = el.textContent.toLowerCase(); + const matches = queryTerms.every( term => text.includes( term ) ); + if ( matches ) { + + targetElement = el.closest( '.tsl-api-table-row' ) || el.closest( '.tsl-api-class-summary' ) || el.closest( '.tsl-api-inherited-summary' ) || el; + + let parent = targetElement.parentElement; + while ( parent && parent !== this.tour.dom.contentArea ) { + + if ( parent.tagName === 'DETAILS' ) { + + parent.open = true; + + } + + parent = parent.parentElement; + + } + + break; + + } + + } + + // 2. If no API element matched, check general text elements + if ( ! targetElement ) { + + const generalElements = this.tour.dom.contentArea.querySelectorAll( 'h1, h2, h3, p, li, td, code, blockquote, .tour-note-block, .tour-important-block, .tour-ai-accordion, .tour-ai-content' ); + for ( const el of generalElements ) { + + const text = el.textContent.toLowerCase(); + const matches = queryTerms.every( term => text.includes( term ) ); + if ( matches ) { + + targetElement = el; + + let parent = el.parentElement; + while ( parent && parent !== this.tour.dom.contentArea ) { + + if ( parent.tagName === 'DETAILS' ) { + + parent.open = true; + + } + + parent = parent.parentElement; + + } + + break; + + } + + } + + } + + if ( ! targetElement && this.tour.readOnlyEditors ) { + + for ( const editor of this.tour.readOnlyEditors ) { + + const codeText = ( editor.getValue() || '' ).toLowerCase(); + const matches = queryTerms.every( term => codeText.includes( term ) ); + if ( matches && editor.container ) { + + targetElement = editor.container.closest( '.tsl-embed-container' ) || editor.container; + break; + + } + + } + + } + + if ( targetElement ) { + + const previousFlashes = this.tour.dom.contentArea.querySelectorAll( '.search-match-flash' ); + previousFlashes.forEach( el => el.classList.remove( 'search-match-flash' ) ); + + const observer = new IntersectionObserver( ( entries ) => { + + entries.forEach( entry => { + + if ( entry.isIntersecting ) { + + observer.unobserve( targetElement ); + targetElement.classList.add( 'search-match-flash' ); + + } + + } ); + + }, { + root: this.tour.dom.contentArea, + threshold: 0.1 + } ); + + observer.observe( targetElement ); + targetElement.scrollIntoView( { behavior: 'smooth', block: 'center' } ); + + } + + } + + updateHashWithSearch( query ) { + + const hash = window.location.hash.substring( 1 ); + if ( hash.startsWith( 'playground=' ) || hash.startsWith( 'playground/' ) ) return; + + const hashParts = hash.split( '&' ); + const pageId = hashParts[ 0 ] || ( this.tour.pages[ this.tour.currentPageIndex ] ? this.tour.pages[ this.tour.currentPageIndex ].id : '' ); + if ( ! pageId ) return; + + let selectedNode = ''; + for ( let i = 1; i < hashParts.length; i ++ ) { + + const part = hashParts[ i ]; + if ( ! part.startsWith( 'q=' ) ) { + + selectedNode = part; + + } + + } + + let newHash = pageId; + if ( selectedNode ) { + + newHash += '&' + selectedNode; + + } + + if ( query.trim().length > 0 ) { + + newHash += '&q=' + encodeURIComponent( query.trim() ); + + } + + history.replaceState( null, null, '#' + newHash ); + this.tour.lastTourPageHash = newHash; + + } + + restoreSearchFromHash( hash ) { + + const hashParts = hash.split( '&' ); + let searchQuery = ''; + + for ( let i = 1; i < hashParts.length; i ++ ) { + + const part = hashParts[ i ]; + if ( part.startsWith( 'q=' ) ) { + + searchQuery = decodeURIComponent( part.substring( 2 ) ); + + } + + } + + if ( searchQuery ) { + + this.tour.dom.searchInput.value = searchQuery; + this.tour.dom.searchClear.style.display = 'flex'; + this.tour.dom.searchContainer.classList.add( 'focused' ); + this.performSearch( searchQuery ); + + } else { + + if ( this.tour.dom.searchInput.value ) { + + this.tour.dom.searchInput.value = ''; + this.tour.dom.searchClear.style.display = 'none'; + this.tour.dom.searchContainer.classList.remove( 'focused' ); + this.performSearch( '' ); + + } + + } + + } + + handleSearchInput( query, updateSearchFocus ) { + + if ( this.debounceTimeout ) { + + clearTimeout( this.debounceTimeout ); + + } + + this.debounceTimeout = setTimeout( () => { + + this.performSearch( query ); + this.updateHashWithSearch( query ); + updateSearchFocus(); + + }, 250 ); + + } + + getCleanText( md ) { + + if ( ! md ) return ''; + + let text = md.replace( /```[\s\S]*?```/gi, '' ); // Remove code blocks + + // Strip API container headers and preserve class names & inheritance + // e.g. "::: api-class MeshStandardNodeMaterial extends NodeMaterial [open]" -> "MeshStandardNodeMaterial extends NodeMaterial" + text = text.replace( /:::\s*(?:api-class|api-group)\s+([^\n]+)/gi, '$1\n' ); + text = text.replace( /:::\s*api\s+([^\n]+)/gi, '$1\n' ); + text = text.replace( /:::/g, ' ' ); + text = text.replace( /\[open\]/gi, '' ); + + // Strip HTML tags repeatedly until no more tags remain + let prev; + do { + + prev = text; + text = text.replace( /<[^<>]*>/g, '' ); + + } while ( text !== prev ); + + // Strip remaining standalone angle brackets + text = text.replace( /[<>]/g, ' ' ); + + return text + .replace( /\[([^\]]+)\]\([^)]+\)/g, '$1' ) // Remove links keeping text + .replace( /\*\*([^*]+)\*\*/g, '$1' ) // Remove bold + .replace( /__([^_]+)__/g, '$1' ) + .replace( /\*([^*]+)\*/g, '$1' ) // Remove italic + .replace( /_([^_]+)_/g, '$1' ) + .replace( /`([^`]+)`/g, '$1' ) // Remove inline code + .replace( /#{1,6}\s+/g, '' ) // Remove headers + .replace( />\s+/g, '' ) // Remove blockquotes + .replace( /\|\s*/g, ' ' ) // Replace table pipe with space + .replace( /\s+/g, ' ' ) // Normalize spaces + .trim(); + + } + + highlightSearchTerms( text, queryTerms ) { + + if ( ! text ) return ''; + + const escapeHtml = ( str ) => str + .replace( /&/g, '&' ) + .replace( //g, '>' ) + .replace( /"/g, '"' ) + .replace( /'/g, ''' ); + + const safeText = escapeHtml( text ); + + if ( ! queryTerms || queryTerms.length === 0 ) return safeText; + + const escapeRegExp = ( string ) => string.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' ); + + const patterns = queryTerms.map( t => { + + const escaped = escapeRegExp( escapeHtml( t ) ); + return `\\w*${escaped}\\w*|${escaped}`; + + } ); + + const regex = new RegExp( `(${patterns.join( '|' )})`, 'gi' ); + return safeText.replace( regex, '$1' ); + + } + + getSearchSnippet( cleanText, queryTerms, title = '' ) { + + if ( ! cleanText ) return ''; + + if ( title ) { + + const titleLower = title.toLowerCase(); + if ( cleanText.toLowerCase().startsWith( titleLower ) ) { + + const nextChar = cleanText.charAt( titleLower.length ); + if ( ! nextChar || /[^a-z0-9]/i.test( nextChar ) ) { + + cleanText = cleanText.substring( titleLower.length ).trim(); + + } + + } + + } + + const textLower = cleanText.toLowerCase(); + // Find the first term that matches in cleanText + let startIndex = - 1; + + for ( const term of queryTerms ) { + + const idx = textLower.indexOf( term ); + if ( idx !== - 1 && ( startIndex === - 1 || idx < startIndex ) ) { + + startIndex = idx; + + } + + } + + if ( startIndex === - 1 ) { + + // If no term matched in description (e.g. they all matched in title only), show start of description + const snippet = cleanText.substring( 0, 80 ); + return this.highlightSearchTerms( snippet, queryTerms ) + ( cleanText.length > 80 ? '...' : '' ); + + } + + // We have a match in the description. Extract a window around the match. + const windowStart = Math.max( 0, startIndex - 40 ); + const windowEnd = Math.min( cleanText.length, startIndex + 80 ); + + let snippet = cleanText.substring( windowStart, windowEnd ); + + if ( windowStart > 0 ) { + + snippet = '...' + snippet; + + } + + if ( windowEnd < cleanText.length ) { + + snippet = snippet + '...'; + + } + + return this.highlightSearchTerms( snippet, queryTerms ); + + } + + getSpellingSuggestion( query ) { + + const trimmed = query.trim().toLowerCase(); + if ( ! trimmed ) return null; + + const terms = trimmed.split( /\s+/ ).filter( t => t.length > 0 ); + let hasCorrection = false; + + const correctedTerms = terms.map( term => { + + if ( this.index.has( term ) ) return term; + + let bestWord = term; + let minDistance = 3; + + for ( const indexedWord of this.index.keys() ) { + + if ( Math.abs( indexedWord.length - term.length ) >= minDistance ) continue; + + const dist = getLevenshteinDistance( term, indexedWord ); + if ( dist < minDistance ) { + + minDistance = dist; + bestWord = indexedWord; + + } + + } + + if ( bestWord !== term ) { + + hasCorrection = true; + return this.casedVocabulary.get( bestWord ) || bestWord; + + } + + return term; + + } ); + + return hasCorrection ? correctedTerms.join( ' ' ) : null; + + } + +} + +function getLevenshteinDistance( a, b ) { + + const matrix = []; + + for ( let i = 0; i <= b.length; i ++ ) matrix[ i ] = [ i ]; + for ( let j = 0; j <= a.length; j ++ ) matrix[ 0 ][ j ] = j; + + for ( let i = 1; i <= b.length; i ++ ) { + + for ( let j = 1; j <= a.length; j ++ ) { + + if ( b.charAt( i - 1 ) === a.charAt( j - 1 ) ) { + + matrix[ i ][ j ] = matrix[ i - 1 ][ j - 1 ]; + + } else { + + matrix[ i ][ j ] = Math.min( + matrix[ i - 1 ][ j - 1 ] + 1, // substitution + matrix[ i ][ j - 1 ] + 1, // insertion + matrix[ i - 1 ][ j ] + 1 // deletion + ); + + } + + } + + } + + return matrix[ b.length ][ a.length ]; + +} + +export { SearchManager }; diff --git a/tsl/js/utils/CodeEditorUtils.js b/tsl/js/utils/CodeEditorUtils.js new file mode 100644 index 00000000000000..49bf86b7b62cde --- /dev/null +++ b/tsl/js/utils/CodeEditorUtils.js @@ -0,0 +1,216 @@ +function isPropertyMethod( proto, name ) { + + let current = proto; + while ( current && current !== Object.prototype ) { + + const desc = Object.getOwnPropertyDescriptor( current, name ); + if ( desc ) { + + if ( desc.get || desc.set ) return false; + return typeof desc.value === 'function'; + + } + + current = Object.getPrototypeOf( current ); + + } + + return false; + +} + +function getPrototypeProperties( proto ) { + + const propNames = new Set(); + let current = proto; + while ( current && current !== Object.prototype ) { + + Object.getOwnPropertyNames( current ).forEach( name => { + + propNames.add( name ); + + } ); + current = Object.getPrototypeOf( current ); + + } + + return Array.from( propNames ) + .filter( name => name !== 'constructor' && ! name.startsWith( '_' ) ) + .sort(); + +} + +function generateClassProperties( classProto ) { + + let dts = ''; + if ( ! classProto ) return dts; + + const props = getPrototypeProperties( classProto ); + props.forEach( prop => { + + try { + + if ( isPropertyMethod( classProto, prop ) ) { + + dts += ` ${prop}( ...args: any[] ): any;\n`; + + } else { + + dts += ` ${prop}: any;\n`; + + } + + } catch ( e ) {} + + } ); + + return dts; + +} + +function generateNodeInterface( THREE ) { + + let dts = ' export interface Node {\n'; + const proto = THREE.Node.prototype; + const props = getPrototypeProperties( proto ); + + props.forEach( name => { + + try { + + if ( isPropertyMethod( proto, name ) ) { + + dts += ` ${name}( ...args: any[] ): Node;\n`; + + } else { + + dts += ` ${name}: Node;\n`; + + } + + } catch ( e ) { + + dts += ` ${name}: any;\n`; + + } + + } ); + + dts += ' }\n\n'; + return dts; + +} + +function generateThreeDeclarations( THREE ) { + + let dts = 'declare module \'three\' {\n\n'; + + dts += generateNodeInterface( THREE ); + + Object.keys( THREE ).forEach( key => { + + if ( key === 'Node' ) return; // already declared as interface + + if ( /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test( key ) ) { + + const val = THREE[ key ]; + if ( typeof val === 'function' ) { + + if ( key[ 0 ] === key[ 0 ].toUpperCase() ) { + + // Class + dts += ` export class ${key} {\n`; + dts += ' constructor( ...args: any[] );\n'; + dts += generateClassProperties( val.prototype ); + dts += ' }\n'; + + } else { + + // Function + dts += ` export function ${key}( ...args: any[] ): any;\n`; + + } + + } else { + + // Variable/Constant + dts += ` export const ${key}: any;\n`; + + } + + } + + } ); + + dts += '}\n\n'; + return dts; + +} + +function generateTslDeclarations( TSL ) { + + let dts = 'declare module \'three/tsl\' {\n'; + dts += ' import { Node } from \'three\';\n\n'; + + Object.keys( TSL ).forEach( key => { + + if ( /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test( key ) ) { + + const val = TSL[ key ]; + + if ( typeof val === 'function' ) { + + if ( key[ 0 ] === key[ 0 ].toUpperCase() ) { + + dts += ` export class ${key} {\n`; + dts += ' constructor( ...args: any[] );\n'; + dts += generateClassProperties( val.prototype ); + dts += ' }\n'; + + } else { + + dts += ` export function ${key}( ...args: any[] ): Node;\n`; + + } + + } else { + + // For TSL constants/variables, type them as Node to allow chaining. + dts += ` export const ${key}: Node;\n`; + + } + + } + + } ); + + dts += '}\n'; + return dts; + +} + +function generateAddonDeclarations( ADDONS_TSL_IMPORTS ) { + + if ( ! ADDONS_TSL_IMPORTS ) return ''; + + let dts = ''; + Object.entries( ADDONS_TSL_IMPORTS ).forEach( ( [ key, modulePath ] ) => { + + dts += `declare module '${modulePath}' {\n`; + dts += ' import { Node } from \'three\';\n'; + dts += ` export const ${key}: any;\n`; + dts += '}\n\n'; + + } ); + + return dts; + +} + +function generateDeclarations( THREE, TSL, ADDONS_TSL_IMPORTS ) { + + return generateThreeDeclarations( THREE ) + generateTslDeclarations( TSL ) + generateAddonDeclarations( ADDONS_TSL_IMPORTS ); + +} + +export { generateDeclarations }; diff --git a/tsl/js/utils/MarkdownUtils.js b/tsl/js/utils/MarkdownUtils.js new file mode 100644 index 00000000000000..781004f7b28565 --- /dev/null +++ b/tsl/js/utils/MarkdownUtils.js @@ -0,0 +1,1459 @@ +import { marked } from 'marked'; +import * as THREE from 'three'; +import * as TSL from 'three/tsl'; + +marked.use( { + renderer: { + code( code, infostring ) { + + if ( infostring === 'mermaid' ) { + + return `
${code}
`; + + } + + return false; + + } + } +} ); + + +function parseTour( rawMarkdown ) { + + const pageTree = []; + + // Regex to match opening tag, or closing tag + const tokenRegex = /]*?)>|<\/page>/gi; + let match; + let lastIndex = 0; + const stack = []; + + while ( ( match = tokenRegex.exec( rawMarkdown ) ) !== null ) { + + const index = match.index; + const textSegment = rawMarkdown.substring( lastIndex, index ); + + if ( stack.length > 0 ) { + + stack[ stack.length - 1 ].content += textSegment; + + } + + if ( match[ 0 ].toLowerCase().startsWith( '' ) ) { + + // Closing tag + if ( stack.length > 0 ) { + + const finishedPage = stack.pop(); + + if ( stack.length > 0 ) { + + stack[ stack.length - 1 ].children.push( finishedPage ); + + } else { + + pageTree.push( finishedPage ); + + } + + } + + } else { + + // Opening tag + const attrString = match[ 1 ] || ''; + const attrs = {}; + const attrRegex = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+))/gi; + let attrMatch; + while ( ( attrMatch = attrRegex.exec( attrString ) ) !== null ) { + + const key = attrMatch[ 1 ].toLowerCase(); + const val = attrMatch[ 2 ] || attrMatch[ 3 ] || attrMatch[ 4 ]; + attrs[ key ] = val; + + } + + const title = ( attrs.name || attrs.title || 'Untitled' ).trim(); + const category = ( attrs.category || '' ).trim(); + const id = ( attrs.id || title.toLowerCase().replace( /[^a-z0-9]+/g, '-' ).replace( /(^-|-$)/g, '' ) ).trim(); + + const newPage = { + id, + title, + category, + content: '', + children: [] + }; + + stack.push( newPage ); + + } + + lastIndex = tokenRegex.lastIndex; + + } + + // Add any remaining text + if ( lastIndex < rawMarkdown.length && stack.length > 0 ) { + + stack[ stack.length - 1 ].content += rawMarkdown.substring( lastIndex ); + + } + + // Clean up any unclosed pages + while ( stack.length > 0 ) { + + const finishedPage = stack.pop(); + + if ( stack.length > 0 ) { + + stack[ stack.length - 1 ].children.push( finishedPage ); + + } else { + + pageTree.push( finishedPage ); + + } + + } + + // Flatten pageTree to pages + const pages = []; + function flatten( node, parentCategory = '', level = 0, path = [] ) { + + const cleanContent = node.content.trim(); + + let defaultNode = ''; + const codeTagRegex = /]*?)>/gi; + let codeTagMatch; + while ( ( codeTagMatch = codeTagRegex.exec( cleanContent ) ) !== null ) { + + const codeAttrString = codeTagMatch[ 1 ]; + const codeAttrs = {}; + const attrRegex = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+))/gi; + let attrMatch; + while ( ( attrMatch = attrRegex.exec( codeAttrString ) ) !== null ) { + + const key = attrMatch[ 1 ].toLowerCase(); + const val = attrMatch[ 2 ] || attrMatch[ 3 ] || attrMatch[ 4 ]; + codeAttrs[ key ] = val; + + } + + if ( codeAttrs.default === 'true' && codeAttrs.name ) { + + defaultNode = codeAttrs.name; + break; + + } + + } + + const hasCodeModifier = / { + + embeds.push( embedCode.trim() ); + const currentIdx = embedIndex ++; + return `\n\n
\n\n`; + + } ); + + } + + node.embeds = embeds; + node.hasEmbed = embeds.length > 0; + + const codeBlocks = {}; + const blockRegex = /```tsl\s*([a-zA-Z0-9_-]*)\r?\n([\s\S]*?)```/gi; + let blockMatch; + let primaryCode = ''; + let hasCodeBlocks = false; + + while ( ( blockMatch = blockRegex.exec( processedContent ) ) !== null ) { + + const modifier = ( blockMatch[ 1 ] || '' ).trim(); + const codeText = blockMatch[ 2 ].trim(); + codeBlocks[ modifier ] = codeText; + hasCodeBlocks = true; + if ( ! primaryCode || modifier === '' ) { + + primaryCode = codeText; + + } + + } + + node.codes = codeBlocks; + node.hasCode = hasCodeBlocks || hasCodeModifier; + + let code = ''; + if ( primaryCode ) { + + code = primaryCode; + + } else if ( defaultNode && codeBlocks[ defaultNode ] ) { + + code = codeBlocks[ defaultNode ]; + + } else { + + const keys = Object.keys( codeBlocks ); + if ( keys.length > 0 ) { + + code = codeBlocks[ keys[ 0 ] ]; + + } else { + + code = '// No example available.'; + + } + + } + + let description = processedContent.replace( /```tsl(?:\s+[a-zA-Z0-9_-]*)?\r?\n[\s\S]*?```/gi, '' ).trim(); + description = description.replace( /\r\n/g, '\n' ).replace( /\n{3,}/g, '\n\n' ); + + // Check if it has no content of its own (no description and no code) + node.isFolder = description.length === 0 && ! node.hasCode; + node.level = level; + node.category = node.category || parentCategory; + + const fullPath = [ ...path ]; + + if ( node.category && fullPath.length === 0 ) { + + fullPath.push( node.category ); + + } + + node.path = fullPath; + + if ( ! node.isFolder ) { + + let finalDescription = description; + if ( ! finalDescription.startsWith( '# ' ) ) { + + finalDescription = `# ${node.title}\n\n${finalDescription}`; + + } + + node.description = finalDescription; + node.code = code; + node.defaultNode = defaultNode; + + pages.push( node ); + + } + + node.children.forEach( child => { + + flatten( child, node.category || parentCategory || node.title, level + 1, [ ...path, node.title ] ); + + } ); + + } + + pageTree.forEach( rootNode => { + + flatten( rootNode, '', 0, [] ); + + } ); + + return { pages, pageTree }; + +} + +function parse( md ) { + + let html = md; + + // Replace double underscores __TEXT__ with underline tags, skipping code blocks (enclosed in backticks) + const parts = html.split( '`' ); + for ( let i = 0; i < parts.length; i += 2 ) { + + parts[ i ] = parts[ i ].replace( /__([^\_]+?)__/g, '$1' ); + + } + + html = parts.join( '`' ); + + // Helper to parse callout blocks (Important, Note, etc.) + const parseCallouts = ( tag, title, icon, className ) => { + + const regex = new RegExp( `(?:^|\\n)[ \\t]*>[ \\t]*${tag}:[ \\t]*([^\\n]+(?:\\n[ \\t]*[^\\n<>:|]+)*)`, 'gi' ); + const matches = []; + let match; + while ( ( match = regex.exec( html ) ) !== null ) { + + matches.push( { + index: match.index, + length: match[ 0 ].length, + content: match[ 1 ].trim() + } ); + + } + + const groups = []; + let currentGroup = []; + for ( let i = 0; i < matches.length; i ++ ) { + + const m = matches[ i ]; + if ( currentGroup.length === 0 ) { + + currentGroup.push( m ); + + } else { + + const prev = currentGroup[ currentGroup.length - 1 ]; + const between = html.substring( prev.index + prev.length, m.index ); + if ( /^\s*$/.test( between ) ) { + + currentGroup.push( m ); + + } else { + + groups.push( currentGroup ); + currentGroup = [ m ]; + + } + + } + + } + + if ( currentGroup.length > 0 ) { + + groups.push( currentGroup ); + + } + + for ( let g = groups.length - 1; g >= 0; g -- ) { + + const group = groups[ g ]; + const first = group[ 0 ]; + const last = group[ group.length - 1 ]; + + const itemsHtml = group.map( item => `
${marked.parseInline( item.content )}
` ).join( `
` ); + + const groupHtml = `\n\n
${icon} ${title}
${itemsHtml}
\n\n`; + + html = html.substring( 0, first.index ) + groupHtml + html.substring( last.index + last.length ); + + } + + }; + + parseCallouts( 'Important', 'Important', '⚠️', 'tour-important' ); + parseCallouts( 'Note', 'Note', '📌', 'tour-note' ); + + // Helper to parse collapsible accordion callout blocks for AI / LLM (> IA: or > AI: or > LLM:) + const parseAccordionCallouts = ( tag, title, icon, className ) => { + + const regex = new RegExp( `(?:^|\\n)[ \\t]*>[ \\t]*${tag}:[ \\t]*([^\\n]+(?:\\n[ \\t]*(?:>[ \\t]*)?[^\\n<>:|]+)*)`, 'gi' ); + const matches = []; + let match; + while ( ( match = regex.exec( html ) ) !== null ) { + + const cleanedContent = match[ 1 ] + .split( '\n' ) + .map( line => line.replace( /^[ \t]*>[ \t]?/, '' ).trim() ) + .filter( Boolean ) + .join( '\n' ); + + matches.push( { + index: match.index, + length: match[ 0 ].length, + content: cleanedContent + } ); + + } + + for ( let i = matches.length - 1; i >= 0; i -- ) { + + const m = matches[ i ]; + const lines = m.content.split( '\n' ); + const itemsHtml = lines.map( l => `
${marked.parseInline( l )}
` ).join( '' ); + + const accordionHtml = `\n\n
${title} Click to expand
${itemsHtml}
\n\n`; + + html = html.substring( 0, m.index ) + accordionHtml + html.substring( m.index + m.length ); + + } + + }; + + parseAccordionCallouts( '(?:IA|AI|LLM)', 'AI / LLM Guide', 'sparkles', 'tour-ai' ); + + // Group consecutive API blocks + const apiBlockRegex = /::: api\s+([^\n]+?)(?:\s*:::\s*(?=\n|$)|(?:\r?\n([\s\S]*?):::))/gi; + const matches = []; + let match; + while ( ( match = apiBlockRegex.exec( html ) ) !== null ) { + + matches.push( { + index: match.index, + length: match[ 0 ].length, + raw: match[ 0 ], + signature: match[ 1 ].trim(), + body: match[ 2 ] ? match[ 2 ].trim() : '' + } ); + + } + + const groups = []; + let currentGroup = []; + for ( let i = 0; i < matches.length; i ++ ) { + + const m = matches[ i ]; + if ( currentGroup.length === 0 ) { + + currentGroup.push( m ); + + } else { + + const prev = currentGroup[ currentGroup.length - 1 ]; + const between = html.substring( prev.index + prev.length, m.index ); + if ( /^\s*$/.test( between ) ) { + + currentGroup.push( m ); + + } else { + + groups.push( currentGroup ); + currentGroup = [ m ]; + + } + + } + + } + + if ( currentGroup.length > 0 ) { + + groups.push( currentGroup ); + + } + + for ( let g = groups.length - 1; g >= 0; g -- ) { + + const group = groups[ g ]; + const first = group[ 0 ]; + const last = group[ group.length - 1 ]; + + let groupHtml = ''; + if ( group.length === 1 ) { + + groupHtml = renderSingleApiCard( first.signature, first.body ); + + } else { + + groupHtml = renderApiTableCard( group ); + + } + + html = html.substring( 0, first.index ) + groupHtml + html.substring( last.index + last.length ); + + } + + // Helper to parse hierarchical class API accordion containers (::: api-class or ::: api-group) + // Example: ::: api-class MeshPhysicalNodeMaterial extends MeshStandardNodeMaterial [open] ... ::: + const apiClassContainerRegex = /(?:^|\n)[ \t]*:::\s*(?:api-class|api-group)\s+([^\n]+?)\r?\n([\s\S]*?)\r?\n[ \t]*:::[ \t]*(?=\n|$)/gi; + const classBlocks = []; + const classMap = new Map(); + let classMatch; + + while ( ( classMatch = apiClassContainerRegex.exec( html ) ) !== null ) { + + let headerText = classMatch[ 1 ].trim(); + const isOpen = /\[open\]|\bopen\b/i.test( headerText ); + headerText = headerText.replace( /\[open\]/gi, '' ).replace( /\bopen\b/gi, '' ).trim(); + + const extendsMatch = headerText.match( /^(.*?)\s+(?:extends|:)\s+(.*)$/i ); + let className = headerText; + let extendsClass = null; + + if ( extendsMatch ) { + + className = extendsMatch[ 1 ].trim(); + extendsClass = extendsMatch[ 2 ].trim(); + + } + + const bodyContent = classMatch[ 2 ]; + const countMatches = ( bodyContent.match( /class='tsl-api-table-row'/g ) || bodyContent.match( /class="tsl-api-table-row"/g ) || [] ).length + + ( bodyContent.match( /class='tsl-api-card'/g ) || bodyContent.match( /class="tsl-api-card"/g ) || [] ).length; + + const blockData = { + index: classMatch.index, + length: classMatch[ 0 ].length, + className, + extendsClass, + isOpen, + bodyContent, + count: countMatches + }; + + classBlocks.push( blockData ); + classMap.set( className, blockData ); + + } + + for ( let i = classBlocks.length - 1; i >= 0; i -- ) { + + const block = classBlocks[ i ]; + const openAttr = block.isOpen ? ' open' : ''; + const extendsHtml = block.extendsClass ? ` extends ${block.extendsClass}` : ''; + const countBadgeHtml = block.count > 0 ? `${block.count} ${block.count === 1 ? 'property' : 'properties'}` : ''; + + // Build inherited accordion blocks from parent chain + let inheritedHtml = ''; + let currParent = block.extendsClass; + const visited = new Set( [ block.className ] ); + + while ( currParent && classMap.has( currParent ) && ! visited.has( currParent ) ) { + + visited.add( currParent ); + const parentObj = classMap.get( currParent ); + const parentContent = parentObj.bodyContent.trim(); + const parentCount = parentObj.count; + + if ( parentContent ) { + + const pCountBadge = parentCount > 0 ? `${parentCount} ${parentCount === 1 ? 'property' : 'properties'}` : ''; + + inheritedHtml += `\n
Inherited from ${currParent}
${pCountBadge}
\n\n${parentContent}\n\n
`; + + } + + currParent = parentObj.extendsClass; + + } + + const inheritedGroupHtml = inheritedHtml ? `\n
${inheritedHtml}
` : ''; + const fullContent = block.bodyContent.trim() + inheritedGroupHtml; + + const accordionHtml = `\n\n
${block.className}${extendsHtml}
${countBadgeHtml}
\n\n${fullContent}\n\n
\n\n`; + + html = html.substring( 0, block.index ) + accordionHtml + html.substring( block.index + block.length ); + + } + + // Replace standalone YouTube watch URLs + html = html.replace( /(?:^|\n)[ \t]*(?:https?:\/\/)?(?:www\.)?youtube\.com\/watch\?v=([a-zA-Z0-9_-]+)(?:&\S*)?[ \t]*(?=\n|$)/gi, ( match, videoId ) => { + + return `\n
\n`; + + } ); + + // Replace standalone YouTube short URLs + html = html.replace( /(?:^|\n)[ \t]*(?:https?:\/\/)?youtu\.be\/([a-zA-Z0-9_-]+)(?:&\S*)?[ \t]*(?=\n|$)/gi, ( match, videoId ) => { + + return `\n
\n`; + + } ); + + // Group and replace consecutive X/Twitter status URLs + const lines = html.split( /\r?\n/ ); + const newLines = []; + let currentTweetGroup = []; + + const parseTweetUrl = ( line ) => { + + const match = line.trim().match( /^(?:https?:\/\/(?:www\.)?(?:x|twitter)\.com\/([a-zA-Z0-9_-]+)\/status\/([0-9]+))([\?&]\S*)?$/i ); + if ( match ) { + + const queryString = match[ 3 ] || ''; + const isShort = queryString.toLowerCase().includes( 'short' ); + return { + url: line.trim(), + username: match[ 1 ], + id: match[ 2 ], + isShort: isShort + }; + + } + + return null; + + }; + + for ( let i = 0; i < lines.length; i ++ ) { + + const line = lines[ i ]; + const tweet = parseTweetUrl( line ); + + if ( tweet ) { + + currentTweetGroup.push( tweet ); + + } else { + + if ( currentTweetGroup.length > 0 ) { + + newLines.push( renderTweetGroup( currentTweetGroup ) ); + currentTweetGroup = []; + + } + + newLines.push( line ); + + } + + } + + if ( currentTweetGroup.length > 0 ) { + + newLines.push( renderTweetGroup( currentTweetGroup ) ); + + } + + html = newLines.join( '\n' ); + + // Protect tabs inside code fences so marked doesn't convert them to spaces + html = html.replace( /(```[\s\S]*?```)/g, match => match.replace( /\t/g, '\uE000' ) ); + + let parsedHtml = marked.parse( html ); + + // Restore tabs + parsedHtml = parsedHtml.replace( /\uE000/g, '\t' ); + + // Tokenize and style inline code tags to match Monaco editor colors + parsedHtml = parsedHtml.replace( /([\s\S]*?)<\/code>/gi, ( match, codeContent ) => { + + return `${tokenizeInlineCode( codeContent )}`; + + } ); + + return parsedHtml; + +} + +function splitArguments( argsText ) { + + if ( ! argsText || ! argsText.trim() ) return []; + + const args = []; + let current = ''; + let angleDepth = 0; + let bracketDepth = 0; + let parenDepth = 0; + let braceDepth = 0; + + for ( let i = 0; i < argsText.length; i ++ ) { + + const char = argsText[ i ]; + + if ( char === '<' ) angleDepth ++; + else if ( char === '>' && angleDepth > 0 ) angleDepth --; + else if ( char === '[' ) bracketDepth ++; + else if ( char === ']' && bracketDepth > 0 ) bracketDepth --; + else if ( char === '(' ) parenDepth ++; + else if ( char === ')' && parenDepth > 0 ) parenDepth --; + else if ( char === '{' ) braceDepth ++; + else if ( char === '}' && braceDepth > 0 ) braceDepth --; + + if ( char === ',' && angleDepth === 0 && bracketDepth === 0 && parenDepth === 0 && braceDepth === 0 ) { + + if ( current.trim() ) args.push( current.trim() ); + current = ''; + + } else { + + current += char; + + } + + } + + if ( current.trim() ) args.push( current.trim() ); + + return args; + +} + +function splitTypeTokens( typeStr ) { + + if ( ! typeStr ) return []; + + const tokens = []; + let current = ''; + let angleDepth = 0; + let bracketDepth = 0; + let parenDepth = 0; + let braceDepth = 0; + + for ( let i = 0; i < typeStr.length; i ++ ) { + + const char = typeStr[ i ]; + + if ( char === '<' ) angleDepth ++; + else if ( char === '>' && angleDepth > 0 ) angleDepth --; + else if ( char === '[' ) bracketDepth ++; + else if ( char === ']' && bracketDepth > 0 ) bracketDepth --; + else if ( char === '(' ) parenDepth ++; + else if ( char === ')' && parenDepth > 0 ) parenDepth --; + else if ( char === '{' ) braceDepth ++; + else if ( char === '}' && braceDepth > 0 ) braceDepth --; + + if ( char === '|' && angleDepth === 0 && bracketDepth === 0 && parenDepth === 0 && braceDepth === 0 ) { + + if ( current.trim() ) tokens.push( current.trim() ); + current = ''; + + } else { + + current += char; + + } + + } + + if ( current.trim() ) tokens.push( current.trim() ); + + return tokens; + +} + +function formatTypeHtml( typeStr ) { + + if ( ! typeStr ) return ''; + + const cleanType = typeStr.replace( /`/g, '' ).trim(); + const typeTokens = splitTypeTokens( cleanType ); + let typeHtml = ''; + + for ( let i = 0; i < typeTokens.length; i ++ ) { + + if ( i > 0 ) typeHtml += ' | '; + + const token = typeTokens[ i ]; + const isString = ( token.startsWith( '\'' ) && token.endsWith( '\'' ) ) || ( token.startsWith( '"' ) && token.endsWith( '"' ) ); + const isKeyword = token === 'null' || token === 'true' || token === 'false'; + + const escapedToken = token.replace( //g, '>' ); + + let className = 'tsl-param-type'; + if ( isString ) className += ' tsl-param-type-string'; + else if ( isKeyword ) className += ' tsl-param-type-keyword'; + + typeHtml += `${escapedToken}`; + + } + + return typeHtml; + +} + +function formatSignatureArgs( argsText ) { + + if ( ! argsText || ! argsText.trim() ) return ''; + + const args = splitArguments( argsText ); + const formattedArgs = []; + + for ( const arg of args ) { + + let cleanArg = arg; + let isOptional = false; + + // Check if parameter has `?` or default value `= ...` + if ( cleanArg.includes( '?' ) || cleanArg.includes( '=' ) ) { + + isOptional = true; + cleanArg = cleanArg.replace( '?', '' ); + + } + + const optionalHtml = isOptional ? '?' : ''; + + // Check if it has a colon (typed argument like `callback: Function` or `type: string = null`) + const colonMatch = cleanArg.match( /^(\.*)?([a-zA-Z0-9_./-]+)\s*:\s*(.+)$/ ); + if ( colonMatch ) { + + const dots = colonMatch[ 1 ] || ''; + const paramName = colonMatch[ 2 ].trim(); + const fullName = dots + paramName; + + formattedArgs.push( `${fullName}${optionalHtml}` ); + + } else if ( cleanArg.includes( '=' ) ) { + + // Parameter with default value like `name = null` + const eqIndex = cleanArg.indexOf( '=' ); + const paramName = cleanArg.substring( 0, eqIndex ).trim(); + const paramVal = cleanArg.substring( eqIndex + 1 ).trim(); + + let valHtml = ''; + const isString = ( paramVal.startsWith( '\'' ) && paramVal.endsWith( '\'' ) ) || ( paramVal.startsWith( '"' ) && paramVal.endsWith( '"' ) ); + const isKeyword = paramVal === 'null' || paramVal === 'true' || paramVal === 'false'; + const isNumber = ! isNaN( Number( paramVal ) ) && ! isKeyword; + + if ( isString ) { + + valHtml = `${paramVal}`; + + } else if ( isKeyword ) { + + valHtml = `${paramVal}`; + + } else if ( isNumber ) { + + valHtml = `${paramVal}`; + + } else { + + valHtml = `${paramVal}`; + + } + + formattedArgs.push( `${paramName}${optionalHtml} = ${valHtml}` ); + + } else { + + // Just a plain parameter name + formattedArgs.push( `${cleanArg}${optionalHtml}` ); + + } + + } + + return formattedArgs.join( ', ' ); + +} + +function formatApiFunctionName( funcName ) { + + const dotIndex = funcName.lastIndexOf( '.' ); + if ( dotIndex !== - 1 ) { + + const prefix = funcName.substring( 0, dotIndex ).trim(); + const name = funcName.substring( dotIndex + 1 ).trim(); + + let prefixHtml = ''; + if ( prefix ) { + + if ( prefix.endsWith( '()' ) ) { + + const base = prefix.substring( 0, prefix.length - 2 ); + prefixHtml = `${base}()`; + + } else { + + prefixHtml = `${prefix}`; + + } + + } + + return `${prefixHtml}.${name}`; + + } + + return `${funcName}`; + +} + +function formatApiParameters( argsText ) { + + if ( ! argsText || ! argsText.trim() ) return ''; + + const args = splitArguments( argsText ); + let paramsHtml = ''; + + for ( const arg of args ) { + + let cleanArg = arg; + let isOptional = false; + + if ( cleanArg.includes( '?' ) || cleanArg.includes( '=' ) ) { + + isOptional = true; + cleanArg = cleanArg.replace( '?', '' ); + + } + + const optionalHtml = isOptional ? '?' : ''; + + const typedArgMatch = cleanArg.match( /^(\.*)?([a-zA-Z0-9_./-]+)\s*:\s*(.+)$/ ); + if ( typedArgMatch ) { + + const dots = typedArgMatch[ 1 ] || ''; + const paramName = typedArgMatch[ 2 ].trim(); + const typesAndDefault = typedArgMatch[ 3 ].trim(); + const fullName = dots + paramName; + + let typesText = typesAndDefault; + let defaultVal = ''; + if ( typesAndDefault.includes( '=' ) ) { + + const eqIdx = typesAndDefault.indexOf( '=' ); + typesText = typesAndDefault.substring( 0, eqIdx ).trim(); + defaultVal = typesAndDefault.substring( eqIdx + 1 ).trim(); + + } + + let typeHtml = formatTypeHtml( typesText ); + + if ( defaultVal ) { + + const isString = ( defaultVal.startsWith( '\'' ) && defaultVal.endsWith( '\'' ) ) || ( defaultVal.startsWith( '"' ) && defaultVal.endsWith( '"' ) ); + const isKeyword = defaultVal === 'null' || defaultVal === 'true' || defaultVal === 'false'; + const isNumber = ! isNaN( Number( defaultVal ) ) && ! isKeyword; + + let className = 'tsl-param-type'; + if ( isString ) className = 'tsl-param-type-string'; + else if ( isKeyword ) className = 'tsl-param-type-keyword'; + else if ( isNumber ) className = 'tsl-param-type-number'; + + typeHtml += ` = ${defaultVal}`; + + } + + paramsHtml += ` +
+
+ ${fullName}${optionalHtml} + ${typeHtml} +
+
`; + + } + + } + + return paramsHtml; + +} + +function parseApiSignature( rawSigText ) { + + const sigText = rawSigText.trim(); + let funcName = ''; + let argsText = ''; + let constName = ''; + let retType = ''; + let rowDesc = ''; + + const firstParen = sigText.indexOf( '(' ); + const lastParen = sigText.lastIndexOf( ')' ); + + const prefixBeforeParen = firstParen !== - 1 ? sigText.substring( 0, firstParen ).trim() : ''; + const isFunction = firstParen !== - 1 && lastParen > firstParen && /^[\.\w$]+$/i.test( prefixBeforeParen ); + + if ( isFunction ) { + + funcName = prefixBeforeParen; + argsText = sigText.substring( firstParen + 1, lastParen ).trim(); + + const remainder = sigText.substring( lastParen + 1 ).trim(); + if ( remainder ) { + + const afterMatch = remainder.match( /^(?:\s*(?::|->)\s*([^—–\-]+?))?(?:\s*[\-—–]\s*([\s\S]*))?$/ ); + if ( afterMatch ) { + + retType = afterMatch[ 1 ] ? afterMatch[ 1 ].trim() : ''; + rowDesc = afterMatch[ 2 ] ? afterMatch[ 2 ].trim() : ''; + + } + + } + + } else { + + const match = sigText.match( /^([^:—–\-]+?)(?:\s*(?::|->)\s*([^—–\-]+?))?(?:\s*[\-—–]\s*([\s\S]*))?$/ ); + if ( match ) { + + constName = match[ 1 ] ? match[ 1 ].trim() : sigText; + retType = match[ 2 ] ? match[ 2 ].trim() : ''; + rowDesc = match[ 3 ] ? match[ 3 ].trim() : ''; + + } else { + + constName = sigText; + + } + + } + + return { funcName, argsText, constName, retType, rowDesc }; + +} + +function renderSingleApiCard( signature, body ) { + + const parsedSig = parseApiSignature( signature ); + let returnTypeHtml = ''; + + if ( parsedSig.retType ) { + + returnTypeHtml = `
: ${formatTypeHtml( parsedSig.retType )}
`; + + } + + let sigHtml = ''; + let paramsHtml = ''; + + if ( parsedSig.funcName ) { + + const argsHtml = formatSignatureArgs( parsedSig.argsText ); + const funcNameHtml = formatApiFunctionName( parsedSig.funcName ); + + if ( argsHtml ) { + + sigHtml = `
${funcNameHtml}( ${argsHtml} )
`; + + } else { + + sigHtml = `
${funcNameHtml}()
`; + + } + + if ( ! body || ! body.trim() ) { + + paramsHtml = formatApiParameters( parsedSig.argsText ); + + } + + } else { + + const constNameHtml = `${parsedSig.constName}`; + sigHtml = `
${constNameHtml}
`; + + } + + if ( body ) { + + const paramLines = body.split( '\n' ); + for ( let line of paramLines ) { + + line = line.trim(); + if ( ! line ) continue; + + const paramMatch = line.match( /^[\-\*]\s+\*\*([a-zA-Z0-9_./-]+)\*\*\s*:\s*(?:`([^`]+)`|([^\-—–\n]+?))\s*(?:(?:[\u2014\-–]\s*)([\s\S]*))?$/ ); + if ( paramMatch ) { + + let name = paramMatch[ 1 ].trim(); + const type = ( paramMatch[ 2 ] !== undefined ? paramMatch[ 2 ] : ( paramMatch[ 3 ] || '' ) ).trim(); + const desc = paramMatch[ 4 ] ? paramMatch[ 4 ].trim() : ''; + let isOptional = false; + + if ( name.includes( '?' ) || desc.toLowerCase().startsWith( '(optional)' ) || type.includes( '=' ) ) { + + isOptional = true; + name = name.replace( '?', '' ); + + } + + const optionalHtml = isOptional ? '?' : ''; + + const parsedDesc = marked.parseInline( desc ).replace( /([^<]+)<\/code>/g, ( m, content ) => { + + const trimmed = content.trim(); + const isQuoted = /^('|'|"|"|['"])([\s\S]+)\1$/.test( trimmed ); + if ( isQuoted ) return `${content}`; + const isKeyword = trimmed === 'null' || trimmed === 'true' || trimmed === 'false'; + if ( isKeyword ) return `${content}`; + return m; + + } ); + + const typeHtml = formatTypeHtml( type ); + + paramsHtml += ` +
+
+ ${name}${optionalHtml} + ${typeHtml} +
+ ${desc ? `
${parsedDesc}
` : ''} +
`; + + } + + } + + } + + let rowDesc = parsedSig.rowDesc; + if ( rowDesc ) { + + rowDesc = rowDesc.replace( /^[\-\u2014\u2013\s]*/, '' ).trim(); + rowDesc = rowDesc.replace( /`([^`]+)`/g, '$1' ); + + } + + const isInline = ! body.trim(); + const cardClass = isInline ? 'tsl-api-card tsl-api-card-inline' : 'tsl-api-card'; + + const rawCardHtml = ` +
+
+ ${sigHtml} + ${returnTypeHtml} + ${rowDesc ? `
${rowDesc}
` : ''} +
+ ${paramsHtml ? `
${paramsHtml}
` : ''} +
`; + + return `\n\n${rawCardHtml.replace( /^\s+/gm, '' )}\n\n`; + +} + +function renderApiTableCard( group ) { + + let rowsHtml = ''; + const isRobustGroup = group.some( block => block.body.trim().length > 0 ); + const rowClass = isRobustGroup ? 'tsl-api-table-row tsl-api-table-row-robust' : 'tsl-api-table-row'; + + for ( const block of group ) { + + const parsedSig = parseApiSignature( block.signature ); + let returnTypeHtml = ''; + + if ( parsedSig.retType ) { + + returnTypeHtml = `
: ${formatTypeHtml( parsedSig.retType )}
`; + + } + + let sigHtml = ''; + let paramsHtml = ''; + + if ( parsedSig.funcName ) { + + const argsHtml = formatSignatureArgs( parsedSig.argsText ); + const funcNameHtml = formatApiFunctionName( parsedSig.funcName ); + + if ( argsHtml ) { + + sigHtml = `
${funcNameHtml}( ${argsHtml} )
`; + + } else { + + sigHtml = `
${funcNameHtml}()
`; + + } + + if ( ! block.body || ! block.body.trim() ) { + + paramsHtml = formatApiParameters( parsedSig.argsText ); + + } + + } else { + + const constNameHtml = `${parsedSig.constName}`; + sigHtml = `
${constNameHtml}
`; + + } + + if ( block.body ) { + + const paramLines = block.body.split( '\n' ); + for ( let line of paramLines ) { + + line = line.trim(); + if ( ! line ) continue; + + const paramMatch = line.match( /^[\-\*]\s+\*\*([a-zA-Z0-9_./-]+)\*\*\s*:\s*(?:`([^`]+)`|([^\-—–\n]+?))\s*(?:(?:[\u2014\-–]\s*)([\s\S]*))?$/ ); + if ( paramMatch ) { + + const name = paramMatch[ 1 ].trim(); + const type = ( paramMatch[ 2 ] !== undefined ? paramMatch[ 2 ] : ( paramMatch[ 3 ] || '' ) ).trim(); + const desc = paramMatch[ 4 ] ? paramMatch[ 4 ].trim() : ''; + + const parsedDesc = marked.parseInline( desc ).replace( /([^<]+)<\/code>/g, ( m, content ) => { + + const trimmed = content.trim(); + const isQuoted = /^('|'|"|"|['"])([\s\S]+)\1$/.test( trimmed ); + if ( isQuoted ) return `${content}`; + const isKeyword = trimmed === 'null' || trimmed === 'true' || trimmed === 'false'; + if ( isKeyword ) return `${content}`; + return m; + + } ); + + const typeHtml = formatTypeHtml( type ); + + paramsHtml += ` +
+
+ ${name} + ${typeHtml} +
+ ${desc ? `
${parsedDesc}
` : ''} +
`; + + } + + } + + } + + let rowDesc = parsedSig.rowDesc; + if ( rowDesc ) { + + rowDesc = rowDesc.replace( /^[\-\u2014\u2013\s]*/, '' ).trim(); + rowDesc = rowDesc.replace( /`([^`]+)`/g, '$1' ); + + } + + rowsHtml += ` +
+
+ ${sigHtml} + ${returnTypeHtml} + ${rowDesc ? `
${rowDesc}
` : ''} +
+ ${paramsHtml ? `
${paramsHtml}
` : ''} +
`; + + } + + const rawCardHtml = ` +
+${rowsHtml} +
`; + + return `\n\n${rawCardHtml.replace( /^\s+/gm, '' )}\n\n`; + +} + +function renderTweetGroup( tweets ) { + + if ( tweets.length === 0 ) return ''; + + let tweetsHtml = ''; + for ( const tweet of tweets ) { + + const shortAttributes = tweet.isShort ? 'data-cards="hidden" data-conversation="none"' : ''; + const safeUrl = `https://x.com/${encodeURIComponent( tweet.username )}/status/${encodeURIComponent( tweet.id )}`; + const safeUsername = encodeURIComponent( tweet.username ); + + tweetsHtml += ` +
+ +
`; + + } + + const gridClass = tweets.length > 1 ? 'x-tweets-grid' : 'x-tweet-single'; + + return `\n\n
${tweetsHtml}
\n\n`; + +} + +function tokenizeInlineCode( codeContent ) { + + if ( codeContent.includes( 'class="tsl-' ) ) return codeContent; + + // Decode HTML entities so we can parse actual operators like > or < (& must be unescaped last to avoid double unescaping) + const decoded = codeContent + .replace( />/g, '>' ) + .replace( /</g, '<' ) + .replace( /"/g, '"' ) + .replace( /"/g, '"' ) + .replace( /'/g, '\'' ) + .replace( /'/g, '\'' ) + .replace( /&/g, '&' ); + + // Helper to escape characters back to HTML entities safely + const escapeHtml = ( str ) => { + + return str + .replace( /&/g, '&' ) + .replace( //g, '>' ) + .replace( /"/g, '"' ) + .replace( /'/g, ''' ); + + }; + + const tokenRegex = new RegExp( + '(\\/\\/.*|\\/\\*[\\s\\S]*?\\*\\/)|' + + '(\'[^\']*\'|\"[^\"]*\")|' + + '(\\b\\d+(?:\\.\\d+)?\\b)|' + + '(\\b(?:const|let|var|function|return|true|false|null|if|else|for|while|new)\\b)|' + + '(\\b[a-zA-Z_][a-zA-Z0-9_]*\\b(?=\\s*\\())|' + + '(\\bTSL\\b)|' + + '(\\b[a-zA-Z_][a-zA-Z0-9_]*\\b)|' + + '([\\(\\)])|' + + '([\\{\\}])|' + + '([\\[\\]\\.\\+\\-\\*\\/=,;:<>!&|~^%?])', + 'g' + ); + + return decoded.replace( tokenRegex, ( match, comment, str, num, keyword, func, namespace, ident, paren, brace, op ) => { + + if ( comment ) { + + return `${escapeHtml( comment )}`; + + } else if ( str ) { + + return `${escapeHtml( str )}`; + + } else if ( num ) { + + return `${escapeHtml( num )}`; + + } else if ( keyword ) { + + return `${escapeHtml( keyword )}`; + + } else if ( func ) { + + const className = isTslBuiltIn( func ) ? 'tsl-function-builtin' : 'tsl-function'; + return `${escapeHtml( func )}`; + + } else if ( namespace ) { + + return `${escapeHtml( namespace )}`; + + } else if ( ident ) { + + if ( isTslBuiltIn( ident ) ) { + + return `${escapeHtml( ident )}`; + + } + + return `${escapeHtml( ident )}`; + + } else if ( paren ) { + + return `${escapeHtml( paren )}`; + + } else if ( brace ) { + + return `${escapeHtml( brace )}`; + + } else if ( op ) { + + return `${escapeHtml( op )}`; + + } + + return escapeHtml( match ); + + } ); + +} + +let tslKeys = null; +const TSL_EXCEPTIONS = new Set( [ 'Case', 'Default', 'ElseIf', 'Else' ] ); + +function isTslBuiltIn( name ) { + + if ( ! tslKeys ) { + + tslKeys = new Set( [ + ...Object.keys( TSL ), + ...Object.keys( THREE ) + ] ); + + } + + return tslKeys.has( name ) || TSL_EXCEPTIONS.has( name ); + +} + +function tokenizeCodeToElement( codeContent, targetElement ) { + + targetElement.textContent = ''; + + // Decode HTML entities so we can parse actual operators like > or < (& must be unescaped last to avoid double unescaping) + const decoded = codeContent + .replace( />/g, '>' ) + .replace( /</g, '<' ) + .replace( /"/g, '"' ) + .replace( /"/g, '"' ) + .replace( /'/g, '\'' ) + .replace( /'/g, '\'' ) + .replace( /&/g, '&' ); + + const tokenRegex = new RegExp( + '(\\/\\/.*|\\/\\*[\\s\\S]*?\\*\\/)|' + + '(\'[^\']*\'|\"[^\"]*\")|' + + '(\\b\\d+(?:\\.\\d+)?\\b)|' + + '(\\b(?:const|let|var|function|return|true|false|null|if|else|for|while|new)\\b)|' + + '(\\b[a-zA-Z_][a-zA-Z0-9_]*\\b(?=\\s*\\())|' + + '(\\bTSL\\b)|' + + '(\\b[a-zA-Z_][a-zA-Z0-9_]*\\b)|' + + '([\\(\\)])|' + + '([\\{\\}])|' + + '([\\[\\]\\.\\+\\-\\*\\/=,;:<>!&|~^%?])', + 'g' + ); + + let lastIndex = 0; + let match; + + while ( ( match = tokenRegex.exec( decoded ) ) !== null ) { + + const index = match.index; + if ( index > lastIndex ) { + + targetElement.appendChild( document.createTextNode( decoded.substring( lastIndex, index ) ) ); + + } + + const [ fullMatch, comment, str, num, keyword, func, namespace, ident, paren, brace, op ] = match; + + let className = ''; + if ( comment ) className = 'tsl-comment'; + else if ( str ) className = 'tsl-param-type-string'; + else if ( num ) className = 'tsl-param-type-number'; + else if ( keyword ) className = 'tsl-param-type-keyword'; + else if ( func ) className = isTslBuiltIn( func ) ? 'tsl-function-builtin' : 'tsl-function'; + else if ( namespace ) className = 'tsl-namespace'; + else if ( ident ) className = isTslBuiltIn( ident ) ? 'tsl-function-builtin' : 'tsl-identifier'; + else if ( paren ) className = 'tsl-bracket'; + else if ( brace ) className = 'tsl-brace'; + else if ( op ) className = 'tsl-operator'; + + if ( className ) { + + const span = document.createElement( 'span' ); + span.className = className; + span.textContent = fullMatch; + targetElement.appendChild( span ); + + } else { + + targetElement.appendChild( document.createTextNode( fullMatch ) ); + + } + + lastIndex = tokenRegex.lastIndex; + + } + + if ( lastIndex < decoded.length ) { + + targetElement.appendChild( document.createTextNode( decoded.substring( lastIndex ) ) ); + + } + +} + +export { parseTour, parse, tokenizeInlineCode, tokenizeCodeToElement }; diff --git a/tsl/js/utils/TourUtils.js b/tsl/js/utils/TourUtils.js new file mode 100644 index 00000000000000..fbe16d7212ac78 --- /dev/null +++ b/tsl/js/utils/TourUtils.js @@ -0,0 +1,129 @@ +const SVG_ICONS = { + 'panel-left': '', + 'panel-right': '', + 'copy': '', + 'check': '', + 'eye': '', + 'eye-off': '', + 'chevron-left': '', + 'chevron-right': '', + 'chevron-up': '', + 'chevron-down': '', + 'chevrons-up-down': '', + 'maximize': '', + 'maximize-2': '', + 'minimize-2': '', + 'terminal': '', + 'search': '', + 'x': '', + 'lock': '', + 'lock-open': '', + 'trash': '', + 'broom': '', + 'arrow-up-right': '', + 'external-link': '', + 'sparkles': '', + 'bot': '', + 'refresh': '', + 'box': '', + 'layers': '', + 'share-2': '' +}; + +const svgCache = {}; +const parser = new DOMParser(); + +function getSVG( name ) { + + if ( ! svgCache[ name ] ) { + + const svgString = SVG_ICONS[ name ]; + if ( svgString ) { + + const doc = parser.parseFromString( svgString, 'image/svg+xml' ); + svgCache[ name ] = doc.documentElement; + + } + + } + + return svgCache[ name ] ? svgCache[ name ].cloneNode( true ) : null; + +} + +async function compressString( str ) { + + if ( typeof CompressionStream === 'undefined' ) { + + return base64Encode( str ); + + } + + try { + + const stream = new Blob( [ str ] ).stream(); + const compressedStream = stream.pipeThrough( new CompressionStream( 'deflate' ) ); + const response = new Response( compressedStream ); + const buffer = await response.arrayBuffer(); + + const bytes = new Uint8Array( buffer ); + let binString = ''; + const chunkSize = 8192; + for ( let i = 0; i < bytes.length; i += chunkSize ) { + + binString += String.fromCharCode.apply( null, bytes.subarray( i, i + chunkSize ) ); + + } + + return btoa( binString ); + + } catch ( err ) { + + return base64Encode( str ); + + } + +} + +async function decompressString( base64 ) { + + if ( typeof DecompressionStream === 'undefined' ) { + + return base64Decode( base64 ); + + } + + try { + + const binString = atob( base64 ); + const bytes = Uint8Array.from( binString, ( m ) => m.codePointAt( 0 ) ); + const stream = new Blob( [ bytes ] ).stream(); + const decompressedStream = stream.pipeThrough( new DecompressionStream( 'deflate' ) ); + const response = new Response( decompressedStream ); + return await response.text(); + + } catch ( err ) { + + return base64Decode( base64 ); + + } + +} + +function base64Encode( str ) { + + const bytes = new TextEncoder().encode( str ); + const binString = Array.from( bytes, ( byte ) => String.fromCodePoint( byte ) ).join( '' ); + return btoa( binString ); + +} + +function base64Decode( base64 ) { + + const binString = atob( base64 ); + const bytes = Uint8Array.from( binString, ( m ) => m.codePointAt( 0 ) ); + return new TextDecoder().decode( bytes ); + +} + +export { getSVG, compressString, decompressString, base64Encode, base64Decode }; diff --git a/utils/llms/build.js b/utils/llms/build.js index 8ca9bc4abc658e..3b128edccf51a2 100644 --- a/utils/llms/build.js +++ b/utils/llms/build.js @@ -7,7 +7,7 @@ const packageJson = JSON.parse( fs.readFileSync( 'package.json', 'utf8' ) ); const version = packageJson.version; // Read TSL specification -const tslSpec = fs.readFileSync( 'docs/TSL.md', 'utf8' ); +const tslSpec = fs.readFileSync( 'tsl/content/Tour.md', 'utf8' ); // Setup Turndown for HTML to Markdown conversion const turndown = new TurndownService( {