feat: Obfuscate variables in plugin - #2755
Conversation
|
pkg.pr.new packages benchmark commit |
Bundle size comparison (
|
| 🟢 Decreased | ➖ Unchanged | 🔴 Increased (max 0.01%) | ❔ Unknown |
|---|---|---|---|
| 0 | 301 | 21 | 0 |
import { ... } in PR vs import * as ... in PR (is the library tree-Shakeable?):
| Test | tsdown |
|---|---|
| tgpu_init.ts | 260.81 kB ( |
| tgpu_initFromDevice.ts | 260.28 kB ( |
| tgpu_resolve.ts | 167.28 kB ( |
| tgpu_resolveWithContext.ts | 167.22 kB ( |
| tgpu_bindGroupLayout.ts | 71.66 kB ( |
| tgpu_mutableAccessor.ts | 68.05 kB ( |
| tgpu_accessor.ts | 68.05 kB ( |
| tgpu_privateVar.ts | 66.79 kB ( |
| tgpu_workgroupVar.ts | 66.79 kB ( |
| tgpu_const.ts | 66.27 kB ( |
| tgpu_lazy.ts | 66.15 kB ( |
| tgpu_fn.ts | 38.59 kB ( |
| tgpu_fragmentFn.ts | 38.58 kB ( |
| tgpu_vertexFn.ts | 38.40 kB ( |
| tgpu_computeFn.ts | 38.10 kB ( |
| tgpu_vertexLayout.ts | 27.22 kB ( |
| tgpu_comptime.ts | 14.91 kB ( |
| tgpu_unroll.ts | 1.66 kB ( |
| tgpu_slot.ts | 1.54 kB ( |
If you wish to run a comparison for other, slower bundlers, run the 'Tree-shake test' from the GitHub Actions menu.
Resolution Time Benchmark---
config:
themeVariables:
xyChart:
plotColorPalette: "#E63946, #3B82F6, #059669"
---
xychart
title "Random Branching (🔴 PR | 🔵 main | 🟢 release)"
x-axis "max depth" [1, 2, 3, 4, 5, 6, 7, 8]
y-axis "time (ms)"
line [0.82, 1.64, 3.48, 5.64, 5.78, 9.48, 18.41, 20.09]
line [0.77, 1.66, 3.51, 5.78, 6.51, 10.66, 19.27, 19.23]
line [0.85, 1.63, 3.66, 5.46, 6.23, 9.33, 17.98, 20.95]
---
config:
themeVariables:
xyChart:
plotColorPalette: "#E63946, #3B82F6, #059669"
---
xychart
title "Linear Recursion (🔴 PR | 🔵 main | 🟢 release)"
x-axis "max depth" [1, 2, 3, 4, 5, 6, 7, 8]
y-axis "time (ms)"
line [0.29, 0.50, 0.64, 0.77, 1.00, 1.05, 1.18, 1.34]
line [0.27, 0.46, 0.62, 0.76, 0.98, 1.03, 1.17, 1.34]
line [0.31, 0.49, 0.65, 0.71, 0.93, 1.05, 1.27, 1.40]
---
config:
themeVariables:
xyChart:
plotColorPalette: "#E63946, #3B82F6, #059669"
---
xychart
title "Full Tree (🔴 PR | 🔵 main | 🟢 release)"
x-axis "max depth" [1, 2, 3, 4, 5, 6, 7, 8]
y-axis "time (ms)"
line [0.76, 1.88, 3.52, 5.64, 10.42, 21.43, 46.99, 94.29]
line [0.84, 1.88, 3.37, 5.80, 10.13, 22.76, 46.31, 93.92]
line [0.93, 1.87, 3.84, 5.67, 10.92, 23.33, 49.45, 98.36]
|
| declaredNames: string[]; | ||
| }; | ||
|
|
||
| export type Externals = Set<string>; |
There was a problem hiding this comment.
Less work swapping it for a map here than in unplugin, and it needs to become a map eventually
|
@pullfrog review |
There was a problem hiding this comment.
Important
The externalNames iteration in the obfuscator swaps the map's key and value. This works today only because tinyest-for-wgsl stores identical strings for both; it will silently produce incorrect WGSL if that invariant ever changes.
Reviewed changes — PR #2755 adds an experimental plugin-time obfuscation pass that renames parameters, local variables, and external bindings in the transpiled tinyest AST, plus the EXPERIMENTAL_obfuscate option across the Babel/Rollup/Bun plugin entry points, a new docs page, and supporting test updates.
- Added
packages/unplugin-typegpu/src/core/obfuscate.ts— AST-to-AST obfuscation ofparams,body, andexternalNames. - Changed
ExternalsfromSet<string>toMap<string, string>inpackages/tinyest-for-wgsl/src/types.tsand updated all consumers/test snapshots. - Wired
EXPERIMENTAL_obfuscateintounplugin-typegpuplugin options, with a guard that rejects enabling it alongsideautoNamingEnabled. - Added docs entry (
apps/typegpu-docs/.../minifying-shaders.mdx) and sidebar link. - Updated/added tests in
packages/tinyest-for-wgsl/tests/parsers.test.ts,packages/unplugin-typegpu/test/obfuscation.test.ts, and severaltypegpusnapshot tests.
Kimi K2 (free via Pullfrog for OSS) | 𝕏
|
@pullfrog review |
There was a problem hiding this comment.
ℹ️ No critical issues — minor suggestions inline.
Reviewed changes
This PR introduces an EXPERIMENTAL_obfuscate option for unplugin-typegpu that renames TGSL parameters, local variables, and external identifiers to short generated names before WGSL code generation. It also adds the supporting infrastructure: changing externalNames from a Set to a Map to carry original accessor chains, adding validation that prevents obfuscation and auto-naming from being enabled together, updating metadata serialization in the Babel and unplugin factories, and adding documentation and tests.
- Added
packages/unplugin-typegpu/src/core/obfuscate.tswith a tinyest-to-tinyest AST transformer that renames identifiers. - Added
EXPERIMENTAL_obfuscateplugin option with a conflict check againstautoNamingEnabled. - Changed
ExternalsfromSet<string>toMap<string, string>so obfuscated keys can map back to original accessor chains. - Updated
packages/typegpu/src/resolutionCtx.tsto resolveundefined/Infinity/NaNexternals correctly. - Added
packages/unplugin-typegpu/test/obfuscation.test.tscovering AST-level obfuscation for variables, parameters, externals, destructuring, shadowing, and name exhaustion. - Added docs page for shader minification/obfuscation.
Kimi K2 (free via Pullfrog for OSS) | 𝕏
| import { bunPlugin, rollupPlugin } from '../src/index.ts'; | ||
| import { defaultOptions } from '../src/core/common.ts'; | ||
|
|
||
| describe('plugin obfuscation', () => { |
There was a problem hiding this comment.
The tests cover the tinyest-to-tinyest transformation well, but there is no end-to-end coverage that runs tgpu.resolve on an obfuscated function and verifies the emitted WGSL. Since shadowing and reserved-word collisions are handled later by the resolver, at least one integration test would guard against regressions in that interaction.
There was a problem hiding this comment.
I tried to configure them in typegpu package but vitest won again, maintainers let me know if you think we need e2e tests.
There was a problem hiding this comment.
Pull request overview
Adds an experimental shader obfuscation/minification pathway by obfuscating identifiers captured in unplugin-typegpu metadata, supported by updates to the tinyest transpiler externals representation and expanded test/docs coverage.
Changes:
- Introduces
EXPERIMENTAL_obfuscateoption to obfuscate parameters/locals/externals stored in plugin metadata. - Changes tinyest-for-wgsl “externals” from
Set<string>toMap<string, string>to support renaming/aliasing of external chains. - Adds/updates tests and documentation for obfuscation behavior and collision/shadowing scenarios.
Reviewed changes
Copilot reviewed 15 out of 18 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/unplugin-typegpu/test/obfuscation.test.ts | New plugin + core obfuscation test coverage and option-conflict checks. |
| packages/unplugin-typegpu/src/core/obfuscate.ts | New obfuscation pass over tinyest AST + externals mapping. |
| packages/unplugin-typegpu/src/core/common.ts | Plumbs EXPERIMENTAL_obfuscate through transpilation and adds option validation. |
| packages/unplugin-typegpu/src/core/factory.ts | Updates externals serialization and validates options in unplugin factory. |
| packages/unplugin-typegpu/src/babel.ts | Updates Babel plugin option validation and externals emission. |
| packages/unplugin-typegpu/src/bun.ts | Validates options for Bun plugin entrypoint. |
| packages/typegpu/src/resolutionCtx.ts | Adjusts external lookup to distinguish “missing” vs “present-but-undefined”. |
| packages/typegpu/src/internal.ts | Exposes stringifyNode via internal exports for tests/tooling. |
| packages/typegpu/tests/tgslFn.test.ts | Adds regression coverage for std shadowing behavior. |
| packages/typegpu/tests/tgsl/wgslGenerator.test.ts | Snapshot formatting updates. |
| packages/typegpu/tests/std/boolean/not.test.ts | Snapshot formatting updates. |
| packages/typegpu/tests/mutabilityTracking.test.ts | Snapshot formatting updates. |
| packages/tinyest-for-wgsl/src/types.ts | Switches Externals to Map<string, string>. |
| packages/tinyest-for-wgsl/src/parsers.ts | Emits externals into a Map instead of a Set. |
| packages/tinyest-for-wgsl/tests/helpers.ts | New shared helpers for dual-parser tests. |
| packages/tinyest-for-wgsl/tests/parsers.test.ts | Updates expectations for externals now being a Map. |
| apps/typegpu-docs/src/content/docs/advanced/minifying-shaders.mdx | New documentation page for obfuscation/minification. |
| apps/typegpu-docs/astro.config.mjs | Adds docs nav entry for the new page. |
Suppressed comments (1)
packages/tinyest-for-wgsl/src/types.ts:15
- The
Context.externalNamesdoc still says it “Holds a set…”, butExternalsis now aMap<string, string>. Updating the comment will prevent confusion for future maintainers.
export type Context = {
/** Holds a set of all identifiers that were used in code, but were not declared in code. */
externalNames: Externals;
/** Used to signal to identifiers that they should not treat their resolution as possible external uses. */
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| Array.from(externals, ([key, value]) => { | ||
| const chain = value.split('.'); | ||
| if (!chain[0]) { | ||
| throw new Error('Internal error, expected chain to not be empty'); | ||
| } |
| const externalNames = new Map(); | ||
| fn.externalNames.forEach((value, key) => externalNames.set(ctx.obfuscator.obfuscate(key), value)); |
| class Obfuscator { | ||
| #nameMap: Map<string, string> = new Map(); | ||
| #nameGenerator: Generator<string> = nameGenerator(); | ||
|
|
||
| #generateFreshName(): string { | ||
| return this.#nameGenerator.next().value; | ||
| } | ||
|
|
||
| /** | ||
| * If `name` wasn't obfuscated before, give it a new obfuscated name. | ||
| * Then, returns the obfuscated version of `name`. | ||
| */ | ||
| obfuscate(name: string): string { | ||
| let obfuscatedName = this.#nameMap.get(name); | ||
| if (!obfuscatedName) { | ||
| obfuscatedName = this.#generateFreshName(); | ||
| this.#nameMap.set(name, obfuscatedName); | ||
| } | ||
|
|
||
| return obfuscatedName; | ||
| } | ||
| } |
| To reduce both the shader size and readability, it is advised to disable the plugin auto-naming by setting `{ autoNamingEnabled: false }`. | ||
| This way, only resources given name via `.$name()` will be named in the resulting shader. |
| } | ||
|
|
||
| if (external !== undefined && external !== null) { | ||
| if (id in layer.externalMap) { |
There was a problem hiding this comment.
Important
The obfuscation name generator can emit WGSL keywords, which will produce invalid shader code once enough identifiers are present. See the inline comment on packages/unplugin-typegpu/src/core/obfuscate.ts.
Reviewed changes
- Added an
EXPERIMENTAL_obfuscateplugin option that renames parameters, variables, and captured externals to short lowercase identifiers at build time. - Changed
tinyest-for-wgslexternalNamesfromSet<string>toMap<string, string>so obfuscated AST keys can still resolve to original external access chains at runtime. - Updated
resolutionCtx.tsto resolve externals by map membership (id in externalMap) rather than by truthiness, preserving null/undefined external values. - Added
stringifyNodeto the internal TypeGPU exports for test assertions. - Added docs page and parser/unplugin tests.
ℹ️ Nitpicks
- The options reference in
apps/typegpu-docs/src/content/docs/tooling/unplugin-typegpu.mdxwas not updated to listEXPERIMENTAL_obfuscate; consider adding it there (marked experimental) to keep the two docs pages consistent.
Kimi K2 (free via Pullfrog for OSS) | 𝕏
| /** | ||
| * Generates all strings consisting of lowercase letters. | ||
| */ | ||
| function* nameGenerator(): Generator<string> { |
There was a problem hiding this comment.
The generator emits a, b, …, z, aa, ab, … without skipping WGSL keywords or builtins. I verified locally that 220 variables produces the obfuscated name fn, which is invalid WGSL. Reuse the existing bannedTokens/builtins sets in packages/typegpu/src/nameUtils.ts here.
| */ | ||
| obfuscate(name: string): string { | ||
| let obfuscatedName = this.#nameMap.get(name); | ||
| if (!obfuscatedName) { |
There was a problem hiding this comment.
if (!obfuscatedName) will regenerate a name for the empty string. Since the generator never yields '', this is harmless, but obfuscatedName === undefined would express the intent more precisely.
| ## Plugin obfuscation | ||
|
|
||
| :::note | ||
| To reduce both the shader size and readability, it is advised to disable the plugin auto-naming by setting `{ autoNamingEnabled: false }`. |
There was a problem hiding this comment.
There's a trailing space inside the inline-code backticks: { autoNamingEnabled: false }. . Remove the space before the closing backtick.

Blocked by #2788
We obfuscate variables, externals and parameters. Struct props are left as-is (at least for now).
Testing:
Further work: #2783, #2786.
Also, helpers like
fullScreenTrianglewill remain as is, they are implemented in raw wgsl.