TSL conversion — community TSL conversion, community, ide skills, Claude Code, Cursor, Windsurf

v1.0.0

About this Skill

Porting the Zelda: Wind Waker Three.js project to TSL using AI tools

Robpayot Robpayot
[17]
[1]
Updated: 4/19/2026

Killer-Skills Review

Decision support comes first. Repository text comes second.

Reference-Only Page Review Score: 1/11

This page remains useful for operators, but Killer-Skills treats it as reference material instead of a primary organic landing page.

Locale and body language aligned
Review Score
1/11
Quality Score
24
Canonical Locale
en
Detected Body Locale
en

Porting the Zelda: Wind Waker Three.js project to TSL using AI tools

Core Value

Porting the Zelda: Wind Waker Three.js project to TSL using AI tools

Ideal Agent Persona

Suitable for operator workflows that need explicit guardrails before installation and execution.

Capabilities Granted for TSL conversion

! Prerequisites & Limits

Why this page is reference-only

  • - The page lacks a strong recommendation layer.
  • - The page lacks concrete use-case guidance.
  • - The page lacks explicit limitations or caution signals.
  • - The underlying skill quality score is below the review floor.

Source Boundary

The section below is imported from the upstream repository and should be treated as secondary evidence. Use the Killer-Skills review above as the primary layer for fit, risk, and installation decisions.

After The Review

Decide The Next Action Before You Keep Reading Repository Material

Killer-Skills should not stop at opening repository instructions. It should help you decide whether to install this skill, when to cross-check against trusted collections, and when to move into workflow rollout.

Labs Demo

Browser Sandbox Environment

⚡️ Ready to unleash?

Experience this Agent in a zero-setup browser environment powered by WebContainers. No installation required.

Boot Container Sandbox

FAQ & Installation Steps

These questions and steps mirror the structured data on this page for better search understanding.

? Frequently Asked Questions

What is TSL conversion?

Porting the Zelda: Wind Waker Three.js project to TSL using AI tools

How do I install TSL conversion?

Run the command: npx killer-skills add Robpayot/tslda/TSL conversion. It works with Cursor, Windsurf, VS Code, Claude Code, and 19+ other IDEs.

Which IDEs are compatible with TSL conversion?

This skill is compatible with Cursor, Windsurf, VS Code, Trae, Claude Code, OpenClaw, Aider, Codex, OpenCode, Goose, Cline, Roo Code, Kiro, Augment Code, Continue, GitHub Copilot, Sourcegraph Cody, and Amazon Q Developer. Use the Killer-Skills CLI for universal one-command installation.

How To Install

  1. 1. Open your terminal

    Open the terminal or command line in your project directory.

  2. 2. Run the install command

    Run: npx killer-skills add Robpayot/tslda/TSL conversion. The CLI will automatically detect your IDE or AI agent and configure the skill.

  3. 3. Start using the skill

    The skill is now active. Your AI agent can use TSL conversion immediately in the current project.

! Reference-Only Mode

This page remains useful for installation and reference, but Killer-Skills no longer treats it as a primary indexable landing page. Read the review above before relying on the upstream repository instructions.

Upstream Repository Material

The section below is imported from the upstream repository and should be treated as secondary evidence. Use the Killer-Skills review above as the primary layer for fit, risk, and installation decisions.

Upstream Source

TSL conversion

Install TSL conversion, an AI agent skill for AI agent workflows and automation. Works with Claude Code, Cursor, and Windsurf with one-command setup.

SKILL.md
Readonly
Upstream Repository Material
The section below is imported from the upstream repository and should be treated as secondary evidence. Use the Killer-Skills review above as the primary layer for fit, risk, and installation decisions.
Supporting Evidence

My Skill

You're the best TSL developer in the world

TSL (Three.js Shading Language) Rules for AI

Always check the doc! @.cursor/skills/tsl/references/TSL-DOC.md and @.cursor/skills/tsl/references/TSL-WIKI.md

STOP. Read this section first. These will cause errors or warnings.

❌ DO NOT USE✅ USE INSTEAD
timerGlobaltime
timerLocaltime
timerDeltadeltaTime
import from 'three/nodes'import from 'three/tsl'
import * as THREE from 'three'import * as THREE from 'three/webgpu'
oscSine(timerGlobal)oscSine(time) or oscSine()
oscSquare(timerGlobal)oscSquare(time) or oscSquare()
oscTriangle(timerGlobal)oscTriangle(time) or oscTriangle()
oscSawtooth(timerGlobal)oscSawtooth(time) or oscSawtooth()

CRITICAL: What TSL Is

TSL is JavaScript that builds shader node graphs. Code executes at TWO times:

  • Build time: JavaScript runs, constructs node graph

  • Run time: Compiled WGSL/GLSL executes on GPU

    // BUILD TIME: JavaScript conditional (runs once when shader compiles) if (material.transparent) { return transparent_shader; }

    // RUN TIME: TSL conditional (runs every pixel/vertex on GPU) If(value.greaterThan(0.5), () => { result.assign(1.0); });


