pixi-effects 0.48.2 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +53 -8
  3. package/ai/chat-template.html +8 -4
  4. package/ai/tools/check.mjs +6 -6
  5. package/dist/3d.cjs +967 -0
  6. package/dist/3d.cjs.map +1 -0
  7. package/dist/3d.d.cts +305 -0
  8. package/dist/3d.d.ts +305 -0
  9. package/dist/3d.js +960 -0
  10. package/dist/3d.js.map +1 -0
  11. package/dist/Canvas-BfZkAFJm.d.cts +12 -0
  12. package/dist/Canvas-BfZkAFJm.d.ts +12 -0
  13. package/dist/Composition-47U5U637.cjs +16 -0
  14. package/dist/{Composition-TJ5D7Z63.cjs.map → Composition-47U5U637.cjs.map} +1 -1
  15. package/dist/Composition-GVWWLFDL.js +7 -0
  16. package/dist/{Composition-5FKV2IBP.js.map → Composition-GVWWLFDL.js.map} +1 -1
  17. package/dist/Controller.d.cts +2 -2
  18. package/dist/Controller.d.ts +2 -2
  19. package/dist/{Movie-svZQF-9Z.d.cts → Movie-DogzDBuW.d.cts} +36 -5
  20. package/dist/{Movie--qSYKgAQ.d.ts → Movie-Y7iHg2V8.d.ts} +36 -5
  21. package/dist/{Spans-6A3VLHRH.js → Spans-3SEZSTQV.js} +6 -6
  22. package/dist/{Spans-6A3VLHRH.js.map → Spans-3SEZSTQV.js.map} +1 -1
  23. package/dist/{Spans-5ENZ3XGS.cjs → Spans-R426NVY3.cjs} +11 -11
  24. package/dist/{Spans-5ENZ3XGS.cjs.map → Spans-R426NVY3.cjs.map} +1 -1
  25. package/dist/{blendModes-V5XHC7U4.cjs → blendModes-2IDILRN6.cjs} +5 -5
  26. package/dist/{blendModes-V5XHC7U4.cjs.map → blendModes-2IDILRN6.cjs.map} +1 -1
  27. package/dist/{blendModes-535EMYGK.js → blendModes-4QH44GOL.js} +4 -4
  28. package/dist/{blendModes-535EMYGK.js.map → blendModes-4QH44GOL.js.map} +1 -1
  29. package/dist/{chunk-NGJXVJB7.cjs → chunk-3FCDRLW3.cjs} +5 -3
  30. package/dist/chunk-3FCDRLW3.cjs.map +1 -0
  31. package/dist/{chunk-JBDBICS2.js → chunk-C2HL5236.js} +4 -4
  32. package/dist/{chunk-JBDBICS2.js.map → chunk-C2HL5236.js.map} +1 -1
  33. package/dist/{chunk-WYR2AU4W.cjs → chunk-GXGIISFS.cjs} +5 -5
  34. package/dist/{chunk-WYR2AU4W.cjs.map → chunk-GXGIISFS.cjs.map} +1 -1
  35. package/dist/{chunk-BFRWSV3R.js → chunk-J4ZP76IL.js} +5 -4
  36. package/dist/chunk-J4ZP76IL.js.map +1 -0
  37. package/dist/{chunk-CCPJD76D.cjs → chunk-O2MD3L4J.cjs} +93 -93
  38. package/dist/chunk-O2MD3L4J.cjs.map +1 -0
  39. package/dist/{chunk-IVKI6CPH.js → chunk-SSVHAF3Q.js} +483 -20
  40. package/dist/chunk-SSVHAF3Q.js.map +1 -0
  41. package/dist/{chunk-6D4W2KZQ.js → chunk-TXNQLMRT.js} +6 -6
  42. package/dist/chunk-TXNQLMRT.js.map +1 -0
  43. package/dist/{chunk-RCKLUCB7.js → chunk-UTUUOQ47.js} +46 -20
  44. package/dist/chunk-UTUUOQ47.js.map +1 -0
  45. package/dist/{chunk-Z3DM4ETD.cjs → chunk-VVUSZVP7.cjs} +507 -42
  46. package/dist/chunk-VVUSZVP7.cjs.map +1 -0
  47. package/dist/{chunk-45ALIMAF.cjs → chunk-YOKRPAKA.cjs} +89 -63
  48. package/dist/chunk-YOKRPAKA.cjs.map +1 -0
  49. package/dist/index.cjs +35 -35
  50. package/dist/index.d.cts +3 -3
  51. package/dist/index.d.ts +3 -3
  52. package/dist/index.js +5 -5
  53. package/dist/loader.css +1 -1
  54. package/dist/{music-P4JRT73P.js → music-ATX22VAT.js} +3 -3
  55. package/dist/{music-P4JRT73P.js.map → music-ATX22VAT.js.map} +1 -1
  56. package/dist/{music-XQEPBIDT.cjs → music-OFVM3PYH.cjs} +3 -3
  57. package/dist/{music-XQEPBIDT.cjs.map → music-OFVM3PYH.cjs.map} +1 -1
  58. package/dist/present.cjs +5 -5
  59. package/dist/present.d.cts +2 -2
  60. package/dist/present.d.ts +2 -2
  61. package/dist/present.js +5 -5
  62. package/dist/three.cjs +13 -10
  63. package/dist/three.cjs.map +1 -1
  64. package/dist/three.d.cts +4 -12
  65. package/dist/three.d.ts +4 -12
  66. package/dist/three.js +8 -5
  67. package/dist/three.js.map +1 -1
  68. package/dist/{types-CTSrPvY3.d.cts → types-BZngm4nT.d.cts} +48 -5
  69. package/dist/{types-CTSrPvY3.d.ts → types-BZngm4nT.d.ts} +48 -5
  70. package/docs/api.md +45 -2
  71. package/docs/dsl.md +115 -1
  72. package/llms-full.txt +307 -6
  73. package/package.json +19 -3
  74. package/skills/pixi-effects/SKILL.md +4 -2
  75. package/skills/pixi-effects/reference/cheatsheet.md +4 -0
  76. package/skills/pixi-effects/reference/pitfalls.md +20 -0
  77. package/skills/pixi-effects/reference/recipes.md +119 -1
  78. package/skills/pixi-effects/template.html +14 -8
  79. package/dist/Composition-5FKV2IBP.js +0 -7
  80. package/dist/Composition-TJ5D7Z63.cjs +0 -16
  81. package/dist/chunk-45ALIMAF.cjs.map +0 -1
  82. package/dist/chunk-6D4W2KZQ.js.map +0 -1
  83. package/dist/chunk-BFRWSV3R.js.map +0 -1
  84. package/dist/chunk-CCPJD76D.cjs.map +0 -1
  85. package/dist/chunk-IVKI6CPH.js.map +0 -1
  86. package/dist/chunk-NGJXVJB7.cjs.map +0 -1
  87. package/dist/chunk-RCKLUCB7.js.map +0 -1
  88. package/dist/chunk-Z3DM4ETD.cjs.map +0 -1
package/llms-full.txt CHANGED
@@ -6,7 +6,7 @@ Concatenation of the skill, cheatsheet, pitfalls, recipes and the DSL / API / me
6
6
 
7
7
  <!-- ===== skills/pixi-effects/SKILL.md ===== -->
8
8
 
9
- > Written for pixi-effects 0.48.2. Update this skill with `npx skills update`; the newest docs are at https://yjmtmtk.github.io/pixi-effects/
9
+ > Written for pixi-effects 0.50.0. Update this skill with `npx skills update`; the newest docs are at https://yjmtmtk.github.io/pixi-effects/
10
10
 
11
11
  # pixi-effects — write a video as data
12
12
 
