@kitn.ai/ui 0.19.0 → 0.20.1
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.
- package/LICENSE +6 -0
- package/NOTICE +54 -0
- package/dist/components/audio-visualizer/audio-visualizer.voice-fixture.d.ts +59 -0
- package/dist/components/audio-visualizer/aurora.glsl.d.ts +114 -0
- package/dist/components/audio-visualizer/index.d.ts +155 -0
- package/dist/components/audio-visualizer/shader-canvas.d.ts +130 -0
- package/dist/components/audio-visualizer/sizes.d.ts +25 -0
- package/dist/components/audio-visualizer/variant-aurora.d.ts +89 -0
- package/dist/components/audio-visualizer/variant-bar.d.ts +46 -0
- package/dist/components/audio-visualizer/variant-custom.d.ts +73 -0
- package/dist/components/audio-visualizer/variant-grid.d.ts +22 -0
- package/dist/components/audio-visualizer/variant-radial.d.ts +13 -0
- package/dist/components/audio-visualizer/variant-wave.d.ts +11 -0
- package/dist/components/audio-visualizer/wave.glsl.d.ts +16 -0
- package/dist/create-tween-hvpmSuwW.js +7 -0
- package/dist/custom-elements.json +282 -0
- package/dist/elements/agent-card.js +1 -1
- package/dist/elements/artifact.js +1 -1
- package/dist/elements/attachments.js +1 -1
- package/dist/elements/audio-visualizer.d.ts +1 -0
- package/dist/elements/audio-visualizer.js +1 -0
- package/dist/elements/autoloader.js +1 -1
- package/dist/elements/avatar.js +1 -1
- package/dist/elements/badge.js +1 -1
- package/dist/elements/button.js +1 -1
- package/dist/elements/card.js +1 -1
- package/dist/elements/cards.js +1 -1
- package/dist/elements/chain-of-thought.js +1 -1
- package/dist/elements/chat-scope-picker.js +1 -1
- package/dist/elements/chat-workspace.js +1 -1
- package/dist/elements/chat.js +1 -1
- package/dist/elements/checkpoint.js +1 -1
- package/dist/elements/choice.js +1 -1
- package/dist/elements/chunks/{Icon-11SRdjJ4.js → Icon-Tj58ES_d.js} +1 -1
- package/dist/elements/chunks/{action-icons-CtRg17uX.js → action-icons-ns1Fnedj.js} +1 -1
- package/dist/elements/chunks/arrow-left-DQocUFvy.js +1 -0
- package/dist/elements/chunks/{attachments-CBqQRh60.js → attachments-mCB5F1Fq.js} +1 -1
- package/dist/elements/chunks/audio-visualizer-BqNy7dfm.js +1 -0
- package/dist/elements/chunks/{badge-CxklEXQm.js → badge-B4Zuajl2.js} +1 -1
- package/dist/elements/chunks/{button-BD7lUvVB.js → button-ZWNif3--.js} +1 -1
- package/dist/elements/chunks/{card-B-PRGXdj.js → card-BkHbp4BA.js} +1 -1
- package/dist/elements/chunks/{chat-thread-DIZbRIKG.js → chat-thread-CkRN6pZ7.js} +1 -1
- package/dist/elements/chunks/check-B9-gn6SS.js +1 -0
- package/dist/elements/chunks/chevron-down-BNsxc2hk.js +1 -0
- package/dist/elements/chunks/chevron-right-Hv00StBO.js +1 -0
- package/dist/elements/chunks/circle-0e3KJTDr.js +1 -0
- package/dist/elements/chunks/circle-check-BNAS0JEy.js +1 -0
- package/dist/elements/chunks/{circle-x-mtjCO_Ok.js → circle-x-B7faJFlY.js} +1 -1
- package/dist/elements/chunks/{code-block-BeWVttmU.js → code-block-BFUHn6Va.js} +1 -1
- package/dist/elements/chunks/collapsible-DM1Ckd4e.js +1 -0
- package/dist/elements/chunks/{composer-CavJww3d.js → composer-BguS0QL_.js} +2 -2
- package/dist/elements/chunks/{context-CnlsNJSJ.js → context-Cb7k4F-v.js} +1 -1
- package/dist/elements/chunks/{conversation-list-5O_KdI_I.js → conversation-list-CvoAkWhN.js} +1 -1
- package/dist/elements/chunks/create-tween-oVvAIG8r.js +7 -0
- package/dist/elements/chunks/{default-input-CB46cJJ1.js → default-input-Cqqilg9t.js} +1 -1
- package/dist/elements/chunks/define-D1din7Ld.js +1 -0
- package/dist/elements/chunks/{disclosure-BtFK3a-M.js → disclosure-CIZ5c8nO.js} +1 -1
- package/dist/elements/chunks/{download-qJv1dS3M.js → download-Dsbg3ste.js} +1 -1
- package/dist/elements/chunks/{dropdown-UhtAueYR.js → dropdown-BDd1-Zgi.js} +1 -1
- package/dist/elements/chunks/{ellipsis-B1sOOqiJ.js → ellipsis-Dw6qrss-.js} +1 -1
- package/dist/elements/chunks/{external-link-DToP_VKE.js → external-link-Vt74zAoL.js} +1 -1
- package/dist/elements/chunks/{file-text-BwUs_Pj4.js → file-text-CZrZF6_v.js} +1 -1
- package/dist/elements/chunks/{file-tree-C6RhmSnX.js → file-tree-DF4okKyo.js} +1 -1
- package/dist/elements/chunks/{folder-H_Xp0GYP.js → folder-glP5rha4.js} +1 -1
- package/dist/elements/chunks/{hover-card-Dn3yrk1l.js → hover-card-Cmm6Q53m.js} +1 -1
- package/dist/elements/chunks/{icon-BrxJQXzC.js → icon-BLb8beoi.js} +1 -1
- package/dist/elements/chunks/{info-11eT2q5H.js → info-CIsv92JK.js} +1 -1
- package/dist/elements/chunks/{input-B9oZKDfb.js → input-Dds1kFyt.js} +1 -1
- package/dist/elements/chunks/{kbd-D2fx3rWh.js → kbd-De_xbgi9.js} +1 -1
- package/dist/elements/chunks/{link-B-RNYWNw.js → link-i4r_k7z4.js} +1 -1
- package/dist/elements/chunks/{loader-BJh_aRlp.js → loader-BmQH4s3V.js} +1 -1
- package/dist/elements/chunks/{markdown-BIpjrDFv.js → markdown-Zi21WhUz.js} +7 -7
- package/dist/elements/chunks/{message-CPfqj8Gi.js → message-BqyXDN3-.js} +1 -1
- package/dist/elements/chunks/{message-feedback-8uj0aVYh.js → message-feedback-Djr3z6Yb.js} +1 -1
- package/dist/elements/chunks/message-square-vFAgbDaX.js +1 -0
- package/dist/elements/chunks/{minimize-2-C9qkiTJg.js → minimize-2--ifeCGrl.js} +1 -1
- package/dist/elements/chunks/{model-switcher-DRrMcRXH.js → model-switcher-B94YAKsM.js} +1 -1
- package/dist/elements/chunks/{overlay-C8_FUL8q.js → overlay-kD8dwRv_.js} +1 -1
- package/dist/elements/chunks/{paperclip-MA9hxLhW.js → paperclip-q21dYOSK.js} +1 -1
- package/dist/elements/chunks/{progress-bar-y5G6HDjg.js → progress-bar-BrAIIaaJ.js} +1 -1
- package/dist/elements/chunks/{prompt-suggestion-LalWvCJy.js → prompt-suggestion-Bjn2K-fR.js} +1 -1
- package/dist/elements/chunks/reasoning-DTpCGtnY.js +1 -0
- package/dist/elements/chunks/{resizable-xhYF5lCQ.js → resizable-Dp4JExql.js} +1 -1
- package/dist/elements/chunks/{rotate-cw-2ndEXjgS.js → rotate-cw-CFWWkpj2.js} +1 -1
- package/dist/elements/chunks/{scroll-area-BzOPDXrZ.js → scroll-area-Bb0kHH1j.js} +1 -1
- package/dist/elements/chunks/scroll-button-B0cH5fXf.js +1 -0
- package/dist/elements/chunks/{separator-CPvL5Xdl.js → separator-9kRgr1X9.js} +1 -1
- package/dist/elements/chunks/{settings-hmzIuNXD.js → settings-B8lmec4D.js} +1 -1
- package/dist/elements/chunks/{settings-group-BiIym17O.js → settings-group-I0hEuXiN.js} +1 -1
- package/dist/elements/chunks/{share-BbziTAzn.js → share-C3lTiYGt.js} +1 -1
- package/dist/elements/chunks/{skeleton-Bxhs1thL.js → skeleton-DSXW5Yya.js} +1 -1
- package/dist/elements/chunks/{star-BHL3Va5p.js → star-CSJtGxgv.js} +1 -1
- package/dist/elements/chunks/{store-tpnnWlxY.js → store-CHyW95TP.js} +1 -1
- package/dist/elements/chunks/{text-shimmer-1DCKivKx.js → text-shimmer-Cn9TmNpI.js} +1 -1
- package/dist/elements/chunks/{textarea-Czaowo9F.js → textarea-CUwqAX0G.js} +1 -1
- package/dist/elements/chunks/{thumbs-up-CdLqA13R.js → thumbs-up-PNVVThEF.js} +1 -1
- package/dist/elements/chunks/{toast-store-BvBquUFA.js → toast-store-DKbQn3Wn.js} +1 -1
- package/dist/elements/chunks/{tool-4P_ne8Go.js → tool-DjTRpIpl.js} +1 -1
- package/dist/elements/chunks/tooltip-CgBZCVKp.js +1 -0
- package/dist/elements/chunks/{triangle-alert-BllqxXq8.js → triangle-alert-DsHTHCPm.js} +1 -1
- package/dist/elements/chunks/{use-card-resolution-73xiddIW.js → use-card-resolution-BSu_XHjH.js} +1 -1
- package/dist/elements/chunks/variant-aurora-Dc4nQUia.js +218 -0
- package/dist/elements/chunks/variant-custom-y_3kVQhS.js +1 -0
- package/dist/elements/chunks/variant-wave-D5OR1-9Z.js +116 -0
- package/dist/elements/chunks/{video-O9rA6k7S.js → video-B7vmenoC.js} +1 -1
- package/dist/elements/chunks/x-GRUeeQFT.js +1 -0
- package/dist/elements/coachmark.js +1 -1
- package/dist/elements/code-block.js +1 -1
- package/dist/elements/command.js +1 -1
- package/dist/elements/compare.js +1 -1
- package/dist/elements/composer.js +1 -1
- package/dist/elements/confirm-card.js +1 -1
- package/dist/elements/context-meter.js +1 -1
- package/dist/elements/conversation-list.js +1 -1
- package/dist/elements/define.d.ts +11 -0
- package/dist/elements/dialog.js +1 -1
- package/dist/elements/editable-label.js +1 -1
- package/dist/elements/embed.js +1 -1
- package/dist/elements/empty.js +1 -1
- package/dist/elements/feedback-bar.js +1 -1
- package/dist/elements/file-tree.js +1 -1
- package/dist/elements/file-upload.js +1 -1
- package/dist/elements/form.js +1 -1
- package/dist/elements/hover-card.js +1 -1
- package/dist/elements/icon.js +1 -1
- package/dist/elements/image.js +1 -1
- package/dist/elements/input.js +1 -1
- package/dist/elements/kbd.js +1 -1
- package/dist/elements/link-preview.js +1 -1
- package/dist/elements/loader.js +1 -1
- package/dist/elements/markdown.js +1 -1
- package/dist/elements/menu.js +1 -1
- package/dist/elements/message-skills.js +1 -1
- package/dist/elements/message.js +1 -1
- package/dist/elements/model-switcher.js +1 -1
- package/dist/elements/nav.js +1 -1
- package/dist/elements/notice.js +1 -1
- package/dist/elements/pane-group.js +1 -1
- package/dist/elements/pane.js +1 -1
- package/dist/elements/popover.js +1 -1
- package/dist/elements/progress-bar.js +1 -1
- package/dist/elements/prompt-dock.js +1 -1
- package/dist/elements/prompt-input.js +1 -1
- package/dist/elements/prompt-suggestions.js +1 -1
- package/dist/elements/reasoning.js +1 -1
- package/dist/elements/remote.js +1 -1
- package/dist/elements/resizable.js +1 -1
- package/dist/elements/response-stream.js +1 -1
- package/dist/elements/screen.js +1 -1
- package/dist/elements/scroll-area.js +1 -1
- package/dist/elements/scroll-button.js +1 -1
- package/dist/elements/search.js +1 -1
- package/dist/elements/segmented.js +1 -1
- package/dist/elements/separator.js +1 -1
- package/dist/elements/setting-item.js +1 -1
- package/dist/elements/settings-group.js +1 -1
- package/dist/elements/skeleton.js +1 -1
- package/dist/elements/slots.d.ts +12 -0
- package/dist/elements/source.js +1 -1
- package/dist/elements/status.js +1 -1
- package/dist/elements/switch.js +1 -1
- package/dist/elements/tabs.js +1 -1
- package/dist/elements/tasks.js +1 -1
- package/dist/elements/text-shimmer.js +1 -1
- package/dist/elements/thinking-bar.js +1 -1
- package/dist/elements/thread.js +1 -1
- package/dist/elements/toast.js +1 -1
- package/dist/elements/tool.js +1 -1
- package/dist/elements/tooltip.js +1 -1
- package/dist/elements/voice-input.js +1 -1
- package/dist/elements/voice-output.js +1 -1
- package/dist/elements.d.ts +38 -0
- package/dist/index.js +18 -17
- package/dist/index.server.js +16404 -0
- package/dist/kai.es.js +1 -1
- package/dist/llms/llms-full.txt +40 -4
- package/dist/llms/llms.txt +3 -3
- package/dist/primitives/audio-bands.d.ts +106 -0
- package/dist/primitives/create-tween.d.ts +33 -0
- package/dist/primitives/use-audio-analysis.d.ts +149 -0
- package/dist/primitives/use-sequencer.d.ts +13 -0
- package/dist/primitives/use-voice-recorder.d.ts +1 -0
- package/dist/primitives/visualizer-sequences.d.ts +39 -0
- package/dist/react/index.d.ts +41 -0
- package/dist/react.js +159 -153
- package/dist/register-impl-DQyejdfb.js +145 -0
- package/dist/test-utils/fake-clock.d.ts +45 -0
- package/dist/variant-aurora-DVtGqVH6.js +218 -0
- package/dist/variant-custom-Dif-IV4u.js +1 -0
- package/dist/variant-wave-DbfNdvSi.js +116 -0
- package/frameworks/react/index.tsx +42 -0
- package/llms-full.txt +40 -4
- package/llms.txt +3 -3
- package/package.json +11 -3
- package/src/components/audio-visualizer/audio-visualizer.voice-fixture.ts +257 -0
- package/src/components/audio-visualizer/aurora.glsl.ts +330 -0
- package/src/components/audio-visualizer/index.tsx +442 -0
- package/src/components/audio-visualizer/shader-canvas.tsx +846 -0
- package/src/components/audio-visualizer/sizes.ts +54 -0
- package/src/components/audio-visualizer/variant-aurora.tsx +299 -0
- package/src/components/audio-visualizer/variant-bar.tsx +143 -0
- package/src/components/audio-visualizer/variant-custom.tsx +338 -0
- package/src/components/audio-visualizer/variant-grid.tsx +170 -0
- package/src/components/audio-visualizer/variant-radial.tsx +158 -0
- package/src/components/audio-visualizer/variant-wave.tsx +125 -0
- package/src/components/audio-visualizer/wave.glsl.ts +130 -0
- package/src/components/component-meta.json +24 -24
- package/src/elements/audio-visualizer.tsx +183 -0
- package/src/elements/compiled.css +1 -1
- package/src/elements/define.tsx +12 -1
- package/src/elements/element-manifest.json +4 -0
- package/src/elements/element-meta.json +170 -0
- package/src/elements/element-types.d.ts +38 -0
- package/src/elements/register-impl.ts +1 -0
- package/src/elements/slots.ts +35 -0
- package/src/primitives/audio-bands.ts +174 -0
- package/src/primitives/create-tween.ts +214 -0
- package/src/primitives/use-audio-analysis.ts +321 -0
- package/src/primitives/use-sequencer.ts +53 -0
- package/src/primitives/use-voice-recorder.ts +16 -4
- package/src/primitives/visualizer-sequences.ts +350 -0
- package/src/test-utils/fake-clock.ts +88 -0
- package/dist/elements/chunks/arrow-left-CifsGq8x.js +0 -1
- package/dist/elements/chunks/check-CUKS495w.js +0 -1
- package/dist/elements/chunks/chevron-down-AELmYT1j.js +0 -1
- package/dist/elements/chunks/chevron-right-C8g26oKh.js +0 -1
- package/dist/elements/chunks/circle-DGcI8Cft.js +0 -1
- package/dist/elements/chunks/circle-check-DuU0IcBQ.js +0 -1
- package/dist/elements/chunks/collapsible-DGjADCN6.js +0 -1
- package/dist/elements/chunks/define-DfTe4zwZ.js +0 -1
- package/dist/elements/chunks/message-square-CCSQ4M9g.js +0 -1
- package/dist/elements/chunks/reasoning-CW3SDdb4.js +0 -1
- package/dist/elements/chunks/scroll-button-B1x4cYqM.js +0 -1
- package/dist/elements/chunks/tooltip-CBeFqJKk.js +0 -1
- package/dist/elements/chunks/x-DeS3rQhx.js +0 -1
- package/dist/register-impl-CaaHJcea.js +0 -145
|
@@ -0,0 +1,846 @@
|
|
|
1
|
+
import { createEffect, createMemo, onCleanup, untrack, type JSX } from 'solid-js';
|
|
2
|
+
import { cn } from '../../utils/cn';
|
|
3
|
+
|
|
4
|
+
export type UniformType =
|
|
5
|
+
| '1f' | '1i' | '1fv' | '2f' | '3f' | '3fv' | '4f' | '4fv'
|
|
6
|
+
| 'Matrix2fv' | 'Matrix3fv' | 'Matrix4fv';
|
|
7
|
+
|
|
8
|
+
export interface UniformSpec {
|
|
9
|
+
type: UniformType;
|
|
10
|
+
value: number | number[];
|
|
11
|
+
/**
|
|
12
|
+
* For array uniforms (`1fv`, `3fv`, `4fv`, or a matrix type repeated), the
|
|
13
|
+
* declared length. Optional: when omitted, it is inferred from
|
|
14
|
+
* `value.length` -- see `inferArraySize`. The inferred length is exactly
|
|
15
|
+
* what the recompile check uses too (see `effectiveArraySize`), so a
|
|
16
|
+
* length change recompiles the shader whether `arraySize` was passed
|
|
17
|
+
* explicitly or left to be inferred -- e.g. a per-band uniform whose
|
|
18
|
+
* length tracks a reactive `size`/`barCount` prop stays correct across a
|
|
19
|
+
* band-count change with no extra care from the caller.
|
|
20
|
+
*/
|
|
21
|
+
arraySize?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ShaderCanvasProps {
|
|
25
|
+
/**
|
|
26
|
+
* GLSL defining `mainImage(out vec4 fragColor, in vec2 fragCoord)`.
|
|
27
|
+
*
|
|
28
|
+
* MUST output PREMULTIPLIED colour: `fragColor = vec4(rgb * alpha, alpha);`,
|
|
29
|
+
* not `vec4(rgb, alpha)`. The canvas context uses the browser default
|
|
30
|
+
* `premultipliedAlpha: true` (see `ShaderCanvas`'s doc for why), so a
|
|
31
|
+
* naturally written soft/anti-aliased edge that returns straight (not
|
|
32
|
+
* premultiplied) colour composites with a dark fringe or halo -- most
|
|
33
|
+
* visible on a light page background.
|
|
34
|
+
*/
|
|
35
|
+
fragment: string;
|
|
36
|
+
/**
|
|
37
|
+
* Custom uniforms. THIS CANVAS DECLARES THEM FOR YOU by injecting
|
|
38
|
+
* `uniform <type> <name>;` into the shader source. Declaring them yourself
|
|
39
|
+
* in `fragment` too is a GLSL redefinition and fails to compile.
|
|
40
|
+
*/
|
|
41
|
+
uniforms?: Record<string, UniformSpec>;
|
|
42
|
+
precision?: 'lowp' | 'mediump' | 'highp';
|
|
43
|
+
/**
|
|
44
|
+
* Called when the shader cannot render at all: no WebGL context, or a
|
|
45
|
+
* compile/link failure. Not called again for a later value-only uniform
|
|
46
|
+
* update (see the reactivity note on `ShaderCanvas` below) -- only when
|
|
47
|
+
* the shader itself is rebuilt and that rebuild fails.
|
|
48
|
+
*/
|
|
49
|
+
onError?: (message: string) => void;
|
|
50
|
+
/**
|
|
51
|
+
* Keep animating while the canvas is off screen. Default `false`.
|
|
52
|
+
*
|
|
53
|
+
* By default an off-screen canvas stops drawing AND hands its WebGL context
|
|
54
|
+
* back to the browser (see `ShaderCanvas`'s doc), because contexts are
|
|
55
|
+
* rationed at roughly 16 per renderer process and a page of visualizers
|
|
56
|
+
* blows through that. Set this when a canvas must keep running unseen --
|
|
57
|
+
* capturing frames, or a shader whose state must not visibly jump when it
|
|
58
|
+
* scrolls back in. The cost is one permanently-held context per canvas, so
|
|
59
|
+
* it does not scale: a page that sets this on more than a handful of
|
|
60
|
+
* visualizers is back to the eviction problem the default exists to avoid.
|
|
61
|
+
*
|
|
62
|
+
* Named to match upstream's prop of the same name, though ours opts out of
|
|
63
|
+
* something stronger: theirs only pauses the draw loop, ours also releases
|
|
64
|
+
* the context.
|
|
65
|
+
*
|
|
66
|
+
* This does NOT override `prefers-reduced-motion`. Reduced motion is applied
|
|
67
|
+
* a layer up, by the variants zeroing their own speed uniforms, so a frozen
|
|
68
|
+
* shader stays a still image whether or not this is set -- this only decides
|
|
69
|
+
* whether frames keep being drawn, never what they contain.
|
|
70
|
+
*/
|
|
71
|
+
animateWhenNotVisible?: boolean;
|
|
72
|
+
class?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Default accent for the shader variants, matching upstream's. */
|
|
76
|
+
export const DEFAULT_SHADER_COLOR = '#1FD5F9';
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* `#rrggbb` to three 0..1 floats, for a `3fv` uniform. Falls back to the
|
|
80
|
+
* default on malformed input (short forms like `#fff`, bare colour names,
|
|
81
|
+
* empty strings) rather than throwing into a render loop.
|
|
82
|
+
*
|
|
83
|
+
* Lives here, not duplicated, because wave, aurora, and custom variants all
|
|
84
|
+
* need it.
|
|
85
|
+
*/
|
|
86
|
+
export function hexToRgb(hex: string): [number, number, number] {
|
|
87
|
+
const m = hex.trim().match(/^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/);
|
|
88
|
+
if (!m) return hexToRgb(DEFAULT_SHADER_COLOR);
|
|
89
|
+
const [, r, g, b] = m;
|
|
90
|
+
return [parseInt(r!, 16) / 255, parseInt(g!, 16) / 255, parseInt(b!, 16) / 255];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const GLSL_TYPE: Record<UniformType, string> = {
|
|
94
|
+
'1f': 'float', '1i': 'int', '1fv': 'float',
|
|
95
|
+
'2f': 'vec2', '3f': 'vec3', '3fv': 'vec3', '4f': 'vec4', '4fv': 'vec4',
|
|
96
|
+
Matrix2fv: 'mat2', Matrix3fv: 'mat3', Matrix4fv: 'mat4',
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* How many raw scalars make up ONE instance of an "*fv"/matrix uniform's
|
|
101
|
+
* GLSL type -- e.g. one `3fv` (a single vec3) is 3 floats, one `Matrix4fv`
|
|
102
|
+
* (a single mat4) is 16. Only these six types take an array `value` where
|
|
103
|
+
* "array" is ambiguous between "one instance, passed as an array because
|
|
104
|
+
* that is how the `*fv` GPU setters work" and "a genuine GLSL array of N
|
|
105
|
+
* instances the caller forgot to size." `inferArraySize` below uses this to
|
|
106
|
+
* tell the two apart: `value.length` equal to one unit's size is a single
|
|
107
|
+
* instance (no `[N]`); a whole-number multiple greater than one is an
|
|
108
|
+
* unsized array, so its size gets inferred.
|
|
109
|
+
*/
|
|
110
|
+
const UNIFORM_UNIT_SIZE: Partial<Record<UniformType, number>> = {
|
|
111
|
+
'1fv': 1, '3fv': 3, '4fv': 4,
|
|
112
|
+
Matrix2fv: 4, Matrix3fv: 9, Matrix4fv: 16,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* When a caller omits `arraySize` on an array-capable uniform (`1fv`, `3fv`,
|
|
117
|
+
* `4fv`, or a matrix type) but `value` is longer than one instance of that
|
|
118
|
+
* type, infer the array length from `value.length` instead of silently
|
|
119
|
+
* declaring a scalar.
|
|
120
|
+
*
|
|
121
|
+
* This closes a real internal inconsistency: without it, `{ type: '1fv',
|
|
122
|
+
* value: [a, b, c] }` declared `uniform float name;` (scalar) while
|
|
123
|
+
* `setUniform` unconditionally called `gl.uniform1fv(location, [a, b, c])`
|
|
124
|
+
* -- a mismatch WebGL reports as `INVALID_OPERATION` with no JS exception
|
|
125
|
+
* and no `onError`, just a stale or zero value on screen. This function is
|
|
126
|
+
* what keeps the declaration and the setter from ever being able to
|
|
127
|
+
* disagree.
|
|
128
|
+
*/
|
|
129
|
+
function inferArraySize(u: UniformSpec): number | undefined {
|
|
130
|
+
const unit = UNIFORM_UNIT_SIZE[u.type];
|
|
131
|
+
if (!unit || !Array.isArray(u.value)) return undefined;
|
|
132
|
+
const instances = u.value.length / unit;
|
|
133
|
+
return instances > 1 && Number.isInteger(instances) ? instances : undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The array length a uniform will actually be declared and compiled with:
|
|
138
|
+
* the caller's explicit `arraySize` if given, otherwise `inferArraySize`'s
|
|
139
|
+
* guess from `value.length`.
|
|
140
|
+
*
|
|
141
|
+
* `buildFragmentSource` (the declaration) and `uniformShapeKey` (the
|
|
142
|
+
* recompile trigger) both call this SAME function, on purpose -- it is the
|
|
143
|
+
* one source of truth for "how big is this array," so the two can never
|
|
144
|
+
* disagree. Before this existed, an uninferred array uniform's declaration
|
|
145
|
+
* came from `value.length` but the recompile check only looked at the
|
|
146
|
+
* explicit `arraySize` field: a reactive `value.length` change (e.g. a
|
|
147
|
+
* `uBands` uniform whose length tracks a `size`/`barCount` prop, which does
|
|
148
|
+
* change while a shader variant stays mounted) would silently NOT recompile,
|
|
149
|
+
* reintroducing the exact declaration/setter mismatch this file exists to
|
|
150
|
+
* prevent, just one door over. Routing both call sites through the same
|
|
151
|
+
* function makes that impossible rather than merely documented.
|
|
152
|
+
*/
|
|
153
|
+
function effectiveArraySize(u: UniformSpec): number | undefined {
|
|
154
|
+
return u.arraySize ?? inferArraySize(u);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Every ShaderToy built-in we support, declared for every shader. */
|
|
158
|
+
const BUILTINS = [
|
|
159
|
+
'uniform float iTime;',
|
|
160
|
+
'uniform vec2 iResolution;',
|
|
161
|
+
'uniform vec4 iMouse;',
|
|
162
|
+
'uniform int iFrame;',
|
|
163
|
+
'uniform vec4 iDate;',
|
|
164
|
+
].join('\n');
|
|
165
|
+
|
|
166
|
+
const VERTEX_SOURCE = `
|
|
167
|
+
attribute vec3 aVertexPosition;
|
|
168
|
+
void main() { gl_Position = vec4(aVertexPosition, 1.0); }
|
|
169
|
+
`;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Assemble the full fragment shader: precision, built-ins, auto-declared
|
|
173
|
+
* custom uniforms, the caller's body, and a `main()` that forwards to
|
|
174
|
+
* `mainImage`.
|
|
175
|
+
*
|
|
176
|
+
* Declares every entry in `uniforms` for the caller (`uniform <type>
|
|
177
|
+
* <name>;`, injected right after the precision qualifier). Declaring the
|
|
178
|
+
* SAME uniform name inside `fragment` too is a GLSL redefinition and fails
|
|
179
|
+
* to compile -- that is the contract this function exists to protect.
|
|
180
|
+
*
|
|
181
|
+
* Split out from the component because it is the only part testable without
|
|
182
|
+
* a GPU (jsdom has no WebGL at all), and it is where both a compile-breaking
|
|
183
|
+
* duplicate declaration and an array-size mismatch would show up.
|
|
184
|
+
*/
|
|
185
|
+
export function buildFragmentSource(
|
|
186
|
+
fragment: string,
|
|
187
|
+
uniforms: Record<string, UniformSpec>,
|
|
188
|
+
precision: string,
|
|
189
|
+
): string {
|
|
190
|
+
const declarations = Object.entries(uniforms)
|
|
191
|
+
.map(([name, u]) => {
|
|
192
|
+
const arraySize = effectiveArraySize(u);
|
|
193
|
+
const size = arraySize ? `[${arraySize}]` : '';
|
|
194
|
+
return `uniform ${GLSL_TYPE[u.type]} ${name}${size};`;
|
|
195
|
+
})
|
|
196
|
+
.join('\n');
|
|
197
|
+
|
|
198
|
+
return [
|
|
199
|
+
`precision ${precision} float;`,
|
|
200
|
+
BUILTINS,
|
|
201
|
+
declarations,
|
|
202
|
+
fragment,
|
|
203
|
+
'void main() { mainImage(gl_FragColor, gl_FragCoord.xy); }',
|
|
204
|
+
].join('\n');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* A stable fingerprint of the uniforms' STRUCTURE -- name, GLSL type, and
|
|
209
|
+
* EFFECTIVE array size (`effectiveArraySize`, explicit or inferred, never
|
|
210
|
+
* raw `u.arraySize` alone) -- deliberately excluding `value` itself. Two
|
|
211
|
+
* calls with the same names/types/sizes but different values return the
|
|
212
|
+
* same string; two calls where an array's `value.length` differs, even with
|
|
213
|
+
* no explicit `arraySize` on either side, return DIFFERENT strings, because
|
|
214
|
+
* that length difference is exactly what `buildFragmentSource` would
|
|
215
|
+
* declare differently.
|
|
216
|
+
*
|
|
217
|
+
* This is what lets the component recompile only when the shader itself
|
|
218
|
+
* must change, not every time a uniform's value updates (which, for an
|
|
219
|
+
* audio-reactive uniform, is every animation frame) -- while still
|
|
220
|
+
* recompiling on every change that actually changes the declared source,
|
|
221
|
+
* including a length-only change to an inferred-size array.
|
|
222
|
+
*/
|
|
223
|
+
function uniformShapeKey(uniforms: Record<string, UniformSpec>): string {
|
|
224
|
+
return Object.keys(uniforms)
|
|
225
|
+
.sort()
|
|
226
|
+
.map((name) => {
|
|
227
|
+
const u = uniforms[name]!;
|
|
228
|
+
return `${name}:${u.type}:${effectiveArraySize(u) ?? ''}`;
|
|
229
|
+
})
|
|
230
|
+
.join('|');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function compile(gl: WebGLRenderingContext, type: number, source: string): WebGLShader | string {
|
|
234
|
+
const shader = gl.createShader(type);
|
|
235
|
+
if (!shader) return 'Could not create a WebGL shader.';
|
|
236
|
+
gl.shaderSource(shader, source);
|
|
237
|
+
gl.compileShader(shader);
|
|
238
|
+
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
|
239
|
+
const log = gl.getShaderInfoLog(shader) ?? 'Unknown shader compile error.';
|
|
240
|
+
gl.deleteShader(shader);
|
|
241
|
+
return log;
|
|
242
|
+
}
|
|
243
|
+
return shader;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Push one uniform value to the GPU, dispatching on its declared type. */
|
|
247
|
+
function setUniform(
|
|
248
|
+
gl: WebGLRenderingContext,
|
|
249
|
+
location: WebGLUniformLocation,
|
|
250
|
+
spec: UniformSpec,
|
|
251
|
+
): void {
|
|
252
|
+
const v = spec.value;
|
|
253
|
+
switch (spec.type) {
|
|
254
|
+
case '1f': gl.uniform1f(location, v as number); break;
|
|
255
|
+
case '1i': gl.uniform1i(location, v as number); break;
|
|
256
|
+
case '1fv': gl.uniform1fv(location, v as number[]); break;
|
|
257
|
+
case '2f': gl.uniform2fv(location, v as number[]); break;
|
|
258
|
+
case '3f': case '3fv': gl.uniform3fv(location, v as number[]); break;
|
|
259
|
+
case '4f': case '4fv': gl.uniform4fv(location, v as number[]); break;
|
|
260
|
+
case 'Matrix2fv': gl.uniformMatrix2fv(location, false, v as number[]); break;
|
|
261
|
+
case 'Matrix3fv': gl.uniformMatrix3fv(location, false, v as number[]); break;
|
|
262
|
+
case 'Matrix4fv': gl.uniformMatrix4fv(location, false, v as number[]); break;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* A full-screen fragment shader on a canvas, ShaderToy-compatible.
|
|
268
|
+
*
|
|
269
|
+
* Deliberately does NOT support textures, `iChannel` inputs, video,
|
|
270
|
+
* multipass, or device orientation. Those are what make upstream's runner
|
|
271
|
+
* 988 lines, and an audio visualizer needs none of them.
|
|
272
|
+
*
|
|
273
|
+
* Renders with a fully transparent clear colour (`gl.clearColor(0,0,0,0)`).
|
|
274
|
+
* Compositing over the page comes from the canvas context's default
|
|
275
|
+
* `premultipliedAlpha: true`, NOT from `gl.blendFunc` (see the comment at
|
|
276
|
+
* the blend call site for why that call has no effect on today's
|
|
277
|
+
* single-pass output). Because of that default, `fragment` MUST output
|
|
278
|
+
* premultiplied colour -- see the doc on the `fragment` prop for the exact
|
|
279
|
+
* contract.
|
|
280
|
+
*
|
|
281
|
+
* Reactivity note: only `fragment`, `precision`, and the STRUCTURE of
|
|
282
|
+
* `uniforms` (which names exist, with which types/sizes) trigger a rebuild
|
|
283
|
+
* of the GL program. A uniform's VALUE is read fresh every animation frame
|
|
284
|
+
* without forcing a rebuild -- see `uniformShapeKey` above. This matters
|
|
285
|
+
* because a caller typically re-creates the `uniforms` object every render
|
|
286
|
+
* (e.g. `{ uVolume: { type: '1f', value: volume() } }`); tracking that
|
|
287
|
+
* object directly would tear down and recompile the whole program on every
|
|
288
|
+
* frame the volume changes. `fragment` and `precision` are ALSO read through
|
|
289
|
+
* their own memos, not directly, for the same reason: a variant's `precision`
|
|
290
|
+
* is commonly computed from a prop (`size`) that arrives via a dispatcher
|
|
291
|
+
* spread bundling unrelated fast-changing signals (`bands`), so an unmemoized
|
|
292
|
+
* read can mark this effect stale on every one of those, even though
|
|
293
|
+
* `precision`'s resolved value never changes. See the comment above
|
|
294
|
+
* `fragmentMemo`/`precisionMemo` below for the measured failure this caused.
|
|
295
|
+
*
|
|
296
|
+
* Visibility: while the canvas is off screen this canvas RELEASES its WebGL
|
|
297
|
+
* context outright, and takes it back when the canvas returns -- see the
|
|
298
|
+
* `ContextState` block inside the component for the whole mechanism and the
|
|
299
|
+
* context-budget measurement that forced it. There is no prop for this; it is
|
|
300
|
+
* always on.
|
|
301
|
+
*/
|
|
302
|
+
export function ShaderCanvas(props: ShaderCanvasProps): JSX.Element {
|
|
303
|
+
let canvas!: HTMLCanvasElement;
|
|
304
|
+
|
|
305
|
+
const shapeKey = createMemo(() => uniformShapeKey(props.uniforms ?? {}));
|
|
306
|
+
// `fragment` and `precision` get the SAME insulation as `shapeKey` above,
|
|
307
|
+
// and for the same underlying reason. A variant's props are commonly built
|
|
308
|
+
// with `{...shared()}` (see index.tsx's dispatcher: `shared()` returns
|
|
309
|
+
// `state`/`size`/`bands`/`frozen`/`color` together). Solid's spread-prop
|
|
310
|
+
// merging re-invokes the WHOLE source function on ANY property read from
|
|
311
|
+
// the merged result -- so a variant computing `precision` from `props.size`
|
|
312
|
+
// (e.g. aurora/wave's `props.size === 'icon' || ... ? 'mediump' :
|
|
313
|
+
// 'highp'`) transitively re-runs `shared()`, and thus re-reads `bands()`,
|
|
314
|
+
// on every read of `precision`, even though `size` itself never changes.
|
|
315
|
+
// Reading `props.precision` directly inside the compile effect below
|
|
316
|
+
// subscribed it to THAT transitive `bands()` read -- at ~31 band updates a
|
|
317
|
+
// second, `precision`'s own resolved value never changed, but the effect
|
|
318
|
+
// reran on every single one anyway, tearing down and recompiling the whole
|
|
319
|
+
// GL program 15-20 times a second in production (measured on the Aurora
|
|
320
|
+
// story: ~70 recompiles in 4s, `iTime` pinned under 0.33s and periodically
|
|
321
|
+
// NEGATIVE from `start` being re-stamped mid-flight). `shapeKey` already
|
|
322
|
+
// proved this class of leak is real for `uniforms`; `fragment`/`precision`
|
|
323
|
+
// needed the identical fix, matching the one just applied to
|
|
324
|
+
// `use-sequencer.ts` for the same root cause (an effect transitively
|
|
325
|
+
// subscribed to a signal it should not be, via a prop getter chain) --
|
|
326
|
+
// wrap in a memo so only the RESOLVED value, never the act of reading it,
|
|
327
|
+
// can mark the effect stale.
|
|
328
|
+
const fragmentMemo = createMemo(() => props.fragment);
|
|
329
|
+
const precisionMemo = createMemo(() => props.precision ?? 'highp');
|
|
330
|
+
|
|
331
|
+
// ------------------------------------------------------------------------
|
|
332
|
+
// Context lifecycle.
|
|
333
|
+
//
|
|
334
|
+
// Chrome caps LIVE WebGL contexts at about 16 per renderer process and
|
|
335
|
+
// silently evicts the oldest past that -- a budget shared across
|
|
336
|
+
// same-origin iframes too, so splitting the page up buys nothing (measured:
|
|
337
|
+
// the AudioVisualizer docs page wanted 18, got 16, and 2 canvases failed to
|
|
338
|
+
// compile with no error anywhere). Holding a context for the component's
|
|
339
|
+
// whole life is what made that page unable to host the shader stories at
|
|
340
|
+
// all.
|
|
341
|
+
//
|
|
342
|
+
// Merely pausing the draw loop off screen -- which is all upstream's
|
|
343
|
+
// runner does -- does NOT return a slot: an idle context still occupies
|
|
344
|
+
// one. So an off-screen canvas gives the context BACK, via
|
|
345
|
+
// `WEBGL_lose_context`'s `loseContext()`, and asks for it again with
|
|
346
|
+
// `restoreContext()` on the way in. Consequence worth stating plainly: N
|
|
347
|
+
// off-screen visualizers now hold ZERO contexts between them, and a page
|
|
348
|
+
// holds one per canvas actually in the viewport.
|
|
349
|
+
//
|
|
350
|
+
// All of this state is COMPONENT-scoped rather than living inside the
|
|
351
|
+
// compile effect, because a context belongs to the CANVAS, which outlives
|
|
352
|
+
// any one run of that effect. A shader released while off screen must still
|
|
353
|
+
// be restorable after a recompile (a `size`/band-count change genuinely
|
|
354
|
+
// rebuilds the shader while a tile is scrolled away).
|
|
355
|
+
// ------------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Where the canvas's context is, independent of whether a program is
|
|
359
|
+
* currently compiled against it (that is `teardown`).
|
|
360
|
+
*
|
|
361
|
+
* `losing` and `restoring` are the IN-FLIGHT states, and they are the whole
|
|
362
|
+
* reason this is a state machine rather than a boolean:
|
|
363
|
+
* `loseContext()`/`restoreContext()` do not take effect synchronously --
|
|
364
|
+
* the browser answers later with `webglcontextlost`/`webglcontextrestored`.
|
|
365
|
+
* A canvas that scrolls out and back in before the answer arrives must sit
|
|
366
|
+
* in the in-flight state and re-decide when the event lands, never start a
|
|
367
|
+
* second draw loop or compile against a context that is still gone.
|
|
368
|
+
*/
|
|
369
|
+
type ContextState = 'none' | 'live' | 'losing' | 'lost' | 'restoring';
|
|
370
|
+
let contextState: ContextState = 'none';
|
|
371
|
+
let context: WebGLRenderingContext | null = null;
|
|
372
|
+
/**
|
|
373
|
+
* Cached WHILE THE CONTEXT IS ALIVE, deliberately: a lost context answers
|
|
374
|
+
* `getExtension` with null, so re-fetching it on the way back in is a dead
|
|
375
|
+
* end. This handle is the only thing that can restore what we released.
|
|
376
|
+
*/
|
|
377
|
+
let loseExtension: WEBGL_lose_context | null = null;
|
|
378
|
+
|
|
379
|
+
/** Undoes everything the current activation built. Null when nothing is built. */
|
|
380
|
+
let teardown: (() => void) | null = null;
|
|
381
|
+
/** The current frame callback. Null when nothing is built. */
|
|
382
|
+
let drawFrame: ((now: number) => void) | null = null;
|
|
383
|
+
/** The pending animation frame id, or 0 when the loop is stopped. */
|
|
384
|
+
let raf = 0;
|
|
385
|
+
|
|
386
|
+
/** The assembled source and the uniform snapshot it was assembled from. */
|
|
387
|
+
let build: { source: string; uniforms: Record<string, UniformSpec> } | null = null;
|
|
388
|
+
/** THIS build cannot compile; reset whenever the effect produces a new one. */
|
|
389
|
+
let buildFailed = false;
|
|
390
|
+
/** No WebGL at all. Permanent for this mount, matching `onError`'s contract. */
|
|
391
|
+
let noWebGL = false;
|
|
392
|
+
/** The BROWSER took the context away (not us). Permanent for this mount. */
|
|
393
|
+
let hardLost = false;
|
|
394
|
+
let disposed = false;
|
|
395
|
+
|
|
396
|
+
let visibility: IntersectionObserver | undefined;
|
|
397
|
+
let wired = false;
|
|
398
|
+
/**
|
|
399
|
+
* Whether the canvas is on screen. Starts true and STAYS true when there is
|
|
400
|
+
* no `IntersectionObserver` to consult (SSR, jsdom, older browsers), which
|
|
401
|
+
* is what keeps that environment on exactly the pre-observer behaviour:
|
|
402
|
+
* compile on mount, draw forever.
|
|
403
|
+
*/
|
|
404
|
+
let onScreen = true;
|
|
405
|
+
/**
|
|
406
|
+
* The resolved `animateWhenNotVisible`, cached rather than read from props
|
|
407
|
+
* inside `sync`. `sync` runs inside the compile effect, and a props read
|
|
408
|
+
* there would subscribe that effect to this flag -- rebuilding the entire GL
|
|
409
|
+
* program every time a caller toggled it, the exact leak the fragment /
|
|
410
|
+
* precision / shapeKey memos above exist to prevent. Its own effect below is
|
|
411
|
+
* the single writer.
|
|
412
|
+
*/
|
|
413
|
+
let alwaysAnimate = false;
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* The shader clock, deliberately outliving both a release and a recompile.
|
|
417
|
+
*
|
|
418
|
+
* `iTime` is ON-SCREEN time: it accumulates only while the loop runs, so a
|
|
419
|
+
* canvas that comes back after a minute off screen picks up exactly where
|
|
420
|
+
* the viewer last saw it rather than jumping a minute forward. Freezing
|
|
421
|
+
* rather than tracking wall-clock is a choice -- nothing here is
|
|
422
|
+
* synchronised to real time, so the only observable difference is a
|
|
423
|
+
* discontinuity on scroll-in, and there is no reason to have one.
|
|
424
|
+
*
|
|
425
|
+
* What it must never do is RESTART. A previous round shipped a defect where
|
|
426
|
+
* a spuriously re-running compile effect re-stamped the clock's origin every
|
|
427
|
+
* few frames, pinning `iTime` under 0.33s forever (shaders looked like they
|
|
428
|
+
* were looping a third of a second). The memos above are the fix for that
|
|
429
|
+
* cause; keeping the clock out here means even a legitimate recompile -- a
|
|
430
|
+
* band-count change, say -- no longer snaps the animation back to zero, so
|
|
431
|
+
* the whole class of "the shader restarted" bugs has one fewer door.
|
|
432
|
+
* `frame` (`iFrame`) is kept monotonic for the same reason.
|
|
433
|
+
*/
|
|
434
|
+
let elapsedMs = 0;
|
|
435
|
+
let runStartedAt = 0;
|
|
436
|
+
let frame = 0;
|
|
437
|
+
|
|
438
|
+
const acquireContext = (): WebGLRenderingContext | null => {
|
|
439
|
+
if (context) return context;
|
|
440
|
+
context = (canvas.getContext('webgl') ??
|
|
441
|
+
canvas.getContext('experimental-webgl')) as WebGLRenderingContext | null;
|
|
442
|
+
if (context) {
|
|
443
|
+
contextState = 'live';
|
|
444
|
+
// Optional call: every real context implements `getExtension`, but a
|
|
445
|
+
// context stub that does not should degrade to "cannot release" rather
|
|
446
|
+
// than throwing out of the compile path.
|
|
447
|
+
loseExtension = context.getExtension?.('WEBGL_lose_context') ?? null;
|
|
448
|
+
}
|
|
449
|
+
return context;
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
const startLoop = () => {
|
|
453
|
+
// The one gate that makes a second concurrent loop impossible, however
|
|
454
|
+
// fast visibility toggles: a loop is running iff `raf !== 0` (a real
|
|
455
|
+
// animation-frame id is never 0).
|
|
456
|
+
if (raf !== 0 || !drawFrame || disposed) return;
|
|
457
|
+
runStartedAt = performance.now();
|
|
458
|
+
raf = requestAnimationFrame(drawFrame);
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
const stopLoop = () => {
|
|
462
|
+
if (raf === 0) return;
|
|
463
|
+
cancelAnimationFrame(raf);
|
|
464
|
+
raf = 0;
|
|
465
|
+
// Bank what was drawn so `iTime` resumes from here, not from zero.
|
|
466
|
+
elapsedMs += Math.max(0, performance.now() - runStartedAt);
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
/** Drop the compiled program/shaders/buffer, leaving the context itself alone. */
|
|
470
|
+
const release = () => {
|
|
471
|
+
stopLoop();
|
|
472
|
+
const run = teardown;
|
|
473
|
+
teardown = null;
|
|
474
|
+
run?.();
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
const requestLoss = () => {
|
|
478
|
+
if (!loseExtension || contextState !== 'live') return;
|
|
479
|
+
contextState = 'losing';
|
|
480
|
+
loseExtension.loseContext();
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
const requestRestore = () => {
|
|
484
|
+
if (!loseExtension) return;
|
|
485
|
+
contextState = 'restoring';
|
|
486
|
+
loseExtension.restoreContext();
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Reconcile what is built against where the canvas is. Every input --
|
|
491
|
+
* a visibility change, a context event, a new compiled source -- routes
|
|
492
|
+
* through here rather than acting directly, which is what keeps the
|
|
493
|
+
* in-flight windows from producing two loops or a half-initialised context.
|
|
494
|
+
*/
|
|
495
|
+
const sync = () => {
|
|
496
|
+
if (disposed || noWebGL || hardLost) return;
|
|
497
|
+
// The ONE place visibility policy is decided. `animateWhenNotVisible`
|
|
498
|
+
// overrides the observer's verdict here rather than anywhere upstream of
|
|
499
|
+
// it, so there is still exactly one writer deciding whether this canvas
|
|
500
|
+
// should be drawing -- a runtime flip of the flag is just another call
|
|
501
|
+
// into this same reconciler, like a scroll or a context event.
|
|
502
|
+
if (onScreen || alwaysAnimate) {
|
|
503
|
+
// Mid-flight: the `webglcontextlost`/`webglcontextrestored` handler
|
|
504
|
+
// re-enters here once the browser answers.
|
|
505
|
+
if (contextState === 'losing' || contextState === 'restoring') return;
|
|
506
|
+
if (contextState === 'lost') { requestRestore(); return; }
|
|
507
|
+
activate();
|
|
508
|
+
} else if (loseExtension && contextState === 'live') {
|
|
509
|
+
// Resources first, THEN the context: deleting GPU objects after the
|
|
510
|
+
// context is gone is a spec no-op, which leaks them for as long as the
|
|
511
|
+
// driver keeps the dead context around.
|
|
512
|
+
release();
|
|
513
|
+
requestLoss();
|
|
514
|
+
} else {
|
|
515
|
+
// No way to hand the context back (no extension), or it is already
|
|
516
|
+
// gone. Stop burning frames either way -- upstream's pause, as the
|
|
517
|
+
// floor rather than the ceiling.
|
|
518
|
+
stopLoop();
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
const setOnScreen = (next: boolean) => {
|
|
523
|
+
if (next === onScreen || disposed) return;
|
|
524
|
+
onScreen = next;
|
|
525
|
+
sync();
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
const onContextLost = (e: Event) => {
|
|
529
|
+
// Required by spec to even ALLOW a later `webglcontextrestored` --
|
|
530
|
+
// including for the losses this component asks for itself.
|
|
531
|
+
e.preventDefault();
|
|
532
|
+
const deliberate = contextState === 'losing';
|
|
533
|
+
contextState = 'lost';
|
|
534
|
+
release();
|
|
535
|
+
if (deliberate) {
|
|
536
|
+
// Ours, to free the budget while off screen. If the canvas scrolled
|
|
537
|
+
// back in while the event was in flight, this is where it gets restored.
|
|
538
|
+
sync();
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
// The browser evicted us -- most commonly because the page blew past the
|
|
542
|
+
// context cap. Permanent for this mount: `onError` is what lets the
|
|
543
|
+
// dispatcher fall back to bars. Without this the draw loop would keep
|
|
544
|
+
// issuing GL calls that are all silent no-ops on a lost context, leaving a
|
|
545
|
+
// shader frozen mid-frame with nothing reported anywhere.
|
|
546
|
+
hardLost = true;
|
|
547
|
+
props.onError?.('WebGL context was lost.');
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
const onContextRestored = () => {
|
|
551
|
+
contextState = 'live';
|
|
552
|
+
// Rebuild if the canvas is still on screen, or hand the context straight
|
|
553
|
+
// back if it scrolled away again while the restore was in flight.
|
|
554
|
+
sync();
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
const wireCanvas = () => {
|
|
558
|
+
if (wired) return;
|
|
559
|
+
wired = true;
|
|
560
|
+
canvas.addEventListener('webglcontextlost', onContextLost, false);
|
|
561
|
+
canvas.addEventListener('webglcontextrestored', onContextRestored, false);
|
|
562
|
+
|
|
563
|
+
if (typeof IntersectionObserver === 'undefined') return;
|
|
564
|
+
|
|
565
|
+
// The observer is installed even when `animateWhenNotVisible` is set, and
|
|
566
|
+
// its verdict is ignored in `sync` instead. Skipping the observer in that
|
|
567
|
+
// mode would be marginally cheaper, but it would leave `onScreen` frozen
|
|
568
|
+
// at a stale default, so flipping the flag OFF at runtime would need the
|
|
569
|
+
// observer wired up right then and would keep drawing until its first
|
|
570
|
+
// callback landed -- a second control path, and a wrong answer in the
|
|
571
|
+
// meantime. Keeping it always-on means `onScreen` is continuously
|
|
572
|
+
// accurate and a flip in either direction is a single `sync()` call.
|
|
573
|
+
// An IntersectionObserver that never fires costs essentially nothing.
|
|
574
|
+
//
|
|
575
|
+
// With an observer, NOTHING is built until it reports the canvas on
|
|
576
|
+
// screen -- unless `animateWhenNotVisible` says otherwise, which `sync`
|
|
577
|
+
// resolves below. That is the load-bearing half of the fix: a page
|
|
578
|
+
// mounting 18 visualizers otherwise creates 18 contexts in the mount task
|
|
579
|
+
// and has 2 evicted by the browser before the first observer callback
|
|
580
|
+
// could possibly run -- and an evicted context is a permanent failure,
|
|
581
|
+
// not a recoverable one.
|
|
582
|
+
onScreen = false;
|
|
583
|
+
visibility = new IntersectionObserver((entries) => {
|
|
584
|
+
const entry = entries[entries.length - 1];
|
|
585
|
+
if (entry) setOnScreen(entry.isIntersecting);
|
|
586
|
+
}, { threshold: 0 });
|
|
587
|
+
visibility.observe(canvas);
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
const activate = () => {
|
|
591
|
+
if (disposed || noWebGL || hardLost || buildFailed) return;
|
|
592
|
+
const current = build;
|
|
593
|
+
if (!current) return;
|
|
594
|
+
|
|
595
|
+
// Already built and only the loop was stopped (the no-extension path
|
|
596
|
+
// above): re-arm it rather than recompiling.
|
|
597
|
+
if (teardown) { startLoop(); return; }
|
|
598
|
+
|
|
599
|
+
const gl = acquireContext();
|
|
600
|
+
if (!gl) {
|
|
601
|
+
noWebGL = true;
|
|
602
|
+
props.onError?.('WebGL is not available in this browser.');
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
if (gl.isContextLost()) {
|
|
606
|
+
// Released earlier -- possibly by a PREVIOUS run of the compile effect,
|
|
607
|
+
// which is why `loseExtension` is cached at component scope. Ask for it
|
|
608
|
+
// back and let `webglcontextrestored` re-enter here.
|
|
609
|
+
requestRestore();
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const { source, uniforms } = current;
|
|
614
|
+
|
|
615
|
+
const vs = compile(gl, gl.VERTEX_SHADER, VERTEX_SOURCE);
|
|
616
|
+
if (typeof vs === 'string') {
|
|
617
|
+
buildFailed = true;
|
|
618
|
+
props.onError?.(vs);
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
const fs = compile(gl, gl.FRAGMENT_SHADER, source);
|
|
622
|
+
if (typeof fs === 'string') {
|
|
623
|
+
gl.deleteShader(vs);
|
|
624
|
+
buildFailed = true;
|
|
625
|
+
props.onError?.(fs);
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const program = gl.createProgram();
|
|
630
|
+
if (!program) {
|
|
631
|
+
gl.deleteShader(vs);
|
|
632
|
+
gl.deleteShader(fs);
|
|
633
|
+
buildFailed = true;
|
|
634
|
+
props.onError?.('Could not create a WebGL program.');
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
gl.attachShader(program, vs);
|
|
638
|
+
gl.attachShader(program, fs);
|
|
639
|
+
gl.linkProgram(program);
|
|
640
|
+
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
|
641
|
+
const log = gl.getProgramInfoLog(program) ?? 'Shader program failed to link.';
|
|
642
|
+
gl.deleteProgram(program);
|
|
643
|
+
gl.deleteShader(vs);
|
|
644
|
+
gl.deleteShader(fs);
|
|
645
|
+
buildFailed = true;
|
|
646
|
+
props.onError?.(log);
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
gl.useProgram(program);
|
|
650
|
+
|
|
651
|
+
// Two triangles covering clip space. The fragment shader does the rest.
|
|
652
|
+
const buffer = gl.createBuffer();
|
|
653
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
|
654
|
+
gl.bufferData(
|
|
655
|
+
gl.ARRAY_BUFFER,
|
|
656
|
+
new Float32Array([-1, -1, 0, 1, -1, 0, -1, 1, 0, 1, 1, 0]),
|
|
657
|
+
gl.STATIC_DRAW,
|
|
658
|
+
);
|
|
659
|
+
const attr = gl.getAttribLocation(program, 'aVertexPosition');
|
|
660
|
+
gl.enableVertexAttribArray(attr);
|
|
661
|
+
gl.vertexAttribPointer(attr, 3, gl.FLOAT, false, 0, 0);
|
|
662
|
+
|
|
663
|
+
// This blendFunc has NO effect on today's output: `gl.clear` runs
|
|
664
|
+
// immediately before the single `gl.drawArrays` every frame (see draw()
|
|
665
|
+
// below), so the framebuffer is always transparent-black at draw time --
|
|
666
|
+
// there is nothing behind the draw call to blend against. The actual
|
|
667
|
+
// transparency mechanism is the canvas context's default
|
|
668
|
+
// `premultipliedAlpha: true` combined with `clearColor(0,0,0,0)` below:
|
|
669
|
+
// the browser composites the canvas's own premultiplied RGBA buffer over
|
|
670
|
+
// the page. That default is also why `fragment` must output premultiplied
|
|
671
|
+
// colour (see the `fragment` prop's doc) -- an unpremultiplied translucent
|
|
672
|
+
// edge produces a dark fringe on a light page.
|
|
673
|
+
//
|
|
674
|
+
// The call stays enabled anyway: it is exactly what a future multipass or
|
|
675
|
+
// non-clearing render path would need, and silently dropping it now would
|
|
676
|
+
// be a behavioural change for that later path, not a cleanup.
|
|
677
|
+
gl.enable(gl.BLEND);
|
|
678
|
+
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
|
|
679
|
+
|
|
680
|
+
const loc = (name: string) => gl.getUniformLocation(program, name);
|
|
681
|
+
const uTime = loc('iTime');
|
|
682
|
+
const uResolution = loc('iResolution');
|
|
683
|
+
const uMouse = loc('iMouse');
|
|
684
|
+
const uFrame = loc('iFrame');
|
|
685
|
+
const uDate = loc('iDate');
|
|
686
|
+
const customLocations = Object.keys(uniforms).map((n) => [n, loc(n)] as const);
|
|
687
|
+
|
|
688
|
+
// Only .xy (pointer position) is implemented. ShaderToy's iMouse.zw
|
|
689
|
+
// carries click-down position; this canvas has no pointerdown tracking,
|
|
690
|
+
// so .zw stays permanently zero.
|
|
691
|
+
const mouse = [0, 0, 0, 0];
|
|
692
|
+
const onMove = (e: PointerEvent) => {
|
|
693
|
+
const r = canvas.getBoundingClientRect();
|
|
694
|
+
mouse[0] = e.clientX - r.left;
|
|
695
|
+
mouse[1] = r.height - (e.clientY - r.top);
|
|
696
|
+
};
|
|
697
|
+
canvas.addEventListener('pointermove', onMove, { passive: true });
|
|
698
|
+
|
|
699
|
+
// Match the backing store to the displayed size so the shader is not blurry.
|
|
700
|
+
const resize = () => {
|
|
701
|
+
const dpr = globalThis.devicePixelRatio ?? 1;
|
|
702
|
+
const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));
|
|
703
|
+
const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));
|
|
704
|
+
if (canvas.width !== w || canvas.height !== h) {
|
|
705
|
+
canvas.width = w;
|
|
706
|
+
canvas.height = h;
|
|
707
|
+
gl.viewport(0, 0, w, h);
|
|
708
|
+
}
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(resize) : undefined;
|
|
712
|
+
ro?.observe(canvas);
|
|
713
|
+
resize();
|
|
714
|
+
|
|
715
|
+
const draw = (now: number) => {
|
|
716
|
+
// Belt-and-braces alongside the `webglcontextlost` listener: if a
|
|
717
|
+
// context goes away by some path that does not fire the event
|
|
718
|
+
// synchronously before the next frame, this still stops issuing GL
|
|
719
|
+
// calls against it.
|
|
720
|
+
if (hardLost || gl.isContextLost()) return;
|
|
721
|
+
resize();
|
|
722
|
+
// Clamped at 0: a browser's rAF callback receives the timestamp for
|
|
723
|
+
// when the current frame BEGAN, captured before this component's
|
|
724
|
+
// synchronous setup finishes and stamps `runStartedAt` -- so the very
|
|
725
|
+
// first frame or two can compute a few milliseconds negative even with
|
|
726
|
+
// nothing wrong. Harmless and self-correcting either way (confirmed:
|
|
727
|
+
// the pathological, CONTINUOUSLY-recurring negative values reported in
|
|
728
|
+
// production were the clock origin being re-stamped by a spuriously
|
|
729
|
+
// re-running effect, not this), but there is no reason `iTime` should
|
|
730
|
+
// ever go negative for a caller, so it does not.
|
|
731
|
+
const seconds = Math.max(0, (elapsedMs + (now - runStartedAt)) / 1000);
|
|
732
|
+
|
|
733
|
+
if (uTime) gl.uniform1f(uTime, seconds);
|
|
734
|
+
if (uResolution) gl.uniform2fv(uResolution, [canvas.width, canvas.height]);
|
|
735
|
+
if (uMouse) gl.uniform4fv(uMouse, mouse);
|
|
736
|
+
if (uFrame) gl.uniform1i(uFrame, frame);
|
|
737
|
+
if (uDate) {
|
|
738
|
+
const d = new Date();
|
|
739
|
+
gl.uniform4fv(uDate, [
|
|
740
|
+
d.getFullYear(), d.getMonth(), d.getDate(),
|
|
741
|
+
d.getHours() * 3600 + d.getMinutes() * 60 + d.getSeconds(),
|
|
742
|
+
]);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// Read straight off props, not the closed-over `uniforms` snapshot, so
|
|
746
|
+
// a value change (e.g. volume ticking every frame) takes effect
|
|
747
|
+
// immediately without going through the compile effect above at all.
|
|
748
|
+
for (const [name, location] of customLocations) {
|
|
749
|
+
const spec = props.uniforms?.[name];
|
|
750
|
+
if (spec && location) setUniform(gl, location, spec);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
gl.clearColor(0, 0, 0, 0);
|
|
754
|
+
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
755
|
+
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
|
756
|
+
|
|
757
|
+
frame++;
|
|
758
|
+
raf = requestAnimationFrame(draw);
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
teardown = () => {
|
|
762
|
+
drawFrame = null;
|
|
763
|
+
ro?.disconnect();
|
|
764
|
+
canvas.removeEventListener('pointermove', onMove);
|
|
765
|
+
gl.deleteProgram(program);
|
|
766
|
+
gl.deleteShader(vs);
|
|
767
|
+
gl.deleteShader(fs);
|
|
768
|
+
gl.deleteBuffer(buffer);
|
|
769
|
+
};
|
|
770
|
+
drawFrame = draw;
|
|
771
|
+
startLoop();
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
onCleanup(() => {
|
|
775
|
+
disposed = true;
|
|
776
|
+
visibility?.disconnect();
|
|
777
|
+
if (wired) {
|
|
778
|
+
canvas.removeEventListener('webglcontextlost', onContextLost);
|
|
779
|
+
canvas.removeEventListener('webglcontextrestored', onContextRestored);
|
|
780
|
+
}
|
|
781
|
+
release();
|
|
782
|
+
// Hand the budget back NOW rather than waiting for the detached canvas to
|
|
783
|
+
// be collected: switching variants, or navigating a page full of tiles,
|
|
784
|
+
// otherwise leaves dead contexts holding slots for a while.
|
|
785
|
+
if (loseExtension && contextState === 'live') loseExtension.loseContext();
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
// Registered before the compile effect so `alwaysAnimate` already holds the
|
|
789
|
+
// caller's policy the first time `sync()` runs, avoiding one reconcile pass
|
|
790
|
+
// that could only ever decide to do nothing. Measured, not assumed: putting
|
|
791
|
+
// this effect AFTER the compile effect passes every test in this file
|
|
792
|
+
// unchanged, because both run in the same synchronous flush either way and
|
|
793
|
+
// the canvas is built before anything can observe it. So this is tidiness,
|
|
794
|
+
// NOT a correctness requirement -- what actually guarantees an opted-out
|
|
795
|
+
// canvas builds without waiting on an observer callback is `sync`'s
|
|
796
|
+
// `onScreen || alwaysAnimate`, which is the thing under test.
|
|
797
|
+
//
|
|
798
|
+
// Wrapped in its own memo for the same reason `fragment`/`precision` are:
|
|
799
|
+
// a variant's props arrive through a dispatcher spread that bundles
|
|
800
|
+
// fast-changing signals, so an unmemoized read would re-run this effect at
|
|
801
|
+
// band cadence even though the resolved boolean never changed.
|
|
802
|
+
const animateWhenNotVisibleMemo = createMemo(() => props.animateWhenNotVisible ?? false);
|
|
803
|
+
createEffect(() => {
|
|
804
|
+
alwaysAnimate = animateWhenNotVisibleMemo();
|
|
805
|
+
// Route the change through the reconciler rather than acting on it here:
|
|
806
|
+
// whether this means "build now", "release now", or "nothing changes"
|
|
807
|
+
// depends on state `sync` already owns.
|
|
808
|
+
sync();
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
createEffect(() => {
|
|
812
|
+
// Reads ONLY the three memos below -- never `props.fragment` /
|
|
813
|
+
// `props.precision` / `props.uniforms` directly -- so this effect's
|
|
814
|
+
// dependency set is exactly {fragmentMemo, precisionMemo, shapeKey}, and
|
|
815
|
+
// nothing a caller's props chain does elsewhere (spread-derived or not)
|
|
816
|
+
// can mark it stale without one of those three RESOLVED values actually
|
|
817
|
+
// changing.
|
|
818
|
+
//
|
|
819
|
+
// Note what this effect does NOT do any more: touch the GPU. It resolves
|
|
820
|
+
// the source and hands it to `sync`, which owns every decision about
|
|
821
|
+
// whether a context should exist right now. That split is what lets the
|
|
822
|
+
// SAME setup path serve a first mount, a recompile, and a scroll-back-in
|
|
823
|
+
// restore, instead of one path per trigger.
|
|
824
|
+
const fragment = fragmentMemo();
|
|
825
|
+
const precision = precisionMemo();
|
|
826
|
+
shapeKey();
|
|
827
|
+
const uniforms = untrack(() => props.uniforms ?? {});
|
|
828
|
+
|
|
829
|
+
build = { source: buildFragmentSource(fragment, uniforms, precision), uniforms };
|
|
830
|
+
// A new source gets a fresh attempt: a shader that failed to compile must
|
|
831
|
+
// not poison the next one the caller supplies.
|
|
832
|
+
buildFailed = false;
|
|
833
|
+
|
|
834
|
+
wireCanvas();
|
|
835
|
+
sync();
|
|
836
|
+
|
|
837
|
+
// Runs before the NEXT execution of this effect as well as on unmount, so
|
|
838
|
+
// a source change always drops the program compiled from the old source
|
|
839
|
+
// before `sync` builds the new one. The context itself deliberately
|
|
840
|
+
// survives -- it is the expensive, rationed thing, and the new source
|
|
841
|
+
// needs it immediately.
|
|
842
|
+
onCleanup(release);
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
return <canvas ref={canvas} part="canvas" class={cn('block h-full w-full', props.class)} />;
|
|
846
|
+
}
|