TSL conversion

  • Please comment anything related to shadow casting for now

  • Verification (mandatory): After any TSL or material change, run npm run dev, open the app in the browser, and check the console for errors. Fix any errors before considering the task done.

  • To avoid this error: "THREE.TSL: NodeError: THREE.TSL: texture( value ) function expects a valid instance of THREE.Texture()."

    • In this project, texture(...) expects a real THREE.Texture as its first argument (see Lightnings, Stars, etc). Do not do texture( uniform(tex), uv ).
    • Use LoaderManager.getTexture('name') or LoaderManager.get('name').texture to get a valid texture, then sample with texture(mapTexture, uv()). Do not expose the texture as a uniform and pass that uniform into texture().
    • Runtime texture swapping: If a material’s texture must change at runtime (e.g. mouth/eyes/pupils), replace the material with a new one created with the new texture (e.g. mesh.material = createXxxMaterial(newTexture)). Do not use material.uMap.value = newTexture or similar; TSL materials must receive the texture at creation time.

Component + Material folder structure

When a component has its own TSL material in a separate file, use a dedicated folder named after the component:

Boat/
├── index.js
├── BoatMaterials.js          # shared boat materials
├── sail/
│   ├── Sail.js               # component logic
│   └── SailMaterials.js      # TSL sail material
├── splashes/
│   ├── Splashes.js           # component logic
│   └── SplashMaterials.js    # TSL splash material
└── ...
  • Folder name: lowercase, singular (e.g. sail, splashes)
  • Component file: PascalCase (e.g. Sail.js, Splashes.js)
  • Material file: *Materials.js (e.g. SailMaterials.js, SplashMaterials.js)
  • Import from parent: import Sail from './sail/Sail'

Shared TSL / reused GLSL logic

When multiple components use the same TSL (or former GLSL) logic, extract it to src/js/tsl-nodes/ and import from there. Example: Boat and Link both use the same receive-shadow toon (former receiveShadow.vert + receiveShadow.frag) → src/js/tsl-nodes/receiveShadowToon.js exports createReceiveShadowMaterial, and both BoatMaterials.js and LinkMaterials.js use it.

Convention: From now on, add all TSL nodes in the src/js/tsl-nodes/ folder, one file per node type (e.g. toon.js, barrel.js, receiveShadowToon.js). Do not add new TSL node logic only inside component folders; centralize reusable materials and node builders in tsl-nodes.

Imports

NPM (Preferred)

import * as THREE from 'three/webgpu';
import { Fn, vec3, float, uniform, /* ... */ } from 'three/tsl';

WRONG Import Patterns

// WRONG: Old path
import { vec3 } from 'three/nodes';
// CORRECT:
import { vec3 } from 'three/tsl';

// WRONG: WebGL renderer with TSL
import * as THREE from 'three';
// CORRECT: WebGPU renderer
import * as THREE from 'three/webgpu';

Renderer Initialization

CRITICAL: Always await renderer.init() before first render or compute.

const renderer = new THREE.WebGPURenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

// REQUIRED before any rendering
await renderer.init();

// Now safe to render/compute
renderer.render(scene, camera);

Type Constructors

ConstructorInputOutput
float(x)number, nodefloat
int(x)number, nodeint
uint(x)number, nodeuint
bool(x)boolean, nodebool
vec2(x,y)numbers, nodes, Vector2vec2
vec3(x,y,z)numbers, nodes, Vector3, Colorvec3
vec4(x,y,z,w)numbers, nodes, Vector4vec4
color(hex)hex numbervec3
color(r,g,b)numbers 0-1vec3
ivec2/3/4integerssigned int vector
uvec2/3/4integersunsigned int vector
mat2/3/4numbers, Matrixmatrix

Type Conversions

node.toFloat()  node.toInt()  node.toUint()  node.toBool()
node.toVec2()   node.toVec3() node.toVec4()  node.toColor()

Operators

Arithmetic (method chaining)

a.add(b)      // a + b (supports multiple: a.add(b, c, d))
a.sub(b)      // a - b
a.mul(b)      // a * b
a.div(b)      // a / b
a.mod(b)      // a % b
a.negate()    // -a

Assignment (for mutable variables)

v.assign(x)        // v = x
v.addAssign(x)     // v += x
v.subAssign(x)     // v -= x
v.mulAssign(x)     // v *= x
v.divAssign(x)     // v /= x

Comparison (returns bool node)

a.equal(b)           // a == b
a.notEqual(b)        // a != b
a.lessThan(b)        // a < b
a.greaterThan(b)     // a > b
a.lessThanEqual(b)   // a <= b
a.greaterThanEqual(b)// a >= b

Logical

a.and(b)   a.or(b)   a.not()   a.xor(b)

Bitwise

a.bitAnd(b)  a.bitOr(b)  a.bitXor(b)  a.bitNot()
a.shiftLeft(n)  a.shiftRight(n)

Swizzle

v.x  v.y  v.z  v.w          // single component
v.xy  v.xyz  v.xyzw         // multiple components
v.zyx  v.bgr                // reorder
v.xxx                       // duplicate
// Aliases: xyzw = rgba = stpq

Variables

RULE: TSL nodes are immutable by default

// WRONG: Cannot modify immutable node
const pos = positionLocal;
pos.y = pos.y.add(1);  // ERROR

// CORRECT: Use .toVar() for mutable variable
const pos = positionLocal.toVar();
pos.y.assign(pos.y.add(1));  // OK

