feat(threejs): add visibility optimization, host styling, transparent background
- Add visibility-based animation pause (IntersectionObserver + wrapped rAF) - Zero CPU usage when widget scrolls out of view - Apply host style variables via useHostStyles - Support transparent WebGL background (alpha: true) - Use light-dark() for theme-aware fallback colors - Tool no longer echoes code back in result - Document performance features in README
This commit is contained in:
parent
8c3b1daf7e
commit
d60224f363
5 changed files with 142 additions and 56 deletions
|
|
@ -108,3 +108,27 @@ React component that:
|
|||
- Displays streaming preview from `toolInputsPartial.code` as code arrives
|
||||
- Executes final code from `toolInputs.code` when complete
|
||||
- Renders to a pre-created canvas with configurable height
|
||||
|
||||
## Performance
|
||||
|
||||
### Visibility-Based Pause
|
||||
|
||||
Animation automatically pauses when the widget scrolls out of view:
|
||||
|
||||
- Uses `IntersectionObserver` to track visibility (browser-native, no polling)
|
||||
- Wraps `requestAnimationFrame` to skip frames when not visible
|
||||
- Animation loop stops completely → zero CPU usage while hidden
|
||||
- Queued callbacks resume instantly when scrolled back into view
|
||||
|
||||
This is transparent to user code - just use `requestAnimationFrame` normally.
|
||||
|
||||
### Transparent Background
|
||||
|
||||
Supports alpha transparency for seamless host UI integration:
|
||||
|
||||
```javascript
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
|
||||
renderer.setClearColor(0x000000, 0); // Fully transparent
|
||||
```
|
||||
|
||||
This is the default - 3D objects composite directly over the host background.
|
||||
|
|
|
|||
|
|
@ -22,9 +22,9 @@ const DIST_DIR = import.meta.filename.endsWith(".ts")
|
|||
// Default code example for the Three.js widget
|
||||
const DEFAULT_THREEJS_CODE = `const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
|
||||
renderer.setSize(width, height);
|
||||
renderer.setClearColor(0x1a1a2e);
|
||||
renderer.setClearColor(0x000000, 0); // Transparent background
|
||||
|
||||
const cube = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(1, 1, 1),
|
||||
|
|
@ -54,27 +54,31 @@ const THREEJS_DOCUMENTATION = `# Three.js Widget Documentation
|
|||
- \`OrbitControls\` - Interactive camera controls
|
||||
- \`EffectComposer\`, \`RenderPass\`, \`UnrealBloomPass\` - Post-processing effects
|
||||
|
||||
## Basic Template
|
||||
## Basic Template (Transparent Background)
|
||||
\`\`\`javascript
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
|
||||
renderer.setSize(width, height);
|
||||
renderer.setClearColor(0x1a1a2e); // Dark background
|
||||
renderer.setClearColor(0x000000, 0); // Transparent - blends with host UI
|
||||
|
||||
// Add objects here...
|
||||
|
||||
camera.position.z = 5;
|
||||
renderer.render(scene, camera); // Static render
|
||||
renderer.render(scene, camera);
|
||||
\`\`\`
|
||||
|
||||
## Example: Rotating Cube with Lighting
|
||||
## Transparent vs Solid Background
|
||||
- **Transparent (default)**: Use \`alpha: true\` and \`setClearColor(0x000000, 0)\`
|
||||
- **Solid color**: Use \`setClearColor(0x1a1a2e)\` (omit alpha param)
|
||||
|
||||
## Example: Rotating Cube
|
||||
\`\`\`javascript
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
|
||||
renderer.setSize(width, height);
|
||||
renderer.setClearColor(0x1a1a2e);
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
|
||||
const cube = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(1, 1, 1),
|
||||
|
|
@ -82,7 +86,6 @@ const cube = new THREE.Mesh(
|
|||
);
|
||||
scene.add(cube);
|
||||
|
||||
// Lighting - keep intensity at 1 or below
|
||||
scene.add(new THREE.DirectionalLight(0xffffff, 1));
|
||||
scene.add(new THREE.AmbientLight(0x404040));
|
||||
|
||||
|
|
@ -101,9 +104,9 @@ animate();
|
|||
\`\`\`javascript
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
|
||||
renderer.setSize(width, height);
|
||||
renderer.setClearColor(0x2d2d44);
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
|
|
@ -128,7 +131,7 @@ animate();
|
|||
\`\`\`
|
||||
|
||||
## Tips
|
||||
- Always set \`renderer.setClearColor()\` to a dark color
|
||||
- Use \`alpha: true\` for transparent backgrounds that blend with host UI
|
||||
- Keep light intensity ≤ 1 to avoid washed-out scenes
|
||||
- Use \`MeshStandardMaterial\` for realistic lighting
|
||||
- For animations, use \`requestAnimationFrame\`
|
||||
|
|
@ -157,7 +160,7 @@ export function createServer(): McpServer {
|
|||
{
|
||||
title: "Show Three.js Scene",
|
||||
description:
|
||||
"Render an interactive 3D scene with custom Three.js code. Available globals: THREE, OrbitControls, EffectComposer, RenderPass, UnrealBloomPass, canvas, width, height.",
|
||||
"Render an interactive 3D scene with custom Three.js code. Supports transparent backgrounds (alpha: true) for seamless host UI integration. Available globals: THREE, OrbitControls, EffectComposer, RenderPass, UnrealBloomPass, canvas, width, height.",
|
||||
inputSchema: {
|
||||
code: z
|
||||
.string()
|
||||
|
|
@ -171,16 +174,14 @@ export function createServer(): McpServer {
|
|||
.describe("Height in pixels"),
|
||||
},
|
||||
outputSchema: z.object({
|
||||
code: z.string(),
|
||||
height: z.number(),
|
||||
success: z.boolean(),
|
||||
}),
|
||||
_meta: { ui: { resourceUri } },
|
||||
},
|
||||
async ({ code, height }) => {
|
||||
const data = { code, height };
|
||||
async () => {
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(data) }],
|
||||
structuredContent: data,
|
||||
content: [{ type: "text", text: "Three.js scene rendered" }],
|
||||
structuredContent: { success: true },
|
||||
};
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,17 @@
|
|||
:root {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
font-family: var(--font-sans, system-ui, -apple-system, sans-serif);
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #1a1a2e;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.loading {
|
||||
|
|
@ -15,13 +19,13 @@ html, body {
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 400px;
|
||||
color: #888;
|
||||
color: var(--color-text-secondary, #888);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 20px;
|
||||
color: #ff6b6b;
|
||||
color: var(--color-text-danger, #ff6b6b);
|
||||
}
|
||||
|
||||
.threejs-container {
|
||||
|
|
@ -35,9 +39,9 @@ html, body {
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ff6b6b;
|
||||
background: rgba(26, 26, 46, 0.9);
|
||||
border-radius: 8px;
|
||||
font-family: system-ui;
|
||||
color: var(--color-text-danger, #ff6b6b);
|
||||
background: var(--color-background-secondary, rgba(26, 26, 46, 0.9));
|
||||
border-radius: var(--border-radius-md, 8px);
|
||||
font-family: var(--font-sans, system-ui);
|
||||
padding: 20px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
* props to the actual widget component.
|
||||
*/
|
||||
import type { App, McpUiHostContext } from "@modelcontextprotocol/ext-apps";
|
||||
import { useApp } from "@modelcontextprotocol/ext-apps/react";
|
||||
import { useApp, useHostStyles } from "@modelcontextprotocol/ext-apps/react";
|
||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { StrictMode, useState, useCallback, useEffect } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
|
@ -78,6 +78,9 @@ function McpAppWrapper() {
|
|||
},
|
||||
});
|
||||
|
||||
// Apply host styling (theme, CSS variables, fonts)
|
||||
useHostStyles(app);
|
||||
|
||||
// Get initial host context after connection
|
||||
useEffect(() => {
|
||||
if (app) {
|
||||
|
|
|
|||
|
|
@ -30,9 +30,10 @@ type ThreeJSAppProps = WidgetProps<ThreeJSToolInput>;
|
|||
// Default demo code shown when no code is provided
|
||||
const DEFAULT_THREEJS_CODE = `const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
|
||||
renderer.setSize(width, height);
|
||||
renderer.setClearColor(0x1a1a2e);
|
||||
// Transparent background - scene composites over host UI
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
|
||||
const cube = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(1, 1, 1),
|
||||
|
|
@ -66,13 +67,6 @@ animate();`;
|
|||
// Streaming Preview
|
||||
// =============================================================================
|
||||
|
||||
const SHIMMER_STYLE = `
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
`;
|
||||
|
||||
function LoadingShimmer({ height, code }: { height: number; code?: string }) {
|
||||
const preRef = useRef<HTMLPreElement>(null);
|
||||
|
||||
|
|
@ -85,28 +79,25 @@ function LoadingShimmer({ height, code }: { height: number; code?: string }) {
|
|||
style={{
|
||||
width: "100%",
|
||||
height,
|
||||
borderRadius: 8,
|
||||
borderRadius: "var(--border-radius-lg, 8px)",
|
||||
padding: 16,
|
||||
boxSizing: "border-box",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
background:
|
||||
"linear-gradient(90deg, #1a1a2e 25%, #2d2d44 50%, #1a1a2e 75%)",
|
||||
backgroundSize: "200% 100%",
|
||||
animation: "shimmer 1.5s ease-in-out infinite",
|
||||
"linear-gradient(135deg, var(--color-background-secondary, light-dark(#f0f0f5, #2a2a3c)) 0%, var(--color-background-tertiary, light-dark(#e5e5ed, #1e1e2e)) 100%)",
|
||||
}}
|
||||
>
|
||||
<style>{SHIMMER_STYLE}</style>
|
||||
<div
|
||||
style={{
|
||||
color: "#888",
|
||||
fontFamily: "system-ui",
|
||||
color: "var(--color-text-tertiary, light-dark(#666, #888))",
|
||||
fontFamily: "var(--font-sans, system-ui)",
|
||||
fontSize: 12,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
🎮 Three.js
|
||||
Three.js
|
||||
</div>
|
||||
{code && (
|
||||
<pre
|
||||
|
|
@ -116,10 +107,10 @@ function LoadingShimmer({ height, code }: { height: number; code?: string }) {
|
|||
padding: 0,
|
||||
flex: 1,
|
||||
overflow: "auto",
|
||||
color: "#aaa",
|
||||
fontFamily: "monospace",
|
||||
fontSize: 11,
|
||||
lineHeight: 1.4,
|
||||
color: "var(--color-text-ghost, light-dark(#777, #aaa))",
|
||||
fontFamily: "var(--font-mono, monospace)",
|
||||
fontSize: "var(--font-text-xs-size, 11px)",
|
||||
lineHeight: "var(--font-text-xs-line-height, 1.4)",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
|
|
@ -135,6 +126,43 @@ function LoadingShimmer({ height, code }: { height: number; code?: string }) {
|
|||
// Three.js Execution
|
||||
// =============================================================================
|
||||
|
||||
// Visibility-aware animation controller
|
||||
function createAnimationController() {
|
||||
let isVisible = true;
|
||||
let pendingCallbacks: FrameRequestCallback[] = [];
|
||||
let rafIds: number[] = [];
|
||||
|
||||
const visibilityAwareRAF = (callback: FrameRequestCallback): number => {
|
||||
if (isVisible) {
|
||||
const id = requestAnimationFrame(callback);
|
||||
rafIds.push(id);
|
||||
return id;
|
||||
} else {
|
||||
// Queue callback for when visible again
|
||||
pendingCallbacks.push(callback);
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
const setVisible = (visible: boolean) => {
|
||||
isVisible = visible;
|
||||
if (visible && pendingCallbacks.length > 0) {
|
||||
// Resume queued animations
|
||||
const callbacks = pendingCallbacks;
|
||||
pendingCallbacks = [];
|
||||
callbacks.forEach((cb) => visibilityAwareRAF(cb));
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
rafIds.forEach((id) => cancelAnimationFrame(id));
|
||||
rafIds = [];
|
||||
pendingCallbacks = [];
|
||||
};
|
||||
|
||||
return { visibilityAwareRAF, setVisible, cleanup };
|
||||
}
|
||||
|
||||
const threeContext = {
|
||||
THREE,
|
||||
OrbitControls,
|
||||
|
|
@ -148,16 +176,18 @@ async function executeThreeCode(
|
|||
canvas: HTMLCanvasElement,
|
||||
width: number,
|
||||
height: number,
|
||||
visibilityAwareRAF: (callback: FrameRequestCallback) => number,
|
||||
): Promise<void> {
|
||||
const fn = new Function(
|
||||
"ctx",
|
||||
"canvas",
|
||||
"width",
|
||||
"height",
|
||||
"requestAnimationFrame",
|
||||
`const { THREE, OrbitControls, EffectComposer, RenderPass, UnrealBloomPass } = ctx;
|
||||
return (async () => { ${code} })();`,
|
||||
);
|
||||
await fn(threeContext, canvas, width, height);
|
||||
await fn(threeContext, canvas, width, height, visibilityAwareRAF);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -177,6 +207,7 @@ export default function ThreeJSApp({
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const animControllerRef = useRef<ReturnType<typeof createAnimationController> | null>(null);
|
||||
|
||||
const height = toolInputs?.height ?? toolInputsPartial?.height ?? 400;
|
||||
const code = toolInputs?.code || DEFAULT_THREEJS_CODE;
|
||||
|
|
@ -191,14 +222,38 @@ export default function ThreeJSApp({
|
|||
paddingLeft: safeAreaInsets?.left,
|
||||
};
|
||||
|
||||
// Visibility-based pause/play
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
animControllerRef.current?.setVisible(entry.isIntersecting);
|
||||
});
|
||||
});
|
||||
observer.observe(containerRef.current);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!code || !canvasRef.current || !containerRef.current) return;
|
||||
|
||||
// Cleanup previous animation
|
||||
animControllerRef.current?.cleanup();
|
||||
animControllerRef.current = createAnimationController();
|
||||
|
||||
setError(null);
|
||||
const width = containerRef.current.offsetWidth || 800;
|
||||
executeThreeCode(code, canvasRef.current, width, height).catch((e) =>
|
||||
setError(e instanceof Error ? e.message : "Unknown error"),
|
||||
);
|
||||
executeThreeCode(
|
||||
code,
|
||||
canvasRef.current,
|
||||
width,
|
||||
height,
|
||||
animControllerRef.current.visibilityAwareRAF,
|
||||
).catch((e) => setError(e instanceof Error ? e.message : "Unknown error"));
|
||||
|
||||
return () => animControllerRef.current?.cleanup();
|
||||
}, [code, height]);
|
||||
|
||||
if (isStreaming || !code) {
|
||||
|
|
@ -221,9 +276,8 @@ export default function ThreeJSApp({
|
|||
style={{
|
||||
width: "100%",
|
||||
height,
|
||||
borderRadius: 8,
|
||||
borderRadius: "var(--border-radius-lg, 8px)",
|
||||
display: "block",
|
||||
background: "#1a1a2e",
|
||||
}}
|
||||
/>
|
||||
{error && <div className="error-overlay">Error: {error}</div>}
|
||||
|
|
|
|||
Loading…
Reference in a new issue