Trust No Custom Effect
A custom effect is arbitrary JavaScript running inside Persona, not a sandboxed shader — once it loads, nothing constrains what it does. Treat every custom effect as untrusted, including ones somebody recommends and ones that look harmless: run only what you wrote yourself, or what you have read line by line and understood. See Trust
This is a tech preview for power users: it asks you to write JavaScript and TSL, and to carry the risk above yourself. Using Persona day to day never requires it — the built-in effects are the ones for everybody, and this page is not
Beyond the built-in effects, you can write your own. A custom effect is a JavaScript module whose TSL expression folds into the effect chain at one fixed slot
Installed effects are listed in the Inventory's Effects category, beside the built-ins. Added to a scene, one becomes a row in the layer list: the eye switches it, dragging reorders it among the other custom effects, and selecting it opens every slider its manifest declares. The category's + is Open Effects Folder, and Reload Effects at the bottom rescans them by hand
Adding, removing and muting all ramp over 0.4 s, so nothing cuts mid-stream — a custom effect ramps in once its module has loaded
This is the one place third-party JavaScript runs inside Persona. Everything else third-party goes through the plugin API, out of process — that route has a boundary, this one does not
What an Effect Looks Like
~/.laplace/persona/effects/scanlines/
├── manifest.json plain data: the name and the sliders
└── effect.js the TSL builderThe folder name is the effect's id: lowercase letters, digits and dashes, up to 64 characters (scanlines, my-effect-2). Anything else is skipped. Only .js and .json inside the folder are ever read
PERSONA_HOME relocates the config home, and the effects folder moves with it
Bundled Effects
Some effects ship with the app and sit beside yours in the Inventory's Effects category from the first launch — Motion Trail and Stardust are in the box
They are read-only in practice: edits land inside the app directory and are replaced on update. To make one yours, copy its whole folder into your own effects directory and edit the copy — a user folder replaces a bundled effect of the same name, and deleting yours falls back to the bundled one
The Inventory lists the two apart: the ones shipped with the app under Bundled, yours under Custom (not "Recent" — an effect is authored, not registered). Once in a scene, a bundled one still reads Bundled on its row
manifest.json
{
"name": "Scanlines",
"author": "you",
"version": "1.0.0",
"params": {
"strength": {
"label": "Strength",
"default": 0.5,
"min": 0,
"max": 1,
"step": 0.01
},
"density": {
"label": "Density",
"default": 400,
"min": 50,
"max": 2000,
"step": 10
},
"tint": { "kind": "color", "label": "Tint", "default": "#88ccff" },
"rolling": { "kind": "boolean", "label": "Rolling", "default": true }
}
}name is required — without it the effect lists as an error and never loads. Every param needs a label; a malformed one is dropped while the rest of the manifest still loads, so a half-typed file still shows what already works
kind is number (the default), color (a #rrggbb string), or boolean. Numbers also take step, digits (readout decimals) and unit (appended to the readout). An inverted range is untangled and a default outside its own range is clamped, so a typo costs you a slider position rather than a usable effect. Up to 32 params per effect
Param names must be JS identifiers — they are the keys on the uniforms object your module returns
Persona's main process reads this file and never imports effect.js. That split is what lets a scene's saved params be validated without running author code
effect.js
export default function ({ tsl, three }) {
const { float, mix, sin, uniform, uv, vec4 } = tsl;
const uniforms = {
strength: uniform(0.5),
density: uniform(400),
tint: uniform(new three.Color("#88ccff")),
rolling: uniform(true),
};
let phase = 0;
return {
uniforms,
build(input, ctx) {
const src = ctx.sample(ctx.uv());
const line = sin(ctx.uv().y.mul(uniforms.density).add(float(phase)))
.mul(0.5)
.add(0.5);
const banded = src.rgb.mul(mix(float(1), line, uniforms.strength));
return vec4(banded.mul(uniforms.tint), src.a);
},
update(dt) {
phase += dt * 6;
},
};
}Default-export a factory, and do not import three. Bare specifiers cannot resolve from a persona:// URL and module workers have no import maps, so the runtime is handed to you: tsl is the whole three/tsl namespace, three is { Color, Vector2, Vector3, Vector4 }, and nodes holds the stock three display nodes an effect cannot build for itself (see Temporal Effects)
The factory runs once per load, not per rebuild — mint your uniforms there and keep them. The chain recomposes its expression whenever anything structural changes, and uniforms re-minted on every rebuild lose their values
| Return key | ||
|---|---|---|
uniforms | optional | Uniform nodes keyed by manifest param name; the panel writes .value directly. A param with no matching key is simply not delivered |
build(input, ctx) | required | Return a vec4 node. input is your own render target — the frame so far, already sampleable |
update(dt, elapsed) | optional | Per-frame CPU state, in seconds. dt is clamped to 100 ms so a stall cannot teleport an animation |
dispose() | optional | Called when the effect is removed or reloaded |
ctx carries sample(at) (the incoming frame at any UV — the seam that makes warps and blurs possible), uv(), viewZ() (the 3D scene's depth at this pixel — see Depth), worldRay() and cameraPosition() (the scene camera — see Camera), and track(disposable) for anything with a render target that must be freed on the next rebuild
Colour uniforms are mutated in place, so declare them as a three.Color (or a vector) rather than a string — a hex string assigned over .value would break the node's inferred type
Typing
The whole contract is typed: @laplace.live/persona-sdk/effects exports CustomEffectFactory, along with CustomEffectApi, CustomEffectModule, the fault shape and the manifest validators. Everywhere it touches three it is type-only, so typechecking against it needs @types/three, declared as an optional peer
In plain JavaScript one JSDoc line lights up completions:
/** @type {import('@laplace.live/persona-sdk/effects').CustomEffectFactory} */
export default function ({ tsl, three, nodes }) {
/* … */
}Temporal Effects
ctx.sample() only reaches the current frame. Anything that needs the previous one — motion trails, echoes, temporal blur, accumulation — cannot be written against build() alone: correct frame feedback needs two render targets ping-ponged plus a renderer reference, because reading and writing a single target in one draw is undefined on most GPUs
So that feedback lives in a stock three node instead, reached through nodes:
export default function ({ tsl, nodes }) {
const { uniform, vec4 } = tsl;
const uniforms = {
damp: uniform(0.92), // 0..1, higher = longer trail
strength: uniform(0.7),
};
return {
uniforms,
build(input, ctx) {
const echo = nodes.afterImage(input, uniforms.damp);
ctx.track(echo); // frees its targets on the next rebuild
const src = ctx.sample(ctx.uv());
return vec4(
src.rgb.max(echo.rgb.mul(uniforms.strength)),
src.a.max(echo.a.mul(uniforms.strength)),
);
},
};
}Lighten-blend rather than average, or the avatar dissolves into its own ghost; alpha has to grow too, or the trail is clipped to the silhouette and never shows
afterImage takes a texture node, and input already is one. It carries its own brightness threshold, so dark pixels trail less than bright ones. A rebuild mints a fresh node and the accumulated history resets — rebuilds only happen on structural changes, so that is a blink, not a visible glitch
nodes is the seam for this whole class. It currently holds afterImage; the rest of the three/tsl namespace is already reachable through tsl
Depth
ctx.viewZ() is the 3D scene's view-space Z at the pixel being shaded, in three's convention: 0 at the camera, more negative with distance, and the far plane where nothing was drawn. It reads the same depth the chain's own DoF uses, so it costs one texture read — and only if you call it
The classic use is occlusion — screen-space particles that hide behind the avatar (the bundled Stardust does exactly this):
const dist = ctx.viewZ().negate(); // metres from the camera
const visible = smoothstep(planeDist - 0.25, planeDist + 0.25, dist);Two limits to know:
- Only the 3D scene writes depth. The Live2D layer and 2D objects composite with no depth, so against them
viewZ()reads whatever 3D geometry (or far plane) sits behind — depth tricks read as 3D on the VRM stage only - Depth is sampled pre-warp. Pixelate, glitch, shockwave and droplets move colour, not depth, so under a heavy warp the two can disagree at the fringes
Camera
A post pass runs on a fullscreen quad, so TSL's own cameraPosition / cameraWorldMatrix describe that quad's camera — not the one orbiting the stage. The scene camera crosses over as two context helpers instead:
ctx.worldRay()— the normalized world-space direction of the scene camera's view ray through this pixel, field of view and aspect folded inctx.cameraPosition()— the scene camera's world position in metres, updated every frame
Together they define the pixel's ray: origin + t · direction. Anchor a screen-space field to worldRay() instead of uv() and it holds still in the world while the camera moves: the bundled Stardust hashes its stars on the ray's azimuth and elevation, so orbiting pans through the field, and offsets each layer by cameraPosition() over its distance for parallax
Where the camera never moves — on a Live2D-only stage — both helpers still answer; they just hold constant
Where It Runs in the Chain
Custom effects compose as one group, in scene order, at a fixed point:
scene → depth of field → Live2D → rim → outline → drop shadow → aberration
→ bloom → diffusion → LUT → colour grading → pixelate/glitch
→ shockwave → droplets
├─▶ custom effects, in scene order
→ film grain → vignette → selection outline → editor overlayAfter every built-in warp and the whole colour stack, before the camera-side finish. The slot is fixed on purpose: the built-in order is an artistic decision, not a permutation the chain has to defend
Each enabled effect costs one full-frame render target, because each gets its own sampleable copy of the frame so far. Eight enabled at once is the cap. At the cap the Inventory greys out the rest with the reason, the ones already in the list refuse to switch on, and a line under the list says so
The whole frame is premultiplied and the window is transparent: src.a is coverage, and returning opaque alpha paints a rectangle over the desktop
The Authoring Loop
The effects folder is watched. Save effect.js and the module reloads on the next frame — no restart, and only the effect you edited reloads. Editing one effect never resets a sibling's uniforms or animation state
Only your folder is watched; bundled effects change with the app, never under a running one. Copy one out first (see Bundled Effects) and you get the live loop on the copy
Errors surface on the effect's own row — a warning triangle in the layer list, an Error badge in the Inventory, where it stays un-addable until fixed — and in the console. A module that throws is skipped and stays skipped until you save again: a TSL codegen throw repeats on every identical rebuild, so retrying would spam the log and re-break the graph on every apply. Fix the file, save, and it recovers
| Stage | What broke |
|---|---|
load | The module never imported (syntax error, no default export), or the factory threw or returned no module |
build | build() threw during TSL codegen, or returned nothing |
update | update() threw |
What is not contained: an infinite loop, or a shader heavy enough to tank the frame rate. Nothing here can save you from those
How Scenes Store It
A scene stores environment.customEffects — { slug, enabled, params } per effect, in composition order. It is a sibling of effects, not a key inside it, so the built-in registry stays a closed compile-time union
Params are clamped against the manifest installed right now. An entry naming an effect this machine does not have is kept, not dropped (unlike a dangling asset reference): the effect is inert — an unregistered slug reaches no file at all — and dropping it would silently discard your tuning whenever a scene travels to a machine without the folder. Install the effect and the settings come back
Such an entry still takes its row in the layer list, marked Missing; selecting it says the effect is not installed here and its settings are kept
Plugins can drive params live: environment is patchable through scene.patch, so a custom effect's sliders are as scriptable as any built-in one. See the SDK reference
Trust
An effect is ordinary JavaScript running in the stage worker, not a sandboxed shader. It is not confined by contextIsolation, and the persona: protocol does not constrain what it can reach once loaded
The default answer is no trust. An effect somebody else wrote passes no review, no signing and no store; and no line inside a module is restricted to drawing — the same code that computes pixels can open a network request. So "this one only seems to add scanlines" is not a security conclusion, only the part you have read so far
Installing an effect is trusting its author the same way you trust anything you drop into ~/.laplace/persona. There is exactly one way to earn that trust: read the whole thing and understand it. If you cannot read it, do not install it
Persona contains mistakes — a throw costs one effect, never the stage. It does not contain malice
Trust No Custom Effect
This boundary is opened deliberately, for one capability the wire cannot serve: new visual maths. Everything else third-party stays out of process. The price is this page — run only what you wrote yourself, or what you have read line by line and understood
Last updated on September 3, 2026