Variable Types

const v = expr.toVar();           // mutable variable
const v = expr.toVar('name');     // named mutable variable
const c = expr.toConst();         // inline constant
const p = property('float');      // uninitialized property

Uniforms

// Create
const u = uniform(initialValue);
const u = uniform(new THREE.Color(0xff0000));
const u = uniform(new THREE.Vector3(1, 2, 3));
const u = uniform(0.5);

// Update from JS
u.value = newValue;

// Auto-update callbacks
u.onFrameUpdate(() => value);                    // once per frame
u.onRenderUpdate(({ camera }) => value);         // once per render
u.onObjectUpdate(({ object }) => object.position.y); // per object

Functions

Fn() Syntax

// Array parameters
const myFn = Fn(([a, b, c]) => { return a.add(b).mul(c); });

// Object parameters
const myFn = Fn(({ color = vec3(1), intensity = 1.0 }) => {
  return color.mul(intensity);
});

// With defaults
const myFn = Fn(([t = time]) => { return t.sin(); });

// Access build context (second param or first if no inputs)
const myFn = Fn(([input], { material, geometry, object, camera }) => {
  // JS conditionals here run at BUILD time
  if (material.transparent) { return input.mul(0.5); }
  return input;
});

Calling Functions

myFn(a, b, c)           // array params
myFn({ color: red })    // object params
myFn()                  // use defaults

Inline Functions (no Fn wrapper)

// OK for simple expressions, no variables/conditionals
const simple = (t) => t.sin().mul(0.5).add(0.5);

Conditionals

If/ElseIf/Else (CAPITAL I)

// WRONG
if(condition, () => {})    // lowercase 'if' is JavaScript

// CORRECT (inside Fn())
If(a.greaterThan(b), () => {
  result.assign(a);
}).ElseIf(a.lessThan(c), () => {
  result.assign(c);
}).Else(() => {
  result.assign(b);
});

Switch/Case

Switch(mode)
  .Case(0, () => { out.assign(red); })
  .Case(1, () => { out.assign(green); })
  .Case(2, 3, () => { out.assign(blue); })  // multiple values
  .Default(() => { out.assign(white); });
// NOTE: No fallthrough, implicit break

select() - Ternary (Preferred)

// Works outside Fn(), returns value directly
const result = select(condition, valueIfTrue, valueIfFalse);

// EQUIVALENT TO: condition ? valueIfTrue : valueIfFalse

// Example: clamp value with custom logic
const clamped = select(x.greaterThan(max), max, x);

Math-Based (Preferred for Performance)

step(edge, x)           // x < edge ? 0 : 1
mix(a, b, t)            // a*(1-t) + b*t
smoothstep(e0, e1, x)   // smooth 0→1 transition
clamp(x, min, max)      // constrain range
saturate(x)             // clamp(x, 0, 1)

// Pattern: conditional selection without branching
mix(valueA, valueB, step(threshold, selector))

Loops

// Basic
Loop(count, ({ i }) => { /* i is loop index */ });

// With options
Loop({ start: int(0), end: int(10), type: 'int', condition: '<' }, ({ i }) => {});

// Nested
Loop(10, 5, ({ i, j }) => {});

// Backward
Loop({ start: 10 }, ({ i }) => {});  // counts down

// While-style
Loop(value.lessThan(10), () => { value.addAssign(1); });

// Control
Break();     // exit loop
Continue();  // skip iteration

Math Functions

// All available as: func(x) OR x.func()

// Basic
abs(x) sign(x) floor(x) ceil(x) round(x) trunc(x) fract(x)
mod(x,y) min(x,y) max(x,y) clamp(x,min,max) saturate(x)

// Interpolation
mix(a,b,t) step(edge,x) smoothstep(e0,e1,x)

// Trig
sin(x) cos(x) tan(x) asin(x) acos(x) atan(y,x)

// Exponential
pow(x,y) exp(x) exp2(x) log(x) log2(x) sqrt(x) inverseSqrt(x)

// Vector
length(v) distance(a,b) dot(a,b) cross(a,b) normalize(v)
reflect(I,N) refract(I,N,eta) faceforward(N,I,Nref)

// Derivatives (fragment only)
dFdx(x) dFdy(x) fwidth(x)

// TSL extras (not in GLSL)
oneMinus(x)     // 1 - x
negate(x)       // -x
saturate(x)     // clamp(x, 0, 1)
reciprocal(x)   // 1/x
cbrt(x)         // cube root
lengthSq(x)     // squared length (no sqrt)
difference(x,y) // abs(x - y)
equals(x,y)     // x == y
pow2(x) pow3(x) pow4(x) // x^2, x^3, x^4

Oscillators

oscSine(t = time)      // sine wave 0→1→0
oscSquare(t = time)    // square wave 0/1
oscTriangle(t = time)  // triangle wave
oscSawtooth(t = time)  // sawtooth wave

Blend Modes

blendBurn(a, b)    // color burn
blendDodge(a, b)   // color dodge
blendScreen(a, b)  // screen
blendOverlay(a, b) // overlay
blendColor(a, b)   // normal blend

UV Utilities