@@ -63,7 +63,9 @@ The same command checks a file you MADE before it goes somewhere with rules (a s
63
63
  - **Time:** `speed` (slow motion, backward) and `time` (a curve of the layer's own seconds: freeze, rewind, ramp) on video, audio-file and composition layers; put what you want to rewind in a composition. Children of such a composition are in ITS seconds. `ease: 'cubic-bezier(.4, 0, .2, 1)'` is the CSS curve. See the recipe `rewind-and-replay` and pitfalls 69–73.
64
64
  - **Built in:** `animateText()` (per-letter / per-word / per-line entrances and exits in one call: `...animateText('Title', style, { x, y, align: 'center', at: 0.5, duration: 5, in: 'rise', out: 'fade', stagger: { each: 0.05 } })`), `splitText()` / `measureText()` (per-letter / per-word layers, exact positions, by hand), `shape: 'arc'` (progress rings, pie / donut slices: animate `endAngle`), `fillGradient` (linear/radial, alpha stops → vignettes), `{value}` counters, `repeat`/`yoyo`, `orbit()`, seeded randomness (`rand(seed)` / `noise(x, seed)` in expressions, `random(seed)` in JS, never `Math.random()`) `wiggle()` (a seeded shake as keyframes; on a camera put it on `offsetX` / `offsetY` so it rides on the move) `cameraPath()` (a camera flight through `[x, y, z]` points as keyframes; layers it passes: `hideBehindCamera: true`) depth of field (camera `focus` + `aperture`: `{ type: 'camera', initial: { focus: 'title' } }` blurs the other threeD layers by depth; a rack focus is a keyframe on `focus`) `music` (a tune with no file: `{ type: 'audio', music: { bpm, tracks: [{ inst: 'keys', notes: 'Cmaj7:4 Am7:4' }, { inst: 'bass', notes: 'c2:2 g1:2' }], drums: { from: 8, kick: 'x...x...' } } }`: notes and chords as text, counted in beats; instruments `keys pluck pad bass sub lead bell musicbox`; `musicEnvelope(music)` feeds `react()`), `audioEnvelope()` / `bpmEnvelope()` + `react()` (visuals that follow music: `const env = await audioEnvelope('music.mp3', { frameRate })` before `movie.init`, then `keyframes: react(env, { duration, props: { scale: { base: 1, amount: 0.4, band: 'bass', release: 0.2 } } })`; `beats: true` pulses on the beats; `bpmEnvelope(120, …)` when there is no file), `deck()` + `Presenter` from `pixi-effects/present` (a talk as one movie: pages, steps, stops that pause playback, arrow keys / click / swipe to move on: `...deck({ transition: { kind: 'slide', duration: 0.6 }, pages: [{ name, duration, stops, sequences }] })`, `new Presenter(movie, { canvas })` from `pixi-effects/presenter`; G = page overview, P = presenter view with notes and a timer; `pixi-effects render talk.html -o talk.pdf` = a PDF of the pages), `particles()` (bursts, snow, confetti, sparks as seeded layers: `...particles({ count: 90, at: 2, life: [1, 1.8], area: { x: 640, y: 330 }, speed: [120, 420], gravity: 240, fade: { out: 0.6 } })`), `followPath()` (a layer along an SVG path: `keyframes: followPath({ d, duration, orient: true })`), path morphing (`{ shape: 'path', d, morphTo, initial/keyframes: morph 0 → 1 }`) and `stagger()` (delays for a wave of layers: `stagger(letters, { each: 0.05, from: 'center' })`, or `stagger(n, …)` for numbers; GSAP-style `each` / `amount` / `from` / `grid` / `ease`). **Not in the DSL (hand-roll with a JS loop):** other per-frame curves (waves, spirals: sample them into short linear keyframes). (Draw-on strokes `trimEnd`, text that changes over time `set: { text }` / `visibleChars`, shake `wiggle()` and particles are built in.)
65
65
  - **Draw it yourself (`canvas` layer):** when nothing in the library draws what you need (an afterimage, rings, a brush stroke, a pixel sprite, a chart), `{ type: 'canvas', width, height, draw: (ctx, t, info) => { … } }`: your function of the time draws into the layer's own cleared 2D canvas and it is a normal layer. Pure function of `t` and `info.params` (`info.random()` is seeded; no `Math.random` / `Date.now`); a simulation is computed in `setup` and read by `info.frame`. `info.time` is the movie's clock (several layers share it; never add `at` by hand), `info.canvas(w, h)` makes sprites and masks in `setup`, data shared by several canvas layers lives in module scope, and `import { ease, noise, noise2, random, samplePath } from 'pixi-effects'` gives the library's easing, seeded noise and a path walked by length. `check` prints a `canvas` line (deterministic, slowest draw in ms) and does NOT see text drawn in a canvas. `help canvas`, recipes `canvas-trail`, `canvas-life`, `canvas-text-mask`, `canvas-text-on-path`, `canvas-bars`.
66
+ - **PixiJS 2D by code (`scene2d` layer):** when you want PixiJS objects (`Graphics`, `Sprite`, `AnimatedSprite`, `Text`, many pieces that each move their own way) rather than a 2D context, `{ type: 'scene2d', setup({ root }) { root.addChild(...); return { objects } }, update(t, { objects }) { … } }` (core, nothing to import; recipe `scene2d-basics`): `update` SETS every object from `t`, keyframes reach `scene2d.<object>.<path>` (raw PixiJS values), `movie.pick` names the object under a point (its `label`).
66
67
  - **Shader layer and warp:** `{ type: 'shader', fragment: 'void mainImage(out vec4 fragColor, in vec2 fragCoord) { … }', uniforms: { speed: 1, tint: '#ff8040' } }` draws a Shadertoy-style formula (GLSL ES 3.00; `iTime` `iResolution` `iFrame` are declared; keyframes move `'uniforms.speed'`; opaque by default; it cannot read the layers behind it; a compile error comes back in your own line numbers). `filters: [{ type: 'warp', kind: 'wave' | 'haze', strength: 6 }]` bends a layer's picture like water or heat.
68
+ - **A real 3D scene** (meshes, a glTF model, PBR, lights, shadows) is a `scene3d` layer (`pixi-effects/3d`, PixiJS 3D; `register3D()` before `init`; recipe `scene3d-basics`): `setup` builds it with `@pixi/3d`, `update(t)` sets it from the time, keyframes reach `scene3d.<object>.<path>`; masks, filters, `alpha` and `blendMode` work on it; `followCamera` / `followLights` / `followFog` put it in the composition's own camera and lights (recipe `scene3d-in-space`), `loadModel` / `fitCamera` load and frame a glTF, `layerTexture(ctx, 'screen')` shows a 2D layer on a mesh, `movie.pick` names what is clicked. A `three` layer (`pixi-effects/three`) is for three.js's own ecosystem.
67
69
  - **Light, shadow, fog (2.5D):** a `{ type: 'light', kind: 'ambient', initial: { intensity: 0.2 } }` plus a `spot` / `point` / `parallel` light (`castsShadows: true` on the light AND on the `threeD` layers that cast) shades every `threeD` layer by angle and distance; `lit: false` opts a layer out; camera `fogNear` / `fogFar` / `fogColor` fade what is far. What no light reaches is black (add the ambient light); layers are drawn in distance order (no depth buffer). `check --at` prints how lit each layer is.
68
70
  - **Blend modes:** `blendMode` takes 18 CSS names (`add` `screen` `multiply` `overlay` `soft-light` `hard-light` `color-dodge` `color-burn` `darken` `lighten` `difference` `exclusion` `hue` `saturation` `color` `luminosity` `linear-burn`, hyphenated) on any layer: glows and light leaks (`add`, `color-dodge`), colour casts and contrast (`soft-light`, `overlay`), vignettes (`multiply`). It blends with everything below the layer; on a composition each child blends (as one picture: `threeD: true` or a filter); 10+ of the new modes on screen at once warn; it works with every mask.
69
71
  - **One `Movie` per page.** A sound effect needs no `duration` (it lasts as long as its clip). Seeking is safe: counters, colours and growing shapes are right after any jump.
@@ -76,7 +78,7 @@ Centred text must be positioned by `anchorX/anchorY: 0.5`; estimate text width a
76
78
 
77
79
  - For an AI in a chat with no shell, the short version is https://raw.githubusercontent.com/yjmtmtk/pixi-effects/main/ai/CHAT.md (it copies https://raw.githubusercontent.com/yjmtmtk/pixi-effects/main/ai/chat-template.html: one self-contained page that loads from a single CDN and shows warnings in a red box).
78
80
  - `reference/cheatsheet.md` — every layer type, prop, default and rule on one page.
79
- - `reference/recipes.md` — tested building blocks: slam type, lower-third, marquee, count-up, bar chart, 2.5D title, camera orbit, slideshow with transitions + music, looping, particles, gradients/vignette, three.js metal.
81
+ - `reference/recipes.md` — tested building blocks: slam type, lower-third, marquee, count-up, bar chart, 2.5D title, camera orbit, slideshow with transitions + music, looping, particles, gradients/vignette, a 3D scene (scene3d), three.js metal.
80
82
  - `reference/pitfalls.md` — the full list of real mistakes, with status.
81
83
  - `reference/direction.md` — how to direct a film (any renderer): brief, style guide, shot list, assets rule, sound on the beat grid, review protocol, formats, gates.
82
84
  - `npx pixi-effects check` — the one-command review (see Workflow step 5).
@@ -204,12 +206,16 @@ Style (`initial` / keyframes): `fillColor fillAlpha strokeColor strokeAlpha stro
204
206
 
205
207
  **canvas** — `{ type: 'canvas', width, height, draw: (ctx, t, info) => { … }, setup?, params?, seed?, resolution?, smoothing? }`: a picture drawn by YOUR function of the time into the layer's own cleared canvas (`t` = the layer's seconds; `info` = `{ frame, duration, progress, width, height, resolution, frameRate, params, state, random, start, time, canvas }`: `time` is the MOVIE's seconds (`start + t`; share it between layers instead of adding `at` by hand), `canvas(w, h)` an offscreen canvas for sprites and masks made in `setup`). It is a normal layer (initial, keyframes, mask, filters, blendMode …); `params` are numbers keyframes animate as `'params.x'`; no `Math.random` / `Date.now` (`info.random()` is seeded); a simulation is computed in `setup` and read by `info.frame`; it cannot see what is below it. Data several canvas layers share lives in module scope; `import { ease, noise, noise2, random, rand, samplePath } from 'pixi-effects'` for easing by keyframe name, seeded noise and a path walked by length (`samplePath(d).at(u)` / `.trim(a, b)`). `check` draws it twice per moment (one draw thrown away first) and reports a picture that differs, with how much; it prints a `canvas` line when all pass and does NOT see text drawn in a canvas.
206
208
 
209
+ **scene2d** (PixiJS 2D built by code, core: nothing to import) — `{ type: 'scene2d', name, width?, height?, setup({ root, width, height, texture }) { const g = new Graphics({ label: 'dot' }).circle(0, 0, 30).fill('#f40'); root.addChild(g); return { objects: { dot: g } } }, update?(t, { objects }) { objects.dot.x = 100 + 200 * t }, dispose?, reuse? }`: your PixiJS objects as one layer; `update` SETS everything from `t`; keyframes `'scene2d.dot.alpha'` (raw PixiJS values: `rotation` radians); an `AnimatedSprite` left playing is posed from the time; `texture('photo')` = a movie asset; `movie.pick` → `kind: 'scene2d'`, `hit.scene2d.object` (the label), `hit.scene2d.grab().move(x, y)`. Recipe `scene2d-basics`.
210
+
207
211
  **shader** — `{ type: 'shader', fragment: 'void mainImage(out vec4 fragColor, in vec2 fragCoord) { … }', uniforms: { speed: 1, tint: '#ff8040', pos: [0.5, 0.5] }, width?, height?, resolution?: 0.5, transparent?: false }` — a Shadertoy fragment shader drawn into a layer (GLSL ES 3.00): `iResolution` `iTime` (the layer's own seconds) `iFrame` and your uniforms are declared for you; keyframes move uniforms as `'uniforms.speed'` and components as `'uniforms.tint.0'`; opaque unless `transparent: true`; it cannot read what is behind it (no `iChannel` / `iMouse` / `iDate`); no `while` or non-constant `for`; a compile error is reported in your own line numbers and the layer is a checkerboard.
208
212
 
209
213
  **null + `parent`** — `{ type: 'null', name: 'rig', initial: { x, y }, keyframes: [...] }` draws nothing; layers with `parent: 'rig'` are drawn inside it and follow its `x y rotation scale skew alpha pivotX pivotY`. Their `x / y` are measured **from the null's origin** (null at (640,360), child `x: 200` → drawn at 840), so rotating the null makes the child circle it. Nulls chain (`parent` on a null). Children are drawn at the null's place in the stack, together. Only `null` layers can be parents; not for `threeD` layers; a mask follows its layer.
210
214
 
211
215
  **camera** (2.5D) — props in `initial`/keyframes: `x y z lookAtX lookAtY lookAtZ fov focus aperture` (defaults: centred, looking at the z = 0 plane, `fov` 40, `z` auto = `(H/2)/tan(fov/2)` ≈ 989 at 720p). **Depth of field:** `focus` = the sharp plane (a `threeD` layer's name = its first z, or a z number), `aperture` = the lens diameter in px (30 default, 0 off); writing either blurs the other `threeD` layers by their distance from the focus (a rack focus is a keyframe on `focus`; recipe `rack-focus`; `inspect` reports `depthBlur`, and `check --at 2.4` prints each layer's blur). A dolly with no typed distance: leave `z` automatic and animate `offsetZ`. Never on the camera object itself. An orbit is `orbit()`; by hand: `x = cx + R·sin θ`, `z = R·cos θ`, `R = (H/2)/tan(fov/2)`, `lookAt` = the centre at `z = 0`.
212
216
 
217
+ **scene3d** (optional entry `pixi-effects/3d`, PixiJS 3D: the main 3D engine) — `{ type: 'scene3d', name, width?, height?, view?: { toneMapping: 'aces', environment, shadowQuality, passes }, setup({ view, root, camera }) { root.addChild(mesh, light); camera.position.set(0, 1.5, 5); camera.lookAt({ x: 0, y: 0, z: 0 }); return { objects: { cube } } }, update?(t, { objects }), dispose? }`; drive with `'scene3d.cube.rotation.y'` keyframes. `import { register3D } from 'pixi-effects/3d'` and call `register3D()` **before** `init`; meshes, models and lights come from `@pixi/3d` (`Mesh3D`, `Model3D` + `Assets.load('model.glb')`, `CubeGeometry`, `PlaneGeometry`, `Material3D`, `DirectionalLight` …). Drawn inside PixiJS: masks, filters, `alpha`, `blendMode` work as on any layer (`tint` does not). A clip: `model.player.getState(name).gotoAndStop(t)` in `update`. **In the composition's space:** `followCamera: true` (the composition's `camera` is the view's: the scene is in composition px with y flipped, (x, y, z) → (x, −y, z)), `followLights: true` (the `light` layers light the scene; parallel/spot `castsShadows` cast, a point light cannot; PixiJS 3D lights in linear light, so 3D is brighter in the shade than a card), `followFog: true`. **glTF:** `import { loadModel, fitCamera } from 'pixi-effects/3d'`: `const m = await loadModel(url, { animation: 'Run' | 0 | false, speed, offset, loop })` in `setup` (`root.addChild(m.object)`; `m.fit(px)`; `m.clips`), `update: (t) => m.pose(t)`, `fitCamera(ctx, m.object)`. **A 2D layer on a mesh:** `const screen = await layerTexture(ctx, 'screen')`, `screen.material` on a mesh (`screen` is a composition listed BEFORE the scene3d layer). `movie.pick` names the object (`hit.scene3d.object`, `hit.scene3d.grab('floor').move(x, y)`). A named layer keeps its scene across `setVars` (`reuse`). Recipes `scene3d-basics`, `scene3d-in-space`.
218
+
213
219
  **three** (optional entry `pixi-effects/three`) — `three({ type:'three', width, height, setup(ctx) { …; return { objects: { knot } } }, update?, dispose? })`; drive with `'three.knot.rotation.y'` keyframes. Call `registerThree()` before `init`. **In the composition's space:** `followCamera: true` (the layer's PerspectiveCamera is the composition's `camera`; the scene is in composition px with y flipped: the composition's `(x, y, z)` is `(x, -y, z)`; give the layer the composition's size), `followLights: true` (the `light` layers become three lights) and `followFog: true` (the camera's fog becomes the scene's `Fog`: close to, not equal to, the card fog); a light's `castsShadows` casts in three (mark your meshes `castShadow` / `receiveShadow`); `ctx.space` has the raw numbers. **glTF:** `import { threeGltf, threeFrameCamera } from 'pixi-effects/three'`: `const m = await threeGltf(url, { animation: 'Run' | 0 | false, speed, offset, loop })` in `setup` (`ctx.scene.add(m.object)`; `m.fit(px)` scales the longest side to px; `m.clips` names the clips), `update: (t) => m.pose(t)` (a function of t: never `mixer.update(dt)`), `threeFrameCamera(ctx.camera, m.object)` frames the whole model from a three-quarter view. Needs `"three/addons/"` in the import map. Recipe `gltf-in-space`. **A 2D layer on a mesh** (a phone screen, a television): `const screen = await threeLayerTexture(ctx, 'screen')` in `setup`, `screen.material` on a mesh; `screen` is a `composition` listed BEFORE the three layer (recipe `three-screen`).
214
220
 
215
221
  ## Camera flight and a camera shake
@@ -466,12 +472,32 @@ A good habit that catches most of these: after `init`, read `window.__logs` (see
466
472
  118. **`check` does not see text drawn in a canvas.** The layout test (cut-off, off-canvas, overlap) reads the text layers; a label or a caption you draw with `fillText` can run off the edge or collide with another without a word, and "no cut-off text" only speaks for the text layers. Look at the sheet and at `--at … --crop`, and keep a margin.
467
473
  119. **`bitrate` is a ceiling, not a target: a quiet or mostly black picture is written far below it, and no setting changes that.** Asking for `'150M'` gave 105 to 151 Mbps on grainy dark pictures and 13 to 18 Mbps on clean ones (a platform that wants at least 35 Mbps for 4K then rejects the file). The encoder spends bits on what changes: a constant bitrate mode reaches the encoder (it was measured, with H.264, HEVC and VP9) and still writes 0.1 Mbps for a black frame with a circle, because no encoder pads. So read the real rate of the file (`render` prints it and says when it is far under what you asked; `npx pixi-effects probe file.mp4` too) and, when a minimum is needed, raise it by what the picture really has: fine film grain (a seeded noise of about ±2/255 in the darks, which also hides banding in dark gradients) costs bits and looks right. Do not stretch it with an invisible pattern.
468
474
 
475
+ ## 3D scenes (`scene3d`, PixiJS 3D)
476
+
477
+ 120. **Import `pixi-effects/3d` before `movie.init` and call `register3D()`.** PixiJS loads the 3D drawing code while its renderer starts; a scene3d layer built on a renderer that started before `@pixi/3d` was imported stops the build with `PixiJS 3D was loaded after the renderer started`. The import map needs `pixi-effects/3d`, `@pixi/3d` and `@pixi/3d/extras`, and `pixi.js` 8.23.0 or later. *(warns)*
478
+ 121. **`update(t)` sets everything from `t`.** `angle += 0.02` per call, a velocity carried from the last call, `Math.random()` or a clock draw a different picture in playback, a seek and an export; `check` draws the layer in two orders and names it. A model's clip: `model.player.getState(name).gotoAndStop(t % clip.animation.duration)` (a clip left playing with `autoPlay` is played on the layer's time by the layer). *(check catches it)*
479
+ 122. **Particle systems (`ParticleSystem3D`) and a `MotionBlurEffect` are not a function of the time.** Particle births add up from frame to frame (a seek does not reproduce them) and motion blur reads the camera of the frame drawn before. Keep particles in 2D (`particles()`) or drive a `ParticleContainer3D` from `t`; leave motion blur out of a scene3d layer. *(check catches it)*
480
+ 123. **An inline shape mask needs a `fillColor`.** A circle or rect with no fill masks out everything: the layer disappears without a word. Give the mask shape `initial: { fillColor: '#fff' }`. (Any layer, not only scene3d.)
481
+ 124. **`tint` does nothing on a scene3d layer** (PixiJS 3D draws its materials' colours): colour the materials, or put a filter on the layer. `alpha` and `blendMode` do work (the layer applies them). *(warns)*
482
+ 125. **PixiJS 3D's units are not pixels (unless the layer has `followCamera`).** The scene has its own units (a glTF is in metres: a fox is about 1.3 units long) and its own camera (`fov` in degrees, default 60, the camera looks down its `-z`); `width` / `height` only size the layer's rectangle. Place the camera with `camera.position.set(…)` and `camera.lookAt({ x, y, z })`; read a model's size with `model.getLocalBounds()` before scaling it.
483
+ 126. **A light is aimed by its rotation, a plane lies in XY.** `DirectionalLight` / `SpotLight` look down their local `-z` (`sun.rotation.set(-0.9, 0.6, 0)`, or `lookAt`); `PlaneGeometry` faces `+z`, so a floor is `rotation: { x: -Math.PI / 2, y: 0, z: 0 }`. A view's `toneMapping` is `'none'` by default: strong lights clip, use `view: { toneMapping: 'aces' }`.
484
+ 127. **Under `followCamera` the scene is in composition PIXELS, y flipped.** A composition point (x, y, z) is `(x, -y, z)` in the scene: put a model at `position.set(640, -400, 0)` for (640, 400), and size it in pixels (`model.fit(480)`), not in metres. Give the layer the composition's size (another shape warns).
485
+ 128. **`followLights` matches where light falls, not how bright the shade is.** PixiJS 3D lights in linear light, the composition's cards shade the sRGB values: a 3D surface under ambient 0.3 is 0.58 × its colour, a card 0.3 ×. A point light casts no shadow in PixiJS 3D (use a spot or a parallel light); `shadowDarkness` / `shadowDiffusion` are not mirrored. *(warns)*
486
+ 129. **A 2D layer for `layerTexture` is a `composition` listed BEFORE the scene3d layer**, in the same composition. It is no longer drawn on the stage (it shows only on the mesh). *(warns with the names it found)*
487
+ 130. **After a rebuild (`setVars`) the scene is the same but the view is a new one.** Its settings (background, environment, exposure, tone mapping, effects, the active camera) move with the scene; read the view from `ctx.view` (or `objects.view`), not from a variable kept in `setup`.
488
+
489
+ ## PixiJS 2D by code (`scene2d`)
490
+
491
+ 131. **`update` SETS each object from `t`; it never adds to it.** `g.x += 2` per call, a velocity kept between calls, or a particle list that grows each frame draws a different picture in a seek and an export than in playback (`check` names the layer). Compute where each object is at `t` (`x = x0 + v * t`, `y = y0 + g * t * t / 2`); draw random numbers once with `random(seed)` (before the layer or in `setup`), never `Math.random` in `update`.
492
+ 132. **Keyframe paths into a scene2d layer are raw PixiJS values.** `'scene2d.dot.rotation'` is in radians (the layer's own `rotation` keyframe is degrees); `'scene2d.dot.angle'` is degrees. Scale is `'scene2d.dot.scale.x'` and `'…scale.y'` (two paths), colour `'scene2d.dot.tint'`.
493
+ 133. **Name what `movie.pick` should name.** A node made without a `label` carries its kind (`'Graphics'`), which counts as no name: the hit is named by its nearest labelled ancestor, or `''`. Give the objects you want to tell apart a `label`, or label a `Container` to name a group. Empty space in the layer's box is not the layer (the point falls through).
494
+
469
495
 
470
496
  <!-- ===== skills/pixi-effects/reference/recipes.md ===== -->
471
497
 
472
498
  # pixi-effects recipes
473
499
 
474
- Copy, adapt, run. Every block marked `@recipe` is a function body that **returns the composition's `sequences`** (or `{ sequences, transitions, duration }`); the repo's tests build each one and fail on any warning, so they stay correct. Blocks marked `@docs-only` need a browser (canvas / three.js) and are not executed by the tests.
500
+ Copy, adapt, run. Every block marked `@recipe` is a function body that **returns the composition's `sequences`** (or `{ sequences, transitions, duration }`); the repo's tests build each one and fail on any warning, so they stay correct. Blocks marked `@docs-only` need a browser (canvas / 3D / three.js) and are not executed by the tests.
475
501
 
476
502
  Assumed canvas: 1280×720 @ 30 fps. `kenBurns`, `withFade` and `orbit` come from `pixi-effects`.
477
503
 
@@ -1763,6 +1789,124 @@ function placeholderPhoto(name, hueA, hueB, w = 1920, h = 1080) {
1763
1789
 
1764
1790
  ---
1765
1791
 
1792
+ ## PixiJS 2D built by code: a burst of confetti and a ring (`scene2d`)
1793
+
1794
+ When the data cannot say it (160 pieces that each fly their own way), build PixiJS display objects in `setup` and set them from the time in `update`. The pieces' angles and speeds come from `random(seed)`, a repeatable stream, computed once before the layer: every load, every seek and the export draw the same burst. A keyframe reaches an object of the scene as `scene2d.<name>.<path>` (raw PixiJS values: `rotation` in radians). `movie.pick` names the object under a point (its `label`).
1795
+
1796
+ ```js
1797
+ // @docs-only scene2d-basics
1798
+ // import { random } from 'pixi-effects'; import { Graphics } from 'pixi.js';
1799
+ // composition 1280 x 720, 4 s.
1800
+ const rand = random(7); // the same numbers at every load
1801
+ const COLORS = ['#ffd166', '#ef476f', '#06d6a0', '#118ab2'];
1802
+ const bits = Array.from({ length: 160 }, () => ({ angle: rand() * Math.PI * 2, speed: 260 + rand() * 520, spin: (rand() - 0.5) * 14, size: 10 + rand() * 16, color: COLORS[Math.floor(rand() * 4)] }));
1803
+ return [
1804
+ { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '#101425' } },
1805
+ { type: 'scene2d', name: 'burst',
1806
+ setup: ({ root, width, height }) => {
1807
+ const pieces = bits.map((b) => root.addChild(new Graphics().rect(-b.size / 2, -b.size / 4, b.size, b.size / 2).fill(b.color)));
1808
+ const ring = new Graphics({ label: 'ring' }).circle(0, 0, 120).stroke({ width: 8, color: '#ffffff' });
1809
+ ring.position.set(width / 2, height / 2);
1810
+ root.addChild(ring);
1811
+ return { objects: { ring, pieces } }; // keyframes reach them as scene2d.ring.…
1812
+ },
1813
+ update: (t, { objects, width, height }) => {
1814
+ objects.pieces.forEach((g, i) => { // where each piece is at t: set, never added to
1815
+ const b = bits[i], d = b.speed * t;
1816
+ g.position.set(width / 2 + Math.cos(b.angle) * d, height / 2 + Math.sin(b.angle) * d + 420 * t * t);
1817
+ g.rotation = b.spin * t;
1818
+ });
1819
+ },
1820
+ keyframes: [
1821
+ { at: 0, from: { 'scene2d.ring.scale.x': 0.2, 'scene2d.ring.scale.y': 0.2 }, to: { 'scene2d.ring.scale.x': 2.4, 'scene2d.ring.scale.y': 2.4 }, duration: 1, ease: 'expo.out' },
1822
+ { at: 0.3, to: { 'scene2d.ring.alpha': 0 }, duration: 0.9 },
1823
+ ] },
1824
+ { type: 'text', text: 'HAPPY NEW YEAR', style: { fontSize: 84, fontWeight: '900', fill: '#ffffff', letterSpacing: 6 },
1825
+ initial: { x: 'GW/2', y: 'GH/2', anchorX: 0.5, anchorY: 0.5 },
1826
+ keyframes: [{ at: 0.5, from: { alpha: 0, scale: 0.8 }, duration: 0.6, ease: 'back.out' }] },
1827
+ ];
1828
+ ```
1829
+
1830
+ The layer is a layer like any other (`at`, `alpha`, `mask`, `filters`, `blendMode`, `threeD`). An `AnimatedSprite` left playing is posed from the layer's time; `ctx.texture(name)` gives a movie asset's texture for a `Sprite`.
1831
+
1832
+ ---
1833
+
1834
+ ## A 3D scene with a model, a light and a shadow, under a title (`scene3d`)
1835
+
1836
+ A real 3D scene as one layer, drawn by PixiJS 3D inside PixiJS itself: a floor that receives a shadow, a glTF model whose clip is posed from the time, a cube moved by a keyframe, a sun that casts. The layer takes a mask like any layer: a circle wipe reveals it, and a 2D title sits over it. Everything in `update` is set from `t` (a function of the time: playback, a seek and an export agree). `loadModel` names the clips (`model.clips`), measures the model (`model.size()`), fits it (`model.fit(n)`: the longest side n units) and poses it from the time (`model.pose(t)`); `fitCamera(ctx, model.object)` puts the camera where the whole model fits.
1837
+
1838
+ ```js
1839
+ // @docs-only scene3d-basics
1840
+ // import { register3D, loadModel } from 'pixi-effects/3d'; register3D(); (before movie.init)
1841
+ // import { Mesh3D, PlaneGeometry, CubeGeometry, Material3D, DirectionalLight } from '@pixi/3d';
1842
+ // composition 1280 x 720, 5 s. 'model.glb' sits next to the page.
1843
+ let model;
1844
+ return [
1845
+ { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '#0d1220' } },
1846
+ { type: 'scene3d', name: 'stage',
1847
+ view: { toneMapping: 'aces', shadowQuality: 'medium', environment: { skyColor: 0xbcd4f0, groundColor: 0x3a3226, intensity: 0.6 } },
1848
+ setup: async ({ root, camera }) => {
1849
+ const floor = new Mesh3D({ geometry: new PlaneGeometry({ width: 40, height: 40 }), material: new Material3D({ baseColor: 0x2a3142, roughness: 0.9 }),
1850
+ rotation: { x: -Math.PI / 2, y: 0, z: 0 }, receiveShadow: true }); // a plane lies in XY: turned flat, it is a floor
1851
+ model = await loadModel('model.glb'); // its first clip, posed from t below
1852
+ model.fit(1.6); // the longest side 1.6 units, whatever the file's own size
1853
+ model.object.position.set(0, 0.8, 0); // its centre: standing on the floor
1854
+ const cube = new Mesh3D({ geometry: new CubeGeometry({ width: 0.8, height: 0.8, depth: 0.8 }), material: new Material3D({ baseColor: 0xff7a1a, roughness: 0.4 }),
1855
+ position: { x: -2.2, y: 0.4, z: 0 }, castShadow: true });
1856
+ const sun = new DirectionalLight({ intensity: 3, castShadow: true });
1857
+ sun.rotation.set(-0.9, 0.6, 0); // lights look down their -z: aim by rotation
1858
+ root.addChild(floor, model.object, cube, sun);
1859
+ camera.position.set(0, 2.2, 6);
1860
+ camera.lookAt({ x: 0, y: 0.6, z: 0 });
1861
+ return { objects: { cube } }; // keyframes reach it as scene3d.cube.…
1862
+ },
1863
+ update: (t, { objects }) => {
1864
+ model.pose(t); // the clip at the layer's time
1865
+ objects.cube.rotation.y = t * 1.5;
1866
+ },
1867
+ keyframes: [{ at: 1, to: { 'scene3d.cube.position.x': 2.2 }, duration: 2.5, ease: 'sine.inOut' }],
1868
+ mask: { type: 'shape', shape: 'circle', radius: 1, initial: { x: 'GW/2', y: 'GH/2', fillColor: '#ffffff' }, // a mask shape needs a fill
1869
+ keyframes: [{ at: 0, to: { radius: 760 }, duration: 1.2, ease: 'power2.inOut' }] } }, // a circle wipe reveals the scene
1870
+ { type: 'text', text: 'PIXIJS 3D', style: { fontSize: 72, fontWeight: '900', fill: '#ffffff', letterSpacing: 8 },
1871
+ initial: { x: 'GW/2', y: 110, anchorX: 0.5, anchorY: 0.5 },
1872
+ keyframes: [{ at: 0.8, from: { alpha: 0, y: '+=20' }, duration: 0.6, ease: 'power2.out' }] },
1873
+ ];
1874
+ ```
1875
+
1876
+ Opacity, blend modes and filters work on the layer as on any layer (`alpha` fades the whole scene). It can live in the composition's own camera and lights (`followCamera`, `followLights`: recipe `scene3d-in-space`), be clicked into (`movie.pick` names the object), and show a 2D layer on a mesh (`layerTexture`).
1877
+
1878
+ ---
1879
+
1880
+ ## A model that runs through the composition's space, past a title (`scene3d`, `followCamera`, `followLights`)
1881
+
1882
+ The scene3d layer lives in the composition's space: its camera IS the composition's `camera` layer and the composition's `light` layers light it, so one camera move carries a flat text card and a 3D model together. The scene is in composition pixels with y flipped: a composition point (x, y, z) is (x, −y, z) in the scene; `fit(px)` sizes a model in metres to pixels. PixiJS 3D lights in linear light, so a 3D surface is brighter in the shade than a flat card under the same light: pick the intensities by looking.
1883
+
1884
+ ```js
1885
+ // @docs-only scene3d-in-space
1886
+ // import { register3D, loadModel } from 'pixi-effects/3d'; register3D(); (before movie.init)
1887
+ // composition 1280 x 720, 4 s. 'Fox.glb' sits next to the page; the model faces +z in the file: rotation.y = PI / 2 runs it toward +x.
1888
+ let fox;
1889
+ return [
1890
+ { type: 'camera', keyframes: [{ at: 0, from: { x: 420, lookAtX: 'GW/2' }, to: { x: 860 }, duration: 4, ease: 'sine.inOut' }] }, // moves the text card AND the model
1891
+ { type: 'light', kind: 'ambient', initial: { intensity: 0.4 } },
1892
+ { type: 'light', kind: 'parallel', initial: { x: 140, y: -260, z: 900, lookAtX: 'GW/2', lookAtY: 'GH/2', lookAtZ: 0, intensity: 0.8 } },
1893
+ { type: 'text', text: 'FOX', threeD: true, style: { fontSize: 420, fontWeight: '900', fill: '#ff7a1a' }, initial: { x: 'GW/2', y: 'GH/2', z: -500, anchorX: 0.5, anchorY: 0.5 } },
1894
+ { type: 'scene3d', name: 'fox', followCamera: true, followLights: true, // covers the composition and sits in the stack like any layer
1895
+ setup: async ({ root }) => {
1896
+ fox = await loadModel('Fox.glb', { animation: 'Run' }); // a clip name, an index, or false (the bind pose); speed, offset, loop
1897
+ fox.fit(480); // the longest side in px: the model is in metres, the scene in pixels
1898
+ fox.object.rotation.y = Math.PI / 2; // faces +x
1899
+ fox.object.position.set(640, -400, 0); // the composition point (640, 400), y flipped
1900
+ root.addChild(fox.object);
1901
+ },
1902
+ update: (t) => fox.pose(t) }, // the clip at the layer's time: a function of t
1903
+ ];
1904
+ ```
1905
+
1906
+ Shadows: give a `parallel` or `spot` light `castsShadows: true` (meshes cast and receive by default in PixiJS 3D; a point light casts none). Fog: `followFog: true`.
1907
+
1908
+ ---
1909
+
1766
1910
  ## Metallic three.js object
1767
1911
 
1768
1912
  `ctx.renderer` is a normal `WebGLRenderer`, so an environment map works; lights alone make `metalness: 1` look black. `three/addons` is not in the importmap: build the environment yourself. Size the layer to the area you want (it clips at its own rectangle) and pull the camera back (`z ≈ 5.4` for a radius-1 torus knot at fov 50).
@@ -2863,6 +3007,40 @@ It is a layer like any other: `name`, `at`, `duration`, `initial`, `keyframes`,
2863
3007
 
2864
3008
  Mistakes are said at build time with the layer's name: no `draw`, a `draw` or `setup` that throws (the frame is a magenta checkerboard and the next frames are tried again), a bad `width` / `height` / `resolution` / `params`, an unknown key (did you mean …), a `params.name` keyframe that names nothing. A draw costs time on every frame that changes (`draw` is called once for each distinct time, so a still picture still redraws every frame of a moving movie: draw what never changes once into a canvas from `info.canvas` in `setup` and `drawImage` it): a slow `draw` slows the export in proportion, and `check` prints the usual time of one draw. The layer's `duration` defaults to its parent's length: with `at` greater than 0 and no `duration`, `info.progress` stops short of 1 when the movie ends, so give a `duration` when you draw from `progress`. `check` draws each canvas layer in a scratch canvas (so reading its pixels does not change how the real one is drawn) at four moments: one draw of each is thrown away (a browser can draw a canvas from `setup` differently the first time), then each moment is drawn twice and in two orders. It prints a `canvas` line when every layer passed (`deterministic · slowest draw 3.2 ms`), so silence is never the only sign that it ran, and a layer that is not deterministic is reported under that heading with the moments, how many channel values differ and by how much (up to N of 255).
2865
3009
 
3010
+ ### `scene2d`
3011
+
3012
+ **PixiJS 2D built by your own code**: display objects (`Graphics`, `Sprite`, `Text`, `AnimatedSprite`, `ParticleContainer`, `Mesh`, …) made in `setup` and set from the time in `update`, shown in the layer's place in the stack. Use it when the data cannot say it (160 pieces that each fly their own way, a generated pattern, a sprite sheet) and you want PixiJS's objects rather than a 2D context (`canvas`) or a formula (`shader`). It is in core `pixi-effects`: nothing to import or register. Its shape is the `scene3d` layer's.
3013
+
3014
+ ```ts
3015
+ import { Graphics } from 'pixi.js';
3016
+ { type: 'scene2d', name: 'dots', duration: 4,
3017
+ setup: ({ root, width, height }) => { // once: build the scene in ctx.root (the layer's pixels, 0, 0 = top-left of its box)
3018
+ const dot = new Graphics({ label: 'dot' }).circle(0, 0, 30).fill('#ff4f1a');
3019
+ root.addChild(dot);
3020
+ return { objects: { dot } }; // keyframes reach it as scene2d.dot.<path>
3021
+ },
3022
+ update: (t, { objects, width }) => { objects.dot.position.set(width * (t / 4), 360); }, // per frame: SET everything from t
3023
+ keyframes: [{ at: 1, to: { 'scene2d.dot.alpha': 0.3 }, duration: 1 }] }
3024
+ ```
3025
+
3026
+ It is a layer like any other: `name`, `at`, `duration`, `initial` (`anchorX` / `anchorY` name a point of its box, as on a canvas layer), `keyframes`, `filters`, `mask`, `blendMode`, `parent`, `threeD`, transitions, mattes, export all work on it.
3027
+
3028
+ | Key | Meaning |
3029
+ |---|---|
3030
+ | `setup(ctx)` | **Required.** Builds the scene once (may be async: Movie.init waits). Returns `{ objects }`: the names `scene2d.<name>.<path>` keyframes reach (`root` is added). |
3031
+ | `update(t, ctx)` | Per frame, `t` the layer's own seconds. Sets the scene from the time. |
3032
+ | `dispose(ctx)` | Frees what you made (the root and everything under it are destroyed by the layer; textures you made are yours). |
3033
+ | `width`, `height` | The layer's box in px or an expression; default the composition's size. Nothing is clipped to it (give a `mask` to clip); `anchorX` / `anchorY` name a point of it and `check` draws it for the determinism test (the layout check measures what is drawn). |
3034
+ | `reuse` | Default `true`: a rebuild (`setVars`, `resize`) keeps the scene of a named layer whose `setup` function and size did not change, so `setup` runs once. `false`: setup runs at every rebuild. |
3035
+
3036
+ `ctx` is `{ root, renderer, width, height, objects, texture(name) }`: `root` is a PixiJS `Container` of the layer's own, `texture('photo')` is the texture of the movie asset `photo` (`init({ assets })`; an unknown name says which there are). Keyframe paths are raw PixiJS values: `scene2d.dot.rotation` is in radians (`angle` is degrees), `scene2d.dot.scale.x`, `scene2d.dot.tint`.
3037
+
3038
+ **The contract: a function of the time.** The same `t` must give the same picture in playback, a seek and an export, so `update` sets every object from `t` (never `x += …`), uses no `Math.random`, `Date.now`, `performance.now` or `new Date` (a warning at build says so; `random(seed)` from `pixi-effects` is repeatable: draw its numbers before the layer or in `setup`), and keeps nothing from the last call. What runs on PixiJS's ticker by itself is turned off after `setup`: an **`AnimatedSprite` left playing is posed by the layer from its time** (frame `floor(t × 60 × animationSpeed)` from its first frame, as fast as in a plain PixiJS page; its loop and per-frame times kept; one that does not loop holds its last frame, and `onComplete` / `onLoop` are not called: for any other timing pose it yourself with `gotoAndStop(frame)` in `update`: a stopped sprite is left alone), anything else with `autoUpdate` is turned off with one warning, a sprite that shows a `<video>` warns (footage is a `video` layer). A `Ticker.shared.add` of your own is not seen: don't. `check` draws each scene2d layer at four moments in two orders and names one whose same frame differs.
3039
+
3040
+ **Pick.** `movie.pick(x, y)` returns `kind: 'scene2d'` and `scene2d: { object, node, point, grab }` for the top object of the scene under the point, by PixiJS's own `containsPoint` (a `Graphics` by its shape, a `Sprite` or `Text` by its box) or its `hitArea`; `eventMode: 'none'`, `interactiveChildren: false`, a `hitArea` that does not hold the point (nothing inside it is hit) and a node used as a mask (never hit) are honoured as PixiJS's own events honour them; the order of a `RenderLayer` is not looked at (the tree's order is). `object` is the node's `label`, or its nearest labelled ancestor's (a node made without a label carries its kind, `'Graphics'`, which counts as no name: label a `Container` to name a group). Empty space in the box is not the layer: the point falls through to the layer below. `hit.scene2d.grab().move(x, y)` gives `{ point, delta }` in the node's parent's pixels (`node.x += delta[0]`); `check --pick X,Y` prints `world (scene2d: box)`. PixiJS's own pointer events (`node.eventMode = 'static'; node.on('pointerdown', …)`) work too in a playing page; prefer `movie.pick` (one vocabulary, testable by `check`).
3041
+
3042
+ Mistakes are said with the layer's name: no `setup`, a `setup` that throws (with its message), an `update` that throws (once), a keyframe path that names nothing (with the known objects), an unknown key (did you mean …). A spec that holds a `scene2d` layer holds functions, so it is not pure JSON (like `canvas`, `scene3d` and `three`). Recipe `scene2d-basics`.
3043
+
2866
3044
  ## 3D layers & camera
2867
3045
 
2868
3046
  Place any 2D layer in depth and view it through a camera — After Effects style, no three.js. Everything uses the normal `initial` / `keyframes` / expression vocabulary.
@@ -3045,9 +3223,89 @@ The camera takes `fogNear`, `fogFar` (distances along the view direction, px), `
3045
3223
 
