Lens studio custom shaders
Skill zajalist/zajal-skills/skills/lens-studio-custom-shaders
Use when writing custom shaders in Lens Studio via MCP — Custom Code (GLSL-like) graph nodes, port declarations, surface/system inputs, animating without a Time node, masking faces, and script-settable graph parameters. Includes why graph wiring is GUI-only.From its SKILL.md
npx -y skills add zajalist/zajal-skills --skill lens-studio-custom-shadersAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
7.3 KB, ~1.8k tokens by cl100k_base, as published. Nobody here has run it
Lens Studio custom shaders (Custom Code nodes)
How to get real procedural/ray-marched shading (oceans, plasma, "matrix" grids, liquid glass) into a material from an agent. Verified porting Shadertoy-style GLSL into Lens Studio graph materials.
The hard boundary: code yes, wiring no
You can create/edit a Custom Code node's source: it lives in a .customCode text file you
edit like any file, and it hot-reloads into the running preview.
You cannot author the material graph over MCP — adding nodes, and connecting a Custom Code
node's output into Shader3D → Color (Pixel) (or the vertex Position Offset) is GUI-only. So:
the human wires the node once; thereafter you iterate purely by editing the .customCode text. Plan
for one manual wiring step per material, then full agent control.
Anatomy of a Custom Code node
// FRAGMENT stage. Wire `result` -> Shader3D Color (Pixel).
output_vec4 result; // an OUTPUT port named "result"
input_float amount; // an INPUT port named "amount" (becomes a graph pin / param)
void main() {
vec2 uv = system.getSurfaceUVCoord0();
result = vec4(uv, 0.0, 1.0);
}
- Declare ports with
output_<type>/input_<type>(vec4,vec3,float, …). These become the node's pins. - Inline comments on a declaration line create GHOST PORTS.
output_vec4 result; // colorcan register a phantom port namedresult ;and your real output silently won't connect / params drop out of the compiled uniforms. Keep declaration lines comment-free; put comments on their own line. - Plain GLSL globals like
uniform float x;or a barevec4 result;are not ports — they become ghost ports too. Use theinput_/output_forms.
Reading surface + system inputs (no extra nodes needed)
The system object exposes built-ins so a fragment node can be self-contained:
system.getSurfaceUVCoord0()→vec2UVssystem.getSurfaceNormalWorldSpace()→vec3world normal (normalize it)system.getSurfacePositionWorldSpace()→vec3world positionsystem.getTimeElapsed()→floatseconds (see animation caveat)
This lets you do effects (masking, tri-planar, fresnel) without wiring Surface/Normal/UV nodes in.
Masking faces (e.g. a disc: sea on top, "underwater" on the sides)
Use the world normal to split a mesh's faces in one shader, so a thin cylinder rim stops smearing the top texture:
vec3 N = normalize(system.getSurfaceNormalWorldSpace());
float topMask = smoothstep(0.80, 0.92, N.y); // ~1 on near-flat top, 0 on vertical sides
vec3 col = mix(sideColor, topColor, topMask);
The threshold band (0.80–0.92) is the knob for how aggressively the rim is treated. Make the side color self-consistent (no bright/white highlights) or seams reappear at the band edge.
Animation: getTimeElapsed works in code, but you often can't drive "time" from outside
Inside a Custom Code fragment shader, system.getTimeElapsed() animates fine. But a graph material's
time parameter cannot be driven via MCP/files from a script in the cases we hit — if you need
script-controlled motion, animate a transform instead (e.g. drift a noise-textured plane with a
tiny UpdateEvent script) rather than fighting the material clock.
For real geometric displacement (waves that actually stick up), use a vertex Custom Code node
writing a World Position Offset; have it self-read position via
system.getSurfacePositionWorldSpace() and output the full offset position. (Same ghost-port rules.)
Script-settable graph parameters (the part everyone gets wrong)
To change a shader value from TypeScript at runtime:
- The parameter must be an
input_<type>on a Custom Code node that is wired into the live output chain. An input that dangles (not connected toward the Pixel/Position output) is dropped from the compiled uniforms and is unsettable. - The settable key is the node input's ScriptName.
- When correct, the param appears in the compiled
.matunderProperties:(NOTCachedProperties:). If you only see it underCachedProperties:, it isn't a live uniform — re-check the wiring and that you usedinput_float(notuniform float, which makes a ghost port and drops the param).
A working "controls" pattern: a small @component (e.g. GlassControls) with an @input material
and an @input settings struct of sliders; on update it pushes each slider into
material.mainPass.<paramName>. The struct's slider widgets give designers live knobs.
Materials a Custom Code shader plugs into
- Graph / Shader Graph material — the host for Custom Code nodes. Author code via
.customCode, wire the GUI once. - Assigning the material to a mesh and swapping the
.customCodecontent is how you reuse one wired material for different effects (we overwrote one ocean node's file to fix rim masking — no re-wiring).
⚠️ Don't reuse a custom-code graph material on a SECOND mesh — clone or isolate
Assigning the same custom-code (e.g. LiquidGlass) material to a freshly-created plane recompiled and corrupted the shared shader, and the effect VANISHED on every instance — with no error logged. The graph material is effectively a singleton; pointing a new RenderMeshVisual at it can re-trigger compilation in a way that breaks the original. You won't see it in logs; you'll see it as a suddenly-blank glass card.
If you just want another panel/glass surface on a new mesh, don't share the custom-code material — build an isolated, non-graph material that cannot touch the shared shader:
- Create an Image material (
ImageMaterialPreset) with a baked PNG texture — draw a rounded-rect alpha + glow rim with PowerShellSystem.Drawing(see ui-building, assets-and-fbx). This gives a controllable glass/panel look with zero risk to the shared graph shader. - Set its texture via the visual's
mainMaterial.passInfos.0.baseTex(reference). - Set
blendShapesEnabled = falseon the new plane'sRenderMeshVisual(a vanilla plane has no blendshapes; leaving it on can cause spurious warnings/cost).
Reserve the precious wired graph material for the single surface it was authored for.
Workflow checklist
- Create/locate the graph material; add a Custom Code node in the GUI; wire its
output_→ Pixel Color (or Position Offset). Do this once, by hand. - Edit the node's
.customCodefile (ports comment-free; usesystem.*inputs). - Reset the preview to hot-reload (see preview-verify).
- Screenshot to verify — shaders are visual; logs won't tell you it's wrong.
- Iterate on the file only. Never trust that an inline-commented declaration compiled to a real port.
Related: lens-studio-ui-building (the liquid-glass card uses this), lens-studio-assets-and-fbx (PBR texture slots).
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.