uv()                                        // default UV coordinates (vec2, 0-1)
uv(index)                                   // specific UV channel
matcapUV                                    // matcap texture coords
rotateUV(uv, rotation, center = vec2(0.5))  // rotate UVs
spherizeUV(uv, strength, center = vec2(0.5))// spherical distortion
spritesheetUV(count, uv = uv(), frame = 0)  // sprite animation
equirectUV(direction = positionWorldDirection) // equirect mapping

Reflect

reflectView    // reflection in view space
reflectVector  // reflection in world space

Interpolation Helpers

remap(node, inLow, inHigh, outLow = 0, outHigh = 1)      // remap range
remapClamp(node, inLow, inHigh, outLow = 0, outHigh = 1) // remap + clamp

Random

hash(seed)      // pseudo-random float [0,1]
range(min, max) // random attribute per instance

Arrays

// Constant array
const arr = array([vec3(1,0,0), vec3(0,1,0), vec3(0,0,1)]);
arr.element(i)    // dynamic index
arr[0]            // constant index only

// Uniform array (updatable from JS)
const arr = uniformArray([new THREE.Color(0xff0000)], 'color');
arr.array[0] = new THREE.Color(0x00ff00);  // update

Varyings

// Compute in vertex, interpolate to fragment
const v = varying(expression, 'name');

// Optimize: force vertex computation
const v = vertexStage(expression);

Textures

texture(tex)                    // sample at default UV
texture(tex, uv)                // sample at UV
texture(tex, uv, level)         // sample with LOD
cubeTexture(tex, direction)     // cubemap
triplanarTexture(texX, texY, texZ, scale, pos, normal)

Shader Inputs

Position

positionGeometry      // raw attribute
positionLocal         // after skinning/morphing
positionWorld         // world space
positionView          // camera space
positionWorldDirection // normalized
positionViewDirection  // normalized

Normal

normalGeometry   normalLocal   normalView   normalWorld

⚠️ SkinnedMesh + NodeMaterial: normalWorld and normalView can be wrong (flat/white lighting). Use normalLocal and transform the light direction to model space instead:

// Sun/light direction with SkinnedMesh – use normalLocal + modelWorldMatrixInverse
const sunDirWorld = normalize(uSunDir.sub(positionWorld))
const sunDirLocal = normalize(modelWorldMatrixInverse.mul(vec4(sunDirWorld, 0)).xyz)
const shadow = dot(normalLocal, sunDirLocal)

Pass uSunDir as uniform(light.position) (reference, not clone) so updates are reflected.

Position and normal spaces — understand the logic

Be careful: choose the right space for your calculation. See @.cursor/skills/tsl/references/TSL-DOC.md and @.cursor/skills/tsl/references/TSL-WIKI.md for full details.

NodeSpaceWhen to use
positionLocalObject/model space (vertex position after skinning/morphing, before model matrix).Vertex displacement, custom .positionNode, logic that should move with the object.
positionWorldWorld space (position after modelWorldMatrix).Lighting (e.g. distance to light), world-space effects, sampling world-aligned textures.
positionGeometryRaw attribute (before any transform).When you need the original mesh attribute.
normalLocalObject space normal (after skinning, before model matrix).With SkinnedMesh (prefer over normalWorld), or when you transform light to model space.
normalWorldWorld space normal.Standard lighting in fragment (e.g. dot(normalWorld, lightDir)), fresnel, world-aligned effects.
  • .positionNode must return a vec3 in local space; the engine applies model/view/projection after. Use positionLocal (or a modification of it) there.
  • For world-space math (e.g. heightmap UV from world XZ), use positionWorld or modelWorldMatrix.mul(vec4(positionLocal, 1)).xyz.
  • SkinnedMesh: normalWorld / normalView can be wrong; use normalLocal and transform the light direction to model space (see SkinnedMesh note above).

Camera

cameraPosition  cameraNear  cameraFar
cameraViewMatrix  cameraProjectionMatrix  cameraNormalMatrix

Screen

screenUV          // normalized [0,1]
screenCoordinate  // pixels
screenSize        // pixels
viewportUV  viewport  viewportCoordinate  viewportSize

Time

time              // elapsed time in seconds (float)
deltaTime         // time since last frame (float)

Model

modelDirection         // vec3
modelViewMatrix        // mat4
modelNormalMatrix      // mat3
modelWorldMatrix       // mat4
modelPosition          // vec3
modelScale             // vec3
modelViewPosition      // vec3
modelWorldMatrixInverse // mat4

Other

uv()  uv(index)           // texture coordinates
vertexColor()             // vertex colors
attribute('name', 'type') // custom attribute
instanceIndex             // instance/thread ID (for instancing and compute)

NodeMaterial Types

Available Materials

MeshBasicNodeMaterial      // unlit, fastest
MeshStandardNodeMaterial   // PBR with roughness/metalness
MeshPhysicalNodeMaterial   // PBR + clearcoat, transmission, etc.
MeshPhongNodeMaterial      // Blinn-Phong shading
MeshLambertNodeMaterial    // Lambert diffuse
MeshToonNodeMaterial       // cel-shaded
MeshMatcapNodeMaterial     // matcap shading
MeshNormalNodeMaterial     // visualize normals
SpriteNodeMaterial         // billboarded quads
PointsNodeMaterial         // point clouds
LineBasicNodeMaterial      // solid lines
LineDashedNodeMaterial     // dashed lines