3046
3224
  ---
3047
3225
 
3226
+ ## scene3d
3227
+
3228
+ A real 3D scene (meshes, glTF models, PBR materials, lights, shadows) as a layer, drawn by [PixiJS 3D](https://pixijs.com/3d) (`@pixi/3d`), the main 3D engine of pixi-effects. The scene is drawn **inside PixiJS's own renderer**, in the layer's place in the stack: no second renderer, no canvas per layer, no copy. Everything a layer has works on it: `at` / `duration`, transform keyframes, `alpha` (and a parent's alpha), `blendMode`, [filters](#filters), [masks](#masks), nesting in compositions and in `threeD` cards.
3229
+
3230
+ `type: 'scene3d'` lives in the optional `pixi-effects/3d` entry (see [API reference](./api.md#pixi-effects3d)). **Import it before `movie.init`**: PixiJS loads the 3D drawing code while its renderer starts, so a renderer that started before `@pixi/3d` was imported cannot draw a scene3d layer (the build says so).
3231
+
3232
+ ```ts
3233
+ import { register3D, scene3d } from 'pixi-effects/3d';
3234
+ import { Mesh3D, CubeGeometry, Material3D, DirectionalLight } from '@pixi/3d';
3235
+
3236
+ register3D(); // once, before Movie.init
3237
+
3238
+ scene3d({
3239
+ type: 'scene3d',
3240
+ name: 'hero',
3241
+ view: { toneMapping: 'aces', environment: { skyColor: 0xbcd4f0, groundColor: 0x6b6250, intensity: 0.7 } },
3242
+ setup: ({ root, camera }) => {
3243
+ const cube = new Mesh3D({ geometry: new CubeGeometry(), material: new Material3D({ baseColor: 0xdd4444, roughness: 0.5 }) });
3244
+ const sun = new DirectionalLight({ intensity: 3 });
3245
+ sun.rotation.set(-0.8, 0.5, 0);
3246
+ root.addChild(cube, sun);
3247
+ camera.position.set(0, 1.5, 5);
3248
+ camera.lookAt({ x: 0, y: 0, z: 0 });
3249
+ return { objects: { cube } }; // exposes `cube` to keyframes
3250
+ },
3251
+ update: (t, { objects }) => { objects.cube.rotation.y = t * 1.2; }, // set everything from t
3252
+ keyframes: [{ at: 1, to: { 'scene3d.cube.position.x': 1.5 }, duration: 1 }],
3253
+ })
3254
+ ```
3255
+
3256
+ | Field | Type | Notes |
3257
+ |---|---|---|
3258
+ | `width?`, `height?` | `PropValue` | layer size in composition px; expressions allowed. Default: the parent composition's size. Place it like any layer (`initial: { x, y, anchorX, anchorY }`). |
3259
+ | `view?` | `View3DOptions` | options of the PixiJS 3D view: `environment`, `toneMapping` (`'none'` default; `'aces'` for strong lights), `exposure`, `shadowQuality`, `passes` (bloom, SSAO … from `@pixi/3d/extras`), `cull`. The layer owns `width` / `height` / `autoResize`, and draws with `cull: 'cpu'` unless you name one. |
3260
+ | `resolution?` | `number` | the view's resolution (a value other than 1 draws the scene at that scale and composites it). |
3261
+ | `followCamera?`, `followLights?`, `followFog?` | `boolean` | live in the composition's space (see below). |
3262
+ | `reuse?` | `boolean` | a rebuild (`setVars`, `resize`, `rebuild`) keeps the scene of a layer that did not change (same `name`, `setup`, size and options): `setup` runs once. Default `true`; a layer with no `name` is always built again; `false` runs `setup` at every rebuild. |
3263
+ | `setup` | `(ctx) => { objects? } \| Promise<…>` | builds the scene once; async, so `Movie.init` waits for a model to load. A setup that throws stops `init` with the layer's name and the reason. |
3264
+ | `update?` | `(t, ctx) => void` | per frame, `t` = the layer's own time in seconds. Set everything from `t`. |
3265
+ | `dispose?` | `(ctx) => void` | cleanup for what you made (the view and everything under its root are destroyed by the layer). |
3266
+
3267
+ `ctx` is `{ view, root, camera, renderer, width, height, objects, space? }`: the `View3D`, its `root` (add meshes, models and lights here), its `camera` (`fov` in degrees, `mode: 'perspective' | 'orthographic'`; place it with `position.set` and `lookAt`), PixiJS's renderer, the layer size, and the `objects` setup returned (plus `camera` and `view`). Keyframes reach any of them as **`scene3d.<object>.<path>`** (`'scene3d.cube.rotation.y'`, `'scene3d.view.exposure'`); a path to an object setup did not name warns once with the known names.
3268
+
3269
+ **Time.** A frame is a function of the time, in playback, a seek and an export:
3270
+ - `register3D()` turns off the self-updating of models and animation players (`autoUpdate`); anything else under the root that still updates itself on PixiJS's ticker (a particle system, an `autoUpdate: true` you passed) is turned off after `setup`, with one warning.
3271
+ - **A model's clip:** pose it in `update`: `model.player.getState('Run').gotoAndStop(t % clip.duration)`. A clip left playing (a `Model3D` made with `autoPlay`) and posed by nobody is played on the layer's time by the layer (its speed and loop kept).
3272
+ - PixiJS 3D's shader time `uTime` reads the layer's time (it is the wall clock in PixiJS 3D itself).
3273
+ - `update` that calls `Math.random`, `Date.now`, `performance.now` or `new Date` warns at build; `check` draws the layer at four moments in two orders and reports a layer whose same frame is not the same picture (state kept from call to call, a particle system or a `MotionBlurEffect`, which reads the frame drawn before).
3274
+ - Particles (`ParticleSystem3D`): give a `seed`; their births add up from frame to frame, so a seek does not reproduce them: drive a `ParticleContainer3D` from `t` instead, or keep particles in 2D (`particles()`).
3275
+
3276
+ **Look.** PixiJS 3D ignores `alpha` and `blendMode` on its view; the layer applies them (and a parent composition's alpha) through one filter of its own, added only while the layer is not at full strength or blends (`add`, `screen`, `multiply`; the other modes work as on any layer). `tint` does nothing on a scene3d layer (it warns): colour the materials, or use a filter. The two backends draw nearly the same scene (measured 2026-11-13 on one Apple machine: a mean difference of 0.03–0.04 of 255 between WebGL and WebGPU for a lit cube), but they are separate pipelines in PixiJS 3D and are not required to match.
3277
+
3278
+ ### Living in the composition's space: `followCamera`, `followLights`, `followFog`
3279
+
3280
+ With `followCamera: true` the view's camera is put where the composition's `camera` layer is, every frame (position, look-at, vertical fov), so a `cameraPath()` flies through flat `threeD` layers and the 3D scene together. The scene is in **composition pixels with y flipped**: a point the composition calls (x, y, z) is (x, −y, z) in the scene; +z is toward the viewer in both. Give the layer the composition's size (the default; another shape warns). Measured 2026-11-13: planes of the scene land on the composition's own cards within 0.02 of 255 at 1280 × 720.
3281
+
3282
+ `followLights: true` makes the composition's `light` layers PixiJS 3D lights in the scene (ambient, point, spot, parallel; position, look-at, colour, intensity, cone, falloff), moved every frame; a light that ends is switched off. `followFog: true` makes the camera's fog the view's linear fog. `ctx.space` holds the raw numbers (camera, lights, fog) before `update` runs.
3283
+
3284
+ - **Brightness differs from the cards.** PixiJS 3D lights in linear light; the composition's cards shade the sRGB values. Positions, directions, cones and falloff match; a 3D surface is brighter in the shade (ambient 0.3: a card is 0.3 × its colour, a 3D surface 0.58 ×). Measured mean against the cards: ambient 7.2–7.7, parallel 2.6–2.8, spot 1.7–2.0, point 1.1–1.2 of 255. Pick intensities by looking.
3285
+ - **Falloff.** `none` is constant over the scene; `inverseSquare` is exact beyond the radius (0.71 at the radius where the composition holds 1); `smooth` is fitted (largest difference 0.10–0.12 of full light for ordinary falls, 0.20–0.28 for a fall that is steep against its radius).
3286
+ - **Shadows.** A `parallel` or `spot` light with `castsShadows` casts in the scene; meshes cast and receive by default in PixiJS 3D (`castShadow` / `receiveShadow` on a mesh to change it). A point light casts no shadow in PixiJS 3D (it warns). `shadowDarkness` / `shadowDiffusion` are not mirrored (warns once); the shadow box of a parallel light is the whole composition, and a spot light's shadow reaches far beyond it (PixiJS 3D's defaults are for a scene of a few units: in pixels they lost every shadow).
3287
+ - **Fog** fades by smoothstep (the composition: linearly) and mixes in linear light: measured mean 1.3–3.5 of 255 against the cards; `fogAmount` below 1 warns (no amount in PixiJS 3D).
3288
+ - With no light layer alive, the scene's own lights stay as they are.
3289
+
3290
+ ### Picking: `movie.pick` into the scene
3291
+
3292
+ `movie.pick(x, y)` on a scene3d layer returns `kind: 'scene3d'` and `scene3d: { object, point, distance, node, grab }`: what the ray through that canvas point meets first, as the frame on screen shows it. `object` is the node's `label` (or its nearest labelled ancestor's: label a group to name a model); `point` is in the scene's units. `hit.scene3d.grab('floor' | 'wall' | 'screen' | [nx, ny, nz]).move(x, y)` gives where the pointer's ray meets a plane through the hit point and the `delta` to move the object by (recipes `pick-click`, `drag-drop`, `drag-3d` work the same way). The layer makes its meshes pickable per triangle (`eventMode: 'static'`, `hitVolume: 'geometry'`) unless you set your own. Skinned and morphed meshes are picked in their rest pose. `check --at T --pick X,Y` prints `world (scene3d: red)`. PixiJS 3D's own pointer events also work in a playing page (`mesh.eventMode = 'static'; mesh.on('pointerdown' | 'pointerover' | 'pointerout', …)` in `setup`; set `hitVolume = 'geometry'` too, or it hits the bounding box), for the frame on screen like `movie.pick`. Prefer `movie.pick` / `movie.hover`: one vocabulary for every layer, and `check --pick` can test it without a pointer.
3293
+
3294
+ ### A 2D layer on a mesh: `layerTexture`
3295
+
3296
+ `const screen = await layerTexture(ctx, 'screen')` in `setup`, then `screen.material` on a mesh: the 2D layer named `screen` (a `composition` listed **before** the scene3d layer) is shown on it, upright on a `PlaneGeometry` and on a `CubeGeometry`'s faces. The 2D layer stays a layer of the movie (its timeline, keyframes, filters); it is drawn into a PixiJS texture at every frame (measured: identical to the 2D layer drawn flat, mean 0.000 of 255) and is no longer drawn on the stage. Options: `resolution` (2 for a screen seen large), `transparent` (its empty parts let the scene through). A pick on the mesh names the 2D layer under the point (`via` says which mesh). An unlit material: a screen shines by itself.
3297
+
3298
+ ### glTF models: `loadModel`, `fitCamera`
3299
+
3300
+ `const model = await loadModel('model.glb', { animation, speed, offset, loop })` in `setup`: `model.object` (a group centred on the model's box: add it to `root`, place, turn and scale it about its centre), `model.clips` (the clip names), `model.size()`, `model.fit(n)` (the longest side n units: n px under `followCamera`), `model.pose(t)` (the clip at `t × speed + offset`, looped or held: call it in `update`). `animation` is a clip name, an index or `false` (the bind pose); default the first clip. `fitCamera(ctx, model.object, { direction, margin })` puts the camera where the whole model fits (a three-quarter view by default). Recipes `scene3d-basics` and `scene3d-in-space`.
3301
+
3302
+ ### Keeping the scene across rebuilds: `reuse`
3303
+
3304
+ A rebuild builds the new composition while the old one plays. A named scene3d layer whose `setup`, size and options did not change takes over the old layer's scene at the swap: `setup` (and a model's loading) runs once, and the objects it made are the same objects. The view itself is a new one (a PixiJS display object has one parent, and the old tree draws until the swap); what `setup` did to it (background, environment, exposure, tone mapping, post effects, shadow settings, the camera it switched to) moves with the scene. Use `ctx.view` (or `objects.view`), not a `view` kept from `setup`. A rebuild that fails leaves the old layer drawing. Known limit: the new composition's `initial` values on `scene3d.<object>.<path>` are written to the shared objects while it is built, before the swap, and stay if the rebuild then fails (it matters only when such an initial value comes from a variable). `reuse: false` builds the layer again every time.
3305
+
3048
3306
  ## three
3049
3307
 
3050
- Composites a [three.js](https://threejs.org/) scene as a normal layer. Each frame, the scene is rendered into an offscreen WebGL canvas and uploaded into the sequence's PIXI texture — so once built, the layer is a plain sprite: 2D `initial` / keyframe props, [masks](#masks), and [filters](#filters) all apply to it exactly like any other sequence type.
3308
+ The sub 3D engine. Choose it over [`scene3d`](#scene3d) for three.js's own ecosystem (its loaders, materials, shaders and post-processing) or a scene you already have in three.js. Composites a [three.js](https://threejs.org/) scene as a normal layer. Each frame, the scene is rendered into an offscreen WebGL canvas and uploaded into the sequence's PIXI texture — so once built, the layer is a plain sprite: 2D `initial` / keyframe props, [masks](#masks), and [filters](#filters) all apply to it exactly like any other sequence type.
3051
3309
 
3052
3310
  `type: 'three'` lives in the optional `pixi-effects/three` entry, not core `pixi-effects` — see [API reference](./api.md#pixi-effectsthree) for install/import details.
3053
3311
 
@@ -4176,7 +4434,8 @@ The added keyframes are layered on top of any keyframes the spec already has. `w
4176
4434
 
4177
4435
  - [`Movie`](#movie) — composition runtime: init, playback, render
4178
4436
  - [`Controller`](#controller) — drop-in player UI overlay
4179
- - [`pixi-effects/three`](#pixi-effectsthree) — optional three.js integration
4437
+ - [`pixi-effects/3d`](#pixi-effects3d) — 3D scenes drawn by PixiJS 3D (the main 3D engine)
4438
+ - [`pixi-effects/three`](#pixi-effectsthree) — optional three.js integration (the sub 3D engine)
4180
4439
 
4181
4440
  For DSL types (composition spec, sequences, filters, keyframes, expressions), see [DSL reference](./dsl.md).
4182
4441
 
@@ -4348,10 +4607,13 @@ What is under a point of the canvas, at the frame that is on screen: the layer o
4348
4607
  ```ts
4349
4608
  interface PickHit {
4350
4609
  name?: string; path: string; type: string; // path: as in inspect(), names or type#index joined by '/'
4351
- kind: 'flat' | 'card' | 'three'; // a 2D layer, a threeD layer (its projected card), a three layer
4610
+ kind: 'flat' | 'card' | 'scene2d' | 'scene3d' | 'three'; // a 2D layer, a threeD layer (its projected card), a scene2d / scene3d / three layer
4352
4611
  point: { x: number; y: number }; // the point asked about
4353
4612
  local?: { x: number; y: number }; // a flat layer: the point in the layer's own pixels
4354
4613
  via?: { path: string; object: string }; // the layer is shown on a mesh of a `three` layer (threeLayerTexture) and the point is on that mesh: the three layer and the object it came through
4614
+ scene2d?: { object: string; node: unknown; point: [number, number]; // a scene2d layer: the top object of its scene under the point (its label)
4615
+ grab: () => { move: (x, y) => { point, delta } } }; // drag it: delta in the node's parent's pixels
4616
+ scene3d?: { object: string; point: [number, number, number]; distance: number; node?: unknown; grab? }; // a scene3d layer: what the ray met first
4355
4617
  three?: { object: string; point: [number, number, number]; distance: number; object3d: unknown; // a three layer: what the ray met first
4356
4618
  grab?: (plane?) => { move: (x, y) => { point, delta } | null } }; // drag it: see below
4357
4619
  }
@@ -4818,6 +5080,45 @@ Active when `keyboard: true` and the key target is not `<input>`/`<textarea>`/`<
4818
5080
 
4819
5081
  The bar uses fixed colors (`#007AFF` for the active track / fill / confirm button, white for icons, `rgba(0,0,0,0.75)` gradient background). Override by adding stricter CSS rules under `.movie-controller`. A theming API (CSS custom properties) is on the roadmap.
4820
5082
 
5083
+ ## `pixi-effects/3d`
5084
+
5085
+ ```ts
5086
+ import { register3D, scene3d, layerTexture, loadModel, fitCamera, Scene3DSequence } from 'pixi-effects/3d';
5087
+ import { Mesh3D, Model3D, DirectionalLight } from '@pixi/3d';
5088
+ ```
5089
+
5090
+ **Install:** `npm i @pixi/3d` (it needs `pixi.js` 8.23.0 or later). `@pixi/3d` is a peer dependency marked optional — a page that never imports `pixi-effects/3d` does not load it. On a page with an import map: `"pixi-effects/3d"`, `"@pixi/3d": "https://cdn.jsdelivr.net/npm/@pixi/3d@0.1.0/dist/pixijs-3d.min.mjs"` and `"@pixi/3d/extras": "https://cdn.jsdelivr.net/npm/@pixi/3d@0.1.0/dist/pixijs-3d-extras.min.mjs"` (the template has them). **Import it before `movie.init`.** The layer is described in [DSL: scene3d](./dsl.md#scene3d).
5091
+
5092
+ ### `register3D(): void`
5093
+
5094
+ Registers the `scene3d` layer type (explicit, so a bundler cannot drop it; calling it again is fine) and turns off the self-updating of `Model3D` and `AnimationPlayer` (`defaultOptions.autoUpdate = false`): a video draws from the time. Warns once when PixiJS is older than 8.23.
5095
+
5096
+ ### `scene3d(spec: Scene3DSequenceSpec): SequenceSpec`
5097
+
5098
+ Typing helper: takes a typed scene3d spec and returns it as a core `SequenceSpec`.
5099
+
5100
+ ### `layerTexture(ctx, name, options?): Promise<LayerTexture>`
5101
+
5102
+ In a scene3d layer's `setup`: the 2D layer `name` (a composition listed before the scene3d layer) shown on a mesh. Returns `{ material, texture, width, height, dispose }`; options `resolution`, `transparent`. See [DSL: scene3d](./dsl.md#a-2d-layer-on-a-mesh-layertexture).
5103
+
5104
+ ### `loadModel(url, options?): Promise<LoadedModel>`
5105
+
5106
+ Loads a glTF / GLB as a PixiJS 3D `Model3D`: `{ object, model, clips, size(), fit(n), pose(t) }`; options `animation` (a name, an index, `false`), `speed`, `offset`, `loop`.
5107
+
5108
+ ### `fitCamera(ctx, object, options?): void`
5109
+
5110
+ Puts the layer's camera where the whole of `object` fits; options `direction` (default `[0.45, 0.3, 1]`), `margin` (default 1.1).
5111
+
5112
+ ### `Scene3DSequence`
5113
+
5114
+ The layer class; `view` is its `View3D`. `checkDeterminism(times)` is what `check` runs; `pickAt(u, v)` and `dragPlane(point, plane)` are what `movie.pick` and `hit.scene3d.grab` call.
5115
+
5116
+ ### Exported types
5117
+
5118
+ ```ts
5119
+ import type { Scene3DContext, Scene3DSetupResult, Scene3DSequenceSpec, LayerTexture, LayerTextureOptions, LoadedModel, LoadModelOptions, FitCameraOptions } from 'pixi-effects/3d';
5120
+ ```
5121
+
4821
5122
  ## `pixi-effects/three`
4822
5123
 
4823
5124
  Optional three.js integration, imported from its own entry so the core `pixi-effects` entry never touches three.js:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pixi-effects",
3
- "version": "0.48.2",
3
+ "version": "0.50.0",
4
4
  "description": "Declarative composition and video rendering for the web. Built on PixiJS v8.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -35,6 +35,16 @@
35
35
  "default": "./dist/present.cjs"
36
36
  }
37
37
  },
38
+ "./3d": {
39
+ "import": {
40
+ "types": "./dist/3d.d.ts",
41
+ "default": "./dist/3d.js"
42
+ },
43
+ "require": {
44
+ "types": "./dist/3d.d.cts",
45
+ "default": "./dist/3d.cjs"
46
+ }
47
+ },
38
48
  "./three": {
39
49
  "import": {
40
50
  "types": "./dist/three.d.ts",
@@ -78,6 +88,7 @@
78
88
  "webgl",
79
89
  "keyframe",
80
90
  "typescript",
91
+ "pixijs-3d",
81
92
  "three",
82
93
  "threejs"
83
94
  ],
@@ -121,24 +132,29 @@
121
132
  "mediabunny": "^1.61.3"
122
133
  },
123
134
  "peerDependencies": {
135
+ "@pixi/3d": "^0.1.0",
124
136
  "pixi-filters": "^6.1.0",
125
137
  "pixi.js": "^8.0.0",
126
138
  "three": ">=0.150.0"
127
139
  },
128
140
  "peerDependenciesMeta": {
129
- "three": {
141
+ "@pixi/3d": {
130
142
  "optional": true
131
143
  },
132
144
  "pixi-filters": {
133
145
  "optional": true
146
+ },
147
+ "three": {
148
+ "optional": true
134
149
  }
135
150
  },
136
151
  "devDependencies": {
152
+ "@pixi/3d": "^0.1.0",
137
153
  "@types/three": "^0.185.0",
138
154
  "gsap": "3.15.0",
139
155
  "happy-dom": "^15.0.0",
140
156
  "pixi-filters": "^6.1.5",
141
- "pixi.js": "^8.22.0",
157
+ "pixi.js": "^8.23.0",
142
158
  "three": "^0.185.1",
143
159
  "tsup": "^8.0.0",
144
160
  "typescript": "^5.5.0",
@@ -3,7 +3,7 @@ name: pixi-effects
3
3
  description: Use when asked to make, animate, preview or export a video, motion graphics, an animated title, a lower third, kinetic type, a promo, a slideshow with transitions, an animated chart or data story, a social clip, a 2.5D / depth scene, or music and sound for a video, written as code in JavaScript or HTML. pixi-effects is a JS library where a video is a plain-object composition (text, shape, image, video, audio and file-free sfx / music, camera, light and shader layers with keyframes) that plays in the browser, is checked by a tool the agent can run, and exports MP4 / WebM / MOV with no server. Not for a project that already uses another video tool (Remotion, Motion Canvas, ffmpeg); follow that tool instead.
4
4
  ---
5
5
 
6
- > Written for pixi-effects 0.48.2. Update this skill with `npx skills update`; the newest docs are at https://yjmtmtk.github.io/pixi-effects/
6
+ > Written for pixi-effects 0.50.0. Update this skill with `npx skills update`; the newest docs are at https://yjmtmtk.github.io/pixi-effects/
7
7
 
8
8
  # pixi-effects — write a video as data
9
9
 
@@ -60,7 +60,9 @@ The same command checks a file you MADE before it goes somewhere with rules (a s
60
60
  - **Time:** `speed` (slow motion, backward) and `time` (a curve of the layer's own seconds: freeze, rewind, ramp) on video, audio-file and composition layers; put what you want to rewind in a composition. Children of such a composition are in ITS seconds. `ease: 'cubic-bezier(.4, 0, .2, 1)'` is the CSS curve. See the recipe `rewind-and-replay` and pitfalls 69–73.
61
61
  - **Built in:** `animateText()` (per-letter / per-word / per-line entrances and exits in one call: `...animateText('Title', style, { x, y, align: 'center', at: 0.5, duration: 5, in: 'rise', out: 'fade', stagger: { each: 0.05 } })`), `splitText()` / `measureText()` (per-letter / per-word layers, exact positions, by hand), `shape: 'arc'` (progress rings, pie / donut slices: animate `endAngle`), `fillGradient` (linear/radial, alpha stops → vignettes), `{value}` counters, `repeat`/`yoyo`, `orbit()`, seeded randomness (`rand(seed)` / `noise(x, seed)` in expressions, `random(seed)` in JS, never `Math.random()`) `wiggle()` (a seeded shake as keyframes; on a camera put it on `offsetX` / `offsetY` so it rides on the move) `cameraPath()` (a camera flight through `[x, y, z]` points as keyframes; layers it passes: `hideBehindCamera: true`) depth of field (camera `focus` + `aperture`: `{ type: 'camera', initial: { focus: 'title' } }` blurs the other threeD layers by depth; a rack focus is a keyframe on `focus`) `music` (a tune with no file: `{ type: 'audio', music: { bpm, tracks: [{ inst: 'keys', notes: 'Cmaj7:4 Am7:4' }, { inst: 'bass', notes: 'c2:2 g1:2' }], drums: { from: 8, kick: 'x...x...' } } }`: notes and chords as text, counted in beats; instruments `keys pluck pad bass sub lead bell musicbox`; `musicEnvelope(music)` feeds `react()`), `audioEnvelope()` / `bpmEnvelope()` + `react()` (visuals that follow music: `const env = await audioEnvelope('music.mp3', { frameRate })` before `movie.init`, then `keyframes: react(env, { duration, props: { scale: { base: 1, amount: 0.4, band: 'bass', release: 0.2 } } })`; `beats: true` pulses on the beats; `bpmEnvelope(120, …)` when there is no file), `deck()` + `Presenter` from `pixi-effects/present` (a talk as one movie: pages, steps, stops that pause playback, arrow keys / click / swipe to move on: `...deck({ transition: { kind: 'slide', duration: 0.6 }, pages: [{ name, duration, stops, sequences }] })`, `new Presenter(movie, { canvas })` from `pixi-effects/presenter`; G = page overview, P = presenter view with notes and a timer; `pixi-effects render talk.html -o talk.pdf` = a PDF of the pages), `particles()` (bursts, snow, confetti, sparks as seeded layers: `...particles({ count: 90, at: 2, life: [1, 1.8], area: { x: 640, y: 330 }, speed: [120, 420], gravity: 240, fade: { out: 0.6 } })`), `followPath()` (a layer along an SVG path: `keyframes: followPath({ d, duration, orient: true })`), path morphing (`{ shape: 'path', d, morphTo, initial/keyframes: morph 0 → 1 }`) and `stagger()` (delays for a wave of layers: `stagger(letters, { each: 0.05, from: 'center' })`, or `stagger(n, …)` for numbers; GSAP-style `each` / `amount` / `from` / `grid` / `ease`). **Not in the DSL (hand-roll with a JS loop):** other per-frame curves (waves, spirals: sample them into short linear keyframes). (Draw-on strokes `trimEnd`, text that changes over time `set: { text }` / `visibleChars`, shake `wiggle()` and particles are built in.)
62
62
  - **Draw it yourself (`canvas` layer):** when nothing in the library draws what you need (an afterimage, rings, a brush stroke, a pixel sprite, a chart), `{ type: 'canvas', width, height, draw: (ctx, t, info) => { … } }`: your function of the time draws into the layer's own cleared 2D canvas and it is a normal layer. Pure function of `t` and `info.params` (`info.random()` is seeded; no `Math.random` / `Date.now`); a simulation is computed in `setup` and read by `info.frame`. `info.time` is the movie's clock (several layers share it; never add `at` by hand), `info.canvas(w, h)` makes sprites and masks in `setup`, data shared by several canvas layers lives in module scope, and `import { ease, noise, noise2, random, samplePath } from 'pixi-effects'` gives the library's easing, seeded noise and a path walked by length. `check` prints a `canvas` line (deterministic, slowest draw in ms) and does NOT see text drawn in a canvas. `help canvas`, recipes `canvas-trail`, `canvas-life`, `canvas-text-mask`, `canvas-text-on-path`, `canvas-bars`.
63
+ - **PixiJS 2D by code (`scene2d` layer):** when you want PixiJS objects (`Graphics`, `Sprite`, `AnimatedSprite`, `Text`, many pieces that each move their own way) rather than a 2D context, `{ type: 'scene2d', setup({ root }) { root.addChild(...); return { objects } }, update(t, { objects }) { … } }` (core, nothing to import; recipe `scene2d-basics`): `update` SETS every object from `t`, keyframes reach `scene2d.<object>.<path>` (raw PixiJS values), `movie.pick` names the object under a point (its `label`).
63
64
  - **Shader layer and warp:** `{ type: 'shader', fragment: 'void mainImage(out vec4 fragColor, in vec2 fragCoord) { … }', uniforms: { speed: 1, tint: '#ff8040' } }` draws a Shadertoy-style formula (GLSL ES 3.00; `iTime` `iResolution` `iFrame` are declared; keyframes move `'uniforms.speed'`; opaque by default; it cannot read the layers behind it; a compile error comes back in your own line numbers). `filters: [{ type: 'warp', kind: 'wave' | 'haze', strength: 6 }]` bends a layer's picture like water or heat.
65
+ - **A real 3D scene** (meshes, a glTF model, PBR, lights, shadows) is a `scene3d` layer (`pixi-effects/3d`, PixiJS 3D; `register3D()` before `init`; recipe `scene3d-basics`): `setup` builds it with `@pixi/3d`, `update(t)` sets it from the time, keyframes reach `scene3d.<object>.<path>`; masks, filters, `alpha` and `blendMode` work on it; `followCamera` / `followLights` / `followFog` put it in the composition's own camera and lights (recipe `scene3d-in-space`), `loadModel` / `fitCamera` load and frame a glTF, `layerTexture(ctx, 'screen')` shows a 2D layer on a mesh, `movie.pick` names what is clicked. A `three` layer (`pixi-effects/three`) is for three.js's own ecosystem.
64
66
  - **Light, shadow, fog (2.5D):** a `{ type: 'light', kind: 'ambient', initial: { intensity: 0.2 } }` plus a `spot` / `point` / `parallel` light (`castsShadows: true` on the light AND on the `threeD` layers that cast) shades every `threeD` layer by angle and distance; `lit: false` opts a layer out; camera `fogNear` / `fogFar` / `fogColor` fade what is far. What no light reaches is black (add the ambient light); layers are drawn in distance order (no depth buffer). `check --at` prints how lit each layer is.
65
67
  - **Blend modes:** `blendMode` takes 18 CSS names (`add` `screen` `multiply` `overlay` `soft-light` `hard-light` `color-dodge` `color-burn` `darken` `lighten` `difference` `exclusion` `hue` `saturation` `color` `luminosity` `linear-burn`, hyphenated) on any layer: glows and light leaks (`add`, `color-dodge`), colour casts and contrast (`soft-light`, `overlay`), vignettes (`multiply`). It blends with everything below the layer; on a composition each child blends (as one picture: `threeD: true` or a filter); 10+ of the new modes on screen at once warn; it works with every mask.
66
68
  - **One `Movie` per page.** A sound effect needs no `duration` (it lasts as long as its clip). Seeking is safe: counters, colours and growing shapes are right after any jump.
@@ -73,7 +75,7 @@ Centred text must be positioned by `anchorX/anchorY: 0.5`; estimate text width a
73
75
 
74
76
  - For an AI in a chat with no shell, the short version is https://raw.githubusercontent.com/yjmtmtk/pixi-effects/main/ai/CHAT.md (it copies https://raw.githubusercontent.com/yjmtmtk/pixi-effects/main/ai/chat-template.html: one self-contained page that loads from a single CDN and shows warnings in a red box).
75
77
  - `reference/cheatsheet.md` — every layer type, prop, default and rule on one page.
76
- - `reference/recipes.md` — tested building blocks: slam type, lower-third, marquee, count-up, bar chart, 2.5D title, camera orbit, slideshow with transitions + music, looping, particles, gradients/vignette, three.js metal.
78
+ - `reference/recipes.md` — tested building blocks: slam type, lower-third, marquee, count-up, bar chart, 2.5D title, camera orbit, slideshow with transitions + music, looping, particles, gradients/vignette, a 3D scene (scene3d), three.js metal.
77
79
  - `reference/pitfalls.md` — the full list of real mistakes, with status.
78
80
  - `reference/direction.md` — how to direct a film (any renderer): brief, style guide, shot list, assets rule, sound on the beat grid, review protocol, formats, gates.
79
81
  - `npx pixi-effects check` — the one-command review (see Workflow step 5).
@@ -81,12 +81,16 @@ Style (`initial` / keyframes): `fillColor fillAlpha strokeColor strokeAlpha stro
81
81
 
82
82
  **canvas** — `{ type: 'canvas', width, height, draw: (ctx, t, info) => { … }, setup?, params?, seed?, resolution?, smoothing? }`: a picture drawn by YOUR function of the time into the layer's own cleared canvas (`t` = the layer's seconds; `info` = `{ frame, duration, progress, width, height, resolution, frameRate, params, state, random, start, time, canvas }`: `time` is the MOVIE's seconds (`start + t`; share it between layers instead of adding `at` by hand), `canvas(w, h)` an offscreen canvas for sprites and masks made in `setup`). It is a normal layer (initial, keyframes, mask, filters, blendMode …); `params` are numbers keyframes animate as `'params.x'`; no `Math.random` / `Date.now` (`info.random()` is seeded); a simulation is computed in `setup` and read by `info.frame`; it cannot see what is below it. Data several canvas layers share lives in module scope; `import { ease, noise, noise2, random, rand, samplePath } from 'pixi-effects'` for easing by keyframe name, seeded noise and a path walked by length (`samplePath(d).at(u)` / `.trim(a, b)`). `check` draws it twice per moment (one draw thrown away first) and reports a picture that differs, with how much; it prints a `canvas` line when all pass and does NOT see text drawn in a canvas.
83
83
 
84
+ **scene2d** (PixiJS 2D built by code, core: nothing to import) — `{ type: 'scene2d', name, width?, height?, setup({ root, width, height, texture }) { const g = new Graphics({ label: 'dot' }).circle(0, 0, 30).fill('#f40'); root.addChild(g); return { objects: { dot: g } } }, update?(t, { objects }) { objects.dot.x = 100 + 200 * t }, dispose?, reuse? }`: your PixiJS objects as one layer; `update` SETS everything from `t`; keyframes `'scene2d.dot.alpha'` (raw PixiJS values: `rotation` radians); an `AnimatedSprite` left playing is posed from the time; `texture('photo')` = a movie asset; `movie.pick` → `kind: 'scene2d'`, `hit.scene2d.object` (the label), `hit.scene2d.grab().move(x, y)`. Recipe `scene2d-basics`.
85
+
84
86
  **shader** — `{ type: 'shader', fragment: 'void mainImage(out vec4 fragColor, in vec2 fragCoord) { … }', uniforms: { speed: 1, tint: '#ff8040', pos: [0.5, 0.5] }, width?, height?, resolution?: 0.5, transparent?: false }` — a Shadertoy fragment shader drawn into a layer (GLSL ES 3.00): `iResolution` `iTime` (the layer's own seconds) `iFrame` and your uniforms are declared for you; keyframes move uniforms as `'uniforms.speed'` and components as `'uniforms.tint.0'`; opaque unless `transparent: true`; it cannot read what is behind it (no `iChannel` / `iMouse` / `iDate`); no `while` or non-constant `for`; a compile error is reported in your own line numbers and the layer is a checkerboard.
85
87
 
86
88
  **null + `parent`** — `{ type: 'null', name: 'rig', initial: { x, y }, keyframes: [...] }` draws nothing; layers with `parent: 'rig'` are drawn inside it and follow its `x y rotation scale skew alpha pivotX pivotY`. Their `x / y` are measured **from the null's origin** (null at (640,360), child `x: 200` → drawn at 840), so rotating the null makes the child circle it. Nulls chain (`parent` on a null). Children are drawn at the null's place in the stack, together. Only `null` layers can be parents; not for `threeD` layers; a mask follows its layer.
87
89
 
88
90
  **camera** (2.5D) — props in `initial`/keyframes: `x y z lookAtX lookAtY lookAtZ fov focus aperture` (defaults: centred, looking at the z = 0 plane, `fov` 40, `z` auto = `(H/2)/tan(fov/2)` ≈ 989 at 720p). **Depth of field:** `focus` = the sharp plane (a `threeD` layer's name = its first z, or a z number), `aperture` = the lens diameter in px (30 default, 0 off); writing either blurs the other `threeD` layers by their distance from the focus (a rack focus is a keyframe on `focus`; recipe `rack-focus`; `inspect` reports `depthBlur`, and `check --at 2.4` prints each layer's blur). A dolly with no typed distance: leave `z` automatic and animate `offsetZ`. Never on the camera object itself. An orbit is `orbit()`; by hand: `x = cx + R·sin θ`, `z = R·cos θ`, `R = (H/2)/tan(fov/2)`, `lookAt` = the centre at `z = 0`.
89
91
 
92
+ **scene3d** (optional entry `pixi-effects/3d`, PixiJS 3D: the main 3D engine) — `{ type: 'scene3d', name, width?, height?, view?: { toneMapping: 'aces', environment, shadowQuality, passes }, setup({ view, root, camera }) { root.addChild(mesh, light); camera.position.set(0, 1.5, 5); camera.lookAt({ x: 0, y: 0, z: 0 }); return { objects: { cube } } }, update?(t, { objects }), dispose? }`; drive with `'scene3d.cube.rotation.y'` keyframes. `import { register3D } from 'pixi-effects/3d'` and call `register3D()` **before** `init`; meshes, models and lights come from `@pixi/3d` (`Mesh3D`, `Model3D` + `Assets.load('model.glb')`, `CubeGeometry`, `PlaneGeometry`, `Material3D`, `DirectionalLight` …). Drawn inside PixiJS: masks, filters, `alpha`, `blendMode` work as on any layer (`tint` does not). A clip: `model.player.getState(name).gotoAndStop(t)` in `update`. **In the composition's space:** `followCamera: true` (the composition's `camera` is the view's: the scene is in composition px with y flipped, (x, y, z) → (x, −y, z)), `followLights: true` (the `light` layers light the scene; parallel/spot `castsShadows` cast, a point light cannot; PixiJS 3D lights in linear light, so 3D is brighter in the shade than a card), `followFog: true`. **glTF:** `import { loadModel, fitCamera } from 'pixi-effects/3d'`: `const m = await loadModel(url, { animation: 'Run' | 0 | false, speed, offset, loop })` in `setup` (`root.addChild(m.object)`; `m.fit(px)`; `m.clips`), `update: (t) => m.pose(t)`, `fitCamera(ctx, m.object)`. **A 2D layer on a mesh:** `const screen = await layerTexture(ctx, 'screen')`, `screen.material` on a mesh (`screen` is a composition listed BEFORE the scene3d layer). `movie.pick` names the object (`hit.scene3d.object`, `hit.scene3d.grab('floor').move(x, y)`). A named layer keeps its scene across `setVars` (`reuse`). Recipes `scene3d-basics`, `scene3d-in-space`.
93
+
90
94
  **three** (optional entry `pixi-effects/three`) — `three({ type:'three', width, height, setup(ctx) { …; return { objects: { knot } } }, update?, dispose? })`; drive with `'three.knot.rotation.y'` keyframes. Call `registerThree()` before `init`. **In the composition's space:** `followCamera: true` (the layer's PerspectiveCamera is the composition's `camera`; the scene is in composition px with y flipped: the composition's `(x, y, z)` is `(x, -y, z)`; give the layer the composition's size), `followLights: true` (the `light` layers become three lights) and `followFog: true` (the camera's fog becomes the scene's `Fog`: close to, not equal to, the card fog); a light's `castsShadows` casts in three (mark your meshes `castShadow` / `receiveShadow`); `ctx.space` has the raw numbers. **glTF:** `import { threeGltf, threeFrameCamera } from 'pixi-effects/three'`: `const m = await threeGltf(url, { animation: 'Run' | 0 | false, speed, offset, loop })` in `setup` (`ctx.scene.add(m.object)`; `m.fit(px)` scales the longest side to px; `m.clips` names the clips), `update: (t) => m.pose(t)` (a function of t: never `mixer.update(dt)`), `threeFrameCamera(ctx.camera, m.object)` frames the whole model from a three-quarter view. Needs `"three/addons/"` in the import map. Recipe `gltf-in-space`. **A 2D layer on a mesh** (a phone screen, a television): `const screen = await threeLayerTexture(ctx, 'screen')` in `setup`, `screen.material` on a mesh; `screen` is a `composition` listed BEFORE the three layer (recipe `three-screen`).
91
95
 
92
96
  ## Camera flight and a camera shake