00:00 / 00:00

Persona

Custom Effects

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 builder

The 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
uniformsoptionalUniform nodes keyed by manifest param name; the panel writes .value directly. A param with no matching key is simply not delivered
build(input, ctx)requiredReturn a vec4 node. input is your own render target — the frame so far, already sampleable
update(dt, elapsed)optionalPer-frame CPU state, in seconds. dt is clamped to 100 ms so a stall cannot teleport an animation
dispose()optionalCalled 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 in
  • ctx.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 overlay

After 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

StageWhat broke
loadThe module never imported (syntax error, no default export), or the factory threw or returned no module
buildbuild() threw during TSL codegen, or returned nothing
updateupdate() 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

Last updated on September 3, 2026

Tech otakus destroy the world