All Materials - Common Properties

.colorNode      // vec4 - base color
.opacityNode    // float - opacity
.positionNode   // vec3 - vertex position (local space)
.normalNode     // vec3 - surface normal
.outputNode     // vec4 - final output
.fragmentNode   // vec4 - replace entire fragment stage
.vertexNode     // vec4 - replace entire vertex stage

NodeMaterial options when overriding colorNode: Do not pass options in new NodeMaterial({ ... }) — they are not applied. Create with new NodeMaterial() and set properties after on the instance, e.g. material.transparent = true, material.depthWrite = false, material.blending = AdditiveBlending, material.side = DoubleSide.

MeshStandardNodeMaterial

.roughnessNode  // float
.metalnessNode  // float
.emissiveNode   // vec3 color
.aoNode         // float
.envNode        // vec3 color

MeshPhysicalNodeMaterial (extends Standard)

.clearcoatNode  .clearcoatRoughnessNode  .clearcoatNormalNode
.sheenNode  .transmissionNode  .thicknessNode
.iorNode  .iridescenceNode  .iridescenceThicknessNode
.anisotropyNode  .specularColorNode  .specularIntensityNode

SpriteNodeMaterial

.positionNode   // vec3 - world position of sprite center
.colorNode      // vec4 - color and alpha
.scaleNode      // float - sprite size (or vec2 for non-uniform)
.rotationNode   // float - rotation in radians

PointsNodeMaterial

.positionNode   // vec3 - point position
.colorNode      // vec4 - color and alpha
.sizeNode       // float - point size in pixels

Converting Points / point-like particles (use SpriteNodeMaterial, no billboardToCamera)

When converting a material that uses new Points() or point-like billboarded particles to TSL, use SpriteNodeMaterial so billboarding is handled by the material and no billboardToCamera is needed:

  1. Geometry: PlaneGeometry(1, 1) with setAttribute('instancePosition', new InstancedBufferAttribute(positionArray, 3)) and any other per-instance attributes (e.g. offset, speed).
  2. Material: SpriteNodeMaterial with:
    • positionNode = attribute('instancePosition', 'vec3') (or add displacement, e.g. heightmap, in the same Fn)
    • scaleNode = float(SPRITE_SCALE) or a uniform
    • colorNode = your fragment logic (use uv() instead of gl_PointCoord)
  3. Mesh: new InstancedMesh(planeGeo, material, count) — no setMatrixAt, no manual instance matrices.
  4. API: Keep a no-op billboardToCamera() if callers still invoke it, to avoid breaking changes.

Examples in this project: Stars, Lightnings, Waves.


Compute Shaders

Basic Compute (Standalone)

import { Fn, instanceIndex, storage } from 'three/tsl';

// Create storage buffer
const count = 1024;
const array = new Float32Array(count * 4);
const bufferAttribute = new THREE.StorageBufferAttribute(array, 4);
const buffer = storage(bufferAttribute, 'vec4', count);

// Define compute shader
const computeShader = Fn(() => {
  const idx = instanceIndex;
  const data = buffer.element(idx);
  buffer.element(idx).assign(data.mul(2));
})().compute(count);

// Execute
renderer.compute(computeShader);              // synchronous (per-frame)
await renderer.computeAsync(computeShader);   // async (heavy one-off tasks)

Compute → Render Pipeline

When compute shader output needs to be rendered (e.g., simulations, procedural geometry), use StorageInstancedBufferAttribute with storage() for writing and attribute() for reading.

import { Fn, instanceIndex, storage, attribute, vec4 } from 'three/tsl';

const COUNT = 1000;

// 1. Create typed array and storage attribute
const dataArray = new Float32Array(COUNT * 4);
const dataAttribute = new THREE.StorageInstancedBufferAttribute(dataArray, 4);

// 2. Create storage node for compute shader (write access)
const dataStorage = storage(dataAttribute, 'vec4', COUNT);

// 3. Define compute shader
const computeShader = Fn(() => {
  const idx = instanceIndex;
  const current = dataStorage.element(idx);

  // Modify data...
  const newValue = current.xyz.add(vec3(0.01, 0, 0));

  dataStorage.element(idx).assign(vec4(newValue, current.w));
})().compute(COUNT);

// 4. Attach attribute to geometry for rendering
const geometry = new THREE.BufferGeometry();
// ... set up base geometry ...
geometry.setAttribute('instanceData', dataAttribute);

// 5. Read in material using attribute()
const material = new THREE.MeshBasicNodeMaterial();
material.positionNode = Fn(() => {
  const data = attribute('instanceData', 'vec4');
  return positionLocal.add(data.xyz);
})();

// 6. Create mesh
const mesh = new THREE.InstancedMesh(geometry, material, COUNT);
scene.add(mesh);

// 7. Animation loop
await renderer.init();
function animate() {
  renderer.compute(computeShader);
  renderer.render(scene, camera);
  requestAnimationFrame(animate);
}
animate();

Updating Buffers from JavaScript

// Modify the underlying array
for (let i = 0; i < COUNT; i++) {
  dataArray[i * 4] = Math.random();
}
// Flag for GPU upload
dataAttribute.needsUpdate = true;

Example: Basic Material Shader

