vite-plugin-tsl-optimizer
v1.0.0
Published
A Vite plugin that optimizes Three.js Shading Language (TSL) call chains — constant folding, identity/noop removal, chain flattening, and redundant float() stripping. Composes with vite-plugin-tsl-operator.
Maintainers
Readme
vite-plugin-tsl-optimizer
A Vite plugin that optimizes Three.js Shading Language (TSL) method chains at build time. Designed to compose with vite-plugin-tsl-operator — run the operator plugin to turn a + b into a.add(b), then this one simplifies the resulting call chains.
What it does
| Optimization | Example | Default |
|--------------|---------|---------|
| Identity removal | x.add(0) → x, x.mul(1) → x, x.pow(1) → x | on |
| Function-form operator cleanup | add(0, x) → x, mul(-1, x) → x.negate() | on, TSL imports only |
| Constant folding | float(1).add(float(2)) → float(3) | on |
| Redundant wrapper collapse | float(float(x)) → float(x), vec3(vec3(x)) → vec3(x) | on |
| Negation absorption | x.mul(-1) → x.negate(), x.div(-1) → x.negate(), float(0).sub(x) → x.negate() | on |
| Idempotent collapse | x.abs().abs() → x.abs(), x.negate().negate() → x | on |
| Vec constructor splat | vec3(0,0,0) → vec3(0), vec3(v,v,v) → vec3(v) | on |
| Swizzle-on-swizzle fusion | v.xyz.xy → v.xy, v.yzw.xz → v.yw | on |
| Negate-on-literal fold | float(-2).negate() → float(2) | on |
| Chain flattening | x.mul(2).mul(3) → x.mul(6) | off (precision, see below) |
Rule details
- Identity-removed methods:
.add(0),.sub(0),.mul(1),.div(1),.pow(1). Reflected identity also fires forfloat(0).add(x)andfloat(1).mul(x). - Function-form operator cleanup covers imported TSL
add,sub,mul,div,mod, andpowcalls. It rewrites identities (add(x, 0),add(0, x),mul(x, 1),mul(1, x), etc.), negation absorption (mul(x, -1),mul(-1, x),div(x, -1),sub(0, x)), and literal folding when afloat(...)wrapper is already present. Ordinary local JavaScript functions namedadd/mulare left alone. - Constant-folded methods:
add,sub,mul,div,mod,pow. - Redundant-wrap collapse fires on same-type, same-width wrappers only:
float(float(x)),int(int(x)),vec2(vec2(x)),vec3(vec3(x)),vec4(vec4(x)). Legitimate casts (float(int(x)),vec3(vec4(x)),vec4(vec3(x))) are preserved. - Negation absorption requires an exact
-1argument for.mul()/.div()and an exact0receiver for.sub(). Also handles.mul(float(-1))etc. via the literal detector, plus reflectedfloat(-1).mul(x). - Idempotent methods:
abs,saturate,floor,ceil,round,fract,sign,normalize, andnegate. Double-negatecollapses to the original receiver; the others collapse to a single call. - Vec splat only fires when all arguments are structurally equal AND are side-effect-free (numeric literals, negated literals, or bare identifiers). Function calls and member accesses are left alone to avoid re-evaluation surprises.
- Swizzle fusion requires the inner swizzle to be at least 2 characters (so plain JS chains like
obj.x.yare never rewritten) and both sides to share an alphabet (allxyzwor allrgba).
Installation
pnpm i vite-plugin-tsl-optimizerUsage
import { defineConfig } from 'vite'
import tslOperatorPlugin from 'vite-plugin-tsl-operator'
import tslOptimizerPlugin from 'vite-plugin-tsl-optimizer'
export default defineConfig({
plugins: [
tslOperatorPlugin(), // first: turn JS operators into TSL calls
tslOptimizerPlugin(), // then: simplify the resulting chains
]
})The optimizer is also safe to run stand-alone on code that already uses TSL method syntax.
Options
| Option | Default | Description |
|--------|---------|-------------|
| logs | false | true | false | string | string[] | RegExp. Log rewrites per file. |
| flattenChains | false | Opt-in: fold consecutive same-op chains on numeric args. See precision note below. |
| maxPasses | 3 | Max fixpoint iterations (each pass is bottom-up). |
Logging
tslOptimizerPlugin({ logs: true }) // log every file
tslOptimizerPlugin({ logs: 'MyShader.js' }) // one file
tslOptimizerPlugin({ logs: /shader/i }) // regexWhy flattenChains is off by default
TSL builds one OperatorNode per .mul() / .add() call, preserving the per-step structure for the shader backend. Different backends (GLSL, WGSL) may depend on that structure for precision — in particular for fused multiply-add and for ordering of rounding in IEEE 754 arithmetic. Rewriting x.mul(2).mul(3) as x.mul(6) is mathematically identical but can yield a different rounded result.
Turn it on if you trust your numeric range and want the smallest possible node graph:
tslOptimizerPlugin({ flattenChains: true })Chain-flattened output uses bare numeric literals (x.mul(6), x.mul(-6)) — TSL auto-wraps primitive numbers at runtime, so an explicit float() would just add a redundant ConstNode.
Why these rules reduce TSL node count
TSL builds one runtime node per call — ConstNode per numeric literal, OperatorNode per .mul / .add, SplitNode per swizzle, ConvertNode per wrapper. Each rule above removes at least one node per match:
| Rule | Nodes saved per match | |------|----------------------| | Identity removal | 1 OperatorNode + 1 ConstNode | | Function-form operator cleanup | Same as the equivalent method-form rewrite | | Constant folding | 1 OperatorNode + 1 ConstNode (merges two literals) | | Redundant wrapper collapse | 1 ConvertNode | | Negation absorption | 1 ConstNode; swaps OperatorNode → UnaryNode | | Idempotent collapse | 1 FunctionCallNode | | Vec constructor splat | up to (arity − 1) ConstNodes or references | | Swizzle fusion | 1 SplitNode | | Negate-on-literal fold | 1 UnaryNode + 1 ConstNode (merged) |
A scan across the Three.js src/nodes/ + examples/jsm/tsl/ tree (269 files) finds 33 real rewrites across 16 files with default settings — ~60+ nodes removed from a freshly-built graph.
What it deliberately does NOT do
- No absorbing-element rewrites (
.mul(0)→0). Safe for scalars but breaks vector types (vec3.mul(0)isvec3(0,0,0), notfloat(0)). - No bare-literal function folds that would introduce a missing import.
mul(2, 3)is left alone unless afloat(...)wrapper is already present in the expression. - No
.toVar()auto-hoisting for repeated subexpressions. Requires static determinism analysis to avoid breakingdFdx/dFdyand varying-dependent expressions. - No automatic
.mod(1)removal.x % 1is not identity for floats (1.5 % 1 = 0.5). - No
clamp(x, 0, 1)→saturate(x)yet — semantically equivalent, but would belong behind a separate opt-in flag since it's a function-name change rather than a literal-shaped identity. - No structural cancellation (
a.add(x).sub(x)→a). Requires purity + referential-equality analysis. - Vec splat is strict about purity:
vec3(foo(), foo(), foo())is NOT splatted (would change call count), andvec3(a.x, a.x, a.x)is NOT splatted in this release (member-access equivalence isn't proven).
API
Named exports are available for programmatic use:
import TSLOptimizerPlugin, {
optimizeNode, // (babelNode, onLog?, flattenChains?) => babelNode
optimizeMember, // (babelNode, onLog?) => babelNode (swizzle fusion)
getLiteralValue, // (babelNode) => number | null (handles float/int/negate/unary minus)
composeSwizzle, // (innerStr, outerStr) => string | null
nodesEqual, // (a, b) => boolean (structural equality for splat detector)
FOLD, // { add, sub, mul, div, mod, pow }
IDENTITY_OF, // { add: 0, sub: 0, mul: 1, div: 1, pow: 1 }
CHAIN_REWRITE, // per-method flatten rules
IDEMPOTENT_METHODS, // Set of methods where f(f(x)) = f(x)
VEC_WRAPPERS, // Set<'vec2'|'vec3'|'vec4'>
SCALAR_WRAPPERS // Set<'float'|'int'>
} from 'vite-plugin-tsl-optimizer'License
MIT
