vectorvesper 2.7.0 → 2.8.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/README.md +2 -2
- package/dist/hooks.json +721 -56
- package/dist/index.js +14 -18
- package/dist/{mcp-RHYAXOSU.js → mcp-D5QWQ2PC.js} +2 -2
- package/dist/{server-DVFJRSZW.js → server-BEBUGQ7F.js} +236 -15
- package/package.json +2 -2
package/dist/hooks.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": "1.0.0",
|
|
3
|
-
"engineVersion": "4.0
|
|
4
|
-
"generatedAt": "2026-09-
|
|
3
|
+
"engineVersion": "4.2.0",
|
|
4
|
+
"generatedAt": "2026-09-24T15:22:31.361Z",
|
|
5
5
|
"contractLevel": "full",
|
|
6
6
|
"hooks": [
|
|
7
7
|
{
|
|
@@ -608,16 +608,18 @@
|
|
|
608
608
|
"context restored",
|
|
609
609
|
"canvas recovery",
|
|
610
610
|
"generation counter",
|
|
611
|
-
"black canvas"
|
|
611
|
+
"black canvas",
|
|
612
|
+
"reportHealthy",
|
|
613
|
+
"builtAt"
|
|
612
614
|
],
|
|
613
615
|
"exports": [
|
|
614
616
|
"getRendererHealth"
|
|
615
617
|
],
|
|
616
618
|
"tagline": "Notices a lost graphics context and counts a generation to remount on.",
|
|
617
619
|
"problem": "A browser can take a WebGL context away at any moment: a driver reset, a backgrounded tab, too many live contexts on one page. React Three Fiber has no handler for it, so what a visitor gets is a permanently black canvas and a completely clean console.",
|
|
618
|
-
"summary": "getRendererHealth() returns a page-level singleton holding whether a context is currently lost and a generation number that increments on each loss. Put that generation on a canvas key and React throws the dead tree away and builds a working one.",
|
|
619
|
-
"mechanism": "A counter and a boolean, deliberately small. No context is created or restored here: something that owns a renderer reports a loss, the generation moves, and whatever renders the canvas is expected to rebuild.\n\nIt exposes a generation rather than a restored event because the restore path is not reliable across drivers. Some refuse a replacement context on the page entirely if the loss event was not cancelled, which is why whoever attaches the listener has to call preventDefault before reporting. Rebuilding from a key sidesteps the question: a fresh context, with every texture re-uploaded from the same declarative tree, which is the kind of rebuild React is already good at.\n\nOn the r3f entry, useRenderQuality attaches that listener and reports for you, so most consumers never touch this and only read the scene gate's generation.",
|
|
620
|
-
"signature": "getRendererHealth(): RendererHealth · .state: RendererHealthState · .reportLost() · .reportHealthy() · .subscribe(fn)",
|
|
620
|
+
"summary": "getRendererHealth() returns a page-level singleton holding whether a context is currently lost and a generation number that increments on each loss. Put that generation on a canvas key and React throws the dead tree away and builds a working one. Wired by hand it is three calls, and all three are required: preventDefault on the event, reportLost with the generation the renderer was built under, and reportHealthy once the replacement has drawn.",
|
|
621
|
+
"mechanism": "A counter and a boolean, deliberately small. No context is created or restored here: something that owns a renderer reports a loss, the generation moves, and whatever renders the canvas is expected to rebuild.\n\nIt exposes a generation rather than a restored event because the restore path is not reliable across drivers. Some refuse a replacement context on the page entirely if the loss event was not cancelled, which is why whoever attaches the listener has to call preventDefault before reporting. Rebuilding from a key sidesteps the question: a fresh context, with every texture re-uploaded from the same declarative tree, which is the kind of rebuild React is already good at.\n\nOne driver reset reaches every canvas on the page within a frame or two, so losses arriving within a quarter of a second of the last one are folded into it: sixteen canvases, one reset, one rebuild. The exception is a canvas the last rebuild itself built, which did not exist when the reset happened. A reporter that passes builtAt, the generation that was current when its renderer was made, is always counted when that equals the current generation, so a replacement lost moments after it was built gets a rebuild of its own instead of staying black. So that a page holding more canvases than the browser keeps contexts for cannot loop, fresh canvases may trigger at most three such rebuilds in a row, after which the runtime warns once and waits for the next separate loss.\n\nreportHealthy is what ends a recovery. While a loss is unresolved every scene gate on the page holds its scene in the recovering state at reduced quality. If nothing reports healthy within five seconds the runtime assumes the rebuild worked, clears the state, and warns once, naming the call to add.\n\nOn the r3f entry, useRenderQuality attaches that listener and reports for you, and on the three entry useThreeScene does the same for a plain three.js scene, so most consumers never touch this and only read the scene gate's generation.",
|
|
622
|
+
"signature": "getRendererHealth(): RendererHealth · .state: RendererHealthState · .reportLost(options?: { builtAt?: number }) · .reportHealthy() · .subscribe(fn)",
|
|
621
623
|
"options": [],
|
|
622
624
|
"returns": [
|
|
623
625
|
{
|
|
@@ -628,17 +630,17 @@
|
|
|
628
630
|
{
|
|
629
631
|
"name": "state.generation",
|
|
630
632
|
"type": "number",
|
|
631
|
-
"description": "Increments on each loss. Put it on the canvas key
|
|
633
|
+
"description": "Increments on each loss. Put it on the canvas key, and read it when you create a renderer so you can pass it back as `builtAt`."
|
|
632
634
|
},
|
|
633
635
|
{
|
|
634
636
|
"name": "reportLost",
|
|
635
|
-
"type": "() => void",
|
|
636
|
-
"description": "Call after preventDefault on a webglcontextlost event."
|
|
637
|
+
"type": "(options?: { builtAt?: number }) => void",
|
|
638
|
+
"description": "Call after preventDefault on a webglcontextlost event. Pass `builtAt`, the generation that was current when the lost renderer was made: a replacement that dies moments after it was built then counts as a new loss instead of being folded into the last one."
|
|
637
639
|
},
|
|
638
640
|
{
|
|
639
641
|
"name": "reportHealthy",
|
|
640
642
|
"type": "() => void",
|
|
641
|
-
"description": "Call once
|
|
643
|
+
"description": "Required. Call once the replacement renderer has drawn. Until then every scene gate on the page holds its scene in recovery at reduced quality; after five seconds with no call the runtime gives up waiting and warns."
|
|
642
644
|
},
|
|
643
645
|
{
|
|
644
646
|
"name": "subscribe",
|
|
@@ -663,32 +665,39 @@
|
|
|
663
665
|
"conflictsWith": [],
|
|
664
666
|
"pairsWith": [
|
|
665
667
|
"useSceneGate",
|
|
666
|
-
"useRenderQuality"
|
|
668
|
+
"useRenderQuality",
|
|
669
|
+
"useThreeScene"
|
|
667
670
|
]
|
|
668
671
|
},
|
|
669
|
-
"quickStart": "import { getRendererHealth } from \"@vectorvesper/motion\";\n\nexport function watchContext(canvas) {\n const onLost = (event) => {\n // Without preventDefault the browser will not attempt a restore, and some\n // drivers then refuse a new context on the page at all.\n event.preventDefault();\n
|
|
672
|
+
"quickStart": "import { getRendererHealth } from \"@vectorvesper/motion\";\n\n// Call right after creating a renderer. Call the returned function before you\n// release the context, or your own teardown is reported as a GPU failure.\nexport function watchContext(canvas: HTMLCanvasElement) {\n const health = getRendererHealth();\n // The generation this renderer belongs to.\n const builtAt = health.state.generation;\n const onLost = (event: Event) => {\n // Without preventDefault the browser will not attempt a restore, and some\n // drivers then refuse a new context on the page at all.\n event.preventDefault();\n health.reportLost({ builtAt });\n };\n canvas.addEventListener(\"webglcontextlost\", onLost);\n return () => canvas.removeEventListener(\"webglcontextlost\", onLost);\n}\n\n// And once the replacement renderer has drawn its first frame:\n// getRendererHealth().reportHealthy();",
|
|
670
673
|
"recipes": [
|
|
671
674
|
{
|
|
672
675
|
"name": "Rebuilding when the generation moves",
|
|
673
676
|
"blurb": "The generation is a key, and the key is the recovery.",
|
|
674
|
-
"code": "import { getRendererHealth } from \"@vectorvesper/motion\";\n\nlet generation = getRendererHealth().state.generation;\n\nconst off = getRendererHealth().subscribe((health) => {\n if (health.generation === generation) return;\n generation = health.generation;\n teardownRenderer();\n buildRenderer(); // fresh context, textures re-uploaded\n getRendererHealth().reportHealthy(); // ends the recovery\n});"
|
|
677
|
+
"code": "import { getRendererHealth } from \"@vectorvesper/motion\";\n\nlet generation = getRendererHealth().state.generation;\n\nconst off = getRendererHealth().subscribe((health) => {\n if (health.generation === generation) return;\n generation = health.generation;\n teardownRenderer();\n buildRenderer(); // fresh context, textures re-uploaded\n // After its first frame has drawn:\n getRendererHealth().reportHealthy(); // ends the recovery\n});"
|
|
675
678
|
}
|
|
676
679
|
],
|
|
677
680
|
"dos": [
|
|
678
681
|
"Call preventDefault before reportLost, or a replacement context may be refused outright.",
|
|
679
|
-
"
|
|
680
|
-
"Call reportHealthy once
|
|
682
|
+
"Read `state.generation` when you create a renderer and pass it to reportLost as `builtAt`.",
|
|
683
|
+
"Call reportHealthy once the replacement has drawn. It is what ends the recovery; skipping it holds every gated scene at reduced quality.",
|
|
684
|
+
"Use the generation as a key rather than trying to revive the context that died."
|
|
681
685
|
],
|
|
682
686
|
"donts": [
|
|
683
687
|
"Don't ignore the loss. The failure mode is a black rectangle with no error anywhere.",
|
|
684
688
|
"Don't tear down on every change. Only a moving generation means the context actually died.",
|
|
685
|
-
"Don't attach your own listener to an r3f canvas that already spreads useRenderQuality's props.
|
|
689
|
+
"Don't attach your own listener to an r3f canvas that already spreads useRenderQuality's props, or around a useThreeScene scene. Both attach one, and two reports move the generation twice.",
|
|
690
|
+
"Don't release a context with forceContextLoss while your listener is still attached. Remove it first, or the teardown reads as a GPU failure and rebuilds every other scene."
|
|
686
691
|
],
|
|
687
692
|
"whenNotToUse": [
|
|
688
693
|
{
|
|
689
694
|
"when": "You are rendering with React Three Fiber.",
|
|
690
695
|
"instead": "useRenderQuality, whose onCreated attaches the listener and reports for you."
|
|
691
696
|
},
|
|
697
|
+
{
|
|
698
|
+
"when": "You are rendering a plain three.js scene.",
|
|
699
|
+
"instead": "useThreeScene, which wires the loss, the rebuild and the healthy report around your scene."
|
|
700
|
+
},
|
|
692
701
|
{
|
|
693
702
|
"when": "You are not rendering with WebGL.",
|
|
694
703
|
"instead": "Nothing. A 2D canvas has no context to lose this way."
|
|
@@ -1054,9 +1063,16 @@
|
|
|
1054
1063
|
"quality tier",
|
|
1055
1064
|
"low end device",
|
|
1056
1065
|
"gpu detection",
|
|
1057
|
-
"progressive enhancement"
|
|
1058
|
-
|
|
1059
|
-
|
|
1066
|
+
"progressive enhancement",
|
|
1067
|
+
"reduced motion",
|
|
1068
|
+
"prefers-reduced-motion",
|
|
1069
|
+
"prefers reduced motion",
|
|
1070
|
+
"usePrefersReducedMotion",
|
|
1071
|
+
"useReducedMotion",
|
|
1072
|
+
"reduced motion media query",
|
|
1073
|
+
"motion preference"
|
|
1074
|
+
],
|
|
1075
|
+
"tagline": "Device capability, live frame health and the reduced-motion preference, fused into one quality signal.",
|
|
1060
1076
|
"problem": "Measured frame health is the truth but is blind for the first seconds; device signals guess instantly but never learn. Using either alone gets one of those wrong.",
|
|
1061
1077
|
"summary": "Probes the device once (WebGL2, GPU renderer string, memory, cores, reduced-motion) and fuses that with the live AnimationBudget, the pressure verdict and the motion preference into one tier plus a `cause` explaining it. The device tier is a FLOOR, and that is the whole point: without it, weak devices oscillate forever, degrading, recovering because they degraded, upgrading, janking, degrading. A software renderer stays a software renderer. `reasons` explains the device half of the verdict.",
|
|
1062
1078
|
"mechanism": "Another thin subscription; the substance is the AdaptiveQuality singleton, which fuses two signals that are each useless alone. The live budget measures truth, but only once frames have accumulated, so the opening seconds of a page are blind. Device heuristics answer instantly but never learn.\n\nThe fusion rule is a maximum, not an average: the device tier is a floor that the live budget can drop quality below but never lift quality above. Without that floor, weak devices oscillate — degrade, frames recover precisely because you degraded, upgrade, jank, degrade again.\n\nDevice tier comes from a single WebGL2 probe taken once and cached. No WebGL2 at all is tier 2 immediately. A software renderer (SwiftShader, llvmpipe) is tier 2. A mobile-class GPU is tier 1 — those run GL perfectly well, but inside a thermal envelope that a sustained effect will find. Four or fewer cores, or four or fewer gigabytes of reported memory, adds one tier on top. The probe releases its GL context immediately rather than holding one open for the page's lifetime, and every branch appends a plain-English string to reasons, so the verdict is explainable instead of a magic number.\n\nThere is no GPU benchmark database anywhere in this, deliberately. The heuristic is conservative and then corrected by real measurement, which ages better than a lookup table of hardware that did not exist when it was written.",
|
|
@@ -1152,8 +1168,8 @@
|
|
|
1152
1168
|
"instead": "Skip it. The device probe creates a throwaway WebGL2 context to read the renderer string, which is not worth paying for on a page of text and CSS transitions."
|
|
1153
1169
|
},
|
|
1154
1170
|
{
|
|
1155
|
-
"when": "You only need
|
|
1156
|
-
"instead": "
|
|
1171
|
+
"when": "You only need the reduced-motion preference, on a page with no canvas or heavy effects.",
|
|
1172
|
+
"instead": "`matchMedia(\"(prefers-reduced-motion: reduce)\")`, read through `useSyncExternalStore` rather than `setState` in an effect. On a page already running effects on this runtime, read this hook's `reducedMotion` instead: the preference arrives with everything else, and freezing an effect under it is one branch."
|
|
1157
1173
|
},
|
|
1158
1174
|
{
|
|
1159
1175
|
"when": "You want to react to load only, ignoring the device.",
|
|
@@ -2177,7 +2193,7 @@
|
|
|
2177
2193
|
"type": "{ full: RenderProfile; reduced: RenderProfile }",
|
|
2178
2194
|
"default": "(required)",
|
|
2179
2195
|
"required": true,
|
|
2180
|
-
"description": "What each quality level means to the renderer. The full profile is used while the state is active, and reduced in every other state. A RenderProfile carries an optional `dpr` and an optional `shadows`, and nothing else: particle counts and geometry detail are not renderer settings, so they stay in your component keyed off the gate's `quality`."
|
|
2196
|
+
"description": "What each quality level means to the renderer. The full profile is used while the state is active, and reduced in every other state. A RenderProfile carries an optional `dpr` and an optional `shadows`, and nothing else: particle counts and geometry detail are not renderer settings, so they stay in your component keyed off the gate's `quality`. `dpr` is a ceiling: it never goes above the screen's own ratio, so `dpr: 2` draws a 1x monitor at 1x and a 3x phone at 2."
|
|
2181
2197
|
}
|
|
2182
2198
|
],
|
|
2183
2199
|
"returns": [
|
|
@@ -2365,8 +2381,8 @@
|
|
|
2365
2381
|
],
|
|
2366
2382
|
"whenNotToUse": [
|
|
2367
2383
|
{
|
|
2368
|
-
"when": "The component is below the fold or
|
|
2369
|
-
"instead": "Use `useSceneGate`,
|
|
2384
|
+
"when": "The component is below the fold, or mounts when it scrolls into view.",
|
|
2385
|
+
"instead": "Use `useSceneGate`, with `content: true` when it is content rather than decoration. It adds viewport proximity and a wait for the scroll to settle. This hook watches spare frame time only, and a smooth scroll has plenty of it, because the compositor does the scrolling while the main thread idles, so it opens mid-scroll. ANDed with an IntersectionObserver it is worse: it latches true while the page sits at the top, and the mount then lands on the very frame the observer fires."
|
|
2370
2386
|
},
|
|
2371
2387
|
{
|
|
2372
2388
|
"when": "You want to keep tuning a scene that is already running.",
|
|
@@ -2405,12 +2421,16 @@
|
|
|
2405
2421
|
"mount 3d scene",
|
|
2406
2422
|
"pause offscreen",
|
|
2407
2423
|
"context lost",
|
|
2408
|
-
"canvas recovery"
|
|
2424
|
+
"canvas recovery",
|
|
2425
|
+
"wait for scroll to stop",
|
|
2426
|
+
"mount after scroll settles",
|
|
2427
|
+
"defer mount below the fold",
|
|
2428
|
+
"heavy chart"
|
|
2409
2429
|
],
|
|
2410
|
-
"tagline": "One policy for a heavy scene: when to mount, whether to draw, at what quality, and how to
|
|
2430
|
+
"tagline": "One policy for a heavy scene or section: when to mount, whether to draw, at what quality, and how to recover.",
|
|
2411
2431
|
"problem": "A heavy scene needs four separate decisions and most code answers only the first. The result is a WebGL canvas that mounted correctly and is now burning frames for someone who scrolled past it ten seconds ago, or a black rectangle after the browser took the graphics context away and nothing noticed.",
|
|
2412
|
-
"summary": "Returns a ref to attach and a verdict to read: whether the scene should exist, whether it should be drawing, how much of it to build, and a generation counter that changes when the graphics context dies. It decides and reports; applying the verdict to a renderer is `useRenderQuality` on the r3f entry, or your own code.",
|
|
2413
|
-
"mechanism": "A small state machine over four inputs, each of which already has its own owner.\n\nProximity comes from an IntersectionObserver with a preload margin, so the scene starts warming before it is visible. Affordability comes from useSafeToMount, which is called rather than reimplemented, so there is one definition of whether the page can carry an expensive mount and every caller shares it. Stillness comes from the sensor bus: the first mount waits for scroll velocity to drop below a threshold, because mounting a scene mid-scroll is what a reader feels as a stutter whatever the frame numbers say. Only the first mount waits for that; once the scene exists, scrolling past it is free.\n\nQuality comes from AdaptiveQuality's fused tier, which already accounts for the device floor, the live frame budget, the motion preference and the pressure verdict. Earlier versions read the pressure classifier here and applied the only-rendering-is-worth-degrading-for rule themselves, which meant that rule was reachable from this one hook and nowhere else. It lives in the governor now, so the tier arrives already knowing whether reducing quality would help, and the cause travels with it.\n\nTwo behaviours exist to stop it flapping. Once the scene has started it stays started, so scrolling past yields the idle state rather than an unmount: tearing down a scene throws away the graphics context and every texture on it, and coming back pays for all of that again. And once quality drops it holds down for a few seconds even if conditions improve, because a scene that rebuilds its detail level twice a second is worse than one that is simply a little plain for a while.\n\nThe ref is a hybrid: a callback ref that also exposes a current property. A plain ref read once from an effect finds nothing when the element arrives on a later commit, which is what happens behind a spinner, a Suspense fallback or a dynamic import placeholder. That shape shipped once and produced a scene stuck in its dormant state forever with no error to explain it.",
|
|
2432
|
+
"summary": "Returns a ref to attach and a verdict to read: whether the scene should exist, whether it should be drawing, how much of it to build, and a generation counter that changes when the graphics context dies. It decides and reports; applying the verdict to a renderer is `useRenderQuality` on the r3f entry, `useThreeScene` on the three entry, or your own code. It is not only for 3D: `mounted` waits for the element to be near, for the scroll to settle and for frame headroom, which is what any expensive section below the fold needs. Pass `content: true` when what it holds is content, so it is never withheld.",
|
|
2433
|
+
"mechanism": "A small state machine over four inputs, each of which already has its own owner.\n\nProximity comes from an IntersectionObserver with a preload margin, so the scene starts warming before it is visible. Affordability comes from useSafeToMount, which is called rather than reimplemented, so there is one definition of whether the page can carry an expensive mount and every caller shares it. Stillness comes from the sensor bus: the first mount waits for scroll velocity to drop below a threshold, because mounting a scene mid-scroll is what a reader feels as a stutter whatever the frame numbers say. Only the first mount waits for that; once the scene exists, scrolling past it is free.\n\nQuality comes from AdaptiveQuality's fused tier, which already accounts for the device floor, the live frame budget, the motion preference and the pressure verdict. Earlier versions read the pressure classifier here and applied the only-rendering-is-worth-degrading-for rule themselves, which meant that rule was reachable from this one hook and nowhere else. It lives in the governor now, so the tier arrives already knowing whether reducing quality would help, and the cause travels with it.\n\nReduced motion and a device below the floor send a scene straight to the poster state, before position or timing are even considered, because there is no point warming a scene that will never be allowed to run. The content option removes exactly that step: the section still waits to be near, settled and affordable, and then mounts in the constrained state rather than never.\n\nTwo behaviours exist to stop it flapping. Once the scene has started it stays started, so scrolling past yields the idle state rather than an unmount: tearing down a scene throws away the graphics context and every texture on it, and coming back pays for all of that again. And once quality drops it holds down for a few seconds even if conditions improve, because a scene that rebuilds its detail level twice a second is worse than one that is simply a little plain for a while.\n\nThe ref is a hybrid: a callback ref that also exposes a current property. A plain ref read once from an effect finds nothing when the element arrives on a later commit, which is what happens behind a spinner, a Suspense fallback or a dynamic import placeholder. That shape shipped once and produced a scene stuck in its dormant state forever with no error to explain it.",
|
|
2414
2434
|
"signature": "useSceneGate<T extends HTMLElement>(options?: UseSceneGateOptions): SceneGate<T>",
|
|
2415
2435
|
"options": [
|
|
2416
2436
|
{
|
|
@@ -2433,6 +2453,13 @@
|
|
|
2433
2453
|
"default": "200",
|
|
2434
2454
|
"required": false,
|
|
2435
2455
|
"description": "How far before the viewport to start warming, in pixels. Also the margin the scene must clear before it counts as off screen, which matters for a sticky element: one that comes to rest flush against the viewport edge never gets far enough away to idle."
|
|
2456
|
+
},
|
|
2457
|
+
{
|
|
2458
|
+
"name": "content",
|
|
2459
|
+
"type": "boolean",
|
|
2460
|
+
"default": "false",
|
|
2461
|
+
"required": false,
|
|
2462
|
+
"description": "What is behind the gate is content the visitor came for — a chart, a table — rather than decoration. It still waits to be near, for the scroll to settle and for headroom, but it is never withheld: without this, reduced motion and a device below the floor keep `mounted` false for good."
|
|
2436
2463
|
}
|
|
2437
2464
|
],
|
|
2438
2465
|
"returns": [
|
|
@@ -2493,6 +2520,7 @@
|
|
|
2493
2520
|
"conflictsWith": [],
|
|
2494
2521
|
"pairsWith": [
|
|
2495
2522
|
"useRenderQuality",
|
|
2523
|
+
"useThreeScene",
|
|
2496
2524
|
"useSafeToMount",
|
|
2497
2525
|
"useAdaptiveQuality"
|
|
2498
2526
|
]
|
|
@@ -2502,7 +2530,12 @@
|
|
|
2502
2530
|
{
|
|
2503
2531
|
"name": "React Three Fiber",
|
|
2504
2532
|
"blurb": "The adapter turns the verdict into Canvas props: pixel ratio, shadows, whether the render loop turns, and context-loss reporting.",
|
|
2505
|
-
"code": "\"use client\";\nimport { useSceneGate } from \"@vectorvesper/motion/react\";\nimport { useRenderQuality } from \"@vectorvesper/motion/r3f\";\nimport { Canvas } from \"@react-three/fiber\";\n\nexport function Hero() {\n const scene = useSceneGate<HTMLDivElement>({ label: \"hero\", cost: \"heavy\" });\n\n const canvas = useRenderQuality(scene.state, {\n full: { dpr: 2, shadows: true },\n reduced: { dpr: 1, shadows: false },\n });\n\n return (\n <div ref={scene.ref} className=\"h-screen\">\n {scene.mounted ? (\n // Do NOT also set dpr, frameloop or shadows here. R3F reconciles all\n // three from props on every render, so a second owner silently wins.\n <Canvas key={scene.generation} {...canvas}>\n <Particles count={scene.quality === \"full\" ? 40_000 : 12_000} />\n </Canvas>\n ) : (\n <img src=\"/hero-poster.jpg\" alt=\"\" />\n )}\n </div>\n );\n}"
|
|
2533
|
+
"code": "\"use client\";\nimport { useSceneGate } from \"@vectorvesper/motion/react\";\nimport { useRenderQuality } from \"@vectorvesper/motion/r3f\";\nimport { Canvas } from \"@react-three/fiber\";\n\nexport function Hero() {\n const scene = useSceneGate<HTMLDivElement>({ label: \"hero\", cost: \"heavy\" });\n\n // dpr is a ceiling: never above the screen's own ratio.\n const canvas = useRenderQuality(scene.state, {\n full: { dpr: 2, shadows: true },\n reduced: { dpr: 1, shadows: false },\n });\n\n return (\n <div ref={scene.ref} className=\"h-screen\">\n {scene.mounted ? (\n // Do NOT also set dpr, frameloop or shadows here. R3F reconciles all\n // three from props on every render, so a second owner silently wins.\n <Canvas key={scene.generation} {...canvas}>\n <Particles count={scene.quality === \"full\" ? 40_000 : 12_000} />\n </Canvas>\n ) : (\n <img src=\"/hero-poster.jpg\" alt=\"\" />\n )}\n </div>\n );\n}"
|
|
2534
|
+
},
|
|
2535
|
+
{
|
|
2536
|
+
"name": "A heavy chart below the fold",
|
|
2537
|
+
"blurb": "Not only for 3D. The chart mounts once it is near, the scroll has settled and the frame has room, and `content: true` keeps reduced motion and a weak device from withholding it.",
|
|
2538
|
+
"code": "\"use client\";\nimport { useSceneGate } from \"@vectorvesper/motion/react\";\n\nexport function Metrics() {\n const { ref, mounted } = useSceneGate<HTMLElement>({\n label: \"metrics\",\n cost: \"normal\",\n content: true,\n });\n\n return (\n <section ref={ref}>\n {mounted ? <RevenueChart /> : <div style={{ height: 320 }} />}\n </section>\n );\n}"
|
|
2506
2539
|
},
|
|
2507
2540
|
{
|
|
2508
2541
|
"name": "Reacting to the reason",
|
|
@@ -2511,7 +2544,7 @@
|
|
|
2511
2544
|
},
|
|
2512
2545
|
{
|
|
2513
2546
|
"name": "A plain canvas, no r3f",
|
|
2514
|
-
"blurb": "The gate knows nothing about three. Stop your own loop when it says idle.",
|
|
2547
|
+
"blurb": "The gate knows nothing about three. Stop your own loop when it says idle. A three.js scene has a hook of its own, `useThreeScene`, that does all of this for you.",
|
|
2515
2548
|
"code": "\"use client\";\nimport { useEffect } from \"react\";\nimport { useSceneGate, getConductor } from \"@vectorvesper/motion/react\";\n\nexport function Viz() {\n const scene = useSceneGate<HTMLDivElement>({ label: \"viz\", cost: \"normal\" });\n const running = scene.mounted && scene.state !== \"idle\";\n\n useEffect(() => {\n if (!running) return;\n return getConductor().subscribe(\"render\", () => draw(), {\n priority: \"decorative\",\n label: \"viz\",\n });\n }, [running]);\n\n return <div ref={scene.ref} />;\n}"
|
|
2516
2549
|
}
|
|
2517
2550
|
],
|
|
@@ -2520,13 +2553,14 @@
|
|
|
2520
2553
|
"Put `generation` on the canvas key. Without it a lost graphics context leaves a permanently black rectangle.",
|
|
2521
2554
|
"Keep the scene mounted while `state` is idle and stop drawing instead. Unmounting throws away the context and every texture on it.",
|
|
2522
2555
|
"Branch on `cause` rather than `reason`. The wording of `reason` is free to change.",
|
|
2523
|
-
"Give the trailing section more height than `preload`, or the scene never gets far enough away to idle."
|
|
2556
|
+
"Give the trailing section more height than `preload`, or the scene never gets far enough away to idle.",
|
|
2557
|
+
"Pass `content: true` for a chart, a table or anything else a reader came for."
|
|
2524
2558
|
],
|
|
2525
2559
|
"donts": [
|
|
2526
2560
|
"Don't treat `quality` as meaningful while `mounted` is false. It is null then, which is the point.",
|
|
2527
2561
|
"Don't reduce quality for anything except rendering being slow. The gate already applies that rule; if you add your own, apply it to `cause === \"render-bound\"` only.",
|
|
2528
2562
|
"Don't declare a small canvas as `heavy`. On a throttled machine the gate will correctly refuse to mount it for a very long time.",
|
|
2529
|
-
"Don't gate content the visitor came for
|
|
2563
|
+
"Don't gate content the visitor came for without `content: true`. Reduced motion and a weak device otherwise keep it unmounted for the whole visit."
|
|
2530
2564
|
],
|
|
2531
2565
|
"whenNotToUse": [
|
|
2532
2566
|
{
|
|
@@ -2540,6 +2574,10 @@
|
|
|
2540
2574
|
{
|
|
2541
2575
|
"when": "The scene is already running and you only want to scale its detail.",
|
|
2542
2576
|
"instead": "`useAdaptiveQuality`. You do not need the mount decision to decide whether to drop a particle layer."
|
|
2577
|
+
},
|
|
2578
|
+
{
|
|
2579
|
+
"when": "The scene is plain three.js.",
|
|
2580
|
+
"instead": "`useThreeScene`, which composes this gate and also owns the renderer's pixel ratio, loop, context recovery and disposal."
|
|
2543
2581
|
}
|
|
2544
2582
|
],
|
|
2545
2583
|
"guardrails": [
|
|
@@ -2663,6 +2701,192 @@
|
|
|
2663
2701
|
"readingSource": "The `mechanism` field above is the answer to almost every 'how does this actually work' question, and it is written for the version you have. Prefer it. The published package is minified and ships no sourcemaps, so node_modules is a poor place to read from — but @vectorvesper/motion is MIT, so the real source is readable in the repository if you want it. Either way it changes nothing about the output: what you write is still an import. A copy compiles, passes review and silently runs its own rAF loop outside the conductor, so 'I read the source and it looked simple' is exactly how that bug gets shipped."
|
|
2664
2702
|
}
|
|
2665
2703
|
},
|
|
2704
|
+
{
|
|
2705
|
+
"name": "useThreeScene",
|
|
2706
|
+
"importFrom": "@vectorvesper/motion/three",
|
|
2707
|
+
"packageName": "@vectorvesper/motion",
|
|
2708
|
+
"since": "4.2.0",
|
|
2709
|
+
"category": "gate",
|
|
2710
|
+
"tier": "free",
|
|
2711
|
+
"aliases": [
|
|
2712
|
+
"three.js",
|
|
2713
|
+
"threejs",
|
|
2714
|
+
"plain three.js",
|
|
2715
|
+
"vanilla three.js",
|
|
2716
|
+
"three scene",
|
|
2717
|
+
"three hero",
|
|
2718
|
+
"three.js hero",
|
|
2719
|
+
"webglrenderer",
|
|
2720
|
+
"webgpurenderer",
|
|
2721
|
+
"three.js production",
|
|
2722
|
+
"three.js context lost",
|
|
2723
|
+
"three.js dispose"
|
|
2724
|
+
],
|
|
2725
|
+
"tagline": "A plain three.js scene in one call: you build the scene, it does the rest.",
|
|
2726
|
+
"problem": "A hand-written three.js hero needs about ten things right besides the scene itself — when to build, when to draw, the pixel ratio, resizing, a lost context, disposal, reduced motion — and generated code gets about half of them.",
|
|
2727
|
+
"summary": "You make the renderer and build the scene; the hook does everything around it. It builds nothing until the element is near the viewport and the page can afford it, draws on the shared frame loop and only while on screen, keeps the pixel ratio at or below the screen's own, follows the element's size, rebuilds after a lost context, frees every GPU resource when the scene goes, and never builds under reduced motion or on a device below the floor.",
|
|
2728
|
+
"mechanism": "It composes the scene gate rather than reimplementing it, so a scene is built only once the gate says the element is near, the page has frame headroom and scrolling has settled, and never under reduced motion or on a device below the floor. Once built, a scene is kept while it is off screen: the gate reports idle, drawing stops, and the renderer, its context and every buffer stay as they are, so coming back costs nothing.\n\nEach build is one renderer on one graphics context. The hook calls your renderer factory, puts its canvas inside the element, sizes it, and attaches the context-loss listener in the same task, so no loss can land before something is listening. Then it calls setup and subscribes one callback to the conductor's render lane. That callback skips the frame while the gate says idle or the context is dead. Otherwise it applies the pixel ratio for the current quality, calls update, draws, and after the first successful draw reports the context healthy, which is what ends a recovery.\n\nThe pixel ratio is the smaller of the screen's own ratio and the cap for the current quality, two at full and one at reduced unless you set others, and it changes on the renderer that already exists: a quality change never rebuilds anything. Size changes are measured by a ResizeObserver and applied at the next drawn frame rather than at once, because resizing clears the canvas and a scene that is off screen would otherwise sit cleared until it drew again. A perspective camera's aspect is kept in step, and resize is called whenever the drawing buffer changes size.\n\nA lost context moves the page's generation, and the hook tears the build down and makes a new one: a new renderer, a new canvas and a fresh call to setup, because nothing made on a dead context works on its replacement. The loss is reported with the generation the renderer was built under, so a replacement that dies moments after it was built still counts as a new failure. The clock handed to update carries on across the rebuild. A WebGPU renderer is waited for until its init resolves, and its device is watched through the same generation counter.\n\nTeardown runs in reverse: the frame subscription stops, the resize observer disconnects, your dispose runs, then every geometry, material and texture reachable from the scene is freed, the loss listener comes off, the renderer is disposed, its context is handed back with forceContextLoss, and the canvas leaves the page. The listener comes off before the context is released, so the hook never hears its own teardown as a failure.",
|
|
2729
|
+
"signature": "useThreeScene<R extends ThreeRendererLike>(options: UseThreeSceneOptions<R>): ThreeScene",
|
|
2730
|
+
"options": [
|
|
2731
|
+
{
|
|
2732
|
+
"name": "renderer",
|
|
2733
|
+
"type": "() => R | Promise<R>",
|
|
2734
|
+
"default": "(required)",
|
|
2735
|
+
"required": true,
|
|
2736
|
+
"description": "Makes the renderer: `() => new THREE.WebGLRenderer({ antialias: true })`. Called each time the scene is built — when it first comes near the viewport, and again after a lost context — so it must return a new renderer every call. A WebGPURenderer works the same way. May return a promise, so three can be imported only when the scene is needed."
|
|
2737
|
+
},
|
|
2738
|
+
{
|
|
2739
|
+
"name": "setup",
|
|
2740
|
+
"type": "(context: { renderer: R; width: number; height: number; quality: \"full\" | \"reduced\" }) => ThreeSceneHandle | Promise<ThreeSceneHandle>",
|
|
2741
|
+
"default": "(required)",
|
|
2742
|
+
"required": true,
|
|
2743
|
+
"description": "Builds the scene: camera, meshes, materials, lights, textures. Called right after `renderer`, every time the scene is built, with the element's size in CSS pixels. Return `{ scene, camera }`, plus any of `update(frame)` to animate before each drawn frame, `render(frame)` to draw it yourself (an EffectComposer), `resize(width, height)` when the drawing buffer changes size, and `dispose()` for anything not in the scene graph. Make every three.js object in here, not outside it."
|
|
2744
|
+
},
|
|
2745
|
+
{
|
|
2746
|
+
"name": "label",
|
|
2747
|
+
"type": "string",
|
|
2748
|
+
"default": "undefined",
|
|
2749
|
+
"required": false,
|
|
2750
|
+
"description": "Shown in devtools and in warnings. No effect on behaviour."
|
|
2751
|
+
},
|
|
2752
|
+
{
|
|
2753
|
+
"name": "cost",
|
|
2754
|
+
"type": "\"light\" | \"normal\" | \"heavy\"",
|
|
2755
|
+
"default": "\"heavy\"",
|
|
2756
|
+
"required": false,
|
|
2757
|
+
"description": "How expensive the scene is to start, passed to the scene gate. A full-screen hero is heavy; a small product turntable is normal."
|
|
2758
|
+
},
|
|
2759
|
+
{
|
|
2760
|
+
"name": "preload",
|
|
2761
|
+
"type": "number",
|
|
2762
|
+
"default": "200",
|
|
2763
|
+
"required": false,
|
|
2764
|
+
"description": "How far before the viewport to start building, in pixels."
|
|
2765
|
+
},
|
|
2766
|
+
{
|
|
2767
|
+
"name": "maxDpr",
|
|
2768
|
+
"type": "{ full?: number; reduced?: number }",
|
|
2769
|
+
"default": "{ full: 2, reduced: 1 }",
|
|
2770
|
+
"required": false,
|
|
2771
|
+
"description": "The highest pixel ratio each quality may draw at. Never above the screen's own, so a 1x monitor is never drawn at 2x."
|
|
2772
|
+
}
|
|
2773
|
+
],
|
|
2774
|
+
"returns": [
|
|
2775
|
+
{
|
|
2776
|
+
"name": "ref",
|
|
2777
|
+
"type": "RefObject<HTMLDivElement | null>",
|
|
2778
|
+
"description": "Put it on the element the scene fills, and give that element a size. The canvas is added inside it."
|
|
2779
|
+
},
|
|
2780
|
+
{
|
|
2781
|
+
"name": "mounted",
|
|
2782
|
+
"type": "boolean",
|
|
2783
|
+
"description": "Whether the scene exists. While false — before it first comes near, under reduced motion, on a device below the floor — show a still image or a background in the element instead."
|
|
2784
|
+
},
|
|
2785
|
+
{
|
|
2786
|
+
"name": "state",
|
|
2787
|
+
"type": "\"dormant\" | \"warming\" | \"active\" | \"constrained\" | \"idle\" | \"recovering\" | \"poster\"",
|
|
2788
|
+
"description": "Where the scene is in its life, straight from the scene gate."
|
|
2789
|
+
},
|
|
2790
|
+
{
|
|
2791
|
+
"name": "quality",
|
|
2792
|
+
"type": "\"full\" | \"reduced\" | null",
|
|
2793
|
+
"description": "How much scene is drawing, or null when there is none. `frame.quality` in update is the live value."
|
|
2794
|
+
},
|
|
2795
|
+
{
|
|
2796
|
+
"name": "cause",
|
|
2797
|
+
"type": "\"ok\" | \"not-near\" | \"waiting-for-headroom\" | \"off-screen\" | \"render-bound\" | \"frame-rate\" | \"device-floor\" | \"reduced-motion\" | \"context-lost\"",
|
|
2798
|
+
"description": "Why the scene is in its current state, in one word. Branch on this."
|
|
2799
|
+
},
|
|
2800
|
+
{
|
|
2801
|
+
"name": "reason",
|
|
2802
|
+
"type": "string",
|
|
2803
|
+
"description": "The same, in a sentence, for a devtools row or a support thread. The wording may change."
|
|
2804
|
+
}
|
|
2805
|
+
],
|
|
2806
|
+
"runtime": {
|
|
2807
|
+
"lane": "render",
|
|
2808
|
+
"priority": "enhanced",
|
|
2809
|
+
"requiresClient": true,
|
|
2810
|
+
"ssrSafeImport": true,
|
|
2811
|
+
"respectsReducedMotion": true,
|
|
2812
|
+
"reRendersPerFrame": 0,
|
|
2813
|
+
"usesPointer": false,
|
|
2814
|
+
"usesScroll": true,
|
|
2815
|
+
"usesWebGL": true,
|
|
2816
|
+
"ownsTransform": false,
|
|
2817
|
+
"sharedSingletons": [
|
|
2818
|
+
"FrameConductor",
|
|
2819
|
+
"SensorBus",
|
|
2820
|
+
"AnimationBudget",
|
|
2821
|
+
"AdaptiveQuality",
|
|
2822
|
+
"RendererHealth"
|
|
2823
|
+
],
|
|
2824
|
+
"conflictsWith": [
|
|
2825
|
+
"A render loop of your own for the same renderer — requestAnimationFrame or renderer.setAnimationLoop. The hook already draws every frame, so the scene draws twice and keeps drawing off screen.",
|
|
2826
|
+
"Setting the renderer's pixel ratio or size yourself. The hook sets both whenever the element or the quality changes, and overwrites yours."
|
|
2827
|
+
],
|
|
2828
|
+
"pairsWith": [
|
|
2829
|
+
"useInteractionScope",
|
|
2830
|
+
"useSensorBus"
|
|
2831
|
+
]
|
|
2832
|
+
},
|
|
2833
|
+
"quickStart": "\"use client\";\nimport * as THREE from \"three\";\nimport { useThreeScene } from \"@vectorvesper/motion/three\";\n\nexport default function Hero() {\n const { ref, mounted } = useThreeScene({\n label: \"hero\",\n renderer: () => new THREE.WebGLRenderer({ antialias: true }),\n setup({ width, height }) {\n const scene = new THREE.Scene();\n const camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 100);\n camera.position.z = 5;\n const knot = new THREE.Mesh(\n new THREE.TorusKnotGeometry(1, 0.3, 200, 32),\n new THREE.MeshNormalMaterial(),\n );\n scene.add(knot);\n return {\n scene,\n camera,\n update({ dt }) {\n knot.rotation.y += dt * 0.5;\n },\n };\n },\n });\n\n // While mounted is false — before the scene is built, under reduced motion,\n // on a device that cannot draw it — put a still of the scene here instead.\n return (\n <div ref={ref} style={{ position: \"relative\", height: \"100vh\", background: \"#05060a\" }}>\n {!mounted && <div aria-hidden style={{ position: \"absolute\", inset: 0, background: \"#0b1622\" }} />}\n </div>\n );\n}",
|
|
2834
|
+
"recipes": [
|
|
2835
|
+
{
|
|
2836
|
+
"name": "Post-processing",
|
|
2837
|
+
"blurb": "Draw through an EffectComposer with `render`, keep it sized with `resize`, and release it in `dispose`. The bloom pass switches off at reduced quality, which is cheap; rebuilding it is not.",
|
|
2838
|
+
"code": "\"use client\";\nimport * as THREE from \"three\";\nimport { EffectComposer } from \"three/examples/jsm/postprocessing/EffectComposer.js\";\nimport { RenderPass } from \"three/examples/jsm/postprocessing/RenderPass.js\";\nimport { UnrealBloomPass } from \"three/examples/jsm/postprocessing/UnrealBloomPass.js\";\nimport { useThreeScene } from \"@vectorvesper/motion/three\";\n\nexport function BloomHero() {\n const { ref } = useThreeScene({\n label: \"bloom hero\",\n renderer: () => new THREE.WebGLRenderer({ antialias: true }),\n setup({ renderer, width, height }) {\n const scene = new THREE.Scene();\n const camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 100);\n camera.position.z = 6;\n const orb = new THREE.Mesh(\n new THREE.IcosahedronGeometry(1.4, 4),\n new THREE.MeshBasicMaterial({ color: 0x66ccff }),\n );\n scene.add(orb);\n\n const composer = new EffectComposer(renderer);\n composer.addPass(new RenderPass(scene, camera));\n const bloom = new UnrealBloomPass(new THREE.Vector2(width, height), 1.1, 0.4, 0.85);\n composer.addPass(bloom);\n\n return {\n scene,\n camera,\n update({ dt }) {\n orb.rotation.y += dt * 0.3;\n },\n render({ quality }) {\n bloom.enabled = quality === \"full\";\n composer.render();\n },\n resize(w, h) {\n composer.setPixelRatio(renderer.getPixelRatio());\n composer.setSize(w, h);\n },\n dispose() {\n composer.dispose();\n },\n };\n },\n });\n\n return <div ref={ref} style={{ position: \"relative\", height: \"100vh\", background: \"#000\" }} />;\n}"
|
|
2839
|
+
},
|
|
2840
|
+
{
|
|
2841
|
+
"name": "WebGPU",
|
|
2842
|
+
"blurb": "Hand it a WebGPURenderer and nothing else changes. The hook waits for the renderer to connect to its device before drawing, and a lost device rebuilds the scene the way a lost context does.",
|
|
2843
|
+
"code": "\"use client\";\nimport * as THREE from \"three/webgpu\";\nimport { useThreeScene } from \"@vectorvesper/motion/three\";\n\nexport function GpuHero() {\n const { ref } = useThreeScene({\n label: \"gpu hero\",\n renderer: () => new THREE.WebGPURenderer({ antialias: true }),\n setup({ width, height }) {\n const scene = new THREE.Scene();\n const camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 100);\n camera.position.z = 4;\n const box = new THREE.Mesh(new THREE.BoxGeometry(1.4, 1.4, 1.4), new THREE.MeshNormalMaterial());\n scene.add(box);\n return {\n scene,\n camera,\n update({ dt }) {\n box.rotation.x += dt * 0.4;\n box.rotation.y += dt * 0.6;\n },\n };\n },\n });\n\n return <div ref={ref} style={{ position: \"relative\", height: \"70vh\" }} />;\n}"
|
|
2844
|
+
},
|
|
2845
|
+
{
|
|
2846
|
+
"name": "Loading three only when the scene is near",
|
|
2847
|
+
"blurb": "`renderer` and `setup` may return promises. Importing three inside them keeps the engine out of the first bundle until the scene is actually about to be built.",
|
|
2848
|
+
"code": "\"use client\";\nimport { useThreeScene } from \"@vectorvesper/motion/three\";\n\nexport function LazyShowcase() {\n const { ref, mounted } = useThreeScene({\n label: \"showcase\",\n cost: \"normal\",\n renderer: async () => {\n const THREE = await import(\"three\");\n return new THREE.WebGLRenderer({ antialias: true });\n },\n async setup({ width, height }) {\n const THREE = await import(\"three\");\n const scene = new THREE.Scene();\n const camera = new THREE.PerspectiveCamera(40, width / height, 0.1, 50);\n camera.position.z = 5;\n const ring = new THREE.Mesh(new THREE.TorusGeometry(1.2, 0.2, 32, 128), new THREE.MeshNormalMaterial());\n scene.add(ring);\n return {\n scene,\n camera,\n update({ time }) {\n ring.rotation.x = time * 0.4;\n },\n };\n },\n });\n\n return (\n <section ref={ref} style={{ position: \"relative\", height: 480 }}>\n {!mounted && <p>Loading the showcase…</p>}\n </section>\n );\n}"
|
|
2849
|
+
}
|
|
2850
|
+
],
|
|
2851
|
+
"dos": [
|
|
2852
|
+
"Make every three.js object inside `setup`. A rebuild after a lost context calls it again on a fresh renderer, and objects made elsewhere belong to the dead one.",
|
|
2853
|
+
"Return a new renderer from `renderer()` on every call.",
|
|
2854
|
+
"Give the element a size, and show a still image or a background while `mounted` is false.",
|
|
2855
|
+
"Change cheap things off `frame.quality` in `update` — a draw range, a pass's `enabled` — rather than rebuilding geometry.",
|
|
2856
|
+
"Put render targets, composers and controls in `dispose`. Everything reachable from the scene is freed for you."
|
|
2857
|
+
],
|
|
2858
|
+
"donts": [
|
|
2859
|
+
"Don't call `renderer.render` or run `requestAnimationFrame` yourself. Return `render(frame)` if you need to draw differently.",
|
|
2860
|
+
"Don't set the renderer's pixel ratio or size. The hook owns both and overwrites yours.",
|
|
2861
|
+
"Don't use it for a scene a reader needs under reduced motion. It never builds one for a visitor who asked for less motion.",
|
|
2862
|
+
"Don't add your own canvas to the element. The hook owns the canvas inside it."
|
|
2863
|
+
],
|
|
2864
|
+
"whenNotToUse": [
|
|
2865
|
+
{
|
|
2866
|
+
"when": "You render with React Three Fiber.",
|
|
2867
|
+
"instead": "`useSceneGate` with `useRenderQuality` spread onto `<Canvas>`. R3F owns the renderer there, so the adapter works through its props."
|
|
2868
|
+
},
|
|
2869
|
+
{
|
|
2870
|
+
"when": "You draw raw WebGL or a 2D canvas without three.",
|
|
2871
|
+
"instead": "`useSceneGate` for the mount decision, and your own drawing on the shared conductor — the `custom-frame-effect` pattern."
|
|
2872
|
+
},
|
|
2873
|
+
{
|
|
2874
|
+
"when": "The scene carries information a visitor needs even with reduced motion, like a data visualisation.",
|
|
2875
|
+
"instead": "Render it without a scene gate, hold the mount with `useSafeToMount`, and stop its animation under reduced motion rather than hiding it."
|
|
2876
|
+
}
|
|
2877
|
+
],
|
|
2878
|
+
"guardrails": [
|
|
2879
|
+
"practices/adaptive-performance.md",
|
|
2880
|
+
"practices/webgl-budget-and-failopen.md"
|
|
2881
|
+
],
|
|
2882
|
+
"docsUrl": "https://vectorvesper.dev/runtime/three-scene",
|
|
2883
|
+
"disclosure": {
|
|
2884
|
+
"exposeImplementation": false,
|
|
2885
|
+
"reason": "Reproducing this hook's implementation makes the consumer run a private requestAnimationFrame loop instead of joining the shared FrameConductor. That silently removes the coordination, scheduling and frame-budget governance the runtime exists to provide, and nothing errors when it happens. Copying is a correctness bug, not just a licensing question.",
|
|
2886
|
+
"correctUsage": "Install @vectorvesper/motion and import the hook. Never inline, reimplement, or paste an equivalent.",
|
|
2887
|
+
"readingSource": "The `mechanism` field above is the answer to almost every 'how does this actually work' question, and it is written for the version you have. Prefer it. The published package is minified and ships no sourcemaps, so node_modules is a poor place to read from — but @vectorvesper/motion is MIT, so the real source is readable in the repository if you want it. Either way it changes nothing about the output: what you write is still an import. A copy compiles, passes review and silently runs its own rAF loop outside the conductor, so 'I read the source and it looked simple' is exactly how that bug gets shipped."
|
|
2888
|
+
}
|
|
2889
|
+
},
|
|
2666
2890
|
{
|
|
2667
2891
|
"name": "useTick",
|
|
2668
2892
|
"importFrom": "@vectorvesper/motion/react",
|
|
@@ -2973,6 +3197,117 @@
|
|
|
2973
3197
|
"correctUsage": "Install @vectorvesper/motion and import the hook. Never inline, reimplement, or paste an equivalent.",
|
|
2974
3198
|
"readingSource": "The `mechanism` field above is the answer to almost every 'how does this actually work' question, and it is written for the version you have. Prefer it. The published package is minified and ships no sourcemaps, so node_modules is a poor place to read from — but @vectorvesper/motion is MIT, so the real source is readable in the repository if you want it. Either way it changes nothing about the output: what you write is still an import. A copy compiles, passes review and silently runs its own rAF loop outside the conductor, so 'I read the source and it looked simple' is exactly how that bug gets shipped."
|
|
2975
3199
|
}
|
|
3200
|
+
},
|
|
3201
|
+
{
|
|
3202
|
+
"name": "watchGPUDevice",
|
|
3203
|
+
"importFrom": "@vectorvesper/motion",
|
|
3204
|
+
"packageName": "@vectorvesper/motion",
|
|
3205
|
+
"since": "4.1.0",
|
|
3206
|
+
"category": "core",
|
|
3207
|
+
"tier": "free",
|
|
3208
|
+
"aliases": [
|
|
3209
|
+
"watchGPUDevice",
|
|
3210
|
+
"webgpu",
|
|
3211
|
+
"device lost",
|
|
3212
|
+
"device.lost",
|
|
3213
|
+
"GPUDevice",
|
|
3214
|
+
"navigator.gpu",
|
|
3215
|
+
"requestDevice",
|
|
3216
|
+
"WebGPURenderer",
|
|
3217
|
+
"gpu device loss"
|
|
3218
|
+
],
|
|
3219
|
+
"exports": [
|
|
3220
|
+
"watchGPUDevice"
|
|
3221
|
+
],
|
|
3222
|
+
"tagline": "Reports a WebGPU device's health, which arrives as a promise rather than an event.",
|
|
3223
|
+
"problem": "A WebGPU device is taken away for the same reasons a WebGL context is: a driver reset, a backgrounded tab, an OS reclaiming memory. It reports it in a completely different shape. WebGL fires webglcontextlost; WebGPU resolves a lost promise it handed you at creation. Code written for the event never hears the promise, so the canvas stops producing frames with nothing thrown and a clean console.",
|
|
3224
|
+
"summary": "watchGPUDevice(device) reports the device healthy on receipt and reports a loss when it dies, into the same generation counter the WebGL path uses. A scene gate keyed on that generation rebuilds a WebGPU scene with no change to how it is written. Returns a function that stops the watch.",
|
|
3225
|
+
"mechanism": "It reads one field, device.lost, so the parameter is a structural type and @webgpu/types is not a dependency. A real GPUDevice satisfies it whether or not your project has the types.\n\nA loss whose reason is \"destroyed\" is ignored. That value means your own code called device.destroy(), and treating it as a failure rebuilds the scene that is being torn down, which tears down more devices, which report more losses. The WebGL side of this runtime shipped exactly that bug and reached generation 18 inside a second with nobody touching the page.\n\nCancellation is a flag, not a detachment, because a promise cannot be un-awaited. The handler still runs after stop(); what it no longer does is report. Call stop() in the same cleanup that destroys the device.\n\nThrough React Three Fiber none of this is manual: useRenderQuality resolves the device off the renderer's backend and watches it, as well as attaching the WebGL listener.",
|
|
3226
|
+
"signature": "watchGPUDevice(device: GPUDeviceLike): StopWatchingDevice",
|
|
3227
|
+
"options": [
|
|
3228
|
+
{
|
|
3229
|
+
"name": "device",
|
|
3230
|
+
"type": "GPUDeviceLike",
|
|
3231
|
+
"default": "—",
|
|
3232
|
+
"required": false,
|
|
3233
|
+
"description": "Anything carrying a `lost` promise. A real GPUDevice satisfies this structurally, as does an engine's wrapper around one."
|
|
3234
|
+
}
|
|
3235
|
+
],
|
|
3236
|
+
"returns": [
|
|
3237
|
+
{
|
|
3238
|
+
"name": "stop",
|
|
3239
|
+
"type": "() => void",
|
|
3240
|
+
"description": "Stops reporting. Idempotent, and safe to call after the device has already been lost."
|
|
3241
|
+
}
|
|
3242
|
+
],
|
|
3243
|
+
"runtime": {
|
|
3244
|
+
"lane": null,
|
|
3245
|
+
"priority": null,
|
|
3246
|
+
"requiresClient": true,
|
|
3247
|
+
"ssrSafeImport": true,
|
|
3248
|
+
"respectsReducedMotion": false,
|
|
3249
|
+
"reRendersPerFrame": 0,
|
|
3250
|
+
"usesPointer": false,
|
|
3251
|
+
"usesScroll": false,
|
|
3252
|
+
"usesWebGL": true,
|
|
3253
|
+
"ownsTransform": false,
|
|
3254
|
+
"sharedSingletons": [
|
|
3255
|
+
"RendererHealth"
|
|
3256
|
+
],
|
|
3257
|
+
"conflictsWith": [],
|
|
3258
|
+
"pairsWith": [
|
|
3259
|
+
"useSceneGate",
|
|
3260
|
+
"useRenderQuality"
|
|
3261
|
+
]
|
|
3262
|
+
},
|
|
3263
|
+
"quickStart": "\"use client\";\nimport { watchGPUDevice } from \"@vectorvesper/motion\";\n\nexport async function startSurface(canvas) {\n const adapter = await navigator.gpu?.requestAdapter();\n if (!adapter) return () => {};\n\n const device = await adapter.requestDevice();\n const stop = watchGPUDevice(device);\n\n canvas.getContext(\"webgpu\").configure({\n device,\n format: navigator.gpu.getPreferredCanvasFormat(),\n alphaMode: \"premultiplied\",\n });\n\n return () => {\n // Stop first: destroying resolves `lost`, and a teardown is not a failure.\n stop();\n device.destroy();\n };\n}",
|
|
3264
|
+
"recipes": [
|
|
3265
|
+
{
|
|
3266
|
+
"name": "A WebGPU scene that recovers",
|
|
3267
|
+
"blurb": "The generation is the recovery. A new key means a device requested fresh.",
|
|
3268
|
+
"code": "\"use client\";\nimport { useSceneGate } from \"@vectorvesper/motion/react\";\n\nexport function GPUHero() {\n const scene = useSceneGate({ label: \"gpu-hero\", cost: \"heavy\" });\n\n return (\n <div ref={scene.ref} className=\"h-screen w-full\">\n {scene.mounted ? (\n // A lost device increments generation, which rebuilds this subtree\n // against a device requested fresh. A dead device cannot be revived.\n <GPUSurface key={scene.generation} quality={scene.quality} />\n ) : (\n <img src=\"/hero-poster.webp\" alt=\"\" />\n )}\n </div>\n );\n}"
|
|
3269
|
+
},
|
|
3270
|
+
{
|
|
3271
|
+
"name": "Telling a real WebGPU renderer from three's fallback",
|
|
3272
|
+
"blurb": "Ask the backend, not the class. init() swaps in WebGL and keeps the object.",
|
|
3273
|
+
"code": "// three's Renderer.init catches a WebGPU failure and assigns a WebGL\n// backend in its place, so `instanceof WebGPURenderer` stays true on a\n// canvas that is really WebGL, with nothing logged either way.\nconst backend = renderer.backend;\nconst device = backend?.isWebGPUBackend ? backend.device : null;\n\n// Wiring both paths is better than choosing: the webglcontextlost listener is\n// inert on a real WebGPU canvas, and it is what saves the fallback case.\nconst stop = device ? watchGPUDevice(device) : () => {};\ncanvas.addEventListener(\"webglcontextlost\", onWebGLLoss);"
|
|
3274
|
+
}
|
|
3275
|
+
],
|
|
3276
|
+
"dos": [
|
|
3277
|
+
"Call stop() in the same cleanup that destroys the device, so a teardown is never reported as a failure.",
|
|
3278
|
+
"Re-request the device on a new generation. A lost device cannot be revived, only replaced.",
|
|
3279
|
+
"Through React Three Fiber, spread useRenderQuality's props and let the adapter do this for you."
|
|
3280
|
+
],
|
|
3281
|
+
"donts": [
|
|
3282
|
+
"Don't await device.lost in a render path. It resolves only when the device dies, which on a healthy machine is never.",
|
|
3283
|
+
"Don't treat reason \"destroyed\" as a failure. That is your own destroy() call, and rebuilding on it cascades.",
|
|
3284
|
+
"Don't decide the wiring with instanceof WebGPURenderer. three's silent WebGL fallback keeps the class and changes the canvas.",
|
|
3285
|
+
"Don't read navigator.gpu as proof of a working device. A blocklisted driver exposes the API and then refuses the adapter."
|
|
3286
|
+
],
|
|
3287
|
+
"whenNotToUse": [
|
|
3288
|
+
{
|
|
3289
|
+
"when": "The scene renders through React Three Fiber.",
|
|
3290
|
+
"instead": "useRenderQuality, which resolves the device off the renderer's backend and watches it for you."
|
|
3291
|
+
},
|
|
3292
|
+
{
|
|
3293
|
+
"when": "You hold a raw WebGL context rather than a WebGPU device.",
|
|
3294
|
+
"instead": "A webglcontextlost listener that calls preventDefault, reporting to getRendererHealth()."
|
|
3295
|
+
},
|
|
3296
|
+
{
|
|
3297
|
+
"when": "You only want to know whether the browser exposes WebGPU at all.",
|
|
3298
|
+
"instead": "The webgpu flag on AdaptiveQuality's device signals. It is synchronous and needs no adapter."
|
|
3299
|
+
}
|
|
3300
|
+
],
|
|
3301
|
+
"guardrails": [
|
|
3302
|
+
"practices/adaptive-performance.md"
|
|
3303
|
+
],
|
|
3304
|
+
"docsUrl": "https://vectorvesper.dev/runtime/webgpu",
|
|
3305
|
+
"disclosure": {
|
|
3306
|
+
"exposeImplementation": false,
|
|
3307
|
+
"reason": "Reproducing this hook's implementation makes the consumer run a private requestAnimationFrame loop instead of joining the shared FrameConductor. That silently removes the coordination, scheduling and frame-budget governance the runtime exists to provide, and nothing errors when it happens. Copying is a correctness bug, not just a licensing question.",
|
|
3308
|
+
"correctUsage": "Install @vectorvesper/motion and import the hook. Never inline, reimplement, or paste an equivalent.",
|
|
3309
|
+
"readingSource": "The `mechanism` field above is the answer to almost every 'how does this actually work' question, and it is written for the version you have. Prefer it. The published package is minified and ships no sourcemaps, so node_modules is a poor place to read from — but @vectorvesper/motion is MIT, so the real source is readable in the repository if you want it. Either way it changes nothing about the output: what you write is still an import. A copy compiles, passes review and silently runs its own rAF loop outside the conductor, so 'I read the source and it looked simple' is exactly how that bug gets shipped."
|
|
3310
|
+
}
|
|
2976
3311
|
}
|
|
2977
3312
|
],
|
|
2978
3313
|
"patterns": [
|
|
@@ -3151,12 +3486,13 @@
|
|
|
3151
3486
|
"wiring": [
|
|
3152
3487
|
"useSceneGate answers all four. It reports `mounted` (should the scene exist), `state` (idle means keep it and stop drawing), `quality` (how much to build) and `generation` (bump the canvas key after a lost context).",
|
|
3153
3488
|
"Load the scene component via dynamic(..., { ssr: false }) so its code and its DOM both stay deferred. The gate's ref is a hybrid, so attaching it on a later commit is safe.",
|
|
3154
|
-
"Call useRenderQuality beside the gate and spread its result onto <Canvas>. It owns pixel ratio, shadows, whether the render loop turns, and context-loss reporting.",
|
|
3489
|
+
"Call useRenderQuality beside the gate and spread its result onto <Canvas>. It owns pixel ratio, shadows, whether the render loop turns, and context-loss reporting. A profile's `dpr` is a ceiling: it never goes above the screen's own, so a 1x monitor draws at 1x.",
|
|
3155
3490
|
"Instance counts and geometry detail stay yours, keyed off `quality`. Only the component knows what its own detail is made of.",
|
|
3156
3491
|
"The placeholder keeps the scene's final dimensions and meaningful content, so nothing shifts and nothing is lost without a heavy render.",
|
|
3157
|
-
"Mount once and keep it mounted. Scrolling past gives you `idle`, which pauses drawing while keeping the context and every texture on it."
|
|
3492
|
+
"Mount once and keep it mounted. Scrolling past gives you `idle`, which pauses drawing while keeping the context and every texture on it.",
|
|
3493
|
+
"This is the React Three Fiber shape. A scene written with three.js directly, with its own `new THREE.WebGLRenderer()`, is `useThreeScene` and the `three-scene` pattern instead."
|
|
3158
3494
|
],
|
|
3159
|
-
"code": "\"use client\";\n\nimport dynamic from \"next/dynamic\";\nimport { useSceneGate } from \"@vectorvesper/motion/react\";\n\nconst ExplodedProduct = dynamic(() => import(\"./ExplodedProduct\"), { ssr: false });\n\nexport function ProductBreakdown() {\n const scene = useSceneGate<HTMLDivElement>({\n label: \"product breakdown\",\n cost: \"heavy\",\n preload: 400,\n });\n\n return (\n <div ref={scene.ref} style={{ height: \"100vh\", position: \"relative\" }}>\n {scene.mounted ? (\n <ExplodedProduct\n generation={scene.generation}\n state={scene.state}\n quality={scene.quality}\n />\n ) : (\n <img src=\"/scenes/breakdown-poster.webp\" alt=\"Exploded view of the chassis\" style={{ width: \"100%\", height: \"100%\", objectFit: \"cover\" }} />\n )}\n </div>\n );\n}\n\n// ─── ExplodedProduct.tsx (separate file, loaded on demand) ───────────\n//\n// \"use client\";\n// import { Canvas } from \"@react-three/fiber\";\n// import { useRenderQuality } from \"@vectorvesper/motion/r3f\";\n// import type { SceneState } from \"@vectorvesper/motion/react\";\n//\n// export default function ExplodedProduct({ generation, state, quality }: {\n// generation: number; state: SceneState; quality: \"full\" | \"reduced\" | null;\n// }) {\n// const canvas = useRenderQuality(state, {\n// full: { dpr: 2, shadows: true },\n// reduced: { dpr: 1, shadows: false },\n// });\n//\n// // Do NOT also set dpr, frameloop or shadows on <Canvas>. R3F reconciles\n// // all three from props on every render, so a second owner silently wins.\n// return (\n// <Canvas key={generation} {...canvas}>\n// <Model particles={quality === \"full\" ? 2000 : 500} />\n// </Canvas>\n// );\n// }",
|
|
3495
|
+
"code": "\"use client\";\n\nimport dynamic from \"next/dynamic\";\nimport { useSceneGate } from \"@vectorvesper/motion/react\";\n\nconst ExplodedProduct = dynamic(() => import(\"./ExplodedProduct\"), { ssr: false });\n\nexport function ProductBreakdown() {\n const scene = useSceneGate<HTMLDivElement>({\n label: \"product breakdown\",\n cost: \"heavy\",\n preload: 400,\n });\n\n return (\n <div ref={scene.ref} style={{ height: \"100vh\", position: \"relative\" }}>\n {scene.mounted ? (\n <ExplodedProduct\n generation={scene.generation}\n state={scene.state}\n quality={scene.quality}\n />\n ) : (\n <img src=\"/scenes/breakdown-poster.webp\" alt=\"Exploded view of the chassis\" style={{ width: \"100%\", height: \"100%\", objectFit: \"cover\" }} />\n )}\n </div>\n );\n}\n\n// ─── ExplodedProduct.tsx (separate file, loaded on demand) ───────────\n//\n// \"use client\";\n// import { Canvas } from \"@react-three/fiber\";\n// import { useRenderQuality } from \"@vectorvesper/motion/r3f\";\n// import type { SceneState } from \"@vectorvesper/motion/react\";\n//\n// export default function ExplodedProduct({ generation, state, quality }: {\n// generation: number; state: SceneState; quality: \"full\" | \"reduced\" | null;\n// }) {\n// // dpr is a ceiling: never above the screen's own, so a 1x monitor stays 1x.\n// const canvas = useRenderQuality(state, {\n// full: { dpr: 2, shadows: true },\n// reduced: { dpr: 1, shadows: false },\n// });\n//\n// // Do NOT also set dpr, frameloop or shadows on <Canvas>. R3F reconciles\n// // all three from props on every render, so a second owner silently wins.\n// return (\n// <Canvas key={generation} {...canvas}>\n// <Model particles={quality === \"full\" ? 2000 : 500} />\n// </Canvas>\n// );\n// }",
|
|
3160
3496
|
"pitfalls": [
|
|
3161
3497
|
{
|
|
3162
3498
|
"mistake": "Importing the scene statically and only gating its render.",
|
|
@@ -3178,6 +3514,10 @@
|
|
|
3178
3514
|
"consequence": "The browser can take a WebGL context away at any time. Without a listener that calls preventDefault and a key that moves afterwards, the visitor is left with a permanently black rectangle and a completely clean console.",
|
|
3179
3515
|
"rule": "webgl-context-loss"
|
|
3180
3516
|
},
|
|
3517
|
+
{
|
|
3518
|
+
"mistake": "Declaring `cost: \"heavy\"` on a page where another scene never stops drawing.",
|
|
3519
|
+
"consequence": "`heavy` waits for a run of frames with real headroom. Next to a scene that keeps drawing off screen those frames never come, and this section stays unmounted: on vv-site's landing page it took 4.5–7.5 seconds, and on a throttled CPU it never mounted. Manage the other scene too, so it idles off screen, or declare this one `normal`."
|
|
3520
|
+
},
|
|
3181
3521
|
{
|
|
3182
3522
|
"mistake": "A zero-height placeholder host.",
|
|
3183
3523
|
"consequence": "The IntersectionObserver fires on a 0px box, not where the scene will render — the gate opens at the wrong scroll position and the mount shifts the page."
|
|
@@ -3412,6 +3752,68 @@
|
|
|
3412
3752
|
"product film"
|
|
3413
3753
|
]
|
|
3414
3754
|
},
|
|
3755
|
+
{
|
|
3756
|
+
"name": "three-scene",
|
|
3757
|
+
"title": "A plain three.js scene, production-safe",
|
|
3758
|
+
"tagline": "One hook around a hand-written three.js scene. You keep the scene.",
|
|
3759
|
+
"problem": "Generated three.js heroes arrive the same way: a WebGLRenderer in an effect, the device's full pixel ratio, their own requestAnimationFrame loop and a window resize listener. They draw while nobody can see them, go black when the browser takes the context away, ignore reduced motion, and leave their context behind on every navigation.",
|
|
3760
|
+
"uses": [
|
|
3761
|
+
"useThreeScene"
|
|
3762
|
+
],
|
|
3763
|
+
"wiring": [
|
|
3764
|
+
"Call `useThreeScene` in a client component and put `ref` on the element the scene fills. That element needs a size: a height, or `position: absolute; inset: 0` inside a sized parent.",
|
|
3765
|
+
"`renderer` makes a new three.js renderer every time it is called — once when the scene is first built, and again after a lost context. Pass whatever options your scene needs.",
|
|
3766
|
+
"`setup` builds everything three.js — camera, meshes, materials, textures — and returns `{ scene, camera }`. Moving an existing scene over means moving its body into `setup` and deleting its loop, its resize listener and its cleanup.",
|
|
3767
|
+
"Animate in `update(frame)`. It runs before every drawn frame with `dt` and `time` in seconds, and not at all while the scene is off screen. Set pixel-ratio-dependent uniforms from `frame.pixelRatio`.",
|
|
3768
|
+
"Show a still image or a background while `mounted` is false. Under reduced motion and on devices below the floor the scene is never built.",
|
|
3769
|
+
"Leave the pixel ratio, the size, the loop, context loss and disposal to the hook. Put anything `setup` made outside the scene graph — render targets, composers, controls — in `dispose`."
|
|
3770
|
+
],
|
|
3771
|
+
"code": "\"use client\";\n\nimport * as THREE from \"three\";\nimport { useThreeScene } from \"@vectorvesper/motion/three\";\n\n/**\n * Everything that is yours lives in setup: the camera, the meshes, the\n * materials. When to build, when to draw, the pixel ratio, resizing, a lost\n * context, cleanup and reduced motion are the hook's.\n */\nexport default function Hero() {\n const { ref, mounted } = useThreeScene({\n label: \"hero\",\n renderer: () => new THREE.WebGLRenderer({ antialias: true, powerPreference: \"high-performance\" }),\n setup({ renderer, width, height }) {\n renderer.setClearColor(0x05060a, 1);\n\n const scene = new THREE.Scene();\n const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 100);\n camera.position.set(0, 0, 6);\n\n const knot = new THREE.Mesh(\n new THREE.TorusKnotGeometry(1.2, 0.35, 256, 32),\n new THREE.MeshStandardMaterial({ color: 0x8fd3ff, roughness: 0.25, metalness: 0.6 }),\n );\n scene.add(knot);\n scene.add(new THREE.AmbientLight(0xffffff, 0.4));\n const light = new THREE.DirectionalLight(0xffffff, 1.4);\n light.position.set(3, 4, 5);\n scene.add(light);\n\n return {\n scene,\n camera,\n // Runs before each drawn frame. dt is in seconds, and nothing runs\n // while the hero is off screen.\n update({ dt }) {\n knot.rotation.x += dt * 0.2;\n knot.rotation.y += dt * 0.35;\n },\n };\n },\n });\n\n return (\n // The element needs a size: the canvas fills it. While mounted is false\n // (before the scene is built, under reduced motion, on a device that\n // cannot draw it smoothly) the stand-in below shows instead.\n <div ref={ref} style={{ position: \"relative\", height: \"100vh\", background: \"#05060a\" }}>\n {!mounted && (\n // Swap in a still of your scene: <img src=\"/hero-still.jpg\" alt=\"\" />\n <div\n aria-hidden\n style={{ position: \"absolute\", inset: 0, background: \"radial-gradient(ellipse at 50% 40%, #1d3b57, #05060a 70%)\" }}\n />\n )}\n </div>\n );\n}",
|
|
3772
|
+
"pitfalls": [
|
|
3773
|
+
{
|
|
3774
|
+
"mistake": "Creating three.js objects outside `setup`: at module scope, in a ref, or in another effect.",
|
|
3775
|
+
"consequence": "After a lost context the hook calls `setup` again on a fresh renderer. Objects made elsewhere belong to the dead context, so the rebuilt scene is black or missing pieces. A renderer made at module scope also crashes server rendering.",
|
|
3776
|
+
"rule": "ssr-module-scope"
|
|
3777
|
+
},
|
|
3778
|
+
{
|
|
3779
|
+
"mistake": "Returning the same renderer from `renderer()` every time.",
|
|
3780
|
+
"consequence": "After a lost context that renderer is dead. The rebuild draws into it and the scene stays black."
|
|
3781
|
+
},
|
|
3782
|
+
{
|
|
3783
|
+
"mistake": "Keeping your own requestAnimationFrame loop, or calling `renderer.render` yourself.",
|
|
3784
|
+
"consequence": "The scene draws twice a frame, keeps drawing off screen, and runs outside the page's frame budget, where it cannot be shed under load. Use `update` to animate and `render` to draw differently.",
|
|
3785
|
+
"rule": "orphan-raf"
|
|
3786
|
+
},
|
|
3787
|
+
{
|
|
3788
|
+
"mistake": "Rebuilding the scene, or the renderer, when quality changes.",
|
|
3789
|
+
"consequence": "Every change makes a new context; the lab watched one page go from 3 contexts to 7 in twenty seconds. The hook already changes the pixel ratio on the live renderer. Change cheap things off `frame.quality` in `update` instead.",
|
|
3790
|
+
"rule": "renderer-rebuilt-on-change"
|
|
3791
|
+
},
|
|
3792
|
+
{
|
|
3793
|
+
"mistake": "An element with no size.",
|
|
3794
|
+
"consequence": "The canvas is sized to the element, so there is nothing to draw into, and the gate's visibility check fires at the wrong scroll position. The hook warns in the console."
|
|
3795
|
+
},
|
|
3796
|
+
{
|
|
3797
|
+
"mistake": "Rendering nothing while `mounted` is false.",
|
|
3798
|
+
"consequence": "Under reduced motion, and on devices below the floor, the scene is never built. Without a still image or a background there, those visitors get an empty box."
|
|
3799
|
+
}
|
|
3800
|
+
],
|
|
3801
|
+
"verify": [
|
|
3802
|
+
"check_motion on the file — expect zero findings.",
|
|
3803
|
+
"Scroll the scene out of view and back: nothing draws while it is away, and it resumes on the same canvas.",
|
|
3804
|
+
"Emulate prefers-reduced-motion and reload: the still image shows and no canvas is ever created.",
|
|
3805
|
+
"In the console, lose the context: `document.querySelector('canvas').getContext('webgl2').getExtension('WEBGL_lose_context').loseContext()`. The scene rebuilds within a second, on a new canvas."
|
|
3806
|
+
],
|
|
3807
|
+
"aliases": [
|
|
3808
|
+
"three.js scene",
|
|
3809
|
+
"plain three.js",
|
|
3810
|
+
"vanilla three.js",
|
|
3811
|
+
"three.js hero",
|
|
3812
|
+
"webglrenderer",
|
|
3813
|
+
"raw three.js",
|
|
3814
|
+
"three hero"
|
|
3815
|
+
]
|
|
3816
|
+
},
|
|
3415
3817
|
{
|
|
3416
3818
|
"name": "velocity-marquee",
|
|
3417
3819
|
"title": "Velocity-reactive marquee",
|
|
@@ -3487,6 +3889,7 @@
|
|
|
3487
3889
|
"useVideoScrubber"
|
|
3488
3890
|
],
|
|
3489
3891
|
"pattern": "scrollytelling-video",
|
|
3892
|
+
"starter": false,
|
|
3490
3893
|
"components": [
|
|
3491
3894
|
"playpause"
|
|
3492
3895
|
],
|
|
@@ -3533,6 +3936,7 @@
|
|
|
3533
3936
|
"damp"
|
|
3534
3937
|
],
|
|
3535
3938
|
"pattern": "pointer-parallax-scene",
|
|
3939
|
+
"starter": false,
|
|
3536
3940
|
"components": [
|
|
3537
3941
|
"lookbook",
|
|
3538
3942
|
"glass-gallery"
|
|
@@ -3567,6 +3971,7 @@
|
|
|
3567
3971
|
"damp"
|
|
3568
3972
|
],
|
|
3569
3973
|
"pattern": "velocity-marquee",
|
|
3974
|
+
"starter": false,
|
|
3570
3975
|
"components": [
|
|
3571
3976
|
"image-ticker"
|
|
3572
3977
|
],
|
|
@@ -3599,6 +4004,7 @@
|
|
|
3599
4004
|
"useSafeToMount"
|
|
3600
4005
|
],
|
|
3601
4006
|
"pattern": "dashboard-live-numbers",
|
|
4007
|
+
"starter": false,
|
|
3602
4008
|
"components": [],
|
|
3603
4009
|
"instead": "State-driven counters re-rendering the tree, and a chart mounted immediately.",
|
|
3604
4010
|
"watchFor": [
|
|
@@ -3627,6 +4033,7 @@
|
|
|
3627
4033
|
"usePointerIntent"
|
|
3628
4034
|
],
|
|
3629
4035
|
"pattern": "predictive-media-card",
|
|
4036
|
+
"starter": false,
|
|
3630
4037
|
"components": [
|
|
3631
4038
|
"video-card",
|
|
3632
4039
|
"lookbook"
|
|
@@ -3663,6 +4070,7 @@
|
|
|
3663
4070
|
"FrameConductor",
|
|
3664
4071
|
"SensorBus"
|
|
3665
4072
|
],
|
|
4073
|
+
"starter": false,
|
|
3666
4074
|
"components": [],
|
|
3667
4075
|
"instead": "Reimplementing the effects for the framework, each with its own frame loop.",
|
|
3668
4076
|
"watchFor": [
|
|
@@ -3705,6 +4113,7 @@
|
|
|
3705
4113
|
"damp"
|
|
3706
4114
|
],
|
|
3707
4115
|
"pattern": "custom-frame-effect",
|
|
4116
|
+
"starter": false,
|
|
3708
4117
|
"components": [],
|
|
3709
4118
|
"instead": "A `requestAnimationFrame` loop in a `useEffect`, which is what training data suggests by default.",
|
|
3710
4119
|
"watchFor": [
|
|
@@ -3756,6 +4165,7 @@
|
|
|
3756
4165
|
"headline": "This is a CSS transition. No runtime primitive belongs here.",
|
|
3757
4166
|
"because": "An element moving from one state to another on mount or on a class change is what CSS transitions and the Web Animations API were built for. Both run on the compositor, off the main thread, so they keep animating while JavaScript is busy — which is exactly when a frame-loop version starts stuttering. A per-frame implementation of a fade costs main-thread time to produce a worse result.",
|
|
3758
4167
|
"use": [],
|
|
4168
|
+
"starter": false,
|
|
3759
4169
|
"components": [],
|
|
3760
4170
|
"instead": "A CSS transition, a keyframe animation, or `element.animate()`. Wrap the state change in `startViewTransition()` when the element also moves between layouts.",
|
|
3761
4171
|
"watchFor": [
|
|
@@ -3792,6 +4202,7 @@
|
|
|
3792
4202
|
"headline": "`:hover` already does this, and it does it while the thread is blocked.",
|
|
3793
4203
|
"because": "A colour, scale, shadow or opacity change on hover is a pseudo-class and a transition. There is no measurement to do and nothing to recompute per frame, so a JavaScript version adds a listener, a state write and a re-render to reproduce two lines of CSS.",
|
|
3794
4204
|
"use": [],
|
|
4205
|
+
"starter": false,
|
|
3795
4206
|
"components": [],
|
|
3796
4207
|
"instead": "`:hover` with a `transition` on the properties that change.",
|
|
3797
4208
|
"watchFor": [],
|
|
@@ -3822,6 +4233,7 @@
|
|
|
3822
4233
|
"headline": "A CSS keyframe animation, plus a reduced-motion guard.",
|
|
3823
4234
|
"because": "A spinner or shimmer repeats forever on a fixed clock with no input. That is the one case where `animation: … infinite` is exactly right, and it keeps spinning during the very main-thread work the spinner exists to cover — which a frame-loop version, by definition, would not.",
|
|
3824
4235
|
"use": [],
|
|
4236
|
+
"starter": false,
|
|
3825
4237
|
"components": [],
|
|
3826
4238
|
"instead": "`@keyframes` with `animation: spin 1s linear infinite`, inside a `@media (prefers-reduced-motion: no-preference)` block so the motion is opt-out by default.",
|
|
3827
4239
|
"watchFor": [
|
|
@@ -3867,7 +4279,13 @@
|
|
|
3867
4279
|
"smooth",
|
|
3868
4280
|
"progress",
|
|
3869
4281
|
"continuous",
|
|
3870
|
-
"video"
|
|
4282
|
+
"video",
|
|
4283
|
+
"expensive",
|
|
4284
|
+
"heavy",
|
|
4285
|
+
"long task",
|
|
4286
|
+
"main thread",
|
|
4287
|
+
"headroom",
|
|
4288
|
+
"synchronous"
|
|
3871
4289
|
],
|
|
3872
4290
|
"minHits": 1,
|
|
3873
4291
|
"priority": 920,
|
|
@@ -3875,6 +4293,7 @@
|
|
|
3875
4293
|
"headline": "One IntersectionObserver and one class. No frame loop, no scroll listener.",
|
|
3876
4294
|
"because": "A reveal fires once, at a threshold, and then never again. IntersectionObserver was added to the platform precisely so this would stop being a scroll handler: it is asynchronous, it does not force layout, and it costs nothing between crossings. Reading scroll position every frame to decide whether an element has crossed a line is the expensive way to ask a question the browser answers for free.",
|
|
3877
4295
|
"use": [],
|
|
4296
|
+
"starter": false,
|
|
3878
4297
|
"components": [],
|
|
3879
4298
|
"instead": "`new IntersectionObserver(([e]) => e.isIntersecting && el.classList.add('in-view'))`, with the animation itself in CSS. Disconnect after the first crossing if it should not repeat.",
|
|
3880
4299
|
"watchFor": [
|
|
@@ -3908,6 +4327,7 @@
|
|
|
3908
4327
|
"headline": "View Transitions, or whatever layout engine your framework already ships.",
|
|
3909
4328
|
"because": "Animating between two layouts means knowing where every element was and where it landed. The browser knows both, and `startViewTransition()` hands you the crossfade without any measurement at all. Reimplementing FLIP over a frame loop means reading layout for every participating element, which is the single most expensive thing you can do per frame.",
|
|
3910
4329
|
"use": [],
|
|
4330
|
+
"starter": false,
|
|
3911
4331
|
"components": [],
|
|
3912
4332
|
"instead": "`document.startViewTransition()`, or your framework's layout animation (Framer Motion's `layout` prop, Next's view transitions).",
|
|
3913
4333
|
"watchFor": [
|
|
@@ -3939,6 +4359,7 @@
|
|
|
3939
4359
|
"headline": "The `<video>` element. Nothing here improves on it.",
|
|
3940
4360
|
"because": "Playback, buffering, codec selection and picture-in-picture are the element's job, and it does them on a media thread you cannot reach from JavaScript. There is no per-frame work to coordinate, so there is nothing for a frame budget to govern.",
|
|
3941
4361
|
"use": [],
|
|
4362
|
+
"starter": false,
|
|
3942
4363
|
"components": [],
|
|
3943
4364
|
"instead": "`<video autoPlay muted loop playsInline />`, with `poster` for the first paint.",
|
|
3944
4365
|
"watchFor": [],
|
|
@@ -3962,6 +4383,7 @@
|
|
|
3962
4383
|
"headline": "No primitive owns this. It is disposal discipline, and it has to be yours.",
|
|
3963
4384
|
"because": "Geometries, materials, textures and render targets hold GPU memory that garbage collection cannot reclaim — the JavaScript object is small and the allocation behind it is not. Nothing in this runtime allocates them, so nothing in it can free them. React Three Fiber disposes what it created declaratively; anything constructed by hand in an effect is yours to release.",
|
|
3964
4385
|
"use": [],
|
|
4386
|
+
"starter": false,
|
|
3965
4387
|
"components": [],
|
|
3966
4388
|
"instead": "Dispose every hand-constructed resource in the same effect's cleanup, and never dispose one created at module scope — that is shared across every instance on the page, and freeing it breaks the second copy rather than the first.",
|
|
3967
4389
|
"watchFor": [
|
|
@@ -4000,6 +4422,7 @@
|
|
|
4000
4422
|
"headline": "A clock is not a frame loop. Use a timer.",
|
|
4001
4423
|
"because": "A value that changes once a second needs one update a second. Subscribing it to the frame loop runs the callback sixty to a hundred and twenty times to produce the same digit, and every one of those wakes the scheduler, occupies the budget, and shows up in devtools as work with nothing to show for it. Frame loops are for values that change every frame.",
|
|
4002
4424
|
"use": [],
|
|
4425
|
+
"starter": false,
|
|
4003
4426
|
"components": [],
|
|
4004
4427
|
"instead": "`setInterval`, or a `setTimeout` chain when the cadence has to drift-correct. Compute the displayed value from `Date.now()` against a fixed start rather than by counting ticks — intervals are throttled in background tabs, so a counter that adds one per tick silently runs slow while a computed one stays right.",
|
|
4005
4428
|
"watchFor": [],
|
|
@@ -4023,6 +4446,7 @@
|
|
|
4023
4446
|
"headline": "It brings its own renderer and its own frame loop. Do not wrap it in ours.",
|
|
4024
4447
|
"because": "These players own their playback clock end to end. Driving one from a frame callback gives the page two schedulers arguing over the same animation: ours decides the work should shed under load, theirs keeps advancing regardless, and the result is worse than either alone. There is no per-frame work of yours here to coordinate.",
|
|
4025
4448
|
"use": [],
|
|
4449
|
+
"starter": false,
|
|
4026
4450
|
"components": [],
|
|
4027
4451
|
"instead": "Let the player drive itself, and use its own API to pause it when it is off screen. What is worth governing is the mount, not the loop: these bundles are large and their first parse lands wherever you put it.",
|
|
4028
4452
|
"watchFor": [
|
|
@@ -4052,6 +4476,7 @@
|
|
|
4052
4476
|
"headline": "That is a windowing problem, not a motion one.",
|
|
4053
4477
|
"because": "The cost is the number of mounted DOM nodes and the reconciliation over them, which is React work rather than frame work. Nothing here reduces a node count, and a frame budget cannot govern a render that has already been committed. Reaching for a scheduler because a list feels slow treats the symptom furthest from the cause.",
|
|
4054
4478
|
"use": [],
|
|
4479
|
+
"starter": false,
|
|
4055
4480
|
"components": [],
|
|
4056
4481
|
"instead": "A windowing library — TanStack Virtual or react-window — so only the visible rows exist. Measure with the browser profiler first: an unmemoised row component is more often the cause than the row count.",
|
|
4057
4482
|
"watchFor": [],
|
|
@@ -4075,7 +4500,12 @@
|
|
|
4075
4500
|
"frame by frame"
|
|
4076
4501
|
],
|
|
4077
4502
|
"allOf": [],
|
|
4078
|
-
"unless": [
|
|
4503
|
+
"unless": [
|
|
4504
|
+
"count up",
|
|
4505
|
+
"counter",
|
|
4506
|
+
"odometer",
|
|
4507
|
+
"animated number"
|
|
4508
|
+
],
|
|
4079
4509
|
"minHits": 1,
|
|
4080
4510
|
"priority": 480,
|
|
4081
4511
|
"verdict": "single",
|
|
@@ -4084,6 +4514,7 @@
|
|
|
4084
4514
|
"use": [
|
|
4085
4515
|
"useTick"
|
|
4086
4516
|
],
|
|
4517
|
+
"starter": false,
|
|
4087
4518
|
"components": [],
|
|
4088
4519
|
"instead": "A `requestAnimationFrame` loop inside a `useEffect`, plus the cancel you have to remember.",
|
|
4089
4520
|
"watchFor": [
|
|
@@ -4135,6 +4566,7 @@
|
|
|
4135
4566
|
"useSensorBus",
|
|
4136
4567
|
"useTick"
|
|
4137
4568
|
],
|
|
4569
|
+
"starter": false,
|
|
4138
4570
|
"components": [],
|
|
4139
4571
|
"instead": "A `pointermove` or `scroll` listener per component, each with its own state and its own removal.",
|
|
4140
4572
|
"watchFor": [
|
|
@@ -4165,11 +4597,81 @@
|
|
|
4165
4597
|
"use": [
|
|
4166
4598
|
"damp"
|
|
4167
4599
|
],
|
|
4600
|
+
"starter": false,
|
|
4168
4601
|
"components": [],
|
|
4169
4602
|
"instead": "A hand-written lerp with a magic constant that behaves differently on every display.",
|
|
4170
4603
|
"watchFor": [],
|
|
4171
4604
|
"notWhen": "The value should arrive at a fixed time — a 300ms transition. Damping is asymptotic and never quite lands; use an eased transition when the duration is the requirement."
|
|
4172
4605
|
},
|
|
4606
|
+
{
|
|
4607
|
+
"id": "defer-content-until-scroll-settles",
|
|
4608
|
+
"when": [
|
|
4609
|
+
"scroll has settled",
|
|
4610
|
+
"scroll settles",
|
|
4611
|
+
"scroll to stop",
|
|
4612
|
+
"scroll stops",
|
|
4613
|
+
"mid-scroll",
|
|
4614
|
+
"during the scroll",
|
|
4615
|
+
"while scrolling",
|
|
4616
|
+
"main thread has room",
|
|
4617
|
+
"spare headroom"
|
|
4618
|
+
],
|
|
4619
|
+
"allOf": [
|
|
4620
|
+
[
|
|
4621
|
+
"mount",
|
|
4622
|
+
"mounting",
|
|
4623
|
+
"render",
|
|
4624
|
+
"build",
|
|
4625
|
+
"load"
|
|
4626
|
+
],
|
|
4627
|
+
[
|
|
4628
|
+
"heavy",
|
|
4629
|
+
"expensive",
|
|
4630
|
+
"costly",
|
|
4631
|
+
"long task",
|
|
4632
|
+
"jank",
|
|
4633
|
+
"dropped frames",
|
|
4634
|
+
"synchronous"
|
|
4635
|
+
],
|
|
4636
|
+
[
|
|
4637
|
+
"scroll",
|
|
4638
|
+
"into view",
|
|
4639
|
+
"in view",
|
|
4640
|
+
"intersectionobserver",
|
|
4641
|
+
"intersection observer",
|
|
4642
|
+
"below the fold",
|
|
4643
|
+
"viewport",
|
|
4644
|
+
"main thread"
|
|
4645
|
+
]
|
|
4646
|
+
],
|
|
4647
|
+
"unless": [
|
|
4648
|
+
"3d",
|
|
4649
|
+
"webgl",
|
|
4650
|
+
"shader",
|
|
4651
|
+
"three.js",
|
|
4652
|
+
"threejs",
|
|
4653
|
+
"r3f",
|
|
4654
|
+
"react three fiber",
|
|
4655
|
+
"canvas",
|
|
4656
|
+
"video"
|
|
4657
|
+
],
|
|
4658
|
+
"minHits": 1,
|
|
4659
|
+
"priority": 455,
|
|
4660
|
+
"verdict": "single",
|
|
4661
|
+
"headline": "`useSceneGate({ content: true })` — mounts once the section is near, the scroll has settled and the frame has room.",
|
|
4662
|
+
"because": "The mount is the long task, so where it lands is the whole question. An IntersectionObserver fires on the frame the section crosses the line — mid-scroll, exactly where a reader feels a long task. `useSafeToMount` does not fix that: it watches spare frame time, and a smooth scroll has plenty, because the compositor does the scrolling while the main thread idles. ANDed with the observer it is worse, since it latches true while the page sits at the top, and the mount then lands on the very frame the observer fires.\n\n`useSceneGate` waits for all three: the section near the viewport, the scroll settled, and frame headroom. It is named for 3D, and `content: true` is what makes it right for a chart or a table: the section still waits for the right moment, but it is never withheld. Without it, reduced motion and a device below the 3D floor keep `mounted` false for good, and those visitors never see the content. Render a placeholder of the same height until `mounted` is true.",
|
|
4663
|
+
"use": [
|
|
4664
|
+
"useSceneGate"
|
|
4665
|
+
],
|
|
4666
|
+
"starter": false,
|
|
4667
|
+
"components": [],
|
|
4668
|
+
"instead": "An IntersectionObserver flipping a boolean that mounts the component on the frame it fires, or that boolean ANDed with `useSafeToMount`, which latched while the page sat at the top.",
|
|
4669
|
+
"watchFor": [
|
|
4670
|
+
"client-boundary",
|
|
4671
|
+
"hand-rolled-scene-gate"
|
|
4672
|
+
],
|
|
4673
|
+
"notWhen": "The component is above the fold and its cost lands during hydration: `useSafeToMount({ cost })` alone is the gate there. Or it is cheap: something that mounts in a few milliseconds needs no gate at all."
|
|
4674
|
+
},
|
|
4173
4675
|
{
|
|
4174
4676
|
"id": "defer-an-expensive-mount",
|
|
4175
4677
|
"when": [
|
|
@@ -4199,6 +4701,7 @@
|
|
|
4199
4701
|
"use": [
|
|
4200
4702
|
"useSafeToMount"
|
|
4201
4703
|
],
|
|
4704
|
+
"starter": false,
|
|
4202
4705
|
"components": [
|
|
4203
4706
|
"heart-stream",
|
|
4204
4707
|
"jigsaw-puzzle"
|
|
@@ -4236,6 +4739,7 @@
|
|
|
4236
4739
|
"use": [
|
|
4237
4740
|
"useAdaptiveQuality"
|
|
4238
4741
|
],
|
|
4742
|
+
"starter": false,
|
|
4239
4743
|
"components": [],
|
|
4240
4744
|
"instead": "Sniffing the user agent, or branching on `navigator.hardwareConcurrency` alone.",
|
|
4241
4745
|
"watchFor": [
|
|
@@ -4262,6 +4766,7 @@
|
|
|
4262
4766
|
"use": [
|
|
4263
4767
|
"useAnimationBudget"
|
|
4264
4768
|
],
|
|
4769
|
+
"starter": false,
|
|
4265
4770
|
"components": [],
|
|
4266
4771
|
"instead": "Timing frames by hand and picking a threshold that is wrong on half of all displays.",
|
|
4267
4772
|
"watchFor": [],
|
|
@@ -4277,7 +4782,11 @@
|
|
|
4277
4782
|
"profile",
|
|
4278
4783
|
"stutter",
|
|
4279
4784
|
"find the bottleneck",
|
|
4280
|
-
"what is eating the frame"
|
|
4785
|
+
"what is eating the frame",
|
|
4786
|
+
"devtools",
|
|
4787
|
+
"mountdevtools",
|
|
4788
|
+
"per-subscriber",
|
|
4789
|
+
"frame cost"
|
|
4281
4790
|
],
|
|
4282
4791
|
"allOf": [],
|
|
4283
4792
|
"unless": [],
|
|
@@ -4290,6 +4799,7 @@
|
|
|
4290
4799
|
"useFramePressure",
|
|
4291
4800
|
"mountDevtools"
|
|
4292
4801
|
],
|
|
4802
|
+
"starter": false,
|
|
4293
4803
|
"components": [],
|
|
4294
4804
|
"instead": "Commenting things out until it gets faster.",
|
|
4295
4805
|
"watchFor": [],
|
|
@@ -4319,6 +4829,7 @@
|
|
|
4319
4829
|
"usePointerIntent"
|
|
4320
4830
|
],
|
|
4321
4831
|
"pattern": "magnetic-prefetch-cta",
|
|
4832
|
+
"starter": false,
|
|
4322
4833
|
"components": [
|
|
4323
4834
|
"video-card"
|
|
4324
4835
|
],
|
|
@@ -4348,6 +4859,7 @@
|
|
|
4348
4859
|
"useMagneticIntent"
|
|
4349
4860
|
],
|
|
4350
4861
|
"pattern": "magnetic-prefetch-cta",
|
|
4862
|
+
"starter": false,
|
|
4351
4863
|
"components": [
|
|
4352
4864
|
"physics-buttons"
|
|
4353
4865
|
],
|
|
@@ -4392,6 +4904,7 @@
|
|
|
4392
4904
|
"useVideoScrubber"
|
|
4393
4905
|
],
|
|
4394
4906
|
"pattern": "scrollytelling-video",
|
|
4907
|
+
"starter": false,
|
|
4395
4908
|
"components": [
|
|
4396
4909
|
"playpause"
|
|
4397
4910
|
],
|
|
@@ -4423,6 +4936,7 @@
|
|
|
4423
4936
|
"useNumberTicker"
|
|
4424
4937
|
],
|
|
4425
4938
|
"pattern": "dashboard-live-numbers",
|
|
4939
|
+
"starter": false,
|
|
4426
4940
|
"components": [],
|
|
4427
4941
|
"instead": "A `setInterval` stepping a state value toward a target.",
|
|
4428
4942
|
"watchFor": [
|
|
@@ -4436,9 +4950,23 @@
|
|
|
4436
4950
|
"image trail",
|
|
4437
4951
|
"trailing images",
|
|
4438
4952
|
"cursor trail",
|
|
4439
|
-
"images follow the cursor"
|
|
4953
|
+
"images follow the cursor",
|
|
4954
|
+
"trail of images"
|
|
4955
|
+
],
|
|
4956
|
+
"allOf": [
|
|
4957
|
+
[
|
|
4958
|
+
"trail",
|
|
4959
|
+
"trailing"
|
|
4960
|
+
],
|
|
4961
|
+
[
|
|
4962
|
+
"image",
|
|
4963
|
+
"images",
|
|
4964
|
+
"photo",
|
|
4965
|
+
"photos",
|
|
4966
|
+
"picture",
|
|
4967
|
+
"pictures"
|
|
4968
|
+
]
|
|
4440
4969
|
],
|
|
4441
|
-
"allOf": [],
|
|
4442
4970
|
"unless": [],
|
|
4443
4971
|
"minHits": 1,
|
|
4444
4972
|
"priority": 370,
|
|
@@ -4448,6 +4976,7 @@
|
|
|
4448
4976
|
"use": [
|
|
4449
4977
|
"useImageTrail"
|
|
4450
4978
|
],
|
|
4979
|
+
"starter": false,
|
|
4451
4980
|
"components": [
|
|
4452
4981
|
"trail-collector",
|
|
4453
4982
|
"text-trail"
|
|
@@ -4492,7 +5021,13 @@
|
|
|
4492
5021
|
"keep up"
|
|
4493
5022
|
]
|
|
4494
5023
|
],
|
|
4495
|
-
"unless": [
|
|
5024
|
+
"unless": [
|
|
5025
|
+
"mount",
|
|
5026
|
+
"mounting",
|
|
5027
|
+
"mounts",
|
|
5028
|
+
"video",
|
|
5029
|
+
"currenttime"
|
|
5030
|
+
],
|
|
4496
5031
|
"minHits": 1,
|
|
4497
5032
|
"priority": 425,
|
|
4498
5033
|
"verdict": "single",
|
|
@@ -4502,6 +5037,7 @@
|
|
|
4502
5037
|
"useInteractionScope",
|
|
4503
5038
|
"useTick"
|
|
4504
5039
|
],
|
|
5040
|
+
"starter": false,
|
|
4505
5041
|
"components": [],
|
|
4506
5042
|
"instead": "Pausing every other animation by hand when a drag starts, and forgetting to resume one.",
|
|
4507
5043
|
"watchFor": [
|
|
@@ -4509,13 +5045,79 @@
|
|
|
4509
5045
|
],
|
|
4510
5046
|
"notWhen": "There is only one interactive region on the page. With nothing to yield, the scope is bookkeeping that changes no decision."
|
|
4511
5047
|
},
|
|
5048
|
+
{
|
|
5049
|
+
"id": "three-scene",
|
|
5050
|
+
"when": [
|
|
5051
|
+
"plain three.js",
|
|
5052
|
+
"plain threejs",
|
|
5053
|
+
"vanilla three.js",
|
|
5054
|
+
"raw three.js",
|
|
5055
|
+
"three.js hero",
|
|
5056
|
+
"three.js",
|
|
5057
|
+
"threejs",
|
|
5058
|
+
"webglrenderer",
|
|
5059
|
+
"three.webglrenderer",
|
|
5060
|
+
"setanimationloop",
|
|
5061
|
+
"3d scene",
|
|
5062
|
+
"webgl scene"
|
|
5063
|
+
],
|
|
5064
|
+
"allOf": [
|
|
5065
|
+
[
|
|
5066
|
+
"three.js",
|
|
5067
|
+
"threejs",
|
|
5068
|
+
"webglrenderer"
|
|
5069
|
+
],
|
|
5070
|
+
[
|
|
5071
|
+
"scene",
|
|
5072
|
+
"hero",
|
|
5073
|
+
"background",
|
|
5074
|
+
"canvas",
|
|
5075
|
+
"section",
|
|
5076
|
+
"3d",
|
|
5077
|
+
"webgl",
|
|
5078
|
+
"shader",
|
|
5079
|
+
"particle",
|
|
5080
|
+
"terrain",
|
|
5081
|
+
"model",
|
|
5082
|
+
"production"
|
|
5083
|
+
]
|
|
5084
|
+
],
|
|
5085
|
+
"unless": [
|
|
5086
|
+
"react three fiber",
|
|
5087
|
+
"r3f",
|
|
5088
|
+
"@react-three",
|
|
5089
|
+
"react-three-fiber",
|
|
5090
|
+
"useframe",
|
|
5091
|
+
"drei",
|
|
5092
|
+
"<canvas>"
|
|
5093
|
+
],
|
|
5094
|
+
"minHits": 1,
|
|
5095
|
+
"priority": 725,
|
|
5096
|
+
"verdict": "single",
|
|
5097
|
+
"headline": "`useThreeScene` — you build the scene; it does everything else a production scene needs.",
|
|
5098
|
+
"because": "A hand-written three.js scene needs about ten things right besides the scene itself. Build it only when it is near the viewport and the page can afford it. Draw on the shared frame loop, and not at all while it is off screen. Keep the pixel ratio at or below the screen's own, and lower it on the renderer you have instead of rebuilding. Follow the element's size. Listen for a lost context from the moment the renderer exists, rebuild on a fresh one, and report when the replacement has drawn. Free every geometry, material and texture, and the context itself, when the scene goes. And never build it for a visitor who asked for less motion.\n\n`useThreeScene` does all of those. You pass a function that makes the renderer and one that builds the scene and returns it with its camera; moving an existing scene over means moving its body into `setup` and deleting its loop, its resize listener and its cleanup. vv-lab runs the file below through every probe it has, on Chrome and Safari's engine, desktop and mobile.",
|
|
5099
|
+
"use": [
|
|
5100
|
+
"useThreeScene"
|
|
5101
|
+
],
|
|
5102
|
+
"pattern": "three-scene",
|
|
5103
|
+
"starter": true,
|
|
5104
|
+
"components": [],
|
|
5105
|
+
"instead": "A `useEffect` that creates a WebGLRenderer, runs its own requestAnimationFrame loop, sets the device's full pixel ratio, listens for window resize and disposes the geometry — the shape every generated hero arrives in, and about ten steps short of production.",
|
|
5106
|
+
"watchFor": [
|
|
5107
|
+
"offscreen-render",
|
|
5108
|
+
"webgl-context-loss",
|
|
5109
|
+
"unreleased-context",
|
|
5110
|
+
"uncapped-pixel-ratio",
|
|
5111
|
+
"renderer-rebuilt-on-change",
|
|
5112
|
+
"no-reduced-motion"
|
|
5113
|
+
],
|
|
5114
|
+
"notWhen": "The scene is React Three Fiber: `<Canvas>` owns the renderer, so the pair is `useSceneGate` and `useRenderQuality`. Or the scene carries information a visitor needs even with reduced motion, such as a data visualisation: this hook never builds a scene for someone who asked for less motion."
|
|
5115
|
+
},
|
|
4512
5116
|
{
|
|
4513
5117
|
"id": "r3f-scene",
|
|
4514
5118
|
"when": [
|
|
4515
5119
|
"react three fiber",
|
|
4516
5120
|
"r3f",
|
|
4517
|
-
"three.js",
|
|
4518
|
-
"threejs",
|
|
4519
5121
|
"3d scene",
|
|
4520
5122
|
"webgl scene",
|
|
4521
5123
|
"3d model",
|
|
@@ -4526,18 +5128,24 @@
|
|
|
4526
5128
|
"unless": [
|
|
4527
5129
|
"shader",
|
|
4528
5130
|
"glsl",
|
|
4529
|
-
"uniform"
|
|
5131
|
+
"uniform",
|
|
5132
|
+
"plain three.js",
|
|
5133
|
+
"vanilla three.js",
|
|
5134
|
+
"raw three.js",
|
|
5135
|
+
"webglrenderer",
|
|
5136
|
+
"three.webglrenderer"
|
|
4530
5137
|
],
|
|
4531
5138
|
"minHits": 1,
|
|
4532
5139
|
"priority": 760,
|
|
4533
5140
|
"verdict": "composition",
|
|
4534
5141
|
"headline": "`useSceneGate` decides, `useRenderQuality` applies, your component owns the detail.",
|
|
4535
|
-
"because": "A `<Canvas>` mounted plainly renders whether or not anyone is looking at it, at whatever pixel ratio the display reports, on every device, forever — and goes permanently black the first time the browser reclaims its context. The three-part split fixes each of those in the one place it can be fixed. The gate holds the mount until the scene is near the viewport, scrolling has settled and there is frame headroom, and it hands back a `generation` to key on. The adapter turns its verdict into `<Canvas>` props, including the `onCreated` that notices a lost context. Instance counts and geometry detail are not renderer settings, so they stay in your component, keyed off the gate's `quality
|
|
5142
|
+
"because": "A `<Canvas>` mounted plainly renders whether or not anyone is looking at it, at whatever pixel ratio the display reports, on every device, forever — and goes permanently black the first time the browser reclaims its context. The three-part split fixes each of those in the one place it can be fixed. The gate holds the mount until the scene is near the viewport, scrolling has settled and there is frame headroom, and it hands back a `generation` to key on. The adapter turns its verdict into `<Canvas>` props, including the `onCreated` that notices a lost context. Instance counts and geometry detail are not renderer settings, so they stay in your component, keyed off the gate's `quality`.\n\nThis is the React Three Fiber answer. If the scene is written with three.js directly, with a `new THREE.WebGLRenderer()` of its own, use `useThreeScene` instead: one hook does all of it.",
|
|
4536
5143
|
"use": [
|
|
4537
5144
|
"useSceneGate",
|
|
4538
5145
|
"useRenderQuality"
|
|
4539
5146
|
],
|
|
4540
5147
|
"pattern": "lazy-3d-section",
|
|
5148
|
+
"starter": false,
|
|
4541
5149
|
"components": [
|
|
4542
5150
|
"volumetric-light",
|
|
4543
5151
|
"refractive-gallery"
|
|
@@ -4569,12 +5177,13 @@
|
|
|
4569
5177
|
"priority": 750,
|
|
4570
5178
|
"verdict": "composition",
|
|
4571
5179
|
"headline": "Write uniforms from a `useTick` callback through a ref. Never through React state.",
|
|
4572
|
-
"because": "A uniform is a value on a GPU program, not application state. Putting it in `useState` means a React render, a reconciliation pass and a commit for every frame of a smooth animation — to change one float that React never reads. Hold the material in a ref and assign `material.uniforms.uTime.value` inside the callback; React is then uninvolved in the animation entirely, which is the point
|
|
5180
|
+
"because": "A uniform is a value on a GPU program, not application state. Putting it in `useState` means a React render, a reconciliation pass and a commit for every frame of a smooth animation — to change one float that React never reads. Hold the material in a ref and assign `material.uniforms.uTime.value` inside the callback; React is then uninvolved in the animation entirely, which is the point. In a `useThreeScene` scene the material already lives in `setup`, so assign it in `update` from `frame.time`.\n\nTwo details that decide whether the scene is smooth. Use the `time` the conductor passes rather than `performance.now()`, so every effect on the page shares one clock and nothing drifts apart. And take shader compilation seriously: it is a synchronous multi-hundred-millisecond stall on some drivers, which is why the mount belongs behind a gate rather than in the hydration burst.",
|
|
4573
5181
|
"use": [
|
|
4574
5182
|
"useTick",
|
|
4575
5183
|
"useSceneGate"
|
|
4576
5184
|
],
|
|
4577
5185
|
"pattern": "custom-frame-effect",
|
|
5186
|
+
"starter": false,
|
|
4578
5187
|
"components": [
|
|
4579
5188
|
"media-shader",
|
|
4580
5189
|
"acrylic-shader"
|
|
@@ -4605,12 +5214,13 @@
|
|
|
4605
5214
|
"priority": 740,
|
|
4606
5215
|
"verdict": "composition",
|
|
4607
5216
|
"headline": "One instanced draw, and the count comes from the gate's `quality`.",
|
|
4608
|
-
"because": "Particle counts are the largest single lever on whether a scene holds its frame, and they are the one thing the render adapter deliberately does not own — a count is not a renderer setting, so putting it in a `RenderProfile` would be an option that looks applied and is not. Read `scene.quality` in your component and pick the count from it.\n\nThe other half is the draw itself: one `InstancedMesh` with a per-instance matrix, not a mesh per particle. A thousand meshes is a thousand draw calls, and no amount of frame scheduling rescues that. Update the instance matrices in a `useTick` callback and set `needsUpdate` once at the end, never per instance.",
|
|
5217
|
+
"because": "Particle counts are the largest single lever on whether a scene holds its frame, and they are the one thing the render adapter deliberately does not own — a count is not a renderer setting, so putting it in a `RenderProfile` would be an option that looks applied and is not. Read `scene.quality` in your component and pick the count from it. In a `useThreeScene` scene, read `frame.quality` in `update` and set the geometry's draw range, which costs nothing to change.\n\nThe other half is the draw itself: one `InstancedMesh` with a per-instance matrix, not a mesh per particle. A thousand meshes is a thousand draw calls, and no amount of frame scheduling rescues that. Update the instance matrices in a `useTick` callback and set `needsUpdate` once at the end, never per instance.",
|
|
4609
5218
|
"use": [
|
|
4610
5219
|
"useSceneGate",
|
|
4611
5220
|
"useTick"
|
|
4612
5221
|
],
|
|
4613
5222
|
"pattern": "lazy-3d-section",
|
|
5223
|
+
"starter": false,
|
|
4614
5224
|
"components": [
|
|
4615
5225
|
"particle-morph-gallery",
|
|
4616
5226
|
"magnetic-sand",
|
|
@@ -4640,12 +5250,13 @@
|
|
|
4640
5250
|
"priority": 730,
|
|
4641
5251
|
"verdict": "composition",
|
|
4642
5252
|
"headline": "Gate the passes on `quality`, and lower the pixel ratio before you drop an effect.",
|
|
4643
|
-
"because": "Every pass is another full-screen draw at the current resolution, so post-processing multiplies whatever the pixel ratio costs. That ordering is the useful part: going from a device pixel ratio of 3 to 1.5 quarters the pixels shaded and usually reads as softer, while removing bloom changes what the scene IS. Let the adapter lower the ratio first and drop passes only at the bottom.\n\nPasses are not renderer settings either, so they do not belong in a `RenderProfile`. Compose them in your scene from the gate's `quality`, the same way instance counts are chosen.",
|
|
5253
|
+
"because": "Every pass is another full-screen draw at the current resolution, so post-processing multiplies whatever the pixel ratio costs. That ordering is the useful part: going from a device pixel ratio of 3 to 1.5 quarters the pixels shaded and usually reads as softer, while removing bloom changes what the scene IS. Let the adapter lower the ratio first and drop passes only at the bottom.\n\nPasses are not renderer settings either, so they do not belong in a `RenderProfile`. Compose them in your scene from the gate's `quality`, the same way instance counts are chosen. With `useThreeScene`, draw through the composer in `render`, size it in `resize`, switch passes off `frame.quality`, and release it in `dispose`; its Post-processing recipe has the whole file.",
|
|
4644
5254
|
"use": [
|
|
4645
5255
|
"useSceneGate",
|
|
4646
5256
|
"useRenderQuality"
|
|
4647
5257
|
],
|
|
4648
5258
|
"pattern": "lazy-3d-section",
|
|
5259
|
+
"starter": false,
|
|
4649
5260
|
"components": [],
|
|
4650
5261
|
"instead": "A fixed composer stack that ships every pass to every device.",
|
|
4651
5262
|
"watchFor": [
|
|
@@ -4676,24 +5287,32 @@
|
|
|
4676
5287
|
"glsl",
|
|
4677
5288
|
"webgl",
|
|
4678
5289
|
"3d",
|
|
4679
|
-
"three.js",
|
|
4680
|
-
"threejs",
|
|
4681
5290
|
"canvas",
|
|
4682
5291
|
"video loop",
|
|
4683
5292
|
"particle"
|
|
4684
5293
|
]
|
|
4685
5294
|
],
|
|
4686
|
-
"unless": [
|
|
5295
|
+
"unless": [
|
|
5296
|
+
"three.js",
|
|
5297
|
+
"threejs",
|
|
5298
|
+
"webglrenderer",
|
|
5299
|
+
"react three fiber",
|
|
5300
|
+
"r3f",
|
|
5301
|
+
"@react-three",
|
|
5302
|
+
"useframe",
|
|
5303
|
+
"<canvas>"
|
|
5304
|
+
],
|
|
4687
5305
|
"minHits": 1,
|
|
4688
5306
|
"priority": 780,
|
|
4689
5307
|
"verdict": "composition",
|
|
4690
5308
|
"headline": "Three renditions behind one layout box: shader, video loop, still.",
|
|
4691
|
-
"because": "A hero is the first thing rendered and the worst possible place for an unconditional WebGL mount — it competes with hydration on the slowest devices, and the visitors who suffer most are the ones deciding whether to stay. The rendition ladder gives every device something that looks deliberate. The device floor picks WHICH rendition; frame headroom decides WHEN the expensive one starts; `prefers-reduced-motion` overrides both and gets the still, because a capable machine can still be asking for less motion.",
|
|
5309
|
+
"because": "First, what draws the hero. A three.js scene is `useThreeScene`, and a React Three Fiber one is `useSceneGate` with `useRenderQuality`: ask again naming it, or read the component before deciding. What follows is for a hero drawn by a full-screen shader or a video, with no scene graph.\n\nA hero is the first thing rendered and the worst possible place for an unconditional WebGL mount — it competes with hydration on the slowest devices, and the visitors who suffer most are the ones deciding whether to stay. The rendition ladder gives every device something that looks deliberate. The device floor picks WHICH rendition; frame headroom decides WHEN the expensive one starts; `prefers-reduced-motion` overrides both and gets the still, because a capable machine can still be asking for less motion.",
|
|
4692
5310
|
"use": [
|
|
4693
5311
|
"useAdaptiveQuality",
|
|
4694
5312
|
"useSafeToMount"
|
|
4695
5313
|
],
|
|
4696
5314
|
"pattern": "adaptive-shader-hero",
|
|
5315
|
+
"starter": false,
|
|
4697
5316
|
"components": [
|
|
4698
5317
|
"volumetric-light",
|
|
4699
5318
|
"media-shader"
|
|
@@ -4703,7 +5322,7 @@
|
|
|
4703
5322
|
"ssr-module-scope",
|
|
4704
5323
|
"no-reduced-motion"
|
|
4705
5324
|
],
|
|
4706
|
-
"notWhen": "The hero is below the fold on most viewports, in which case it is an ordinary lazy scene and `useSceneGate` is the better gate — it adds viewport proximity, which the hero ladder does not need and a lower section does."
|
|
5325
|
+
"notWhen": "The hero is a three.js or React Three Fiber scene: those have their own answers above. Or it is below the fold on most viewports, in which case it is an ordinary lazy scene and `useSceneGate` is the better gate — it adds viewport proximity, which the hero ladder does not need and a lower section does."
|
|
4707
5326
|
},
|
|
4708
5327
|
{
|
|
4709
5328
|
"id": "lost-context",
|
|
@@ -4715,23 +5334,69 @@
|
|
|
4715
5334
|
"context restore"
|
|
4716
5335
|
],
|
|
4717
5336
|
"allOf": [],
|
|
4718
|
-
"unless": [
|
|
5337
|
+
"unless": [
|
|
5338
|
+
"webgpu",
|
|
5339
|
+
"webgpurenderer",
|
|
5340
|
+
"navigator.gpu",
|
|
5341
|
+
"device.lost",
|
|
5342
|
+
"gpudevice"
|
|
5343
|
+
],
|
|
4719
5344
|
"minHits": 1,
|
|
4720
5345
|
"priority": 710,
|
|
4721
5346
|
"verdict": "single",
|
|
4722
5347
|
"headline": "`getRendererHealth()` and a generation on the canvas key. Rebuild, do not revive.",
|
|
4723
|
-
"because": "The browser can take a context away at any moment — a driver reset, a backgrounded tab, too many live contexts on one page — and React Three Fiber has no handler for it, so the result is a permanently black rectangle and a completely clean console. Recovery works by rebuilding rather than restoring: the restore path is unreliable across drivers, and some refuse a replacement context on the page entirely if the loss event was not cancelled. So call `preventDefault()` on the loss, report it, and let a moving key throw the dead tree away and build a working one from the same declarative description.",
|
|
5348
|
+
"because": "The browser can take a context away at any moment — a driver reset, a backgrounded tab, too many live contexts on one page — and React Three Fiber has no handler for it, so the result is a permanently black rectangle and a completely clean console. Recovery works by rebuilding rather than restoring: the restore path is unreliable across drivers, and some refuse a replacement context on the page entirely if the loss event was not cancelled. So call `preventDefault()` on the loss, report it, and let a moving key throw the dead tree away and build a working one from the same declarative description.\n\nWired by hand the loop is three calls, and the third is the one that gets missed: `preventDefault()`, then `reportLost({ builtAt })` with the generation that was current when the renderer was made, then `reportHealthy()` once the replacement has drawn. Without the last, the page is held in recovery at reduced quality until the runtime gives up waiting. Without `builtAt`, a replacement that dies moments after it was built is taken for the same loss and stays black. A plain three.js scene gets all of it from `useThreeScene`, and an R3F one from `useRenderQuality`.",
|
|
4724
5349
|
"use": [
|
|
4725
5350
|
"RendererHealth",
|
|
4726
5351
|
"useRenderQuality"
|
|
4727
5352
|
],
|
|
4728
5353
|
"pattern": "lazy-3d-section",
|
|
5354
|
+
"starter": false,
|
|
4729
5355
|
"components": [],
|
|
4730
5356
|
"instead": "Nothing, which is what React Three Fiber does by default.",
|
|
4731
5357
|
"watchFor": [
|
|
4732
5358
|
"webgl-context-loss"
|
|
4733
5359
|
],
|
|
4734
|
-
"notWhen": "You already spread `useRenderQuality`'s props onto the canvas.
|
|
5360
|
+
"notWhen": "You already use `useThreeScene`, or spread `useRenderQuality`'s props onto the canvas. Both attach the listener and report for you, and a second listener moves the generation twice per loss."
|
|
5361
|
+
},
|
|
5362
|
+
{
|
|
5363
|
+
"id": "webgpu-device-loss",
|
|
5364
|
+
"when": [
|
|
5365
|
+
"webgpu",
|
|
5366
|
+
"navigator.gpu",
|
|
5367
|
+
"requestadapter",
|
|
5368
|
+
"requestdevice",
|
|
5369
|
+
"webgpurenderer",
|
|
5370
|
+
"device lost",
|
|
5371
|
+
"device.lost",
|
|
5372
|
+
"gpudevice",
|
|
5373
|
+
"webgpu canvas",
|
|
5374
|
+
"webgpu context lost",
|
|
5375
|
+
"webgpu scene",
|
|
5376
|
+
"tsl"
|
|
5377
|
+
],
|
|
5378
|
+
"allOf": [],
|
|
5379
|
+
"unless": [],
|
|
5380
|
+
"minHits": 1,
|
|
5381
|
+
"priority": 790,
|
|
5382
|
+
"verdict": "single",
|
|
5383
|
+
"headline": "`watchGPUDevice(device)`. WebGPU does not fire an event, it resolves a promise.",
|
|
5384
|
+
"because": "A WebGPU device is taken away for the same reasons a WebGL context is, and reports it in a completely different shape: the device hands you a `lost` promise when you create it, and that promise resolves when it dies. There is no event, so a `webglcontextlost` listener hears nothing and the scene stops drawing with a clean console. Hand the device to `watchGPUDevice`, which reports into the same `generation` counter the WebGL path uses, so `useSceneGate` rebuilds the scene with no change to how it is written. Through React Three Fiber you get this for free: spread `useRenderQuality`'s props and it watches the device as well as listening for the WebGL event. `useThreeScene` does the same for a plain three.js `WebGPURenderer`. Two things that are easy to get wrong on your own. A loss whose `reason` is `\"destroyed\"` is your own `destroy()` call, and reporting it as a failure rebuilds the scene you are tearing down, which tears down more scenes. And three quietly swaps in a WebGL backend when WebGPU init fails, so `instanceof WebGPURenderer` can be true on a canvas that is really WebGL: ask `renderer.backend?.isWebGPUBackend`, or simply wire both paths, which is what the adapter does.",
|
|
5385
|
+
"use": [
|
|
5386
|
+
"watchGPUDevice",
|
|
5387
|
+
"useSceneGate",
|
|
5388
|
+
"useRenderQuality"
|
|
5389
|
+
],
|
|
5390
|
+
"pattern": "lazy-3d-section",
|
|
5391
|
+
"starter": false,
|
|
5392
|
+
"components": [],
|
|
5393
|
+
"instead": "A `webglcontextlost` listener, which a WebGPU canvas never fires, or `await device.lost` in a render path, which resolves only when the device dies and on a healthy machine never resolves.",
|
|
5394
|
+
"watchFor": [
|
|
5395
|
+
"webgpu-device-loss",
|
|
5396
|
+
"webgpu-destroy-not-a-loss",
|
|
5397
|
+
"webgpu-renderer-class-check"
|
|
5398
|
+
],
|
|
5399
|
+
"notWhen": "You are drawing with WebGL or Canvas 2D. Neither loses a device this way, and `useRenderQuality` already covers the WebGL event for an R3F canvas."
|
|
4735
5400
|
}
|
|
4736
5401
|
]
|
|
4737
5402
|
}
|