import * as THREE from 'three/webgpu';
import { Fn, uniform, vec3, vec4, float, uv, time,
         normalWorld, positionWorld, cameraPosition,
         mix, pow, dot, normalize, max } from 'three/tsl';

// Uniforms
const baseColor = uniform(new THREE.Color(0x4488ff));
const fresnelPower = uniform(3.0);

// Create material
const material = new THREE.MeshStandardNodeMaterial();

// Custom color with fresnel rim lighting
material.colorNode = Fn(() => {
  // Calculate fresnel
  const viewDir = normalize(cameraPosition.sub(positionWorld));
  const NdotV = max(dot(normalWorld, viewDir), 0.0);
  const fresnel = pow(float(1.0).sub(NdotV), fresnelPower);

  // Mix base color with white rim
  const rimColor = vec3(1.0, 1.0, 1.0);
  const finalColor = mix(baseColor, rimColor, fresnel);

  return vec4(finalColor, 1.0);
})();

// Animated vertex displacement
material.positionNode = Fn(() => {
  const pos = positionLocal.toVar();
  const wave = sin(pos.x.mul(4.0).add(time.mul(2.0))).mul(0.1);
  pos.y.addAssign(wave);
  return pos;
})();

Example: Compute Shader Structure

import * as THREE from 'three/webgpu';
import { Fn, instanceIndex, storage, uniform, vec4, float, sin, time } from 'three/tsl';

const COUNT = 10000;

// Storage buffer
const dataArray = new Float32Array(COUNT * 4);
const dataAttribute = new THREE.StorageBufferAttribute(dataArray, 4);
const dataBuffer = storage(dataAttribute, 'vec4', COUNT);

// Uniforms for compute
const speed = uniform(1.0);

// Compute shader
const updateCompute = Fn(() => {
  const idx = instanceIndex;
  const data = dataBuffer.element(idx);

  // Read current values
  const position = data.xyz.toVar();
  const phase = data.w;

  // Update logic
  const offset = sin(time.mul(speed).add(phase)).mul(0.1);
  position.y.addAssign(offset);

  // Write back
  dataBuffer.element(idx).assign(vec4(position, phase));
});

const computeNode = updateCompute().compute(COUNT);

// In animation loop:
// renderer.compute(computeNode);

Common Error Patterns

ERROR: "If is not defined"

// WRONG
if(condition, () => {})
// CORRECT
If(condition, () => {})  // capital I

ERROR: Cannot assign

// WRONG
const v = vec3(1,2,3);
v.x = 5;
// CORRECT
const v = vec3(1,2,3).toVar();
v.x.assign(5);

ERROR: Type mismatch

// WRONG
sqrt(intValue)
// CORRECT
sqrt(intValue.toFloat())

ERROR: Uniform not changing

// WRONG
myUniform = newValue;
// CORRECT
myUniform.value = newValue;

ERROR: Import not found

// WRONG
import { vec3 } from 'three/nodes';
import * as THREE from 'three';
// CORRECT
import { vec3 } from 'three/tsl';
import * as THREE from 'three/webgpu';

ERROR: Compute data not visible in render

// WRONG: Using storage() in render material
material.positionNode = storage(attr, 'vec4', count).element(idx).xyz;

// CORRECT: Use attribute() to read in render shaders
geometry.setAttribute('myData', attr);
material.positionNode = attribute('myData', 'vec4').xyz;

ERROR: Nothing renders

// WRONG: Rendering before init
renderer.render(scene, camera);

// CORRECT: Always await init first
await renderer.init();
renderer.render(scene, camera);

Quick Patterns

Fresnel

const fresnel = Fn(() => {
  const NdotV = normalize(cameraPosition.sub(positionWorld)).dot(normalWorld).max(0);
  return pow(float(1).sub(NdotV), 5);
});

Wave Displacement

material.positionNode = Fn(() => {
  const p = positionLocal.toVar();
  p.y.addAssign(sin(p.x.mul(5).add(time)).mul(0.2));
  return p;
})();

UV Scroll

material.colorNode = texture(map, uv().add(vec2(time.mul(0.1), 0)));

Conditional Value

const result = select(value.greaterThan(0.5), valueA, valueB);
// OR branchless:
const result = mix(valueB, valueA, step(0.5, value));

Gradient Mapping

const t = smoothstep(float(0.0), float(1.0), inputValue);
const colorA = vec3(0.1, 0.2, 0.8);
const colorB = vec3(1.0, 0.5, 0.2);
const gradient = mix(colorA, colorB, t);

Soft Falloff

// Exponential falloff (good for glow, attenuation)
const falloff = exp(distance.negate().mul(rate));

// Inverse square falloff
const attenuation = float(1.0).div(distance.mul(distance).add(1.0));

Circular Mask (for sprites/points)

const uvCentered = uv().sub(0.5).mul(2.0);  // -1 to 1
const dist = length(uvCentered);
const circle = smoothstep(float(1.0), float(0.8), dist);

OceanHeightMap — Synchronizing Elements with the Ocean Surface

The project uses a render-to-texture heightmap (OceanHeightMap) to encode the live ocean surface height. Any element that needs to follow the ocean (waves, foam, floating objects, etc.) should sample this texture in its vertex shader.

