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:
Anton Pidkuiko MacBook 2026-01-17 23:19:34 +00:00
commit d60224f363
No known key found for this signature in database
5 changed files with 142 additions and 56 deletions

View file

@ -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.

View file

@ -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 },
};
},
);

View file

@ -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;
}

View file

@ -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) {

View file

@ -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>}