How the HeightMap Works

  • An orthographic camera renders a PlaneGeometry(1, 1, 200, 200) scaled to SCALE_OCEAN (3000) in the XY plane.
  • The vertex shader computes a wave depth from a sum-of-sines surface function and encodes it into varyings.
  • The fragment shader writes to a WebGLRenderTarget:
    • R = (depth + yStrength) / (2 * yStrength) — normalized height [0,1]
    • G = same as R (average, for future use)
    • B = yStrength / 100 — strength scaling factor
    • A = 1.0

Uniforms Updated Each Frame (from Ocean update())

OceanHeightMap.uTimeWave.value  = this.uTimeWave.value
OceanHeightMap.uDirTex.value    = GridManager.offsetUV
OceanHeightMap.uYScale.value    = yScale
OceanHeightMap.uYStrength.value = yStrength

Sampling the HeightMap in a TSL positionNode

1. Get the heightmap texture reference (at build time)

import OceanHeightMap from '../Ocean/OceanHeightMap'
const heightMapTex = OceanHeightMap.heightMap?.texture  // THREE.Texture from WebGLRenderTarget

2. Map world X,Z to heightmap UV

The heightmap covers [-SCALE_OCEAN/2, SCALE_OCEAN/2] in world X and Z.

// For a regular Mesh or Points:
const wPos = modelWorldMatrix.mul(vec4(positionLocal, 1.0))

// For an InstancedMesh — sample at instance CENTER, NOT per-vertex:
const wCenter = modelWorldMatrix.mul(vec4(0.0, 0.0, 0.0, 1.0))

const uScaleOcean = uniform(SCALE_OCEAN)
const uvGrid = vec2(
  float(0.5).add(wCenter.x.div(uScaleOcean)),
  float(0.5).sub(wCenter.z.div(uScaleOcean)),
)

CRITICAL for InstancedMesh: Use vec4(0,0,0,1) (instance origin) as the sampling point. Using positionLocal would sample at each vertex corner (±0.5 after scale), causing the quad to warp instead of displacing uniformly. The original GLSL Points shader used position which was the point center — vec4(0,0,0,1) is the equivalent for instanced geometry.

3. 5-tap cross average (reduces flicker)

const off = float(0.01)
const hmC  = texture(heightMapTex, uvGrid)
const hm1A = texture(heightMapTex, vec2(uvGrid.x.add(off), uvGrid.y))
const hm1B = texture(heightMapTex, vec2(uvGrid.x, uvGrid.y.add(off)))
const hm2A = texture(heightMapTex, vec2(uvGrid.x.sub(off), uvGrid.y))
const hm2B = texture(heightMapTex, vec2(uvGrid.x, uvGrid.y.sub(off)))
const avgH = hmC.r.add(hm1A.r).add(hm1B.r).add(hm2A.r).add(hm2B.r).div(5.0)

4. Compute world-space Y displacement

// Decode: (avgH - 0.5) * 2 * yStrength = depth (actual wave height)
// B channel * 100 recovers yStrength
const disp = avgH.sub(0.5).mul(2.0).mul(hmC.b.mul(100.0))

Note: The original GLSL waves.vert used (avgH - 0.5) * 2 * (B*100) * 2 because it applied the displacement in clip space (gl_Position.y +=), where the trailing *2 gets naturally attenuated by perspective divide. In world space, omit the trailing *2.

5. Apply displacement

For a standard Mesh (local Y ≈ world Y):

const pos = positionLocal.toVar()
pos.y.addAssign(disp)
return pos

For a billboarded InstancedMesh (local Y ≠ world Y due to billboard rotation):

// Transform world-Y displacement to instance-local space
const worldDispVec = vec4(0.0, disp, 0.0, 0.0)  // w=0 for direction
const localDisp = modelWorldMatrixInverse.mul(worldDispVec)
return positionLocal.add(localDisp.xyz)

Required imports for this pattern:

import { positionLocal, modelWorldMatrix, modelWorldMatrixInverse } from 'three/tsl'

Toon lighting when positionNode is overridden (Barrel-style)

When a material overrides positionNode (e.g. barrel with ocean heightmap), the pipeline’s positionWorld and normalLocal are wrong for lighting, so shading can move with the camera or be wrong at distance.

Approach:

  1. World-space lighting — In the vertex, assign a world normal varying: vNormalWorld.assign(normalize(transformDirection(normalLocal, transpose(modelWorldMatrixInverse)))). Pass normalWorldNode: vNormalWorld into buildToonShadingNode (from tsl-nodes/toon.js).

  2. Directional sun (no position) — Do not use position for the sun direction. Use a constant direction so lighting is camera- and distance-independent: when normalWorldNode is set, buildToonShadingNode uses normalize(uSunDir) (sun at uSunDir, same direction everywhere).

  3. Sun direction Y/Z sign fix — In this project the directional sun direction must flip Y and Z so that “sun at top” (zenith) lights surfaces from above. In buildToonShadingNode, for the directional path use:

    const rawDir = normalize(uSunDir)
    const sunDirDirectional = vec3(rawDir.x, rawDir.y.negate(), rawDir.z.negate())
    

    Without this, barrels (and any material using normalWorldNode + directional sun) are lit as if the sun were below when it is above, and vice versa.

Summary: For materials with custom positionNode, use buildToonShadingNode with normalWorldNode (world normal varying). The toon module then uses directional sun (normalize(uSunDir) with Y/Z negated) and no position, so lighting is stable at any camera distance and orientation.

Complete positionNode Example (InstancedMesh with Billboard)

const positionNodeFn = Fn(() => {
  const pos = positionLocal
  const wCenter = modelWorldMatrix.mul(vec4(0.0, 0.0, 0.0, 1.0))

  const uvGrid = vec2(
    float(0.5).add(wCenter.x.div(uScaleOcean)),
    float(0.5).sub(wCenter.z.div(uScaleOcean)),
  )

  const off = float(0.01)
  const hmC  = texture(heightMapTex, uvGrid)
  const hm1A = texture(heightMapTex, vec2(uvGrid.x.add(off), uvGrid.y))
  const hm1B = texture(heightMapTex, vec2(uvGrid.x, uvGrid.y.add(off)))
  const hm2A = texture(heightMapTex, vec2(uvGrid.x.sub(off), uvGrid.y))
  const hm2B = texture(heightMapTex, vec2(uvGrid.x, uvGrid.y.sub(off)))

  const avgH = hmC.r.add(hm1A.r).add(hm1B.r).add(hm2A.r).add(hm2B.r).div(5.0)
  const disp = avgH.sub(0.5).mul(2.0).mul(hmC.b.mul(100.0))

  const worldDispVec = vec4(0.0, disp, 0.0, 0.0)
  const localDisp = modelWorldMatrixInverse.mul(worldDispVec)
  return pos.add(localDisp.xyz)
})

material.positionNode = positionNodeFn()

Points → InstancedMesh Migration Notes

WebGPU does not support gl_PointCoord or variable gl_PointSize like WebGL. PointsNodeMaterial.sizeNode has no effect — points render as 1×1 pixel regardless. When converting Points-based effects:

Preferred: Use SpriteNodeMaterial with InstancedMesh + PlaneGeometry(1, 1) and InstancedBufferAttribute for instancePosition (see "Converting Points / point-like particles" above). Billboarding is then automatic; no CPU billboardToCamera or setMatrixAt needed.

If you cannot use SpriteNodeMaterial:

  1. Replace Points with InstancedMesh using PlaneGeometry(1, 1).
  2. Replace gl_PointCoord with uv().
  3. Use instanceIndex or InstancedBufferAttribute for per-instance variation. Use hash(instanceIndex) for pseudo-random per-instance values.
  4. Billboard rotation must be done on CPU (update instance matrices each frame with lookAt toward camera).
  5. When billboarding on CPU, UV Y may need flipping: float(1).sub(uv().y).
  6. For transparent instances, sort back-to-front by camera depth each frame.

Converting gl_PointSize to World-Space Scale

The original gl_PointSize formula gives a pixel size that shrinks with distance:

gl_PointSize = uSize * (perspectiveFactor / -mvPosition.z)

For InstancedMesh, the quad scale is in world units and perspective is handled by the camera projection. Both gl_PointSize and world-space objects scale as 1/distance, so the conversion is a constant factor.

Formula:

SPRITE_SCALE = uSize * perspectiveFactor * 2 * tan(fov / 2) / screenHeight

Since fov and screenHeight are runtime values, derive the conversion factor C from one known-good conversion and apply to others:

C = knownWorldScale / (knownUSize * knownPerspectiveFactor)
newWorldScale = newUSize * newPerspectiveFactor * C

This project's reference (FOV = 50°):

ComponentOriginal uSizePerspective factorK = uSize × factorWorld scale (SPRITE_SCALE)
Waves45010045,00015
Lightnings1000400400,000133
Stars501005,000~2
C = 15 / 45000 = 1/3000  →  SPRITE_SCALE = K / 3000

GLSL → TSL Migration

GLSLTSL
positionpositionGeometry
transformedpositionLocal
transformedNormalnormalLocal
vWorldPositionpositionWorld
vColorvertexColor()
vUv / uvuv()
vNormalnormalView
viewMatrixcameraViewMatrix
modelMatrixmodelWorldMatrix
modelViewMatrixmodelViewMatrix
projectionMatrixcameraProjectionMatrix
diffuseColormaterial.colorNode
gl_FragColormaterial.fragmentNode
texture2D(tex, uv)texture(tex, uv)
textureCube(tex, dir)cubeTexture(tex, dir)
gl_FragCoordscreenCoordinate
gl_PointCoorduv() in SpriteNodeMaterial/PointsNodeMaterial
gl_InstanceIDinstanceIndex

Related Skills

Looking for an alternative to TSL conversion or another community skill for your workflow? Explore these related open-source skills.

View All

openclaw-release-maintainer

Logo of openclaw
openclaw

Your own personal AI assistant. Any OS. Any Platform. The lobster way. 🦞

333.8k
0
AI

widget-generator

Logo of f
f

Generate customizable widget plugins for the prompts.chat feed system

149.6k
0
AI

flags

Logo of vercel
vercel

The React Framework

138.4k
0
Browser

pr-review

Logo of pytorch
pytorch

Tensors and Dynamic neural networks in Python with strong GPU acceleration

98.6k
0
Developer